A compact contract for CLIs that humans can use (good UX) and machines can trust (predictable behavior, stable output).
Use when designing, implementing, or reviewing command-line tools, especially CLIs that need reliable automation behavior, stable machine-readable output, safe mutation semantics, or good terminal UX.
When building or reviewing a CLI:
- First classify it by capability/risk dimensions:
- minimal/local read-or-transform
- emits structured or machine-readable output
- produces artifacts or writes files
- mutates state
- mutates remote/shared/destructive state
- long-running or asynchronous
- interactive or prompt-driven
- handles untrusted input, paths, code, archives, remote data, or secrets
- configurable or environment-dependent
- cross-platform
- distributed, installed, public, or telemetry-capable
- evolving compatibility surface
- Always apply the Non-Negotiable Core.
- Apply only the sections triggered by those dimensions.
- For existing CLIs, preserve current script-facing behavior unless the user explicitly wants a breaking change.
- Verify behavior with real commands: help, missing input, exit codes, stdout/stderr separation, piped stdin/stdout, non-TTY behavior, and any machine-output modes.
- Treat this as a decision guide, not a mandatory checklist. Apply requirements when they fit the CLI's job, risk, and audience.
- Language is intentional: the Non-Negotiable Core is ordered by importance;
must/nevermark invariants,prefermarks defaults with exceptions, and direct imperatives state guidance. - Default to noninteractive command semantics. Treat interactive UI as an intentional capability with explicit non-TTY behavior, not as the only path through a workflow.
- Defer to strong platform, ecosystem, framework, and project conventions unless they conflict with safety or automation reliability. Rust, Go/Cobra, Python/Click, Node, Unix, Windows, and domain-specific CLIs may have established conventions for help flags, exit codes, signal statuses, short-flag grouping, subcommand grammar, pagers, and version/capability reporting.
- Small single-purpose tools still need predictable parsing, help, exit codes, and sensible use of stdout/stderr; they do not need subcommands, pagers, config, telemetry policy, async jobs, or doctor/status commands unless those features match the situation.
- CLIs that mutate state, mutate remote/shared/destructive state, are interactive or prompt-driven, or are long-running or asynchronous carry heavier requirements: confirmation, dry-run, retries/idempotency, timeouts, structured status, progress, and recovery.
- Data must go to
stdout; diagnostics, status, progress, errors, and logs must go tostderr. Machine-readable output must never be mixed with diagnostics. - Successful commands must exit
0; failed commands must exit nonzero. Prefer1for general failure and2for usage errors unless the ecosystem has a stronger convention. Distinct domain codes should exist only for failure classes scripts should branch on. - Every automation-relevant workflow must have a complete noninteractive form. Prompts must never be required for automation. Intentionally interactive workflows must fail cleanly in noninteractive contexts or expose alternate machine-safe primitives where practical.
- Commands must never hang waiting for input when required input is absent in a noninteractive context.
- Secrets must never be passed in process arguments.
- Machine-readable output must be deterministic and documented when exposed.
- Commands must validate before side effects and fail early with actionable errors.
Human output may change. Stable script-facing API surface includes commands, args, flags, env, config, exit codes, and machine-readable modes where applicable.
A minimal/local read-or-transform tool needs the Non-Negotiable Core, predictable arg parsing, useful --help, and --version when distributed, installed, public, or versioned. Everything below this point is triggered by the capability/risk dimensions the tool actually has: emits structured or machine-readable output; produces artifacts or writes files; mutates state; mutates remote/shared/destructive state; is long-running or asynchronous; is interactive or prompt-driven; handles untrusted input, paths, code, archives, remote data, or secrets; is configurable or environment-dependent; is cross-platform; is distributed, installed, public, or telemetry-capable; or has an evolving compatibility surface. If none apply, the Core plus this small surface is enough.
- Mature arg parser; use the platform or standard-library parser for small tools, and a dedicated parser library for larger command trees. Avoid custom parsing unless necessary.
- Positionals for primary operands users type every time; flags for modifiers, modes, optional/uncommon inputs.
- Prefer input files as positionals; specify output files with
-o/--outputunless a positional output is canonical or needed for compatibility. - Avoid multiple semantic positionals unless canonical (
cp SRC DST). - Every short flag has long form; short flags only for frequent/domain-obvious options.
- Long option names use common spellings across tools (
--verbose,--output,--quiet,--help,--version) unless the domain has a stronger convention. - Common when applicable:
-h/--help,-q/--quiet,-o/--output,-f/--force,-n/--dry-run,--json,--version --quietsuppresses nonessential human chatter and progress. It must not suppress data written tostdout, errors, or required safety prompts.- Defaults correct for most users; no alias/flag required for normal path.
- Dangerous op => confirm. Noninteractive =>
--forceor--confirm=<target>. - Classify danger by decision tests: crosses a machine/account/project boundary; touches remote/shared state; deletes or overwrites user data; affects many resources; is hard to undo; requires elevated privilege; changes billing/security/identity; or targets a name users can mistype.
- More danger tests matched => stronger guard. Low-risk reversible local changes may skip confirmation; bulk/remote/irreversible changes get confirmation plus
--dry-run; high-impact named targets require typed target or--confirm=<name>. - Accept
--as end-of-options; treat following args as operands even when they start with-. - Required flag values are separate args where practical (
--output FILE,-o FILE); avoid optional flag values. - Support grouped short boolean flags where conventional (
-abc=-a -b -c); only the last grouped short flag may take a value. - Repeated value flags preserve command-line order when order is meaningful.
- Options normally precede operands; allow interspersed args only when the parser and docs make the behavior unambiguous.
- File I/O supports
-for stdin/stdout where useful; document any case where-means a literal filename. - For stdin fallback tools: args present => use args; no args + piped stdin => read stdin; no args + stdin TTY => fail with concise usage unless intentionally interactive.
- Expected pipe +
stdinTTY => concise help/diagnostic + exit; no hang. - Validate all inputs before writing output files. Prefer atomic replace for user-visible output files where practical.
- Avoid optional flag values. Prefer separate flags (
--no-cache,--clear-owner) or documented enum values over blank/missing-value magic; if a sentinel string is needed, define escaping/collision behavior. - Accept global flags, subcommand flags, subcommands, and operands in any relative order only when the parser and docs make precedence unambiguous.
- No secrets in process args. Prefer credential store/protected file/stdin/socket/secret manager/secure IPC; env acceptable for CI/cloud convention with redaction.
Use this section for all CLIs, especially when the CLI produces artifacts or writes files, emits structured or machine-readable output, mutates state, or is long-running or asynchronous.
- Default human-readable. Brief success output; liveness for slow work.
- Artifact-producing commands can default to writing the artifact to
stdout; in that case stdout is not prose and must contain only the artifact bytes. - Binary artifacts on
stdoutmust be byte-exact: no encoding conversion, newline normalization, progress text, or terminal formatting. - When
stdoutis an artifact or machine stream, success messages, status, progress, and next-step hints go tostderr. - State change => report change and/or state-inspection command.
- Multi-step workflow => suggest next commands.
- Boundary effects need explicit intent/consent: unrequested file I/O, remote calls, system config.
- Default output user-facing; maintainer/debug internals behind
--debug/verbose. stderrnot a log dump; no noisy log levels except debug/verbose.- Human text in short scan chunks; important info visually distinct.
- Preserve grep/awk line utility when possible.
- Long output pager only for interactive terminal. Honor
PAGER/LESSwhere conventional; conservative default if unset.
Use this subsection only when the CLI emits structured or machine-readable output, including --plain, --csv, --json, stable artifacts, or other script-facing output.
--plainwhen human formatting hurts parsing: stable, one record/line, no wrap/decor. Skip when the primary output is already a stable artifact or byte stream.--csvwhen tabular data needs spreadsheet/database interchange: UTF-8, header row by default, stable column order, documented delimiter/quoting/escaping, no ANSI/wrapping.--jsonfor structure; documented stable schema; pretty only if documented/conventional. Skip when there is no structured status/listing/metadata output to expose.- JSON schemas include a detectable
schema_version/api_versionwhen the shape can evolve independently of the CLI version. Document the schema location, additive-change policy, deprecation policy, and feature detection path. --markdownis optional human/document output for richer text such as links, headings, and basic styling. Do not treat Markdown as a stable machine interface unless the tool's domain explicitly requires Markdown artifacts.- Scripts use stable surfaces (
--plain,--jsonor artifact format); human output may change. - Machine-readable text output is UTF-8.
- Machine timestamps use RFC 3339/ISO 8601 with timezone/offset. Prefer UTC
Zunless local time is the subject. - Machine output ordering is stable and documented. Sort by stable keys using byte/codepoint ordering, not locale-sensitive collation, unless input order or domain order is the contract.
- Machine output omits volatile values by default: wall-clock timestamps, durations, random IDs, temp paths, PIDs, hostnames, absolute local paths, and nondeterministic ordering unless they are requested or domain data.
- Large outputs stream records where practical; do not buffer unbounded result sets into memory before writing the first output.
- Streaming output flushes complete records promptly when piped so pipelines, monitors, and agents can observe progress.
- Large listings expose limits/pagination/cursors in machine modes. Defaults are bounded when result sets can grow without limit.
Use this section when the CLI is interactive or prompt-driven, long-running or asynchronous, or otherwise changes behavior based on TTY capability, color, width, progress UI, pagers, or terminal escape support.
- Check
stdin/stdout/stderrTTY independently. - Color sparse/semantic: error, warning, diff, highlight, state.
- Disable color if target is not TTY,
NO_COLORnon-empty,TERM=dumb, or--no-color - Color precedence: explicit flags (
--color/--no-color) >NO_COLOR>FORCE_COLOR> TTY/capability auto-detection. - No spinners/progress animations on non-TTY target.
- Width via TTY API/
COLUMNS; no script-mode wrapping. - Symbols/emoji only for scanning; not required for meaning.
- Escape sequences gated by capability (
TERM,TERMINFO,TERMCAP) or library.
- Expected errors rewritten: failure, cause if known, next action.
- High signal; group repeated errors; most important line last/obvious.
- Red/error styling sparse.
- Internal/unexpected error => concise message + opt-in traceback/log capture + version/context + bug-report URL. No raw trace by default.
- Bug reports prefilled when feasible; no secret leakage.
Use this section when the CLI emits structured or machine-readable output and exposes errors in --json, newline-delimited JSON events, or another machine-readable mode.
- JSON errors use a stable envelope:
code,message, optionaldetails, optionalnext_actionsas argv arrays, and optionalaffected_resourceswith stable IDs. - JSON error envelopes go to
stderr, including when--jsonis requested.stdoutremains reserved for successful data/artifacts and must not contain error objects unless the command's documented primary output is an error-report artifact.
-h/--help= help only; overrides other args.--helpand--versionprint tostdout, exit0, ignore other args, and perform no normal work or side effects.--versionstarts with a stable parseable line: canonical program/package name and version; do not derive the display name fromargv[0].- Usage errors print diagnostics/concise help to
stderrand exit2; requested help prints tostdoutand exits0. - Support
cmd -h|--help; subcommands:cmd sub -h|--help,cmd help,cmd help sub. - Missing required input => concise help/error unless intentional interactive recovery is better.
- Concise help = purpose, usage, 1-2 examples, common flags, full-help/docs pointer.
- Full help = examples, usage, common flags/commands, all options, subcommands, docs/support.
- Full help includes bug-report/support and project/documentation links where applicable.
- Usage notation follows common CLI/POSIX conventions:
[]optional,|mutually exclusive,...repeatable, placeholders not literal input. - Sort by utility: common before advanced.
- Scannable headings/alignment; ANSI only when target stream supports it.
- Web docs + terminal docs; man pages optional, reachable via
cmd help. - Invalid input => suggest likely correction; never silently execute corrected mutating command.
Use this section when the CLI is interactive or prompt-driven, long-running or asynchronous, performs cancellable work, handles long cleanup, or does network/process I/O that users may interrupt.
--no-inputdisables prompts/interactive UI; missing data => fail with exact flag/file/stdin remedy.- Secret prompt disables echo.
- Multi-step flows expose escape; Ctrl-C works during network I/O unless impossible; document protocol escape inline.
SIGINT=> acknowledge immediately, exit ASAP; cleanup timeout.- On platforms with conventional signal exit statuses, interrupted commands exit
128 + signo(130forSIGINT) unless a more specific documented code or ecosystem convention applies. - Long cleanup: second Ctrl-C skips/forces; print second-Ctrl-C effect.
- Startup tolerates previous interrupted cleanup.
Not all CLI apps require subcommands, but they can help as complexity grows. In such a situation:
- Use for multi-operation/related tools sharing config/help/state.
- Same concept across subcommands => same flag/output/validation/semantics.
- Object/action hierarchy consistent; prefer
noun verbabsent a stronger ecosystem or domain convention; reuse verbs. - Avoid near-synonyms (
update/upgrade) unless disambiguated.
Use this section when the CLI emits structured or machine-readable output, mutates state, mutates remote/shared/destructive state, is long-running or asynchronous, or is driven by scripts, CI, services, or agentic software without a human.
- Every workflow has noninteractive form; no required TTY UI/editor/browser/pager/prompt.
- Provide
--no-input,--no-pager,--no-open,--no-editorwhere applicable. - With
--json, structured failures emit the JSON error envelope tostderrand exit nonzero. - Machine next-actions use argv arrays, not only shell strings.
- Mutations support
--dry-run --json: planned actions, risk, confirmations, irreversible effects. - Retryable remote/state mutations expose safe retry semantics: client-supplied
--idempotency-key, returned operation ID, or documented conflict handling. State whether keys are generated by caller or tool, how long they are retained, and what repeated requests return. - Output stable IDs/resource refs; no parsing display names/colors/tables/prose.
- Async ops return job ID + status/result commands; no required stream-watching.
- Long-running operations that stream in machine mode prefer newline-delimited JSON events:
started,progress,warning,result,error,finished.errorevents use the JSON error envelope from Structured Errors. - Bounded waits: explicit
--wait,--timeout,--poll-intervalwhen relevant. - Capability discovery: expose supported features/formats in
--version --json,capabilities, or an equivalent stable command when behavior can vary by build, plugin, server, or protocol version. Use the form that best matches the ecosystem. - Inspectability:
status,doctor, orexplain <error-code>for ambiguous failures in tools with external state, installation complexity, or recurring failure modes. - Examples shell-safe/copyable; no aliases, hidden state, relative date dependence, or required interactive setup.
Use this section when the CLI handles untrusted input, paths, code, archives, remote data, or secrets; is configurable or environment-dependent; mutates remote/shared/destructive state; uses plugins/templates; or runs with elevated privilege.
- Never shell-concat untrusted input; use argv arrays/process APIs.
- Treat paths, config, env, archives, plugins, templates, remote responses as untrusted.
- Defend path traversal, symlink races, unsafe permissions, writes outside intended root.
- Verify TLS by default; certificate bypass only explicit/debug.
- Downloads/installers/updaters may require provenance, integrity checks, consent before code execution.
- Redact secrets/tokens in errors, logs, debug, telemetry, bug reports, examples.
- Least privilege; no admin/root unless required.
Use this section when the CLI is configurable or environment-dependent, including behavior that depends on persistent settings, project context, user/machine state, environment variables, or config files.
- Scope: invocation => flags; session/user/machine/project context => flags+env; stable versioned project => config file.
- Default precedence: flags > explicit env > project config/
.env> user config > system config > defaults. Document exceptions. - Use platform config/cache/data/state conventions: XDG on Unix/Linux where appropriate, macOS application support/cache directories, Windows known folders/AppData.
- Non-owned config modification => consent + exact disclosure; prefer drop-ins; appended blocks dated/delimited.
- Env = context-varying behavior. Names
[A-Z0-9_]+, not leading digit. Prefer single-line. Avoid common-name hijack. - Honor when relevant:
NO_COLOR,FORCE_COLOR,DEBUG,EDITOR,HTTP_PROXY,HTTPS_PROXY,SHELLonly for interactive shell,TERM,TERMINFO,TERMCAP,TMPDIR,HOME,PAGER,LINES,COLUMNS. .envonly for project-local env-style settings; not structured config when types/history/security matter.- Env secrets acceptable when conventional for CI/cloud; redact everywhere. Prefer credential files/pipes/Unix sockets/secret managers/secure IPC.
Use this section when the CLI is cross-platform, including support for more than one OS, shell, terminal, filesystem, locale, encoding, or text/binary stream environment.
- State supported platforms explicitly. Do not let POSIX assumptions leak into cross-platform docs or examples.
- Treat path syntax, path separators, executable extensions, newline conventions, shell quoting, and filesystem case sensitivity as platform-specific.
- Machine-readable text output stays UTF-8 across platforms. Handle Windows console encoding deliberately for human output.
- Accept and normalize CRLF input where text files commonly cross platforms; document output newline behavior when it matters.
- Binary streams must be byte-exact. Disable text-mode newline translation for binary
stdin/stdoutand document when a stream is text vs bytes. - Terminal capability detection accounts for Windows consoles as well as Unix TTYs.
NO_COLORbehavior is cross-platform.
Use this section when the CLI is distributed, installed, public, or telemetry-capable.
- Name simple/memorable/short/lowercase/easy-type; dashes only if needed; avoid generic collisions/over-short names.
- Prefer single binary; else native installer/package manager; easy uninstall.
- No telemetry/crash/usage phone-home without consent or clear opt-out disclosure. State data/purpose/anonymization/retention/disable path. Never block on telemetry.
Use this section when the CLI has an evolving compatibility surface: commands, flags, config, output schemas, exit codes, or user workflows may change after release.
- Prefer additive changes. Breaking change => in-product deprecation warning + replacement syntax available now.
- Suppress warning after replacement usage.
- Stable script output; evolvable human output.
- Document stability guarantees for commands, flags, env vars, config keys, exit codes, and machine schemas. State whether release versions follow semantic versioning or another compatibility policy.
Use this section when adding examples for shared machine-facing surfaces; keep domain result schemas tool-specific.
Examples are illustrative envelopes for shared machine-facing surfaces, not required schemas for every command's domain output.
JSON error:
{
"error": {
"code": "not_found",
"message": "Bucket not found.",
"details": { "bucket": "logs-prod" },
"next_actions": [
{ "argv": ["tool", "bucket", "list"] }
],
"affected_resources": [
{ "type": "bucket", "id": "logs-prod" }
]
}
}Version line and --version --json capabilities:
tool 1.8.3
{
"name": "tool",
"version": "1.8.3",
"schema_version": "2026-06-01",
"features": ["json", "dry_run", "idempotency_key"]
}Dry-run mutation plan:
{
"schema_version": "2026-06-01",
"dry_run": true,
"requires_confirmation": true,
"confirm": { "argv": ["tool", "bucket", "delete", "logs-prod", "--confirm=logs-prod"] },
"planned_actions": [
{ "action": "delete", "resource": { "type": "bucket", "id": "logs-prod" }, "reversible": false }
],
"risks": ["remote_state", "irreversible"]
}NDJSON event stream:
{"event":"started","schema_version":"2026-06-01","operation_id":"op_123"}
{"event":"progress","completed":20,"total":100}
{"event":"warning","message":"Retrying after rate limit.","retry_after":"2026-06-06T12:00:05Z"}
{"event":"error","error":{"code":"rate_limited","message":"Rate limit exceeded.","next_actions":[{"argv":["tool","status","op_123"]}]}}
{"event":"finished","status":"failed","operation_id":"op_123"}Use this section when the CLI has automated tests or when behavior is important enough to verify before release.
If/when testing (if appropriate):
- Cover help paths, missing-input help, exit codes, stdout/stderr, non-TTY behavior, and
--no-inputwhere applicable. - Cover
--json,--plain,--csv,--markdownif appropriate, dangerous confirmation, color disable, pagers, interrupts, retries, and async/status flows when the CLI implements those surfaces. - Piped stdout contains downstream data only.
- CI/non-TTY output: no prompts/spinners/ANSI unless forced/wrapped human tables.
- Machine output tests cover stable ordering, UTF-8, timestamp format, absence of volatile fields, pagination/limits, and streaming behavior where applicable.
- Secrets absent from process args, env logs, history examples, traces, telemetry, bug URLs, diagnostics.
Some great tips in here! 👍