pressly/cli

A small Go library for building CLIs. Extends the standard library's flag package with nested subcommands, flag inheritance, and flags-anywhere parsing.

Intentionally minimal.

Go 1.21+ · 0 dependencies · MIT

go get github.com/pressly/cli@latest

Why this over cobra or urfave? →


Quick start

A complete program. Drop it in main.go and go run main.go.

package main

import (
    "context"
    "flag"
    "fmt"
    "os"
    "strings"

    "github.com/pressly/cli"
)

func main() {
    root := &cli.Command{
        Name:    "echo",
        Usage:   "echo [flags] ...",
        Summary: "Print text",
        Flags: cli.FlagsFunc(func(f *flag.FlagSet) {
            f.Bool("capitalize", false, "capitalize the input")
        }),
        FlagConfigs: []cli.FlagConfig{
            {Name: "capitalize", Short: "c"},
        },
        Exec: func(ctx context.Context, state *cli.State) error {
            text := strings.Join(state.Args, " ")
            if cli.GetFlag[bool](state, "capitalize") {
                text = strings.ToUpper(text)
            }
            fmt.Fprintln(state.Stdout, text)
            return nil
        },
    }
    if err := cli.ParseAndRun(context.Background(), root, os.Args[1:], nil); err != nil {
        fmt.Fprintf(os.Stderr, "error: %v\n", err)
        os.Exit(1)
    }
}
$ echo hello world
hello world

$ echo -c hello world
HELLO WORLD

$ echo --help
Print text

Usage:
  echo [flags] <text>...

Flags:
  -c, --capitalize    capitalize the input

ParseAndRun parses, handles --help, and runs the resolved command. Use Parse + Run separately when you need to do work between the two.


At a glance

The full surface of the package fits on one screen. Each symbol links to its godoc entry, the canonical reference.

FUNCTIONS
    func FlagsFunc(fn func(f *flag.FlagSet)) *flag.FlagSet
    func GetFlag[T any](s *State, name string) T
    func Parse(root *Command, args []string) error
    func ParseAndRun(ctx context.Context, root *Command, args []string, opts *RunOptions) error
    func Run(ctx context.Context, root *Command, opts *RunOptions) error
    func UsageErrorf(format string, args ...any) error

TYPES
    type Command
        func (c *Command) Path() []*Command
    type FlagConfig
    type RunOptions
    type State

Examples

Required flags

Set Required: true on a FlagConfig and Parse returns an error if the user omits it. The generated help marks it (required).

Flags: cli.FlagsFunc(func(f *flag.FlagSet) {
    f.String("output", "", "output file")
}),
FlagConfigs: []cli.FlagConfig{
    {Name: "output", Short: "o", Required: true},
},
$ build
error: command "build": required flag "-output" not set

$ build --help
Usage:
  build [flags]

Flags:
  -o, --output string    output file (required)

Nested subcommands with inherited flags

Subcommands inherit any flag defined on the root. Set Local: true to opt out.

root := &cli.Command{
    Name:  "todo",
    Usage: "todo  [flags]",

    // --verbose lives on the root, so every subcommand inherits it.
    Flags: cli.FlagsFunc(func(f *flag.FlagSet) {
        f.Bool("verbose", false, "enable verbose output")
    }),

    SubCommands: []*cli.Command{
        {
            Name:    "list",
            Summary: "List all tasks",
            Exec: func(ctx context.Context, state *cli.State) error {
                // No need to redeclare --verbose; it's inherited.
                if cli.GetFlag[bool](state, "verbose") {
                    fmt.Fprintln(state.Stderr, "listing tasks...")
                }
                return nil
            },
        },
        {
            Name:    "add",
            Usage:   "todo add ",
            Summary: "Add a task",
            Exec: func(ctx context.Context, state *cli.State) error {
                // state.Args is everything left after flag parsing.
                fmt.Fprintf(state.Stdout, "added: %s\n",
                    strings.Join(state.Args, " "))
                return nil
            },
        },
    },
}
$ todo --help
Usage:
  todo <command> [flags]

Available Commands:
  add     Add a task
  list    List all tasks

Flags:
  --verbose    enable verbose output

Use "todo [command] --help" for more information about a command.
$ todo list --help
List all tasks

Usage:
  todo list [flags]

Inherited Flags:
  --verbose    enable verbose output

Inheritance shows up explicitly when a subcommand is asked for help: --verbose lives under Inherited Flags, not Flags. Flags can also appear anywhere on the command line.


Subpackages

Optional helpers that ship in this repo. Each one stands alone; use them where they fit.

flagtype

Common flag.Value implementations: string slices, enums, maps, URLs, regexes. Register a value with f.Var() and read it back with the matching cli.GetFlag[T].

f.Var(flagtype.Enum("json", "yaml", "table"), "format", "output format")

format := cli.GetFlag[string](state, "format")

graceful

Signal handling and shutdown semantics for long-lived processes: servers, workers, batch jobs. First Ctrl+C cancels the context for clean shutdown; second forces an exit. Independent of cli.

graceful.Run(
    graceful.ListenAndServe(server, 15*time.Second),
    graceful.WithTerminationTimeout(30*time.Second),
)

xflag

A drop-in replacement for flag.Parse that accepts flags anywhere in the argument list. Used by cli internally, also usable on its own with a stdlib *flag.FlagSet.

err := xflag.ParseToEnd(f, os.Args[1:])

But why?

The library makes a handful of opinionated choices. Each one is explained below.

Compared to cobra, urfave, and kong

This library gives you the command tree, flag parsing, and subcommand resolution, built directly on the standard library's flag package so there's no new flag system to learn. Color, config loading, env-var binding, and the rest you compose from packages of your choice. Your binary's dependency graph is whatever you put there.

cobra, urfave/cli, and kong take a different shape. cobra is what Kubernetes, Docker, and gh use. urfave is well-trodden and battle-tested. kong is declarative via struct tags. They include code generators, lifecycle hooks, completion script generation, and struct-tag definitions. Pick one of those if you specifically want any of those features built in.

Built on the standard library's flag package

If you have ever written a Go CLI without a framework, you already know how to define flags here. Command.Flags is a plain *flag.FlagSet:

f.Bool("verbose", false, "enable verbose output")
f.String("output", "", "output file")
f.Duration("timeout", 5*time.Second, "request timeout")

Most CLI libraries ship their own flag system and then re-implement bool, int, string, duration, and the rest. That is a lot of surface area for very little benefit; this library doesn't reach for it. Custom types implement flag.Value the standard way.

Custom flag types follow the same pattern

The flagtype subpackage currently provides the types most CLIs reach for: slices, enums, maps, URLs, and regexes. Each constructor returns a value that implements flag.Value and registers through f.Var(), the same way custom types have always plugged into the standard flag package:

f.Bool("verbose", false, "enable verbose output")
f.String("output", "", "output file")
f.Var(flagtype.StringSlice(), "tag", "add a tag (repeatable)")
f.Var(flagtype.URL(), "endpoint", "API endpoint")

There is no second API to learn. Anything implementing flag.Value registers the same way, including custom types you write yourself. If there's a type you think belongs in flagtype, open an issue.

Flags can appear anywhere

Flags can appear before, between, or after positional arguments. All three of these mean the same thing:

todo --verbose add buy milk
todo add --verbose buy milk
todo add buy milk --verbose

The standard flag package stops parsing at the first positional, so cmd build foo --verbose would treat --verbose as a positional. That's not what modern CLI users expect, so this library uses the xflag package, which also ships standalone for callers who want flags-anywhere behavior without the rest of cli.

Parent flags inherit by default

A flag defined on the root command is visible from every subcommand without being redeclared. --verbose on todo works on todo list and todo add automatically.

Most flags want to be inherited: --verbose, --config, --dry-run, --quiet. They apply at any depth. For the rare flag that genuinely shouldn't escape its command (a --force on task remove that has no meaning anywhere else), set Local: true on its FlagConfig:

FlagConfigs: []cli.FlagConfig{
    {Name: "force", Local: true},
},

That's the entire opt-out.

Type-safe flag access through GetFlag[T]

Inside Exec, GetFlag[T] returns a flag's value as the requested type, walking from the current command up to the root so inherited flags are visible:

verbose := cli.GetFlag[bool](state, "verbose")
output  := cli.GetFlag[string](state, "output")
tags    := cli.GetFlag[[]string](state, "tag")

The signature is deliberately just T, not (T, error). A missing flag name or mismatched type is a programming error, not something the caller can recover from: GetFlag panics, cli catches it and reports cleanly. The mistake surfaces in development without forcing every callsite to write if err != nil { return err }.

The free-function form (with state passed in) exists because Go doesn't yet allow type parameters on methods. Once generic methods land, this will move onto State and read more naturally:

verbose := state.GetFlag[bool]("verbose")
output  := state.GetFlag[string]("output")

No dependencies

go.mod has no require block, and that is deliberate. CLI libraries tend to grow into framework-shaped objects with their own color packages, configuration layers, log routing, and DI containers. None of that belongs in a small library.

Compose those concerns as a caller. For color and styled output, reach for lipgloss or fatih/color. For prompts, huh or promptui. For configuration, viper or koanf. For env-var binding, envconfig. Pick whichever you like. The library has no opinion, and your binary's transitive dependency tree is whatever you put there.

Auxiliary packages live alongside, not inside, cli

The repo ships a few auxiliary packages: currently flagtype, graceful, and xflag, with room for more. Each is intentionally separate from the core. None import cli, and cli does not import them (except xflag, which cli uses internally to do its parsing).

They live here because the same problems show up in almost every CLI binary, and shipping them in the same repo keeps the boilerplate out of every main without bloating the core library. A package earns a place here only if it depends solely on the standard library, stands alone, and solves a problem any CLI author would otherwise solve themselves the same way.

Inspired by ff/v3

This library traces its lineage to Peter Bourgon's ff. The v3 branch was small, focused, and built on flag.FlagSet. It was almost exactly what this library wants to be. v4 went toward more features and a different shape, so this library carries the v3 spirit forward.