Building a CLI in Go to Automate Local Tasks

Tutorial for building a terminal tool in Go: reading files, validating inputs, generating output and compiling to a distributable binary.

Cover for Building a CLI in Go to Automate Local Tasks

I used to write Bash scripts for everything. Process a CSV, rename files, validate configurations, generate reports. It worked, until it didn’t. A badly escaped space, a sed that behaves differently on macOS and Linux, a variable without quotes that explodes with weird filenames. Debugging a 200-line Bash script is an experience I wouldn’t wish on anyone.

One day I rewrote one of those scripts in Go. It took a bit longer to write, but the result was a binary that I compiled once, copied to three different machines and worked on all of them without installing anything. No Python, no Node, no system dependencies, no “works on my machine”. Just an executable.

Since then, every time a Bash script exceeds 50 lines, I rewrite it in Go. And in this article I’m going to show how to build a real CLI step by step: a tool that reads CSV files, validates data, generates a report and compiles to a binary you can distribute to any machine.


Why Go for terminal tools

There are dozens of languages for writing CLIs. Python has argparse and click. Rust has clap. Node has commander. But Go has three advantages that make it especially good for terminal tools:

Static binary with no dependencies. When you compile a Go program, you get a single executable file. You don’t need a runtime, you don’t need an interpreter, you don’t need the target machine to have Go installed. Copy the binary and it works. For internal tools you distribute to a team, this eliminates all installation friction.

Trivial cross-compilation. From your Mac you can generate binaries for Linux and Windows with two environment variables. No Docker, no virtual machines, no special CI. If your team uses a mix of operating systems, this is transformative.

Instant startup. Go programs start in milliseconds. There’s no JVM to warm up, no interpreter to initialize. For a terminal tool you run a hundred times a day, the difference is noticeable. Compare that to a Python CLI that imports pandas and takes two seconds to start to process a ten-line file.

Also, the Go standard library includes everything you need for a CLI: argument parsing, file reading, text manipulation, JSON/CSV encoding, and robust error handling. For most tools you don’t need a single external dependency.

If you’re exploring Go and looking for practical ideas, in Go projects to learn I include CLIs as one of the best entry points to the language.


Setting up the project

We start by creating a Go module. If you don’t have Go installed, in getting started with Go I cover installation and environment setup.

mkdir csv-reporter && cd csv-reporter
go mod init github.com/your-username/csv-reporter

We create the main.go file with the minimal structure:

package main

import "fmt"

func main() {
    fmt.Println("csv-reporter v0.1.0")
}

We verify that it compiles and runs:

go run main.go

This should print csv-reporter v0.1.0. If it works, you have a functional Go project. The go run command compiles and executes in a single step, ideal for development. If you want to dig deeper into what each Go subcommand does, in the go command I cover run, build, test, fmt and the rest.


Parsing arguments: flag vs os.Args

Go offers two basic ways to read terminal arguments: directly accessing os.Args or using the flag package from the standard library.

os.Args: direct access

os.Args is a slice of strings with all the arguments the program receives. The first element is always the name of the executable:

package main

import (
    "fmt"
    "os"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Fprintln(os.Stderr, "usage: csv-reporter <file.csv>")
        os.Exit(1)
    }
    fmt.Println("Processing:", os.Args[1])
}

This works for trivial CLIs with one or two positional arguments. But as soon as you need optional flags (--output, --format, --verbose), parsing os.Args manually becomes a hell of ifs and switches.

flag: the standard package

The flag package solves this problem. Define flags with type, default value and description, and it handles parsing:

package main

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

func main() {
    input := flag.String("input", "", "input CSV file (required)")
    output := flag.String("output", "report.txt", "output file")
    verbose := flag.Bool("verbose", false, "show debug information")

    flag.Parse()

    if *input == "" {
        fmt.Fprintln(os.Stderr, "error: the --input flag is required")
        flag.Usage()
        os.Exit(1)
    }

    if *verbose {
        fmt.Printf("Input: %s\n", *input)
        fmt.Printf("Output: %s\n", *output)
    }

    fmt.Printf("Processing %s -> %s\n", *input, *output)
}
go run main.go --input data.csv --output report.txt --verbose

