Updated Aug 9, 2026

12 - CLI Tools & Cross-Compilation

📋 Jump to Takeaways

🎁 You can pipe data into a Go program, but does it know whether it's reading from a keyboard or a pipe? And how do you take that program and ship one binary that runs on a colleague's Mac, a Linux server, and a Raspberry Pi, all from a single build command?

Your monitoring tool works great on your Mac. But your infra team runs Linux servers, your ops team has a mix of ARM-based machines, and one on-call engineer somehow still has a Windows laptop. Today you build each version by SSHing into a box of the right type and running go build. Next week you'll do it again for the bug fix. The week after, someone will ask for a Linux ARM build and you'll realize you don't have one of those machines.

The binary also behaves badly in pipelines. It colorizes output with ANSI escape codes. When someone runs mytool | grep ERROR, the terminal colors show up as \e[31mERROR\e[0m and grep matches nothing. The tool doesn't know it's being piped.

These are two separate problems: how you ship the binary, and how it behaves at runtime. This lesson covers both.

Why Not Just Parse os.Args Manually?

You could. os.Args[1:] gives you every argument as a string slice. For a tool with one or two flags, manual parsing is fine.

It breaks down fast. You need to handle -v, --verbose, and -verbose as the same flag. You need to handle -o output.txt and -o=output.txt. You need to generate -h help text. You need to handle -- as a flag terminator. You need to handle combined short flags like -vn. The flag package implements all of this. Writing it yourself means writing a flag parser, which is a distraction from the actual tool.

For tools with subcommands (mytool deploy, mytool status) you want Cobra, covered in CLI Tooling with Cobra. For a single-purpose tool with a handful of flags, the stdlib flag package is exactly the right scope.

Defining and Parsing Flags

Define each flag before calling flag.Parse. The pointer-returning form is most common:

verbose := flag.Bool("v", false, "verbose output")
output  := flag.String("o", "stdout", "output file path")
count   := flag.Int("n", 10, "number of results")

flag.Parse()

if *verbose {
    fmt.Fprintln(os.Stderr, "verbose mode on")
}

The gotcha most people hit once: reading *verbose before flag.Parse() always gives you the default, regardless of what the user passed. The flag value is not set until Parse runs. If you call flag.Parse() at the top of main and read the flags in a function called from main, you're fine. If you initialize something at package level based on a flag value, you're not.

flag.StringVar skips the pointer indirection if you already have a variable:

var outputPath string
flag.StringVar(&outputPath, "o", "stdout", "output file path")
flag.Parse()
// outputPath is now set directly

After flag.Parse, positional arguments (non-flag values) are in flag.Args():

// mytool -v report.txt summary.txt
flag.Parse()
files := flag.Args()   // ["report.txt", "summary.txt"]

One more thing about -h: the flag package handles it automatically, printing usage and exiting with code 2. Not 0. If you have a script that checks mytool -h && do_something, the && branch never runs. If you need -h to exit 0, override flag.Usage and call os.Exit(0) yourself.

Standard Streams and Piping

The Unix contract: write results to os.Stdout, write diagnostics and logs to os.Stderr. This lets users do mytool | jq . and get clean JSON without your log lines mixed in.

The second part of the contract: detect whether you're talking to a terminal or a pipe, and change behavior accordingly. ls shows colors and columns in a terminal; plain filenames one-per-line in a pipe. grep highlights matches in a terminal; no highlights in a pipe. Your tool should do the same.

func isTerminal(f *os.File) bool {
    info, err := f.Stat()
    if err != nil {
        return false
    }
    return (info.Mode() & os.ModeCharDevice) != 0
}

func main() {
    if isTerminal(os.Stdin) {
        fmt.Fprintln(os.Stderr, "usage: mytool < input.txt")
        os.Exit(1)
    }
    data, _ := io.ReadAll(os.Stdin)
    process(data)
}

os.ModeCharDevice is set when the file descriptor is an interactive terminal. When it's clear, stdin is a pipe, a file redirect, or /dev/null. The same check works for stdout: if isTerminal(os.Stdout) is false, suppress colors and decorations so downstream tools see clean text.

Exit Codes

The shell judges your tool entirely by its exit code. Zero means success. Non-zero means failure. Scripts use this: mytool && deploy only deploys if mytool succeeded.

os.Exit does the job, but it skips deferred functions. This is the trap:

// ❌ WRONG: defers inside processFile never run
func main() {
    if err := processFile("data.txt"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1) // skips defers in processFile
    }
}

The fix is the run() error pattern: keep all real logic in a function that returns an error, and only call os.Exit in main after that function has returned and all its defers have fired:

func main() {
    if err := run(); err != nil {
        fmt.Fprintln(os.Stderr, "error:", err)
        os.Exit(1)
    }
}

func run() error {
    f, err := os.Open("data.txt")
    if err != nil {
        return err
    }
    defer f.Close() // this runs, always

    return process(f)
}

os.Exit in main fires after run() has already returned, so every defer inside run has already executed. This is the standard pattern in Go CLI tools.

Cross-Compilation

Set GOOS and GOARCH before go build and Go produces a binary for that target from whatever machine you're on. No cross-toolchain, no VMs, no SSH into a Linux box:

# Linux AMD64 (most servers)
GOOS=linux GOARCH=amd64 go build -o mytool-linux-amd64

# Linux ARM64 (AWS Graviton, Raspberry Pi 4, Apple Silicon containers)
GOOS=linux GOARCH=arm64 go build -o mytool-linux-arm64

# macOS Apple Silicon
GOOS=darwin GOARCH=arm64 go build -o mytool-darwin-arm64

# Windows
GOOS=windows GOARCH=amd64 go build -o mytool-windows-amd64.exe

# See every supported target
go tool dist list

The one requirement for this to work: CGO_ENABLED=0. CGO is Go's mechanism for calling C code. When it's enabled, the linker ties your binary to the C standard library (libc) of the machine it was built on. That libc won't be present on the target, so the binary crashes on startup with "no such file or directory" even though the file is right there.

# This WILL cross-compile cleanly
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o mytool-linux-arm64

# This will FAIL with a confusing error about a missing C cross-compiler
GOOS=linux GOARCH=arm64 go build -o mytool-linux-arm64
# cgo: C compiler "aarch64-linux-gnu-gcc" not found

CGO_ENABLED=0 also gives you a truly static binary: no dynamic library dependencies at all. It runs on a scratch container, an Alpine base image, an ancient CentOS box. This is what "single static binary" actually means.

Most Go code doesn't use CGO. The exception is code that imports packages with C bindings: SQLite drivers, some crypto libraries, system-specific packages. If your tool uses any of those, you'll need to find pure-Go alternatives or set up a real cross-compilation toolchain.

Stamping Build Info

A production binary should be able to tell you exactly what it is. Not "version 1.0" — the exact commit, build time, and version string:

var (
    version = "dev"     // overridden at build time
    commit  = "none"
    date    = "unknown"
)

var versionFlag = flag.Bool("version", false, "print version and exit")

func main() {
    flag.Parse()
    if *versionFlag {
        fmt.Printf("version=%s commit=%s date=%s\n", version, commit, date)
        os.Exit(0)
    }
    // ...
}

Set those variables at link time with -ldflags:

go build \
  -ldflags="-X main.version=1.4.0 -X main.commit=$(git rev-parse --short HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -o mytool

Running mytool -version now prints version=1.4.0 commit=abc1234 date=2026-08-09T14:00:00Z. When someone files a bug, they can tell you exactly what they're running.

Strip debug symbols for release builds to cut binary size by 20-30%:

go build -ldflags="-s -w -X main.version=1.4.0" -o mytool

-s removes the symbol table, -w removes DWARF debug info. Neither affects runtime behavior. Don't strip debug symbols in development builds — it makes stack traces harder to read.

Putting It Together

Here's what the monitoring tool from the intro looks like with all of this wired up:

var (
    version    = "dev"
    verbose    = flag.Bool("v", false, "verbose output")
    outputFile = flag.String("o", "", "write output to file (default: stdout)")
    versionF   = flag.Bool("version", false, "print version and exit")
)

func main() {
    flag.Parse()

    if *versionF {
        fmt.Println("version:", version)
        os.Exit(0)
    }

    if err := run(); err != nil {
        fmt.Fprintln(os.Stderr, "error:", err)
        os.Exit(1)
    }
}

func run() error {
    // Detect piped input
    if isTerminal(os.Stdin) {
        return fmt.Errorf("no input: pipe data or redirect a file")
    }

    // Choose output destination
    out := os.Stdout
    if *outputFile != "" {
        f, err := os.Create(*outputFile)
        if err != nil {
            return fmt.Errorf("open output: %w", err)
        }
        defer f.Close()
        out = f
    }

    // Only colorize if the output is a terminal
    colorize := isTerminal(out)

    data, err := io.ReadAll(os.Stdin)
    if err != nil {
        return fmt.Errorf("read input: %w", err)
    }

    return process(data, out, colorize, *verbose)
}

This binary cross-compiles to any target with CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build. It behaves correctly in pipelines. It exits non-zero on failure. It defers properly. The version string is stamped at build time.

Key Takeaways

  • Read flag values only after flag.Parse(). Reading before parse always returns the default, silently.
  • -h exits with code 2, not 0. If your scripts check exit codes around -h, override flag.Usage.
  • Keep os.Exit in main only, after a run() error helper returns. os.Exit skips defers everywhere.
  • Check os.Stdin.Stat() for os.ModeCharDevice to detect piped input. Check the same on os.Stdout before emitting ANSI color codes.
  • CGO_ENABLED=0 is required for cross-compilation. Without it, the binary links against your local libc and fails to run on the target.
  • Static binaries (CGO_ENABLED=0) run on scratch containers, Alpine images, and any Linux without library dependencies.
  • Stamp version, commit, and date with -ldflags="-X main.version=..." at build time. Strip with -s -w for release.

🎁 A CLI tool runs, does its job, and exits. But some programs are never supposed to exit: they run for months, restart themselves on failure, and reload config without dropping a request. How do you build one of those, and who takes care of keeping it alive?

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy