caduh

Make Your CLI a Joy to Use — ergonomics, DX patterns, and copy‑paste snippets

6 min read

Design principles and ready-to-use patterns: consistent subcommands, great help, smart defaults, colors/TTY, progress, JSON output, config precedence, auth, errors, exit codes, and distribution.

TL;DR

  • Favor subcommands with consistent verbs/nouns, predictable flags, and excellent --help.
  • Default to safe actions, support --yes/--force, and prompt on destructive ops.
  • Output human‑readable by default; offer --json (and --quiet) for scripts. No colors/progress when not TTY.
  • Respect config precedence: flags > env > config file > defaults. Provide completions for bash/zsh/fish/powershell.
  • Return clear errors with suggestions (did‑you‑mean), proper exit codes, and actionable next steps.
  • Ship a single static binary (or simple pipx/npm i -g), supply checksums/signatures, and self‑update with --version/update built‑ins.

1) Command model (predictability first)

Structure: tool <noun> <verb> or tool <verb> <noun> — pick one and stick to it.

Examples:

# noun-first
tool project create
tool auth login
tool user list

# or verb-first
tool create project
tool login
tool list users

Rules of thumb

  • Subcommand names are short, verbs are imperative.
  • Flags are kebab‑case, long form preferred (--access-token). Provide short aliases only for frequent flags (-o, -q, -v).
  • Every subcommand supports: --help, --json, --quiet, --verbose/-v, --no-color.

2) Help that actually helps

$ tool project create --help
Create a new project.

USAGE
  tool project create [NAME] [flags]

EXAMPLES
  # create interactively
  tool project create
  # non-interactive, specific region and plan
  tool project create myapp --region eu-west-1 --plan hobby --yes
  # output machine-readable JSON
  tool project create myapp --json

FLAGS
  -r, --region string        Region slug (e.g., us-east-1)
      --plan string          Plan tier (hobby|pro|enterprise) (default "hobby")
      --tags stringArray     Add tags (repeat: --tags a --tags b)
  -y, --yes                  Skip confirmation prompts
  -q, --quiet                Suppress human output (errors only)
  -o, --output string        Output format (table|json) (default "table")
      --no-color             Disable colors
      --config string        Path to config file (default: ~/.tool/config.yaml)
  • Put Examples right under USAGE.
  • Group flags by category and show defaults.
  • For --json, document the schema (fields, types, stability).

3) Output modes (humans vs machines)

  • Default (human): aligned table, subtle colors, concise messages.
  • --json / -o json: deterministic field order, no ANSI, no prose, one object/line for streams.
  • --quiet: only errors to stderr; success prints IDs only (or nothing).
  • Non‑TTY: auto‑disable colors/spinners; keep progress on stderr.

Go (Cobra)

type GlobalFlags struct {
  JSON bool; Quiet bool; NoColor bool; Output string
}
var gf GlobalFlags

func init() {
  rootCmd.PersistentFlags().BoolVar(&gf.JSON, "json", false, "Output JSON")
  rootCmd.PersistentFlags().BoolVarP(&gf.Quiet, "quiet", "q", false, "Quiet mode")
  rootCmd.PersistentFlags().BoolVar(&gf.NoColor, "no-color", false, "Disable colors")
  rootCmd.PersistentFlags().StringVarP(&gf.Output, "output", "o", "table", "Output format (table|json)")
}

Node (commander)

program
  .option("--json", "output JSON")
  .option("-q, --quiet", "quiet mode")
  .option("--no-color", "disable colors");

Python (Typer)

import typer, sys, json, os
app = typer.Typer(add_completion=False)

def is_tty() -> bool:
    return sys.stdout.isatty()

@app.callback()
def common(json_: bool = typer.Option(False, "--json"), quiet: bool = typer.Option(False, "--quiet"),
           no_color: bool = typer.Option(False, "--no-color")):
    if not is_tty(): os.environ["NO_COLOR"] = "1"

4) Config & precedence

Sources (highest → lowest): flagsenvconfig filedefaults.

  • Config file: ~/.tool/config.yaml (or TOML/JSON). Support profiles: [default], [staging].
  • Env vars: TOOL_REGION, TOOL_TOKEN. Document them.
  • Support --config and --profile. Merge with clear precedence, and print effective config on tool config inspect.

Example (YAML)

profile: default
profiles:
  default:
    region: us-east-1
  staging:
    region: eu-west-1

5) Prompts, safety, and interactivity

  • Default to safe: require --yes for destructive ops; show diff/preview on changes (--dry-run).
  • Prompts must timeout or respect --no-input for CI.
  • Always support stdin/stdout: use - to read/write files (tool secrets set --from-file -).

Node prompt (enquirer)

import { Confirm } from "enquirer";
if (!argv.yes) {
  const ok = await new Confirm({ name: "proceed", message: "Delete ALL resources?" }).run();
  if (!ok) process.exit(0);
}

