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.

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-reporterWe 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.goThis 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 --verboseAn 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 informationFlags 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.99Project 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.modIn 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.txtAdding 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@latestThe 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 --helpCobra 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 lineColor 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 --cleanTo publish to GitHub Releases when you tag:
git tag v0.1.0
git push origin v0.1.0
goreleaser releaseThis 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 againstio.Writerfor 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.


