@wirecat/cli-base 0.66.1 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,80 @@ 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.0 — 31.10.2026
12
+
13
+ ### Removed
14
+
15
+ - **WC-REL-10: a messenger CLI is released after the owner's yes in the conversation, with no signed report.**
16
+ `bin/release` in max-cli and whatsapp-cli no longer reads a release report or a `Signed off:` line. The agent asks
17
+ the owner before it runs `bin/release`; one yes covers one version.
18
+
19
+ ### Changed — may break callers
20
+
21
+ - **`Credentials` in `auto` does the whole file fallback itself (WC-SECR-4).** A secret the file holds is read from the
22
+ file before the keyring, so a locked keyring is not tried for it, and a refresh `write` keeps it there. A sign-in
23
+ passes `write(account, secret, { signIn: true })`: it tries the keyring and, once the keyring takes it, removes the
24
+ file copy. The "could not reach the OS keyring" warning comes once per run (per `env` object), not once per
25
+ `Credentials`. Watch for: a file copy left next to a newer keyring entry by an older CLI now wins the read; sign in
26
+ again to move it back. CLIs can drop their own file-first read, "remove the file copy" step and `warnOnce`.
27
+ - **WC-SECR-4 is the one credential-storage standard.** The keyring by default; when it fails, the file without
28
+ asking, one warning on stderr, and the answer that saved the secret says where it went;
29
+ `--credential-storage file` on the sign-in command chooses the file and is discouraged; a secret in the file is
30
+ read from there until a sign-in reaches the keyring again. No CLI asks, none is keyring-only. `Credentials` already
31
+ falls back this way in `auto`; the rule now says every CLI must use it.
32
+
33
+ ### Fixed
34
+
35
+ - **`Credentials.remove` with `storage: "keyring"` leaves the file alone.** It also deleted the account from the
36
+ credentials file, so a CLI that cleared the keyring copy after a file sign-in lost the sign-in it had just saved.
37
+
38
+ ### Added
39
+
40
+ - **`--credential-storage <storage>` in the flag vocabulary** (role `mode`), so `cli-dev lint` accepts it on a sign-in
41
+ command without a per-CLI override.
42
+
43
+ ## 0.67.0 — 31.10.2026
44
+
45
+ ### Added
46
+
47
+ - **`page.total`: how many match, every page together** (WC-OUT-5). An optional `{ value, relation }`, shaped as
48
+ Elasticsearch's `hits.total`: `eq` is the exact count, `gte` a lower bound. A search answer can now say "20 of 144"
49
+ without the caller paging to the end. `totalSchema` and the `Total` type are exported from
50
+ `@wirecat/cli-base/standards`. Optional, so every existing answer stays valid.
51
+
52
+ ### Fixed
53
+
54
+ - **WC-LIFE-15 says a success is kept by default.** It described `--record` as the way to keep a success and said
55
+ `record: false` also drops failures. cli-core keeps every run of a command that works through it; `record: false`
56
+ keeps failures only, and `--no-record` keeps nothing.
57
+
58
+ - **`Credentials.remove` in a moved run says "kept" only when the owner's entry is there and was kept.** It printed
59
+ "kept X in the OS keyring: it is the owner's sign-in" on every removal in a moved run, also when it had just removed
60
+ the run's own temporary entry and when the owner had no entry at all. Now it looks the owner's entry up after the
61
+ removal: with the temporary entry removed it says so and that the owner's was kept; with nothing removed it says the
62
+ owner's was kept; with no owner's entry it prints nothing. A keyring that fails on that look-up does not fail the
63
+ removal. The return value is unchanged (WC-SECR-24).
64
+ - **WC-SECR-24 says how to hide the keyring from a run.** Set `DBUS_SESSION_BUS_ADDRESS=disabled:`. Removing the
65
+ variable, as the rule said before, still reaches the keyring through the default session bus.
66
+ - **WC-TOOL-6 counts cli-template's Node 22 job.** cli-template 0.11.0 runs its tests on Node 22.16.0, and a CLI made
67
+ from it gets the job with `upgrade`; only CLIs made from an older template still lack it.
68
+ - **Five rules answer what cli-template's 0.10.0 audit found unclear.** WC-SECR-20 names the release `tag` job,
69
+ which keeps its credentials to push the tag. WC-TOOL-6 lists cli-template and its CLIs among those that do not run
70
+ Node 22 yet. WC-PERM-22 says a template-made CLI calls `commandPermissions` with its first command that writes to a
71
+ service. WC-REL-11 covers ClawHub's stored `CLAWHUB_TOKEN` and a ClawHub failure after the tag. WC-REL-16 says
72
+ where the forbidden-path check lives now, and that `upgrade` does not deliver it.
73
+ - **The handbook catches up with cli-core 0.491.0 and gcal-cli 0.4.0.** WC-CMD-17 and WC-MCP-4 no longer list
74
+ `sync` and `calendars list` as running through `write`: cli-core's `fetchesOnly` sends them through `read`.
75
+ WC-PERM-22 no longer says `CALENDAR_WRITE_DEFAULTS` asks before `events create`, `update` and `respond`; those and
76
+ `move` are `allow`. "Tools generated from commands" (mcp.md) counts gcal among the CLIs that pin
77
+ `contracts/mcp-catalogue.json`.
78
+ - **WC-PERM-6 lists all of cli-core's built-in defaults.** It showed the 8 messenger keys; `DEFAULT_PERMISSIONS` has
79
+ 25, including `mcp.authority: deny` and `ask` for `store.*`, `server.*`, `models.download` and `schedule.*`.
80
+ - **WC-CMD-23 exempts generated commands,** as the naming and example rules do: their help is the API description's
81
+ own text.
82
+ - **gcal-cli is named where it meets a rule:** no correction marks on user pages, `rumdl` on user pages, pull
83
+ request validation, required checks on `main`, the free version number and `bin/release --local`.
84
+
11
85
  ## 0.66.1 — 31.10.2026
12
86
 
13
87
  ### Fixed
