Skip to content

Latest commit

 

History

History
113 lines (91 loc) · 8.91 KB

ARCHITECTURE.md

File metadata and controls

113 lines (91 loc) · 8.91 KB

Architecture

cli-base is the part of a command line tool that is not about any one service: the two output streams, the renderer, the error model and exit codes, the keyring and credentials, config and paths, clocks, retry, and — behind their own entry points — HTTP, the command registry, completion, self-update and code generation. What each export does is the table in the README; this page is about the seams and how a change travels.

Entry points

The shared modularity standard defines responsibility, dependency direction and review guidelines for module size. The entry-point map below applies it to this package.

Import Owns Depends on
@wirecat/cli-base streams, renderer, pretty, errors, exit codes, keyring, credentials, config, paths, logging, retry, time, sanitize, field projection, output buffering, deadlines cli-table3; @napi-rs/keyring loaded on first use
/http status → error code, Retry-After and rate-limit parsing, bounded UTF-8 JSON response reads, the fetch seam the error model and WallClock type
/commands the command tree as data (describeProgram, annotate, flatten), protected shared definitions and explicit overrides (sharedCommand, overrideSharedContract, customizeSharedCommand), the commands --json document and its schema (commandsDocument), and the reference page written from it (commandsPage) Commander at runtime for contract guards; valibot
/completion shell completion over that tree /commands types
/update which installer put the CLI there, the daily notice, running the update config (the state file), /http's FetchLike
/codegen build time: an OpenAPI document → ApiModel → types, Valibot schemas, manifest, coverage page nothing at run time
/codegen/runtime the number helpers generated schemas import valibot
/release release checks a CLI runs before it publishes: releaseCheck and the checks it runs (changelog shape, document links, versions in step, package contents) Node built-ins only
/skill the CLI's SKILL.md for agents: the skill command, the daily hint, the MCP resource Commander at runtime, /update's state file, the root's Renderer and Streams types
/mcp local stdio MCP entry setup and a handshake probe Node child processes; no messenger or MCP SDK dependency
/standards the registries of the shared standard: vocabulary, envelope, error object and reasons, next steps, log record, lint rules valibot, @valibot/to-json-schema; the root's error codes
/wcat the wcat umbrella: dispatching to wcat-<name> programs, --wcat-describe, the launcher and the shortcuts Node built-ins, cross-spawn; the root's errors and failure rendering, loaded only when wcat itself runs
/wcat/start wcatStart, a CLI's first call: describe, dispatch mode, the launcher check Node built-ins; the dispatcher only in dispatch mode
/testing captureStreams, memoryKeyring, brokenKeyring, fakeClock, allowNetwork the root
/testing/setup a vitest setupFiles entry: per-file sandbox, a keyring that throws, no network vitest (optional peer)

Each is a separate exports entry in package.json, built from src/<name>/index.ts, except /testing/setup from src/testing/setup.ts. Everything under src/ ships in dist/, including src/testing/, so a helper only cli-base's own tests use goes in test/.

Two more things ship that are not imports:

  • cli-dev, the bin built from src/dev/: the development scripts each CLI used to copy. It reads and writes the repository it is run in and never the network.
  • config/: the tsconfig, Biome and lefthook bases a CLI extends, exported as ./tsconfig.base.json, ./tsconfig.test.json, ./tsconfig.scripts.json, ./biome and ./lefthook.yml. cli-base extends them itself, so its own checks exercise them. The reusable CI workflow, .github/workflows/node-ci.yml, is not in the package; a repository calls it from GitHub.

The two lines this package does not cross

Nothing in the root export is HTTP, so a CLI that only speaks a socket never depends on an HTTP stack. max-cli's personal account is a WebSocket; its bot commands and tg-cli import /http. A new helper that reads a status code or a header goes to /http.

The environment is an argument. Clock, sleep, keyring, env, fetch, streams — each is a parameter, with the real one only as the default. That is what lets a timeout test finish instantly and makes the keyring unreachable from a test (TESTING.md). The trap is the defaults: new Credentials({...}) without keyring is the owner's real keychain, and resolvePaths without env reads process.env.

cli-base logs nothing and prints nothing by itself. It hands a host a Logger interface, a Pino adapter and a renderer; what is written, and where, is the host's call.

Portable command control

result-fields preserves the existing messaging projection behavior without importing its runner. It validates parsed and directly supplied paths, traverses nested objects and arrays, and preserves item/list envelopes, coverage, continuation, missing-data and execution metadata and operation identifiers. Paths are payload-relative; the items. shorthand addresses each list row. Parent selections retain an entire subtree, and missing descendants never invent values. Consumers call the same projector without unwrapping item answers. A host chooses rendering and JSONL framing; projection never selects a messenger profile.

batch owns bounded JSON input, pure registered-handler preparation and serial correlated execution. It has no Commander, account, store or shell dependency. Domain handlers reserve conservative read ceilings; the host supplies those handlers and retains authorization, cancellation, envelope framing and final output limits.

output-buffer holds raw rendered chunks until the host has completed its operation and closed resources. The byte limit applies after optional complete-value JSON/JSONL projection, including caller-supplied delimiters. It is a one-use buffer; flush consumes all chunks even on a writer failure. It does not impose renderer framing or conceal partial output from a failing writer.

deadline uses an injected SleepLike and optional parent signal, never process-wide signal handlers. It cancels cooperatively and waits for the body to settle on interruption. This preserves resource lifetimes at the cost of delayed completion when a body ignores cancellation. outcome_unknown from the body takes precedence over timeout/cancellation; other body failures remain unchanged when there was no interruption. Hosts own close ordering and provider mutation policy. Dispose the scope in finally; timers and listeners do not belong to the store or a messenger execution shell.

Who consumes it

Repository Subpaths used (source and scripts, main, 2026-10-14)
cli-core root, /codegen, /codegen/runtime, /commands, /completion, /mcp, /query, /skill, /standards, /update
max-cli root, /codegen, /codegen/runtime, /commands, /http, /mcp, /query, /release, /skill, /update
tg-cli root, /codegen, /codegen/runtime, /commands, /http, /release, /skill, /testing, /update

Each consumer's pin is in its package.json; a caret on 0.x does not cross a minor, so every pin is exact in effect. A release reaches nobody until each consumer bumps its pin.

⚠ cli-core takes cli-base as a peer dependency with the range declared in its package.json, so a CLI installs one copy that both use. A new minor version of cli-base reaches the CLIs only after cli-core widens its range. isCliError() checks a Symbol.for brand, so copies from 0.22.0 on recognise each other if two are ever installed; an older copy has no brand, which is why errorObjectOf also accepts a CliError by name and code.

How a change is published and picked up

  1. A pull request here, with the CHANGELOG.md entry under ## Unreleased and, when it is ready to ship, version in package.json raised and the heading dated as that version.
  2. After merge, bin/release on main (RELEASING.md). If npm already has the number, it takes the next free one and renames the changelog heading with it, then starts release.yml, which runs every check, publishes through npm's trusted publishing and tags v<version>.
  3. In each consumer, raise the pin in package.json and run its suite — cli-core first, since its peer range decides which cli-base the CLIs may install.