Skip to content
Merged
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
74 changes: 74 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,19 +132,93 @@ 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{}),
),

// 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 <command> --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
Expand Down
45 changes: 32 additions & 13 deletions cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ type Node interface {
type commandNode struct {
name string
desc string
long string
handler Runnable
children []Node
opts cmdOptions
Expand All @@ -93,30 +94,37 @@ func (c *commandNode) isGroup() bool { return false }
type groupNode struct {
name string
desc string
long string
children []Node
}

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.
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) {
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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 == "--" {
Expand Down Expand Up @@ -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
}

Expand Down
7 changes: 6 additions & 1 deletion cli/example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
),
Expand Down
23 changes: 21 additions & 2 deletions cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> [flags]\n\n", appName, path)

Expand All @@ -67,13 +82,17 @@ func printGroupHelp(w io.Writer, appName, path, desc string, children []Node) {
fmt.Fprintf(w, "\nUse \"%s %s <command> --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)
Expand Down
27 changes: 27 additions & 0 deletions cli/registry.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading