@wirecat/cli-base 0.55.1 → 0.57.0

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/CHANGELOG.md CHANGED
@@ -8,6 +8,56 @@ Versions before 0.8.0 are in the [git tags](https://github.com/WireCatLabs/cli-b
8
8
  Every entry says what changed as a caller sees it, why, and what to watch for — the rules are
9
9
  [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog).
10
10
 
11
+ ## 0.57.0 — 25.10.2026
12
+
13
+ ### Changed — may break callers
14
+
15
+ - **`saveConfigFile` writes `config.json` as `0600`, owner-only, instead of `0644`.** A config names the owner's
16
+ accounts, profiles and paths, and cli-core already created the first one as `0600` — so the first `config set`
17
+ turned a private file into one every user on the computer could read. A save now replaces the file with an
18
+ owner-only one, whatever mode it had. A folder that already exists keeps its mode; a CLI's `doctor` reports one
19
+ others can enter. Watch for: anything that reads the config as a different user — a system-wide service, a
20
+ container running under another uid, a second account sharing the file — can no longer read it after the next
21
+ save. Run it as the owner, or give it its own config. Windows is unchanged: Node sets only the read-only bit there.
22
+
23
+ ## 0.56.0 — 25.10.2026
24
+
25
+ ### Added
26
+
27
+ - **`<cli> skill show --for claude|agents` prints the copy installed for that agent** instead of this version's
28
+ SKILL.md, as text or `{ name, target, path, installed, content }` with `--json`. A host could show only the bundled
29
+ copy and `skill status`'s version numbers, so it could not show what `skill install` would change; now it reads
30
+ both and compares them. It reads only, fails with `not_found` and the install command when the agent has no copy,
31
+ and refuses a shipped skill by name, which is never installed.
32
+
33
+ - **`removeStdioEntry` and `stdioEntryStatus` in `@wirecat/cli-base/mcp`** undo and read what `installStdioEntry`
34
+ writes. A CLI could add itself to Codex or Claude Code, but not take itself out or say whether it is there, so a
35
+ host such as WireCat Desktop had to read each client's configuration on its own. `removeStdioEntry(client, name)`
36
+ runs the client's own `mcp remove`, for Claude Code with `--scope user` (the scope `add` uses), so a project's entry
37
+ with the same name stays; a name the client does not have returns `removed: false`. `stdioEntryStatus(client,
38
+ name)` returns `registered`, `missing`, or `unavailable` when the client is not installed. Both go through the
39
+ client's `mcp get` first and refuse a name that is not letters, digits, `_` or `-`.
40
+
41
+ - **Every CLI keeps a run log: `WC-LIFE-15` and the `missing-run-log` lint rule**
42
+ ([Lifecycle](docs/standards/lifecycle.md#wc-life-15--keep-a-run-log-through-cli-core-and-read-it-back-with-runs)).
43
+ The handbook described what a log may hold but never required one, and drive, memo and zm shipped without a run
44
+ log. `cli-dev lint` now fails a CLI whose command tree has no `runs list` or `runs show`, or no global `--record` or
45
+ `--no-record`. With `--cli`, it also runs the built CLI once with an unknown flag, in a new empty home with none of
46
+ the caller's environment but `PATH`, and fails when `runs list --json` then shows no failed run. A CLI without
47
+ `runs` gets the finding on its next cli-base bump: register cli-core's `runsCommand(app)`, or baseline the
48
+ finding with `cli-dev lint --write-baseline` until it does. The weekly cross-CLI audit lists it too.
49
+
50
+ - **`<cli> skill status` says whether the installed SKILL.md is current, for each agent.** It reads
51
+ `~/.claude/skills/<appName>/SKILL.md` and `~/.agents/skills/<appName>/SKILL.md` and prints, per place, the absolute
52
+ path, the version installed there (`null` when there is none) and a `state`: `current`, `stale` (older than the
53
+ CLI), `missing` or `unversioned`. It writes nothing. A desktop app or a setup script could tell this only by reading
54
+ the files itself. `skillStatus(app, env)` returns the same list, and the daily skill hint now decides from it, with
55
+ the same outcome as before. A CLI on cli-core gets the command when cli-core picks up this release.
56
+
57
+ - **The collection noun `tables`: "the tables of the local store, with their kind, row count and size".** cli-core's
58
+ `store tables list` uses it to answer which tables the shared archive holds, so a desktop app or an agent can see
59
+ them without opening the file. `copies` keeps its narrower meaning, the tables a repair kept aside.
60
+
11
61
  ## 0.55.1 — 25.10.2026
12
62
 
13
63
  ### Fixed
package/README.md CHANGED
@@ -180,7 +180,8 @@ operation and never falls back to `any`. 64-bit integers stay exact.
180
180
 
181
181
  ### Built for agents and for keeping installs current
182
182
 
183
- - **`skill show` and `skill install`** give a coding agent the CLI's own guide, versioned with the CLI.
183
+ - **`skill show` and `skill install`** give a coding agent the CLI's own guide, versioned with the CLI; `skill status`
184
+ says whether each installed copy matches it.
184
185
  - **MCP setup** for Codex and Claude Code, with a read-only handshake probe.
185
186
  - **Shell completion** from the command tree, answered from local data only — a shell asks on every Tab.
186
187
  - **Update checks that never update unasked.** Which package manager installed the CLI, the command that updates it,
package/dist/config.d.ts CHANGED
@@ -14,5 +14,8 @@ export declare const writeSecurely: (path: string, contents: string | Uint8Array
14
14
  * The schema belongs to the host: what a profile holds is the one thing no shared package can know.
15
15
  */
16
16
  export declare const loadConfigFile: <TSchema extends v.GenericSchema>(path: string, schema: TSchema, fallback: () => v.InferOutput<TSchema>) => v.InferOutput<TSchema>;
17
- /** Config is not a secret, so it is `0644`; the credential file next to it is not. */
17
+ /**
18
+ * Owner-only, `0600`: a config names the owner's accounts and paths, and a save replaces a file any
19
+ * other user could read. A folder that already exists keeps its mode; `doctor` reports an open one.
20
+ */
18
21
  export declare const saveConfigFile: (path: string, config: unknown) => void;
package/dist/config.js CHANGED
@@ -55,7 +55,10 @@ export const loadConfigFile = (path, schema, fallback) => {
55
55
  }
56
56
  return result.output;
57
57
  };
58
- /** Config is not a secret, so it is `0644`; the credential file next to it is not. */
58
+ /**
59
+ * Owner-only, `0600`: a config names the owner's accounts and paths, and a save replaces a file any
60
+ * other user could read. A folder that already exists keeps its mode; `doctor` reports an open one.
61
+ */
59
62
  export const saveConfigFile = (path, config) => {
60
- writeSecurely(path, `${JSON.stringify(config, null, 2)}\n`, 0o644);
63
+ writeSecurely(path, `${JSON.stringify(config, null, 2)}\n`, 0o600);
61
64
  };
@@ -21,7 +21,17 @@ export interface LintContext {
21
21
  * `log_attributes` (`logAttributesJsonSchema(allow)`) is the CLI's log allow-list, for `log-attribute-key`.
22
22
  */
23
23
  schemas?: Readonly<Record<string, unknown>>;
24
+ /** What `cli-dev lint --cli` saw when it made the built CLI fail once in an empty home; absent with `--commands`. */
25
+ runLog?: RunLogProbe;
24
26
  }
27
+ export interface RunLogProbe {
28
+ kept: boolean;
29
+ /** Why no failed run was found, when none was. */
30
+ detail?: string;
31
+ }
32
+ /** The run log's reader and its switches; what `runsCommand` and cli-core's shared program add. */
33
+ export declare const RUN_LOG_COMMANDS: readonly ["runs list", "runs show"];
34
+ export declare const RUN_LOG_FLAGS: readonly ["--record", "--no-record"];
25
35
  /** The schemas cli-base itself defines; every CLI's lint checks them too. */
26
36
  export declare const baseSchemas: () => Record<string, unknown>;
27
37
  /** Splits an example the way a shell would for the quoting examples use: '…', "…" and a backslash. */
package/dist/dev/lint.js CHANGED
@@ -25,6 +25,9 @@ export const kindOf = (option) => {
25
25
  };
26
26
  export const summaryOf = (command) => (command.summary ?? command.description.split("\n")[0] ?? "").trim();
27
27
  const label = (command) => command.path.join(" ");
28
+ /** The run log's reader and its switches; what `runsCommand` and cli-core's shared program add. */
29
+ export const RUN_LOG_COMMANDS = ["runs list", "runs show"];
30
+ export const RUN_LOG_FLAGS = ["--record", "--no-record"];
28
31
  /** The `--schemas` entry holding the CLI's log allow-list; its keys are camelCase, so `key-casing` skips it. */
29
32
  const LOG_ATTRIBUTES = "log_attributes";
30
33
  /** The schemas cli-base itself defines; every CLI's lint checks them too. */
@@ -395,6 +398,28 @@ const CHECKS = {
395
398
  message: `"${key}" names content or a secret, which no log may carry`,
396
399
  }));
397
400
  },
401
+ "missing-run-log": (document, _vocabulary, context) => {
402
+ const paths = new Set(walk(document.commands).map(label));
403
+ const flags = new Set(document.global_options.flatMap(optionNames));
404
+ return [
405
+ ...RUN_LOG_COMMANDS.filter((path) => !paths.has(path)).map((path) => ({
406
+ target: path,
407
+ message: `no \`${path}\`: register cli-core's \`runsCommand(app)\`, so a person can read what a run did`,
408
+ })),
409
+ ...RUN_LOG_FLAGS.filter((flag) => !flags.has(flag)).map((flag) => ({
410
+ target: flag,
411
+ message: `no global ${flag}: run the program through cli-core's \`run\`, or add the flag at the root`,
412
+ })),
413
+ ...(context.runLog && !context.runLog.kept
414
+ ? [
415
+ {
416
+ target: "runs",
417
+ message: `a failing command left no failed run: ${context.runLog.detail ?? "none listed"}`,
418
+ },
419
+ ]
420
+ : []),
421
+ ];
422
+ },
398
423
  };
399
424
  export const lintRuleIds = () => Object.keys(CHECKS);
400
425
  /** Every violation, one per rule and target, sorted. Generated commands keep the names of the API they mirror. */
@@ -1,3 +1,4 @@
1
+ import { type RunLogProbe } from "./lint.js";
1
2
  export interface DevIo {
2
3
  cwd: string;
3
4
  /** The clock an override's `review_after` is judged by. */
@@ -5,4 +6,10 @@ export interface DevIo {
5
6
  out: (text: string) => void;
6
7
  err: (text: string) => void;
7
8
  }
9
+ /**
10
+ * Makes the built CLI fail once on an unknown flag and asks its own `runs list --json` whether that failure was
11
+ * kept. The CLI sees a new empty home and nothing else from this environment: a stray `*_STATE_DIR` or
12
+ * `XDG_STATE_HOME` would point it at the owner's real runs.
13
+ */
14
+ export declare const probeRunLog: (cwd: string, bin: string, environment?: NodeJS.ProcessEnv) => RunLogProbe;
8
15
  export declare const main: (argv: readonly string[], io: DevIo) => Promise<number>;
package/dist/dev/main.js CHANGED
@@ -3,7 +3,8 @@
3
3
  * reads and writes under `cwd` and reports through `out` and `err`; `main` returns the exit code.
4
4
  */
5
5
  import { randomUUID } from "node:crypto";
6
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
7
8
  import { isAbsolute, join, relative } from "node:path";
8
9
  import { pathToFileURL } from "node:url";
9
10
  import { parseArgs } from "node:util";
@@ -20,7 +21,7 @@ import { VOCABULARY } from "../standards/vocabulary.js";
20
21
  import { auditClis, scorecardTable, snakeKeys } from "./audit.js";
21
22
  import { readCapabilities } from "./capabilities.js";
22
23
  import { changelogChange, landChange, landFold, newestTag, processShell, restoreChange } from "./land.js";
23
- import { baselineOf, judge, lintCommands } from "./lint.js";
24
+ import { baselineOf, judge, lintCommands, RUN_LOG_COMMANDS, walk } from "./lint.js";
24
25
  import { nextVersion } from "./next-version.js";
25
26
  import { slowTests } from "./slow-tests.js";
26
27
  import { matrixPage, matrixRows, parseArgvLog } from "./test-matrix.js";
@@ -238,6 +239,52 @@ const commandsJson = (io, cli, file, commandArgs = ["commands", "--json"]) => {
238
239
  throw new Error(`${bin} commands --json failed: ${run.error?.message ?? String(run.stderr).trim().split("\n")[0]}`);
239
240
  return JSON.parse(String(run.stdout));
240
241
  };
242
+ const PROBE_FLAG = "--cli-dev-run-log-probe";
243
+ /**
244
+ * Makes the built CLI fail once on an unknown flag and asks its own `runs list --json` whether that failure was
245
+ * kept. The CLI sees a new empty home and nothing else from this environment: a stray `*_STATE_DIR` or
246
+ * `XDG_STATE_HOME` would point it at the owner's real runs.
247
+ */
248
+ export const probeRunLog = (cwd, bin, environment = process.env) => {
249
+ const home = mkdtempSync(join(tmpdir(), "cli-dev-run-log-"));
250
+ const env = {
251
+ PATH: environment.PATH ?? "",
252
+ HOME: home,
253
+ USERPROFILE: home,
254
+ APPDATA: join(home, "AppData", "Roaming"),
255
+ LOCALAPPDATA: join(home, "AppData", "Local"),
256
+ CI: "true",
257
+ ...(environment.SystemRoot ? { SystemRoot: environment.SystemRoot } : {}),
258
+ };
259
+ const script = /\.[cm]?js$/.test(bin);
260
+ const call = (args) => spawn.sync(script ? process.execPath : bin, [...(script ? [bin] : []), ...args], {
261
+ cwd,
262
+ env,
263
+ encoding: "utf8",
264
+ timeout: 60_000,
265
+ maxBuffer: 16 * 1024 * 1024,
266
+ });
267
+ try {
268
+ const failed = call([PROBE_FLAG]);
269
+ if (failed.status === 0)
270
+ return { kept: false, detail: `\`${PROBE_FLAG}\` did not fail, so nothing could be kept` };
271
+ const listed = call(["runs", "list", "--json"]);
272
+ if (listed.status !== 0)
273
+ return { kept: false, detail: `\`runs list --json\` exited ${listed.status ?? listed.error?.message}` };
274
+ let items;
275
+ try {
276
+ items = JSON.parse(String(listed.stdout)).items;
277
+ }
278
+ catch {
279
+ return { kept: false, detail: "`runs list --json` did not print JSON" };
280
+ }
281
+ const kept = Array.isArray(items) && items.some((item) => item?.status === "failed");
282
+ return kept ? { kept } : { kept, detail: `\`runs list --json\` shows no failed run after \`${PROBE_FLAG}\`` };
283
+ }
284
+ finally {
285
+ rmSync(home, { recursive: true, force: true });
286
+ }
287
+ };
241
288
  const readBaseline = (dir, label, problems) => {
242
289
  const baseline = {};
243
290
  if (existsSync(dir))
@@ -316,9 +363,12 @@ const lintRun = (args, io) => {
316
363
  io.err(`${values.schemas}: a JSON object of JSON Schemas by name`);
317
364
  return 2;
318
365
  }
366
+ const paths = new Set(walk(parsed.document.commands).map((command) => command.path.join(" ")));
367
+ const hasRuns = RUN_LOG_COMMANDS.every((path) => paths.has(path));
319
368
  const violations = lintCommands(parsed.document, VOCABULARY, LINT_RULES, {
320
369
  raw,
321
370
  schemas: schemas,
371
+ ...(values.cli && hasRuns ? { runLog: probeRunLog(io.cwd, values.cli) } : {}),
322
372
  });
323
373
  const severity = new Map(LINT_RULES.map((rule) => [rule.id, rule.severity]));
324
374
  if (values["write-baseline"]) {
@@ -14,6 +14,25 @@ export interface CommandResult {
14
14
  }
15
15
  export type RunCommand = (file: string, args: string[]) => CommandResult;
16
16
  export declare const setupArguments: (client: McpClient, name: string, entry: StdioEntry) => string[];
17
+ /** `unavailable`: the client is not installed here, or could not be started. */
18
+ export type StdioEntryState = "registered" | "missing" | "unavailable";
19
+ /** Whether the client has a server by this name; reads only, through the client's own `mcp get`. */
20
+ export declare const stdioEntryStatus: (client: McpClient, name: string, { run }?: {
21
+ run?: RunCommand;
22
+ }) => {
23
+ client: McpClient;
24
+ name: string;
25
+ state: StdioEntryState;
26
+ };
27
+ export declare const removeArguments: (client: McpClient, name: string) => string[];
28
+ /** Takes out what `installStdioEntry` added; a name the client does not have is left alone and reported. */
29
+ export declare const removeStdioEntry: (client: McpClient, name: string, { run }?: {
30
+ run?: RunCommand;
31
+ }) => {
32
+ client: McpClient;
33
+ name: string;
34
+ removed: boolean;
35
+ };
17
36
  export declare const installStdioEntry: (client: McpClient, name: string, entry: StdioEntry, { run }?: {
18
37
  run?: RunCommand;
19
38
  }) => {
package/dist/mcp/index.js CHANGED
@@ -10,26 +10,60 @@ const realRun = (file, args) => {
10
10
  return { status: result.status, error: result.error, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
11
11
  };
12
12
  const executable = (client) => (client === "codex" ? "codex" : "claude");
13
+ const presentIn = (client, name, run) => {
14
+ const state = lookUp(client, name, run);
15
+ if (state === "unavailable")
16
+ throw new Error(`${executable(client)} is not installed or could not be started`);
17
+ return state === "registered";
18
+ };
13
19
  export const setupArguments = (client, name, entry) => {
14
20
  const variables = Object.entries(entry.env ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
15
21
  if (client === "codex")
16
22
  return ["mcp", "add", name, ...variables, "--", entry.command, ...entry.args];
17
23
  return ["mcp", "add", "--scope", "user", name, ...variables, "--", entry.command, ...entry.args];
18
24
  };
19
- export const installStdioEntry = (client, name, entry, { run = realRun } = {}) => {
25
+ const checkName = (name) => {
20
26
  if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name))
21
27
  throw new Error("the MCP name must be letters, digits, _ or -");
22
- if (!entry.command || entry.args.some((arg) => arg.includes("\0")))
23
- throw new Error("invalid MCP command");
28
+ };
29
+ const lookUp = (client, name, run) => {
24
30
  const file = executable(client);
25
31
  const existing = run(file, ["mcp", "get", name, ...(client === "codex" ? ["--json"] : [])]);
26
32
  if (existing.error)
27
- throw new Error(`${file} is not installed or could not be started`);
33
+ return "unavailable";
28
34
  if (existing.status === 0)
29
- throw new Error(`${name} is already configured in ${file}; remove it there first`);
30
- if (existing.status !== 0 && !/No MCP server named/i.test(existing.stdout + existing.stderr)) {
35
+ return "registered";
36
+ if (!/No MCP server named/i.test(existing.stdout + existing.stderr)) {
31
37
  throw new Error(`${file} could not check its MCP configuration`);
32
38
  }
39
+ return "missing";
40
+ };
41
+ /** Whether the client has a server by this name; reads only, through the client's own `mcp get`. */
42
+ export const stdioEntryStatus = (client, name, { run = realRun } = {}) => {
43
+ checkName(name);
44
+ return { client, name, state: lookUp(client, name, run) };
45
+ };
46
+ export const removeArguments = (client, name) =>
47
+ // Claude Code without a scope removes from whichever scope holds the name, which may be a project's own entry.
48
+ client === "codex" ? ["mcp", "remove", name] : ["mcp", "remove", "--scope", "user", name];
49
+ /** Takes out what `installStdioEntry` added; a name the client does not have is left alone and reported. */
50
+ export const removeStdioEntry = (client, name, { run = realRun } = {}) => {
51
+ checkName(name);
52
+ if (!presentIn(client, name, run))
53
+ return { client, name, removed: false };
54
+ const file = executable(client);
55
+ const removed = run(file, removeArguments(client, name));
56
+ if (removed.status !== 0 || removed.error)
57
+ throw new Error(`${file} could not remove ${name}`);
58
+ return { client, name, removed: true };
59
+ };
60
+ export const installStdioEntry = (client, name, entry, { run = realRun } = {}) => {
61
+ checkName(name);
62
+ if (!entry.command || entry.args.some((arg) => arg.includes("\0")))
63
+ throw new Error("invalid MCP command");
64
+ const file = executable(client);
65
+ if (presentIn(client, name, run))
66
+ throw new Error(`${name} is already configured in ${file}; remove it there first`);
33
67
  const args = setupArguments(client, name, entry);
34
68
  const added = run(file, args);
35
69
  if (added.status !== 0 || added.error)
@@ -25,6 +25,16 @@ export declare const installSkill: (app: SkillApp, skill: URL, { targets, env }?
25
25
  targets?: readonly SkillTarget[];
26
26
  env?: Env;
27
27
  }) => string[];
28
+ export type SkillState = "current" | "stale" | "missing" | "unversioned";
29
+ export interface SkillTargetStatus {
30
+ target: SkillTarget;
31
+ path: string;
32
+ /** The `metadata.version` of the installed copy; `null` when there is none or it has no version. */
33
+ installed: string | null;
34
+ state: SkillState;
35
+ }
36
+ /** Each target's installed copy against this CLI's version; reads only, and an unreadable file is missing. */
37
+ export declare const skillStatus: (app: SkillApp, env?: Env) => SkillTargetStatus[];
28
38
  /** What the command writes to and where home is, for this invocation — the host's own output resolution. */
29
39
  export type SkillEnvironment = (command: Command) => {
30
40
  renderer: Renderer;
@@ -88,6 +88,21 @@ export const installSkill = (app, skill, { targets = SKILL_TARGETS, env = proces
88
88
  return path;
89
89
  });
90
90
  };
91
+ /** Each target's installed copy against this CLI's version; reads only, and an unreadable file is missing. */
92
+ export const skillStatus = (app, env = process.env) => SKILL_TARGETS.map((target) => {
93
+ const path = skillPath(target, app.appName, env);
94
+ let content;
95
+ try {
96
+ content = readFileSync(path, "utf8");
97
+ }
98
+ catch {
99
+ return { target, path, installed: null, state: "missing" };
100
+ }
101
+ const installed = skillVersion(content);
102
+ if (installed === undefined)
103
+ return { target, path, installed: null, state: "unversioned" };
104
+ return { target, path, installed, state: isNewer(app.version, installed) ? "stale" : "current" };
105
+ });
91
106
  /**
92
107
  * `more`: skills a library ships for every CLI built on it, by name — `skill show <name>` prints one with
93
108
  * `{{command}}` turned into this CLI's command. They are printed, not installed: two CLIs would install
@@ -102,13 +117,32 @@ export const skillCommand = (app, skill, environment, more = {}) => {
102
117
  const named = Object.keys(more);
103
118
  const show = annotate(command.command("show"), { effects: { remote: "none", local: "read" } })
104
119
  .summary("print the tool's instructions for AI agents")
105
- .description(`print SKILL.md — \`${app.command} skill install\` puts it where Claude Code, Codex and Gemini CLI look for it`);
120
+ .description(`print SKILL.md — \`${app.command} skill install\` puts it where Claude Code, Codex and Gemini CLI look for it; --for prints the copy already there`);
106
121
  if (named.length > 0)
107
122
  show.argument("[name]", `one of the skills shipped for a task: ${named.join(", ")}`);
123
+ show.addOption(new Option("--for <agents>", "print the copy installed for these agents instead of this version's").choices(SKILL_TARGETS));
108
124
  show.action(function (given) {
109
125
  // Without a declared argument Commander passes the options here, not a name.
110
126
  const name = typeof given === "string" ? given : undefined;
111
- const { renderer, streams } = environment(this);
127
+ const { renderer, streams, env } = environment(this);
128
+ const { for: target } = this.opts();
129
+ if (target !== undefined) {
130
+ if (name !== undefined)
131
+ throw new CliError("validation_error", `only ${app.appName} is installed; leave out "${name}" with --for`);
132
+ const path = skillPath(target, app.appName, env ?? process.env);
133
+ let content;
134
+ try {
135
+ content = readFileSync(path, "utf8").trimEnd();
136
+ }
137
+ catch {
138
+ throw new CliError("not_found", `no skill is installed for ${target} at ${path} — \`${app.command} skill install --for ${target}\` writes it`);
139
+ }
140
+ if (machine(this))
141
+ renderer.result({ name: app.appName, target, path, installed: skillVersion(content) ?? null, content });
142
+ else
143
+ streams.data(content);
144
+ return;
145
+ }
112
146
  const source = name === undefined ? skill : more[name];
113
147
  if (!source) {
114
148
  throw new CliError("validation_error", `no skill "${name}" — there are: ${named.join(", ")}`);
@@ -134,20 +168,24 @@ export const skillCommand = (app, skill, environment, more = {}) => {
134
168
  renderer.result({ name: app.appName, version: app.version, written });
135
169
  renderer.success(`${app.appName} ${app.version} skill installed: ${written.join(", ")}`);
136
170
  });
171
+ annotate(command.command("status"), {
172
+ effects: { remote: "none", local: "read" },
173
+ examples: [`${app.command} skill status`, `${app.command} skill status --json`],
174
+ })
175
+ .summary("whether the installed instructions for AI agents match this version")
176
+ .description(`for each place \`${app.command} skill install\` writes to: the path, the version installed there, and whether it is current, stale, missing or unversioned`)
177
+ .action(function () {
178
+ const { renderer, env } = environment(this);
179
+ const targets = skillStatus(app, env ?? process.env);
180
+ renderer.result({ name: app.appName, version: app.version, targets });
181
+ });
137
182
  return command;
138
183
  };
139
184
  const isAgent = (env) => Boolean(env.AI_AGENT || env.CLAUDECODE);
140
185
  const needsInstall = (app, env) => {
141
- const installed = SKILL_TARGETS.flatMap((target) => {
142
- try {
143
- return [skillVersion(readFileSync(skillPath(target, app.appName, env), "utf8"))];
144
- }
145
- catch {
146
- return [];
147
- }
148
- });
186
+ const states = skillStatus(app, env).map(({ state }) => state);
149
187
  // A location never installed to is a choice (`--for claude`); only no copy at all, or an old one, is worth a line.
150
- return installed.length === 0 || installed.some((version) => version === undefined || isNewer(app.version, version));
188
+ return (states.every((state) => state === "missing") || states.some((state) => state === "stale" || state === "unversioned"));
151
189
  };
152
190
  /**
153
191
  * One line for an agent whose copy of SKILL.md is missing or older than this CLI, at most once a
@@ -110,6 +110,12 @@ export const LINT_RULES = defineLintRules([
110
110
  scope: "key",
111
111
  summary: "no log attribute key on the allow-list names message text, a token or a phone number",
112
112
  },
113
+ {
114
+ id: "missing-run-log",
115
+ severity: "high",
116
+ scope: "group",
117
+ summary: "the CLI keeps a failed run and reads it back: `runs list`, `runs show`, global `--record` and `--no-record`",
118
+ },
113
119
  ]);
114
120
  /** `lint/baseline/<rule-id>.json`: today's violations of one rule, by target. It may only shrink. */
115
121
  export const baselineSchema = v.array(text);
@@ -216,6 +216,7 @@ const collections = [
216
216
  ["tags", "labels the owner puts on things"],
217
217
  ["searches", "the owner's saved searches"],
218
218
  ["copies", "tables kept aside by a repair"],
219
+ ["tables", "the tables of the local store, with their kind, row count and size"],
219
220
  ["attachments", "files of stored messages"],
220
221
  ["tasks", "what waits on the owner"],
221
222
  ["members", "people in a chat"],
@@ -33,7 +33,7 @@ the four steps that need a person. A new CLI is ready for its first release when
33
33
  | [Accounts](accounts.md) | `ACCT` | 11 | The explicit profile, reads across accounts, scope flags, linked identities |
34
34
  | [Permissions](permissions.md) | `PERM` | 21 | Levels, keys, defaults, hard limits, the write guard and its journal |
35
35
  | [Architecture](architecture.md) | `ARCH` | 17 | Layers, dependency direction, vendor types, what goes in which package |
36
- | [Lifecycle](lifecycle.md) | `LIFE` | 13 | Opening late, who closes, one-shot exits, long-running commands, the `serve` relay, resumable syncs |
36
+ | [Lifecycle](lifecycle.md) | `LIFE` | 14 | Opening late, who closes, one-shot exits, long-running commands, the `serve` relay, resumable syncs, the run log |
37
37
  | [Store](store.md) | `STORE` | 14 | Store areas, account scope, migrations, test isolation |
38
38
  | [Data model](data-model.md) | `DATA` | 7 | Core entities, entities and source records, `external_id`, field names, links and duplicates |
39
39
  | [Testing](testing.md) | `TEST` | 17 | Sandbox, no network, no real waits, leak tests, coverage, contracts, test matrix, parity |
@@ -18,7 +18,7 @@ The list is what the template's `src/` imports today, type imports included.
18
18
  |---|---|
19
19
  | `@wirecat/cli-base` | `catchCrashes`, `CliError`, `configFilePath`, `mcpErrorResult`, `resolvePaths` |
20
20
  | `@wirecat/cli-base/commands` | `annotate`, `CommandInfo`, `describeProgram` |
21
- | `@wirecat/cli-core/cli` | `AppIdentity`, `changesAuthorityAt`, `commandsCommand`, `configCommand`, `Configuration`, `createProgram`, `environmentOf`, `GlobalFlags`, `itemOf`, `LOCAL_READ`, `outputFor`, `ProgramDefinition`, `ProgramOptions`, `readOnlyLockOf`, `REMOTE_WRITE`, `renderAnswer`, `rootOf`, `run`, `RunOptions`, `settingsFor`, `skillCommand`, `upgradeCommand` |
21
+ | `@wirecat/cli-core/cli` | `AppIdentity`, `changesAuthorityAt`, `commandsCommand`, `configCommand`, `Configuration`, `createProgram`, `environmentOf`, `GlobalFlags`, `itemOf`, `LOCAL_READ`, `outputFor`, `ProgramDefinition`, `ProgramOptions`, `readOnlyLockOf`, `REMOTE_WRITE`, `renderAnswer`, `rootOf`, `run`, `RunOptions`, `runsCommand`, `settingsFor`, `skillCommand`, `upgradeCommand` |
22
22
  | `@wirecat/cli-core/mcp` | `McpCommand`, `registerCommands`, `runCommandForAgent`, `toolsFromCommands` |
23
23
  | `@wirecat/cli-core/sends` | `Level` |
24
24
  | `@wirecat/cli-core/update` | `appUpdate` |
@@ -14,6 +14,16 @@ A "one-shot" command does one job and ends: most commands. A "long-running" comm
14
14
  `lifetime: "persistent"` (it runs until it is stopped, such as `serve` or `watch`) or `lifetime: "interactive"` (it
15
15
  waits for a person, such as a login).
16
16
 
17
+ A "run" is one command's record: what it was, how it ended, and its events, never content. The rules on what a
18
+ run's events may hold are [WC-SECR-1](security.md#wc-secr-1--private-data-and-secrets-never-reach-a-log-a-fixture-or-a-committed-document)
19
+ and [WC-SECR-2](security.md#wc-secr-2--a-log-keeps-only-known-attribute-keys-and-hides-content-keys).
20
+
21
+ ### Who follows these rules today
22
+
23
+ - **WC-LIFE-15:** tg, max, wa and the template. drive, memo and zm do not keep runs yet; each is adding them on a
24
+ `feat/runs` branch (2026-10-25). zm's `resolveSettings` also returns `keepFailedRuns: false`, which turns off the
25
+ failure log cli-core's `run` would keep.
26
+
17
27
  ## One-shot commands
18
28
 
19
29
  ### WC-LIFE-1 · The code that opens a resource closes it, so a one-shot command exits when it has answered
@@ -183,6 +193,29 @@ provider's limit as often as it ends. These three rules make the next run carry
183
193
  failed is a different question, answered by `recordRun` and `failingNotice` in cli-core's schedules.
184
194
  - **Applies to:** every CLI with a sync. **Since:** cli-base 0.44.0. **Rulings:** none.
185
195
 
196
+ ## Runs
197
+
198
+ ### WC-LIFE-15 · Keep a run log through cli-core, and read it back with `runs`
199
+
200
+ A failed command is always kept as a run, unless the person turned recording off with `--no-record` or
201
+ `record: false` in the config. `--record` keeps every run of a command that does its work inside cli-core's run
202
+ (`context.run`, or `startRecording` in a program of its own). A run is a folder `<state>/runs/<day>/<run_id>/` with
203
+ `run.json` and `events.jsonl`, one log record per line. `runs list` and `runs show` read it.
204
+
205
+ - A CLI that runs its program through cli-core's `run` gets the failure log and `--record`/`--no-record` from it,
206
+ and adds `runsCommand(app)` to its commands. Its configuration is `settingsFor(app)`, or a `resolveSettings` that
207
+ returns the owner's `record` and `keep_runs_for_days` as `keepFailedRuns` and `keepRunsForDays`, never fixed values.
208
+ - A CLI with a program of its own opens each command's run with `startRecording` from `@wirecat/cli-core/cli`,
209
+ ends it with `succeed` or `fail`, adds `--record` and `--no-record` at the root and registers `runsCommand(app)`.
210
+
211
+ - **Why:** when a command fails, a person or an agent needs to see what it did, in the same place and shape in every
212
+ CLI. Without the rule, three CLIs shipped with no run log, and one switched cli-core's off.
213
+ - **Enforced by:** `missing-run-log`.
214
+ - **Example:** `tg --bogus` fails, `tg runs list` shows that run as `failed`, and `tg runs show <run_id>` shows its
215
+ events.
216
+ - **Exceptions:** none.
217
+ - **Applies to:** all CLIs. **Since:** cli-base 0.56.0. **Rulings:** none.
218
+
186
219
  ## Sources
187
220
 
188
221
  These documents explain the rules on this page in more detail. Where one of them disagrees with this page, this page
@@ -50,7 +50,8 @@ Give the agent `docs/dev/DESIGN.md` next. Every example passes a profile explici
50
50
  The reference CLI is the one to compare with when a step is unclear.
51
51
 
52
52
  The copy already holds every file this handbook asks for: the required root commands (`commands`, `doctor`,
53
- `skill show`, `skill install`, `mcp`, `upgrade`) and `config` from cli-core, the MCP server with a tool for every
53
+ `skill show`, `skill install`, `mcp`, `upgrade`) and `config` from cli-core, the run log and its `runs` commands
54
+ ([WC-LIFE-15](lifecycle.md#wc-life-15--keep-a-run-log-through-cli-core-and-read-it-back-with-runs)), the MCP server with a tool for every
54
55
  command, writes included, the community presets in `config/wirecat/`, the TypeScript, Biome and vitest setup,
55
56
  `contracts/`, `lint/`, the test matrix, the user pages, `CHANGELOG.md`, `SECURITY.md`, `bin/release`, and the
56
57
  `pr.yml`, `ci.yml`, `main.yml`, `standards.yml`, `changelog-guard.yml`, `monthly.yml` and `release.yml` workflows.
@@ -83,7 +84,8 @@ All of these pass on `main`, before the first release:
83
84
 
84
85
  - `pnpm standards:check`
85
86
  - `pnpm typecheck`, `pnpm lint` and `pnpm test` with coverage
86
- - `cli-dev lint` against the built CLI
87
+ - `cli-dev lint` against the built CLI. It also makes the CLI fail once in an empty home and checks that
88
+ `runs list` shows the failed run
87
89
  - `cli-dev audit --for <cli> --lint <cli>=lint` with no new finding, run with the command trees of the CLIs in
88
90
  the same parity groups. `--lint` judges the CLI's lint rules by its own `lint/` folder. What it shares with the
89
91
  other CLIs is judged by cli-base's `audit/baseline`; the pull request that adds the CLI to a parity group adds its
@@ -123,10 +123,11 @@ the message store. The owner adds `--allow-any-file` to send one anyway.
123
123
 
124
124
  ### WC-SECR-10 · Private files are written atomically with private modes
125
125
 
126
- A file that holds a secret or private state is written with `writeSecurely`: atomically, mode `0600`, in a directory
126
+ A file that holds a secret or private state, `config.json` included, is written with `writeSecurely`: atomically, mode `0600`, in a directory
127
127
  created with mode `0700`. `writeSecurely` takes text or bytes. A
128
128
  temporary credential file is `0600` and removed in `finally`.
129
- tg's `doctor` reports a wrong mode and prints the `chmod` fix without applying it.
129
+ The shared messenger `doctor` reports a wrong mode, the config file and its folder included, and prints the `chmod`
130
+ fix without applying it.
130
131
 
131
132
  - **Why:** another user on the same computer must not read the session.
132
133
  - **Enforced by:** `src/config.test.ts`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-base",
3
- "version": "0.55.1",
3
+ "version": "0.57.0",
4
4
  "description": "The parts every command line tool needs: output modes, terminal rendering, error model, exit codes, credentials, clocks",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",