Skip to content

$ 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/s

That'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 changes

First 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 changes

I don't need documentation to guess what those do. That's the point.

Bad CLI design:

zvezda repo-management list-all --format=table

Why? 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 interval

Subcommands when functionality is distinct:

sysmon-cli export --output metrics.csv

Don'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 origin

Colors, 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 -l

Unix 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 failed

Sysmon-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 $USER

You 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+C

Zvezda uses exit codes so you can:

if zvezda sync --quiet; then
    echo "All repos synced"
else
    echo "Sync failed"
    exit 1
fi

Automation 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.yaml

Most 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: .exe on 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.exe

Ship 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 CSV

Short. 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-cli

GitHub Releases (all platforms)

curl -L https://github.com/nf-software/sysmon-cli/releases/latest/download/sysmon-cli-linux -o sysmon-cli
chmod +x sysmon-cli

Go Install (if you have Go)

go install github.com/nf-software/sysmon-cli@latest

Multiple 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:

  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:

  • 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

more in technical/

Independent studio building developer tools, local-first software and custom projects.

contact@nf-software.com
© 2026 NF Software · SIRET 104 780 820 00015privacyterms
NORMAL~/blog/cli-tools-best-practicesTop