@wirecat/cli-core 0.564.0 → 0.565.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/calendar/busy.d.ts +10 -10
  3. package/dist/calendar/busy.js +9 -9
  4. package/dist/calendar/cli/answers.d.ts +7 -8
  5. package/dist/calendar/cli/answers.js +4 -5
  6. package/dist/calendar/cli/bodies.d.ts +33 -0
  7. package/dist/calendar/cli/bodies.js +52 -0
  8. package/dist/calendar/cli/files.d.ts +8 -0
  9. package/dist/calendar/cli/files.js +41 -0
  10. package/dist/calendar/cli/index.d.ts +58 -51
  11. package/dist/calendar/cli/index.js +311 -284
  12. package/dist/calendar/cli/remind.d.ts +2 -11
  13. package/dist/calendar/cli/remind.js +81 -81
  14. package/dist/calendar/cli/search-all.d.ts +3 -8
  15. package/dist/calendar/cli/search-all.js +47 -42
  16. package/dist/calendar/mcp/index.d.ts +13 -13
  17. package/dist/calendar/prepare.d.ts +1 -1
  18. package/dist/calendar/testing/answers.d.ts +27 -5
  19. package/dist/calendar/testing/answers.js +88 -17
  20. package/dist/calendar/testing/writer.d.ts +1 -0
  21. package/dist/calendar/testing/writer.js +6 -0
  22. package/dist/calendar/writer.d.ts +6 -1
  23. package/dist/calendar/writes.d.ts +3 -1
  24. package/dist/calendar/writes.js +42 -20
  25. package/dist/cli/config-command.js +1 -1
  26. package/dist/cli/config-issues.d.ts +3 -1
  27. package/dist/cli/config-issues.js +2 -2
  28. package/dist/cli/contract-evidence.d.ts +1 -1
  29. package/dist/cli/early-refusal.d.ts +2 -1
  30. package/dist/cli/early-refusal.js +4 -2
  31. package/dist/cli/entry.d.ts +6 -2
  32. package/dist/cli/entry.js +4 -1
  33. package/dist/cli/envelope.d.ts +2 -2
  34. package/dist/cli/index.d.ts +1 -0
  35. package/dist/cli/index.js +1 -0
  36. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  37. package/dist/cli/permission-keys.d.ts +3 -1
  38. package/dist/cli/permission-keys.js +2 -1
  39. package/dist/cli/preview.d.ts +12 -0
  40. package/dist/cli/preview.js +8 -1
  41. package/dist/cli/settings.d.ts +2 -0
  42. package/dist/cli/settings.js +8 -1
  43. package/dist/errors/reasons.js +14 -1
  44. package/dist/mcp/meeting-preparation.d.ts +1 -1
  45. package/dist/mcp/meeting-project-candidates.d.ts +1 -1
  46. package/dist/mcp/meeting-series-inventory.d.ts +1 -1
  47. package/dist/meetings/mcp/passage.d.ts +1 -1
  48. package/dist/meetings/mcp/project-evidence.d.ts +1 -1
  49. package/dist/meetings/mcp/search.d.ts +1 -1
  50. package/dist/services/metadata.d.ts +1 -1
  51. package/package.json +3 -3
@@ -1,6 +1,8 @@
1
+ import { Command } from "commander";
1
2
  import * as v from "valibot";
2
3
  import { answerEntitySchema } from "../../cli/answer-entity.js";
