Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 27 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ 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
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},
},
Expand Down Expand Up @@ -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

Expand Down
77 changes: 41 additions & 36 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
29 changes: 29 additions & 0 deletions error.go
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 19 additions & 0 deletions error_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
5 changes: 2 additions & 3 deletions examples/cmd/echo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package main

import (
"context"
"errors"
"flag"
"fmt"
"os"
Expand All @@ -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
Expand Down
19 changes: 10 additions & 9 deletions examples/cmd/task/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"os"
Expand All @@ -12,6 +11,7 @@ import (
"time"

"github.com/pressly/cli"
"github.com/pressly/cli/usage"
)

func main() {
Expand All @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading