cli

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 27, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

README

cli

A command tree, flags that read the environment, and generated help — everything a container-deployed service needs from its entrypoint, on the standard library alone.

Go Reference

Status: v0, released. Production-track at Wigata InTech, cli/migrationx included. Semver v0 applies: the API can still move between minor versions until v1.0.0, which lands only after surviving production use.

TL;DR

  • Command tree dispatched from os.Args, context-first, plain-func handlers — exit codes 0/1/2
  • Every flag settable four ways with one fixed precedence: flag > env > *_FILE > config file > default
  • Env names derived mechanically (--http-addr → MY_SERVICE_HTTP_ADDR) — the mapping is never documented by hand, and --help prints it
  • Secret flags: masked defaults in help, error messages that never echo the value
  • Struct binding: fs.Bind(&cfg) declares flags from your config struct's tags — required fields fail at startup, not mid-run — and LoadDotEnv feeds a .env file into the environment layer
  • JSON config built in; other formats plug in through a Decoder seam — this module never grows parsers
  • Zero dependencies, zero require lines, CGO-free

What problem this solves

A real service entrypoint is never just main(): it is serve, migrate, version — subcommands with flags, most of which must also come from environment variables, because that is how containers configure things. The stdlib flag package covers a third of that and stops: no subcommands, no env binding, no unified help.

The ecosystem's answer, cobra + viper, works — and carries remote config backends, live reload, YAML parsing, and shell completion into every binary that just wanted three subcommands and env-settable flags. cli is the missing floor without the freight: the tree, the precedence chain, the generated help, and nothing else.

How it solves it

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	os.Exit(root().Execute(ctx))
}

func root() *cli.Command {
	var addr string
	return &cli.Command{
		Name:    "my-service",
		Short:   "example service",
		Version: version, // stamped via -ldflags "-X main.version=..."
		Config:  cli.ConfigFile{Flag: "config"},
		Commands: []*cli.Command{{
			Name:  "serve",
			Short: "start the HTTP server",
			Flags: func(fs *cli.FlagSet) {
				fs.StringVar(&addr, "http-addr", ":8080", "listen address")
			},
			Run: func(ctx context.Context, args []string) error {
				return serve(ctx, addr)
			},
		}},
	}
}

--http-addr :9090 on the command line, MY_SERVICE_HTTP_ADDR in the environment, MY_SERVICE_HTTP_ADDR_FILE naming a mounted secret file, or {"http-addr": ":9090"} in the config file — first one present wins, in exactly that order. Flags declared on a command are visible to all its descendants; FlagSet mirrors the flag.FlagSet constructors exactly, so nothing new to learn there.

Or skip individual declarations and make your config struct the schema:

type Config struct {
	HTTPAddr   string        `cli:"http-addr" default:":8080" usage:"listen address"`
	DBPassword string        `cli:"db-password,secret,required" usage:"database password"`
	Timeout    time.Duration `default:"5s" usage:"request timeout"` // name derives: timeout
}

var cfg Config
// inside Flags: fs.Bind(&cfg)

Every layer resolves into cfg before Run executes. A required field no layer supplied stops the process at startup — exit 2, naming the flag and its env var — never mid-request. cli.LoadDotEnv(".env") before Execute feeds a dotenv file into the environment layer (real environment wins), for bare-machine runs without an orchestrator.

Secrets travel by env or *_FILE, never by flag (argv is world-readable via ps). Mark one with fs.Secret("db-password") and its default renders as <secret> in help while parse errors carry the flag name only — a credential never reaches stderr or CI logs.

Using migrationx

The schema half of the module: timestamped SQL migration pairs, a checksummed history table, database-side locking, refuse-by-default late-merge handling. One append wires the whole migrate create|up|down|status tree into your root:

root.Commands = append(root.Commands, migrationx.Command(openMigrator))

The file format, annotations, standalone use, and operational rules: migrationx/README.md.

Why it matters

  • Conventions you already know: POSIX/GNU flag syntax, -- terminator, help to stdout, errors to stderr, exit codes 0/1/2 — script-friendly by construction
  • Library, never a binary: each deployable assembles its own tree in its own package main, so the linker's dead-code elimination keeps every binary to exactly what it imports
  • No second source of truth: the env mapping is derived, printed in --help, and covered by the same precedence tests that gate every release
  • No lock-in: handlers are func(ctx, args) error; leaving cli is deleting the tree, not rewriting the service

What it costs

Everything happens once at process start — there is no hot path. Measured on a MacBook Pro — Apple M2 Pro (10 cores), 16 GB RAM, macOS 27.0, go1.26.8.

cd cli && go test -run='^$' -bench=. -benchmem ./...
Raw output
goos: darwin
goarch: arm64
pkg: github.com/Wigata-Intech/w-tools/cli
cpu: Apple M2 Pro
BenchmarkExecute-10                	  361466	      2847 ns/op	    4488 B/op	      69 allocs/op
BenchmarkExecuteEnvAndConfig-10    	   59241	     20252 ns/op	    7320 B/op	      95 allocs/op
PASS
ok  	github.com/Wigata-Intech/w-tools/cli	2.710s
goos: darwin
goarch: arm64
pkg: github.com/Wigata-Intech/w-tools/cli/migrationx
cpu: Apple M2 Pro
BenchmarkParseScript-10    	   29581	     37752 ns/op	   42320 B/op	     509 allocs/op
BenchmarkLoad-10           	    6304	    183409 ns/op	  130201 B/op	    3443 allocs/op
BenchmarkUpTen-10          	   25263	     47847 ns/op	   40046 B/op	     562 allocs/op
PASS
ok  	github.com/Wigata-Intech/w-tools/cli/migrationx	4.708s
Measure Result
Full dispatch (subcommand + flag), BenchmarkExecute ~2.8 µs, 69 allocs — once per process
Every layer active (env + config file read/decoded), BenchmarkExecuteEnvAndConfig ~20 µs, 95 allocs — once per process
Binary size: hello-world main 1.6 MB (-trimpath -ldflags "-s -w")
the same main on cli 2.1 MB — +0.5 MB
the same main on cobra + viper 4.7 MB — +3.1 MB, 6× the cli delta

Structural notes, honest ones:

  • Parsing re-registers visible flags per tree level during dispatch — that is the ~2 µs above, zero afterward
  • JSON is the only built-in config format; YAML/TOML mean waiting for (or writing) a Decoder
  • No shell completion, no live config reload, no remote config — deliberate omissions, documented in the design, revisited only on evidence

Fuzzing runs three contract targets — argv dispatch with the secret-leak invariant, the env-name binding proof, and the config decoder against a stdlib differential oracle:

Fuzzing — commands and raw output
$ go test -run='^$' -fuzz=FuzzExecute -fuzztime=10s .
$ go test -run='^$' -fuzz=FuzzEnvName -fuzztime=10s .
$ go test -run='^$' -fuzz=FuzzDecodeJSON -fuzztime=10s .

The promises

As of v0.1.0:

  • The precedence order — flag > env > *_FILE > config file > default — is contract, not implementation detail
  • A Secret-marked flag's value never appears in help or error output
  • Conflicting configuration fails closed: an env var and its _FILE variant both set, or a config file that sets its own path flag, abort with exit 2 — while config keys other subcommands own are simply ignored, so one file serves the whole binary
  • Zero third-party dependencies, permanently; the go.mod stays empty and dependabot exists to scream if it doesn't

Documentation

Overview

Package cli is a zero-dependency command framework: a command tree dispatched from os.Args, flags with environment binding, layered configuration, and generated help.

Commands compose into a tree and dispatch context-first; handlers are plain funcs. Flags declared on a command are visible to it and all of its descendants, and every flag resolves with one fixed precedence:

explicit flag > environment variable > *_FILE indirection > config file > default

Environment names derive mechanically from flag names — with prefix MY_SERVICE, --http-addr binds to MY_SERVICE_HTTP_ADDR and MY_SERVICE_HTTP_ADDR_FILE — so the mapping is never documented by hand. The prefix is per-binary, derived from the root command's name, overridable via EnvPrefix, and disabled with NoPrefix.

Config files are JSON (the stdlib format); other formats plug in through the Decoder seam without cli growing parsers.

Exit codes follow convention: 0 success, 1 a command's Run returned an error, 2 usage, environment, or configuration error. Help and version output go to stdout; errors go to stderr.

Index

Constants

View Source
const NoPrefix = "-"

NoPrefix disables environment variable prefixing when set as a root Command's EnvPrefix: --http-addr binds to HTTP_ADDR. An empty EnvPrefix derives the prefix from the root command's name instead.

