docs: add human-friendly documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
76cd3a5306
commit
7be4e243f2
9 changed files with 1303 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
168
docs/pkg/cli/commands.md
Normal file
168
docs/pkg/cli/commands.md
Normal file
|
|
@ -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),
|
||||
)
|
||||
```
|
||||
131
docs/pkg/cli/daemon.md
Normal file
131
docs/pkg/cli/daemon.md
Normal file
|
|
@ -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 |
|
||||
110
docs/pkg/cli/errors.md
Normal file
110
docs/pkg/cli/errors.md
Normal file
|
|
@ -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: <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>"
|
||||
```
|
||||
|
||||
`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: <error> (stderr)
|
||||
cli.ErrorWrapVerb(err, "load", "config") // ✗ Failed to load config: <error> (stderr)
|
||||
cli.ErrorWrapAction(err, "connect") // ✗ Failed to connect: <error> (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.
|
||||
219
docs/pkg/cli/getting-started.md
Normal file
219
docs/pkg/cli/getting-started.md
Normal file
|
|
@ -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()
|
||||
},
|
||||
})
|
||||
```
|
||||
103
docs/pkg/cli/index.md
Normal file
103
docs/pkg/cli/index.md
Normal file
|
|
@ -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?
|
||||
```
|
||||
283
docs/pkg/cli/output.md
Normal file
283
docs/pkg/cli/output.md
Normal file
|
|
@ -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: <error>
|
||||
cli.ErrorWrapVerb(err, "load", "config") // ✗ Failed to load config: <error>
|
||||
cli.ErrorWrapAction(err, "connect") // ✗ Failed to connect: <error>
|
||||
```
|
||||
|
||||
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")
|
||||
```
|
||||
187
docs/pkg/cli/prompts.md
Normal file
187
docs/pkg/cli/prompts.md
Normal file
|
|
@ -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)
|
||||
```
|
||||
97
docs/pkg/cli/streaming.md
Normal file
97
docs/pkg/cli/streaming.md
Normal file
|
|
@ -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
|
||||
}
|
||||
```
|
||||
Loading…
Add table
Reference in a new issue