An important detail: flag automatically generates a help message with -h or --help. You don’t need to write it yourself. Each flag appears with its name, type, default value and description.

$ go run main.go --help
Usage of csv-reporter:
  -input string
        input CSV file (required)
  -output string
        output file (default "report.txt")
  -verbose
        show debug information

Flags return pointers (*string, *bool), that’s why you use them with *input, *output, etc. If you prefer to avoid pointers, you can use the StringVar, BoolVar, etc. variants, which write directly to a variable:

var input string
flag.StringVar(&input, "input", "", "input CSV file")

For most CLIs, flag is sufficient. It’s simple, it’s in the standard library and it adds no dependencies. Only when you need subcommands like git commit or docker build is it worth bringing in something external.


Building a real tool: CSV processor

Let’s build something useful. Our CLI will read a CSV file with sales data, validate rows, calculate statistics and generate a report. The input CSV has this format:

date,product,quantity,price
2026-01-15,Widget A,10,29.99
2026-01-15,Widget B,5,49.99
2026-01-16,Widget A,8,29.99
2026-01-16,Widget C,3,99.99

Project structure

Before putting everything in main.go, let’s separate responsibilities. You don’t need a complex architecture for a CLI, but the code should be organized in a readable way:

csv-reporter/
├── main.go           # Entry point, flag parsing
├── reader.go         # CSV reading and parsing
├── validator.go      # Row validation
├── reporter.go       # Report generation
└── go.mod

In Go project structure I cover more elaborate patterns, but for a CLI this flat structure works well.

Defining the types

We start by defining the data structures in reader.go:

package main

import (
    "encoding/csv"
    "fmt"
    "io"
    "os"
    "strconv"
    "strings"
    "time"
)

type Sale struct {
    Date     time.Time
    Product  string
    Quantity int
    Price    float64
    Line     int // Line number for error messages
}

func ReadCSV(path string) ([]Sale, []string, error) {
    file, err := os.Open(path)
    if err != nil {
        return nil, nil, fmt.Errorf("opening file: %w", err)
    }
    defer file.Close()

    reader := csv.NewReader(file)
    reader.TrimLeadingSpace = true

    // Read and discard the header
    header, err := reader.Read()
    if err != nil {
        return nil, nil, fmt.Errorf("reading CSV header: %w", err)
    }
    if len(header) < 4 {
        return nil, nil, fmt.Errorf("invalid header: expected 4 columns, got %d", len(header))
    }

    var sales []Sale
    var warnings []string
    lineNum := 1 // The header is line 1

    for {
        lineNum++
        record, err := reader.Read()
        if err == io.EOF {
            break
        }
        if err != nil {
            warnings = append(warnings, fmt.Sprintf("line %d: CSV format error: %v", lineNum, err))
            continue
        }

        sale, err := parseRecord(record, lineNum)
        if err != nil {
            warnings = append(warnings, fmt.Sprintf("line %d: %v", lineNum, err))
            continue
        }
        sales = append(sales, sale)
    }

    return sales, warnings, nil
}

