| name | build-cli-tool |
| description | Designs the UX and contract of a command-line program in any language — argument parsing via a real lib (commander/yargs, click/typer, cobra, clap), meaningful exit codes, the stdout=data / stderr=logs split so the tool pipes cleanly, TTY-aware color/spinners that auto-plain when redirected, a --json machine mode, layered config precedence, signal cleanup, and shell completion. Covers the whole interface contract that makes a CLI scriptable, composable, and safe — not the language-internal logic. |
| when_to_use | Building a new CLI/terminal program or fixing one that misbehaves in pipes, CI, or non-TTY contexts (logs on stdout, colors in files, wrong exit codes, secrets in flags). Distinct from shell-script-robust (writing a robust Bash script — set -euo pipefail, quoting, traps; this skill DESIGNS the CLI program/UX in any language) and publish-package-registry (PUBLISHING the finished tool to npm/PyPI/crates; this skill DESIGNS it). |
When to Use
- "I'm writing a CLI — how should I structure subcommands, flags, and help?"
- "My tool breaks when I pipe it (
tool | jq) or redirect to a file — output is garbled / has color codes."
- "CI can't tell why my command failed — every error exits 1."
- "I need a
--json mode so scripts can parse my output."
- "Colors/spinners show up in log files but shouldn't" / "respect
NO_COLOR."
- "How do I take a secret without it leaking in
ps / shell history?"
- "Add shell completion / a
--dry-run / proper Ctrl-C cleanup."
NOT this skill:
- Writing a robust Bash script (strict mode, quoting, trap cleanup) → shell-script-robust (that's a shell implementation; this is CLI interface design in any language).
- Publishing the built tool to npm/PyPI/crates (bin field, OIDC, semver) → publish-package-registry.
- The exact wording of a failure string (what/why/next) → error-message (use it for message copy; this skill decides the channel and exit code).
- Choosing names for commands/flags/config keys → naming-helper.
- Hardening the language-internal correctness (concurrency, types, money math) → the respective domain skills.
Steps
The contract in one line: stdout = data, stderr = everything else, exit code = the verdict. Get those three right and the tool composes with Unix.
-
Pick a parser library, never hand-roll. Hand-rolled process.argv parsing misses --, =, bundled short flags, and negation. Use the idiomatic one:
| Lang | Library | Notes |
|---|
| Node | commander (simple) / yargs (rich) / clipanion (class-based, typed) | commander for most; yargs for middleware/completion |
| Python | typer (type-hint driven) / click / argparse (stdlib, zero-dep) | typer = click + types; argparse if no deps allowed |
| Go | cobra (+ pflag/viper) | kubectl/gh use it; gives completion + config for free |
| Rust | clap (derive) | derive macro → struct = the CLI |
Define subcommands (tool sync, tool config get), flags with both short and long (-v/--verbose), positionals, and let the lib handle -- (everything after it is a positional, never a flag — so rm -- -weird-file). Support --flag=value and --flag value.
-
Generate --help and include examples + a one-line summary. Every command and subcommand needs --help; the lib auto-generates usage from the spec — your job is to add a one-line summary and real examples (most help is useless without them):
sync — mirror a local dir to remote storage
Usage: tool sync [options] <src> <dest>
Examples:
tool sync ./build s3://bucket/site # one-shot
tool sync --dry-run ./build s3://... # preview, no writes
Provide --version (print version + exit 0). Unknown flag → usage error on stderr + exit 2, not a stack trace.
-
Define exit codes that mean something. Scripts and CI branch on $?. Don't return 1 for everything:
Common Errors
- Logs on stdout. A
console.log("Done!") or progress bar to stdout silently corrupts tool | jq and tool > file. The single most common CLI bug — route all non-data to stderr.
- Everything exits 1. CI can't distinguish "bad input" from "network down". Use distinct codes (Step 3) and 2 for usage errors.
- Color codes in files. Forgetting the isTTY check writes raw
\e[31m into redirected output. Auto-plain when not a TTY; honor NO_COLOR.
- Secret in a flag.
--api-key sk-... is visible to every user via ps and saved in ~/.zsh_history. Use env/file/stdin (Step 9).
- Buffering huge output then printing at the end →
head hangs, memory blows up. Stream (Step 7).
- No
-- handling → tool rm -weird-name treats the filename as a flag. The parser lib handles --; don't hand-roll past it.
- Prompting in a non-TTY → CI hangs forever waiting on stdin. Detect TTY; require
--yes/--input otherwise.
- Leaving temp files / a hidden cursor on Ctrl-C — register signal cleanup (Step 10) before creating temps.
Verify
tool sub --json | jq . succeeds and tool sub > out.txt produces clean data — zero log lines or ANSI in stdout.
tool sub 2>/dev/null still prints the full payload; tool sub >/dev/null still shows progress (proves the stream split).
tool --color=never | cat has no escape codes; NO_COLOR=1 tool is plain; piped output auto-plains without any flag.
- Bad flag → exit 2 + usage on stderr; a real failure → documented non-zero code; success → 0.
echo $? after each.
- Ctrl-C mid-run → exit 130, no temp file left, cursor visible, terminal usable.
ps aux | grep tool during a run shows no secret; --help lists examples, exit codes, and config precedence.
tool completion zsh emits a valid script; a non-TTY run with a destructive command refuses without --yes/--dry-run.