CLI Tool Skill
When to use
- Building a new CLI tool or developer utility from scratch
- Adding subcommands, flags, interactive prompts, or config file support
- Improving error messages, help text, or shell completion
- Packaging and distributing a CLI (npm, PyPI, Homebrew, GitHub Releases, Scoop)
- Profiling slow CLI startup or command execution
Workflow
Classify the project — Read
docs/specs/product-spec.mdto confirm target users (developers, ops, end users), distribution method, and whether the tool is a standalone binary, a language-specific package, or a plugin to an existing CLI ecosystem.Design the command interface before coding
- Sketch the full command tree:
tool <resource> <verb> [flags] - Prefer noun-verb ordering (
tool user create) over verb-noun (tool create-user) for tools with many resources - Map every flag to a single responsibility; avoid flags that change a command's fundamental behaviour
- Document the interface in
docs/cli-reference.mdfirst; implement second
- Sketch the full command tree:
Choose the parsing library
Language Production-grade choice Node.js Oclif (rich plugin ecosystem), Commander.js (simple), Yargs (flexible) Python Typer (type-safe + auto-help), Click (battle-tested) Go Cobra + pflag (k8s-style CLIs) Rust Clap with derive macros Ruby Thor Match the library's conventions — don't fight its model. Implement argument and flag handling
- Required positional args: document clearly, give an actionable error when missing
- Optional flags: provide sensible defaults; document defaults in
--help - Global flags:
--help,--version,--config,--output(format),--quiet,--verbose - Environment variable overrides: every flag should be settable via
TOOL_FLAG_NAME=value; document the precedence order (flag > env var > config file > default)
Implement config file support — Load from standard OS locations:
- macOS/Linux:
~/.config/<tool>/config.yamlor~/.toolrc - Windows:
%APPDATA%\<tool>\config.yaml - Project-local override:
.toolconfigor.tool.yamlin the current directory Use a library (cosmiconfig, dynaconf, Viper) rather than hand-rolling config search.
- macOS/Linux:
Design output for humans and machines
- Default: human-readable with colour (when stdout is a TTY)
--output json: machine-parseable; stable key names--output table: aligned columns for tabular data--quiet: suppress all output except errors- Detect TTY: when
stdoutis piped, suppress colour and progress bars automatically (chalk.level,isatty)
Implement interactive prompts sparingly — Prompts block automation. Use them only when no flag can reasonably be provided (e.g. first-run wizard, destructive confirmation). Never prompt if
--yesor--non-interactiveis passed. Useinquirer,prompts,questionary, ordialoguer.Handle errors gracefully
- Print a clear, actionable error message to
stderr(not stdout) - Exit with a meaningful code:
0success,1general error,2misuse/bad args,3+domain-specific - Suggest the fix: "Unknown flag --versbose. Did you mean --verbose?"
- Never print a raw stack trace to end users; log it to a debug file or only when
--debugis set
- Print a clear, actionable error message to
Add shell completion — Generate completion scripts for bash, zsh, fish, and PowerShell. Register the
completionsubcommand:tool completion bash >> ~/.bashrc. Most frameworks (Cobra, Oclif, Click) generate these automatically.Write tests
- Unit: flag parsing, config loading, output formatters
- Integration: spawn the CLI as a subprocess and assert on stdout/stderr/exit code
- Test both
--output jsonand human output on key commands Reference.claude/checklists/qa.md.
Package for distribution
- Node.js: publish to npm; add a
binentry inpackage.json; install globally or vianpx - Python: publish to PyPI; use
pyproject.tomlwith[project.scripts]; usepipxfor isolated installs - Go / Rust: cross-compile for
linux-amd64,linux-arm64,darwin-amd64,darwin-arm64,windows-amd64; publish binaries to GitHub Releases; provide a Homebrew formula and Scoop manifest - All: provide a one-line install snippet in
README.md
- Node.js: publish to npm; add a
Measure and optimise startup time — A CLI that takes > 500 ms to start feels slow. Profile with
node --prof(Node.js),py-spy(Python), orsamply(Rust/Go). Common culprits: loading all modules at startup, heavyrequire/import, slow network calls at init.
Standards
Do
- Write
--helptext that explains what the command does, lists all flags with defaults, and shows a usage example. - Print progress to
stderr, final output tostdout— this keeps pipes clean. - Validate all flags and args before starting any side-effectful work.
- Support
--dry-runon any command that modifies files, network resources, or external services. - Write a
CHANGELOG.mdand respect semantic versioning (major.minor.patch). - Respect
NO_COLORenvironment variable (disable colour output when set).
Do not
- Prompt in a non-interactive context (CI, piped stdin) — check for TTY and require explicit flags instead.
- Write to files outside the current directory without explicit user consent.
- Execute arbitrary shell commands from user-supplied strings (command injection risk).
- Hard-code API keys or tokens in the binary; read from env vars or the config file.
- Add
sudo-requiring steps to install scripts without clearly documenting why. - Output progress or spinner frames to stdout when it will break downstream parsing.
Common mistakes to avoid
- Unclear error on missing args —
Error: undefinedis useless; print "Missing required argument . Usage: tool command ". - Colour codes in piped output — always strip ANSI codes when stdout is not a TTY; piped output into
greporjqbreaks otherwise. - No
--versionflag — users need to report their version in bug reports; implement it from day one. - Config and flag precedence surprises — document the order; a flag that silently wins over a config file key (or vice versa) confuses users.
- Slow startup from eager imports — loading database clients or HTTP libraries at module load time even for unrelated subcommands; use lazy-loading.
- Breaking changes without a major version bump — changing a flag name, removing a subcommand, or altering JSON output shape breaks scripts; treat as semver-major.
- Not flushing stdout before exit — some runtimes buffer stdout; call
process.stdout.write+ explicit flush or use synchronous write beforeprocess.exit().
Output format
A production-ready CLI project should include:
/
├── src/ (or cmd/, lib/)
│ ├── commands/ # One file per top-level command/subcommand
│ ├── config/ # Config file loading and validation
│ ├── output/ # Formatters (table, json, plain)
│ └── utils/ # Shared helpers
├── tests/
│ ├── unit/
│ └── integration/ # Subprocess-based end-to-end tests
├── docs/
│ └── cli-reference.md # Full command/flag reference (auto-generated or hand-written)
├── scripts/
│ └── install.sh # One-line installer (optional)
├── .env.example # Supported env var overrides
├── CHANGELOG.md
└── README.md # Install, quickstart, config, all commands summary
Related checklists
.claude/checklists/security.md.claude/checklists/qa.md.claude/checklists/production.md
Related agents
.claude/agents/core/orchestrator.md.claude/agents/quality/security-auditor.md