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 |
|---|---|
|
Command-Line Programs in the Go section |
Cobra (command tree, |
|
|
|
Cancellation and |
|
Errors and |
|
The Go language itself |
|
Why |
|
Terminals, raw mode, process groups |
|
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 |
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 |
|
|
Shape of a program |
|
A tree of |
A tree of |
Action signature |
Your own |
|
|
Flag syntax |
|
Aliases per flag; compound short flags such as |
POSIX / GNU style through pflag |
Environment variables |
By hand |
Built in: |
By hand, or with a companion library |
Shell completion |
None |
|
A generated |
Man pages and Markdown |
None |
The separate |
The |
Dependencies |
None |
The README states none beyond the standard library |
|
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
--helpand 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
runfunction that returns an exit status, data onstdoutand diagnostics onstderr, a context cancelled by SIGINT / SIGTERM, and tests that callrunrather thanmain. 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
}
}
Source: urfave/cli v3 — Subcommands, Flags, Value sources (environment variables), Shell completions, Exit codes and os/signal — NotifyContext
What each part does, and why it is written that way:
-
Runreturns an error. The urfave/cli v3 documentation states thatCommand.Rundoes not callos.Exititself; the exit code is0unless the program chooses one.maintherefore owns the exit decision, as it should: oneos.Exitcall, after cleanup. The library also suppliescli.Exit(message, code)and thecli.ExitCoderinterface for an error that carries a code; the program above uses its own smallstatusErrorso that the mapping from error to status lives in onerunfunction 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: truemakes the library rejectshelf lend 3without--towith a "required flag" error before theActionruns. A flag can also carry anActionthat validates its value once parsed, for example to reject a port above 65535; that is the documented way to validate a single flag. -
EnableShellCompletion: trueadds acompletionsubcommand.shelf completion bashprints 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 (OnUsageErrorexists 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 useflag, whereContinueOnErrorplus 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 forexec.CommandContextwhen 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 |
|---|---|
|
Is this descriptor a terminal? Decides between interactive and piped behaviour |
|
Visible size in columns and rows, excluding scrollback |
|
Reads a line without local echo; the slice excludes the newline |
|
Puts the terminal in raw mode and returns the previous state |
|
Restores a saved state |
|
Saves the current state, for restoring after a signal |
|
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()
}
}
}
}
Source: golang.org/x/term — IsTerminal, GetSize, ReadPassword, MakeRaw, Restore and os/signal — NotifyContext
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
\ninto a carriage return plus a line feed, so a plain\nmoves down without returning to column 0 and the text staircases. Writing\r\nis 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.Restorecovers a normal return, a returned error and a recovered panic. It does not run afteros.Exitor after a fatal signal, which is whybrowsereturns tomainand why the signal context makes SIGTERM an ordinary return.term.GetStateis 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[2Jworks on macOS and Linux terminals and in Windows Terminal. A classic Windows console host needs virtual-terminal processing switched on for the output handle withSetConsoleMode, whichgolang.org/x/termdoes 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,kandq; 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 ( |
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.Exitafter a deferredterm.Restoreleaves the terminal raw. Return tomainand exit there. -
Writing
\nin raw mode. Use\r\n. -
Treating
os.Stdin.Fd()as0. On non-Unix systems it may not be; passint(os.Stdin.Fd()). -
Prompting on
stdout. A prompt onstdoutends up in the file when the user redirects the output; prompt onstderr. -
Asking for a secret when no one can answer. Check
IsTerminalfirst and fall back to standard input or an environment variable, asreadTokendoes; 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.Exitsurprise a test. If a library version exits the process by itself on some error, a test ofrunwill exit with it. Run the program and checkecho $?(echo %ERRORLEVEL%on Windows) for each status in the table before trusting your tests. -
Forgetting that
GOOSandGOARCHstay set in a PowerShell orcmd.exesession after a cross build. -
Enabling cgo by accident. Importing a package that uses cgo (some
os/userand DNS resolver paths in the standard library do when cgo is enabled) changes a static build into a dynamic one; build withCGO_ENABLED=0and let the compiler tell you. -
go installinto a directory that is not onPATH. Addgo env GOBINor thebindirectory underGOPATHtoPATH.
Version notes
| Component | Note |
|---|---|
Go 1.27.3 |
The toolchain used for every command on this page; |
urfave/cli v3.14.0 |
Released 2026-10-02; the module declares |
Cobra v1.10.2 |
Released 2025-12-03 |
golang.org/x/term v0.47.0 |
Released 2026-10-09; |
References
-
cmd/go documentation (
go build,go install) -
Command-Line Programs and The go Command, Builds and Cross-Compilation on this site
-
Go & Rust CLI Frameworks on this site