Skip to main content
Go intermediate Lesson 24 of 25

CLI Tools in Go

Build command-line tools with the flag package, cobra CLI framework, and work with os.Args, stdin, stdout, and stderr.

The flag Package

The standard library flag package handles command-line flags:

package main

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

func main() {
    // Define flags
    host    := flag.String("host", "localhost", "server hostname")
    port    := flag.Int("port", 8080, "server port")
    verbose := flag.Bool("verbose", false, "enable verbose output")
    timeout := flag.Duration("timeout", 30*time.Second, "request timeout")

    flag.Parse() // parse os.Args

    // Remaining non-flag arguments
    args := flag.Args()

    if *verbose {
        fmt.Printf("connecting to %s:%d (timeout: %v)\n", *host, *port, *timeout)
    }

    if len(args) == 0 {
        fmt.Fprintln(os.Stderr, "error: at least one argument required")
        flag.Usage()
        os.Exit(1)
    }

    fmt.Printf("host=%s port=%d args=%v\n", *host, *port, args)
}
./mytool -host=api.example.com -port=443 -verbose file.txt
# connecting to api.example.com:443 (timeout: 30s)
# host=api.example.com port=443 args=[file.txt]

Custom flag.Usage

flag.Usage = func() {
    fmt.Fprintf(os.Stderr, "Usage: %s [options] <files...>\n\n", os.Args[0])
    fmt.Fprintln(os.Stderr, "Options:")
    flag.PrintDefaults()
    fmt.Fprintln(os.Stderr, "\nExamples:")
    fmt.Fprintln(os.Stderr, "  mytool -port 9090 file.txt")
}

os.Args — Raw Argument Access

// os.Args[0] = program name
// os.Args[1:] = arguments

if len(os.Args) < 2 {
    fmt.Fprintln(os.Stderr, "usage: greet <name>")
    os.Exit(1)
}

name := os.Args[1]
fmt.Printf("Hello, %s!\n", name)

Reading stdin/stdout/stderr

// Write to stderr (errors, logs)
fmt.Fprintln(os.Stderr, "error: something went wrong")

// Read all stdin (for pipe input)
data, err := io.ReadAll(os.Stdin)
if err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
}
fmt.Printf("read %d bytes\n", len(data))

// Read stdin line by line
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
    line := scanner.Text()
    // process line
    fmt.Println(strings.ToUpper(line))
}
if err := scanner.Err(); err != nil {
    fmt.Fprintln(os.Stderr, "reading stdin:", err)
    os.Exit(1)
}

Detect if stdin is a pipe

func stdinIsPipe() bool {
    stat, err := os.Stdin.Stat()
    if err != nil {
        return false
    }
    return (stat.Mode() & os.ModeCharDevice) == 0
}

func main() {
    if stdinIsPipe() {
        // process piped input
        data, _ := io.ReadAll(os.Stdin)
        process(data)
    } else {
        // interactive — prompt for input
        fmt.Print("Enter input: ")
        scanner := bufio.NewScanner(os.Stdin)
        scanner.Scan()
        process([]byte(scanner.Text()))
    }
}

Environment Variables

// Read
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
    dbURL = "postgres://localhost/mydb" // default
}

// Read with a helper
func getEnv(key, fallback string) string {
    if v, ok := os.LookupEnv(key); ok {
        return v
    }
    return fallback
}

// Set (for the current process)
os.Setenv("DEBUG", "true")

// All environment variables
for _, env := range os.Environ() {
    fmt.Println(env) // KEY=VALUE
}

Cobra CLI Framework

Cobra is the standard library for multi-command CLIs (used by kubectl, gh, Hugo):

go get github.com/spf13/cobra@latest

Project structure

myapp/
├── main.go
└── cmd/
    ├── root.go
    ├── serve.go
    └── migrate.go
// cmd/root.go
package cmd

