CLI Development
Purpose
Build command-line tools that behave the way the shell expects: composable, scriptable, quiet by default, and honest about failure.
When to Use
- Building a developer tool, deployment script, or internal utility with a CLI.
- Designing the argument surface of an existing tool.
- Making a tool safe to use inside pipelines and CI.
Capabilities
- Argument and subcommand design.
- Correct use of stdout, stderr, and exit codes.
- Configuration precedence: flags, environment, config file, defaults.
- Human output versus machine output (
--json, TTY detection).
- Progress, colour, and interactivity that degrade correctly when piped.
Inputs
- The tasks the tool must perform and who runs it (humans, CI, both).
- Whether it will be composed with other tools.
- Whether it performs destructive operations.
Outputs
- A command surface that is predictable and discoverable.
- Machine-readable output behind a flag.
- Exit codes that scripts can branch on.
- Help text that answers the question without a web search.
Workflow
- Design the verbs — Subcommands are verbs on nouns:
tool deploy service, not tool --deploy --service. Group related operations.
- Separate the streams — Results go to stdout. Everything else — progress, warnings, logs — goes to stderr. This is what makes
tool list | grep x work.
- Define the exit codes — 0 for success, 1 for a general failure, 2 for a usage error. Document any others.
- Set the configuration precedence — Command-line flag beats environment variable beats config file beats default. Never surprise the user by reversing this.
- Detect the TTY — Colour, spinners, and prompts only when stdout is a terminal. When piped, output is plain and non-interactive.
- Make destruction opt-in — Anything irreversible requires confirmation, or
--yes when non-interactive. --dry-run on anything with side effects.
Best Practices
- Be quiet on success. A tool that prints five lines of celebration on every run is unusable in a loop.
- Provide
--json for anything a script might parse. Parsing human output is a bug generator for everyone downstream.
- Honour
NO_COLOR and --no-color. Honour CI by disabling interactivity.
- Long flags are self-documenting; short flags are for the ones typed constantly. Do not invent a short flag for every option.
- Read from stdin when the input argument is
-. It costs three lines and makes the tool composable.
- Error messages state what failed, why, and what to do about it. "Error: invalid input" is not one of those.
Examples
Stream and exit-code discipline:
import json, sys
def main(argv: list[str]) -> int:
args = parse_args(argv)
try:
results = search(args.query, limit=args.limit)
except ConfigError as e:
print(f"error: {e}\nhint: run `tool config init` to create a config file", file=sys.stderr)
return 2
except UpstreamError as e:
print(f"error: search backend unavailable: {e}", file=sys.stderr)
return 1
if args.json:
json.dump([r.to_dict() for r in results], sys.stdout)
sys.stdout.write("\n")
else:
for r in results:
print(f"{r.id}\t{r.title}") # stdout: the result
if not results:
print("no matches", file=sys.stderr) # stderr: the commentary
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
tool search foo | head -5 works. tool search foo --json | jq . works. tool search foo > /dev/null prints nothing but the commentary. All three are the point.
Notes
- Exiting non-zero on "no results found" is a design decision, not an obvious one.
grep does it; most tools should not. Whatever you choose, document it.
- A
--verbose flag that changes stdout breaks pipelines. Verbosity belongs on stderr.
- If the tool takes more than a second, print progress to stderr — but only when attached to a TTY.
1---2name: cli-development3description: Use when building command-line tools. Covers argument design, exit codes, streams and piping, progress output, configuration precedence, and behavior that respects the shell.4---56# CLI Development78## Purpose910Build command-line tools that behave the way the shell expects: composable, scriptable, quiet by default, and honest about failure.1112## When to Use1314- Building a developer tool, deployment script, or internal utility with a CLI.15- Designing the argument surface of an existing tool.16- Making a tool safe to use inside pipelines and CI.1718## Capabilities1920- Argument and subcommand design.21- Correct use of stdout, stderr, and exit codes.22- Configuration precedence: flags, environment, config file, defaults.23- Human output versus machine output (`--json`, TTY detection).24- Progress, colour, and interactivity that degrade correctly when piped.2526## Inputs2728- The tasks the tool must perform and who runs it (humans, CI, both).29- Whether it will be composed with other tools.30- Whether it performs destructive operations.3132## Outputs3334- A command surface that is predictable and discoverable.35- Machine-readable output behind a flag.36- Exit codes that scripts can branch on.37- Help text that answers the question without a web search.3839## Workflow40411. **Design the verbs** — Subcommands are verbs on nouns: `tool deploy service`, not `tool --deploy --service`. Group related operations.422. **Separate the streams** — Results go to stdout. Everything else — progress, warnings, logs — goes to stderr. This is what makes `tool list | grep x` work.433. **Define the exit codes** — 0 for success, 1 for a general failure, 2 for a usage error. Document any others.444. **Set the configuration precedence** — Command-line flag beats environment variable beats config file beats default. Never surprise the user by reversing this.455. **Detect the TTY** — Colour, spinners, and prompts only when stdout is a terminal. When piped, output is plain and non-interactive.466. **Make destruction opt-in** — Anything irreversible requires confirmation, or `--yes` when non-interactive. `--dry-run` on anything with side effects.4748## Best Practices4950- Be quiet on success. A tool that prints five lines of celebration on every run is unusable in a loop.51- Provide `--json` for anything a script might parse. Parsing human output is a bug generator for everyone downstream.52- Honour `NO_COLOR` and `--no-color`. Honour `CI` by disabling interactivity.53- Long flags are self-documenting; short flags are for the ones typed constantly. Do not invent a short flag for every option.54- Read from stdin when the input argument is `-`. It costs three lines and makes the tool composable.55- Error messages state what failed, why, and what to do about it. "Error: invalid input" is not one of those.5657## Examples5859**Stream and exit-code discipline:**6061```python62import json, sys6364def main(argv: list[str]) -> int:65 args = parse_args(argv)6667 try:68 results = search(args.query, limit=args.limit)69 except ConfigError as e:70 print(f"error: {e}\nhint: run `tool config init` to create a config file", file=sys.stderr)71 return 272 except UpstreamError as e:73 print(f"error: search backend unavailable: {e}", file=sys.stderr)74 return 17576 if args.json:77 json.dump([r.to_dict() for r in results], sys.stdout)78 sys.stdout.write("\n")79 else:80 for r in results:81 print(f"{r.id}\t{r.title}") # stdout: the result8283 if not results:84 print("no matches", file=sys.stderr) # stderr: the commentary85 return 0868788if __name__ == "__main__":89 sys.exit(main(sys.argv[1:]))90```9192`tool search foo | head -5` works. `tool search foo --json | jq .` works. `tool search foo > /dev/null` prints nothing but the commentary. All three are the point.9394## Notes9596- Exiting non-zero on "no results found" is a design decision, not an obvious one. `grep` does it; most tools should not. Whatever you choose, document it.97- A `--verbose` flag that changes stdout breaks pipelines. Verbosity belongs on stderr.98- If the tool takes more than a second, print progress to stderr — but only when attached to a TTY.