Skip to main content
Development

CLI Patterns

CLI conventions — output modes, TTY detection, JSON piping, stdout/stderr separation, interactivity, signal handling, visual style, and CI integration. Use when building or reviewing any CLI tool.

CLI Patterns

Design Philosophy

A junior developer should understand any CLI tool’s behavior from --help alone. Prefer obvious defaults. Machine output (JSON) enables composability. All human-facing output goes to stderr.

Output Modes

Decision Tree

ScenarioOutput ModeFlag
Data/device API (zigbee-rest)Always JSON on stdoutNone needed
Dual human + machine consumers (sr, oag)--format json|humanDefault: human
CLI with optional machine output (llmem)--json flag + TTY auto-detectDefault: human if TTY, JSON if piped
Human-only tool (teasr, embed-src)Styled stderr onlyNo JSON mode

TTY Auto-Detection

Standard behavior when no explicit format flag is given:

stdout.is_terminal() → human output (styled, colored)
!stdout.is_terminal() → JSON output (machine-readable)

Override flags:

  • --json — force JSON regardless of TTY
  • --no-color / NO_COLOR=1 env — strip ANSI escape codes
  • CI=true env — treat as non-TTY (no spinners, no prompts, no color)

JSON Conventions

  • Rust: serde_json::to_string_pretty() to stdout
  • Go: json.MarshalIndent(v, "", " ") to stdout
  • Python: json.dumps(v, indent=2) to stdout
  • Errors in JSON mode: {"error": "message"} to stdout (not stderr), exit 1

Stdout vs Stderr

StreamContent
stdoutMachine-readable data: JSON, generated content, completions
stderrAll human-facing UI: headers, spinners, checkmarks, errors, progress

This enables piping: tool command | jq '.field' and tool command 2>/dev/null.

Interactivity

Prompts

  Prompt text? [y/N] _

Bold prompt, [y/N] suffix. Rules:

  • Return false / skip in non-TTY (CI-safe)
  • --yes / -y flag: auto-confirm all prompts
  • Never block on stdin without checking stdin.is_terminal()

Dry-Run

Prefix all hypothetical actions with [dry-run] to stderr:

[dry-run] Would create tag: v2.1.0
[dry-run] Would push tag: v2.1.0

TUI Policy

Never use full-screen TUI frameworks (ratatui, bubbletea, textual, blessed). Reasons:

  • Breaks piping and shell composition
  • Breaks CI / non-interactive environments
  • Breaks screen readers and accessibility tools
  • Adds heavy dependencies for marginal UX gain

Use inline styled output (the ui module pattern below) for all interactive feedback. For selection, use a numbered list + prompt, not a TUI picker.

Signal Handling

Trap SIGINT and SIGTERM. On signal: finish current atomic operation, clean up temp files, clear spinner line, exit with correct code.

LanguagePattern
Rustctrlc crate or tokio::signal. Set AtomicBool, check in loops.
Gosignal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM). Pass ctx to all long operations.
Pythonsignal.signal(SIGINT, handler) + threading.Event for cooperative cancellation.

Exit Codes

CodeMeaning
0Success
1Error
2No-op / no changes (sr pattern)
130Interrupted (SIGINT / Ctrl-C)
143Terminated (SIGTERM)

Config Systems

FormatUseDiscovery
TOMLUser-editable configsWalk up from cwd (teasr, linear-gp)
YAMLRelease/CI configsFixed name at repo root (sr.yaml)
  • Override: --override key.path=value (dot-notation)
  • Env vars: TOOL_CONFIG, TOOL_VERSION, etc.

Output Directories

  • outputs/<name>/<YYYYMMDD_HHMMSS>/ — timestamped results (linear-gp)
  • showcase/ — demo captures (teasr, default: ./teasr-output)
  • bin/ — Go builds, target/ — Rust builds

Visual Style

Stack by Language

LanguageStylingSpinnersCLI Parsing
Rustcrossterm 0.28indicatif 0.17clap 4 (derive)
Golipgloss / termenvspinner (braille)cobra
Pythonrichrich.progresstyper

Color Semantics

Every color has exactly one meaning. Do not deviate.

ColorMeaningUsed for
Cyan + boldAction / headingHeaders, section titles, spinner, indices [1]
Green + boldSuccessCheckmarks , file additions A, arrows
Yellow + boldWarning / cautionWarnings , modifications M, skipped
RedError / destructiveDeletions D, error messages
BlueInformational alternateRenames R
DimSecondary / supportingDividers, detail text, tree connectors, timestamps
Bold (no color)Primary contentCommit messages, prompts, emphasis

