@wirecat/cli-base 0.68.0 → 0.68.1

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,17 @@ 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.68.1 — 31.10.2026
12
+
13
+ ### Added
14
+
15
+ - **The audit accepts a flag that one CLI lacks by design, by name.** A parity difference may carry `absent: "<cli>"`
16
+ with a target of `<path> <flag>` and no CLI list; it covers every finding where only that CLI lacks the flag and the
17
+ others agree, so another CLI joining the group does not make it stale. `PARITY_GROUPS` uses it to declare that memo
18
+ has no profiles of its own (no `--profiles` on `mcp serve`, `mcp revoke`, `mcp server start|status|stop`); memo
19
+ selects other CLIs' accounts with `--source` and `--account`. A run without the absent CLI does not call it stale.
20
+ WC-MCP-9 and WC-ACCT-1 say so. See [#390](https://github.com/WireCatLabs/cli-base/issues/390).
21
+
11
22
  ## 0.68.0 — 31.10.2026
12
23
 
13
24
  ### Removed
@@ -26,6 +26,8 @@ export interface Finding {
26
26
  /** `<clis> <what>`: `max+tg chats list --limit`, `zm upgrade`, `tg: chats grab` for a lint violation. */
27
27
  target: string;
28
28
  message: string;
29
+ /** What each CLI showed, for a finding about one flag or output; `absent` when it lacks the flag. */
30
+ values?: Record<string, string>;
29
31
  }
30
32
  export interface ScoreRow {
31
33
  cli: string;
package/dist/dev/audit.js CHANGED
@@ -74,6 +74,7 @@ const samePathContract = (inputs, groups) => {
74
74
  clis: having,
75
75
  target: `${named(having)} ${path}${what}`,
76
76
  message: describe(values),
77
+ values: Object.fromEntries(values),
77
78
  });
78
79
  };
