Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e29955d
feat: add cli package — struct-driven CLI framework
abraithwaite Apr 5, 2026
4dbe5f0
fix(cli): address PR #22 review feedback
abraithwaite Apr 5, 2026
68b7204
ci: update Go to 1.25, fix line length formatting
abraithwaite Apr 6, 2026
4b947b8
ci: remove obsolete GOEXPERIMENT=nocoverageredesign
abraithwaite Apr 6, 2026
d7a90ba
ci: downgrade to Go 1.24 for golangci-lint compatibility
abraithwaite Apr 6, 2026
65f1f24
ci: upgrade golangci-lint to v1.64.8 for Go 1.24 compat
abraithwaite Apr 6, 2026
77e6724
cli: add WithGlobals, ConfigAt, and GlobalsFromContext
abraithwaite Apr 6, 2026
c807f97
cli: add Configure/Validate/Run lifecycle on globals and handlers
abraithwaite Apr 6, 2026
5207080
cli: simplify flags, fix redundant ExitError unwrap, consistent error…
abraithwaite Apr 6, 2026
cf598f0
cli: add shell completion for bash, zsh, and fish
abraithwaite Apr 6, 2026
a129e06
cli: update example with await integration, fix errcheck lint
abraithwaite Apr 6, 2026
d5bf6a9
cli: add default-config command, embedded config in example
abraithwaite Apr 6, 2026
bdca9fa
cli: rename default-config to defcon, make overridable
abraithwaite Apr 6, 2026
0d7d064
cli: fix struct field alignment
abraithwaite Apr 6, 2026
dc38482
cli: add README
abraithwaite Apr 6, 2026
85b92c8
cli: fix confusing DB naming in example, clarify ConfigAt semantics
abraithwaite Apr 6, 2026
741eb9e
cli: remove ConfigAt, add loader integration example
abraithwaite Apr 6, 2026
f06052c
cli: add tests for coverage, fix errcheck lint, fix Makefile cd issue
abraithwaite Apr 6, 2026
6dab80c
cli: add HelpExtra interface; loader: add List and Describe
abraithwaite Apr 6, 2026
60209db
cli: flatten example config onto Globals, remove config:"." pattern
abraithwaite Apr 6, 2026
bdd5ab4
cli: use loader.Helper for self-describing config types in help
abraithwaite Apr 6, 2026
6cadb0a
cli: fix completion output going to stderr instead of stdout
abraithwaite Apr 6, 2026
942ad5f
cli: show defcon hint in help output, consolidate defconCmd logic
abraithwaite Apr 6, 2026
36575bc
cli: fix review findings — panic on bad Command opts, defcon arg hand…
abraithwaite Apr 6, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: "1.21"
go-version: "1.24"
# HACK: actions doesn't support multiple modules in one repo for caching
cache: false

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.coverprofile
cli/example/example
14 changes: 6 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,15 @@ GOTAGS = testing

GO ?= $(shell which go)

export GOEXPERIMENT=nocoverageredesign
# GOEXPERIMENT=nocoverageredesign was removed in Go 1.25

.PHONY: test
test:
@for dir in $(SUBDIRS); do \
cd $$dir && \
(cd $$dir && \
$(GO) test -vet=off -tags='$(GOTAGS)' $(GOTESTFLAGS) -coverpkg="./..." -coverprofile=.coverprofile ./... && \
grep -v 'cmd' < .coverprofile > .covprof && mv .covprof .coverprofile && \
$(GO) tool cover -func=.coverprofile && \
cd .. ; \
$(GO) tool cover -func=.coverprofile) || exit 1; \
done

.PHONY: coverage
Expand All @@ -35,13 +34,12 @@ version:
.PHONY: lint
lint: $(GOPATH)/bin/golangci-lint
@for dir in $(SUBDIRS); do \
cd $$dir && \
golangci-lint run --timeout 5m . && \
cd .. ; \
(cd $$dir && \
golangci-lint run --timeout 5m .) || exit 1; \
done

