Command-Line Programs in Go

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Go produces a single native executable per target, starts in milliseconds and cross-compiles from any machine, which is why so many command-line tools are written in it. The language section of this site already documents the standard-library side of a command-line program in depth, so this page does not repeat it. It adds what that page deliberately leaves out: how to choose between the standard flag package, urfave/cli v3 and Cobra, a complete urfave/cli v3 program for the running Bookshelf scenario (the shelf tool), the golang.org/x/term module for everything that talks to a terminal, and the Windows, macOS and Linux details of building and installing the result.

The page was written against Go 1.27.3, urfave/cli v3.14.0, Cobra v1.10.2 and golang.org/x/term v0.47.0, all checked on 25 October 2026. The Go snippets on this page were not compiled or run while it was written, because no Go toolchain was available on the host; they follow the documented APIs, and each is followed by a link to the documentation it derives from. Run go mod tidy, go vet ./... and the tests of your own project before relying on any of them.

What is already covered, and where

Link rather than repeat: these topics have their own pages, and everything below assumes them.

Topic Where

os.Args, the flag package (flag.Value, flag.Func, FlagSet subcommands), exit codes and os.Exit, standard streams, environment variables, os/signal, os/exec, a complete shelf built on the standard library

Command-Line Programs in the Go section

Cobra (command tree, RunE, SilenceUsage, completion, documentation generation) and Bubble Tea

Go & Rust CLI Frameworks

go build, -ldflags -X, -trimpath, go install, the full GOOS / GOARCH table, build constraints, cgo

The go Command, Builds and Cross-Compilation

Cancellation and signal.NotifyContext

Context

Errors and errors.Is / errors.As

Error Handling

The Go language itself

Go

Why stdout carries data and stderr carries diagnostics, and what exit codes mean to a caller

Streams & Exit Codes

Terminals, raw mode, process groups

Terminals, TTYs & Pseudo-Terminals

POSIX signals and descriptors; the Win32 console and console control events

POSIX Processes, Signals & I/O and Windows Console & Processes

Full-screen terminal applications in Go

Go TUIs with Bubble Tea

Choosing a parser: flag, urfave/cli or Cobra

All three produce the same kind of program. The choice is about how much of the command-line surface you want written for you. The facts below come from the documentation of each library.

Standard flag urfave/cli v3 Cobra

Module

Standard library

github.com/urfave/cli/v3 (declares go 1.22 in its go.mod)

github.com/spf13/cobra, with github.com/spf13/pflag for flags

Shape of a program

FlagSet per subcommand, switch on os.Args[1] by hand

A tree of *cli.Command values; each has Name, Usage, Flags, Commands and an Action

A tree of *cobra.Command values; each has Use, Short, Long, Args and a RunE

Action signature

Your own run function

func(context.Context, *cli.Command) error

func(*cobra.Command, []string) error

Flag syntax

-x and --x are the same; no grouped short flags; parsing stops at the first non-flag

Aliases per flag; compound short flags such as -abc are listed as a feature

POSIX / GNU style through pflag

Environment variables

By hand

Built in: Sources: cli.EnvVars("SHELF_DATA") on the flag

By hand, or with a companion library

Shell completion

None

EnableShellCompletion: true adds a completion subcommand for bash, zsh, fish and PowerShell

A generated completion command

Man pages and Markdown

None

The separate cli-docs module

The doc package, shown on the Go & Rust CLI Frameworks page

Dependencies

None

The README states none beyond the standard library

pflag and further modules listed in its go.mod

Practical guidance that follows from the table:

  • A handful of flags and at most a few subcommands: stay with flag. The cost is the code you write for subcommand dispatch and for usage text, covered on the Go section’s page.

  • A command tree users will explore with --help and Tab: urfave/cli or Cobra. Both generate help and completion; the deciding factors are habit, the ecosystem around the tool (the Cobra documentation and its add-on packages are the larger of the two) and whether you want environment sources as a flag option (urfave/cli) or a larger set of add-on packages (Cobra).

  • Whichever parser you pick, keep the same four seams: a run function that returns an exit status, data on stdout and diagnostics on stderr, a context cancelled by SIGINT / SIGTERM, and tests that call run rather than main. Those are all outside the parser, which is why they are documented once, in the Go section.

A shelf program with urfave/cli v3