func parseRecord(record []string, line int) (Sale, error) {
    if len(record) < 4 {
        return Sale{}, fmt.Errorf("expected 4 fields, got %d", len(record))
    }

    date, err := time.Parse("2006-01-02", strings.TrimSpace(record[0]))
    if err != nil {
        return Sale{}, fmt.Errorf("invalid date %q: %w", record[0], err)
    }

    product := strings.TrimSpace(record[1])
    if product == "" {
        return Sale{}, fmt.Errorf("product cannot be empty")
    }

    quantity, err := strconv.Atoi(strings.TrimSpace(record[2]))
    if err != nil {
        return Sale{}, fmt.Errorf("invalid quantity %q: %w", record[2], err)
    }
    if quantity <= 0 {
        return Sale{}, fmt.Errorf("quantity must be positive: %d", quantity)
    }

    price, err := strconv.ParseFloat(strings.TrimSpace(record[3]), 64)
    if err != nil {
        return Sale{}, fmt.Errorf("invalid price %q: %w", record[3], err)
    }
    if price < 0 {
        return Sale{}, fmt.Errorf("price cannot be negative: %.2f", price)
    }

    return Sale{
        Date:     date,
        Product:  product,
        Quantity: quantity,
        Price:    price,
        Line:     line,
    }, nil
}

There are several important decisions here. First: the ReadCSV function returns both the valid data and a list of warnings. It doesn’t fail on the first bad row. In a terminal tool, it’s much more useful to process everything you can and report problems at the end. The user doesn’t want to run the tool ten times to discover ten errors one by one.

Second: each error includes the line number. When a CSV has 10,000 rows and three are wrong, knowing the problem is on line 4,287 saves you half an hour of searching.

Third: we use fmt.Errorf with %w to wrap errors. If you want to review why this matters, in errors in Go I cover it in detail.


Data validation

In validator.go we add business rules that go beyond format:

package main

import (
    "fmt"
    "time"
)

type ValidationResult struct {
    Valid    []Sale
    Invalid []string
}

func Validate(sales []Sale) ValidationResult {
    var result ValidationResult
    now := time.Now()

    for _, s := range sales {
        if s.Date.After(now) {
            result.Invalid = append(result.Invalid,
                fmt.Sprintf("line %d: future date %s", s.Line, s.Date.Format("2006-01-02")))
            continue
        }

        if s.Price > 10000 {
            result.Invalid = append(result.Invalid,
                fmt.Sprintf("line %d: suspiciously high price %.2f for %s",
                    s.Line, s.Price, s.Product))
            continue
        }

        if s.Quantity > 1000 {
            result.Invalid = append(result.Invalid,
                fmt.Sprintf("line %d: suspiciously high quantity %d for %s",
                    s.Line, s.Quantity, s.Product))
            continue
        }

        result.Valid = append(result.Valid, s)
    }

    return result
}

The separation between parsing and validation is not accidental. The reader ensures that the data has the correct format. The validator ensures that the data makes sense. They are two different things and will change for different reasons. If tomorrow you need to accept a different date format, you touch the reader. If you need a different price threshold, you touch the validator.


Generating the report

In reporter.go we calculate statistics and generate the output:

package main

import (
    "fmt"
    "io"
    "sort"
    "strings"
    "text/tabwriter"
)

type ProductStats struct {
    Product       string
    TotalQuantity int
    TotalRevenue  float64
    AvgPrice      float64
    NumSales      int
}

