@wirecat/cli-base 0.50.3 → 0.51.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/dist/dev/main.js CHANGED
@@ -11,12 +11,13 @@ import spawn from "cross-spawn";
11
11
  import * as v from "valibot";
12
12
  import { parseCommandsDocument } from "../commands/document.js";
13
13
  import { changelogProblems, docsProblems, JOURNAL_IDS, markdownFiles, packageVersion, structureProblems, versionScript, } from "../release/index.js";
14
- import { AUDIT_BLOCKS, PARITY_GROUPS, REQUIRED_ROOTS } from "../standards/audit.js";
14
+ import { AUDIT_BLOCKS, defineParityGroups, PARITY_GROUPS, REQUIRED_ROOTS } from "../standards/audit.js";
15
15
  import { defineReasons } from "../standards/errors.js";
16
16
  import { baselineProblems, baselineSchema, LINT_RULES, OVERRIDE_BUDGET, OVERRIDE_RULE_REVIEW, overridesProblems, overridesSchema, rulesForReview, } from "../standards/lint.js";
17
17
  import { issuesOf } from "../standards/registry.js";
18
18
  import { VOCABULARY } from "../standards/vocabulary.js";
19
19
  import { auditClis, scorecardTable, snakeKeys } from "./audit.js";
20
+ import { readCapabilities } from "./capabilities.js";
20
21
  import { changelogChange, landFold, processShell } from "./land.js";
21
22
  import { baselineOf, judge, lintCommands } from "./lint.js";
22
23
  import { nextVersion } from "./next-version.js";
@@ -48,7 +49,7 @@ const USAGE = `usage: cli-dev <command> [options]
48
49
  lint/baseline/<rule>.json to today's violations. --schemas
49
50
  is a JSON object of the CLI's own JSON Schemas by name
50
51
  audit (--cli <bin> | --commands <file>)... [--schemas <cli>=<file>]... [--reasons <cli>=<file>]...
51
- [--lint <cli>=<dir>]... [--baseline <dir>] [--for <cli>] [--json] [--write-baseline]
52
+ [--lint <cli>=<dir>]... [--capabilities <file>] [--complete] [--differences <file>] [--baseline <dir>] [--for <cli>] [--json] [--write-baseline]
52
53
  several CLIs against every lint rule and against each other:
53
54
  required roots, same path same contract, same flag same
54
55
  meaning, same entity same schema, same failure same reason.
@@ -186,12 +187,12 @@ const readJson = (path) => {
186
187
  throw new Error(`${path}: ${error.message}`);
187
188
  }
188
189
  };
189
- const commandsJson = (io, cli, file) => {
190
+ const commandsJson = (io, cli, file, commandArgs = ["commands", "--json"]) => {
190
191
  if (file)
191
192
  return readJson(isAbsolute(file) ? file : join(io.cwd, file));
192
193
  const bin = cli ?? "";
193
194
  const script = /\.[cm]?js$/.test(bin);
194
- const run = spawn.sync(script ? process.execPath : bin, [...(script ? [bin] : []), "commands", "--json"], {
195
+ const run = spawn.sync(script ? process.execPath : bin, [...(script ? [bin] : []), ...commandArgs], {
195
196
  cwd: io.cwd,
196
197
  encoding: "utf8",
197
198
  maxBuffer: 64 * 1024 * 1024,
@@ -331,6 +332,9 @@ const auditRun = (args, io) => {
331
332
  cli: { type: "string", multiple: true },
332
333
  commands: { type: "string", multiple: true },
333
334
  schemas: { type: "string", multiple: true },
335
+ capabilities: { type: "string" },
336
+ complete: { type: "boolean", default: false },
337
+ differences: { type: "string" },
334
338
  reasons: { type: "string", multiple: true },
335
339
  lint: { type: "string", multiple: true },
336
340
  baseline: { type: "string", default: "audit/baseline" },
@@ -352,18 +356,60 @@ const auditRun = (args, io) => {
352
356
  const reasons = byCli(values.reasons, "--reasons");
353
357
  const lintDirs = byCli(values.lint, "--lint");
354
358
  const problems = [];
359
+ const evidence = new Map();
360
+ if (values.complete) {
361
+ for (const [index, bin] of (values.cli ?? []).entries()) {
362
+ const raw = raws[index];
363
+ const data = commandsJson(io, bin, undefined, ["commands", "info", "--json"]);
364
+ if (typeof raw.cli === "string")
365
+ evidence.set(raw.cli, data);
366
+ }
367
+ }
355
368
  const inputs = raws.map((raw) => {
356
369
  const parsed = parseCommandsDocument(snakeKeys(raw));
357
370
  if (!parsed.document)
358
371
  throw new Error(`a commands document is not the shape cli-base's commandsDocument() writes: ${parsed.problems[0]}`);