3
- import { snakeKeys } from "../../cli/envelope.js";
4
+ import { provide } from "../../cli/context.js";
5
+ import { addCalendarCommands } from "../cli/index.js";
4
6
  const commandAt = (root, argv) => {
5
7
  let at = root;
6
8
  for (const word of argv) {
@@ -11,28 +13,97 @@ const commandAt = (root, argv) => {
11
13
  }
12
14
  return at;
13
15
  };
14
- /**
15
- * Parses what a shared calendar command handed its host against the command's entity, as a host prints it: keys in
16
- * snake_case, a list's items one by one, a search's hits each with the search's `untrusted`, and no `next`. A `meta`
17
- * goes into the host's envelope, never into the item.
18
- */
19
- export const checkCalendarAnswer = (root, argv, value) => {
16
+ /** Each item of a printed calendar answer, parsed against the command's declared entity. */
17
+ export const checkCalendarAnswer = (root, argv, envelope) => {
20
18
  const command = commandAt(root, argv);
21
19
  const schema = answerEntitySchema(command);
22
20
  if (!schema)
23
21
  throw new Error(`${argv.join(" ")} declares no answer entity`);
24
- const { next: _next, meta: _meta, ...body } = snakeKeys(value);
25
- const marker = body.untrusted
26
- ?.filter((field) => field.startsWith("hits."))
27
- .map((field) => field.slice("hits.".length));
28
- const items = Array.isArray(body.items)
29
- ? body.items
30
- : Array.isArray(body.hits)
31
- ? body.hits.map((hit) => ({ ...hit, untrusted: marker }))
32
- : [body];
33
- for (const item of items) {
22
+ const body = envelope;
23
+ for (const item of body.items ?? [body.item]) {
34
24
  const parsed = v.safeParse(schema, item);
35
25
  if (!parsed.success)
36
26
  throw new Error(`${argv.join(" ")}: ${parsed.issues.map((issue) => `${v.getDotPath(issue) ?? ""} ${issue.message}`).join("; ")}`);
37
27
  }
38
28
  };
29
+ export const CALENDAR_TEST_APP = {
30
+ command: "gcal",
31
+ appName: "gcal-cli",
32
+ envPrefix: "GCAL",
33
+ description: "synthetic",
34
+ version: "1.0.0",
35
+ };
36
+ /**
37
+ * The shared calendar commands on a bare program over `run`, as an app mounts them, printing to captured streams. A
38
+ * test of the commands themselves; an app's own tests go through its `run()`.
39
+ */
40
+ export const calendarTestProgram = (run, options = {}) => {
41
+ const stderr = [];
42
+ const opened = [];
43
+ let data = "";
44
+ const build = (tty = false) => {
45
+ const root = new Command("gcal")
46
+ .exitOverride()
47
+ .configureOutput({ writeErr: () => undefined, writeOut: () => undefined })
48
+ .option("--json")
49
+ .option("--jsonl")
50
+ .option("--offline")
51
+ .option("--full")
52
+ .option("--timeout <duration>")
53
+ .option("--yes")
54
+ .option("--dry-run");
55
+ addCalendarCommands(root, {
56
+ app: CALENDAR_TEST_APP,
57
+ open: async (_command, access) => {
58
+ opened.push(access);
59
+ return run;
60
+ },
61
+ now: () => Date.now(),
62
+ timezone: "UTC",
63
+ // A help example names a file; no test writes into the working folder.
64
+ readFile: async () => "",
65
+ writeFile: async () => undefined,
66
+ ...options,
67
+ });
68
+ provide(root, {
69
+ streams: {
70
+ data: (text) => {
71
+ data += text;
72
+ },
73
+ diagnostic: (text) => {
74
+ stderr.push(text);
75
+ },
76
+ },
77
+ tty,
78
+ });
79
+ return root;
80
+ };
81
+ const root = build();
82
+ const cli = async (...args) => {
83
+ const fresh = build();
84
+ data = "";
85
+ const dashes = args.indexOf("--");
86
+ const argv = args.includes("--json")
87
+ ? args
88
+ : dashes < 0
89
+ ? [...args, "--json"]
90
+ : [...args.slice(0, dashes), "--json", ...args.slice(dashes)];
91
+ await fresh.parseAsync(argv, { from: "user" });
92
+ if (!data.trim())
93
+ throw new Error(`${args.join(" ")} printed nothing`);
94
+ const envelope = JSON.parse(data);
95
+ // A --dry-run preview answers what would happen, not the command's entity.
96
+ if (!args.includes("--dry-run"))
97
+ checkCalendarAnswer(fresh, args, envelope);
98
+ // The run's own facts differ every run; a test compares the rest.
99
+ const { item, meta, ...rest } = envelope;
100
+ const { run_id: _run, duration_ms: _duration, ...facts } = meta;
101
+ return { ...(item ?? {}), ...rest, meta: facts };
102
+ };
103
+ const text = async (...args) => {
104
+ data = "";
105
+ await build(true).parseAsync(args, { from: "user" });
106
+ return data;
107
+ };
108
+ return { root, cli, text, stderr, opened };
109
+ };
@@ -25,4 +25,5 @@ export interface FakeCalendarWriter extends CalendarWriter {
25
25
  }
26
26
  export declare const fakeCalendarWriter: (options?: {
27
27
  provider?: string;
28
+ now?: () => number;
28
29
  }) => FakeCalendarWriter;
@@ -1,7 +1,9 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { calendarConflict } from "../writer.js";
3
3
  export const fakeCalendarWriter = (options = {}) => {
4
+ const now = options.now ?? Date.now;
4
5
  const calendars = new Map();
6
+ const made = new Map();
5
7
  const calls = [];
6
8
  let version = 0;
7
9
  let lose = false;
@@ -89,8 +91,12 @@ export const fakeCalendarWriter = (options = {}) => {
89
91
  participants: [],
90
92
  });
91
93
  of(calendar.externalId).set(id, entry);
94
+ made.set(id, now());
92
95
  return answer(entry);
93
96
  },
97
+ async quickAdded(calendar, since) {
98
+ return [...of(calendar.externalId).values()].filter((one) => (made.get(one.externalId) ?? -1) >= since);
99
+ },
94
100
  /** Keeps one event per UID on a calendar: a UID sent again replaces the event under the same id. */
95
101
  async importEvent(calendar, event) {
96
102
  const held = [...of(calendar.externalId).values()].find((one) => one.icalUid === event.uid);
@@ -67,11 +67,16 @@ export interface CalendarWriter {
67
67
  }): Promise<EntryInput>;
68
68
  /**
69
69
  * Optional: the provider makes an event from one line of text, reading its date, time and place its own way. The
70
- * provider picks the id, so a retry after `outcome_unknown` may make a second event.
70
+ * provider picks the id, so a retry after `outcome_unknown` first asks `quickAdded`.
71
71
  */
72
72
  quickAdd?(calendar: StoredCalendar, text: string, options: {
73
73
  notify: boolean;
74
74
  }): Promise<EntryInput>;
75
+ /**
76
+ * With `quickAdd`: the events this account made on `calendar` at or after `since` (epoch ms), so a retry of a quick
77
+ * add whose answer was lost finds the event instead of making a second one.
78
+ */
79
+ quickAdded?(calendar: StoredCalendar, since: number): Promise<EntryInput[]>;
75
80
  /**
76
81
  * Optional: adds a private copy of an event read from an iCalendar file, under its own UID, as Google's
77
82
  * `events.import`. The provider mails nobody. Sending the same UID again goes to the provider again; what it does
@@ -86,10 +86,12 @@ export declare const createEvent: (context: WriteContext, calendarReference: str
86
86
  }>;
87
87
  /**
88
88
  * An event the provider makes from one line of text, as Google's quickAdd. Nobody is mailed unless `notify` says so:
89
- * the attendees are known only once the provider has read the text.
89
+ * the attendees are known only once the provider has read the text. The provider picks the event's id, so a retry
90
+ * with the `id` a lost attempt named first looks for the event that attempt made.
90
91
  */
91
92
  export declare const createEventFromText: (context: WriteContext, calendarReference: string, text: string, options?: {
92
93
  notify?: boolean;
94
+ id?: string;
93
95
  }) => Promise<{
94
96
  operation_id: string;
95
97
  event: string | null;
@@ -165,9 +165,38 @@ export const createEvent = async (context, calendarReference, fields, options =
165
165
  };
166
166
  /** The longest line `createEventFromText` sends; a quick add is one sentence, not a description. */
167
167
  const TEXT_MAX = 1000;
168
+ /** How far the provider's clock may run behind this computer's when a retry looks for what a lost quick add made. */
169
+ const CLOCK_SKEW_MS = 120_000;
170
+ const TIME_DIGITS = 9;
171
+ /**
172
+ * A quick add's id: `q`, which no hex create id has, then when it was sent, so a retry knows from when to look for the
173
+ * event it may have made.
174
+ */
175
+ const quickId = (at) => `q${at.toString(32).padStart(TIME_DIGITS, "0")}${newEventId()}`;
176
+ const QUICK_ID = new RegExp(`^q[a-v0-9]{${TIME_DIGITS}}[a-f0-9]{32}$`, "u");
177
+ const sentAt = (id) => Number.parseInt(id.slice(1, 1 + TIME_DIGITS), 32);
178
+ const wordsOf = (text) => text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
179
+ /**
180
+ * What a lost quick add made: an event this account made on the calendar since the attempt, whose title's every word
181
+ * is in the line. The provider builds the title from the line's own words.
182
+ */
183
+ const lostQuickAdd = async (context, calendar, line, id) => {
184
+ const quickAdded = context.writes.writer.quickAdded;
185
+ if (!quickAdded)
186
+ throw new CliError("validation_error", `this provider cannot find a lost quick add; leave out ${context.names.id}`, {
187
+ param: context.names.id,
188
+ });
189
+ const words = new Set(wordsOf(line));
190
+ const found = (await quickAdded.call(context.writes.writer, calendar, sentAt(id) - CLOCK_SKEW_MS)).filter((entry) => entry.status !== "cancelled" && wordsOf(entry.title ?? "").every((word) => words.has(word)));
191
+ if (found.length > 1)
192
+ throw new CliError("validation_error", `${found.length} events made since that attempt could be the one it added; look with events list ` +
193
+ "changed_at>=today and add it again only if none is", { reason: "event.quick-add-ambiguous", operation_id: id, retryable: false });
194
+ return found[0];
195
+ };
168
196
  /**
169
197
  * An event the provider makes from one line of text, as Google's quickAdd. Nobody is mailed unless `notify` says so:
170
- * the attendees are known only once the provider has read the text.
198
+ * the attendees are known only once the provider has read the text. The provider picks the event's id, so a retry
199
+ * with the `id` a lost attempt named first looks for the event that attempt made.
171
200
  */
172
201
  export const createEventFromText = async (context, calendarReference, text, options = {}) => {
173
202
  const quickAdd = context.writes.writer.quickAdd;
@@ -178,29 +207,22 @@ export const createEventFromText = async (context, calendarReference, text, opti
178
207
  throw new CliError("validation_error", "the text is empty: say what, when and where");
179
208
  if (line.length > TEXT_MAX || /[\r\n]/u.test(line))
180
209
  throw new CliError("validation_error", `the text is one line of at most ${TEXT_MAX} characters`);
210
+ if (options.id !== undefined && !QUICK_ID.test(options.id))
211
+ throw new CliError("validation_error", `${context.names.id} is the id a quick add's outcome_unknown named`, {
212
+ param: context.names.id,
213
+ });
181
214
  const calendar = writable(await calendarIn(context.store, context.accountId, calendarReference));
215
+ const lost = options.id === undefined ? undefined : await lostQuickAdd(context, calendar, line, options.id);
182
216
  const notify = options.notify ?? false;
183
- const guard = writeGuard(context, keysFor(context, "quick-add", notify));
184
- const id = newEventId();
217
+ const id = lost ? options.id : quickId(context.now);
185
218
  let entry;
186
- try {
187
- entry = await guardedWrite(guard, { chatId: null, kind: "edit", key: COMMAND_KEY["quick-add"], operationId: id, sendId: id, notify }, () => quickAdd.call(context.writes.writer, calendar, line, { notify }));
188
- }
189
- catch (error) {
190
- if (isCliError(error) && error.code === "outcome_unknown")
191
- throw new CliError("outcome_unknown", `${error.message} — the provider may have made the event, and a create from text cannot be retried safely: ` +
192
- "sync, then look with events list changed_at>=today before adding it again", { ...error.details, operation_id: id, retryable: false });
193
- throw error;
194
- }
195
- let saved;
196
- try {
197
- ;
198
- [saved] = await context.store.saveEntries(calendar.id, [entry], context.now);
199
- }
200
- catch (error) {
201
- throw new CliError(isCliError(error) ? error.code : "invalid_response", `the provider made event ${entry.externalId}, but the local store did not save it ` +
202
- `(${error instanceof Error ? error.message : String(error)}); sync to store it — do not create it again`, { ...(isCliError(error) ? error.details : {}), operation_id: id, id: entry.externalId, retryable: false });
219
+ if (lost)
220
+ entry = lost;
221
+ else {
222
+ const guard = writeGuard(context, keysFor(context, "quick-add", notify));
223
+ entry = await retryable(context, id, () => guardedWrite(guard, { chatId: null, kind: "edit", key: COMMAND_KEY["quick-add"], operationId: id, sendId: id, notify }, () => quickAdd.call(context.writes.writer, calendar, line, { notify })));
203
224
  }
225
+ const [saved] = await savedAfterCreate(context, id, () => context.store.saveEntries(calendar.id, [entry], context.now));
204
226
  return answerOf(id, saved, (entry.recurrence?.length ?? 0) > 0);
205
227
  };
206
228
  /** The most events one import sends: a file of more is refused before anything goes. */
@@ -56,7 +56,7 @@ export const configCommand = (app, config, { permissionKey = config.permissions,
56
56
  ...(declared ? { default: declared.default, description: declared.description } : {}),
57
57
  };
58
58
  }),
59
- store_settings: await storeSettings(env),
59
+ store_settings: (await storeSettings(env)).filter(({ setting }) => offersStoreSetting(app, setting)),
60
60
  }));
61
61
  for (const issue of [
62
62
  ...(settings.configIssues ?? []),
@@ -76,11 +76,13 @@ export declare const issueMessage: (issues: readonly ConfigIssue[], doctor?: str
76
76
  * `knownPermissions` (from `knownPermissionKeys`), each saved permission key outside it is an issue too, in every
77
77
  * section.
78
78
  */
79
- export declare const checkConfigText: <T extends v.GenericSchema>(text: string, { file, tool, schema, knownPermissions, }: {
79
+ export declare const checkConfigText: <T extends v.GenericSchema>(text: string, { file, tool, schema, knownPermissions, repair, }: {
80
80
  file: string;
81
81
  tool: string;
82
82
  schema: T;
83
83
  knownPermissions?: ReadonlySet<string>;
84
+ /** `false` for a CLI with its own `config` that has no `repair`: no fix then names one. */
85
+ repair?: boolean;
84
86
  }) => {
85
87
  issues: ConfigIssue[];
86
88
  config: v.InferOutput<T> | undefined;
@@ -317,12 +317,12 @@ export const issueMessage = (issues, doctor = "doctor") => {
317
317
  * `knownPermissions` (from `knownPermissionKeys`), each saved permission key outside it is an issue too, in every
318
318
  * section.
319
319
  */
320
- export const checkConfigText = (text, { file, tool, schema, knownPermissions, }) => {
320
+ export const checkConfigText = (text, { file, tool, schema, knownPermissions, repair = true, }) => {
321
321
  const parsed = parseConfigText(text, file);
322
322
  if ("issue" in parsed) {
323
323
  const issue = publicIssue(parsed.issue);
324
324
  // Offered only where it would work, so the desktop never shows a Repair button that fails.
325
- if (repairConfigText(text).state === "repaired")
325
+ if (repair && repairConfigText(text).state === "repaired")
326
326
  issue.fix = `${tool} config repair`;
327
327
  return { issues: [issue], config: undefined };
328
328
  }
@@ -15,7 +15,7 @@ export declare const contractEvidence: (root: Command, { fields, errors }?: Cont
15
15
  field_only: string[];
16
16
  reasons: readonly {
17
17
  reason: string;
18
- code: "authentication_error" | "cancelled" | "configuration_error" | "confirmation_required" | "internal_error" | "invalid_response" | "network_error" | "not_found" | "outcome_unknown" | "permission_error" | "provider_error" | "provider_unavailable" | "rate_limited" | "timeout" | "validation_error";
18
+ code: "authentication_error" | "cancelled" | "configuration_error" | "confirmation_required" | "conflict" | "internal_error" | "invalid_response" | "network_error" | "not_found" | "outcome_unknown" | "permission_error" | "provider_error" | "provider_unavailable" | "rate_limited" | "timeout" | "validation_error";
19
19
  message: string;
20
20
  details: string[];
21
21
  step?: string | undefined;
@@ -22,7 +22,8 @@ export declare const refuseWriteBeforeConnecting: (action: Command, { app, env,
22
22
  * A command of a CLI that declares its own keys (`commandPermissions`), refused by its key before anything connects:
23
23
  * `deny` stops it, `readonly` stops a write, and the read-only lock stops a write to the service. `ask` is left to
24
24
  * the write guard, which can put the question. A saved or `--permission` key that names no command stops every keyed
25
- * command; housekeeping still runs, so `config unset` can remove it.
25
+ * command; housekeeping still runs, so `config unset` can remove it. Under the lock a non-messenger CLI still previews
26
+ * a `--dry-run`, which sends nothing (WC-PERM-12); a messenger's preview stays refused (SEC-65).
26
27
  */
27
28
  export declare const refuseByCommandPermissions: (action: Command, permissions: CommandPermissions, { app, env, dryRun, resolve }: {
28
29
  app: AppIdentity;
@@ -79,7 +79,8 @@ export const refuseWriteBeforeConnecting = (action, { app, env, dryRun, resolve
79
79
  * A command of a CLI that declares its own keys (`commandPermissions`), refused by its key before anything connects:
80
80
  * `deny` stops it, `readonly` stops a write, and the read-only lock stops a write to the service. `ask` is left to
81
81
  * the write guard, which can put the question. A saved or `--permission` key that names no command stops every keyed
82
- * command; housekeeping still runs, so `config unset` can remove it.
82
+ * command; housekeeping still runs, so `config unset` can remove it. Under the lock a non-messenger CLI still previews
83
+ * a `--dry-run`, which sends nothing (WC-PERM-12); a messenger's preview stays refused (SEC-65).
83
84
  */
84
85
  export const refuseByCommandPermissions = (action, permissions, { app, env, dryRun, resolve }) => {
85
86
  const key = permissions.keyOf(commandPathOf(action));
@@ -87,7 +88,8 @@ export const refuseByCommandPermissions = (action, permissions, { app, env, dryR
87
88
  return;
88
89
  const { effects } = metaOf(action);
89
90
  const lock = readOnlyLockOf(app, env);
90
- if (lock && effects?.remote === "write")
91
+ const preview = dryRun && app.messenger === false;
92
+ if (lock && effects?.remote === "write" && !preview)
91
93
  throw readOnlyRefusal(lock, `run ${commandPathOf(action).join(" ")}`);
92
94
  const flags = action.optsWithGlobals();
93
95
  const overrides = environmentOf(action).agent?.overrides ?? [];
@@ -16,8 +16,12 @@ export interface EntryContext {
16
16
  atStart: (command: Command) => Promise<void>;
17
17
  /** A yes/no question on stderr; false without asking when there is no terminal, under CI, `--json`, `--jsonl` or `--no-input`. */
18
18
  ask: (question: string, command?: Command) => Promise<boolean>;
19
- /** Opens the URL in the system browser, or in `browser` when given; never waits for the browser to close. */
20
- openBrowser: (url: string, browser?: string) => Promise<void>;
19
+ /**
20
+ * Opens the URL in the system browser, or in `browser` when given; never waits for the browser to close. Does nothing
21
+ * where nobody can use a browser (WC-CMD-15): under CI, `--json`, `--jsonl`, `--no-input`, or with no terminal.
22
+ * The caller prints the URL either way.
23
+ */
24
+ openBrowser: (url: string, browser?: string, command?: Command) => Promise<void>;
21
25
  /** True once a reader closed the output (`| head`): nothing more reaches the terminal. */
22
26
  outputClosed: () => boolean;
23
27
  }
package/dist/cli/entry.js CHANGED
@@ -88,7 +88,10 @@ export const startCli = async (entry) => {
88
88
  });
89
89
  return /^(y|yes)$/i.test(answer.trim());
90
90
  },
91
- openBrowser: (url, browser) => openBrowser(url, browser, io),
91
+ openBrowser: async (url, browser, command) => {
92
+ if (!headless(command))
93
+ await openBrowser(url, browser, io);
94
+ },
92
95
  outputClosed: () => closed,
93
96
  };
94
97
  let code;
@@ -89,7 +89,7 @@ export declare const answerSchema: (item?: v.GenericSchema) => v.UnionSchema<[v.
89
89
  readonly accounts: v.OptionalSchema<v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>, undefined>;
90
90
  readonly complete: v.BooleanSchema<undefined>;
91
91
  readonly issues: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
92
- 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>;
92
+ 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", "conflict", "cancelled"], undefined>;
93
93
  readonly reason: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "dotted lower-case words, such as `page.next`">]>, undefined>;
94
94
  readonly source: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>;
95
95
  }, undefined>, undefined>, undefined>;
@@ -144,7 +144,7 @@ export declare const answerSchema: (item?: v.GenericSchema) => v.UnionSchema<[v.
144
144
  readonly accounts: v.OptionalSchema<v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>, undefined>;
145
145
  readonly complete: v.BooleanSchema<undefined>;
146
146
  readonly issues: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
147
- 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>;
147
+ 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", "conflict", "cancelled"], undefined>;
148
148
  readonly reason: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "dotted lower-case words, such as `page.next`">]>, undefined>;
149
149
  readonly source: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>;
150
150
  }, undefined>, undefined>, undefined>;
@@ -45,6 +45,7 @@ export { type AgentPolicy, type AgentTask, type EvaluationResult, evaluateAgent,
45
45
  export { answerEntity } from "./answer-entity.js";
46
46
  export { type AppDoctorOptions, appDoctorReport } from "./app-doctor-command.js";
47
47
  export type { AppMcpOptions } from "./app-mcp-command.js";
48
+ export { actorOf } from "./author.js";
48
49
  export type { ApiCommandInput, ApiCommands } from "./bot/api.js";
49
50
  export { apiFlagOf, apiJson, apiOptionKey, apiPlainJson, checkApiBody, checkApiParameter, parseApiJson, readApiBody, } from "./bot/api-input.js";
50
51
  export { prepareRpcApiBody, type RpcApiBody } from "./bot/api-rpc.js";
package/dist/cli/index.js CHANGED
@@ -39,6 +39,7 @@ export { liftOptionsAfterDashes } from "./after-dashes.js";
39
39
  export { evaluateAgent, } from "./agent-evaluation.js";
40
40
  export { answerEntity } from "./answer-entity.js";
41
41
  export { appDoctorReport } from "./app-doctor-command.js";
42
+ export { actorOf } from "./author.js";
42
43
  export { apiFlagOf, apiJson, apiOptionKey, apiPlainJson, checkApiBody, checkApiParameter, parseApiJson, readApiBody, } from "./bot/api-input.js";
43
44
  export { prepareRpcApiBody } from "./bot/api-rpc.js";
44
45
  export { botContext, botWords } from "./bot/context.js";
@@ -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;
6
7
  path: string;
7
8
  exists: boolean;
8
- error?: undefined;
9
9
  } | {
10
10
  path: string;
11
11
  exists: boolean;
@@ -14,12 +14,14 @@ export interface CommandPermissions {
14
14
  extra: readonly PermissionKey[];
15
15
  /** cli-core's built-in levels and the CLI's own; they only ever tighten. */
16
16
  defaults: Readonly<Record<PermissionKey, Level>>;
17
+ /** The other keys a command's real run checks, so its `--dry-run` preview shows each: gcal's `api.calendar.*`. */
18
+ alsoChecks?: (path: readonly string[]) => readonly PermissionKey[];
17
19
  }
18
20
  /** Either way of naming a program's keys: a messenger's per-command mapping, or a declared CLI's own. */
19
21
  export type PermissionKeySource = PermissionKeyOf | CommandPermissions;
20
22
  /** A command's own path is its key; the commands that look after the tool have none. */
21
23
  export declare const commandPathKey: (path: readonly string[]) => PermissionKey | null;
22
- export declare const commandPermissions: ({ keyOf, extra, defaults, }?: Partial<CommandPermissions>) => CommandPermissions;
24
+ export declare const commandPermissions: ({ keyOf, extra, defaults, alsoChecks, }?: Partial<CommandPermissions>) => CommandPermissions;
23
25
  /**
24
26
  * The keys `permissions` may name: every command of the program, every write key, and each of their
25
27
  * parents. A key outside it is a typo that would otherwise do nothing (BUG-137).
@@ -16,10 +16,11 @@ const HOUSEKEEPING = new Set([
16
16
  ]);
17
17
  /** A command's own path is its key; the commands that look after the tool have none. */
18
18
  export const commandPathKey = (path) => path[0] === undefined || HOUSEKEEPING.has(path[0]) ? null : path.join(".");
19
- export const commandPermissions = ({ keyOf = commandPathKey, extra = [], defaults = {}, } = {}) => ({
19
+ export const commandPermissions = ({ keyOf = commandPathKey, extra = [], defaults = {}, alsoChecks, } = {}) => ({
20
20
  keyOf,
21
21
  extra: [AUTHORITY_KEY, ...extra],
22
22
  defaults: { ...DEFAULT_PERMISSIONS, ...defaults },
23
+ ...(alsoChecks ? { alsoChecks } : {}),
23
24
  });
24
25
  const pathOf = (command) => {
25
26
  const words = [];
@@ -10,6 +10,10 @@ export declare const preview: (command: Command, settings: Pick<Settings, "permi
10
10
  permission?: {
11
11
  key: PermissionKey | null;
12
12
  level: string;
13
+ also?: {
14
+ key: PermissionKey;
15
+ level: string;
16
+ }[];
13
17
  } | undefined;
14
18
  validation: {
15
19
  arguments: string;
@@ -42,12 +46,20 @@ export declare const preview: (command: Command, settings: Pick<Settings, "permi
42
46
  export declare const previewBody: (command: Command, permission: {
43
47
  key: PermissionKey | null;
44
48
  level: string;
49
+ also?: {
50
+ key: PermissionKey;
51
+ level: string;
52
+ }[];
45
53
  } | undefined, messenger: boolean) => {
46
54
  preview: boolean;
47
55
  path: string[];
48
56
  permission?: {
49
57
  key: PermissionKey | null;
50
58
  level: string;
59
+ also?: {
60
+ key: PermissionKey;
61
+ level: string;
62
+ }[];
51
63
  } | undefined;
52
64
  validation: {
53
65
  arguments: string;
@@ -23,7 +23,14 @@ export const preview = (command, settings, tool, keys, messenger = true) => {
23
23
  (!declared && key !== null && key !== undefined && WRITE_KEYS.includes(key));
24
24
  if (level === "deny" || (writes && level === "readonly"))
25
25
  throw new CliError("permission_error", `this profile does not permit ${path.join(" ")}`, { permission: key });
26
- return previewBody(command, { key: key ?? null, level }, messenger);
26
+ const also = (declared?.alsoChecks?.(path) ?? []).map((one) => ({
27
+ key: one,
28
+ level: levelFor(permissions, one, declared?.defaults).level,
29
+ }));
30
+ const stopped = also.find((one) => one.level === "deny" || (writes && one.level === "readonly"));
31
+ if (stopped)
32
+ throw new CliError("permission_error", `this profile does not permit ${stopped.key}`, { permission: stopped.key });
33
+ return previewBody(command, { key: key ?? null, level, ...(also.length ? { also } : {}) }, messenger);
27
34
  };
28
35
  const TARGETS = new Set(["chat", "message", "person", "profile"]);
29
36
  /**
@@ -42,6 +42,8 @@ export interface SettingsExtension<T extends object = Record<never, never>> {
42
42
  * `--permission` then take any command path, which the program checks against its commands.
43
43
  */
44
44
  permissions?: CommandPermissions;
45
+ /** `false` for a CLI with its own `config` that has no `repair`: no config issue then names one. */
46
+ configRepair?: boolean;
45
47
  }
46
48
  export interface SettingsExtensionContext {
47
49
  flags: GlobalFlags;
@@ -102,6 +102,8 @@ export const MESSENGER_ONLY_SETTINGS = [
102
102
  "history_group_days",
103
103
  "history_group_members_max",
104
104
  "read_other_bots",
105
+ "speech_model",
106
+ "picture_search",
105
107
  ];
106
108
  /** One program, one version: whether to look for a newer one is not a per-profile matter. */
107
109
  const SHARED_DEFAULTS_ONLY = {
@@ -259,7 +261,12 @@ export const settingsFor = (app, extension = {}) => {
259
261
  return { config: { profiles: {} }, issues: [read.issue] };
260
262
  if (read.text === undefined)
261
263
  return { config: { profiles: {} }, issues: [] };
262
- const { config, issues } = checkConfigText(read.text, { file: path, tool: app.command, schema: readSchema });
264
+ const { config, issues } = checkConfigText(read.text, {
265
+ file: path,
266
+ tool: app.command,
267
+ schema: readSchema,
268
+ repair: extension.configRepair !== false,
269
+ });
263
270
  return { config: (config ?? { profiles: {} }), issues };
264
271
  };
265
272
  /**
@@ -31,6 +31,18 @@ const SIGN_IN = [
31
31
  ["oauth.not-saved", "configuration_error", "the sign-in cannot be saved or read where it is kept"],
32
32
  ["sign-in.client-file-open", "configuration_error", "other users of this computer can read the client file"],
33
33
  ["sign-in.client-file-invalid", "configuration_error", "the file is not a Desktop app OAuth client JSON"],
34
+ ["oauth.scope-missing", "permission_error", "the sign-in lacks a permission this command needs"],
35
+ ["sign-in.client-rejected", "configuration_error", "the provider rejected the OAuth client"],
36
+ ];
37
+ /** Reasons every CLI over a provider's API raises alike: gcal and drive today. */
38
+ const PROVIDER_API = [
39
+ ["offline.refused", "permission_error", "this command needs the provider; --offline was given"],
40
+ validation("api.unknown-method", "the provider has no method by this name"),
41
+ ["api.disabled", "configuration_error", "the provider's API is turned off for this OAuth project"],
42
+ ["provider.usage-limit", "rate_limited", "the account's usage limit at the provider is reached; wait, do not retry"],
43
+ ["file.not-found", "not_found", "the file named, or its folder, does not exist"],
44
+ validation("file.not-a-file", "the path names a folder or another thing that is not a file"),
45
+ ["file.denied", "permission_error", "the file cannot be opened: permission denied"],
34
46
  ];
35
47
  const OTHERS = [
36
48
  ["query.time-limit", "timeout", "the search took longer than its time limit; the query itself is valid"],
@@ -87,6 +99,7 @@ const OTHERS = [
87
99
  validation("event.repeat-split", "the series cannot be split at this occurrence, or was split there already"),
88
100
  validation("event.repeat-count", "a COUNT series is split only with a rule for its new part"),
89
101
  validation("event.unmovable", "the provider keeps this kind of event on its calendar"),
102
+ validation("event.quick-add-ambiguous", "more than one event could be the one a lost quick add made"),
90
103
  validation("event.text-with-fields", "--text takes the whole event from its line, so no field option goes with it"),
91
104
  validation("calendar.ambiguous", "two or more calendars have this name; name one by its id"),
92
105
  validation("export.too-many", "the query finds more events than one export writes"),
@@ -258,7 +271,7 @@ export const INPUT_FIELD_REASONS = defineReasons([
258
271
  export const REASONS = defineReasons([
259
272
  ...QUERY_REASONS,
260
273
  ...INPUT_FIELD_REASONS,
261
- ...[...QUERY, ...SIGN_IN, ...OTHERS].map(([reason, code, message]) => ({
274
+ ...[...QUERY, ...SIGN_IN, ...PROVIDER_API, ...OTHERS].map(([reason, code, message]) => ({
262
275
  reason,
263
276
  code,
264
277
  message,
@@ -28,7 +28,7 @@ export declare const callMeetingPreparationTool: (read: MeetingPreparationReader
28
28
  }[];
29
29
  structuredContent: {
30
30
  error: {
31
- code: "authentication_error" | "cancelled" | "configuration_error" | "confirmation_required" | "internal_error" | "invalid_response" | "network_error" | "not_found" | "outcome_unknown" | "permission_error" | "provider_error" | "provider_unavailable" | "rate_limited" | "timeout" | "validation_error";
31
+ code: "authentication_error" | "cancelled" | "configuration_error" | "confirmation_required" | "conflict" | "internal_error" | "invalid_response" | "network_error" | "not_found" | "outcome_unknown" | "permission_error" | "provider_error" | "provider_unavailable" | "rate_limited" | "timeout" | "validation_error";
32
32
  reason: string;
33
33
  message: string;
34
34
  param?: string | undefined;
@@ -23,7 +23,7 @@ export declare const callMeetingProjectCandidatesTool: (read: StoredMeetingProje
23
23
  }[];
24
24
  structuredContent: {
25
25
  error: {
26
- code: "authentication_error" | "cancelled" | "configuration_error" | "confirmation_required" | "internal_error" | "invalid_response" | "network_error" | "not_found" | "outcome_unknown" | "permission_error" | "provider_error" | "provider_unavailable" | "rate_limited" | "timeout" | "validation_error";
26
+ code: "authentication_error" | "cancelled" | "configuration_error" | "confirmation_required" | "conflict" | "internal_error" | "invalid_response" | "network_error" | "not_found" | "outcome_unknown" | "permission_error" | "provider_error" | "provider_unavailable" | "rate_limited" | "timeout" | "validation_error";
27
27
  reason: string;
28
28
  message: string;
29
29
  param?: string | undefined;
@@ -23,7 +23,7 @@ export declare const callMeetingSeriesInventoryTool: (read: MeetingSeriesInvento
23
23
  }[];
24
24
  structuredContent: {
25
25
  error: {
26
- code: "authentication_error" | "cancelled" | "configuration_error" | "confirmation_required" | "internal_error" | "invalid_response" | "network_error" | "not_found" | "outcome_unknown" | "permission_error" | "provider_error" | "provider_unavailable" | "rate_limited" | "timeout" | "validation_error";
26
+ code: "authentication_error" | "cancelled" | "configuration_error" | "confirmation_required" | "conflict" | "internal_error" | "invalid_response" | "network_error" | "not_found" | "outcome_unknown" | "permission_error" | "provider_error" | "provider_unavailable" | "rate_limited" | "timeout" | "validation_error";
27
27
  reason: string;
28
28
  message: string;
29
29
  param?: string | undefined;