docs: initial wiki — pkg/cli API reference and usage guides

Virgil 2026-02-23 04:54:00 +00:00
commit 197f05cc69
9 changed files with 630 additions and 0 deletions

102
Command-Builders.md Normal file

@ -0,0 +1,102 @@
# Command Builders & Flag Helpers
## Command Types
### `NewCommand` — Standard command (returns error)
```go
cmd := cli.NewCommand("build", "Build the project", "", func(cmd *cli.Command, args []string) error {
// Business logic here
return nil // or return fmt.Errorf("failed: %w", err)
})
```
### `NewGroup` — Parent command (no handler, only subcommands)
```go
scoreCmd := cli.NewGroup("score", "Scoring commands", "")
scoreCmd.AddCommand(grammarCmd, attentionCmd, tierCmd)
root.AddCommand(scoreCmd)
```
### `NewRun` — Simple command (no error return)
```go
cmd := cli.NewRun("version", "Show version", "", func(cmd *cli.Command, args []string) {
cli.Println("v1.0.0")
})
```
## Flag Helpers
All flag helpers follow the same signature: `(cmd, ptr, name, short, default, usage)`.
```go
var cfg struct {
Model string
Verbose bool
Count int
Score float64
Seed int64
Timeout time.Duration
Tags []string
}
cli.StringFlag(cmd, &cfg.Model, "model", "m", "", "Model path")
cli.BoolFlag(cmd, &cfg.Verbose, "verbose", "v", false, "Verbose output")
cli.IntFlag(cmd, &cfg.Count, "count", "n", 10, "Item count")
cli.Float64Flag(cmd, &cfg.Score, "score", "s", 0.0, "Min score")
cli.Int64Flag(cmd, &cfg.Seed, "seed", "", 0, "Random seed")
cli.DurationFlag(cmd, &cfg.Timeout, "timeout", "t", 30*time.Second, "Timeout")
cli.StringSliceFlag(cmd, &cfg.Tags, "tag", "", nil, "Tags")
```
### Persistent Flags (inherited by subcommands)
```go
cli.PersistentStringFlag(parentCmd, &dbPath, "db", "d", "", "Database path")
cli.PersistentBoolFlag(parentCmd, &debug, "debug", "", false, "Debug mode")
```
## Args Validation
```go
cmd := cli.NewCommand("deploy", "Deploy to env", "", deployFn)
cli.WithArgs(cmd, cli.ExactArgs(1)) // Exactly 1 arg
cli.WithArgs(cmd, cli.MinimumNArgs(1)) // At least 1
cli.WithArgs(cmd, cli.NoArgs()) // No args allowed
cli.WithArgs(cmd, cli.ArbitraryArgs()) // Any args
```
## Re-exports
- `cli.Command` = `cobra.Command` (no need to import cobra)
- `cli.PositionalArgs` = `cobra.PositionalArgs`
## Pattern: Config Struct + Flags
The idiomatic pattern for commands with many flags:
```go
type DistillOpts struct {
Model string
Probes string
Runs int
DryRun bool
}
func addDistillCommand(parent *cli.Command) {
var cfg DistillOpts
cmd := cli.NewCommand("distill", "Run distillation", "", func(cmd *cli.Command, args []string) error {
return RunDistill(cfg)
})
cli.StringFlag(cmd, &cfg.Model, "model", "m", "", "Model config path")
cli.StringFlag(cmd, &cfg.Probes, "probes", "p", "", "Probe set name")
cli.IntFlag(cmd, &cfg.Runs, "runs", "r", 3, "Runs per probe")
cli.BoolFlag(cmd, &cfg.DryRun, "dry-run", "", false, "Preview without executing")
parent.AddCommand(cmd)
}
```

78
Daemon-Mode.md Normal file

@ -0,0 +1,78 @@
# Daemon Mode
## Execution Modes
```go
mode := cli.DetectMode()
// cli.ModeInteractive — TTY attached, colours enabled
// cli.ModePipe — stdout piped, colours disabled
// cli.ModeDaemon — CORE_DAEMON=1, log-only output
cli.IsTTY() // stdout is terminal?
cli.IsStdinTTY() // stdin is terminal?
```
## Simple Daemon
```go
func runDaemon(cmd *cli.Command, args []string) error {
ctx := cli.Context() // Cancelled on SIGINT/SIGTERM
// ... start work ...
return cli.Run(ctx) // Blocks until signal
}
```
## Full Daemon with Health Checks
```go
daemon := cli.NewDaemon(cli.DaemonOptions{
PIDFile: "/var/run/myapp.pid",
ShutdownTimeout: 30 * time.Second,
HealthAddr: ":9090",
HealthChecks: []cli.HealthCheck{checkDB},
})
if err := daemon.Start(); err != nil {
return err // "another instance is running (PID 1234)"
}
daemon.SetReady(true)
return daemon.Run(cli.Context()) // Blocks, handles shutdown
```
### Health Endpoints
- `GET /health` — Liveness (200 if server up, 503 if checks fail)
- `GET /ready` — Readiness (200 if ready, 503 if not)
## PID File
```go
pid := cli.NewPIDFile("/tmp/myapp.pid")
if err := pid.Acquire(); err != nil {
// "another instance is running (PID 1234)"
}
defer pid.Release()
```
## Shutdown with Timeout
```go
cli.Init(cli.Options{AppName: "worker"})
shutdown := cli.RunWithTimeout(30 * time.Second)
defer shutdown() // Replaces cli.Shutdown()
cli.Run(cli.Context())
```
## Signal Handling
Built into the runtime — SIGINT/SIGTERM cancel `cli.Context()`. Optional SIGHUP for config reload:
```go
cli.Init(cli.Options{
AppName: "daemon",
OnReload: func() error {
return reloadConfig()
},
})
```

