Skip to main content
General

build-cli

Design and audit CLI tools end-to-end: output modes, TTY detection, JSON piping, stderr/stdout separation, color/symbol semantics, the ui module pattern, signal handling, exit codes, and install.sh. Use when building, reviewing, or releasing any CLI and you need general CLI design guidance. Do NOT use for the mandatory urmzd/* portfolio CLI rules (argument-parser choice, self-update requirement, --format flag, global flags, install.sh template): cli-standards owns those and overrides this skill where they conflict.

build-cli

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 (e.g. release tools, agent orchestrators)--format json|humanDefault: human
CLI with optional machine output (llmem)--json flag + TTY auto-detectDefault: human if TTY, JSON if piped
Human-only tool (e.g. demo recorders, interactive wizards)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 looking for <tool>.toml
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 (default output dir for your demo recorder)
  • 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, and 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: a small internal/ui/ package wrapping lipgloss with IsTTY(), Info/Warn/Error helpers, and an output.Render(format, v) switch on --format. ~50 lines.
  • 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/<owner>/<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

Portfolio Requirements

The conventions above are portable design defaults. The rules below are mandatory for every CLI tool in this portfolio so every tool is independently installable, self-updating, and composable with scripts and CI pipelines.

Argument Parser Selection

LanguageFrameworkNotes
Rustclap v4 (derive macros)#[derive(Parser)], #[derive(Subcommand)]
GoCobrarootCmd.AddCommand(), persistent flags on root
NodeCommander v14.command(), .option(), .addOption()
PythontyperDecorator-based, consistent with Rust mental model

Never use stdlib flag parsing (flag package in Go, argparse in Python) for new tools.

Self-Update (Mandatory)

Every CLI tool must have a update subcommand (or self-update if update is already taken for content management) that replaces the running binary with the latest GitHub release.

Rust . agentspec_update crate

# workspace Cargo.toml
agentspec-update = "0.6.0"

# CLI crate Cargo.toml
agentspec-update = { workspace = true }
// In Commands enum
/// Self-update to the latest release
Update,

// Handler
Commands::Update => {
    eprintln!("current version: {}", env!("CARGO_PKG_VERSION"));
    match agentspec_update::self_update("<owner>/<repo>", env!("CARGO_PKG_VERSION"), "<binary>")? {
        agentspec_update::UpdateResult::AlreadyUpToDate => {
            eprintln!("already up to date");
        }
        agentspec_update::UpdateResult::Updated { from, to } => {
            eprintln!("updated: {from} → {to}");
        }
    }
    Ok(())
}

Go . GitHub Releases HTTP fetch

Implement internal/updater/updater.go with:

  1. Fetch latest tag via gh api repos/<owner>/<repo>/releases/latest (works with github.com and GHES)
  2. Compare against current version (injected via -ldflags)
  3. Construct asset URL from the response’s assets[].browser_download_url matching BINARY-OS-ARCH
  4. Download to a temp file, chmod +x, then os.Rename() over os.Executable()
  5. Wire as cobra.Command on root: rootCmd.AddCommand(newUpdateCmd(version))

Node . npm self-install

// update command
.command('update')
.description('Update to latest release')
.action(async () => {
  const { execSync } = await import('child_process');
  execSync('npm install -g @<scope>/<package>@latest', { stdio: 'inherit' });
});

Output Format Flag

Rule: --format json|human (default: human) for any command that emits structured data.

  • Always-JSON CLIs (device/REST APIs like zigbee-skill): no flag needed . all output is JSON on stdout
  • Never use --json (boolean) . use the enum form instead
  • Never use --export-json . that’s not composable

Rust

#[derive(Clone, clap::ValueEnum)]
enum OutputFormat {
    Human,
    Json,
}

// In Cli struct (global):
#[arg(long, global = true, default_value = "human", value_enum)]
format: OutputFormat,

// In command handler:
match cli.format {
    OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&data)?),
    OutputFormat::Human => { /* styled output to stderr */ }
}

Go

var format string
rootCmd.PersistentFlags().StringVar(&format, "format", "human", "Output format: json|human")

// In command:
if format == "json" {
    enc := json.NewEncoder(os.Stdout)
    enc.SetIndent("", "  ")
    enc.Encode(data)
} else {
    // human output to stderr
}

Node

.addOption(new Option('--format <fmt>', 'Output format').choices(['json', 'human']).default('human'))

Standard Global Flags

FlagTypeWhen to include
--format json|humanenumAny tool with structured data output
--verbose / -vboolTools with meaningful progress output
--dry-runboolMutating tools (release, file modification)

Do NOT add --verbose to always-JSON tools (no human output to make verbose).

install.sh (Mandatory)

Every CLI tool must have install.sh at the repo root.

Template (copy from sr/install.sh, substitute binary name and prefix):

#!/bin/sh
# install.sh . Installs BINARY from GitHub releases.
#
# Usage:
#   curl -fsSL https://raw.githubusercontent.com/<owner>/<repo>/main/install.sh | sh
#
# Environment variables:
#   PREFIX_VERSION     . version to install (default: latest)
#   PREFIX_INSTALL_DIR . installation directory (default: $HOME/.local/bin)
#   PREFIX_SHA256      . optional SHA256 checksum

set -eu
REPO="<owner>/<repo>"
# ... (full template in sr/install.sh)

Prefix naming: SR_TEASR_, AGENTSPEC_, OAG_, MNEMONIST_, LGP_, EMBED_SRC_, etc.

Platform targets (Rust musl): x86_64-unknown-linux-musl, aarch64-unknown-linux-musl, x86_64-apple-darwin, aarch64-apple-darwin.

Subcommand Conventions

  • Max 2 levels of nesting: tool command subcommand
  • Binary name matches the [[bin]] name in Cargo.toml or "bin" in package.json
  • Every CLI must have both:
    • --version flag (auto-provided by clap/cobra/commander)
    • version subcommand that prints <name> v<version> to stdout
    • update subcommand for self-update (see above; use self-update only if update is taken at top level for content management)
  • fsrc exception: uses run <files> subcommand to preserve positional-arg UX while still having update and version as peers

Reference Implementations

PatternReference
Self-update (Rust)sr/crates/sr-cli/src/main.rs lines 343-356
--format json|humansr/crates/sr-cli/src/main.rs, oag/crates/oag-cli/src/main.rs
install.shsr/install.sh
Cobra root setupinline 20-line example below
Go self-updatesaige/internal/updater/updater.go (after migration)