urfave/cli (Go)
Declarative CLI framework for Go. Commands, subcommands, typed flags, env var binding, and help generation with zero dependencies outside the standard library.
Version
Use v3 (current stable: v3.8.0). Import path is github.com/urfave/cli/v3. v2 is mature and still maintained, but new code should target v3 — its API centers on cli.Command (a recursive tree) instead of the v2 split between cli.App and cli.Command, and the action signature is func(context.Context, *cli.Command) error rather than func(*cli.Context) error.
If you see existing code with cli.App, cli.Context, or github.com/urfave/cli/v2, that is v2. Don't mix versions in one module.
Install
go get github.com/urfave/cli/v3@latest
import "github.com/urfave/cli/v3"
Minimal main.go
Every urfave/cli app has the same top-level shape: construct a *cli.Command, call Run with a context.Context and os.Args, exit non-zero on error.
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/urfave/cli/v3"
)
func main() {
cmd := &cli.Command{
Name: "greet",
Usage: "fight the loneliness!",
Action: func(ctx context.Context, cmd *cli.Command) error {
fmt.Println("Hello friend!")
return nil
},
}
if err := cmd.Run(context.Background(), os.Args); err != nil {
log.Fatal(err)
}
}
cmd.Run does not call os.Exit. Exit codes fall through to 0 unless you return a cli.ExitCoder (see Exit codes below) or wrap with log.Fatal.
Command metadata
The root cli.Command holds the app-level fields. Subcommands use the same struct.
cmd := &cli.Command{
Name: "myapp",
Usage: "one-line summary shown in help",
UsageText: "myapp [global options] command [command options] [arguments...]",
Description: "Long-form description. Shown under `myapp --help`.",
Version: "v1.2.3",
Copyright: "(c) 2026 Acme Inc.",
ArgsUsage: "[target...]",
Authors: []any{
"Jane Doe <jane@example.com>",
},
Metadata: map[string]any{
"build": "abc123",
},
HideHelp: false,
HideVersion: false,
Suggest: true, // "did you mean X?" for typos
}
Pass the version via -ldflags at build time so it reflects the actual release:
go build -ldflags "-X main.version=$(git describe --tags)" ./cmd/myapp
var version = "dev"
func main() {
cmd := &cli.Command{Name: "myapp", Version: version /* ... */}
_ = cmd.Run(context.Background(), os.Args)
}
Commands and subcommands
Subcommands are just *cli.Command values nested under Commands. They can nest arbitrarily deep — a template add / template remove pattern is one level, git remote add is two.
cmd := &cli.Command{
Name: "tasks",
Commands: []*cli.Command{
{
Name: "add",
Aliases: []string{"a"},
Usage: "add a task to the list",
Action: func(ctx context.Context, cmd *cli.Command) error {
fmt.Println("added task:", cmd.Args().First())
return nil
},
},
{
Name: "complete",
Aliases: []string{"c"},
Usage: "complete a task on the list",
Action: func(ctx context.Context, cmd *cli.Command) error {
fmt.Println("completed task:", cmd.Args().First())
return nil
},
},
{
Name: "template",
Aliases: []string{"t"},
Usage: "options for task templates",
Commands: []*cli.Command{
{
Name: "add",
Usage: "add a new template",
Action: func(ctx context.Context, cmd *cli.Command) error {
fmt.Println("new template:", cmd.Args().First())
return nil
},
},
{
Name: "remove",
Usage: "remove an existing template",
Action: func(ctx context.Context, cmd *cli.Command) error {
fmt.Println("removed template:", cmd.Args().First())
return nil
},
},
},
},
},
}
Command categories
Group subcommands in the help output by setting Category:
Commands: []*cli.Command{
{Name: "noop"},
{Name: "add", Category: "template"},
{Name: "remove", Category: "template"},
}
Renders as:
COMMANDS:
noop
template:
add
remove
Organizing larger apps
Put each subcommand in its own file and expose a constructor:
cmd/myapp/
main.go
commands/
serve.go // func ServeCommand() *cli.Command
migrate.go // func MigrateCommand() *cli.Command
user.go // func UserCommand() *cli.Command (has its own sub-Commands)
// commands/serve.go
package commands
import (
"context"
"github.com/urfave/cli/v3"
)
func ServeCommand() *cli.Command {
return &cli.Command{
Name: "serve",
Usage: "run the HTTP server",
Flags: []cli.Flag{
&cli.StringFlag{Name: "addr", Value: ":8080", Sources: cli.EnvVars("ADDR")},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
// start server, honor ctx cancellation
return nil
},
}
}
// main.go
cmd := &cli.Command{
Name: "myapp",
Commands: []*cli.Command{
commands.ServeCommand(),
commands.MigrateCommand(),
commands.UserCommand(),
},
}
Flags
Flags are declared on a cli.Command via the Flags slice. Flags on the root are global; flags on a subcommand are local to that subcommand. Read them in the Action with cmd.String("name"), cmd.Int("name"), cmd.Bool("name"), cmd.Duration("name"), cmd.StringSlice("name"), etc.
Basic types
cmd := &cli.Command{
Flags: []cli.Flag{
&cli.StringFlag{
Name: "lang",
Aliases: []string{"l"},
Value: "english", // default
Usage: "language for the greeting",
},
&cli.IntFlag{
Name: "port",
Value: 8080,
Usage: "listen port",
},
&cli.BoolFlag{
Name: "verbose",
Usage: "enable verbose logging",
},
&cli.DurationFlag{
Name: "timeout",
Value: 30 * time.Second,
Usage: "request timeout",
},
&cli.FloatFlag{
Name: "ratio",
Value: 0.5,
},
&cli.StringSliceFlag{
Name: "tag",
Usage: "tags (can be repeated)",
},
&cli.IntSliceFlag{
Name: "port-list",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
fmt.Println("lang:", cmd.String("lang"))
fmt.Println("port:", cmd.Int("port"))
fmt.Println("verbose:", cmd.Bool("verbose"))
fmt.Println("timeout:", cmd.Duration("timeout"))
fmt.Println("ratio:", cmd.Float("ratio"))
fmt.Println("tags:", cmd.StringSlice("tag"))
fmt.Println("ports:", cmd.IntSlice("port-list"))
return nil
},
}
Full set of basic flag types: StringFlag, BoolFlag, IntFlag (plus Int8/16/32/64Flag), UintFlag (plus Uint8/16/32/64Flag), FloatFlag (plus Float32/64Flag), DurationFlag, TimestampFlag.
Slice flags: StringSliceFlag, IntSliceFlag (plus Int8/16/32/64SliceFlag), UintSliceFlag (plus Uint8/16/32/64SliceFlag), FloatSliceFlag. Slice flags are repeatable on the command line: --tag a --tag b --tag c.
Timestamp flags
&cli.TimestampFlag{
Name: "meeting",
Config: cli.TimestampConfig{
Layouts: []string{"2006-01-02T15:04:05"},
Timezone: time.Local, // optional; defaults to UTC
},
}
// Use: myapp --meeting 2026-04-17T09:00:00
// Read: cmd.Timestamp("meeting") returns time.Time
Aliases
&cli.StringFlag{
Name: "config",
Aliases: []string{"c", "cfg"},
Usage: "config file path",
}
// Equivalent: --config foo.yaml / -c foo.yaml / --cfg foo.yaml
Required flags
&cli.StringFlag{
Name: "token",
Usage: "API token",
Required: true,
}
// Missing it yields: `Required flag "token" not set`
Destination binding
Write the parsed value directly into a variable instead of calling cmd.String(...) later:
var language string
cmd := &cli.Command{
Flags: []cli.Flag{
&cli.StringFlag{
Name: "lang",
Value: "english",
Destination: &language,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
fmt.Println("lang:", language)
return nil
},
}
Help-output default text
Use DefaultText when the real default is runtime-computed or zero-but-meaningful:
&cli.IntFlag{
Name: "port",
Value: 0,
DefaultText: "random",
Usage: "listen port",
}
// Help shows: --port value listen port (default: random)
Placeholder in usage
Back-quoted words in Usage become the placeholder shown in help:
&cli.StringFlag{
Name: "config",
Aliases: []string{"c"},
Usage: "Load configuration from `FILE`",
}
// Help: --config FILE, -c FILE Load configuration from FILE
Per-flag validators
Run validation at parse time (before Action):
&cli.IntFlag{
Name: "longdistance",
Validator: func(v int) error {
if v < 10 {
return fmt.Errorf("10 miles isn't long distance!")
}
return nil
},
}
Per-flag action (post-parse hook)
&cli.IntFlag{
Name: "port",
Action: func(ctx context.Context, cmd *cli.Command, v int) error {
if v >= 65536 {
return fmt.Errorf("port %d out of range [0-65535]", v)
}
return nil
},
}
Bool count flag (-vvv)
var verbosity int
cmd := &cli.Command{
UseShortOptionHandling: true, // required for -vvv (combined shorts)
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Config: cli.BoolConfig{Count: &verbosity},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
fmt.Println("verbosity:", verbosity) // -vvv => 3
return nil
},
}
When UseShortOptionHandling is on, you cannot use single-dash long flags like -option — only --option or true shorts.
Mutually exclusive flag groups
MutuallyExclusiveFlags: []cli.MutuallyExclusiveFlags{
{
Required: true,
Flags: [][]cli.Flag{
{&cli.StringFlag{Name: "login"}},
{&cli.StringFlag{Name: "id"}},
},
},
}
// Error if neither or both: "one of these flags needs to be provided: login, id"
Environment variable binding (Sources)
In v3, env vars are one of several "value sources" exposed via the Sources field. (v2's EnvVars: []string{...} is gone.)
&cli.StringFlag{
Name: "lang",
Value: "english",
Sources: cli.EnvVars("APP_LANG"),
}
Multiple env vars — first one that resolves wins:
Sources: cli.EnvVars("LEGACY_COMPAT_LANG", "APP_LANG", "LANG")
From a file:
&cli.StringFlag{
Name: "password",
Sources: cli.Files("/etc/mysql/password"),
}
Chain multiple sources — evaluated in declaration order, first match wins:
&cli.StringFlag{
Name: "token",
Sources: cli.NewValueSourceChain(
cli.EnvVar("APP_TOKEN"),
cli.File("/etc/myapp/token"),
),
}
Structured config files (YAML/JSON/TOML)
Use the companion module github.com/urfave/cli-altsrc/v3:
import (
altsrc "github.com/urfave/cli-altsrc/v3"
altsrcyaml "github.com/urfave/cli-altsrc/v3/yaml"
)
&cli.StringFlag{
Name: "password",
Sources: cli.NewValueSourceChain(
altsrcyaml.YAML("db.password", altsrc.StringSourcer("/etc/myapp/config.yaml")),
),
}
Lazy-load based on another flag (the --config flag determines the file path):
var cfgPath string
cmd := &cli.Command{
Flags: []cli.Flag{
&cli.StringFlag{
Name: "config",
Aliases: []string{"c"},
Value: "/etc/myapp/config.yaml",
Destination: &cfgPath,
},
&cli.StringFlag{
Name: "password",
Sources: cli.NewValueSourceChain(
altsrcyaml.YAML("db.password", altsrc.NewStringPtrSourcer(&cfgPath)),
),
},
},
}
Action functions
The Action signature is:
func(ctx context.Context, cmd *cli.Command) error
ctxis thecontext.Contextthreaded through fromcmd.Run(ctx, os.Args). Propagate it to downstream calls (HTTP clients, DB drivers, goroutines) soSIGINTor caller cancellation flows all the way through.cmdis the*cli.Commandthat matched — typically the deepest one, i.e. the subcommand being run. Read flags off it.- Return
nilfor success, a regularerrorfor a generic failure, orcli.Exit(msg, code)to control the process exit code.
Action: func(ctx context.Context, cmd *cli.Command) error {
addr := cmd.String("addr")
timeout := cmd.Duration("timeout")
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, "GET", addr, nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
fmt.Println("status:", resp.Status)
return nil
}
Reading flags from the right command
cmd.String(...) looks up the flag on this command, then walks up the lineage (parent, grandparent, root). That means global flags set on the root are visible from any subcommand's Action. Call cmd.Root() to explicitly target the root, or cmd.IsSet("flag") to see if a flag was actually set (vs. default).
Action: func(ctx context.Context, cmd *cli.Command) error {
// global flag from root
verbose := cmd.Bool("verbose")
// local flag on this subcommand
addr := cmd.String("addr")
// check if user explicitly set it
if cmd.IsSet("timeout") {
// ...
}
return nil
}
Positional arguments
Typed positional args (new in v3) via Arguments:
cmd := &cli.Command{
Arguments: []cli.Argument{
&cli.IntArg{Name: "count"},
&cli.StringArgs{Name: "files", Min: 1, Max: -1}, // -1 = unbounded
},
Action: func(ctx context.Context, cmd *cli.Command) error {
count := cmd.IntArg("count")
files := cmd.StringArgs("files")
fmt.Println(count, files)
return nil
},
}
Types: StringArg/Args, IntArg/Args (+ sized), UintArg/Args (+ sized), FloatArg/Args, TimestampArg/Args. Use Args (plural) for multi-value, with Min/Max bounds.
Untyped positional args (always available) via cmd.Args():
Action: func(ctx context.Context, cmd *cli.Command) error {
fmt.Println("n:", cmd.Args().Len())
fmt.Println("first:", cmd.Args().First())
fmt.Println("third:", cmd.Args().Get(2))
fmt.Println("rest:", cmd.Args().Tail())
fmt.Println("any?", cmd.Args().Present())
fmt.Println("slice:", cmd.Args().Slice())
return nil
}
Before / After / CommandNotFound hooks
Lifecycle callbacks fire around a command's Action. Defined on any cli.Command node.
cmd := &cli.Command{
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
// Runs before Action (and before subcommand dispatch).
// Returned context replaces the one passed to Action; return nil to keep current.
logger := slog.New(/*...*/)
ctx = context.WithValue(ctx, loggerKey{}, logger)
return ctx, nil
},
After: func(ctx context.Context, cmd *cli.Command) error {
// Runs after Action, even on error.
return nil
},
CommandNotFound: func(ctx context.Context, cmd *cli.Command, name string) {
fmt.Fprintf(cmd.Root().Writer, "no such command: %q\n", name)
},
OnUsageError: func(ctx context.Context, cmd *cli.Command, err error, isSubcommand bool) error {
fmt.Fprintf(cmd.Root().Writer, "usage error: %v\n", err)
return err
},
Action: /* ... */,
}
Before is the right place for logger setup, config loading, auth token resolution, opening a DB connection, and similar cross-cutting setup. Stash things on ctx via context.WithValue, then pull them out in subcommand Actions.
After runs regardless of Action error, so use it for cleanup (close DB, flush traces).
Error handling and exit codes
cmd.Run returns the error; it does not call os.Exit. The simplest idiom uses log.Fatal, which prints and exits 1:
if err := cmd.Run(context.Background(), os.Args); err != nil {
log.Fatal(err)
}
For controlled exit codes, return cli.Exit(message, code) from your Action. cli.Exit implements cli.ExitCoder, and urfave/cli will print the message and call os.Exit(code) when you invoke cli.HandleExitCoder (which Run does automatically for this specific interface):
Action: func(ctx context.Context, cmd *cli.Command) error {
if !cmd.Bool("ginger-crouton") {
return cli.Exit("Ginger croutons are not in the soup", 86)
}
return nil
}
Wrap exit-coded errors with your own type for richer control:
type exitErr struct {
msg string
code int
}
func (e *exitErr) Error() string { return e.msg }
func (e *exitErr) ExitCode() int { return e.code }
// return &exitErr{msg: "db unreachable", code: 3}
Idiomatic main that preserves exit codes:
func main() {
if err := cmd.Run(context.Background(), os.Args); err != nil {
// cli.Exit errors already printed themselves; print others to stderr.
var ec cli.ExitCoder
if errors.As(err, &ec) {
os.Exit(ec.ExitCode())
}
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Signal handling
Wire SIGINT/SIGTERM into the root context so Ctrl-C cancels in-flight work:
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := cmd.Run(ctx, os.Args); err != nil {
log.Fatal(err)
}
}
Inside Actions, always pass ctx into anything blocking (HTTP, sql, time.After → use time.NewTimer + <-ctx.Done()).
Help text customization
The generated help covers most needs. Tune it via:
Usage,UsageText,Description,ArgsUsageon each command.- Templates: package vars
cli.RootCommandHelpTemplate,cli.CommandHelpTemplate,cli.SubcommandHelpTemplate. Replace or append:
cli.RootCommandHelpTemplate = fmt.Sprintf(`%s
WEBSITE: https://example.com
SUPPORT: support@example.com
`, cli.RootCommandHelpTemplate)
- Full override via
cli.HelpPrinter:
cli.HelpPrinter = func(w io.Writer, templ string, data any) {
fmt.Fprintln(w, "custom help output")
}
- Custom help flag: reassign
cli.HelpFlagat init time.
cli.HelpFlag = &cli.BoolFlag{Name: "halp", Aliases: []string{"?"}}
- Suggestions:
Suggest: trueon a command enables "did you mean X?" for typos. - Hide commands/flags:
Hidden: trueon a*cli.Command,Hidden: trueon any flag type.
Shell completion
Dynamic completion for bash, zsh, fish, and PowerShell is built in. Enable it on the root command:
cmd := &cli.Command{
Name: "greet",
EnableShellCompletion: true,
Commands: /* ... */,
}
At that point, greet completion bash (or zsh/fish/powershell) emits a shell script. Users source it:
# Bash (one-off)
source <(greet completion bash)
# Bash (persistent)
greet completion bash > /etc/bash_completion.d/greet
# Zsh (persistent — add to ~/.zshrc)
PROG=greet source /path/to/greet_completion.zsh
Custom completion logic per command:
&cli.Command{
Name: "deploy",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
// print one suggestion per line to cmd.Root().Writer
for _, env := range []string{"staging", "production"} {
fmt.Fprintln(cmd.Root().Writer, env)
}
},
}
Context propagation
v3 unifies the cli.Context (v2) into context.Context + *cli.Command. This means:
- Callers control cancellation by passing their own
context.Contextintocmd.Run. - Actions receive that same context and should pass it down.
Beforecan return a replacement context to add values (logger, DB handle, request ID) visible to all nested Before/Action/After calls.
type ctxKey string
const loggerKey ctxKey = "logger"
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
return context.WithValue(ctx, loggerKey, logger), nil
},
Action: func(ctx context.Context, cmd *cli.Command) error {
logger := ctx.Value(loggerKey).(*slog.Logger)
logger.Info("running", "addr", cmd.String("addr"))
return nil
},
Common patterns
Global flags shared by all subcommands
Put them on the root; read them from any Action via flag-lookup walking.
cmd := &cli.Command{
Name: "myapp",
Flags: []cli.Flag{
&cli.StringFlag{Name: "log-level", Value: "info", Sources: cli.EnvVars("LOG_LEVEL")},
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Sources: cli.EnvVars("MYAPP_CONFIG")},
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
lvl := cmd.String("log-level")
logger := makeLogger(lvl)
return context.WithValue(ctx, loggerKey, logger), nil
},
Commands: []*cli.Command{
serveCommand(),
migrateCommand(),
},
}
Config file loading pattern
Use the Before hook to parse the config once, then stash the struct on ctx:
type cfgKey struct{}
type Config struct {
Addr string
DBURL string
}
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
path := cmd.String("config")
cfg, err := loadConfig(path) // your YAML/JSON/TOML loader
if err != nil {
return ctx, fmt.Errorf("load config %q: %w", path, err)
}
return context.WithValue(ctx, cfgKey{}, cfg), nil
},
Action: func(ctx context.Context, cmd *cli.Command) error {
cfg := ctx.Value(cfgKey{}).(*Config)
// ...
return nil
}
Twelve-factor env binding
Every flag also honors an env var with a predictable prefix:
func envFlag(name, env, def, usage string) *cli.StringFlag {
return &cli.StringFlag{
Name: name,
Value: def,
Usage: usage,
Sources: cli.EnvVars("MYAPP_" + env),
}
}
Flags: []cli.Flag{
envFlag("addr", "ADDR", ":8080", "listen address"),
envFlag("db-url", "DB_URL", "", "Postgres connection string"),
}
JSON output flag (machine-readable vs. human)
&cli.StringFlag{
Name: "format",
Value: "table",
Usage: "output format (table|json|csv)",
}
// In action:
switch cmd.String("format") {
case "json":
_ = json.NewEncoder(os.Stdout).Encode(result)
default:
printTable(os.Stdout, result)
}
Unix-style quiet defaults
Write data to stdout, logs/errors to stderr, only print on events or errors. Exit 0 on success, non-zero on failure. urfave/cli stays out of the way by default.
Testing CLI commands
Because *cli.Command is a plain struct and Run takes a context + args slice, unit tests don't need subprocesses. Redirect Writer and ErrWriter to buffers and call Run.
package main
import (
"bytes"
"context"
"strings"
"testing"
"github.com/urfave/cli/v3"
)
func TestGreet(t *testing.T) {
var out bytes.Buffer
cmd := &cli.Command{
Name: "greet",
Writer: &out,
ErrWriter: &out,
Flags: []cli.Flag{
&cli.StringFlag{Name: "name", Value: "world"},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
_, err := cmd.Writer.Write([]byte("hello " + cmd.String("name") + "\n"))
return err
},
}
if err := cmd.Run(context.Background(), []string{"greet", "--name", "claude"}); err != nil {
t.Fatalf("run: %v", err)
}
if got, want := out.String(), "hello claude\n"; !strings.Contains(got, want) {
t.Errorf("output = %q, want contains %q", got, want)
}
}
Table-driven CLI tests
func TestCommands(t *testing.T) {
cases := []struct {
name string
args []string
env map[string]string
want string
wantErr string
}{
{name: "add", args: []string{"app", "add", "laundry"}, want: "added: laundry"},
{name: "missing required", args: []string{"app", "login"}, wantErr: `Required flag "user" not set`},
{name: "via env", env: map[string]string{"APP_USER": "alice"}, args: []string{"app", "login"}, want: "hi alice"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
for k, v := range tc.env {
t.Setenv(k, v)
}
var out bytes.Buffer
cmd := newRootCommand() // your constructor
cmd.Writer = &out
cmd.ErrWriter = &out
err := cmd.Run(context.Background(), tc.args)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("err = %v, want contains %q", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("run: %v", err)
}
if !strings.Contains(out.String(), tc.want) {
t.Errorf("output = %q, want contains %q", out.String(), tc.want)
}
})
}
}
Testing exit codes
Intercept exits by replacing cli.OsExiter (which cli.HandleExitCoder calls) with a test double, or check the returned error directly:
func TestExitCode(t *testing.T) {
cmd := &cli.Command{
Action: func(ctx context.Context, cmd *cli.Command) error {
return cli.Exit("boom", 42)
},
}
err := cmd.Run(context.Background(), []string{"app"})
var ec cli.ExitCoder
if !errors.As(err, &ec) {
t.Fatalf("err %v does not implement ExitCoder", err)
}
if ec.ExitCode() != 42 {
t.Fatalf("code = %d, want 42", ec.ExitCode())
}
}
Full worked example
A realistic root-command scaffold pulling the patterns together:
// cmd/myapp/main.go
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/urfave/cli/v3"
)
var version = "dev" // set via -ldflags
type loggerCtxKey struct{}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
cmd := &cli.Command{
Name: "myapp",
Usage: "example service and CLI",
Version: version,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "log-level",
Value: "info",
Sources: cli.EnvVars("MYAPP_LOG_LEVEL"),
Usage: "log level (debug|info|warn|error)",
},
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: parseLevel(cmd.String("log-level")),
}))
return context.WithValue(ctx, loggerCtxKey{}, logger), nil
},
EnableShellCompletion: true,
Suggest: true,
Commands: []*cli.Command{
serveCommand(),
migrateCommand(),
},
}
if err := cmd.Run(ctx, os.Args); err != nil {
var ec cli.ExitCoder
if errors.As(err, &ec) {
os.Exit(ec.ExitCode())
}
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func serveCommand() *cli.Command {
return &cli.Command{
Name: "serve",
Usage: "run the HTTP server",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "addr",
Value: ":8080",
Sources: cli.EnvVars("MYAPP_ADDR"),
},
&cli.DurationFlag{
Name: "shutdown-timeout",
Value: 10 * time.Second,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
logger := ctx.Value(loggerCtxKey{}).(*slog.Logger)
logger.Info("starting", "addr", cmd.String("addr"))
<-ctx.Done()
return cli.Exit("shutdown", 0)
},
}
}
func migrateCommand() *cli.Command {
return &cli.Command{
Name: "migrate",
Usage: "run database migrations",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "db-url",
Required: true,
Sources: cli.EnvVars("MYAPP_DB_URL"),
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
// ... run migrations
return nil
},
}
}
func parseLevel(s string) slog.Level {
switch s {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
Quick reference
| Need | API |
|---|---|
| Root command / app | &cli.Command{Name, Usage, Version, Action} |
| Run | cmd.Run(ctx, os.Args) |
| Subcommand tree | Commands: []*cli.Command{...} (nest freely) |
| Action signature | func(ctx context.Context, cmd *cli.Command) error |
| Read flag | cmd.String("x"), cmd.Int, cmd.Bool, cmd.Duration, cmd.StringSlice, ... |
| Was flag set? | cmd.IsSet("x") |
| Positional args | cmd.Args().First()/Get(i)/Tail()/Len()/Slice() or typed Arguments + cmd.IntArg etc. |
| Env var binding | Sources: cli.EnvVars("APP_X", "LEGACY_X") |
| File source | Sources: cli.Files("/etc/app/x") |
| Chain sources | Sources: cli.NewValueSourceChain(cli.EnvVar(...), cli.File(...)) |
| Required flag | Required: true |
| Alias | Aliases: []string{"x"} |
| Mutually exclusive | MutuallyExclusiveFlags: []cli.MutuallyExclusiveFlags{...} |
| Exit with code | return cli.Exit("msg", 2) |
| Before/After | Before func(ctx,*cli.Command) (context.Context,error), After func(ctx,*cli.Command) error |
| Shell completion | EnableShellCompletion: true, then myapp completion bash|zsh|fish|powershell |
| Customize help | cli.RootCommandHelpTemplate, cli.HelpPrinter, cli.HelpFlag |
| Did-you-mean | Suggest: true |
| Testing | Set cmd.Writer/cmd.ErrWriter to buffers, call cmd.Run(ctx, args) |
Pitfalls
- v2 vs v3 mix-up.
cli.App,cli.Context,EnvVars: []string{...}, andSubcommandsare v2-only. Don't combine with/v3imports. cmd.Rundoesn't exit. You must propagate the error tolog.Fataloros.Exityourself. A missing check silently swallows failures.- Global flag reads from subcommand.
cmd.String("x")walks up the lineage, butcmd.LocalFlagNames()only lists this level's. Usecmd.Root().String("x")to force root lookup. UseShortOptionHandlingbreaks-longname. Single-dash long flags stop parsing. Use--longnameor short aliases only.BeforeFuncreturns(context.Context, error). Returningnilfor the context is fine (keeps current); returning a non-nil value replaces it for nested calls.- Slice flags append, not overwrite.
--tag a --tag byields["a","b"]. If a default is set, explicit values replace the default entirely. - Timestamp layout is required.
TimestampFlaghas no default layout; setConfig.Layouts. cli.Exitprints tocli.ErrWriter(defaultos.Stderr), not to your logger. RedirectErrWriterin tests.