Command-Line Tools

Go is a favourite for command-line tools: programs compile to a single fast binary that's easy to distribute, start instantly and run on every platform. This lesson covers reading arguments with os.Args, options with the flag package, subcommands, environment variables, exit codes, and working with standard input and output so your tool plays well with others.

Arguments: os.Args

os.Args holds the command line: os.Args[0] is the program's path and the rest are the arguments, already split on spaces (quotes are handled by the shell):

GO
package main

import (
	"fmt"
	"os"
)

func main() {
	args := os.Args[1:]
	fmt.Println(len(args), "arguments")
	for i, a := range args {
		fmt.Printf("%d: %q\n", i, a)
	}
}
$
go run . hello "big world"
Output
2 arguments
0: "hello"
1: "big world"

Options with the flag package

The flag package parses options such as -n 5 or -verbose, converts them to the right types, and generates help text. Define flags, call Parse, then use the values; flag.Args() returns whatever is left over:

GO
package main

import (
	"flag"
	"fmt"
	"strings"
	"time"
)

func main() {
	count := flag.Int("n", 1, "how many times to repeat")
	upper := flag.Bool("upper", false, "print in capitals")
	sep := flag.String("sep", " ", "separator between words")
	delay := flag.Duration("delay", 0, "pause between lines (e.g. 200ms)")
	flag.Parse()

	text := strings.Join(flag.Args(), *sep)
	if *upper {
		text = strings.ToUpper(text)
	}
	for range *count {
		fmt.Println(text)
		time.Sleep(*delay)
	}
}
$
go run . -n 3 -upper go is fun
Output
GO IS FUN
GO IS FUN
GO IS FUN

Each flag.Int, flag.Bool… returns a pointer, filled in by Parse. Flags may be written -n 3, -n=3 or --n=3, and must come before the other arguments. Running with -h prints the help, built from the descriptions; an unknown flag prints an error and the help, and exits with status 2.

Subcommands

Tools like git and go have subcommands, each with its own flags. Give each one a flag.FlagSet:

GO
package main

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

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: tasks <add|list> [options]")
		os.Exit(2)
	}

	switch os.Args[1] {
	case "add":
		fs := flag.NewFlagSet("add", flag.ExitOnError)
		priority := fs.Int("priority", 1, "priority from 1 (low) to 3 (high)")
		fs.Parse(os.Args[2:])
		fmt.Printf("added %q with priority %d\n", strings.Join(fs.Args(), " "), *priority)
	case "list":
		fs := flag.NewFlagSet("list", flag.ExitOnError)
		all := fs.Bool("all", false, "include finished tasks")
		fs.Parse(os.Args[2:])
		fmt.Println("listing tasks, all =", *all)
	default:
		fmt.Fprintf(os.Stderr, "unknown command %q\n", os.Args[1])
		os.Exit(2)
	}
}
$
go run . add -priority 2 write the report
Output
added "write the report" with priority 2

For larger tools with many subcommands, nested help and shell completion, third-party libraries such as cobra are popular, but flag covers a lot of ground.

Environment variables

os.Getenv returns a variable's value, or "" if it isn't set; os.LookupEnv also tells you whether it was set at all. Environment variables are the usual way to configure programs running in containers and cloud platforms:

GO
package main

import (
	"fmt"
	"os"
)

func envOr(key, fallback string) string {
	if v, ok := os.LookupEnv(key); ok {
		return v
	}
	return fallback
}

func main() {
	os.Setenv("APP_MODE", "production") // normally set outside the program
	fmt.Println(envOr("APP_MODE", "development"))
	fmt.Println(envOr("APP_PORT_NOT_SET", "8080"))
}
Output
production
8080

Standard input, output and exit codes

Good command-line tools follow a few conventions: read from standard input when no file is given, so they work in pipelines; write results to standard output and messages to standard error; and exit with status 0 on success and non-zero on failure, so scripts can react:

GO
package main

import (
	"bufio"
	"fmt"
	"os"
	"strings"
)

func main() {
	sc := bufio.NewScanner(os.Stdin)
	lineNo, problems := 0, 0
	for sc.Scan() {
		lineNo++
		line := sc.Text()
		if strings.Contains(line, "\t") {
			fmt.Fprintf(os.Stderr, "line %d: contains a tab\n", lineNo)
			problems++
			continue
		}
		fmt.Println(strings.TrimRight(line, " "))
	}
	if problems > 0 {
		os.Exit(1)
	}
}
Input
clean line
trailing spaces   
a	tab
Output
clean line
trailing spaces
line 3: contains a tab
exit status 1

Run it as cat notes.txt | go run . > cleaned.txt: the cleaned text goes to the file, while the warning still appears on screen because it was written to standard error. (exit status 1 is printed by go run; a built binary just exits with that status.)

Embedding files

A //go:embed directive puts files into the compiled binary — templates, default config, a web front-end — so your tool stays a single file:

TXT help.txt
tasks - a tiny task manager
Commands: add, list, done
GO
package main

import (
	_ "embed"
	"fmt"
)

//go:embed help.txt
var helpText string

func main() {
	fmt.Print(helpText)
}
Output
tasks - a tiny task manager
Commands: add, list, done

Embed into a string, a []byte, or an embed.FS for whole folders.

Building and distributing

go build produces one self-contained executable. Cross-compile for other systems by setting GOOS and GOARCH, and add -ldflags="-s -w" to make the binary smaller by leaving out debug information:

$
GOOS=windows GOARCH=amd64 go build -o tasks.exe .
GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o tasks-linux-arm64 .

Users with Go installed can also install your tool straight from its repository with go install github.com/you/tasks@latest.

Try it yourself

Write a tiny wc: count lines, words and characters on standard input, with a -l flag to print only the line count.

Try it yourself · main.go
Edit the code, copy it into main.go in a new folder and run it with go run main.go (Go 1.27 or later). How to install Go
Input
Go makes
command-line tools
easy
Output
3 5 33
Next: structured logging with log/slog.