func GenerateReport(w io.Writer, sales []Sale) {
    if len(sales) == 0 {
        fmt.Fprintln(w, "No valid data to generate the report.")
        return
    }

    // Group by product
    statsMap := make(map[string]*ProductStats)
    var totalRevenue float64
    var totalQuantity int

    for _, s := range sales {
        stats, ok := statsMap[s.Product]
        if !ok {
            stats = &ProductStats{Product: s.Product}
            statsMap[s.Product] = stats
        }
        revenue := float64(s.Quantity) * s.Price
        stats.TotalQuantity += s.Quantity
        stats.TotalRevenue += revenue
        stats.NumSales++
        totalRevenue += revenue
        totalQuantity += s.Quantity
    }

    // Calculate average price and sort
    var statsList []ProductStats
    for _, stats := range statsMap {
        stats.AvgPrice = stats.TotalRevenue / float64(stats.TotalQuantity)
        statsList = append(statsList, *stats)
    }
    sort.Slice(statsList, func(i, j int) bool {
        return statsList[i].TotalRevenue > statsList[j].TotalRevenue
    })

    // Write report
    fmt.Fprintln(w, strings.Repeat("=", 60))
    fmt.Fprintln(w, "SALES REPORT")
    fmt.Fprintln(w, strings.Repeat("=", 60))
    fmt.Fprintf(w, "Records processed: %d\n", len(sales))
    fmt.Fprintf(w, "Unique products:   %d\n", len(statsList))
    fmt.Fprintf(w, "Total quantity:    %d\n", totalQuantity)
    fmt.Fprintf(w, "Total revenue:     %.2f EUR\n\n", totalRevenue)

    // Product table
    tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
    fmt.Fprintln(tw, "PRODUCT\tSALES\tQUANTITY\tAVG PRICE\tREVENUE")
    fmt.Fprintln(tw, "-------\t-----\t--------\t---------\t-------")
    for _, s := range statsList {
        fmt.Fprintf(tw, "%s\t%d\t%d\t%.2f EUR\t%.2f EUR\n",
            s.Product, s.NumSales, s.TotalQuantity, s.AvgPrice, s.TotalRevenue)
    }
    tw.Flush()
}

Notice that GenerateReport receives an io.Writer, not a filename. This is a fundamental design decision in Go: write against interfaces, not concrete implementations. If you pass it os.Stdout, it prints to the terminal. If you pass it a file, it writes to the file. If you pass it a bytes.Buffer, you can test it without touching the filesystem. It’s the same function for all three cases.

tabwriter is a standard library package that automatically aligns columns using tabs. It produces clean output in the terminal without needing external libraries.


Connecting everything in main.go

Now we join the modules in main.go:

package main

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

const version = "0.1.0"

func main() {
    input := flag.String("input", "", "input CSV file (required)")
    output := flag.String("output", "", "output file (default: stdout)")
    showVersion := flag.Bool("version", false, "show version")
    verbose := flag.Bool("verbose", false, "show debug information")

    flag.Parse()

    if *showVersion {
        fmt.Printf("csv-reporter v%s\n", version)
        os.Exit(0)
    }

    if *input == "" {
        fmt.Fprintln(os.Stderr, "error: the --input flag is required")
        fmt.Fprintln(os.Stderr, "")
        fmt.Fprintln(os.Stderr, "Usage: csv-reporter --input <file.csv> [--output <file.txt>]")
        os.Exit(1)
    }

    // Read CSV
    sales, warnings, err := ReadCSV(*input)
    if err != nil {
        fmt.Fprintf(os.Stderr, "error: %v\n", err)
        os.Exit(1)
    }

    if *verbose {
        fmt.Fprintf(os.Stderr, "Rows read: %d\n", len(sales))
        fmt.Fprintf(os.Stderr, "Warnings: %d\n", len(warnings))
    }

    // Show warnings
    for _, w := range warnings {
        fmt.Fprintf(os.Stderr, "warning: %s\n", w)
    }

    // Validate
    result := Validate(sales)

    if *verbose {
        fmt.Fprintf(os.Stderr, "Valid rows: %d\n", len(result.Valid))
        fmt.Fprintf(os.Stderr, "Invalid rows: %d\n", len(result.Invalid))
    }

    for _, inv := range result.Invalid {
        fmt.Fprintf(os.Stderr, "validation: %s\n", inv)
    }

    // Generate report
    var writer *os.File
    if *output != "" {
        writer, err = os.Create(*output)
        if err != nil {
            fmt.Fprintf(os.Stderr, "error creating output file: %v\n", err)
            os.Exit(1)
        }
        defer writer.Close()
    } else {
        writer = os.Stdout
    }

    GenerateReport(writer, result.Valid)

    if *output != "" {
        fmt.Fprintf(os.Stderr, "Report generated in %s\n", *output)
    }
}

