@wirecat/cli-base 0.27.0 → 0.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,27 +2,43 @@
2
2
 
3
3
  # @wirecat/cli-base
4
4
 
5
- The parts every command line tool needs and nobody enjoys writing twice: the two output streams,
6
- a renderer for people and for machines, a closed error model with stable exit codes, the OS
7
- keyring behind a testable seam, and injectable clocks.
5
+ The foundation for command-line tools that people and AI agents both use. Output for a person and for a program,
6
+ a closed error model with stable exit codes, secrets in the OS keyring, injectable clocks, one query language,
7
+ the command tree as data — the parts every CLI needs and nobody should write twice.
8
8
 
9
- Extracted from [`brazecli`](https://github.com/leemour/brazecli), where each piece earned its
10
- shape, and shared with [`max-cli`](https://github.com/WireCatLabs/max-cli).
9
+ [Architecture](docs/dev/ARCHITECTURE.md) · [API reference](docs/REFERENCE.md) · [Shared tooling](docs/TOOLING.md) ·
10
+ [Testing](docs/dev/TESTING.md) · [Changelog](CHANGELOG.md) · [wirecat.dev](https://wirecat.dev)
11
11
 
12
- Published on npm and used by `max-cli`, `cli-messaging`, `tg-cli` and `brazecli`. What changed in
13
- each version is in [`CHANGELOG.md`](CHANGELOG.md); how the package is put together and released is in
14
- [`docs/dev/`](docs/dev/ARCHITECTURE.md).
12
+ [![npm](https://img.shields.io/npm/v/@wirecat/cli-base)](https://www.npmjs.com/package/@wirecat/cli-base)
13
+ [![CI](https://github.com/WireCatLabs/cli-base/actions/workflows/ci.yml/badge.svg)](https://github.com/WireCatLabs/cli-base/actions/workflows/ci.yml)
14
+ [![Node](https://img.shields.io/node/v/@wirecat/cli-base)](https://nodejs.org/)
15
+ [![Bun](https://img.shields.io/badge/bun-tested-f9f1e1)](https://bun.sh/)
16
+ [![License: Apache 2.0](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
15
17
 
16
- Up to version 0.20.0 (October 2026) this package was `@wirecat/cli-core`, in the repository `WireCatLabs/cli-core`.
17
- That name now passes to the shared messaging library (today `@wirecat/cli-messaging`): in older changelogs, plans and
18
- issues, "cli-core" means this package.
18
+ ## Key documents
19
19
 
20
- ## The rule this package exists to keep
20
+ | Document | What it answers |
21
+ |---|---|
22
+ | [Architecture](docs/dev/ARCHITECTURE.md) | The entry points, the lines this package does not cross, who consumes what |
23
+ | [API reference](docs/REFERENCE.md) | What each entry point holds, with examples |
24
+ | [Shared tooling](docs/TOOLING.md) | The TypeScript, Biome, lefthook, docs and CI setup a CLI takes from here, and `cli-dev` |
25
+ | [Testing](docs/dev/TESTING.md) | The checks, and what each one is for |
26
+ | [Conventions](docs/dev/CONVENTIONS.md) | How code here is written |
27
+ | [CLI standard](https://github.com/WireCatLabs/cli-core/blob/main/docs/dev/STANDARD.md) | The rules every WireCat CLI follows; this package holds them as data |
28
+ | [Changelog](CHANGELOG.md) | What each version changed, and what may break callers |
29
+
30
+ ## What it is
31
+
32
+ Every WireCat tool is built on it: [tg-cli](https://github.com/WireCatLabs/tg-cli),
33
+ [max-cli](https://github.com/WireCatLabs/max-cli), and [cli-core](https://github.com/WireCatLabs/cli-core) — the shared
34
+ messaging engine — and through it the mail, notes, Zoom and Drive tools.
21
35
 
22
- **In a machine mode, stdout carries data and nothing else.** No spinner, no `✓`, no warning, no
23
- ANSI. Diagnostics go to stderr, in every mode including the pretty one. That is the contract a
24
- script or an agent depends on, and `captureStreams` exists so a test can prove nothing leaked
25
- across.
36
+ Up to version 0.20.0 (October 2026) this package was `@wirecat/cli-core`. That name now belongs to the shared
37
+ messaging engine: in older changelogs, plans and issues, "cli-core" means this package.
38
+
39
+ **One rule shapes everything here: in a machine mode, stdout carries data and nothing else.** No spinner, no `✓`, no
40
+ warning, no ANSI. Diagnostics go to stderr, in every mode including the pretty one. That is the contract a script or
41
+ an agent depends on, and `captureStreams` exists so a test can prove nothing leaked across.
26
42
 
27
43
  ```ts
28
44
  import { captureStreams, createRenderer } from "@wirecat/cli-base"
@@ -34,215 +50,75 @@ streams.stdout // ['{"chats":2}']
34
50
  streams.stderr // []
35
51
  ```
36
52
 
37
- ## What is in it
53
+ ## Architecture
54
+
55
+ The package is split by **when** a piece runs, so a tool loads only what it calls:
38
56
 
39
- | | |
57
+ | When | Entry points |
40
58
  |---|---|
41
- | `streams` | the stdout/stderr split, and the capture used to test it |
42
- | `renderer` · `pretty` | `pretty` / `json` / `jsonl`; tables for lists, labelled lines for objects; `quiet` keeps only failures on stderr |
43
- | `errors` · `exit-codes` | 14 closed error codes, one exit number each, so a script can branch on `$?` |
44
- | `keyring` | `KeyringStore` with the system, memory and deliberately-broken implementations |
45
- | `fieldsOf` · `projectFields` | validated field selection preserving list metadata and operation identifiers |
46
- | `createOutputBuffer` | bounded UTF-8 output held until successful completion and resource cleanup |
47
- | `createDeadline` | cooperative command cancellation with injected sleep and a parent abort signal |
48
- | `time` | monotonic and wall clocks, and the one sleep that both timeouts and backoff use |
49
- | `logger` | the four-method interface a host adapts Pino to — this package logs nothing itself |
50
- | `paths` | config, state and cache in the XDG layout on Linux and macOS (`~/.config`, `~/.local/share`, `~/.cache`) and the `AppData` one on Windows, each overridable by environment variable; `migrateLegacyMacPaths` moves an older release's `~/Library` folders once |
51
- | `config` | JSON config loading that names the bad field, and an atomic write that locks the directory down |
52
- | `credentials` | environment → keyring → file, warning once and falling through when the keyring refuses |
53
- | `logging` | `createRecordLog`: one `LogRecord` per JSON line (OpenTelemetry field names, flat), secrets and content-naming keys redacted at any depth, top-level keys off the allow-list (`LOG_ATTRIBUTE_KEYS` + `allow`) written without their value; `logRecordOf` builds the same record for a writer of its own; a file that cannot be written is reported to `onError`, never thrown |
54
- | `retry` | full-jitter backoff, and the distinction between "no answer came" and "safe to repeat" |
55
- | `/testing` | `captureStreams`, `memoryKeyring`, `brokenKeyring`, `fakeClock` |
56
- | `/commands` | the command registry: `describeProgram`, `annotate`, `flatten`, `refuseJoinedPath`, and `commandsDocument` — the one `commands --json` shape, with its schema — see below |
57
- | `/completion` | shell completion over the registry: `suggest`, `formatSuggestions` — see below |
58
- | `/update` | keeping an install current: `installerOf`, `updateCommand`, `latestVersion`, `mayNotify`, `updateNotice`, `runUpdate` — see below |
59
- | `/codegen` | build time: an official API description → types, Valibot schemas, an operation manifest and a coverage page — see below |
60
- | `/release` | release time: `releaseCheck` and the checks it runs — changelog shape, links, package contents, version in step — see below |
61
- | `/skill` | the CLI's SKILL.md for coding agents: `skillCommand` (`show`, `install`), `skillHint`, `skillResource` — see below |
62
- | `/mcp` | local stdio MCP setup for Codex and Claude Code, plus a read-only handshake and tool-list probe |
63
- | `/query` | one query language for every list: the Lucene 9.12.3 parser port, fields from an entity schema, validation, the Fields help and `evaluate` as a test oracle — see below |
64
- | `/standards` | the registries the standard is made of — vocabulary, output envelope, error object and reasons, next steps, log record, lint rules — as Valibot schemas that also emit JSON Schema 2020-12 |
65
-
66
- **Nothing in the root export is HTTP.** Status classification, `Retry-After` parsing and the fetch
67
- seam live in `@wirecat/cli-base/http`, so a CLI that speaks a socket never depends on a stack it
68
- does not call:
59
+ | Every command | root: streams, renderer, errors and exit codes, keyring, credentials, paths, config, time, retry, logging, field selection, output buffer, deadlines |
60
+ | Only if the tool speaks HTTP | `/http` |
61
+ | Commands that describe the tool | `/commands`, `/completion`, `/query`, `/skill`, `/mcp`, `/update`, `/standards` |
62
+ | Build time | `/codegen`, with a small `/codegen/runtime` for the generated schemas |
63
+ | Release time | `/release` |
64
+ | Tests | `/testing`: captured streams, memory and broken keyrings, a fake clock |
65
+ | Configuration | `biome`, `tsconfig.*.json`, `lefthook.yml`, `cspell` presets |
69
66
 
70
- ```ts
71
- import { providerWaitMs, statusToCode } from "@wirecat/cli-base/http"
72
- ```
67
+ Three lines this package does not cross:
73
68
 
74
- **Portable command control stays independent of provider and store lifetimes.**
75
- `fieldsOf("id,speaker.id")` parses up to 128 field paths; `projectFields(value, paths)` selects data
76
- from objects or list items while preserving the list envelope and `operation_id` / `send_id`.
77
- `items.id` also works for bare objects and JSONL items. Unknown fields are omitted; parent selections
78
- win over their children. Unsafe prototype paths are refused, including when paths are passed directly.
79
-
80
- `createOutputBuffer({ maxOutputBytes })` retains raw output chunks until `flush(write)` or `discard()`.
81
- Its default limit is 4 MiB; zero disables the limit. It counts actual UTF-8 bytes, including delimiters
82
- the caller passes, and adds no newlines. Use a new buffer for each command and flush only after resource
83
- cleanup succeeds; discard on failure. A flush consumes the buffer even when its writer throws.
84
- The `bytes` property reports the cumulative accepted byte count. A writer failure can leave partial
85
- output; buffering only guarantees no output before flushing.
86
-
87
- Optional `{ fields, format: "json" | "jsonl" }` projects before counting bytes. With projection,
88
- each `data(text)` must contain a complete JSON value or complete JSONL lines; fragments fail before
89
- buffering. Without projection, arbitrary text chunks work. Hosts can instead project values before
90
- rendering and buffer the renderer's raw text. For core renderers, pass the line delimiter explicitly
91
- when adapting `Streams.data`: `data: (text) => buffer.data(text + "\n")`.
92
-
93
- `createDeadline({ timeoutMs, signal, sleep })` supplies a command signal and `race(body)`. An omitted
94
- timeout creates no timer; zero aborts synchronously without calling sleep, and the maximum is 2147483647 milliseconds.
95
- Pass its signal to every cancellable operation and check it before writes. On timeout or parent
96
- cancellation, `race` waits for the body to settle before rejecting, so a caller can safely close
97
- resources afterwards. An operation ignoring its signal can delay completion; this helper never
98
- abandons it in the background. A body's more precise `outcome_unknown` error survives interruption.
99
- Call `dispose()` in `finally` to remove the timer and parent listener. There are no process signal
100
- handlers, retries, automatic resource cleanup or HTTP behavior in these helpers.
101
-
102
- **The command registry is `@wirecat/cli-base/commands`** — the whole command tree as data, like
103
- `rails routes`, for an agent to read instead of `--help` and for generated documentation. It walks
104
- the live [Commander](https://github.com/tj/commander.js) tree, so a command built in a loop from a
105
- catalog appears exactly like a handwritten one; `annotate` adds what the tree cannot say. Commander
106
- is a type here, not a runtime dependency — an optional peer, 15 or newer.
69
+ - **Nothing in the root export is HTTP.** Status classification, `Retry-After` and the fetch seam live in `/http`, so a
70
+ CLI that speaks a WebSocket never carries an HTTP stack.
71
+ - **The environment is an argument.** The clock, the sleep, the keyring, `env`, `fetch` and the streams are all
72
+ parameters; the real ones are only defaults. A timeout test finishes instantly, and a keyring test cannot reach a
73
+ real keychain.
74
+ - **It logs nothing and prints nothing by itself.** It hands the host a `Logger` interface, a Pino adapter and a
75
+ renderer; what is written, and where, is the host's call.
107
76
 
108
- ```ts
109
- import { annotate, describeProgram } from "@wirecat/cli-base/commands"
77
+ Five runtime dependencies: valibot and its JSON Schema converter, cli-table3, picocolors and cross-spawn. Commander is
78
+ an optional peer, used as a type.
110
79
 
111
- annotate(program.command("send"), { mutates: true, examples: ["max messages send 42 hi"] })
112
- annotate(generated, { origin: "generated", operation_id: "campaigns.list" })
80
+ ## Features
113
81
 
114
- describeProgram(program) // [{ path: ["send"], usage, origin, mutates, options: [...], commands: [...] }]
115
- ```
82
+ ### Output for people and machines
116
83
 
117
- Each option says whether it `takes_value` and, separately, whether it is `mandatory` — not
118
- Commander's `required`, which means "takes a value when given" — plus its choices, default,
119
- environment variable, the options it `conflicts` with and the values it `implies`, keyed by long flag.
120
- Every key of the document is snake_case.
121
-
122
- **A list filters with one query, `@wirecat/cli-base/query`** — `tasks list state:open priority>1`, the
123
- query positional and last, `[query...]`, over the entity's own snake_case keys. `queryFieldsOf(schema, annotations)`
124
- reads the fields from the entity's Valibot schema (string → `kw`, a list of choices → `enum`, number → `num`, `isoTime` →
125
- `time`, boolean → `bool`, `reference` → `ref`); annotations add the exceptions: words (`text`), relations,
126
- `is:` states, examples, the fields a provider filters natively. `parseLucene(text, { defaultField: "none",
127
- command })` refuses a bare word (`query.bare-word`); `validateQuery` checks names, operators, values and
128
- placement and names every field when one is unknown; `fieldsHelp` prints the help's Fields section;
129
- `QUERY_REASONS` registers the errors. A list filters where its data lives — its store or the provider — and
130
- never in memory: `evaluate` exists to test those compilers against. `annotate(list, { output: { entity },
131
- query })` puts both in `commands --json`, where `cli-dev lint` reads them.
132
-
133
- `refuseJoinedPath(program, args)` throws a `validation_error` when a command path arrived as one
134
- argument (`"projects add" --help`, what a zsh loop over `$c` passes), naming the words to type.
135
- Commander would print the root help instead; call it on the words before `parseAsync`, after a
136
- leading profile word if the CLI takes one.
137
-
138
- **Shell completion is `@wirecat/cli-base/completion`**, built on the registry. `suggest` takes the
139
- words typed so far and answers what may come next: a command, an action, an option, one of its
140
- values, or an argument's values from a source the CLI hands in — a local cache, never the network,
141
- because a shell calls it on every Tab. `formatSuggestions` writes the answer in the protocol of the
142
- shell scripts [`@bomb.sh/tab`](https://github.com/bombshell-dev/tab) generates, so the CLI prints
143
- those scripts with tab and answers `<cli> complete -- <words>` with this; cli-base itself does not
144
- depend on tab.
84
+ - **Three formats from one renderer:** `pretty` for a person — tables for lists, labelled lines for objects — and
85
+ `json` or `jsonl` for a program. `quiet` keeps only failures.
86
+ - **Field selection** (`--json=id,speaker.id`) that keeps the list envelope and the operation ids, and refuses unsafe
87
+ paths.
88
+ - **A bounded output buffer.** Output waits until the command succeeds and has closed what it holds; past the limit
89
+ (4 MiB by default) it fails visibly instead of truncating.
145
90
 
146
- ```ts
147
- import { formatSuggestions, suggest } from "@wirecat/cli-base/completion"
91
+ ### Errors a script can branch on
148
92
 
149
- const words = argv.slice(argv.indexOf("--") + 1)
150
- streams.data(formatSuggestions(suggest({ commands, globalOptions, words, sources: { arguments: { chat: chatNames } } })))
151
- ```
93
+ Fourteen closed error codes, one exit number each: validation, configuration, authentication, permission, not found,
94
+ rate limited, timeout, network, provider, outcome unknown and the rest. A script branches on `$?`, an agent on the
95
+ `code` and `reason` in one JSON object on stderr. Errors are recognised by a brand, so two installed copies of the
96
+ package still understand each other's errors.
152
97
 
153
- **Two traps worth knowing before you use the credential store.** The OS keyring is global: an entry
154
- is addressed by service and account and knows nothing about which config directory asked for it, so
155
- a throwaway config directory silently overwrites the real secret unless you pass `isolated: true`.
156
- And a secret should never be handed to a logger in the first place — redaction by field name is the
157
- second line of defence, not the first.
158
-
159
- **Everything the environment knows can be passed in**: the clock, the sleep, the keyring, `env`,
160
- `fetch` and the streams. The real ones are only defaults — `Credentials` without a `keyring` uses the
161
- system keychain, `resolvePaths` without `env` reads `process.env`. Passing them is what makes a
162
- timeout test finish instantly and a keyring test incapable of reaching a real keychain.
163
-
164
- **Generating an API catalog is `@wirecat/cli-base/codegen`**, a build-time tool. It reads a
165
- format-neutral `ApiModel` — operations with a transport binding (`http` method and path, or `rpc`
166
- name), an `effect` (`read`, `write`, `destructive`) and how far the source can be trusted
167
- (`contract`, `example`, `inferred`, `override`) — and writes committed files. It parses no file
168
- format: the adapter from OpenAPI, Postman or anything else lives in the CLI that has that source,
169
- so this adds no dependency. Generation stops, naming the problem, when two operations collide, an
170
- operation is not classified as a read or a write, an override matches nothing, a reference points
171
- nowhere or a construct cannot be expressed; it never drops an operation or falls back to `any`.
98
+ ### The standard, as data a machine checks
172
99
 
173
- ```ts
174
- import { generate, manifestGenerator, typesGenerator, valibotGenerator, writeArtifacts } from "@wirecat/cli-base/codegen"
100
+ The rules every WireCat CLI follows — the verb vocabulary, the output envelope, the error reasons, next steps, the log
101
+ record — live in `/standards` as Valibot schemas that also emit JSON Schema 2020-12.
175
102
 
176
- const artifacts = generate(model, [typesGenerator({ path }), valibotGenerator({ path, typesImport }), manifestGenerator({ path })], {
177
- overrides: { answerOnCallback: { effect: "write", reason: "a POST that edits a message" } },
178
- banner: ["Source: spec/bot/schema.yaml", "Run: pnpm bot:generate"],
179
- })
180
- const stale = writeArtifacts(artifacts, { root, check: process.argv.includes("--check") })
181
- ```
103
+ - **The whole command tree as data.** `describeProgram` walks the live Commander tree, like `rails routes`: every
104
+ command, option, default, conflict and whether it writes. `commands --json` gives it to an agent instead of `--help`.
105
+ - **A linter for command trees.** `cli-dev lint` checks a CLI's tree against 15 rules — unknown verbs, filter flags
106
+ that should be query fields, examples that do not run, key casing and more. Known violations live in a baseline
107
+ that may only shrink.
108
+ - **An audit across CLIs.** `cli-dev audit` reads several CLIs' command trees and checks them against each other:
109
+ the commands every CLI must have, the same options and output where two CLIs share a path, the same meaning for the
110
+ same flag, the same entity schemas and error reasons. A weekly workflow runs it over the published CLIs.
182
111
 
183
- The generated schemas import `@wirecat/cli-base/codegen/runtime` at run time — a few number
184
- helpers, not the generator. Numbers are expected from a lossless JSON parser (`lossless-json`): a
185
- 64-bit integer comes out as its exact decimal string, and any other integer that does not fit a JS
186
- number fails instead of rounding. Objects are loose, so a field the API added later passes through.
187
- Unions retain every alternative, including recursive references and Boolean literals.
188
- `definitionsGenerator` emits the same schema nodes for runtime input traversal; RPC parameters can be in the JSON body.
189
- Enums and discriminated unions are **strict**: a value or subtype the snapshot does not know fails,
190
- so decode a live stream (updates, webhooks) only where that is what you want.
191
-
192
- **The schemas decode; they do not encode.** Their output carries an `int64` as a string, so sending
193
- it back would put a string where the API expects a number. Validate a request with the schema, then
194
- send the original lossless value.
195
-
196
- **Keeping an install current is `@wirecat/cli-base/update`.** `installerOf(realpath(script))` says
197
- which package manager put the CLI there — pnpm, npm or bun, measured on real installs — and
198
- `updateCommand` gives the argv that updates it; a checkout or `npx` gets none. `latestVersion` asks
199
- npm with a timeout and answers `undefined` on any failure. `mayNotify` says whether a person is at a
200
- terminal to be told: never in JSON, a pipe, `--quiet`, `CI` or with `NO_UPDATE_NOTIFIER`.
201
- `updateNotice` is the whole daily line: it stays silent when `update` or `complete` is anywhere on
202
- the command line, asks npm at most once a day through a state file the CLI names, and returns the
203
- sentence or `undefined` — it never throws. `runUpdate` starts the package manager with its output on
204
- stderr (`spawnPlan` is the Windows `.cmd` rule). Nothing here updates or prints by itself — a CLI
205
- holding somebody's credentials must not change itself unasked.
206
-
207
- **A CLI's guide for coding agents is `@wirecat/cli-base/skill`.** `skillCommand(app, skillUrl,
208
- environment)` is `<cli> skill show` — SKILL.md on stdout, even into a pipe, or `{ name, content }`
209
- with `--json` — and `<cli> skill install [--for claude|agents|all]`, which writes it to
210
- `~/.claude/skills/<appName>/` and `~/.agents/skills/<appName>/` with the CLI's version as
211
- `metadata.version` in the frontmatter, refusing a file whose `name` is not `<appName>`. `environment` is the host's own renderer, streams and `env` for that invocation.
212
- `skillHint` is one line, or `undefined`, for an agent (`AI_AGENT` or `CLAUDECODE` set) with no copy
213
- installed or an older one, at most once a day, through the same state file as `updateNotice`; the
214
- host prints it on stderr. `skillResource` is what an MCP server registers to serve the file as
215
- `<command>://skill`, plus a line for its `instructions` — the SDK stays the host's.
216
-
217
- **The release checks are `@wirecat/cli-base/release`** — everything about a release that a program
218
- can decide. A check is `{ name, run }`, and `run` returns the problems it found, one line each; an
219
- empty list is a pass. `releaseCheck(checks, { version, log })` runs them all, even after a failure,
220
- prints `ok` or `FAIL` per check and returns how many failed — the caller exits. The checks every CLI
221
- shares: `command(root, "pnpm", "lint")` for anything that passes by exiting 0, `notOnNpm`,
222
- `packContents` against a list of what may ship, `changelogProblems` for the fixed headings and the
223
- dated top section, `docsProblems` for dead links and anchors and the rules of the pages a user reads,
224
- and `versionScript` — the whole of a `version:check` / `version:sync` script. Each CLI passes its
225
- own headings, id prefixes and pages; the checks that belong to one messenger stay in that CLI.
112
+ ### One query language for every list
226
113
 
227
- ```ts
228
- import { command, notOnNpm, packageVersion, releaseCheck } from "@wirecat/cli-base/release"
229
-
230
- const version = packageVersion(root)
231
- const failed = releaseCheck(
232
- [
233
- { name: "version not on npm", run: notOnNpm(root, "@wirecat/tg-cli", version) },
234
- { name: "lint", run: command(root, "pnpm", "lint") },
235
- ],
236
- { version, log: console.log },
237
- )
238
- process.exit(failed === 0 ? 0 : 1)
239
- ```
114
+ `tasks list state:open priority>1`: a port of the Lucene 9.12.3 parser, with the fields read from the entity's own
115
+ schema — strings, choices, numbers, times, booleans, references. A list filters where its data lives, in its store or
116
+ at the provider, never in memory; `evaluate` exists only as a test oracle for those compilers. An unknown field fails
117
+ with the list of fields that exist.
240
118
 
241
- ## Where secrets actually go, per platform
119
+ ### Secrets in the OS keyring, on every platform
242
120
 
243
- The keyring is real on every desktop and absent on most servers, so the fallback is not an edge
244
- case — it is the normal path in CI and containers. Checked against `@napi-rs/keyring` 2.1.0 on
245
- 2026-09-19.
121
+ Credentials resolve environment → keyring → file, warning once and falling through when the keyring refuses.
246
122
 
247
123
  | Platform | Backing store | Needs installing |
248
124
  |---|---|---|
@@ -252,164 +128,67 @@ case — it is the normal path in CI and containers. Checked against `@napi-rs/k
252
128
  | Linux headless, container, WSL, CI | usually **nothing** — no session bus, no secrets daemon | falls back to a `0600` file, with one warning on stderr |
253
129
  | FreeBSD | Secret Service, same as Linux | same |
254
130
 
255
- **No compiler is involved.** The package ships prebuilt binaries for twelve platform triples —
256
- macOS arm64/x64, Windows x64/ia32/arm64, Linux x64 and arm64 in both glibc and musl, armv7,
257
- riscv64, FreeBSD x64 — so there is no Rust toolchain and no node-gyp on any mainstream target.
258
- The Linux binary links only against libc: it speaks D-Bus itself rather than through libsecret,
259
- so *libsecret is not a requirement* — a running Secret Service provider is.
260
-
261
- Two consequences worth designing for rather than discovering:
262
-
263
- - **`auto` is the right default and `file` must stay available.** `credentialStorage: "file"` skips
264
- the keyring entirely, which is what a container wants and what a locked keyring makes necessary.
265
- - **A locked Linux keyring can block on a prompt** rather than failing. That is the one case the
266
- fallback does not rescue, and a CLI that hangs looks broken rather than locked.
267
-
268
- ## Both runtimes
269
-
270
- Node 22+ and Bun, and the Bun half is executed rather than assumed:
271
-
272
- ```sh
273
- pnpm test # vitest, Node
274
- pnpm smoke:bun # the same exports, actually run under Bun
275
- pnpm lint
276
- pnpm typecheck
277
- pnpm build
278
- ```
131
+ **No compiler is involved.** Prebuilt binaries cover twelve platform triples, and the Linux one speaks D-Bus itself,
132
+ so libsecret is not a requirement. Checked against `@napi-rs/keyring` 2.1.0 on 2026-09-19.
279
133
 
280
- The rest of the checks, and what each is for, are in [`docs/dev/TESTING.md`](docs/dev/TESTING.md).
134
+ Two traps worth designing for: the keyring is global, so a throwaway config directory overwrites the real secret
135
+ unless you pass `isolated: true`; and a locked Linux keyring can block on a prompt rather than fail.
281
136
 
282
- ## Shared tooling
137
+ ### Time, retries and deadlines
283
138
 
284
- The configuration, development scripts and CI that every CLI on cli-base used to copy. A repository
285
- takes each piece on its own.
139
+ - **Monotonic and wall clocks, and one sleep** that both timeouts and backoff use, so a fake clock controls both.
140
+ - **Retries with full-jitter backoff**, and a distinction most retry helpers miss: "no answer came" is not "safe to
141
+ repeat". A write whose outcome is unknown stays unknown.
142
+ - **Cooperative deadlines.** A timeout waits for the command to settle before it rejects, so resources close in order.
286
143
 
287
- **TypeScript.** Compiler options only; paths and file lists stay in the repository, because `tsc`
288
- resolves them against the file that declares them.
144
+ ### Files on disk
289
145
 
290
- ```jsonc
291
- // tsconfig.json
292
- {
293
- "extends": "@wirecat/cli-base/tsconfig.base.json",
294
- "compilerOptions": { "rootDir": "src", "outDir": "dist" },
295
- "include": ["src/**/*.ts"],
296
- "exclude": ["src/**/*.test.ts"]
297
- }
298
- // tsconfig.test.json — checks every file under src/, tests included, without emitting
299
- { "extends": ["./tsconfig.json", "@wirecat/cli-base/tsconfig.test.json"] }
300
- // tsconfig.scripts.json — checks scripts/
301
- { "extends": ["./tsconfig.json", "@wirecat/cli-base/tsconfig.scripts.json"] }
302
- ```
146
+ - **Paths:** the XDG layout on Linux and macOS (`~/.config`, `~/.local/share`, `~/.cache`), `AppData` on Windows,
147
+ each overridable by environment variable. Folders an older release kept in `~/Library` move once.
148
+ - **Config** that names the bad field, and an atomic write that locks the directory down.
149
+ - **Logs** as one JSON record per line with OpenTelemetry field names; secrets are redacted by field name at any
150
+ depth.
303
151
 
304
- **Biome.** Formatter, linter and the rule that lets `scripts/` print. Your `files.includes` and
305
- your own `overrides` (import boundaries) stay in your file; Biome adds them to the shared ones.
306
- Write the specifier without `.json`: Biome reads anything ending in `.json` as a relative path.
152
+ ### Generated API clients that never guess
307
153
 
308
- ```json
309
- { "extends": ["@wirecat/cli-base/biome"], "files": { "includes": ["**", "!**/dist", "!**/node_modules", "!pnpm-lock.yaml"] } }
310
- ```
154
+ `/codegen` turns an API description into TypeScript types, Valibot schemas, an operation manifest and a coverage page.
155
+ Every operation is classified as a read, a write or destructive, with how far the source can be trusted. Generation
156
+ stops, naming the problem, on a collision, an unclassified operation or a dangling reference — it never drops an
157
+ operation and never falls back to `any`. 64-bit integers stay exact.
311
158
 
312
- **lefthook.** Biome and gitleaks on staged files before a commit, `pnpm typecheck` before a push.
313
- An extended file **overrides** your `lefthook.yml`; to change a shared hook, use `lefthook-local.yml`.
159
+ ### Built for agents and for keeping installs current
314
160
 
315
- ```yaml
316
- extends:
317
- - node_modules/@wirecat/cli-base/config/lefthook.yml
318
- ```
161
+ - **`skill show` and `skill install`** give a coding agent the CLI's own guide, versioned with the CLI.
162
+ - **MCP setup** for Codex and Claude Code, with a read-only handshake probe.
163
+ - **Shell completion** from the command tree, answered from local data only — a shell asks on every Tab.
164
+ - **Update checks that never update unasked.** Which package manager installed the CLI, the command that updates it,
165
+ and a daily notice only when a person is at a terminal. A tool holding someone's credentials does not change itself.
166
+ - **Release checks** every CLI shares: changelog shape, dead links, package contents, versions in step.
319
167
 
320
- **`cli-dev`.** Installed with the package; `pnpm exec cli-dev help` lists the commands.
168
+ ## Engineering
321
169
 
322
- | Instead of | Run |
323
- |---|---|
324
- | `scripts/slow-tests.ts` | `cli-dev slow-tests [report] [count]` |
325
- | `scripts/next-version.mjs` | `cli-dev next-version <wanted> <latest> <versions-json>` |
326
- | `scripts/version.ts` | `cli-dev version [--sync] [--file src/version.ts]` |
327
- | `scripts/docs-check.ts` | `cli-dev docs-check --rules scripts/release/checks.ts`, or `--ids CLI,MAX` with the English defaults |
328
- | `scripts/test-matrix.ts` | `cli-dev test-matrix --program dist/program.js --untested scripts/test-matrix-untested.ts --name max [--check]` |
329
-
330
- | — | `cli-dev lint --cli dist/bin.js` (or `--commands <file>`): the command tree against the standard |
331
-
332
- `cli-dev lint` reads `<cli> commands --json`, refuses a document not in `commandsDocument`'s shape, and
333
- judges it by `LINT_RULES` and `VOCABULARY` from `/standards`. Known violations live in
334
- `lint/baseline/<rule-id>.json`, one file per rule, and may only shrink: an entry with no violation left
335
- fails the run. `--write-baseline` rewrites those files to today's violations. Permanent exceptions go in
336
- `lint/overrides.json` — `[{ "rule", "target", "reason" }]` — and more than `--budget` (10) of them fails.
337
- Every option needs a role from the vocabulary (`paging`, `output`, `sort`, `mode`, `action`, `budget`, `scope`,
338
- `target`, `value`, `query`) — there is no filter role. `filter-flag` reports an option of a read-only command
339
- named after a field of its entity (`--kind`, `--since-time`, `--before-id`), except a `scope` flag such as
340
- `--account`; `list-without-query` a `list` with an `output` entity and no query; `query-example` a field example
341
- or default term that does not run. `--schemas <file>` names a JSON object of the CLI's own JSON Schemas by name; `key-casing` checks their
342
- keys, cli-base's envelope, error, log record and commands document schemas, and the document as printed. Its entry
343
- `log_attributes` (`logAttributesJsonSchema(allow)`) is the CLI's log allow-list instead: `log-attribute-key` fails a
344
- key that names message text, a token or a phone number.
345
-
346
- `--rules` names a module exporting `CHANGELOG` and `docsRules(root)`; `--program` one exporting
347
- `createProgram`; `--untested` one exporting `UNTESTED`. A TypeScript module loads on Node 24 as it is.
348
-
349
- **Docs.** Spelling, Markdown form and prose rules for the pages a user reads. The tools are your
350
- own dev dependencies (`cspell`, `@cspell/dict-ru_ru`, `@cspell/dict-en-gb`, `rumdl`); the settings
351
- and the shared word list are here. Code blocks and inline code are not spell-checked: commands and
352
- options are checked against the program by the parity check.
353
-
354
- ```jsonc
355
- // cspell.json — the dictionaries resolve from your node_modules, so you import them yourself
356
- {
357
- "version": "0.2",
358
- "import": ["@wirecat/cli-base/cspell", "@cspell/dict-en-gb/cspell-ext.json", "@cspell/dict-ru_ru/cspell-ext.json"],
359
- "ignorePaths": ["docs/commands.md", "docs/dev/**", "CHANGELOG.md"],
360
- "words": []
361
- }
362
- ```
170
+ - **Both runtimes, executed.** Node 22+ and Bun; `pnpm smoke:bun` runs the same exports under Bun rather than
171
+ assuming they work.
172
+ - **One shared toolchain.** TypeScript, Biome, lefthook, CSpell, a Markdown linter and a reusable CI workflow — install, lint,
173
+ typecheck, test with coverage, build, Bun and a gitleaks scan of the whole history. A CLI takes each piece on its
174
+ own: [Shared tooling](docs/TOOLING.md).
175
+ - **`cli-dev`** replaces the scripts every CLI used to copy: version sync, docs checks, slow-test reports, the test
176
+ matrix and the command-tree lint.
363
177
 
364
178
  ```sh
365
- cspell --no-progress README.md "docs/*.md"
366
- rumdl check --config node_modules/@wirecat/cli-base/config/rumdl.toml README.md docs/*.md
367
- vale README.md docs/*.md # .vale.ini: StylesPath = node_modules/@wirecat/cli-base/config/vale
368
- ```
369
-
370
- `cli-dev docs-check --pages` adds the structure check: `docs/` against `docs/meta.json`, the
371
- sidebar of [the docs portal](https://github.com/WireCatLabs/cli-docs/blob/main/docs/STRUCTURE.md).
372
- Vale's styles are `CliDocs` (filler words, long sentences) and `Tg` (tg's tone: a Telegram client,
373
- never "unofficial"); its rules warn rather than fail until they are tuned.
374
-
375
- **CI.** Install, lint, typecheck, test with coverage, build, your extra checks, Bun, and a gitleaks
376
- scan of the whole history. Jobs only your repository needs stay beside it in your `ci.yml`.
377
-
378
- ```yaml
379
- jobs:
380
- ci:
381
- uses: WireCatLabs/cli-base/.github/workflows/node-ci.yml@v<version>
382
- with:
383
- checks: |
384
- pnpm docs:check
385
- bun-run: pnpm build && pnpm smoke:bun # the default is pnpm smoke:bun
179
+ pnpm test # vitest, Node
180
+ pnpm smoke:bun # the same exports, run under Bun
181
+ pnpm lint
182
+ pnpm typecheck
183
+ pnpm build
386
184
  ```
387
185
 
388
- Inputs: `node-version` (`"24"`), `checks` (shell lines run after the build), `bun` (`true`),
389
- `bun-run`. The workflow is in the repository, not the package: pin a tag or a commit, not `main`.
390
-
391
186
  ## Releasing
392
187
 
393
- Raise `version` in `package.json` and date the `## Unreleased` section of
394
- [`CHANGELOG.md`](CHANGELOG.md) as that version, through a pull request; merge it, then on `main`:
395
-
396
- ```sh
397
- bin/release
398
- ```
399
-
400
- It refuses a dirty tree, a branch other than `main` and a `main` that is not pushed. When npm already
401
- has the version, or a higher one, it commits the next free version to `main` — the next minor for
402
- `x.y.0`, the next patch otherwise — renames the changelog heading to match, and publishes that. Then it starts
403
- [`release.yml`](.github/workflows/release.yml) and follows it. The workflow runs every check,
404
- publishes through npm's [trusted publishing](https://docs.npmjs.com/trusted-publishers/) — no npm
405
- token in GitHub, and npm attaches provenance — and tags `v<version>` only once npm shows the new version.
406
-
407
- npm trusts the workflow **by file name**: the package's Trusted Publisher settings on npmjs.com name
408
- `WireCatLabs` / `cli-base` / `release.yml`. Rename the file and publishing stops until they are updated.
409
-
410
- `bin/release --local` is the fallback: it runs the same checks here and publishes with the npm token
411
- from the keyring (`secret-tool`, service `npm`, account `leemour`) without printing it. The token
412
- must be allowed to write `@wirecat/cli-base`, not only `@wirecat/max-cli`.
188
+ `bin/release` on `main` starts [`release.yml`](.github/workflows/release.yml), which runs every check and publishes
189
+ through npm's [trusted publishing](https://docs.npmjs.com/trusted-publishers/) with provenance — no npm token in
190
+ GitHub. It tags `v<version>` once npm shows the new version. The steps are in [RELEASING.md](docs/dev/RELEASING.md); how a release reaches the tools is in the
191
+ [architecture](docs/dev/ARCHITECTURE.md#how-a-change-is-published-and-picked-up).
413
192
 
414
193
  ## Licence
415
194
 
@@ -0,0 +1,64 @@
1
+ import type { CommandsDocument } from "../commands/document.js";
2
+ import { type AuditRule, type ParityGroup, type RequiredRoot } from "../standards/audit.js";
3
+ import { type ErrorReason } from "../standards/errors.js";
4
+ import { type Override } from "../standards/lint.js";
5
+ import type { Vocabulary } from "../standards/vocabulary.js";
6
+ export interface AuditInput {
7
+ document: CommandsDocument;
8
+ /** As the CLI printed it, for `key-casing`. */
9
+ raw: unknown;
10
+ schemas?: Readonly<Record<string, unknown>>;
11
+ reasons?: readonly ErrorReason[];
12
+ /** The CLI's own `lint/` baseline and overrides; its lint rules are then judged by them, not the audit's. */
13
+ lint?: {
14
+ baseline: Readonly<Record<string, readonly string[]>>;
15
+ overrides: readonly Override[];
16
+ budget: number;
17
+ };
18
+ }
19
+ export interface Finding {
20
+ rule: string;
21
+ clis: string[];
22
+ /** `<clis> <what>`: `max+tg chats list --limit`, `zm upgrade`, `tg: chats grab` for a lint violation. */
23
+ target: string;
24
+ message: string;
25
+ }
26
+ export interface ScoreRow {
27
+ cli: string;
28
+ rule: string;
29
+ /** False when the CLI gave no input the rule reads: no schemas, no reasons. Its counts are then 0, not a pass. */
30
+ measured: boolean;
31
+ found: number;
32
+ baseline: number;
33
+ overridden: number;
34
+ new: number;
35
+ fixed: number;
36
+ new_targets: string[];
37
+ }
38
+ export interface AuditOptions {
39
+ groups: readonly ParityGroup[];
40
+ roots: readonly RequiredRoot[];
41
+ vocabulary: Vocabulary;
42
+ /** `audit/baseline/<rule>.json`, by rule. */
43
+ baseline: Readonly<Record<string, readonly string[]>>;
44
+ blocks: "new" | "never";
45
+ /** Only new findings that name this CLI block: the one being released. */
46
+ only?: string;
47
+ }
48
+ export interface AuditResult {
49
+ findings: Finding[];
50
+ rows: ScoreRow[];
51
+ /** New findings that fail the run. */
52
+ blocking: Finding[];
53
+ /** What `--write-baseline` writes, by rule. */
54
+ nextBaseline: Record<string, string[]>;
55
+ problems: string[];
56
+ }
57
+ /** A document from cli-base before 0.26 prints camelCase keys; the audit reads both, `key-casing` still reports it. */
58
+ export declare const snakeKeys: (value: unknown) => unknown;
59
+ /** The CLIs a target names: `max+tg chats list` → max, tg; `tg: chats grab` → tg. */
60
+ export declare const clisOf: (target: string) => string[];
61
+ export declare const auditClis: (inputs: readonly AuditInput[], options: AuditOptions, rules?: readonly AuditRule[]) => AuditResult;
62
+ /** One line per CLI and rule that found something, then one total per CLI. */
63
+ export declare const scorecardTable: (rows: readonly ScoreRow[]) => string;
64
+ //# sourceMappingURL=audit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../../src/dev/audit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAA;AAE/D,OAAO,EAAe,KAAK,SAAS,EAAE,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAA;AACxG,OAAO,EAAgB,KAAK,WAAW,EAAE,MAAM,wBAAwB,CAAA;AACvE,OAAO,EAA+B,KAAK,QAAQ,EAAE,MAAM,sBAAsB,CAAA;AACjF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAA;AAG5D,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,gBAAgB,CAAA;IAC1B,+CAA+C;IAC/C,GAAG,EAAE,OAAO,CAAA;IACZ,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IAC3C,OAAO,CAAC,EAAE,SAAS,WAAW,EAAE,CAAA;IAChC,6GAA6G;IAC7G,IAAI,CAAC,EAAE;QAAE,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;QAAC,SAAS,EAAE,SAAS,QAAQ,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAA;CACjH;AAED,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,EAAE,CAAA;IACd,yGAAyG;IACzG,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,QAAQ;IACvB,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,kHAAkH;IAClH,QAAQ,EAAE,OAAO,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,EAAE,CAAA;CACtB;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,SAAS,WAAW,EAAE,CAAA;IAC9B,KAAK,EAAE,SAAS,YAAY,EAAE,CAAA;IAC9B,UAAU,EAAE,UAAU,CAAA;IACtB,6CAA6C;IAC7C,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAA;IACrD,MAAM,EAAE,KAAK,GAAG,OAAO,CAAA;IACvB,0EAA0E;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,OAAO,EAAE,CAAA;IACnB,IAAI,EAAE,QAAQ,EAAE,CAAA;IAChB,sCAAsC;IACtC,QAAQ,EAAE,OAAO,EAAE,CAAA;IACnB,+CAA+C;IAC/C,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;IACtC,QAAQ,EAAE,MAAM,EAAE,CAAA;CACnB;AAQD,uHAAuH;AACvH,eAAO,MAAM,SAAS,UAAW,OAAO,KAAG,OAM1C,CAAA;AAED,qFAAqF;AACrF,eAAO,MAAM,MAAM,WAAY,MAAM,KAAG,MAAM,EAA+D,CAAA;AAiM7G,eAAO,MAAM,SAAS,WACZ,SAAS,UAAU,EAAE,WACpB,YAAY,UACd,SAAS,SAAS,EAAE,KAC1B,WAwHF,CAAA;AAOD,8EAA8E;AAC9E,eAAO,MAAM,cAAc,SAAU,SAAS,QAAQ,EAAE,KAAG,MAgB1D,CAAA"}