Repository navigation
REFERENCE
What each entry point holds and how to use it. The README explains what the package is and why it is built this way.
streams |
the stdout/stderr split, and the capture used to test it |
renderer · pretty
|
pretty / json / jsonl; tables for lists, labelled lines for objects; quiet keeps only failures on stderr |
errors · exit-codes
|
14 closed error codes, one exit number each, so a script can branch on $?
|
keyring |
KeyringStore with the system, memory and deliberately-broken implementations |
prepareBatch · readBatchInput · runBatch · projectBatchAnswers
|
bounded registered-handler batches with independent correlated answers |
fieldsOf · projectFields
|
validated field selection preserving list metadata and operation identifiers |
createOutputBuffer |
bounded UTF-8 output held until successful completion and resource cleanup |
createDeadline |
cooperative command cancellation with injected sleep and a parent abort signal |
time |
monotonic and wall clocks, and the one sleep that both timeouts and backoff use |
logger |
the four-method interface a host adapts Pino to — this package logs nothing itself |
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 |
config |
JSON config loading that names the bad field, and an atomic write that locks the directory down |
credentials |
environment → keyring → file, warning once and falling through when the keyring refuses |
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 |
retry |
full-jitter backoff, and the distinction between "no answer came" and "safe to repeat" |
/testing |
captureStreams, memoryKeyring, brokenKeyring, fakeClock
|
/commands |
the command registry: describeProgram, annotate, annotateOption, flatten, refuseJoinedPath, and commandsDocument — the one commands --json shape, with its schema — see below |
/completion |
shell completion over the registry: suggest, formatSuggestions — see below |
/update |
keeping an install current: installerOf, updateCommand, latestVersion, mayNotify, updateNotice, runUpdate — see below |
/codegen |
build time: an official API description → types, Valibot schemas, an operation manifest and a coverage page — see below |
/release |
release time: releaseCheck and the checks it runs — changelog shape, links, package contents, version in step — see below |
/skill |
the CLI's SKILL.md for coding agents: skillCommand (show, install), skillHint, skillResource — see below |
/mcp |
local stdio MCP setup for Codex and Claude Code, plus a read-only handshake and tool-list probe |
/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 |
/standards |
the run-time contract: effects, output envelope, error object and reasons, next steps, log record — as Valibot schemas that also emit JSON Schema 2020-12 |
/standards/tables |
the tables lint, audit and docs read: vocabulary, lint and audit rules, parity groups, identifiers, output field rules |
Nothing in the root export is HTTP. Status classification, Retry-After parsing and the fetch
seam live in @wirecat/cli-base/http, so a CLI that speaks a socket never depends on a stack it
does not call:
import { providerWaitMs, readJsonResponse, statusToCode } from "@wirecat/cli-base/http"readJsonResponse(response, { maxBytes, signal }) reads an existing response within an explicit
positive byte budget, then decodes strict UTF-8 JSON. It bounds declared length and actual body
bytes, cancels on rejection/interruption and releases its own reader. It never fetches or interprets
HTTP status, provider fields or credentials. A caller still owns those policies and its deadline.
Portable command control stays independent of provider and store lifetimes.
fieldsOf("id,speaker.id") parses up to 128 field paths; projectFields(value, paths) selects data
from objects or list items while preserving the list envelope and operation_id.
items.id also works for bare objects and JSONL items, and item.id for the one thing in an item
envelope. Unknown fields are omitted; parent selections win over their children. Unsafe prototype paths are refused, including when paths are passed directly.
prepareBatch(json, handlers) validates 1–32 uniquely named requests before work begins. Each request
names a registered command path and string argument array; handler preparation must be pure. readBatchInput
reads at most 256 KiB of strict UTF-8 from an injected async source; the host owns cancelling a blocked source.
runBatch runs the prepared operations sequentially. Operations reserve stored-read ceilings before reads,
with a 64 MiB aggregate cap; reservations are never refunded or reported as measured consumption. A 4 MiB
retained-result cap leaves room for correlated refusals. Errors stay with their input, while an aborted signal
rejects the invocation. Hosts still enforce final rendered bytes, emit standard JSONL framing and close resources.
projectBatchAnswers applies ordinary payload selectors to each answer while keeping result correlation/errors.
createOutputBuffer({ maxOutputBytes }) retains raw output chunks until flush(write) or discard().
Its default limit is 4 MiB; zero disables the limit. It counts actual UTF-8 bytes, including delimiters
the caller passes, and adds no newlines. Use a new buffer for each command and flush only after resource
cleanup succeeds; discard on failure. A flush consumes the buffer even when its writer throws.
The bytes property reports the cumulative accepted byte count. A writer failure can leave partial
output; buffering only guarantees no output before flushing.
Optional { fields, format: "json" | "jsonl" } projects before counting bytes. With projection,
each data(text) must contain a complete JSON value or complete JSONL lines; fragments fail before
buffering. Without projection, arbitrary text chunks work. Hosts can instead project values before
rendering and buffer the renderer's raw text. For core renderers, pass the line delimiter explicitly
when adapting Streams.data: data: (text) => buffer.data(text + "\n").
createDeadline({ timeoutMs, signal, sleep }) supplies a command signal and race(body). An omitted
timeout creates no timer; zero aborts synchronously without calling sleep, and the maximum is 2147483647 milliseconds.
Pass its signal to every cancellable operation and check it before writes. On timeout or parent
cancellation, race waits for the body to settle before rejecting, so a caller can safely close
resources afterwards. An operation ignoring its signal can delay completion; this helper never
abandons it in the background. A body's more precise outcome_unknown error survives interruption.
Call dispose() in finally to remove the timer and parent listener. There are no process signal
handlers, retries, automatic resource cleanup or HTTP behavior in these helpers.
The command registry is @wirecat/cli-base/commands — the whole command tree as data, like
rails routes, for an agent to read instead of --help and for generated documentation. It walks
the live Commander tree, so a command built in a loop from a
catalog appears exactly like a handwritten one; annotate adds what the tree cannot say. Commander
is a type here, not a runtime dependency — an optional peer, 15 or newer.
import { annotate, describeProgram } from "@wirecat/cli-base/commands"
annotate(program.command("send"), { effects: { remote: "write", local: "none" }, examples: ["max messages send 42 hi"] })
annotate(generated, { origin: "generated", api_operation: "campaigns.list" })
describeProgram(program) // [{ path: ["send"], usage, origin, effects, options: [...], commands: [...] }]effects says what a command touches, remote (the service) and local (this machine), each none, read or
write. A command without it counts as a write: the error handler never marks its failure safe to retry
(error.retry.safe), and a next step that runs it is never safe_to_auto_execute. stepTargets(program) gives
stepOf and errorObjectOf each command's effects and the names it takes, so a step that would not bind is dropped.
A group with an action of its own says runnable: true; a group without it only prints its help. cli-dev lint
(missing-command-effects) asks effects of every leaf and every runnable group.
lifetime says how long a command runs: persistent until it is stopped (a server, a watch), interactive until a
person answers (a login); unset, the runner bounds it with its default timeout. cooperative marks a long command that
stops at its own pace when the run is aborted from outside, keeping what it already did. A runner reads both with
metaOf(command) rather than matching command names.
reaches: "other_service" says the remote side of a command's effects is not the CLI's own service but another
one: an embedding or analysis provider, a model host. It belongs to that command alone. commandsPage then labels a
remote write "Sends text to an outside service" and a remote read "Reads from an outside service" (labels.sendsOutside,
labels.readsOutside), instead of the CLI's mutates or mutatesLocal line.
mcp: false keeps a command, and every command under it, off MCP servers; the CLI itself still runs it. The command
description says mcp: false on that command and each one below it, and leaves the key out everywhere else, so an
MCP server builds its tool list from describeProgram without walking the parents itself. On one option,
annotateOption(new Option("--for-everyone", "…"), { mcp: false }) leaves it out of MCP tools' input, and that
option alone says mcp: false; optionMetaOf(option) reads it back.
offline: false marks a command, and every command under it, that cannot answer from the local copy — a sync, or a
read only the service can answer. The description carries it the same way as mcp: false, so an MCP server offers
no offline input on that tool. The CLI's global --offline still reaches the command, which refuses it itself.
Each option says whether it takes_value and, separately, whether it is mandatory — not
Commander's required, which means "takes a value when given" — plus its choices, default,
environment variable, the options it conflicts with, the values it implies, keyed by long flag, and mcp: false
when it is kept off MCP tools.
Every key of the document is snake_case.
A list filters with one query, @wirecat/cli-base/query — tasks list state:open priority>1, the
query positional and last, [query...], over the entity's own snake_case keys. queryFieldsOf(schema, annotations)
reads the fields from the entity's Valibot schema (string → kw, a list of choices → enum, number → num, isoTime →
time, boolean → bool, reference → ref); annotations add the exceptions: words (text), relations,
is: states, examples, the fields a provider filters natively. parseLucene(text, { defaultField: "none", command }) refuses a bare word (query.bare-word); validateQuery checks names, operators, values and
placement and names every field when one is unknown; fieldsHelp prints the help's Fields section;
QUERY_REASONS registers the errors. A list filters where its data lives — its store or the provider — and
never in memory: evaluate exists to test those compilers against. annotate(list, { output: { entity }, query }) puts both in commands --json, where cli-dev lint reads them.
refuseJoinedPath(program, args) throws a validation_error when a command path arrived as one
argument ("projects add" --help, what a zsh loop over $c passes), naming the words to type.
Commander would print the root help instead; call it on the words before parseAsync, after a
leading profile word if the CLI takes one.
Shell completion is @wirecat/cli-base/completion, built on the registry. suggest takes the
words typed so far and answers what may come next: a command, an action, an option, one of its
values, or an argument's values from a source the CLI hands in — a local cache, never the network,
because a shell calls it on every Tab. formatSuggestions writes the answer in the protocol of the
shell scripts @bomb.sh/tab generates, so the CLI prints
those scripts with tab and answers <cli> complete -- <words> with this; cli-base itself does not
depend on tab.
import { formatSuggestions, suggest } from "@wirecat/cli-base/completion"
const words = argv.slice(argv.indexOf("--") + 1)
streams.data(formatSuggestions(suggest({ commands, globalOptions, words, sources: { arguments: { chat: chatNames } } })))Two traps worth knowing before you use the credential store. The OS keyring is global: an entry
is addressed by service and account and knows nothing about which config directory asked for it, so
a throwaway config directory silently overwrites the real secret unless you pass isolated: true.
And a secret should never be handed to a logger in the first place — redaction by field name is the
second line of defence, not the first.
Everything the environment knows can be passed in: the clock, the sleep, the keyring, env,
fetch and the streams. The real ones are only defaults — Credentials without a keyring uses the
system keychain, resolvePaths without env reads process.env. Passing them is what makes a
timeout test finish instantly and a keyring test incapable of reaching a real keychain.
Generating an API catalog is @wirecat/cli-base/codegen, a build-time tool. It reads a
format-neutral ApiModel — operations with a transport binding (http method and path, or rpc
name), an effect (read, write, destructive) and how far the source can be trusted
(contract, example, inferred, override) — and writes committed files. It parses no file
format: the adapter from OpenAPI, Postman or anything else lives in the CLI that has that source,
so this adds no dependency. Generation stops, naming the problem, when two operations collide, an
operation is not classified as a read or a write, an override matches nothing, a reference points
nowhere or a construct cannot be expressed; it never drops an operation or falls back to any.
import { generate, manifestGenerator, typesGenerator, valibotGenerator, writeArtifacts } from "@wirecat/cli-base/codegen"
const artifacts = generate(model, [typesGenerator({ path }), valibotGenerator({ path, typesImport }), manifestGenerator({ path })], {
overrides: { answerOnCallback: { effect: "write", reason: "a POST that edits a message" } },
banner: ["Source: spec/bot/schema.yaml", "Run: pnpm bot:generate"],
})
const stale = writeArtifacts(artifacts, { root, check: process.argv.includes("--check") })The generated schemas import @wirecat/cli-base/codegen/runtime at run time — a few number
helpers, not the generator. Numbers are expected from a lossless JSON parser (lossless-json): a
64-bit integer comes out as its exact decimal string, and any other integer that does not fit a JS
number fails instead of rounding. Objects are loose, so a field the API added later passes through.
Unions retain every alternative, including recursive references and Boolean literals.
definitionsGenerator emits the same schema nodes for runtime input traversal; RPC parameters can be in the JSON body.
Enums and discriminated unions are strict: a value or subtype the snapshot does not know fails,
so decode a live stream (updates, webhooks) only where that is what you want.
The schemas decode; they do not encode. Their output carries an int64 as a string, so sending
it back would put a string where the API expects a number. Validate a request with the schema, then
send the original lossless value.
Keeping an install current is @wirecat/cli-base/update. installerOf(realpath(script)) says
which package manager put the CLI there — pnpm, npm or bun, measured on real installs — and
updateCommand gives the argv that updates it; a checkout or npx gets none. latestVersion asks
npm with a timeout and answers undefined on any failure. mayNotify says whether a person is at a
terminal to be told: never in JSON, a pipe, --quiet, CI or with NO_UPDATE_NOTIFIER.
updateNotice is the whole daily line: it stays silent while the upgrade (upgradeCommand, default
upgrade) or complete runs — one of the first two words that are not options — and names that
command in the line; it asks npm at most once a day through a state file the CLI names, and returns the
sentence or undefined — it never throws. runUpdate starts the package manager with its output on
stderr (spawnPlan is the Windows .cmd rule). Nothing here updates or prints by itself — a CLI
holding somebody's credentials must not change itself unasked.
A CLI's guide for coding agents is @wirecat/cli-base/skill. skillCommand(app, skillUrl, environment) is <cli> skill show — SKILL.md on stdout, even into a pipe, or { name, content }
with --json — and <cli> skill install [--for claude|agents|all], which writes it to
~/.claude/skills/<appName>/ and ~/.agents/skills/<appName>/ with the CLI's version as
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.
skillHint is one line, or undefined, for an agent (AI_AGENT or CLAUDECODE set) with no copy
installed or an older one, at most once a day, through the same state file as updateNotice; the
host prints it on stderr. skillResource is what an MCP server registers to serve the file as
<command>://skill, plus a line for its instructions — the SDK stays the host's.
The release checks are @wirecat/cli-base/release — everything about a release that a program
can decide. A check is { name, run }, and run returns the problems it found, one line each; an
empty list is a pass. releaseCheck(checks, { version, log }) runs them all, even after a failure,
prints ok or FAIL per check and returns how many failed — the caller exits. The checks every CLI
shares: command(root, "pnpm", "lint") for anything that passes by exiting 0, notOnNpm,
packContents against a list of what may ship, changelogProblems for the fixed headings and the
dated top section, docsProblems for dead links and anchors and the rules of the pages a user reads,
and versionScript — the whole of a version:check / version:sync script. Each CLI passes its
own headings, id prefixes and pages; the checks that belong to one messenger stay in that CLI.
A CLI whose released sections used other headings sets legacyHeadingsThrough: "<version>" in its
ChangelogRules: sections at or below that version are not checked for heading names or repeats.
import { command, notOnNpm, packageVersion, releaseCheck } from "@wirecat/cli-base/release"
const version = packageVersion(root)
const failed = releaseCheck(
[
{ name: "version not on npm", run: notOnNpm(root, "@wirecat/tg-cli", version) },
{ name: "lint", run: command(root, "pnpm", "lint") },
],
{ version, log: console.log },
)
process.exit(failed === 0 ? 0 : 1)The keyring is real on every desktop and absent on most servers, so the fallback is not an edge
case — it is the normal path in CI and containers. Checked against @napi-rs/keyring 2.1.0 on
2026-09-19.
| Platform | Backing store | Needs installing |
|---|---|---|
| macOS | Keychain, via the Security framework | nothing — part of the OS |
| Windows | Credential Manager | nothing — part of the OS |
| Linux desktop (GNOME, KDE) | Secret Service over D-Bus — gnome-keyring, kwallet
|
nothing on a normal desktop; the keyring must be unlocked |
| Linux headless, container, WSL, CI | usually nothing — no session bus, no secrets daemon | falls back to a 0600 file, with one warning on stderr |
| FreeBSD | Secret Service, same as Linux | same |
No compiler is involved. The package ships prebuilt binaries for twelve platform triples — macOS arm64/x64, Windows x64/ia32/arm64, Linux x64 and arm64 in both glibc and musl, armv7, riscv64, FreeBSD x64 — so there is no Rust toolchain and no node-gyp on any mainstream target. The Linux binary links only against libc: it speaks D-Bus itself rather than through libsecret, so libsecret is not a requirement — a running Secret Service provider is.
Two consequences worth designing for rather than discovering:
-
autois the right default andfilemust stay available.credentialStorage: "file"skips the keyring entirely, which is what a container wants and what a locked keyring makes necessary. - A locked Linux keyring can block on a prompt rather than failing. That is the one case the fallback does not rescue, and a CLI that hangs looks broken rather than locked.