79
80
  const kinds = new Map([...commands].map(([cli, command]) => [
@@ -253,18 +254,35 @@ export const auditClis = (inputs, options, rules = AUDIT_RULES) => {
253
254
  options.capabilities &&
254
255
  capabilityDifference(finding.target, finding.clis, inputs.map((input) => input.document), options.capabilities))
255
256
  .map((finding) => `${finding.rule} ${finding.target}`));
256
- const declared = new Map(options.groups.flatMap((group) => group.differences.map((entry) => [`${entry.rule} ${entry.target}`, group.name])));
257
+ // An `absent` difference names no CLI list: it stands for every finding where only that CLI lacks the flag.
258
+ const differences = options.groups.flatMap((group) => group.differences.flatMap((entry) => {
259
+ if (!entry.absent)
260
+ return [{ group: group.name, ...entry }];
261
+ const matching = findings.filter((finding) => {
262
+ if (finding.rule !== entry.rule || finding.target !== `${named(finding.clis)} ${entry.target}`)
263
+ return false;
264
+ const others = Object.entries(finding.values ?? {}).filter(([cli]) => cli !== entry.absent);
265
+ return (finding.values?.[entry.absent] === "absent" && new Set(others.map(([, value]) => value)).size === 1);
266
+ });
267
+ if (matching.length === 0 && clis.includes(entry.absent))
268
+ problems.push(`stale difference in ${group.name}, no such divergence — remove it: ${entry.rule} ${entry.target} (absent in ${entry.absent})`);
269
+ return matching.map((finding) => ({
270
+ ...entry,
271
+ group: group.name,
272
+ target: finding.target,
273
+ expected: finding.message,
274
+ }));
275
+ }));
276
+ const declared = new Map(differences.map((entry) => [`${entry.rule} ${entry.target}`, entry.group]));
257
277
  const found = new Set(findings.map((finding) => `${finding.rule} ${finding.target}`));
258
278
  for (const [key, group] of declared)
259
279
  if (!found.has(key))
260
280
  problems.push(`stale difference in ${group}, no such divergence — remove it: ${key}`);
261
- for (const group of options.groups) {
262
- for (const difference of group.differences) {
263
- const finding = findings.find((one) => one.rule === difference.rule && one.target === difference.target);
264
- if (finding && difference.expected !== finding.message) {
265
- declared.delete(`${difference.rule} ${difference.target}`);
266
- problems.push(`difference in ${group.name} must match the exact current values: ${difference.rule} ${difference.target}`);
267
- }
281
+ for (const difference of differences) {
282
+ const finding = findings.find((one) => one.rule === difference.rule && one.target === difference.target);
283
+ if (finding && difference.expected !== finding.message) {
284
+ declared.delete(`${difference.rule} ${difference.target}`);
285
+ problems.push(`difference in ${difference.group} must match the exact current values: ${difference.rule} ${difference.target}`);
268
286
  }
269
287
  }
270
288
  for (const group of options.groups)
package/dist/dev/main.js CHANGED
@@ -575,7 +575,8 @@ const auditRun = (args, io) => {
575
575
  group: v.string(),
576
576
  rule: v.string(),
577
577
  target: v.string(),
578
- expected: v.string(),
578
+ expected: v.optional(v.string()),
579
+ absent: v.optional(v.string()),
579
580
  reason: v.string(),
580
581
  }));
581
582
  const parsed = v.safeParse(schema, readJson(at(values.differences)));
@@ -22,8 +22,11 @@ declare const parityGroupSchema: v.StrictObjectSchema<{
22
22
  /** Permanent differences, each with its reason; they count against the override budget of the group. */
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
+ /** `<clis> <path> <flag>`; with `absent`, only `<path> <flag>`: the CLI list is whoever else has it. */
25
26
  readonly target: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>;
26
27
  readonly expected: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
28
+ /** The CLI that lacks the path or flag by design; the rest must agree, so other CLIs joining do not stale it. */
29
+ readonly absent: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a lower-case word, words joined by -">]>, undefined>;
27
30
  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">]>;
28
31
  }, undefined>, undefined>;
29
32
  }, undefined>;
@@ -51,14 +51,22 @@ const parityGroupSchema = v.strictObject({
51
51
  /** Permanent differences, each with its reason; they count against the override budget of the group. */
52
52
  differences: v.array(v.strictObject({
53
53
  rule: word,
54
+ /** `<clis> <path> <flag>`; with `absent`, only `<path> <flag>`: the CLI list is whoever else has it. */
54
55
  target: text,
55
56
  expected: v.optional(text),
57
+ /** The CLI that lacks the path or flag by design; the rest must agree, so other CLIs joining do not stale it. */
58
+ absent: v.optional(word),
56
59
  reason: v.pipe(text, v.minLength(10, "say why, in a sentence")),
57
60
  })),
58
61
  });
59
62
  export const defineParityGroups = (input) => parseRegistry("parity groups", v.array(parityGroupSchema), input, (groups) => [
60
63
  ...duplicates(groups.map((group) => group.name)).map((name) => `"${name}" is listed twice`),
61
- ...groups.flatMap((group) => duplicates(group.differences.map((entry) => `${entry.rule} ${entry.target}`)).map((both) => `${group.name}: "${both}" is listed twice`)),
64
+ ...groups.flatMap((group) => [
65
+ ...duplicates(group.differences.map((entry) => `${entry.rule} ${entry.target}`)).map((both) => `${group.name}: "${both}" is listed twice`),
66
+ ...group.differences
67
+ .filter((entry) => entry.absent && entry.expected)
68
+ .map((entry) => `${group.name}: "${entry.target}" has \`absent\`, which measures the values itself; drop \`expected\``),
69
+ ]),
62
70
  ]);
63
71
  export const PARITY_GROUPS = defineParityGroups([
64
72
  { name: "messengers", members: ["tg", "max"], covers: "tree", differences: [] },
@@ -66,7 +74,13 @@ export const PARITY_GROUPS = defineParityGroups([
66
74
  name: "all",
67
75
  members: ["tg", "max", "memo", "drive", "zm", "wa", "mail"],
68
76
  covers: ["commands", "doctor", "skill", "mcp", "upgrade", "search", "notes", "meetings"],
69
- differences: [],
77
+ differences: ["mcp serve", "mcp revoke", "mcp server start", "mcp server status", "mcp server stop"].map((path) => ({
78
+ rule: "same-path-contract",
79
+ target: `${path} --profiles`,
80
+ absent: "memo",
81
+ reason: "memo has no profiles: it reads other CLIs' stores and selects their accounts with --source and --account " +
82
+ "(https://github.com/WireCatLabs/cli-base/issues/390)",
83
+ })),
70
84
  },
71
85
  // gcal is compared on what it already shares with every CLI; doctor, mcp, search and skill wait until the published
72
86
  // CLIs agree there (https://github.com/WireCatLabs/cli-base/issues/390), then gcal moves back into "all".
@@ -14,7 +14,9 @@ Rule ids on this page are `WC-ACCT-<n>`. How to read a rule, how ids work and wh
14
14
  ### Who follows these rules today
15
15
 
16
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`
17
- and `--account` (WC-ACCT-7). zoom resolves its own profile word by the same rules (`zm <profile> …`, `ZM_PROFILE`,
17
+ and `--account` (WC-ACCT-7). memo never creates profiles of its own: it reads the stores of other CLIs and selects
18
+ their accounts with `--source email:<name>` or `--account telegram:1`, so it has no `--profiles` either
19
+ ([cli-base#390](https://github.com/WireCatLabs/cli-base/issues/390), owner decision 2026-10-31). zoom resolves its own profile word by the same rules (`zm <profile> …`, `ZM_PROFILE`,
18
20
  `ZM_PROFILE_LOCK`) from zoom-cli 0.35.0, with cli-core's `liftProfile` and `usableProfileName`. drive still has its own
19
21
  profile code: it registers its own profiles. That is a known gap, not an exception. gcal resolves the profile through
20
22
  cli-core's `run()`, like tg, max and wa. Which CLIs lack a sign-in command of WC-ACCT-12 is listed in that rule.
@@ -48,7 +50,8 @@ profile.
48
50
  - **Example:** `max work bot messages send …` uses the same first word as `max work chats list`. `tg --json chats list`
49
51
  still works, because `--json` is an option.
50
52
  - **Exceptions:** `mcp serve --profiles <names|all>` names the profiles one server serves ([WC-MCP-9](mcp.md#wc-mcp-9--a-server-serves-the-profiles-it-is-told-with-one-connection-for-each)). It is used in
51
- place of the first word, never with it.
53
+ place of the first word, never with it. memo has no profiles of its own, so it has neither the first word nor
54
+ `--profiles`; it selects the accounts of other CLIs with `--source` and `--account`.
52
55
  - **Applies to:** all CLIs. **Since:** cli-base 0.41.0. **Rulings:** NEED-84, NEED-294, NEED-1881.
53
56
 
54
57
  ### WC-ACCT-3 · A missing profile is an error that lists the choices
@@ -155,7 +155,11 @@ and lists the profiles served. Each call follows its own profile's permissions.
155
155
  - **Example:** `max work mcp serve`, `max mcp serve --profiles work,personal` or `max mcp serve --profiles all`.
156
156
  Under the last two, a call reads `max_read { "profile": "work", "command": "inbox", "arguments": {} }`.
157
157
  `max mcp serve` alone is refused, and its message lists `personal` and `work`.
158
- - **Exceptions:** the bot server needs no held connection: each Bot API call is plain HTTPS.
158
+ - **Exceptions:** the bot server needs no held connection: each Bot API call is plain HTTPS. memo has no profiles of
159
+ its own, so `--profiles` stays absent on its `mcp serve`, `mcp revoke` and `mcp server start`, `status` and `stop`;
160
+ it reads other CLIs' stores and selects their accounts with `--source email:<name>` or `--account telegram:1`
161
+ ([cli-base#390](https://github.com/WireCatLabs/cli-base/issues/390), owner decision 2026-10-31). The audit declares
162
+ this once, as a difference of memo, not of the other CLIs.
159
163
  - **Applies to:** messenger CLIs. **Since:** cli-base 0.41.0. **Rulings:** NEED-1881.
160
164
 
161
165
  ### WC-MCP-10 · A tool and its command give the same answer and the same error
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-base",
3
- "version": "0.68.0",
3
+ "version": "0.68.1",
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",