← Commit history

Initial commit: skill/adom-cli-design

Kyle Bergstedt ·e4ed6ec7b5 ·4mo ago
2 files changed +378
README.mdadded+328
@@ -0,0 +1,328 @@+---+name: adom-cli-design+description: Guidelines for building CLI tools in the Adom ecosystem. Use when creating a new CLI, adding commands to an existing one, or deciding between CLI vs MCP vs HTTP API. Covers Rust CLI conventions, AI-oriented output, skill files, and why we prefer CLIs over MCP servers. Trigger words: build a cli, new cli tool, cli design, cli guidelines, mcp vs cli, should I use mcp, make a tool for the ai.+---++# Building CLI Tools for Adom++## Why CLI over MCP++We use Rust CLIs as the primary interface between Claude Code and Adom tools. **Do not build MCP servers** for new tools.++| | MCP Server | Rust CLI |+|---|---|---|+| Context cost | High — tool schemas injected into every conversation | Zero — skill file loaded only when triggered |+| Discoverability | Always visible, clutters tool list | Skill-triggered, on-demand |+| Reliability | Stdio transport can hang, crash, or desync | Process runs and exits cleanly |+| Debugging | Opaque — hard to see what happened | Just run the command in terminal |+| Distribution | Requires .mcp.json wiring per container | Single binary, `cp` to `/usr/local/bin/` |+| AI usability | AI must guess tool params from schema | AI reads skill examples, runs bash |++**The pattern:** Rust CLI binary + Claude Code skill file. The AI reads the skill to learn the commands, then runs them via Bash.++## CLI Conventions++### Language: Rust++- Use `clap` with derive macros for argument parsing+- Use `serde_json` for JSON input/output+- Minimize dependencies — prefer raw TCP over `reqwest` for simple HTTP calls+- Build with `--release`, `opt-level = "z"`, `lto = true`, `strip = true` for small binaries+- Install to `/usr/local/bin/` so it's on PATH everywhere++### Output: AI-Oriented++The CLI output is consumed by Claude Code, not humans. Design accordingly:++**Every response starts with `OK:` or `ERROR:`** — the AI can instantly determine success/failure.++**Success output is descriptive and confirms what happened:**+```text+OK: Opened /home/adom/project/README.md in VS Code editor tab.+OK: Injected shot-2026-03-28T14-30-22.png (1920x1080, 245 KB) into channel "dart2".+OK: Server listening on port 8820, recovered 3 channels from disk.+```++**Error output includes a `Hint:` line with the next action:**+```text+ERROR: Cannot connect to shotlog server on port 8820.+Hint: Start the server with `shotlog serve &`++ERROR: File not found: /home/adom/project/missing.png+Hint: Check the path exists. Use `ls /home/adom/project/` to list files.++ERROR: Adom Bridge extension not responding on port 8821.+Hint: The extension may need a window reload. Run: adom-bridge health+```++**Never output ambiguous or empty responses.** If a command succeeds silently, still print `OK:`.++### Colored output++Use ANSI escape codes for colored terminal output. No crate needed — raw escape codes work everywhere. But colors are for **humans at interactive terminals only**: when the CLI's output is piped (`cmd | grep`, `cmd > log`, `nohup cmd > log.txt 2>&1`, bash command substitution, etc.), ANSI escape codes leak into log files and into the AI's stdout capture as ugly garbage like `[36mstarting server[0m`. Every CLI MUST strip ANSI when stdout/stderr is not a TTY.++**Required pattern** — `is_terminal()` check gated on each output helper, with a matching `strip_ansi` fallback. Both `println!` / `eprintln!` calls that embed color constants AND internal progress messages need this — not just the `ok` / `err` / `hint` / `warn` helpers:++```rust+use std::io::IsTerminal;+use std::sync::OnceLock;++const GREEN: &str = "\x1b[32m";+const RED: &str = "\x1b[31m";+const YELLOW: &str = "\x1b[33m";+const CYAN: &str = "\x1b[36m";+const BOLD: &str = "\x1b[1m";+const DIM: &str = "\x1b[2m";+const RESET: &str = "\x1b[0m";++static STDOUT_IS_TTY: OnceLock<bool> = OnceLock::new();+static STDERR_IS_TTY: OnceLock<bool> = OnceLock::new();+pub fn stdout_is_tty() -> bool { *STDOUT_IS_TTY.get_or_init(|| std::io::stdout().is_terminal()) }+pub fn stderr_is_tty() -> bool { *STDERR_IS_TTY.get_or_init(|| std::io::stderr().is_terminal()) }++pub fn strip_ansi(s: &str) -> String {+    // Strip CSI sequences like \x1b[36m, \x1b[0m, \x1b[1;32m etc.+    let mut out = String::with_capacity(s.len());+    let mut chars = s.chars().peekable();+    while let Some(c) = chars.next() {+        if c == '\x1b' && chars.peek() == Some(&'[') {+            chars.next();+            for c2 in chars.by_ref() { if c2.is_ascii_alphabetic() { break; } }+            continue;+        }+        out.push(c);+    }+    out+}++fn ok(msg: impl std::fmt::Display) {+    if stdout_is_tty() {+        println!("{GREEN}{BOLD}OK:{RESET} {msg}");+    } else {+        println!("OK: {}", strip_ansi(&msg.to_string()));+    }+}+fn err(msg: impl std::fmt::Display) {+    if stderr_is_tty() {+        eprintln!("{RED}{BOLD}ERROR:{RESET} {msg}");+    } else {+        eprintln!("ERROR: {}", strip_ansi(&msg.to_string()));+    }+}+fn hint(msg: impl std::fmt::Display) {+    if stderr_is_tty() {+        eprintln!("{DIM}Hint:{RESET} {msg}");+    } else {+        eprintln!("Hint: {}", strip_ansi(&msg.to_string()));+    }+}++// And — critically — `note!` for every internal progress message+// ("starting server on port X", "running ffmpeg...", "filter graph:+// N chars", etc.). These are the ones that tend to get written as+// bare `println!("{CYAN}...{RESET}")` and leak raw escape codes into+// logs when the CLI is run under nohup or backgrounded to a file.+// Route them all through a `note!` macro so the isatty gate is+// unmissable:+macro_rules! note {+    ($($arg:tt)*) => {{+        let __s = format!($($arg)*);+        if $crate::stdout_is_tty() {+            println!("{}", __s);+        } else {+            println!("{}", $crate::strip_ansi(&__s));+        }+    }};+}+```++- `OK:` in green bold, paths/values in cyan, search terms in yellow.+- `ERROR:` in red bold, hints in dim.+- The AI reads `OK:` / `ERROR:` prefixes; colors are for human readability **only**.+- **Never leak raw ANSI escape codes to pipes.** If you see `[36m` or `[0m` in a log file, a CLI is broken. The `video-post` crate (adom-inc/video-post on GitHub) implements the full pattern above in `src/main.rs` — `note!` + `enote!` macros + `ok/err/hint/warn` helpers all gated on isatty. Copy from there for any new CLI.++### Bash tab completions++Use `clap_complete` to generate bash completions. The `install` command should write them to `~/.local/share/bash-completion/completions/<tool>` and append an `eval` line to `.bashrc`:++```rust+// In Cargo.toml:+clap_complete = "4"++// Generate completions:+let mut cmd = Cli::command();+let mut buf = Vec::new();+clap_complete::generate(clap_complete::Shell::Bash, &mut cmd, "my-tool", &mut buf);+fs::write(format!("{completions_dir}/my-tool"), &buf).ok();+```++Users get tab completion immediately in new terminals -- `my-tool <TAB>` shows all subcommands.++### Subcommand Structure++Use subcommands for logical grouping:+```text+toolname <verb> [options] [args]++shotlog serve --port 8820+shotlog inject --channel dart2 --desc "..." file.png+shotlog open --channel dart2++adom-bridge open /path/to/file.png+adom-bridge reveal /path/to/folder/+adom-bridge claude new+adom-bridge extensions search "python"+```++### Port Registration++**Before picking a port**, check `/home/adom/gallia/PORT-REGISTRY.md`. Add your service to the registry before writing code. Port ranges are pre-allocated by category (8820–8829 for Adom tools, 8850–8899 for user/ephemeral, etc.).++### Health Check++Every CLI that talks to a server should have a `health` subcommand:+```text+toolname health+```+Returns `OK: ...` if the server is reachable, `ERROR: ...` with a hint if not. This lets the AI self-diagnose connection issues.++## Skill File++Every CLI **must** have a `SKILL.md` so Claude Code knows how to use it.++### Where it lives++- Source: in the tool's own repo (e.g., `/home/adom/project/my-tool/SKILL.md`)+- Embedded in the binary via `include_str!`+- Deployed to `~/.claude/skills/<tool-name>/SKILL.md` by `my-tool install`++### What it contains++```markdown+---+name: tool-name+description: "One-line description with trigger words..."+---++# Tool Name++Brief description of what it does.++## Commands++### `toolname verb`+What it does.++| Flag | Required | Default | Description |+|------|----------|---------|-------------|+| `--flag` | yes | — | What it controls |++**Example:**+\`\`\`bash+toolname verb --flag value /path/to/thing+\`\`\`+```++Include **concrete examples** for every command. The AI copies from examples — if the example is wrong or missing, the AI will guess and get it wrong.++### Register in the capabilities table++Add a row to `~/.claude/skills/adom/SKILL.md`:+```text+| Tool Name | "trigger words" | Standalone skill: `~/.claude/skills/<tool>/SKILL.md` |+```++## Distribution++### Own GitHub repo (recommended)++Every shared CLI should live in its own `adom-inc/<tool-name>` repo. This keeps the tool self-contained, gives it its own release cycle, and avoids bloating gallia with build artifacts. Examples: `adom-inc/adom-cli`, `adom-inc/adom-vscode`.++### The `install` command pattern++**The binary owns its own install.** One command — `my-tool install` — sets up everything: the skill, shell completions, companion pieces (VSIX, config files, etc.). `gallia/install.mjs` just downloads the binary and runs it.++#### DO NOT embed SKILL.md / BUILD-SKILL.md in the binary (hard rule)++Skills are prose, and we edit them *a lot* more often than we rebuild the Rust code — adding triggers, clarifying tool-use patterns, patching a broken sentence an AI misinterpreted. If a skill is compiled into the binary via `include_str!`, every single prose fix requires a full binary rebuild, a GitHub release, a wiki asset upload, and a re-install on every user container. That's way too much ceremony for a 10-character typo. Worse: until a user reinstalls the binary, they silently run the stale skill.++**The rule:**++- SKILL.md and BUILD-SKILL.md ship as **independent assets on the wiki page**. Upload them alongside the binary:+  ```bash+  adom-wiki asset upload apps/my-tool --asset-type skill       --file SKILL.md+  adom-wiki asset upload apps/my-tool --asset-type build-skill --file BUILD-SKILL.md+  ```+- The binary's `install` subcommand **fetches the latest SKILL.md from the wiki** at install time (and falls back to a bundled copy if the wiki is unreachable). The fallback is a safety net, not the primary path.+- Bumping a skill becomes: edit `SKILL.md` in the repo → `adom-wiki asset upload …` → done. No binary rebuild, no version bump. The next user to run `my-tool install` (or re-run gallia's Tier A refresh) picks up the new prose.++Same rule applies to any other rapidly-changing prose asset (policy docs, prompt templates, wiki `body.md`). Binary-side data that is *strictly coupled* to the binary's own structure (e.g., a `VSIX` whose API matches the current binary's RPC schema, or baked-in default config schemas used by the Rust types) can still be embedded via `include_bytes!` — couple-to-code, not couple-to-prose.++#### Implementation — `install` subcommand++1. **Fetch skill from the wiki, fall back to a bundled copy:**+   ```rust+   // Bundled SKILL.md is the fallback used only when the wiki is unreachable.+   // `// SAFETY NET — do not rely on for release propagation.`+   const FALLBACK_SKILL_MD: &str = include_str!("../../SKILL.md");++   fn fetch_skill_md() -> Cow<'static, str> {+       let url = "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/my-tool/SKILL.md";+       match ureq::get(url).timeout(Duration::from_secs(5)).call() {+           Ok(r) => r.into_string().map(Cow::Owned).unwrap_or(Cow::Borrowed(FALLBACK_SKILL_MD)),+           Err(_) => {+               warn("wiki unreachable — installing bundled SKILL.md (may be stale)");+               Cow::Borrowed(FALLBACK_SKILL_MD)+           }+       }+   }+   ```++2. **Add an `install` subcommand** that handles all setup:+   ```rust+   Commands::Install => {+       // 1. Install companion pieces (VSIX, config, etc.)+       // 2. Install skill — fetched fresh from the wiki+       let skill_dir = format!("{home}/.claude/skills/my-tool");+       fs::create_dir_all(&skill_dir).ok();+       fs::write(format!("{skill_dir}/SKILL.md"), fetch_skill_md().as_ref()).ok();+       // 3. Install bash completions+       let comp_dir = format!("{home}/.local/share/bash-completion/completions");+       fs::create_dir_all(&comp_dir).ok();+       let mut cmd = Cli::command();+       let mut buf = Vec::new();+       clap_complete::generate(Shell::Bash, &mut cmd, "my-tool", &mut buf);+       fs::write(format!("{comp_dir}/my-tool"), &buf).ok();+       // 4. Add to .bashrc if not already there+       let bashrc = fs::read_to_string(format!("{home}/.bashrc")).unwrap_or_default();+       if !bashrc.contains("# my-tool completions") {+           fs::OpenOptions::new().append(true).open(format!("{home}/.bashrc"))+               .map(|mut f| f.write_all(b"\n# my-tool completions\neval \"$(my-tool completions bash 2>/dev/null)\"\n")).ok();+       }+   }+   ```++3. **Attach binary to GitHub Releases** — no Rust toolchain needed on user containers++4. **`gallia/install.mjs` is just two lines:**+   ```javascript+   execSync('gh release download --repo adom-inc/my-tool --pattern "my-tool" --output /usr/local/bin/my-tool --clobber');+   execSync('sudo chmod +x /usr/local/bin/my-tool && my-tool install');+   ```++The binary is the primary artifact for *code*. The wiki is the primary artifact for *prose*. `gallia/install.mjs` never needs to know the internals — it just downloads the binary and runs `install`, which then reaches back to the wiki for the skills.++### build.sh (for local development)++Every tool should have a `build.sh` for building locally before cutting a release:+1. Build the binary (`cargo build --release`)+2. Install to `/usr/local/bin/`+3. Run `my-tool install` (installs everything locally)++## Examples of This Pattern++| Tool | Port | What it does |+|------|------|-------------|+| `shotlog` | 8820 | Screenshot log viewer + injector |+| `adom-vscode` | 8821 | VS Code control API (open files, reveal, extensions, Claude Code) |+| `adom-cli` | — | Adom platform API (containers, repos, users, workspaces) |
page.jsonadded+50
@@ -0,0 +1,50 @@+{+  "schema_version": 1,+  "type": "skill",+  "slug": "adom-cli-design",+  "title": "adom CLI design",+  "brief": "Design conventions for every Rust CLI in the Adom ecosystem. Output formats (OK:/ERROR: + JSON companion), no-color-when-piped, --json for machines and pretty for humans, exit codes, subcommand layout",+  "version": "1.0.0",+  "tags": [],+  "license": "MIT",+  "sample_prompts": [+    {+      "label": "CLI conventions",+      "prompt": "What output format should my new Adom CLI use?"+    },+    {+      "label": "No ANSI piped",+      "prompt": "How do I gate ANSI escapes on isatty?"+    },+    {+      "label": "OK ERROR",+      "prompt": "How do I emit OK:/ERROR: lines for AI parseability?"+    },+    {+      "label": "JSON companion",+      "prompt": "Add a JSON companion line to my CLI output"+    },+    {+      "label": "Exit codes",+      "prompt": "What exit codes does Adom expect?"+    }+  ],+  "source_path": "SKILL.md",+  "readme": "---\nname: adom-cli-design\ndescription: Guidelines for building CLI tools in the Adom ecosystem. Use when creating a new CLI, adding commands to an existing one, or deciding between CLI vs MCP vs HTTP API. Covers Rust CLI conventions, AI-oriented output, skill files, and why we prefer CLIs over MCP servers. Trigger words: build a cli, new cli tool, cli design, cli guidelines, mcp vs cli, should I use mcp, make a tool for the ai.\n---\n\n# Building CLI Tools for Adom\n\n## Why CLI over MCP\n\nWe use Rust CLIs as the primary interface between Claude Code and Adom tools. **Do not build MCP servers** for new tools.\n\n| | MCP Server | Rust CLI |\n|---|---|---|\n| Context cost | High — tool schemas injected into every conversation | Zero — skill file loaded only when triggered |\n| Discoverability | Always visible, clutters tool list | Skill-triggered, on-demand |\n| Reliability | Stdio transport can hang, crash, or desync | Process runs and exits cleanly |\n| Debugging | Opaque — hard to see what happened | Just run the command in terminal |\n| Distribution | Requires .mcp.json wiring per container | Single binary, `cp` to `/usr/local/bin/` |\n| AI usability | AI must guess tool params from schema | AI reads skill examples, runs bash |\n\n**The pattern:** Rust CLI binary + Claude Code skill file. The AI reads the skill to learn the commands, then runs them via Bash.\n\n## CLI Conventions\n\n### Language: Rust\n\n- Use `clap` with derive macros for argument parsing\n- Use `serde_json` for JSON input/output\n- Minimize dependencies — prefer raw TCP over `reqwest` for simple HTTP calls\n- Build with `--release`, `opt-level = \"z\"`, `lto = true`, `strip = true` for small binaries\n- Install to `/usr/local/bin/` so it's on PATH everywhere\n\n### Output: AI-Oriented\n\nThe CLI output is consumed by Claude Code, not humans. Design accordingly:\n\n**Every response starts with `OK:` or `ERROR:`** — the AI can instantly determine success/failure.\n\n**Success output is descriptive and confirms what happened:**\n```text\nOK: Opened /home/adom/project/README.md in VS Code editor tab.\nOK: Injected shot-2026-03-28T14-30-22.png (1920x1080, 245 KB) into channel \"dart2\".\nOK: Server listening on port 8820, recovered 3 channels from disk.\n```\n\n**Error output includes a `Hint:` line with the next action:**\n```text\nERROR: Cannot connect to shotlog server on port 8820.\nHint: Start the server with `shotlog serve &`\n\nERROR: File not found: /home/adom/project/missing.png\nHint: Check the path exists. Use `ls /home/adom/project/` to list files.\n\nERROR: Adom Bridge extension not responding on port 8821.\nHint: The extension may need a window reload. Run: adom-bridge health\n```\n\n**Never output ambiguous or empty responses.** If a command succeeds silently, still print `OK:`.\n\n### Colored output\n\nUse ANSI escape codes for colored terminal output. No crate needed — raw escape codes work everywhere. But colors are for **humans at interactive terminals only**: when the CLI's output is piped (`cmd | grep`, `cmd > log`, `nohup cmd > log.txt 2>&1`, bash command substitution, etc.), ANSI escape codes leak into log files and into the AI's stdout capture as ugly garbage like `[36mstarting server[0m`. Every CLI MUST strip ANSI when stdout/stderr is not a TTY.\n\n**Required pattern** — `is_terminal()` check gated on each output helper, with a matching `strip_ansi` fallback. Both `println!` / `eprintln!` calls that embed color constants AND internal progress messages need this — not just the `ok` / `err` / `hint` / `warn` helpers:\n\n```rust\nuse std::io::IsTerminal;\nuse std::sync::OnceLock;\n\nconst GREEN: &str = \"\\x1b[32m\";\nconst RED: &str = \"\\x1b[31m\";\nconst YELLOW: &str = \"\\x1b[33m\";\nconst CYAN: &str = \"\\x1b[36m\";\nconst BOLD: &str = \"\\x1b[1m\";\nconst DIM: &str = \"\\x1b[2m\";\nconst RESET: &str = \"\\x1b[0m\";\n\nstatic STDOUT_IS_TTY: OnceLock<bool> = OnceLock::new();\nstatic STDERR_IS_TTY: OnceLock<bool> = OnceLock::new();\npub fn stdout_is_tty() -> bool { *STDOUT_IS_TTY.get_or_init(|| std::io::stdout().is_terminal()) }\npub fn stderr_is_tty() -> bool { *STDERR_IS_TTY.get_or_init(|| std::io::stderr().is_terminal()) }\n\npub fn strip_ansi(s: &str) -> String {\n    // Strip CSI sequences like \\x1b[36m, \\x1b[0m, \\x1b[1;32m etc.\n    let mut out = String::with_capacity(s.len());\n    let mut chars = s.chars().peekable();\n    while let Some(c) = chars.next() {\n        if c == '\\x1b' && chars.peek() == Some(&'[') {\n            chars.next();\n            for c2 in chars.by_ref() { if c2.is_ascii_alphabetic() { break; } }\n            continue;\n        }\n        out.push(c);\n    }\n    out\n}\n\nfn ok(msg: impl std::fmt::Display) {\n    if stdout_is_tty() {\n        println!(\"{GREEN}{BOLD}OK:{RESET} {msg}\");\n    } else {\n        println!(\"OK: {}\", strip_ansi(&msg.to_string()));\n    }\n}\nfn err(msg: impl std::fmt::Display) {\n    if stderr_is_tty() {\n        eprintln!(\"{RED}{BOLD}ERROR:{RESET} {msg}\");\n    } else {\n        eprintln!(\"ERROR: {}\", strip_ansi(&msg.to_string()));\n    }\n}\nfn hint(msg: impl std::fmt::Display) {\n    if stderr_is_tty() {\n        eprintln!(\"{DIM}Hint:{RESET} {msg}\");\n    } else {\n        eprintln!(\"Hint: {}\", strip_ansi(&msg.to_string()));\n    }\n}\n\n// And — critically — `note!` for every internal progress message\n// (\"starting server on port X\", \"running ffmpeg...\", \"filter graph:\n// N chars\", etc.). These are the ones that tend to get written as\n// bare `println!(\"{CYAN}...{RESET}\")` and leak raw escape codes into\n// logs when the CLI is run under nohup or backgrounded to a file.\n// Route them all through a `note!` macro so the isatty gate is\n// unmissable:\nmacro_rules! note {\n    ($($arg:tt)*) => {{\n        let __s = format!($($arg)*);\n        if $crate::stdout_is_tty() {\n            println!(\"{}\", __s);\n        } else {\n            println!(\"{}\", $crate::strip_ansi(&__s));\n        }\n    }};\n}\n```\n\n- `OK:` in green bold, paths/values in cyan, search terms in yellow.\n- `ERROR:` in red bold, hints in dim.\n- The AI reads `OK:` / `ERROR:` prefixes; colors are for human readability **only**.\n- **Never leak raw ANSI escape codes to pipes.** If you see `[36m` or `[0m` in a log file, a CLI is broken. The `video-post` crate (adom-inc/video-post on GitHub) implements the full pattern above in `src/main.rs` — `note!` + `enote!` macros + `ok/err/hint/warn` helpers all gated on isatty. Copy from there for any new CLI.\n\n### Bash tab completions\n\nUse `clap_complete` to generate bash completions. The `install` command should write them to `~/.local/share/bash-completion/completions/<tool>` and append an `eval` line to `.bashrc`:\n\n```rust\n// In Cargo.toml:\nclap_complete = \"4\"\n\n// Generate completions:\nlet mut cmd = Cli::command();\nlet mut buf = Vec::new();\nclap_complete::generate(clap_complete::Shell::Bash, &mut cmd, \"my-tool\", &mut buf);\nfs::write(format!(\"{completions_dir}/my-tool\"), &buf).ok();\n```\n\nUsers get tab completion immediately in new terminals -- `my-tool <TAB>` shows all subcommands.\n\n### Subcommand Structure\n\nUse subcommands for logical grouping:\n```text\ntoolname <verb> [options] [args]\n\nshotlog serve --port 8820\nshotlog inject --channel dart2 --desc \"...\" file.png\nshotlog open --channel dart2\n\nadom-bridge open /path/to/file.png\nadom-bridge reveal /path/to/folder/\nadom-bridge claude new\nadom-bridge extensions search \"python\"\n```\n\n### Port Registration\n\n**Before picking a port**, check `/home/adom/gallia/PORT-REGISTRY.md`. Add your service to the registry before writing code. Port ranges are pre-allocated by category (8820–8829 for Adom tools, 8850–8899 for user/ephemeral, etc.).\n\n### Health Check\n\nEvery CLI that talks to a server should have a `health` subcommand:\n```text\ntoolname health\n```\nReturns `OK: ...` if the server is reachable, `ERROR: ...` with a hint if not. This lets the AI self-diagnose connection issues.\n\n## Skill File\n\nEvery CLI **must** have a `SKILL.md` so Claude Code knows how to use it.\n\n### Where it lives\n\n- Source: in the tool's own repo (e.g., `/home/adom/project/my-tool/SKILL.md`)\n- Embedded in the binary via `include_str!`\n- Deployed to `~/.claude/skills/<tool-name>/SKILL.md` by `my-tool install`\n\n### What it contains\n\n```markdown\n---\nname: tool-name\ndescription: \"One-line description with trigger words...\"\n---\n\n# Tool Name\n\nBrief description of what it does.\n\n## Commands\n\n### `toolname verb`\nWhat it does.\n\n| Flag | Required | Default | Description |\n|------|----------|---------|-------------|\n| `--flag` | yes | — | What it controls |\n\n**Example:**\n\\`\\`\\`bash\ntoolname verb --flag value /path/to/thing\n\\`\\`\\`\n```\n\nInclude **concrete examples** for every command. The AI copies from examples — if the example is wrong or missing, the AI will guess and get it wrong.\n\n### Register in the capabilities table\n\nAdd a row to `~/.claude/skills/adom/SKILL.md`:\n```text\n| Tool Name | \"trigger words\" | Standalone skill: `~/.claude/skills/<tool>/SKILL.md` |\n```\n\n## Distribution\n\n### Own GitHub repo (recommended)\n\nEvery shared CLI should live in its own `adom-inc/<tool-name>` repo. This keeps the tool self-contained, gives it its own release cycle, and avoids bloating gallia with build artifacts. Examples: `adom-inc/adom-cli`, `adom-inc/adom-vscode`.\n\n### The `install` command pattern\n\n**The binary owns its own install.** One command — `my-tool install` — sets up everything: the skill, shell completions, companion pieces (VSIX, config files, etc.). `gallia/install.mjs` just downloads the binary and runs it.\n\n#### DO NOT embed SKILL.md / BUILD-SKILL.md in the binary (hard rule)\n\nSkills are prose, and we edit them *a lot* more often than we rebuild the Rust code — adding triggers, clarifying tool-use patterns, patching a broken sentence an AI misinterpreted. If a skill is compiled into the binary via `include_str!`, every single prose fix requires a full binary rebuild, a GitHub release, a wiki asset upload, and a re-install on every user container. That's way too much ceremony for a 10-character typo. Worse: until a user reinstalls the binary, they silently run the stale skill.\n\n**The rule:**\n\n- SKILL.md and BUILD-SKILL.md ship as **independent assets on the wiki page**. Upload them alongside the binary:\n  ```bash\n  adom-wiki asset upload apps/my-tool --asset-type skill       --file SKILL.md\n  adom-wiki asset upload apps/my-tool --asset-type build-skill --file BUILD-SKILL.md\n  ```\n- The binary's `install` subcommand **fetches the latest SKILL.md from the wiki** at install time (and falls back to a bundled copy if the wiki is unreachable). The fallback is a safety net, not the primary path.\n- Bumping a skill becomes: edit `SKILL.md` in the repo → `adom-wiki asset upload …` → done. No binary rebuild, no version bump. The next user to run `my-tool install` (or re-run gallia's Tier A refresh) picks up the new prose.\n\nSame rule applies to any other rapidly-changing prose asset (policy docs, prompt templates, wiki `body.md`). Binary-side data that is *strictly coupled* to the binary's own structure (e.g., a `VSIX` whose API matches the current binary's RPC schema, or baked-in default config schemas used by the Rust types) can still be embedded via `include_bytes!` — couple-to-code, not couple-to-prose.\n\n#### Implementation — `install` subcommand\n\n1. **Fetch skill from the wiki, fall back to a bundled copy:**\n   ```rust\n   // Bundled SKILL.md is the fallback used only when the wiki is unreachable.\n   // `// SAFETY NET — do not rely on for release propagation.`\n   const FALLBACK_SKILL_MD: &str = include_str!(\"../../SKILL.md\");\n\n   fn fetch_skill_md() -> Cow<'static, str> {\n       let url = \"https://wiki-ufypy5dpx93o.adom.cloud/static/apps/my-tool/SKILL.md\";\n       match ureq::get(url).timeout(Duration::from_secs(5)).call() {\n           Ok(r) => r.into_string().map(Cow::Owned).unwrap_or(Cow::Borrowed(FALLBACK_SKILL_MD)),\n           Err(_) => {\n               warn(\"wiki unreachable — installing bundled SKILL.md (may be stale)\");\n               Cow::Borrowed(FALLBACK_SKILL_MD)\n           }\n       }\n   }\n   ```\n\n2. **Add an `install` subcommand** that handles all setup:\n   ```rust\n   Commands::Install => {\n       // 1. Install companion pieces (VSIX, config, etc.)\n       // 2. Install skill — fetched fresh from the wiki\n       let skill_dir = format!(\"{home}/.claude/skills/my-tool\");\n       fs::create_dir_all(&skill_dir).ok();\n       fs::write(format!(\"{skill_dir}/SKILL.md\"), fetch_skill_md().as_ref()).ok();\n       // 3. Install bash completions\n       let comp_dir = format!(\"{home}/.local/share/bash-completion/completions\");\n       fs::create_dir_all(&comp_dir).ok();\n       let mut cmd = Cli::command();\n       let mut buf = Vec::new();\n       clap_complete::generate(Shell::Bash, &mut cmd, \"my-tool\", &mut buf);\n       fs::write(format!(\"{comp_dir}/my-tool\"), &buf).ok();\n       // 4. Add to .bashrc if not already there\n       let bashrc = fs::read_to_string(format!(\"{home}/.bashrc\")).unwrap_or_default();\n       if !bashrc.contains(\"# my-tool completions\") {\n           fs::OpenOptions::new().append(true).open(format!(\"{home}/.bashrc\"))\n               .map(|mut f| f.write_all(b\"\\n# my-tool completions\\neval \\\"$(my-tool completions bash 2>/dev/null)\\\"\\n\")).ok();\n       }\n   }\n   ```\n\n3. **Attach binary to GitHub Releases** — no Rust toolchain needed on user containers\n\n4. **`gallia/install.mjs` is just two lines:**\n   ```javascript\n   execSync('gh release download --repo adom-inc/my-tool --pattern \"my-tool\" --output /usr/local/bin/my-tool --clobber');\n   execSync('sudo chmod +x /usr/local/bin/my-tool && my-tool install');\n   ```\n\nThe binary is the primary artifact for *code*. The wiki is the primary artifact for *prose*. `gallia/install.mjs` never needs to know the internals — it just downloads the binary and runs `install`, which then reaches back to the wiki for the skills.\n\n### build.sh (for local development)\n\nEvery tool should have a `build.sh` for building locally before cutting a release:\n1. Build the binary (`cargo build --release`)\n2. Install to `/usr/local/bin/`\n3. Run `my-tool install` (installs everything locally)\n\n## Examples of This Pattern\n\n| Tool | Port | What it does |\n|------|------|-------------|\n| `shotlog` | 8820 | Screenshot log viewer + injector |\n| `adom-vscode` | 8821 | VS Code control API (open files, reveal, extensions, Claude Code) |\n| `adom-cli` | — | Adom platform API (containers, repos, users, workspaces) |\n",+  "author": {+    "id": "695820315b5f1e4db2fcf602",+    "name": "Kyle Bergstedt",+    "email": "[email protected]"+  },+  "visibility": {+    "public": true+  },+  "hero": null,+  "discovery_triggers": [],+  "discovery_pitch": null,+  "metadata": {},+  "created_at": "2026-05-28T05:29:40.324Z",+  "updated_at": "2026-05-28T05:29:40.324Z",+  "sub_skills": [],+  "parent_app": null+}\ No newline at end of file