72
Error-Handling.md Normal file

@ -0,0 +1,72 @@
# Error Handling
## Error Creation
```go
// Simple error (replaces fmt.Errorf)
return cli.Err("invalid model: %s", name)
// Wrap with context (nil-safe)
return cli.Wrap(err, "load config") // "load config: <original>"
// Wrap with i18n grammar
return cli.WrapVerb(err, "load", "config") // "Failed to load config: <original>"
return cli.WrapAction(err, "connect") // "Failed to connect: <original>"
```
## Error Inspection
Re-exports of `errors` package for convenience:
```go
if cli.Is(err, os.ErrNotExist) { ... }
var exitErr *cli.ExitError
if cli.As(err, &exitErr) {
os.Exit(exitErr.Code)
}
combined := cli.Join(err1, err2, err3)
```
## Exit Codes
```go
// Return specific exit code from a command
return cli.Exit(2, fmt.Errorf("validation failed"))
```
The `ExitError` type is checked in `Main()` — commands that return `*ExitError` cause the process to exit with that code.
## Fatal Functions (Deprecated)
These exist for legacy code but should not be used in new commands. Return errors from `RunE` instead.
```go
// DON'T use these in new code:
cli.Fatal(err) // prints + os.Exit(1)
cli.Fatalf("bad: %v", err) // prints + os.Exit(1)
cli.FatalWrap(err, "load config") // prints + os.Exit(1)
cli.FatalWrapVerb(err, "load", "x") // prints + os.Exit(1)
```
## Pattern: Commands Return Errors
```go
// GOOD: return error, let Main() handle exit
func runBuild(cmd *cli.Command, args []string) error {
if err := compile(); err != nil {
return cli.WrapVerb(err, "compile", "project")
}
cli.Success("Build complete")
return nil
}
// BAD: calling os.Exit from library code
func runBuild(cmd *cli.Command, args []string) error {
if err := compile(); err != nil {
cli.Fatal(err) // DON'T DO THIS
}
return nil
}
```

120
Framework-Integration.md Normal file

@ -0,0 +1,120 @@
# Framework Integration
## How Commands Register
Commands register through the Core framework lifecycle using `WithCommands`:
```go
// In main.go
cli.Main(
cli.WithCommands("score", score.AddScoreCommands),
cli.WithCommands("gen", gen.AddGenCommands),
)
```
Internally, `WithCommands` wraps the registration function in a `commandService` that implements `Startable.OnStartup()`. During startup, it casts `Core.App` to `*cobra.Command` and calls the registration function.
**Startup order:**
1. Core services start (i18n, log, crypt, workspace)
2. Command services start (your `WithCommands` functions)
3. `Execute()` runs the matched command
## Registration Function Pattern
```go
// score/commands.go
package score
import "forge.lthn.ai/core/cli/pkg/cli"
func AddScoreCommands(root *cli.Command) {
scoreCmd := cli.NewGroup("score", "Scoring commands", "")
grammarCmd := cli.NewCommand("grammar", "Grammar analysis", "", runGrammar)
cli.StringFlag(grammarCmd, &inputPath, "input", "i", "", "Input file")
scoreCmd.AddCommand(grammarCmd)
root.AddCommand(scoreCmd)
}
```
## Accessing Core Services
```go
func runMyCommand(cmd *cli.Command, args []string) error {
ctx := cli.Context() // Root context (cancelled on signal)
core := cli.Core() // Framework Core instance
root := cli.RootCmd() // Root cobra command
// Type-safe service retrieval
ws, err := framework.ServiceFor[*workspace.Service](core)
if err != nil {
return cli.WrapVerb(err, "get", "workspace service")
}
return nil
}
```
## Built-in Services
| Service | Name | Purpose |
|---------|------|---------|
| i18n | `i18n` | Internationalisation, grammar |
| log | `log` | Structured logging (slog) |
| crypt | `crypt` | OpenPGP encryption |
| workspace | `workspace` | Project root detection |
| signal | `signal` | SIGINT/SIGTERM/SIGHUP handling |
## `Main()` Lifecycle
1. Recovery defer (catches panics)
2. Register core services (i18n, log, crypt, workspace)
3. Append user command services
4. `Init()` — creates cobra root, signals, starts all services
5. Add completion command
6. `Execute()` — runs matched command
7. `Shutdown()` — stops all services in reverse order
## Legacy: `RegisterCommands`
For packages that need `init()`-time registration (not recommended):
```go
func init() {
cli.RegisterCommands(func(root *cobra.Command) {
root.AddCommand(myCmd)
})
}
```
Prefer `WithCommands` — it's explicit and doesn't rely on import side effects.
## Building a CLI Binary
```go
// cmd/lem/main.go
package main
import (
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/lthn/lem/cmd/lemcmd"
)
func main() {
cli.WithAppName("lem")
cli.Main(lemcmd.Commands()...)
}
```
Where `Commands()` returns `[]framework.Option`:
```go
func Commands() []framework.Option {
return []framework.Option{
cli.WithCommands("score", addScoreCommands),
cli.WithCommands("gen", addGenCommands),
cli.WithCommands("data", addDataCommands),
}
}
```