Notice that all status messages (warnings, errors, debug information) go to os.Stderr, and only the report goes to os.Stdout or the output file. This is an important convention in terminal tools: stdout is for data, stderr is for messages. If someone pipes your tool (csv-reporter --input data.csv | less), warnings don’t contaminate the output.

You can test everything with:

go run . --input data.csv --verbose
go run . --input data.csv --output report.txt

Adding subcommands with Cobra

The flag package is sufficient for simple CLIs with a single function. But if your tool grows and needs subcommands (think git commit, docker build, kubectl get), you need something more structured.

Cobra is the de facto library for CLIs in Go. It’s used by Kubernetes, Hugo, GitHub CLI and dozens of relevant projects.

go get github.com/spf13/cobra@latest

The core idea of Cobra is simple: each subcommand is a cobra.Command with its own set of flags and its Run function:

package main

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:     "csv-reporter",
    Short:   "Tool for generating reports from CSV files",
    Version: "0.1.0",
}

var reportCmd = &cobra.Command{
    Use:   "report",
    Short: "Generate a sales report",
    RunE: func(cmd *cobra.Command, args []string) error {
        input, _ := cmd.Flags().GetString("input")
        output, _ := cmd.Flags().GetString("output")

        sales, warnings, err := ReadCSV(input)
        if err != nil {
            return err
        }

        for _, w := range warnings {
            fmt.Fprintf(os.Stderr, "warning: %s\n", w)
        }

        result := Validate(sales)
        for _, inv := range result.Invalid {
            fmt.Fprintf(os.Stderr, "validation: %s\n", inv)
        }

        writer := os.Stdout
        if output != "" {
            f, err := os.Create(output)
            if err != nil {
                return fmt.Errorf("creating output file: %w", err)
            }
            defer f.Close()
            writer = f
        }

        GenerateReport(writer, result.Valid)
        return nil
    },
}

var validateCmd = &cobra.Command{
    Use:   "validate",
    Short: "Validate a CSV file without generating a report",
    RunE: func(cmd *cobra.Command, args []string) error {
        input, _ := cmd.Flags().GetString("input")

        sales, warnings, err := ReadCSV(input)
        if err != nil {
            return err
        }

        for _, w := range warnings {
            fmt.Fprintf(os.Stderr, "warning: %s\n", w)
        }

        result := Validate(sales)
        if len(result.Invalid) == 0 && len(warnings) == 0 {
            fmt.Println("All rows are valid.")
        } else {
            for _, inv := range result.Invalid {
                fmt.Println(inv)
            }
        }
        fmt.Printf("\nSummary: %d valid, %d invalid, %d warnings\n",
            len(result.Valid), len(result.Invalid), len(warnings))
        return nil
    },
}

func init() {
    reportCmd.Flags().StringP("input", "i", "", "input CSV file")
    reportCmd.Flags().StringP("output", "o", "", "output file")
    reportCmd.MarkFlagRequired("input")

    validateCmd.Flags().StringP("input", "i", "", "input CSV file")
    validateCmd.MarkFlagRequired("input")

    rootCmd.AddCommand(reportCmd)
    rootCmd.AddCommand(validateCmd)
}

func main() {
    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}
csv-reporter report --input data.csv --output report.txt
csv-reporter validate --input data.csv
csv-reporter --help

Cobra generates help automatically, supports autocomplete for Bash/Zsh/Fish, and handles exit codes. The price is an external dependency. For an internal team tool, it’s worth it. For a personal script that only you use, stick with flag.

I won’t go further with Cobra here. The important thing is that you know it exists and when it makes sense to use it. For the flag version we built in the previous sections, you don’t need it.


Reading from stdin and from files

A well-designed CLI should be able to read both from a file and from stdin. This allows using pipes:

cat data.csv | csv-reporter --input -
curl -s https://example.com/data.csv | csv-reporter --input -

The universal convention is to use - as the filename to indicate stdin. Implementing it is trivial:

func OpenInput(path string) (io.ReadCloser, error) {
    if path == "-" {
        return os.Stdin, nil
    }
    file, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("opening %s: %w", path, err)
    }
    return file, nil
}

