diff --git a/cli/README.md b/cli/README.md index f9d6c09..c6faed3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -132,6 +132,14 @@ app.AddCommand( // Command with handler cli.Command("serve", "Start the server", &ServeCmd{}), + // Command with long-form help text shown by --help + cli.Command("up", "Launch a forge pod", &UpCmd{}, + cli.WithLong(`Launch a new forge pod for the given branch. + +If the branch doesn't exist, create it first. The pod runs Claude Code +in headless mode with the given prompt.`), + ), + // Command with handler AND subcommands cli.Command("admin", "Admin tools", &AdminCmd{}, cli.Command("migrate", "Run migrations", &MigrateCmd{}), @@ -139,12 +147,78 @@ app.AddCommand( // Group (no handler, prints help when invoked directly) cli.Group("db", "Database commands", + cli.WithLong("Manage database migrations and connections."), cli.Command("migrate", "Run migrations", &MigrateCmd{}), cli.Command("seed", "Seed data", &SeedCmd{}), ), ) ``` +The short description is always used in parent command listings. The long text +appears indented below the title line when the user runs `myapp --help`: + +``` +myapp up - Launch a forge pod + + Launch a new forge pod for the given branch. + + If the branch doesn't exist, create it first. The pod runs Claude Code + in headless mode with the given prompt. + +Usage: + myapp up [flags] + +Flags: + ... +``` + +### Command Registry + +Allow commands to register themselves from outside the main package, enabling +build-tag-based inclusion of optional commands: + +```go +// cmd/myapp/debug/debug.go +//go:build debug + +package debug + +import ( + "context" + "github.com/runreveal/lib/cli" +) + +type DebugCmd struct{} + +func (d *DebugCmd) Run(ctx context.Context, args []string) error { ... } + +func init() { + cli.Register(cli.Command("debug", "Debug tools", &DebugCmd{})) +} +``` + +```go +// cmd/myapp/main.go +package main + +import ( + "github.com/runreveal/lib/cli" + _ "myapp/cmd/myapp/debug" // only included with -tags debug +) + +func main() { + app := cli.New("myapp", "My app") + app.AddCommand(cli.Registered()...) // commands from init() calls + app.AddCommand( // explicit commands + cli.Command("serve", "...", &ServeCmd{}), + ) + os.Exit(app.Run(context.Background(), os.Args[1:])) +} +``` + +`Register` is safe to call from `init()` and accumulates across multiple calls. +`Registered` returns a copy of the registered nodes. + ### Middleware ```go diff --git a/cli/cli.go b/cli/cli.go index b54c2ca..bf693d1 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -81,6 +81,7 @@ type Node interface { type commandNode struct { name string desc string + long string handler Runnable children []Node opts cmdOptions @@ -93,6 +94,7 @@ func (c *commandNode) isGroup() bool { return false } type groupNode struct { name string desc string + long string children []Node } @@ -100,11 +102,12 @@ func (g *groupNode) nodeName() string { return g.name } func (g *groupNode) nodeDesc() string { return g.desc } func (g *groupNode) isGroup() bool { return true } -// CmdOption configures a Command node. +// CmdOption configures a Command or Group node. type CmdOption func(*cmdOptions) type cmdOptions struct { argsFunc ArgsFunc + long string } // WithArgs sets an args validation function on a command. @@ -112,11 +115,16 @@ func WithArgs(f ArgsFunc) CmdOption { return func(o *cmdOptions) { o.argsFunc = f } } -// Command creates a command node. Each element of opts may be a Node (child -// subcommand) or a CmdOption (behavioural option); they are distinguished by -// type at runtime. -func Command(name, desc string, handler Runnable, opts ...any) Node { - o := cmdOptions{} +// WithLong sets long-form help text shown when the command is invoked with +// --help. The short description is still used in parent command listings. +func WithLong(text string) CmdOption { + return func(o *cmdOptions) { o.long = text } +} + +// parseNodeOpts processes the variadic opts accepted by Command and Group, +// separating Node children from CmdOption configurers. +func parseNodeOpts(kind, name string, opts []any) ([]Node, cmdOptions) { + var o cmdOptions var children []Node for _, opt := range opts { switch v := opt.(type) { @@ -125,15 +133,26 @@ func Command(name, desc string, handler Runnable, opts ...any) Node { case CmdOption: v(&o) default: - panic(fmt.Sprintf("cli.Command %q: unsupported option type %T", name, v)) + panic(fmt.Sprintf("cli.%s %q: unsupported option type %T", kind, name, v)) } } - return &commandNode{name: name, desc: desc, handler: handler, children: children, opts: o} + return children, o +} + +// Command creates a command node. Each element of opts may be a Node (child +// subcommand) or a CmdOption (behavioural option); they are distinguished by +// type at runtime. +func Command(name, desc string, handler Runnable, opts ...any) Node { + children, o := parseNodeOpts("Command", name, opts) + return &commandNode{name: name, desc: desc, long: o.long, handler: handler, children: children, opts: o} } // Group creates a group node that only prints help when invoked directly. -func Group(name, desc string, children ...Node) Node { - return &groupNode{name: name, desc: desc, children: children} +// Each element of opts may be a Node (child subcommand) or a CmdOption +// (e.g. WithLong); they are distinguished by type at runtime. +func Group(name, desc string, opts ...any) Node { + children, o := parseNodeOpts("Group", name, opts) + return &groupNode{name: name, desc: desc, long: o.long, children: children} } // AppOption configures an App. @@ -319,7 +338,7 @@ func (a *App) executeNode(ctx context.Context, node Node, args []string, path st switch n := node.(type) { case *groupNode: // Groups print help when invoked directly (no matching subcommand) - printGroupHelp(a.output, a.name, path, n.desc, n.children) + printGroupHelp(a.output, a.name, path, n.desc, n.long, n.children) return 0, nil case *commandNode: @@ -334,7 +353,7 @@ func (a *App) executeCommand(ctx context.Context, node *commandNode, args []stri // Check for --help before doing anything else for _, arg := range args { if arg == "--help" || arg == "-h" { - printCommandHelp(a.output, a.name, path, node.desc, handler, node.children, a.globals) + printCommandHelp(a.output, a.name, path, node.desc, node.long, handler, node.children, a.globals) return 0, nil } if arg == "--" { @@ -371,7 +390,7 @@ func (a *App) executeCommand(ctx context.Context, node *commandNode, args []stri posArgs, err := fs.Parse(args) if err != nil { fmt.Fprintf(a.output, "error: %s\n\n", err) - printCommandHelp(a.output, a.name, path, node.desc, handler, node.children, a.globals) + printCommandHelp(a.output, a.name, path, node.desc, node.long, handler, node.children, a.globals) return 1, nil } diff --git a/cli/example/main.go b/cli/example/main.go index 6dcbca7..4b3bd00 100644 --- a/cli/example/main.go +++ b/cli/example/main.go @@ -245,7 +245,12 @@ func main() { ) app.AddCommand( - cli.Command("serve", "Start the HTTP server", &ServeCmd{}), + cli.Command("serve", "Start the HTTP server", &ServeCmd{}, + cli.WithLong(`Start the HTTP server using the configured sources and cache. + +The server address is taken from --addr, falling back to the "server.addr" +field in the config file. Sources must be configured in the config file.`), + ), cli.Command("list-sources", "List configured sources", &ListSourcesCmd{}, cli.WithArgs(cli.NoArgs), ), diff --git a/cli/help.go b/cli/help.go index fe6d1b3..c646c79 100644 --- a/cli/help.go +++ b/cli/help.go @@ -43,13 +43,28 @@ func printAppHelp(w io.Writer, appName, desc string, children []Node, version, d } } -func printGroupHelp(w io.Writer, appName, path, desc string, children []Node) { +func printLongText(w io.Writer, long string) { + for _, line := range strings.Split(long, "\n") { + if line == "" { + fmt.Fprintln(w) + } else { + fmt.Fprintf(w, " %s\n", line) + } + } + fmt.Fprintln(w) +} + +func printGroupHelp(w io.Writer, appName, path, desc, long string, children []Node) { if desc != "" { fmt.Fprintf(w, "%s %s - %s\n\n", appName, path, desc) } else { fmt.Fprintf(w, "%s %s\n\n", appName, path) } + if long != "" { + printLongText(w, long) + } + fmt.Fprintf(w, "Usage:\n") fmt.Fprintf(w, " %s %s [flags]\n\n", appName, path) @@ -67,13 +82,17 @@ func printGroupHelp(w io.Writer, appName, path, desc string, children []Node) { fmt.Fprintf(w, "\nUse \"%s %s --help\" for more information.\n", appName, path) } -func printCommandHelp(w io.Writer, appName, path, desc string, handler Runnable, children []Node, globals any) { +func printCommandHelp(w io.Writer, appName, path, desc, long string, handler Runnable, children []Node, globals any) { if desc != "" { fmt.Fprintf(w, "%s %s - %s\n\n", appName, path, desc) } else { fmt.Fprintf(w, "%s %s\n\n", appName, path) } + if long != "" { + printLongText(w, long) + } + fmt.Fprintf(w, "Usage:\n") if len(children) > 0 { fmt.Fprintf(w, " %s %s [command] [flags]\n\n", appName, path) diff --git a/cli/registry.go b/cli/registry.go new file mode 100644 index 0000000..d387040 --- /dev/null +++ b/cli/registry.go @@ -0,0 +1,27 @@ +package cli + +import "sync" + +var ( + registryMu sync.Mutex + registry []Node +) + +// Register appends nodes to the package-level command registry. +// Intended for use in init() functions to enable build-tag-based +// inclusion of optional commands. +func Register(nodes ...Node) { + registryMu.Lock() + defer registryMu.Unlock() + registry = append(registry, nodes...) +} + +// Registered returns a copy of all nodes added via Register. +// Call this when building the App to include registered commands. +func Registered() []Node { + registryMu.Lock() + defer registryMu.Unlock() + out := make([]Node, len(registry)) + copy(out, registry) + return out +} diff --git a/cli/registry_test.go b/cli/registry_test.go new file mode 100644 index 0000000..0d7be74 --- /dev/null +++ b/cli/registry_test.go @@ -0,0 +1,137 @@ +package cli_test + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/runreveal/lib/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- WithLong tests --- + +func TestWithLong_AppearsInCommandHelp(t *testing.T) { + var buf bytes.Buffer + app := cli.New("myapp", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("up", "Launch a pod", &noopCmd{}, + cli.WithLong("Launch a new pod for the given branch.\n\nUse --issue to seed from a GitHub issue."), + )) + + code := app.Run(context.Background(), []string{"up", "--help"}) + assert.Equal(t, 0, code) + out := buf.String() + assert.Contains(t, out, "myapp up - Launch a pod") + assert.Contains(t, out, "Launch a new pod for the given branch.") + assert.Contains(t, out, "Use --issue to seed from a GitHub issue.") +} + +func TestWithLong_LongTextIsIndented(t *testing.T) { + var buf bytes.Buffer + app := cli.New("myapp", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("up", "Launch a pod", &noopCmd{}, + cli.WithLong("Detailed description here."), + )) + + app.Run(context.Background(), []string{"up", "--help"}) + out := buf.String() + // Long text should be indented with 2 spaces. + assert.Contains(t, out, " Detailed description here.") +} + +func TestWithLong_LongTextAppearsBeforeUsage(t *testing.T) { + var buf bytes.Buffer + app := cli.New("myapp", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("up", "Launch a pod", &noopCmd{}, + cli.WithLong("Long text here."), + )) + + app.Run(context.Background(), []string{"up", "--help"}) + out := buf.String() + longIdx := strings.Index(out, "Long text here.") + usageIdx := strings.Index(out, "Usage:") + require.True(t, longIdx >= 0, "long text not found") + require.True(t, usageIdx >= 0, "Usage: not found") + assert.Less(t, longIdx, usageIdx, "long text should appear before Usage:") +} + +func TestWithLong_ShortDescStillInParentListing(t *testing.T) { + var buf bytes.Buffer + app := cli.New("myapp", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("up", "Launch a pod", &noopCmd{}, + cli.WithLong("This is a very long description."), + )) + + // App-level help shows short desc, not long text. + app.Run(context.Background(), []string{"--help"}) + out := buf.String() + assert.Contains(t, out, "Launch a pod") + assert.NotContains(t, out, "This is a very long description.") +} + +func TestWithLong_NoLongTextBackwardCompat(t *testing.T) { + var buf bytes.Buffer + app := cli.New("myapp", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("serve", "Start the server", &noopCmd{})) + + code := app.Run(context.Background(), []string{"serve", "--help"}) + assert.Equal(t, 0, code) + out := buf.String() + assert.Contains(t, out, "myapp serve - Start the server") + assert.Contains(t, out, "Usage:") +} + +func TestWithLong_GroupLongText(t *testing.T) { + var buf bytes.Buffer + app := cli.New("myapp", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Group("db", "Database commands", + cli.WithLong("Manage database migrations and connections."), + cli.Command("migrate", "Run migrations", &noopCmd{}), + )) + + code := app.Run(context.Background(), []string{"db"}) + assert.Equal(t, 0, code) + out := buf.String() + assert.Contains(t, out, "Database commands") + assert.Contains(t, out, "Manage database migrations and connections.") +} + +// --- Registry tests --- + +func TestRegistry_RegisterAndRetrieve(t *testing.T) { + before := len(cli.Registered()) + cli.Register(cli.Command("reg-test-cmd", "a registered command", &noopCmd{})) + after := cli.Registered() + assert.Equal(t, before+1, len(after)) +} + +func TestRegistry_MultipleRegisterCalls(t *testing.T) { + before := len(cli.Registered()) + cli.Register(cli.Command("reg-multi-1", "cmd 1", &noopCmd{})) + cli.Register(cli.Command("reg-multi-2", "cmd 2", &noopCmd{})) + after := cli.Registered() + assert.Equal(t, before+2, len(after)) +} + +func TestRegistry_ReturnsCopy(t *testing.T) { + cli.Register(cli.Command("reg-copy-test", "cmd", &noopCmd{})) + a := cli.Registered() + b := cli.Registered() + // Mutating one slice should not affect the other. + a[0] = nil + assert.NotNil(t, b[0]) +} + +func TestRegistry_IntegratesWithApp(t *testing.T) { + cli.Register(cli.Command("reg-app-cmd", "registered app command", &noopCmd{})) + + var buf bytes.Buffer + app := cli.New("myapp", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Registered()...) + + code := app.Run(context.Background(), []string{"--help"}) + assert.Equal(t, 0, code) + assert.Contains(t, buf.String(), "reg-app-cmd") +}