From 7be4e243f2c5a8210235932bab95fa22d5bab9ef Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 11 Mar 2026 13:02:39 +0000 Subject: [PATCH] docs: add human-friendly documentation Co-Authored-By: Claude Opus 4.6 --- docs/index.md | 5 + docs/pkg/cli/commands.md | 168 +++++++++++++++++++ docs/pkg/cli/daemon.md | 131 +++++++++++++++ docs/pkg/cli/errors.md | 110 +++++++++++++ docs/pkg/cli/getting-started.md | 219 ++++++++++++++++++++++++ docs/pkg/cli/index.md | 103 ++++++++++++ docs/pkg/cli/output.md | 283 ++++++++++++++++++++++++++++++++ docs/pkg/cli/prompts.md | 187 +++++++++++++++++++++ docs/pkg/cli/streaming.md | 97 +++++++++++ 9 files changed, 1303 insertions(+) create mode 100644 docs/pkg/cli/commands.md create mode 100644 docs/pkg/cli/daemon.md create mode 100644 docs/pkg/cli/errors.md create mode 100644 docs/pkg/cli/getting-started.md create mode 100644 docs/pkg/cli/index.md create mode 100644 docs/pkg/cli/output.md create mode 100644 docs/pkg/cli/prompts.md create mode 100644 docs/pkg/cli/streaming.md diff --git a/docs/index.md b/docs/index.md index bab97f1..8103fd8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,8 @@ +--- +title: Core CLI +description: Unified CLI for building, releasing, and deploying Go, Wails, PHP, and container workloads. +--- + # Core CLI Core is a unified CLI for the host-uk ecosystem - build, release, and deploy Go, Wails, PHP, and container workloads. diff --git a/docs/pkg/cli/commands.md b/docs/pkg/cli/commands.md new file mode 100644 index 0000000..b917c8c --- /dev/null +++ b/docs/pkg/cli/commands.md @@ -0,0 +1,168 @@ +--- +title: Command Builders +description: Creating commands, flag helpers, args validation, and the config struct pattern. +--- + +# Command Builders + +The framework provides three command constructors and a full set of flag helpers. All wrap cobra but remove the need to import it directly. + +## Command Types + +### `NewCommand` -- Standard command (returns error) + +The most common form. The handler returns an error which `Main()` handles: + +```go +cmd := cli.NewCommand("build", "Build the project", "", func(cmd *cli.Command, args []string) error { + if err := compile(); err != nil { + return cli.WrapVerb(err, "compile", "project") + } + cli.Success("Build complete") + return nil +}) +``` + +The third parameter is the long description (shown in `--help`). Pass `""` to omit it. + +### `NewGroup` -- Parent command (subcommands only) + +Creates a command with no handler, used to group subcommands: + +```go +scoreCmd := cli.NewGroup("score", "Scoring commands", "") +scoreCmd.AddCommand(grammarCmd, attentionCmd, tierCmd) +root.AddCommand(scoreCmd) +``` + +### `NewRun` -- Simple command (no error return) + +For commands that cannot fail: + +```go +cmd := cli.NewRun("version", "Show version", "", func(cmd *cli.Command, args []string) { + cli.Println("v1.0.0") +}) +``` + +## Re-exports + +The framework re-exports cobra types so you never need to import cobra directly: + +```go +cli.Command // = cobra.Command +cli.PositionalArgs // = cobra.PositionalArgs +``` + +## Flag Helpers + +All flag helpers follow the same signature: `(cmd, ptr, name, short, default, usage)`. Pass `""` for the short name to omit the short flag. + +```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 + +Persistent flags are inherited by all subcommands: + +```go +cli.PersistentStringFlag(parentCmd, &dbPath, "db", "d", "", "Database path") +cli.PersistentBoolFlag(parentCmd, &debug, "debug", "", false, "Debug mode") +``` + +## Args Validation + +Constrain the number of positional arguments: + +```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.MaximumNArgs(3)) // At most 3 +cli.WithArgs(cmd, cli.RangeArgs(1, 3)) // Between 1 and 3 +cli.WithArgs(cmd, cli.NoArgs()) // No args allowed +cli.WithArgs(cmd, cli.ArbitraryArgs()) // Any number of args +``` + +## Command Configuration + +Add examples to help text: + +```go +cli.WithExample(cmd, ` core build --targets linux/amd64 + core build --ci`) +``` + +## Pattern: Config Struct + Flags + +The idiomatic pattern for commands with many flags is to define a config struct, bind flags to its fields, then pass the struct to the business logic: + +```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) +} +``` + +## Registration Function Pattern + +Commands are organised in packages under `cmd/`. Each package exports an `Add*Commands` function: + +```go +// cmd/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) +} +``` + +Then in `main.go`: + +```go +cli.Main( + cli.WithCommands("score", score.AddScoreCommands), +) +``` diff --git a/docs/pkg/cli/daemon.md b/docs/pkg/cli/daemon.md new file mode 100644 index 0000000..140c7ee --- /dev/null +++ b/docs/pkg/cli/daemon.md @@ -0,0 +1,131 @@ +--- +title: Daemon Mode +description: Daemon process management, PID files, health checks, and execution modes. +--- + +# Daemon Mode + +The framework provides both low-level daemon primitives and a high-level command group that adds `start`, `stop`, `status`, and `run` subcommands to your CLI. + +## Execution Modes + +The framework auto-detects the execution environment: + +```go +mode := cli.DetectMode() +``` + +| Mode | Condition | Behaviour | +|------|-----------|-----------| +| `ModeInteractive` | TTY attached | Colours enabled, spinners active | +| `ModePipe` | stdout piped | Colours disabled, plain output | +| `ModeDaemon` | `CORE_DAEMON=1` env var | Log-only output | + +Helper functions: + +```go +cli.IsTTY() // stdout is a terminal? +cli.IsStdinTTY() // stdin is a terminal? +cli.IsStderrTTY() // stderr is a terminal? +``` + +## Adding Daemon Commands + +`AddDaemonCommand` registers a command group with four subcommands: + +```go +func AddMyCommands(root *cli.Command) { + cli.AddDaemonCommand(root, cli.DaemonCommandConfig{ + Name: "daemon", // Command group name (default: "daemon") + Description: "Manage the worker", // Short description + PIDFile: "/var/run/myapp.pid", + HealthAddr: ":9090", + RunForeground: func(ctx context.Context, daemon *process.Daemon) error { + // Your long-running service logic here. + // ctx is cancelled on SIGINT/SIGTERM. + return runWorker(ctx) + }, + }) +} +``` + +This creates: + +- `myapp daemon start` -- Re-executes the binary as a background process with `CORE_DAEMON=1` +- `myapp daemon stop` -- Sends SIGTERM to the daemon, waits for shutdown (30s timeout, then SIGKILL) +- `myapp daemon status` -- Reports whether the daemon is running and queries health endpoints +- `myapp daemon run` -- Runs in the foreground (for development or process managers like systemd) + +### Custom Persistent Flags + +Add flags that apply to all daemon subcommands: + +```go +cli.AddDaemonCommand(root, cli.DaemonCommandConfig{ + // ... + Flags: func(cmd *cli.Command) { + cli.PersistentStringFlag(cmd, &configPath, "config", "c", "", "Config file") + }, + ExtraStartArgs: func() []string { + return []string{"--config", configPath} + }, +}) +``` + +`ExtraStartArgs` passes additional flags when re-executing the binary as a daemon. + +### Health Endpoints + +When `HealthAddr` is set, the daemon serves: + +- `GET /health` -- Liveness check (200 if server is up, 503 if health checks fail) +- `GET /ready` -- Readiness check (200 if `daemon.SetReady(true)` has been called) + +The `start` command waits up to 5 seconds for the health endpoint to become available before reporting success. + +## Simple Daemon (Manual) + +For cases where you do not need the full command group: + +```go +func runDaemon(cmd *cli.Command, args []string) error { + ctx := cli.Context() // Cancelled on SIGINT/SIGTERM + // ... start your work ... + <-ctx.Done() + return nil +} +``` + +## Shutdown with Timeout + +The daemon stop logic sends SIGTERM and waits up to 30 seconds. If the process has not exited by then, it sends SIGKILL and removes the PID file. + +## Signal Handling + +Signal handling is built into the CLI runtime: + +- **SIGINT/SIGTERM** cancel `cli.Context()` +- **SIGHUP** calls the `OnReload` handler if configured: + +```go +cli.Init(cli.Options{ + AppName: "daemon", + OnReload: func() error { + return reloadConfig() + }, +}) +``` + +No manual signal handling is needed in commands. Use `cli.Context()` for cancellation-aware operations. + +## DaemonCommandConfig Reference + +| Field | Type | Description | +|-------|------|-------------| +| `Name` | `string` | Command group name (default: `"daemon"`) | +| `Description` | `string` | Short description for help text | +| `PIDFile` | `string` | PID file path (default flag value) | +| `HealthAddr` | `string` | Health check listen address (default flag value) | +| `RunForeground` | `func(ctx, daemon) error` | Service logic for foreground/daemon mode | +| `Flags` | `func(cmd)` | Registers custom persistent flags | +| `ExtraStartArgs` | `func() []string` | Additional args for background re-exec | diff --git a/docs/pkg/cli/errors.md b/docs/pkg/cli/errors.md new file mode 100644 index 0000000..d3dfe52 --- /dev/null +++ b/docs/pkg/cli/errors.md @@ -0,0 +1,110 @@ +--- +title: Error Handling +description: Error creation, wrapping with i18n grammar, exit codes, and inspection helpers. +--- + +# Error Handling + +The framework provides error creation and wrapping functions that integrate with i18n grammar composition, plus re-exports of the standard `errors` package for convenience. + +## Error Creation + +```go +// Simple error (replaces fmt.Errorf) +return cli.Err("invalid model: %s", name) + +// Wrap with context (nil-safe -- returns nil if err is nil) +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: " +``` + +`WrapVerb` and `WrapAction` use the i18n `ActionFailed` function, which produces grammatically correct error messages across languages. + +All wrapping functions are nil-safe: they return `nil` if the input error is `nil`. + +## Error Inspection + +Re-exports of the `errors` package for convenience, so you do not need a separate import: + +```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 + +Return a specific exit code from a command: + +```go +return cli.Exit(2, fmt.Errorf("validation failed")) +``` + +The `ExitError` type wraps an error with an exit code. `Main()` checks for `*ExitError` and uses its code when exiting the process. All other errors exit with code 1. + +```go +type ExitError struct { + Code int + Err error +} +``` + +`ExitError` implements `error` and `Unwrap()`, so it works with `errors.Is` and `errors.As`. + +## The Pattern: Commands Return Errors + +Commands should return errors rather than calling `os.Exit` directly. `Main()` handles the exit: + +```go +// Correct: 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 +} + +// Wrong: calling os.Exit from command code +func runBuild(cmd *cli.Command, args []string) error { + if err := compile(); err != nil { + cli.Fatal(err) // Do not do this + } + return nil +} +``` + +## Fatal Functions (Deprecated) + +These exist for legacy code but should not be used in new commands: + +```go +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) +``` + +All `Fatal*` functions log the error, print it to stderr with the error style, and call `os.Exit(1)`. They bypass the normal shutdown sequence. + +## Error Output Functions + +For displaying errors without returning them (see [Output](output.md) for full details): + +```go +cli.Error("message") // ✗ message (stderr) +cli.Errorf("port %d in use", port) // ✗ port 8080 in use (stderr) +cli.ErrorWrap(err, "context") // ✗ context: (stderr) +cli.ErrorWrapVerb(err, "load", "config") // ✗ Failed to load config: (stderr) +cli.ErrorWrapAction(err, "connect") // ✗ Failed to connect: (stderr) +``` + +These print styled error messages but do not terminate the process or return an error value. Use them when you want to report a problem and continue. diff --git a/docs/pkg/cli/getting-started.md b/docs/pkg/cli/getting-started.md new file mode 100644 index 0000000..8f669d9 --- /dev/null +++ b/docs/pkg/cli/getting-started.md @@ -0,0 +1,219 @@ +--- +title: Getting Started +description: How to use cli.Main(), WithCommands(), and build CLI binaries with the framework. +--- + +# Getting Started + +## The `core` Binary + +The `core` binary is built from `main.go` at the repo root. It composes commands from both local packages and ecosystem modules: + +```go +package main + +import ( + "forge.lthn.ai/core/cli/cmd/config" + "forge.lthn.ai/core/cli/cmd/gocmd" + "forge.lthn.ai/core/cli/pkg/cli" + + // Ecosystem packages self-register via init() + _ "forge.lthn.ai/core/go-devops/cmd/dev" + _ "forge.lthn.ai/core/go-build/cmd/build" +) + +func main() { + cli.Main( + cli.WithCommands("config", config.AddConfigCommands), + cli.WithCommands("go", gocmd.AddGoCommands), + ) +} +``` + +## `cli.Main()` + +`Main()` is the primary entry point. It: + +1. Registers core services (i18n, log, crypt, workspace) +2. Appends your command services +3. Creates the cobra root command and signal handler +4. Starts all services via the Core DI framework +5. Adds the `completion` command +6. Executes the matched command +7. Shuts down all services in reverse order +8. Exits with the appropriate code + +```go +cli.Main( + cli.WithCommands("score", score.AddScoreCommands), + cli.WithCommands("gen", gen.AddGenCommands), +) +``` + +If a command returns an `*ExitError`, the process exits with that code. All other errors exit with code 1. + +## `cli.WithCommands()` + +This is the preferred way to register commands. It wraps your registration function in a Core service that participates in the lifecycle: + +```go +func WithCommands(name string, register func(root *Command)) core.Option +``` + +During startup, the Core framework calls your function with the root cobra command. Your function adds subcommands to it: + +```go +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) +} +``` + +**Startup order:** +1. Core services start (i18n, log, crypt, workspace, signal) +2. Command services start (your `WithCommands` functions run) +3. `Execute()` runs the matched command + +## Building a Variant Binary + +To create a standalone binary (not the `core` binary), set the app name and compose your commands: + +```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 a slice of framework options: + +```go +package lemcmd + +import ( + "forge.lthn.ai/core/go/pkg/core" + "forge.lthn.ai/core/cli/pkg/cli" +) + +func Commands() []core.Option { + return []core.Option{ + cli.WithCommands("score", addScoreCommands), + cli.WithCommands("gen", addGenCommands), + cli.WithCommands("data", addDataCommands), + } +} +``` + +## `cli.RegisterCommands()` (Legacy) + +For ecosystem packages that need to self-register via `init()`: + +```go +func init() { + cli.RegisterCommands(func(root *cobra.Command) { + root.AddCommand(myCmd) + }) +} +``` + +The `core` binary imports these packages with blank imports (`_ "forge.lthn.ai/core/go-build/cmd/build"`), triggering their `init()` functions. + +**Prefer `WithCommands`** -- it is explicit and does not rely on import side effects. + +## Manual Initialisation (Advanced) + +If you need more control over the lifecycle: + +```go +cli.Init(cli.Options{ + AppName: "myapp", + Version: "1.0.0", + Services: []core.Option{...}, + OnReload: func() error { return reloadConfig() }, +}) +defer cli.Shutdown() + +// Add commands manually +cli.RootCmd().AddCommand(myCmd) + +if err := cli.Execute(); err != nil { + os.Exit(1) +} +``` + +## Version Info + +Version fields are 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)" +``` + +## Accessing Core Services + +Inside a command handler, you can access the Core DI container and retrieve 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 +} +``` + +## Signal Handling + +Signal handling is automatic. SIGINT and SIGTERM cancel `cli.Context()`. Use this context in your commands for graceful cancellation: + +```go +func runServer(cmd *cli.Command, args []string) error { + ctx := cli.Context() + // ctx is cancelled when the user presses Ctrl+C + <-ctx.Done() + return nil +} +``` + +Optional SIGHUP handling for configuration reload: + +```go +cli.Init(cli.Options{ + AppName: "daemon", + OnReload: func() error { + return reloadConfig() + }, +}) +``` diff --git a/docs/pkg/cli/index.md b/docs/pkg/cli/index.md new file mode 100644 index 0000000..b1ed2fa --- /dev/null +++ b/docs/pkg/cli/index.md @@ -0,0 +1,103 @@ +--- +title: CLI Framework Overview +description: Go CLI framework built on cobra, with styled output, streaming, daemon mode, and TUI components. +--- + +# CLI Framework (`pkg/cli`) + +`pkg/cli` is the CLI framework that powers the `core` binary and all derivative binaries in the ecosystem. It wraps cobra with the Core DI framework, adding styled output, streaming, interactive prompts, daemon management, 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) +} +``` + +## Architecture + +The framework has three layers: + +1. **Runtime** (`runtime.go`, `app.go`) -- Initialises the Core DI container, cobra root command, and signal handling. Provides global accessors (`cli.Core()`, `cli.Context()`, `cli.RootCmd()`). + +2. **Command registration** (`commands.go`, `command.go`) -- Two mechanisms for adding commands: `WithCommands` (lifecycle-aware, preferred) and `RegisterCommands` (init-time, for ecosystem packages). + +3. **Output & interaction** (`output.go`, `stream.go`, `prompt.go`, `utils.go`) -- Styled output functions, streaming text renderer, interactive prompts, tables, trees, and task trackers. + +## Key Types + +| Type | Description | +|------|-------------| +| `Command` | Re-export of `cobra.Command` | +| `Stream` | Token-by-token text renderer with optional word-wrap | +| `Table` | Aligned tabular output with optional box-drawing borders | +| `TreeNode` | Tree structure with box-drawing connectors | +| `TaskTracker` | Concurrent task display with live spinners | +| `CheckBuilder` | Fluent API for pass/fail/skip result lines | +| `AnsiStyle` | Terminal text styling (bold, dim, colour) | + +## Built-in Services + +When you call `cli.Main()`, these services are registered automatically: + +| Service | Name | Purpose | +|---------|------|---------| +| I18nService | `i18n` | Internationalisation and grammar composition | +| LogService | `log` | Structured logging with CLI-styled output | +| openpgp | `crypt` | OpenPGP encryption | +| workspace | `workspace` | Project root detection | +| signalService | `signal` | SIGINT/SIGTERM/SIGHUP handling | + +## Documentation + +- [Getting Started](getting-started.md) -- `Main()`, `WithCommands()`, building binaries +- [Commands](commands.md) -- Command builders, flag helpers, args validation +- [Output](output.md) -- Styled output, tables, trees, task trackers +- [Prompts](prompts.md) -- Interactive prompts, confirmations, selections +- [Streaming](streaming.md) -- Real-time token streaming with word-wrap +- [Daemon](daemon.md) -- Daemon mode, PID files, health checks +- [Errors](errors.md) -- Error creation, wrapping, exit codes + +## Colour & Theme Control + +Colours are enabled by default and respect the `NO_COLOR` environment variable and `TERM=dumb`. You can also control them programmatically: + +```go +cli.SetColorEnabled(false) // Disable ANSI colours +cli.UseASCII() // ASCII glyphs + disable colours +cli.UseEmoji() // Emoji glyph theme +cli.UseUnicode() // Default Unicode glyph theme +``` + +## Execution Modes + +The framework auto-detects the execution environment: + +```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 a terminal? +cli.IsStdinTTY() // stdin is a terminal? +cli.IsStderrTTY() // stderr is a terminal? +``` diff --git a/docs/pkg/cli/output.md b/docs/pkg/cli/output.md new file mode 100644 index 0000000..d907e7f --- /dev/null +++ b/docs/pkg/cli/output.md @@ -0,0 +1,283 @@ +--- +title: Output Functions +description: Styled output, tables, trees, task trackers, glyphs, and formatting utilities. +--- + +# Output Functions + +All output functions support glyph shortcodes (`:check:`, `:cross:`, `:warn:`, `:info:`) which are auto-converted to Unicode symbols. + +## Styled Messages + +```go +cli.Success("All tests passed") // ✓ All tests passed (green, bold) +cli.Successf("Built %d files", n) // ✓ Built 5 files (green, bold) +cli.Error("Connection refused") // ✗ Connection refused (red, bold, stderr) +cli.Errorf("Port %d in use", port) // ✗ Port 8080 in use (red, bold, stderr) +cli.Warn("Deprecated flag used") // ⚠ Deprecated flag used (amber, bold, stderr) +cli.Warnf("Skipping %s", name) // ⚠ Skipping foo (amber, bold, 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 (grey, dimmed) +``` + +`Error` and `Warn` write to stderr and also log the message. `Success` and `Info` write to stdout. + +## 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 +``` + +## Error Wrapping for Output + +These display errors without returning them -- useful when you need to show an error but continue: + +```go +cli.ErrorWrap(err, "load config") // ✗ load config: +cli.ErrorWrapVerb(err, "load", "config") // ✗ Failed to load config: +cli.ErrorWrapAction(err, "connect") // ✗ Failed to connect: +``` + +All three are nil-safe -- they do nothing if `err` is nil. + +## Progress Indicator + +Overwrites the current terminal line to show progress: + +```go +for i, item := range items { + cli.Progress("check", i+1, len(items), item.Name) // Overwrites line +} +cli.ProgressDone() // Clears progress line +``` + +The verb is passed through `i18n.Progress()` for gerund form ("Checking..."). + +## Severity Levels + +```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] grey +``` + +## Check Results + +Fluent API for building pass/fail/skip/warn result lines: + +```go +cli.Check("audit").Pass().Print() // ✓ audit passed +cli.Check("fmt").Fail().Duration("2.3s").Print() // ✗ fmt failed 2.3s +cli.Check("test").Skip().Print() // - test skipped +cli.Check("lint").Warn().Message("3 warnings").Print() +``` + +## Tables + +Aligned tabular output with optional box-drawing borders: + +```go +t := cli.NewTable("REPO", "STATUS", "BRANCH") +t.AddRow("core-php", "clean", "main") +t.AddRow("core-tenant", "dirty", "feature/x") +t.Render() +``` + +Output: + +``` +REPO STATUS BRANCH +core-php clean main +core-tenant dirty feature/x +``` + +### Bordered Tables + +```go +t := cli.NewTable("REPO", "STATUS"). + WithBorders(cli.BorderRounded) +t.AddRow("core-php", "clean") +t.Render() +``` + +Output: + +``` +╭──────────┬────────╮ +│ REPO │ STATUS │ +├──────────┼────────┤ +│ core-php │ clean │ +╰──────────┴────────╯ +``` + +Border styles: `BorderNone` (default), `BorderNormal`, `BorderRounded`, `BorderHeavy`, `BorderDouble`. + +### Per-Column Cell Styling + +```go +t := cli.NewTable("REPO", "STATUS", "BRANCH"). + WithCellStyle(1, func(val string) *cli.AnsiStyle { + if val == "clean" { + return cli.SuccessStyle + } + return cli.WarningStyle + }). + WithMaxWidth(80) +``` + +## Trees + +Hierarchical output with box-drawing connectors: + +```go +tree := cli.NewTree("core-php") +tree.Add("core-tenant").Add("core-bio") +tree.Add("core-admin") +tree.Add("core-api") +tree.Render() +``` + +Output: + +``` +core-php +├── core-tenant +│ └── core-bio +├── core-admin +└── core-api +``` + +Styled nodes: + +```go +tree.AddStyled("core-tenant", cli.RepoStyle) +``` + +## Task Tracker + +Displays multiple concurrent tasks with live spinners. Uses ANSI cursor manipulation when connected to a TTY; falls back to line-by-line output otherwise. + +```go +tracker := cli.NewTaskTracker() +for _, repo := range repos { + t := tracker.Add(repo.Name) + go func(t *cli.TrackedTask) { + t.Update("pulling...") + if err := pull(repo); err != nil { + t.Fail(err.Error()) + return + } + t.Done("up to date") + }(t) +} +tracker.Wait() +cli.Println(tracker.Summary()) // "5/5 passed" +``` + +`TrackedTask` methods (`Update`, `Done`, `Fail`) are safe for concurrent use. + +## String Builders (No Print) + +These return styled strings without printing, for use in composing output: + +```go +cli.SuccessStr("done") // Returns "✓ done" styled green +cli.ErrorStr("failed") // Returns "✗ failed" styled red +cli.WarnStr("warning") // Returns "⚠ warning" styled amber +cli.InfoStr("note") // Returns "ℹ note" styled blue +cli.DimStr("detail") // Returns dimmed text +cli.Styled(style, "text") // Apply any AnsiStyle +cli.Styledf(style, "formatted %s", arg) +``` + +## Glyph Shortcodes + +All output functions auto-convert shortcodes to symbols: + +| Shortcode | Unicode | Emoji | ASCII | +|-----------|---------|-------|-------| +| `:check:` | ✓ | ✅ | [OK] | +| `:cross:` | ✗ | ❌ | [FAIL] | +| `:warn:` | ⚠ | ⚠️ | [WARN] | +| `:info:` | ℹ | ℹ️ | [INFO] | +| `:arrow_right:` | → | ➡️ | -> | +| `:bullet:` | • | • | * | +| `:dash:` | ─ | ─ | - | +| `:pipe:` | │ | │ | \| | +| `:corner:` | └ | └ | \` | +| `:tee:` | ├ | ├ | + | +| `:pending:` | … | ⏳ | ... | + +Switch themes: + +```go +cli.UseUnicode() // Default +cli.UseEmoji() // Emoji symbols +cli.UseASCII() // ASCII fallback (also disables colours) +``` + +## ANSI Styles + +Build custom styles with the fluent `AnsiStyle` API: + +```go +style := cli.NewStyle().Bold().Foreground(cli.ColourBlue500) +fmt.Println(style.Render("Important text")) +``` + +Available methods: `Bold()`, `Dim()`, `Italic()`, `Underline()`, `Foreground(hex)`, `Background(hex)`. + +The framework provides pre-defined styles using the Tailwind colour palette: + +| Style | Description | +|-------|-------------| +| `SuccessStyle` | Green, bold | +| `ErrorStyle` | Red, bold | +| `WarningStyle` | Amber, bold | +| `InfoStyle` | Blue | +| `SecurityStyle` | Purple, bold | +| `DimStyle` | Grey, dimmed | +| `HeaderStyle` | Grey-200, bold | +| `AccentStyle` | Cyan | +| `LinkStyle` | Blue, underlined | +| `CodeStyle` | Grey-300 | +| `NumberStyle` | Blue-300 | +| `RepoStyle` | Blue, bold | + +Colours respect `NO_COLOR` and `TERM=dumb`. Use `cli.ColorEnabled()` to check and `cli.SetColorEnabled(false)` to disable programmatically. + +## Formatting Utilities + +```go +cli.Truncate("long string", 10) // "long st..." +cli.Pad("short", 20) // "short " +cli.FormatAge(time.Now().Add(-2*time.Hour)) // "2h ago" +``` + +## Logging + +The framework provides package-level log functions that delegate to the Core log service when available: + +```go +cli.LogDebug("cache miss", "key", "foo") +cli.LogInfo("server started", "port", 8080) +cli.LogWarn("slow query", "duration", "3.2s") +cli.LogError("connection failed", "err", err) +cli.LogSecurity("login attempt", "user", "admin") +``` diff --git a/docs/pkg/cli/prompts.md b/docs/pkg/cli/prompts.md new file mode 100644 index 0000000..58353c8 --- /dev/null +++ b/docs/pkg/cli/prompts.md @@ -0,0 +1,187 @@ +--- +title: Interactive Prompts +description: Text prompts, confirmations, single and multi-select menus, and type-safe generic selectors. +--- + +# Interactive Prompts + +The framework provides several prompt functions, ranging from simple text input to type-safe generic selectors. + +## Text Prompt + +Basic text input with an optional default: + +```go +name, err := cli.Prompt("Project name", "my-app") +// Project name [my-app]: _ +// Returns default if user presses Enter +``` + +## Question (Enhanced Prompt) + +`Question` extends `Prompt` with validation and required-input support: + +```go +name := cli.Question("Enter your name:") +name := cli.Question("Enter your name:", cli.WithDefault("Anonymous")) +name := cli.Question("Enter your name:", cli.RequiredInput()) +``` + +With validation: + +```go +port := cli.Question("Port:", cli.WithValidator(func(s string) error { + n, err := strconv.Atoi(s) + if err != nil || n < 1 || n > 65535 { + return fmt.Errorf("must be 1-65535") + } + return nil +})) +``` + +Grammar-composed question: + +```go +name := cli.QuestionAction("rename", "old.txt") +// Rename old.txt? _ +``` + +## Confirmation + +Yes/no confirmation with sensible defaults: + +```go +if cli.Confirm("Delete file?") { ... } // Default: no [y/N] +if cli.Confirm("Save?", cli.DefaultYes()) { ... } // Default: yes [Y/n] +if cli.Confirm("Destroy?", cli.Required()) { ... } // Must type y or n [y/n] +``` + +With auto-timeout: + +```go +if cli.Confirm("Continue?", cli.Timeout(30*time.Second)) { ... } +// Continue? [y/N] (auto in 30s) +// Auto-selects default after timeout +``` + +Combine options: + +```go +if cli.Confirm("Deploy?", cli.DefaultYes(), cli.Timeout(10*time.Second)) { ... } +// Deploy? [Y/n] (auto in 10s) +``` + +### Grammar-Composed Confirmation + +```go +if cli.ConfirmAction("delete", "config.yaml") { ... } +// Delete config.yaml? [y/N] + +if cli.ConfirmAction("save", "changes", cli.DefaultYes()) { ... } +// Save changes? [Y/n] +``` + +### Dangerous Action (Double Confirmation) + +```go +if cli.ConfirmDangerousAction("delete", "production database") { ... } +// Delete production database? [y/n] (must type y/n) +// Really delete production database? [y/n] +``` + +## Single Select + +Numbered menu, returns the selected string: + +```go +choice, err := cli.Select("Choose backend:", []string{"metal", "rocm", "cpu"}) +// Choose backend: +// 1. metal +// 2. rocm +// 3. cpu +// Choose [1-3]: _ +``` + +## Multi Select + +Space-separated number input, returns selected strings: + +```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]: _ +``` + +## Type-Safe Generic Select (`Choose`) + +For selecting from typed slices with custom display: + +```go +type File struct { + Name string + Size int64 +} + +files := []File{{Name: "a.go", Size: 1024}, {Name: "b.go", Size: 2048}} + +choice := cli.Choose("Select a file:", files, + cli.Display(func(f File) string { + return fmt.Sprintf("%s (%d bytes)", f.Name, f.Size) + }), +) +``` + +With a default selection: + +```go +choice := cli.Choose("Select:", items, cli.WithDefaultIndex[Item](0)) +// Items marked with * are the default when Enter is pressed +``` + +Grammar-composed: + +```go +file := cli.ChooseAction("select", "file", files) +// Select file: +// 1. ... +``` + +## Type-Safe Generic Multi-Select (`ChooseMulti`) + +Select multiple items with ranges: + +```go +selected := cli.ChooseMulti("Select files:", files, + cli.Display(func(f File) string { return f.Name }), +) +// Select files: +// 1. a.go +// 2. b.go +// 3. c.go +// Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: _ +``` + +Input formats: +- `1 3 5` -- select items 1, 3, and 5 +- `1-3` -- select items 1, 2, and 3 +- `1 3-5` -- select items 1, 3, 4, and 5 +- (empty) -- select none + +Grammar-composed: + +```go +files := cli.ChooseMultiAction("select", "files", allFiles) +``` + +## Testing Prompts + +Override stdin for testing: + +```go +cli.SetStdin(strings.NewReader("test input\n")) +defer cli.SetStdin(os.Stdin) +``` diff --git a/docs/pkg/cli/streaming.md b/docs/pkg/cli/streaming.md new file mode 100644 index 0000000..d619d16 --- /dev/null +++ b/docs/pkg/cli/streaming.md @@ -0,0 +1,97 @@ +--- +title: Streaming Output +description: Real-time token-by-token text rendering with optional word-wrap. +--- + +# Streaming Output + +The `Stream` type renders growing text as tokens arrive, with optional word-wrap. It is designed for displaying LLM output, log tails, or any content that arrives incrementally. 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() +``` + +`Done()` ensures a trailing newline if the stream did not end with one. `Wait()` blocks until `Done()` is called. + +## Word Wrap + +Wrap text at a column boundary: + +```go +stream := cli.NewStream(cli.WithWordWrap(80)) +``` + +When word-wrap is enabled, the stream tracks the current column position and inserts line breaks when the column limit is reached. + +## Custom Output Writer + +By default, streams write to `os.Stdout`. Redirect to any `io.Writer`: + +```go +var buf strings.Builder +stream := cli.NewStream(cli.WithStreamOutput(&buf)) +// ... write tokens ... +stream.Done() +result := stream.Captured() // or buf.String() +``` + +`Captured()` returns the output as a string when using a `*strings.Builder` or any `fmt.Stringer`. + +## Reading from `io.Reader` + +Stream content from an HTTP response body, file, or any `io.Reader`: + +```go +stream := cli.NewStream(cli.WithWordWrap(120)) +err := stream.WriteFrom(resp.Body) +stream.Done() +``` + +`WriteFrom` reads in 256-byte chunks until EOF, calling `Write` for each chunk. + +## API Reference + +| Method | Description | +|--------|-------------| +| `NewStream(opts...)` | Create a 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 (requires `*strings.Builder` or `fmt.Stringer` writer) | + +## Options + +| Option | Description | +|--------|-------------| +| `WithWordWrap(cols)` | Set the word-wrap column width | +| `WithStreamOutput(w)` | Set the output writer (default: `os.Stdout`) | + +## Example: LLM Token Streaming + +```go +func streamResponse(ctx context.Context, model *Model, prompt string) error { + stream := cli.NewStream(cli.WithWordWrap(100)) + + go func() { + ch := model.Generate(ctx, prompt) + for token := range ch { + stream.Write(token) + } + stream.Done() + }() + + stream.Wait() + return nil +} +```