The program below is the shelf tool of the Bookshelf scenario in urfave/cli v3: three subcommands (search, lend, login) plus an interactive browse, a --data flag with a short alias and an environment source, a required flag, a flag-level validation action, shell completion, and a distinct exit status for "not found" and "already lent".

Create the module first:

$ mkdir -p bookshelf/cmd/shelf && cd bookshelf
$ go mod init example.com/bookshelf
$ go get github.com/urfave/cli/v3@v3.14.0 golang.org/x/term@v0.47.0
// cmd/shelf/main.go
package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/signal"
	"strconv"
	"strings"
	"syscall"

	"github.com/urfave/cli/v3"
)

// version is stamped at build time: go build -ldflags "-X main.version=1.4.0"
var version = "dev"

// Exit statuses of shelf. 0 is success and 1 is any other failure.
const (
	exitFailure  = 1
	exitUsage    = 2
	exitNotFound = 3
	exitConflict = 4
)

// statusError is an error that carries the process exit status.
type statusError struct {
	status int
	msg    string
}

func (e *statusError) Error() string { return e.msg }
func (e *statusError) Status() int   { return e.status }

type book struct {
	ID     int    `json:"id"`
	Title  string `json:"title"`
	Author string `json:"author"`
	LentTo string `json:"lent_to,omitempty"`
}

func load(path string) ([]book, error) {
	data, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		return nil, nil // an absent file is an empty bookshelf
	}
	if err != nil {
		return nil, err
	}
	var books []book
	if err := json.Unmarshal(data, &books); err != nil {
		return nil, fmt.Errorf("%s: %w", path, err)
	}
	return books, nil
}

func save(path string, books []book) error {
	data, err := json.MarshalIndent(books, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(path, data, 0o644)
}

// dataFlag is defined once and attached to every command that reads the bookshelf.
func dataFlag() cli.Flag {
	return &cli.StringFlag{
		Name:    "data",
		Aliases: []string{"d"},
		Usage:   "path of the bookshelf file",
		Value:   "shelf.json",
		Sources: cli.EnvVars("SHELF_DATA"),
	}
}

func newApp() *cli.Command {
	return &cli.Command{
		Name:                  "shelf",
		Usage:                 "search and lend the books on your shelf",
		Version:               version,
		EnableShellCompletion: true,
		Commands: []*cli.Command{
			{
				Name:      "search",
				Aliases:   []string{"s"},
				Usage:     "list books whose title or author contains QUERY",
				ArgsUsage: "QUERY",
				Flags: []cli.Flag{
					dataFlag(),
					&cli.BoolFlag{Name: "json", Usage: "print a JSON array instead of a table"},
				},
				Action: search,
			},
			{
				Name:      "lend",
				Usage:     "lend the book with the given ID",
				ArgsUsage: "ID",
				Flags: []cli.Flag{
					dataFlag(),
					&cli.StringFlag{Name: "to", Usage: "who borrows the book", Required: true},
				},
				Action: lend,
			},
			{
				Name:  "login",
				Usage: "read a shelfd token from the terminal or from standard input",
				Action: func(ctx context.Context, cmd *cli.Command) error {
					token, err := readToken()
					if err != nil {
						return err
					}
					fmt.Fprintf(os.Stderr, "shelf: token read (%d bytes)\n", len(token))
					return nil
				},
			},
			{
				Name:  "browse",
				Usage: "browse the shelf interactively (needs a terminal)",
				Flags: []cli.Flag{dataFlag()},
				Action: func(ctx context.Context, cmd *cli.Command) error {
					books, err := load(cmd.String("data"))
					if err != nil {
						return err
					}
					titles := make([]string, len(books))
					for i, b := range books {
						titles[i] = b.Title + " - " + b.Author
					}
					return browse(ctx, titles)
				},
			},
		},
	}
}

func search(ctx context.Context, cmd *cli.Command) error {
	if cmd.NArg() != 1 {
		return &statusError{exitUsage, "usage: shelf search QUERY"}
	}
	books, err := load(cmd.String("data"))
	if err != nil {
		return err
	}
	query := strings.ToLower(cmd.Args().First())
	var found []book
	for _, b := range books {
		if strings.Contains(strings.ToLower(b.Title+" "+b.Author), query) {
			found = append(found, b)
		}
	}
	if len(found) == 0 {
		return &statusError{exitNotFound, "no book matches " + strconv.Quote(cmd.Args().First())}
	}
	if cmd.Bool("json") {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		return enc.Encode(found)
	}
	for _, b := range found {
		fmt.Printf("%3d  %-32s %s\n", b.ID, b.Title, b.Author)
	}
	return nil
}

func lend(ctx context.Context, cmd *cli.Command) error {
	id, err := strconv.Atoi(cmd.Args().First())
	if err != nil {
		return &statusError{exitUsage, "usage: shelf lend --to NAME ID"}
	}
	path := cmd.String("data")
	books, err := load(path)
	if err != nil {
		return err
	}
	for i := range books {
		if books[i].ID != id {
			continue
		}
		if books[i].LentTo != "" {
			return &statusError{exitConflict, fmt.Sprintf("book %d is already lent to %s", id, books[i].LentTo)}
		}
		if err := ctx.Err(); err != nil {
			return err // interrupted before anything was written
		}
		books[i].LentTo = cmd.String("to")
		return save(path, books)
	}
	return &statusError{exitNotFound, fmt.Sprintf("no book with ID %d", id)}
}

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	code := run(ctx, os.Args)
	stop() // os.Exit skips deferred calls, so release the signal handler explicitly
	os.Exit(code)
}