359
372
  const cli = parsed.document.cli;
360
- const input = { document: parsed.document, raw };
373
+ const input = { document: parsed.document, raw, ...evidence.get(cli) };
361
374
  const schemaFile = schemas.get(cli);
362
375
  if (schemaFile)
363
376
  input.schemas = readJson(at(schemaFile));
364
377
  const reasonFile = reasons.get(cli);
365
378
  if (reasonFile)
366
379
  input.reasons = defineReasons(readJson(at(reasonFile)));
380
+ if (values.complete) {
381
+ if (!input.contracts ||
382
+ typeof input.contracts !== "object" ||
383
+ Array.isArray(input.contracts) ||
384
+ Object.keys(input.contracts).length === 0)
385
+ problems.push(`${cli}: complete audit needs core contract references from commands info`);
386
+ for (const root of REQUIRED_ROOTS) {
387
+ const path = root.path.join(" ");
388
+ if (!input.contracts?.[path])
389
+ problems.push(`${cli}: complete audit needs an inherited contract reference for ${path}`);
390
+ }
391
+ if (!input.schemas ||
392
+ typeof input.schemas !== "object" ||
393
+ Array.isArray(input.schemas) ||
394
+ Object.keys(input.schemas).length === 0)
395
+ problems.push(`${cli}: complete audit needs nonempty entity schemas from commands info or --schemas`);
396
+ if (!Array.isArray(input.reasons) || input.reasons.length === 0)
397
+ problems.push(`${cli}: complete audit needs declared error reasons from commands info or --reasons`);
398
+ if (input.reasons)
399
+ input.reasons = defineReasons(input.reasons);
400
+ if (input.schemas) {
401
+ const visit = (commands) => {
402
+ for (const command of commands) {
403
+ const names = [command.output?.entity, command.query?.entity].filter((name) => name !== undefined);
404
+ for (const name of names)
405
+ if (input.schemas?.[name] === undefined)
406
+ problems.push(`${cli}: complete audit has no schema for ${name} (${command.path.join(" ")})`);
407
+ visit(command.commands);
408
+ }
409
+ };
410
+ visit(input.document.commands);
411
+ }
412
+ }
367
413
  const dir = lintDirs.get(cli);
