diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6961a7e..4447019 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,29 +13,28 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: - go-version: stable + go-version: "1.27.x" - name: Run golangci-lint - uses: golangci/golangci-lint-action@v7 + uses: golangci/golangci-lint-action@9fae48acfc02a90574d7c304a1758ef9895495fa # v7.0.1 build: name: Build and test runs-on: ubuntu-latest strategy: matrix: - # go-version: ['oldstable', 'stable', '1.23.0-rc.2'] - go-version: ["oldstable", "stable"] + go-version: ["1.27.x"] env: VERBOSE: 1 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: ${{ matrix.go-version }} - name: Install tparse diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ffe0c..f484a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- **BREAKING**: Replace the top-level `GetFlag` function with the generic `State.GetFlag` method +- **BREAKING**: Require Go 1.27 or higher + ## [v0.7.0] - 2026-05-14 ### Added diff --git a/README.md b/README.md index 34d9c0e..2727f32 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ [![CI](https://github.com/pressly/cli/actions/workflows/ci.yaml/badge.svg)](https://github.com/pressly/cli/actions/workflows/ci.yaml) [![Docs](https://img.shields.io/badge/docs-pressly.github.io%2Fcli-blue)](https://pressly.github.io/cli) -An intentionally minimal Go package for building CLI applications. Extends the standard library's -`flag` package to support [flags -anywhere](https://mfridman.com/blog/2024/allowing-flags-anywhere-on-the-cli/) in command arguments, -adds nested subcommands, and gets out of the way. +An intentionally minimal Go package for building CLI applications. It extends the standard library's +`flag` package with nested subcommands and [flags +anywhere](https://mfridman.com/blog/2024/allowing-flags-anywhere-on-the-cli/), then gets out of the +way. Docs: @@ -19,24 +19,21 @@ Docs: go get github.com/pressly/cli@latest ``` -Requires Go 1.21 or higher. +Requires Go 1.27 or higher. ## Quick Start ```go root := &cli.Command{ - Name: "echo", - Usage: "echo [flags] ...", - Summary: "Print text", + Name: "echo", + Usage: "echo [flags] ...", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.Bool("capitalize", false, "capitalize the input") }), - FlagConfigs: []cli.FlagConfig{ - {Name: "capitalize", Short: "c"}, - }, Exec: func(ctx context.Context, s *cli.State) error { text := strings.Join(s.Args, " ") - if cli.GetFlag[bool](s, "capitalize") { + // GetFlag uses generic methods, available in Go 1.27. + if s.GetFlag[bool]("capitalize") { text = strings.ToUpper(text) } fmt.Fprintln(s.Stdout, text) @@ -49,187 +46,30 @@ if err := cli.ParseAndRun(ctx, root, os.Args[1:], nil); err != nil { } ``` -`ParseAndRun` parses the command hierarchy, handles `--help` automatically, and executes the -resolved command. For applications that need work between parsing and execution, use `Parse` and -`Run` separately. See the [examples](examples/) directory for more complete applications. +`ParseAndRun` parses the command hierarchy, handles `--help`, and runs the selected command. -The command above gets usable help without any extra setup: +The command above gets usable help without extra setup: ```text -Print text - Usage: echo [flags] ... Flags: - -c, --capitalize capitalize the input -``` - -## Flags - -`FlagsFunc` is a convenience for defining flags inline. Use `FlagConfigs` to extend the standard -`flag` package with features like required flag enforcement and short aliases: - -```go -Flags: cli.FlagsFunc(func(f *flag.FlagSet) { - f.Bool("verbose", false, "enable verbose output") - f.String("output", "", "output file") -}), -FlagConfigs: []cli.FlagConfig{ - {Name: "verbose", Short: "v"}, - {Name: "output", Short: "o", Required: true}, -}, -``` - -Short aliases register `-v` as an alias for `--verbose`, `-o` as an alias for `--output`, and so on. -Both forms are shown in help output automatically. - -Access flags inside `Exec` with the type-safe `GetFlag` function: - -```go -verbose := cli.GetFlag[bool](s, "verbose") -output := cli.GetFlag[string](s, "output") + --capitalize capitalize the input ``` -Child commands automatically inherit flags from parent commands, so a `--verbose` flag on the root -is accessible from any subcommand via `GetFlag`. - -## Subcommands - -Commands can have nested subcommands, each with their own flags and `Exec` function: - -```go -root := &cli.Command{ - Name: "todo", - Usage: "todo [flags]", - Summary: "Manage tasks", - Description: "todo manages tasks stored in a local file.", - SubCommands: []*cli.Command{ - { - Name: "list", - Summary: "List tasks", - Description: `List tasks in the current workspace. - -By default, completed tasks are hidden.`, - Exec: func(ctx context.Context, s *cli.State) error { - // ... - return nil - }, - }, - }, -} -``` - -`Summary` is the short sentence shown when a command appears in another command's help: - -```text -Available Commands: - list List tasks -``` - -When a command only groups subcommands, leave `Exec` unset. Selecting it without a child command -returns a usage error and shows that command's help. - -`Description` is the longer text shown at the top of that command's own help: - -```text -List tasks in the current workspace. - -By default, completed tasks are hidden. - -Usage: - todo list -``` - -If a command only needs one sentence, set `Summary` and leave `Description` empty. If `Description` -is set and `Summary` is empty, command lists use the first line of `Description`. - -For a more complete example with deeply nested subcommands, see the [todo -example](examples/cmd/task/). - -## Help - -Help text is generated automatically and displayed when `--help` is passed. Most commands only need -`Name`, `Summary`, flags, subcommands, and `Exec`. - -Set the `Help` field only when a command needs to replace the generated help entirely: - -```go -Help: func(c *cli.Command) string { - return `Print a greeting. - -Usage: - greet - -Examples: - greet margo` -}, -``` - -That replaces the built-in help with: - -```text -Print a greeting. - -Usage: - greet - -Examples: - greet margo -``` - -If you use `Parse` directly, handle `flag.ErrHelp` yourself. Most applications should use -`ParseAndRun` when they want cli to print help automatically. - -```go -if err := cli.Parse(root, args); err != nil { - if errors.Is(err, flag.ErrHelp) { - // Print custom help here, or use ParseAndRun for built-in help. - return nil - } - return err -} -``` - -Inside `Exec`, `State` exposes the resolved command as `Cmd`, so usage errors can stay explicit: - -```go -Exec: func(ctx context.Context, s *cli.State) error { - if len(s.Args) == 0 { - return cli.UsageErrorf("must supply a name") - } - fmt.Fprintf(s.Stdout, "hello, %s\n", s.Args[0]) - return nil -}, -``` - -`UsageErrorf` is opt-in: `Run` prints the resolved command's help to stderr before returning the -underlying error. Normal errors are returned unchanged. - -For command-aware errors, use `s.Cmd.Path()` to get the resolved command path. - -## Usage Strings - -Set `Command.Usage` when the default usage line is too broad. A common convention is to write -required values as ``, optional values as `[name]`, and repeated values with `...`: - -```go -Usage: "echo [flags] ..." -``` - -## Status - -This project is in active development and undergoing changes as the API gets refined. Please open an -issue if you encounter any problems or have suggestions for improvement. +For subcommands, inherited, local, and required flags, custom help, usage errors, and subpackages, +see the [documentation](https://pressly.github.io/cli). More complete programs live in +[examples](examples/). ## Acknowledgements There are many great CLI libraries out there, but I always felt [they were too heavy for my needs](https://mfridman.com/blog/2021/a-simpler-building-block-for-go-clis/). -Inspired by Peter Bourgon's [ff](https://github.com/peterbourgon/ff) library, specifically the `v3` -branch, which was so close to what I wanted. The `v4` branch took a different direction, and I -wanted to keep the simplicity of `v3`. This library carries that idea forward. +Inspired by Peter Bourgon's [ff](https://github.com/peterbourgon/ff) library, especially its `v3` +branch, which was close to what I wanted. `v4` took a different direction, but I wanted to keep the +simplicity of `v3`. This library carries that idea forward. ## License diff --git a/cli.go b/cli.go index 06dcf5f..205959b 100644 --- a/cli.go +++ b/cli.go @@ -5,7 +5,7 @@ // - Nested subcommands via [Command.SubCommands] // - Flags placed anywhere on the command line // - Parent flags inherited by child commands -// - Type-safe flag access via [GetFlag] +// - Type-safe flag access via [State.GetFlag] // - Generated help, replaceable per command via [Command.Help] // - "Did you mean" suggestions for misspelled subcommands // @@ -21,7 +21,7 @@ // }), // Exec: func(ctx context.Context, s *cli.State) error { // output := strings.Join(s.Args, " ") -// if cli.GetFlag[bool](s, "c") { +// if s.GetFlag[bool]("c") { // output = strings.ToUpper(output) // } // fmt.Fprintln(s.Stdout, output) @@ -103,7 +103,7 @@ type Command struct { // [flag.NewFlagSet], or use [FlagsFunc] to define flags inline. // // Subcommands inherit these flags unless they are marked [FlagConfig.Local] in - // [Command.FlagConfigs]. Read flag values inside [Command.Exec] with [GetFlag]. + // [Command.FlagConfigs]. Read flag values inside [Command.Exec] with [State.GetFlag]. Flags *flag.FlagSet // FlagConfigs adds extra behavior to flags already defined in [Command.Flags]. See [FlagConfig] @@ -221,10 +221,10 @@ func FlagsFunc(fn func(f *flag.FlagSet)) (fset *flag.FlagSet) { // 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. // -// verbose := cli.GetFlag[bool](s, "verbose") -// count := cli.GetFlag[int](s, "count") -// path := cli.GetFlag[string](s, "path") -func GetFlag[T any](s *State, name string) T { +// verbose := s.GetFlag[bool]("verbose") +// count := s.GetFlag[int]("count") +// path := s.GetFlag[string]("path") +func (s *State) GetFlag[T any](name string) T { if s == nil { panic(&internalError{err: errors.New("state is nil")}) } diff --git a/cli_test.go b/cli_test.go index b29e5aa..48c9b6f 100644 --- a/cli_test.go +++ b/cli_test.go @@ -172,7 +172,7 @@ func TestParse(t *testing.T) { cmd := getCommand(t, s.root) require.Equal(t, s.add, cmd) - require.False(t, GetFlag[bool](s.root.state, "dry-run")) + require.False(t, s.root.state.GetFlag[bool]("dry-run")) }) t.Run("unknown flag", func(t *testing.T) { t.Parallel() @@ -191,7 +191,7 @@ func TestParse(t *testing.T) { cmd := getCommand(t, s.root) assert.Equal(t, s.add, cmd) - assert.True(t, GetFlag[bool](s.root.state, "dry-run")) + assert.True(t, s.root.state.GetFlag[bool]("dry-run")) }) t.Run("help flag", func(t *testing.T) { t.Parallel() @@ -242,8 +242,8 @@ func TestParse(t *testing.T) { cmd := getCommand(t, s.root) assert.Equal(t, s.add, cmd) - assert.True(t, GetFlag[bool](s.root.state, "dry-run")) - assert.True(t, GetFlag[bool](s.root.state, "verbose")) + assert.True(t, s.root.state.GetFlag[bool]("dry-run")) + assert.True(t, s.root.state.GetFlag[bool]("verbose")) }) t.Run("nested subcommand and root flag", func(t *testing.T) { t.Parallel() @@ -254,8 +254,8 @@ func TestParse(t *testing.T) { cmd := getCommand(t, s.root) assert.Equal(t, s.sub, cmd) - assert.Equal(t, "hello", GetFlag[string](s.root.state, "echo")) - assert.True(t, GetFlag[bool](s.root.state, "verbose")) + assert.Equal(t, "hello", s.root.state.GetFlag[string]("echo")) + assert.True(t, s.root.state.GetFlag[bool]("verbose")) }) t.Run("nested subcommand with mixed flags", func(t *testing.T) { t.Parallel() @@ -266,8 +266,8 @@ func TestParse(t *testing.T) { cmd := getCommand(t, s.root) assert.Equal(t, s.sub, cmd) - assert.Equal(t, "hello", GetFlag[string](s.root.state, "echo")) - assert.True(t, GetFlag[bool](s.root.state, "verbose")) + assert.Equal(t, "hello", s.root.state.GetFlag[string]("echo")) + assert.True(t, s.root.state.GetFlag[bool]("verbose")) }) t.Run("end of options delimiter", func(t *testing.T) { t.Parallel() @@ -279,7 +279,7 @@ func TestParse(t *testing.T) { assert.Equal(t, s.root, cmd) assert.Equal(t, []string{"nested", "sub", "--echo", "hello"}, s.root.state.Args) - assert.True(t, GetFlag[bool](s.root.state, "verbose")) + assert.True(t, s.root.state.GetFlag[bool]("verbose")) }) t.Run("flags and args", func(t *testing.T) { t.Parallel() @@ -290,7 +290,7 @@ func TestParse(t *testing.T) { cmd := getCommand(t, s.root) assert.Equal(t, s.add, cmd) - assert.True(t, GetFlag[bool](s.root.state, "dry-run")) + assert.True(t, s.root.state.GetFlag[bool]("dry-run")) assert.Equal(t, []string{"item1", "item2"}, s.root.state.Args) }) t.Run("nested subcommand with flags and args", func(t *testing.T) { @@ -302,7 +302,7 @@ func TestParse(t *testing.T) { cmd := getCommand(t, s.root) assert.Equal(t, s.sub, cmd) - assert.Equal(t, "hello", GetFlag[string](s.root.state, "echo")) + assert.Equal(t, "hello", s.root.state.GetFlag[string]("echo")) assert.Equal(t, []string{"world"}, s.root.state.Args) }) t.Run("subcommand flags not available in parent", func(t *testing.T) { @@ -322,7 +322,7 @@ func TestParse(t *testing.T) { cmd := getCommand(t, s.root) assert.Equal(t, s.sub, cmd) - assert.True(t, GetFlag[bool](s.root.state, "force")) + assert.True(t, s.root.state.GetFlag[bool]("force")) }) t.Run("unrelated subcommand flags not inherited in other subcommands", func(t *testing.T) { t.Parallel() @@ -357,7 +357,7 @@ func TestParse(t *testing.T) { cmd := getCommand(t, s.root) assert.Equal(t, s.hello, cmd) - require.True(t, GetFlag[bool](s.root.state, "mandatory-flag")) + require.True(t, s.root.state.GetFlag[bool]("mandatory-flag")) } { // Correct type - false @@ -366,7 +366,7 @@ func TestParse(t *testing.T) { require.NoError(t, err) cmd := s.root.terminal() assert.Equal(t, s.hello, cmd) - require.False(t, GetFlag[bool](s.root.state, "mandatory-flag")) + require.False(t, s.root.state.GetFlag[bool]("mandatory-flag")) } { // Incorrect type @@ -585,7 +585,7 @@ func TestParse(t *testing.T) { } err := Parse(cmd, []string{"--config="}) require.NoError(t, err) - require.Equal(t, "", GetFlag[string](cmd.state, "config")) + require.Equal(t, "", cmd.state.GetFlag[string]("config")) }) t.Run("boolean flag with explicit false", func(t *testing.T) { t.Parallel() @@ -598,7 +598,7 @@ func TestParse(t *testing.T) { } err := Parse(cmd, []string{"--verbose=false"}) require.NoError(t, err) - require.False(t, GetFlag[bool](cmd.state, "verbose")) + require.False(t, cmd.state.GetFlag[bool]("verbose")) }) t.Run("deeply nested command hierarchy", func(t *testing.T) { t.Parallel() @@ -746,7 +746,7 @@ func TestParse(t *testing.T) { err := Parse(root, []string{"parent", "--output", "foo", "child"}) require.NoError(t, err, "ancestor flag value should not be treated as unknown command") assert.Equal(t, child, getCommand(t, root)) - assert.Equal(t, "foo", GetFlag[string](root.state, "output")) + assert.Equal(t, "foo", root.state.GetFlag[string]("output")) }) t.Run("required flag set to default value", func(t *testing.T) { t.Parallel() @@ -765,7 +765,7 @@ func TestParse(t *testing.T) { // Explicitly passing the default value should satisfy the required check. err := Parse(root, []string{"--port", "8080"}) require.NoError(t, err, "explicitly setting required flag to its default value should not fail") - assert.Equal(t, "8080", GetFlag[string](root.state, "port")) + assert.Equal(t, "8080", root.state.GetFlag[string]("port")) }) t.Run("required bool flag prefix match not too broad", func(t *testing.T) { t.Parallel() @@ -799,8 +799,8 @@ func TestParse(t *testing.T) { } err := Parse(cmd, []string{"arg1", "--flag1=val1", "arg2", "--flag2", "val2", "arg3"}) require.NoError(t, err) - require.Equal(t, "val1", GetFlag[string](cmd.state, "flag1")) - require.Equal(t, "val2", GetFlag[string](cmd.state, "flag2")) + require.Equal(t, "val1", cmd.state.GetFlag[string]("flag1")) + require.Equal(t, "val2", cmd.state.GetFlag[string]("flag2")) require.Equal(t, []string{"arg1", "arg2", "arg3"}, cmd.state.Args) }) } @@ -824,8 +824,8 @@ func TestShortFlags(t *testing.T) { } err := Parse(cmd, []string{"-v", "-o", "file.txt"}) require.NoError(t, err) - require.True(t, GetFlag[bool](cmd.state, "verbose")) - require.Equal(t, "file.txt", GetFlag[string](cmd.state, "output")) + require.True(t, cmd.state.GetFlag[bool]("verbose")) + require.Equal(t, "file.txt", cmd.state.GetFlag[string]("output")) }) t.Run("long flag still works with short alias defined", func(t *testing.T) { @@ -842,7 +842,7 @@ func TestShortFlags(t *testing.T) { } err := Parse(cmd, []string{"-verbose"}) require.NoError(t, err) - require.True(t, GetFlag[bool](cmd.state, "verbose")) + require.True(t, cmd.state.GetFlag[bool]("verbose")) }) t.Run("short flag with subcommand", func(t *testing.T) { @@ -870,8 +870,8 @@ func TestShortFlags(t *testing.T) { } err := Parse(root, []string{"-v", "child", "-n", "hello"}) require.NoError(t, err) - require.True(t, GetFlag[bool](root.state, "verbose")) - require.Equal(t, "hello", GetFlag[string](root.state, "name")) + require.True(t, root.state.GetFlag[bool]("verbose")) + require.Equal(t, "hello", root.state.GetFlag[string]("name")) }) t.Run("short and long flags are aliases sharing same value", func(t *testing.T) { @@ -890,7 +890,7 @@ func TestShortFlags(t *testing.T) { err := Parse(cmd, []string{"-c", "42"}) require.NoError(t, err) // Both short and long name should return the same value - require.Equal(t, 42, GetFlag[int](cmd.state, "count")) + require.Equal(t, 42, cmd.state.GetFlag[int]("count")) }) t.Run("option references unknown flag", func(t *testing.T) { @@ -1008,7 +1008,7 @@ func TestLocalFlags(t *testing.T) { } err = Parse(root2, []string{"child", "--verbose"}) require.NoError(t, err) - assert.True(t, GetFlag[bool](root2.state, "verbose")) + assert.True(t, root2.state.GetFlag[bool]("verbose")) }) t.Run("local flag works on defining command", func(t *testing.T) { @@ -1025,7 +1025,7 @@ func TestLocalFlags(t *testing.T) { } err := Parse(root, []string{"--version"}) require.NoError(t, err) - assert.True(t, GetFlag[bool](root.state, "version")) + assert.True(t, root.state.GetFlag[bool]("version")) }) t.Run("local required flag only enforced on defining command", func(t *testing.T) { @@ -1532,7 +1532,7 @@ func TestRun(t *testing.T) { f.Bool("dry-run", false, "dry run") }), Exec: func(ctx context.Context, s *State) error { - if !GetFlag[bool](s, "dry-run") { + if !s.GetFlag[bool]("dry-run") { count++ } return nil @@ -1612,7 +1612,7 @@ func TestRun(t *testing.T) { Exec: func(ctx context.Context, s *State) error { // Simulate concurrent access to state go func() { - _ = GetFlag[string](s, "value") + _ = s.GetFlag[string]("value") }() return nil }, @@ -1662,17 +1662,17 @@ func TestRun(t *testing.T) { // Test max int err := Parse(root, []string{"--int", "2147483647"}) require.NoError(t, err) - require.Equal(t, 2147483647, GetFlag[int](root.state, "int")) + require.Equal(t, 2147483647, root.state.GetFlag[int]("int")) // Test min int err = Parse(root, []string{"--int", "-2147483648"}) require.NoError(t, err) - require.Equal(t, -2147483648, GetFlag[int](root.state, "int")) + require.Equal(t, -2147483648, root.state.GetFlag[int]("int")) // Test that parsing still works with large values (may not overflow in Go flag package) err = Parse(root, []string{"--int", "999999999"}) require.NoError(t, err) - require.Equal(t, 999999999, GetFlag[int](root.state, "int")) + require.Equal(t, 999999999, root.state.GetFlag[int]("int")) }) t.Run("location file path is relative", func(t *testing.T) { t.Parallel() @@ -1705,7 +1705,7 @@ func TestRun(t *testing.T) { for _, val := range specialValues { err := Parse(root, []string{"--text", val}) require.NoError(t, err) - require.Equal(t, val, GetFlag[string](root.state, "text")) + require.Equal(t, val, root.state.GetFlag[string]("text")) } }) } @@ -1788,9 +1788,20 @@ func TestParseAndRun(t *testing.T) { }) } -func TestGetFlag(t *testing.T) { +func TestStateGetFlag(t *testing.T) { t.Parallel() + t.Run("nil state", func(t *testing.T) { + defer func() { + r := recover() + require.NotNil(t, r) + err, ok := r.(error) + require.True(t, ok) + assert.EqualError(t, err, "state is nil") + }() + var state *State + _ = state.GetFlag[string]("version") + }) t.Run("flag not found", func(t *testing.T) { cmd := &Command{ Name: "root", @@ -1807,7 +1818,7 @@ func TestGetFlag(t *testing.T) { assert.ErrorContains(t, err, `flag "-version" not found in command "root" flag set`) }() // Panic because author tried to access a flag that doesn't exist in any of the commands - _ = GetFlag[string](state, "version") + _ = state.GetFlag[string]("version") }) t.Run("flag type mismatch", func(t *testing.T) { cmd := &Command{ @@ -1825,7 +1836,7 @@ func TestGetFlag(t *testing.T) { assert.ErrorContains(t, err, `type mismatch for flag "-version" in command "root": registered string, requested int`) }() // Panic because author tried to access a registered flag with the wrong type - _ = GetFlag[int](state, "version") + _ = state.GetFlag[int]("version") }) } diff --git a/examples/cmd/echo/main.go b/examples/cmd/echo/main.go index adb5d95..edfd80e 100644 --- a/examples/cmd/echo/main.go +++ b/examples/cmd/echo/main.go @@ -12,27 +12,18 @@ import ( func main() { root := &cli.Command{ - Name: "echo", - Usage: "echo [flags] ...", - Summary: "Print text", - Description: "echo prints the provided text.", + Name: "echo", + Usage: "echo [flags] ...", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { - // Add a flag to capitalize the input - f.Bool("c", false, "capitalize the input") + f.Bool("capitalize", false, "capitalize the input") }), - FlagConfigs: []cli.FlagConfig{ - {Name: "c", Required: true}, - }, Exec: func(ctx context.Context, s *cli.State) error { - if len(s.Args) == 0 { - return cli.UsageErrorf("must provide text to echo") - } - output := strings.Join(s.Args, " ") - // If -c flag is set, capitalize the output - if cli.GetFlag[bool](s, "c") { - output = strings.ToUpper(output) + text := strings.Join(s.Args, " ") + // GetFlag uses generic methods, available in Go 1.27 or later. + if s.GetFlag[bool]("capitalize") { + text = strings.ToUpper(text) } - fmt.Fprintln(s.Stdout, output) + fmt.Fprintln(s.Stdout, text) return nil }, } diff --git a/examples/cmd/task/main.go b/examples/cmd/task/main.go index 49fc35a..df77a08 100644 --- a/examples/cmd/task/main.go +++ b/examples/cmd/task/main.go @@ -24,7 +24,7 @@ func main() { f.Bool("version", false, "print the version") }), Exec: func(ctx context.Context, s *cli.State) error { - if cli.GetFlag[bool](s, "version") { + if s.GetFlag[bool]("version") { fmt.Fprintf(s.Stdout, "todo v1.0.0\n") return nil } @@ -63,7 +63,7 @@ func list() *cli.Command { } func getTasksFromFile(s *cli.State) (*TaskList, error) { - file := cli.GetFlag[string](s, "file") + file := s.GetFlag[string]("file") return Load(file) } @@ -144,8 +144,8 @@ func taskAdd() *cli.Command { }), Exec: func(ctx context.Context, s *cli.State) error { var ( - tagsText = cli.GetFlag[string](s, "tags") - file = cli.GetFlag[string](s, "file") + tagsText = s.GetFlag[string]("tags") + file = s.GetFlag[string]("file") ) var tags []string if tagsText != "" { @@ -207,9 +207,9 @@ func taskRemove() *cli.Command { }), Exec: func(ctx context.Context, s *cli.State) error { var ( - force = cli.GetFlag[bool](s, "force") - all = cli.GetFlag[bool](s, "all") - file = cli.GetFlag[string](s, "file") + force = s.GetFlag[bool]("force") + all = s.GetFlag[bool]("all") + file = s.GetFlag[string]("file") ) if len(s.Args) == 0 && !all { return cli.UsageErrorf("task ID required, or use --all to remove all tasks") diff --git a/flagtype/doc.go b/flagtype/doc.go index a2c84e7..7c0c144 100644 --- a/flagtype/doc.go +++ b/flagtype/doc.go @@ -1,6 +1,6 @@ // Package flagtype provides common [flag.Value] implementations for use with [flag.FlagSet.Var]. // -// All types implement [flag.Getter] so they work with [cli.GetFlag]. +// All types implement [flag.Getter] so they work with [cli.State.GetFlag]. // // The following types are available: // - [StringSlice] - repeatable flag that collects values into []string @@ -21,7 +21,7 @@ // // Example retrieval in Exec: // -// tags := cli.GetFlag[[]string](s, "tag") -// format := cli.GetFlag[string](s, "format") -// labels := cli.GetFlag[map[string]string](s, "label") +// tags := s.GetFlag[[]string]("tag") +// format := s.GetFlag[string]("format") +// labels := s.GetFlag[map[string]string]("label") package flagtype diff --git a/flagtype/enum.go b/flagtype/enum.go index 7bfa401..9ae2827 100644 --- a/flagtype/enum.go +++ b/flagtype/enum.go @@ -15,7 +15,7 @@ type enumValue struct { // Enum returns a [flag.Value] that restricts the flag to one of the allowed values. If a value not // in the allowed list is provided, an error is returned listing valid options. // -// Use [cli.GetFlag] with type string to retrieve the value. +// Use [cli.State.GetFlag] with type string to retrieve the value. func Enum(allowed ...string) flag.Value { return &enumValue{allowed: allowed} } @@ -23,7 +23,7 @@ func Enum(allowed ...string) flag.Value { // EnumDefault is like [Enum] but sets an initial default value. The default must be one of the // allowed values, otherwise EnumDefault panics. // -// Use [cli.GetFlag] with type string to retrieve the value. +// Use [cli.State.GetFlag] with type string to retrieve the value. func EnumDefault(defaultVal string, allowed []string) flag.Value { if !slices.Contains(allowed, defaultVal) { panic(fmt.Sprintf("flagtype: default value %q is not in allowed values: %s", diff --git a/flagtype/regexp.go b/flagtype/regexp.go index 6df9931..e82dcde 100644 --- a/flagtype/regexp.go +++ b/flagtype/regexp.go @@ -12,7 +12,7 @@ type regexpValue struct { // Regexp returns a [flag.Value] that compiles the flag value as a regular expression. If the // pattern is invalid, an error is returned. // -// Use [cli.GetFlag] with type *regexp.Regexp to retrieve the value. +// Use [cli.State.GetFlag] with type *regexp.Regexp to retrieve the value. func Regexp() flag.Value { return ®expValue{} } diff --git a/flagtype/string_map.go b/flagtype/string_map.go index 8d28e3f..fc5a1da 100644 --- a/flagtype/string_map.go +++ b/flagtype/string_map.go @@ -15,7 +15,7 @@ type stringMapValue struct { // to add multiple entries, like --label=env=prod --label=tier=web. The value is split on the first // "=" character, so values may contain additional "=" characters. // -// Use [cli.GetFlag] with type map[string]string to retrieve the value. +// Use [cli.State.GetFlag] with type map[string]string to retrieve the value. func StringMap() flag.Value { return &stringMapValue{} } diff --git a/flagtype/string_slice.go b/flagtype/string_slice.go index 047a925..66a246a 100644 --- a/flagtype/string_slice.go +++ b/flagtype/string_slice.go @@ -12,7 +12,7 @@ type stringSliceValue struct { // StringSlice returns a [flag.Value] that collects values into a string slice. Each time the flag // is set, the value is appended. This allows repeatable flags like --tag=foo --tag=bar. // -// Use [cli.GetFlag] with type []string to retrieve the value. +// Use [cli.State.GetFlag] with type []string to retrieve the value. func StringSlice() flag.Value { return &stringSliceValue{} } diff --git a/flagtype/url.go b/flagtype/url.go index 27c7443..9756ff6 100644 --- a/flagtype/url.go +++ b/flagtype/url.go @@ -13,7 +13,7 @@ type urlValue struct { // URL returns a [flag.Value] that parses the flag value as a URL. The URL must have both a scheme // and a host, otherwise an error is returned. // -// Use [cli.GetFlag] with type *url.URL to retrieve the value. +// Use [cli.State.GetFlag] with type *url.URL to retrieve the value. func URL() flag.Value { return &urlValue{} } diff --git a/go.mod b/go.mod index 9912a8e..6b1ca63 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/pressly/cli -go 1.21.0 +go 1.27.0 require github.com/stretchr/testify v1.11.1