The foundation for command-line tools that people and AI agents both use. Output for a person and for a program, a closed error model with stable exit codes, secrets in the OS keyring, injectable clocks, one query language, the command tree as data — the parts every CLI needs and nobody should write twice.
Architecture · API reference · Shared tooling · Testing · Changelog · wirecat.dev
| Document | What it answers |
|---|---|
| Architecture | The entry points, the lines this package does not cross, who consumes what |
| API reference | What each entry point holds, with examples |
| Shared tooling | The TypeScript, Biome, lefthook, docs and CI setup a CLI takes from here, and cli-dev |
| Testing | The checks, and what each one is for |
| Conventions | How code here is written |
| CLI standard | The rules every WireCat CLI follows; this package holds them as data |
| Changelog | What each version changed, and what may break callers |
Every WireCat tool is built on it: tg-cli, max-cli, and cli-core — the shared messaging engine — and through it the mail, notes, Zoom and Drive tools.
Up to version 0.20.0 (October 2026) this package was @wirecat/cli-core. That name now belongs to the shared
messaging engine: in older changelogs, plans and issues, "cli-core" means this package.
One rule shapes everything here: in a machine mode, stdout carries data and nothing else. No spinner, no ✓, no
warning, no ANSI. Diagnostics go to stderr, in every mode including the pretty one. That is the contract a script or
an agent depends on, and captureStreams exists so a test can prove nothing leaked across.
import { captureStreams, createRenderer } from "@wirecat/cli-base"
const streams = captureStreams()
createRenderer({ format: "json", color: false, streams }).result({ chats: 2 })
streams.stdout // ['{"chats":2}']
streams.stderr // []The package is split by when a piece runs, so a tool loads only what it calls:
| When | Entry points |
|---|---|
| Every command | root: streams, renderer, errors and exit codes, keyring, credentials, paths, config, time, retry, logging, field selection, output buffer, deadlines |
| Only if the tool speaks HTTP | /http |
| Commands that describe the tool | /commands, /completion, /query, /skill, /mcp, /update, /standards |
| Build time | /codegen, with a small /codegen/runtime for the generated schemas |
| Release time | /release |
| Tests | /testing: captured streams, memory and broken keyrings, a fake clock |
| Configuration | biome, tsconfig.*.json, lefthook.yml, cspell presets |
Three lines this package does not cross:
- Nothing in the root export is HTTP. Status classification,
Retry-Afterand the fetch seam live in/http, so a CLI that speaks a WebSocket never carries an HTTP stack. - The environment is an argument. The clock, the sleep, the keyring,
env,fetchand the streams are all parameters; the real ones are only defaults. A timeout test finishes instantly, and a keyring test cannot reach a real keychain. - It logs nothing and prints nothing by itself. It hands the host a
Loggerinterface, a Pino adapter and a renderer; what is written, and where, is the host's call.
Five runtime dependencies: valibot and its JSON Schema converter, cli-table3, picocolors and cross-spawn. Commander is an optional peer, used as a type.
- Three formats from one renderer:
prettyfor a person — tables for lists, labelled lines for objects — andjsonorjsonlfor a program.quietkeeps only failures. - Field selection (
--json=id,speaker.id) that keeps the list envelope and the operation ids, and refuses unsafe paths. - A bounded output buffer. Output waits until the command succeeds and has closed what it holds; past the limit (4 MiB by default) it fails visibly instead of truncating.
Fourteen closed error codes, one exit number each: validation, configuration, authentication, permission, not found,
rate limited, timeout, network, provider, outcome unknown and the rest. A script branches on $?, an agent on the
code and reason in one JSON object on stderr. Errors are recognised by a brand, so two installed copies of the
package still understand each other's errors.
The rules every WireCat CLI follows — the verb vocabulary, the output envelope, the error reasons, next steps, the log
record — live in /standards as Valibot schemas that also emit JSON Schema 2020-12.
- The whole command tree as data.
describeProgramwalks the live Commander tree, likerails routes: every command, option, default, conflict and whether it writes.commands --jsongives it to an agent instead of--help. - A linter for command trees.
cli-dev lintchecks a CLI's tree against 15 rules — unknown verbs, filter flags that should be query fields, examples that do not run, key casing and more. Known violations live in a baseline that may only shrink. - An audit across CLIs.
cli-dev auditreads several CLIs' command trees and checks them against each other: the commands every CLI must have, the same options and output where two CLIs share a path, the same meaning for the same flag, the same entity schemas and error reasons. A weekly workflow runs it over the published CLIs.
tasks list state:open priority>1: a port of the Lucene 9.12.3 parser, with the fields read from the entity's own
schema — strings, choices, numbers, times, booleans, references. A list filters where its data lives, in its store or
at the provider, never in memory; evaluate exists only as a test oracle for those compilers. An unknown field fails
with the list of fields that exist.
Credentials resolve environment → keyring → file, warning once and falling through when the keyring refuses.
| 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. Prebuilt binaries cover twelve platform triples, and the Linux one speaks D-Bus itself,
so libsecret is not a requirement. Checked against @napi-rs/keyring 2.1.0 on 2026-09-19.
Two traps worth designing for: the keyring is global, so a throwaway config directory overwrites the real secret
unless you pass isolated: true; and a locked Linux keyring can block on a prompt rather than fail.
- Monotonic and wall clocks, and one sleep that both timeouts and backoff use, so a fake clock controls both.
- Retries with full-jitter backoff, and a distinction most retry helpers miss: "no answer came" is not "safe to repeat". A write whose outcome is unknown stays unknown.
- Cooperative deadlines. A timeout waits for the command to settle before it rejects, so resources close in order.
- Paths: the XDG layout on Linux and macOS (
~/.config,~/.local/share,~/.cache),AppDataon Windows, each overridable by environment variable. - Config that names the bad field, and an atomic write with the file's own mode. A directory it creates gets
0700; a directory that already exists keeps its mode. - Logs as one JSON record per line with OpenTelemetry field names. A top-level key outside the allow-list is
written as
[not allowed]. At any depth, a field named exactly as inREDACTED_FIELDSor the program'sredactlist (case-sensitive) and a field whose name marks message content are written as[redacted].
/codegen turns an API description into TypeScript types, Valibot schemas, an operation manifest and a coverage page.
Every operation is classified as a read, a write or destructive, with how far the source can be trusted. Generation
stops, naming the problem, on a collision, an unclassified operation or a dangling reference — it never drops an
operation and never falls back to any. 64-bit integers stay exact.
skill showandskill installgive a coding agent the CLI's own guide, versioned with the CLI.- MCP setup for Codex and Claude Code, with a read-only handshake probe.
- Shell completion from the command tree, answered from local data only — a shell asks on every Tab.
- Update checks that never update unasked. Which package manager installed the CLI, the command that updates it, and a daily notice only when a person is at a terminal. A tool holding someone's credentials does not change itself.
- Release checks every CLI shares: changelog shape, dead links, package contents, versions in step.
- Both runtimes, executed. Node 22+ and Bun;
pnpm smoke:bunruns the same exports under Bun rather than assuming they work. - One shared toolchain. TypeScript, Biome, lefthook, CSpell, a Markdown linter and a reusable CI workflow — install, lint, typecheck, test with coverage, build, Bun and a gitleaks scan of the whole history. A CLI takes each piece on its own: Shared tooling.
cli-devreplaces the scripts every CLI used to copy: version sync, docs checks, slow-test reports, the test matrix and the command-tree lint.
pnpm test # vitest, Node
pnpm smoke:bun # the same exports, run under Bun
pnpm lint
pnpm typecheck
pnpm buildbin/release on main starts release.yml, which runs every check and publishes
through npm's trusted publishing with provenance — no npm token in
GitHub. It tags v<version> once npm shows the new version. The steps are in RELEASING.md; how a release reaches the tools is in the
architecture.
