@wirecat/cli-base 0.75.0 → 0.75.2

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,50 @@ 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.75.2 — 03.11.2026
12
+
13
+ ### Added
14
+
15
+ - **Rule WC-CMD-30: every setting is in the config file with its default, and `config show` lists them all**
16
+ ([WC-CMD-30](docs/standards/commands.md#wc-cmd-30--write-every-setting-into-the-config-file-with-its-default-and-list-them-all-in-config-show)).
17
+ A CLI declares each scalar setting with its default and a description through cli-core's `DeclaredSetting`. It
18
+ fills the missing ones whenever it writes the file anyway, and on `config migrate`. A plain read never rewrites the
19
+ file. The owner then finds and edits every setting in the file without the docs. memo follows it first; the
20
+ messenger CLIs and zm are listed as known gaps.
21
+
22
+ ### Fixed
23
+
24
+ - **A test that reaches the real keyring now fails, even when it falls back to a file.** The shared test setup's
25
+ keyring stand-in threw, but `Credentials` in `auto` mode catches that, warns and saves to a file, so the test
26
+ passed with only a warning on the console. The stand-in now records the reach, and the setup fails the test after it
27
+ with the stack of the call. Pass `memoryKeyring()` to fix such a test. mail-cli's and drive-cli's suites pass
28
+ under it.
29
+ - **One bad entry in `lint/overrides.json` no longer turns off all the others.** `cli-dev lint` and `cli-dev audit`
30
+ dropped the whole file when one entry did not fit its shape, so every violation the good entries covered came back
31
+ as new, and the cause was one line among them. Now the bad entry fails the run as `entry <n> (<rule>) does not apply
32
+ — <why>`, and the other entries still apply.
33
+ - **The `wcat-bin-name` and `wcat-describe` warnings can be overridden.** An entry for them was refused as "no lint
34
+ rule" and the warning stayed. Now an override with its reason silences the warning, counts against the budget, and
35
+ fails as stale once the warning is gone. A CLI that keeps its short command name says why this way.
36
+ - **`docs:check` refuses an entry under no `###` heading in the top changelog section.** It passed one, so a release
37
+ could ship notes with an entry outside the five headings
38
+ ([the changelog rule](docs/standards/docs.md#wc-doc-15--changelog-entries-go-under-five-fixed-headings-each-at-most-once-per-section)).
39
+ Older sections are not checked: they are frozen, and several CLIs have such entries in them.
40
+ - **The handbook's lines about gcal match gcal again:** `store status` (not `status`), a calendar sync's 15-minute
41
+ terminal deadline, all-day events matched by local date, `mcp serve --profiles` built, gcal's own Node 22 job and
42
+ Windows/macOS runs, its fixed package list, its generated `api` among the generated-command exceptions, and its
43
+ `api` writes no longer listed as all `ask`.
44
+
45
+ ## 0.75.1 — 02.11.2026
46
+
47
+ ### Fixed
48
+
49
+ - **A list's help and MCP `query` description no longer offer `word*` or ranges that none of its fields takes.**
50
+ `fieldsHelp`'s `Syntax:` line on a `backend: "sql"` list now lists the phrase, `word*` and `[a TO b] · > >= < <=`
51
+ forms only when one of its shown fields takes them, as a native list's line already did; `AND OR NOT` and `( )`
52
+ stay. A list of whole values only (enum, bool) said it took both, and refused them. `queryDescription` drops
53
+ `[a TO b], > <` the same way. A list whose fields take every form prints the same line as before.
54
+
11
55
  ## 0.75.0 — 02.11.2026
12
56
 
13
57
  ### Fixed
package/dist/dev/main.js CHANGED
@@ -15,7 +15,7 @@ import { taggedSection } from "../release/fold.js";
15
15
  import { changelogProblems, docsProblems, JOURNAL_IDS, markdownFiles, packageVersion, structureProblems, versionScript, } from "../release/index.js";
16
16
  import { AUDIT_BLOCKS, defineParityGroups, PARITY_GROUPS, REQUIRED_ROOTS } from "../standards/audit.js";
17
17
  import { defineReasons } from "../standards/errors.js";
18
- import { baselineProblems, baselineSchema, LINT_RULES, OVERRIDE_BUDGET, OVERRIDE_RULE_REVIEW, overridesProblems, overridesSchema, rulesForReview, } from "../standards/lint.js";
18
+ import { baselineProblems, baselineSchema, LINT_RULES, OVERRIDE_BUDGET, OVERRIDE_RULE_REVIEW, overrideSchema, overridesProblems, rulesForReview, } from "../standards/lint.js";
19
19
  import { issuesOf, RegistryError } from "../standards/registry.js";
20
20
  import { VOCABULARY } from "../standards/vocabulary.js";
21
21
  import { auditClis, scorecardTable, snakeKeys } from "./audit.js";
@@ -25,7 +25,7 @@ import { baselineOf, judge, lintCommands, RUN_LOG_COMMANDS, walk } from "./lint.
25
25
  import { nextVersion } from "./next-version.js";
26
26
  import { slowTests } from "./slow-tests.js";
27
27
  import { matrixPage, matrixRows, parseArgvLog } from "./test-matrix.js";
28
- import { binNameWarnings, describeWarnings } from "./wcat-lint.js";
28
+ import { binNameWarnings, describeWarnings, WCAT_RULES } from "./wcat-lint.js";
29
29
  const USAGE = `usage: cli-dev <command> [options]
30
30
 
31
31
  slow-tests [report] [count] the slowest tests and files (report: coverage/tests.json, count: 20)
@@ -314,6 +314,30 @@ const readBaseline = (dir, label, problems) => {
314
314
  }
315
315
  return baseline;
316
316
  };
317
+ /**
318
+ * Each entry on its own: one bad entry is named and ignored, and the others still apply. Dropping the whole file hid
319
+ * the bad entry behind every violation the good ones had covered.
320
+ */
321
+ const readOverrides = (path, label, today, problems) => {
322
+ if (!existsSync(path))
323
+ return [];
324
+ const raw = readJson(path);
325
+ if (!Array.isArray(raw)) {
326
+ problems.push(`${label}: a JSON array of overrides, so none of it applies`);
327
+ return [];
328
+ }
329
+ const overrides = raw.flatMap((entry, index) => {
330
+ const read = v.safeParse(overrideSchema, entry);
331
+ if (read.success)
332
+ return [read.output];
333
+ const rule = typeof entry === "object" && entry !== null && "rule" in entry ? ` (${String(entry.rule)})` : "";
334
+ problems.push(`${label}: entry ${index}${rule} does not apply — ${issuesOf(read.issues).join("; ")}`);
335
+ return [];
336
+ });
337
+ for (const issue of overridesProblems(overrides, today))
338
+ problems.push(`${label}: ${issue}`);
339
+ return overrides;
340
+ };
317
341
  const lint = (args, io) => {
318
342
  try {
319
343
  return lintRun(args, io);
@@ -364,15 +388,9 @@ const lintRun = (args, io) => {
364
388
  return 2;
365
389
  }
366
390
  const problems = [];
367
- const overridesPath = join(io.cwd, values.overrides);
368
- let overrides = [];
369
- if (existsSync(overridesPath)) {
370
- const read = v.safeParse(overridesSchema, readJson(overridesPath));
371
- const found = read.success ? overridesProblems(read.output, today(io)) : issuesOf(read.issues);
372
- if (read.success)
373
- overrides = read.output;
374
- problems.push(...found.map((issue) => `${values.overrides}: ${issue}`));
375
- }
391
+ const overrides = readOverrides(join(io.cwd, values.overrides), values.overrides, today(io), problems);
392
+ const isWcat = (entry) => WCAT_RULES.includes(entry.rule);
393
+ const ruleOverrides = overrides.filter((entry) => !isWcat(entry));
376
394
  const baselineDir = join(io.cwd, values.baseline);
377
395
  const baseline = readBaseline(baselineDir, values.baseline, problems);
378
396
  const schemas = values.schemas === undefined ? undefined : readJson(join(io.cwd, values.schemas));
@@ -390,7 +408,7 @@ const lintRun = (args, io) => {
390
408
  const severity = new Map(LINT_RULES.map((rule) => [rule.id, rule.severity]));
391
409
  if (values["write-baseline"]) {
392
410
  mkdirSync(baselineDir, { recursive: true });
393
- for (const [rule, targets] of Object.entries(baselineOf(violations, overrides))) {
411
+ for (const [rule, targets] of Object.entries(baselineOf(violations, ruleOverrides))) {
394
412
  const path = join(baselineDir, `${rule}.json`);
395
413
  if (targets.length > 0)
396
414
  writeFileSync(path, `${JSON.stringify(targets, null, 2)}\n`);
@@ -399,7 +417,7 @@ const lintRun = (args, io) => {
399
417
  baseline[rule] = targets;
400
418
  }
401
419
  }
402
- const verdict = judge(violations, baseline, overrides, budget);
420
+ const verdict = judge(violations, baseline, ruleOverrides, budget);
403
421
  for (const id of verdict.unknownRules)
404
422
  problems.push(`no lint rule "${id}" (named in ${values.baseline} or ${values.overrides})`);
405
423
  for (const found of verdict.fresh)
@@ -408,11 +426,19 @@ const lintRun = (args, io) => {
408
426
  problems.push(entry.in === "baseline"
409
427
  ? `stale baseline entry, fixed or overridden — remove it: ${values.baseline}/${entry.rule}.json "${entry.target}"`
410
428
  : `stale override, no such violation — remove it: ${entry.rule} "${entry.target}"`);
411
- if (verdict.overBudget)
429
+ const warnings = wcatWarnings(io.cwd, values.cli);
430
+ const warned = new Set(warnings.map((warning) => `${warning.rule} ${warning.target}`));
431
+ const wcatOverridden = new Set(overrides.filter(isWcat).map((entry) => `${entry.rule} ${entry.target}`));
432
+ // wcat-describe is checked only with --cli, so without it an override of it is neither used nor stale.
433
+ for (const entry of overrides.filter(isWcat))
434
+ if (!warned.has(`${entry.rule} ${entry.target}`) && (values.cli || entry.rule !== "wcat-describe"))
435
+ problems.push(`stale override, no such warning — remove it: ${entry.rule} "${entry.target}"`);
436
+ if (overrides.length > budget)
412
437
  problems.push(`${overrides.length} overrides; the budget is ${budget}`);
413
438
  io.err(`lint: ${violations.length} violations — ${verdict.baselined} in the baseline, ${verdict.overridden} overridden (budget ${budget}), ${verdict.fresh.length} new`);
414
- for (const warning of wcatWarnings(io.cwd, values.cli))
415
- io.err(`warning ${warning.rule} ${warning.target}: ${warning.message}`);
439
+ for (const warning of warnings)
440
+ if (!wcatOverridden.has(`${warning.rule} ${warning.target}`))
441
+ io.err(`warning ${warning.rule} ${warning.target}: ${warning.message}`);
416
442
  return report(io, problems, "lint: ok");
417
443
  };
418
444
  const wcatWarnings = (cwd, cli) => {
@@ -527,15 +553,7 @@ const auditRun = (args, io) => {
527
553
  }
528
554
  const dir = lintDirs.get(cli);
529
555
  if (dir) {
530
- const overridesPath = join(at(dir), "overrides.json");
531
- let overrides = [];
532
- if (existsSync(overridesPath)) {
533
- const read = v.safeParse(overridesSchema, readJson(overridesPath));
534
- const found = read.success ? overridesProblems(read.output, today(io)) : issuesOf(read.issues);
535
- if (read.success)
536
- overrides = read.output;
537
- problems.push(...found.map((issue) => `${dir}/overrides.json: ${issue}`));
538
- }
556
+ const overrides = readOverrides(join(at(dir), "overrides.json"), `${dir}/overrides.json`, today(io), problems);
539
557
  input.lint = {
540
558
  baseline: readBaseline(join(at(dir), "baseline"), `${dir}/baseline`, problems),
541
559
  overrides,
@@ -1,5 +1,7 @@
1
+ /** Warnings, not lint rules: an entry in `lint/overrides.json` silences one, with its reason, like any override. */
2
+ export declare const WCAT_RULES: readonly ["wcat-bin-name", "wcat-describe"];
1
3
  export interface WcatWarning {
2
- rule: "wcat-bin-name" | "wcat-describe";
4
+ rule: (typeof WCAT_RULES)[number];
3
5
  target: string;
4
6
  message: string;
5
7
  }
@@ -4,6 +4,8 @@
4
4
  */
5
5
  import { parseDescribe } from "../wcat/describe.js";
6
6
  import { DESCRIBE_FLAG, PROGRAM_PREFIX, RESERVED_NAMES, TOOL_NAME } from "../wcat/names.js";
7
+ /** Warnings, not lint rules: an entry in `lint/overrides.json` silences one, with its reason, like any override. */
8
+ export const WCAT_RULES = ["wcat-bin-name", "wcat-describe"];
7
9
  const WIRECAT_SCOPE = "@wirecat/";
8
10
  /** The commands a `package.json` installs: its `"bin"` keys, or the package's own name for a `"bin"` string. */
9
11
  const binNames = (pkg) => {
@@ -2,18 +2,14 @@ export const QUERY_SYNTAX = 'field:value · "a phrase" · word* · AND OR NOT ·
2
2
  const shownOf = (registry) => registry.backend === "native" ? registry.fields.filter((field) => field.native) : registry.fields;
3
3
  const leftOut = (fields) => fields.filter((field) => field.negatable).map((field) => field.name);
4
4
  const has = (fields, operator) => fields.some((field) => field.operators.includes(operator));
5
- // A provider takes only field:value joined by AND, so a native list advertises no OR and no grouping.
6
- const syntaxOf = (shown, backend) => {
7
- if (backend !== "native")
8
- return QUERY_SYNTAX;
9
- return [
10
- "field:value",
11
- ...(has(shown, "phrase") ? ['"a phrase"'] : []),
12
- ...(has(shown, "wildcard") ? ["word*"] : []),
13
- "terms joined by AND",
14
- ...(has(shown, "range") ? ["[a TO b] · > >= < <="] : []),
15
- ].join(" · ");
16
- };
5
+ // Each form is offered only when a shown field takes it, and a provider takes no OR and no grouping.
6
+ const syntaxOf = (shown, backend) => [
7
+ "field:value",
8
+ ...(has(shown, "phrase") ? ['"a phrase"'] : []),
9
+ ...(has(shown, "wildcard") ? ["word*"] : []),
10
+ ...(backend === "native" ? ["terms joined by AND"] : ["AND OR NOT", "( )"]),
11
+ ...(has(shown, "range") ? ["[a TO b] · > >= < <="] : []),
12
+ ].join(" · ");
17
13
  const kindOf = (field) => field.values ? `${field.type}: ${field.values.join("|")}` : `${field.type}: ${field.operators.join(", ")}`;
18
14
  /** The Fields section of a list's help: one line per field it filters by, its defaults, the syntax and the guide. */
19
15
  export const fieldsHelp = (registry, guide) => {
@@ -47,7 +43,7 @@ export const queryDescription = (registry) => {
47
43
  ? `field:value terms joined by AND that narrow the list${negatable.length > 0
48
44
  ? `; -field:value or NOT field:value leaves out what matches, for ${negatable.join(", ")} only`
49
45
  : ""}`
50
- : "field:value terms that narrow the list (Lucene: AND OR NOT, -field:value, [a TO b], > <)";
46
+ : `field:value terms that narrow the list (Lucene: AND OR NOT, -field:value${has(shown, "range") ? ", [a TO b], > <" : ""})`;
51
47
  const fields = shown.map((field) => (field.values ? `${field.name} (${field.values.join("|")})` : field.name));
52
48
  const defaults = registry.defaults?.length ? `; unless named: ${registry.defaults.join(" ")}` : "";
53
49
  return `${terms}; fields: ${fields.join(", ")}; e.g. ${shown[0]?.example ?? ""}${defaults}`;
@@ -27,6 +27,7 @@ export const changelogProblems = (text, { version, release, headings, unreleased
27
27
  let section;
28
28
  let subheadings = new Set();
29
29
  let first = true;
30
+ let top = false;
30
31
  let legacy = false;
31
32
  if (legacyHeadingsThrough !== undefined && !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(legacyHeadingsThrough))
32
33
  problems.push(`legacyHeadingsThrough: "${legacyHeadingsThrough}" is not a version`);
@@ -35,6 +36,7 @@ export const changelogProblems = (text, { version, release, headings, unreleased
35
36
  if (line.startsWith("## ")) {
36
37
  section = line.slice(3);
37
38
  subheadings = new Set();
39
+ top = first;
38
40
  legacy = false;
39
41
  if (section === unreleased) {
40
42
  if (release)
@@ -66,6 +68,9 @@ export const changelogProblems = (text, { version, release, headings, unreleased
66
68
  problems.push(`${where}: "${heading}" twice in "${section}"`);
67
69
  subheadings.add(heading);
68
70
  }
71
+ // Only the top section: released ones are frozen, and older ones in several CLIs still have such entries.
72
+ if (top && !legacy && subheadings.size === 0 && /^[-*] /.test(line))
73
+ problems.push(`${where}: an entry under no "###" heading in "${section}" — put it under one of: ${headings.join(", ")}`);
69
74
  for (const found of line.match(id) ?? [])
70
75
  problems.push(`${where}: internal id ${found} — say what changed instead`);
71
76
  });
@@ -14,6 +14,15 @@ export declare const defineLintRules: (input: unknown) => LintRule[];
14
14
  export declare const LINT_RULES: LintRule[];
15
15
  /** `lint/baseline/<rule-id>.json`: today's violations of one rule, by target. It may only shrink. */
16
16
  export declare const baselineSchema: v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
17
+ /** One `lint/overrides.json` entry: the exception, its reason, who keeps it and when to look again. */
18
+ export declare const overrideSchema: v.StrictObjectSchema<{
19
+ readonly rule: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a lower-case word, words joined by -">]>;
20
+ readonly target: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>;
21
+ 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">]>;
22
+ /** A GitHub handle or a team, `@octocat`. */
23
+ readonly owner: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a GitHub handle or team, such as @octocat">]>;
24
+ readonly review_after: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoDateAction<string, "a date, YYYY-MM-DD">]>;
25
+ }, undefined>;
17
26
  /** `lint/overrides.json`: exceptions, each with its reason, who keeps it and when to look again; counted against a budget. */
18
27
  export declare const overridesSchema: v.ArraySchema<v.StrictObjectSchema<{
19
28
  readonly rule: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a lower-case word, words joined by -">]>;
@@ -119,15 +119,17 @@ export const LINT_RULES = defineLintRules([
119
119
  ]);
120
120
  /** `lint/baseline/<rule-id>.json`: today's violations of one rule, by target. It may only shrink. */
121
121
  export const baselineSchema = v.array(text);
122
- /** `lint/overrides.json`: exceptions, each with its reason, who keeps it and when to look again; counted against a budget. */
123
- export const overridesSchema = v.array(v.strictObject({
122
+ /** One `lint/overrides.json` entry: the exception, its reason, who keeps it and when to look again. */
123
+ export const overrideSchema = v.strictObject({
124
124
  rule: word,
125
125
  target: text,
126
126
  reason: v.pipe(text, v.minLength(10, "say why, in a sentence")),
127
127
  /** A GitHub handle or a team, `@octocat`. */
128
128
  owner: v.pipe(v.string(), v.regex(/^@[\w.-]+(\/[\w.-]+)?$/, "a GitHub handle or team, such as @octocat")),
129
129
  review_after: v.pipe(v.string(), v.isoDate("a date, YYYY-MM-DD")),
130
- }));
130
+ });
131
+ /** `lint/overrides.json`: exceptions, each with its reason, who keeps it and when to look again; counted against a budget. */
132
+ export const overridesSchema = v.array(overrideSchema);
131
133
  /** What the schemas cannot say, so they stay convertible to JSON Schema: no entry twice. */
132
134
  export const baselineProblems = (targets) => duplicates(targets).map((target) => `"${target}" is listed twice`);
133
135
  /** No entry twice, and none past its `review_after` on `today` (YYYY-MM-DD, the caller's clock). */
@@ -15,6 +15,8 @@ export interface Sandbox {
15
15
  export declare const enterSandbox: (env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform) => Sandbox;
16
16
  export declare const removed: (path: string, maxRetries: number, rm?: typeof rmSync) => boolean;
17
17
  export declare const REAL_KEYRING_REACHED = "a test reached the real OS keyring \u2014 pass memoryKeyring() instead";
18
+ /** The stack of the first reach of the keyring stand-in since the last call, and forgets it. */
19
+ export declare const takeKeyringReach: () => string | undefined;
18
20
  /**
19
21
  * `systemKeyring` loads `@napi-rs/keyring` through `createRequire`, which `vi.mock` does not
20
22
  * intercept, so the stand-in goes into the CommonJS require cache. Resolved from here (the copy
@@ -99,11 +99,24 @@ export const removed = (path, maxRetries, rm = rmSync) => {
99
99
  }
100
100
  };
101
101
  export const REAL_KEYRING_REACHED = "a test reached the real OS keyring — pass memoryKeyring() instead";
102
+ let reached;
103
+ /**
104
+ * Its throw alone is not enough: `Credentials` in `auto` mode catches it, warns and falls back to a file, so a test
105
+ * that reached the keyring still passed. The first reach is kept for `takeKeyringReach`.
106
+ */
102
107
  class ThrowingEntry {
103
108
  constructor() {
104
- throw new Error(REAL_KEYRING_REACHED);
109
+ const error = new Error(REAL_KEYRING_REACHED);
110
+ reached ??= error.stack ?? error.message;
111
+ throw error;
105
112
  }
106
113
  }
114
+ /** The stack of the first reach of the keyring stand-in since the last call, and forgets it. */
115
+ export const takeKeyringReach = () => {
116
+ const stack = reached;
117
+ reached = undefined;
118
+ return stack;
119
+ };
107
120
  /**
108
121
  * `systemKeyring` loads `@napi-rs/keyring` through `createRequire`, which `vi.mock` does not
109
122
  * intercept, so the stand-in goes into the CommonJS require cache. Resolved from here (the copy
@@ -1,6 +1,6 @@
1
- import { afterAll } from "vitest";
1
+ import { afterAll, afterEach } from "vitest";
2
2
  import { installNetworkGuard } from "./network.js";
3
- import { enterSandbox, installThrowingKeyring } from "./sandbox.js";
3
+ import { enterSandbox, installThrowingKeyring, takeKeyringReach } from "./sandbox.js";
4
4
  /**
5
5
  * One vitest `setupFiles` entry for every WireCat CLI: `setupFiles: ["@wirecat/cli-base/testing/setup"]`.
6
6
  * Each test file gets its own sandbox directory, removed when the file is done; the real keyring and
@@ -9,4 +9,9 @@ import { enterSandbox, installThrowingKeyring } from "./sandbox.js";
9
9
  const sandbox = enterSandbox();
10
10
  installThrowingKeyring();
11
11
  installNetworkGuard();
12
+ afterEach(() => {
13
+ const stack = takeKeyringReach();
14
+ if (stack)
15
+ throw new Error(`the test passed only because the keyring fell back to a file:\n${stack}`);
16
+ });
12
17
  afterAll(() => sandbox.restore());
@@ -29,7 +29,7 @@ the four steps that need a person. A new CLI is ready for its first release when
29
29
  |---|---|---:|---|
30
30
  | [Toolchain](toolchain.md) | `TOOL` | 20 | TypeScript, Biome, grit plugins, size caps, import cycles, spelling, Markdown, Go |
31
31
  | [Layout](layout.md) | `LAY` | 10 | Repository layout, entry points, exports, file size, one job per file, splitting a unit, the bundled bin |
32
- | [Commands](commands.md) | `CMD` | 29 | Paths, vocabulary, flags, arguments, effects, lifetime, query, help, external references, joining `wcat` |
32
+ | [Commands](commands.md) | `CMD` | 30 | Paths, vocabulary, flags, arguments, effects, lifetime, query, help, external references, joining `wcat`, settings in the config file |
33
33
  | [Output](output.md) | `OUT` | 24 | Data on stdout only, envelopes, errors, exit codes, partial answers, projection |
34
34
  | [MCP](mcp.md) | `MCP` | 13 | The MCP tools, how they come from commands, what each permission shows |
35
35
  | [Accounts](accounts.md) | `ACCT` | 12 | The explicit profile, reads across accounts, scope flags, linked identities, sign-in commands |
@@ -86,7 +86,7 @@ at start-up and the `serve` relay are in [Lifecycle](lifecycle.md); the store's
86
86
  - **Example:** topics and polls are port groups and shared commands in cli-core. A messenger that lacks a capability
87
87
  refuses the command; it does not silently do less. A bot is another messenger with fewer capabilities, and tg and
88
88
  max share the bot code. Calendars have the same shape: cli-core's `addCalendarCommands` builds `calendars`,
89
- `sync`, `status`, `events` and `search` over the `CalendarSource` and `CalendarWriter` ports, and mounts
89
+ `sync`, `store status`, `events` and `search` over the `CalendarSource` and `CalendarWriter` ports, and mounts
90
90
  `calendars busy` or the event writes only when the provider has them.
91
91
  - **Exceptions:** a feature stays in one CLI only when the other providers cannot have it, such as MAX's socket server,
92
92
  Telegram's app registration, or gcal's generated `api` commands.
@@ -127,7 +127,7 @@ catalogue and parity manifest stay in cli-core
127
127
  before the create, so a retry finds it at the provider. A CLI whose provider takes such an id may offer both. gcal
128
128
  offers only `--operation-id` (from gcal 0.9.0): its `operation_id` is also the event's id at Google, so one value
129
129
  serves both.
130
- - **Exceptions:** generated commands (`bot api`) are exempt from the naming rules.
130
+ - **Exceptions:** generated commands (`bot api`, and gcal's and drive's `api`) are exempt from the naming rules.
131
131
  - **Applies to:** all CLIs. **Since:** cli-base 0.41.0. **Rulings:** none.
132
132
 
133
133
  ### WC-CMD-11 · Name an option in plain words, with the kind of value in the name
@@ -291,7 +291,7 @@ store or files without reading the service first (`remote: "none"`, `local: "wri
291
291
  - **Enforced by:** `command-example-count` · `faulty-help-example-parameters`.
292
292
  - **Example:** `annotate(list, { examples: ["max chats list --limit 20"] })`. Each example starts with the tool, runs
293
293
  its own command, and gives only arguments and options that command takes.
294
- - **Exceptions:** generated commands (`bot api`) need no examples.
294
+ - **Exceptions:** generated commands (`bot api`, and gcal's and drive's `api`) need no examples.
295
295
  - **Applies to:** all CLIs. **Since:** cli-base 0.41.0. **Rulings:** none.
296
296
 
297
297
  ### WC-CMD-25 · Give a shared command and a shared option the same sentence in every CLI of its group
@@ -380,10 +380,31 @@ The references, each for its own part:
380
380
  `help`, `version`, `shortcuts`, `doctor`, `update`, `upgrade`, `app`), and, with `--cli`, when the built CLI does not
381
381
  answer `--wcat-describe`.
382
382
  - **Exceptions:** a CLI keeps its short `"bin"` name beside `wcat-<name>` until callers have moved to the new name; the
383
- warning stays until then.
383
+ warning stays until then. A CLI that keeps the short name for good says why in `lint/overrides.json` (`"rule":
384
+ "wcat-bin-name"`, the target as the warning prints it), which silences the warning and counts against the budget.
384
385
  - **Applies to:** all CLIs, and third-party CLIs that join `wcat`. **Since:** cli-base 0.66.0. **Rulings:** NEED-149,
385
386
  NEED-153, NEED-154.
386
387
 
388
+ ## Settings
389
+
390
+ ### WC-CMD-30 · Write every setting into the config file with its default, and list them all in `config show`
391
+
392
+ A CLI declares each scalar setting once, with its default and one line that says what it does, through cli-core's
393
+ `DeclaredSetting`. `config show` lists every declared setting with its value, its default and whether the file sets
394
+ it. Whenever the CLI writes its config file anyway, and on an explicit `config migrate`, it adds each missing setting
395
+ at its default with `withSettingDefaults`. It keeps the owner's values, their order and unknown keys, and creates no
396
+ section. A plain read never rewrites the file.
397
+
398
+ - **Why:** the owner finds and edits every setting in the file, without reading the docs to learn that it exists.
399
+ - **Enforced by:** review only.
400
+ - **Example:** `memo config migrate` adds `"agent_decisions": false` to a file that lacks it; `memo config show` lists
401
+ it as `false (default)`.
402
+ - **Exceptions:** lists and sections that need fields no default can give (accounts, folders, permissions) get no
403
+ placeholder. A secret is never declared, since `config show` prints every value. Known gaps, as of 2026-11-03:
404
+ tg, max, wa, mail, gcal and drive list every setting with its source in `config show`, but their starter file holds
405
+ only five of them and nothing fills it later; zm declares nothing yet.
406
+ - **Applies to:** all CLIs. **Since:** cli-base 0.76.0. **Rulings:** none.
407
+
387
408
  ## Sources
388
409
 
389
410
  These documents explain the rules on this page in more detail. Where one of them disagrees with this page, this page
@@ -153,7 +153,8 @@ Rule ids on this page are `WC-DOC-<n>`. How to read a rule, how ids work and whi
153
153
  - **Enforced by:** `pnpm docs:check` · `changelog-guard.yml`.
154
154
  - **Example:** `### Added`, `### Changed — may break callers`, `### Fixed`, `### Security`, `### Removed`. max-cli
155
155
  uses this fixed Russian rendering: `Что нового`, `Изменено — может сломать скрипты`, `Исправлено`, `Безопасность`,
156
- `Удалено`.
156
+ `Удалено`. Every entry sits under one of them: `docs:check` refuses an entry under no heading in the top
157
+ section.
157
158
  - **Exceptions:** a released section keeps the headings it was released with, because it is never edited
158
159
  ([WC-DOC-20](#wc-doc-20--a-released-section-is-frozen-and-a-new-entry-goes-under-an-existing-heading)). A CLI that
159
160
  moves to this set puts its last version with the old headings in its changelog rules as
@@ -82,6 +82,7 @@ and [WC-SECR-2](security.md#wc-secr-2--a-log-keeps-only-known-attribute-keys-and
82
82
  |---|---|---|
83
83
  | one-shot | 30 s | 30 s |
84
84
  | `cooperative` ([WC-LIFE-10](#wc-life-10--mark-a-long-command-cooperative-when-it-should-stop-at-its-own-pace-and-keep-its-work)) | 5 min | 45 s |
85
+ | `cooperative` that also declares `partial` (a resumable sync) | 15 min | 45 s |
85
86
 
86
87
  `--timeout 2m` or the `<PREFIX>_TIMEOUT` variable sets the deadline, higher or lower. Past the deadline a one-shot
87
88
  command fails with `timeout` (exit 9).
@@ -93,10 +94,10 @@ command fails with `timeout` (exit 9).
93
94
  a cut call. Claude Code waits longer ([its MCP page](https://code.claude.com/docs/en/mcp)), but a CLI does not know
94
95
  its client, so it takes the shortest.
95
96
  - **Enforced by:** `cli-core: src/cli/deadline.test.ts` · `cli-core: src/cli/program.test.ts`.
96
- - **Example:** from cli-core 0.464.0, a calendar `sync` in a terminal may run 5 minutes; the same command called over
97
- MCP stops at 45 seconds with what it saved. cli-core's `DEFAULT_COMMAND_MS`, `DEFAULT_COOPERATIVE_MS` and
98
- `DEFAULT_AGENT_COOPERATIVE_MS` (`src/cli/execution.ts`) hold the three values, and `run()` picks one when it starts
99
- the command.
97
+ - **Example:** from cli-core 0.473.0, a calendar `sync` in a terminal may run 15 minutes; the same command called over
98
+ MCP stops at 45 seconds with what it saved. cli-core's `DEFAULT_COMMAND_MS`, `DEFAULT_COOPERATIVE_MS`,
99
+ `DEFAULT_SYNC_MS` and `DEFAULT_AGENT_COOPERATIVE_MS` (`src/cli/execution.ts`) hold the four values, and `run()` picks
100
+ one when it starts the command.
100
101
  - **Exceptions:** a `persistent` command has no deadline and ignores `--timeout`. An `interactive` command has none
101
102
  while a person is at the terminal; run headless, it keeps the deadline. `--help` and `--version` need none. A
102
103
  background fetch started with `<PREFIX>_BACKFILL_JOB` is bounded only by an explicit `--timeout`.
@@ -274,7 +275,7 @@ status command answers how far it is
274
275
  - **Enforced by:** `cli-core: src/background/budget.test.ts` · `cli-core: src/calendar/testing/sync-contract.ts`.
275
276
  - **Example:** `gcal personal sync` over MCP has 45 s: margin 6.75 s, so with gcal's 1.5 s about 25 requests. It
276
277
  answers `state: "partial"`, `stopped: "requests"`, `progress: { calendars, calendarsDone, requests, saved }`, and
277
- `next` with `gcal personal sync`; `gcal personal status` shows each calendar's progress. cli-core builds the budget
278
+ `next` with `gcal personal sync`; `gcal personal store status` shows each calendar's progress. cli-core builds the budget
278
279
  with `runBudgetOf(command, maxPages)` from `@wirecat/cli-core/cli` over `runBudget` in
279
280
  `@wirecat/cli-core/background`.
280
281
  - **Exceptions:** known gaps, as of 2026-10-27: only the calendar sync spends a budget (see "Who follows"). The
@@ -16,8 +16,9 @@ Rule ids on this page are `WC-MCP-<n>`. How to read a rule, how ids work and whi
16
16
  ### Who follows these rules today
17
17
 
18
18
  tg, max and wa serve cli-core's personal server (`<cli> <profile> mcp serve`) and bot server (`<cli> <profile> bot mcp serve`).
19
- `mcp serve --profiles` ([WC-MCP-9](#wc-mcp-9--a-server-serves-the-profiles-it-is-told-with-one-connection-for-each)) is not
20
- built yet: cli-core serves one named profile.
19
+ cli-core serves `mcp serve --profiles`
20
+ ([WC-MCP-9](#wc-mcp-9--a-server-serves-the-profiles-it-is-told-with-one-connection-for-each)) from 0.470.0, for the
21
+ messengers and for the CLIs below that use its shared server.
21
22
  zoom serves the three shared tools over its own commands from zoom-cli 0.35.0 (`zm <profile> mcp serve`, cli-core's
22
23
  `toolsFromCommands` and `runCommandForAgent`), and gcal does the same (`gcal <profile> mcp serve`). memo and drive have their own servers: memo has hand-written
23
24
  tools, and drive builds one tool per command from its command tree and skips commands through its own list. Those two are known
@@ -358,10 +358,9 @@ A messenger's keys come from its resources (`messages`, `chats`). Any other CLI
358
358
  `ask` runs without a question, and each reaches people other than the owner. gcal adds its own keys for its generated
359
359
  `api <method>` commands: sharing a calendar is `ask` (NEED-1857), and emptying the main calendar
360
360
  (`api.calendar.calendars.clear`) is `deny`.
361
- - **Exceptions:** known gaps, as of 2026-10-31:
362
- - gcal's generated `api` commands default every write to `ask`, ordinary changes included.
363
- - drive keeps its own policy and `--read-only` flag, zoom reads its own `remote-meetings` key with `levelFor`, and
364
- memo uses cli-core's permissions for the shared commands only. None of them declares `commandPermissions` yet.
361
+ - **Exceptions:** known gaps, as of 2026-11-03: drive keeps its own policy and `--read-only` flag, zoom reads its own
362
+ `remote-meetings` key with `levelFor`, and memo uses cli-core's permissions for the shared commands only. None of
363
+ them declares `commandPermissions` yet.
365
364
  - **Applies to:** every CLI that writes and is not a messenger. **Since:** cli-base 0.64.0 (the levels and the shared
366
365
  keys on every path: cli-base 0.65.0). **Rulings:** NEED-1857, NEED-1883.
367
366
 
@@ -199,7 +199,7 @@ fixtures, `scripts/`, `.github/`, `bin/release`, `config/`, `lint/`, `contracts/
199
199
  `scripts/smoke.ts` does, checks only that npm packs what `files` says, and cannot fail on a wrong `files` list.
200
200
  `npm pack --dry-run` in gcal-cli packed 256 files on 2026-10-27, `src/`, `scripts/`, `.github/`, `config/`,
201
201
  `contracts/`, `lint/` and `template.json` among them, because its `files` list named them; its check could not see
202
- it. On 2026-10-31 (gcal-cli 0.2.0) it packs 144. The check this rule needs
202
+ it. gcal's check now uses a fixed list of its own (`scripts/smoke.ts`). The check this rule needs
203
203
  refuses the forbidden paths above whatever `files` says: cli-template's `scripts/smoke.ts` does since
204
204
  [cli-template#85](https://github.com/WireCatLabs/cli-template/pull/85). `scripts/` is a seed that `upgrade` never
205
205
  changes, so a CLI made before it copies the check by hand.
@@ -80,10 +80,8 @@ local midnight. The answer gives the window it used, in UTC.
80
80
  not included), matches by comparing local dates, never UTC moments: it is in the window when its dates overlap the
81
81
  local dates the window covers. So in `Europe/Madrid` a filter for 1 September returns the all-day event of
82
82
  1 September and not the one of 31 August, whatever zone the event's calendar is in.
83
- - **Exceptions:** none. Known gap, as of 2026-10-27, read from the code and not run: cli-core's event filter
84
- (`src/store/sqlite/calendar.ts`, the `events` query) compares `starts_at` and `ends_at` with the UTC window for
85
- all-day events too, and gcal stores an all-day event's start as midnight in its calendar's zone. The two agree
86
- when the calendar's zone is the zone in use, and miss by a day at the edges when it is not.
83
+ - **Exceptions:** none. cli-core's event filter matches an all-day event by its local dates
84
+ (`src/store/sqlite/calendar-events.ts`), and `events list` answers the window in `meta.window`.
87
85
  - **Applies to:** all CLIs. **Since:** cli-base 0.48.0. **Rulings:** none.
88
86
 
89
87
  ### WC-TIME-6 · Run the tests in a zone that is not UTC, and prove stored values do not depend on it
@@ -90,11 +90,13 @@ plus community itself. cli-template follows every rule here as a CLI does
90
90
  `scripts/smoke.ts` runs the built exports with plain assertions, and the Bun job runs it
91
91
  (`pnpm build && pnpm smoke:bun`). A new entry point gets a line there. Node 22 runs in the Windows install matrix of
92
92
  tg, max and wa, in zoom's full validation, in cli-core's old-Node job and in cli-template's `node-22` job (Node
93
- 22.16.0, from cli-template 0.11.0), which a CLI made from the template gets with `upgrade`.
93
+ 22.16.0, from cli-template 0.11.0), which a CLI made from the template gets with `upgrade`; gcal has its own
94
+ `node-22` job.
94
95
  - **Exceptions:** cli-docs and community publish no runtime package. tg needs `^22.19.0`. memo, drive, cli-base and
95
- the CLIs made from a template before 0.11.0 do not run Node 22 in any workflow yet; gcal is adding its own job.
96
+ the CLIs made from a template before 0.11.0 do not run Node 22 in any workflow yet.
96
97
  cli-base's own `engines` is still `>=22`. The unit suite runs on Windows and macOS for tg, max and drive
97
- (`platforms: true`); zoom and memo still fail tests there, so theirs is off.
98
+ (`platforms: true`), and for gcal at release, monthly and on a manual run; zoom and memo still fail tests there,
99
+ so theirs is off.
98
100
  - **Applies to:** the CLIs, cli-core and cli-base. **Since:** cli-base 0.41.0. **Rulings:** NEED-11.
99
101
 
100
102
  ### WC-TOOL-7 · Let Biome format the code, and do not format by hand
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-base",
3
- "version": "0.75.0",
3
+ "version": "0.75.2",
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",