Build a Command-Line Tool in Go

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Go 1.24

Go produces one static binary with no runtime to install, which makes it a good fit for command-line tools. This chapter builds textstat from an empty directory: it counts lines, words and the longest line, from files or stdin.

What it should do

textstat README.md
textstat -json README.md docs/*.md
cat main.go | textstat
textstat -version

Requirements:

  • no arguments means read stdin;
  • -json emits machine-readable output;
  • a missing file goes to stderr with exit code 1;
  • bad flag usage exits with 2.

Layout

mkdir textstat && cd textstat
go mod init example.com/textstat
textstat/
  go.mod
  main.go
  stats/stats.go
  stats/stats_test.go

Counting lives in stats; main only handles flags and output. That split is what makes the logic testable without spawning a process.

The counting core

// stats/stats.go
package stats

import (
    "bufio"
    "io"
    "strings"
    "unicode"
    "unicode/utf8"
)

type Result struct {
    Lines   int `json:"lines"`
    Words   int `json:"words"`
    Longest int `json:"longest_line"`
    Bytes   int `json:"bytes"`
}

// Count reads r line by line and reports lines, words and the longest line in runes.
func Count(r io.Reader) (Result, error) {
    scanner := bufio.NewScanner(r)
    scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)

    var res Result
    for scanner.Scan() {
        line := scanner.Text()
        res.Lines++
        res.Bytes += len(line) + 1
        res.Words += len(splitWords(line))
        if n := utf8.RuneCountInString(line); n > res.Longest {
            res.Longest = n
        }
    }

    if err := scanner.Err(); err != nil {
        return res, err
    }
    return res, nil
}

func splitWords(line string) []string {
    return strings.FieldsFunc(line, unicode.IsSpace)
}

Three points that bite in real use:

  • bufio.Scanner caps a line at 64 KB by default and returns bufio.ErrTooLong. Minified JSON and log lines exceed that routinely, so raise the buffer — and still check scanner.Err(), because “end of input” and “line too long” look identical otherwise.
  • len(line) counts bytes; utf8.RuneCountInString counts characters. For an accented or CJK line these differ, and the “longest” answer is wrong if you pick the byte length.
  • If you only need whitespace splitting, strings.Fields is enough. FieldsFunc is here so the separator set can grow (punctuation, for instance).

Flags

// main.go
package main

import (
    "encoding/json"
    "errors"
    "flag"
    "fmt"
    "io"
    "os"

    "example.com/textstat/stats"
)

// version is injected at build time: -ldflags "-X main.version=1.2.0"
var version = "dev"

func main() {
    os.Exit(run(os.Args[1:]))
}

func run(args []string) int {
    fs := flag.NewFlagSet("textstat", flag.ContinueOnError)
    fs.SetOutput(os.Stderr)

    jsonOut := fs.Bool("json", false, "emit JSON")
    showVersion := fs.Bool("version", false, "print version")

    fs.Usage = func() {
        fmt.Fprintf(fs.Output(), "usage: textstat [flags] [file...]\n\nflags:\n")
        fs.PrintDefaults()
    }

    if err := fs.Parse(args); err != nil {
        if errors.Is(err, flag.ErrHelp) {
            return 0
        }
        return 2
    }

    if *showVersion {
        fmt.Printf("textstat %s\n", version)
        return 0
    }

    files := fs.Args()
    if len(files) == 0 {
        res, err := stats.Count(os.Stdin)
        if err != nil {
            fmt.Fprintln(os.Stderr, "reading stdin:", err)
            return 1
        }
        return emit(os.Stdout, "stdin", res, *jsonOut)
    }

    exitCode := 0
    for _, name := range files {
        res, err := countFile(name)
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            exitCode = 1
            continue
        }
        if emit(os.Stdout, name, res, *jsonOut) != 0 {
            exitCode = 1
        }
    }
    return exitCode
}

func countFile(name string) (stats.Result, error) {
    f, err := os.Open(name)
    if err != nil {
        return stats.Result{}, err
    }
    defer f.Close() // scoped to this call, so no descriptor pile-up in the loop

    res, err := stats.Count(f)
    if err != nil {
        return stats.Result{}, fmt.Errorf("%s: %w", name, err)
    }
    return res, nil
}

Why run(args []string) int instead of work inside main:

  • the exit code becomes return value, so tests assert on it;
  • nothing calls os.Exit deep inside, so defer still runs;
  • flag.ContinueOnError keeps parsing errors under your control, rather than having the package call os.Exit(2) for you.

One file failing does not abort the rest; the loop records the failure and the final exit code reflects it.

Output

func emit(w io.Writer, name string, res stats.Result, asJSON bool) int {
    if asJSON {
        payload := struct {
            File string `json:"file"`
            stats.Result
        }{name, res}

        if err := json.NewEncoder(w).Encode(payload); err != nil {
            fmt.Fprintln(os.Stderr, "writing json:", err)
            return 1
        }
        return 0
    }

    fmt.Fprintf(w, "%s: %d lines, %d words, longest %d chars\n", name, res.Lines, res.Words, res.Longest)
    return 0
}

json.NewEncoder writes straight to the stream and appends the newline itself — no intermediate []byte. Results go to stdout, diagnostics only to stderr, so textstat -json a.md | jq . stays parseable.

Version injection

go build -ldflags "-X main.version=1.2.0" -o textstat .

-X only sets string variables and needs the full import path; for the root package the short form main.version works. A build script:

VERSION=$(git describe --tags --always --dirty)
go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o bin/textstat .

-s -w drops the symbol table and DWARF data; -trimpath removes local paths so the build is reproducible. If you would rather not inject anything, debug.ReadBuildInfo() exposes the module version and VCS revision that go build embeds when run inside a git checkout — but dev is the honest answer for a plain go build.

Testing

package stats

import (
    "strings"
    "testing"
)

func TestCount(t *testing.T) {
    input := "hello world\nsecond line\n\n"

    got, err := Count(strings.NewReader(input))
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if got.Lines != 3 {
        t.Errorf("lines = %d, want 3", got.Lines)
    }
    if got.Words != 3 {
        t.Errorf("words = %d, want 3", got.Words)
    }
}
go test ./...

Count takes an io.Reader, so tests pass strings.NewReader and production passes *os.File. No temp files, no fixture directory.

Subcommands

Once a tool grows, subcommands are usually next. The standard library has none, so start with the obvious dispatch:

if len(os.Args) < 2 {
    return run(os.Args[1:])
}

switch os.Args[1] {
case "count":
    return runCount(os.Args[2:])
case "version":
    fmt.Println(version)
    return 0
default:
    fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1])
    return 2
}

Reach for spf13/cobra when you actually need shell completion, nested command trees and grouped help. Adding it up front costs a dependency and a layer of abstraction for a tool that might only ever need two commands.

Common mistakes

1. Handling -h badly

flag.Parse returns flag.ErrHelp for -h. Treating every parse error as code 2 makes a help request look like a failure; return 0 for ErrHelp.

2. Assuming flags can come after arguments

The standard flag package stops at the first non-flag argument, so in textstat README.md -json the -json becomes a filename. Document “flags first”, or choose a parser that interleaves.

3. Leaking file descriptors

os.Open in a loop without Close eventually hits the descriptor limit, and only under load. Scope the open/close in a helper as above.

4. Printing errors without a non-zero exit

textstat a.md b.md | wc -l cannot tell you anything failed unless the exit code says so. Set it.

5. Mixing logs into JSON output

One stray fmt.Println and jq fails. Structured results on stdout, one JSON object per line, everything else on stderr.

6. Ignoring scanner.Err()

A truncated final line or an oversized line is an error, not a clean end of file.

7. Shelling out with hand-built strings

Paths with spaces or non-ASCII characters break. Let the shell expand globs, or use filepath.Glob and pass []string around.

Summary

  • main handles flags and output; logic lives in its own package behind io.Reader.
  • run(args) int plus os.Exit(run(...)) makes exit codes testable.
  • Per-file errors do not stop the batch; the exit code summarises them.
  • stdout carries results, stderr carries diagnostics; json.Encoder writes NDJSON efficiently.
  • Inject the version with -ldflags -X and build with -trimpath.

Next: the same discipline applied to an HTTP service.