Skip to content

Latest commit

 

History

History
274 lines (211 loc) · 11.6 KB

File metadata and controls

274 lines (211 loc) · 11.6 KB

Agent CLI Framework -- Build Instructions for AI Agents

You are building a Rust CLI that AI agents can discover, call, and learn from. Follow these rules exactly. Do not deviate, add features, or invent patterns not described here.

Spirit

This framework builds tools that are self-explanatory, hyper-efficient, powerful, fast, and local. The binary IS the interface -- no MCP servers, no protocol layers, no external documentation. An agent that has your CLI on PATH has everything it needs.

Architecture

Split your CLI into focused modules. Never write a monolithic main.rs.

src/
  main.rs         # Entry point only: parse, detect format, dispatch, exit
  cli.rs          # Clap derive: Cli struct + Commands enum + help footer
  config.rs       # AppConfig + load() via figment (3-tier precedence)
  error.rs        # Error enum with exit_code(), error_code(), suggestion()
  output.rs       # Format enum, Ctx struct, print_success_or(), print_error()
  guard.rs        # Duplicate guard (when any command is expensive/irreversible)
  commands/
    mod.rs        # Re-exports
    <command>.rs  # One file per domain command
    agent_info.rs # Capability manifest with arg schemas (always present)
    skill.rs      # Skill install + status (always present)
    config.rs     # config show/path (always present)
    doctor.rs     # Dependency diagnostics (required when the CLI has external deps)
    update.rs     # Distribution-aware update (required when distributed)
tests/            # Integration tests verifying contracts (crate root, not src/)
Cargo.toml

Non-Negotiable Rules

  1. Every stdout path respects output format. JSON when piped, colored human-readable output in terminal. No exceptions. No raw text leaks.
  2. --help and --version exit 0. They are not errors. Wrap in success envelope when piped.
  3. Errors go to stderr. Both JSON and human-readable. tool cmd | jq must never break on error text.
  4. Exit codes are: 0=success, 1=retry, 2=config, 3=input, 4=rate-limited. Nothing else.
  5. agent-info matches reality. Every command listed works. Every flag described exists. This is a tested contract.
  6. Suggestions are tested instructions. An agent follows them literally. Wrong suggestions are P0 bugs.
  7. No interactive prompts. No stdin reads. No pagers. Destructive ops take --confirm flag.
  8. Secrets are never displayed in plain text. Mask with mask_secret(). Never include in error messages.

Output Format

Detect automatically. Bundle format + quiet into an output context:

pub enum Format { Json, Human }

impl Format {
    pub fn detect(json_flag: bool) -> Self {
        if json_flag || !std::io::stdout().is_terminal() { Format::Json }
        else { Format::Human }
    }
}

pub struct Ctx { pub format: Format, pub quiet: bool }

impl Ctx {
    pub fn new(json_flag: bool, quiet: bool) -> Self {
        Self { format: Format::detect(json_flag), quiet }
    }
}

Pass Ctx to all commands. --quiet suppresses human output; JSON always emits.

Success envelope (stdout):

{"version": "1", "status": "success", "data": { ... }}

Error envelope (stderr):

{"version": "1", "status": "error", "error": {"code": "...", "message": "...", "suggestion": "..."}}

Extended status values for multi-source operations: success, partial_success, all_failed, no_results.

Error Pattern

Every error enum implements three methods -- the contract that connects errors to exit codes and JSON envelopes:

impl AppError {
    pub fn exit_code(&self) -> i32;    // 1=transient, 2=config, 3=input, 4=rate-limited
    pub fn error_code(&self) -> &str;  // "invalid_input", "config_error", etc.
    pub fn suggestion(&self) -> &str;  // Tested recovery instruction (agents follow literally)
}

Standard categories: InvalidInput (3), Config (2), Transient/Io/Update (1), RateLimited (4).

Entry Point Pattern

Pre-scan --json before clap parses so it works on help/version/error paths. Never let clap own the exit code — always exit explicitly through the framework.

fn has_json_flag() -> bool {
    std::env::args_os().any(|a| a == "--json")
}

fn main() {
    let json_flag = has_json_flag();
    let cli = match Cli::try_parse() {
        Ok(cli) => cli,
        Err(e) => {
            if matches!(e.kind(),
                clap::error::ErrorKind::DisplayHelp
                | clap::error::ErrorKind::DisplayVersion
            ) {
                let format = Format::detect(json_flag);
                match format {
                    Format::Json => { print_help_json(e); std::process::exit(0); }
                    Format::Human => e.exit(),
                }
            }
            // Parse errors: we own the exit code, always 3
            let format = Format::detect(json_flag);
            print_clap_error(format, &e);
            std::process::exit(3);
        }
    };
    let ctx = Ctx::new(cli.json, cli.quiet);
    // Load config lazily -- only inside commands that need it. agent-info,
    // config path, skill, and pure domain commands must keep working even
    // when config.toml is malformed.
    if let Err(e) = run(cli, ctx) {
        print_error(ctx.format, &e);
        std::process::exit(e.exit_code());
    }
}

Config Convention

  • Path: ~/.config/<app>/config.toml
  • Load: defaults -> TOML file -> env vars (prefix <APP>_)
  • Use figment crate for merging
  • Use directories crate for platform paths

