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