368
414
  if (dir) {
369
415
  const overridesPath = join(at(dir), "overrides.json");
@@ -398,8 +444,32 @@ const auditRun = (args, io) => {
398
444
  problems.push(`--for names ${values.for}, which is not audited`);
399
445
  const baselineDir = at(values.baseline);
400
446
  const baseline = readBaseline(baselineDir, values.baseline, problems);
447
+ let groups = PARITY_GROUPS;
448
+ if (values.differences) {
449
+ const schema = v.array(v.strictObject({
450
+ group: v.string(),
451
+ rule: v.string(),
452
+ target: v.string(),
453
+ expected: v.string(),
454
+ reason: v.string(),
455
+ }));
456
+ const parsed = v.safeParse(schema, readJson(at(values.differences)));
457
+ if (!parsed.success)
458
+ throw new Error(`${values.differences}: ${issuesOf(parsed.issues).join("; ")}`);
459
+ for (const entry of parsed.output)
460
+ if (!groups.some((group) => group.name === entry.group))
461
+ throw new Error(`${values.differences}: unknown group ${entry.group}`);
462
+ groups = defineParityGroups(groups.map((group) => ({
463
+ ...group,
464
+ differences: [
465
+ ...group.differences,
466
+ ...parsed.output.filter((entry) => entry.group === group.name).map(({ group: _, ...entry }) => entry),
467
+ ],
468
+ })));
469
+ }
401
470
  const result = auditClis(inputs, {
402
- groups: PARITY_GROUPS,
471
+ ...(values.capabilities ? { capabilities: readCapabilities(readJson(at(values.capabilities))) } : {}),
472
+ groups,
403
473
  roots: REQUIRED_ROOTS,
404
474
  vocabulary: VOCABULARY,
405
475
  baseline,
@@ -0,0 +1,13 @@
1
+ import type { CommandInfo } from "../commands/index.js";
2
+ export interface ContractEdit {
3
+ command: readonly string[];
4
+ field: string;
5
+ before: unknown;
6
+ after: unknown;
7
+ reason: string;
8
+ }
9
+ /** A baseline cannot replace a core definition: only an exact, reasoned edit changes its effective value. */
10
+ export declare const sharedContractProblems: (commands: readonly CommandInfo[], references: Readonly<Record<string, Readonly<Record<string, unknown>>>>, edits?: readonly ContractEdit[]) => {
11
+ path: string;
12
+ message: string;
13
+ }[];
@@ -0,0 +1,85 @@
1
+ const stable = (value) => {
2
+ if (Array.isArray(value))
3
+ return value.map(stable);
4
+ if (value && typeof value === "object")
5
+ return Object.fromEntries(Object.entries(value)
6
+ .sort(([a], [b]) => a.localeCompare(b))
7
+ .map(([key, child]) => [key, stable(child)]));
8
+ return value;
9
+ };
10
+ const same = (left, right) => JSON.stringify(stable(left ?? null)) === JSON.stringify(stable(right ?? null));
11
+ const FIELDS = new Set([
12
+ "name",
13
+ "summary",
14
+ "description",
15
+ "origin",
16
+ "api_operation",
17
+ "effects",
18
+ "state",
19
+ "partial",
20
+ "lifetime",
21
+ "reaches",
22
+ "mcp",
23
+ "offline",
24
+ "cooperative",
25
+ "examples",
26
+ "output",
27
+ "query",
28
+ "runnable",
29
+ "arguments",
30
+ "options",
31
+ "aliases",
32
+ "children",
33
+ ]);
34
+ /** A baseline cannot replace a core definition: only an exact, reasoned edit changes its effective value. */
35
+ export const sharedContractProblems = (commands, references, edits = []) => {
36
+ const effective = new Map(Object.entries(references).map(([path, contract]) => [path, structuredClone(contract)]));
37
+ const problems = [];
38
+ for (const edit of edits) {
39
+ const path = edit.command.join(" ");
40
+ const contract = effective.get(path);
41
+ if (!contract ||
42
+ !FIELDS.has(edit.field) ||
43
+ edit.reason.trim().length < 10 ||
44
+ !same(contract[edit.field], edit.before) ||
45
+ same(edit.before, edit.after)) {
46
+ problems.push({ path, message: `invalid or stale explicit override for ${edit.field}` });
47
+ continue;
48
+ }
49
+ if (edit.after === null)
50
+ delete contract[edit.field];
51
+ else
52
+ contract[edit.field] = structuredClone(edit.after);
53
+ }
54
+ const walk = (items, inherited) => {
55
+ for (const command of items) {
56
+ const path = command.path.join(" ");
57
+ const expected = effective.get(path);
58
+ if (expected) {
59
+ const { path: _, usage: __, commands: nested, ...fields } = command;
60
+ const original = references[path]?.children;
61
+ const actual = {
62
+ ...fields,
63
+ ...(Array.isArray(original)
64
+ ? { children: nested.filter((child) => original.includes(child.name)).map((child) => child.name) }
65
+ : {}),
66
+ };
67
+ const wanted = { ...expected, ...inherited };
68
+ const changed = [...new Set([...Object.keys(actual), ...Object.keys(wanted)])].filter((field) => field !== "aliases" &&
69
+ field !== "availability" &&
70
+ !same(actual[field], wanted[field]));
71
+ if (changed.length)
72
+ problems.push({ path, message: `core contract differs without an exact override: ${changed.join(", ")}` });
73
+ effective.delete(path);
74
+ }
75
+ walk(command.commands, {
76
+ ...(inherited.mcp === false || expected?.mcp === false ? { mcp: false } : {}),
77
+ ...(inherited.offline === false || expected?.offline === false ? { offline: false } : {}),
78
+ });
79
+ }
80
+ };
81
+ walk(commands, {});
82
+ for (const path of effective.keys())
83
+ problems.push({ path, message: "core contract names a command absent from discovery" });
84
+ return problems;
85
+ };
@@ -23,6 +23,7 @@ declare const parityGroupSchema: v.StrictObjectSchema<{
23
23
  readonly differences: v.ArraySchema<v.StrictObjectSchema<{
24
24
  readonly rule: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a lower-case word, words joined by -">]>;
25
25
  readonly target: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>;
26
+ readonly expected: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
26
27
  readonly reason: v.SchemaWithPipe<readonly [v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, v.MinLengthAction<string, 10, "say why, in a sentence">]>;
27
28
  }, undefined>, undefined>;
28
29
  }, undefined>;
@@ -10,6 +10,11 @@ const auditRuleSchema = v.strictObject({ id: word, severity: v.picklist(LINT_SEV
10
10
  export const defineAuditRules = (input) => parseRegistry("audit rules", v.array(auditRuleSchema), input, (rules) => duplicates(rules.map((rule) => rule.id)).map((id) => `"${id}" is listed twice`));
11
11
  /** The rules that compare CLIs; each CLI is also judged alone by every lint rule. */
12
12
  export const AUDIT_RULES = defineAuditRules([
13
+ {
14
+ id: "shared-command-contract",
15
+ severity: "high",
16
+ summary: "core-owned command definitions change only through exact, reasoned overrides",
17
+ },
13
18
  {
14
19
  id: "required-root",
15
20
  severity: "high",
@@ -44,7 +49,12 @@ const parityGroupSchema = v.strictObject({
44
49
  /** `tree` compares every path; otherwise the root words whose paths are compared. */
45
50
  covers: v.union([v.literal("tree"), v.pipe(v.array(segment), v.minLength(1))]),
46
51
  /** Permanent differences, each with its reason; they count against the override budget of the group. */
47
- differences: v.array(v.strictObject({ rule: word, target: text, reason: v.pipe(text, v.minLength(10, "say why, in a sentence")) })),
52
+ differences: v.array(v.strictObject({
53
+ rule: word,
54
+ target: text,
55
+ expected: v.optional(text),
56
+ reason: v.pipe(text, v.minLength(10, "say why, in a sentence")),
57
+ })),
48
58
  });
49
59
  export const defineParityGroups = (input) => parseRegistry("parity groups", v.array(parityGroupSchema), input, (groups) => [
50
60
  ...duplicates(groups.map((group) => group.name)).map((name) => `"${name}" is listed twice`),
@@ -448,6 +448,12 @@ export const HOW_FLAGS = [
448
448
  meaning: "override one permission for this run; repeat it for more",
449
449
  },
450
450
  { name: "--read-only", role: "mode", type: "switch", meaning: "refuse every write, local and remote" },
451
+ {
452
+ name: "--append",
453
+ role: "mode",
454
+ type: "switch",
455
+ meaning: "keep existing entries and add the explicitly named one beside them",
456
+ },
451
457
  { name: "--replace", role: "mode", type: "switch", meaning: "overwrite an existing one of the same name" },
452
458
  {
453
459
  name: "--rescan",
@@ -323,6 +323,34 @@ export const WHAT_FLAGS = [
323
323
  meaning: "look again at this many recent days, for what arrived late",
324
324
  unit: "days",
325
325
  },
326
+ {
327
+ name: "--meeting-id",
328
+ value: "<id>",
329
+ role: "target",
330
+ type: "id",
331
+ meaning: "one stored meeting selected for transcript search",
332
+ },
333
+ {
334
+ name: "--identity-id",
335
+ value: "<id>",
336
+ role: "target",
337
+ type: "id",
338
+ meaning: "a stored participant identity whose meetings are selected; does not identify the speaker of a hit",
339
+ },
340
+ {
341
+ name: "--person-id",
342
+ value: "<id>",
343
+ role: "target",
344
+ type: "id",
345
+ meaning: "a stored person whose linked participant identities select meetings",
346
+ },
347
+ {
348
+ name: "--series-id",
349
+ value: "<id>",
350
+ role: "target",
351
+ type: "id",
352
+ meaning: "one stored recurring series selected for meeting search",
353
+ },
326
354
  { name: "--meeting", value: "<id>", role: "target", type: "id", meaning: "a meeting, by its store id" },
327
355
  {
328
356
  name: "--message",
@@ -408,6 +436,13 @@ export const WHAT_FLAGS = [
408
436
  },
409
437
  { name: "--task", value: "<id>", role: "target", type: "id", meaning: "a task, by id" },
410
438
  { name: "--to", value: "<chat>", role: "target", type: "id", meaning: "the chat it goes to" },
439
+ {
440
+ name: "--topic-name",
441
+ value: "<name>",
442
+ role: "target",
443
+ type: "string",
444
+ meaning: "a stored meeting topic label, by name or alias",
445
+ },
411
446
  { name: "--topic", value: "<id>", role: "target", type: "id", meaning: "the forum topic, in a chat that has topics" },
412
447
  { name: "--transcript-id", value: "<id>", role: "target", type: "id", meaning: "one transcript version, by id" },
413
448
  { name: "--until-id", value: "<id>", role: "target", type: "id", meaning: "up to and including this message id" },
@@ -120,7 +120,7 @@ const verbs = [
120
120
  ["cancel", "stop a job or a pending thing"],
121
121
  [
122
122
  "sync",
123
- "bring the local store up to date from the source: only what changed, or the whole list where the source cannot say",
123
+ "bring the local store up to date from the source: only what changed, or the whole list where the source cannot say; a mirror syncs both ways",
124
124
  ],
125
125
  ["fetch", "one bounded pull of one named thing into the store, not a catch-up"],
126
126
  ["export", "write from the store to a file"],
@@ -169,6 +169,10 @@ const verbs = [
169
169
  ["unarchive", "bring an archived chat back to the main chat list"],
170
170
  ["reset", "replace a thing; the old one stops working"],
171
171
  ["moderate", "apply a chat's rules: delete what breaks them, act on who broke them"],
172
+ ["assign", "name the person responsible for work, with their task role"],
173
+ ["reverse", "record that an accepted decision no longer holds, without replacing it"],
174
+ ["invalidate", "mark a derived record whose supporting evidence no longer holds"],
175
+ ["acknowledge", "record successful delivery using the exact receipt issued when the item was claimed"],
172
176
  ["confirm", "the owner accepts what an agent proposed"],
173
177
  ["reject", "the owner refuses what an agent proposed"],
174
178
  ["move", "move a file in the source"],
@@ -239,6 +243,10 @@ const collections = [
239
243
  ["recordings", "the files a provider recorded of a meeting"],
240
244
  ["jobs", "work this tool runs in the background or on a timer"],
241
245
  ["actions", "what an agent proposes to do; the owner confirms or rejects each"],
246
+ ["agents", "the agents whose actions are recorded in the shared store"],
247
+ ["reminders", "scheduled notices about stored work, claimed and acknowledged with delivery receipts"],
248
+ ["key-documents", "selected source documents that a project keeps as its starting references"],
249
+ ["answers", "evidence-grounded answers assembled from selected stored sources"],
242
250
  ["aliases", "other names of one thing"],
243
251
  ["mentions", "phones, e-mail addresses, links and documents found in stored text, each where it was written"],
244
252
  ["images", "pictures in stored messages, searched by what they show"],
@@ -264,6 +272,7 @@ const collections = [
264
272
  const singulars = [
265
273
  ["session", "this tool's login"],
266
274
  ["account", "the messenger or mail account behind a profile"],
275
+ ["mail", "the local email archive and its mailbox import, rules and cleanup operations"],
267
276
  ["config", "a profile's settings"],
268
277
  ["server", "the long-running process of a profile"],
269
278
  ["store", "the local SQLite database the tools share"],
@@ -38,6 +38,7 @@ rules it keeps. A new CLI is ready for its first release when every check under
38
38
  | [Testing](testing.md) | `TEST` | 17 | Sandbox, no network, no real waits, leak tests, coverage, contracts, test matrix, parity |
39
39
  | [Security](security.md) | `SECR` | 20 | Secrets, logs, keyring, argv, error text, untrusted text, scanners, workflow hardening, reporting |
40
40
  | [Documents](docs.md) | `DOC` | 20 | User pages and developer pages, generated pages, current facts, changelog |
41
+ | [Comparisons](comparisons.md) | `CMP` | 11 | Which tools a comparison page lists, rows as user tasks, agents and safety, where others are better, proof of marks |
41
42
  | [Releases](releases.md) | `REL` | 16 | `bin/release`, the release lock, versions, release reports, trusted publishing |
42
43
  | [CI](ci.md) | `CI` | 12 | Hooks, the pull request budget, full validation, monthly runs |
43
44
  | [Time](time.md) | `TIME` | 6 | UTC in the store and in answers, the zone at input and display edges, date-only filters, tests in another zone |
@@ -13,8 +13,7 @@ Rule ids on this page are `WC-ACCT-<n>`. How to read a rule, how ids work and wh
13
13
 
14
14
  ### Who follows these rules today
15
15
 
16
- cli-core resolves the profile for tg, max and wa. WC-ACCT-1 needs cli-core 0.319.0 or newer, and all three pin
17
- 0.321.0. memo has no messenger profile: its shared commands run without one, and they pick accounts with `--provider`
16
+ cli-core resolves the profile for tg, max and wa. WC-ACCT-1 needs cli-core 0.319.0 or newer. memo has no messenger profile: its shared commands run without one, and they pick accounts with `--provider`
18
17
  and `--account` (WC-ACCT-7). zoom resolves its own profile word by the same rules (`zm <profile> …`, `ZM_PROFILE`,
19
18
  `ZM_PROFILE_LOCK`) from zoom-cli 0.35.0, with cli-core's `liftProfile` and `usableProfileName`. drive still has its own
20
19
  profile code: it registers its own profiles. That is a known gap, not an exception.
@@ -96,7 +96,7 @@ cli-testing, cli-docs, plus community itself.
96
96
  cli-docs validates its site before each deployment instead, and community has no build. Third-party security apps
97
97
  may post their own checks.
98
98
  - **Applies to:** the CLIs, cli-base, cli-core and cli-testing. cli-base, whatsapp-cli, tg-cli, max-cli, cli-core
99
- cli-memo and zoom-cli have `pr.yml`; drive-cli and cli-testing do not have it yet. **Since:**
99
+ cli-memo, zoom-cli and drive-cli have `pr.yml`; cli-testing does not have it yet. **Since:**
100
100
  cli-base 0.41.0 (pull request validation since cli-base 0.44.6). **Rulings:** none.
101
101
 
102
102
  ### WC-CI-6 · Run the changelog guard on every pull request
@@ -168,9 +168,10 @@ cli-testing, cli-docs, plus community itself.
168
168
  - **Why:** a red merge breaks `main` for every parallel branch, and an admin merge skips the only gate there is.
169
169
  - **Enforced by:** `whatsapp-cli: main ruleset required checks` · `cli-core: main ruleset required checks` ·
170
170
  `tg-cli: main ruleset required checks` · `max-cli: main ruleset required checks` ·
171
- `cli-memo: main ruleset required checks`, plus the main ruleset of cli-base itself. In each repository with pr.yml,
172
- the ruleset requires the pull request validation jobs besides the standards job. drive-cli, cli-testing and cli-docs
173
- have no pr.yml, so their rulesets require only the standards job, and review covers the rest. zoom-cli has pr.yml,
171
+ `cli-memo: main ruleset required checks` · `drive-cli: main ruleset required checks`, plus the main ruleset of
172
+ cli-base itself. In each repository with pr.yml, the ruleset requires the pull request validation jobs besides the
173
+ standards job. cli-testing and cli-docs have no pr.yml, so their rulesets require only the standards job, and
174
+ review covers the rest. zoom-cli has pr.yml,
174
175
  but its ruleset still requires only the standards job.
175
176
  - **Example:** `gh pr checks <number>` shows every line as pass before `gh pr merge`. Never merge with `--admin`. A
176
177
  release changes `main` the same way
@@ -287,10 +287,10 @@ Each of these is set with `annotate(command, { … })` from `@wirecat/cli-base/c
287
287
  ### WC-CMD-27 · Refuse a command path that arrived as one word
288
288
 
289
289
  - **Why:** a shell loop that quotes `"projects add"` would otherwise get the root help and no reason.
290
- - **Enforced by:** `src/commands/commands.test.ts` · `cli-memo: src/program.test.ts`.
290
+ - **Enforced by:** `src/commands/commands.test.ts` · `cli-memo: src/program.test.ts` · `cli-core: src/cli/program.ts`.
291
291
  - **Example:** call `refuseJoinedPath(program, args)` before `parseAsync`. It throws a `validation_error` that names
292
292
  the words to type.
293
- - **Exceptions:** none. Only memo calls `refuseJoinedPath` today; cli-core and the other CLIs do not yet.
293
+ - **Exceptions:** none. memo and cli-core call `refuseJoinedPath` before parsing.
294
294
  - **Applies to:** all CLIs. **Since:** cli-base 0.41.0. **Rulings:** none.
295
295
 
296
296
  ## External references
@@ -0,0 +1,133 @@
1
+ # Comparisons
2
+
3
+ ## Scope
4
+
5
+ How a CLI's comparison page is written: which tools it compares, which rows it has, how a mark is proven, and what it
6
+ says about the places where another tool is better. The page is read by someone choosing a tool for a job, not by
7
+ someone reviewing our code.
8
+
9
+ The page lives in the site repository (`cli-docs`, published on wirecat.dev), not in the CLI repository, because a
10
+ comparison changes when the other tools change, not when we release. The CLI repository keeps a short
11
+ `docs/compare.md` that links to the site page, and its other docs link there too.
12
+
13
+ Rule ids on this page are `WC-CMP-<n>`. How to read a rule, how ids work and which rule wins are in the
14
+ [index](README.md). The general rules for user pages are in [Documents](docs.md).
15
+
16
+ ## Rules
17
+
18
+ ### WC-CMP-1 · Compare with the tools a reader would pick instead, for the same job
19
+
20
+ - **Why:** a table against products in another category answers a question nobody asked and makes us look either
21
+ better or worse than we are.
22
+ - **Enforced by:** review only.
23
+ - **Example:** zm is compared with other command lines and MCP servers for a Zoom account. Meeting assistants that
24
+ join calls (Otter, Fireflies) are a different job; that comparison stays private until zm does that job too.
25
+ - **Exceptions:** one column may be the official app or the official API, when readers would otherwise use it.
26
+ - **Applies to:** all CLIs. **Since:** cli-base 0.50.4. **Rulings:** owner, 2026-10-24.
27
+
28
+ ### WC-CMP-2 · A row is a task the user wants done, grouped by how people use the tool
29
+
30
+ - **Why:** people choose a tool by what they can do with it: read and send messages, send photos, find a chat, find a
31
+ file, keep a draft, see who is active in a group. How login works or where data is stored is rarely the reason.
32
+ - **Enforced by:** review only.
33
+ - **Example:** groups such as "Catch up", "Reply and send", "Find", "Files and media", "Organise", "Overview and
34
+ analytics", "Agents and safety", "Setup". Rows such as "Send several photos as one album", "Find a file someone sent
35
+ in any chat". Not rows such as "QR login and two-step password" or "Works without a local copy".
36
+ - **Exceptions:** an implementation detail gets a row only when it changes what the user can do, such as "Search
37
+ without internet".
38
+ - **Applies to:** all CLIs. **Since:** cli-base 0.50.4. **Rulings:** owner, 2026-10-24.
39
+
40
+ ### WC-CMP-3 · A missing feature gets a row only if users would choose a tool by it
41
+
42
+ - **Why:** a row we fail reads as a gap to fix. A rare account setting listed as missing makes the product look
43
+ incomplete for no reason.
44
+ - **Enforced by:** review only.
45
+ - **Example:** "Set or remove the cloud password" is not a row on max's page. "Drafts" is.
46
+ - **Exceptions:** none.
47
+ - **Applies to:** all CLIs. **Since:** cli-base 0.50.4. **Rulings:** owner, 2026-10-24.
48
+
49
+ ### WC-CMP-4 · Write each row so that ❌ cannot be misread
50
+
51
+ - **Why:** a short noun can name a bigger thing than the row means; with ❌ beside it the reader concludes the tool
52
+ cannot do the bigger thing.
53
+ - **Enforced by:** review only.
54
+ - **Example:** "Show the other person that you are typing", not "Typing". ❌ beside "Typing" reads as "cannot send
55
+ messages".
56
+ - **Exceptions:** none.
57
+ - **Applies to:** all CLIs. **Since:** cli-base 0.50.4. **Rulings:** owner, 2026-10-24.
58
+
59
+ ### WC-CMP-5 · Every page has an "Agents and safety" group, and it may be the largest
60
+
61
+ - **Why:** it is what the WireCat tools are built for, and what a reader cannot learn from a feature list: what an
62
+ agent may do without asking, how a wrong send is stopped, what the agent sees.
63
+ - **Enforced by:** review only.
64
+ - **Example:** rows such as "An agent can read but not send unless you allow it", "Ask before each send", "Limit sends
65
+ to a list of chats", "A send is never made twice after a retry", "Every command reachable through MCP", "Works with
66
+ an agent without a browser".
67
+ - **Exceptions:** none.
68
+ - **Applies to:** all CLIs. **Since:** cli-base 0.50.4. **Rulings:** owner, 2026-10-24.
69
+
70
+ ### WC-CMP-6 · Keep technical rows few, and only where they help a choice
71
+
72
+ - **Why:** a reader who wants protocol details reads the docs; a comparison full of them hides the answer.
73
+ - **Enforced by:** review only.
74
+ - **Example:** one "Setup" group: install command, platforms, licence, price, MCP over stdio or HTTP, and how the tool
75
+ compares with a browser agent driving the web app (speed, cost, reliability).
76
+ - **Exceptions:** none.
77
+ - **Applies to:** all CLIs. **Since:** cli-base 0.50.4. **Rulings:** owner, 2026-10-24.
78
+
79
+ ### WC-CMP-7 · Say plainly where another tool is better
80
+
81
+ - **Why:** a table that only shows our wins is not believed, and a reader who finds the gap later stops trusting the
82
+ rest.
83
+ - **Enforced by:** review only.
84
+ - **Example:** a section "Where another tool fits better" with two to four scenarios, each one sentence: the job, the
85
+ tool that does it better, why. Only gaps a reader would care about (WC-CMP-3).
86
+ - **Exceptions:** none.
87
+ - **Applies to:** all CLIs. **Since:** cli-base 0.50.4. **Rulings:** owner, 2026-10-24.
88
+
89
+ ### WC-CMP-8 · No "better" or "faster" without a published measurement
90
+
91
+ - **Why:** a quality claim against another tool is the claim a reader is most likely to test.
92
+ - **Enforced by:** review only.
93
+ - **Example:** "memo finds better answers than Hindsight" needs a result in cli-testing `performance/` and a link to
94
+ it. Without one, the row says what each tool does ("search by words and by meaning"), not which does it better.
95
+ - **Exceptions:** none.
96
+ - **Applies to:** all CLIs. **Since:** cli-base 0.50.4. **Rulings:** none.
97
+
98
+ ### WC-CMP-9 · Every mark is proven, and the page says when it was checked
99
+
100
+ - **Why:** a comparison goes stale faster than any other page, and a wrong mark about another project is unfair to
101
+ it.
102
+ - **Enforced by:** review only.
103
+ - **Example:** our marks are checked against the code on `main` or by running the tool. A competitor mark links to
104
+ its own documentation or source. A mark that cannot be proven is ➖ ("not in its documentation"). The intro names the
105
+ check date. The legend is ✅ yes · 🟡 partly · ❌ no · ➖ not in its documentation, plus "n/a" where
106
+ a row cannot apply to a tool (an MCP row for a connector that is itself the agent).
107
+ - **Exceptions:** none.
108
+ - **Applies to:** all CLIs. **Since:** cli-base 0.50.4. **Rulings:** none.
109
+
110
+ ### WC-CMP-10 · Keep the table short enough to read
111
+
112
+ - **Why:** past about 25 rows the reader stops scanning, and the rows that matter are lost.
113
+ - **Enforced by:** review only.
114
+ - **Example:** one feature table, with each group as a bold row inside it; 15 to 25 rows and 3 to 6 tools. A row
115
+ where every tool has the same mark is dropped.
116
+ - **Exceptions:** none.
117
+ - **Applies to:** all CLIs. **Since:** cli-base 0.50.4. **Rulings:** owner, 2026-10-24.
118
+
119
+ ### WC-CMP-11 · Our marks are re-checked at each release
120
+
121
+ - **Why:** our own marks go stale with every release, and a mark that says we lack something we have shipped, or the
122
+ reverse, is the error a reader finds first.
123
+ - **Enforced by:** review only.
124
+ - **Example:** the release report lists the compare page as checked. Before a release, each ✅, 🟡 and ❌ for the CLI is re-proven against the release build, the check date
125
+ and version in the intro are updated in the site repository, and the release report names the page.
126
+ - **Exceptions:** none.
127
+ - **Applies to:** all CLIs. **Since:** cli-base 0.50.5. **Rulings:** owner, 2026-10-24.
128
+
129
+ ## Sources
130
+
131
+ - Owner review of the max, zm and memo comparison pages, 2026-10-24.
132
+ - The tg comparison page, the first page written this way.
133
+ - Owner decision, 2026-10-24: comparison pages live in `cli-docs`; each CLI's docs link to them.