Secret Convention

  • Resolution: flag value -> env var -> config file (first non-empty wins)
  • Display: always masked (sk-pr...1234)
  • Never store in state databases, never log plain text

Directory Convention

Purpose Path Deletable?
Config ~/.config/<app>/ No (user settings)
State ~/.local/share/<app>/ Careful (operational data)
Cache ~/.cache/<app>/ Always safe

Command Naming

Always alias CRUD subcommands: list/ls, create/new, delete/rm, show/get. Use #[command(visible_alias = "ls")]. Be consistent across all subcommand groups.

Standard Commands

Every CLI has these built-in commands:

  • agent-info (alias info) -- capability manifest, raw JSON, not wrapped in envelope
  • skill install -- write SKILL.md to ~/.claude/skills/<name>/, ~/.codex/skills/<name>/, ~/.gemini/skills/<name>/
  • skill status -- check installation status

Standard:

  • config show -- display effective merged config (secrets masked)
  • config path -- print config file path

Required when applicable:

  • doctor -- required when the CLI has external dependencies (API keys, binaries, endpoints). Returns structured pass/warn/fail checks. Exit 0 when no check fails (warnings allowed), exit 2 when any check fails.
  • update [--check] -- required when the CLI is distributed; distribution-aware update check/apply

The agent-info manifest uses ONE canonical shape (defined by the example/ binary): commands is an object of command objects with description, args, and options schemas; aliases go in an aliases array; global flags under global_flags; config metadata nests under config with path and env_prefix. Do not invent alternative shapes.

Rich Help

--help output should include a Tips section and an Examples section after the standard clap output, using after_long_help in clap. Tips are contextual guidance (3-8 bullets). Examples are real commands agents can copy. This is especially valuable for agents that read --help to bootstrap usage.

Global Flags

Always at the top-level Cli struct:

  • --json -- force JSON output even in terminal (required, always present)
  • --quiet -- suppress informational human output; JSON always emits (required, always present)

Dependencies

clap = { version = "4", features = ["derive", "env"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
comfy-table = "7"
owo-colors = "4"
directories = "6"
figment = { version = "0.10", features = ["toml", "env"] }
which = "8"     # doctor: binary-on-PATH checks
chrono = "0.4"  # duplicate guard: lock timestamps
libc = "0.2"    # duplicate guard: PID liveness

[profile.release]
lto = true
codegen-units = 1
strip = true
opt-level = 3

Duplicate Guard

For commands that do expensive or irreversible work (API calls, long computations, deployments), prevent accidental duplicate runs. Use a lock file in the state directory. The pattern:

  1. Before starting: check for ~/.local/share/<app>/locks/<operation>.lock
  2. If lock exists, its PID is alive, and it is fresh (< 1 hour): exit 3 with suggestion "Operation already running. Use --force to override."
  3. If the lock's PID is dead, the lock is stale (> 1 hour), or the timestamp is unparseable: overwrite and continue
  4. Create lock file with PID + timestamp
  5. Remove lock on completion (success or failure) -- use a Drop impl so early returns and panics still clean up
  6. --force flag bypasses the guard

Lock file format: {"pid": 12345, "started_at": "2026-04-12T10:00:00Z", "operation": "deploy"}

Update Standard

The update rule is one command, distribution-aware update paths.

update --check is always safe: no filesystem mutation, no package-manager upgrade, no shell profile changes, no raw stdout leaks, and exit 0 when the check completes even if a new version exists.

update must respect the channel that owns the installed binary:

  • Standalone installer binary: may self-replace from GitHub Releases after exact platform asset selection, HTTPS download, SHA256 verification, optional attestation/signature verification, temp-file staging, <new-binary> --version validation, and atomic replacement.
  • Homebrew install: do not self-replace; use or return brew upgrade <formula>.
  • Cargo install: do not self-replace; use or return cargo install --locked --force <crate> or cargo binstall --no-confirm <crate> when supported.
  • npm, Bun package-manager, uv tool, pipx, winget, scoop, apt, and enterprise-managed installs: defer to the owning package manager or internal rollout process.
  • Unknown install source: return update_mode = "instructions_only" instead of blindly replacing the current executable.

update --check --json must return a success envelope with current_version, latest_version, status, install_source, update_mode, upgrade_command, release_url, and requires_skill_reinstall.

Release artifacts should be built in CI, not on a developer laptop. For Rust CLIs, prefer cargo-dist or an equivalent release pipeline that produces GitHub Release archives, checksums, Homebrew formulae, cargo-binstall-compatible artifacts, and optional GitHub artifact attestations. See docs/update-standard.md for the full policy and required tests.

Conformance

Your built binary MUST pass the framework conformance probe before you ship:

<path-to-this-repo>/conformance/conformance.sh ./target/release/<your-cli>

It checks the agent-info manifest shape, command routability, help/version behavior, stderr discipline, and the exit-code contract. The JSON Schemas in schemas/ (envelope.schema.json, agent-info.schema.json) are the precise shape definitions -- validate against them when in doubt. Wire the script into your CI the way this repo's .github/workflows/ci.yml does.

Reference

See the example/ directory in this repo for a working implementation of all eight patterns, including the entry point, error type, output helpers, guard, and doctor. Everything marked REPLACE in the example source is placeholder content -- replace it, keep the structure and contracts. Secret handling, XDG paths, and HTTP retry are documented as code patterns in the README's Reusable Modules section.