@@ -38,17 +38,25 @@ export declare const keyringService: (service: string, _configDir?: string, _iso
38
38
  * shape `bin/keyring-dedupe` already finds and removes.
39
39
  */
40
40
  export declare const movedRunService: (service: string, configDir: string) => string;
41
+ export interface WriteOptions {
42
+ /**
43
+ * A new sign-in rather than a refresh: it tries the keyring even when the file holds the account, and once the
44
+ * keyring takes it, removes the file copy (WC-SECR-4).
45
+ */
46
+ signIn?: boolean;
47
+ }
41
48
  /**
42
- * Environment first, then the OS keyring, then a file.
49
+ * Environment first, then the OS keyring, then a file. In `auto`, a file that holds the account is read before the
50
+ * keyring and keeps its refreshes, so a locked keyring is not tried again for it; a sign-in moves it back.
43
51
  *
44
52
  * `auto` does not probe whether a keyring exists: it attempts the operation, and on failure warns
45
- * once and falls through. A probe would touch the user's keychain for nothing and could still
53
+ * once per run and falls through. A probe would touch the user's keychain for nothing and could still
46
54
  * succeed where the real operation fails.
47
55
  */
48
56
  export declare class Credentials {
49
57
  #private;
50
58
  constructor(options: CredentialsOptions);
51
59
  read(account: string): StoredCredential | undefined;
52
- write(account: string, secret: string): CredentialSource;
60
+ write(account: string, secret: string, options?: WriteOptions): CredentialSource;
53
61
  remove(account: string): CredentialSource[];
54
62
  }
@@ -15,11 +15,14 @@ export const keyringService = (service, _configDir, _isolated) => service;
15
15
  * shape `bin/keyring-dedupe` already finds and removes.
16
16
  */
17
17
  export const movedRunService = (service, configDir) => `${service}:${resolve(configDir)}`;
18
+ // One warning per run, not per instance: a run over several profiles builds one Credentials for each.
19
+ const warnedRuns = new WeakSet();
18
20
  /**
19
- * Environment first, then the OS keyring, then a file.
21
+ * Environment first, then the OS keyring, then a file. In `auto`, a file that holds the account is read before the
22
+ * keyring and keeps its refreshes, so a locked keyring is not tried again for it; a sign-in moves it back.
20
23
  *
21
24
  * `auto` does not probe whether a keyring exists: it attempts the operation, and on failure warns
22
- * once and falls through. A probe would touch the user's keychain for nothing and could still
25
+ * once per run and falls through. A probe would touch the user's keychain for nothing and could still
23
26
  * succeed where the real operation fails.
24
27
  */
25
28
  export class Credentials {
@@ -33,7 +36,6 @@ export class Credentials {
33
36
  #warn;
34
37
  /** Set only for a moved run with the system keyring. */
35
38
  #temporaryService;
36
- #warned = false;
37
39
  constructor(options) {
38
40
  this.#configDir = options.configDir;
39
41
  this.#storage = options.storage ?? "auto";
@@ -50,23 +52,29 @@ export class Credentials {
50
52
  const fromEnv = this.#envVar ? this.#env[this.#envVar]?.trim() : undefined;
51
53
  if (fromEnv)
52
54
  return { secret: fromEnv, source: "environment" };
55
+ const fromFile = this.#storage === "keyring" ? undefined : this.#readFile()?.[account]?.secret;
56
+ if (fromFile)
57
+ return { secret: fromFile, source: "file" };
53
58
  if (this.#storage !== "file") {
54
59
  const temporary = this.#temporaryService;
55
60
  const fromKeyring = this.#tryKeyring(() => (temporary && this.#keyring.get(temporary, account)) || this.#keyring.get(this.#service, account));
56
61
  if (fromKeyring)
57
62
  return { secret: fromKeyring, source: "keyring" };
58
63
  }
59
- const secret = this.#readFile()?.[account]?.secret;
60
- return secret ? { secret, source: "file" } : undefined;
64
+ return undefined;
61
65
  }
62
- write(account, secret) {
63
- if (this.#storage !== "file") {
66
+ write(account, secret, options = {}) {
67
+ const inFile = this.#storage === "auto" && this.#readFile()?.[account] !== undefined;
68
+ if (this.#storage === "keyring" || (this.#storage === "auto" && (options.signIn || !inFile))) {
64
69
  const stored = this.#tryKeyring(() => {
65
70
  this.#keyring.set(this.#temporaryService ?? this.#service, account, secret);
66
71
  return true;
67
72
  });
68
- if (stored)
73
+ if (stored) {
74
+ if (inFile)
75
+ this.#removeFromFile(account);
69
76
  return "keyring";
77
+ }
70
78
  }
71
79
  const store = this.#readFile();
72
80
  if (!store) {
@@ -83,19 +91,35 @@ export class Credentials {
83
91
  if (this.#tryKeyring(() => this.#keyring.delete(this.#temporaryService ?? this.#service, account))) {
84
92
  removed.push("keyring");
85
93
  }
86
- if (this.#temporaryService) {
87
- this.#warn(`kept ${account} in the OS keyring: it is the owner's sign-in, shared with every run, and a run with moved ` +
88
- "folders never removes it");
94
+ if (this.#temporaryService && this.#ownersEntryExists(account)) {
95
+ this.#warn(removed.includes("keyring")
96
+ ? `removed this run's own sign-in of ${account} from the OS keyring and kept the owner's: a run with ` +
97
+ "moved folders never removes it"
98
+ : `kept ${account} in the OS keyring: it is the owner's sign-in, shared with every run, and a run with ` +
99
+ "moved folders never removes it");
89
100
  }
90
101
  }
91
- const store = this.#readFile();
92
- if (store?.[account] !== undefined) {
93
- delete store[account];
94
- this.#writeFile(store);
102
+ if (this.#storage !== "keyring" && this.#removeFromFile(account))
95
103
  removed.push("file");
96
- }
97
104
  return removed;
98
105
  }
106
+ #removeFromFile(account) {
107
+ const store = this.#readFile();
108
+ if (store?.[account] === undefined)
109
+ return false;
110
+ delete store[account];
111
+ this.#writeFile(store);
112
+ return true;
113
+ }
114
+ /** Only for the warning: a keyring that fails here must not fail a removal that already happened. */
115
+ #ownersEntryExists(account) {
116
+ try {
117
+ return this.#keyring.get(this.#service, account) !== null;
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ }
99
123
  #path() {
100
124
  return join(this.#configDir, this.#fileName);
101
125
  }
@@ -129,8 +153,8 @@ export class Credentials {
129
153
  return operation();
130
154
  }
131
155
  catch (error) {
132
- if (!this.#warned) {
133
- this.#warned = true;
156
+ if (!warnedRuns.has(this.#env)) {
157
+ warnedRuns.add(this.#env);
134
158
  this.#warn(`could not reach the OS keyring — it may be locked, or no keyring service is running ` +
135
159
  `(${error instanceof Error ? error.message : String(error)}); ` +
136
160
  `using ${this.#path()} instead, readable only by you`);
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { BATCH_LIMITS, type BatchContext, type BatchHandler, type BatchOperation, type BatchReport, type BatchResult, type PreparedBatchRequest, prepareBatch, projectBatchAnswers, readBatchInput, runBatch, } from "./batch.js";
2
2
  export { loadConfigFile, saveConfigFile, writeSecurely } from "./config.js";
3
- export type { CredentialSource, CredentialStorage, CredentialsOptions, StoredCredential } from "./credentials.js";
3
+ export type { CredentialSource, CredentialStorage, CredentialsOptions, StoredCredential, WriteOptions, } from "./credentials.js";
4
4
  export { CREDENTIAL_STORAGE, Credentials, keyringService, movedRunService } from "./credentials.js";
5
5
  export { createDeadline, type Deadline, type DeadlineOptions } from "./deadline.js";
6
6
  export { CliError, type CliErrorDetails, type ErrorCode, errorCodes, isCliError } from "./errors.js";
@@ -375,6 +375,13 @@ export const HOW_FLAGS = [
375
375
  type: "string",
376
376
  meaning: "the browser program to open for sign-in",
377
377
  },
378
+ {
379
+ name: "--credential-storage",
380
+ value: "<storage>",
381
+ role: "mode",
382
+ type: "choice",
383
+ meaning: "where a sign-in is kept: keyring, which falls back to a private file by itself, or file (WC-SECR-4)",
384
+ },
378
385
  { name: "--checkpoint", value: "<file>", role: "mode", type: "path", meaning: "keep resume progress in this file" },
379
386
  {
380
387
  name: "--expected-event-id",
@@ -1,6 +1,6 @@
1
1
  export { EFFECT_LEVELS, type EffectLevel, type Effects, effectsSchema, writes } from "./effects.js";
2
2
  export { BASE_REASONS, COMMON_DETAILS, CRASH_CODE, CRASH_REASON, defineReasons, type ErrorObject, type ErrorReason, errorObjectSchema, } from "./errors.js";
3
3
  export { defineLogEvents, isContentKey, LOG_ATTRIBUTE_KEYS, LOG_EVENTS, LOG_SEVERITIES, type LogEvent, type LogRecord, type LogSeverity, logAttributesJsonSchema, logEventProblems, logRecordSchema, traceIdOf, } from "./log.js";
4
- export { type Issue, type ItemEnvelope, isoTime, issueSchema, itemEnvelopeSchema, jsonlEndSchema, jsonlItemSchema, jsonlOf, type ListEnvelope, listEnvelopeSchema, MAX_NEXT_STEPS, type Meta, type Missing, metaSchema, missingSchema, type NextStep, nextStepSchema, type Page, pageSchema, reference, } from "./output.js";
4
+ export { type Issue, type ItemEnvelope, isoTime, issueSchema, itemEnvelopeSchema, jsonlEndSchema, jsonlItemSchema, jsonlOf, type ListEnvelope, listEnvelopeSchema, MAX_NEXT_STEPS, type Meta, type Missing, metaSchema, missingSchema, type NextStep, nextStepSchema, type Page, pageSchema, reference, type Total, totalSchema, } from "./output.js";
5
5
  export { fill, jsonSchemaOf, RegistryError } from "./registry.js";
6
6
  export { defineSteps, SAME_COMMAND, STEPS, type StepDefinition, type StepInput, type StepTarget, stepOf, } from "./steps.js";
@@ -1,6 +1,6 @@
1
1
  export { EFFECT_LEVELS, effectsSchema, writes } from "./effects.js";
2
2
  export { BASE_REASONS, COMMON_DETAILS, CRASH_CODE, CRASH_REASON, defineReasons, errorObjectSchema, } from "./errors.js";
3
3
  export { defineLogEvents, isContentKey, LOG_ATTRIBUTE_KEYS, LOG_EVENTS, LOG_SEVERITIES, logAttributesJsonSchema, logEventProblems, logRecordSchema, traceIdOf, } from "./log.js";
4
- export { isoTime, issueSchema, itemEnvelopeSchema, jsonlEndSchema, jsonlItemSchema, jsonlOf, listEnvelopeSchema, MAX_NEXT_STEPS, metaSchema, missingSchema, nextStepSchema, pageSchema, reference, } from "./output.js";
4
+ export { isoTime, issueSchema, itemEnvelopeSchema, jsonlEndSchema, jsonlItemSchema, jsonlOf, listEnvelopeSchema, MAX_NEXT_STEPS, metaSchema, missingSchema, nextStepSchema, pageSchema, reference, totalSchema, } from "./output.js";
5
5
  export { fill, jsonSchemaOf, RegistryError } from "./registry.js";
6
6
  export { defineSteps, SAME_COMMAND, STEPS, stepOf, } from "./steps.js";
@@ -30,18 +30,35 @@ export declare const missingSchema: v.StrictObjectSchema<{
30
30
  /** Left out when there is nothing to run that would fill the field. */
31
31
  readonly step: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "dotted lower-case words, such as `page.next`">]>, undefined>;
32
32
  }, undefined>;
33
+ /**
34
+ * How many match, every page together, as Elasticsearch's `hits.total`: `eq` is the exact count, `gte` a lower bound
35
+ * (counting stopped at a cap, or could not be done cheaply).
36
+ */
37
+ export declare const totalSchema: v.StrictObjectSchema<{
38
+ readonly value: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
39
+ readonly relation: v.PicklistSchema<["eq", "gte"], undefined>;
40
+ }, undefined>;
33
41
  /** `has_more`: another page exists. A cursor source never pretends to have page numbers. */
34
42
  export declare const pageSchema: v.VariantSchema<"mode", [v.StrictObjectSchema<{
35
43
  readonly mode: v.LiteralSchema<"page", undefined>;
36
44
  readonly limit: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>;
37
45
  readonly has_more: v.BooleanSchema<undefined>;
38
46
  readonly next_page: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 2, undefined>]>, undefined>;
47
+ readonly total: v.OptionalSchema<v.StrictObjectSchema<{
48
+ readonly value: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
49
+ readonly relation: v.PicklistSchema<["eq", "gte"], undefined>;
50
+ }, undefined>, undefined>;
39
51
  }, undefined>, v.StrictObjectSchema<{
40
52
  readonly mode: v.LiteralSchema<"cursor", undefined>;
41
53
  readonly limit: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>;
42
54
  readonly has_more: v.BooleanSchema<undefined>;
43
55
  readonly next_cursor: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
56
+ readonly total: v.OptionalSchema<v.StrictObjectSchema<{
57
+ readonly value: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
58
+ readonly relation: v.PicklistSchema<["eq", "gte"], undefined>;
59
+ }, undefined>, undefined>;
44
60
  }, undefined>], undefined>;
61
+ export type Total = v.InferOutput<typeof totalSchema>;
45
62
  /** A source that failed or was skipped: the answer holds what the others gave. */
46
63
  export declare const issueSchema: v.StrictObjectSchema<{
47
64
  readonly code: v.PicklistSchema<readonly ["validation_error", "configuration_error", "authentication_error", "permission_error", "not_found", "confirmation_required", "rate_limited", "timeout", "network_error", "provider_error", "provider_unavailable", "invalid_response", "outcome_unknown", "cancelled"], undefined>;
@@ -78,11 +95,19 @@ export declare const listEnvelopeSchema: <T extends v.GenericSchema>(item: T) =>
78
95
  readonly limit: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>;
79
96
  readonly has_more: v.BooleanSchema<undefined>;
80
97
  readonly next_page: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 2, undefined>]>, undefined>;
98
+ readonly total: v.OptionalSchema<v.StrictObjectSchema<{
99
+ readonly value: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
100
+ readonly relation: v.PicklistSchema<["eq", "gte"], undefined>;
101
+ }, undefined>, undefined>;
81
102
  }, undefined>, v.StrictObjectSchema<{
82
103
  readonly mode: v.LiteralSchema<"cursor", undefined>;
83
104
  readonly limit: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>;
84
105
  readonly has_more: v.BooleanSchema<undefined>;
85
106
  readonly next_cursor: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
107
+ readonly total: v.OptionalSchema<v.StrictObjectSchema<{
108
+ readonly value: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
109
+ readonly relation: v.PicklistSchema<["eq", "gte"], undefined>;
110
+ }, undefined>, undefined>;
86
111
  }, undefined>], undefined>;
87
112
  readonly next: v.OptionalSchema<v.SchemaWithPipe<readonly [v.ArraySchema<v.VariantSchema<"kind", [v.StrictObjectSchema<{
88
113
  readonly kind: v.LiteralSchema<"command", undefined>;
@@ -243,11 +268,19 @@ export declare const jsonlEndSchema: v.StrictObjectSchema<{
243
268
  readonly limit: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>;
244
269
  readonly has_more: v.BooleanSchema<undefined>;
245
270
  readonly next_page: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 2, undefined>]>, undefined>;
271
+ readonly total: v.OptionalSchema<v.StrictObjectSchema<{
272
+ readonly value: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
273
+ readonly relation: v.PicklistSchema<["eq", "gte"], undefined>;
274
+ }, undefined>, undefined>;
246
275
  }, undefined>, v.StrictObjectSchema<{
247
276
  readonly mode: v.LiteralSchema<"cursor", undefined>;
248
277
  readonly limit: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>;
249
278
  readonly has_more: v.BooleanSchema<undefined>;
250
279
  readonly next_cursor: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
280
+ readonly total: v.OptionalSchema<v.StrictObjectSchema<{
281
+ readonly value: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
282
+ readonly relation: v.PicklistSchema<["eq", "gte"], undefined>;
283
+ }, undefined>, undefined>;
251
284
  }, undefined>], undefined>;
252
285
  }, undefined>;
253
286
  /** A list answer as JSON lines, the end line last. */
@@ -37,6 +37,14 @@ export const missingSchema = v.strictObject({
37
37
  step: v.optional(dottedId),
38
38
  });
39
39
  const limit = v.pipe(v.number(), v.integer(), v.minValue(1));
40
+ /**
41
+ * How many match, every page together, as Elasticsearch's `hits.total`: `eq` is the exact count, `gte` a lower bound
42
+ * (counting stopped at a cap, or could not be done cheaply).
43
+ */
44
+ export const totalSchema = v.strictObject({
45
+ value: v.pipe(v.number(), v.integer(), v.minValue(0)),
46
+ relation: v.picklist(["eq", "gte"]),
47
+ });
40
48
  /** `has_more`: another page exists. A cursor source never pretends to have page numbers. */
41
49
  export const pageSchema = v.variant("mode", [
42
50
  v.strictObject({
@@ -44,8 +52,15 @@ export const pageSchema = v.variant("mode", [
44
52
  limit,
45
53
  has_more: v.boolean(),
46
54
  next_page: v.optional(v.pipe(v.number(), v.integer(), v.minValue(2))),
55
+ total: v.optional(totalSchema),
56
+ }),
57
+ v.strictObject({
58
+ mode: v.literal("cursor"),
59
+ limit,
60
+ has_more: v.boolean(),
61
+ next_cursor: v.optional(text),
62
+ total: v.optional(totalSchema),
47
63
  }),
48
- v.strictObject({ mode: v.literal("cursor"), limit, has_more: v.boolean(), next_cursor: v.optional(text) }),
49
64
  ]);
50
65
  /** A source that failed or was skipped: the answer holds what the others gave. */
51
66
  export const issueSchema = v.strictObject({
@@ -42,7 +42,7 @@ the four steps that need a person. A new CLI is ready for its first release when
42
42
  | [Security](security.md) | `SECR` | 23 | Secrets, logs, keyring, argv, error text, untrusted text, scanners, workflow hardening, reporting, OAuth sign-in, meeting secrets, keyring in moved-folder runs |
43
43
  | [Documents](docs.md) | `DOC` | 20 | User pages and developer pages, generated pages, current facts, changelog |
44
44
  | [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 |
45
- | [Releases](releases.md) | `REL` | 15 | `bin/release`, the release lock, versions, release reports, trusted publishing |
45
+ | [Releases](releases.md) | `REL` | 15 | `bin/release`, the release lock, versions, the owner's yes, trusted publishing |
46
46
  | [CI](ci.md) | `CI` | 12 | Hooks, the pull request budget, full validation, monthly runs |
47
47
  | [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 |
48
48
  | [Style](style.md) | `STYLE` | 10 | Comments, TypeScript habits, commit messages |
@@ -98,7 +98,7 @@ plus community itself. cli-template follows every rule here as a CLI does
98
98
  cli-docs validates its site before each deployment instead, and community has no build. Third-party security apps
99
99
  may post their own checks.
100
100
  - **Applies to:** the CLIs, cli-base, cli-core and cli-testing. cli-base, whatsapp-cli, tg-cli, max-cli, cli-core,
101
- cli-memo, zoom-cli, drive-cli and cli-template have `pr.yml`; cli-testing does not have it yet. **Since:**
101
+ cli-memo, zoom-cli, drive-cli, gcal-cli and cli-template have `pr.yml`; cli-testing does not have it yet. **Since:**
102
102
  cli-base 0.41.0 (pull request validation since cli-base 0.45.0). **Rulings:** none.
103
103
 
104
104
  ### WC-CI-6 · Run the changelog guard on every pull request
@@ -208,7 +208,8 @@ its prompt in cli-template says so from cli-template 0.8.0. A CLI on an older te
208
208
  - **Why:** a red merge breaks `main` for every parallel branch, and an admin merge skips the only gate there is.
209
209
  - **Enforced by:** `whatsapp-cli: main ruleset required checks` · `cli-core: main ruleset required checks` ·
210
210
  `tg-cli: main ruleset required checks` · `max-cli: main ruleset required checks` ·
211
- `cli-memo: main ruleset required checks` · `drive-cli: main ruleset required checks`, plus the main rulesets of
211
+ `cli-memo: main ruleset required checks` · `drive-cli: main ruleset required checks` ·
212
+ `gcal-cli: main ruleset required checks`, plus the main rulesets of
212
213
  cli-base and cli-template. In each repository with pr.yml, the ruleset requires the pull request validation jobs besides the
213
214
  standards job. cli-testing and cli-docs have no pr.yml, so their rulesets require only the standards job, and
214
215
  review covers the rest. zoom-cli has pr.yml,
@@ -212,10 +212,9 @@ store or files without reading the service first (`remote: "none"`, `local: "wri
212
212
  - **Example:** `annotate(send, { effects: { remote: "write", local: "write" } })`. Each side is `none`, `read` or
213
213
  `write`. A sync that reads the service and writes the local store is `{ remote: "read", local: "write" }`.
214
214
  - **Exceptions:** none. Without `effects`, the error handler never marks a failure safe to retry, and a next step
215
- that runs the command is never safe to run unasked. Known gap, as of 2026-10-27: cli-base's `writes()`
216
- (`src/standards/effects.ts`) still counts every local write as a write, so cli-core's MCP router serves `sync` and
217
- `calendars list` through the `write` tool. The follow-up makes `writes()` true for `remote: "write"`, or for
218
- `local: "write"` when `remote` is `none`.
215
+ that runs the command is never safe to run unasked. cli-base's `writes()` (`src/standards/effects.ts`) still
216
+ counts every local write as a write; cli-core's MCP router adds its own `fetchesOnly` test (cli-core 0.468.0), so
217
+ `sync` and `calendars list` run through `read`.
219
218
  - **Applies to:** all CLIs. **Since:** cli-base 0.41.0 (what counts as a read: cli-base 0.65.0). **Rulings:** the
220
219
  owner's ruling on what counts as a read over MCP (2026-10-27).
221
220
 
@@ -277,7 +276,9 @@ store or files without reading the service first (`remote: "none"`, `local: "wri
277
276
  - **Why:** a wire field, a port or an adapter name means nothing to the person reading help.
278
277
  - **Enforced by:** review only.
279
278
  - **Example:** an option says what it changes and its unit: `--pause <duration>` "wait this long between pages".
280
- - **Exceptions:** none.
279
+ - **Exceptions:** generated commands (`bot api`, gcal's and drive's `api`) keep the help their API description
280
+ gives, such as Google's full sentences for `gcal api calendar-acl-delete --calendar-id`; rewriting it by hand would
281
+ drift from the API on the next regeneration.
281
282
  - **Applies to:** all CLIs. **Since:** cli-base 0.41.0. **Rulings:** none.
282
283
 
283
284
  ### WC-CMD-24 · Give every leaf command one to three examples that run as written
@@ -25,10 +25,10 @@ Rule ids on this page are `WC-DOC-<n>`. How to read a rule, how ids work and whi
25
25
 
26
26
  - **Why:** the reader wants the current behaviour, not the history of a page or a journal number they cannot open.
27
27
  - **Enforced by:** `tg-cli: pnpm docs:check` · `max-cli: pnpm docs:check` · `whatsapp-cli: pnpm docs:check` ·
28
- `zoom-cli: pnpm docs:check`.
28
+ `zoom-cli: pnpm docs:check` · `gcal-cli: pnpm docs:check`.
29
29
  - **Example:** write "`--limit` defaults to 50", not "~~20~~ 50 (correction 2026-10-01, NEED-12)".
30
30
  - **Exceptions:** none. `cli-dev docs-check` checks this when the CLI's rules module sets `userPage`. tg, max, wa,
31
- zoom and cli-template set it; memo, drive and cli-base's default check do not.
31
+ zoom, gcal and cli-template set it; memo, drive and cli-base's default check do not.
32
32
  - **Applies to:** all CLIs. **Since:** cli-base 0.41.0. **Rulings:** none.
33
33
 
34
34
  ### WC-DOC-3 · Every document states current facts; rewrite a wrong sentence in place
@@ -220,12 +220,13 @@ before its deadline.
220
220
 
221
221
  ### WC-LIFE-15 · Keep a run log through cli-core, and read it back with `runs`
222
222
 
223
- A failed command is always kept as a run, unless the person turned recording off with `--no-record` or
224
- `record: false` in the config. A run keeps the command line as typed, secrets hidden; `record_input: false` masks
225
- every value that may be content
223
+ Every failed command is kept as a run, and so is every command that does its work inside cli-core's run
224
+ (`context.run`, or `startRecording` in a program of its own). `record: false` in the config keeps failures only;
225
+ `--no-record` keeps nothing; `--record` keeps a success the config, WireCat Desktop or a command that runs until
226
+ stopped would not. A run keeps the command line as typed, secrets hidden; `record_input: false` masks every value
227
+ that may be content
226
228
  ([WC-SECR-1](security.md#wc-secr-1--private-data-and-secrets-never-reach-a-log-a-fixture-or-a-committed-document)).
227
- `--record` keeps every run of a command that does its work inside cli-core's run
228
- (`context.run`, or `startRecording` in a program of its own). A run is a folder `<state>/runs/<day>/<run_id>/` with
229
+ A run is a folder `<state>/runs/<day>/<run_id>/` with
229
230
  `run.json` and `events.jsonl`, one log record per line. `runs list` and `runs show` read it.
230
231
 
231
232
  - A CLI that runs its program through cli-core's `run` gets the failure log and `--record`/`--no-record` from it,
@@ -78,10 +78,9 @@ A command that reads the service and only saves what it read into the owner's lo
78
78
  nothing the owner would have to undo, so a sync stays under it; sending it through `write` would make a host ask a
79
79
  person before every refresh.
80
80
  - **Enforced by:** `cli-core: src/mcp/mcp.test.ts` · `cli-core: src/mcp/output-schema.test.ts`.
81
- - **Example:** `max_read { "command": "messages send", … }` is refused. Once the gap below is closed,
82
- `gcal_read { "command": "sync" }` runs.
83
- - **Exceptions:** none. Known gap, as of 2026-10-27: cli-core decides with cli-base's `writes()`, which still counts
84
- the local write, so `sync` runs through `write` until that follow-up lands (WC-CMD-17).
81
+ - **Example:** `max_read { "command": "messages send", … }` is refused. `gcal_read { "command": "sync" }` runs:
82
+ cli-core counts a command that only fetches into the local store as a read (`fetchesOnly`, cli-core 0.468.0).
83
+ - **Exceptions:** none.
85
84
  - **Applies to:** all CLIs. **Since:** cli-base 0.41.0 (what counts as a read: cli-base 0.65.0). **Rulings:** the
86
85
  owner's ruling on what counts as a read over MCP (2026-10-27).
87
86
 
@@ -237,7 +236,7 @@ cli-core builds its personal and bot MCP catalogues from the command tree with `
237
236
  (WC-MCP-1) that is three names and their schemas, which do not change when a command is added. So each CLI also
238
237
  pins the command catalogue behind the tools, every command `tools_search` can return and whether it runs through
239
238
  `read` or `write`, in `contracts/mcp-catalogue.json`. A command that becomes visible to agents then shows up as a diff in
240
- its pull request. tg and max keep both; wa, zm, gcal and drive pin only the tools, a known gap as of 2026-10-27.
239
+ its pull request. tg, max and gcal keep both; wa, zm and drive pin only the tools, a known gap as of 2026-10-27.
241
240
 
242
241
  ## Sources
243
242
 
@@ -63,7 +63,9 @@ JSON Schema 2020-12.
63
63
  - **Why:** a cursor source has no page numbers, and pretending it has them sends the caller to the wrong place.
64
64
  - **Enforced by:** review only.
65
65
  - **Example:** `{ mode: "page", limit, has_more, next_page? }`, or `{ mode: "cursor", limit, has_more,
66
- next_cursor? }` for a source with its own position, such as a message history.
66
+ next_cursor? }` for a source with its own position, such as a message history. A search that can count its
67
+ matches adds `total: { value, relation }`, shaped as Elasticsearch's `hits.total`: `relation` is `eq` for an exact
68
+ count and `gte` for a lower bound, when counting stopped at a cap or could not be done cheaply.
67
69
  - **Exceptions:** none.
68
70
  - **Applies to:** all CLIs. **Since:** cli-base 0.41.0. **Rulings:** none.
69
71
 
@@ -97,26 +97,39 @@ Commands that look after the tool and its files, and never show a message, have
97
97
 
98
98
  ### WC-PERM-6 · The built-in defaults ask only before what cannot be undone
99
99
 
100
- With no `permissions` set, a profile may do almost everything. The built-in defaults are:
100
+ With no `permissions` set, a profile may do almost everything. The built-in defaults, cli-core's
101
+ `DEFAULT_PERMISSIONS` (`src/sends/permissions.ts`), are:
101
102
 
102
103
  ```json
103
104
  {
104
105
  "messages.delete": "ask", "bot.messages.delete": "ask", "account.sessions.end": "ask",
105
106
  "topics.enable": "ask", "topics.delete": "ask", "chats.delete": "ask", "chats.clear": "ask",
106
- "replies.send": "deny"
107
+ "replies.send": "deny",
108
+ "mcp.authority": "deny",
109
+ "store.backup": "ask", "store.restore": "ask", "store.migrate": "ask", "store.repair": "ask",
110
+ "store.reset": "ask", "store.clear": "ask", "store.decrypt": "ask", "store.copies.delete": "ask",
111
+ "server.start": "ask", "server.stop": "ask", "server.restart": "ask",
112
+ "models.download": "ask", "images.models.download": "ask",
113
+ "schedule.run": "ask", "schedule.exec": "ask", "doctor.reports.create": "ask"
107
114
  }
108
115
  ```
109
116
 
117
+ `mcp.authority` keeps an agent from running the commands that change what a later run may do, such as settings,
118
+ schedules or recipient lists (NEED-1662, cli-core 0.400.0). The `store`, `server`, `models`, `schedule` and `doctor`
119
+ keys give a key to writes that had none, so an MCP server can offer them (NEED-1663, cli-core 0.401.0). A CLI that
120
+ declares `commandPermissions` (WC-PERM-22) gets these defaults beneath its own.
121
+
110
122
  A default is never made stricter without the owner's word.
111
123
 
112
- - **Why:** the tool should work without questions; only a deletion, a logout or an automatic reply needs one.
124
+ - **Why:** the tool should work without questions; only a deletion, a logout, an automatic reply, a change to the
125
+ store or this machine, or a change to what an agent may do needs one.
113
126
  - **Enforced by:** `cli-core: src/sends/permissions.test.ts`.
114
127
  - **Example:** a new profile sends at once, and asks before `messages delete`.
115
128
  - **Exceptions:** where the messenger has replies, `replies.send` is `deny`: a reply rule writes to people with nobody
116
129
  typing a command, so it waits for the owner's own `allow`. A messenger with no `replies` group, such as wa, has no
117
130
  use for the key.
118
131
  - **Applies to:** messenger CLIs. A CLI that is not a messenger sets its own defaults (WC-PERM-22). **Since:** cli-base
119
- 0.41.0. **Rulings:** NEED-568.
132
+ 0.41.0. **Rulings:** NEED-568, NEED-1662, NEED-1663.
120
133
 
121
134
  ### WC-PERM-7 · A built-in default only ever tightens
122
135
 
@@ -333,19 +346,19 @@ A messenger's keys come from its resources (`messages`, `chats`). Any other CLI
333
346
  write runs through cli-core's write guard with `permissionDefaults: configuration.permissions.defaults`
334
347
  (cli-core 0.455.0), so `ask` is a y/N question at a terminal (WC-PERM-9) and every attempt goes into the send journal
335
348
  (WC-PERM-17). A CLI that sends no messages has no recipient list or hourly limit to set.
349
+ - **When.** cli-template's skeleton has no command of its own that writes to a service, so it does not call
350
+ `commandPermissions`; a CLI made from it adds the call with its first such command.
336
351
 
337
352
  - **Why:** one permission model for every CLI; a CLI-own layer drifts from the shared one and is audited by nobody.
338
353
  - **Enforced by:** `cli-core: src/cli/command-permissions.test.ts` · `cli-core: src/calendar/cli/writes.test.ts`.
339
354
  - **Example:** a calendar CLI takes cli-core's `CALENDAR_WRITE_DEFAULTS` and `CALENDAR_WRITE_KEYS`
340
- (`@wirecat/cli-core/calendar`, cli-core 0.458.0). By this rule `events create`, `update` and `respond` are `allow`
341
- and `events delete` is `ask`. `events.delete.series` (every occurrence of a repeating series, in every attendee's
342
- calendar) and `events.notify` (the provider mails the attendees) are `deny` until the owner allows them: over MCP
355
+ (`@wirecat/cli-core/calendar`, cli-core 0.458.0). By this rule `events create`, `update`, `respond` and `move` are
356
+ `allow` and `events delete` is `ask`. `events.delete.series` (every occurrence of a repeating series, in every
357
+ attendee's calendar) and `events.notify` (the provider mails the attendees) are `deny` until the owner allows them: over MCP
343
358
  `ask` runs without a question, and each reaches people other than the owner. gcal adds its own keys for its generated
344
359
  `api <method>` commands: sharing a calendar is `ask` (NEED-1857), and emptying the main calendar
345
360
  (`api.calendar.calendars.clear`) is `deny`.
346
- - **Exceptions:** known gaps, as of 2026-10-27:
347
- - cli-core's `CALENDAR_WRITE_DEFAULTS` (`src/calendar/writer.ts`) still has `events create`, `update` and
348
- `respond` at `ask`.
361
+ - **Exceptions:** known gaps, as of 2026-10-31:
349
362
  - gcal's generated `api` commands default every write to `ask`, ordinary changes included.
350
363
  - drive keeps its own policy and `--read-only` flag, zoom reads its own `remote-meetings` key with `levelFor`, and
351
364
  memo uses cli-core's permissions for the shared commands only. None of them declares `commandPermissions` yet.
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Scope
6
6
 
7
- How a CLI is released: `bin/release`, the release lock, version numbers, the release report and trusted publishing.
7
+ How a CLI is released: `bin/release`, the release lock, version numbers, the owner's yes and trusted publishing.
8
8
 
9
9
  Rule ids on this page are `WC-REL-<n>`. How to read a rule, how ids work and which rule wins are in the
10
10
  [index](README.md). The changelog a release dates is on the [Documents](docs.md) page. The checks a release runs are
@@ -65,7 +65,7 @@ on the [CI](ci.md) page.
65
65
 
66
66
  - **Why:** two sessions often prepare the same number; the second must not publish over the first or fail late.
67
67
  - **Enforced by:** `tg-cli: bin/release` · `max-cli: bin/release` · `whatsapp-cli: bin/release` ·
68
- `cli-memo: bin/release`.
68
+ `cli-memo: bin/release` · `gcal-cli: bin/release`.
69
69
  - **Example:** before it publishes, `bin/release` asks npm. When npm already has the version, or a higher one, it
70
70
  takes the next free number at the same level (after `x.y.0` the next minor, else the next patch). It lands that
71
71
  number through a pull request and publishes it.
@@ -106,17 +106,16 @@ on the [CI](ci.md) page.
106
106
 
107
107
  ### WC-REL-9 · Merged into [WC-REL-8](#wc-rel-8--any-package-may-release-at-any-time)
108
108
 
109
- ### WC-REL-10 · A messenger CLI is released only with a release report the owner signed for that commit
109
+ ### WC-REL-10 · A messenger CLI is released only after the owner says yes in the conversation
110
110
 
111
- - **Why:** the judgement half of a release (changelog wording, docs against the change, live checks) has no other
112
- record that it ran for this exact commit.
113
- - **Enforced by:** `max-cli: bin/release` · `whatsapp-cli: bin/release`.
114
- - **Example:** max-cli's and whatsapp-cli's `bin/release` refuse unless the report has `Commit: <HEAD>` and
115
- `Signed off: <date>`. When `bin/release` only renumbers or folds the changelog, it carries the report to the new
116
- commit. A fork passes its own report with `bin/release --report <path>`.
117
- - **Exceptions:** none. max-cli and whatsapp-cli (from 0.1.0) check the report in `bin/release`. tg-cli has the
118
- release skill, but its `bin/release` does not check a report yet.
119
- - **Applies to:** messenger CLIs. **Since:** cli-base 0.41.0. **Rulings:** none.
111
+ - **Why:** a messenger CLI acts on the owner's own accounts, so the owner decides when a new version ships. A signed
112
+ release report added a step without adding a check: the owner removed it on 2026-10-31 ("remove the signing
113
+ requirements instead just ask").
114
+ - **Enforced by:** review only.
115
+ - **Example:** the agent finishes the release skill, then asks "release max 0.91.0?". It runs `bin/release` only after
116
+ the owner says yes for that version. One yes covers one version.
117
+ - **Exceptions:** none.
118
+ - **Applies to:** messenger CLIs. **Since:** cli-base 0.41.0. **Rulings:** owner, 2026-10-31.
120
119
 
121
120
  ### WC-REL-11 · Publish from GitHub Actions with npm trusted publishing
122
121
 
@@ -129,7 +128,9 @@ on the [CI](ci.md) page.
129
128
  ([npm docs](https://docs.npmjs.com/trusted-publishers)), which a package has only once it is published, so its
130
129
  first version is published with `bin/release --local` by the owner, from a clean `main`. Right after it, the owner
131
130
  adds the trusted publisher on npmjs.com (step 2 of [New CLI](new-cli.md#four-steps-that-need-a-person)), and every
132
- later version goes through `release.yml`.
131
+ later version goes through `release.yml`. ClawHub has no trusted publishing: the `clawhub` job publishes the agent
132
+ skill with the organization secret `CLAWHUB_TOKEN`. It runs after the tag, so a ClawHub failure never holds back
133
+ npm; the run then fails although npm and the tag are done, and only that job is run again.
133
134
  - **Applies to:** all CLIs and shared libraries. **Since:** cli-base 0.41.0. **Rulings:** none.
134
135
 
135
136
  ### WC-REL-12 · Publish only after full validation and the release security checks pass
@@ -157,7 +158,7 @@ on the [CI](ci.md) page.
157
158
 
158
159
  - **Why:** a release must still be possible when GitHub Actions is down, without leaking the token.
159
160
  - **Enforced by:** `tg-cli: bin/release` · `max-cli: bin/release` · `whatsapp-cli: bin/release` ·
160
- `cli-memo: bin/release`.
161
+ `cli-memo: bin/release` · `gcal-cli: bin/release`.
161
162
  - **Example:** `bin/release --local` runs the same checks on this machine and reads the npm token from the keyring
162
163
  into the publish command's environment. The checks include the test suite with its coverage floor,
163
164
  any shipped non-JavaScript engine's tests, offline contracts, leak and secret scans, and dependency
@@ -199,8 +200,9 @@ fixtures, `scripts/`, `.github/`, `bin/release`, `config/`, `lint/`, `contracts/
199
200
  `npm pack --dry-run` in gcal-cli packed 256 files on 2026-10-27, `src/`, `scripts/`, `.github/`, `config/`,
200
201
  `contracts/`, `lint/` and `template.json` among them, because its `files` list named them; its check could not see
201
202
  it. On 2026-10-31 (gcal-cli 0.2.0) it packs 144. The check this rule needs
202
- refuses the forbidden paths above whatever `files` says; it belongs in cli-template, so every CLI gets it by
203
- `upgrade` ([cli-template#77](https://github.com/WireCatLabs/cli-template/issues/77)).
203
+ refuses the forbidden paths above whatever `files` says: cli-template's `scripts/smoke.ts` does since
204
+ [cli-template#85](https://github.com/WireCatLabs/cli-template/pull/85). `scripts/` is a seed that `upgrade` never
205
+ changes, so a CLI made before it copies the check by hand.
204
206
  - **Exceptions:** cli-template's own package ships the skeleton's sources and configs, since `new` copies them; the
205
207
  `files` list `new` writes into a CLI follows this rule. Each CLI's `package:check` runs `packContents`; tg and max
206
208
  as `release-check.ts --package-only`.
@@ -67,19 +67,31 @@ name. Any other top-level key is written as `[not allowed]`. A key that names co
67
67
  ### WC-SECR-4 · Secrets come from the environment, then the OS keyring, then a private file
68
68
 
69
69
  cli-base's `Credentials` reads the environment variable first (for CI), then the OS keyring, then a file with mode
70
- `0600`. The keyring service is the CLI's app name, `appName` in its identity (`max-cli`, `gcal-cli`, `cli-memo`), not
70
+ `0600` in the CLI's own folder. Storage is the keyring by default (`auto`): when the keyring fails (locked, not
71
+ running, no D-Bus), the CLI uses the file without asking, warns once on stderr, and the answer that saved the secret
72
+ says where it went (gcal: `credential_storage: "file"`). `--credential-storage file` on the sign-in command chooses
73
+ the file outright; help and docs discourage it. Once the file holds a secret, it is read from the file, so a locked
74
+ keyring is not tried again for it; the next sign-in that reaches the keyring moves it back and removes the file copy.
75
+ `Credentials` in `auto` does all of this: the CLI passes `{ signIn: true }` to `write` for a new sign-in and nothing
76
+ for a refresh.
77
+ No CLI asks which store to use, and none is keyring-only.
78
+ The keyring service is the CLI's app name, `appName` in its identity (`max-cli`, `gcal-cli`, `cli-memo`), not
71
79
  the command word people type (`max`, `gcal`). A run with moved folders reads the same entry, writes a temporary one,
72
80
  and never replaces or deletes the owner's
73
81
  ([WC-SECR-24](#wc-secr-24--a-run-with-moved-folders-shares-the-owners-keyring-entry-and-never-deletes-it)).
74
82
 
75
- - **Why:** the keyring is the safest store on the computer.
83
+ - **Why:** the keyring is the safest store on the computer, and a sign-in that fails on a server or in a container
84
+ because no keyring runs there is a broken CLI, not a safe one.
76
85
  - **Enforced by:** `src/credentials.test.ts`.
77
86
  - **Example:** a bot token is read from `MAX_BOT_TOKEN`, then the keyring entry `bot:<profile>`, then the file.
78
- - **Exceptions:** `auto` storage tries the keyring and, when it fails, warns once on stderr and uses the file. A
79
- messenger library's own device store, which the library opens as a file and which cannot live in the keyring
80
- (WhatsApp's `private.db`), is an owner-only file under
81
- [WC-SECR-10](#wc-secr-10--private-files-are-written-atomically-with-private-modes) instead.
82
- - **Applies to:** all CLIs. **Since:** cli-base 0.41.0. **Rulings:** NEED-295.
87
+ `gcal personal session start` on a computer without a keyring signs in, warns once, and answers
88
+ `credential_storage: "file"`.
89
+ - **Exceptions:** a messenger library's own device store, which the library opens as a file and which cannot live in
90
+ the keyring (WhatsApp's `private.db`, tg's session file), is an owner-only file under
91
+ [WC-SECR-10](#wc-secr-10--private-files-are-written-atomically-with-private-modes) instead. A copy between two
92
+ profiles that must land in the keyring (cli-core's bot transfer) may use `storage: "keyring"`.
93
+ - **Applies to:** all CLIs. **Since:** cli-base 0.41.0. **Rulings:** NEED-295; owner, 2026-10-31 (never ask, keyring
94
+ by default, fall back to a file on error, a discouraged flag for the file).
83
95
 
84
96
  ### WC-SECR-5 · A secret is never taken on the command line
85
97
 
@@ -274,7 +286,8 @@ general words.
274
286
  `persist-credentials: false`, and `permissions: contents: read` at the top of the workflow. A job that needs more
275
287
  asks for it on that job only. The `zizmor` scan in `release-checks.yml` reports high findings at release, but does
276
288
  not fail on them.
277
- - **Exceptions:** none.
289
+ - **Exceptions:** the `tag` job of `release.yml` keeps its checkout's credentials (no `persist-credentials: false`),
290
+ because it pushes `v<version>`; it runs no project code.
278
291
  - **Applies to:** the WireCat repositories: every CLI, cli-base, cli-core, cli-testing, cli-docs and community; and
279
292
  cli-template.
280
293
  **Since:** cli-base 0.41.0. **Rulings:** none.
@@ -347,7 +360,8 @@ store and runs no search. A CLI declares its provider and may replace any step.
347
360
 
348
361
  A CLI never revokes a sign-in at the provider in a moved run, even one read from the temporary entry: a refreshed
349
362
  token is still the owner's grant. A test passes `memoryKeyring()`; a run that must not see the keyring at all also
350
- runs with `DBUS_SESSION_BUS_ADDRESS` removed, since the keyring is reached over D-Bus.
363
+ runs with `DBUS_SESSION_BUS_ADDRESS=disabled:`, since the keyring is reached over D-Bus. Removing the variable is not
364
+ enough: D-Bus then connects to `$XDG_RUNTIME_DIR/bus`, the default session bus, and reaches the keyring anyway.
351
365
  - **Cleanup:** a moved run that never reaches `session end` leaves its temporary entry, and so does one that refreshes
352
366
  a token, since the refreshed token is written there. `bin/keyring-dedupe --delete` in the projects folder removes
353
367
  temporary entries that sit beside the owner's real one; a test or agent can also delete one by name with
@@ -89,10 +89,12 @@ plus community itself. cli-template follows every rule here as a CLI does
89
89
  Node 22 from 22.16.0, then 24 and later. Full validation runs on Node 24. `bun test` cannot run the vitest suite, so
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
- tg, max and wa, in zoom's full validation and in cli-core's old-Node job.
93
- - **Exceptions:** cli-docs and community publish no runtime package. tg needs `^22.19.0`. memo, drive and cli-base
94
- do not run Node 22 in any workflow yet, and cli-base's own `engines` is still `>=22`. The unit suite runs on Windows
95
- and macOS for tg, max and drive (`platforms: true`); zoom and memo still fail tests there, so theirs is off.
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`.
94
+ - **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
+ 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.
96
98
  - **Applies to:** the CLIs, cli-core and cli-base. **Since:** cli-base 0.41.0. **Rulings:** NEED-11.
97
99
 
98
100
  ### WC-TOOL-7 · Let Biome format the code, and do not format by hand
@@ -239,7 +241,7 @@ no-unbounded-spread ([WC-TOOL-12](#wc-tool-12--do-not-spread-a-list-of-user-data
239
241
 
240
242
  - **Why:** user pages feed the docs site, so their Markdown form is checked more closely than developer notes.
241
243
  - **Enforced by:** `tg-cli: pnpm docs:check` · `max-cli: pnpm docs:check` · `whatsapp-cli: pnpm docs:check` ·
242
- `zoom-cli: pnpm docs:check` · `cli-testing: pnpm docs:check`.
244
+ `zoom-cli: pnpm docs:check` · `gcal-cli: pnpm docs:check` · `cli-testing: pnpm docs:check`.
243
245
  - **Example:** `rumdl check --config config/wirecat/rumdl.toml README.md docs/*.md`, inside `docs:check`. The preset
244
246
  turns off line length (MD013), first-line heading (MD041) and relative links (MD057), because other checks cover
245
247
  them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-base",
3
- "version": "0.66.1",
3
+ "version": "0.68.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",