github.com/toaweme/cli is a small, generics-based lib for building command-line apps where a command is just a struct.
Its flags, positional arguments, environment bindings, defaults, and validation rules are declared once as struct tags, and the module does the parsing, merging, validating, help, and dispatch.
cli.NewApp(Config, GlobalFlags)builds an [App]; chain the setters to wire it up.App.Add(name, cmd)registers a command and returns it, so subcommands chain off the result.App.Default(cmd)sets the command that runs on a bare invocation (no args).App.Resolve(...Resolver)registers an ordered config resolver chain (lowest precedence first).App.Help(cmd)registers the help renderer;App.HelpOutputs(...)adds output codecs.App.Run(osArgs)parses, merges, validates, and dispatches.
cli.BaseCommand[T]is embedded in your command struct;Tis the config struct whose tags define the flags and args.cli.NewBaseCommand[T]()constructs it; the parsed config lands inc.Inputs.cli.Command[T]is the interface every command satisfies (mostly free viaBaseCommand):Run, plus help providers (Help,Description,Examples,Args,Flags).cli.IsRealError(err)filters theErrShowingHelp/ErrShowingVersionclean-exit sentinels from genuine failures.cli.Verbosityis an optional embeddable-v/-vv/-vvvflag group with aLevel()query.
Define the config (flags and positional args, via tags) and a command that embeds BaseCommand[ThatConfig]:
type GreetConfig struct {
Name string `arg:"0" env:"GREET_NAME" help:"Name to greet" rules:"required"`
Shout bool `arg:"shout" short:"s" help:"Uppercase the greeting"`
cli.Verbosity // optional -v/-vv/-vvv switches with Level()/Verbose()/AtLeast()
}
type GreetCommand struct {
cli.BaseCommand[GreetConfig]
}
func (c *GreetCommand) Run(_ cli.GlobalFlags, _ cli.Unknowns) error {
msg := fmt.Sprintf("hello, %s!", c.Inputs.Name)
if c.Inputs.Shout {
msg = strings.ToUpper(msg)
}
fmt.Println(msg)
return nil
}
func (c *GreetCommand) Help() string { return "Greet someone by name" }The tags drive everything:
arg:"0"is a positional argument (by zero-based index);arg:"shout"is a named flag (--shout).short:"s"adds-s.env:"GREET_NAME"binds an environment variable.default:"..."seeds a value when nothing else sets it.help:"..."is the one-line help text.rules:"required"(andrules:"oneof:a,b,c") validate the merged value.secret:"true"masks the resolved value in--help-valuesoutput.sep:","splits a single string into a scalar slice ([]string,[]int, ...).
Before Run, the module merges values for the matched command in this order of increasing precedence:
struct default < resolver chain (files / mapping) < environment < parsed flags
Flags always win. With no resolvers registered, only defaults, env, and flags apply. Env is folded in by the core, so file config is entirely optional.
A flag bound to a slice field can be passed more than once, and every occurrence is kept:
type ServeCommand struct {
Tags []string `arg:"tag" short:"t"`
Ports []int `arg:"port" short:"p" sep:","`
}serve -t edge -t beta -t canary # Tags => ["edge", "beta", "canary"]
serve -t edge,beta -t canary # Tags => ["edge", "beta", "canary"] (repeats and sep compose)
serve -p 8080,9090 -p 3000 # Ports => [8080, 9090, 3000]
Repeats and the sep separator stack together. Each occurrence is split on sep
first, then all the pieces are joined into one slice. A scalar (non-slice) field
keeps only the last value, the way it always has. When your values are free-form
and should not be split at all, set sep:"" so each occurrence stays whole.
Add returns the command it registered, so trees chain naturally:
db := app.Add("db", &DBCommand{BaseCommand: cli.NewBaseCommand[struct{}]()})
db.Add("migrate", &MigrateCommand{BaseCommand: cli.NewBaseCommand[struct{}]()})
// runs the leaf: tool db migrateA parent that only groups subcommands (a "namespace" like db, with no behavior of its own) shouldn't need a real command. Register help.NewParentPlaceholder() for it: invoking the parent directly prints its child list instead of doing nothing. Under the hood the placeholder's Run returns cli.ErrDisplaySubCommands, which any command can return to get the same listing:
db := help.NewParentPlaceholder()
app.Add("db", db)
db.Add("migrate", &MigrateCommand{BaseCommand: cli.NewBaseCommand[struct{}]()})
db.Add("seed", &SeedCommand{BaseCommand: cli.NewBaseCommand[struct{}]()})
// `tool db` lists migrate and seed; `tool db migrate` runs the leafFollowing Go's own field-promotion rules (and structs): an anonymous embedded struct has its fields promoted to plain top-level flags (no prefix), while a named (tagged) nested struct groups under a dotted path (database.host). Embed a shared RepoFlags to give several commands the same flags with zero duplication.
--help/-h, --version/-V, --cwd, and --help-format are parsed before dispatch and passed to every Run as cli.GlobalFlags. The reserved shorts are deliberately minimal (-h, -V) so they never squat on your own DX: -v, -c, and --format stay yours. -h and -V trigger regardless of position. Help and version are handled by the module, which then returns the ErrShowingHelp / ErrShowingVersion sentinels; IsRealError filters them at the call site.
go get github.com/toaweme/clipackage main
import (
"fmt"
"os"
"strings"
"github.com/toaweme/cli"
)
type GreetConfig struct {
Name string `arg:"0" env:"GREET_NAME" help:"Name to greet" rules:"required"`
Shout bool `arg:"shout" short:"s" help:"Uppercase the greeting"`
cli.Verbosity // optional -v/-vv/-vvv switches with Level()/Verbose()/AtLeast()
}
type GreetCommand struct {
cli.BaseCommand[GreetConfig]
}
func (c *GreetCommand) Run(_ cli.GlobalFlags, _ cli.Unknowns) error {
msg := fmt.Sprintf("hello, %s!", c.Inputs.Name)
if c.Inputs.Shout {
msg = strings.ToUpper(msg)
}
fmt.Println(msg)
if c.Inputs.Verbose() {
fmt.Printf("verbosity: %d\n", c.Inputs.Level())
}
return nil
}
func (c *GreetCommand) Help() string { return "Greet someone by name" }
func main() {
app := cli.NewApp(
cli.Config{Name: "greet", Version: "1.0.0"},
cli.GlobalFlags{},
)
app.Add("hello", &GreetCommand{BaseCommand: cli.NewBaseCommand[GreetConfig]()})
if err := app.Run(os.Args[1:]); cli.IsRealError(err) {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}greet hello Ada # hello, Ada!
greet hello Ada --shout # HELLO, ADA!
greet hello Ada -vv # hello, Ada! + "verbosity: 2"
GREET_NAME=Ada greet hello # hello, Ada! (env binding)
greet hello --help # generated help for the command
greet --version # greet 1.0.0- Command-as-struct - declare flags, positional args, env bindings, defaults, validation, and help once as struct tags; embed
BaseCommand[T]and implementRun. The parsed config isc.Inputs. - Layered config merge -
default< resolver chain < env < flags, in that order, with flags always winning. Env is folded by the core, so file config stays optional. - Decoupled resolvers - the only config seam in core is the
Resolverinterface; resolvers compose like middleware. The core never imports the file-config package. - Subcommand trees -
Addchaining and parent placeholders; a default command for bare invocation. - Type coercion and slice splitting - loosely typed inputs (an env string
"9090") land in the field's real type; a single string splits into a scalar slice viasep. - Repeatable flags - pass a flag bound to a slice field more than once and the values pile up instead of clobbering each other, so
-t a -t bgives you both. - Embedded and nested config - embedded structs promote to top-level flags (no prefix); named nested structs group under a dotted path.
- Minimal, non-squatting globals - only
-hand-Vare reserved;--cwdis long-only and help formatting is--help-format, leaving-v/-c/--formatfor you. - Optional verbosity - embed
cli.Verbosityfor-v/-vv/-vvvwithLevel()/Verbose()/AtLeast(); the module imposes no verbosity of its own. - Clean-exit sentinels -
ErrShowingHelp/ErrShowingVersionplus theIsRealErrorhelper so the call site filters them in one call. - Rich, multi-format help - one-line
Help()plusDescription,Examples,Args,Flagsproviders; output asplain,pretty,md,json, orjsonschema, with pluggableOutputCodecs. - Resolved-value help -
--help-valuesannotates each flag with its merged value (defaults < config < env < flags), with secrets prefix-masked so they never leak into pasted help. - Shell completion -
bash/zsh/fishscripts and the__completehook viacommands/completion. - Docs generation -
commands/gendocsrenders the app's own command tree to files in every help format, using the same in-process renderers as--help-format, so docs never go stale. .envloading -LoadDotEnv()sets unset env vars;GetDotEnv()/GetDotEnvs()parse into a map without touching the environment.
The core is dependency-light. Pull these in only when you need them:
commands/help-help.NewHelpCommand(...)(register withapp.Help(...)) andhelp.NewParentPlaceholder()for grouping subcommands.commands/completion-completion.NewCompletionCommand(appName)for shell completion scripts.commands/gendocs-gendocs.NewGenDocsCommand(...)to generate reference docs.config- file-backed configuration:config.NewFileStore(dir, name, ensureConfigDir, codec...)- one config file with whole-file (Read/Write/Exists/Delete) and dotted-key (KeyRead/KeyWrite/...) access. Reads create nothing and report absence explicitly (ErrConfigNotFound/ErrKeyNotFound).config.FileSecrets(dir, codec...)- the same store at 0600, namedsecrets.config.NewResolver(store, rules)- one resolver per store, satisfyingcli.Resolverstructurally; layer several viaapp.Resolve(global, project, secrets). Optional per-command field mapping rules.config.Discover(...)/config.HomePath(appName)helpers;~home expansion.- Codecs are addons:
config/addons/json,config/addons/yaml,config/addons/toml(eachNew(exts...)); JSON is the default and YAML/TOML are separate modules carrying their own third-party deps. The CLI works with none registered.
A fully wired app using all of the above:
app := cli.NewApp(
cli.Config{Name: "full", Version: "0.1.0"},
cli.GlobalFlags{},
).Resolve(
config.NewResolver(config.NewFileStore(config.HomePath("full"), "config", true), nil),
config.NewResolver(config.NewFileStore(cwd, "config", true), nil),
config.NewResolver(config.FileSecrets(config.HomePath("full")), nil),
)
app.Help(help.NewHelpCommand(app.Config, app.Commands, app.OutputFormats, app.DefaultCommand))
app.Add("completion", completion.NewCompletionCommand("full"))
app.Add("gendocs", gendocs.NewGenDocsCommand(app.Config, app.Commands, app.OutputFormats))See example_test.go for short, runnable versions of everything above, and examples/ for complete programs (basic, greet, server, deploy, full, full_3rd_party).
go test -run Example -vcli uses an issue-first workflow. Open an issue describing the change and wait for a maintainer to approve the approach (the approved label) before you open a pull request. PRs that don't reference an approved issue are flagged by a bot and usually closed, so the issue step saves you wasted work.
Every commit must be signed off for the Developer Certificate of Origin with git commit -s. A CI check enforces this on every commit in a pull request.
Full flow in CONTRIBUTING.md, ground rules in CODE_OF_CONDUCT.md.
Reports for this repo are hosted by our code viewer, which also serves the badges and cards above.
Made with ❤️ in Lithuania 🇱🇹.
