⛺ Home

Writing CLI Applications using the standard Flag Package

January 14th, 2024

The Flag package can do more than we think

It is common practice within the Go community to reach for cobra when writing CLIs in Go. It provides a lot of functionality and is a good choice for many projects. However I believe it is overused. Part of our ethos within the Go community is to stick to the standard library as much as possible and avoid third party dependencies. There is a common misconception that the flag package is handy for parsing simple flag arguments but not for constructing complex CLIs that utilize subcommands. Let's disprove it!

Small Idiosyncrasies: A Great Design

Let's take a simple greeter program as an example. Our program takes in a flag for the language we want to output our greeting in, and an optional positional argument for the name of the person we are greeting.


package main

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

type Config struct {
	Language string
}

func main() {
	var cfg Config
	flag.StringVar(&cfg.Language, "lang", "en", "language to use for greeting")
	flag.Parse()

	name := flag.Arg(0)

	switch cfg.Language {
	case "en":
		fmt.Printf("Hello %s\n", name)
	case "fr":
		fmt.Printf("Salut %s\n", name)
	default:
		fmt.Fprintf(os.Stderr, "language %q not supported\n", cfg.Language)
		os.Exit(1)
	}
}
  

Suppose that we compiled this program to the binary greet . Let's look at the output of a couple invocations:

greet
Hello

greet -lang fr
Salut

greet -lang fr Alice
Salut Alice

greet Alice -lang fr
Hello Alice

The last result can be surprising to some that haven't seen worked with the flag package before. Flags must come before positional arguments. If they come after the first positional argument then they themselves are considered positional args. We can see this if we print out the parsed args via flag.Args()

In the latter case we would get the result:

[]string{"Alice", "-lang", "fr"}

This may seem odd. It may even be slightly annoying when working with CLIs that won't let you tag on some flags after your last positional argument.

I am sure everybody has run into the difference between:

go build -o ./exe ./cmd
and
go build ./cmd -o ./exe

However this design has a useful property: it allows us to build subcommands. The positional args of the parent command can become the flags of a subcommand. This is done by using flag.NewFlagSet .

Let us break our greet command into two subcommands: friend and stranger.


package main

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

type Config struct {
	Language string
}

func main() {
	var cfg Config
	flag.StringVar(&cfg.Language, "lang", "en", "language to use for greeting")
	flag.Parse()

	if cfg.Language != "en" && cfg.Language != "fr" {
		fmt.Fprintf(os.Stderr, "language %q not supported\n", cfg.Language)
		os.Exit(1)
	}

	cmd := flag.Arg(0)
	if cmd != "friend" && cmd != "stranger" {
		fmt.Fprintf(os.Stderr, "unknown command %q\n", cmd)
		os.Exit(1)
	}

	// We take all the args after our first positional arg to be the commandline args of our subcommand
	subCmdArgs := flag.Args()[1:]

	switch cmd {
	case "friend":
		GreetFriend(cfg, subCmdArgs)
	case "stranger":
		GreetStranger(cfg, subCmdArgs)
	}
}

// GreetFriend is our friend subcommand.
// It will parse the args given to it for a positional arg that represents the friends name.
func GreetFriend(cfg Config, args []string) {
	commandLine := flag.NewFlagSet("greet friend", flag.ExitOnError)
	commandLine.Parse(args) // We parse the args given to us to allow us find the first positionl argument

	name := commandLine.Arg(0) // get the first positional arg.
	if name == "" {
		name = "buddy"
	}

	switch cfg.Language {
	case "en":
		fmt.Printf("Hello %s\n", name)
	case "fr":
		fmt.Printf("Salut %s\n", name)
	}
}

type StrangerOptions struct {
	Config
	Informal bool
}

// GreetStranger is our stranger subcommand.
// We don't know the name of our stranger so use some generic greeting.
// It inherits the parent commands configuration and defines its own flags that we will parse.
func GreetStranger(cfg Config, args []string) {
	opts := StrangerOptions{Config: cfg}

	commandLine := flag.NewFlagSet("greet stranger", flag.ExitOnError)
	commandLine.BoolVar(&opts.Informal, "informal", false, "use a more informal greeting")
	commandLine.Parse(args)

	switch opts.Language {
	case "en":
		if opts.Informal {
			fmt.Println("Hey, what's up?")
			return
		}
		fmt.Println("Hello, how are you?")
	case "fr":
		if opts.Informal {
			fmt.Println("Salut, ça va ?")
			return
		}
		fmt.Println("Bonjour, comment ça va ?")
	}
}
  

With this approach, we are able to break down our CLI applications into multiple subcommand have different flag sets for each.

greet -lang fr friend Alice
Salut Alice

greet stranger -informal
Hey, what's up?

Conclusion

Although the flag package isn't as batteries included as notable CLI frameworks, it is versatile and flexible. You can easily describe your CLI application as simple functions and have as many nested commands as you desire with nothing more than the standard library.