skill / adom-cli-design
!

Not installable via adompkg

This skill has no published release. adompkg install kyle/adom-cli-design will not work until a maintainer publishes a tarball with install.sh and uninstall.sh.

See the publishing docs for the package.json schema and tarball layout required to ship this 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:

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

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)