cli
Leaf · Tier 2Used in tasks:Building a CLI
Public summary
cli is Glacier's gold-standard CLI builder. You write a plain Go struct with a Run(ctx context.Context) error method and annotate its fields with doc-comment markers. The accompanying glaciergen tool reads those markers, discovers the command tree, and emits a single zz_generated_cli.go file that wires everything together: flag parsing, env-var binding, help text, subcommand routing, signal handling, and the branded banner. No init() calls scattered across packages. No global registries buried in framework internals. No reflection at runtime for the happy path. You hand back a Run result; Glacier handles exit codes, error formatting, and the lifecycle the same way on every OS.
Mental model
The developer's mental model is a two-step loop:
- Write a command struct. Give it a
Run(ctx) errormethod. Annotate fields with+glacier:*markers in doc comments. That is your entire surface; the rest is generated. - Run
glaciergen. The codegen tool discovers every type in your module that satisfies the command contract, builds the parent-child tree from the markers, and emitszz_generated_cli.go. That file callscli.Registeron each command in dependency order.
At runtime the framework is a thin dispatch layer over stdlib flag. It reads the generated registration, resolves the argv path, binds env vars, runs validation hooks, installs signal handling, and calls Run. Nothing in the hot path touches reflection.
Key invariants:
- A type is a command if and only if it has the exact method
Run(ctx context.Context) error. The interface is unexported; users never name it. - Every field with a
+glacier:marker becomes a flag. Fields without markers are ignored by codegen; they are yours to use as injected dependencies. - The generated file is a pure Go source file. It compiles with the host module and never imports
cli/genat runtime. Removing the generator does not break a built binary. cli.Defaultis the package-level*Appused by generated code. Programmatic use may construct a namedAppindependently.
API
Marker grammar
Markers live in Go doc comments on the command struct and its fields. The full grammar:
| Marker | Applies to | Effect |
|---|---|---|
// +glacier:command name=<name> | struct | Sets the command name in argv |
// +glacier:root | struct | Marks this command as the tree root |
// +glacier:command parent=<dotted.path> | struct | Parents this command under an existing node |
// +glacier:command alias=<name> | struct | Adds an additional routing name |
// +glacier:command app=<ident> | struct | Targets a named App other than cli.Default |
// +glacier:default <value> | field | Sets the flag default value |
// +glacier:short <letter> | field | Registers a single-character shorthand |
// +glacier:env <VAR> | field | Binds the flag to an environment variable |
// +glacier:required | field | Makes the flag required across all sources |
// +glacier:choices a|b|c | field | Constrains the flag to an enumerated set |
// +glacier:deprecated <message> | field | Logs a warning when the flag is used |
// +glacier:validate <funcName> | field | Calls func(string) error after binding |
Package-level variables
// Default is the package-level App used by generated code.
// Code generated by glaciergen calls Register on Default.
// Tests must not leak registrations: use cli.resetDefaultForTest() (build-tagged) or
// construct a separate App with New().
var Default = New()App construction
// New constructs an App with the supplied options applied.
// The banner is shown by default; suppress with WithoutBanner().
// New is not goroutine-safe; it must complete before concurrent use of the returned App.
//
// Postcondition: returned *App is ready for Register and Run calls.
// Postcondition: stdout defaults to os.Stdout; stderr defaults to os.Stderr.
// Postcondition: logger defaults to slog.Default().
func New(opts ...option.Option[appConfig]) *AppRegistration
// Register adds cmd to the App's command registry.
//
// opts configure the command's name, parent path, aliases, flag metadata, and
// root status. See the With* family below.
//
// Register is goroutine-safe; multiple goroutines may call Register concurrently
// during the construction phase (before the first Run call).
//
// Error contract:
// - returns *ErrMultipleRoots if a second root is registered.
// - returns *ErrUnresolvedParent if the declared parent path has no registration.
// - returns a descriptive non-nil error if cmd lacks Run(ctx) error.
// - returns a descriptive non-nil error on duplicate name/path.
func (a *App) Register(cmd any, opts ...option.Option[regConfig]) errorDispatch
// Run dispatches argv to the matching command and calls its Run(ctx) method.
// Automatic flags added to every command:
// --help / -h print usage and return nil
// --version / -v print version string (root only)
//
// Error contract:
// - returns *ErrUnknownCommand when argv names an unregistered path.
// - returns *ErrUnknownFlag when argv names an unregistered flag.
// - returns *FlagParseError when a flag value cannot be coerced.
// - returns ErrCancelled if ctx is already done before dispatch.
// - returns *ErrPanic if the handler panics (stack trace included).
// - returns the handler's error value verbatim otherwise.
//
// Concurrency: goroutine-safe. Signal handling: SIGINT/SIGTERM cancel the derived ctx.
func (a *App) Run(ctx context.Context, argv []string) error
// Main calls Run with os.Args[1:] and on non-nil error formats the error
// to stderr and calls os.Exit(1). On ErrUnknownCommand, Main also calls Help.
// Main never returns to the caller.
func (a *App) Main()
// Help writes the usage page for the command identified by path to stdout.
// Returns nil if the command exists, *ErrUnknownCommand otherwise.
func (a *App) Help(path string) error
// Version writes the version string to stdout.
func (a *App) Version()
// Banner writes the banner art to stdout. On a TTY the 24-bit ANSI gradient is used;
// on a non-TTY or when NO_COLOR / GLACIER_NO_COLOR is set, plain text is used.
func (a *App) Banner()
// Lookup returns the string-serialized value of the named flag as set during the
// most recent Run call. Returns ("", false) for unknown names.
// Implements the conf.Source interface as FlagSource.
func (a *App) Lookup(name string) (string, bool)
// Close shuts down the App: cancels the signal handler, flushes buffered output,
// and closes owned resources. Close is idempotent. Errors are merged via errs.Join.
func (a *App) Close() errorFlagSource
// FlagSource adapts an App into a conf.Source, making parsed flag values available
// to the conf layered-config pipeline.
type FlagSource struct{ app *App }
// NewFlagSource constructs a FlagSource from the given App.
// Precondition: app must not be nil.
func NewFlagSource(app *App) *FlagSource
// Lookup implements conf.Source. Returns the string-serialized flag value.
func (f *FlagSource) Lookup(key string) (string, bool)Option constructors (App)
func WithVersion(v string) option.Option[appConfig]
func WithStdout(w io.Writer) option.Option[appConfig]
func WithStderr(w io.Writer) option.Option[appConfig]
func WithLogger(l *slog.Logger) option.Option[appConfig]
func WithoutBanner() option.Option[appConfig]Option constructors (Register)
func WithName(name string) option.Option[regConfig]
func WithParent(path string) option.Option[regConfig]
func WithAlias(alias string) option.Option[regConfig]
func WithRoot() option.Option[regConfig]
func WithFlagShort(name string, short rune) option.Option[regConfig]
func WithFlagEnv(name string, envVar string) option.Option[regConfig]
func WithFlagHelp(name string, help string) option.Option[regConfig]
func WithFlagRequired(name string) option.Option[regConfig]
func WithFlagChoices(name string, choices ...string) option.Option[regConfig]
func WithFlagDeprecated(name string, message string) option.Option[regConfig]
func WithFlagValidate(name string, fn func(string) error) option.Option[regConfig]Error types
type FlagParseError struct{ Name string; Err error }
type ErrUnknownCommand struct{ Path string }
type ErrUnknownFlag struct{ Name string }
type ErrMultipleRoots struct{ First, Second string }
type ErrUnresolvedParent struct{ Child, Parent string }
var ErrCancelled = errors.New("glacier/cli: context cancelled before dispatch")
type ErrPanic struct{ Value any; Stack []byte }
type RequiredError struct{ Name string }
type ChoicesError struct{ Name string; Value string; Choices []string }cli/gen: Generate
// Options configures a Generate call.
type Options struct {
Pattern string // go/packages pattern, e.g. "./..."
OutputName string // defaults to "zz_generated_cli.go"
AppName string // defaults to "cli.Default"
Check bool // drift detection: no files written; non-zero exit if stale
Lint bool // upgrades unknown marker warnings to errors
Logger *slog.Logger
}
// Generate discovers cli.Command implementers, parses +glacier:* markers,
// and emits or checks the generated registration file.
// Idempotent and deterministic across N invocations on the same source.
func Generate(opts Options) errorExamples
Annotated command struct (source file)
Annotate struct fields with +glacier:* doc-comment markers. glaciergen reads these at build time; they produce no runtime overhead.
package main
import "context"
// ServeCmd starts the HTTP server.
//
// +glacier:command name=serve
// +glacier:root
type ServeCmd struct {
// Port is the TCP port to listen on.
//
// +glacier:default 8080
// +glacier:short p
// +glacier:env GLACIER_PORT
Port int
// Host is the interface address to bind.
//
// +glacier:default "0.0.0.0"
Host string
// Verbose enables debug logging.
//
// +glacier:short v
Verbose bool
// Config is the path to the JSON config file.
//
// +glacier:required
// +glacier:env GLACIER_CONFIG
Config string
// Mode selects the operating mode.
//
// +glacier:choices http|grpc|both
// +glacier:default http
Mode string
}
func (s *ServeCmd) Run(ctx context.Context) error {
// ... your server logic here ...
return nil
}Main entrypoint
After running glaciergen ./..., the generated zz_generated_cli.go populates cli.Default. Call Main to dispatch.
package main
func main() {
// cli.Default was populated by init() in zz_generated_cli.go.
// Main dispatches os.Args[1:], handles errors, and calls os.Exit.
cli.Default.Main()
}Programmatic registration (no codegen)
Codegen is optional. Use Register and With* options directly for full programmatic control.
package main
import (
"context"
"os"
"github.com/nathanbrophy/glacier/cli"
"github.com/nathanbrophy/glacier/option"
)
type DeployCmd struct {
Env string
Dry bool
}
func (d *DeployCmd) Run(ctx context.Context) error {
// deployment logic
return nil
}
func main() {
app := cli.New(
cli.WithVersion("v0.1.0"),
cli.WithStdout(os.Stdout),
)
defer app.Close()
if err := app.Register(&DeployCmd{},
cli.WithRoot(),
cli.WithName("deploy"),
cli.WithFlagShort("Env", 'e'),
cli.WithFlagChoices("Env", "staging", "prod"),
cli.WithFlagRequired("Env"),
); err != nil {
panic(err)
}
app.Main()
}Nested subcommand tree (markers)
Parent commands must be registered before their children. The parent= marker accepts a dot-separated path.
// RootCmd is the application root.
//
// +glacier:command name=myapp
// +glacier:root
type RootCmd struct{}
func (r *RootCmd) Run(_ context.Context) error { return nil }
// DeployStaging deploys to the staging environment.
//
// +glacier:command name=staging parent=myapp.deploy
type DeployStaging struct {
Force bool
}
func (d *DeployStaging) Run(_ context.Context) error { return nil }FAQ
Do I have to use codegen? Can I register commands programmatically?
Codegen is optional. cli.Register accepts any value whose concrete type has Run(ctx context.Context) error. The With* option constructors give you full control over names, parents, aliases, flag metadata, and all marker equivalents. The generated file is a convenience, not a requirement. Many integration tests use programmatic registration directly.
How does the subcommand tree get depth beyond one level without codegen?
Call Register on the parent command first (it must be registered before its children), then call Register on the child with WithParent("parentname"). The parent path is a dot-separated sequence of command names from the root: WithParent("root.deploy") places the command under root -> deploy.
What happens if my handler panics?
App.Run recovers the panic and returns *ErrPanic{Value: recovered, Stack: runtimeStack}. Main formats the panic to stderr and exits non-zero. The stack trace is always captured. A build-tagged glacier_debug mode includes the stack in the formatted error display.
Why is the generated file named zz_generated_cli.go with the zz_ prefix?
The zz_ prefix causes the file to sort last in directory listings and most IDEs, making it visually distinct from hand-written source. The DO NOT EDIT comment in the first line is machine-readable by gofmt and most editors.
Does the banner render correctly in all terminals?
The banner uses 24-bit ANSI color escapes on TTY output. When NO_COLOR or GLACIER_NO_COLOR is set to any non-empty value, or when the output is not a TTY, plain-text output is used (no escapes). CI runs banner tests against a PTY emulator to verify ANSI presence.
Can I target a named App other than cli.Default with codegen?
Yes. Add +glacier:command app=MyApp to the command struct (where MyApp must be a valid Go identifier in scope at the generated file's package). The Options.AppName field sets the module-wide default; the per-command app= modifier overrides per struct.