Variables

This section is empty.

Functions

func LoadDotEnv

func LoadDotEnv(path string) error

LoadDotEnv loads KEY=VALUE lines from path into the process environment, then returns — the environment layer picks the values up through the normal precedence, so keys carry the same prefixed names real environment variables do. Call it before Execute.

Variables already present in the environment win: the file supplies defaults for a bare machine, the real environment — a container orchestrator, CI — always overrides it.

The format is the minimal dotenv subset: one KEY=VALUE per line, blank lines and #-comments skipped, whitespace trimmed around the key, matching single or double quotes stripped from the value, a leading UTF-8 BOM ignored. A key containing whitespace — including shell-style "export KEY=..." lines — is a line-numbered error, never a silently wrong variable. No interpolation, no escapes, no multiline values — a file needing those belongs to an orchestrator's env_file, not to this loader.

Types

type Command

type Command struct {
	// Name is the binary name on the root command and the verb on a
	// subcommand ("serve", "migrate").
	Name string

	// Short is the one-line description shown in the parent's command list.
	Short string

	// Long is the description shown in this command's own help; Short is
	// used when Long is empty.
	Long string

	// Flags declares this command's flags on the given set. Declared
	// flags are visible to this command and all of its descendants;
	// redeclaring an inherited name panics at dispatch.
	Flags func(fs *FlagSet)

	// Run executes the command; args are the operands left after flag
	// parsing. A command with subcommands may leave Run nil — invoking it
	// bare then prints help.
	Run func(ctx context.Context, args []string) error

	// Commands are the subcommands, matched by Name.
	Commands []*Command

	// Version enables the version subcommand and the --version flag,
	// printing "<name> <version>". Root-only.
	Version string

	// EnvPrefix overrides the environment prefix derived from Name; the
	// NoPrefix sentinel disables prefixing. Root-only.
	EnvPrefix string

	// Config enables config file support; the zero value disables it.
	// Root-only.
	Config ConfigFile
	// contains filtered or unexported fields
}

Command is one node of a command tree. The zero value is unusable; set at least Name. Fields marked root-only are ignored on subcommands.

func (*Command) Execute

func (c *Command) Execute(ctx context.Context) int

Execute parses os.Args[1:], dispatches through the tree, resolves every visible flag with the documented precedence, and runs the resolved command. It returns the process exit code: 0 success, 1 the command's Run returned an error, 2 usage, environment, or configuration error.

type ConfigFile

type ConfigFile struct {
	// Flag is the flag name carrying the path, e.g. "config"; empty
	// disables config file support.
	Flag string

	// Decoder decodes the file into flag values. Nil means JSON: a flat
	// object keyed by flag name, with string, number, or bool values.
	Decoder Decoder
}

ConfigFile enables config file support on a root Command. The named flag carries the file path; if the root does not declare it, cli declares it as a string flag with an empty default. A path set explicitly (flag or environment) must exist; a non-empty default path that does not exist is skipped.

type Decoder

type Decoder func(data []byte) (map[string]string, error)

Decoder decodes config file bytes into a flat map keyed by flag name. Values are strings and flow through flag.Value.Set exactly like command-line input. Format packages plug in here.

type FlagSet

type FlagSet struct {
	// contains filtered or unexported fields
}

FlagSet declares a command's flags. It mirrors the flag.FlagSet constructors exactly — String, Bool, Int, Int64, Float64, Duration, their *Var forms, and Var — and adds only Secret. Values set from the environment or a config file flow through the same flag.Value.Set path as command-line input.

func (*FlagSet) Bind

func (fs *FlagSet) Bind(v any)

Bind declares one flag per exported field of the struct v points to — the struct is the schema, and every configuration layer resolves into it through the declared flags.

The `cli` tag names the flag and carries options; without it the name derives from the field name (HTTPAddr → http-addr, DBPassword → db-password). `cli:"-"` skips the field.

type Config struct {
    HTTPAddr   string        `cli:"http-addr" default:":8080" usage:"listen address"`
    DBPassword string        `cli:"db-password,secret,required" usage:"database password"`
    Timeout    time.Duration `usage:"request timeout"`
}

Options: `secret` marks the flag as Secret; `required` makes startup fail with exit 2 unless some layer supplies a value. The `default` tag sets the default; without it the field's current value is the default. A field that is `required` cannot also carry a `default` — the pair is contradictory and panics at declaration.

