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]. Mirrorsgit,kubectl,docker,rsk. Top-level flags are global; subcommand flags are scoped to that subcommand. - One verb per subcommand.
tool create useris fine.tool create-user-nowis 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. toolalone (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 -dbecomes unreadable; long form is self-documenting. - Boolean flags are pure switches:
--dry-run, not--dry-run=true. Negate with--no-fooif both states need an explicit form. - Plural for repeatable flags:
--tag debug --tag perfbecomes--tags debug,perfor--tagrepeatable. 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=debugflag wins overTOOL_LOG_LEVEL=infoenv wins overlog_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(ortool 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).
- Config:
- Discovery order for the config file:
--config <path>flag (explicit override)TOOL_CONFIGenv 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,.toolrcall at once. Pick one and stick to it. tool initwrites a sane default config to the canonical location; refuse to overwrite without--force.
Note:
rskitself 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 --helplists subcommands with a one-line description each.tool <subcommand> --helphas: 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.
-oshort 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 | grepworks. - 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:
0success,1generic failure,2misuse,130SIGINT. Anything custom is domain-specific and documented.- Document every non-standard exit code in
--helportool 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 | lessshould not contain ANSI escapes). - Respect
NO_COLORenvironment variable (no-color.org). Set → no color, regardless of TTY detection. - Respect
--no-colorflag as an explicit override. - Respect
FORCE_COLORwhen set (CI logs in tools like GitHub Actions render ANSI). --color=auto|always|neverfor full control (matchesgit,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) orstructlog(Python).charmbracelet/log(Go) andrich(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...