Skip to content
Back to Project Tutorials

CLI Tool

Build a distributable CLI in Node.js, Go, or Rust and ship via npm/brew/cargo.

Design & Interface

Define commands, flags, output format, and the UX of a great terminal tool.

Why

CLI design is a UX problem in text. Good CLIs are discoverable (--help), consistent (flags work everywhere), and scriptable (stable output, exit codes). A poorly designed CLI gets aliased-around and abandoned; a great one (like ripgrep) becomes a daily habit. The interface is the product.

When

Before implementation. 1-3 days to nail down commands, flags, and output format. Sketch the help output first.

How

1. Choose the language - Node.js: fastest to ship, distribute via npm (npx your-cli) - Go: single static binary, distribute via brew/release (no runtime dep) - Rust: fast + single binary, distribute via cargo + release 2. Design commands and flags - Subcommands: mycli build, mycli deploy (not mycli-build) - Flags: --flag (long), -f (short), consistent across subcommands - Global flags: --verbose, --config, --help, --version - Use a framework: commander (Node), cobra (Go), clap (Rust) 3. Design output - Default: human-readable, color when TTY (respect NO_COLOR) - --json or --output json for machine consumption - Errors to stderr, results to stdout (pipeable) - Exit codes: 0 success, 1 generic error, 2 usage error 4. Design config + state - Config: ~/.config/mycli/config.toml (XDG base dir spec) - Env vars for CI: MYCLI_API_KEY, MYCLI_CONFIG - Precedence: flags > env > config file > defaults 5. Sketch the help - Write the --help output before coding — it's the spec - Include examples in the long description - Auto-generate completion (bash/zsh/fish)

Example

Sketch the help first (the spec): mycli - Deploy static sites from the terminal USAGE: mycli <command> [flags] COMMANDS: deploy [dir] Deploy a directory (default: .) init Create a .myclirc config logs [limit] Show recent deploy logs FLAGS: --prod Deploy to production (default: preview) --json Output JSON for scripting -v, --verbose Verbose logging EXAMPLES: mycli deploy --prod mycli logs 20 --json | jq '.[] | .url' Framework: clap (Rust) auto-generates this from struct attributes

Common Problems

  • ⚠Inconsistent flags across subcommands (--prod works on deploy but not logs) — breaks muscle memory
  • ⚠Color output that breaks pipes — always detect TTY and respect NO_COLOR
  • ⚠No machine-readable output — users resort to grep/awk on human text, which is fragile

Best Practices

  • ✓Write the --help text before coding — it forces you to design the full interface upfront
  • ✓Always emit errors to stderr and results to stdout so the tool composes in Unix pipes
  • ✓Provide --json output from day 1; scripting users will thank you and never leave

Implementation & Testing

Build the CLI with a framework, tests for flags/output, and a release build.

Why

CLIs are run in pipelines and CI — a non-zero exit code on success, or human text where JSON was expected, breaks automation downstream. Testing flag parsing, exit codes, and output format catches these before users script around them and get locked into buggy behavior.

When

After the interface is designed. Implement the happy path first, then edge cases (missing args, invalid config, network failure).

How

1. Scaffold with a framework - Node: commander + chalk (or oclif for multi-command at scale) - Go: cobra + viper (config), zerolog (logging) - Rust: clap + anyhow (errors), tracing (logging) 2. Implement commands incrementally - One subcommand per PR; share a core library, not copy-paste - Validate args early, fail fast with a clear usage error (exit 2) - Long-running work: show a spinner (ora/indicatif), respect --quiet 3. Handle errors and exit codes - Usage errors -> exit 2, print --help hint - Runtime errors (network, config) -> exit 1, clear message to stderr - Success -> exit 0, never print to stderr 4. Test the CLI - Unit tests for arg parsing and core logic - Integration tests: run the binary, assert stdout/stderr/exit code - Snapshot test --help output to catch accidental interface changes 5. Build for distribution - Node: ship ESM + CJS, set "bin" in package.json - Go: GOOS/GOARCH matrix -> static binaries (no runtime dep) - Rust: cargo build --release -> optimized binary