Supported field types: string, bool, int, int64, float64, time.Duration, and any field whose pointer implements flag.Value. Anything else panics at declaration — a programmer error, caught at boot. Unexported fields are skipped. A required field must start at its zero value.

Bind assumes one Execute per process: without a default tag the field's current value is the default, so re-executing the same tree treats the previous run's result as the new default.

func (*FlagSet) Bool

func (fs *FlagSet) Bool(name string, value bool, usage string) *bool

Bool defines a bool flag with specified name, default value, and usage string. The return value is the address of a bool variable that stores the value of the flag.

func (*FlagSet) BoolVar

func (fs *FlagSet) BoolVar(p *bool, name string, value bool, usage string)

BoolVar defines a bool flag with specified name, default value, and usage string. The argument p points to a bool variable in which to store the value of the flag.

func (*FlagSet) Duration

func (fs *FlagSet) Duration(name string, value time.Duration, usage string) *time.Duration

Duration defines a time.Duration flag with specified name, default value, and usage string. The return value is the address of a time.Duration variable that stores the value of the flag.

func (*FlagSet) DurationVar

func (fs *FlagSet) DurationVar(p *time.Duration, name string, value time.Duration, usage string)

DurationVar defines a time.Duration flag with specified name, default value, and usage string. The argument p points to a time.Duration variable in which to store the value of the flag.

func (*FlagSet) Float64

func (fs *FlagSet) Float64(name string, value float64, usage string) *float64

Float64 defines a float64 flag with specified name, default value, and usage string. The return value is the address of a float64 variable that stores the value of the flag.

func (*FlagSet) Float64Var

func (fs *FlagSet) Float64Var(p *float64, name string, value float64, usage string)

Float64Var defines a float64 flag with specified name, default value, and usage string. The argument p points to a float64 variable in which to store the value of the flag.

func (*FlagSet) Int

func (fs *FlagSet) Int(name string, value int, usage string) *int

Int defines an int flag with specified name, default value, and usage string. The return value is the address of an int variable that stores the value of the flag.

func (*FlagSet) Int64

func (fs *FlagSet) Int64(name string, value int64, usage string) *int64

Int64 defines an int64 flag with specified name, default value, and usage string. The return value is the address of an int64 variable that stores the value of the flag.

func (*FlagSet) Int64Var

func (fs *FlagSet) Int64Var(p *int64, name string, value int64, usage string)

Int64Var defines an int64 flag with specified name, default value, and usage string. The argument p points to an int64 variable in which to store the value of the flag.

func (*FlagSet) IntVar

func (fs *FlagSet) IntVar(p *int, name string, value int, usage string)

IntVar defines an int flag with specified name, default value, and usage string. The argument p points to an int variable in which to store the value of the flag.

func (*FlagSet) Required

func (fs *FlagSet) Required(name string)

Required marks a declared flag as mandatory: unless some layer — command line, environment, *_FILE, or config file — supplies a value, Execute fails with exit 2 before Run. Marking an undeclared flag panics.

func (*FlagSet) Secret

func (fs *FlagSet) Secret(name string)

Secret marks a declared flag as secret: generated help masks its default value and error messages never echo its value. Marking an undeclared flag panics — a programmer error, caught at boot.

func (*FlagSet) String

func (fs *FlagSet) String(name, value, usage string) *string

String defines a string flag with specified name, default value, and usage string. The return value is the address of a string variable that stores the value of the flag.

func (*FlagSet) StringVar

func (fs *FlagSet) StringVar(p *string, name, value, usage string)

StringVar defines a string flag with specified name, default value, and usage string. The argument p points to a string variable in which to store the value of the flag.

func (*FlagSet) Var

func (fs *FlagSet) Var(value flag.Value, name, usage string)

Var defines a flag with the specified name and usage string, backed by the given flag.Value.

Directories

Path Synopsis
Package migrationx is a SQL migration engine on the standard library: ordered SQL files, a history table with checksums, transactions where the database supports them, and a migration lock — with database drivers staying entirely on the consumer's side.
Package migrationx is a SQL migration engine on the standard library: ordered SQL files, a history table with checksums, transactions where the database supports them, and a migration lock — with database drivers staying entirely on the consumer's side.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL