AGENTS.md@sdk/libs ยท git:20260909.2f2d28c ยท 2026-09-09 ยท sha256 151b98911e9f339f

AGENTS.md@sdk/libs git:20260909.2f2d28cA

Immutable. This exact content is served forever at /api/v1/blob/151b98911e9f339f.

# ๐Ÿ“š sdk/libs โ€” shared packages for sdk tools

> **Up:** [`sdk/`](../AGENTS.md)

Code that more than one sdk tool needs. Its own Go module, so a tool opts in by
requiring it โ€” nothing here is forced on anyone.

| Package | What it is |
|---------|-----------|
| [`log/`](./log) | **The logging standard.** logrus for structured diagnostics, lumberjack for rotation, `Capture` for raw captured output. |
| [`tui/`](./tui) | **Shared TUI behaviors** โ€” `keymap` (data-driven keys + dispatch), `nav` (cursor/viewport, `gg`), `prompt` (line editor), `search` (smartcase `/`, `n`/`N`), `cmdline` (`:` verbs + Tab completion), `overlay` (help + confirm). Read [`tui/GUIDE.md`](./tui/GUIDE.md) before writing a TUI. |

## Using it from a tool

Each sdk tool is a separate module and there is no `go.work`, so a consumer
needs a `replace` โ€” these are built from the repo by `build.sh`, never fetched
by version:

```
require github.com/sfc-gh-eraigosa/dotfiles/sdk/libs v0.0.0
replace github.com/sfc-gh-eraigosa/dotfiles/sdk/libs => ../libs
```

## Logging: use `libs/log`, do not hand-roll

gsl proved the shape (logrus + lumberjack in `internal/observe`); this package
generalizes it so every tool gets the same behaviour instead of a worse
fraction of it. fleet was writing install output with bare `fmt.Fprintf` and
its own pruning when this was extracted โ€” that is the thing to stop doing.

```go
import fleetlog "github.com/sfc-gh-eraigosa/dotfiles/sdk/libs/log"

fleetlog.SetDefaultTool("fleet")            // once, at startup
fleetlog.Default().WithField("host", h).Info("update started")
```

**Two writers, on purpose:**

- **Diagnostics** โ€” what the tool did and why. `New` / `Default`. JSON,
  rotated, greppable.
- **Captured output** โ€” bytes some *other* process produced (a remote
  install's stdout). `NewCapture`. Plain text, because its whole value is
  being readable as-is in `less`; JSON-wrapping it destroys the one thing it
  is for. What is standardized is the file's lifecycle โ€” location, `0600`,
  header, per-line timestamps, retention.

**Construction is total.** Nothing returns an error for a logging problem; a
logger that cannot open its file writes to `io.Discard`, and `NewCapture`
returns a nil `*Capture` that is safe to call. A tool that dies because it
could not log is strictly worse than one that runs unlogged.

**A capture is written ONLY where its caller named.** `CaptureOptions.Dir` is
required: an empty `Dir` means *no capture*, never a fallback to
`<state>/<tool>/logs`. The fallback existed and was actively harmful โ€” it made
every zero-value construction (which is what tests and not-yet-configured
callers produce) write into the developer's own home. `go test ./...` was
depositing files in `~/.local/state/fleet/logs` on every run; 351 of the 384
files found there were named after test fixtures (`h`, `host-a`, `alpha`).
There is deliberately no `Tool` field on `CaptureOptions` to resolve a
directory *from*, so the mistake is unrepresentable rather than merely
avoided. Pinned by `TestEmptyDirMeansNoCapture`.

**Retention is per subject, and the caller sets it.** `Keep` (default 200) is
how many captures survive for *that* subject; `Prune` runs inside `NewCapture`
and never touches another subject's files, so a rarely-updated host cannot
have its history evicted by a busy one. fleet sets 50. Pinned by
`TestPruneKeepsNewestPerSubject`.

**Environment**, per tool: `$FLEET_LOG_FILE`, `$FLEET_LOG_LEVEL`
(hyphens become underscores: `tmux-mgr` โ†’ `$TMUX_MGR_LOG_FILE`).

## Adding a package here

It belongs here once a **second** tool needs it โ€” not in anticipation. Until
then it lives in the tool's own `internal/`, where it can change freely.