From b2a638612d4bcab369fd59a7eff97b14b714c07e Mon Sep 17 00:00:00 2001 From: Mike Fridman Date: Fri, 21 Aug 2026 11:23:23 +0200 Subject: [PATCH 1/2] feat: add typed flag names --- CHANGELOG.md | 4 ++++ cli.go | 24 ++++++++++++++++++++---- cli_test.go | 14 ++++++++++++++ examples/cmd/echo/main.go | 7 ++++--- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f484a0d..ac15cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Typed `FlagName` values that let `State.GetFlag` infer its return type + ### Changed - **BREAKING**: Replace the top-level `GetFlag` function with the generic `State.GetFlag` method diff --git a/cli.go b/cli.go index 205959b..3ed1262 100644 --- a/cli.go +++ b/cli.go @@ -162,6 +162,16 @@ type FlagConfig struct { Local bool } +// FlagName associates a flag's canonical name with its Go type. Pass a FlagName to [State.GetFlag] +// to infer the returned type instead of specifying it at each lookup. +// +// const verbose FlagName[bool] = "verbose" +// +// Define the flag with its string name as usual: +// +// f.Bool(string(verbose), false, "enable verbose output") +type FlagName[T any] string + // State is the value passed to [Command.Exec]. It holds the parsed inputs the command needs to run. type State struct { // Args holds the positional arguments left after the command name and flags are parsed. @@ -224,10 +234,16 @@ func FlagsFunc(fn func(f *flag.FlagSet)) (fset *flag.FlagSet) { // verbose := s.GetFlag[bool]("verbose") // count := s.GetFlag[int]("count") // path := s.GetFlag[string]("path") -func (s *State) GetFlag[T any](name string) T { +// +// Use [FlagName] to define a reusable name and infer the returned type: +// +// const verbose FlagName[bool] = "verbose" +// enabled := s.GetFlag(verbose) +func (s *State) GetFlag[T any](name FlagName[T]) T { if s == nil { panic(&internalError{err: errors.New("state is nil")}) } + flagName := string(name) // Try to find the flag in each command's flag set, starting from the current command for i := len(s.path) - 1; i >= 0; i-- { cmd := s.path[i] @@ -235,14 +251,14 @@ func (s *State) GetFlag[T any](name string) T { continue } - if f := cmd.Flags.Lookup(name); f != nil { + if f := cmd.Flags.Lookup(flagName); f != nil { if getter, ok := f.Value.(flag.Getter); ok { value := getter.Get() if v, ok := value.(T); ok { return v } err := fmt.Errorf("type mismatch for flag %q in command %q: registered %T, requested %T", - formatFlagName(name), + formatFlagName(flagName), getCommandPath(s.path), value, *new(T), @@ -255,7 +271,7 @@ func (s *State) GetFlag[T any](name string) T { // If flag not found anywhere in hierarchy, panic with helpful message err := fmt.Errorf("flag %q not found in command %q flag set", - formatFlagName(name), + formatFlagName(flagName), getCommandPath(s.path), ) panic(&internalError{err: err}) diff --git a/cli_test.go b/cli_test.go index 48c9b6f..4be7e45 100644 --- a/cli_test.go +++ b/cli_test.go @@ -1840,6 +1840,20 @@ func TestStateGetFlag(t *testing.T) { }) } +func TestStateGetFlagTypedName(t *testing.T) { + t.Parallel() + + const verbose FlagName[bool] = "verbose" + cmd := &Command{ + Name: "root", + Flags: FlagsFunc(func(f *flag.FlagSet) { f.Bool(string(verbose), false, "verbose output") }), + Exec: func(context.Context, *State) error { return nil }, + } + + require.NoError(t, Parse(cmd, []string{"--verbose"})) + require.True(t, cmd.state.GetFlag(verbose)) +} + func TestStateCommandContext(t *testing.T) { t.Parallel() diff --git a/examples/cmd/echo/main.go b/examples/cmd/echo/main.go index edfd80e..4082f93 100644 --- a/examples/cmd/echo/main.go +++ b/examples/cmd/echo/main.go @@ -10,17 +10,18 @@ import ( "github.com/pressly/cli" ) +const capitalize cli.FlagName[bool] = "capitalize" + func main() { root := &cli.Command{ Name: "echo", Usage: "echo [flags] ...", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { - f.Bool("capitalize", false, "capitalize the input") + f.Bool(string(capitalize), false, "capitalize the input") }), Exec: func(ctx context.Context, s *cli.State) error { text := strings.Join(s.Args, " ") - // GetFlag uses generic methods, available in Go 1.27 or later. - if s.GetFlag[bool]("capitalize") { + if s.GetFlag(capitalize) { text = strings.ToUpper(text) } fmt.Fprintln(s.Stdout, text) From 478059f60d4e6af9c10bf0e04a4d85849f8471a8 Mon Sep 17 00:00:00 2001 From: Mike Fridman Date: Fri, 21 Aug 2026 12:16:56 +0200 Subject: [PATCH 2/2] docs: condense FlagName and GetFlag doc comments --- cli.go | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/cli.go b/cli.go index 3ed1262..88de39b 100644 --- a/cli.go +++ b/cli.go @@ -162,14 +162,7 @@ type FlagConfig struct { Local bool } -// FlagName associates a flag's canonical name with its Go type. Pass a FlagName to [State.GetFlag] -// to infer the returned type instead of specifying it at each lookup. -// -// const verbose FlagName[bool] = "verbose" -// -// Define the flag with its string name as usual: -// -// f.Bool(string(verbose), false, "enable verbose output") +// FlagName ties a flag name to the type returned by [State.GetFlag]. type FlagName[T any] string // State is the value passed to [Command.Exec]. It holds the parsed inputs the command needs to run. @@ -224,21 +217,12 @@ func FlagsFunc(fn func(f *flag.FlagSet)) (fset *flag.FlagSet) { return fset } -// GetFlag returns the value of a flag as type T. Call it from inside [Command.Exec] with the same -// Go type that was used when the flag was defined. -// -// GetFlag looks for the flag on the picked command first, then in its parent commands. A flag -// defined on the root command can be read from any subcommand. An unknown flag name or a wrong type -// is a programming error: GetFlag panics, and [Run] catches the panic and returns the error. +// GetFlag returns a flag value as T, searching the picked command before its parents. Unknown names +// and type mismatches are programming errors: GetFlag panics, and [Run] returns the error. // // verbose := s.GetFlag[bool]("verbose") -// count := s.GetFlag[int]("count") -// path := s.GetFlag[string]("path") -// -// Use [FlagName] to define a reusable name and infer the returned type: -// -// const verbose FlagName[bool] = "verbose" -// enabled := s.GetFlag(verbose) +// const count FlagName[int] = "count" +// n := s.GetFlag(count) func (s *State) GetFlag[T any](name FlagName[T]) T { if s == nil { panic(&internalError{err: errors.New("state is nil")})