💬 Sample prompts Paste any of these into Claude Code to use this skill
CLI conventions What output format should my new Adom CLI use?
No ANSI piped How do I gate ANSI escapes on isatty?
OK ERROR How do I emit OK:/ERROR: lines for AI parseability?
JSON companion Add a JSON companion line to my CLI output
Exit codes What exit codes does Adom expect?
Install this skill

Paste this into Claude Code (VS Code panel, Adom editor, or terminal) to install:

Search the Adom Wiki for the skill "adom CLI design" (slug: adom-cli-design) at https://wiki-ufypy5dpx93o.adom.cloud/wiki/skills/adom-cli-design and install it into my local ~/.claude/skills/adom-cli-design/ directory. Fetch the skill_source content from the wiki page and save it as SKILL.md. Then confirm it's installed by showing the first 5 lines.
?
What is a skill? Skills are instructions that teach AI assistants like Claude Code how to perform specific tasks. The description below is loaded into the AI as context when you invoke this skill. Well-written skills make the AI significantly more effective. Like Wikipedia, anyone can improve a skill by clicking Edit AI Skill — or have your AI submit an edit on your behalf.

Description

Edit AI Skill

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 ServerRust CLI
Context costHigh — tool schemas injected into every conversationZero — skill file loaded only when triggered
DiscoverabilityAlways visible, clutters tool listSkill-triggered, on-demand
ReliabilityStdio transport can hang, crash, or desyncProcess runs and exits cleanly
DebuggingOpaque — hard to see what happenedJust run the command in terminal
DistributionRequires .mcp.json wiring per containerSingle binary, cp to /usr/local/bin/
AI usabilityAI must guess tool params from schemaAI 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:

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:

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 patternis_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:

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.rsnote! + 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:

// 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:

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:

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

---
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:

| 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:
    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:

    // 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:

    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:

    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

ToolPortWhat it does
shotlog8820Screenshot log viewer + injector
adom-vscode8821VS Code control API (open files, reveal, extensions, Claude Code)
adom-cliAdom platform API (containers, repos, users, workspaces)

Skill Source

Edit AI Skill
---
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) |

Sub-Skills
?
What are Sub-Skills?

Sub-skills are community-contributed AI skill extensions for this component. They teach AI assistants about specific tools, configurators, or workflows.

Examples:

  • A manufacturer’s configuration tool for a motor controller
  • A community-written design guide for an amplifier circuit
  • An automated test/validation script for a sensor module

How to add one: Click Add Sub-Skill, provide the URL to your skill and a brief description. Submissions are reviewed by the Adom team before going live.

No sub-skills yet. Be the first to contribute one!

Recent activity

1 commit
  • Edit v1.0.0 John Lauer 26 days ago
    Sync with gallia — cleanup pass on descriptions, code fences, and paths
0 revisions · Updated 2026-05-01 14:14:18