Example

Rust + clap command: #[derive(Parser)] struct Cli { #[command(subcommand)] cmd: Cmd, } #[derive(Subcommand)] enum Cmd { Deploy { dir: Option<PathBuf>, #[arg(long)] prod: bool }, Logs { limit: Option<u8> }, } Integration test (assertable): let out = Command::new("./mycli").args(["logs","1","--json"]).output()?; assert_eq!(out.status.code(), Some(0)); let v: Vec<Log> = serde_json::from_slice(&out.stdout)?; CI matrix build (Go): go build -o dist/mycli-$GOOS-$GOARCH .

Common Problems

  • ⚠Returning exit 0 on errors — breaks CI pipelines that rely on non-zero to fail the step
  • ⚠Printing errors to stdout — corrupts --json output for downstream parsers
  • ⚠No integration tests on the binary — unit tests pass but the built CLI has a broken flag

Best Practices

  • ✓Test the built binary, not just library functions — run it and assert exit code + output
  • ✓Snapshot the --help output so any interface change is a deliberate, reviewed diff
  • ✓Build a GOOS/GOARCH (or target triple) matrix in CI so every release ships all platforms

Distribution & Releases

Ship via npm/brew/cargo and automate versioned releases with changelogs.

Why

Distribution friction kills CLIs — if install is more than one command, adoption stalls. npm gives npx one-liner installs; brew taps reach macOS devs; cargo install reaches Rustaceans. Automated, signed releases with a changelog turn 'did I update?' into a solved problem.

When

When the CLI builds and tests pass on all target platforms. Set up the release pipeline before v1.0 so the first public release is automated.

How

1. Choose distribution channels - npm: "bin" field + npx your-cli (no install needed) - Homebrew: create a tap, brew install your-tap/your-cli - cargo: publish to crates.io, cargo install your-cli - GitHub Releases: static binaries for every OS/arch 2. Automate releases - Semantic versioning: tag v1.2.0 -> CI builds + publishes - release-please or release-plz to bump versions + changelog - Attach signed binaries to the GitHub Release 3. Generate a changelog - Conventional Commits -> auto-generated CHANGELOG.md - Group by feat/fix/breaking so users scan upgrades fast - Link each entry to the PR/commit for context 4. Ship shell completions - Generate bash/zsh/fish completions from the framework - Document install: "source <(mycli completions zsh)" - Homebrew formula can install completions automatically 5. Announce and support - Post v1.0 to HN, relevant subreddits, the language's forum - Pin an issue for v1.0 feedback; triage into the changelog - Keep a "migrating to v2" doc from the start of breaking changes

Example

npm (package.json): { "name": "mycli", "bin": { "mycli": "./dist/cli.js" } } # Users run: npx mycli deploy --prod (no install) GitHub Actions release (on tag): - go build matrix (linux/darwin/windows x amd64/arm64) - gh release create v1.2.0 dist/* --notes-from-tag - npm publish (Node) / cargo publish (Rust) Homebrew tap (homebrew-tap/mycli.rb): class Mycli < Formula url "https://github.com/me/mycli/releases/download/v1.2.0/mycli-darwin-arm64" sha256 "..." def install; bin.install "mycli-darwin-arm64" => "mycli"; end end

Common Problems

  • ⚠Manual releases — forgotten platform, wrong version bump, no changelog, users stuck on old
  • ⚠No shell completions — power users tab-complete nothing and switch to a rival CLI
  • ⚠Breaking changes without a migration doc — users upgrade, things break, they never update again

Best Practices

  • ✓Automate releases on git tag — one command ships npm + brew + cargo + GitHub binaries at once
  • ✓Generate and ship shell completions with every release; document the one-line install

Full Developer Lifecycle

A 14-stage developer roadmap from idea to maintenance — visualize dependencies and track progress.

Next Step