Skip to content

cli: add CLI framework package#22

Merged
abraithwaite merged 24 commits into
mainfrom
add-cli-package
Apr 6, 2026
Merged

cli: add CLI framework package#22
abraithwaite merged 24 commits into
mainfrom
add-cli-package

Conversation

@forge-coding-environment

Copy link
Copy Markdown

Summary

  • Adds a new cli package: a struct-driven CLI framework that replaces Cobra+Viper
  • Commands are Go structs with tagged fields that become flags and config file bindings automatically
  • Implements Configure-Validate-Run lifecycle with native loader and await integration
  • Supports POSIX-style flags, embedded struct composability, middleware, and auto-generated help

Design

  • cli:"name,alias" struct tags define flags
  • config:"json.path" struct tags bind fields to config file sections
  • Precedence: explicit CLI flag > config file value > default
  • Validator interface is opt-in (no empty Validate methods)
  • Middleware for cross-cutting concerns (logging, tracing)
  • WithConfigFlag loads one root config file via loader.LoadConfig

Test plan

  • Review struct tag parsing and flag generation
  • Review config file loading and precedence logic
  • Review help output formatting
  • Run go test ./cli/...

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
@abraithwaite

Copy link
Copy Markdown
Member

Team Review

Product Manager with Taste

Verdict: Approve with suggestions

  • The API is dramatically simpler than Cobra+Viper. A command is a struct with tags and a Run method — that's it. A new engineer can understand a command in seconds.
  • The example program is excellent — demonstrates globals embedding, config file loading, middleware, positional args, groups, and validation in 117 lines.
  • CommandWithOptions is awkward. The []CmdOption slice parameter feels clunky compared to the clean variadic pattern elsewhere. Consider eliminating it and folding options into Command more naturally.

Principal Software Engineer

Verdict: Needs changes

  • Overall architecture is sound — clean separation between cli.go (orchestration), flags.go (parsing), reflect.go (struct scanning), config.go (file loading), help.go (output).
  • handlerFlagSets global sync.Map (cli.go:73) is a code smell. It maps handler pointers to flag sets so IsSet() works. This couples IsSet() to the execution lifecycle via global state. Cleaner: pass the FlagSet through context, or make IsSet a method on a context-carried type.
  • DumpConfig (cli.go:431) returns "(set)" as the value for explicitly-set flags instead of the actual value. This defeats the purpose of a config dump for debugging.
  • config.go uses encoding/json for unmarshaling config sections but the config was processed by loader.LoadConfig using segmentio/encoding/json. This inconsistency could cause subtle behavior differences.
  • flags.go type switch (~180 lines) for makeFlagDef could be simplified — the pointer-type variants double the code unnecessarily.
  • Tests: 693 lines is solid coverage. Config precedence test (TestConfig_CLIFlagOverridesConfig) is present and critical. However, tests aren't table-driven where they could be.

Blocking changes:

  1. config.go: Use github.com/segmentio/encoding/json instead of encoding/json for unmarshalIntoField to match loader.
  2. DumpConfig should return actual field values, not "(set)".

Devil's Advocate

Verdict: Suggestions

  • No test for HuJSON features in config files. The config path goes through loader.LoadConfig which supports comments and trailing commas, but no test verifies this end-to-end.
  • No test for env var replacement in config files. This is a key feature of loader.LoadConfig — the test suite should verify "$MY_VAR" replacement works through the CLI framework.
  • Embedded struct diamond problem: If two embedded structs both define cli:"verbose", buildFlagSet returns "duplicate flag --verbose" — correct behavior, but the error message doesn't tell you which embedded structs conflict.

Sysadmin Greybeard

Verdict: Approve with suggestions

  • Panic recovery with stack trace logging via slog is exactly right.
  • Help output is clean and shows defaults.
  • Config file not-found behavior is correct: silently skips if not explicitly set, errors if it was.
  • Consider adding --dump-config as a built-in flag for operational debugging.

Summary

Overall: Approve with changes

Top priorities (blocking):

  1. Fix encoding/json inconsistency in config.go — use segmentio/encoding/json to match loader
  2. Fix DumpConfig returning "(set)" instead of actual values

High-value (non-blocking):
3. Eliminate CommandWithOptions — fold options into Command more naturally
4. Add tests for HuJSON config and $ENV_VAR replacement through the framework
5. Replace handlerFlagSets global with context-carried state

Nice-to-haves:
6. Table-driven test refactoring
7. Built-in --dump-config flag
8. Better error messages for duplicate flags from embedded struct conflicts

CI note: Build failure is unrelated — golines requires Go 1.23+ but CI runs 1.21.

- 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
@abraithwaite

Copy link
Copy Markdown
Member

Final Team Review (Pre-Merge)

Product Manager

Verdict: Approve

  • API is clean and intuitive — struct tags, Command/Group, WithGlobals, lifecycle interfaces. A Go developer will feel at home.
  • README accurately reflects the implementation. Code examples match the actual signatures.
  • defcon is whimsical but the help footer makes it discoverable. Overridable via WithDefaultConfigCommand for serious contexts.
  • Command(...any) silently dropped bad types — fixed: now panics with a descriptive message.

Principal Engineer

Verdict: Approve (blocking items fixed)

Fixed in latest commit:

  • Command() now panics on unsupported option types instead of silently swallowing them
  • defcon with extra args (e.g., --help) no longer falls through to "unknown command"
  • Added aliasing comment on reflect.go:36 slice construction

Non-blocking notes for future iterations:

  • io.Closer is only called on globals, not handlers — worth documenting
  • []string flag + default tag interaction (appends, doesn't reset) could surprise — consider a test
  • config:"key" collision between globals and handler both get populated; undocumented but harmless
  • GoDoc coverage is good across all exports
  • All README code examples match the implementation

Devil's Advocate

Verdict: Approve

  • Globals mutation across multiple app.Run() calls (e.g., in tests) retains state. Not a real-world issue since CLIs run once, but worth a doc comment on WithGlobals eventually.
  • No race conditions — App is single-goroutine by design, globals pointer is immutable in context.
  • replaceEnvInJSON duplication between cli and loader is documented with a clear comment explaining why both exist (pinned loader version may not have it).

Sysadmin Greybeard

Verdict: Approve

  • Error messages are actionable: flag names, file paths, phase names all included.
  • Panic recovery with stack traces via slog — production services won't die silently.
  • Help footer shows defcon when a default config is registered — good discoverability.
  • Completion scripts are correct for bash/zsh. Fish strips descriptions unnecessarily (minor).
  • --version only works at top level as sole arg — matches cobra behavior.

Summary

Overall: 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):

  1. Document io.Closer only on globals in README lifecycle section
  2. Test []string flag + default interaction
  3. Fish completion could pass descriptions through instead of stripping

@abraithwaite abraithwaite merged commit 865815b into main Apr 6, 2026
1 check passed
@abraithwaite abraithwaite deleted the add-cli-package branch April 6, 2026 23:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant