From 5c778361528e8dae437f2d70b52035196aaa91d5 Mon Sep 17 00:00:00 2001 From: Mike Fridman Date: Sun, 26 Apr 2026 11:05:16 +0200 Subject: [PATCH 1/8] refactor help API --- CHANGELOG.md | 23 ++++ README.md | 31 +++++- command.go | 77 +++++++------ error.go | 29 +++++ error_test.go | 19 ++++ examples/cmd/echo/main.go | 5 +- examples/cmd/task/main.go | 19 ++-- parse.go | 75 +++++++------ parse_test.go | 52 ++++----- path_test.go | 2 + run.go | 41 +++---- run_test.go | 39 +++++++ state.go | 27 +++-- state_test.go | 127 ++++++++++++++++++++++ usage.go | 223 ++++++++++++++------------------------ usage/command.go | 19 ++++ usage/flag.go | 71 ++++++++++++ usage/flag_test.go | 23 ++++ usage/help.go | 146 +++++++++++++++++++++++++ usage/help_test.go | 49 +++++++++ usage_test.go | 94 ++++++++++++---- xflag/parse.go | 2 +- 22 files changed, 891 insertions(+), 302 deletions(-) create mode 100644 error.go create mode 100644 error_test.go create mode 100644 usage/command.go create mode 100644 usage/flag.go create mode 100644 usage/flag_test.go create mode 100644 usage/help.go create mode 100644 usage/help_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c67389..33faf9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,29 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - `flagtype.EnumDefault` constructor for enums with an initial default value +- New `usage` package with composable help document blocks: `Text`, `Lines`, `List`, `Flags`, and + `Commands` +- `Help` function for rendering the resolved command help document +- `Cmd` field on `State` for accessing the terminal command selected by parsing +- `UsageErrorf` for opt-in usage errors; `Run` prints command help to stderr before returning the + underlying error + +### Changed + +- **BREAKING**: Replace `Command.UsageFunc` with `Command.Help`, which receives the built-in + `usage.Help` document and returns the customized document +- **BREAKING**: Custom help now composes `usage.Help` documents instead of string-concatenating + default usage text +- **BREAKING**: Rename `FlagOption` to `FlagConfig` and `Command.FlagOptions` to + `Command.FlagConfigs` +- Help output is now built through the `usage` package while preserving the default automatic + `--help` behavior + +### Removed + +- **BREAKING**: Remove `DefaultUsage` and `Usage`; use `Help(cmd).String()` for direct rendering +- **BREAKING**: Remove usage-related `State` helpers: `Command`, `CommandPath`, `Usage`, and + `UsageErrorf`; use `State.Cmd`, `Command.Path`, `Help`, and top-level `UsageErrorf` instead ## [v0.6.0] - 2026-02-18 diff --git a/README.md b/README.md index df2d4c1..41db788 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ resolved command. For applications that need work between parsing and execution, ## Flags -`FlagsFunc` is a convenience for defining flags inline. Use `FlagOptions` to extend the standard +`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 @@ -49,7 +49,7 @@ Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.Bool("verbose", false, "enable verbose output") f.String("output", "", "output file") }), -FlagOptions: []cli.FlagOption{ +FlagConfigs: []cli.FlagConfig{ {Name: "verbose", Short: "v"}, {Name: "output", Short: "o", Required: true}, }, @@ -95,8 +95,31 @@ example](examples/cmd/task/). ## Help -Help text is generated automatically and displayed when `--help` is passed. To customize it, set the -`UsageFunc` field on a command. +Help text is generated automatically and displayed when `--help` is passed. To customize it, set +the `Help` field on a command: + +```go +Help: func(c *cli.Command, h usage.Help) usage.Help { + return append(h, usage.Lines("Examples:", "greet margo")) +}, +``` + +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 Syntax diff --git a/command.go b/command.go index 07a12ee..ed96413 100644 --- a/command.go +++ b/command.go @@ -7,55 +7,64 @@ import ( "strings" "github.com/pressly/cli/pkg/suggest" + "github.com/pressly/cli/usage" ) -// ErrHelp is returned by [Parse] when the -help or -h flag is invoked. It is identical to -// [flag.ErrHelp] but re-exported here so callers using [Parse] and [Run] separately do not need to -// import the flag package solely for error checking. +// ErrHelp is returned by [Parse] when a help flag is present. // -// Note: [ParseAndRun] handles this automatically and never surfaces ErrHelp to the caller. +// [ParseAndRun] handles ErrHelp automatically by printing [Help] to stdout and returning nil. +// Callers that use [Parse] and [Run] separately can check errors.Is(err, ErrHelp) and render help +// themselves. var ErrHelp = flag.ErrHelp -// Command represents a CLI command or subcommand within the application's command hierarchy. +// Command defines one command in a CLI. +// +// A command can be the root command passed to [ParseAndRun], or a subcommand listed in +// [Command.SubCommands]. Most programs define Name, optional help fields, optional flags, and Exec. type Command struct { - // Name is always a single word representing the command's name. It is used to identify the - // command in the command hierarchy and in help text. + // Name is the single word users type to select the command. Name string - // Usage provides the command's full usage pattern. + // Usage overrides the generated usage line when the command needs a custom synopsis. // // Example: "cli todo list [flags]" Usage string - // ShortHelp is a brief description of the command's purpose. It is displayed in the help text - // when the command is shown. + // ShortHelp describes the command in help output and parent command listings. ShortHelp string - // UsageFunc is an optional function that can be used to generate a custom usage string for the - // command. It receives the current command and should return a string with the full usage - // pattern. - UsageFunc func(*Command) string + // Help customizes the command's help document. + // + // Leave Help nil for the built-in help. Set it when you want to append examples, reorder + // sections, or replace the document entirely. The function receives the command being shown and + // the built-in document. + Help func(*Command, usage.Help) usage.Help - // Flags holds the command-specific flag definitions. Each command maintains its own flag set - // for parsing arguments. + // Flags defines the command's flags using the standard library flag package. Flags *flag.FlagSet - // FlagOptions is an optional list of flag options to extend the FlagSet with additional - // behavior. This is useful for tracking required flags, short aliases, and local flags. - FlagOptions []FlagOption - // SubCommands is a list of nested commands that exist under this command. + // FlagConfigs adds cli-specific behavior to flags already defined in Flags. + // + // Use it for required flags, short aliases, and flags that should not be inherited by + // subcommands. + FlagConfigs []FlagConfig + + // SubCommands lists commands users can select after this command's name. SubCommands []*Command - // Exec defines the command's execution logic. It receives the current application [State] and - // returns an error if execution fails. This function is called when [Run] is invoked on the - // command. + // Exec runs after parsing selects this command. + // + // Return [UsageErrorf] for invalid args or flag combinations so Run can print help. Return a + // normal error for operational failures. Exec func(ctx context.Context, s *State) error state *State } -// Path returns the command chain from root to current command. It can only be called after the root -// command has been parsed and the command hierarchy has been established. +// Path returns the parsed command chain from root to this command. +// +// Call Path after [Parse] when command logic needs to inspect where the selected command sits in +// the command tree. func (c *Command) Path() []*Command { if c.state == nil { return nil @@ -71,26 +80,22 @@ func (c *Command) terminal() *Command { return c.state.path[len(c.state.path)-1] } -// FlagOption holds additional options for a flag, such as whether it is required or has a short -// alias. -type FlagOption struct { - // Name is the flag's name. Must match the flag name in the flag set. +// FlagConfig adds cli-specific behavior to a flag defined in a command's FlagSet. +type FlagConfig struct { + // Name is the flag's long name as registered in the command's FlagSet. Name string - // Short is an optional single-character alias for the flag. When set, users can use either -v - // or -verbose (if Short is "v" and Name is "verbose"). Must be a single ASCII letter. + // Short lets users type a one-letter alias, such as -v for --verbose. Short string - // Required indicates whether the flag is required. + // Required requires users to provide the flag explicitly. Required bool - // Local indicates that the flag should not be inherited by child commands. When true, the flag - // is only available on the command that defines it. + // Local keeps the flag on this command instead of inheriting it into subcommands. Local bool } -// FlagsFunc is a helper function that creates a new [flag.FlagSet] and applies the given function -// to it. Intended for use in command definitions to simplify flag setup. Example usage: +// FlagsFunc creates a FlagSet inline for a command definition. // // cmd.Flags = cli.FlagsFunc(func(f *flag.FlagSet) { // f.Bool("verbose", false, "enable verbose output") diff --git a/error.go b/error.go new file mode 100644 index 0000000..eb9cadb --- /dev/null +++ b/error.go @@ -0,0 +1,29 @@ +package cli + +import "fmt" + +// UsageError marks an invalid command invocation. +// +// You normally create one with [UsageErrorf] rather than constructing this type directly. +type UsageError struct { + err error +} + +// UsageErrorf returns an error for invalid command-line usage. +// +// Return UsageErrorf from Exec when the command was selected successfully but the remaining args or +// flag combination are invalid. [Run] prints Help(s.Cmd) to stderr, then returns the formatted error +// without the UsageError wrapper. +func UsageErrorf(format string, args ...any) error { + return &UsageError{err: fmt.Errorf(format, args...)} +} + +// Error returns the formatted usage error message. +func (e *UsageError) Error() string { + return e.err.Error() +} + +// Unwrap exposes the formatted error for errors.Is and errors.As. +func (e *UsageError) Unwrap() error { + return e.err +} diff --git a/error_test.go b/error_test.go new file mode 100644 index 0000000..3751e2c --- /dev/null +++ b/error_test.go @@ -0,0 +1,19 @@ +package cli + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUsageError(t *testing.T) { + t.Parallel() + + err := UsageErrorf("missing %s", "name") + require.EqualError(t, err, "missing name") + + var usageErr *UsageError + require.True(t, errors.As(err, &usageErr)) + require.EqualError(t, errors.Unwrap(err), "missing name") +} diff --git a/examples/cmd/echo/main.go b/examples/cmd/echo/main.go index b5f0310..7b1812d 100644 --- a/examples/cmd/echo/main.go +++ b/examples/cmd/echo/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "errors" "flag" "fmt" "os" @@ -20,12 +19,12 @@ func main() { // Add a flag to capitalize the input f.Bool("c", false, "capitalize the input") }), - FlagOptions: []cli.FlagOption{ + FlagConfigs: []cli.FlagConfig{ {Name: "c", Required: true}, }, Exec: func(ctx context.Context, s *cli.State) error { if len(s.Args) == 0 { - return errors.New("must provide text to echo, see --help") + return cli.UsageErrorf("must provide text to echo") } output := strings.Join(s.Args, " ") // If -c flag is set, capitalize the output diff --git a/examples/cmd/task/main.go b/examples/cmd/task/main.go index 48ed76c..8799fc6 100644 --- a/examples/cmd/task/main.go +++ b/examples/cmd/task/main.go @@ -3,7 +3,6 @@ package main import ( "bufio" "context" - "errors" "flag" "fmt" "os" @@ -12,6 +11,7 @@ import ( "time" "github.com/pressly/cli" + "github.com/pressly/cli/usage" ) func main() { @@ -23,13 +23,15 @@ func main() { f.Bool("verbose", false, "enable verbose output") f.Bool("version", false, "print the version") }), + Help: func(c *cli.Command, h usage.Help) usage.Help { + return append(h, usage.Lines("Examples:", "todo list today --file tasks.json", "todo task add --file tasks.json \"write docs\"")) + }, Exec: func(ctx context.Context, s *cli.State) error { if cli.GetFlag[bool](s, "version") { fmt.Fprintf(s.Stdout, "todo v1.0.0\n") return nil } - fmt.Fprintf(s.Stderr, "todo: subcommand required, use --help for more information\n") - return nil + return cli.UsageErrorf("subcommand required") }, SubCommands: []*cli.Command{ list(), @@ -52,12 +54,11 @@ func list() *cli.Command { f.String("file", "", "path to the tasks file") f.String("tags", "", "filter tasks by tags") }), - FlagOptions: []cli.FlagOption{ + FlagConfigs: []cli.FlagConfig{ {Name: "file", Required: true}, }, Exec: func(ctx context.Context, s *cli.State) error { - fmt.Fprintf(s.Stderr, "todo list: subcommand required, use --help for more information\n") - return nil + return cli.UsageErrorf("subcommand required") }, SubCommands: []*cli.Command{ listToday(), @@ -126,7 +127,7 @@ func task() *cli.Command { Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.String("file", "", "path to the tasks file") }), - FlagOptions: []cli.FlagOption{ + FlagConfigs: []cli.FlagConfig{ {Name: "file", Required: true}, }, ShortHelp: "Manage tasks", @@ -184,7 +185,7 @@ func taskDone() *cli.Command { ShortHelp: "Mark a task as done", Exec: func(ctx context.Context, s *cli.State) error { if len(s.Args) == 0 { - return errors.New("task ID required") + return cli.UsageErrorf("task ID required") } tasks, err := getTasksFromFile(s) if err != nil { @@ -216,7 +217,7 @@ func taskRemove() *cli.Command { file = cli.GetFlag[string](s, "file") ) if len(s.Args) == 0 && !all { - return errors.New("task ID required, or use --all to remove all tasks") + return cli.UsageErrorf("task ID required, or use --all to remove all tasks") } if all { if !force { diff --git a/parse.go b/parse.go index df65291..ec17d87 100644 --- a/parse.go +++ b/parse.go @@ -13,12 +13,11 @@ import ( "github.com/pressly/cli/xflag" ) -// Parse traverses the command hierarchy and parses arguments. It returns an error if parsing fails -// at any point. +// Parse resolves a command and parses its flags without running it. // -// This function is the main entry point for parsing command-line arguments and should be called -// with the root command and the arguments to parse, typically os.Args[1:]. Once parsing is -// complete, the root command is ready to be executed with the [Run] function. +// Most programs should use [ParseAndRun]. Use Parse directly when you need to inspect parsed flags +// or initialize resources before calling [Run]. If the user asks for help, Parse returns [ErrHelp] +// after resolving the command so [Help] can render the right command document. func Parse(root *Command, args []string) error { if root == nil { return fmt.Errorf("failed to parse: root command is nil") @@ -27,15 +26,17 @@ func Parse(root *Command, args []string) error { return fmt.Errorf("failed to parse: %w", err) } - // Initialize or update root state - if root.state == nil { - root.state = &State{ - path: []*Command{root}, - } - } else { - // Reset command path but preserve other state - root.state.path = []*Command{root} + // Initialize or update root state. Clear command pointers across the tree first so stale + // subcommands from a previous parse do not retain the newly resolved path. + state := root.state + clearCommandState(root) + if state == nil { + state = &State{} } + root.state = state + root.state.Args = nil + root.state.Cmd = nil + root.state.path = []*Command{root} argsToParse, remainingArgs := splitAtDelimiter(args) @@ -43,6 +44,7 @@ func Parse(root *Command, args []string) error { if err != nil { return err } + root.state.Cmd = current current.Flags.Usage = func() { /* suppress default usage */ } // Check for help flags after resolving the correct command @@ -106,11 +108,11 @@ func resolveCommandPath(root *Command, argsToParse []string) (*Command, error) { // Check if this flag expects a value across all commands in the chain (not just the // current command), since flags from ancestor commands are inherited and can appear - // anywhere. Also check short flag aliases from FlagOptions. + // anywhere. Also check short flag aliases from FlagConfigs. name := strings.TrimLeft(arg, "-") skipValue := false for _, cmd := range root.state.path { - localFlags := localFlagSet(cmd.FlagOptions) + localFlags := localFlagSet(cmd.FlagConfigs) // Skip local flags on ancestor commands (any command already in the path is an // ancestor of the not-yet-resolved terminal command). if localFlags[name] { @@ -120,7 +122,7 @@ func resolveCommandPath(root *Command, argsToParse []string) (*Command, error) { f := cmd.Flags.Lookup(name) // If not found, check if it's a short alias. if f == nil { - for _, fm := range cmd.FlagOptions { + for _, fm := range cmd.FlagConfigs { if fm.Short == name { if localFlags[fm.Name] { break @@ -150,6 +152,7 @@ func resolveCommandPath(root *Command, argsToParse []string) (*Command, error) { if len(current.SubCommands) > 0 { if sub := current.findSubCommand(arg); sub != nil { root.state.path = append(slices.Clone(root.state.path), sub) + sub.state = root.state if sub.Flags == nil { sub.Flags = flag.NewFlagSet(sub.Name, flag.ContinueOnError) } @@ -164,9 +167,19 @@ func resolveCommandPath(root *Command, argsToParse []string) (*Command, error) { return current, nil } +func clearCommandState(cmd *Command) { + if cmd == nil { + return + } + cmd.state = nil + for _, sub := range cmd.SubCommands { + clearCommandState(sub) + } +} + // combineFlags merges flags from the command path into a single FlagSet. Flags are added in reverse // order (deepest command first) so that child flags take precedence over parent flags. Short flag -// aliases from FlagOptions are also registered, sharing the same Value as their long counterpart. +// aliases from FlagConfigs are also registered, sharing the same Value as their long counterpart. func combineFlags(path []*Command) *flag.FlagSet { combined := flag.NewFlagSet(path[0].Name, flag.ContinueOnError) combined.SetOutput(io.Discard) @@ -176,8 +189,8 @@ func combineFlags(path []*Command) *flag.FlagSet { if cmd.Flags == nil { continue } - localFlags := localFlagSet(cmd.FlagOptions) - shortMap := shortFlagMap(cmd.FlagOptions) + localFlags := localFlagSet(cmd.FlagConfigs) + shortMap := shortFlagMap(cmd.FlagConfigs) isAncestor := i < terminalIdx cmd.Flags.VisitAll(func(f *flag.Flag) { // Skip local flags from ancestor commands — they are not inherited. @@ -198,8 +211,8 @@ func combineFlags(path []*Command) *flag.FlagSet { return combined } -// localFlagSet builds a set of flag names that are marked as local in FlagOptions. -func localFlagSet(options []FlagOption) map[string]bool { +// localFlagSet builds a set of flag names that are marked as local in FlagConfigs. +func localFlagSet(options []FlagConfig) map[string]bool { m := make(map[string]bool, len(options)) for _, fm := range options { if fm.Local { @@ -209,8 +222,8 @@ func localFlagSet(options []FlagOption) map[string]bool { return m } -// shortFlagMap builds a map from long flag name to short alias from FlagOptions. -func shortFlagMap(options []FlagOption) map[string]string { +// shortFlagMap builds a map from long flag name to short alias from FlagConfigs. +func shortFlagMap(options []FlagConfig) map[string]string { m := make(map[string]string, len(options)) for _, fm := range options { if fm.Short != "" { @@ -220,7 +233,7 @@ func shortFlagMap(options []FlagOption) map[string]string { return m } -// checkRequiredFlags verifies that all flags marked as required in FlagOptions were explicitly set +// checkRequiredFlags verifies that all flags marked as required in FlagConfigs were explicitly set // during parsing. func checkRequiredFlags(path []*Command, combined *flag.FlagSet) error { // Build a set of flags that were explicitly set during parsing. Visit (unlike VisitAll) only @@ -233,7 +246,7 @@ func checkRequiredFlags(path []*Command, combined *flag.FlagSet) error { terminalIdx := len(path) - 1 var missingFlags []string for i, cmd := range path { - for _, fo := range cmd.FlagOptions { + for _, fo := range cmd.FlagConfigs { if !fo.Required { continue } @@ -312,7 +325,7 @@ func validateCommands(root *Command, path []string) error { return fmt.Errorf("command [%s]: %w", strings.Join(quoted, ", "), err) } - if err := validateFlagOptions(root); err != nil { + if err := validateFlagConfigs(root); err != nil { quoted := make([]string, len(currentPath)) for i, p := range currentPath { quoted[i] = strconv.Quote(p) @@ -328,17 +341,17 @@ func validateCommands(root *Command, path []string) error { return nil } -// validateFlagOptions checks that each FlagOption entry refers to a flag that exists in the +// validateFlagConfigs checks that each FlagConfig entry refers to a flag that exists in the // command's FlagSet, that Short aliases are single ASCII letters, and that no two entries share the // same Short alias. -func validateFlagOptions(cmd *Command) error { - if len(cmd.FlagOptions) == 0 { +func validateFlagConfigs(cmd *Command) error { + if len(cmd.FlagConfigs) == 0 { return nil } seenShorts := make(map[string]string) // short -> flag name - for _, fm := range cmd.FlagOptions { + for _, fm := range cmd.FlagConfigs { if cmd.Flags == nil || cmd.Flags.Lookup(fm.Name) == nil { - return fmt.Errorf("flag option references unknown flag %q", fm.Name) + return fmt.Errorf("flag config references unknown flag %q", fm.Name) } if fm.Short == "" { continue diff --git a/parse_test.go b/parse_test.go index 920edc0..7b84585 100644 --- a/parse_test.go +++ b/parse_test.go @@ -38,7 +38,7 @@ func newTestState() testState { Flags: FlagsFunc(func(fset *flag.FlagSet) { fset.String("echo", "", "echo the message") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "echo", Required: false}, // not required }, Exec: exec, @@ -49,7 +49,7 @@ func newTestState() testState { fset.Bool("mandatory-flag", false, "mandatory flag") fset.String("another-mandatory-flag", "", "another mandatory flag") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "mandatory-flag", Required: true}, {Name: "another-mandatory-flag", Required: true}, }, @@ -362,13 +362,13 @@ func TestParse(t *testing.T) { t.Parallel() cmd := &Command{ Name: "root", - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "some-other-flag", Required: true}, }, } err := Parse(cmd, nil) require.Error(t, err) - require.ErrorContains(t, err, `flag option references unknown flag "some-other-flag"`) + require.ErrorContains(t, err, `flag config references unknown flag "some-other-flag"`) }) t.Run("space in command name", func(t *testing.T) { t.Parallel() @@ -552,14 +552,14 @@ func TestParse(t *testing.T) { require.NoError(t, err) // Just ensure it doesn't crash and can parse the first match }) - t.Run("flag option for non-existent flag", func(t *testing.T) { + t.Run("flag config for non-existent flag", func(t *testing.T) { t.Parallel() cmd := &Command{ Name: "root", Flags: FlagsFunc(func(fset *flag.FlagSet) { fset.String("existing", "", "existing flag") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "existing", Required: true}, {Name: "nonexistent", Required: true}, }, @@ -567,7 +567,7 @@ func TestParse(t *testing.T) { } err := Parse(cmd, []string{"--existing=value"}) require.Error(t, err) - require.ErrorContains(t, err, `flag option references unknown flag "nonexistent"`) + require.ErrorContains(t, err, `flag config references unknown flag "nonexistent"`) }) t.Run("args with special characters", func(t *testing.T) { t.Parallel() @@ -645,7 +645,7 @@ func TestParse(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.String("port", "8080", "port number") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "port", Required: true}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -665,7 +665,7 @@ func TestParse(t *testing.T) { f.Bool("force", false, "force operation") f.Bool("force-all", false, "force all") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "force", Required: true}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -705,7 +705,7 @@ func TestShortFlags(t *testing.T) { f.Bool("verbose", false, "enable verbose output") f.String("output", "", "output file") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "verbose", Short: "v"}, {Name: "output", Short: "o"}, }, @@ -724,7 +724,7 @@ func TestShortFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.Bool("verbose", false, "enable verbose output") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "verbose", Short: "v"}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -741,7 +741,7 @@ func TestShortFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.String("name", "", "the name") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "name", Short: "n"}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -751,7 +751,7 @@ func TestShortFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.Bool("verbose", false, "verbose") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "verbose", Short: "v"}, }, SubCommands: []*Command{child}, @@ -770,7 +770,7 @@ func TestShortFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.Int("count", 0, "number of items") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "count", Short: "c"}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -789,14 +789,14 @@ func TestShortFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.Bool("verbose", false, "enable verbose output") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "vrbose", Short: "v"}, // typo in Name }, Exec: func(ctx context.Context, s *State) error { return nil }, } err := Parse(cmd, []string{}) require.Error(t, err) - require.Contains(t, err.Error(), `flag option references unknown flag "vrbose"`) + require.Contains(t, err.Error(), `flag config references unknown flag "vrbose"`) }) t.Run("short alias must be single ASCII letter", func(t *testing.T) { @@ -806,7 +806,7 @@ func TestShortFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.Bool("verbose", false, "enable verbose output") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "verbose", Short: "vv"}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -824,7 +824,7 @@ func TestShortFlags(t *testing.T) { f.Bool("verbose", false, "enable verbose output") f.Bool("version", false, "show version") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "verbose", Short: "v"}, {Name: "version", Short: "v"}, }, @@ -851,7 +851,7 @@ func TestLocalFlags(t *testing.T) { f.Bool("version", false, "show version") f.Bool("verbose", false, "enable verbose output") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "version", Local: true}, }, SubCommands: []*Command{child}, @@ -869,7 +869,7 @@ func TestLocalFlags(t *testing.T) { f.Bool("version", false, "show version") f.Bool("verbose", false, "enable verbose output") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "version", Local: true}, }, SubCommands: []*Command{{ @@ -890,7 +890,7 @@ func TestLocalFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.Bool("version", false, "show version") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "version", Local: true}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -911,7 +911,7 @@ func TestLocalFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.String("token", "", "auth token") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "token", Required: true, Local: true}, }, SubCommands: []*Command{child}, @@ -927,7 +927,7 @@ func TestLocalFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.String("token", "", "auth token") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "token", Required: true, Local: true}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -952,7 +952,7 @@ func TestLocalFlags(t *testing.T) { f.Bool("version", false, "show version") f.Bool("verbose", false, "enable verbose output") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "version", Local: true}, }, SubCommands: []*Command{child}, @@ -961,7 +961,7 @@ func TestLocalFlags(t *testing.T) { err := Parse(root, []string{"child", "--help"}) require.ErrorIs(t, err, flag.ErrHelp) - usage := DefaultUsage(root) + usage := Help(root).String() // --verbose should appear in inherited flags (not local) assert.Contains(t, usage, "--verbose") // --version should NOT appear (local to root, not inherited) @@ -981,7 +981,7 @@ func TestLocalFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.Bool("version", false, "show version") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "version", Short: "V", Local: true}, }, SubCommands: []*Command{child}, diff --git a/path_test.go b/path_test.go index 274168e..290d475 100644 --- a/path_test.go +++ b/path_test.go @@ -181,6 +181,8 @@ func TestCommandPath(t *testing.T) { require.Len(t, path, 2) require.Equal(t, "root", path[0].Name) require.Equal(t, "child2", path[1].Name) + require.Nil(t, child1.Path()) + require.Equal(t, path, child2.Path()) }) t.Run("command with complex names in path", func(t *testing.T) { diff --git a/run.go b/run.go index c35cf22..d31f6b0 100644 --- a/run.go +++ b/run.go @@ -13,20 +13,21 @@ import ( "sync" ) -// RunOptions specifies options for running a command. +// RunOptions overrides the standard streams used by [Run] and [ParseAndRun]. +// +// Leave it nil for normal CLI programs. Provide it in tests or embedded applications that need to +// capture output or supply custom input. type RunOptions struct { - // Stdin, Stdout, and Stderr are the standard input, output, and error streams for the command. - // If any of these are nil, the command will use the default streams ([os.Stdin], [os.Stdout], - // and [os.Stderr], respectively). + // Stdin, Stdout, and Stderr replace os.Stdin, os.Stdout, and os.Stderr when set. Stdin io.Reader Stdout, Stderr io.Writer } -// Run executes the current command. It returns an error if the command has not been parsed or if -// the command has no execution function. +// Run executes the command selected by [Parse]. // -// The options parameter may be nil, in which case default values are used. See [RunOptions] for -// more details. +// Use Run only with the split [Parse]/Run flow. [ParseAndRun] is the usual entry point. If Exec +// returns [UsageErrorf], Run prints [Help] for the selected command to stderr and returns the +// underlying error. func Run(ctx context.Context, root *Command, options *RunOptions) error { if ctx == nil { ctx = context.Background() @@ -49,27 +50,23 @@ func Run(ctx context.Context, root *Command, options *RunOptions) error { return run(ctx, cmd, root.state) } -// ParseAndRun is a convenience function that combines [Parse] and [Run] into a single call. It -// parses the command hierarchy, handles help flags automatically (printing usage to stdout and -// returning nil), and then executes the resolved command. +// ParseAndRun parses args and runs the selected command. // -// This is the recommended entry point for most CLI applications: +// Use ParseAndRun as the normal entry point for CLI applications. It handles help flags by printing +// [Help] to stdout and returning nil, then runs Exec for the selected command. // // if err := cli.ParseAndRun(ctx, root, os.Args[1:], nil); err != nil { // fmt.Fprintf(os.Stderr, "error: %v\n", err) // os.Exit(1) // } // -// The options parameter may be nil, in which case default values are used. See [RunOptions] for -// more details. -// -// For applications that need to perform work between parsing and execution (e.g., initializing -// resources based on parsed flags), use [Parse] and [Run] separately. +// Use [Parse] and [Run] separately when you need work between parsing and execution, such as +// initializing resources from parsed flags. func ParseAndRun(ctx context.Context, root *Command, args []string, options *RunOptions) error { if err := Parse(root, args); err != nil { if errors.Is(err, ErrHelp) { options = checkAndSetRunOptions(options) - _, _ = fmt.Fprintln(options.Stdout, DefaultUsage(root)) + _, _ = fmt.Fprintln(options.Stdout, Help(root)) return nil } return err @@ -94,7 +91,13 @@ func run(ctx context.Context, cmd *Command, state *State) (retErr error) { } } }() - return cmd.Exec(ctx, state) + err := cmd.Exec(ctx, state) + var usageErr *UsageError + if errors.As(err, &usageErr) { + _, _ = fmt.Fprintln(state.Stderr, Help(state.Cmd)) + return usageErr.Unwrap() + } + return err } func updateState(s *State, opt *RunOptions) { diff --git a/run_test.go b/run_test.go index 4b1639e..66e06f5 100644 --- a/run_test.go +++ b/run_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "flag" + "fmt" "strings" "testing" @@ -229,3 +230,41 @@ func TestRun(t *testing.T) { } }) } + +func TestParseAndRun(t *testing.T) { + t.Parallel() + + t.Run("runs command", func(t *testing.T) { + t.Parallel() + + stdout := bytes.NewBuffer(nil) + root := &Command{ + Name: "greet", + Exec: func(ctx context.Context, s *State) error { + fmt.Fprintln(s.Stdout, "hello") + return nil + }, + } + + err := ParseAndRun(context.Background(), root, nil, &RunOptions{Stdout: stdout}) + require.NoError(t, err) + require.Equal(t, "hello\n", stdout.String()) + }) + + t.Run("prints help", func(t *testing.T) { + t.Parallel() + + stdout := bytes.NewBuffer(nil) + root := &Command{ + Name: "greet", + ShortHelp: "Print a greeting", + Exec: func(ctx context.Context, s *State) error { return nil }, + } + + err := ParseAndRun(context.Background(), root, []string{"--help"}, &RunOptions{Stdout: stdout}) + require.NoError(t, err) + require.Contains(t, stdout.String(), "Print a greeting") + require.Contains(t, stdout.String(), "Usage:") + require.Contains(t, stdout.String(), "greet") + }) +} diff --git a/state.go b/state.go index e1fa297..0a98104 100644 --- a/state.go +++ b/state.go @@ -1,37 +1,46 @@ package cli import ( + "errors" "flag" "fmt" "io" ) -// State holds command information during Exec function execution, allowing child commands to access -// parent flags. Use [GetFlag] to get flag values across the command hierarchy. +// State is passed to Exec with the parsed invocation context. +// +// Use Args for remaining positional arguments, Stdin/Stdout/Stderr for command I/O, Cmd for the +// selected command, and [GetFlag] to read parsed flag values. type State struct { - // Args contains the remaining arguments after flag parsing. + // Args contains positional arguments left after command and flag parsing. Args []string - // Standard I/O streams. + // Stdin, Stdout, and Stderr are the streams command code should use instead of package-level + // os.Stdin, os.Stdout, and os.Stderr. Stdin io.Reader Stdout, Stderr io.Writer + // Cmd is the command selected by parsing. + Cmd *Command + // path is the command hierarchy from the root command to the current command. The root command // is the first element in the path, and the terminal command is the last element. path []*Command } -// GetFlag retrieves a flag value by name from the command hierarchy. It first checks the current -// command's flags, then walks up through parent commands. +// GetFlag reads a parsed flag value from State. // -// If the flag doesn't exist or if the type doesn't match the requested type T an error will be -// raised in the Run function. This is an internal error and should never happen in normal usage. -// This ensures flag-related programming errors are caught early during development. +// Call GetFlag from Exec with the same Go type used to define the flag. It checks the selected +// command first, then inherited parent flags. A missing flag or wrong type is treated as a +// programming error and returned from [Run]. // // verbose := GetFlag[bool](state, "verbose") // count := GetFlag[int](state, "count") // path := GetFlag[string](state, "path") func GetFlag[T any](s *State, name string) T { + if s == nil { + panic(&internalError{err: errors.New("state is nil")}) + } // 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] diff --git a/state_test.go b/state_test.go index 6e09711..05d91d7 100644 --- a/state_test.go +++ b/state_test.go @@ -1,9 +1,13 @@ package cli import ( + "context" "flag" + "fmt" + "strings" "testing" + "github.com/pressly/cli/usage" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -48,3 +52,126 @@ func TestGetFlag(t *testing.T) { _ = GetFlag[int](state, "version") }) } + +func TestStateCommandContext(t *testing.T) { + t.Parallel() + + t.Run("command and command path", func(t *testing.T) { + t.Parallel() + + child := &Command{ + Name: "child", + Exec: func(ctx context.Context, s *State) error { + require.Equal(t, "child", s.Cmd.Name) + require.Equal(t, []*Command{s.path[0], s.Cmd}, s.Cmd.Path()) + return nil + }, + } + root := &Command{ + Name: "root", + SubCommands: []*Command{child}, + } + + err := Parse(root, []string{"child"}) + require.NoError(t, err) + err = Run(context.Background(), root, nil) + require.NoError(t, err) + }) + + t.Run("usage uses terminal custom usage", func(t *testing.T) { + t.Parallel() + + root := &Command{ + Name: "root", + SubCommands: []*Command{ + { + Name: "child", + Help: func(c *Command, h usage.Help) usage.Help { + return append(h, usage.Lines("Examples:", "root child file.txt")) + }, + Exec: func(ctx context.Context, s *State) error { + output := Help(s.Cmd).String() + require.Contains(t, output, "Examples:") + require.Contains(t, output, "root child file.txt") + return nil + }, + }, + }, + } + + err := Parse(root, []string{"child"}) + require.NoError(t, err) + err = Run(context.Background(), root, nil) + require.NoError(t, err) + }) + + t.Run("usage error prints help and returns underlying error", func(t *testing.T) { + t.Parallel() + + root := &Command{ + Name: "greet", + Exec: func(ctx context.Context, s *State) error { + return UsageErrorf("must supply a name") + }, + } + + err := Parse(root, nil) + require.NoError(t, err) + stderr := new(strings.Builder) + err = Run(context.Background(), root, &RunOptions{Stderr: stderr}) + require.Error(t, err) + require.EqualError(t, err, "must supply a name") + require.Contains(t, stderr.String(), "Usage:") + require.Contains(t, stderr.String(), "greet") + }) + + t.Run("usage error prints terminal command help", func(t *testing.T) { + t.Parallel() + + root := &Command{ + Name: "root", + Flags: FlagsFunc(func(f *flag.FlagSet) { + f.Bool("verbose", false, "enable verbose output") + }), + SubCommands: []*Command{ + { + Name: "child", + ShortHelp: "Run the child command", + Exec: func(ctx context.Context, s *State) error { + return UsageErrorf("missing file") + }, + }, + }, + } + + err := Parse(root, []string{"child"}) + require.NoError(t, err) + stderr := new(strings.Builder) + err = Run(context.Background(), root, &RunOptions{Stderr: stderr}) + require.Error(t, err) + require.EqualError(t, err, "missing file") + require.Contains(t, stderr.String(), "Run the child command") + require.Contains(t, stderr.String(), "root child [flags]") + require.Contains(t, stderr.String(), "Inherited Flags:") + require.Contains(t, stderr.String(), "--verbose") + }) + + t.Run("normal error does not print help", func(t *testing.T) { + t.Parallel() + + root := &Command{ + Name: "greet", + Exec: func(ctx context.Context, s *State) error { + return fmt.Errorf("boom") + }, + } + + err := Parse(root, nil) + require.NoError(t, err) + stderr := new(strings.Builder) + err = Run(context.Background(), root, &RunOptions{Stderr: stderr}) + require.Error(t, err) + require.EqualError(t, err, "boom") + require.Empty(t, stderr.String()) + }) +} diff --git a/usage.go b/usage.go index 58e9b4e..07a808e 100644 --- a/usage.go +++ b/usage.go @@ -7,87 +7,105 @@ import ( "slices" "strings" - "github.com/pressly/cli/pkg/textutil" + "github.com/pressly/cli/usage" ) -// defaultTerminalWidth is the assumed terminal width for wrapping help text. -const defaultTerminalWidth = 80 - -// DefaultUsage returns the default usage string for the command hierarchy. It is used when the -// command does not provide a custom usage function. The usage string includes the command's short -// help, usage pattern, available subcommands, and flags. -func DefaultUsage(root *Command) string { +// Help returns the help document for root's resolved command. +// +// Call Help after Parse when you want to render help yourself, or inside a Command.Help hook when +// composing the default help document. ParseAndRun calls it automatically for --help and UsageErrorf +// errors. +func Help(root *Command) usage.Help { if root == nil { - return "" + return nil } // Get terminal command from state terminalCmd := root.terminal() - var b strings.Builder - - if terminalCmd.UsageFunc != nil { - return terminalCmd.UsageFunc(terminalCmd) - } + var help usage.Help if terminalCmd.ShortHelp != "" { - b.WriteString(terminalCmd.ShortHelp) - b.WriteString("\n\n") + help = append(help, usage.Text(terminalCmd.ShortHelp)) } - b.WriteString("Usage:\n") + flags := collectHelpFlags(root, terminalCmd) + + var usageLine string if terminalCmd.Usage != "" { - b.WriteString(" " + terminalCmd.Usage + "\n") + usageLine = terminalCmd.Usage } else { - usage := terminalCmd.Name + usageLine = terminalCmd.Name if root.state != nil && len(root.state.path) > 0 { - usage = getCommandPath(root.state.path) + usageLine = getCommandPath(root.state.path) } - if terminalCmd.Flags != nil { - usage += " [flags]" + if len(flags) > 0 { + usageLine += " [flags]" } if len(terminalCmd.SubCommands) > 0 { - usage += " " + usageLine += " " } - b.WriteString(" " + usage + "\n") } - b.WriteString("\n") + help = append(help, usage.Lines("Usage:", usageLine)) if len(terminalCmd.SubCommands) > 0 { - b.WriteString("Available Commands:\n") sortedCommands := slices.Clone(terminalCmd.SubCommands) slices.SortFunc(sortedCommands, func(a, b *Command) int { return cmp.Compare(a.Name, b.Name) }) - maxNameLen := 0 + subcommands := make([]usage.Command, 0, len(sortedCommands)) for _, sub := range sortedCommands { - if len(sub.Name) > maxNameLen { - maxNameLen = len(sub.Name) - } + subcommands = append(subcommands, usage.Command{ + Name: sub.Name, + Summary: sub.ShortHelp, + }) } + help = append(help, usage.Commands("Available Commands:", subcommands)) + } - nameWidth := maxNameLen + 4 - wrapWidth := defaultTerminalWidth - nameWidth + if len(flags) > 0 { + slices.SortFunc(flags, func(a, b flagInfo) int { + return cmp.Compare(a.name, b.name) + }) - for _, sub := range sortedCommands { - if sub.ShortHelp == "" { - fmt.Fprintf(&b, " %s\n", sub.Name) - continue + hasLocal := false + hasInherited := false + for _, f := range flags { + if f.inherited { + hasInherited = true + } else { + hasLocal = true } + } - lines := textutil.Wrap(sub.ShortHelp, wrapWidth) - padding := strings.Repeat(" ", maxNameLen-len(sub.Name)+4) - fmt.Fprintf(&b, " %s%s%s\n", sub.Name, padding, lines[0]) + if hasLocal { + help = append(help, usage.Flags("Flags:", usageFlags(flags, false))) + } - indentPadding := strings.Repeat(" ", nameWidth+2) - for _, line := range lines[1:] { - fmt.Fprintf(&b, "%s%s\n", indentPadding, line) - } + if hasInherited { + help = append(help, usage.Flags("Inherited Flags:", usageFlags(flags, true))) + } + } + + if len(terminalCmd.SubCommands) > 0 { + cmdName := terminalCmd.Name + if root.state != nil && len(root.state.path) > 0 { + cmdName = getCommandPath(root.state.path) } - b.WriteString("\n") + help = append(help, usage.Text( + fmt.Sprintf("Use \"%s [command] --help\" for more information about a command.", cmdName), + )) } + if terminalCmd.Help != nil { + help = terminalCmd.Help(terminalCmd, help) + } + + return help +} + +func collectHelpFlags(root, terminalCmd *Command) []flagInfo { var flags []flagInfo if root.state != nil && len(root.state.path) > 0 { terminalIdx := len(root.state.path) - 1 @@ -96,7 +114,7 @@ func DefaultUsage(root *Command) string { continue } isInherited := i < terminalIdx - metaMap := flagOptionMap(cmd.FlagOptions) + metaMap := flagConfigMap(cmd.FlagConfigs) cmd.Flags.VisitAll(func(f *flag.Flag) { // Skip local flags from ancestor commands — they don't appear in child help. if isInherited { @@ -120,7 +138,7 @@ func DefaultUsage(root *Command) string { } } else if terminalCmd.Flags != nil { // Pre-parse fallback: show the command's own flags even without state. - metaMap := flagOptionMap(terminalCmd.FlagOptions) + metaMap := flagConfigMap(terminalCmd.FlagConfigs) terminalCmd.Flags.VisitAll(func(f *flag.Flag) { fi := flagInfo{ name: "--" + f.Name, @@ -135,93 +153,34 @@ func DefaultUsage(root *Command) string { flags = append(flags, fi) }) } - - if len(flags) > 0 { - slices.SortFunc(flags, func(a, b flagInfo) int { - return cmp.Compare(a.name, b.name) - }) - - hasAnyShort := false - for _, f := range flags { - if f.short != "" { - hasAnyShort = true - break - } - } - - maxFlagLen := 0 - for _, f := range flags { - if n := len(f.displayName(hasAnyShort)); n > maxFlagLen { - maxFlagLen = n - } - } - - hasLocal := false - hasInherited := false - for _, f := range flags { - if f.inherited { - hasInherited = true - } else { - hasLocal = true - } - } - - if hasLocal { - b.WriteString("Flags:\n") - writeFlagSection(&b, flags, maxFlagLen, false, hasAnyShort) - b.WriteString("\n") - } - - if hasInherited { - b.WriteString("Inherited Flags:\n") - writeFlagSection(&b, flags, maxFlagLen, true, hasAnyShort) - b.WriteString("\n") - } - } - - if len(terminalCmd.SubCommands) > 0 { - cmdName := terminalCmd.Name - if root.state != nil && len(root.state.path) > 0 { - cmdName = getCommandPath(root.state.path) - } - fmt.Fprintf(&b, "Use \"%s [command] --help\" for more information about a command.\n", cmdName) - } - - return strings.TrimRight(b.String(), "\n") + return flags } -// writeFlagSection handles the formatting of flag descriptions -func writeFlagSection(b *strings.Builder, flags []flagInfo, maxLen int, inherited, hasAnyShort bool) { - nameWidth := maxLen + 4 - wrapWidth := defaultTerminalWidth - nameWidth - +func usageFlags(flags []flagInfo, inherited bool) []usage.Flag { + out := make([]usage.Flag, 0, len(flags)) for _, f := range flags { if f.inherited != inherited { continue } - - description := f.usage - if f.required { - description += " (required)" - } else if !isZeroDefault(f.defval, f.typeName) { - description += fmt.Sprintf(" (default: %s)", f.defval) - } - - display := f.displayName(hasAnyShort) - lines := textutil.Wrap(description, wrapWidth) - padding := strings.Repeat(" ", maxLen-len(display)+4) - fmt.Fprintf(b, " %s%s%s\n", display, padding, lines[0]) - - indentPadding := strings.Repeat(" ", nameWidth+2) - for _, line := range lines[1:] { - fmt.Fprintf(b, "%s%s\n", indentPadding, line) + defval := "" + if !f.required && !isZeroDefault(f.defval, f.typeName) { + defval = f.defval } + out = append(out, usage.Flag{ + Name: strings.TrimPrefix(f.name, "--"), + Short: f.short, + Placeholder: f.typeName, + Usage: f.usage, + Default: defval, + Required: f.required, + }) } + return out } -// flagOptionMap builds a lookup map from flag name to its FlagOption. -func flagOptionMap(options []FlagOption) map[string]FlagOption { - m := make(map[string]FlagOption, len(options)) +// flagConfigMap builds a lookup map from flag name to its FlagConfig. +func flagConfigMap(options []FlagConfig) map[string]FlagConfig { + m := make(map[string]FlagConfig, len(options)) for _, fm := range options { m[fm.Name] = fm } @@ -238,24 +197,6 @@ type flagInfo struct { required bool } -// displayName returns the flag name with optional short alias and type hint. When hasAnyShort is -// true, flags without a short alias are padded to align with those that have one. Examples: "-v, -// --verbose", "-o, --output string", " --config string", "--debug". -func (f flagInfo) displayName(hasAnyShort bool) string { - var name string - if f.short != "" { - name = "-" + f.short + ", " + f.name - } else if hasAnyShort { - name = " " + f.name - } else { - name = f.name - } - if f.typeName == "" { - return name - } - return name + " " + f.typeName -} - // flagTypeName returns a short type name for a flag's value. Bool flags return "" since their type // is obvious from usage. This mirrors the approach used by Go's flag.PrintDefaults. func flagTypeName(f *flag.Flag) string { diff --git a/usage/command.go b/usage/command.go new file mode 100644 index 0000000..5580156 --- /dev/null +++ b/usage/command.go @@ -0,0 +1,19 @@ +package usage + +// Command describes one command row in a help document. +type Command struct { + Name string + Summary string +} + +// Commands returns a help section for subcommands. +func Commands(heading string, commands []Command) Block { + items := make([]Item, 0, len(commands)) + for _, cmd := range commands { + items = append(items, Item{ + Name: cmd.Name, + Summary: cmd.Summary, + }) + } + return List(heading, items...) +} diff --git a/usage/flag.go b/usage/flag.go new file mode 100644 index 0000000..a39e1d7 --- /dev/null +++ b/usage/flag.go @@ -0,0 +1,71 @@ +package usage + +import "fmt" + +// Flag describes one flag row in a help document. +type Flag struct { + // Name is the long flag name without dashes, such as "verbose". + Name string + + // Short is the optional short flag name without dashes, such as "v". + Short string + + // Placeholder is shown after non-boolean flags, such as "string" or "int". + Placeholder string + + // Usage describes what the flag changes. + Usage string + + // Default is shown when the default is useful to users. + Default string + + // Required marks the flag as required in help output. + Required bool +} + +// Flags returns a help section for flag rows. +func Flags(heading string, flags []Flag) Block { + hasShort := false + for _, f := range flags { + if f.Short != "" { + hasShort = true + break + } + } + items := make([]Item, 0, len(flags)) + for _, f := range flags { + items = append(items, Item{ + Name: f.Spec(hasShort), + Summary: f.Description(), + }) + } + return List(heading, items...) +} + +// Spec returns the flag spelling shown in help output. +func (f Flag) Spec(padShort bool) string { + var name string + if f.Short != "" { + name = "-" + f.Short + ", --" + f.Name + } else if padShort { + name = " --" + f.Name + } else { + name = "--" + f.Name + } + if f.Placeholder == "" { + return name + } + return name + " " + f.Placeholder +} + +// Description returns the help text shown after the flag spelling. +func (f Flag) Description() string { + description := f.Usage + if f.Required { + return description + " (required)" + } + if f.Default != "" { + return fmt.Sprintf("%s (default: %s)", description, f.Default) + } + return description +} diff --git a/usage/flag_test.go b/usage/flag_test.go new file mode 100644 index 0000000..01ee25f --- /dev/null +++ b/usage/flag_test.go @@ -0,0 +1,23 @@ +package usage + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFlagSpec(t *testing.T) { + t.Parallel() + + require.Equal(t, "--verbose", Flag{Name: "verbose"}.Spec(false)) + require.Equal(t, " --config string", Flag{Name: "config", Placeholder: "string"}.Spec(true)) + require.Equal(t, "-o, --output string", Flag{Name: "output", Short: "o", Placeholder: "string"}.Spec(false)) +} + +func TestFlagDescription(t *testing.T) { + t.Parallel() + + require.Equal(t, "enable verbose output", Flag{Usage: "enable verbose output"}.Description()) + require.Equal(t, "output file (default: stdout)", Flag{Usage: "output file", Default: "stdout"}.Description()) + require.Equal(t, "path to file (required)", Flag{Usage: "path to file", Required: true, Default: "ignored"}.Description()) +} diff --git a/usage/help.go b/usage/help.go new file mode 100644 index 0000000..4e24241 --- /dev/null +++ b/usage/help.go @@ -0,0 +1,146 @@ +// Package usage provides small building blocks for command help documents. +// +// Use this package from a cli.Command Help hook when the default help needs examples, extra +// sections, or a different layout. +package usage + +import ( + "bytes" + "fmt" + "io" + "strings" + "text/tabwriter" +) + +// Help is an ordered list of blocks that can be rendered as command help. +type Help []Block + +// Text returns an untitled paragraph block. +// +// Use Text for descriptions, notes, or closing hints. +func Text(lines ...string) Block { + return Block{lines: lines} +} + +// Lines returns a titled block of indented lines. +// +// Use Lines for sections such as Usage or Examples where each line should stand on its own. +func Lines(heading string, lines ...string) Block { + return Block{Heading: heading, lines: lines, indent: true} +} + +// List returns a titled list of name/summary pairs. +// +// Use List for aligned sections such as commands, flags, or named examples. +func List(heading string, items ...Item) Block { + return Block{Heading: heading, items: items} +} + +// String renders the full help document as a string. +// +// Use String in tests or when you want to pass help text to an API that expects a string. +func (h Help) String() string { + var b strings.Builder + _, _ = h.WriteTo(&b) + return strings.TrimRight(b.String(), "\n") +} + +// WriteTo writes the help document to w. +// +// Use WriteTo when streaming help directly to stdout, stderr, or another writer. +func (h Help) WriteTo(w io.Writer) (n int64, err error) { + cw := &countWriter{w: w} + for i, block := range h { + if i > 0 { + if _, err := fmt.Fprintln(cw); err != nil { + return cw.n, err + } + } + if _, err := block.WriteTo(cw); err != nil { + return cw.n, err + } + } + return cw.n, nil +} + +// Block is one section in a help document. +type Block struct { + // Heading is rendered above the block when set, such as "Usage:" or "Examples:". + Heading string + + lines []string + indent bool + items []Item +} + +// String renders the block as a string. +func (b Block) String() string { + var s strings.Builder + _, _ = b.WriteTo(&s) + return strings.TrimRight(s.String(), "\n") +} + +// WriteTo writes the block to w. +func (b Block) WriteTo(w io.Writer) (n int64, err error) { + cw := &countWriter{w: w} + if b.Heading != "" { + if _, err := fmt.Fprintln(cw, b.Heading); err != nil { + return cw.n, err + } + } + if len(b.items) > 0 { + if _, err := writeItems(cw, b.items); err != nil { + return cw.n, err + } + } + for _, line := range b.lines { + if b.indent { + line = " " + line + } + if _, err := fmt.Fprintln(cw, line); err != nil { + return cw.n, err + } + } + return cw.n, nil +} + +// Item is one row in a List block. +type Item struct { + Name string + Summary string +} + +func writeItems(w io.Writer, items []Item) (int64, error) { + cw := &countWriter{w: w} + var b bytes.Buffer + tw := tabwriter.NewWriter(&b, 0, 0, 4, ' ', 0) + for _, item := range items { + if item.Summary == "" { + if _, err := fmt.Fprintf(tw, " %s\n", item.Name); err != nil { + return cw.n, err + } + continue + } + if _, err := fmt.Fprintf(tw, " %s\t%s\n", item.Name, item.Summary); err != nil { + return cw.n, err + } + } + if err := tw.Flush(); err != nil { + return cw.n, err + } + if _, err := cw.Write(b.Bytes()); err != nil { + return cw.n, err + } + return cw.n, nil +} + +type countWriter struct { + w io.Writer + n int64 +} + +func (w *countWriter) Write(p []byte) (int, error) { + n, err := w.w.Write(p) + w.n += int64(n) + return n, err +} diff --git a/usage/help_test.go b/usage/help_test.go new file mode 100644 index 0000000..433846c --- /dev/null +++ b/usage/help_test.go @@ -0,0 +1,49 @@ +package usage + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHelpString(t *testing.T) { + t.Parallel() + + h := Help{ + Text("print a greeting"), + Lines("Usage:", "greet [flags] "), + Flags("Flags:", []Flag{ + {Name: "verbose", Short: "v", Usage: "enable verbose output"}, + {Name: "output", Placeholder: "string", Usage: "output file", Required: true}, + }), + Commands("Available Commands:", []Command{ + {Name: "hello", Summary: "print hello"}, + }), + } + + output := h.String() + require.Contains(t, output, "print a greeting") + require.Contains(t, output, "Usage:") + require.Contains(t, output, "greet [flags] ") + require.Contains(t, output, "-v, --verbose") + require.Contains(t, output, "--output string") + require.Contains(t, output, "output file (required)") + require.Contains(t, output, "Available Commands:") + require.Contains(t, output, "hello") + require.False(t, strings.HasSuffix(output, "\n")) +} + +func TestBlockString(t *testing.T) { + t.Parallel() + + output := Lines("Examples:", "greet margo").String() + require.Equal(t, "Examples:\n greet margo", output) +} + +func TestListWithoutSummary(t *testing.T) { + t.Parallel() + + output := List("Commands:", Item{Name: "serve"}).String() + require.Equal(t, "Commands:\n serve", output) +} diff --git a/usage_test.go b/usage_test.go index 8bacfb0..b1b4f37 100644 --- a/usage_test.go +++ b/usage_test.go @@ -5,6 +5,7 @@ import ( "flag" "testing" + "github.com/pressly/cli/usage" "github.com/stretchr/testify/require" ) @@ -22,10 +23,13 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.NotEmpty(t, output) require.Contains(t, output, "simple") require.Contains(t, output, "Usage:") + require.Contains(t, output, " simple") + require.NotContains(t, output, "[flags]") + require.NotContains(t, output, "Flags:") }) t.Run("usage with flags", func(t *testing.T) { @@ -44,8 +48,9 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "withflags") + require.Contains(t, output, "withflags [flags]") require.Contains(t, output, "-verbose") require.Contains(t, output, "-config") require.Contains(t, output, "-count") @@ -69,13 +74,15 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "parent") require.Contains(t, output, "child1") require.Contains(t, output, "child2") require.Contains(t, output, "first child command") require.Contains(t, output, "second child command") require.Contains(t, output, "Available Commands:") + require.Contains(t, output, "parent ") + require.NotContains(t, output, "[flags]") }) t.Run("usage with flags and subcommands", func(t *testing.T) { @@ -103,7 +110,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "complex") require.Contains(t, output, "complex command with flags and subcommands") require.Contains(t, output, "-global") @@ -128,7 +135,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "longdesc") require.Contains(t, output, "very long description") require.Contains(t, output, "-long-flag") @@ -149,7 +156,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "globalonly") require.Contains(t, output, "-debug") require.Contains(t, output, "-output") @@ -178,7 +185,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "manychildren") for i := 0; i < 10; i++ { require.Contains(t, output, "cmd"+string(rune('0'+i))) @@ -197,7 +204,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "empty") require.NotEmpty(t, output) }) @@ -226,7 +233,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(root, []string{}) require.NoError(t, err) - output := DefaultUsage(root) + output := Help(root).String() require.Contains(t, output, "root") require.Contains(t, output, "root command") require.Contains(t, output, "parent") @@ -253,7 +260,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "-bool-flag") require.Contains(t, output, "-string-flag") require.Contains(t, output, "-int-flag") @@ -274,14 +281,14 @@ func TestUsageGeneration(t *testing.T) { fset.Bool("debug", false, "enable debug mode") fset.String("config", "", "config file path") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "config", Required: true}, }, Exec: func(ctx context.Context, s *State) error { return nil }, } // Usage should work even before parsing and show flags - output := DefaultUsage(cmd) + output := Help(cmd).String() require.NotEmpty(t, output) require.Contains(t, output, "Flags:") require.Contains(t, output, "-debug") @@ -301,10 +308,51 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "custom [options] ") }) + t.Run("help hook composes default document", func(t *testing.T) { + t.Parallel() + + cmd := &Command{ + Name: "custom", + ShortHelp: "custom command", + Help: func(c *Command, h usage.Help) usage.Help { + return append(h, usage.Lines("Examples:", "custom example")) + }, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + + err := Parse(cmd, []string{}) + require.NoError(t, err) + + output := Help(cmd).String() + require.Contains(t, output, "custom command") + require.Contains(t, output, "Examples:") + require.Contains(t, output, "custom example") + }) + + t.Run("help hook replaces default document", func(t *testing.T) { + t.Parallel() + + cmd := &Command{ + Name: "custom", + Help: func(c *Command, h usage.Help) usage.Help { + return usage.Help{ + usage.Text("custom help"), + } + }, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + + err := Parse(cmd, []string{}) + require.NoError(t, err) + + output := Help(cmd).String() + require.Equal(t, "custom help", output) + }) + t.Run("usage with inherited and local flags", func(t *testing.T) { t.Parallel() @@ -326,7 +374,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(parent, []string{"child"}) require.NoError(t, err) - output := DefaultUsage(parent) + output := Help(parent).String() require.Contains(t, output, "-local") require.Contains(t, output, "-global") require.Contains(t, output, "local flag") @@ -334,7 +382,7 @@ func TestUsageGeneration(t *testing.T) { }) } -func TestWriteFlagSection(t *testing.T) { +func TestFlagHelp(t *testing.T) { t.Parallel() t.Run("non-zero defaults shown and type hints", func(t *testing.T) { @@ -353,7 +401,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "Flags:") require.Contains(t, output, "-verbose") require.Contains(t, output, "-config string") @@ -384,7 +432,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() // Zero-value defaults should not appear require.NotContains(t, output, "(default: false)") require.NotContains(t, output, "(default: 0)") @@ -406,7 +454,7 @@ func TestWriteFlagSection(t *testing.T) { fset.String("file", "", "path to file") fset.String("output", "stdout", "output destination") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "file", Required: true}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -415,7 +463,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{"-file", "test.txt"}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "(required)") // Required flag should not also show a default require.NotContains(t, output, "(default: )") @@ -433,7 +481,7 @@ func TestWriteFlagSection(t *testing.T) { fset.String("output", "", "output file") fset.String("config", "", "config file path") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "verbose", Short: "v"}, {Name: "output", Short: "o"}, }, @@ -443,7 +491,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() // Flags with short aliases show both forms require.Contains(t, output, "-v, --verbose") require.Contains(t, output, "-o, --output string") @@ -466,7 +514,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() // Without any short flags, no extra padding should be added require.Contains(t, output, " --verbose") require.Contains(t, output, " --config string") @@ -485,7 +533,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.NotContains(t, output, "Flags:") require.NotContains(t, output, "Inherited Flags:") }) diff --git a/xflag/parse.go b/xflag/parse.go index 7284d76..0bbcea5 100644 --- a/xflag/parse.go +++ b/xflag/parse.go @@ -32,7 +32,7 @@ func ParseToEnd(f *flag.FlagSet, arguments []string) error { // // If you want to treat an unknown flag as a positional argument. For example: // - // $ ./cmd --valid=true arg1 --unknown-flag=foo arg2 + // $ ./cmd --valid=true arg1 --unknown-flag=foo arg2 // // Right now, this will trigger an error. But *some* users might want that unknown flag to // be treated as a positional argument. It's trivial to add this behavior, by using VisitAll From 4b5d9589db2bc6699d7e1f630731d4acc63cb692 Mon Sep 17 00:00:00 2001 From: Mike Fridman Date: Sun, 26 Apr 2026 11:18:16 +0200 Subject: [PATCH 2/8] fix lint --- run_test.go | 4 ++-- usage/command.go | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/run_test.go b/run_test.go index 66e06f5..c35e012 100644 --- a/run_test.go +++ b/run_test.go @@ -241,8 +241,8 @@ func TestParseAndRun(t *testing.T) { root := &Command{ Name: "greet", Exec: func(ctx context.Context, s *State) error { - fmt.Fprintln(s.Stdout, "hello") - return nil + _, err := fmt.Fprintln(s.Stdout, "hello") + return err }, } diff --git a/usage/command.go b/usage/command.go index 5580156..fb693a9 100644 --- a/usage/command.go +++ b/usage/command.go @@ -10,10 +10,7 @@ type Command struct { func Commands(heading string, commands []Command) Block { items := make([]Item, 0, len(commands)) for _, cmd := range commands { - items = append(items, Item{ - Name: cmd.Name, - Summary: cmd.Summary, - }) + items = append(items, Item(cmd)) } return List(heading, items...) } From 3e42435ceacb3019fd0b506b769325c0003b5950 Mon Sep 17 00:00:00 2001 From: Mike Fridman Date: Fri, 1 May 2026 13:11:04 +0200 Subject: [PATCH 3/8] feat: simplify help customization --- CHANGELOG.md | 24 ++-- README.md | 26 +++- command.go | 10 +- examples/cmd/task/main.go | 6 +- parse.go | 2 +- parse_test.go | 2 +- state_test.go | 7 +- usage.go | 145 ++++++++++++++++++---- usage/help.go | 255 ++++++++++++++++++++++++++++++++++---- usage/help_test.go | 83 ++++++++++++- usage_test.go | 63 +++++----- 11 files changed, 509 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33faf9b..b25f98b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,29 +10,29 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - `flagtype.EnumDefault` constructor for enums with an initial default value -- New `usage` package with composable help document blocks: `Text`, `Lines`, `List`, `Flags`, and - `Commands` -- `Help` function for rendering the resolved command help document +- New optional `usage` package with composable help document blocks: `Document`, `Text`, `Lines`, + `List`, `Flags`, and `Commands` +- `usage.New` and `usage.Help` for building or rendering default help from a command +- `Help` function for rendering the resolved command help text - `Cmd` field on `State` for accessing the terminal command selected by parsing - `UsageErrorf` for opt-in usage errors; `Run` prints command help to stderr before returning the underlying error ### Changed -- **BREAKING**: Replace `Command.UsageFunc` with `Command.Help`, which receives the built-in - `usage.Help` document and returns the customized document -- **BREAKING**: Custom help now composes `usage.Help` documents instead of string-concatenating - default usage text +- **BREAKING**: Replace `Command.UsageFunc` with `Command.Help`, which returns the full help string + for a command +- **BREAKING**: `Help` now returns a string instead of a structured help document; use + `usage.New(cmd)` when you want composable help blocks +- **BREAKING**: Rename the structured `usage.Help` document type to `usage.Document` - **BREAKING**: Rename `FlagOption` to `FlagConfig` and `Command.FlagOptions` to `Command.FlagConfigs` -- Help output is now built through the `usage` package while preserving the default automatic - `--help` behavior +- Help output keeps the default automatic `--help` behavior while making the richer `usage` package + optional ### Removed -- **BREAKING**: Remove `DefaultUsage` and `Usage`; use `Help(cmd).String()` for direct rendering -- **BREAKING**: Remove usage-related `State` helpers: `Command`, `CommandPath`, `Usage`, and - `UsageErrorf`; use `State.Cmd`, `Command.Path`, `Help`, and top-level `UsageErrorf` instead +- **BREAKING**: Remove `DefaultUsage` and `Usage`; use `Help(cmd)` for direct rendering ## [v0.6.0] - 2026-02-18 diff --git a/README.md b/README.md index 41db788..319a804 100644 --- a/README.md +++ b/README.md @@ -96,14 +96,34 @@ example](examples/cmd/task/). ## Help Help text is generated automatically and displayed when `--help` is passed. To customize it, set -the `Help` field on a command: +the `Help` field on a command. It returns a string, so you can replace help entirely: ```go -Help: func(c *cli.Command, h usage.Help) usage.Help { - return append(h, usage.Lines("Examples:", "greet margo")) +Help: func(c *cli.Command) string { + return "Usage:\n greet \n" }, ``` +If you want to keep the default layout and add to it, use the optional `usage` package: + +```go +Help: func(c *cli.Command) string { + doc := usage.New(c) + doc = append(doc, usage.Lines("Examples:", "greet margo")) + return doc.String() +}, +``` + +That renders as: + +```text +Usage: + greet + +Examples: + greet margo +``` + Inside `Exec`, `State` exposes the resolved command as `Cmd`, so usage errors can stay explicit: ```go diff --git a/command.go b/command.go index ed96413..76cc129 100644 --- a/command.go +++ b/command.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/pressly/cli/pkg/suggest" - "github.com/pressly/cli/usage" ) // ErrHelp is returned by [Parse] when a help flag is present. @@ -33,12 +32,11 @@ type Command struct { // ShortHelp describes the command in help output and parent command listings. ShortHelp string - // Help customizes the command's help document. + // Help customizes the command's help text. // - // Leave Help nil for the built-in help. Set it when you want to append examples, reorder - // sections, or replace the document entirely. The function receives the command being shown and - // the built-in document. - Help func(*Command, usage.Help) usage.Help + // Leave Help nil for the built-in help. Set it to replace help entirely, or use the usage + // package when you want to compose help from structured blocks. + Help func(*Command) string // Flags defines the command's flags using the standard library flag package. Flags *flag.FlagSet diff --git a/examples/cmd/task/main.go b/examples/cmd/task/main.go index 8799fc6..851aa86 100644 --- a/examples/cmd/task/main.go +++ b/examples/cmd/task/main.go @@ -23,8 +23,10 @@ func main() { f.Bool("verbose", false, "enable verbose output") f.Bool("version", false, "print the version") }), - Help: func(c *cli.Command, h usage.Help) usage.Help { - return append(h, usage.Lines("Examples:", "todo list today --file tasks.json", "todo task add --file tasks.json \"write docs\"")) + Help: func(c *cli.Command) string { + doc := usage.New(c) + doc = append(doc, usage.Lines("Examples:", "todo list today --file tasks.json", "todo task add --file tasks.json \"write docs\"")) + return doc.String() }, Exec: func(ctx context.Context, s *cli.State) error { if cli.GetFlag[bool](s, "version") { diff --git a/parse.go b/parse.go index ec17d87..8780800 100644 --- a/parse.go +++ b/parse.go @@ -17,7 +17,7 @@ import ( // // Most programs should use [ParseAndRun]. Use Parse directly when you need to inspect parsed flags // or initialize resources before calling [Run]. If the user asks for help, Parse returns [ErrHelp] -// after resolving the command so [Help] can render the right command document. +// after resolving the command so [Help] can render the right help text. func Parse(root *Command, args []string) error { if root == nil { return fmt.Errorf("failed to parse: root command is nil") diff --git a/parse_test.go b/parse_test.go index 7b84585..d0a7239 100644 --- a/parse_test.go +++ b/parse_test.go @@ -961,7 +961,7 @@ func TestLocalFlags(t *testing.T) { err := Parse(root, []string{"child", "--help"}) require.ErrorIs(t, err, flag.ErrHelp) - usage := Help(root).String() + usage := Help(root) // --verbose should appear in inherited flags (not local) assert.Contains(t, usage, "--verbose") // --version should NOT appear (local to root, not inherited) diff --git a/state_test.go b/state_test.go index 05d91d7..19f7102 100644 --- a/state_test.go +++ b/state_test.go @@ -7,7 +7,6 @@ import ( "strings" "testing" - "github.com/pressly/cli/usage" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -86,11 +85,11 @@ func TestStateCommandContext(t *testing.T) { SubCommands: []*Command{ { Name: "child", - Help: func(c *Command, h usage.Help) usage.Help { - return append(h, usage.Lines("Examples:", "root child file.txt")) + Help: func(c *Command) string { + return "Usage:\n root child\n\nExamples:\n root child file.txt" }, Exec: func(ctx context.Context, s *State) error { - output := Help(s.Cmd).String() + output := Help(s.Cmd) require.Contains(t, output, "Examples:") require.Contains(t, output, "root child file.txt") return nil diff --git a/usage.go b/usage.go index 07a808e..bead55b 100644 --- a/usage.go +++ b/usage.go @@ -1,32 +1,39 @@ package cli import ( + "bytes" "cmp" "flag" "fmt" "slices" "strings" - - "github.com/pressly/cli/usage" + "text/tabwriter" ) -// Help returns the help document for root's resolved command. +// Help returns help text for root's resolved command. // -// Call Help after Parse when you want to render help yourself, or inside a Command.Help hook when -// composing the default help document. ParseAndRun calls it automatically for --help and UsageErrorf -// errors. -func Help(root *Command) usage.Help { +// Call Help after Parse when you want to render help yourself. ParseAndRun calls it automatically +// for --help, and Run calls it automatically for UsageErrorf errors. +func Help(root *Command) string { if root == nil { - return nil + return "" } - // Get terminal command from state terminalCmd := root.terminal() + if terminalCmd.Help != nil { + return strings.TrimRight(terminalCmd.Help(terminalCmd), "\n") + } - var help usage.Help + return defaultHelp(root) +} + +func defaultHelp(root *Command) string { + terminalCmd := root.terminal() + + var blocks []string if terminalCmd.ShortHelp != "" { - help = append(help, usage.Text(terminalCmd.ShortHelp)) + blocks = append(blocks, terminalCmd.ShortHelp) } flags := collectHelpFlags(root, terminalCmd) @@ -46,7 +53,7 @@ func Help(root *Command) usage.Help { usageLine += " " } } - help = append(help, usage.Lines("Usage:", usageLine)) + blocks = append(blocks, renderLines("Usage:", usageLine)) if len(terminalCmd.SubCommands) > 0 { sortedCommands := slices.Clone(terminalCmd.SubCommands) @@ -54,14 +61,14 @@ func Help(root *Command) usage.Help { return cmp.Compare(a.Name, b.Name) }) - subcommands := make([]usage.Command, 0, len(sortedCommands)) + subcommands := make([]helpItem, 0, len(sortedCommands)) for _, sub := range sortedCommands { - subcommands = append(subcommands, usage.Command{ + subcommands = append(subcommands, helpItem{ Name: sub.Name, Summary: sub.ShortHelp, }) } - help = append(help, usage.Commands("Available Commands:", subcommands)) + blocks = append(blocks, renderItems("Available Commands:", subcommands)) } if len(flags) > 0 { @@ -80,11 +87,11 @@ func Help(root *Command) usage.Help { } if hasLocal { - help = append(help, usage.Flags("Flags:", usageFlags(flags, false))) + blocks = append(blocks, renderFlags("Flags:", usageFlags(flags, false))) } if hasInherited { - help = append(help, usage.Flags("Inherited Flags:", usageFlags(flags, true))) + blocks = append(blocks, renderFlags("Inherited Flags:", usageFlags(flags, true))) } } @@ -93,16 +100,12 @@ func Help(root *Command) usage.Help { if root.state != nil && len(root.state.path) > 0 { cmdName = getCommandPath(root.state.path) } - help = append(help, usage.Text( + blocks = append(blocks, fmt.Sprintf("Use \"%s [command] --help\" for more information about a command.", cmdName), - )) - } - - if terminalCmd.Help != nil { - help = terminalCmd.Help(terminalCmd, help) + ) } - return help + return strings.Join(blocks, "\n\n") } func collectHelpFlags(root, terminalCmd *Command) []flagInfo { @@ -156,8 +159,8 @@ func collectHelpFlags(root, terminalCmd *Command) []flagInfo { return flags } -func usageFlags(flags []flagInfo, inherited bool) []usage.Flag { - out := make([]usage.Flag, 0, len(flags)) +func usageFlags(flags []flagInfo, inherited bool) []helpFlag { + out := make([]helpFlag, 0, len(flags)) for _, f := range flags { if f.inherited != inherited { continue @@ -166,7 +169,7 @@ func usageFlags(flags []flagInfo, inherited bool) []usage.Flag { if !f.required && !isZeroDefault(f.defval, f.typeName) { defval = f.defval } - out = append(out, usage.Flag{ + out = append(out, helpFlag{ Name: strings.TrimPrefix(f.name, "--"), Short: f.short, Placeholder: f.typeName, @@ -178,6 +181,94 @@ func usageFlags(flags []flagInfo, inherited bool) []usage.Flag { return out } +func renderLines(heading string, lines ...string) string { + var b strings.Builder + b.WriteString(heading) + for _, line := range lines { + b.WriteString("\n ") + b.WriteString(line) + } + return b.String() +} + +func renderFlags(heading string, flags []helpFlag) string { + hasShort := false + for _, f := range flags { + if f.Short != "" { + hasShort = true + break + } + } + items := make([]helpItem, 0, len(flags)) + for _, f := range flags { + items = append(items, helpItem{ + Name: f.spec(hasShort), + Summary: f.description(), + }) + } + return renderItems(heading, items) +} + +func renderItems(heading string, items []helpItem) string { + var out strings.Builder + out.WriteString(heading) + out.WriteByte('\n') + + var rows bytes.Buffer + tw := tabwriter.NewWriter(&rows, 0, 0, 4, ' ', 0) + for _, item := range items { + if item.Summary == "" { + _, _ = fmt.Fprintf(tw, " %s\n", item.Name) + continue + } + _, _ = fmt.Fprintf(tw, " %s\t%s\n", item.Name, item.Summary) + } + _ = tw.Flush() + out.Write(rows.Bytes()) + + return strings.TrimRight(out.String(), "\n") +} + +type helpItem struct { + Name string + Summary string +} + +type helpFlag struct { + Name string + Short string + Placeholder string + Usage string + Default string + Required bool +} + +func (f helpFlag) spec(padShort bool) string { + var name string + if f.Short != "" { + name = "-" + f.Short + ", --" + f.Name + } else if padShort { + name = " --" + f.Name + } else { + name = "--" + f.Name + } + if f.Placeholder == "" { + return name + } + return name + " " + f.Placeholder +} + +func (f helpFlag) description() string { + description := f.Usage + if f.Required { + return description + " (required)" + } + if f.Default != "" { + return fmt.Sprintf("%s (default: %s)", description, f.Default) + } + return description +} + // flagConfigMap builds a lookup map from flag name to its FlagConfig. func flagConfigMap(options []FlagConfig) map[string]FlagConfig { m := make(map[string]FlagConfig, len(options)) diff --git a/usage/help.go b/usage/help.go index 4e24241..260eebb 100644 --- a/usage/help.go +++ b/usage/help.go @@ -1,19 +1,39 @@ -// Package usage provides small building blocks for command help documents. +// Package usage provides optional building blocks for command help documents. // -// Use this package from a cli.Command Help hook when the default help needs examples, extra -// sections, or a different layout. +// Use this package when cli's default help text is close to what you want, but you need to append +// examples, add sections, or render the same command metadata in a different layout. package usage import ( "bytes" + "flag" "fmt" "io" + "slices" "strings" "text/tabwriter" + + "github.com/pressly/cli" ) -// Help is an ordered list of blocks that can be rendered as command help. -type Help []Block +// Document is an ordered list of blocks that can be rendered as command help. +type Document []Block + +// Block is one section in a help document. +type Block struct { + // Heading is rendered above the block when set, such as "Usage:" or "Examples:". + Heading string + + lines []string + indent bool + items []Item +} + +// Item is one row in a List block. +type Item struct { + Name string + Summary string +} // Text returns an untitled paragraph block. // @@ -38,19 +58,19 @@ func List(heading string, items ...Item) Block { // String renders the full help document as a string. // -// Use String in tests or when you want to pass help text to an API that expects a string. -func (h Help) String() string { +// Use String when returning help from cli.Command.Help or when comparing help text in tests. +func (d Document) String() string { var b strings.Builder - _, _ = h.WriteTo(&b) + _, _ = d.WriteTo(&b) return strings.TrimRight(b.String(), "\n") } // WriteTo writes the help document to w. // // Use WriteTo when streaming help directly to stdout, stderr, or another writer. -func (h Help) WriteTo(w io.Writer) (n int64, err error) { +func (d Document) WriteTo(w io.Writer) (n int64, err error) { cw := &countWriter{w: w} - for i, block := range h { + for i, block := range d { if i > 0 { if _, err := fmt.Fprintln(cw); err != nil { return cw.n, err @@ -63,16 +83,6 @@ func (h Help) WriteTo(w io.Writer) (n int64, err error) { return cw.n, nil } -// Block is one section in a help document. -type Block struct { - // Heading is rendered above the block when set, such as "Usage:" or "Examples:". - Heading string - - lines []string - indent bool - items []Item -} - // String renders the block as a string. func (b Block) String() string { var s strings.Builder @@ -104,12 +114,6 @@ func (b Block) WriteTo(w io.Writer) (n int64, err error) { return cw.n, nil } -// Item is one row in a List block. -type Item struct { - Name string - Summary string -} - func writeItems(w io.Writer, items []Item) (int64, error) { cw := &countWriter{w: w} var b bytes.Buffer @@ -144,3 +148,202 @@ func (w *countWriter) Write(p []byte) (int, error) { w.n += int64(n) return n, err } + +// New returns the default help document for cmd. +func New(cmd *cli.Command) Document { + if cmd == nil { + return nil + } + if path := cmd.Path(); len(path) > 0 { + cmd = path[len(path)-1] + } + + var doc Document + + if cmd.ShortHelp != "" { + doc = append(doc, Text(cmd.ShortHelp)) + } + + flags := collectHelpFlags(cmd) + + usageLine := cmd.Usage + if usageLine == "" { + usageLine = commandPath(cmd) + if len(flags) > 0 { + usageLine += " [flags]" + } + if len(cmd.SubCommands) > 0 { + usageLine += " " + } + } + doc = append(doc, Lines("Usage:", usageLine)) + + if len(cmd.SubCommands) > 0 { + sortedCommands := slices.Clone(cmd.SubCommands) + slices.SortFunc(sortedCommands, func(a, b *cli.Command) int { + return strings.Compare(a.Name, b.Name) + }) + + subcommands := make([]Command, 0, len(sortedCommands)) + for _, sub := range sortedCommands { + subcommands = append(subcommands, Command{ + Name: sub.Name, + Summary: sub.ShortHelp, + }) + } + doc = append(doc, Commands("Available Commands:", subcommands)) + } + + if len(flags) > 0 { + slices.SortFunc(flags, func(a, b flagInfo) int { + return strings.Compare(a.name, b.name) + }) + + hasLocal := false + hasInherited := false + for _, f := range flags { + if f.inherited { + hasInherited = true + } else { + hasLocal = true + } + } + + if hasLocal { + doc = append(doc, Flags("Flags:", usageFlags(flags, false))) + } + + if hasInherited { + doc = append(doc, Flags("Inherited Flags:", usageFlags(flags, true))) + } + } + + if len(cmd.SubCommands) > 0 { + doc = append(doc, Text( + fmt.Sprintf("Use \"%s [command] --help\" for more information about a command.", commandPath(cmd)), + )) + } + + return doc +} + +// Help returns the default help text for cmd. +func Help(cmd *cli.Command) string { + return New(cmd).String() +} + +func collectHelpFlags(cmd *cli.Command) []flagInfo { + var flags []flagInfo + path := cmd.Path() + if len(path) == 0 { + path = []*cli.Command{cmd} + } + + terminalIdx := len(path) - 1 + for i, c := range path { + if c.Flags == nil { + continue + } + isInherited := i < terminalIdx + metaMap := flagConfigMap(c.FlagConfigs) + c.Flags.VisitAll(func(f *flag.Flag) { + if isInherited { + if m, ok := metaMap[f.Name]; ok && m.Local { + return + } + } + fi := flagInfo{ + name: "--" + f.Name, + usage: f.Usage, + defval: f.DefValue, + typeName: flagTypeName(f), + inherited: isInherited, + } + if m, ok := metaMap[f.Name]; ok { + fi.required = m.Required + fi.short = m.Short + } + flags = append(flags, fi) + }) + } + return flags +} + +func commandPath(cmd *cli.Command) string { + path := cmd.Path() + if len(path) == 0 { + return cmd.Name + } + var names []string + for _, c := range path { + names = append(names, c.Name) + } + return strings.Join(names, " ") +} + +func usageFlags(flags []flagInfo, inherited bool) []Flag { + out := make([]Flag, 0, len(flags)) + for _, f := range flags { + if f.inherited != inherited { + continue + } + defval := "" + if !f.required && !isZeroDefault(f.defval, f.typeName) { + defval = f.defval + } + out = append(out, Flag{ + Name: strings.TrimPrefix(f.name, "--"), + Short: f.short, + Placeholder: f.typeName, + Usage: f.usage, + Default: defval, + Required: f.required, + }) + } + return out +} + +func flagConfigMap(options []cli.FlagConfig) map[string]cli.FlagConfig { + m := make(map[string]cli.FlagConfig, len(options)) + for _, fm := range options { + m[fm.Name] = fm + } + return m +} + +type flagInfo struct { + name string + short string + usage string + defval string + typeName string + inherited bool + required bool +} + +func flagTypeName(f *flag.Flag) string { + typeName := fmt.Sprintf("%T", f.Value) + if i := strings.LastIndex(typeName, "."); i >= 0 { + typeName = typeName[i+1:] + } + typeName = strings.TrimPrefix(typeName, "*") + typeName = strings.TrimSuffix(typeName, "Value") + if typeName == "bool" { + return "" + } + return typeName +} + +func isZeroDefault(defval, typeName string) bool { + switch { + case defval == "": + return true + case defval == "false" && typeName == "": + return true + case defval == "0" && (typeName == "int" || typeName == "int64" || typeName == "uint" || typeName == "uint64"): + return true + case defval == "0" && typeName == "float64": + return true + } + return false +} diff --git a/usage/help_test.go b/usage/help_test.go index 433846c..ab74d5d 100644 --- a/usage/help_test.go +++ b/usage/help_test.go @@ -1,16 +1,19 @@ package usage import ( + "context" + "flag" "strings" "testing" + "github.com/pressly/cli" "github.com/stretchr/testify/require" ) func TestHelpString(t *testing.T) { t.Parallel() - h := Help{ + h := Document{ Text("print a greeting"), Lines("Usage:", "greet [flags] "), Flags("Flags:", []Flag{ @@ -47,3 +50,81 @@ func TestListWithoutSummary(t *testing.T) { output := List("Commands:", Item{Name: "serve"}).String() require.Equal(t, "Commands:\n serve", output) } + +func TestCommandHelp(t *testing.T) { + t.Parallel() + + root := &cli.Command{ + Name: "greet", + ShortHelp: "print a greeting", + Flags: cli.FlagsFunc(func(f *flag.FlagSet) { + f.Bool("verbose", false, "enable verbose output") + f.String("format", "plain", "output format") + }), + FlagConfigs: []cli.FlagConfig{ + {Name: "verbose", Short: "v"}, + }, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + require.NoError(t, cli.Parse(root, nil)) + + output := Help(root) + require.Equal(t, cli.Help(root), output) + require.Contains(t, output, "print a greeting") + require.Contains(t, output, "Usage:") + require.Contains(t, output, "greet [flags]") + require.Contains(t, output, "-v, --verbose") + require.Contains(t, output, "--format string") +} + +func TestCommandHelpUsesResolvedCommand(t *testing.T) { + t.Parallel() + + child := &cli.Command{ + Name: "child", + ShortHelp: "run the child command", + Flags: cli.FlagsFunc(func(f *flag.FlagSet) { + f.String("file", "", "input file") + }), + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + root := &cli.Command{ + Name: "root", + Flags: cli.FlagsFunc(func(f *flag.FlagSet) { + f.Bool("verbose", false, "enable verbose output") + }), + SubCommands: []*cli.Command{child}, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + require.NoError(t, cli.Parse(root, []string{"child"})) + + output := Help(root) + require.Equal(t, cli.Help(root), output) + require.Contains(t, output, "run the child command") + require.Contains(t, output, "root child [flags]") + require.Contains(t, output, "Flags:") + require.Contains(t, output, "--file string") + require.Contains(t, output, "Inherited Flags:") + require.Contains(t, output, "--verbose") +} + +func TestCommandDocumentComposition(t *testing.T) { + t.Parallel() + + root := &cli.Command{ + Name: "greet", + ShortHelp: "print a greeting", + Help: func(c *cli.Command) string { + doc := New(c) + doc = append(doc, Lines("Examples:", "greet margo")) + return doc.String() + }, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + require.NoError(t, cli.Parse(root, nil)) + + output := cli.Help(root) + require.Contains(t, output, "print a greeting") + require.Contains(t, output, "Examples:") + require.Contains(t, output, "greet margo") +} diff --git a/usage_test.go b/usage_test.go index b1b4f37..de10204 100644 --- a/usage_test.go +++ b/usage_test.go @@ -3,9 +3,9 @@ package cli import ( "context" "flag" + "strings" "testing" - "github.com/pressly/cli/usage" "github.com/stretchr/testify/require" ) @@ -23,7 +23,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) require.NotEmpty(t, output) require.Contains(t, output, "simple") require.Contains(t, output, "Usage:") @@ -48,7 +48,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) require.Contains(t, output, "withflags") require.Contains(t, output, "withflags [flags]") require.Contains(t, output, "-verbose") @@ -74,7 +74,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) require.Contains(t, output, "parent") require.Contains(t, output, "child1") require.Contains(t, output, "child2") @@ -110,7 +110,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) require.Contains(t, output, "complex") require.Contains(t, output, "complex command with flags and subcommands") require.Contains(t, output, "-global") @@ -135,7 +135,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) require.Contains(t, output, "longdesc") require.Contains(t, output, "very long description") require.Contains(t, output, "-long-flag") @@ -156,7 +156,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) require.Contains(t, output, "globalonly") require.Contains(t, output, "-debug") require.Contains(t, output, "-output") @@ -185,7 +185,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) require.Contains(t, output, "manychildren") for i := 0; i < 10; i++ { require.Contains(t, output, "cmd"+string(rune('0'+i))) @@ -204,7 +204,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) require.Contains(t, output, "empty") require.NotEmpty(t, output) }) @@ -233,7 +233,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(root, []string{}) require.NoError(t, err) - output := Help(root).String() + output := Help(root) require.Contains(t, output, "root") require.Contains(t, output, "root command") require.Contains(t, output, "parent") @@ -260,7 +260,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) require.Contains(t, output, "-bool-flag") require.Contains(t, output, "-string-flag") require.Contains(t, output, "-int-flag") @@ -288,7 +288,7 @@ func TestUsageGeneration(t *testing.T) { } // Usage should work even before parsing and show flags - output := Help(cmd).String() + output := Help(cmd) require.NotEmpty(t, output) require.Contains(t, output, "Flags:") require.Contains(t, output, "-debug") @@ -308,18 +308,19 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) require.Contains(t, output, "custom [options] ") }) - t.Run("help hook composes default document", func(t *testing.T) { + t.Run("help hook replaces default text", func(t *testing.T) { t.Parallel() cmd := &Command{ Name: "custom", ShortHelp: "custom command", - Help: func(c *Command, h usage.Help) usage.Help { - return append(h, usage.Lines("Examples:", "custom example")) + Help: func(c *Command) string { + require.Equal(t, "custom", c.Name) + return "custom help\n\nExamples:\n custom example\n" }, Exec: func(ctx context.Context, s *State) error { return nil }, } @@ -327,21 +328,21 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() - require.Contains(t, output, "custom command") + output := Help(cmd) + require.NotContains(t, output, "custom command") + require.Contains(t, output, "custom help") require.Contains(t, output, "Examples:") require.Contains(t, output, "custom example") + require.False(t, strings.HasSuffix(output, "\n")) }) - t.Run("help hook replaces default document", func(t *testing.T) { + t.Run("help hook can return a plain string", func(t *testing.T) { t.Parallel() cmd := &Command{ Name: "custom", - Help: func(c *Command, h usage.Help) usage.Help { - return usage.Help{ - usage.Text("custom help"), - } + Help: func(c *Command) string { + return "custom help" }, Exec: func(ctx context.Context, s *State) error { return nil }, } @@ -349,7 +350,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) require.Equal(t, "custom help", output) }) @@ -374,7 +375,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(parent, []string{"child"}) require.NoError(t, err) - output := Help(parent).String() + output := Help(parent) require.Contains(t, output, "-local") require.Contains(t, output, "-global") require.Contains(t, output, "local flag") @@ -401,7 +402,7 @@ func TestFlagHelp(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) require.Contains(t, output, "Flags:") require.Contains(t, output, "-verbose") require.Contains(t, output, "-config string") @@ -432,7 +433,7 @@ func TestFlagHelp(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) // Zero-value defaults should not appear require.NotContains(t, output, "(default: false)") require.NotContains(t, output, "(default: 0)") @@ -463,7 +464,7 @@ func TestFlagHelp(t *testing.T) { err := Parse(cmd, []string{"-file", "test.txt"}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) require.Contains(t, output, "(required)") // Required flag should not also show a default require.NotContains(t, output, "(default: )") @@ -491,7 +492,7 @@ func TestFlagHelp(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) // Flags with short aliases show both forms require.Contains(t, output, "-v, --verbose") require.Contains(t, output, "-o, --output string") @@ -514,7 +515,7 @@ func TestFlagHelp(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) // Without any short flags, no extra padding should be added require.Contains(t, output, " --verbose") require.Contains(t, output, " --config string") @@ -533,7 +534,7 @@ func TestFlagHelp(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd).String() + output := Help(cmd) require.NotContains(t, output, "Flags:") require.NotContains(t, output, "Inherited Flags:") }) From 9bcaa29c9c8d140737d29d6348f740bbcfa117bd Mon Sep 17 00:00:00 2001 From: Mike Fridman Date: Sat, 2 May 2026 09:46:45 +0200 Subject: [PATCH 4/8] feat: refine help API surface --- CHANGELOG.md | 12 +++--- README.md | 27 ++++++++++---- command.go | 18 +++------ doc.go | 6 +-- error.go | 17 +++------ error_test.go | 2 +- examples/cmd/echo/main.go | 6 +-- examples/cmd/task/main.go | 44 +++++++++++----------- parse.go | 7 ++-- parse_test.go | 2 +- run.go | 15 ++++---- run_test.go | 6 +-- state_test.go | 6 +-- usage.go | 12 ++---- usage/help.go | 42 ++++++++++++++++----- usage/help_test.go | 75 +++++++++++++++++++++++++++++++------ usage_test.go | 78 +++++++++++++++++++-------------------- 17 files changed, 226 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b25f98b..5133350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `flagtype.EnumDefault` constructor for enums with an initial default value - New optional `usage` package with composable help document blocks: `Document`, `Text`, `Lines`, `List`, `Flags`, and `Commands` -- `usage.New` and `usage.Help` for building or rendering default help from a command -- `Help` function for rendering the resolved command help text +- `usage.New` and `usage.Help` for building default help documents or rendering command help - `Cmd` field on `State` for accessing the terminal command selected by parsing - `UsageErrorf` for opt-in usage errors; `Run` prints command help to stderr before returning the underlying error @@ -22,9 +21,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **BREAKING**: Replace `Command.UsageFunc` with `Command.Help`, which returns the full help string for a command -- **BREAKING**: `Help` now returns a string instead of a structured help document; use - `usage.New(cmd)` when you want composable help blocks -- **BREAKING**: Rename the structured `usage.Help` document type to `usage.Document` +- **BREAKING**: Rename `Command.ShortHelp` to `Command.Description` - **BREAKING**: Rename `FlagOption` to `FlagConfig` and `Command.FlagOptions` to `Command.FlagConfigs` - Help output keeps the default automatic `--help` behavior while making the richer `usage` package @@ -32,7 +29,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Removed -- **BREAKING**: Remove `DefaultUsage` and `Usage`; use `Help(cmd)` for direct rendering +- **BREAKING**: Remove `ErrHelp`; check `errors.Is(err, flag.ErrHelp)` when handling `Parse` + directly +- **BREAKING**: Remove `DefaultUsage` and the top-level `Usage` function; use `usage.Help(cmd)` + for direct help rendering ## [v0.6.0] - 2026-02-18 diff --git a/README.md b/README.md index 319a804..9be59df 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ Requires Go 1.21 or higher. ```go root := &cli.Command{ - Name: "greet", - ShortHelp: "Print a greeting", + Name: "greet", + Description: "Print a greeting", Exec: func(ctx context.Context, s *cli.State) error { fmt.Fprintln(s.Stdout, "hello, world!") return nil @@ -74,13 +74,13 @@ Commands can have nested subcommands, each with their own flags and `Exec` funct ```go root := &cli.Command{ - Name: "todo", - Usage: "todo [flags]", - ShortHelp: "A simple CLI for managing your tasks", + Name: "todo", + Usage: "todo [flags]", + Description: "A simple CLI for managing your tasks", SubCommands: []*cli.Command{ { - Name: "list", - ShortHelp: "List all tasks", + Name: "list", + Description: "List all tasks", Exec: func(ctx context.Context, s *cli.State) error { // ... return nil @@ -124,6 +124,19 @@ Examples: greet margo ``` +If you use `Parse` directly and need to handle help yourself, render help with the optional `usage` +package: + +```go +if err := cli.Parse(root, args); err != nil { + if errors.Is(err, flag.ErrHelp) { + fmt.Fprintln(stdout, usage.Help(root)) + return nil + } + return err +} +``` + Inside `Exec`, `State` exposes the resolved command as `Cmd`, so usage errors can stay explicit: ```go diff --git a/command.go b/command.go index 76cc129..8bf7331 100644 --- a/command.go +++ b/command.go @@ -9,13 +9,6 @@ import ( "github.com/pressly/cli/pkg/suggest" ) -// ErrHelp is returned by [Parse] when a help flag is present. -// -// [ParseAndRun] handles ErrHelp automatically by printing [Help] to stdout and returning nil. -// Callers that use [Parse] and [Run] separately can check errors.Is(err, ErrHelp) and render help -// themselves. -var ErrHelp = flag.ErrHelp - // Command defines one command in a CLI. // // A command can be the root command passed to [ParseAndRun], or a subcommand listed in @@ -24,18 +17,19 @@ type Command struct { // Name is the single word users type to select the command. Name string - // Usage overrides the generated usage line when the command needs a custom synopsis. + // Usage overrides the generated usage line. // // Example: "cli todo list [flags]" Usage string - // ShortHelp describes the command in help output and parent command listings. - ShortHelp string + // Description describes the command in help output and command lists. + Description string // Help customizes the command's help text. // - // Leave Help nil for the built-in help. Set it to replace help entirely, or use the usage - // package when you want to compose help from structured blocks. + // Leave Help nil for the built-in help. Set it to replace help entirely. To keep the built-in + // layout and add sections, call usage.New from the usage package and return the document's + // String result. Help func(*Command) string // Flags defines the command's flags using the standard library flag package. diff --git a/doc.go b/doc.go index 462824e..30a1974 100644 --- a/doc.go +++ b/doc.go @@ -13,9 +13,9 @@ // Quick example: // // root := &cli.Command{ -// Name: "echo", -// Usage: "echo [flags] ...", -// ShortHelp: "prints the provided text", +// Name: "echo", +// Usage: "echo [flags] ...", +// Description: "prints the provided text", // Flags: cli.FlagsFunc(func(f *flag.FlagSet) { // f.Bool("c", false, "capitalize the input") // }), diff --git a/error.go b/error.go index eb9cadb..892b626 100644 --- a/error.go +++ b/error.go @@ -2,28 +2,23 @@ package cli import "fmt" -// UsageError marks an invalid command invocation. -// -// You normally create one with [UsageErrorf] rather than constructing this type directly. -type UsageError struct { +type usageError struct { err error } // UsageErrorf returns an error for invalid command-line usage. // // Return UsageErrorf from Exec when the command was selected successfully but the remaining args or -// flag combination are invalid. [Run] prints Help(s.Cmd) to stderr, then returns the formatted error -// without the UsageError wrapper. +// flag combination are invalid. [Run] prints command help to stderr, then returns the formatted +// error without the usage wrapper. Return a normal error when you do not want help printed. func UsageErrorf(format string, args ...any) error { - return &UsageError{err: fmt.Errorf(format, args...)} + return &usageError{err: fmt.Errorf(format, args...)} } -// Error returns the formatted usage error message. -func (e *UsageError) Error() string { +func (e *usageError) Error() string { return e.err.Error() } -// Unwrap exposes the formatted error for errors.Is and errors.As. -func (e *UsageError) Unwrap() error { +func (e *usageError) Unwrap() error { return e.err } diff --git a/error_test.go b/error_test.go index 3751e2c..70a4f7b 100644 --- a/error_test.go +++ b/error_test.go @@ -13,7 +13,7 @@ func TestUsageError(t *testing.T) { err := UsageErrorf("missing %s", "name") require.EqualError(t, err, "missing name") - var usageErr *UsageError + var usageErr *usageError require.True(t, errors.As(err, &usageErr)) require.EqualError(t, errors.Unwrap(err), "missing name") } diff --git a/examples/cmd/echo/main.go b/examples/cmd/echo/main.go index 7b1812d..359ad9d 100644 --- a/examples/cmd/echo/main.go +++ b/examples/cmd/echo/main.go @@ -12,9 +12,9 @@ import ( func main() { root := &cli.Command{ - Name: "echo", - Usage: "echo [flags] ...", - ShortHelp: "echo is a simple command that prints the provided text", + Name: "echo", + Usage: "echo [flags] ...", + Description: "echo is a simple command that prints the provided text", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { // Add a flag to capitalize the input f.Bool("c", false, "capitalize the input") diff --git a/examples/cmd/task/main.go b/examples/cmd/task/main.go index 851aa86..8344c46 100644 --- a/examples/cmd/task/main.go +++ b/examples/cmd/task/main.go @@ -16,9 +16,9 @@ import ( func main() { root := &cli.Command{ - Name: "todo", - Usage: "todo [flags]", - ShortHelp: "A simple CLI for managing your tasks", + Name: "todo", + Usage: "todo [flags]", + Description: "A simple CLI for managing your tasks", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.Bool("verbose", false, "enable verbose output") f.Bool("version", false, "print the version") @@ -49,9 +49,9 @@ func main() { func list() *cli.Command { return &cli.Command{ - Name: "list", - Usage: "todo list [flags]", - ShortHelp: "List tasks", + Name: "list", + Usage: "todo list [flags]", + Description: "List tasks", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.String("file", "", "path to the tasks file") f.String("tags", "", "filter tasks by tags") @@ -76,9 +76,9 @@ func getTasksFromFile(s *cli.State) (*TaskList, error) { func listToday() *cli.Command { return &cli.Command{ - Name: "today", - Usage: "todo list today [flags]", - ShortHelp: "List tasks due today", + Name: "today", + Usage: "todo list today [flags]", + Description: "List tasks due today", Exec: func(ctx context.Context, s *cli.State) error { tasks, err := getTasksFromFile(s) if err != nil { @@ -100,9 +100,9 @@ func listToday() *cli.Command { func listOverdue() *cli.Command { return &cli.Command{ - Name: "overdue", - Usage: "todo list overdue [flags]", - ShortHelp: "List overdue tasks", + Name: "overdue", + Usage: "todo list overdue [flags]", + Description: "List overdue tasks", Exec: func(ctx context.Context, s *cli.State) error { tasks, err := getTasksFromFile(s) if err != nil { @@ -132,7 +132,7 @@ func task() *cli.Command { FlagConfigs: []cli.FlagConfig{ {Name: "file", Required: true}, }, - ShortHelp: "Manage tasks", + Description: "Manage tasks", SubCommands: []*cli.Command{ taskAdd(), taskDone(), @@ -143,9 +143,9 @@ func task() *cli.Command { func taskAdd() *cli.Command { return &cli.Command{ - Name: "add", - Usage: "todo task add [flags]", - ShortHelp: "Add a new task", + Name: "add", + Usage: "todo task add [flags]", + Description: "Add a new task", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.String("tags", "", "comma-separated list of tags") }), @@ -182,9 +182,9 @@ func taskAdd() *cli.Command { func taskDone() *cli.Command { return &cli.Command{ - Name: "done", - Usage: "todo task done [flags]", - ShortHelp: "Mark a task as done", + Name: "done", + Usage: "todo task done [flags]", + Description: "Mark a task as done", Exec: func(ctx context.Context, s *cli.State) error { if len(s.Args) == 0 { return cli.UsageErrorf("task ID required") @@ -205,9 +205,9 @@ func taskDone() *cli.Command { func taskRemove() *cli.Command { return &cli.Command{ - Name: "remove", - Usage: "todo task remove [flags]", - ShortHelp: "Remove a task", + Name: "remove", + Usage: "todo task remove [flags]", + Description: "Remove a task", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.Bool("force", false, "force removal without confirmation") f.Bool("all", false, "remove all tasks") diff --git a/parse.go b/parse.go index 8780800..74b27e8 100644 --- a/parse.go +++ b/parse.go @@ -16,8 +16,9 @@ import ( // Parse resolves a command and parses its flags without running it. // // Most programs should use [ParseAndRun]. Use Parse directly when you need to inspect parsed flags -// or initialize resources before calling [Run]. If the user asks for help, Parse returns [ErrHelp] -// after resolving the command so [Help] can render the right help text. +// or initialize resources before calling [Run]. If the user asks for help, Parse returns +// [flag.ErrHelp] after resolving the command so callers can render the right help text with the +// usage.Help function. func Parse(root *Command, args []string) error { if root == nil { return fmt.Errorf("failed to parse: root command is nil") @@ -52,7 +53,7 @@ func Parse(root *Command, args []string) error { if arg == "-h" || arg == "--h" || arg == "-help" || arg == "--help" { // Combine flags first so the help message includes all inherited flags combineFlags(root.state.path) - return ErrHelp + return flag.ErrHelp } } diff --git a/parse_test.go b/parse_test.go index d0a7239..75924e5 100644 --- a/parse_test.go +++ b/parse_test.go @@ -961,7 +961,7 @@ func TestLocalFlags(t *testing.T) { err := Parse(root, []string{"child", "--help"}) require.ErrorIs(t, err, flag.ErrHelp) - usage := Help(root) + usage := help(root) // --verbose should appear in inherited flags (not local) assert.Contains(t, usage, "--verbose") // --version should NOT appear (local to root, not inherited) diff --git a/run.go b/run.go index d31f6b0..4fc9178 100644 --- a/run.go +++ b/run.go @@ -3,6 +3,7 @@ package cli import ( "context" "errors" + "flag" "fmt" "io" "os" @@ -26,8 +27,8 @@ type RunOptions struct { // Run executes the command selected by [Parse]. // // Use Run only with the split [Parse]/Run flow. [ParseAndRun] is the usual entry point. If Exec -// returns [UsageErrorf], Run prints [Help] for the selected command to stderr and returns the -// underlying error. +// returns an error created with [UsageErrorf], Run prints help for the selected command to stderr +// and returns the underlying error. func Run(ctx context.Context, root *Command, options *RunOptions) error { if ctx == nil { ctx = context.Background() @@ -53,7 +54,7 @@ func Run(ctx context.Context, root *Command, options *RunOptions) error { // ParseAndRun parses args and runs the selected command. // // Use ParseAndRun as the normal entry point for CLI applications. It handles help flags by printing -// [Help] to stdout and returning nil, then runs Exec for the selected command. +// command help to stdout and returning nil, then runs Exec for the selected command. // // if err := cli.ParseAndRun(ctx, root, os.Args[1:], nil); err != nil { // fmt.Fprintf(os.Stderr, "error: %v\n", err) @@ -64,9 +65,9 @@ func Run(ctx context.Context, root *Command, options *RunOptions) error { // initializing resources from parsed flags. func ParseAndRun(ctx context.Context, root *Command, args []string, options *RunOptions) error { if err := Parse(root, args); err != nil { - if errors.Is(err, ErrHelp) { + if errors.Is(err, flag.ErrHelp) { options = checkAndSetRunOptions(options) - _, _ = fmt.Fprintln(options.Stdout, Help(root)) + _, _ = fmt.Fprintln(options.Stdout, help(root)) return nil } return err @@ -92,9 +93,9 @@ func run(ctx context.Context, cmd *Command, state *State) (retErr error) { } }() err := cmd.Exec(ctx, state) - var usageErr *UsageError + var usageErr *usageError if errors.As(err, &usageErr) { - _, _ = fmt.Fprintln(state.Stderr, Help(state.Cmd)) + _, _ = fmt.Fprintln(state.Stderr, help(state.Cmd)) return usageErr.Unwrap() } return err diff --git a/run_test.go b/run_test.go index c35e012..a01c2cd 100644 --- a/run_test.go +++ b/run_test.go @@ -256,9 +256,9 @@ func TestParseAndRun(t *testing.T) { stdout := bytes.NewBuffer(nil) root := &Command{ - Name: "greet", - ShortHelp: "Print a greeting", - Exec: func(ctx context.Context, s *State) error { return nil }, + Name: "greet", + Description: "Print a greeting", + Exec: func(ctx context.Context, s *State) error { return nil }, } err := ParseAndRun(context.Background(), root, []string{"--help"}, &RunOptions{Stdout: stdout}) diff --git a/state_test.go b/state_test.go index 19f7102..cfd52b6 100644 --- a/state_test.go +++ b/state_test.go @@ -89,7 +89,7 @@ func TestStateCommandContext(t *testing.T) { return "Usage:\n root child\n\nExamples:\n root child file.txt" }, Exec: func(ctx context.Context, s *State) error { - output := Help(s.Cmd) + output := help(s.Cmd) require.Contains(t, output, "Examples:") require.Contains(t, output, "root child file.txt") return nil @@ -134,8 +134,8 @@ func TestStateCommandContext(t *testing.T) { }), SubCommands: []*Command{ { - Name: "child", - ShortHelp: "Run the child command", + Name: "child", + Description: "Run the child command", Exec: func(ctx context.Context, s *State) error { return UsageErrorf("missing file") }, diff --git a/usage.go b/usage.go index bead55b..6695cbe 100644 --- a/usage.go +++ b/usage.go @@ -10,11 +10,7 @@ import ( "text/tabwriter" ) -// Help returns help text for root's resolved command. -// -// Call Help after Parse when you want to render help yourself. ParseAndRun calls it automatically -// for --help, and Run calls it automatically for UsageErrorf errors. -func Help(root *Command) string { +func help(root *Command) string { if root == nil { return "" } @@ -32,8 +28,8 @@ func defaultHelp(root *Command) string { var blocks []string - if terminalCmd.ShortHelp != "" { - blocks = append(blocks, terminalCmd.ShortHelp) + if terminalCmd.Description != "" { + blocks = append(blocks, terminalCmd.Description) } flags := collectHelpFlags(root, terminalCmd) @@ -65,7 +61,7 @@ func defaultHelp(root *Command) string { for _, sub := range sortedCommands { subcommands = append(subcommands, helpItem{ Name: sub.Name, - Summary: sub.ShortHelp, + Summary: sub.Description, }) } blocks = append(blocks, renderItems("Available Commands:", subcommands)) diff --git a/usage/help.go b/usage/help.go index 260eebb..535771a 100644 --- a/usage/help.go +++ b/usage/help.go @@ -1,7 +1,7 @@ // Package usage provides optional building blocks for command help documents. // -// Use this package when cli's default help text is close to what you want, but you need to append -// examples, add sections, or render the same command metadata in a different layout. +// Use this package when you need to render help yourself, append examples, add sections, or render +// command metadata in a different layout. package usage import ( @@ -150,18 +150,20 @@ func (w *countWriter) Write(p []byte) (int, error) { } // New returns the default help document for cmd. +// +// Use New from cli.Command.Help when you want to keep the built-in help layout and add or reorder +// sections before returning the final string. Use New, not Help, inside a cli.Command.Help hook so +// the hook does not call itself. func New(cmd *cli.Command) Document { + cmd = resolveCommand(cmd) if cmd == nil { return nil } - if path := cmd.Path(); len(path) > 0 { - cmd = path[len(path)-1] - } var doc Document - if cmd.ShortHelp != "" { - doc = append(doc, Text(cmd.ShortHelp)) + if cmd.Description != "" { + doc = append(doc, Text(cmd.Description)) } flags := collectHelpFlags(cmd) @@ -188,7 +190,7 @@ func New(cmd *cli.Command) Document { for _, sub := range sortedCommands { subcommands = append(subcommands, Command{ Name: sub.Name, - Summary: sub.ShortHelp, + Summary: sub.Description, }) } doc = append(doc, Commands("Available Commands:", subcommands)) @@ -227,11 +229,33 @@ func New(cmd *cli.Command) Document { return doc } -// Help returns the default help text for cmd. +// Help returns help text for cmd. +// +// Use Help when handling flag.ErrHelp yourself after calling cli.Parse directly. It returns the +// same text cli.ParseAndRun prints for --help: if the resolved command has a cli.Command.Help hook, +// Help returns that hook's output; otherwise, it renders the default document from New. Inside a +// cli.Command.Help hook, use New instead. func Help(cmd *cli.Command) string { + cmd = resolveCommand(cmd) + if cmd == nil { + return "" + } + if cmd.Help != nil { + return strings.TrimRight(cmd.Help(cmd), "\n") + } return New(cmd).String() } +func resolveCommand(cmd *cli.Command) *cli.Command { + if cmd == nil { + return nil + } + if path := cmd.Path(); len(path) > 0 { + return path[len(path)-1] + } + return cmd +} + func collectHelpFlags(cmd *cli.Command) []flagInfo { var flags []flagInfo path := cmd.Path() diff --git a/usage/help_test.go b/usage/help_test.go index ab74d5d..1f67deb 100644 --- a/usage/help_test.go +++ b/usage/help_test.go @@ -1,6 +1,7 @@ package usage import ( + "bytes" "context" "flag" "strings" @@ -55,8 +56,8 @@ func TestCommandHelp(t *testing.T) { t.Parallel() root := &cli.Command{ - Name: "greet", - ShortHelp: "print a greeting", + Name: "greet", + Description: "print a greeting", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.Bool("verbose", false, "enable verbose output") f.String("format", "plain", "output format") @@ -69,7 +70,12 @@ func TestCommandHelp(t *testing.T) { require.NoError(t, cli.Parse(root, nil)) output := Help(root) - require.Equal(t, cli.Help(root), output) + var stdout bytes.Buffer + err := cli.ParseAndRun(context.Background(), root, []string{"--help"}, &cli.RunOptions{ + Stdout: &stdout, + }) + require.NoError(t, err) + require.Equal(t, output, strings.TrimRight(stdout.String(), "\n")) require.Contains(t, output, "print a greeting") require.Contains(t, output, "Usage:") require.Contains(t, output, "greet [flags]") @@ -81,8 +87,8 @@ func TestCommandHelpUsesResolvedCommand(t *testing.T) { t.Parallel() child := &cli.Command{ - Name: "child", - ShortHelp: "run the child command", + Name: "child", + Description: "run the child command", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.String("file", "", "input file") }), @@ -99,7 +105,12 @@ func TestCommandHelpUsesResolvedCommand(t *testing.T) { require.NoError(t, cli.Parse(root, []string{"child"})) output := Help(root) - require.Equal(t, cli.Help(root), output) + var stdout bytes.Buffer + err := cli.ParseAndRun(context.Background(), root, []string{"child", "--help"}, &cli.RunOptions{ + Stdout: &stdout, + }) + require.NoError(t, err) + require.Equal(t, output, strings.TrimRight(stdout.String(), "\n")) require.Contains(t, output, "run the child command") require.Contains(t, output, "root child [flags]") require.Contains(t, output, "Flags:") @@ -112,8 +123,8 @@ func TestCommandDocumentComposition(t *testing.T) { t.Parallel() root := &cli.Command{ - Name: "greet", - ShortHelp: "print a greeting", + Name: "greet", + Description: "print a greeting", Help: func(c *cli.Command) string { doc := New(c) doc = append(doc, Lines("Examples:", "greet margo")) @@ -121,10 +132,52 @@ func TestCommandDocumentComposition(t *testing.T) { }, Exec: func(ctx context.Context, s *cli.State) error { return nil }, } - require.NoError(t, cli.Parse(root, nil)) - - output := cli.Help(root) + var stdout bytes.Buffer + err := cli.ParseAndRun(context.Background(), root, []string{"--help"}, &cli.RunOptions{ + Stdout: &stdout, + }) + require.NoError(t, err) + + output := stdout.String() + require.Equal(t, strings.TrimRight(output, "\n"), Help(root)) require.Contains(t, output, "print a greeting") require.Contains(t, output, "Examples:") require.Contains(t, output, "greet margo") } + +func TestCommandHelpUsesCustomHook(t *testing.T) { + t.Parallel() + + root := &cli.Command{ + Name: "greet", + Help: func(c *cli.Command) string { + require.Equal(t, "greet", c.Name) + return "custom help\n" + }, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + require.NoError(t, cli.Parse(root, nil)) + + require.Equal(t, "custom help", Help(root)) +} + +func TestCommandHelpUsesResolvedCustomHook(t *testing.T) { + t.Parallel() + + child := &cli.Command{ + Name: "child", + Help: func(c *cli.Command) string { + require.Equal(t, "child", c.Name) + return "child help" + }, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + root := &cli.Command{ + Name: "root", + SubCommands: []*cli.Command{child}, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + require.NoError(t, cli.Parse(root, []string{"child"})) + + require.Equal(t, "child help", Help(root)) +} diff --git a/usage_test.go b/usage_test.go index de10204..df90281 100644 --- a/usage_test.go +++ b/usage_test.go @@ -23,7 +23,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) require.NotEmpty(t, output) require.Contains(t, output, "simple") require.Contains(t, output, "Usage:") @@ -48,7 +48,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) require.Contains(t, output, "withflags") require.Contains(t, output, "withflags [flags]") require.Contains(t, output, "-verbose") @@ -65,8 +65,8 @@ func TestUsageGeneration(t *testing.T) { cmd := &Command{ Name: "parent", SubCommands: []*Command{ - {Name: "child1", ShortHelp: "first child command", Exec: func(ctx context.Context, s *State) error { return nil }}, - {Name: "child2", ShortHelp: "second child command", Exec: func(ctx context.Context, s *State) error { return nil }}, + {Name: "child1", Description: "first child command", Exec: func(ctx context.Context, s *State) error { return nil }}, + {Name: "child2", Description: "second child command", Exec: func(ctx context.Context, s *State) error { return nil }}, }, Exec: func(ctx context.Context, s *State) error { return nil }, } @@ -74,7 +74,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) require.Contains(t, output, "parent") require.Contains(t, output, "child1") require.Contains(t, output, "child2") @@ -89,15 +89,15 @@ func TestUsageGeneration(t *testing.T) { t.Parallel() cmd := &Command{ - Name: "complex", - ShortHelp: "complex command with flags and subcommands", + Name: "complex", + Description: "complex command with flags and subcommands", Flags: FlagsFunc(func(fset *flag.FlagSet) { fset.Bool("global", false, "global flag") }), SubCommands: []*Command{ { - Name: "sub", - ShortHelp: "subcommand with its own flags", + Name: "sub", + Description: "subcommand with its own flags", Flags: FlagsFunc(func(fset *flag.FlagSet) { fset.String("local", "", "local flag") }), @@ -110,7 +110,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) require.Contains(t, output, "complex") require.Contains(t, output, "complex command with flags and subcommands") require.Contains(t, output, "-global") @@ -124,8 +124,8 @@ func TestUsageGeneration(t *testing.T) { longDesc := "This is a very long description that should be wrapped properly when displayed in the usage output to ensure readability and proper formatting" cmd := &Command{ - Name: "longdesc", - ShortHelp: longDesc, + Name: "longdesc", + Description: longDesc, Flags: FlagsFunc(func(fset *flag.FlagSet) { fset.String("long-flag", "", longDesc) }), @@ -135,7 +135,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) require.Contains(t, output, "longdesc") require.Contains(t, output, "very long description") require.Contains(t, output, "-long-flag") @@ -156,7 +156,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) require.Contains(t, output, "globalonly") require.Contains(t, output, "-debug") require.Contains(t, output, "-output") @@ -170,9 +170,9 @@ func TestUsageGeneration(t *testing.T) { var subcommands []*Command for i := 0; i < 10; i++ { subcommands = append(subcommands, &Command{ - Name: "cmd" + string(rune('0'+i)), - ShortHelp: "command number " + string(rune('0'+i)), - Exec: func(ctx context.Context, s *State) error { return nil }, + Name: "cmd" + string(rune('0'+i)), + Description: "command number " + string(rune('0'+i)), + Exec: func(ctx context.Context, s *State) error { return nil }, }) } @@ -185,7 +185,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) require.Contains(t, output, "manychildren") for i := 0; i < 10; i++ { require.Contains(t, output, "cmd"+string(rune('0'+i))) @@ -204,7 +204,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) require.Contains(t, output, "empty") require.NotEmpty(t, output) }) @@ -213,19 +213,19 @@ func TestUsageGeneration(t *testing.T) { t.Parallel() child := &Command{ - Name: "child", - ShortHelp: "nested child command", - Exec: func(ctx context.Context, s *State) error { return nil }, + Name: "child", + Description: "nested child command", + Exec: func(ctx context.Context, s *State) error { return nil }, } parent := &Command{ Name: "parent", - ShortHelp: "parent command", + Description: "parent command", SubCommands: []*Command{child}, Exec: func(ctx context.Context, s *State) error { return nil }, } root := &Command{ Name: "root", - ShortHelp: "root command", + Description: "root command", SubCommands: []*Command{parent}, Exec: func(ctx context.Context, s *State) error { return nil }, } @@ -233,7 +233,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(root, []string{}) require.NoError(t, err) - output := Help(root) + output := help(root) require.Contains(t, output, "root") require.Contains(t, output, "root command") require.Contains(t, output, "parent") @@ -260,7 +260,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) require.Contains(t, output, "-bool-flag") require.Contains(t, output, "-string-flag") require.Contains(t, output, "-int-flag") @@ -288,7 +288,7 @@ func TestUsageGeneration(t *testing.T) { } // Usage should work even before parsing and show flags - output := Help(cmd) + output := help(cmd) require.NotEmpty(t, output) require.Contains(t, output, "Flags:") require.Contains(t, output, "-debug") @@ -308,7 +308,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) require.Contains(t, output, "custom [options] ") }) @@ -316,8 +316,8 @@ func TestUsageGeneration(t *testing.T) { t.Parallel() cmd := &Command{ - Name: "custom", - ShortHelp: "custom command", + Name: "custom", + Description: "custom command", Help: func(c *Command) string { require.Equal(t, "custom", c.Name) return "custom help\n\nExamples:\n custom example\n" @@ -328,7 +328,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) require.NotContains(t, output, "custom command") require.Contains(t, output, "custom help") require.Contains(t, output, "Examples:") @@ -350,7 +350,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) require.Equal(t, "custom help", output) }) @@ -375,7 +375,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(parent, []string{"child"}) require.NoError(t, err) - output := Help(parent) + output := help(parent) require.Contains(t, output, "-local") require.Contains(t, output, "-global") require.Contains(t, output, "local flag") @@ -402,7 +402,7 @@ func TestFlagHelp(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) require.Contains(t, output, "Flags:") require.Contains(t, output, "-verbose") require.Contains(t, output, "-config string") @@ -433,7 +433,7 @@ func TestFlagHelp(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) // Zero-value defaults should not appear require.NotContains(t, output, "(default: false)") require.NotContains(t, output, "(default: 0)") @@ -464,7 +464,7 @@ func TestFlagHelp(t *testing.T) { err := Parse(cmd, []string{"-file", "test.txt"}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) require.Contains(t, output, "(required)") // Required flag should not also show a default require.NotContains(t, output, "(default: )") @@ -492,7 +492,7 @@ func TestFlagHelp(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) // Flags with short aliases show both forms require.Contains(t, output, "-v, --verbose") require.Contains(t, output, "-o, --output string") @@ -515,7 +515,7 @@ func TestFlagHelp(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) // Without any short flags, no extra padding should be added require.Contains(t, output, " --verbose") require.Contains(t, output, " --config string") @@ -534,7 +534,7 @@ func TestFlagHelp(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := Help(cmd) + output := help(cmd) require.NotContains(t, output, "Flags:") require.NotContains(t, output, "Inherited Flags:") }) From 70011438c2dd0b428af3f941d00d7d640a5aa7ce Mon Sep 17 00:00:00 2001 From: Mike Fridman Date: Sat, 2 May 2026 09:48:56 +0200 Subject: [PATCH 5/8] format --- CHANGELOG.md | 4 ++-- README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5133350..09be7ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,8 +31,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **BREAKING**: Remove `ErrHelp`; check `errors.Is(err, flag.ErrHelp)` when handling `Parse` directly -- **BREAKING**: Remove `DefaultUsage` and the top-level `Usage` function; use `usage.Help(cmd)` - for direct help rendering +- **BREAKING**: Remove `DefaultUsage` and the top-level `Usage` function; use `usage.Help(cmd)` for + direct help rendering ## [v0.6.0] - 2026-02-18 diff --git a/README.md b/README.md index 9be59df..8e48ff6 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,8 @@ example](examples/cmd/task/). ## Help -Help text is generated automatically and displayed when `--help` is passed. To customize it, set -the `Help` field on a command. It returns a string, so you can replace help entirely: +Help text is generated automatically and displayed when `--help` is passed. To customize it, set the +`Help` field on a command. It returns a string, so you can replace help entirely: ```go Help: func(c *cli.Command) string { From c407e9647c3757a10376d1b9759b2d6f2f541ee4 Mon Sep 17 00:00:00 2001 From: Mike Fridman Date: Sun, 3 May 2026 10:04:05 +0200 Subject: [PATCH 6/8] feat!: refine public API and consolidate help internals --- .gitignore | 1 - CHANGELOG.md | 14 +- README.md | 76 +++-- command.go | 94 ++++-- doc.go | 32 ++- error.go | 14 +- examples/cmd/echo/main.go | 3 +- examples/cmd/task/main.go | 44 ++- internal/helpdoc/helpdoc.go | 381 +++++++++++++++++++++++++ internal/usage/help.go | 180 ++++++++++++ {usage => internal/usage}/help_test.go | 89 +++++- parse.go | 10 +- run.go | 30 +- state.go | 33 ++- usage.go | 322 +++------------------ usage/command.go | 16 -- usage/flag.go | 71 ----- usage/flag_test.go | 23 -- usage/help.go | 373 ------------------------ usage_test.go | 69 +++++ 20 files changed, 959 insertions(+), 916 deletions(-) create mode 100644 internal/helpdoc/helpdoc.go create mode 100644 internal/usage/help.go rename {usage => internal/usage}/help_test.go (65%) delete mode 100644 usage/command.go delete mode 100644 usage/flag.go delete mode 100644 usage/flag_test.go delete mode 100644 usage/help.go diff --git a/.gitignore b/.gitignore index f68fd60..3fec32c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -internal/ tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 09be7ac..9071bdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - `flagtype.EnumDefault` constructor for enums with an initial default value -- New optional `usage` package with composable help document blocks: `Document`, `Text`, `Lines`, - `List`, `Flags`, and `Commands` -- `usage.New` and `usage.Help` for building default help documents or rendering command help - `Cmd` field on `State` for accessing the terminal command selected by parsing +- `Summary` field on `Command` for the short text shown in command lists - `UsageErrorf` for opt-in usage errors; `Run` prints command help to stderr before returning the underlying error @@ -21,18 +19,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **BREAKING**: Replace `Command.UsageFunc` with `Command.Help`, which returns the full help string for a command -- **BREAKING**: Rename `Command.ShortHelp` to `Command.Description` +- **BREAKING**: Replace `Command.ShortHelp` with `Command.Summary` for command lists and + `Command.Description` for longer command help text - **BREAKING**: Rename `FlagOption` to `FlagConfig` and `Command.FlagOptions` to `Command.FlagConfigs` -- Help output keeps the default automatic `--help` behavior while making the richer `usage` package - optional +- Help output keeps the default automatic `--help` behavior through `ParseAndRun`; `Command.Help` + replaces the generated help string when a command needs full control ### Removed - **BREAKING**: Remove `ErrHelp`; check `errors.Is(err, flag.ErrHelp)` when handling `Parse` directly -- **BREAKING**: Remove `DefaultUsage` and the top-level `Usage` function; use `usage.Help(cmd)` for - direct help rendering +- **BREAKING**: Remove `DefaultUsage` and the top-level `Usage` function from the public API ## [v0.6.0] - 2026-02-18 diff --git a/README.md b/README.md index 8e48ff6..4caf448 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ Requires Go 1.21 or higher. ```go root := &cli.Command{ - Name: "greet", - Description: "Print a greeting", + Name: "greet", + Summary: "Print a greeting", Exec: func(ctx context.Context, s *cli.State) error { fmt.Fprintln(s.Stdout, "hello, world!") return nil @@ -39,6 +39,15 @@ if err := cli.ParseAndRun(ctx, root, os.Args[1:], nil); err != nil { 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. +The command above gets usable help without any extra setup: + +```text +Print a greeting + +Usage: + greet +``` + ## Flags `FlagsFunc` is a convenience for defining flags inline. Use `FlagConfigs` to extend the standard @@ -76,11 +85,15 @@ Commands can have nested subcommands, each with their own flags and `Exec` funct root := &cli.Command{ Name: "todo", Usage: "todo [flags]", - Description: "A simple CLI for managing your tasks", + Summary: "Manage tasks", + Description: "todo manages tasks stored in a local file.", SubCommands: []*cli.Command{ { - Name: "list", - Description: "List all tasks", + 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 @@ -90,47 +103,68 @@ root := &cli.Command{ } ``` +`Summary` is the short sentence shown when a command appears in another command's help: + +```text +Available Commands: + list List tasks +``` + +`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. To customize it, set the -`Help` field on a command. It returns a string, so you can replace help entirely: +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 "Usage:\n greet \n" -}, -``` + return `Print a greeting. -If you want to keep the default layout and add to it, use the optional `usage` package: +Usage: + greet -```go -Help: func(c *cli.Command) string { - doc := usage.New(c) - doc = append(doc, usage.Lines("Examples:", "greet margo")) - return doc.String() +Examples: + greet margo` }, ``` -That renders as: +That replaces the built-in help with: ```text +Print a greeting. + Usage: - greet + greet Examples: greet margo ``` -If you use `Parse` directly and need to handle help yourself, render help with the optional `usage` -package: +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) { - fmt.Fprintln(stdout, usage.Help(root)) + // Print custom help here, or use ParseAndRun for built-in help. return nil } return err diff --git a/command.go b/command.go index 8bf7331..1bd55dd 100644 --- a/command.go +++ b/command.go @@ -9,54 +9,82 @@ import ( "github.com/pressly/cli/pkg/suggest" ) -// Command defines one command in a CLI. +// Command describes a single command in the CLI. // -// A command can be the root command passed to [ParseAndRun], or a subcommand listed in -// [Command.SubCommands]. Most programs define Name, optional help fields, optional flags, and Exec. +// Pass a Command to [ParseAndRun] (or [Parse] and [Run]) to drive a program, or list one inside +// another command's [Command.SubCommands] to add a subcommand. Most commands set Name, a one-line +// Summary, Flags, and Exec; Description and SubCommands are added as the program grows. type Command struct { - // Name is the single word users type to select the command. + // Name is the word users type to select this command. It must start with a letter and may + // contain letters, digits, dashes, or underscores. For the root command it also identifies the + // program in generated help. Name string - // Usage overrides the generated usage line. + // Usage replaces the auto-generated usage line shown at the top of help. Set it to convey the + // expected positional arguments; the generated form covers only the command path plus a + // trailing [flags] when the command has flags. // - // Example: "cli todo list [flags]" + // Example: "todo list [flags]" Usage string - // Description describes the command in help output and command lists. + // Summary is the one-line description shown next to this command in a parent's command listing. + // It is also shown at the top of this command's own help when Description is empty. + // + // Most commands only need Summary; reach for Description when one line is not enough. + Summary string + + // Description is the longer help text shown at the top of this command's own help. Use it for + // paragraphs that explain behavior, defaults, or important context. + // + // When Summary is empty, the first line of Description is used in command listings. Description string - // Help customizes the command's help text. + // Help replaces the built-in help text for this command. Leave it nil to use the generated help. // - // Leave Help nil for the built-in help. Set it to replace help entirely. To keep the built-in - // layout and add sections, call usage.New from the usage package and return the document's - // String result. + // The function receives the resolved command and returns the full help string printed for + // --help and for [UsageErrorf] errors. Each command in a tree may set its own Help; only the + // selected command's Help is invoked. Help func(*Command) string - // Flags defines the command's flags using the standard library flag package. + // Flags is the standard library [flag.FlagSet] that defines this command's flags. Construct it + // with [flag.NewFlagSet], or use [FlagsFunc] for a compact inline form. + // + // Flags defined here are inherited by SubCommands unless marked Local in FlagConfigs. Read + // parsed values inside Exec with [GetFlag]. Flags *flag.FlagSet - // FlagConfigs adds cli-specific behavior to flags already defined in Flags. + // FlagConfigs layers cli-specific behavior on top of flags already defined in Flags: short + // aliases ([FlagConfig.Short]), required flags ([FlagConfig.Required]), and opting out of + // inheritance ([FlagConfig.Local]). // - // Use it for required flags, short aliases, and flags that should not be inherited by - // subcommands. + // Each entry must reference a flag registered in Flags by Name; otherwise [Parse] returns an + // error. FlagConfigs []FlagConfig - // SubCommands lists commands users can select after this command's name. + // SubCommands are commands selected after this command's Name. + // + // When a command has SubCommands, the first non-flag argument must match one of them; an + // unknown name produces an "unknown command" error with suggestions. Commands without + // SubCommands receive any non-flag arguments as positionals in [State.Args]. SubCommands []*Command - // Exec runs after parsing selects this command. + // Exec is the function invoked once parsing selects this command. It receives the parsed + // [State], which carries positional arguments, I/O streams, and access to flag values via + // [GetFlag]. // - // Return [UsageErrorf] for invalid args or flag combinations so Run can print help. Return a - // normal error for operational failures. + // Return [UsageErrorf] for bad arguments or flag combinations so [Run] prints command help to + // stderr; return a normal error for operational failures, which [Run] returns without printing + // help. Exec func(ctx context.Context, s *State) error state *State } -// Path returns the parsed command chain from root to this command. +// Path returns the chain of resolved commands from the root down to this command, inclusive. It is +// available after [Parse] succeeds and is most often called from inside Exec as s.Cmd.Path() to +// build command-aware error messages or breadcrumbs. // -// Call Path after [Parse] when command logic needs to inspect where the selected command sits in -// the command tree. +// Path returns nil if called before parsing. func (c *Command) Path() []*Command { if c.state == nil { return nil @@ -72,28 +100,34 @@ func (c *Command) terminal() *Command { return c.state.path[len(c.state.path)-1] } -// FlagConfig adds cli-specific behavior to a flag defined in a command's FlagSet. +// FlagConfig attaches cli-specific behavior to a single flag already defined in a [Command.Flags] +// FlagSet. It is the entry type used in [Command.FlagConfigs]. type FlagConfig struct { - // Name is the flag's long name as registered in the command's FlagSet. + // Name is the long flag name as registered in the command's FlagSet. Name string - // Short lets users type a one-letter alias, such as -v for --verbose. + // Short is a one-letter alias for the flag, such as "v" so users can type -v in place of + // --verbose. Both forms are listed in help output. Short string - // Required requires users to provide the flag explicitly. + // Required, when true, makes [Parse] fail unless the user provides the flag explicitly. The + // flag's default value alone is not enough. Required bool - // Local keeps the flag on this command instead of inheriting it into subcommands. + // Local, when true, keeps the flag on this command and prevents it from being inherited by + // subcommands. By default, parent flags are inherited. Local bool } -// FlagsFunc creates a FlagSet inline for a command definition. +// FlagsFunc constructs a [flag.FlagSet] inline for a [Command.Flags] field, sparing callers from +// declaring and assigning the FlagSet separately. The returned FlagSet uses [flag.ContinueOnError] +// so parsing errors are returned rather than fatal. // -// cmd.Flags = cli.FlagsFunc(func(f *flag.FlagSet) { +// Flags: cli.FlagsFunc(func(f *flag.FlagSet) { // f.Bool("verbose", false, "enable verbose output") // f.String("output", "", "output file") // f.Int("count", 0, "number of items") -// }) +// }), func FlagsFunc(fn func(f *flag.FlagSet)) *flag.FlagSet { fset := flag.NewFlagSet("", flag.ContinueOnError) fn(fset) diff --git a/doc.go b/doc.go index 30a1974..fa77ba2 100644 --- a/doc.go +++ b/doc.go @@ -1,21 +1,21 @@ -// Package cli provides a lightweight library for building command-line applications using Go's -// standard library flag package. It extends flag functionality to support flags anywhere in command -// arguments. +// Package cli builds command-line programs on top of the standard library [flag] package. It adds +// nested subcommands and lets users place flags anywhere in command arguments. // -// Key features: -// - Nested subcommands for organizing complex CLIs -// - Flexible flag parsing, allowing flags anywhere in arguments -// - Parent-to-child flag inheritance -// - Type-safe flag access -// - Automatic help text generation -// - Command suggestions for misspelled inputs +// Features: +// - Nested subcommands via [Command.SubCommands] +// - Flags placed anywhere on the command line +// - Parent flags inherited by child commands +// - Type-safe flag access via [GetFlag] +// - Generated help, replaceable per command via [Command.Help] +// - "Did you mean" suggestions for misspelled subcommands // // Quick example: // // root := &cli.Command{ // Name: "echo", // Usage: "echo [flags] ...", -// Description: "prints the provided text", +// Summary: "Print text", +// Description: "echo prints the provided text.", // Flags: cli.FlagsFunc(func(f *flag.FlagSet) { // f.Bool("c", false, "capitalize the input") // }), @@ -28,9 +28,11 @@ // return nil // }, // } +// if err := cli.ParseAndRun(ctx, root, os.Args[1:], nil); err != nil { +// fmt.Fprintf(os.Stderr, "error: %v\n", err) +// os.Exit(1) +// } // -// The package intentionally maintains a minimal API surface to serve as a building block for CLI -// applications while leveraging the standard library's flag package. This approach enables -// developers to build maintainable command-line tools quickly while focusing on application logic -// rather than framework complexity. +// The API stays deliberately small. cli builds on the standard library's flag package instead of +// replacing it, so most of what you write is your program rather than the scaffolding around it. package cli diff --git a/error.go b/error.go index 892b626..c42c9de 100644 --- a/error.go +++ b/error.go @@ -6,11 +6,17 @@ type usageError struct { err error } -// UsageErrorf returns an error for invalid command-line usage. +// UsageErrorf builds an error that signals invalid command-line usage. Return it from +// [Command.Exec] when the command was selected successfully but the arguments or flag combination +// are wrong: // -// Return UsageErrorf from Exec when the command was selected successfully but the remaining args or -// flag combination are invalid. [Run] prints command help to stderr, then returns the formatted -// error without the usage wrapper. Return a normal error when you do not want help printed. +// if len(s.Args) == 0 { +// return cli.UsageErrorf("must supply a name") +// } +// +// When [Run] sees a UsageErrorf error, it prints the resolved command's help to stderr and returns +// the underlying formatted error to the caller. Return a normal error if you do not want help +// printed. func UsageErrorf(format string, args ...any) error { return &usageError{err: fmt.Errorf(format, args...)} } diff --git a/examples/cmd/echo/main.go b/examples/cmd/echo/main.go index 359ad9d..adb5d95 100644 --- a/examples/cmd/echo/main.go +++ b/examples/cmd/echo/main.go @@ -14,7 +14,8 @@ func main() { root := &cli.Command{ Name: "echo", Usage: "echo [flags] ...", - Description: "echo is a simple command that prints the provided text", + Summary: "Print text", + Description: "echo prints the provided text.", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { // Add a flag to capitalize the input f.Bool("c", false, "capitalize the input") diff --git a/examples/cmd/task/main.go b/examples/cmd/task/main.go index 8344c46..b0318bd 100644 --- a/examples/cmd/task/main.go +++ b/examples/cmd/task/main.go @@ -11,23 +11,18 @@ import ( "time" "github.com/pressly/cli" - "github.com/pressly/cli/usage" ) func main() { root := &cli.Command{ Name: "todo", Usage: "todo [flags]", - Description: "A simple CLI for managing your tasks", + Summary: "Manage tasks", + Description: "todo manages tasks stored in a local JSON file.", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.Bool("verbose", false, "enable verbose output") f.Bool("version", false, "print the version") }), - Help: func(c *cli.Command) string { - doc := usage.New(c) - doc = append(doc, usage.Lines("Examples:", "todo list today --file tasks.json", "todo task add --file tasks.json \"write docs\"")) - return doc.String() - }, Exec: func(ctx context.Context, s *cli.State) error { if cli.GetFlag[bool](s, "version") { fmt.Fprintf(s.Stdout, "todo v1.0.0\n") @@ -51,7 +46,8 @@ func list() *cli.Command { return &cli.Command{ Name: "list", Usage: "todo list [flags]", - Description: "List tasks", + Summary: "List tasks", + Description: "List tasks by saved views such as today or overdue.", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.String("file", "", "path to the tasks file") f.String("tags", "", "filter tasks by tags") @@ -76,9 +72,9 @@ func getTasksFromFile(s *cli.State) (*TaskList, error) { func listToday() *cli.Command { return &cli.Command{ - Name: "today", - Usage: "todo list today [flags]", - Description: "List tasks due today", + Name: "today", + Usage: "todo list today [flags]", + Summary: "List tasks due today", Exec: func(ctx context.Context, s *cli.State) error { tasks, err := getTasksFromFile(s) if err != nil { @@ -100,9 +96,9 @@ func listToday() *cli.Command { func listOverdue() *cli.Command { return &cli.Command{ - Name: "overdue", - Usage: "todo list overdue [flags]", - Description: "List overdue tasks", + Name: "overdue", + Usage: "todo list overdue [flags]", + Summary: "List overdue tasks", Exec: func(ctx context.Context, s *cli.State) error { tasks, err := getTasksFromFile(s) if err != nil { @@ -132,7 +128,7 @@ func task() *cli.Command { FlagConfigs: []cli.FlagConfig{ {Name: "file", Required: true}, }, - Description: "Manage tasks", + Summary: "Manage tasks", SubCommands: []*cli.Command{ taskAdd(), taskDone(), @@ -143,9 +139,9 @@ func task() *cli.Command { func taskAdd() *cli.Command { return &cli.Command{ - Name: "add", - Usage: "todo task add [flags]", - Description: "Add a new task", + Name: "add", + Usage: "todo task add [flags]", + Summary: "Add a new task", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.String("tags", "", "comma-separated list of tags") }), @@ -182,9 +178,9 @@ func taskAdd() *cli.Command { func taskDone() *cli.Command { return &cli.Command{ - Name: "done", - Usage: "todo task done [flags]", - Description: "Mark a task as done", + Name: "done", + Usage: "todo task done [flags]", + Summary: "Mark a task as done", Exec: func(ctx context.Context, s *cli.State) error { if len(s.Args) == 0 { return cli.UsageErrorf("task ID required") @@ -205,9 +201,9 @@ func taskDone() *cli.Command { func taskRemove() *cli.Command { return &cli.Command{ - Name: "remove", - Usage: "todo task remove [flags]", - Description: "Remove a task", + Name: "remove", + Usage: "todo task remove [flags]", + Summary: "Remove a task", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.Bool("force", false, "force removal without confirmation") f.Bool("all", false, "remove all tasks") diff --git a/internal/helpdoc/helpdoc.go b/internal/helpdoc/helpdoc.go new file mode 100644 index 0000000..18f6dfb --- /dev/null +++ b/internal/helpdoc/helpdoc.go @@ -0,0 +1,381 @@ +// Package helpdoc builds the default command help document. +package helpdoc + +import ( + "bytes" + "flag" + "fmt" + "io" + "slices" + "strings" + "text/tabwriter" +) + +// Document is an ordered list of help blocks. +type Document []Block + +// Block is one section in a help document. +type Block struct { + heading string + lines []string + indent bool + items []Item +} + +// Item is one row in a list block. +type Item struct { + Name string + Summary string +} + +// Command is the command metadata needed to build help. +type Command struct { + Name string + Usage string + Summary string + Description string + Flags *flag.FlagSet + FlagConfigs []FlagConfig + Subcommands []Command +} + +// FlagConfig adds help-specific behavior to a flag in a FlagSet. +type FlagConfig struct { + Name string + Short string + Required bool + Local bool +} + +// New returns the default help document for the parsed command path. +func New(path []Command) Document { + if len(path) == 0 { + return nil + } + + cmd := path[len(path)-1] + flags := collectFlags(path) + + var doc Document + if text := commandHelpText(cmd); text != "" { + doc = append(doc, Text(text)) + } + + usageLine := cmd.Usage + if usageLine == "" { + usageLine = commandPath(path) + if len(flags) > 0 { + usageLine += " [flags]" + } + if len(cmd.Subcommands) > 0 { + usageLine += " " + } + } + doc = append(doc, Lines("Usage:", usageLine)) + + if len(cmd.Subcommands) > 0 { + subcommands := slices.Clone(cmd.Subcommands) + slices.SortFunc(subcommands, func(a, b Command) int { + return strings.Compare(a.Name, b.Name) + }) + + items := make([]Item, 0, len(subcommands)) + for _, sub := range subcommands { + items = append(items, Item{ + Name: sub.Name, + Summary: commandListSummary(sub), + }) + } + doc = append(doc, List("Available Commands:", items...)) + } + + if len(flags) > 0 { + slices.SortFunc(flags, func(a, b flagInfo) int { + return strings.Compare(a.name, b.name) + }) + + localFlags, inheritedFlags := splitFlags(flags) + if len(localFlags) > 0 { + doc = append(doc, flagsBlock("Flags:", localFlags)) + } + if len(inheritedFlags) > 0 { + doc = append(doc, flagsBlock("Inherited Flags:", inheritedFlags)) + } + } + + if len(cmd.Subcommands) > 0 { + doc = append(doc, Text( + fmt.Sprintf("Use \"%s [command] --help\" for more information about a command.", commandPath(path)), + )) + } + + return doc +} + +// Text returns an untitled paragraph block. +func Text(lines ...string) Block { + return Block{lines: lines} +} + +// Lines returns a titled block of indented lines. +func Lines(heading string, lines ...string) Block { + return Block{heading: heading, lines: lines, indent: true} +} + +// List returns a titled list of name/summary pairs. +func List(heading string, items ...Item) Block { + return Block{heading: heading, items: items} +} + +func flagsBlock(heading string, flags []flagInfo) Block { + hasShort := false + for _, f := range flags { + if f.short != "" { + hasShort = true + break + } + } + + items := make([]Item, 0, len(flags)) + for _, f := range flags { + items = append(items, Item{ + Name: flagSpec(f.name, f.short, f.placeholder, hasShort), + Summary: flagDescription(f.usage, f.defaultValue, f.required), + }) + } + return List(heading, items...) +} + +func commandPath(path []Command) string { + names := make([]string, 0, len(path)) + for _, cmd := range path { + names = append(names, cmd.Name) + } + return strings.Join(names, " ") +} + +// String renders the help document as a string. +func (d Document) String() string { + var b strings.Builder + _, _ = d.WriteTo(&b) + return strings.TrimRight(b.String(), "\n") +} + +// WriteTo writes the help document to w. +func (d Document) WriteTo(w io.Writer) (n int64, err error) { + cw := &countWriter{w: w} + for i, block := range d { + if i > 0 { + if _, err := fmt.Fprintln(cw); err != nil { + return cw.n, err + } + } + if _, err := block.writeTo(cw); err != nil { + return cw.n, err + } + } + return cw.n, nil +} + +func (b Block) writeTo(w io.Writer) (n int64, err error) { + cw := &countWriter{w: w} + if b.heading != "" { + if _, err := fmt.Fprintln(cw, b.heading); err != nil { + return cw.n, err + } + } + if len(b.items) > 0 { + if _, err := writeItems(cw, b.items); err != nil { + return cw.n, err + } + } + for _, line := range b.lines { + if b.indent { + line = " " + line + } + if _, err := fmt.Fprintln(cw, line); err != nil { + return cw.n, err + } + } + return cw.n, nil +} + +func flagSpec(name, short, placeholder string, padShort bool) string { + var spec string + if short != "" { + spec = "-" + short + ", --" + name + } else if padShort { + spec = " --" + name + } else { + spec = "--" + name + } + if placeholder == "" { + return spec + } + return spec + " " + placeholder +} + +func flagDescription(usage, defaultValue string, required bool) string { + if required { + return usage + " (required)" + } + if defaultValue != "" { + return fmt.Sprintf("%s (default: %s)", usage, defaultValue) + } + return usage +} + +func flagTypeName(f *flag.Flag) string { + typeName := fmt.Sprintf("%T", f.Value) + if i := strings.LastIndex(typeName, "."); i >= 0 { + typeName = typeName[i+1:] + } + typeName = strings.TrimPrefix(typeName, "*") + typeName = strings.TrimSuffix(typeName, "Value") + if typeName == "bool" { + return "" + } + return typeName +} + +func commandHelpText(cmd Command) string { + if cmd.Description != "" { + return cmd.Description + } + return cmd.Summary +} + +func commandListSummary(cmd Command) string { + if cmd.Summary != "" { + return cmd.Summary + } + return firstLine(cmd.Description) +} + +func firstLine(text string) string { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line != "" { + return line + } + } + return "" +} + +func collectFlags(path []Command) []flagInfo { + var flags []flagInfo + terminalIdx := len(path) - 1 + for i, cmd := range path { + if cmd.Flags == nil { + continue + } + inherited := i < terminalIdx + meta := flagConfigMap(cmd.FlagConfigs) + cmd.Flags.VisitAll(func(f *flag.Flag) { + info := flagInfo{ + name: f.Name, + usage: f.Usage, + defaultValue: f.DefValue, + placeholder: flagTypeName(f), + } + if cfg, ok := meta[f.Name]; ok { + info.short = cfg.Short + info.required = cfg.Required + info.local = cfg.Local + } + if inherited && info.local { + return + } + info.inherited = inherited + info.defaultValue = flagDefault(info.defaultValue, info.placeholder, info.required) + flags = append(flags, info) + }) + } + return flags +} + +func splitFlags(flags []flagInfo) (local, inherited []flagInfo) { + for _, f := range flags { + if f.inherited { + inherited = append(inherited, f) + } else { + local = append(local, f) + } + } + return local, inherited +} + +func flagConfigMap(configs []FlagConfig) map[string]FlagConfig { + m := make(map[string]FlagConfig, len(configs)) + for _, cfg := range configs { + m[cfg.Name] = cfg + } + return m +} + +func flagDefault(defval, typeName string, required bool) string { + if required || isZeroDefault(defval, typeName) { + return "" + } + return defval +} + +func isZeroDefault(defval, typeName string) bool { + switch { + case defval == "": + return true + case defval == "false" && typeName == "": + return true + case defval == "0" && (typeName == "int" || typeName == "int64" || typeName == "uint" || typeName == "uint64"): + return true + case defval == "0" && typeName == "float64": + return true + } + return false +} + +type flagInfo struct { + name string + short string + placeholder string + usage string + defaultValue string + required bool + local bool + inherited bool +} + +func writeItems(w io.Writer, items []Item) (int64, error) { + cw := &countWriter{w: w} + var b bytes.Buffer + tw := tabwriter.NewWriter(&b, 0, 0, 4, ' ', 0) + for _, item := range items { + if item.Summary == "" { + if _, err := fmt.Fprintf(tw, " %s\n", item.Name); err != nil { + return cw.n, err + } + continue + } + if _, err := fmt.Fprintf(tw, " %s\t%s\n", item.Name, item.Summary); err != nil { + return cw.n, err + } + } + if err := tw.Flush(); err != nil { + return cw.n, err + } + if _, err := cw.Write(b.Bytes()); err != nil { + return cw.n, err + } + return cw.n, nil +} + +type countWriter struct { + w io.Writer + n int64 +} + +func (w *countWriter) Write(p []byte) (int, error) { + n, err := w.w.Write(p) + w.n += int64(n) + return n, err +} diff --git a/internal/usage/help.go b/internal/usage/help.go new file mode 100644 index 0000000..2f6d346 --- /dev/null +++ b/internal/usage/help.go @@ -0,0 +1,180 @@ +// Package usage contains experimental building blocks for command help documents. +// +// It is internal while the public usage API is still being refined. Generated help prefers +// cli.Command.Description for the command's long help text and cli.Command.Summary for command +// lists, with fallbacks for simple commands that only set one field. +package usage + +import ( + "io" + "strings" + + "github.com/pressly/cli" + "github.com/pressly/cli/internal/helpdoc" +) + +// Document is an ordered list of blocks that can be rendered as command help. +type Document []Block + +// Block is one section in a help document. +type Block struct { + block helpdoc.Block +} + +// Item is one row in a List block. +type Item struct { + Name string + Summary string +} + +// Text returns an untitled paragraph block. +// +// Use Text for descriptions, notes, or closing hints. +func Text(lines ...string) Block { + return Block{block: helpdoc.Text(lines...)} +} + +// Lines returns a titled block of indented lines. +// +// Use Lines for sections such as Usage or Examples where each line should stand on its own. +func Lines(heading string, lines ...string) Block { + return Block{block: helpdoc.Lines(heading, lines...)} +} + +// List returns a titled list of name/summary pairs. +// +// Use List for aligned sections such as commands, flags, or named examples. +func List(heading string, items ...Item) Block { + return Block{block: helpdoc.List(heading, helpItems(items)...)} +} + +// String renders the full help document as a string. +// +// Use String when returning help from cli.Command.Help or when comparing help text in tests. +func (d Document) String() string { + return d.helpdoc().String() +} + +// WriteTo writes the help document to w. +// +// Use WriteTo when streaming help directly to stdout, stderr, or another writer. +func (d Document) WriteTo(w io.Writer) (int64, error) { + return d.helpdoc().WriteTo(w) +} + +// New returns the default help document for cmd. +// +// Use New from cli.Command.Help when you want to keep the built-in help layout and add or reorder +// sections before returning the final string. The document starts with Description when set, or +// Summary otherwise. Subcommand lists use Summary when set, or the first line of Description +// otherwise. Use New, not Help, inside a cli.Command.Help hook so the hook does not call itself. +func New(cmd *cli.Command) Document { + cmd = resolveCommand(cmd) + if cmd == nil { + return nil + } + return fromHelpDoc(helpdoc.New(helpPath(cmd))) +} + +// Help returns help text for cmd. +// +// Use Help when handling flag.ErrHelp yourself after calling cli.Parse directly. It returns the +// same text cli.ParseAndRun prints for --help: if the resolved command has a cli.Command.Help hook, +// Help returns that hook's output; otherwise, it renders the default document from New. Inside a +// cli.Command.Help hook, use New instead. +func Help(cmd *cli.Command) string { + cmd = resolveCommand(cmd) + if cmd == nil { + return "" + } + if cmd.Help != nil { + return strings.TrimRight(cmd.Help(cmd), "\n") + } + return New(cmd).String() +} + +func resolveCommand(cmd *cli.Command) *cli.Command { + if cmd == nil { + return nil + } + if path := cmd.Path(); len(path) > 0 { + return path[len(path)-1] + } + return cmd +} + +func fromHelpDoc(doc helpdoc.Document) Document { + out := make(Document, 0, len(doc)) + for _, block := range doc { + out = append(out, Block{block: block}) + } + return out +} + +func (d Document) helpdoc() helpdoc.Document { + out := make(helpdoc.Document, 0, len(d)) + for _, block := range d { + out = append(out, block.block) + } + return out +} + +func helpItems(items []Item) []helpdoc.Item { + out := make([]helpdoc.Item, 0, len(items)) + for _, item := range items { + out = append(out, helpdoc.Item{ + Name: item.Name, + Summary: item.Summary, + }) + } + return out +} + +func helpPath(cmd *cli.Command) []helpdoc.Command { + path := cmd.Path() + if len(path) == 0 { + path = []*cli.Command{cmd} + } + out := make([]helpdoc.Command, 0, len(path)) + for _, c := range path { + out = append(out, helpCommand(c)) + } + return out +} + +func helpCommand(cmd *cli.Command) helpdoc.Command { + return helpdoc.Command{ + Name: cmd.Name, + Usage: cmd.Usage, + Summary: cmd.Summary, + Description: cmd.Description, + Flags: cmd.Flags, + FlagConfigs: helpFlagConfigs(cmd.FlagConfigs), + Subcommands: helpSubcommands(cmd.SubCommands), + } +} + +func helpSubcommands(commands []*cli.Command) []helpdoc.Command { + out := make([]helpdoc.Command, 0, len(commands)) + for _, cmd := range commands { + out = append(out, helpdoc.Command{ + Name: cmd.Name, + Summary: cmd.Summary, + Description: cmd.Description, + }) + } + return out +} + +func helpFlagConfigs(configs []cli.FlagConfig) []helpdoc.FlagConfig { + out := make([]helpdoc.FlagConfig, 0, len(configs)) + for _, cfg := range configs { + out = append(out, helpdoc.FlagConfig{ + Name: cfg.Name, + Short: cfg.Short, + Required: cfg.Required, + Local: cfg.Local, + }) + } + return out +} diff --git a/usage/help_test.go b/internal/usage/help_test.go similarity index 65% rename from usage/help_test.go rename to internal/usage/help_test.go index 1f67deb..46ab802 100644 --- a/usage/help_test.go +++ b/internal/usage/help_test.go @@ -17,13 +17,13 @@ func TestHelpString(t *testing.T) { h := Document{ Text("print a greeting"), Lines("Usage:", "greet [flags] "), - Flags("Flags:", []Flag{ - {Name: "verbose", Short: "v", Usage: "enable verbose output"}, - {Name: "output", Placeholder: "string", Usage: "output file", Required: true}, - }), - Commands("Available Commands:", []Command{ - {Name: "hello", Summary: "print hello"}, - }), + List("Flags:", + Item{Name: "-v, --verbose", Summary: "enable verbose output"}, + Item{Name: "--output string", Summary: "output file (required)"}, + ), + List("Available Commands:", + Item{Name: "hello", Summary: "print hello"}, + ), } output := h.String() @@ -38,17 +38,17 @@ func TestHelpString(t *testing.T) { require.False(t, strings.HasSuffix(output, "\n")) } -func TestBlockString(t *testing.T) { +func TestDocumentStringForSingleBlock(t *testing.T) { t.Parallel() - output := Lines("Examples:", "greet margo").String() + output := Document{Lines("Examples:", "greet margo")}.String() require.Equal(t, "Examples:\n greet margo", output) } func TestListWithoutSummary(t *testing.T) { t.Parallel() - output := List("Commands:", Item{Name: "serve"}).String() + output := Document{List("Commands:", Item{Name: "serve"})}.String() require.Equal(t, "Commands:\n serve", output) } @@ -145,6 +145,75 @@ func TestCommandDocumentComposition(t *testing.T) { require.Contains(t, output, "greet margo") } +func TestCommandHelpSummaryAndDescription(t *testing.T) { + t.Parallel() + + child := &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 }, + } + root := &cli.Command{ + Name: "todo", + Summary: "Manage tasks", + SubCommands: []*cli.Command{child}, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + + require.NoError(t, cli.Parse(root, nil)) + output := Help(root) + require.Contains(t, output, "list List tasks") + require.NotContains(t, output, "By default, completed tasks are hidden.") + + require.NoError(t, cli.Parse(root, []string{"list"})) + output = Help(root) + require.Contains(t, output, "List tasks in the current workspace.") + require.Contains(t, output, "By default, completed tasks are hidden.") +} + +func TestCommandHelpDescriptionFallbacks(t *testing.T) { + t.Parallel() + + t.Run("summary is shown in command help when description is empty", func(t *testing.T) { + t.Parallel() + + cmd := &cli.Command{ + Name: "greet", + Summary: "Print a greeting", + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + + require.NoError(t, cli.Parse(cmd, nil)) + require.Contains(t, Help(cmd), "Print a greeting") + }) + + t.Run("description first line is shown in command lists when summary is empty", func(t *testing.T) { + t.Parallel() + + root := &cli.Command{ + Name: "todo", + SubCommands: []*cli.Command{ + { + Name: "list", + Description: `List tasks in the current workspace. + +By default, completed tasks are hidden.`, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + }, + }, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + + require.NoError(t, cli.Parse(root, nil)) + output := Help(root) + require.Contains(t, output, "list List tasks in the current workspace.") + require.NotContains(t, output, "By default, completed tasks are hidden.") + }) +} + func TestCommandHelpUsesCustomHook(t *testing.T) { t.Parallel() diff --git a/parse.go b/parse.go index 74b27e8..b8adb86 100644 --- a/parse.go +++ b/parse.go @@ -13,12 +13,12 @@ import ( "github.com/pressly/cli/xflag" ) -// Parse resolves a command and parses its flags without running it. +// Parse resolves the selected command and parses its flags from args, but does not run Exec. Pair +// it with [Run] when you need to do work between parsing and execution; for the common case, call +// [ParseAndRun]. // -// Most programs should use [ParseAndRun]. Use Parse directly when you need to inspect parsed flags -// or initialize resources before calling [Run]. If the user asks for help, Parse returns -// [flag.ErrHelp] after resolving the command so callers can render the right help text with the -// usage.Help function. +// Parse returns [flag.ErrHelp] when the user passes -h or --help. The caller is responsible for +// printing help in that case; [ParseAndRun] does this automatically. func Parse(root *Command, args []string) error { if root == nil { return fmt.Errorf("failed to parse: root command is nil") diff --git a/run.go b/run.go index 4fc9178..247f946 100644 --- a/run.go +++ b/run.go @@ -14,21 +14,24 @@ import ( "sync" ) -// RunOptions overrides the standard streams used by [Run] and [ParseAndRun]. +// RunOptions overrides the standard streams used by [Run] and [ParseAndRun]. Pass nil for normal +// programs to use os.Stdin, os.Stdout, and os.Stderr. // -// Leave it nil for normal CLI programs. Provide it in tests or embedded applications that need to -// capture output or supply custom input. +// Provide RunOptions in tests, or in embedded applications that need to capture output or supply +// custom input. type RunOptions struct { - // Stdin, Stdout, and Stderr replace os.Stdin, os.Stdout, and os.Stderr when set. + // Stdin, Stdout, and Stderr replace os.Stdin, os.Stdout, and os.Stderr when set. Any field + // left nil falls back to its os equivalent. Stdin io.Reader Stdout, Stderr io.Writer } -// Run executes the command selected by [Parse]. +// Run executes the command selected by a prior call to [Parse]. Use Run only with the split +// [Parse]/Run flow; for the common case, call [ParseAndRun]. // -// Use Run only with the split [Parse]/Run flow. [ParseAndRun] is the usual entry point. If Exec -// returns an error created with [UsageErrorf], Run prints help for the selected command to stderr -// and returns the underlying error. +// If Exec returns an error created by [UsageErrorf], Run prints the command's help to stderr and +// returns the underlying error. Other errors are returned unchanged. A nil ctx defaults to +// [context.Background]. func Run(ctx context.Context, root *Command, options *RunOptions) error { if ctx == nil { ctx = context.Background() @@ -51,18 +54,17 @@ func Run(ctx context.Context, root *Command, options *RunOptions) error { return run(ctx, cmd, root.state) } -// ParseAndRun parses args and runs the selected command. -// -// Use ParseAndRun as the normal entry point for CLI applications. It handles help flags by printing -// command help to stdout and returning nil, then runs Exec for the selected command. +// ParseAndRun parses args, resolves the selected command, and runs its Exec. It is the normal +// entry point for CLI programs: // // if err := cli.ParseAndRun(ctx, root, os.Args[1:], nil); err != nil { // fmt.Fprintf(os.Stderr, "error: %v\n", err) // os.Exit(1) // } // -// Use [Parse] and [Run] separately when you need work between parsing and execution, such as -// initializing resources from parsed flags. +// When the user passes -h or --help, ParseAndRun prints the resolved command's help to stdout and +// returns nil. Use [Parse] and [Run] separately when you need to do work between parsing and +// execution, such as initializing resources from parsed flags. func ParseAndRun(ctx context.Context, root *Command, args []string, options *RunOptions) error { if err := Parse(root, args); err != nil { if errors.Is(err, flag.ErrHelp) { diff --git a/state.go b/state.go index 0a98104..f5783ee 100644 --- a/state.go +++ b/state.go @@ -7,20 +7,21 @@ import ( "io" ) -// State is passed to Exec with the parsed invocation context. -// -// Use Args for remaining positional arguments, Stdin/Stdout/Stderr for command I/O, Cmd for the -// selected command, and [GetFlag] to read parsed flag values. +// State carries the parsed invocation context into [Command.Exec]. Use Args for positional +// arguments, Stdin/Stdout/Stderr for I/O, Cmd for the selected command, and [GetFlag] to read flag +// values. type State struct { - // Args contains positional arguments left after command and flag parsing. + // Args holds the positional arguments left after command resolution and flag parsing. Anything + // after a "--" delimiter is included verbatim, even if it would otherwise look like a flag. Args []string - // Stdin, Stdout, and Stderr are the streams command code should use instead of package-level - // os.Stdin, os.Stdout, and os.Stderr. + // Stdin, Stdout, and Stderr are the streams command code should use in place of the package- + // level os.Stdin, os.Stdout, and os.Stderr. Tests can swap them via [RunOptions]. Stdin io.Reader Stdout, Stderr io.Writer - // Cmd is the command selected by parsing. + // Cmd is the resolved (terminal) command. Call Cmd.Path() for the chain from the root down, + // useful for command-aware error messages or breadcrumbs. Cmd *Command // path is the command hierarchy from the root command to the current command. The root command @@ -28,15 +29,17 @@ type State struct { path []*Command } -// GetFlag reads a parsed flag value from State. +// GetFlag returns the parsed value of a flag, type-checked against T. Call it from inside +// [Command.Exec] with the same Go type that was used when the flag was defined. // -// Call GetFlag from Exec with the same Go type used to define the flag. It checks the selected -// command first, then inherited parent flags. A missing flag or wrong type is treated as a -// programming error and returned from [Run]. +// Lookup walks from the selected command up through inherited parent flags, so a flag defined on +// the root command is reachable from any subcommand. An unknown flag name, or one read with the +// wrong type, is treated as a programming error: GetFlag panics, and [Run] recovers and returns +// the error to the caller. // -// verbose := GetFlag[bool](state, "verbose") -// count := GetFlag[int](state, "count") -// path := GetFlag[string](state, "path") +// 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 { if s == nil { panic(&internalError{err: errors.New("state is nil")}) diff --git a/usage.go b/usage.go index 6695cbe..4f82012 100644 --- a/usage.go +++ b/usage.go @@ -1,13 +1,9 @@ package cli import ( - "bytes" - "cmp" - "flag" - "fmt" - "slices" "strings" - "text/tabwriter" + + "github.com/pressly/cli/internal/helpdoc" ) func help(root *Command) string { @@ -24,299 +20,55 @@ func help(root *Command) string { } func defaultHelp(root *Command) string { - terminalCmd := root.terminal() - - var blocks []string - - if terminalCmd.Description != "" { - blocks = append(blocks, terminalCmd.Description) - } - - flags := collectHelpFlags(root, terminalCmd) - - var usageLine string - if terminalCmd.Usage != "" { - usageLine = terminalCmd.Usage - } else { - usageLine = terminalCmd.Name - if root.state != nil && len(root.state.path) > 0 { - usageLine = getCommandPath(root.state.path) - } - if len(flags) > 0 { - usageLine += " [flags]" - } - if len(terminalCmd.SubCommands) > 0 { - usageLine += " " - } - } - blocks = append(blocks, renderLines("Usage:", usageLine)) - - if len(terminalCmd.SubCommands) > 0 { - sortedCommands := slices.Clone(terminalCmd.SubCommands) - slices.SortFunc(sortedCommands, func(a, b *Command) int { - return cmp.Compare(a.Name, b.Name) - }) - - subcommands := make([]helpItem, 0, len(sortedCommands)) - for _, sub := range sortedCommands { - subcommands = append(subcommands, helpItem{ - Name: sub.Name, - Summary: sub.Description, - }) - } - blocks = append(blocks, renderItems("Available Commands:", subcommands)) - } - - if len(flags) > 0 { - slices.SortFunc(flags, func(a, b flagInfo) int { - return cmp.Compare(a.name, b.name) - }) - - hasLocal := false - hasInherited := false - for _, f := range flags { - if f.inherited { - hasInherited = true - } else { - hasLocal = true - } - } - - if hasLocal { - blocks = append(blocks, renderFlags("Flags:", usageFlags(flags, false))) - } - - if hasInherited { - blocks = append(blocks, renderFlags("Inherited Flags:", usageFlags(flags, true))) - } - } - - if len(terminalCmd.SubCommands) > 0 { - cmdName := terminalCmd.Name - if root.state != nil && len(root.state.path) > 0 { - cmdName = getCommandPath(root.state.path) - } - blocks = append(blocks, - fmt.Sprintf("Use \"%s [command] --help\" for more information about a command.", cmdName), - ) - } - - return strings.Join(blocks, "\n\n") + return helpdoc.New(helpPath(root)).String() } -func collectHelpFlags(root, terminalCmd *Command) []flagInfo { - var flags []flagInfo - if root.state != nil && len(root.state.path) > 0 { - terminalIdx := len(root.state.path) - 1 - for i, cmd := range root.state.path { - if cmd.Flags == nil { - continue - } - isInherited := i < terminalIdx - metaMap := flagConfigMap(cmd.FlagConfigs) - cmd.Flags.VisitAll(func(f *flag.Flag) { - // Skip local flags from ancestor commands — they don't appear in child help. - if isInherited { - if m, ok := metaMap[f.Name]; ok && m.Local { - return - } - } - fi := flagInfo{ - name: "--" + f.Name, - usage: f.Usage, - defval: f.DefValue, - typeName: flagTypeName(f), - inherited: isInherited, - } - if m, ok := metaMap[f.Name]; ok { - fi.required = m.Required - fi.short = m.Short - } - flags = append(flags, fi) - }) - } - } else if terminalCmd.Flags != nil { - // Pre-parse fallback: show the command's own flags even without state. - metaMap := flagConfigMap(terminalCmd.FlagConfigs) - terminalCmd.Flags.VisitAll(func(f *flag.Flag) { - fi := flagInfo{ - name: "--" + f.Name, - usage: f.Usage, - defval: f.DefValue, - typeName: flagTypeName(f), - } - if m, ok := metaMap[f.Name]; ok { - fi.required = m.Required - fi.short = m.Short - } - flags = append(flags, fi) - }) +func helpPath(root *Command) []helpdoc.Command { + path := root.Path() + if len(path) == 0 { + path = []*Command{root.terminal()} } - return flags -} -func usageFlags(flags []flagInfo, inherited bool) []helpFlag { - out := make([]helpFlag, 0, len(flags)) - for _, f := range flags { - if f.inherited != inherited { - continue - } - defval := "" - if !f.required && !isZeroDefault(f.defval, f.typeName) { - defval = f.defval - } - out = append(out, helpFlag{ - Name: strings.TrimPrefix(f.name, "--"), - Short: f.short, - Placeholder: f.typeName, - Usage: f.usage, - Default: defval, - Required: f.required, - }) + out := make([]helpdoc.Command, 0, len(path)) + for _, cmd := range path { + out = append(out, helpCommand(cmd)) } return out } -func renderLines(heading string, lines ...string) string { - var b strings.Builder - b.WriteString(heading) - for _, line := range lines { - b.WriteString("\n ") - b.WriteString(line) +func helpCommand(cmd *Command) helpdoc.Command { + return helpdoc.Command{ + Name: cmd.Name, + Usage: cmd.Usage, + Summary: cmd.Summary, + Description: cmd.Description, + Flags: cmd.Flags, + FlagConfigs: helpFlagConfigs(cmd.FlagConfigs), + Subcommands: helpSubcommands(cmd.SubCommands), } - return b.String() } -func renderFlags(heading string, flags []helpFlag) string { - hasShort := false - for _, f := range flags { - if f.Short != "" { - hasShort = true - break - } - } - items := make([]helpItem, 0, len(flags)) - for _, f := range flags { - items = append(items, helpItem{ - Name: f.spec(hasShort), - Summary: f.description(), +func helpSubcommands(commands []*Command) []helpdoc.Command { + out := make([]helpdoc.Command, 0, len(commands)) + for _, cmd := range commands { + out = append(out, helpdoc.Command{ + Name: cmd.Name, + Summary: cmd.Summary, + Description: cmd.Description, }) } - return renderItems(heading, items) -} - -func renderItems(heading string, items []helpItem) string { - var out strings.Builder - out.WriteString(heading) - out.WriteByte('\n') - - var rows bytes.Buffer - tw := tabwriter.NewWriter(&rows, 0, 0, 4, ' ', 0) - for _, item := range items { - if item.Summary == "" { - _, _ = fmt.Fprintf(tw, " %s\n", item.Name) - continue - } - _, _ = fmt.Fprintf(tw, " %s\t%s\n", item.Name, item.Summary) - } - _ = tw.Flush() - out.Write(rows.Bytes()) - - return strings.TrimRight(out.String(), "\n") -} - -type helpItem struct { - Name string - Summary string -} - -type helpFlag struct { - Name string - Short string - Placeholder string - Usage string - Default string - Required bool -} - -func (f helpFlag) spec(padShort bool) string { - var name string - if f.Short != "" { - name = "-" + f.Short + ", --" + f.Name - } else if padShort { - name = " --" + f.Name - } else { - name = "--" + f.Name - } - if f.Placeholder == "" { - return name - } - return name + " " + f.Placeholder -} - -func (f helpFlag) description() string { - description := f.Usage - if f.Required { - return description + " (required)" - } - if f.Default != "" { - return fmt.Sprintf("%s (default: %s)", description, f.Default) - } - return description -} - -// flagConfigMap builds a lookup map from flag name to its FlagConfig. -func flagConfigMap(options []FlagConfig) map[string]FlagConfig { - m := make(map[string]FlagConfig, len(options)) - for _, fm := range options { - m[fm.Name] = fm - } - return m -} - -type flagInfo struct { - name string - short string - usage string - defval string - typeName string - inherited bool - required bool -} - -// flagTypeName returns a short type name for a flag's value. Bool flags return "" since their type -// is obvious from usage. This mirrors the approach used by Go's flag.PrintDefaults. -func flagTypeName(f *flag.Flag) string { - // Use the type name from the Value interface, which returns the type as a string. - typeName := fmt.Sprintf("%T", f.Value) - // The flag package uses unexported types like *flag.boolValue, *flag.stringValue, etc. Extract - // just the base name and strip the "Value" suffix. - if i := strings.LastIndex(typeName, "."); i >= 0 { - typeName = typeName[i+1:] - } - typeName = strings.TrimPrefix(typeName, "*") - typeName = strings.TrimSuffix(typeName, "Value") - - // Don't show type for bools — their usage is self-evident. - if typeName == "bool" { - return "" - } - return typeName + return out } -// isZeroDefault returns true if the default value is the zero value for its type and should be -// suppressed in help output to reduce noise. -func isZeroDefault(defval, typeName string) bool { - switch { - case defval == "": - return true - case defval == "false" && typeName == "": - // Bool flags (typeName is "" for bools). - return true - case defval == "0" && (typeName == "int" || typeName == "int64" || typeName == "uint" || typeName == "uint64"): - return true - case defval == "0" && typeName == "float64": - return true +func helpFlagConfigs(configs []FlagConfig) []helpdoc.FlagConfig { + out := make([]helpdoc.FlagConfig, 0, len(configs)) + for _, cfg := range configs { + out = append(out, helpdoc.FlagConfig{ + Name: cfg.Name, + Short: cfg.Short, + Required: cfg.Required, + Local: cfg.Local, + }) } - return false + return out } diff --git a/usage/command.go b/usage/command.go deleted file mode 100644 index fb693a9..0000000 --- a/usage/command.go +++ /dev/null @@ -1,16 +0,0 @@ -package usage - -// Command describes one command row in a help document. -type Command struct { - Name string - Summary string -} - -// Commands returns a help section for subcommands. -func Commands(heading string, commands []Command) Block { - items := make([]Item, 0, len(commands)) - for _, cmd := range commands { - items = append(items, Item(cmd)) - } - return List(heading, items...) -} diff --git a/usage/flag.go b/usage/flag.go deleted file mode 100644 index a39e1d7..0000000 --- a/usage/flag.go +++ /dev/null @@ -1,71 +0,0 @@ -package usage - -import "fmt" - -// Flag describes one flag row in a help document. -type Flag struct { - // Name is the long flag name without dashes, such as "verbose". - Name string - - // Short is the optional short flag name without dashes, such as "v". - Short string - - // Placeholder is shown after non-boolean flags, such as "string" or "int". - Placeholder string - - // Usage describes what the flag changes. - Usage string - - // Default is shown when the default is useful to users. - Default string - - // Required marks the flag as required in help output. - Required bool -} - -// Flags returns a help section for flag rows. -func Flags(heading string, flags []Flag) Block { - hasShort := false - for _, f := range flags { - if f.Short != "" { - hasShort = true - break - } - } - items := make([]Item, 0, len(flags)) - for _, f := range flags { - items = append(items, Item{ - Name: f.Spec(hasShort), - Summary: f.Description(), - }) - } - return List(heading, items...) -} - -// Spec returns the flag spelling shown in help output. -func (f Flag) Spec(padShort bool) string { - var name string - if f.Short != "" { - name = "-" + f.Short + ", --" + f.Name - } else if padShort { - name = " --" + f.Name - } else { - name = "--" + f.Name - } - if f.Placeholder == "" { - return name - } - return name + " " + f.Placeholder -} - -// Description returns the help text shown after the flag spelling. -func (f Flag) Description() string { - description := f.Usage - if f.Required { - return description + " (required)" - } - if f.Default != "" { - return fmt.Sprintf("%s (default: %s)", description, f.Default) - } - return description -} diff --git a/usage/flag_test.go b/usage/flag_test.go deleted file mode 100644 index 01ee25f..0000000 --- a/usage/flag_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package usage - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestFlagSpec(t *testing.T) { - t.Parallel() - - require.Equal(t, "--verbose", Flag{Name: "verbose"}.Spec(false)) - require.Equal(t, " --config string", Flag{Name: "config", Placeholder: "string"}.Spec(true)) - require.Equal(t, "-o, --output string", Flag{Name: "output", Short: "o", Placeholder: "string"}.Spec(false)) -} - -func TestFlagDescription(t *testing.T) { - t.Parallel() - - require.Equal(t, "enable verbose output", Flag{Usage: "enable verbose output"}.Description()) - require.Equal(t, "output file (default: stdout)", Flag{Usage: "output file", Default: "stdout"}.Description()) - require.Equal(t, "path to file (required)", Flag{Usage: "path to file", Required: true, Default: "ignored"}.Description()) -} diff --git a/usage/help.go b/usage/help.go deleted file mode 100644 index 535771a..0000000 --- a/usage/help.go +++ /dev/null @@ -1,373 +0,0 @@ -// Package usage provides optional building blocks for command help documents. -// -// Use this package when you need to render help yourself, append examples, add sections, or render -// command metadata in a different layout. -package usage - -import ( - "bytes" - "flag" - "fmt" - "io" - "slices" - "strings" - "text/tabwriter" - - "github.com/pressly/cli" -) - -// Document is an ordered list of blocks that can be rendered as command help. -type Document []Block - -// Block is one section in a help document. -type Block struct { - // Heading is rendered above the block when set, such as "Usage:" or "Examples:". - Heading string - - lines []string - indent bool - items []Item -} - -// Item is one row in a List block. -type Item struct { - Name string - Summary string -} - -// Text returns an untitled paragraph block. -// -// Use Text for descriptions, notes, or closing hints. -func Text(lines ...string) Block { - return Block{lines: lines} -} - -// Lines returns a titled block of indented lines. -// -// Use Lines for sections such as Usage or Examples where each line should stand on its own. -func Lines(heading string, lines ...string) Block { - return Block{Heading: heading, lines: lines, indent: true} -} - -// List returns a titled list of name/summary pairs. -// -// Use List for aligned sections such as commands, flags, or named examples. -func List(heading string, items ...Item) Block { - return Block{Heading: heading, items: items} -} - -// String renders the full help document as a string. -// -// Use String when returning help from cli.Command.Help or when comparing help text in tests. -func (d Document) String() string { - var b strings.Builder - _, _ = d.WriteTo(&b) - return strings.TrimRight(b.String(), "\n") -} - -// WriteTo writes the help document to w. -// -// Use WriteTo when streaming help directly to stdout, stderr, or another writer. -func (d Document) WriteTo(w io.Writer) (n int64, err error) { - cw := &countWriter{w: w} - for i, block := range d { - if i > 0 { - if _, err := fmt.Fprintln(cw); err != nil { - return cw.n, err - } - } - if _, err := block.WriteTo(cw); err != nil { - return cw.n, err - } - } - return cw.n, nil -} - -// String renders the block as a string. -func (b Block) String() string { - var s strings.Builder - _, _ = b.WriteTo(&s) - return strings.TrimRight(s.String(), "\n") -} - -// WriteTo writes the block to w. -func (b Block) WriteTo(w io.Writer) (n int64, err error) { - cw := &countWriter{w: w} - if b.Heading != "" { - if _, err := fmt.Fprintln(cw, b.Heading); err != nil { - return cw.n, err - } - } - if len(b.items) > 0 { - if _, err := writeItems(cw, b.items); err != nil { - return cw.n, err - } - } - for _, line := range b.lines { - if b.indent { - line = " " + line - } - if _, err := fmt.Fprintln(cw, line); err != nil { - return cw.n, err - } - } - return cw.n, nil -} - -func writeItems(w io.Writer, items []Item) (int64, error) { - cw := &countWriter{w: w} - var b bytes.Buffer - tw := tabwriter.NewWriter(&b, 0, 0, 4, ' ', 0) - for _, item := range items { - if item.Summary == "" { - if _, err := fmt.Fprintf(tw, " %s\n", item.Name); err != nil { - return cw.n, err - } - continue - } - if _, err := fmt.Fprintf(tw, " %s\t%s\n", item.Name, item.Summary); err != nil { - return cw.n, err - } - } - if err := tw.Flush(); err != nil { - return cw.n, err - } - if _, err := cw.Write(b.Bytes()); err != nil { - return cw.n, err - } - return cw.n, nil -} - -type countWriter struct { - w io.Writer - n int64 -} - -func (w *countWriter) Write(p []byte) (int, error) { - n, err := w.w.Write(p) - w.n += int64(n) - return n, err -} - -// New returns the default help document for cmd. -// -// Use New from cli.Command.Help when you want to keep the built-in help layout and add or reorder -// sections before returning the final string. Use New, not Help, inside a cli.Command.Help hook so -// the hook does not call itself. -func New(cmd *cli.Command) Document { - cmd = resolveCommand(cmd) - if cmd == nil { - return nil - } - - var doc Document - - if cmd.Description != "" { - doc = append(doc, Text(cmd.Description)) - } - - flags := collectHelpFlags(cmd) - - usageLine := cmd.Usage - if usageLine == "" { - usageLine = commandPath(cmd) - if len(flags) > 0 { - usageLine += " [flags]" - } - if len(cmd.SubCommands) > 0 { - usageLine += " " - } - } - doc = append(doc, Lines("Usage:", usageLine)) - - if len(cmd.SubCommands) > 0 { - sortedCommands := slices.Clone(cmd.SubCommands) - slices.SortFunc(sortedCommands, func(a, b *cli.Command) int { - return strings.Compare(a.Name, b.Name) - }) - - subcommands := make([]Command, 0, len(sortedCommands)) - for _, sub := range sortedCommands { - subcommands = append(subcommands, Command{ - Name: sub.Name, - Summary: sub.Description, - }) - } - doc = append(doc, Commands("Available Commands:", subcommands)) - } - - if len(flags) > 0 { - slices.SortFunc(flags, func(a, b flagInfo) int { - return strings.Compare(a.name, b.name) - }) - - hasLocal := false - hasInherited := false - for _, f := range flags { - if f.inherited { - hasInherited = true - } else { - hasLocal = true - } - } - - if hasLocal { - doc = append(doc, Flags("Flags:", usageFlags(flags, false))) - } - - if hasInherited { - doc = append(doc, Flags("Inherited Flags:", usageFlags(flags, true))) - } - } - - if len(cmd.SubCommands) > 0 { - doc = append(doc, Text( - fmt.Sprintf("Use \"%s [command] --help\" for more information about a command.", commandPath(cmd)), - )) - } - - return doc -} - -// Help returns help text for cmd. -// -// Use Help when handling flag.ErrHelp yourself after calling cli.Parse directly. It returns the -// same text cli.ParseAndRun prints for --help: if the resolved command has a cli.Command.Help hook, -// Help returns that hook's output; otherwise, it renders the default document from New. Inside a -// cli.Command.Help hook, use New instead. -func Help(cmd *cli.Command) string { - cmd = resolveCommand(cmd) - if cmd == nil { - return "" - } - if cmd.Help != nil { - return strings.TrimRight(cmd.Help(cmd), "\n") - } - return New(cmd).String() -} - -func resolveCommand(cmd *cli.Command) *cli.Command { - if cmd == nil { - return nil - } - if path := cmd.Path(); len(path) > 0 { - return path[len(path)-1] - } - return cmd -} - -func collectHelpFlags(cmd *cli.Command) []flagInfo { - var flags []flagInfo - path := cmd.Path() - if len(path) == 0 { - path = []*cli.Command{cmd} - } - - terminalIdx := len(path) - 1 - for i, c := range path { - if c.Flags == nil { - continue - } - isInherited := i < terminalIdx - metaMap := flagConfigMap(c.FlagConfigs) - c.Flags.VisitAll(func(f *flag.Flag) { - if isInherited { - if m, ok := metaMap[f.Name]; ok && m.Local { - return - } - } - fi := flagInfo{ - name: "--" + f.Name, - usage: f.Usage, - defval: f.DefValue, - typeName: flagTypeName(f), - inherited: isInherited, - } - if m, ok := metaMap[f.Name]; ok { - fi.required = m.Required - fi.short = m.Short - } - flags = append(flags, fi) - }) - } - return flags -} - -func commandPath(cmd *cli.Command) string { - path := cmd.Path() - if len(path) == 0 { - return cmd.Name - } - var names []string - for _, c := range path { - names = append(names, c.Name) - } - return strings.Join(names, " ") -} - -func usageFlags(flags []flagInfo, inherited bool) []Flag { - out := make([]Flag, 0, len(flags)) - for _, f := range flags { - if f.inherited != inherited { - continue - } - defval := "" - if !f.required && !isZeroDefault(f.defval, f.typeName) { - defval = f.defval - } - out = append(out, Flag{ - Name: strings.TrimPrefix(f.name, "--"), - Short: f.short, - Placeholder: f.typeName, - Usage: f.usage, - Default: defval, - Required: f.required, - }) - } - return out -} - -func flagConfigMap(options []cli.FlagConfig) map[string]cli.FlagConfig { - m := make(map[string]cli.FlagConfig, len(options)) - for _, fm := range options { - m[fm.Name] = fm - } - return m -} - -type flagInfo struct { - name string - short string - usage string - defval string - typeName string - inherited bool - required bool -} - -func flagTypeName(f *flag.Flag) string { - typeName := fmt.Sprintf("%T", f.Value) - if i := strings.LastIndex(typeName, "."); i >= 0 { - typeName = typeName[i+1:] - } - typeName = strings.TrimPrefix(typeName, "*") - typeName = strings.TrimSuffix(typeName, "Value") - if typeName == "bool" { - return "" - } - return typeName -} - -func isZeroDefault(defval, typeName string) bool { - switch { - case defval == "": - return true - case defval == "false" && typeName == "": - return true - case defval == "0" && (typeName == "int" || typeName == "int64" || typeName == "uint" || typeName == "uint64"): - return true - case defval == "0" && typeName == "float64": - return true - } - return false -} diff --git a/usage_test.go b/usage_test.go index df90281..4f75377 100644 --- a/usage_test.go +++ b/usage_test.go @@ -383,6 +383,75 @@ func TestUsageGeneration(t *testing.T) { }) } +func TestHelpSummaryAndDescription(t *testing.T) { + t.Parallel() + + child := &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 *State) error { return nil }, + } + root := &Command{ + Name: "todo", + Summary: "Manage tasks", + SubCommands: []*Command{child}, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + + require.NoError(t, Parse(root, nil)) + output := help(root) + require.Contains(t, output, "list List tasks") + require.NotContains(t, output, "By default, completed tasks are hidden.") + + require.NoError(t, Parse(root, []string{"list"})) + output = help(root) + require.Contains(t, output, "List tasks in the current workspace.") + require.Contains(t, output, "By default, completed tasks are hidden.") +} + +func TestHelpDescriptionFallbacks(t *testing.T) { + t.Parallel() + + t.Run("summary is shown in command help when description is empty", func(t *testing.T) { + t.Parallel() + + cmd := &Command{ + Name: "greet", + Summary: "Print a greeting", + Exec: func(ctx context.Context, s *State) error { return nil }, + } + + require.NoError(t, Parse(cmd, nil)) + require.Contains(t, help(cmd), "Print a greeting") + }) + + t.Run("description first line is shown in command lists when summary is empty", func(t *testing.T) { + t.Parallel() + + root := &Command{ + Name: "todo", + SubCommands: []*Command{ + { + Name: "list", + Description: `List tasks in the current workspace. + +By default, completed tasks are hidden.`, + Exec: func(ctx context.Context, s *State) error { return nil }, + }, + }, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + + require.NoError(t, Parse(root, nil)) + output := help(root) + require.Contains(t, output, "list List tasks in the current workspace.") + require.NotContains(t, output, "By default, completed tasks are hidden.") + }) +} + func TestFlagHelp(t *testing.T) { t.Parallel() From d19b91c9f1045a88b356372cfe2066c54a5fbea4 Mon Sep 17 00:00:00 2001 From: Mike Fridman Date: Sun, 3 May 2026 11:14:59 +0200 Subject: [PATCH 7/8] docs: simplify public API doc comments --- command.go | 99 ++++++++++++++++++++++++++---------------------------- doc.go | 4 +-- error.go | 10 +++--- parse.go | 8 ++--- run.go | 25 +++++++------- state.go | 28 +++++++-------- 6 files changed, 83 insertions(+), 91 deletions(-) diff --git a/command.go b/command.go index 1bd55dd..089b8f8 100644 --- a/command.go +++ b/command.go @@ -11,80 +11,77 @@ import ( // Command describes a single command in the CLI. // -// Pass a Command to [ParseAndRun] (or [Parse] and [Run]) to drive a program, or list one inside -// another command's [Command.SubCommands] to add a subcommand. Most commands set Name, a one-line -// Summary, Flags, and Exec; Description and SubCommands are added as the program grows. +// Pass a Command to [ParseAndRun] (or [Parse] and [Run]) to run a program. To add a subcommand, +// list it in another command's [Command.SubCommands]. Most commands set Name, a one-line Summary, +// Flags, and Exec. Add Description for longer help and SubCommands for nested commands. type Command struct { - // Name is the word users type to select this command. It must start with a letter and may - // contain letters, digits, dashes, or underscores. For the root command it also identifies the - // program in generated help. + // Name is the word users type to pick this command. It must start with a letter and can contain + // letters, digits, dashes, or underscores. For the root command it is also the program name + // shown in help. Name string - // Usage replaces the auto-generated usage line shown at the top of help. Set it to convey the - // expected positional arguments; the generated form covers only the command path plus a - // trailing [flags] when the command has flags. + // Usage replaces the usage line shown at the top of help. Set it to show the expected + // arguments. The default usage line shows only the command path, plus "[flags]" when the + // command has flags. // // Example: "todo list [flags]" Usage string - // Summary is the one-line description shown next to this command in a parent's command listing. + // Summary is the one-line description shown next to this command in its parent's command list. // It is also shown at the top of this command's own help when Description is empty. // - // Most commands only need Summary; reach for Description when one line is not enough. + // Most commands only need Summary. Use Description when one line is not enough. Summary string - // Description is the longer help text shown at the top of this command's own help. Use it for - // paragraphs that explain behavior, defaults, or important context. + // Description is the longer help text shown at the top of this command's own help. Use it to + // explain behavior, defaults, or anything else worth knowing. // - // When Summary is empty, the first line of Description is used in command listings. + // When Summary is empty, the first line of Description is used in command lists instead. Description string - // Help replaces the built-in help text for this command. Leave it nil to use the generated help. + // Help replaces the built-in help text for this command. Leave it nil to use the default help. // - // The function receives the resolved command and returns the full help string printed for - // --help and for [UsageErrorf] errors. Each command in a tree may set its own Help; only the - // selected command's Help is invoked. + // The function is given the command and returns the full help string. Help is used for --help + // and for [UsageErrorf] errors. Each command can set its own Help, and only the selected + // command's Help is called. Help func(*Command) string - // Flags is the standard library [flag.FlagSet] that defines this command's flags. Construct it - // with [flag.NewFlagSet], or use [FlagsFunc] for a compact inline form. + // Flags holds this command's flags as a standard library [flag.FlagSet]. Build it with + // [flag.NewFlagSet], or use [FlagsFunc] to define flags inline. // - // Flags defined here are inherited by SubCommands unless marked Local in FlagConfigs. Read - // parsed values inside Exec with [GetFlag]. + // Subcommands inherit these flags unless they are marked Local in FlagConfigs. Read flag values + // inside Exec with [GetFlag]. Flags *flag.FlagSet - // FlagConfigs layers cli-specific behavior on top of flags already defined in Flags: short - // aliases ([FlagConfig.Short]), required flags ([FlagConfig.Required]), and opting out of - // inheritance ([FlagConfig.Local]). + // FlagConfigs adds extra behavior to flags already defined in Flags: short aliases + // ([FlagConfig.Short]), required flags ([FlagConfig.Required]), and flags that should not be + // inherited ([FlagConfig.Local]). // - // Each entry must reference a flag registered in Flags by Name; otherwise [Parse] returns an - // error. + // Each entry must point to a flag defined in Flags. Otherwise [Parse] returns an error. FlagConfigs []FlagConfig - // SubCommands are commands selected after this command's Name. + // SubCommands are the commands users can pick after this command's Name. // - // When a command has SubCommands, the first non-flag argument must match one of them; an - // unknown name produces an "unknown command" error with suggestions. Commands without - // SubCommands receive any non-flag arguments as positionals in [State.Args]. + // When a command has SubCommands, the first non-flag argument must match one of them. An + // unknown name returns an "unknown command" error with suggestions. Commands without + // SubCommands pass any non-flag arguments through to [State.Args]. SubCommands []*Command - // Exec is the function invoked once parsing selects this command. It receives the parsed - // [State], which carries positional arguments, I/O streams, and access to flag values via - // [GetFlag]. + // Exec is the function that runs when this command is picked. It is given a [State] with the + // positional arguments, I/O streams, and access to flag values via [GetFlag]. // - // Return [UsageErrorf] for bad arguments or flag combinations so [Run] prints command help to - // stderr; return a normal error for operational failures, which [Run] returns without printing + // Return [UsageErrorf] for bad arguments or flag combinations so [Run] prints the command's + // help to stderr. Return a normal error for everything else; [Run] returns it without printing // help. Exec func(ctx context.Context, s *State) error state *State } -// Path returns the chain of resolved commands from the root down to this command, inclusive. It is -// available after [Parse] succeeds and is most often called from inside Exec as s.Cmd.Path() to -// build command-aware error messages or breadcrumbs. +// Path returns the list of commands from the root down to this command. It is usually called inside +// Exec as s.Cmd.Path() to build error messages that include the full command path. // -// Path returns nil if called before parsing. +// Path returns nil if called before [Parse]. func (c *Command) Path() []*Command { if c.state == nil { return nil @@ -100,28 +97,28 @@ func (c *Command) terminal() *Command { return c.state.path[len(c.state.path)-1] } -// FlagConfig attaches cli-specific behavior to a single flag already defined in a [Command.Flags] -// FlagSet. It is the entry type used in [Command.FlagConfigs]. +// FlagConfig adds extra behavior to a single flag already defined in a [Command.Flags] FlagSet. It +// is used as an entry in [Command.FlagConfigs]. type FlagConfig struct { // Name is the long flag name as registered in the command's FlagSet. Name string - // Short is a one-letter alias for the flag, such as "v" so users can type -v in place of - // --verbose. Both forms are listed in help output. + // Short is a one-letter alias for the flag, such as "v" so users can type -v instead of + // --verbose. Both forms are shown in help. Short string - // Required, when true, makes [Parse] fail unless the user provides the flag explicitly. The - // flag's default value alone is not enough. + // Required, when true, makes [Parse] fail unless the user sets the flag. The default value is + // not enough; the user must pass it. Required bool - // Local, when true, keeps the flag on this command and prevents it from being inherited by - // subcommands. By default, parent flags are inherited. + // Local, when true, keeps the flag on this command only and stops it from being inherited by + // subcommands. Parent flags are inherited by default. Local bool } -// FlagsFunc constructs a [flag.FlagSet] inline for a [Command.Flags] field, sparing callers from -// declaring and assigning the FlagSet separately. The returned FlagSet uses [flag.ContinueOnError] -// so parsing errors are returned rather than fatal. +// FlagsFunc creates a [flag.FlagSet] inline so you don't have to make one and assign it separately. +// The returned FlagSet uses [flag.ContinueOnError], so parsing errors are returned instead of being +// fatal. // // Flags: cli.FlagsFunc(func(f *flag.FlagSet) { // f.Bool("verbose", false, "enable verbose output") diff --git a/doc.go b/doc.go index fa77ba2..2ac146a 100644 --- a/doc.go +++ b/doc.go @@ -33,6 +33,6 @@ // os.Exit(1) // } // -// The API stays deliberately small. cli builds on the standard library's flag package instead of -// replacing it, so most of what you write is your program rather than the scaffolding around it. +// The API is small on purpose. cli uses the standard library flag package instead of replacing it, +// so most of what you write is your program. package cli diff --git a/error.go b/error.go index c42c9de..bb601d3 100644 --- a/error.go +++ b/error.go @@ -6,17 +6,15 @@ type usageError struct { err error } -// UsageErrorf builds an error that signals invalid command-line usage. Return it from -// [Command.Exec] when the command was selected successfully but the arguments or flag combination -// are wrong: +// UsageErrorf returns an error that means the command was used incorrectly. Return it from +// [Command.Exec] when the command itself was right but the arguments or flag combination are wrong: // // if len(s.Args) == 0 { // return cli.UsageErrorf("must supply a name") // } // -// When [Run] sees a UsageErrorf error, it prints the resolved command's help to stderr and returns -// the underlying formatted error to the caller. Return a normal error if you do not want help -// printed. +// When [Run] sees a UsageErrorf error, it prints the command's help to stderr and returns the error +// message you passed in. Return a normal error if you do not want help printed. func UsageErrorf(format string, args ...any) error { return &usageError{err: fmt.Errorf(format, args...)} } diff --git a/parse.go b/parse.go index b8adb86..720bb5a 100644 --- a/parse.go +++ b/parse.go @@ -13,12 +13,12 @@ import ( "github.com/pressly/cli/xflag" ) -// Parse resolves the selected command and parses its flags from args, but does not run Exec. Pair -// it with [Run] when you need to do work between parsing and execution; for the common case, call +// Parse picks the right command and parses its flags from args, but does not run Exec. Use Parse +// with [Run] when you need to do work between parsing and running. For the common case, call // [ParseAndRun]. // -// Parse returns [flag.ErrHelp] when the user passes -h or --help. The caller is responsible for -// printing help in that case; [ParseAndRun] does this automatically. +// Parse returns [flag.ErrHelp] when the user passes -h or --help. You have to print the help +// yourself when this happens. [ParseAndRun] does it for you. func Parse(root *Command, args []string) error { if root == nil { return fmt.Errorf("failed to parse: root command is nil") diff --git a/run.go b/run.go index 247f946..332d89f 100644 --- a/run.go +++ b/run.go @@ -14,24 +14,23 @@ import ( "sync" ) -// RunOptions overrides the standard streams used by [Run] and [ParseAndRun]. Pass nil for normal +// RunOptions replaces the standard streams used by [Run] and [ParseAndRun]. Pass nil for normal // programs to use os.Stdin, os.Stdout, and os.Stderr. // -// Provide RunOptions in tests, or in embedded applications that need to capture output or supply -// custom input. +// Use RunOptions in tests, or anywhere you need to capture output or supply your own input. type RunOptions struct { - // Stdin, Stdout, and Stderr replace os.Stdin, os.Stdout, and os.Stderr when set. Any field - // left nil falls back to its os equivalent. + // Stdin, Stdout, and Stderr replace os.Stdin, os.Stdout, and os.Stderr when set. A nil field + // falls back to its os equivalent. Stdin io.Reader Stdout, Stderr io.Writer } -// Run executes the command selected by a prior call to [Parse]. Use Run only with the split -// [Parse]/Run flow; for the common case, call [ParseAndRun]. +// Run runs the command picked by a previous call to [Parse]. Use Run only when you call [Parse] +// separately. For the common case, use [ParseAndRun]. // // If Exec returns an error created by [UsageErrorf], Run prints the command's help to stderr and -// returns the underlying error. Other errors are returned unchanged. A nil ctx defaults to -// [context.Background]. +// returns the error you passed to UsageErrorf. Other errors are returned as-is. A nil ctx defaults +// to [context.Background]. func Run(ctx context.Context, root *Command, options *RunOptions) error { if ctx == nil { ctx = context.Background() @@ -54,17 +53,17 @@ func Run(ctx context.Context, root *Command, options *RunOptions) error { return run(ctx, cmd, root.state) } -// ParseAndRun parses args, resolves the selected command, and runs its Exec. It is the normal -// entry point for CLI programs: +// ParseAndRun parses args, picks the right command, and runs its Exec. This is the normal way to +// start a CLI program: // // if err := cli.ParseAndRun(ctx, root, os.Args[1:], nil); err != nil { // fmt.Fprintf(os.Stderr, "error: %v\n", err) // os.Exit(1) // } // -// When the user passes -h or --help, ParseAndRun prints the resolved command's help to stdout and +// When the user passes -h or --help, ParseAndRun prints the picked command's help to stdout and // returns nil. Use [Parse] and [Run] separately when you need to do work between parsing and -// execution, such as initializing resources from parsed flags. +// running, such as setting up resources based on parsed flags. func ParseAndRun(ctx context.Context, root *Command, args []string, options *RunOptions) error { if err := Parse(root, args); err != nil { if errors.Is(err, flag.ErrHelp) { diff --git a/state.go b/state.go index f5783ee..8bc2095 100644 --- a/state.go +++ b/state.go @@ -7,21 +7,20 @@ import ( "io" ) -// State carries the parsed invocation context into [Command.Exec]. Use Args for positional -// arguments, Stdin/Stdout/Stderr for I/O, Cmd for the selected command, and [GetFlag] to read flag -// values. +// State is the value passed to [Command.Exec]. Use Args for positional arguments, +// Stdin/Stdout/Stderr for I/O, Cmd for the picked command, and [GetFlag] to read flag values. type State struct { - // Args holds the positional arguments left after command resolution and flag parsing. Anything - // after a "--" delimiter is included verbatim, even if it would otherwise look like a flag. + // Args holds the positional arguments left after the command name and flags are parsed. + // Anything after "--" is included as-is, even if it looks like a flag. Args []string - // Stdin, Stdout, and Stderr are the streams command code should use in place of the package- - // level os.Stdin, os.Stdout, and os.Stderr. Tests can swap them via [RunOptions]. + // Stdin, Stdout, and Stderr are the streams to use in your command code instead of os.Stdin, + // os.Stdout, and os.Stderr. Tests can swap them via [RunOptions]. Stdin io.Reader Stdout, Stderr io.Writer - // Cmd is the resolved (terminal) command. Call Cmd.Path() for the chain from the root down, - // useful for command-aware error messages or breadcrumbs. + // Cmd is the command that was picked. Call Cmd.Path() to get the full list of commands from the + // root down, useful for error messages that include the command path. Cmd *Command // path is the command hierarchy from the root command to the current command. The root command @@ -29,13 +28,12 @@ type State struct { path []*Command } -// GetFlag returns the parsed value of a flag, type-checked against T. Call it from inside -// [Command.Exec] with the same Go type that was used when the flag was defined. +// 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. // -// Lookup walks from the selected command up through inherited parent flags, so a flag defined on -// the root command is reachable from any subcommand. An unknown flag name, or one read with the -// wrong type, is treated as a programming error: GetFlag panics, and [Run] recovers and returns -// the error to the caller. +// 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. // // verbose := cli.GetFlag[bool](s, "verbose") // count := cli.GetFlag[int](s, "count") From 1d35d67850608273f2489a424c45171be2c0c08e Mon Sep 17 00:00:00 2001 From: Mike Fridman Date: Sun, 3 May 2026 21:19:58 +0200 Subject: [PATCH 8/8] docs: clarify command API documentation --- README.md | 4 ++-- command.go | 36 ++++++++++++++++++------------------ parse.go | 6 +++--- run.go | 10 +++++----- state.go | 3 +-- 5 files changed, 29 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 4caf448..2c1b41b 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,8 @@ 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`. +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/). diff --git a/command.go b/command.go index 089b8f8..73b0927 100644 --- a/command.go +++ b/command.go @@ -12,8 +12,7 @@ import ( // Command describes a single command in the CLI. // // Pass a Command to [ParseAndRun] (or [Parse] and [Run]) to run a program. To add a subcommand, -// list it in another command's [Command.SubCommands]. Most commands set Name, a one-line Summary, -// Flags, and Exec. Add Description for longer help and SubCommands for nested commands. +// list it in another command's [Command.SubCommands]. type Command struct { // Name is the word users type to pick this command. It must start with a letter and can contain // letters, digits, dashes, or underscores. For the root command it is also the program name @@ -28,15 +27,16 @@ type Command struct { Usage string // Summary is the one-line description shown next to this command in its parent's command list. - // It is also shown at the top of this command's own help when Description is empty. + // It is also shown at the top of this command's own help when [Command.Description] is empty. // - // Most commands only need Summary. Use Description when one line is not enough. + // Most commands only need Summary. Use [Command.Description] when one line is not enough. Summary string // Description is the longer help text shown at the top of this command's own help. Use it to // explain behavior, defaults, or anything else worth knowing. // - // When Summary is empty, the first line of Description is used in command lists instead. + // When [Command.Summary] is empty, the first line of Description is used in command lists + // instead. Description string // Help replaces the built-in help text for this command. Leave it nil to use the default help. @@ -49,26 +49,26 @@ type Command struct { // Flags holds this command's flags as a standard library [flag.FlagSet]. Build it with // [flag.NewFlagSet], or use [FlagsFunc] to define flags inline. // - // Subcommands inherit these flags unless they are marked Local in FlagConfigs. Read flag values - // inside Exec with [GetFlag]. + // Subcommands inherit these flags unless they are marked [FlagConfig.Local] in + // [Command.FlagConfigs]. Read flag values inside [Command.Exec] with [GetFlag]. Flags *flag.FlagSet - // FlagConfigs adds extra behavior to flags already defined in Flags: short aliases - // ([FlagConfig.Short]), required flags ([FlagConfig.Required]), and flags that should not be - // inherited ([FlagConfig.Local]). + // FlagConfigs adds extra behavior to flags already defined in [Command.Flags]. See [FlagConfig] + // for the available options. // - // Each entry must point to a flag defined in Flags. Otherwise [Parse] returns an error. + // Each entry must point to a flag defined in [Command.Flags]. Otherwise [Parse] returns an + // error. FlagConfigs []FlagConfig - // SubCommands are the commands users can pick after this command's Name. + // SubCommands are the commands users can pick after this command's name. // // When a command has SubCommands, the first non-flag argument must match one of them. An // unknown name returns an "unknown command" error with suggestions. Commands without // SubCommands pass any non-flag arguments through to [State.Args]. SubCommands []*Command - // Exec is the function that runs when this command is picked. It is given a [State] with the - // positional arguments, I/O streams, and access to flag values via [GetFlag]. + // Exec is the function that runs when this command is picked. It is given a [State] holding the + // parsed inputs the command needs. // // Return [UsageErrorf] for bad arguments or flag combinations so [Run] prints the command's // help to stderr. Return a normal error for everything else; [Run] returns it without printing @@ -79,7 +79,7 @@ type Command struct { } // Path returns the list of commands from the root down to this command. It is usually called inside -// Exec as s.Cmd.Path() to build error messages that include the full command path. +// [Command.Exec] as s.Cmd.Path() to build error messages that include the full command path. // // Path returns nil if called before [Parse]. func (c *Command) Path() []*Command { @@ -97,10 +97,10 @@ func (c *Command) terminal() *Command { return c.state.path[len(c.state.path)-1] } -// FlagConfig adds extra behavior to a single flag already defined in a [Command.Flags] FlagSet. It -// is used as an entry in [Command.FlagConfigs]. +// FlagConfig adds extra behavior to a single flag already defined in [Command.Flags]. It is used as +// an entry in [Command.FlagConfigs]. type FlagConfig struct { - // Name is the long flag name as registered in the command's FlagSet. + // Name is the long flag name as registered in the command's [flag.FlagSet]. Name string // Short is a one-letter alias for the flag, such as "v" so users can type -v instead of diff --git a/parse.go b/parse.go index 720bb5a..58f0e74 100644 --- a/parse.go +++ b/parse.go @@ -13,9 +13,9 @@ import ( "github.com/pressly/cli/xflag" ) -// Parse picks the right command and parses its flags from args, but does not run Exec. Use Parse -// with [Run] when you need to do work between parsing and running. For the common case, call -// [ParseAndRun]. +// Parse picks the right command and parses its flags from args, but does not run [Command.Exec]. +// Use Parse with [Run] when you need to do work between parsing and running. For the common case, +// call [ParseAndRun]. // // Parse returns [flag.ErrHelp] when the user passes -h or --help. You have to print the help // yourself when this happens. [ParseAndRun] does it for you. diff --git a/run.go b/run.go index 332d89f..bbd1a99 100644 --- a/run.go +++ b/run.go @@ -28,9 +28,9 @@ type RunOptions struct { // Run runs the command picked by a previous call to [Parse]. Use Run only when you call [Parse] // separately. For the common case, use [ParseAndRun]. // -// If Exec returns an error created by [UsageErrorf], Run prints the command's help to stderr and -// returns the error you passed to UsageErrorf. Other errors are returned as-is. A nil ctx defaults -// to [context.Background]. +// If [Command.Exec] returns an error created by [UsageErrorf], Run prints the command's help to +// stderr and returns the error you passed to [UsageErrorf]. Other errors are returned as-is. A nil +// ctx defaults to [context.Background]. func Run(ctx context.Context, root *Command, options *RunOptions) error { if ctx == nil { ctx = context.Background() @@ -53,8 +53,8 @@ func Run(ctx context.Context, root *Command, options *RunOptions) error { return run(ctx, cmd, root.state) } -// ParseAndRun parses args, picks the right command, and runs its Exec. This is the normal way to -// start a CLI program: +// ParseAndRun parses args, picks the right command, and runs its [Command.Exec]. This is the normal +// way to start a CLI program: // // if err := cli.ParseAndRun(ctx, root, os.Args[1:], nil); err != nil { // fmt.Fprintf(os.Stderr, "error: %v\n", err) diff --git a/state.go b/state.go index 8bc2095..fa8638b 100644 --- a/state.go +++ b/state.go @@ -7,8 +7,7 @@ import ( "io" ) -// State is the value passed to [Command.Exec]. Use Args for positional arguments, -// Stdin/Stdout/Stderr for I/O, Cmd for the picked command, and [GetFlag] to read flag values. +// 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. // Anything after "--" is included as-is, even if it looks like a flag.