Back to Blog
Technical
November 2, 2024
11 min read

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.

Noam Favier
Developer & Founder
Building Great CLI Tools: Lessons from Sysmon and Zvezda

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/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.

    CLIDeveloper ToolsGoSoftware Design