sync: auto-sync from DESKTOP-0O8A1RL at 2026-05-20 14:38:07
Author: Mike Swanson Machine: DESKTOP-0O8A1RL Timestamp: 2026-05-20 14:38:07
This commit is contained in:
@@ -253,6 +253,7 @@ Vault structure: `infrastructure/`, `clients/`, `services/`, `projects/`, `msp-t
|
||||
| `/feature-request` | Howard submits a GuruRMM feature request — Claude classifies it and messages Mike |
|
||||
| `/shape-spec` | Pre-implementation spec for a GuruRMM feature — produces plan.md, shape.md, references.md, standards.md |
|
||||
| `/rmm-audit` | Full end-to-end audit of GuruRMM: API coverage, UI gaps, Rust/TS quality, security, data integrity. Produces timestamped report + updates UI_GAPS.md |
|
||||
| `/forum-post` | Post a technical article to community.azcomputerguru.com — drafts from context, shows preview, inserts via paramiko SSH to Flarum DB |
|
||||
|
||||
---
|
||||
|
||||
|
||||
324
.claude/commands/forum-post.md
Normal file
324
.claude/commands/forum-post.md
Normal file
@@ -0,0 +1,324 @@
|
||||
Post a technical article to community.azcomputerguru.com (Flarum forum).
|
||||
|
||||
Converts markdown to Flarum's s9e TextFormatter XML format and inserts directly into the
|
||||
database via paramiko SSH to IX. Shows a preview and waits for user confirmation before posting.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/forum-post Draft interactively — Claude asks for title, content, tag
|
||||
/forum-post <topic hint> Claude drafts from conversation context, then confirms
|
||||
```
|
||||
|
||||
Arguments are optional. With no args, Claude uses the current conversation context to
|
||||
determine what to post (the most recent technical problem solved, fix documented, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Forum URL | https://community.azcomputerguru.com |
|
||||
| DB host | localhost (on IX) |
|
||||
| DB name | azcompu_flarum |
|
||||
| DB user | azcompu_flarum |
|
||||
| DB pass | `Fl@rum2026!CGS` |
|
||||
| IX SSH | root@172.16.3.10 — password from vault: `infrastructure/ix-server.sops.yaml credentials.password` |
|
||||
| Admin user_id | 1 (MikeSwanson) |
|
||||
|
||||
Get the IX SSH password via vault before connecting:
|
||||
```bash
|
||||
bash "$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" get-field infrastructure/ix-server.sops.yaml credentials.password
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Tags
|
||||
|
||||
| tag_id | Name | When to use |
|
||||
|--------|------|-------------|
|
||||
| 7 | How-Tos & Tips | Technical fixes, how-tos, diagnostics, field notes |
|
||||
|
||||
To see all current tags: query `SELECT id, name_singular FROM tags;` on azcompu_flarum.
|
||||
Default to tag_id=7 unless the user specifies otherwise.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Gather Inputs
|
||||
|
||||
Collect in one pass — do not ask field by field:
|
||||
|
||||
1. **Title** — descriptive, search-friendly, sentence case. Include the technology/product.
|
||||
Good: `OneDrive KFM Fails After Folder Redirection Migration -- Here's What's Actually Going On`
|
||||
Bad: `OneDrive issue fix`
|
||||
|
||||
2. **Content** — full markdown body. Minimum useful length: 3-4 paragraphs with concrete detail.
|
||||
Write from the conversation context: what was the problem, what was tried, what actually worked, what to remember.
|
||||
|
||||
3. **Tag** — default How-Tos & Tips (7) unless user says otherwise.
|
||||
|
||||
Generate the slug from the title: lowercase, spaces/punctuation to hyphens, remove apostrophes,
|
||||
collapse multiple hyphens. Max ~90 chars. Example:
|
||||
`onedrive-kfm-fails-after-folder-redirection-migration-heres-whats-actually-going-on`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Show Preview
|
||||
|
||||
Before posting, show:
|
||||
|
||||
```
|
||||
FORUM POST PREVIEW
|
||||
------------------
|
||||
Title: <title>
|
||||
Tag: How-Tos & Tips
|
||||
Slug: <slug>
|
||||
URL: https://community.azcomputerguru.com/d/<next_id>-<slug>
|
||||
|
||||
--- Content ---
|
||||
<first 500 chars of markdown>
|
||||
...
|
||||
|
||||
Post this? (yes/no)
|
||||
```
|
||||
|
||||
Wait for explicit confirmation before executing.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Execute
|
||||
|
||||
Write and run a Python script (use `py` on Windows). The script must:
|
||||
|
||||
1. **Get IX SSH password** from vault (see Infrastructure above)
|
||||
2. **Connect via paramiko** (`AutoAddPolicy`, password auth)
|
||||
3. **Generate the s9e XML** from the markdown (see Converter section below)
|
||||
4. **Build the PHP insert script** (see PHP Template below)
|
||||
5. **SFTP upload** the PHP script to `/tmp/flarum_post_<timestamp>.php` on IX
|
||||
6. **Run** `php /tmp/flarum_post_<timestamp>.php 2>&1` via SSH
|
||||
7. **Parse output** — look for `Discussion ID: N` and `Post ID: N`
|
||||
8. **Clean up** — `rm /tmp/flarum_post_<timestamp>.php`
|
||||
9. **Report** the live URL
|
||||
|
||||
---
|
||||
|
||||
## Markdown → s9e XML Converter
|
||||
|
||||
Flarum stores post content as s9e TextFormatter XML, not raw markdown. The stored format
|
||||
must match what Flarum's TextFormatter produces. Based on confirmed existing posts:
|
||||
|
||||
### Inline elements
|
||||
|
||||
| Markdown | s9e XML |
|
||||
|---|---|
|
||||
| `**bold**` | `<STRONG><s>**</s>bold<e>**</e></STRONG>` |
|
||||
| `*italic*` | `<EM><s>*</s>italic<e>*</e></EM>` |
|
||||
| `` `code` `` | `<C><s>`</s>code<e>`</e></C>` |
|
||||
|
||||
XML-escape `&` → `&`, `<` → `<`, `>` → `>` in all text content and code spans.
|
||||
|
||||
### Block elements
|
||||
|
||||
| Markdown | s9e XML |
|
||||
|---|---|
|
||||
| `## Heading` | `<H2><s>## </s>Heading</H2>` |
|
||||
| `### Heading` | `<H3><s>### </s>Heading</H3>` |
|
||||
| Paragraph | `<p>text</p>` |
|
||||
| `- item` (list) | `<LIST><LI><s>- </s>item</LI>\n<LI>...</LI></LIST>` |
|
||||
| `1. item` (ordered) | `<LIST type="decimal"><LI><s>1. </s>item</LI>\n...</LIST>` |
|
||||
| ` ```lang ` fenced block | `<CODE lang="lang"><s>```lang</s><i>\n</i>code\n<e>```</e></CODE>` |
|
||||
|
||||
Block elements are separated by `\n\n` (two real newlines) inside the `<r>` root.
|
||||
List items are separated by `\n` (one newline).
|
||||
Entire content is wrapped: `<r>...</r>`.
|
||||
|
||||
### Python converter (copy this directly into the py script)
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
def xml_escape(t):
|
||||
return t.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
def inline_to_xml(text):
|
||||
result = ''
|
||||
i = 0
|
||||
while i < len(text):
|
||||
if text[i:i+2] == '**':
|
||||
end = text.find('**', i+2)
|
||||
if end != -1:
|
||||
inner = inline_to_xml(text[i+2:end])
|
||||
result += '<STRONG><s>**</s>' + inner + '<e>**</e></STRONG>'
|
||||
i = end + 2; continue
|
||||
if text[i] == '`':
|
||||
end = text.find('`', i+1)
|
||||
if end != -1:
|
||||
result += '<C><s>`</s>' + xml_escape(text[i+1:end]) + '<e>`</e></C>'
|
||||
i = end + 1; continue
|
||||
if text[i] == '*' and text[i:i+2] != '**':
|
||||
j = i + 1; end = -1
|
||||
while j < len(text):
|
||||
if text[j] == '*' and text[j:j+2] != '**': end = j; break
|
||||
j += 1
|
||||
if end != -1:
|
||||
result += '<EM><s>*</s>' + xml_escape(text[i+1:end]) + '<e>*</e></EM>'
|
||||
i = end + 1; continue
|
||||
result += xml_escape(text[i]); i += 1
|
||||
return result
|
||||
|
||||
def md_to_s9e(md):
|
||||
lines = md.split('\n')
|
||||
elements = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if not line.strip(): i += 1; continue
|
||||
if line.startswith('## '):
|
||||
elements.append('<H2><s>## </s>' + inline_to_xml(line[3:]) + '</H2>'); i += 1
|
||||
elif line.startswith('### '):
|
||||
elements.append('<H3><s>### </s>' + inline_to_xml(line[4:]) + '</H3>'); i += 1
|
||||
elif line.startswith('- '):
|
||||
items = []
|
||||
while i < len(lines) and lines[i].startswith('- '):
|
||||
items.append('<LI><s>- </s>' + inline_to_xml(lines[i][2:]) + '</LI>'); i += 1
|
||||
elements.append('<LIST>' + '\n'.join(items) + '</LIST>')
|
||||
elif re.match(r'^\d+\. ', line):
|
||||
items = []
|
||||
while i < len(lines) and re.match(r'^\d+\. ', lines[i]):
|
||||
m = re.match(r'^(\d+)\. (.*)', lines[i])
|
||||
items.append('<LI><s>' + m.group(1) + '. </s>' + inline_to_xml(m.group(2)) + '</LI>'); i += 1
|
||||
elements.append('<LIST type="decimal">' + '\n'.join(items) + '</LIST>')
|
||||
elif line.startswith('```'):
|
||||
lang = line[3:].strip(); code_lines = []; i += 1
|
||||
while i < len(lines) and not lines[i].startswith('```'):
|
||||
code_lines.append(xml_escape(lines[i])); i += 1
|
||||
i += 1
|
||||
tag = f'<CODE lang="{lang}">' if lang else '<CODE>'
|
||||
elements.append(tag + f'<s>```{lang}</s><i>\n</i>' + '\n'.join(code_lines) + '\n<e>```</e></CODE>')
|
||||
else:
|
||||
para_lines = []
|
||||
while i < len(lines) and lines[i].strip():
|
||||
l = lines[i]
|
||||
if l.startswith('## ') or l.startswith('### ') or l.startswith('- ') or l.startswith('```') or re.match(r'^\d+\. ', l): break
|
||||
para_lines.append(l); i += 1
|
||||
elements.append('<p>' + inline_to_xml('\n'.join(para_lines)) + '</p>')
|
||||
return '<r>' + '\n\n'.join(elements) + '</r>'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHP Insert Template
|
||||
|
||||
Use `%%XML_CONTENT%%` as the placeholder — replace with the generated s9e XML before uploading.
|
||||
The closing nowdoc marker `FLARUM_POST_XML_END;` must be at column 0 with no leading whitespace.
|
||||
|
||||
```php
|
||||
<?php
|
||||
ini_set('display_errors', 1); error_reporting(E_ALL);
|
||||
$dsn = 'mysql:host=localhost;dbname=azcompu_flarum;charset=utf8mb4';
|
||||
$pdo = new PDO($dsn, 'azcompu_flarum', 'Fl@rum2026!CGS', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
|
||||
$user_id = 1; $tag_id = %%TAG_ID%%;
|
||||
$title = %%TITLE_PHP%%;
|
||||
$slug = '%%SLUG%%';
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$content = <<<'FLARUM_POST_XML_END'
|
||||
%%XML_CONTENT%%
|
||||
FLARUM_POST_XML_END;
|
||||
|
||||
// Insert discussion first (post refs are nullable)
|
||||
$stmt = $pdo->prepare("INSERT INTO discussions (title, comment_count, post_number_index, created_at, user_id, slug, is_private, is_approved) VALUES (?, 1, 1, ?, ?, ?, 0, 1)");
|
||||
$stmt->execute([$title, $now, $user_id, $slug]);
|
||||
$disc_id = $pdo->lastInsertId();
|
||||
echo "Discussion ID: $disc_id\n";
|
||||
|
||||
// Insert post
|
||||
$stmt = $pdo->prepare("INSERT INTO posts (discussion_id, number, created_at, user_id, type, content, is_private, is_approved) VALUES (?, 1, ?, ?, 'comment', ?, 0, 1)");
|
||||
$stmt->execute([$disc_id, $now, $user_id, $content]);
|
||||
$post_id = $pdo->lastInsertId();
|
||||
echo "Post ID: $post_id\n";
|
||||
|
||||
// Link post back to discussion
|
||||
$pdo->prepare("UPDATE discussions SET first_post_id=?, last_post_id=?, last_posted_at=?, last_posted_user_id=?, last_post_number=1 WHERE id=?")->execute([$post_id, $post_id, $now, $user_id, $disc_id]);
|
||||
|
||||
// Tag
|
||||
$pdo->prepare("INSERT INTO discussion_tag (discussion_id, tag_id) VALUES (?, ?)")->execute([$disc_id, $tag_id]);
|
||||
|
||||
echo "Done! URL: https://community.azcomputerguru.com/d/$disc_id-$slug\n";
|
||||
```
|
||||
|
||||
### Template substitutions
|
||||
|
||||
| Placeholder | How to fill |
|
||||
|---|---|
|
||||
| `%%TAG_ID%%` | Integer (e.g. `7`) |
|
||||
| `%%TITLE_PHP%%` | PHP double-quoted string with escaped `"` — e.g. `"The Title Here"` |
|
||||
| `%%SLUG%%` | URL-safe slug string |
|
||||
| `%%XML_CONTENT%%` | The output of `md_to_s9e(content_md)` |
|
||||
|
||||
Build the PHP script in Python using `.replace()` — never f-strings (curly braces in XML content
|
||||
will cause Python to try to expand them as template expressions).
|
||||
|
||||
---
|
||||
|
||||
## Paramiko Execution Pattern
|
||||
|
||||
```python
|
||||
import paramiko, time
|
||||
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect('172.16.3.10', username='root', password=IX_PASS, timeout=10)
|
||||
|
||||
# Upload
|
||||
remote_path = f'/tmp/flarum_post_{int(time.time())}.php'
|
||||
sftp = client.open_sftp()
|
||||
with sftp.open(remote_path, 'wb') as f:
|
||||
f.write(php_script.encode('utf-8'))
|
||||
sftp.close()
|
||||
|
||||
# Execute
|
||||
def run_chan(cmd):
|
||||
chan = client.get_transport().open_session()
|
||||
chan.exec_command(cmd)
|
||||
chan.shutdown_write()
|
||||
out = b''
|
||||
while not chan.exit_status_ready():
|
||||
if chan.recv_ready(): out += chan.recv(4096)
|
||||
while chan.recv_ready(): out += chan.recv(4096)
|
||||
return out.decode('utf-8', errors='replace'), chan.recv_exit_status()
|
||||
|
||||
out, rc = run_chan(f'php {remote_path} 2>&1')
|
||||
run_chan(f'rm -f {remote_path}')
|
||||
client.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
| rc | Symptom | Cause | Fix |
|
||||
|---|---|---|---|
|
||||
| 255, no output | Script crashes silently | Exception with display_errors off | Script already includes `ini_set('display_errors',1)` |
|
||||
| FK constraint error | Cannot add child row | Post inserted before discussion | Use discussion-first insert order (template above does this) |
|
||||
| Empty curl response | localhost curl returns nothing | HTTP→HTTPS redirect on vhost | Use paramiko PHP approach, not curl |
|
||||
| Cloudflare challenge | API blocked externally | Bot protection | Always insert via paramiko, never via external HTTP |
|
||||
|
||||
---
|
||||
|
||||
## After Posting
|
||||
|
||||
Report:
|
||||
```
|
||||
[SUCCESS] Posted to community.azcomputerguru.com
|
||||
Discussion: #<id>
|
||||
URL: https://community.azcomputerguru.com/d/<id>-<slug>
|
||||
Tag: How-Tos & Tips
|
||||
```
|
||||
|
||||
Do NOT try to verify via curl or browser — Cloudflare blocks external API calls and
|
||||
localhost curl has a redirect issue. The DB output is the authoritative confirmation.
|
||||
Reference in New Issue
Block a user