Symbol Set

SymbolColorPurpose
green boldPhase/step completion
yellow boldWarning
cyanInformational note
green boldItem created/produced
yellowSkipped action
cyanSub-action / tool call
dimUsage/stats

Layout

All output is indented 2 spaces from the terminal edge. Nested content adds 2 more spaces.

  header text
  ────────────────────────────────────────

  ✓ Phase completed · detail
  ⚠ Warning message
  ℹ Informational note

  SECTION TITLE · count
  ──────────────────────────────────────────────────

  [1] Primary content
   │  Secondary detail

   ├─ A file_added.rs
   └─ M file_modified.rs

  ──────────────────────────────────────────────────
println!();  // blank line
eprintln!("  {}", cmd.cyan().bold());
eprintln!("  {}", "─".repeat(40).dim());
println!();

Cyan bold title, 40-char dim rule, blank lines above and below. All to stderr.

Spinner

Braille animation, cyan, 80ms tick, 2-space indent:

ProgressStyle::default_spinner()
    .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
    .template("  {spinner:.cyan} {msg}")

When done, replace with checkmark line via phase_ok().

Phase Completion

  ✓ Phase name · optional detail

Green+bold checkmark. Detail after · is dim. Always 2-space indent.

Tree Visualization

   │   dim vertical connector
   ├─  dim branch connector (non-last item)
   └─  dim last-item connector

Tree connectors are always dim. Content after connectors uses semantic colors.

Progress Tracking

  [1/3] Step description
    ✓ sub-item
    → result

Index is cyan+bold. Description is bold. Sub-items indented 4 spaces.

Warnings, Info, Errors

  ⚠ Warning text          (yellow symbol, yellow text)
  ℹ Info text              (cyan symbol, dim text)
  error: message           (red, to stderr, with anyhow context chain)

Token/Cost Usage

  ⊘ 1.2k in / 3.4k out · $0.0042

Dim symbol, dim counts, dim cost. Format: >=1M1.2M, >=1k1.2k, else raw.

The ui Module Pattern

Every CLI project has a ui module exporting a consistent API. Do not extract a shared crate/library — the modules are 30-60 lines each; copying is cheaper than cross-repo dependency management.

Canonical API

FunctionPurpose
header(title)Cyan bold title + dim rule to stderr
phase_ok(msg, detail?)Green checkmark + message to stderr
warn(msg)Yellow warning to stderr
info(msg)Cyan info note to stderr
error(msg)Red error to stderr
spinner(msg) → handleStart braille spinner to stderr
confirm(prompt) → boolTTY-safe y/N prompt (false in non-TTY)

Reference Implementations

  • Rust: sr/crates/sr-ai/src/ui/mod.rs
  • Go: incipit/internal/ui/ui.go (lipgloss-based)
  • Python: rich.Console(stderr=True) with matching color semantics

Rule

All ui output goes to stderr. Use eprintln! / fmt.Fprintf(os.Stderr, ...) / Console(stderr=True). Never println! / fmt.Println for UI.

Install Script Convention

Every CLI tool MUST have install.sh at repo root:

  • Portable #!/bin/sh
  • Platform detection: uname -s (OS) + uname -m (arch)
  • Targets: GNU + musl for Linux, Darwin for macOS, MSVC for Windows
  • Version override: ${BINARY_VERSION:-}, fallback to latest release
  • Install dir: $HOME/.local/bin (override: ${BINARY_INSTALL_DIR:-})
  • PATH management: detect shell, update rc file
  • One-liner: curl -fsSL https://raw.githubusercontent.com/urmzd/{repo}/main/install.sh | bash

GitHub Action Pattern

For tools that benefit from CI integration:

  • Composite actions (using: composite), not JavaScript
  • Binary download: detect OS/arch, download from releases
  • JSON stdout → jq -r '.field' → export as action outputs
  • Inputs/outputs in action.yml
  • Branding: icon + color for GitHub Marketplace

Anti-Patterns

Don’tDo Instead
ASCII box borders (═══, ***)Dim rules
println! without indentationAlways 2-space minimum
info!() / warn!() tracing macros for user outputStyled crossterm/lipgloss/rich output
Mixing colors arbitrarilyFollow color semantics table
Verbose banners or ASCII artClean, minimal headers
Raw error dumps{e:#} with anyhow context chains
Full-screen TUI frameworksInline styled output (ui module)
Blocking on stdin without TTY checkCheck is_terminal(), respect --yes
Ignoring SIGINT/SIGTERMTrap signals, clean up, exit 130/143
Swallowing errors in JSON modeOutput {"error": "..."} to stdout