cli: add CLI framework package#22
Conversation
Adds github.com/runreveal/lib/cli, a simple Configure-Validate-Run CLI framework that integrates with loader for HuJSON config file loading. Key features: - Struct tag-driven flags (cli:"name,alias", default:"val", usage:"...") - Config file loading via loader.LoadConfig with gjson path extraction - Embedded struct composition for shared/global flags - Command/Group tree with recursive routing - Middleware chain, Validator interface, ArgsFunc validation - ExitError for custom exit codes, panic recovery - IsSet() to detect explicitly-set flags during Run
Team ReviewProduct Manager with TasteVerdict: Approve with suggestions
Principal Software EngineerVerdict: Needs changes
Blocking changes:
Devil's AdvocateVerdict: Suggestions
Sysadmin GreybeardVerdict: Approve with suggestions
SummaryOverall: Approve with changes Top priorities (blocking):
High-value (non-blocking): Nice-to-haves: CI note: Build failure is unrelated — |
- config.go: use github.com/segmentio/encoding/json for unmarshalIntoField - config.go: add replaceEnvInJSON for env-var substitution on raw JSON bytes, since loader.LoadConfig's reflection-based pass is opaque to json.RawMessage - cli.go: replace handlerFlagSets sync.Map with context-carried *FlagSet; add FlagSetFromContext and update IsSet(ctx, name) signature - cli.go: fix DumpConfig to reflect actual handler field values instead of returning "(set)" for explicitly-set flags - cli.go: collapse Command+CommandWithOptions into a single Command(...any) that type-switches each element as Node or CmdOption - flags.go: remove dead DumpValues method (superseded by DumpConfig) - cli_test.go: update tests for new IsSet/Command APIs; add HuJSON and env-var replacement config tests - example/main.go: update to new Command API
- Update CI workflow from Go 1.21 to 1.25 (fixes golines install) - Update cli and cli/example go.mod to Go 1.25 - Fix lines exceeding 128 char limit (golines -m 128)
This experiment flag was removed in Go 1.25 and causes build failures.
- WithGlobals(ptr) registers app-level flags available on all commands, eliminating the need to embed a Globals struct in every command - GlobalsFromContext[T](ctx) retrieves the globals pointer in handlers - ConfigAt(key, dst) registers a config file section to unmarshal into a command's field, as an alternative to config:"key" struct tags - Config flag can now live on globals instead of each handler - Both patterns (struct tags and ConfigAt) work and can be mixed
Globals and handler structs can now implement the full CVR pattern: - Configurer: Configure() called after config loading to init resources - Validator: Validate() called after Configure to check readiness - io.Closer: Close() deferred for globals cleanup after command exits Lifecycle order: 1. Parse flags (globals + handler) 2. Load config file → populate struct fields 3. Globals: Configure → Validate (with deferred Close) 4. Handler: Configure → Validate 5. Middleware → Handler.Run 6. Globals.Close
… wrapping - Replace getBool closure with isBool bool field — the closure return value was never called, only its nil-ness was checked - Remove redundant ExitError unwrap in executeCommand — the caller App.Run already handles ExitError extraction - Wrap handler Configure/Validate errors consistently with globals - Add comment explaining why replaceEnvInJSON is duplicated from loader
- Hidden `completion` command outputs shell-specific scripts - Hidden `__complete` command provides runtime completions - Completer interface for custom positional arg completions - Completes subcommands, flags (including globals), and custom values - Neither command appears in help output
- Example shows three await patterns: single server, multi-service daemon, and context-based worker loop - Fix unchecked addGlobalsToFlagSet errors in complete.go and help.go
- WithDefaultConfig(data) registers a default config (typically go:embed) - Hidden "default-config" command prints it to stdout - Example includes config.json with HuJSON comments demonstrating all config sections (database, daemon, worker) - Commands use ConfigAt + Configure() to apply config file values
Rename MigrateCmd.DB (a database name flag) to MigrateCmd.Target to avoid confusion with ServeCmd.DB (a DBConfig struct from config file). ConfigAt is designed for config-only struct fields that have no corresponding CLI flag. For values that need both flag and config file support, use the config:"key" struct tag on the flag field (which checks IsSet for proper precedence), or load into a separate Cfg field and reconcile in Configure() as DaemonCmd demonstrates.
ConfigAt added complexity without clear value over the globals pattern for shared resources. Removed entirely. The example now demonstrates the loader polymorphic config pattern: - Source interface with webhook and syslog implementations - Cache interface with memory implementation - loader.Register + loader.Loader[T] for type-driven config - Globals.Configure() initializes resources from config - Commands access shared resources via GlobalsFromContext
Tests: - Table-driven config precedence tests (flag > config > default) - Pointer type flag coverage (*string, *int, *float64, *uint64, *bool, *Duration) - Config error paths (missing file, invalid JSON) - DumpConfig, defcon, ExitError, MinArgs - Globals Configure/Validate error paths - Handler Configure error path - Short flag and long flag edge cases Fixes: - Handle error from a.output.Write in handleDefaultConfig - Use subshells in Makefile for/cd loops so failures don't break subsequent iterations with stale working directory
cli: HelpExtra interface lets handlers and globals append extra text to help output. Useful for showing available loader types without the cli package knowing about loader. loader: List[T]() returns sorted registered type names. Describe[T]() returns a zero-value builder for introspection (e.g. reflecting on JSON struct tags for help output). Example shows the bridge pattern: Globals.ExtraHelp() uses loader.List and loader.Describe to render available source and cache types with their config fields directly in --help output.
Config fields (Sources, Cache, Server) now live directly on Globals with individual config:"key" tags instead of being nested in an AppConfig wrapper with config:".". Clearer, no indirection. Also removed the empty Configure() on ServeCmd — the addr fallback to g.Server.Addr is handled directly in Run.
Builder types implement Help() string to provide their own description and example config. No reflection — each type owns its documentation. loader: add Helper interface checked via type assertion from Describe.
Completion scripts, __complete output, and defcon all write to stdout (via a.stdout) since shells read completions from stdout. Errors and help continue to go to stderr (via a.output). WithOutput sets both for testing convenience.
App help now shows "Use myapp defcon to print the default configuration" when a default config is registered. Extracted defconCmd() helper to deduplicate the command name logic.
…ling - Command() now panics on unsupported option types instead of silently dropping them (catches misuse at init time) - defcon command matches on args[0] regardless of arg count, so "myapp defcon --help" works instead of falling through to routing - Add aliasing comment on reflect.go slice construction
Final Team Review (Pre-Merge)Product ManagerVerdict: Approve
Principal EngineerVerdict: Approve (blocking items fixed) Fixed in latest commit:
Non-blocking notes for future iterations:
Devil's AdvocateVerdict: Approve
Sysadmin GreybeardVerdict: Approve
SummaryOverall: Approve All three blocking items from review have been addressed. The package is well-tested (84% coverage), well-documented, and the API surface is clean. The loader integration (List, Describe, Helper) is minimal and well-designed. Remaining nice-to-haves (post-merge):
|
Summary
clipackage: a struct-driven CLI framework that replaces Cobra+ViperloaderandawaitintegrationDesign
cli:"name,alias"struct tags define flagsconfig:"json.path"struct tags bind fields to config file sectionsValidatorinterface is opt-in (no empty Validate methods)WithConfigFlagloads one root config file vialoader.LoadConfigTest plan
go test ./cli/...