@wirecat/cli-core 0.565.0 → 0.567.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
@@ -5,7 +5,65 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
- ## Unreleased
8
+ ## 0.567.0 — 05.11.2026
9
+
10
+ ### Added
11
+
12
+ - **`<tool> bot list` runs without a profile word**, as `account list` does; `<tool> <profile> bot list` still
13
+ works.
14
+ - **A scheduled agent may run other CLIs the host declares** (`ScheduleHost.agentRuns`, type `AgentCli`): memo's
15
+ mail triage runs `mail`. Claude gets `Bash(<cli>:*)` for each; codex may write each one's state, config and cache
16
+ folders; every run sets `<PREFIX>_READ_ONLY=1` and `<PREFIX>_SCHEDULED` for each, whatever the schedule file says.
17
+ `runSchedule` takes the same list as `agentRuns`.
18
+ - **`<PREFIX>_READ_ONLY` lets through one authority change, `rules add`, when the command is marked
19
+ `narrowsUnderLock`** (`LOCKED_AUTHORITY_COMMANDS`, `admittedUnderLock`). The command narrows itself under the lock;
20
+ every other command marked `grantsAuthority` is still refused, and MCP still needs `mcp.authority`.
21
+
22
+ ### Fixed
23
+
24
+ - **`account list` no longer lists a bot as an account that is not signed in.** A name that only the bot side
25
+ uses (the file's `bot` section, a bot's own files) and that has no personal section, recorded login or session is
26
+ left out; `bot list` has it. A profile that is an account and a bot too says `bot: true`, a new optional field of
27
+ `account_profile`. `mcp serve --profiles all` serves the same account profiles. `Settings` gains the optional
28
+ `accountProfiles` and `botProfiles`.
29
+ - **`config set` keeps a long id as the digits typed.** `config set --bot phone_number_id 100000000000001` was
30
+ parsed as a number and refused by a setting that takes text; when the parsed value is refused and the text as
31
+ typed passes, the text is kept, never through a JS number.
32
+
33
+ ### Changed — may break callers
34
+
35
+ - **A write on a stale task revision fails with `conflict`, exit 15, not `configuration_error`, exit 3.** The reason
36
+ stays `task.stale` with the task's current `revision` in `details`; only the code and the exit number change
37
+ (cli-base's new `conflict`). A caller that branched on `configuration_error` for it should branch on the reason or
38
+ the new code. `vocabulary.stale`, `tag-review.stale` and `meeting-project.stale` keep `configuration_error` for now.
39
+
40
+ ## 0.566.0 — 05.11.2026
41
+
42
+ ### Changed — may break callers
43
+
44
+ - **cli-core now needs `@wirecat/cli-base` 0.76.0 or later** (peer range `>=0.76.0 <0.78.0`), which holds
45
+ `loadConstants`.
46
+ - **`appDoctorCommand`'s `oauthClient` returns `oauthClientReport`'s shape, `{ client, source, reason? }`**, and a
47
+ throw there is reported as `{ client: null, source: null, reason }` (was `error`). drive's and gcal's callers
48
+ change with their bump.
49
+
50
+ ### Added
51
+
52
+ - **`@wirecat/cli-core/constants`: WireCat's sign-in apps ship in cli-core's `constants.json`.** `constants()` reads
53
+ cli-base's, cli-core's and the tool's `constants.json` once per process, merged, validated and frozen; a tool
54
+ passes its own layer and a schema built on `constantsSchema`. It holds WireCat's Google Desktop app
55
+ (`google.app.client_id`, `client_secret`) and Microsoft client id (`microsoft.app.client_id`), which drive and mail
56
+ kept in source and gcal expected from a file CI wrote. `googleApp({ env, prefix, clientFile, clientId,
57
+ configClientId })` and `microsoftApp(…)` pick the app in WC-SECR-25's order: `--client-file` or `--client-id`
58
+ (`flag`), `<PREFIX>_<PROVIDER>_CLIENT_ID` (`env`), the account's `<provider>.client_id` (`config`), WireCat's app
59
+ (`constants`); a `_SECRET` with no id is a `configuration_error`, reason `sign-in.secret-without-id`. `oauthApp`
60
+ does the same for a provider whose app is in a tool's layer, such as Zoom, and returns nothing when there is none.
61
+ `oauthClientReport(app, reason?)` is `doctor`'s `oauth_client`: `{ client: "wirecat" | "own" | null, source }`.
62
+ See [Constants](docs/dev/CONSTANTS.md).
63
+ - **A sign-in entry records where its app came from** (`clientSource` in the standard and drive entries, read back
64
+ as `client.source`), so `doctor` reports the app the account signed in with. The standard entry also keeps
65
+ WireCat's public secret, so a refresh still works after a release ships another default app; an owner's own
66
+ secret is still never written.
9
67
 
10
68
  ## 0.565.0 — 05.11.2026
11
69
 
package/constants.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "google": {
3
+ "app": {
4
+ "client_id": "_WAlQe_1Jamd97lrbGU1kcXOJ8TWiJSbdfyTOEEF6IqtlI9-L2lr8iTW7MmAqUn7XMpK1OXeRZNLCFlYULdV3C5HGV28xT2CL0QFEI3FC3oHZp-ak5TX9g",
5
+ "client_secret": "X6afcHNZ_v0d_N6-jCWHFQSeT2O-saKLY8_WY_W5aIlswI6q2EZ6GtF3IFhy6WjIHUzB"
6
+ }
7
+ },
8
+ "microsoft": {
9
+ "app": {
10
+ "client_id": "f7f05d3a-b948-4d50-b3ce-59c816ce69e9"
11
+ }
12
+ }
13
+ }
@@ -20,6 +20,8 @@ export interface Schedule {
20
20
  /** A holder whose mark gates the run: it starts only when mail is stored past that mark. */
21
21
  onNewMail?: string;
22
22
  }
23
+ /** Another CLI a scheduled agent may run by name, as the host declares it: `memo`'s agent runs `mail`. */
24
+ export type AgentCli = Pick<AppIdentity, "command" | "appName" | "envPrefix">;
23
25
  export interface LastRun {
24
26
  at: string;
25
27
  exitCode: number;
@@ -113,11 +115,13 @@ export declare const CREDENTIALS_UNREACHABLE = "credentials.unreachable";
113
115
  * keyring, so a run that fails for that reason is tried again, a minute then up to 15 minutes apart, for up to
114
116
  * 12 hours. A schedule that repeats every few minutes is left to its next tick; a run by hand fails at once.
115
117
  */
116
- export declare const runSchedule: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv, file: string, { now, timer, signal, killAfterMs, untilUnlocked, wait, }?: {
118
+ export declare const runSchedule: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv, file: string, { now, timer, signal, killAfterMs, untilUnlocked, wait, agentRuns, }?: {
117
119
  now?: () => Date;
118
120
  timer?: Timer;
119
121
  signal?: AbortSignal;
120
122
  killAfterMs?: number;
121
123
  untilUnlocked?: boolean;
122
124
  wait?: (ms: number, signal?: AbortSignal) => Promise<void>;
125
+ /** The other CLIs an agent may run; each runs under its own read-only lock and knows it runs from a schedule. */
126
+ agentRuns?: readonly AgentCli[];
123
127
  }) => Promise<LastRun>;
@@ -358,7 +358,7 @@ const pause = (ms, signal) => new Promise((resolve) => {
358
358
  * keyring, so a run that fails for that reason is tried again, a minute then up to 15 minutes apart, for up to
359
359
  * 12 hours. A schedule that repeats every few minutes is left to its next tick; a run by hand fails at once.
360
360
  */
361
- export const runSchedule = async (app, system, env, file, { now = () => new Date(), timer, signal, killAfterMs = KILL_AFTER_MS, untilUnlocked = false, wait = pause, } = {}) => {
361
+ export const runSchedule = async (app, system, env, file, { now = () => new Date(), timer, signal, killAfterMs = KILL_AFTER_MS, untilUnlocked = false, wait = pause, agentRuns = [], } = {}) => {
362
362
  const schedule = readSchedule(file);
363
363
  const folder = join(file, "..");
364
364
  const runs = join(folder, "runs");
@@ -366,12 +366,12 @@ export const runSchedule = async (app, system, env, file, { now = () => new Date
366
366
  const agent = schedule.kind === "agent";
367
367
  const retries = untilUnlocked && !("every" in schedule.when);
368
368
  const firstAt = now().getTime();
369
- let last = await attemptSchedule(app, system, env, schedule, folder, { now, signal, killAfterMs });
369
+ let last = await attemptSchedule(app, system, env, schedule, folder, { now, signal, killAfterMs, agentRuns });
370
370
  for (let delay = LOCKED_FIRST_WAIT_MS; retries && lockedOut(last) && !signal?.aborted && now().getTime() - firstAt + delay <= LOCKED_GIVE_UP_MS; delay = Math.min(delay * 2, LOCKED_LONGEST_WAIT_MS)) {
371
371
  await wait(delay, signal);
372
372
  if (signal?.aborted)
373
373
  break;
374
- last = await attemptSchedule(app, system, env, schedule, folder, { now, signal, killAfterMs });
374
+ last = await attemptSchedule(app, system, env, schedule, folder, { now, signal, killAfterMs, agentRuns });
375
375
  }
376
376
  const { exitCode, output } = last;
377
377
  const errors = agent ? output.replace(/\.md$/, ".log") : output;
@@ -399,7 +399,7 @@ export const runSchedule = async (app, system, env, file, { now = () => new Date
399
399
  return last;
400
400
  };
401
401
  /** One run of the schedule's command or agent: its output in `runs/`, its result in `last.json`. */
402
- const attemptSchedule = async (app, system, env, schedule, folder, { now, signal, killAfterMs }) => {
402
+ const attemptSchedule = async (app, system, env, schedule, folder, { now, signal, killAfterMs, agentRuns, }) => {
403
403
  const runs = join(folder, "runs");
404
404
  const stamp = now()
405
405
  .toISOString()
@@ -417,6 +417,12 @@ const attemptSchedule = async (app, system, env, schedule, folder, { now, signal
417
417
  [`${app.envPrefix}_SCHEDULED`]: schedule.name,
418
418
  // A run started under the lock stays under it, whatever the schedule file says.
419
419
  ...(agent || readOnlyLockOf(app, env) ? { [`${app.envPrefix}_READ_ONLY`]: "1" } : {}),
420
+ ...(agent
421
+ ? Object.fromEntries(agentRuns.flatMap((other) => [
422
+ [`${other.envPrefix}_READ_ONLY`, "1"],
423
+ [`${other.envPrefix}_SCHEDULED`, schedule.name],
424
+ ]))
425
+ : {}),
420
426
  };
421
427
  // The child's output is data, and an agent's is another program's: it is kept as it came. These lines say
422
428
  // only when it ran and how it ended.
@@ -1,14 +1,14 @@
1
1
  import { Command } from "commander";
2
+ import type { OAuthClientReport } from "../constants/index.js";
2
3
  import { type AppIdentity } from "../ports/app.js";
3
4
  import type { Configuration } from "./settings.js";
4
5
  export interface AppDoctorOptions {
5
6
  /**
6
- * Which OAuth client the CLI signs in with — WireCat's or one of the owner's own — read from the environment and
7
- * files only. A throw is reported as its message.
7
+ * The OAuth app a new sign-in would use (WC-SECR-25), read from the environment and files only, usually
8
+ * `oauthClientReport` of the tool's `googleApp`/`oauthApp`. A throw is reported as a null client with its message as
9
+ * the reason.
8
10
  */
9
- oauthClient?: (env: NodeJS.ProcessEnv) => {
10
- client: "wirecat" | "own";
11
- };
11
+ oauthClient?: (env: NodeJS.ProcessEnv) => OAuthClientReport;
12
12
  /** The npm package, so `--online` can say whether a newer version is published; without it that check is left out. */
13
13
  packageName?: string;
14
14
  /**
@@ -48,10 +48,7 @@ export declare const appDoctorReport: (app: AppIdentity, configuration: Configur
48
48
  path: string;
49
49
  exists: boolean;
50
50
  };
51
- oauth_client?: {
52
- client: "wirecat" | "own" | null;
53
- error?: string;
54
- } | undefined;
51
+ oauth_client?: OAuthClientReport | undefined;
55
52
  read_only: string | null;
56
53
  profile_lock: string | null;
57
54
  config: {
@@ -90,10 +87,7 @@ export declare const appDoctorReport: (app: AppIdentity, configuration: Configur
90
87
  path: string;
91
88
  exists: boolean;
92
89
  };
93
- oauth_client?: {
94
- client: "wirecat" | "own" | null;
95
- error?: string;
96
- } | undefined;
90
+ oauth_client?: OAuthClientReport | undefined;
97
91
  read_only: string | null;
98
92
  profile_lock: string | null;
99
93
  config: {
@@ -85,7 +85,7 @@ export const appDoctorReport = (app, configuration, env, command, options = {})
85
85
  oauth = options.oauthClient(env);
86
86
  }
87
87
  catch (error) {
88
- oauth = { client: null, error: failure(error) };
88
+ oauth = { client: null, source: null, reason: failure(error) };
89
89
  }
90
90
  const common = {
91
91
  version: app.version,
@@ -119,13 +119,16 @@ const meCommand = (bot) => annotate(new Command("me"), { effects: REMOTE_READ, e
119
119
  }
120
120
  });
121
121
  });
122
- const listCommand = (bot, tokenVariable) => withQuery(annotate(new Command("list"), { effects: LOCAL_READ, examples: botExamples(bot, "list", "list --check") }), BOT_PROFILES_QUERY)
122
+ const listCommand = (bot, tokenVariable) => withQuery(annotate(new Command("list"), {
123
+ effects: LOCAL_READ,
124
+ examples: [`${bot.app.command} bot list`, `${bot.app.command} bot list --check`],
125
+ }), BOT_PROFILES_QUERY)
123
126
  .description(`every name on this machine that has a bot token; --check asks ${bot.name ?? "the messenger"} which bot each is`)
124
127
  .summary("every bot profile on this machine; --check asks which bot each is")
125
128
  .option("--check", "ask the messenger who each bot is, with its token")
126
129
  .action(async function (words) {
127
130
  const wanted = botProfileName(words);
128
- const context = botContext(this, bot);
131
+ const context = botContext(this, bot, { needsProfile: false });
129
132
  const check = this.opts().check === true;
130
133
  if (check)
131
134
  online(context, this);
@@ -2,14 +2,14 @@ import type { Command } from "commander";
2
2
  import { SendJournal } from "../../sends/journal.js";
3
3
  import { type Level, type PermissionKey } from "../../sends/permissions.js";
4
4
  import { RecipientList } from "../../sends/recipients.js";
5
- import type { Settings } from "../settings.js";
5
+ import type { ResolveOptions, Settings } from "../settings.js";
6
6
  import type { BotAdapter, BotChatRef, BotConnectOptions, BotMessenger } from "./port.js";
7
7
  import { ChatRegistry } from "./registry.js";
8
8
  import { BotTokenStore } from "./token.js";
9
9
  /** How the owner types this bot — `max sales bot` — so a refusal names the command that fixes it. */
10
10
  export declare const botWords: (bot: BotMessenger, profile: string) => string;
11
11
  /** What a bot command needs, resolved once: the bot's settings, its token, its lists. */
12
- export declare const botContext: (command: Command, bot: BotMessenger) => {
12
+ export declare const botContext: (command: Command, bot: BotMessenger, resolving?: Pick<ResolveOptions, "needsProfile">) => {
13
13
  settings: Settings;
14
14
  renderer: import("@wirecat/cli-base").Renderer;
15
15
  format: import("@wirecat/cli-base").RenderFormat;
@@ -28,8 +28,8 @@ const refuseBotPermissions = (command, bot, settings) => {
28
28
  }
29
29
  };
30
30
  /** What a bot command needs, resolved once: the bot's settings, its token, its lists. */
31
- export const botContext = (command, bot) => {
32
- const base = baseContext(command, (flags, options) => bot.resolveSettings(flags, { ...options, kind: "bot" }));
31
+ export const botContext = (command, bot, resolving = {}) => {
32
+ const base = baseContext(command, (flags, options) => bot.resolveSettings(flags, { ...options, ...resolving, kind: "bot" }));
33
33
  const { settings, env, keyring } = base;
34
34
  const profile = settings.profile;
35
35
  const files = botFiles(bot.app, profile, env);
@@ -49,6 +49,16 @@ export declare const PROPOSE_COMMANDS: readonly ["messages acknowledge", "mail f
49
49
  /** Marks a command of the `propose` tier; the lock lets it through only when its path is in `PROPOSE_COMMANDS`. */
50
50
  export declare const proposesOnly: <T extends Command>(command: T) => T;
51
51
  export declare const isProposal: (command: Command) => boolean;
52
+ /**
53
+ * The authority changes `<PREFIX>_READ_ONLY` lets through, a closed list: `mail rules add`, which the owner lets the
54
+ * scheduled mail triage run. Such a command is marked `narrowsUnderLock` **and** has one of these paths, and under the
55
+ * lock it narrows itself: it only adds what the owner can list and remove, and replaces nothing. Adding a path widens
56
+ * what a read-only agent decides for later runs, so it needs its own review.
57
+ */
58
+ export declare const LOCKED_AUTHORITY_COMMANDS: readonly ["rules add"];
59
+ /** Marks an authority change that checks the lock itself; the lock lets it through only when its path is listed. */
60
+ export declare const narrowsUnderLock: <T extends Command>(command: T) => T;
61
+ export declare const admittedUnderLock: (command: Command) => boolean;
52
62
  /** A local edit remains an edit even when it first fetches metadata from the messenger. */
53
63
  export declare const changesStore: <T extends Command>(command: T) => T;
54
64
  /**
@@ -79,6 +79,25 @@ export const isProposal = (command) => {
79
79
  const path = pathOf(command).join(" ");
80
80
  return PROPOSE_COMMANDS.some((listed) => path === listed || path.endsWith(` ${listed}`));
81
81
  };
82
+ /**
83
+ * The authority changes `<PREFIX>_READ_ONLY` lets through, a closed list: `mail rules add`, which the owner lets the
84
+ * scheduled mail triage run. Such a command is marked `narrowsUnderLock` **and** has one of these paths, and under the
85
+ * lock it narrows itself: it only adds what the owner can list and remove, and replaces nothing. Adding a path widens
86
+ * what a read-only agent decides for later runs, so it needs its own review.
87
+ */
88
+ export const LOCKED_AUTHORITY_COMMANDS = ["rules add"];
89
+ const NARROWS = new WeakSet();
90
+ /** Marks an authority change that checks the lock itself; the lock lets it through only when its path is listed. */
91
+ export const narrowsUnderLock = (command) => {
92
+ NARROWS.add(command);
93
+ return command;
94
+ };
95
+ export const admittedUnderLock = (command) => {
96
+ if (!NARROWS.has(command))
97
+ return false;
98
+ const path = pathOf(command).join(" ");
99
+ return LOCKED_AUTHORITY_COMMANDS.some((listed) => path === listed || path.endsWith(` ${listed}`));
100
+ };
82
101
  const STORE_WRITES = new WeakSet();
83
102
  /** A local edit remains an edit even when it first fetches metadata from the messenger. */
84
103
  export const changesStore = (command) => {
@@ -1,4 +1,5 @@
1
1
  export { AI_ENTRIES, AI_SETTING_KEYS, type AISettings, resolveAISettings } from "../analysis/settings.js";
2
+ export type { AgentCli } from "../background/schedules.js";
2
3
  export { parseDuration } from "../domain/duration.js";
3
4
  export { openOf, PROFILE_PLACEHOLDER, type ProfilesFor, type RefOpen, type RefSummary, refLabel, } from "../domain/ref-summaries.js";
4
5
  export { botTemplate } from "../entities/list-results.js";
@@ -70,7 +71,7 @@ export { sharedCommandTemplate } from "./contract-templates.js";
70
71
  export { type Closeable, withDeadline } from "./deadline.js";
71
72
  export { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
72
73
  export { DISCOVERY_DESCRIPTIONS } from "./discovery-descriptions.js";
73
- export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isProposal, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, PROPOSE_COMMANDS, previewFlag, proposesOnly, REMOTE_READ, REMOTE_WRITE, reachesOtherService, readsOverMcp, readsOverMcpAt, refuseUnderReadOnly, storeChangeRefusal, writesFileOnlyWith, } from "./effects.js";
74
+ export { admittedUnderLock, changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isProposal, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, LOCKED_AUTHORITY_COMMANDS, logsInOrOut, NO_EFFECTS, narrowsUnderLock, ownerOnlyOption, PROPOSE_COMMANDS, previewFlag, proposesOnly, REMOTE_READ, REMOTE_WRITE, reachesOtherService, readsOverMcp, readsOverMcpAt, refuseUnderReadOnly, storeChangeRefusal, writesFileOnlyWith, } from "./effects.js";
74
75
  export { type CliEntry, type EntryContext, type EntryIo, openBrowser, startCli } from "./entry.js";
75
76
  export { type AnswerOutput, answerSchema, type Body, type ItemBody, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, type ListBody, type ListInput, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
76
77
  export { withAbort } from "./execution.js";
package/dist/cli/index.js CHANGED
@@ -62,7 +62,7 @@ export { sharedCommandTemplate } from "./contract-templates.js";
62
62
  export { withDeadline } from "./deadline.js";
63
63
  export { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
64
64
  export { DISCOVERY_DESCRIPTIONS } from "./discovery-descriptions.js";
65
- export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isProposal, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, PROPOSE_COMMANDS, previewFlag, proposesOnly, REMOTE_READ, REMOTE_WRITE, reachesOtherService, readsOverMcp, readsOverMcpAt, refuseUnderReadOnly, storeChangeRefusal, writesFileOnlyWith, } from "./effects.js";
65
+ export { admittedUnderLock, changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isProposal, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, LOCKED_AUTHORITY_COMMANDS, logsInOrOut, NO_EFFECTS, narrowsUnderLock, ownerOnlyOption, PROPOSE_COMMANDS, previewFlag, proposesOnly, REMOTE_READ, REMOTE_WRITE, reachesOtherService, readsOverMcp, readsOverMcpAt, refuseUnderReadOnly, storeChangeRefusal, writesFileOnlyWith, } from "./effects.js";
66
66
  export { openBrowser, startCli } from "./entry.js";
67
67
  export { answerSchema, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
68
68
  export { withAbort } from "./execution.js";
@@ -22,13 +22,18 @@ export const accountCommand = (messenger) => {
22
22
  .description("the logged-in account")
23
23
  .addCommand(withQuery(annotate(new Command("list"), { effects: REMOTE_READ, examples: [`${tool} account list`] }), ACCOUNT_PROFILES_QUERY)
24
24
  .summary("profiles on this computer and their signed-in accounts")
25
- .description("every profile on this computer, and the account each is logged in as; asks the messenger nothing")
25
+ .description("every account profile on this computer, and the account each is logged in as; a name that is only a bot " +
26
+ "is left out (`bot list` has it), one that is a bot too says `bot: true`; asks the messenger nothing")
26
27
  .action(async function (words) {
27
28
  const terms = queryTerms(words, ACCOUNT_PROFILES_QUERY, "account list");
28
29
  const context = messengerContext(this, messenger, { needsProfile: false });
29
30
  const { app, provider } = messenger;
30
31
  const current = context.settings.sources.profile === NONE_NAMED ? undefined : context.settings.profile;
31
- const profiles = [...new Set([...(current === undefined ? [] : [current]), ...context.settings.profilesHere])]
32
+ const { profilesHere, accountProfiles = profilesHere, botProfiles = [] } = context.settings;
33
+ const isAccount = (profile) => accountProfiles.includes(profile) || messenger.profileState?.(profile, context.env) === "signed-in";
34
+ const profiles = [
35
+ ...new Set([...(current === undefined ? [] : [current]), ...profilesHere.filter(isAccount)]),
36
+ ]
32
37
  .filter((profile) => terms.get("profile") === undefined || profile === terms.get("profile"))
33
38
  .filter((profile) => terms.get("current") === undefined ||
34
39
  (profile === current) === (terms.get("current")?.toLowerCase() === "true"))
@@ -48,6 +53,7 @@ export const accountCommand = (messenger) => {
48
53
  ...(messenger.profileState
49
54
  ? { signed_in: messenger.profileState(profile, context.env) !== "signed-out" }
50
55
  : {}),
56
+ ...(botProfiles.includes(profile) ? { bot: true } : {}),
51
57
  });
52
58
  }
53
59
  renderAnswer(this, context, wholeList("account list", items));
@@ -56,7 +56,10 @@ export const servedProfiles = (self, messenger, { settings, env }, given, words,
56
56
  if (lock)
57
57
  return { set: { option: lock, profiles: () => [lock], key: `_${lock}` } };
58
58
  if (names.length === 1 && names[0] === EVERY_PROFILE) {
59
- const every = () => messenger.resolveSettings(self.optsWithGlobals(), { env, needsProfile: false, readOnly: true }).profilesHere;
59
+ const every = () => {
60
+ const here = messenger.resolveSettings(self.optsWithGlobals(), { env, needsProfile: false, readOnly: true });
61
+ return here.accountProfiles ?? here.profilesHere;
62
+ };
60
63
  return { set: { option: EVERY_PROFILE, profiles: every, key: ALL_PROFILES } };
61
64
  }
62
65
  if (names.length === 0 || names.includes(EVERY_PROFILE))
@@ -3,9 +3,9 @@ import type { CacheDatabase } from "../../store/driver.js";
3
3
  import type { Messenger } from "./context.js";
4
4
  /** `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. */
5
5
  export declare const storeSummary: (env: NodeJS.ProcessEnv) => Promise<{
6
- error?: undefined;
7
6
  path: string;
8
7
  exists: boolean;
8
+ error?: undefined;
9
9
  } | {
10
10
  path: string;
11
11
  exists: boolean;
@@ -21,7 +21,7 @@ import { commandPathOf } from "./command-contract.js";
21
21
  import { entityRows } from "./compact.js";
22
22
  import { outputFor, provide } from "./context.js";
23
23
  import { refuseByCommandPermissions, refuseWriteBeforeConnecting } from "./early-refusal.js";
24
- import { changesAuthorityAt, changesAuthorityNow, isLogin, isStoreChange, judgedEffects, judgedTargets, storeChangeRefusal, } from "./effects.js";
24
+ import { admittedUnderLock, changesAuthorityAt, changesAuthorityNow, isLogin, isStoreChange, judgedEffects, judgedTargets, storeChangeRefusal, } from "./effects.js";
25
25
  import { itemOf, renderAnswer, snakeKeys } from "./envelope.js";
26
26
  import { byteCount, DEFAULT_AGENT_COOPERATIVE_MS, DEFAULT_COMMAND_MS, DEFAULT_COOPERATIVE_MS, DEFAULT_OUTPUT_BYTES, DEFAULT_SYNC_MS, execution, } from "./execution.js";
27
27
  import { setupAtStart } from "./first-run.js";
@@ -334,7 +334,7 @@ const runOnce = async (argv, definition, options) => {
334
334
  if (options.relayed && isLogin(action))
335
335
  throw new CliError("permission_error", `\`${command} ${path.join(" ")}\` logs the account in or out, which serve never runs for a client — stop ` +
336
336
  `serve first (\`${command} ${options.relayed.profile} server stop\`), or update ${command}, which then runs it here`, { sent: false });
337
- if (lock && changesAuthorityNow(action) && !flags.dryRun)
337
+ if (lock && changesAuthorityNow(action) && !admittedUnderLock(action) && !flags.dryRun)
338
338
  throw new CliError("permission_error", `this process is read-only (${lock}) — \`${command} ${path.join(" ")}\` changes what a later run may do; ` +
339
339
  `run it without ${lock}`);
340
340
  if (options.agent && options.agent.authority !== true && changesAuthorityNow(action))
@@ -1,4 +1,5 @@
1
1
  import { Command } from "commander";
2
+ import { type AgentCli } from "../../background/schedules.js";
2
3
  import { type AppIdentity } from "../../ports/app.js";
3
4
  import { type AnswerOutput } from "../envelope.js";
4
5
  export interface ScheduleHost {
@@ -14,10 +15,15 @@ export interface ScheduleHost {
14
15
  * the agent then runs only after a sync stored mail it has not processed.
15
16
  */
16
17
  newMail?: (command: Command, holder: string) => Promise<boolean>;
18
+ /**
19
+ * Other CLIs the scheduled agent may run besides this one, each under its own `<PREFIX>_READ_ONLY`: memo's mail
20
+ * triage runs `mail`. Claude may run them; codex may write their state, config and cache folders.
21
+ */
22
+ agentRuns?: readonly AgentCli[];
17
23
  }
18
24
  /**
19
25
  * Runs the CLI's own commands, or an agent, later or on a timer — through systemd (Linux), launchd
20
26
  * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
21
27
  * the rest, so what runs and with which variables is the same on all three.
22
28
  */
23
- export declare const scheduleCommandFor: ({ app, context: contextOf, newMail }: ScheduleHost) => Command;
29
+ export declare const scheduleCommandFor: ({ app, context: contextOf, newMail, agentRuns }: ScheduleHost) => Command;
@@ -81,7 +81,8 @@ const promptExample = (app) => app.messenger === false
81
81
  * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
82
82
  * the rest, so what runs and with which variables is the same on all three.
83
83
  */
84
- export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
84
+ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns = [] }) => {
85
+ const agentClis = [app, ...agentRuns];
85
86
  const command = new Command("schedule").description(`run a ${app.command} command or an agent later or on a timer (systemd, launchd, Task Scheduler); an agent cannot send`);
86
87
  const prepare = (self, resolving = {}) => {
87
88
  const context = contextOf(self, resolving);
@@ -154,7 +155,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
154
155
  `${app.command} personal schedule agent triage codex "list what needs an answer today" --every 2h --max-cost 1`,
155
156
  ],
156
157
  }))
157
- .description(`start an agent (claude or codex) with a prompt on a timer; it runs with ${app.envPrefix}_READ_ONLY=1, so it reads and drafts and cannot send — its answer is kept and you are notified`)
158
+ .description(`start an agent (claude or codex) with a prompt on a timer; it runs with ${agentClis.map((one) => `${one.envPrefix}_READ_ONLY=1`).join(" and ")}, so it reads and drafts and cannot send — its answer is kept and you are notified`)
158
159
  .argument("<name>", "lowercase letters, digits and dashes")
159
160
  .addArgument(new Argument("<harness>", "the agent to start").choices(HARNESSES))
160
161
  .argument("<prompt>", `what to do, e.g. "${promptExample(app)}"`)
@@ -166,7 +167,10 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
166
167
  const program = findProgram(harness, context.env, system.platform);
167
168
  if (!program)
168
169
  throw new CliError("not_found", `${harness} is not on PATH`);
169
- const paths = resolvePaths({ appName: app.appName, prefix: app.envPrefix, env: context.env });
170
+ const folders = agentClis.flatMap((one) => {
171
+ const paths = resolvePaths({ appName: one.appName, prefix: one.envPrefix, env: context.env });
172
+ return [paths.state, paths.config, paths.cache];
173
+ });
170
174
  const argv = harness === "claude"
171
175
  ? [
172
176
  program,
@@ -176,7 +180,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
176
180
  "--permission-mode",
177
181
  "dontAsk",
178
182
  "--allowedTools",
179
- `Bash(${app.command}:*)`,
183
+ ...agentClis.map((one) => `Bash(${one.command}:*)`),
180
184
  "--no-session-persistence",
181
185
  ...(options.maxCost ? ["--max-budget-usd", String(options.maxCost)] : []),
182
186
  ]
@@ -187,13 +191,10 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
187
191
  "--ephemeral",
188
192
  "--sandbox",
189
193
  "workspace-write",
190
- // The CLI reaches the messenger and writes its store and state, and nothing else.
194
+ // The CLIs reach their provider and write the store and their own state, and nothing else.
191
195
  "-c",
192
196
  "sandbox_workspace_write.network_access=true",
193
- ...[dirname(storePath(context.env)), paths.state, paths.config, paths.cache].flatMap((dir) => [
194
- "--add-dir",
195
- dir,
196
- ]),
197
+ ...[dirname(storePath(context.env)), ...folders].flatMap((dir) => ["--add-dir", dir]),
197
198
  prompt,
198
199
  ];
199
200
  await save({
@@ -263,6 +264,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
263
264
  return say({ name, skipped: true, holder }, [`${name}: skipped — no mail stored past ${holder}'s mark`]);
264
265
  const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, scheduleFile(state, scheduleName(name)), {
265
266
  timer: timer(),
267
+ agentRuns,
266
268
  ...(signal ? { signal } : {}),
267
269
  }));
268
270
  say(snakeKeys({ name, ...last }), [`${name}: exit ${last.exitCode} — ${last.output}`]);
@@ -297,6 +299,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
297
299
  return;
298
300
  const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, file, {
299
301
  timer: timer(),
302
+ agentRuns,
300
303
  untilUnlocked: true,
301
304
  ...(signal ? { signal } : {}),
302
305
  }));
@@ -156,8 +156,16 @@ export interface Settings extends AISettings {
156
156
  configFound: boolean;
157
157
  /** Profiles the configuration file names, whether or not anyone has logged in to them. */
158
158
  configuredProfiles: string[];
159
- /** Every profile on this computer — what `account list` shows and a missing profile's error lists. */
159
+ /** Every profile on this computer, bots included — what a missing profile's error lists. */
160
160
  profilesHere: string[];
161
+ /**
162
+ * The profiles here that are an account, not only a bot — what `account list` shows. A name only the file's bot
163
+ * section names, with no personal section, recorded login or messenger profile of its own, is a bot. Absent for an
164
+ * app that builds `Settings` itself; `profilesHere` stands in.
165
+ */
166
+ accountProfiles?: string[];
167
+ /** Every name with a bot: the file's bot section, or a bot's own files. `account list` marks these. */
168
+ botProfiles?: string[];
161
169
  /** Where each value came from — `config show` prints it. */
162
170
  sources: Record<string, Source>;
163
171
  /** This profile's entries and the shared defaults, for an adapter resolving its own fields with `first`. */
@@ -10,9 +10,10 @@ import { assertOverridesTighten, CEILING_KEY, COMMAND_PATH_KEY, LEVELS, layerPer
10
10
  import { DEFAULT_HISTORY_LIMITS, HISTORY_MAX_DAYS } from "../services/history-limits.js";
11
11
  import { storePath } from "../store/path.js";
12
12
  import { sharedConfigPath } from "../store/shared-config.js";
13
+ import { registryProfiles } from "./bot/registry.js";
13
14
  import { checkConfigText, issueMessage, readConfigFile, readConfigText, savedPermissionKeys, schemaIssues, settingPathOf, withoutPath, } from "./config-issues.js";
14
15
  import { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
15
- import { profilesHere } from "./messenger/accounts.js";
16
+ import { profilesHere, profilesWithAccounts } from "./messenger/accounts.js";
16
17
  import { usableProfileName } from "./profile.js";
17
18
  import { withSettingDefaults } from "./setting-defaults.js";
18
19
  import { permissionCeiling } from "./setup-answers.js";
@@ -298,6 +299,16 @@ export const settingsFor = (app, extension = {}) => {
298
299
  throw new CliError("validation_error", `name a profile: \`${app.command} <profile> …\` — profiles here: ${here.join(", ")} (or set ${PROFILE})`, { profiles: here });
299
300
  };
300
301
  const profilesOf = (config, env) => profilesHere(app, [...namedProfiles(config), ...(extension.profiles?.(env) ?? [])], env);
302
+ const botsOf = (config, env) => [...new Set([...Object.keys(config.bot?.profiles ?? {}), ...registryProfiles(app, env)])].sort();
303
+ // The shared `profiles` section is no sign of an account: a bot's owner may set something there for both sides.
304
+ const accountsOf = (config, env, bots) => {
305
+ const personal = new Set([
306
+ ...Object.keys(config.personal?.profiles ?? {}),
307
+ ...profilesWithAccounts(app, env),
308
+ ...(extension.profiles?.(env) ?? []),
309
+ ]);
310
+ return profilesOf(config, env).filter((name) => !bots.includes(name) || personal.has(name));
311
+ };
301
312
  const resolveSettings = (flags = {}, options = {}) => {
302
313
  const env = options.env ?? process.env;
303
314
  const configPath = configPathFor(options);
@@ -322,6 +333,7 @@ export const settingsFor = (app, extension = {}) => {
322
333
  ? unnamed(config, env, options.needsProfile !== false)
323
334
  : { value: named.value, from: named.from }, lock);
324
335
  const kind = options.kind ?? "personal";
336
+ const bots = botsOf(config, env);
325
337
  const name = usableProfileName(profile.value);
326
338
  const configured = config.profiles[name] ?? {};
327
339
  const shared = config.defaults ?? {};
@@ -429,6 +441,8 @@ export const settingsFor = (app, extension = {}) => {
429
441
  configFound: existsSync(configPath),
430
442
  configuredProfiles: namedProfiles(config),
431
443
  profilesHere: profilesOf(config, env),
444
+ accountProfiles: accountsOf(config, env, bots),
445
+ botProfiles: bots,
432
446
  sources: {
433
447
  ...ai.sources,
434
448
  profile: profile.from,
@@ -527,10 +541,22 @@ export const settingsFor = (app, extension = {}) => {
527
541
  if (kind !== "bot" && botOnlyKeys.includes(setting)) {
528
542
  throw new CliError("validation_error", `${setting} is a bot's setting — add --bot`);
529
543
  }
530
- const changed = structuredClone(raw);
531
- setAt(changed, at, level === undefined ? parseValue(value) : value.trim());
532
544
  const had = new Set(before.map((issue) => issue.at.join(".")));
533
- const added = schemaIssues(changed, readSchema, context(path)).find((issue) => !had.has(issue.at.join(".")) || issue.at.join(".") === at.join("."));
545
+ const tried = (typed) => {
546
+ const changed = structuredClone(raw);
547
+ setAt(changed, at, typed);
548
+ const added = schemaIssues(changed, readSchema, context(path)).find((issue) => !had.has(issue.at.join(".")) || issue.at.join(".") === at.join("."));
549
+ return { changed, added };
550
+ };
551
+ const parsed = level === undefined ? parseValue(value) : value.trim();
552
+ let { changed, added } = tried(parsed);
553
+ // `phone_number_id 100000000000001` parses as a number; a setting that takes text keeps the digits as typed,
554
+ // never through a number, which rounds any id past 2^53.
555
+ if (added && typeof parsed !== "string") {
556
+ const asText = tried(value.trim());
557
+ if (!asText.added)
558
+ ({ changed, added } = asText);
559
+ }
534
560
  if (added)
535
561
  throw new CliError("validation_error", `${setting} cannot be "${value}": ${added.what}`);
536
562
  const checked = v.safeParse(readSchema, changed);
@@ -0,0 +1,83 @@
1
+ import * as v from "valibot";
2
+ import type { AppSource, OAuthClient } from "../oauth/provider.js";
3
+ /** cli-core's layer, read after cli-base's and before the tool's. */
4
+ export declare const CORE_CONSTANTS: import("url").URL;
5
+ /** What cli-base's and cli-core's layers hold; a tool extends it with `v.object({ ...constantsSchema.entries, … })`. */
6
+ export declare const constantsSchema: v.ObjectSchema<{
7
+ readonly google: v.ObjectSchema<{
8
+ readonly app: v.ObjectSchema<{
9
+ readonly client_id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.NonEmptyAction<string, undefined>, v.TransformAction<string, string>]>;
10
+ readonly client_secret: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.TransformAction<string, string>]>, undefined>;
11
+ }, undefined>;
12
+ }, undefined>;
13
+ readonly microsoft: v.ObjectSchema<{
14
+ readonly app: v.ObjectSchema<{
15
+ readonly client_id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.NonEmptyAction<string, undefined>]>;
16
+ }, undefined>;
17
+ }, undefined>;
18
+ }, undefined>;
19
+ export type Constants = v.InferOutput<typeof constantsSchema>;
20
+ /**
21
+ * The packaged constants, base → core → the tool's `constants.json` when given, read once per process for each tool
22
+ * layer. A tool with constants of its own passes its layer and a schema built on `constantsSchema`; the first call for
23
+ * a layer decides its schema.
24
+ */
25
+ export declare const constants: <TSchema extends v.GenericSchema<unknown, Constants> = typeof constantsSchema>(toolLayer?: URL, schema?: TSchema) => v.InferOutput<TSchema>;
26
+ export interface AppOptions {
27
+ env: NodeJS.ProcessEnv;
28
+ /** The tool's environment prefix: `GCAL` with provider `google` reads `GCAL_GOOGLE_CLIENT_ID` and `_SECRET`. */
29
+ prefix: string;
30
+ /** `--client-id`. A secret for it comes from `<PREFIX>_<PROVIDER>_CLIENT_SECRET`. */
31
+ clientId?: string | undefined;
32
+ /** The account's config key `<provider>.client_id`. A config file never holds the secret: it comes from the env. */
33
+ configClientId?: string | undefined;
34
+ }
35
+ export interface OAuthAppOptions extends AppOptions {
36
+ /** `google`, `microsoft`, `zoom`: names the env variables, upper-cased. */
37
+ provider: string;
38
+ /** WireCat's app from the constants; an empty or missing id means WireCat has none for this provider. */
39
+ wirecat?: {
40
+ client_id?: string | undefined;
41
+ client_secret?: string | undefined;
42
+ } | undefined;
43
+ }
44
+ export interface OAuthApp {
45
+ client: OAuthClient & {
46
+ source: AppSource;
47
+ };
48
+ source: AppSource;
49
+ }
50
+ /**
51
+ * The app a sign-in uses, first match wins (WC-SECR-25): `--client-id`, `<PREFIX>_<PROVIDER>_CLIENT_ID`, the account's
52
+ * `<provider>.client_id`, WireCat's app. A secret without any of the first three ids is a `configuration_error`.
53
+ * Undefined when nothing names an app and WireCat has none.
54
+ */
55
+ export declare const oauthApp: (options: OAuthAppOptions) => OAuthApp | undefined;
56
+ export interface GoogleAppOptions extends AppOptions {
57
+ /** The owner's Desktop client JSON, `--client-file`; it must be private to the owner. */
58
+ clientFile?: string | undefined;
59
+ platform?: NodeJS.Platform;
60
+ /** The tool's own constants, when its layer overrides the app. */
61
+ constants?: Constants;
62
+ }
63
+ /**
64
+ * The Google Desktop app a sign-in uses: `--client-file`, then `oauthApp`'s order. Google treats a Desktop app's
65
+ * secret as public (https://developers.google.com/identity/protocols/oauth2/native-app), so WireCat's ships, obscured,
66
+ * in cli-core's constants.
67
+ */
68
+ export declare const googleApp: (options: GoogleAppOptions) => OAuthApp;
69
+ /** WireCat's Microsoft app is a public client: it has an id and no secret. */
70
+ export declare const microsoftApp: (options: AppOptions & {
71
+ constants?: Constants;
72
+ }) => OAuthApp;
73
+ export interface OAuthClientReport {
74
+ client: "wirecat" | "own" | null;
75
+ source: AppSource | null;
76
+ /** Why there is no app, when `client` is null. */
77
+ reason?: string;
78
+ }
79
+ /** `doctor`'s `oauth_client` for one provider: the app a new sign-in would use; doctor reads no keyring. */
80
+ export declare const oauthClientReport: (app: {
81
+ source?: AppSource | undefined;
82
+ } | undefined, reason?: string) => OAuthClientReport;
83
+ export type { AppSource };
@@ -0,0 +1,77 @@
1
+ import { BASE_CONSTANTS, baseConstantsSchema, CliError, loadConstants } from "@wirecat/cli-base";
2
+ import * as v from "valibot";
3
+ import { readClientFile } from "../oauth/client-file.js";
4
+ import { reveal } from "./obscure.js";
5
+ /** cli-core's layer, read after cli-base's and before the tool's. */
6
+ export const CORE_CONSTANTS = new URL("../../constants.json", import.meta.url);
7
+ /** What cli-base's and cli-core's layers hold; a tool extends it with `v.object({ ...constantsSchema.entries, … })`. */
8
+ export const constantsSchema = v.object({
9
+ ...baseConstantsSchema.entries,
10
+ google: v.object({
11
+ app: v.object({
12
+ client_id: v.pipe(v.string(), v.nonEmpty(), v.transform(reveal)),
13
+ client_secret: v.optional(v.pipe(v.string(), v.transform(reveal))),
14
+ }),
15
+ }),
16
+ microsoft: v.object({ app: v.object({ client_id: v.pipe(v.string(), v.nonEmpty()) }) }),
17
+ });
18
+ const loaded = new Map();
19
+ /**
20
+ * The packaged constants, base → core → the tool's `constants.json` when given, read once per process for each tool
21
+ * layer. A tool with constants of its own passes its layer and a schema built on `constantsSchema`; the first call for
22
+ * a layer decides its schema.
23
+ */
24
+ export const constants = (toolLayer, schema) => {
25
+ const key = toolLayer?.href ?? "";
26
+ if (!loaded.has(key)) {
27
+ const layers = toolLayer ? [BASE_CONSTANTS, CORE_CONSTANTS, toolLayer] : [BASE_CONSTANTS, CORE_CONSTANTS];
28
+ loaded.set(key, loadConstants(layers, schema ?? constantsSchema));
29
+ }
30
+ return loaded.get(key);
31
+ };
32
+ /**
33
+ * The app a sign-in uses, first match wins (WC-SECR-25): `--client-id`, `<PREFIX>_<PROVIDER>_CLIENT_ID`, the account's
34
+ * `<provider>.client_id`, WireCat's app. A secret without any of the first three ids is a `configuration_error`.
35
+ * Undefined when nothing names an app and WireCat has none.
36
+ */
37
+ export const oauthApp = (options) => {
38
+ const { env, prefix, provider } = options;
39
+ const name = `${prefix}_${provider.toUpperCase()}_CLIENT`;
40
+ const clientSecret = env[`${name}_SECRET`];
41
+ const own = [
42
+ [options.clientId, "flag"],
43
+ [env[`${name}_ID`], "env"],
44
+ [options.configClientId, "config"],
45
+ ];
46
+ const [clientId, source] = own.find(([id]) => id) ?? [];
47
+ if (clientId && source)
48
+ return app({ clientId, ...(clientSecret ? { clientSecret } : {}) }, source);
49
+ if (clientSecret)
50
+ throw new CliError("configuration_error", `${name}_SECRET is set without a client id; set ${name}_ID too, or unset it to use WireCat's app`, { reason: "sign-in.secret-without-id" });
51
+ const wirecat = options.wirecat;
52
+ if (!wirecat?.client_id)
53
+ return undefined;
54
+ return app({ clientId: wirecat.client_id, ...(wirecat.client_secret ? { clientSecret: wirecat.client_secret } : {}) }, "constants");
55
+ };
56
+ const app = (client, source) => ({ client: { ...client, source }, source });
57
+ /**
58
+ * The Google Desktop app a sign-in uses: `--client-file`, then `oauthApp`'s order. Google treats a Desktop app's
59
+ * secret as public (https://developers.google.com/identity/protocols/oauth2/native-app), so WireCat's ships, obscured,
60
+ * in cli-core's constants.
61
+ */
62
+ export const googleApp = (options) => {
63
+ const { clientFile, platform } = options;
64
+ if (clientFile)
65
+ return app(readClientFile(clientFile, platform ? { platform } : {}), "flag");
66
+ const wirecat = (options.constants ?? constants()).google.app;
67
+ return oauthApp({ ...options, provider: "google", wirecat });
68
+ };
69
+ /** WireCat's Microsoft app is a public client: it has an id and no secret. */
70
+ export const microsoftApp = (options) => {
71
+ const wirecat = (options.constants ?? constants()).microsoft.app;
72
+ return oauthApp({ ...options, provider: "microsoft", wirecat });
73
+ };
74
+ /** `doctor`'s `oauth_client` for one provider: the app a new sign-in would use; doctor reads no keyring. */
75
+ export const oauthClientReport = (app, reason) => app?.source
76
+ ? { client: app.source === "constants" ? "wirecat" : "own", source: app.source }
77
+ : { client: null, source: null, ...(reason ? { reason } : {}) };
@@ -0,0 +1 @@
1
+ export declare const reveal: (obscured: string) => string;
@@ -0,0 +1,9 @@
1
+ import { createDecipheriv } from "node:crypto";
2
+ // Not protection: anyone holding the package can reveal these. It keeps secret scanners and a casual read from
3
+ // treating WireCat's public Desktop app as a leaked credential, as rclone does for its own app.
4
+ const KEY = Buffer.from("d43012779aa2dded3e98092a3dbcf5d8980c564172f5ce0a5fd0574e5f740339", "hex");
5
+ export const reveal = (obscured) => {
6
+ const bytes = Buffer.from(obscured, "base64url");
7
+ const decipher = createDecipheriv("aes-256-ctr", KEY, bytes.subarray(0, 16));
8
+ return Buffer.concat([decipher.update(bytes.subarray(16)), decipher.final()]).toString("utf8");
9
+ };
@@ -6,4 +6,6 @@ export declare const accountProfile: v.StrictObjectSchema<{
6
6
  readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
7
7
  /** False for a profile with no session on this computer; absent when the messenger does not say. */
8
8
  readonly signed_in: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
9
+ /** True when the same name is a bot profile too (`<tool> <profile> bot …`); absent otherwise. */
10
+ readonly bot: v.OptionalSchema<v.LiteralSchema<true, undefined>, undefined>;
9
11
  }, undefined>;
@@ -7,4 +7,6 @@ export const accountProfile = v.strictObject({
7
7
  name: v.nullable(text),
8
8
  /** False for a profile with no session on this computer; absent when the messenger does not say. */
9
9
  signed_in: v.optional(v.boolean()),
10
+ /** True when the same name is a bot profile too (`<tool> <profile> bot …`); absent otherwise. */
11
+ bot: v.optional(v.literal(true)),
10
12
  });
@@ -3885,6 +3885,7 @@ export declare const ENTITIES: {
3885
3885
  readonly account: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
3886
3886
  readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
3887
3887
  readonly signed_in: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
3888
+ readonly bot: v.OptionalSchema<v.LiteralSchema<true, undefined>, undefined>;
3888
3889
  }, undefined>;
3889
3890
  readonly organization: v.StrictObjectSchema<{
3890
3891
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
@@ -33,6 +33,7 @@ const SIGN_IN = [
33
33
  ["sign-in.client-file-invalid", "configuration_error", "the file is not a Desktop app OAuth client JSON"],
34
34
  ["oauth.scope-missing", "permission_error", "the sign-in lacks a permission this command needs"],
35
35
  ["sign-in.client-rejected", "configuration_error", "the provider rejected the OAuth client"],
36
+ ["sign-in.secret-without-id", "configuration_error", "an OAuth client secret is set without its client id"],
36
37
  ];
37
38
  /** Reasons every CLI over a provider's API raises alike: gcal and drive today. */
38
39
  const PROVIDER_API = [
@@ -234,7 +235,7 @@ const OTHERS = [
234
235
  validation("task.category-unknown", "the category is not in the task category list"),
235
236
  validation("task.claimed", "someone else holds the task"),
236
237
  validation("task.closed", "the task is already closed"),
237
- ["task.stale", "configuration_error", "the task changed since it was read; read it again"],
238
+ ["task.stale", "conflict", "the task changed since it was read; read it again"],
238
239
  validation("task.column", "the task beside which it moves is in another column than the status asked for"),
239
240
  validation("task.dependency-cycle", "the task would depend on itself"),
240
241
  validation("task.is-subtask", "a subtask moves to another project alone only when it is detached from its parent in the same write"),
@@ -1,6 +1,6 @@
1
1
  export { type Authorization, type AuthorizeOptions, authorize } from "./authorize.js";
2
2
  export { type ClientFileOptions, readClientFile } from "./client-file.js";
3
3
  export { type SignInOptions, type TokenKeeper, type TokenKeeperOptions, type TokensOptions, tokenKeeper, } from "./keeper.js";
4
- export type { ExchangeContext, OAuthClient, OAuthProvider, RequestContext, TokenSet } from "./provider.js";
4
+ export type { AppSource, ExchangeContext, OAuthClient, OAuthProvider, RequestContext, TokenSet } from "./provider.js";
5
5
  export { requestTokens, revokeToken, TOKEN_LOG_ALLOW, TOKEN_LOG_EVENT } from "./requests.js";
6
6
  export { type CredentialStorageOptions, credentialStorage, driveSessionCodec, type EncodeInput, oauthAccount, type StoredTokens, standardCodec, type TokenCodec, type TokenStorage, zoomTokenCodec, } from "./storage.js";
@@ -3,7 +3,12 @@ import type { Logger } from "@wirecat/cli-base";
3
3
  export interface OAuthClient {
4
4
  clientId: string;
5
5
  clientSecret?: string;
6
+ /** Where the sign-in found the app (WC-SECR-25); saved with the entry, so `doctor` reports the app it used. */
7
+ source?: AppSource;
6
8
  }
9
+ /** `flag`: `--client-file` or `--client-id`; `env`: `<TOOL>_<PROVIDER>_CLIENT_ID`; `config`: the account's
10
+ * `<provider>.client_id`; `constants`: WireCat's app. */
11
+ export type AppSource = "flag" | "env" | "config" | "constants";
7
12
  export interface TokenSet {
8
13
  accessToken?: string;
9
14
  refreshToken: string;
@@ -40,8 +40,9 @@ export const standardCodec = (providerId) => ({
40
40
  throw invalid();
41
41
  if (!Array.isArray(value.scopes) || !value.scopes.every((scope) => typeof scope === "string"))
42
42
  throw invalid();
43
+ const source = text(value.clientSource);
43
44
  return {
44
- client: { clientId },
45
+ client: defined({ clientId, clientSecret: text(value.clientSecret), source }),
45
46
  tokens: defined({
46
47
  accessToken: text(value.accessToken),
47
48
  refreshToken,
@@ -57,6 +58,10 @@ export const standardCodec = (providerId) => ({
57
58
  version: 1,
58
59
  provider: providerId,
59
60
  clientId: client.clientId,
61
+ // Only WireCat's secret, which is public: a later release may ship another default app, and a refresh needs
62
+ // the secret of the app that signed in. An owner's own secret stays where the owner keeps it.
63
+ clientSecret: client.source === "constants" ? client.clientSecret : undefined,
64
+ clientSource: client.source,
60
65
  accessToken: tokens.accessToken,
61
66
  refreshToken: tokens.refreshToken,
62
67
  expiresAt: tokens.expiresAt,
@@ -80,7 +85,11 @@ export const driveSessionCodec = {
80
85
  if (!refreshToken || !clientId || typeof value.principal !== "string")
81
86
  throw invalid();
82
87
  return {
83
- client: defined({ clientId, clientSecret: text(value.clientSecret) }),
88
+ client: defined({
89
+ clientId,
90
+ clientSecret: text(value.clientSecret),
91
+ source: text(value.clientSource),
92
+ }),
84
93
  tokens: defined({
85
94
  accessToken: text(fields.access_token),
86
95
  refreshToken,
@@ -98,6 +107,7 @@ export const driveSessionCodec = {
98
107
  ...extra,
99
108
  clientId: client.clientId,
100
109
  ...(client.clientSecret ? { clientSecret: client.clientSecret } : {}),
110
+ ...(client.source ? { clientSource: client.source } : {}),
101
111
  tokens: defined({
102
112
  ...oldTokens,
103
113
  access_token: tokens.accessToken,
@@ -68,7 +68,7 @@ const timesAsIso = (changes) => Object.fromEntries(Object.entries(changes).map((
68
68
  export const checkRevision = (row, expected) => {
69
69
  if (expected === undefined || Number(row.revision) === expected)
70
70
  return;
71
- throw new CliError("configuration_error", `task ${String(row.key)} changed since revision ${expected}; it is at ${String(row.revision)}: read it again`, { reason: "task.stale", revision: Number(row.revision) });
71
+ throw new CliError("conflict", `task ${String(row.key)} changed since revision ${expected}; it is at ${String(row.revision)}: read it again`, { reason: "task.stale", revision: Number(row.revision) });
72
72
  };
73
73
  export const workTaskOperationsOver = (context) => {
74
74
  const { database, now } = context;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.565.0",
3
+ "version": "0.567.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
@@ -19,6 +19,7 @@
19
19
  "packageManager": "pnpm@11.28.8",
20
20
  "files": [
21
21
  "dist",
22
+ "constants.json",
22
23
  "parity.json",
23
24
  "skills",
24
25
  "README.md",
@@ -166,6 +167,10 @@
166
167
  "./oauth": {
167
168
  "types": "./dist/oauth/index.d.ts",
168
169
  "default": "./dist/oauth/index.js"
170
+ },
171
+ "./constants": {
172
+ "types": "./dist/constants/index.d.ts",
173
+ "default": "./dist/constants/index.js"
169
174
  }
170
175
  },
171
176
  "scripts": {
@@ -273,7 +278,7 @@
273
278
  },
274
279
  "peerDependencies": {
275
280
  "@napi-rs/canvas": ">=0.1.69 <2",
276
- "@wirecat/cli-base": ">=0.75.0 <0.78.0",
281
+ "@wirecat/cli-base": ">=0.76.0 <0.78.0",
277
282
  "commander": ">=15"
278
283
  },
279
284
  "bin": {