41
Home.md Normal file

@ -0,0 +1,41 @@
# Core CLI Framework (`pkg/cli`)
Go 1.26 CLI framework built on cobra + bubbletea + lipgloss. Provides command registration, flag helpers, styled output, streaming, daemon mode, and TUI components.
**Import:** `forge.lthn.ai/core/cli/pkg/cli`
## Quick Start
```go
package main
import "forge.lthn.ai/core/cli/pkg/cli"
func main() {
cli.WithAppName("myapp")
cli.Main(
cli.WithCommands("greet", addGreetCommands),
)
}
func addGreetCommands(root *cli.Command) {
cmd := cli.NewCommand("greet", "Say hello", "", func(cmd *cli.Command, args []string) error {
cli.Success("Hello, world!")
return nil
})
root.AddCommand(cmd)
}
```
## Pages
- [[Command-Builders]] — NewCommand, NewGroup, NewRun, flag helpers
- [[Output-Functions]] — Success, Error, Warn, Info, Progress, Section, Label
- [[Error-Handling]] — Err, Wrap, WrapVerb, ExitError, Is/As/Join
- [[Streaming]] — Stream for token-by-token rendering
- [[Daemon-Mode]] — PIDFile, HealthServer, Daemon runner
- [[Prompt-Input]] — Prompt, Select, MultiSelect
- [[Runtime]] — Init, Main, Shutdown, Context, signal handling
- [[Framework-Integration]] — WithCommands, Core lifecycle, services
- [[Styles-and-Glyphs]] — ANSI styles, glyph shortcodes, colours
- [[Frame-TUI]] — Bubbletea AppShell with focus, navigation, keymaps

63
Output-Functions.md Normal file

@ -0,0 +1,63 @@
# Output Functions
All output functions support glyph shortcodes (`:check:`, `:cross:`, `:warn:`, `:info:`) which are auto-converted to Unicode symbols.
## Styled Output
```go
cli.Success("All tests passed") // ✓ All tests passed (green)
cli.Successf("Built %d files", n) // ✓ Built 5 files (green)
cli.Error("Connection refused") // ✗ Connection refused (red, stderr)
cli.Errorf("Port %d in use", port) // ✗ Port 8080 in use (red, stderr)
cli.Warn("Deprecated flag used") // ⚠ Deprecated flag used (amber, stderr)
cli.Warnf("Skipping %s", name) // ⚠ Skipping foo (amber, stderr)
cli.Info("Using default config") // Using default config (blue)
cli.Infof("Found %d items", n) // Found 42 items (blue)
cli.Dim("Optional detail") // Optional detail (gray)
```
## Plain Output
```go
cli.Println("Hello %s", name) // fmt.Sprintf + glyph conversion + newline
cli.Print("Loading...") // No newline
cli.Text("raw", "text") // Like fmt.Println but with glyphs
cli.Blank() // Empty line
cli.Echo("key.label", args...) // i18n.T translation + newline
```
## Structured Output
```go
cli.Label("version", "1.2.0") // Version: 1.2.0 (styled key)
cli.Task("php", "Running tests") // [php] Running tests
cli.Section("audit") // ── AUDIT ──
cli.Hint("fix", "go mod tidy") // fix: go mod tidy
cli.Result(passed, "Tests passed") // ✓ or ✗ based on bool
```
## Progress
```go
for i, item := range items {
cli.Progress("check", i+1, len(items), item.Name) // Overwrites line
}
cli.ProgressDone() // Clears progress line
```
## Severity
```go
cli.Severity("critical", "SQL injection found") // [critical] red+bold
cli.Severity("high", "XSS vulnerability") // [high] orange+bold
cli.Severity("medium", "Missing CSRF token") // [medium] amber
cli.Severity("low", "Debug mode enabled") // [low] gray
```
## Error Wrapping for Output
```go
cli.ErrorWrap(err, "load config") // ✗ load config: <error>
cli.ErrorWrapVerb(err, "load", "config") // ✗ Failed to load config: <error>
cli.ErrorWrapAction(err, "connect") // ✗ Failed to connect: <error>
```