We return io.ReadCloser because both os.File and os.Stdin implement that interface. The code that calls OpenInput doesn’t need to know if it’s reading from a file or from standard input.

To read line by line from stdin (useful for tools that process streams):

func ProcessStdin() error {
    scanner := bufio.NewScanner(os.Stdin)
    lineNum := 0

    for scanner.Scan() {
        lineNum++
        line := scanner.Text()
        // Process each line
        fmt.Printf("[%d] %s\n", lineNum, line)
    }

    if err := scanner.Err(); err != nil {
        return fmt.Errorf("reading stdin: %w", err)
    }
    return nil
}

One detail: bufio.Scanner has a default limit of 64KB per line. If you process files with very long lines (JSON on a single line, for example), you need to increase it:

scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB per line

Color output and progress indicators

A CLI doesn’t need to be pretty, but a bit of color helps distinguish errors from normal information. The simplest way is to use ANSI codes directly:

const (
    colorReset  = "\033[0m"
    colorRed    = "\033[31m"
    colorGreen  = "\033[32m"
    colorYellow = "\033[33m"
    colorCyan   = "\033[36m"
)

func printError(msg string) {
    fmt.Fprintf(os.Stderr, "%serror:%s %s\n", colorRed, colorReset, msg)
}

func printWarning(msg string) {
    fmt.Fprintf(os.Stderr, "%swarning:%s %s\n", colorYellow, colorReset, msg)
}

func printSuccess(msg string) {
    fmt.Fprintf(os.Stderr, "%s%s%s\n", colorGreen, msg, colorReset)
}

func printInfo(msg string) {
    fmt.Fprintf(os.Stderr, "%s%s%s\n", colorCyan, msg, colorReset)
}

This works in most modern terminals (macOS Terminal, iTerm2, Linux terminals, Windows Terminal). But there’s an important nuance: if your tool’s output is redirected to a file or to a pipe, ANSI codes contaminate the output. The solution is to detect if stderr is a terminal:

import "golang.org/x/term"

func useColor() bool {
    return term.IsTerminal(int(os.Stderr.Fd()))
}

For progress indicators when processing large files, a simple spinner is enough:

func showProgress(current, total int) {
    percent := float64(current) / float64(total) * 100
    fmt.Fprintf(os.Stderr, "\rprocessing: %d/%d (%.0f%%)", current, total, percent)
    if current == total {
        fmt.Fprintln(os.Stderr)
    }
}

The \r (carriage return) moves the cursor to the beginning of the line without advancing to the next, which overwrites the previous text. Simple and effective.


Error handling in CLIs: exit codes and clear messages

Error handling in a CLI has different requirements than a web server or a library. In a CLI, the user is a human sitting in front of a terminal. Error messages must be understandable, and exit codes must be correct so they work in scripts.

Exit codes

The standard convention is:

  • 0: successful execution
  • 1: generic error
  • 2: incorrect usage (invalid arguments)

Some tools define additional codes (for example, grep uses 1 for “not found” and 2 for errors). For most CLIs, 0 and 1 are sufficient, plus 2 for argument errors.

const (
    exitOK       = 0
    exitError    = 1
    exitBadUsage = 2
)

func main() {
    if err := run(); err != nil {
        var usageErr *UsageError
        if errors.As(err, &usageErr) {
            fmt.Fprintf(os.Stderr, "error: %v\n", err)
            flag.Usage()
            os.Exit(exitBadUsage)
        }
        fmt.Fprintf(os.Stderr, "error: %v\n", err)
        os.Exit(exitError)
    }
}

type UsageError struct {
    Msg string
}

func (e *UsageError) Error() string {
    return e.Msg
}

