How to Use urfave/cli for Building CLIs in Go

Cli
Build Go CLIs quickly by defining commands and flags in a cli.App struct and running it with app.Run(os.Args).

When os.Args becomes a maze

You wrote a Go script that processes files. It works great until you need to add a --verbose flag, then a --output flag, then a convert subcommand. Suddenly your main function is a tangled mess of flag.Parse() calls and string splitting. You find yourself writing helper functions to check os.Args length, handling index out of range panics, and manually formatting help text that goes out of date the moment you add a new flag.

You need a structure that handles arguments, help text, and errors without you writing the parser from scratch. urfave/cli gives you that structure. It turns a list of strings into a typed, navigable command tree. You define the shape of your interface, and the library handles the rest.

The menu system analogy

Think of a CLI library like a restaurant menu system. You don't want customers shouting random orders at the kitchen. You want a menu with categories, items, and modifiers. urfave/cli is the menu. You define the categories (commands), the items (actions), and the modifiers (flags).

The library handles the customer interaction. It shows the menu, checks if the order makes sense, and passes the clean ticket to the kitchen. When a user runs your tool, the library acts as the waiter. It reads the arguments, validates them against your definitions, and hands the parsed data to your code. If the user asks for help, the waiter reads the menu aloud. If the user orders something impossible, the waiter tells them what went wrong. Your code only sees the valid order.

Minimal example

Here's the smallest app that runs. It defines one command, one flag, and prints a greeting.

package main

import (
	"fmt"
	"os"

	"github.com/urfave/cli/v2"
)

func main() {
	// App struct holds the root configuration. Name and Usage appear in help text.
	app := &cli.App{
		Name:  "greet",
		Usage: "Say hello to someone",
		// Action runs when no subcommand is provided.
		Action: func(c *cli.Context) error {
			// String retrieves the flag value. Returns the default if the flag is absent.
			name := c.String("name")
			fmt.Printf("Hello, %s!\n", name)
			return nil
		},
		// Flags attach to the command or app where they are defined.
		Flags: []cli.Flag{
			&cli.StringFlag{
				Name:  "name",
				Usage: "Person to greet",
				Value: "World",
			},
		},
	}

	// Run parses os.Args and executes the matching action.
	if err := app.Run(os.Args); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

Run greet --name Alice and you get Hello, Alice!. Run greet --help and the library prints the usage string and flag list. You didn't write the help logic. The library generated it from the struct fields.

gofmt is the standard. Don't argue about indentation. Let the tool decide. Most editors run it on save. Your code should look like everyone else's code so readers focus on logic, not formatting.

How the flow works

When you call app.Run(os.Args), the library splits the arguments and walks the command tree. It matches the first non-flag argument to a command name. If no command matches, it runs the app-level Action. Flags are consumed as it goes.

The result is a *cli.Context passed to your Action. This context is a map of parsed values. You call methods like c.String("name") or c.Bool("verbose") to retrieve values. The context also holds the command name and parent context, so you can navigate the tree if needed.

If you return an error from Action, the library prints the error to stderr and exits with code 1. If you return nil, it exits with code 0. This pattern keeps error handling consistent. You don't need to check the exit code manually. The library does it.

The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Always check errors from Run and return errors from actions. Silence is not success.

Realistic example

Real tools have subcommands, global flags, and environment variable support. Here's a tool with a serve command and a build command. It uses a global --verbose flag and environment variables for configuration.

package main

import (
	"fmt"
	"os"

	"github.com/urfave/cli/v2"
)

func main() {
	// Root app defines global flags and the list of subcommands.
	app := &cli.App{
		Name:  "mytool",
		Usage: "Manage local development",
		// Global flags are available in every subcommand's context.
		Flags: []cli.Flag{
			&cli.BoolFlag{
				Name:  "verbose",
				Usage: "Enable debug logging",
				Aliases: []string{"v"},
			},
		},
		Commands: []*cli.Command{
			{
				Name:  "serve",
				Usage: "Start the HTTP server",
				Action: func(c *cli.Context) error {
					// Check global flag from subcommand context.
					if c.Bool("verbose") {
						fmt.Println("Debug mode on")
					}
					fmt.Println("Serving on :8080")
					return nil
				},
			},
			{
				Name:  "build",
				Usage: "Compile assets",
				Flags: []cli.Flag{
					&cli.StringFlag{
						Name:    "out",
						Usage:   "Output directory",
						Value:   "dist",
						EnvVars: []string{"BUILD_OUT"},
					},
				},
				Action: func(c *cli.Context) error {
					outDir := c.String("out")
					fmt.Printf("Building to %s\n", outDir)
					return nil
				},
			},
		},
	}

	if err := app.Run(os.Args); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

The EnvVars field on the StringFlag lets users set the value via BUILD_OUT=release mytool build. The library checks the environment variable if the flag is not provided on the command line. This is useful for CI/CD pipelines where flags are hard to inject but environment variables are standard.

Global flags leak into subcommands. Name them carefully. If a global flag and a command flag have the same name, the command flag takes precedence in that command's context. This can be confusing for users. Document the shadowing or use unique names.

Pitfalls and errors

If you pass a flag that doesn't exist, urfave/cli returns an error. The error message looks like flag provided but not defined: -unknown. If you forget to return an error from an action, the app exits with code 0 even if something failed. Always return errors from actions.

New Go developers often mix up cli.Context and context.Context. These are different types. cli.Context holds parsed flags. context.Context carries deadlines and cancellation signals. If you start a goroutine in your action, you need a context.Context to stop it. Don't pass cli.Context to background goroutines. It doesn't support cancellation. Derive a context.Context from context.Background() or context.WithTimeout and pass that to your business logic.

Go functions that take a context should put context.Context as the first parameter. This convention applies to your business logic, not the CLI wrapper. When your action calls a database function, pass a context.Context derived from context.Background(). The cli.Context stays in the CLI layer. Keep the layers separate.

Testing CLIs can be hard. urfave/cli provides cli.TestCmd to simulate arguments and capture output. You can assert on the exit code and stdout without writing shell scripts. This makes your tests fast and reliable.

The worst goroutine bug is the one that never logs. If your CLI starts a background goroutine, ensure it has a way to report errors. Use a channel or a logger that survives the main function exit.

When to use urfave/cli

Use urfave/cli when you need a full-featured CLI with subcommands, flags, and auto-generated help. Use the standard library flag package when your tool has a single command and fewer than five flags. Use cobra when you need shell completion scripts, plugin systems, or integration with Kubernetes-style tooling. Use plain os.Args slicing when you are writing a tiny script that takes positional arguments and you want zero dependencies.

urfave/cli handles the parsing. You handle the logic.

Where to go next