42
Prompt-Input.md Normal file

@ -0,0 +1,42 @@
# Prompt & Input
## Text Prompt
```go
name, err := cli.Prompt("Project name", "my-app")
// Project name [my-app]: _
// Returns default if user presses Enter
```
## Single Select
```go
choice, err := cli.Select("Choose backend:", []string{"metal", "rocm", "cpu"})
// Choose backend:
// 1. metal
// 2. rocm
// 3. cpu
// Choose [1-3]: _
```
## Multi Select
```go
tags, err := cli.MultiSelect("Enable features:", []string{"auth", "api", "admin", "mcp"})
// Enable features:
// 1. auth
// 2. api
// 3. admin
// 4. mcp
// Choose (space-separated) [1-4]: _
// Returns []string of selected items
```
## Confirm (via Prompt)
```go
answer, _ := cli.Prompt("Proceed? (y/n)", "y")
if answer == "y" || answer == "Y" {
// proceed
}
```

60
Runtime.md Normal file

@ -0,0 +1,60 @@
# Runtime
## Initialisation
```go
// Simple (recommended)
cli.WithAppName("myapp")
cli.Main(
cli.WithCommands("cmd", addCommands),
)
// Manual (advanced)
cli.Init(cli.Options{
AppName: "myapp",
Version: "1.0.0",
Services: []framework.Option{...},
OnReload: func() error { return reloadConfig() },
})
defer cli.Shutdown()
cli.Execute()
```
## Global Accessors
| Function | Returns | Description |
|----------|---------|-------------|
| `cli.Core()` | `*framework.Core` | Framework container |
| `cli.RootCmd()` | `*cobra.Command` | Root cobra command |
| `cli.Context()` | `context.Context` | Cancelled on SIGINT/SIGTERM |
| `cli.Execute()` | `error` | Run matched command |
| `cli.Shutdown()` | — | Stop all services |
## Version Info
Set via ldflags at build time:
```go
cli.AppVersion // "1.2.0"
cli.BuildCommit // "df94c24"
cli.BuildDate // "2026-02-06"
cli.BuildPreRelease // "dev.8"
cli.SemVer() // "1.2.0-dev.8+df94c24.20260206"
```
Build command:
```bash
go build -ldflags="-X forge.lthn.ai/core/cli/pkg/cli.AppVersion=1.2.0 \
-X forge.lthn.ai/core/cli/pkg/cli.BuildCommit=$(git rev-parse --short HEAD) \
-X forge.lthn.ai/core/cli/pkg/cli.BuildDate=$(date +%Y-%m-%d)"
```
## Signal Handling
Automatic via the `signalService` registered during `Init()`:
- **SIGINT/SIGTERM** → cancels `cli.Context()`
- **SIGHUP** → calls `OnReload` if configured
No manual signal handling needed in commands — use `cli.Context()` for cancellation.

52
Streaming.md Normal file

@ -0,0 +1,52 @@
# Streaming Text Output
The `Stream` type renders growing text as tokens arrive, with optional word-wrap. Thread-safe for a single producer goroutine.
## Basic Usage
```go
stream := cli.NewStream()
go func() {
for token := range tokens {
stream.Write(token)
}
stream.Done()
}()
stream.Wait()
```
## Word Wrap
```go
stream := cli.NewStream(cli.WithWordWrap(80))
```
## Custom Output
```go
var buf strings.Builder
stream := cli.NewStream(cli.WithStreamOutput(&buf))
// ... write tokens ...
stream.Done()
result := stream.Captured() // or buf.String()
```
## Reading from io.Reader
```go
stream := cli.NewStream(cli.WithWordWrap(120))
err := stream.WriteFrom(resp.Body)
stream.Done()
```
## API
| Method | Description |
|--------|-------------|
| `NewStream(opts...)` | Create stream with options |
| `Write(text)` | Append text (thread-safe) |
| `WriteFrom(r)` | Stream from io.Reader until EOF |
| `Done()` | Signal completion (adds trailing newline if needed) |
| `Wait()` | Block until Done is called |
| `Column()` | Current column position |
| `Captured()` | Get output as string (when using Builder) |