import (
    "fmt"
    "os"
    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:   "myapp",
    Short: "My application",
    Long:  `A longer description of my application.`,
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func init() {
    rootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output")
}
// cmd/serve.go
package cmd

import (
    "fmt"
    "github.com/spf13/cobra"
)

var serveCmd = &cobra.Command{
    Use:   "serve",
    Short: "Start the HTTP server",
    RunE: func(cmd *cobra.Command, args []string) error {
        port, _ := cmd.Flags().GetInt("port")
        verbose, _ := cmd.Root().PersistentFlags().GetBool("verbose")

        if verbose {
            fmt.Printf("starting server on port %d\n", port)
        }

        return startServer(port)
    },
}

func init() {
    rootCmd.AddCommand(serveCmd)
    serveCmd.Flags().IntP("port", "p", 8080, "HTTP port to listen on")
}
// cmd/migrate.go
package cmd

var migrateCmd = &cobra.Command{
    Use:   "migrate [up|down]",
    Short: "Run database migrations",
    Args:  cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        direction := args[0]
        if direction != "up" && direction != "down" {
            return fmt.Errorf("direction must be 'up' or 'down', got %q", direction)
        }
        return runMigrations(direction)
    },
}

func init() {
    rootCmd.AddCommand(migrateCmd)
}
// main.go
package main

import "myapp/cmd"

func main() {
    cmd.Execute()
}
./myapp serve --port 9090 -v
./myapp migrate up
./myapp --help
./myapp serve --help

Colored Output

// Using ANSI codes directly (no dependency)
const (
    colorRed    = "\033[31m"
    colorGreen  = "\033[32m"
    colorYellow = "\033[33m"
    colorBlue   = "\033[34m"
    colorReset  = "\033[0m"
)

func success(msg string) { fmt.Printf("%s%s %s\n", colorGreen, colorReset, msg) }
func failure(msg string) { fmt.Fprintf(os.Stderr, "%s%s %s\n", colorRed, colorReset, msg) }
func info(msg string)    { fmt.Printf("%s%s %s\n", colorBlue, colorReset, msg) }

Progress Indicators

func showProgress(total int, fn func(i int)) {
    for i := 0; i < total; i++ {
        fn(i)
        pct := float64(i+1) / float64(total) * 100
        filled := int(pct / 5)
        bar := strings.Repeat("█", filled) + strings.Repeat("░", 20-filled)
        fmt.Printf("\r[%s] %.0f%%", bar, pct)
    }
    fmt.Println()
}

showProgress(50, func(i int) {
    time.Sleep(50 * time.Millisecond) // simulate work
})

Practical Example — File Line Counter

package main

import (
    "bufio"
    "flag"
    "fmt"
    "os"
)

func countLines(path string) (int, error) {
    f, err := os.Open(path)
    if err != nil {
        return 0, err
    }
    defer f.Close()

    count := 0
    scanner := bufio.NewScanner(f)
    for scanner.Scan() {
        count++
    }
    return count, scanner.Err()
}

func main() {
    total := flag.Bool("total", false, "print only the total")
    flag.Parse()

    files := flag.Args()
    if len(files) == 0 {
        // read from stdin
        scanner := bufio.NewScanner(os.Stdin)
        n := 0
        for scanner.Scan() {
            n++
        }
        fmt.Printf("%8d\n", n)
        return
    }

    grand := 0
    for _, f := range files {
        n, err := countLines(f)
        if err != nil {
            fmt.Fprintf(os.Stderr, "wc: %v\n", err)
            continue
        }
        grand += n
        if !*total {
            fmt.Printf("%8d %s\n", n, f)
        }
    }

    if *total || len(files) > 1 {
        fmt.Printf("%8d total\n", grand)
    }
}

Frequently Asked Questions

Should I use the flag package or cobra for my CLI?
Use the standard flag package for simple tools with a few flags. Use cobra when you need subcommands (like git commit, git push), nested commands, shell completion, or a more polished user experience.
How do I read from stdin in Go?
Use bufio.NewScanner(os.Stdin) to read line by line. Use io.ReadAll(os.Stdin) to read everything at once. This enables your tool to work in Unix pipelines.
How do I exit a Go program with a specific exit code?
Use os.Exit(code). Exit code 0 means success, non-zero means failure. Note that os.Exit does not run deferred functions, so call it only at the top level of main after cleanup.