Skip to content
AgileLogics
8 min read

Building CLI Tools & Cross-Compiling

Prerequisites: Security essentials

Go produces a single static binary with no runtime to install, which makes it a popular choice for command-line tools. The standard library covers arguments, flags, and I/O; cross-compiling for another OS is one environment variable away.

os.Args

os.Args holds the raw command line; os.Args[0] is the program name:

package main

import (
    "fmt"
    "os"
)

func main() {
    fmt.Println(len(os.Args)-1, "arguments:", os.Args[1:])
}
$ go run . one two
2 arguments: [one two]

The flag package

flag parses -name value, -name=value, and boolean -verbose flags, and generates -h help automatically:

package main

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

func main() {
    name := flag.String("name", "world", "who to greet")
    count := flag.Int("n", 1, "number of greetings")
    shout := flag.Bool("shout", false, "print in upper case")
    flag.Usage = func() {
        fmt.Fprintf(flag.CommandLine.Output(), "usage: greet [flags]\n")
        flag.PrintDefaults()
    }
    flag.Parse()

    if *count < 1 {
        fmt.Fprintln(os.Stderr, "greet: -n must be at least 1")
        os.Exit(2)
    }

    msg := "hello, " + *name
    if *shout {
        msg = strings.ToUpper(msg)
    }
    for i := 0; i < *count; i++ {
        fmt.Println(msg)
    }
}
$ go run . -name Ada -n 2 -shout
HELLO, ADA
HELLO, ADA

Flags must come before positional arguments; flag.Args() returns whatever is left after parsing. For subcommands (tool add, tool list), create one flag.NewFlagSet per subcommand and switch on os.Args[1]. Larger tools often use a third-party library such as spf13/cobra, but flag goes a long way.

Exit codes, stdout, and stderr

Command-line tools communicate through exit codes: 0 for success, non-zero for failure (2 conventionally means incorrect usage). Keep normal output on stdout and errors on stderr, so tool > out.txt still shows errors.

os.Exit skips deferred functions, so a common structure keeps all logic in a run function that returns an error, and calls os.Exit only in main:

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

func run(args []string, stdin io.Reader, stdout io.Writer) error {
    // parse args with a flag.NewFlagSet, do the work, return errors
    return nil
}

Taking the arguments and streams as parameters also makes run easy to test.

Reading from stdin

Tools that compose in pipelines read standard input when no file is given:

scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
    line := scanner.Text()
    fmt.Println(strings.ToUpper(line)) // your processing here
}
if err := scanner.Err(); err != nil {
    return err
}

bufio.Scanner rejects lines over 64 KB by default; call scanner.Buffer to raise the limit.

Cross-compiling

Set GOOS and GOARCH to build for another platform — no extra toolchain needed:

GOOS=linux   GOARCH=amd64 go build -o dist/tool-linux-amd64 .
GOOS=linux   GOARCH=arm64 go build -o dist/tool-linux-arm64 .
GOOS=darwin  GOARCH=arm64 go build -o dist/tool-darwin-arm64 .
GOOS=windows GOARCH=amd64 go build -o dist/tool-windows-amd64.exe .

In PowerShell, set the variables first: $env:GOOS="linux"; $env:GOARCH="arm64"; go build . Run go tool dist list to see every supported combination.

CGO_ENABLED=0

Some standard packages (net, os/user) can use C libraries through cgo when it's available. Cross-compiling with cgo needs a C cross-compiler, and a cgo binary depends on the target's C library. Disabling cgo gives a fully static, portable binary:

CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o tool .

-trimpath removes local file paths from the binary; -ldflags="-s -w" strips debug symbols to shrink it. If a dependency requires cgo (for example, mattn/go-sqlite3), you'll need to keep cgo and set up a cross C toolchain, or pick a pure-Go alternative.

Common mistakes

  • Printing errors to stdout, which corrupts piped output.
  • Exiting with 0 after a failure, so scripts and CI think it succeeded.
  • Calling os.Exit or log.Fatal deep inside the program, skipping deferred cleanup such as flushing files.
  • Putting flags after positional arguments — flag stops parsing at the first non-flag argument.

Practice

Write linecount: it prints the number of lines in each file given as an argument (<count> <filename>), or counts standard input if no files are given. Exit with status 1 if any file can't be opened, but still process the rest.

Show solution
package main

import (
    "bufio"
    "fmt"
    "io"
    "os"
)

func count(r io.Reader) (int, error) {
    sc := bufio.NewScanner(r)
    n := 0
    for sc.Scan() {
        n++
    }
    return n, sc.Err()
}

func main() {
    files := os.Args[1:]
    if len(files) == 0 {
        n, err := count(os.Stdin)
        if err != nil {
            fmt.Fprintln(os.Stderr, "linecount:", err)
            os.Exit(1)
        }
        fmt.Println(n)
        return
    }

    failed := false
    for _, name := range files {
        f, err := os.Open(name)
        if err != nil {
            fmt.Fprintln(os.Stderr, "linecount:", err)
            failed = true
            continue
        }
        n, err := count(f)
        f.Close()
        if err != nil {
            fmt.Fprintln(os.Stderr, "linecount:", name+":", err)
            failed = true
            continue
        }
        fmt.Println(n, name)
    }
    if failed {
        os.Exit(1)
    }
}