Session 9 · Command-line tools: args and flags#

Watch the class#

The big idea#

A command-line tool is a program you run by typing its name and some options: ls -l /tmp. Go makes these tools easy to build and ship, because the result is one file with no runtime. Your program gets the typed words from os.Args, and the flag package turns -name=value options into ready-to-use variables, with a free -h help page.

Everyday example: ordering coffee. “Latte, large, no sugar” is the command and its options. The barista (your program) must cope with a missing size or an unknown drink by asking, not by throwing the cup.

In plain words

os.Args is the raw list of words (Args[0] is the program). For named options use flag: declare each one, call flag.Parse(), then check them. Print a usage message and os.Exit(1) for bad input.

Reach for it when:

  • you are building utilities: backup, file finder, log parser

  • the same program should behave differently without editing code

  • scripts and cron jobs must be able to call your tool

What the program receives The shell splits the command into words. os.Args holds them all; flag.Parse picks the -name=value ones out. finder Args[0] -dir=src flag -ext=.go flag -verbose flag notes.txt flag.Args() $ finder -dir=src -ext=.go -verbose notes.txt flag.StringVar(&dir, "dir", "", usage) flag.BoolVar(&verbose, "verbose", …) flag.Parse() // fills the variables Validate early: empty dir? → print usage, os.Exit(1) not a directory? → clear error, exit 1
The shell splits the command into words. os.Args holds them all; flag.Parse picks the -name=value ones out.

Raw arguments#

args.go: run with no argument, then with Asha.

package main

import (
	"fmt"
	"os"
)

func main() {
	fmt.Println(len(os.Args) > 0) // Args[0] is the program itself
	if len(os.Args) < 2 {
		fmt.Println("Usage: app <name>")
		return
	}
	fmt.Println("Hello", os.Args[1])
}
true
Usage: app <name>

With the argument Asha:

true
Hello Asha

Named flags#

flags.go declares four options and a custom usage message. First with no flags, then with -dir=./src -ext=.go -verbose -limit=5 extra.

package main

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

func main() {
	var (
		dir     string
		ext     string
		verbose bool
		limit   int
	)
	flag.StringVar(&dir, "dir", "", "directory to search")
	flag.StringVar(&ext, "ext", "", "only files with this extension")
	flag.BoolVar(&verbose, "verbose", false, "print extra detail")
	flag.IntVar(&limit, "limit", 0, "max results (0 = all)")
	flag.Usage = func() {
		fmt.Println("Usage: finder -dir=<dir> [-ext=.go] [-verbose] [-limit=N]")
		flag.PrintDefaults()
	}
	flag.Parse()

	if dir == "" {
		fmt.Println("Error: -dir is required")
		flag.Usage()
		os.Exit(1)
	}
	fmt.Println("dir:", dir, "ext:", ext, "verbose:", verbose, "limit:", limit)
	fmt.Println("leftover args:", flag.Args())
}
Error: -dir is required
Usage: finder -dir=<dir> [-ext=.go] [-verbose] [-limit=N]
  -dir string
    	directory to search
  -ext string
    	only files with this extension
  -limit int
    	max results (0 = all)
  -verbose
    	print extra detail
dir: ./src ext: .go verbose: true limit: 5
leftover args: [extra]
  • flag.StringVar(&dir, "dir", "", "help text") ties the option to your variable with a default.

  • All flags must come before positional words. After the first non-flag word, parsing stops; the rest are in flag.Args().

  • -verbose alone means true for a bool flag. -limit=5 and -limit 5 both work for ints.

  • Without flag.Parse() nothing is read. Forgetting it is the classic bug.

Worked example: a file finder#

The course project combines flags with the folder walk from Session 8.

finder.go (run twice: with a valid folder, then a bad one)

package main

import (
	"flag"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
)

func main() {
	dir := flag.String("dir", "", "directory to search")
	ext := flag.String("ext", "", "extension filter, like .go")
	limit := flag.Int("limit", 0, "max results")
	flag.Parse()

	info, err := os.Stat(*dir)
	if err != nil || !info.IsDir() {
		fmt.Println("Error: not a directory:", *dir)
		os.Exit(1)
	}

	found := 0
	filepath.WalkDir(*dir, func(path string, d fs.DirEntry, err error) error {
		if err != nil || d.IsDir() {
			return err
		}
		if *ext != "" && filepath.Ext(path) != *ext {
			return nil
		}
		if *limit > 0 && found >= *limit {
			return filepath.SkipAll
		}
		fmt.Println(path)
		found++
		return nil
	})
	fmt.Println("found", found)
}
09-cli/finder_tree/a.go
09-cli/finder_tree/sub/c.go
found 2
Error: not a directory: nope

filepath.SkipAll stops the walk early once the limit is reached. The error path ends with os.Exit(1): a non-zero exit code tells scripts the tool failed.

Common mistakes#

  • Forgetting flag.Parse().

  • Putting options after the positional words. tool file.txt -v leaves -v unparsed.

  • Dereferencing at the wrong time. flag.String returns a pointer; read *name only after Parse().

  • Printing errors to stdout and exiting 0. Use fmt.Fprintln(os.Stderr, ...) and os.Exit(1).

  • Trusting input. Check that the path exists and is a directory before walking it.

Hands-on exercises#

Exercise 1 · Greeter flags. Define -name (default Guest) and -times (default 1). Print the greeting that many times. (Parse a fixed slice so the example is repeatable.)

Solution
package main

import (
	"flag"
	"fmt"
)

func main() {
	name := flag.String("name", "Guest", "who to greet")
	times := flag.Int("times", 1, "how many times")
	flag.CommandLine.Parse([]string{"-name=Asha", "-times=2"}) // normally flag.Parse()

	for i := 0; i < *times; i++ {
		fmt.Println("Hello,", *name)
	}
}
Hello, Asha
Hello, Asha

In a real tool, replace the Parse([]string{…}) line with flag.Parse().

Exercise 2 · Sum the args. Treat leftover arguments as integers and print their total, skipping anything that is not a number with a message.

Solution
package main

import (
	"fmt"
	"strconv"
)

func main() {
	args := []string{"10", "x", "5"} // normally os.Args[1:]
	total := 0
	for _, a := range args {
		n, err := strconv.Atoi(a)
		if err != nil {
			fmt.Println("skipping", a)
			continue
		}
		total += n
	}
	fmt.Println("total:", total)
}
skipping x
total: 15

Exercise 3 · Required flag. Print an error and exit with status 1 if -file is empty. Show what happens with -file=notes.txt.

Solution
package main

import (
	"flag"
	"fmt"
)

func main() {
	file := flag.String("file", "", "file to process (required)")
	flag.CommandLine.Parse([]string{"-file=notes.txt"})

	if *file == "" {
		fmt.Println("Error: -file is required")
		flag.PrintDefaults()
		return // a real tool would call os.Exit(1)
	}
	fmt.Println("processing", *file)
}
processing notes.txt