CLI Tool Building skill for Claude Code
CLIs that work for humans and in pipes. Claude writes the help text before the code, keeps data on stdout and everything else on stderr, uses meaningful exit codes, and adds --json, --dry-run and --yes so the tool works in scripts as well as terminals. It scaffolds in Typer, Click, commander or cobra, tests with CLI runners, and ships via uv tool, npm or GoReleaser.
- name
cli-tool-building - size210 lines · ≈2,250 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds command-line tools people enjoy using — Python with Typer or Click, Node with commander, Go with cobra — covering command and flag design, help text, stdout versus stderr, exit codes, JSON output, config and environment precedence, prompts with non-interactive fallbacks, shell completion, testing with CLI runners, and distribution through uv tool, pipx, npm, Homebrew, or GoReleaser. Use when creating a CLI, adding subcommands or flags, turning a script into a proper tool, improving a CLI's help or errors, or packaging and releasing a command-line app. Try asking
- Turn this Python script into a proper CLI with subcommands, --json output and tests
- Build a Go CLI with cobra that manages our feature flags, with shell completion
- Our Node CLI prints logs into piped output and hangs in CI on a prompt, fix it
Use it when
- Creating a new command-line tool
- Adding subcommands, flags or config handling
- A CLI has confusing help, errors or exit codes
- Packaging and releasing a CLI
Skip it when
- A short shell script is enough — use bash-scripting
- You want project task shortcuts rather than a distributable tool — use task-runners
How to check the result
Missing arguments exit 2 with a message naming the flag, --json output parses with jq while logs go to stderr, and non-interactive runs never hang on a prompt.
The common mistake it prevents
Printing progress and log lines to stdout, which corrupts piped output and breaks every script that parses the tool.
Done well means: tool --help answers most questions, every command works both for a
human at a terminal and in a script or pipe, errors say what went wrong and what to do
next, exit codes are meaningful, destructive actions ask first (and accept --yes in
automation), and installing is one command.
Design rules
- Data to stdout, everything else (logs, progress, prompts, warnings) to stderr. A
pipeline
tool list | jqmust never receive a spinner. - Exit codes: 0 success, 1 general failure, 2 usage error (bad flags/args). Document any others (e.g., 3 = partial success). Never exit 0 after an error.
- Machine output: offer
--json(stable field names, documented) for anything a script might parse. Human tables can change; JSON is a contract. - Detect TTY: colors, spinners, and prompts only when the stream is a terminal. Honor
NO_COLORand provide--no-color. Never prompt when stdin is not a TTY; fail with a message naming the flag to pass instead. - Destructive operations: confirm interactively,
--yes/--forceto skip,--dry-runto preview. Never default to the destructive choice. - Flags over positional arguments beyond one or two obvious ones. Long names always
(
--output), short aliases for the most common (-o). Standard names:-h/--help,--version,-v/--verbose,-q/--quiet,-n/--dry-run,-f/--force,-o/--output. - Config precedence (highest first): flags > environment variables (
TOOL_*) > project config file > user config ($XDG_CONFIG_HOME/tool/config.toml) > defaults. - Secrets: read from env vars, a credentials file with mode 600, or the OS keychain;
never require them as flags (they land in shell history and
ps). Offer--token-stdin.
Pick the framework
| Language | Default | Why |
|---|---|---|
| Python | Typer (built on Click) | Type hints define args; help and completion for free |
| Python, need fine control or existing Click code | Click | Mature, composable groups, plugins |
| Node / TypeScript | commander | Small, standard, subcommands, typed with @commander-js/extra-typings |
| Go | cobra (+ pflag) | Used by kubectl, gh, hugo; completion, docs generation |
| Rust | clap (derive) | See language-specific skills |
| Tiny script, < 3 flags | stdlib (argparse, flag, util.parseArgs) | No dependency |
Workflow
- [ ] 1. Write the help text first: commands, args, flags, examples
- [ ] 2. Scaffold with the framework; one module per command group
- [ ] 3. Implement core logic as plain functions (testable without the CLI layer)
- [ ] 4. Errors: catch expected failures, print one-line cause + fix, exit non-zero
- [ ] 5. Output: human default, --json, quiet mode; TTY detection
- [ ] 6. Tests with the framework's runner; snapshot help output
- [ ] 7. Packaging, version, completion, release automation
Python with Typer
# src/acme/cli.py
from pathlib import Path
from typing import Annotated
import json, sys
import typer
app = typer.Typer(help="Sync assets to storage.", no_args_is_help=True)
@app.command()
def sync(
source: Annotated[Path, typer.Argument(exists=True, file_okay=False, help="Directory to upload")],
bucket: Annotated[str, typer.Option("--bucket", "-b", envvar="ACME_BUCKET", help="Target bucket")],
dry_run: Annotated[bool, typer.Option("--dry-run", "-n", help="Show actions without uploading")] = False,
as_json: Annotated[bool, typer.Option("--json", help="Machine-readable output")] = False,
):
"""Upload changed files from SOURCE to BUCKET."""
try:
result = do_sync(source, bucket, dry_run=dry_run) # plain function, unit-tested
except PermissionError as e:
typer.echo(f"error: cannot read {e.filename}. Check file permissions.", err=True)
raise typer.Exit(code=1)
if as_json:
typer.echo(json.dumps(result))
else:
typer.echo(f"{result['uploaded']} uploaded, {result['skipped']} unchanged")
@app.command()
def purge(bucket: str, yes: Annotated[bool, typer.Option("--yes", "-y")] = False):
"""Delete every object in BUCKET."""
if not yes:
if not sys.stdin.isatty():
typer.echo("error: refusing to purge without --yes in non-interactive mode", err=True)
raise typer.Exit(code=2)
typer.confirm(f"Delete everything in {bucket}?", abort=True)
...
# pyproject.toml
[project]
name = "acme-cli"
version = "0.3.0"
requires-python = ">=3.10"
dependencies = ["typer>=0.12"]
[project.scripts]
acme = "acme.cli:app"
Test with typer.testing.CliRunner (or Click’s CliRunner):
from typer.testing import CliRunner
from acme.cli import app
def test_sync_requires_bucket(tmp_path):
r = CliRunner().invoke(app, ["sync", str(tmp_path)])
assert r.exit_code == 2 and "--bucket" in r.output
Install for users: uv tool install acme-cli or pipx install acme-cli. Completion:
acme --install-completion.
Node with commander
#!/usr/bin/env node
import { Command, InvalidArgumentError } from 'commander';
const program = new Command()
.name('acme').description('Sync assets to storage').version('0.3.0');
program.command('sync')
.argument('<source>', 'directory to upload')
.requiredOption('-b, --bucket <name>', 'target bucket', process.env.ACME_BUCKET)
.option('-n, --dry-run', 'show actions without uploading')
.option('--concurrency <n>', 'parallel uploads', (v) => {
const n = Number(v); if (!Number.isInteger(n) || n < 1) throw new InvalidArgumentError('must be a positive integer'); return n;
}, 4)
.option('--json', 'machine-readable output')
.action(async (source, opts) => {
const result = await doSync(source, opts);
if (opts.json) process.stdout.write(JSON.stringify(result) + '\n');
else console.log(`${result.uploaded} uploaded`);
});
program.parseAsync().catch((err) => { console.error(`error: ${err.message}`); process.exit(1); });
package.json: "bin": { "acme": "./dist/cli.js" }, "type": "module", "engines": {"node": ">=20"}.
Users run npx acme or npm i -g acme. Test by spawning the built binary with
execa/child_process and asserting stdout, stderr, and exit code.
Go with cobra
var dryRun bool
var syncCmd = &cobra.Command{
Use: "sync <source>",
Short: "Upload changed files to a bucket",
Example: " acme sync ./public --bucket assets",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
bucket, _ := cmd.Flags().GetString("bucket")
res, err := doSync(cmd.Context(), args[0], bucket, dryRun)
if err != nil { return fmt.Errorf("sync %s: %w", args[0], err) }
fmt.Fprintf(cmd.OutOrStdout(), "%d uploaded\n", res.Uploaded)
return nil
},
}
func init() {
syncCmd.Flags().StringP("bucket", "b", "", "target bucket (env ACME_BUCKET)")
syncCmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "show actions without uploading")
_ = syncCmd.MarkFlagRequired("bucket")
rootCmd.AddCommand(syncCmd)
}
Set rootCmd.SilenceUsage = true so runtime errors do not dump usage text; print the
error once in main and os.Exit(1). Cobra adds acme completion bash|zsh|fish|powershell.
Use viper only if you need layered config files; env binding can be done by hand. Test
by setting cmd.SetArgs, cmd.SetOut(&buf), then cmd.Execute(). Release with
GoReleaser (binaries, checksums, Homebrew tap) on tag push.
Help and error text
- Help: one-line summary, usage line, arguments, flags with defaults, 2-3 examples, pointer to docs. Examples are the most-read part; make them copy-pasteable.
- Errors:
error: <what failed>: <why>then a hint line:hint: run 'acme login' first. No stack traces unless--verboseorDEBUG=1. - Suggest corrections for typos in subcommands (cobra and Click do this; commander shows suggestions by default).
Verification
acme --help; acme sync --help # readable, examples present
acme sync; echo "exit=$?" # missing args -> exit 2, message names the flag
acme sync ./dir -b x --json | jq . # stdout is valid JSON, logs went to stderr
acme purge x < /dev/null; echo "exit=$?" # no prompt hang; refuses without --yes
NO_COLOR=1 acme sync ./dir -b x | cat # no escape codes in pipes
Snapshot --help output in tests so accidental flag changes show up in review.
Gotchas
- Breaking flag renames break users’ scripts; keep the old name as a hidden deprecated alias for at least one minor version and warn on stderr.
- Startup time matters for CLIs: lazy-import heavy modules in Python and Node.
- Windows: paths, line endings, and ANSI colors differ; test on it if you ship there.
- Reading from stdin: support
-as a filename meaning stdin/stdout. - Version must come from one source (package metadata, build ldflags), not hardcoded twice.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.