commit 197f05cc69bb0a1973d34affe907eac3d29676f4 Author: Virgil Date: Mon Feb 23 04:54:00 2026 +0000 docs: initial wiki — pkg/cli API reference and usage guides diff --git a/Command-Builders.md b/Command-Builders.md new file mode 100644 index 0000000..cdee33f --- /dev/null +++ b/Command-Builders.md @@ -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) +} +``` diff --git a/Daemon-Mode.md b/Daemon-Mode.md new file mode 100644 index 0000000..0187d71 --- /dev/null +++ b/Daemon-Mode.md @@ -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() + }, +}) +``` diff --git a/Error-Handling.md b/Error-Handling.md new file mode 100644 index 0000000..3a00922 --- /dev/null +++ b/Error-Handling.md @@ -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: " + +// Wrap with i18n grammar +return cli.WrapVerb(err, "load", "config") // "Failed to load config: " +return cli.WrapAction(err, "connect") // "Failed to connect: " +``` + +## 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 +} +``` diff --git a/Framework-Integration.md b/Framework-Integration.md new file mode 100644 index 0000000..7c9e140 --- /dev/null +++ b/Framework-Integration.md @@ -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), + } +} +``` diff --git a/Home.md b/Home.md new file mode 100644 index 0000000..86b9851 --- /dev/null +++ b/Home.md @@ -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 diff --git a/Output-Functions.md b/Output-Functions.md new file mode 100644 index 0000000..c721787 --- /dev/null +++ b/Output-Functions.md @@ -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: +cli.ErrorWrapVerb(err, "load", "config") // ✗ Failed to load config: +cli.ErrorWrapAction(err, "connect") // ✗ Failed to connect: +``` diff --git a/Prompt-Input.md b/Prompt-Input.md new file mode 100644 index 0000000..3e91a58 --- /dev/null +++ b/Prompt-Input.md @@ -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 +} +``` diff --git a/Runtime.md b/Runtime.md new file mode 100644 index 0000000..031a8c8 --- /dev/null +++ b/Runtime.md @@ -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. diff --git a/Streaming.md b/Streaming.md new file mode 100644 index 0000000..28f410a --- /dev/null +++ b/Streaming.md @@ -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) |