// run is the testable part of main: it returns the exit status instead of exiting.
func run(ctx context.Context, args []string) int {
	err := newApp().Run(ctx, args)
	if err == nil {
		return 0
	}
	var s interface{ Status() int }
	switch {
	case errors.As(err, &s):
		fmt.Fprintln(os.Stderr, "shelf:", err)
		return s.Status()
	case errors.Is(err, context.Canceled):
		fmt.Fprintln(os.Stderr, "shelf: interrupted")
		return 130 // the shell convention 128 + SIGINT; meaningless on Windows
	default:
		fmt.Fprintln(os.Stderr, "shelf:", err)
		return exitFailure
	}
}

What each part does, and why it is written that way:

  • Run returns an error. The urfave/cli v3 documentation states that Command.Run does not call os.Exit itself; the exit code is 0 unless the program chooses one. main therefore owns the exit decision, as it should: one os.Exit call, after cleanup. The library also supplies cli.Exit(message, code) and the cli.ExitCoder interface for an error that carries a code; the program above uses its own small statusError so that the mapping from error to status lives in one run function that a test can call.

  • Flags are declared per command. dataFlag() is called for each command that needs it, so the program does not depend on whether a flag set on a parent command is visible in a child. Sources: cli.EnvVars("SHELF_DATA") makes the environment variable a fallback; the order of precedence is then flag, environment variable, default, the same as in the Go section’s page.

  • Required: true makes the library reject shelf lend 3 without --to with a "required flag" error before the Action runs. A flag can also carry an Action that validates its value once parsed, for example to reject a port above 65535; that is the documented way to validate a single flag.

  • EnableShellCompletion: true adds a completion subcommand. shelf completion bash prints a script for bash; the documentation lists bash, zsh, fish and PowerShell. Installing the script in the right place for each shell is covered on Shells, Completions & Shell Integration.

  • Parse errors are ordinary errors. An unknown flag or a missing required flag returns an error that is not a statusError, so this program maps it to status 1. The documentation pages consulted for this page do not describe a hook for giving usage errors their own status (OnUsageError exists in the API reference but is not explained there), so if you need status 2 for every usage mistake, read the API reference for your version or use flag, where ContinueOnError plus your own mapping is explicit.

  • The signal context is the standard-library pattern. SIGINT and SIGTERM are both valid on Windows in Go, where Ctrl+C and Ctrl+Break arrive as console control events that the runtime converts for os/signal; see the Go section for the details and for exec.CommandContext when the context must stop a child process.

Try it from a shell that has a shelf.json next to it:

$ shelf search tolkien --json
$ shelf lend --to Ana 3
$ shelf lend --to Ben 3; echo "status $?"
shelf: book 3 is already lent to Ana
status 4
$ SHELF_DATA=/srv/shelf.json shelf s hobbit
$ shelf --version

The same command in Cobra

The structure maps one-to-one: a *cobra.Command per node, RunE returning an error, SilenceUsage and SilenceErrors set so that main decides what is printed, and the same run function that turns the error into a status. The complete Cobra program, its documentation generator and the notes for AI-agent callers are on the Go & Rust CLI Frameworks page; they are not repeated here. Moving a tool between urfave/cli and Cobra changes the tree declaration and the flag reads and leaves main, run, the store and the exit-status table alone, which is the reason to keep those separate from the parser.

