@wirecat/cli-base 0.67.0 → 0.68.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,49 @@ Versions before 0.8.0 are in the [git tags](https://github.com/WireCatLabs/cli-b
8
8
  Every entry says what changed as a caller sees it, why, and what to watch for — the rules are
9
9
  [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog).
10
10
 
11
+ ## 0.68.1 — 31.10.2026
12
+
13
+ ### Added
14
+
15
+ - **The audit accepts a flag that one CLI lacks by design, by name.** A parity difference may carry `absent: "<cli>"`
16
+ with a target of `<path> <flag>` and no CLI list; it covers every finding where only that CLI lacks the flag and the
17
+ others agree, so another CLI joining the group does not make it stale. `PARITY_GROUPS` uses it to declare that memo
18
+ has no profiles of its own (no `--profiles` on `mcp serve`, `mcp revoke`, `mcp server start|status|stop`); memo
19
+ selects other CLIs' accounts with `--source` and `--account`. A run without the absent CLI does not call it stale.
20
+ WC-MCP-9 and WC-ACCT-1 say so. See [#390](https://github.com/WireCatLabs/cli-base/issues/390).
21
+
22
+ ## 0.68.0 — 31.10.2026
23
+
24
+ ### Removed
25
+
26
+ - **WC-REL-10: a messenger CLI is released after the owner's yes in the conversation, with no signed report.**
27
+ `bin/release` in max-cli and whatsapp-cli no longer reads a release report or a `Signed off:` line. The agent asks
28
+ the owner before it runs `bin/release`; one yes covers one version.
29
+
30
+ ### Changed — may break callers
31
+
32
+ - **`Credentials` in `auto` does the whole file fallback itself (WC-SECR-4).** A secret the file holds is read from the
33
+ file before the keyring, so a locked keyring is not tried for it, and a refresh `write` keeps it there. A sign-in
34
+ passes `write(account, secret, { signIn: true })`: it tries the keyring and, once the keyring takes it, removes the
35
+ file copy. The "could not reach the OS keyring" warning comes once per run (per `env` object), not once per
36
+ `Credentials`. Watch for: a file copy left next to a newer keyring entry by an older CLI now wins the read; sign in
37
+ again to move it back. CLIs can drop their own file-first read, "remove the file copy" step and `warnOnce`.
38
+ - **WC-SECR-4 is the one credential-storage standard.** The keyring by default; when it fails, the file without
39
+ asking, one warning on stderr, and the answer that saved the secret says where it went;
40
+ `--credential-storage file` on the sign-in command chooses the file and is discouraged; a secret in the file is
41
+ read from there until a sign-in reaches the keyring again. No CLI asks, none is keyring-only. `Credentials` already
42
+ falls back this way in `auto`; the rule now says every CLI must use it.
43
+
44
+ ### Fixed
45
+
46
+ - **`Credentials.remove` with `storage: "keyring"` leaves the file alone.** It also deleted the account from the
47
+ credentials file, so a CLI that cleared the keyring copy after a file sign-in lost the sign-in it had just saved.
48
+
49
+ ### Added
50
+
51
+ - **`--credential-storage <storage>` in the flag vocabulary** (role `mode`), so `cli-dev lint` accepts it on a sign-in
52
+ command without a per-CLI override.
53
+
11
54
  ## 0.67.0 — 31.10.2026
12
55
 
13
56
  ### Added
@@ -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) {
@@ -91,14 +99,18 @@ export class Credentials {
91
99
  "moved folders never removes it");
92
100
  }
93
101
  }
94
- const store = this.#readFile();
95
- if (store?.[account] !== undefined) {
96
- delete store[account];
97
- this.#writeFile(store);
102
+ if (this.#storage !== "keyring" && this.#removeFromFile(account))
98
103
  removed.push("file");
99
- }
100
104
  return removed;
101
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
+ }
102
114
  /** Only for the warning: a keyring that fails here must not fail a removal that already happened. */
103
115
  #ownersEntryExists(account) {
