Building CLI Tools That People Actually Use
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:
I've had users run Sysmon-CLI on:
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:
1. **CLI flags** (immediate override)
2. **Environment variables** (session-specific)
3. **Config file** (persistent preferences)
4. **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:
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:
I don't test:
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:
1. **It must work immediately** - no setup, no config required
2. **Single binary beats everything** - distribution, deployment, debugging
3. **Obvious commands** - don't make me read docs to guess `list` or `sync`
4. **Human output by default** - JSON when asked
5. **Helpful errors** - tell me what broke and how to fix it
6. **Cross-platform from day 1** - don't "add Windows support later"
7. **Sane defaults** - most users never touch config
Everything else is details.
What Doesn't Matter
Don't obsess over:
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.