An important pattern: delegate all logic to a run() function that returns error, and let main() only handle printing the error and calling os.Exit. This has two advantages. First, you can test run() without the test calling os.Exit (which would kill the test process). Second, defer doesn’t execute after os.Exit, so the fewer os.Exit calls scattered through your code, the less risk of unclosed resources.

Readable error messages

An error like open config.yaml: no such file or directory is technically correct but doesn’t help the user. Better:

fmt.Fprintf(os.Stderr, "error: cannot open %q: does the file exist?\n", path)

Some rules for error messages in CLIs:

  • Start with lowercase (Go convention and many Unix tools)
  • Don’t end with a period
  • Include the specific data that failed (filename, line number, invalid value)
  • If it’s a user error, suggest the solution
// Bad
fmt.Fprintln(os.Stderr, "Error occurred")

// Good
fmt.Fprintf(os.Stderr, "error: file %q does not exist\n", path)

// Better
fmt.Fprintf(os.Stderr, "error: file %q does not exist; check the path or use --input to specify another\n", path)

Cross-compilation: generating binaries for Linux, macOS and Windows

One of the reasons Go is ideal for CLIs is that you can compile for any platform from any machine. You only need two environment variables: GOOS and GOARCH.

# Linux AMD64 (servers, Docker containers, WSL)
GOOS=linux GOARCH=amd64 go build -o csv-reporter-linux-amd64 .

# macOS Apple Silicon
GOOS=darwin GOARCH=arm64 go build -o csv-reporter-darwin-arm64 .

# macOS Intel
GOOS=darwin GOARCH=amd64 go build -o csv-reporter-darwin-amd64 .

# Windows
GOOS=windows GOARCH=amd64 go build -o csv-reporter-windows-amd64.exe .

If you want to know which environment variables affect compilation, in Go environment variables I cover GOOS, GOARCH and the rest.

To automate building for all platforms, a simple script:

#!/bin/bash
APP_NAME="csv-reporter"
VERSION="0.1.0"
OUTPUT_DIR="dist"

mkdir -p "$OUTPUT_DIR"

platforms=(
    "linux/amd64"
    "linux/arm64"
    "darwin/amd64"
    "darwin/arm64"
    "windows/amd64"
)

for platform in "${platforms[@]}"; do
    GOOS="${platform%/*}"
    GOARCH="${platform#*/}"
    output="${OUTPUT_DIR}/${APP_NAME}-${VERSION}-${GOOS}-${GOARCH}"
    if [ "$GOOS" = "windows" ]; then
        output="${output}.exe"
    fi
    echo "Building $GOOS/$GOARCH..."
    GOOS=$GOOS GOARCH=$GOARCH go build -o "$output" .
done

echo "Binaries generated in $OUTPUT_DIR/"
ls -lh "$OUTPUT_DIR/"

Embedding version information in the binary

It’s good practice for the binary to know its own version. Go allows injecting values at compile time with -ldflags:

// In main.go
var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)
go build -ldflags "-X main.version=0.1.0 -X main.commit=$(git rev-parse --short HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o csv-reporter .

Now csv-reporter --version shows the exact version, the commit and the build date. When someone reports a bug, you know exactly which version of the code they’re running.


Distribution with GoReleaser

If your tool is more than a personal script and you need to distribute it (to teammates, to a public repository, as part of a pipeline), GoReleaser automates the entire process of building, packaging and publishing.

GoReleaser reads a .goreleaser.yaml file in the root of your project and generates binaries for multiple platforms, creates .tar.gz and .zip archives, generates checksums and publishes to GitHub Releases.

# .goreleaser.yaml
version: 2
builds:
  - env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64
    ldflags:
      - -s -w
      - -X main.version={{.Version}}
      - -X main.commit={{.Commit}}
      - -X main.date={{.Date}}

archives:
  - format: tar.gz
    name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
    format_overrides:
      - goos: windows
        format: zip

checksum:
  name_template: "checksums.txt"

changelog:
  sort: asc
  filters:
    exclude:
      - "^docs:"
      - "^test:"

