$ less ~/blog/cli-tools-best-practices.md
Building Great CLI Tools: Lessons from Sysmon and Zvezda
Three CLI tools, three different problems, same lesson: if it doesn't work immediately, no one uses it. Here's what actually matters.
I've built three CLI tools: Sysmon-CLI, Zvezda, and Iris. Different purposes, same constraints: if it takes more than 10 seconds to understand, people bounce.
Here's what worked, what didn't, and why most CLI design advice is wrong.
The Real First Rule: It Must Work Immediately
Not "run the installer, edit the config, set up your environment, then run it." Just download and run.
$ sysmon-cli
CPU: 23% Memory: 8.2 GB Disk: 120 GB Network: ↓ 1.2 MB/s ↑ 0.8 MB/sThat's it. No setup. No config. No "getting started" guide. It does the obvious thing.
Zvezda same deal:
$ zvezda list
~/code/sysmon-cli main ✓
~/code/zvezda main 2 ahead
~/code/iris dev uncommitted changesFirst run. No config file. It found my repos, checked their status, showed me what I need to know.
Most CLI tools fail here. They require configuration before they're useful. Wrong. Sensible defaults beat customization.
Single Binary or It's Not Portable
Sysmon-CLI is one file. 12 MB. No dependencies. Copy it anywhere, it runs.
Compare to tools that need:
- Python interpreter (which version?)
- Node.js runtime (and 400 MB of node_modules)
- System libraries (good luck on Alpine)
- Config files in specific directories
I've had users run Sysmon-CLI on:
- Raspberry Pis
- Docker containers
- macOS, Linux, Windows
- Air-gapped servers
One binary. Works everywhere. Go makes this easy. That's why I use Go for CLI tools.
Commands Should Be Obvious
Zvezda manages Git repos. The commands are:
zvezda list # show repos
zvezda sync # pull all repos
zvezda status # check all repos for changesI don't need documentation to guess what those do. That's the point.
Bad CLI design:
zvezda repo-management list-all --format=tableWhy? Verbosity isn't clarity. list is enough. Everyone knows what "list" means.
Subcommands When Needed, Not Always
Sysmon-CLI doesn't have subcommands for everything. Default behavior: show metrics. That's what you want 90% of the time.
sysmon-cli # show metrics
sysmon-cli --json # JSON output
sysmon-cli --interval 5s # custom intervalSubcommands when functionality is distinct:
sysmon-cli export --output metrics.csvDon't make users type sysmon-cli metrics show when sysmon-cli is clearer.
Output: Humans First, Machines Second
Default output is for humans:
$ zvezda status
✓ sysmon-cli clean
✗ zvezda uncommitted changes
⚠ iris 2 commits ahead of originColors, symbols, readable text. Not JSON. Not CSV. Not "machine-parseable format."
But when you need machine output:
$ zvezda status --json
[
{"name": "sysmon-cli", "status": "clean"},
{"name": "zvezda", "status": "dirty"},
{"name": "iris", "status": "ahead", "commits": 2}
]Same data, different format. Flag-controlled. Don't make me pipe through jq to get basic info.
Pipeable by Default
Zvezda's list command outputs one repo per line. Why? So this works:
zvezda list | grep pending | wc -lUnix pipes are still the best composition tool. Don't break them with fancy formatting.
Progress bars? Only show them if stdout is a TTY:
if isatty.IsTerminal(os.Stdout.Fd()) {
// show progress bar
} else {
// just output results
}Errors: Say What Went Wrong and How to Fix It
Bad error:
Error: operation failedSysmon-CLI error:
Error: failed to collect CPU metrics
Caused by: permission denied reading /proc/stat
Fix: run with sudo or add user to 'perf' group
sudo usermod -a -G perf $USERYou know what broke. You know how to fix it. That's the standard.
Exit Codes That Actually Mean Something
0 - success
1 - general error
2 - invalid arguments
130 - killed by Ctrl+CZvezda uses exit codes so you can:
if zvezda sync --quiet; then
echo "All repos synced"
else
echo "Sync failed"
exit 1
fiAutomation depends on this. Don't return 0 when things fail.
Configuration: Layers, Not Requirements
Sysmon-CLI has a config file. You'll probably never use it. Because defaults are good enough.
Priority order:
- CLI flags (immediate override)
- Environment variables (session-specific)
- Config file (persistent preferences)
- Defaults (sane choices)
Example:
# Use default interval (1s)
sysmon-cli
# Override for this run
sysmon-cli --interval 5s
# Override for this session
SYSMON_INTERVAL=5s sysmon-cli
# Set permanently
echo "interval: 5s" > ~/.config/sysmon-cli/config.yamlMost users stick with defaults. Power users customize. Everyone's happy.
Cross-Platform Is Non-Negotiable
Sysmon-CLI works on Linux, macOS, Windows. Same binary design, different builds.
Zvezda same. Iris same.
This requires thinking about:
- File paths: use
filepath.Join(), never hardcode/ - Config locations:
os.UserConfigDir()handles platform differences - Line endings: don't assume
\n - Executables:
.exeon Windows, nothing elsewhere
Go makes cross-compilation trivial:
GOOS=linux GOARCH=amd64 go build -o sysmon-cli-linux
GOOS=darwin GOARCH=arm64 go build -o sysmon-cli-macos
GOOS=windows GOARCH=amd64 go build -o sysmon-cli.exeShip all three. Users download the right one.
What I Learned From Mistakes
Mistake: Too Many Flags
Early Zvezda had 20+ flags. Confusing. No one used most of them.
Now: 5 flags you'll actually use. Everything else has sane defaults.
Mistake: Fancy UI
I tried adding a TUI (terminal UI) to Sysmon-CLI. Looked cool. Broke piping. Broke SSH sessions with limited terminfo.
Removed it. Plain text output works everywhere.
Mistake: Requiring Config
First version of Zvezda needed a repos.yaml config listing all repos. No one set it up.
Now: scans ~/code automatically. Finds Git repos. Just works.
Help Text That Actually Helps
$ sysmon-cli --help
sysmon-cli - system monitoring
USAGE:
sysmon-cli [flags]
FLAGS:
-i, --interval duration update interval (default 1s)
-j, --json output JSON
-o, --output file export to file
-h, --help show help
EXAMPLES:
sysmon-cli # start monitoring
sysmon-cli --json # JSON output
sysmon-cli -o metrics.csv # export to CSVShort. Examples. No walls of text. If they want more, there's a GitHub README.
Distribution: Make It Easy to Install
Homebrew (macOS/Linux)
brew install nf-software/tap/sysmon-cliGitHub Releases (all platforms)
curl -L https://github.com/nf-software/sysmon-cli/releases/latest/download/sysmon-cli-linux -o sysmon-cli
chmod +x sysmon-cliGo Install (if you have Go)
go install github.com/nf-software/sysmon-cli@latestMultiple options. Single binary makes this easy.
Testing: The Boring Stuff Matters
I test:
- CLI args parsing
- Output formats (human, JSON, CSV)
- Exit codes
- Cross-platform builds
I don't test:
- UI colors (who cares)
- Help text formatting (as long as it's readable)
- Feature requests no one asked for
Integration tests run the actual binary:
func TestCLI(t *testing.T) {
cmd := exec.Command("./sysmon-cli", "--json")
output, err := cmd.Output()
require.NoError(t, err)
var metrics SystemMetrics
json.Unmarshal(output, &metrics)
assert.Greater(t, metrics.CPU, 0.0)
}If the binary works, tests pass. That's the contract.
What Actually Matters
Building CLI tools for 3 years taught me:
- It must work immediately - no setup, no config required
- Single binary beats everything - distribution, deployment, debugging
- Obvious commands - don't make me read docs to guess
listorsync - Human output by default - JSON when asked
- Helpful errors - tell me what broke and how to fix it
- Cross-platform from day 1 - don't "add Windows support later"
- Sane defaults - most users never touch config
Everything else is details.
What Doesn't Matter
Don't obsess over:
- Perfect flag naming (good enough beats perfect)
- Every edge case (ship, then fix)
- UI frameworks (plain text works everywhere)
- Plugin systems (YAGNI until proven otherwise)
- Config file format (YAML, TOML, JSON—no one cares)
Ship something that works. Iterate based on real usage. Most "best practices" are someone's preference, not requirements.
Sysmon-CLI is 3000 lines. Zvezda is 2000 lines. Iris is 4000 lines. Small, focused, shipped. That's the goal.
(END) — press q… or just go back to ~/blog