104
116
  try {
@@ -141,8 +153,8 @@ export class Credentials {
141
153
  return operation();
142
154
  }
143
155
  catch (error) {
144
- if (!this.#warned) {
145
- this.#warned = true;
156
+ if (!warnedRuns.has(this.#env)) {
157
+ warnedRuns.add(this.#env);
146
158
  this.#warn(`could not reach the OS keyring — it may be locked, or no keyring service is running ` +
147
159
  `(${error instanceof Error ? error.message : String(error)}); ` +
148
160
  `using ${this.#path()} instead, readable only by you`);
@@ -26,6 +26,8 @@ export interface Finding {
26
26
  /** `<clis> <what>`: `max+tg chats list --limit`, `zm upgrade`, `tg: chats grab` for a lint violation. */
27
27
  target: string;
28
28
  message: string;
29
+ /** What each CLI showed, for a finding about one flag or output; `absent` when it lacks the flag. */
30
+ values?: Record<string, string>;
29
31
  }
30
32
  export interface ScoreRow {
31
33
  cli: string;
package/dist/dev/audit.js CHANGED
@@ -74,6 +74,7 @@ const samePathContract = (inputs, groups) => {
74
74
  clis: having,
75
75
  target: `${named(having)} ${path}${what}`,
76
76
  message: describe(values),
77
+ values: Object.fromEntries(values),
77
78
  });
78
79
  };
79
80
  const kinds = new Map([...commands].map(([cli, command]) => [
@@ -253,18 +254,35 @@ export const auditClis = (inputs, options, rules = AUDIT_RULES) => {
253
254
  options.capabilities &&
254
255
  capabilityDifference(finding.target, finding.clis, inputs.map((input) => input.document), options.capabilities))
255
256
  .map((finding) => `${finding.rule} ${finding.target}`));
256
- const declared = new Map(options.groups.flatMap((group) => group.differences.map((entry) => [`${entry.rule} ${entry.target}`, group.name])));
257
+ // An `absent` difference names no CLI list: it stands for every finding where only that CLI lacks the flag.
258
+ const differences = options.groups.flatMap((group) => group.differences.flatMap((entry) => {
259
+ if (!entry.absent)
260
+ return [{ group: group.name, ...entry }];
261
+ const matching = findings.filter((finding) => {
262
+ if (finding.rule !== entry.rule || finding.target !== `${named(finding.clis)} ${entry.target}`)
263
+ return false;
264
+ const others = Object.entries(finding.values ?? {}).filter(([cli]) => cli !== entry.absent);
265
+ return (finding.values?.[entry.absent] === "absent" && new Set(others.map(([, value]) => value)).size === 1);
266
+ });
267
+ if (matching.length === 0 && clis.includes(entry.absent))
268
+ problems.push(`stale difference in ${group.name}, no such divergence — remove it: ${entry.rule} ${entry.target} (absent in ${entry.absent})`);
269
+ return matching.map((finding) => ({
270
+ ...entry,
271
+ group: group.name,
272
+ target: finding.target,
273
+ expected: finding.message,
274
+ }));
275
+ }));
276
+ const declared = new Map(differences.map((entry) => [`${entry.rule} ${entry.target}`, entry.group]));
257
277
  const found = new Set(findings.map((finding) => `${finding.rule} ${finding.target}`));
258
278
  for (const [key, group] of declared)
259
279
  if (!found.has(key))
260
280
  problems.push(`stale difference in ${group}, no such divergence — remove it: ${key}`);
261
- for (const group of options.groups) {
262
- for (const difference of group.differences) {
263
- const finding = findings.find((one) => one.rule === difference.rule && one.target === difference.target);
264
- if (finding && difference.expected !== finding.message) {
265
- declared.delete(`${difference.rule} ${difference.target}`);
266
- problems.push(`difference in ${group.name} must match the exact current values: ${difference.rule} ${difference.target}`);
267
- }
281
+ for (const difference of differences) {
282
+ const finding = findings.find((one) => one.rule === difference.rule && one.target === difference.target);
283
+ if (finding && difference.expected !== finding.message) {
284
+ declared.delete(`${difference.rule} ${difference.target}`);
285
+ problems.push(`difference in ${difference.group} must match the exact current values: ${difference.rule} ${difference.target}`);
268
286
  }
269
287
  }
270
288
  for (const group of options.groups)
package/dist/dev/main.js CHANGED
@@ -575,7 +575,8 @@ const auditRun = (args, io) => {
575
575
  group: v.string(),
576
576
  rule: v.string(),
577
577
  target: v.string(),
578
- expected: v.string(),
578
+ expected: v.optional(v.string()),
579
+ absent: v.optional(v.string()),
579
580
  reason: v.string(),
580
581
  }));
581
582
  const parsed = v.safeParse(schema, readJson(at(values.differences)));
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";
@@ -22,8 +22,11 @@ declare const parityGroupSchema: v.StrictObjectSchema<{
22
22
  /** Permanent differences, each with its reason; they count against the override budget of the group. */
23
23
  readonly differences: v.ArraySchema<v.StrictObjectSchema<{
24
24
  readonly rule: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a lower-case word, words joined by -">]>;
25
+ /** `<clis> <path> <flag>`; with `absent`, only `<path> <flag>`: the CLI list is whoever else has it. */
25
26
  readonly target: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>;
26
27
  readonly expected: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
28
+ /** The CLI that lacks the path or flag by design; the rest must agree, so other CLIs joining do not stale it. */
29
+ readonly absent: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a lower-case word, words joined by -">]>, undefined>;
27
30
  readonly reason: v.SchemaWithPipe<readonly [v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, v.MinLengthAction<string, 10, "say why, in a sentence">]>;
28
31
  }, undefined>, undefined>;
29
32
  }, undefined>;
@@ -51,14 +51,22 @@ const parityGroupSchema = v.strictObject({
51
51
  /** Permanent differences, each with its reason; they count against the override budget of the group. */
52
52
  differences: v.array(v.strictObject({
53
53
  rule: word,
54
+ /** `<clis> <path> <flag>`; with `absent`, only `<path> <flag>`: the CLI list is whoever else has it. */
54
55
  target: text,
55
56
  expected: v.optional(text),
57
+ /** The CLI that lacks the path or flag by design; the rest must agree, so other CLIs joining do not stale it. */
58
+ absent: v.optional(word),
56
59
  reason: v.pipe(text, v.minLength(10, "say why, in a sentence")),
57
60
  })),
58
61
  });
59
62
  export const defineParityGroups = (input) => parseRegistry("parity groups", v.array(parityGroupSchema), input, (groups) => [
60
63
  ...duplicates(groups.map((group) => group.name)).map((name) => `"${name}" is listed twice`),
61
- ...groups.flatMap((group) => duplicates(group.differences.map((entry) => `${entry.rule} ${entry.target}`)).map((both) => `${group.name}: "${both}" is listed twice`)),
64
+ ...groups.flatMap((group) => [
65
+ ...duplicates(group.differences.map((entry) => `${entry.rule} ${entry.target}`)).map((both) => `${group.name}: "${both}" is listed twice`),
66
+ ...group.differences
67
+ .filter((entry) => entry.absent && entry.expected)
68
+ .map((entry) => `${group.name}: "${entry.target}" has \`absent\`, which measures the values itself; drop \`expected\``),
69
+ ]),
62
70
  ]);
63
71
  export const PARITY_GROUPS = defineParityGroups([
64
72
  { name: "messengers", members: ["tg", "max"], covers: "tree", differences: [] },
@@ -66,7 +74,13 @@ export const PARITY_GROUPS = defineParityGroups([
66
74
  name: "all",
67
75
  members: ["tg", "max", "memo", "drive", "zm", "wa", "mail"],
68
76
  covers: ["commands", "doctor", "skill", "mcp", "upgrade", "search", "notes", "meetings"],
69
- differences: [],
77
+ differences: ["mcp serve", "mcp revoke", "mcp server start", "mcp server status", "mcp server stop"].map((path) => ({
78
+ rule: "same-path-contract",
79
+ target: `${path} --profiles`,
80
+ absent: "memo",
81
+ reason: "memo has no profiles: it reads other CLIs' stores and selects their accounts with --source and --account " +
82
+ "(https://github.com/WireCatLabs/cli-base/issues/390)",
83
+ })),
70
84
  },
71
85
  // gcal is compared on what it already shares with every CLI; doctor, mcp, search and skill wait until the published
72
86
  // CLIs agree there (https://github.com/WireCatLabs/cli-base/issues/390), then gcal moves back into "all".
@@ -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",
@@ -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 |
@@ -14,7 +14,9 @@ Rule ids on this page are `WC-ACCT-<n>`. How to read a rule, how ids work and wh
14
14
  ### Who follows these rules today
15
15
 
16
16
  cli-core resolves the profile for tg, max and wa. WC-ACCT-1 needs cli-core 0.319.0 or newer. memo has no messenger profile: its shared commands run without one, and they pick accounts with `--provider`
17
- and `--account` (WC-ACCT-7). zoom resolves its own profile word by the same rules (`zm <profile> …`, `ZM_PROFILE`,
17
+ and `--account` (WC-ACCT-7). memo never creates profiles of its own: it reads the stores of other CLIs and selects
18
+ their accounts with `--source email:<name>` or `--account telegram:1`, so it has no `--profiles` either
19
+ ([cli-base#390](https://github.com/WireCatLabs/cli-base/issues/390), owner decision 2026-10-31). zoom resolves its own profile word by the same rules (`zm <profile> …`, `ZM_PROFILE`,
18
20
  `ZM_PROFILE_LOCK`) from zoom-cli 0.35.0, with cli-core's `liftProfile` and `usableProfileName`. drive still has its own
19
21
  profile code: it registers its own profiles. That is a known gap, not an exception. gcal resolves the profile through
20
22
  cli-core's `run()`, like tg, max and wa. Which CLIs lack a sign-in command of WC-ACCT-12 is listed in that rule.
@@ -48,7 +50,8 @@ profile.
48
50
  - **Example:** `max work bot messages send …` uses the same first word as `max work chats list`. `tg --json chats list`
49
51
  still works, because `--json` is an option.
50
52
  - **Exceptions:** `mcp serve --profiles <names|all>` names the profiles one server serves ([WC-MCP-9](mcp.md#wc-mcp-9--a-server-serves-the-profiles-it-is-told-with-one-connection-for-each)). It is used in
51
- place of the first word, never with it.
53
+ place of the first word, never with it. memo has no profiles of its own, so it has neither the first word nor
54
+ `--profiles`; it selects the accounts of other CLIs with `--source` and `--account`.
52
55
  - **Applies to:** all CLIs. **Since:** cli-base 0.41.0. **Rulings:** NEED-84, NEED-294, NEED-1881.
53
56
 
54
57
  ### WC-ACCT-3 · A missing profile is an error that lists the choices
@@ -155,7 +155,11 @@ and lists the profiles served. Each call follows its own profile's permissions.
155
155
  - **Example:** `max work mcp serve`, `max mcp serve --profiles work,personal` or `max mcp serve --profiles all`.
156
156
  Under the last two, a call reads `max_read { "profile": "work", "command": "inbox", "arguments": {} }`.
157
157
  `max mcp serve` alone is refused, and its message lists `personal` and `work`.
158
- - **Exceptions:** the bot server needs no held connection: each Bot API call is plain HTTPS.
158
+ - **Exceptions:** the bot server needs no held connection: each Bot API call is plain HTTPS. memo has no profiles of
159
+ its own, so `--profiles` stays absent on its `mcp serve`, `mcp revoke` and `mcp server start`, `status` and `stop`;
160
+ it reads other CLIs' stores and selects their accounts with `--source email:<name>` or `--account telegram:1`
161
+ ([cli-base#390](https://github.com/WireCatLabs/cli-base/issues/390), owner decision 2026-10-31). The audit declares
162
+ this once, as a difference of memo, not of the other CLIs.
159
163
  - **Applies to:** messenger CLIs. **Since:** cli-base 0.41.0. **Rulings:** NEED-1881.
160
164
 
161
165
  ### WC-MCP-10 · A tool and its command give the same answer and the same error
@@ -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
@@ -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
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.
109
+ ### WC-REL-10 · A messenger CLI is released only after the owner says yes in the conversation
110
+
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
 
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-base",
3
- "version": "0.67.0",
3
+ "version": "0.68.1",
4
4
  "description": "The parts every command line tool needs: output modes, terminal rendering, error model, exit codes, credentials, clocks",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",