To generate a local release (without publishing to GitHub):

goreleaser release --snapshot --clean

To publish to GitHub Releases when you tag:

git tag v0.1.0
git push origin v0.1.0
goreleaser release

This generates the binaries, uploads them as release assets, and creates an automatic changelog based on commits between the previous tag and the current one. For team tools, this turns distribution from “send a Slack with the attached binary” to “download it from the releases page”.

The -s -w flags in ldflags strip the symbol table and debug information from the binary. The result is a smaller executable (typically 30% less) without affecting functionality. For terminal tools you distribute, it’s worth it.


Complete code and summary

Here is the complete version of main.go with flag (without Cobra), incorporating everything covered in the article:

package main

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

var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)

const (
    exitOK       = 0
    exitError    = 1
    exitBadUsage = 2
)

type UsageError struct {
    Msg string
}

func (e *UsageError) Error() string {
    return e.Msg
}

func run() error {
    input := flag.String("input", "", "input CSV file (required)")
    output := flag.String("output", "", "output file (default: stdout)")
    showVersion := flag.Bool("version", false, "show version and exit")
    verbose := flag.Bool("verbose", false, "show debug information")

    flag.Parse()

    if *showVersion {
        fmt.Printf("csv-reporter %s (commit: %s, date: %s)\n", version, commit, date)
        return nil
    }

    if *input == "" {
        return &UsageError{Msg: "the --input flag is required"}
    }

    // Read CSV
    reader, err := OpenInput(*input)
    if err != nil {
        return err
    }
    defer reader.Close()

    sales, warnings, err := ReadCSVFromReader(reader)
    if err != nil {
        return fmt.Errorf("reading CSV: %w", err)
    }

    if *verbose {
        fmt.Fprintf(os.Stderr, "rows read: %d, warnings: %d\n", len(sales), len(warnings))
    }

    for _, w := range warnings {
        printWarning(w)
    }

    // Validate
    result := Validate(sales)

    if *verbose {
        fmt.Fprintf(os.Stderr, "valid: %d, invalid: %d\n",
            len(result.Valid), len(result.Invalid))
    }

    for _, inv := range result.Invalid {
        printWarning(inv)
    }

    // Generate report
    var writer *os.File
    if *output != "" {
        writer, err = os.Create(*output)
        if err != nil {
            return fmt.Errorf("creating output file: %w", err)
        }
        defer writer.Close()
    } else {
        writer = os.Stdout
    }

    GenerateReport(writer, result.Valid)

    if *output != "" {
        printSuccess(fmt.Sprintf("report generated in %s", *output))
    }

    return nil
}

func main() {
    if err := run(); err != nil {
        var usageErr *UsageError
        if errors.As(err, &usageErr) {
            printError(err.Error())
            fmt.Fprintln(os.Stderr)
            flag.Usage()
            os.Exit(exitBadUsage)
        }
        printError(err.Error())
        os.Exit(exitError)
    }
}

What we covered

  • Argument parsing with flag (and an introduction to Cobra for subcommands).
  • CSV reading with the standard library, with row-by-row error handling.
  • Validation separated from parsing, with explicit business rules.
  • Report generation with tabwriter, writing against io.Writer for flexibility.
  • Reading from stdin and files with the - convention.
  • Color output using ANSI codes and terminal detection.
  • Exit codes correct for integration with scripts.
  • Cross-compilation for Linux, macOS and Windows.
  • Distribution with GoReleaser.

Next steps

What’s missing from this CLI is a good set of tests. Go has a built-in testing framework that doesn’t need external libraries. You can test each component (reader, validator, report generator) in isolation thanks to using interfaces (io.Writer, io.ReadCloser) instead of concrete files.

Go shines for terminal tools for the same reason it shines for backend services: it gives you a simple binary that works, without surprises, without hidden dependencies, without “install this first”. For a developer who needs to automate tasks and distribute tools to a team, that’s worth more than any framework.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved