@wirecat/cli-base 0.55.1 → 0.56.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 +38 -0
- package/README.md +2 -1
- package/dist/dev/lint.d.ts +10 -0
- package/dist/dev/lint.js +25 -0
- package/dist/dev/main.d.ts +7 -0
- package/dist/dev/main.js +52 -2
- package/dist/mcp/index.d.ts +19 -0
- package/dist/mcp/index.js +40 -6
- package/dist/skill/index.d.ts +10 -0
- package/dist/skill/index.js +49 -11
- package/dist/standards/lint.js +6 -0
- package/dist/standards/vocabulary.js +1 -0
- package/docs/standards/README.md +1 -1
- package/docs/standards/kit-surface.md +1 -1
- package/docs/standards/lifecycle.md +33 -0
- package/docs/standards/new-cli.md +4 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,44 @@ 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.56.0 — 25.10.2026
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`<cli> skill show --for claude|agents` prints the copy installed for that agent** instead of this version's
|
|
16
|
+
SKILL.md, as text or `{ name, target, path, installed, content }` with `--json`. A host could show only the bundled
|
|
17
|
+
copy and `skill status`'s version numbers, so it could not show what `skill install` would change; now it reads
|
|
18
|
+
both and compares them. It reads only, fails with `not_found` and the install command when the agent has no copy,
|
|
19
|
+
and refuses a shipped skill by name, which is never installed.
|
|
20
|
+
|
|
21
|
+
- **`removeStdioEntry` and `stdioEntryStatus` in `@wirecat/cli-base/mcp`** undo and read what `installStdioEntry`
|
|
22
|
+
writes. A CLI could add itself to Codex or Claude Code, but not take itself out or say whether it is there, so a
|
|
23
|
+
host such as WireCat Desktop had to read each client's configuration on its own. `removeStdioEntry(client, name)`
|
|
24
|
+
runs the client's own `mcp remove`, for Claude Code with `--scope user` (the scope `add` uses), so a project's entry
|
|
25
|
+
with the same name stays; a name the client does not have returns `removed: false`. `stdioEntryStatus(client,
|
|
26
|
+
name)` returns `registered`, `missing`, or `unavailable` when the client is not installed. Both go through the
|
|
27
|
+
client's `mcp get` first and refuse a name that is not letters, digits, `_` or `-`.
|
|
28
|
+
|
|
29
|
+
- **Every CLI keeps a run log: `WC-LIFE-15` and the `missing-run-log` lint rule**
|
|
30
|
+
([Lifecycle](docs/standards/lifecycle.md#wc-life-15--keep-a-run-log-through-cli-core-and-read-it-back-with-runs)).
|
|
31
|
+
The handbook described what a log may hold but never required one, and drive, memo and zm shipped without a run
|
|
32
|
+
log. `cli-dev lint` now fails a CLI whose command tree has no `runs list` or `runs show`, or no global `--record` or
|
|
33
|
+
`--no-record`. With `--cli`, it also runs the built CLI once with an unknown flag, in a new empty home with none of
|
|
34
|
+
the caller's environment but `PATH`, and fails when `runs list --json` then shows no failed run. A CLI without
|
|
35
|
+
`runs` gets the finding on its next cli-base bump: register cli-core's `runsCommand(app)`, or baseline the
|
|
36
|
+
finding with `cli-dev lint --write-baseline` until it does. The weekly cross-CLI audit lists it too.
|
|
37
|
+
|
|
38
|
+
- **`<cli> skill status` says whether the installed SKILL.md is current, for each agent.** It reads
|
|
39
|
+
`~/.claude/skills/<appName>/SKILL.md` and `~/.agents/skills/<appName>/SKILL.md` and prints, per place, the absolute
|
|
40
|
+
path, the version installed there (`null` when there is none) and a `state`: `current`, `stale` (older than the
|
|
41
|
+
CLI), `missing` or `unversioned`. It writes nothing. A desktop app or a setup script could tell this only by reading
|
|
42
|
+
the files itself. `skillStatus(app, env)` returns the same list, and the daily skill hint now decides from it, with
|
|
43
|
+
the same outcome as before. A CLI on cli-core gets the command when cli-core picks up this release.
|
|
44
|
+
|
|
45
|
+
- **The collection noun `tables`: "the tables of the local store, with their kind, row count and size".** cli-core's
|
|
46
|
+
`store tables list` uses it to answer which tables the shared archive holds, so a desktop app or an agent can see
|
|
47
|
+
them without opening the file. `copies` keeps its narrower meaning, the tables a repair kept aside.
|
|
48
|
+
|
|
11
49
|
## 0.55.1 — 25.10.2026
|
|
12
50
|
|
|
13
51
|
### 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/dev/lint.d.ts
CHANGED
|
@@ -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. */
|
package/dist/dev/main.d.ts
CHANGED
|
@@ -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"]) {
|
package/dist/mcp/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
33
|
+
return "unavailable";
|
|
28
34
|
if (existing.status === 0)
|
|
29
|
-
|
|
30
|
-
if (
|
|
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)
|
package/dist/skill/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/skill/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/dist/standards/lint.js
CHANGED
|
@@ -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"],
|
package/docs/standards/README.md
CHANGED
|
@@ -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` |
|
|
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
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-base",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.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",
|