Talking to the terminal: golang.org/x/term

The standard library can tell a program that standard input is a character device (os.ModeCharDevice), but that is also true of /dev/null. The Go project’s golang.org/x/term module is the portable answer. It is a separate module (go get golang.org/x/term), not part of the standard library, and its functions take a file descriptor as an int.

Function Use

term.IsTerminal(fd int) bool

Is this descriptor a terminal? Decides between interactive and piped behaviour

term.GetSize(fd int) (width, height int, err error)

Visible size in columns and rows, excluding scrollback

term.ReadPassword(fd int) ([]byte, error)

Reads a line without local echo; the slice excludes the newline

term.MakeRaw(fd int) (*term.State, error)

Puts the terminal in raw mode and returns the previous state

term.Restore(fd int, oldState *term.State) error

Restores a saved state

term.GetState(fd int) (*term.State, error)

Saves the current state, for restoring after a signal

term.NewTerminal(c io.ReadWriter, prompt string) *term.Terminal

A small line editor (history, completion callback, bracketed paste) on a terminal already in raw mode

Platform notes from the package documentation: the descriptor is an int, and "on non-Unix systems os.Stdin.Fd() may not be 0", so always call int(os.Stdin.Fd()) instead of writing 0. The package is documented for Windows builds as well as Linux and macOS, but its documentation does not describe the Windows console behaviour in detail; verify any Windows-specific claim of your own by running the program in Windows Terminal and in a legacy console host.

Prompt for a secret, or read it from a pipe

A command that asks for a token must work both ways: interactively without echo, and from a script. The prompt goes to stderr so that stdout stays clean for data, the same split as everywhere else in this section.

// cmd/shelf/term.go
package main

import (
	"bufio"
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"strings"
	"time"

	"golang.org/x/term"
)

// readToken reads a shelfd token. On a terminal it prompts without echo;
// otherwise it reads the first line of standard input.
func readToken() (string, error) {
	fd := int(os.Stdin.Fd())
	if term.IsTerminal(fd) {
		fmt.Fprint(os.Stderr, "shelfd token: ")
		secret, err := term.ReadPassword(fd)
		fmt.Fprintln(os.Stderr) // the user's Enter was not echoed, so end the line
		if err != nil {
			return "", err
		}
		return string(secret), nil
	}
	line, err := bufio.NewReader(os.Stdin).ReadString('\n')
	if err != nil && !(errors.Is(err, io.EOF) && line != "") {
		return "", fmt.Errorf("no token on standard input: %w", err)
	}
	return strings.TrimRight(line, "\r\n"), nil
}

// browse lets the user move through titles with j and k and leave with q.
func browse(ctx context.Context, titles []string) error {
	in, out := int(os.Stdin.Fd()), int(os.Stdout.Fd())
	if !term.IsTerminal(in) || !term.IsTerminal(out) {
		return errors.New("browse needs a terminal; use shelf search --json in scripts")
	}
	old, err := term.MakeRaw(in)
	if err != nil {
		return err
	}
	defer term.Restore(in, old) // runs because browse returns instead of calling os.Exit

	keys := make(chan byte)
	go func() {
		buf := make([]byte, 1)
		for {
			if _, err := os.Stdin.Read(buf); err != nil {
				close(keys)
				return
			}
			keys <- buf[0]
		}
	}()

	width, height, _ := term.GetSize(out)
	selected := 0
	draw := func() {
		fmt.Fprint(os.Stdout, "\x1b[H\x1b[2J") // home, then clear the screen
		fmt.Fprint(os.Stdout, "shelf browse: j and k move, q quits\r\n")
		for i, t := range titles {
			if i+3 > height {
				break // do not scroll the screen
			}
			prefix := "  "
			if i == selected {
				prefix = "> "
			}
			line := []rune(prefix + t)
			if width > 0 && len(line) > width {
				line = line[:width]
			}
			fmt.Fprintf(os.Stdout, "%s\r\n", string(line))
		}
	}
	draw()

	tick := time.NewTicker(250 * time.Millisecond)
	defer tick.Stop()
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case k, ok := <-keys:
			if !ok {
				return nil
			}
			switch k {
			case 'q', 3, 4: // q, Ctrl+C and Ctrl+D arrive as bytes in raw mode
				return nil
			case 'j':
				if selected < len(titles)-1 {
					selected++
				}
			case 'k':
				if selected > 0 {
					selected--
				}
			}
			draw()
		case <-tick.C: // poll for a resize: it works on every OS
			if w, h, err := term.GetSize(out); err == nil && (w != width || h != height) {
				width, height = w, h
				draw()
			}
		}
	}
}

