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.
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.
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 + Args structs
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()
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 (optional, recommended)
update.rs # Distribution-aware update (optional)
tests/ # Integration tests verifying contracts
Cargo.toml
- Every stdout path respects output format. JSON when piped, colored human-readable output in terminal. No exceptions. No raw text leaks.
--helpand--versionexit 0. They are not errors. Wrap in success envelope when piped.- Errors go to stderr. Both JSON and human-readable.
tool cmd | jqmust never break on error text. - Exit codes are: 0=success, 1=retry, 2=config, 3=input, 4=rate-limited. Nothing else.
agent-infomatches reality. Every command listed works. Every flag described exists. This is a tested contract.- Suggestions are tested instructions. An agent follows them literally. Wrong suggestions are P0 bugs.
- No interactive prompts. No stdin reads. No pagers. Destructive ops take
--confirmflag. - Secrets are never displayed in plain text. Mask with
mask_secret(). Never include in error messages.
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.
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).
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);
let config = config::load().unwrap_or_else(|e| {
print_error(ctx.format, &e);
std::process::exit(e.exit_code());
});
if let Err(e) = run(cli, ctx, &config) {
print_error(ctx.format, &e);
std::process::exit(e.exit_code());
}
}- Path:
~/.config/<app>/config.toml - Load: defaults -> TOML file -> env vars (prefix
<APP>_) - Use
figmentcrate for merging - Use
directoriescrate for platform paths
- 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
| Purpose | Path | Deletable? |
|---|---|---|
| Config | ~/.config/<app>/ |
No (user settings) |
| State | ~/.local/share/<app>/ |
Careful (operational data) |
| Cache | ~/.cache/<app>/ |
Always safe |
Always alias CRUD subcommands: list/ls, create/new, delete/rm, show/get. Use #[command(visible_alias = "ls")]. Be consistent across all subcommand groups.
Every CLI has these built-in commands:
agent-info(aliasinfo) -- capability manifest, raw JSON, not wrapped in envelopeskill 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
Optional:
doctor-- check external dependencies (API keys, binaries, endpoints). Returns structured pass/warn/fail. Exit 0 if all pass, exit 2 if any fail.update [--check]-- distribution-aware update check/apply
--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.
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)
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"] }
[profile.release]
lto = true
codegen-units = 1
strip = true
opt-level = 3For 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:
- Before starting: check for
~/.local/share/<app>/locks/<operation>.lock - If lock exists and is fresh (< 1 hour): exit 3 with suggestion "Operation already running. Use --force to override."
- If lock exists but stale (> 1 hour): warn and continue
- Create lock file with PID + timestamp
- Remove lock on completion (success or failure)
--forceflag bypasses the guard
Lock file format: {"pid": 12345, "started_at": "2026-04-12T10:00:00Z", "operation": "deploy"}
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> --versionvalidation, 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>orcargo 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.
See the example/ directory in this repo for a working implementation of the core patterns and the entry point, error type, and output helpers. Config loading, secret handling, XDG paths, doctor, duplicate guard, and HTTP retry are documented as code patterns in the README's Reusable Modules section.