Mert Tosun
← Posts
Building CLIs in Go: A Deep Dive into Command-Line Tools

Building CLIs in Go: A Deep Dive into Command-Line Tools

Mert TosunGo

Command-line tools are the backbone of developer workflows. Shell scripts grow hard to maintain; a Go CLI ships as a single static binary, offers type safety, and stays simple even when you need concurrency.

This article walks you from the standard library’s flag package to the popular Cobra framework and all the way to release engineering.

Why Go for CLIs?

Goal How Go helps
Single-file distribution go build → one binary per platform
Fast startup Small binaries, low memory footprint
Concurrent work Goroutines and channels
Rich ecosystem Cobra, Viper, urfave/cli, and more

Go is widely used for DevOps, data tooling, and internal developer tools: traces of Go appear across kubectl, docker, hugo, and the Terraform ecosystem.


1. Project skeleton

mkdir mycli && cd mycli
go mod init github.com/user/mycli

Minimal entrypoint:

package main

import (
    "fmt"
    "os"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Fprintln(os.Stderr, "usage: mycli <command>")
        os.Exit(2)
    }
    fmt.Println("args:", os.Args[1:])
}

os.Args[0] is the program name; the rest are user arguments. That is enough for toys, but real tools scale better with flag or Cobra.


2. The flag package: options and defaults

The standard library covers many cases:

package main

import (
    "flag"
    "fmt"
    "os"
)

func main() {
    verbose := flag.Bool("v", false, "verbose output")
    outPath := flag.String("o", "", "output file (empty = stdout)")
    flag.Parse()

    if *verbose {
        fmt.Fprintln(os.Stderr, "verbose on")
    }
    if *outPath != "" {
        fmt.Println("output:", *outPath)
    }
    fmt.Println("positional args:", flag.Args())
}

Note: Flags are not processed until you call flag.Parse(). Positional arguments come from flag.Args().


3. Subcommands with Cobra

Multiple commands (mycli init, mycli deploy) are where Cobra becomes the de facto choice.

go get github.com/spf13/cobra@latest

Example layout:

cmd/
  root.go       // root command, persistent flags
  version.go    // mycli version
  run.go        // mycli run
main.go

Typical root.go:

package cmd

import "github.com/spf13/cobra"

var rootCmd = &cobra.Command{
    Use:   "mycli",
    Short: "Example CLI",
    Long:  `Long description shown in help text.`,
}

func Execute() error {
    return rootCmd.Execute()
}

Adding a subcommand:

var runCmd = &cobra.Command{
    Use:   "run",
    Short: "runs the job",
    RunE: func(cmd *cobra.Command, args []string) error {
        // business logic; return errors for non-zero exit
        return nil
    },
}

func init() {
    rootCmd.AddCommand(runCmd)
    runCmd.Flags().StringP("env", "e", "dev", "environment name")
}

Prefer RunE over Run so errors propagate cleanly and tests stay straightforward.


4. stdin, stdout, and stderr

The usual contract:

  • stdout — data meant for machines (pipelines)
  • stderr — logs and warnings for humans
  • stdin — piped or redirected input
import (
    "io"
    "os"
)

func copyOut(r io.Reader) {
    _, _ = io.Copy(os.Stdout, r)
}

func logErr(msg string) {
    _, _ = fmt.Fprintln(os.Stderr, msg)
}

For TTY detection (toggle colors), small helpers like golang.org/x/term or isatty-style packages help; disabling color in CI improves readability of logs.


5. Exit codes

Unix conventions:

Code Meaning
0 Success
1 General error
2 Misuse (bad arguments)

Cobra generally exits with 1 when RunE returns an error. For custom codes you can call os.Exit — use sparingly and keep business logic testable outside main.

Good practice: Keep core logic in pure functions; let a thin main / cmd layer map results to os.Exit. That way go test can validate behavior without real process exit codes.


6. Configuration: flags, env, files

A common precedence stack:

  1. Command-line flags (highest priority)
  2. Environment variables (MYCLI_API_KEY)
  3. User config under ~/.config/mycli/config.yaml

Viper fits the Cobra ecosystem, but small tools often need only os.Getenv and a one-time YAML/JSON read. Match dependency weight to project size.


7. Cross-compilation

Go can build for many targets from one machine:

GOOS=linux GOARCH=amd64 go build -o mycli-linux-amd64 .
GOOS=darwin GOARCH=arm64 go build -o mycli-darwin-arm64 .
GOOS=windows GOARCH=amd64 go build -o mycli-windows-amd64.exe .

CI pipelines commonly run these commands to publish artifacts.


8. Testing strategy

  • Unit tests: avoid binding logic to global os.Args state.
  • Integration: running the built binary via exec.Command is heavier but useful for critical paths.
  • Golden files: snapshot stdout against checked-in expected output.

Injecting io.Writer instead of calling fmt.Printf directly — e.g. Run(out io.Writer, args []string) error — dramatically improves testability.


9. Releases: GoReleaser

GoReleaser can produce multi-platform binaries, checksums, and sometimes package-manager recipes when you push a Git tag. It saves a lot of time for open-source CLIs.

Even without it, go install github.com/user/mycli@latest lets users install from your module path (Go 1.17+).


10. UX tips

  • Help text: fill Short and Long; show example invocations.
  • Version: inject with ldflags (-X main.version=...) from CI or git.
  • Color: pretty in terminals; messy in log files — gate on TTY.
  • Progress: for long jobs, use stderr for progress so stdout stays machine-clean.

Checklist

  • Clear separation of stdout vs stderr?
  • Errors return actionable messages?
  • Exit codes documented for automation?
  • -h / --help actually helpful?
  • Cross-build integrated into CI?

Building CLIs in Go gives you a fast path from prototype to long-lived internal tools. Start with flag, graduate to Cobra when complexity grows, and automate distribution early.

The command line is your UI — keep it short, clear, and predictable.