Why the example looks the way it does:

  • Raw mode changes output as well as input. On a Unix terminal raw mode turns off the driver’s translation of \n into a carriage return plus a line feed, so a plain \n moves down without returning to column 0 and the text staircases. Writing \r\n is correct in both raw and cooked mode and on Windows, so use it for every line while raw mode is on. In raw mode the driver also stops turning Ctrl+C into SIGINT: the key arrives as the byte 3, and the program must decide what it means. The mechanism is explained on Terminals, TTYs & Pseudo-Terminals.

  • Restore the terminal on every exit path. A program killed while the terminal is raw leaves the user’s shell unusable until they run reset. defer term.Restore covers a normal return, a returned error and a recovered panic. It does not run after os.Exit or after a fatal signal, which is why browse returns to main and why the signal context makes SIGTERM an ordinary return. term.GetState is the call to use when a separate signal handler has to restore the terminal itself.

  • Resize is polled. POSIX systems deliver SIGWINCH when the window changes size, but Windows does not; a program that must also work there polls term.GetSize, as above, or uses the Windows console input events described on Windows Console & Processes. Polling every few hundred milliseconds is cheap and identical on every platform.

  • Escape sequences are not portable by themselves. \x1b[H\x1b[2J works on macOS and Linux terminals and in Windows Terminal. A classic Windows console host needs virtual-terminal processing switched on for the output handle with SetConsoleMode, which golang.org/x/term does not document as part of its API; see Escape Sequences & Terminal Capabilities for the sequences and for detecting support.

  • The key reader reads single bytes. Arrow keys, function keys and pasted text arrive as multi-byte escape sequences, and a UTF-8 character can span several bytes. This is enough for j, k and q; anything richer belongs in a TUI library such as Bubble Tea or in the line editors described on Interactive Prompts & Line Editing.

Errors, exit codes and processes

Everything that is not parsing is the same as in the standard-library program, so only the decisions specific to this page are listed:

Status Meaning in shelf

0

Success

1

Any other failure: unreadable file, parse error from the library, I/O error

2

Wrong usage detected by the program itself (shelf lend abc)

3

Nothing matched, or no such book

4

The book is already lent

130

Interrupted by SIGINT, by the shell convention 128 plus the signal number; Windows has no such convention

The numbers are this tool’s contract and belong in its --help and documentation; see Streams & Exit Codes for how callers use them. On Windows the status is a 32-bit value and %ERRORLEVEL% or $LASTEXITCODE shows it, so statuses above 255 are legal there but not on POSIX, where only the low eight bits survive. Starting other programs from a shelf command uses os/exec; pass the same context with exec.CommandContext so that an interrupt reaches the child, and note that a bare name such as git is resolved through PATH on every platform, with PATHEXT consulted on Windows. The Go section’s page explains exec.ErrDot, Cmd.Cancel and Cmd.WaitDelay; the long-running case is on Long-Running Processes & Services.

Building for Windows, macOS and Linux

Go compiles for another operating system and architecture by setting two environment variables for the build, and as long as no cgo code is involved no extra toolchain is needed. Both libraries used above are pure Go, so a shelf build needs only the Go compiler. CGO_ENABLED=0 makes that explicit: the build fails instead of silently pulling in a C toolchain. Setting an environment variable for one command differs by shell:

# macOS and Linux (sh, bash, zsh)
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w -X main.version=1.4.0" -o dist/shelf.exe ./cmd/shelf
CGO_ENABLED=0 GOOS=linux   GOARCH=arm64 go build -trimpath -ldflags "-s -w -X main.version=1.4.0" -o dist/shelf-linux-arm64 ./cmd/shelf
CGO_ENABLED=0 GOOS=darwin  GOARCH=arm64 go build -trimpath -ldflags "-s -w -X main.version=1.4.0" -o dist/shelf-darwin-arm64 ./cmd/shelf
# Windows PowerShell and PowerShell 7
$env:CGO_ENABLED = "0"; $env:GOOS = "linux"; $env:GOARCH = "amd64"
go build -trimpath -o dist/shelf-linux-amd64 ./cmd/shelf
Remove-Item Env:GOOS, Env:GOARCH   # go back to building for this machine
:: Windows cmd.exe
set CGO_ENABLED=0
set GOOS=linux
set GOARCH=amd64
go build -trimpath -o dist\shelf-linux-amd64 .\cmd\shelf

On Windows, PowerShell and cmd.exe keep the variables for the rest of the session, so unset them (or open a new terminal) before building for the host again. go tool dist list prints every supported pair for your toolchain; the table with the first-class ports and the operating-system requirements is on the The go Command, Builds and Cross-Compilation page. A binary built for the host gets the .exe suffix on Windows by default; a cross-compiled one gets it only if you name it so with -o. Distributing the results (archives, checksums, signing, notarization, installers) is covered on Native Binaries & Cross-Compilation and Signing, Notarization & OS Trust; a release script that runs exactly these commands is on the Go section’s page above.

Installing with go install

go install builds a main package and copies the executable into a directory that you put on PATH. With a version suffix it fetches and builds a module without needing a checkout:

$ go install example.com/bookshelf/cmd/shelf@v1.4.0
$ go install example.com/bookshelf/cmd/shelf@latest
$ go install ./cmd/shelf          # from a checkout: build the local code
$ go env GOBIN GOPATH

The destination is GOBIN when it is set, otherwise the bin directory under GOPATH, which defaults to $HOME/go/bin on macOS and Linux and %USERPROFILE%\go\bin on Windows. That directory is not on PATH by default, which is the most common reason that a freshly installed tool is "not found". Two limits to state in a project’s README: the person installing needs a Go toolchain, and the version reported by shelf --version is only correct if the build stamped it with -ldflags -X or reads it from the build information that go install module builds record; the Go section’s page shows both. For users without Go, ship the binaries from the previous section or a package, as described on Native Packages & Installers.

Testing the command line

Because run takes a context and arguments and returns a status, a test calls it directly and checks the status, and tests of the terminal helpers need a pseudo-terminal rather than a pipe, since term.IsTerminal is false for a pipe. The Go section’s page shows the table-driven run test; pseudo-terminal tests for browse and readToken are covered on Testing Terminal Apps & TUIs. Redirecting the process’s own os.Stdout in a test is possible but fragile; if the commands print a lot, pass an io.Writer into them instead of using os.Stdout directly, which is what the Writer and ErrWriter fields of the urfave/cli Command are for.

Pitfalls

  • os.Exit after a deferred term.Restore leaves the terminal raw. Return to main and exit there.

  • Writing \n in raw mode. Use \r\n.

  • Treating os.Stdin.Fd() as 0. On non-Unix systems it may not be; pass int(os.Stdin.Fd()).

  • Prompting on stdout. A prompt on stdout ends up in the file when the user redirects the output; prompt on stderr.

  • Asking for a secret when no one can answer. Check IsTerminal first and fall back to standard input or an environment variable, as readToken does; never hang waiting for a terminal in a script or CI job.

  • Passing secrets as flags. Command lines are visible to other users in the process list; read secrets from the terminal, a file or standard input.

  • Letting a parser’s default os.Exit surprise a test. If a library version exits the process by itself on some error, a test of run will exit with it. Run the program and check echo $? (echo %ERRORLEVEL% on Windows) for each status in the table before trusting your tests.

  • Forgetting that GOOS and GOARCH stay set in a PowerShell or cmd.exe session after a cross build.

  • Enabling cgo by accident. Importing a package that uses cgo (some os/user and DNS resolver paths in the standard library do when cgo is enabled) changes a static build into a dynamic one; build with CGO_ENABLED=0 and let the compiler tell you.

  • go install into a directory that is not on PATH. Add go env GOBIN or the bin directory under GOPATH to PATH.

Version notes

Component Note

Go 1.27.3

The toolchain used for every command on this page; signal.NotifyContext is available since Go 1.16

urfave/cli v3.14.0

Released 2026-10-02; the module declares go 1.22. v3 is a different module path (/v3) from v1 and v2, and documentation found for older majors does not apply

Cobra v1.10.2

Released 2025-12-03

golang.org/x/term v0.47.0

Released 2026-10-09; Terminal history support was added in v0.32.0