$(GOPATH)/bin/golangci-lint:
$(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.51.2
$(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8

$(GOPATH)/bin/golines:
$(GO) install github.com/segmentio/golines@latest
Expand Down
240 changes: 240 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
# cli

A struct-driven CLI framework for Go with a Configure-Validate-Run lifecycle.

Designed as a simpler alternative to Cobra+Viper, with native integration with
[`loader`](../loader) (config file loading with HuJSON + env var replacement)
and [`await`](../await) (goroutine lifecycle and graceful shutdown).

## Quick Start

```go
package main

import (
"context"
"fmt"
"os"

"github.com/runreveal/lib/cli"
)

type ServeCmd struct {
Addr string `cli:"addr,a" usage:"listen address" default:":8080"`
}

func (s *ServeCmd) Run(ctx context.Context, args []string) error {
fmt.Printf("serving on %s\n", s.Addr)
return nil
}

func main() {
app := cli.New("myapp", "My application")
app.AddCommand(cli.Command("serve", "Start the server", &ServeCmd{}))
os.Exit(app.Run(context.Background(), os.Args[1:]))
}
```

## Features

### Struct Tags

Command structs use tags to define flags and config bindings:

```go
type ServeCmd struct {
Addr string `cli:"addr,a" usage:"listen address" default:":8080"`
Timeout time.Duration `cli:"timeout,t" usage:"request timeout" default:"30s"`
DB DBConfig `config:"database"` // loaded from config file
}
```

| Tag | Purpose |
|---|---|
| `cli:"name,alias"` | Flag name and optional single-char alias |
| `cli:"-"` | Skip this field |
| `usage:"text"` | Help text |
| `default:"value"` | Default value (parsed to field type) |
| `config:"key"` | JSON path in config file to unmarshal into this field |

Supported types: `string`, `bool`, `int`, `int64`, `uint`, `uint64`, `float64`,
`time.Duration`, `[]string`, pointer variants, and `encoding.TextUnmarshaler`.

### Global Flags

Define shared flags once at the app level instead of embedding in every command:

```go
type Globals struct {
Verbose bool `cli:"verbose,v" usage:"enable verbose output"`
Config string `cli:"config,c" usage:"config file" default:"config.json"`
}

globals := &Globals{}
app := cli.New("myapp", "desc",
cli.WithGlobals(globals),
cli.WithConfigFlag("config"),
)

// Access in any handler:
func (s *ServeCmd) Run(ctx context.Context, args []string) error {
g := cli.GlobalsFromContext[Globals](ctx)
if g.Verbose { ... }
}
```

### Configure-Validate-Run Lifecycle

Globals and handlers can implement optional lifecycle interfaces:

```go
// Configurer is called after config loading to initialize resources.
type Configurer interface {
Configure() error
}

// Validator is called after Configure to check readiness.
type Validator interface {
Validate() error
}
```

The framework also calls `io.Closer` on globals after the command exits.

**Lifecycle order:**
1. Parse flags (globals + handler)
2. Load config file into struct fields
3. Globals: `Configure()` -> `Validate()` -> defer `Close()`
4. Handler: `Configure()` -> `Validate()`
5. Middleware -> `handler.Run(ctx, args)`
6. Globals `Close()`

### Config File Loading

One config file for the whole app. Commands declare which sections they need
using `config:"key"` struct tags:

```go
type ServeCmd struct {
DB DBConfig `config:"database"`
}
```

Config files are processed through `loader.LoadConfig`, which supports HuJSON
(comments, trailing commas) and `$ENV_VAR` replacement in string values.

**Precedence:** explicit CLI flag > config file > default tag > zero value

### Commands and Groups

```go
app.AddCommand(
// Command with handler
cli.Command("serve", "Start the server", &ServeCmd{}),

// 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.Command("migrate", "Run migrations", &MigrateCmd{}),
cli.Command("seed", "Seed data", &SeedCmd{}),
),
)
```

### Middleware

```go
app := cli.New("myapp", "desc",
cli.WithMiddleware(func(ctx context.Context, info cli.CommandInfo, next func(context.Context) error) error {
slog.Info("running", "command", info.Name)
return next(ctx)
}),
)
```

### Args Validation

```go
cli.Command("get", "Get a resource", &GetCmd{}, cli.WithArgs(cli.ExactArgs(1)))
cli.Command("run", "Run a task", &RunCmd{}, cli.WithArgs(cli.NoArgs))
cli.Command("ping", "Ping hosts", &PingCmd{}, cli.WithArgs(cli.MinArgs(1)))
```

### Await Integration

For long-running services, use `await` in your handler's `Run` method:

```go
func (s *ServeCmd) Run(ctx context.Context, args []string) error {
server := &http.Server{Addr: s.Addr, Handler: mux}

w := await.New(await.WithSignals)
w.AddNamed(await.ListenAndServe(server), "http")
return w.Run(ctx)
}
```

For multiple services under one process:

```go
func (d *DaemonCmd) Run(ctx context.Context, args []string) error {
w := await.New(await.WithSignals, await.WithStopTimeout(15*time.Second))
w.AddNamed(await.ListenAndServe(apiServer), "api")
w.AddNamed(await.ListenAndServe(metricServer), "metrics")
return w.Run(ctx)
}
```

### Shell Completion

```bash
# Generate completion script
eval "$(myapp completion bash)" # or zsh, fish
```

Handlers can provide custom completions for positional args:

```go
func (g *GetCmd) Complete(ctx context.Context, args []string) []cli.Completion {
return []cli.Completion{
{Value: "pods", Description: "list pods"},
{Value: "services", Description: "list services"},
}
}
```

### Default Config

Ship a reference config with your binary using `go:embed`:

```go
//go:embed config.json
var defaultConfig []byte

app := cli.New("myapp", "desc",
cli.WithDefaultConfig(defaultConfig),
)
```

```bash
myapp defcon > config.json # dump the default config
```

The command name is `defcon` by default, overridable with `WithDefaultConfigCommand`.

### Built-in Flags

| Flag | Behavior |
|---|---|
| `-h`, `--help` | Print help for the app or command |
| `--version` | Print version (requires `WithVersion`) |

## See Also

- [`await`](../await) — goroutine lifecycle management
- [`loader`](../loader) — polymorphic config loading
- [`cli/example`](./example) — complete working example
34 changes: 34 additions & 0 deletions cli/args.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package cli

import "fmt"

// ArgsFunc validates positional arguments.
type ArgsFunc func(args []string) error

// NoArgs returns an error if any positional args are present.
func NoArgs(args []string) error {
if len(args) > 0 {
return fmt.Errorf("unexpected arguments: %v", args)
}
return nil
}

// ExactArgs returns an ArgsFunc that requires exactly n positional args.
func ExactArgs(n int) ArgsFunc {
return func(args []string) error {
if len(args) != n {
return fmt.Errorf("expected exactly %d argument(s), got %d", n, len(args))
}
return nil
}
}

// MinArgs returns an ArgsFunc that requires at least n positional args.
func MinArgs(n int) ArgsFunc {
return func(args []string) error {
if len(args) < n {
return fmt.Errorf("expected at least %d argument(s), got %d", n, len(args))
}
return nil
}
}
Loading
Loading