CLI Tool Architecture
Language-agnostic conventions for command-line tools. Go-specific recipes use spf13/cobra + spf13/pflag + spf13/viper; Python-specific recipes use typer. Per-language stacks are canonical in go-architect and python-architect. See STACK.md for additional CLI-specific libraries (output styling, alternative loggers).
1. Command structure
- Root + subcommands.
tool <subcommand> [args] [flags]. Mirrors git, kubectl, docker, rsk. Top-level flags are global; subcommand flags are scoped to that subcommand.
- One verb per subcommand.
tool create user is fine. tool create-user-now is not.
- Hierarchy when it reads naturally:
tool resource action (e.g. kubectl get pods, aws s3 ls). Skip if your tool has fewer than ~5 commands — flatten.
tool alone (no subcommand) prints help. Never run "the default action" — implicit behavior is the source of surprise commits and accidental deploys.
- Names: short, lowercase, single-word where possible.
get, list, apply, delete. Two words if they're a phrase: get-config.
2. Arguments vs flags
- Positional arguments for the required noun of the verb:
tool delete <user-id>. Use sparingly — every extra positional reduces clarity.
- Flags for everything else. Required flags exist; mark them with
Required: true (cobra) / Option(...) (typer) and document explicitly.
- No more than 2 positional args in most cases. Beyond that, switch to flags.
- Short flags only for the top 5 most-used.
-v, -h, -f, -o. Don't shortify every flag — tool deploy -e prod -r us-east-1 -p high -d becomes unreadable; long form is self-documenting.
- Boolean flags are pure switches:
--dry-run, not --dry-run=true. Negate with --no-foo if both states need an explicit form.
- Plural for repeatable flags:
--tag debug --tag perf becomes --tags debug,perf or --tag repeatable. Pick one and document.
3. Flag / env / config-file / defaults precedence
Configuration values can come from four places. The order is fixed and inviolable:
flag > env var > config file > built-in default
- A
--log-level=debug flag wins over TOOL_LOG_LEVEL=info env wins over log_level = "warn" in the config wins over the compiled-in default (info).
- Env-var prefix is the tool name in upper-snake:
TOOL_LOG_LEVEL, TOOL_API_URL. Document the prefix in --help.
- Show effective config: every CLI should have a
tool config show (or tool config --show) that prints the resolved values and where each came from (flag / env / file / default). This is the difference between "user can diagnose" and "user files a bug ticket".
4. Configuration file — TOML in XDG location
- Default format: TOML — readable, supports comments, modern default (Rust/Cargo, Python
pyproject.toml, uv.lock).
- Default location: XDG Base Directory spec.
- Config:
$XDG_CONFIG_HOME/<tool>/config.toml, falling back to ~/.config/<tool>/config.toml
- Cache:
$XDG_CACHE_HOME/<tool>/, falling back to ~/.cache/<tool>/
- Data:
$XDG_DATA_HOME/<tool>/, falling back to ~/.local/share/<tool>/
- Windows:
%APPDATA%\<tool>\config.toml (per XDG-on-Windows convention translated to platform).
- Discovery order for the config file:
--config <path> flag (explicit override)
TOOL_CONFIG env var
./<tool>.toml (project-local; useful for tools that have per-repo config)
$XDG_CONFIG_HOME/<tool>/config.toml (user-global)
- One canonical filename per tool. Don't accept
.tool.toml, tool.config.toml, config.toml, .toolrc all at once. Pick one and stick to it.
tool init writes a sane default config to the canonical location; refuse to overwrite without --force.
Note: rsk itself uses ~/.config/rsk/config.json (JSON) for legacy / parsing reasons; TOML is the recommended default for new CLIs.
5. Help text discipline
Every command, subcommand, and flag has documented help. Help is the API surface.
tool --help lists subcommands with a one-line description each.
tool <subcommand> --help has: short description, usage line, flags grouped (Required, Common, Global), and at least one example at the bottom.
- Examples drive understanding — newcomers read examples before they read flag tables. Include 2–3 realistic invocations per subcommand.
- Line length 80 chars in help text — terminals are still ~80 cols by default.
- No marketing copy. Short, factual, actionable.
6. Output discipline — stdout for data, stderr for logs
Strict separation. Always.
- Data → stdout. The thing the user wants — JSON, the filename created, the resource ID, the table.
- Logs, progress, errors → stderr. Anything the user doesn't want piped into the next command.
- Errors that prevent producing data must exit non-zero (see §8). Don't print an error to stdout and exit 0.
Non-negotiable. Tools that mix the streams break every shell pipeline. Concrete pipe examples in RECIPES § Output discipline.
7. Output formats — --output json|yaml mandatory on list/get
Human-readable text is the default. Every command returning structured data must also support --output json and --output yaml.
-o short form is standard (kubectl, gh, oc).
- JSON output is stable and documented — clients depend on it. Schema changes are breaking.
- JSONL for list operations — one line per record, so
tool list -o json | grep works.
- YAML for human eyeballing of nested data; rarely useful for pipes.
Invocation examples in RECIPES § Output format flag examples.
8. Exit codes
Standard semantics across the ecosystem — full table in RECIPES § Exit-code reference. Key rules:
0 success, 1 generic failure, 2 misuse, 130 SIGINT. Anything custom is domain-specific and documented.
- Document every non-standard exit code in
--help or tool help exit-codes.
- Don't reuse codes across categories within one tool.
- Misuse vs failure: parsing errors are 2; tool worked but operation failed is 1 or a custom non-zero.
9. Color & TTY behavior
- Auto-detect TTY: color on when stdout is a terminal, off when piped (
tool list | less should not contain ANSI escapes).
- Respect
NO_COLOR environment variable (no-color.org). Set → no color, regardless of TTY detection.
- Respect
--no-color flag as an explicit override.
- Respect
FORCE_COLOR when set (CI logs in tools like GitHub Actions render ANSI).
--color=auto|always|never for full control (matches git, grep, ls).
- Don't go wild with color. A status column (
green: OK, red: FAILED, yellow: WARN) is great. Rainbow output is not.
10. Logging — structured to stderr
CLI logging is for the user's terminal, not log aggregation. Different style from server logging.
- Default log level:
info. -v → debug, -vv → trace. --quiet / -q → warn.
- Log to stderr always. No exceptions.
- Structured output even for human reading — key=value pairs are scannable. Use
log/slog (Go) or structlog (Python). charmbracelet/log (Go) and rich (Python) add pretty-printing while preserving structure.
- Errors include the operation that failed, the inputs that mattered, and any correlation id if cross-service. Format example in RECIPES § Error message format.
- No stack traces in user-facing errors (see §15).
11. Progress feedback
For long-running operations only — anything that takes more than ~2 seconds.
- Spinner for indeterminate work ("connecting to API...").
- Progress bar when total is known ("uploading 1.2 GB / 4 GB").
- Suppress when not a TTY — same auto-detection as color. Piping shouldn't fill the output with
\r overwrites.
- Don't conflict with log output. Pause/reposition the spinner when printing a log line, or write progress to a single line that's overwritten in place.
- Cancellation: Ctrl-C should immediately stop work, clean up, and exit 130.
12. Shell completions
- Ship completions for bash, zsh, fish, pwsh. Cobra and typer both generate them — there's no excuse not to.
- Install command:
tool completion <shell> writes the script to stdout; user can pipe to the right location or use the tool's tool completion install --shell <shell> if you provide that convenience.
- Document the one-time install in
--help or a tool help completion page.
13. Versioning
tool --version and tool version both work; both print the same thing.
- Output format is
tool <semver> (rev <sha>, built <date>, <runtime>) — version + git short SHA + build date + runtime version. The SHA is critical for dev builds. Example in RECIPES § Version output.
- Semver always. Pre-1.0 if the API isn't stable; commit to backward compatibility once you ship 1.0.
tool version --output json for scripting.
14. Distribution & install
- Static single binary as the default delivery (
go build static; Python via pyinstaller or distributed as a uv tool install package).
- Multi-arch: linux/amd64 + linux/arm64 + darwin/amd64 + darwin/arm64 + windows/amd64 + windows/arm64. Mirrors docker-architect §5.
- Release artifacts: signed binaries + checksums + (optional) SBOM. Use
goreleaser (Go) or equivalent for the release matrix.
- Package managers, in order of effort/reach:
- Direct binary download from GitHub Releases (always; the universal fallback).
- Homebrew tap (macOS + Linux).
- Scoop / Winget (Windows).
- Linux package repos (apt, yum) — only when the user base genuinely justifies it.
tool upgrade is a nice-to-have — fetches the latest release and replaces the binary. Make it opt-in; never auto-upgrade.
15. Error messages
Tell the user what failed, why, and what to try next. Three-line format + structured example in RECIPES § Error message format. Key rules:
- No stack traces in user-facing errors. Log them at
debug level (visible with -vv) or write to a file with a correlation id.
- Suggest the fix when the cause is unambiguous.
Did you mean: tool depoly → deploy? (Levenshtein distance from known subcommands).
- Distinguish user errors from tool bugs. "Invalid flag value" → user error, exit 2. "Internal: nil pointer in handler" → bug, exit 1 with a "please file an issue" hint.
16. Language-specific recipes
Implementation skeletons for Go (cobra + pflag + viper) and Python (typer + rich) live in RECIPES.md. Canonical per go-architect §11 and python-architect §10.
1---2name: cli-tool-architect3description: Cross-language CLI standards — subcommand structure, flag/env/config/default precedence, TOML in XDG, stdout-data/stderr-logs split, --output json|yaml, exit codes, NO_COLOR, completions. Go (cobra+pflag+viper) and Python (typer) recipes. Use when designing or reviewing a CLI.4---56# CLI Tool Architecture78Language-agnostic conventions for command-line tools. Go-specific recipes use `spf13/cobra` + `spf13/pflag` + `spf13/viper`; Python-specific recipes use `typer`. Per-language stacks are canonical in [go-architect](../../languages/go-architect/SKILL.md) and [python-architect](../../languages/python-architect/SKILL.md). See [STACK.md](STACK.md) for additional CLI-specific libraries (output styling, alternative loggers).910## 1. Command structure1112- **Root + subcommands.** `tool <subcommand> [args] [flags]`. Mirrors `git`, `kubectl`, `docker`, `rsk`. Top-level flags are global; subcommand flags are scoped to that subcommand.13- **One verb per subcommand.** `tool create user` is fine. `tool create-user-now` is not.14- **Hierarchy when it reads naturally:** `tool resource action` (e.g. `kubectl get pods`, `aws s3 ls`). Skip if your tool has fewer than ~5 commands — flatten.15- **`tool` alone (no subcommand) prints help.** Never run "the default action" — implicit behavior is the source of surprise commits and accidental deploys.16- **Names: short, lowercase, single-word where possible.** `get`, `list`, `apply`, `delete`. Two words if they're a phrase: `get-config`.1718## 2. Arguments vs flags1920- **Positional arguments** for the *required noun* of the verb: `tool delete <user-id>`. Use sparingly — every extra positional reduces clarity.21- **Flags** for everything else. Required flags exist; mark them with `Required: true` (cobra) / `Option(...)` (typer) and document explicitly.22- **No more than 2 positional args** in most cases. Beyond that, switch to flags.23- **Short flags only for the top 5 most-used.** `-v`, `-h`, `-f`, `-o`. Don't shortify every flag — `tool deploy -e prod -r us-east-1 -p high -d` becomes unreadable; long form is self-documenting.24- **Boolean flags are pure switches:** `--dry-run`, not `--dry-run=true`. Negate with `--no-foo` if both states need an explicit form.25- **Plural for repeatable flags:** `--tag debug --tag perf` becomes `--tags debug,perf` or `--tag` repeatable. Pick one and document.2627## 3. Flag / env / config-file / defaults precedence2829Configuration values can come from four places. The order is fixed and inviolable:3031```32flag > env var > config file > built-in default33```3435- A `--log-level=debug` flag wins over `TOOL_LOG_LEVEL=info` env wins over `log_level = "warn"` in the config wins over the compiled-in default (`info`).36- **Env-var prefix** is the tool name in upper-snake: `TOOL_LOG_LEVEL`, `TOOL_API_URL`. Document the prefix in `--help`.37- **Show effective config**: every CLI should have a `tool config show` (or `tool config --show`) that prints the resolved values *and* where each came from (flag / env / file / default). This is the difference between "user can diagnose" and "user files a bug ticket".3839## 4. Configuration file — TOML in XDG location4041- **Default format: TOML** — readable, supports comments, modern default (Rust/Cargo, Python `pyproject.toml`, `uv.lock`).42- **Default location: XDG Base Directory spec.**43 - Config: `$XDG_CONFIG_HOME/<tool>/config.toml`, falling back to `~/.config/<tool>/config.toml`44 - Cache: `$XDG_CACHE_HOME/<tool>/`, falling back to `~/.cache/<tool>/`45 - Data: `$XDG_DATA_HOME/<tool>/`, falling back to `~/.local/share/<tool>/`46 - Windows: `%APPDATA%\<tool>\config.toml` (per [XDG-on-Windows convention](https://specifications.freedesktop.org/basedir-spec/latest/) translated to platform).47- **Discovery order for the config file:**48 1. `--config <path>` flag (explicit override)49 2. `TOOL_CONFIG` env var50 3. `./<tool>.toml` (project-local; useful for tools that have per-repo config)51 4. `$XDG_CONFIG_HOME/<tool>/config.toml` (user-global)52- **One canonical filename per tool.** Don't accept `.tool.toml`, `tool.config.toml`, `config.toml`, `.toolrc` all at once. Pick one and stick to it.53- **`tool init`** writes a sane default config to the canonical location; refuse to overwrite without `--force`.5455> Note: `rsk` itself uses `~/.config/rsk/config.json` (JSON) for legacy / parsing reasons; TOML is the recommended default for new CLIs.5657## 5. Help text discipline5859Every command, subcommand, and flag has documented help. Help is the API surface.6061- **`tool --help`** lists subcommands with a one-line description each.62- **`tool <subcommand> --help`** has: short description, usage line, flags grouped (Required, Common, Global), and **at least one example** at the bottom.63- **Examples drive understanding** — newcomers read examples before they read flag tables. Include 2–3 realistic invocations per subcommand.64- **Line length 80 chars** in help text — terminals are still ~80 cols by default.65- **No marketing copy.** Short, factual, actionable.6667## 6. Output discipline — stdout for data, stderr for logs6869**Strict separation.** Always.7071- **Data → stdout.** The thing the user wants — JSON, the filename created, the resource ID, the table.72- **Logs, progress, errors → stderr.** Anything the user doesn't want piped into the next command.73- **Errors that prevent producing data must exit non-zero** (see §8). Don't print an error to stdout and exit 0.7475Non-negotiable. Tools that mix the streams break every shell pipeline. Concrete pipe examples in [RECIPES § Output discipline](RECIPES.md#output-discipline--stdoutstderr-separation).7677## 7. Output formats — `--output json|yaml` mandatory on list/get7879Human-readable text is the default. Every command returning structured data must also support `--output json` and `--output yaml`.8081- **`-o` short form is standard** (`kubectl`, `gh`, `oc`).82- **JSON output is stable and documented** — clients depend on it. Schema changes are breaking.83- **JSONL for list operations** — one line per record, so `tool list -o json | grep` works.84- **YAML for human eyeballing** of nested data; rarely useful for pipes.8586Invocation examples in [RECIPES § Output format flag examples](RECIPES.md#output-format-flag-examples).8788## 8. Exit codes8990Standard semantics across the ecosystem — full table in [RECIPES § Exit-code reference](RECIPES.md#exit-code-reference). Key rules:9192- **`0` success, `1` generic failure, `2` misuse, `130` SIGINT.** Anything custom is domain-specific and documented.93- **Document every non-standard exit code** in `--help` or `tool help exit-codes`.94- **Don't reuse codes** across categories within one tool.95- **Misuse vs failure:** parsing errors are 2; tool worked but operation failed is 1 or a custom non-zero.9697## 9. Color & TTY behavior9899- **Auto-detect TTY:** color on when stdout is a terminal, off when piped (`tool list | less` should not contain ANSI escapes).100- **Respect `NO_COLOR`** environment variable ([no-color.org](https://no-color.org)). Set → no color, regardless of TTY detection.101- **Respect `--no-color` flag** as an explicit override.102- **Respect `FORCE_COLOR`** when set (CI logs in tools like GitHub Actions render ANSI).103- **`--color=auto|always|never`** for full control (matches `git`, `grep`, `ls`).104- **Don't go wild with color.** A status column (`green: OK`, `red: FAILED`, `yellow: WARN`) is great. Rainbow output is not.105106## 10. Logging — structured to stderr107108CLI logging is for the user's terminal, not log aggregation. Different style from server logging.109110- **Default log level: `info`.** `-v` → `debug`, `-vv` → `trace`. `--quiet` / `-q` → `warn`.111- **Log to stderr always.** No exceptions.112- **Structured output** even for human reading — key=value pairs are scannable. Use `log/slog` (Go) or `structlog` (Python). `charmbracelet/log` (Go) and `rich` (Python) add pretty-printing while preserving structure.113- **Errors include the operation** that failed, the inputs that mattered, and any correlation id if cross-service. Format example in [RECIPES § Error message format](RECIPES.md#error-message-format).114- **No stack traces in user-facing errors** (see §15).115116## 11. Progress feedback117118For long-running operations only — anything that takes more than ~2 seconds.119120- **Spinner** for indeterminate work ("connecting to API...").121- **Progress bar** when total is known ("uploading 1.2 GB / 4 GB").122- **Suppress when not a TTY** — same auto-detection as color. Piping shouldn't fill the output with `\r` overwrites.123- **Don't conflict with log output.** Pause/reposition the spinner when printing a log line, or write progress to a single line that's overwritten in place.124- **Cancellation:** Ctrl-C should immediately stop work, clean up, and exit 130.125126## 12. Shell completions127128- **Ship completions for bash, zsh, fish, pwsh.** Cobra and typer both generate them — there's no excuse not to.129- **Install command:** `tool completion <shell>` writes the script to stdout; user can pipe to the right location or use the tool's `tool completion install --shell <shell>` if you provide that convenience.130- **Document the one-time install in `--help`** or a `tool help completion` page.131132## 13. Versioning133134- **`tool --version` and `tool version`** both work; both print the same thing.135- **Output format** is `tool <semver> (rev <sha>, built <date>, <runtime>)` — version + git short SHA + build date + runtime version. The SHA is critical for dev builds. Example in [RECIPES § Version output](RECIPES.md#version-output).136- **Semver always.** Pre-1.0 if the API isn't stable; commit to backward compatibility once you ship 1.0.137- **`tool version --output json`** for scripting.138139## 14. Distribution & install140141- **Static single binary** as the default delivery (`go build` static; Python via `pyinstaller` or distributed as a `uv tool install` package).142- **Multi-arch:** linux/amd64 + linux/arm64 + darwin/amd64 + darwin/arm64 + windows/amd64 + windows/arm64. Mirrors [docker-architect §5](../../infra/docker-architect/SKILL.md#5-multi-arch-builds).143- **Release artifacts:** signed binaries + checksums + (optional) SBOM. Use `goreleaser` (Go) or equivalent for the release matrix.144- **Package managers, in order of effort/reach:**145 1. Direct binary download from GitHub Releases (always; the universal fallback).146 2. **Homebrew** tap (macOS + Linux).147 3. **Scoop / Winget** (Windows).148 4. Linux package repos (apt, yum) — only when the user base genuinely justifies it.149- **`tool upgrade`** is a nice-to-have — fetches the latest release and replaces the binary. Make it opt-in; never auto-upgrade.150151## 15. Error messages152153Tell the user what failed, why, and what to try next. Three-line format + structured example in [RECIPES § Error message format](RECIPES.md#error-message-format). Key rules:154155- **No stack traces** in user-facing errors. Log them at `debug` level (visible with `-vv`) or write to a file with a correlation id.156- **Suggest the fix** when the cause is unambiguous. `Did you mean: tool depoly → deploy?` (Levenshtein distance from known subcommands).157- **Distinguish user errors from tool bugs.** "Invalid flag value" → user error, exit 2. "Internal: nil pointer in handler" → bug, exit 1 with a "please file an issue" hint.158159## 16. Language-specific recipes160161Implementation skeletons for **Go (cobra + pflag + viper)** and **Python (typer + rich)** live in [RECIPES.md](RECIPES.md). Canonical per [go-architect §11](../../languages/go-architect/SKILL.md#11-dependencies--logging) and [python-architect §10](../../languages/python-architect/SKILL.md#10-tooling).