6) Colors, TTY, and progress

  • Detect TTY: disable colors/spinners when piped; respect NO_COLOR.
  • Send progress to stderr; results to stdout.
  • Use spinners for < a few seconds; progress bars for longer tasks; --no-progress flag to disable.
isTTY := term.IsTerminal(int(os.Stdout.Fd()))
if !isTTY || gf.NoColor || gf.JSON { color.NoColor = true }

7) Errors & exit codes

  • Errors are one line + hint. For --json, return a machine‑readable error object to stderr, keep exit code ≠ 0.

Suggested codes

  • 0 success
  • 1 runtime/unknown error
  • 2 usage/validation error
  • 3 not found / missing resource
  • 4 unauthorized/forbidden
  • 5 conflict / precondition failed
  • 130 terminated by Ctrl‑C

Did‑you‑mean (subcommands/flags)

// Using levenshtein distance to suggest "projects" when user typed "projectz"

8) JSON that doesn’t hurt

  • Stable field names, documented schema; ISO 8601 timestamps; lowercase_with_underscores or camelCase, pick one.
  • Support --jq <expr> or --query (JMESPath‑like) for client‑side filtering.
  • Stream lists as NDJSON (one JSON per line) for large outputs: --jsonl.
{"id":"p_123","name":"myapp","region":"us-east-1","created_at":"2025-10-05T11:22:33Z"}

9) Auth & credentials

  • tool auth login opens a browser for OAuth device flow; fallback to --token for CI.
  • Store tokens in OS keychain when possible; else in ~/.tool/credentials.json with 0600.
  • Support tool whoami, tool auth logout, tool auth refresh.

10) Completions & man pages

  • Generate shell completions and ship an install-completions command.
  • Provide rich descriptions per flag/subcommand so completion menus are useful.
  • Include a man page or --help-man that prints a long help.

Cobra

rootCmd.CompletionOptions.DisableDefaultCmd = false
rootCmd.PersistentFlags().Bool("install-completions", false, "Install shell completions")

11) Distribution & updates

  • Prefer a single static binary (macOS, Linux, Windows, arm64/amd64). Provide Homebrew, Scoop, winget, apt/rpm, or npx/pipx.
  • Publish checksums and sign releases.
  • Provide tool update (checks GitHub Releases) and tool --version with commit/date.

12) Testing & UX checks

  • Golden tests for command outputs; run with NO_COLOR=1.
  • Contract tests for --json schema.
  • Smoke tests in CI on macOS/Linux/Windows shells.
  • Dogfood with record‑replay tests to minimize flakiness.

13) Snippets you can paste

Go (Cobra): root with JSON/quiet & non‑TTY guard

var rootCmd = &cobra.Command{
  Use:   "tool",
  Short: "Tool does useful things",
  PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
    if !term.IsTerminal(int(os.Stdout.Fd())) { color.NoColor = true }
    if gf.JSON { color.NoColor = true; gf.Quiet = true }
    return nil
  },
}

Node (commander): consistent errors & exit codes

program.exitOverride((err) => {
  if (err.code === "commander.unknownOption") {
    console.error("Unknown option. Try --help");
    process.exit(2);
  }
  console.error(err.message);
  process.exit(1);
});

Python (Typer): --json toggle

@app.command()
def projects_list(json_: bool = typer.Option(False, "--json")):
    projects = [{"id":"p1","name":"demo"}]
    if json_: print(json.dumps({"items": projects})); return
    for p in projects: print(f"{p['id']}	{p['name']}")

Pitfalls & fast fixes

| Pitfall | Why it hurts | Fix | |---|---|---| | Chatty output in scripts | Breaks automation | Add --json/--quiet, disable colors when not TTY | | Confusing flags | Support tickets | Consistent naming, aliases, and great help | | Destructive defaults | Data loss | Default to safe; --yes required | | Unstable JSON | CI failures | Versioned schema; contract tests | | No completions | Slower users | Ship completions with descriptions | | Mixed stdout/stderr | Piping pain | Results stdout, progress/errors stderr | | Inconsistent exit codes | Poor scripting ergonomics | Map errors → codes; document them |


Quick checklist

  • [ ] Consistent subcommands + flags with excellent help/Examples.
  • [ ] Dual output: human (table) & machine (--json, --jsonl).
  • [ ] TTY‑aware colors/progress; results on stdout, errors on stderr.
  • [ ] Clear config precedence and config inspect.
  • [ ] Safe defaults (--yes, --dry-run), prompts respect --no-input.
  • [ ] Proper exit codes, did‑you‑mean suggestions.
  • [ ] Shell completions, man pages, and self‑update.
  • [ ] Cross‑platform installers and signed releases.
  • [ ] Golden tests; contract tests for JSON.

One‑minute adoption plan

  1. Add --json, --quiet, --no-color, and TTY detection.
  2. Rewrite --help with USAGE/EXAMPLES/FLAGS and show defaults.
  3. Standardize config precedence (flags > env > file > defaults) and add tool config inspect.
  4. Wire safe prompts (--yes, --dry-run) and send progress to stderr.
  5. Generate completions and document install + update flows.