| name | build-cli |
| description | 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.
|
| allowed-tools | Read, Grep, Glob |
| metadata | {"title":"CLI Patterns","category":"development","order":3} |
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
| Scenario | Output Mode | Flag |
|---|
| Data/device API (zigbee-rest) | Always JSON on stdout | None needed |
| Dual human + machine consumers (e.g. release tools, agent orchestrators) | --format json|human | Default: human |
| CLI with optional machine output (llmem) | --json flag + TTY auto-detect | Default: human if TTY, JSON if piped |
| Human-only tool (e.g. demo recorders, interactive wizards) | Styled stderr only | No 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
| Stream | Content |
|---|
| stdout | Machine-readable data: JSON, generated content, completions |
| stderr | All 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.
| Language | Pattern |
|---|
| Rust | ctrlc crate or tokio::signal. Set AtomicBool, check in loops. |
| Go | signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM). Pass ctx to all long operations. |
| Python | signal.signal(SIGINT, handler) + threading.Event for cooperative cancellation. |
Exit Codes
| Code | Meaning |
|---|
0 | Success |
1 | Error |
2 | No-op / no changes (sr pattern) |
130 | Interrupted (SIGINT / Ctrl-C) |
143 | Terminated (SIGTERM) |
Config Systems
| Format | Use | Discovery |
|---|
| TOML | User-editable configs | Walk up from cwd looking for <tool>.toml |
| YAML | Release/CI configs | Fixed 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
| Language | Styling | Spinners | CLI Parsing |
|---|
| Rust | crossterm 0.28 | indicatif 0.17 | clap 4 (derive) |
| Go | lipgloss / termenv | spinner (braille) | cobra |
| Python | rich | rich.progress | typer |
Color Semantics
Every color has exactly one meaning. Do not deviate.
| Color | Meaning | Used for |
|---|
| Cyan + bold | Action / heading | Headers, section titles, spinner, indices [1] |
| Green + bold | Success | Checkmarks ✓, file additions A, arrows → |
| Yellow + bold | Warning / caution | Warnings ⚠, modifications M, skipped − |
| Red | Error / destructive | Deletions D, error messages |
| Blue | Informational alternate | Renames R |
| Dim | Secondary / supporting | Dividers, detail text, tree connectors, timestamps |
| Bold (no color) | Primary content | Commit messages, prompts, emphasis |
Symbol Set
| Symbol | Color | Purpose |
|---|
✓ | green bold | Phase/step completion |
⚠ | yellow bold | Warning |
ℹ | cyan | Informational note |
→ | green bold | Item created/produced |
− | yellow | Skipped action |
▸ | cyan | Sub-action / tool call |
⊘ | dim | Usage/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
──────────────────────────────────────────────────
Header
println!();
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: >=1M → 1.2M, >=1k → 1.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
| Function | Purpose |
|---|
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) → handle | Start braille spinner to stderr |
confirm(prompt) → bool | TTY-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't | Do Instead |
|---|
ASCII box borders (═══, ***) | Dim ─ rules |
println! without indentation | Always 2-space minimum |
info!() / warn!() tracing macros for user output | Styled crossterm/lipgloss/rich output |
| Mixing colors arbitrarily | Follow color semantics table |
| Verbose banners or ASCII art | Clean, minimal headers |
| Raw error dumps | {e:#} with anyhow context chains |
| Full-screen TUI frameworks | Inline styled output (ui module) |
| Blocking on stdin without TTY check | Check is_terminal(), respect --yes |
| Ignoring SIGINT/SIGTERM | Trap signals, clean up, exit 130/143 |
| Swallowing errors in JSON mode | Output {"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
| Language | Framework | Notes |
|---|
| Rust | clap v4 (derive macros) | #[derive(Parser)], #[derive(Subcommand)] |
| Go | Cobra | rootCmd.AddCommand(), persistent flags on root |
| Node | Commander v14 | .command(), .option(), .addOption() |
| Python | typer | Decorator-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
agentspec-update = "0.6.0"
agentspec-update = { workspace = true }
Update,
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:
- Fetch latest tag via
gh api repos/<owner>/<repo>/releases/latest (works with github.com and GHES)
- Compare against current version (injected via
-ldflags)
- Construct asset URL from the response's
assets[].browser_download_url matching BINARY-OS-ARCH
- Download to a temp file,
chmod +x, then os.Rename() over os.Executable()
- Wire as
cobra.Command on root: rootCmd.AddCommand(newUpdateCmd(version))
Node . npm self-install
.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,
}
#[arg(long, global = true, default_value = "human", value_enum)]
format: OutputFormat,
match cli.format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&data)?),
OutputFormat::Human => { }
}
Go
var format string
rootCmd.PersistentFlags().StringVar(&format, "format", "human", "Output format: json|human")
if format == "json" {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.Encode(data)
} else {
}
Node
.addOption(new Option('--format <fmt>', 'Output format').choices(['json', 'human']).default('human'))
Standard Global Flags
| Flag | Type | When to include |
|---|
--format json|human | enum | Any tool with structured data output |
--verbose / -v | bool | Tools with meaningful progress output |
--dry-run | bool | Mutating 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
set -eu
REPO="<owner>/<repo>"
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
| Pattern | Reference |
|---|
| Self-update (Rust) | sr/crates/sr-cli/src/main.rs lines 343-356 |
--format json|human | sr/crates/sr-cli/src/main.rs, oag/crates/oag-cli/src/main.rs |
| install.sh | sr/install.sh |
| Cobra root setup | inline 20-line example below |
| Go self-update | saige/internal/updater/updater.go (after migration) |