Quick take
Most CLI tools suck because their authors treated them as throwaway scripts. Spend two extra hours on flags, output, and error handling and you get something people actually reach for instead of working around.
I’ve built more CLI tools than I can count. Internal deploy scripts at the fintech startup. A bike fleet management CLI at Dropbyke. Prototype tooling during the Decloud days at EF. Every startup I’ve touched, the first real productivity win was a CLI that replaced a Slack message to someone who knew how to SSH into the right box.
Go is my default for this. Not because it’s perfect, but because go build gives you a static binary, cross-compilation is a flag, and the stdlib covers 80% of what a CLI needs. No runtime. No pip install. No “works on my machine.” You hand someone a binary and it runs.
This post isn’t a philosophy essay about CLI design principles. It’s the concrete patterns I use, with code, for building tools that hold up past the first week.
Start With Cobra. Don’t Reinvent This.
I know there are ten Go CLI libraries. I’ve tried most of them. Cobra wins because kubectl uses it, Docker uses it, Hugo uses it, and that means your users already know the UX patterns. That’s worth more than any technical advantage of alternatives.
Here is the skeleton I start every project with:
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "fleet",
Short: "Manage bike fleet operations",
// No Run: field -- root command just shows help
}
var listCmd = &cobra.Command{
Use: "list",
Short: "List all bikes in a zone",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
zone := args[0]
format, _ := cmd.Flags().GetString("output")
return listBikes(zone, format)
},
}
func init() {
listCmd.Flags().StringP("output", "o", "table", "Output format: table, json")
rootCmd.AddCommand(listCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
A few things to notice. RunE instead of Run – always return errors instead of calling os.Exit deep inside your command tree. cobra.ExactArgs(1) validates the argument count before your code even runs, so you skip an entire class of nil-pointer bugs. And the root command has no Run field, which means it prints help by default.
That last one is a tiny detail but it matters. A CLI that dumps help when you run it with no arguments is a CLI that teaches itself.
Output Is an API. Treat It Like One.
This is the hill I’ll die on. The moment someone puts your CLI in a shell script or a Makefile, your stdout is a contract. Break the format and you break their pipeline.
I separate human output and machine output with a simple interface:
type Formatter interface {
Format(w io.Writer, data interface{}) error
}
type tableFormatter struct{}
type jsonFormatter struct{}
func (f *tableFormatter) Format(w io.Writer, data interface{}) error {
bikes, ok := data.([]Bike)
if !ok {
return fmt.Errorf("unexpected data type for table format")
}
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tSTATUS\tZONE\tBATTERY")
for _, b := range bikes {
fmt.Fprintf(tw, "%s\t%s\t%s\t%d%%\n", b.ID, b.Status, b.Zone, b.Battery)
}
return tw.Flush()
}
func (f *jsonFormatter) Format(w io.Writer, data interface{}) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(data)
}
The key insight: human output goes to stdout. Progress messages, warnings, and spinners go to stderr. Always. If someone runs fleet list downtown --output json > bikes.json, the JSON file should contain only JSON. Not your spinner. Not your “connecting to API…” message. Just data.
func listBikes(zone, format string) error {
// Progress goes to stderr
fmt.Fprintln(os.Stderr, "Fetching bikes...")
bikes, err := api.GetBikes(zone)
if err != nil {
return fmt.Errorf("fetching bikes in zone %q: %w", zone, err)
}
// Data goes to stdout
f := getFormatter(format)
return f.Format(os.Stdout, bikes)
}
Error Messages That Actually Help
Generic errors are a waste of everyone’s time. “Error: request failed” tells me nothing. I spend maybe 30% of my CLI development time on error paths, and it pays for itself immediately.
My pattern: state what happened, state what the user can do about it.
func validateZone(zone string) error {
valid := []string{"downtown", "midtown", "uptown", "harbor"}
for _, v := range valid {
if v == zone {
return nil
}
}
return fmt.Errorf(
"unknown zone %q\nAvailable zones: %s\nRun 'fleet zones list' to see all options",
zone,
strings.Join(valid, ", "),
)
}
Three lines of context save someone five minutes of guessing. Every time.
Go’s error wrapping with %w is perfect for CLIs because it lets you build a chain of context without losing the original cause. At the top level, I unwrap and format:
func main() {
if err := rootCmd.Execute(); err != nil {
// Cobra already prints the error, but we control the exit code
var usageErr *UsageError
if errors.As(err, &usageErr) {
os.Exit(2)
}
os.Exit(1)
}
}
Exit code 1 for general errors. Exit code 2 for usage errors (wrong flags, missing arguments). Scripts depend on this distinction.
Signal Handling: The Thing Everyone Forgets
This one bit me during the Dropbyke days. We had a CLI that talked to the fleet management API to reassign bikes. Someone would Ctrl+C halfway through a batch operation and end up with bikes in a half-assigned state. Not great when you have physical hardware sitting on street corners.
The fix is a context that listens for signals:
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
fmt.Fprintln(os.Stderr, "\nInterrupt received, finishing current operation...")
cancel()
<-sigCh
fmt.Fprintln(os.Stderr, "Force quit.")
os.Exit(1)
}()
if err := rootCmd.ExecuteContext(ctx); err != nil {
os.Exit(1)
}
}
First Ctrl+C sets the context to done, which lets your operation finish its current unit of work gracefully. Second Ctrl+C kills it immediately. This is the pattern kubectl uses and it’s the right one. Users learn it once.
Then in your actual command logic, check the context:
for _, bike := range bikes {
select {
case <-ctx.Done():
fmt.Fprintf(os.Stderr, "Cancelled. %d/%d bikes reassigned.\n", i, len(bikes))
return ctx.Err()
default:
if err := api.Reassign(ctx, bike.ID, targetZone); err != nil {
return fmt.Errorf("reassigning bike %s: %w", bike.ID, err)
}
}
}
Configuration Precedence
This is the order. Don’t get creative:
- Explicit flags
- Environment variables
- Config file in the current directory
- Config file in
$HOME - Compiled defaults
Cobra + Viper handle this natively. The one rule I enforce: every config value must be settable as a flag. Config files are convenience, not a requirement. If someone can’t run your tool in a CI pipeline by passing flags, your config story is broken.
func init() {
rootCmd.PersistentFlags().String("api-url", "", "Fleet API endpoint")
viper.BindPFlag("api_url", rootCmd.PersistentFlags().Lookup("api-url"))
viper.SetEnvPrefix("FLEET")
viper.AutomaticEnv()
viper.SetDefault("api_url", "https://api.fleet.internal")
}
FLEET_API_URL as an env var, --api-url as a flag, api_url in a config file. All resolve to the same value. The user picks whichever fits their context.
Shell Completion Isn’t Optional
I’m a zsh person. Have been since before it was cool (or at least since before macOS made it the default). Cobra generates completion scripts for free:
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish]",
Short: "Generate shell completion scripts",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return rootCmd.GenBashCompletion(os.Stdout)
case "zsh":
return rootCmd.GenZshCompletion(os.Stdout)
case "fish":
return rootCmd.GenFishCompletion(os.Stdout, true)
default:
return fmt.Errorf("unsupported shell: %s", args[0])
}
},
}
But the real win is dynamic completion. Register custom completions for your arguments so the shell can suggest zone names, bike IDs, whatever your tool works with. The difference between typing fleet reassign <tab> and seeing actual bike IDs versus nothing is the difference between a tool people love and a tool people tolerate.
Distribution: Just Ship a Binary
goreleaser handles this and I refuse to do it any other way. Tag a release, push, and goreleaser builds binaries for every platform, creates a GitHub release, and updates your Homebrew tap.
# .goreleaser.yml
builds:
- main: ./cmd/fleet
binary: fleet
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.ShortCommit}}
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
brews:
- tap:
owner: lawzava
name: homebrew-tap
Embed the version and commit hash at build time. fleet version should tell you exactly what binary you’re running. When someone files a bug report, the first question is always “what version?” Make that answer trivial.
The Stuff That Compounds
None of this is individually hard. A --dry-run flag that previews destructive operations. A --force flag that skips confirmations. Respecting NO_COLOR. Printing a non-zero exit code when something fails instead of printing “error” and exiting 0.
These are small decisions. But they compound. A tool that gets all of them right feels professional. A tool that misses even a few feels like a prototype that escaped.
I’ve shipped maybe fifteen CLI tools across three startups now. The ones that survived weren’t the cleverest ones. They were the ones where I spent an afternoon on the boring parts – the flags, the error messages, the signal handling, the output formatting. The plumbing that nobody notices until it’s missing.
Build the plumbing first. The features are the easy part.