@wirecat/cli-core 0.564.0 → 0.566.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 (61) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/constants.json +13 -0
  3. package/dist/calendar/busy.d.ts +10 -10
  4. package/dist/calendar/busy.js +9 -9
  5. package/dist/calendar/cli/answers.d.ts +7 -8
  6. package/dist/calendar/cli/answers.js +4 -5
  7. package/dist/calendar/cli/bodies.d.ts +33 -0
  8. package/dist/calendar/cli/bodies.js +52 -0
  9. package/dist/calendar/cli/files.d.ts +8 -0
  10. package/dist/calendar/cli/files.js +41 -0
  11. package/dist/calendar/cli/index.d.ts +58 -51
  12. package/dist/calendar/cli/index.js +311 -284
  13. package/dist/calendar/cli/remind.d.ts +2 -11
  14. package/dist/calendar/cli/remind.js +81 -81
  15. package/dist/calendar/cli/search-all.d.ts +3 -8
  16. package/dist/calendar/cli/search-all.js +47 -42
  17. package/dist/calendar/mcp/index.d.ts +13 -13
  18. package/dist/calendar/prepare.d.ts +1 -1
  19. package/dist/calendar/testing/answers.d.ts +27 -5
  20. package/dist/calendar/testing/answers.js +88 -17
  21. package/dist/calendar/testing/writer.d.ts +1 -0
  22. package/dist/calendar/testing/writer.js +6 -0
  23. package/dist/calendar/writer.d.ts +6 -1
  24. package/dist/calendar/writes.d.ts +3 -1
  25. package/dist/calendar/writes.js +42 -20
  26. package/dist/cli/app-doctor-command.d.ts +7 -13
  27. package/dist/cli/app-doctor-command.js +1 -1
  28. package/dist/cli/config-command.js +1 -1
  29. package/dist/cli/config-issues.d.ts +3 -1
  30. package/dist/cli/config-issues.js +2 -2
  31. package/dist/cli/contract-evidence.d.ts +1 -1
  32. package/dist/cli/early-refusal.d.ts +2 -1
  33. package/dist/cli/early-refusal.js +4 -2
  34. package/dist/cli/entry.d.ts +6 -2
  35. package/dist/cli/entry.js +4 -1
  36. package/dist/cli/envelope.d.ts +2 -2
  37. package/dist/cli/index.d.ts +1 -0
  38. package/dist/cli/index.js +1 -0
  39. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  40. package/dist/cli/permission-keys.d.ts +3 -1
  41. package/dist/cli/permission-keys.js +2 -1
  42. package/dist/cli/preview.d.ts +12 -0
  43. package/dist/cli/preview.js +8 -1
  44. package/dist/cli/settings.d.ts +2 -0
  45. package/dist/cli/settings.js +8 -1
  46. package/dist/constants/index.d.ts +83 -0
  47. package/dist/constants/index.js +77 -0
  48. package/dist/constants/obscure.d.ts +1 -0
  49. package/dist/constants/obscure.js +9 -0
  50. package/dist/errors/reasons.js +15 -1
  51. package/dist/mcp/meeting-preparation.d.ts +1 -1
  52. package/dist/mcp/meeting-project-candidates.d.ts +1 -1
  53. package/dist/mcp/meeting-series-inventory.d.ts +1 -1
  54. package/dist/meetings/mcp/passage.d.ts +1 -1
  55. package/dist/meetings/mcp/project-evidence.d.ts +1 -1
  56. package/dist/meetings/mcp/search.d.ts +1 -1
  57. package/dist/oauth/index.d.ts +1 -1
  58. package/dist/oauth/provider.d.ts +5 -0
  59. package/dist/oauth/storage.js +12 -2
  60. package/dist/services/metadata.d.ts +1 -1
  61. package/package.json +8 -3
@@ -1,17 +1,19 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { annotate, sharedCommand } from "@wirecat/cli-base/commands";
2
+ import { annotate, metaOf, sharedCommand } from "@wirecat/cli-base/commands";
3
3
  import { timezoneOf } from "@wirecat/cli-base/query";
4
4
  import { clockMeta } from "@wirecat/cli-base/standards";
5
5
  import { Argument, InvalidArgumentError, Option } from "commander";
6
6
  import { environmentOf, outputFor, runBudgetOf } from "../../cli/context.js";
7
7
  import { honoursDryRun } from "../../cli/dry-run-guard.js";
8
8
  import { changesStore, ownerOnlyOption, writesFileOnlyWith } from "../../cli/effects.js";
9
+ import { renderAnswer } from "../../cli/envelope.js";
9
10
  import { queryTerms, withQuery } from "../../cli/list-query.js";
10
11
  import { terminalAsker } from "../../cli/messenger/ask.js";
11
12
  import { lineOf } from "../../cli/steps.js";
12
13
  import { secretInput } from "../../runs/run-context.js";
13
14
  import { searchEvents } from "../../services/events-search.js";
14
15
  import { dateWindow } from "../../services/moment.js";
16
+ import { embedEvents } from "../../services/notes-search.js";
15
17
  import { linkPeople } from "../../services/people.js";
16
18
  import { BUSY_QUERY, BUSY_QUERY_NOTE, busyAnswer, busyQuery, workingHoursOf } from "../busy.js";
17
19
  import { isFeedCalendar, isPlainHttp, syncWithFeeds } from "../feeds.js";
@@ -22,6 +24,8 @@ import { bothSearchAnswer, serverSearchAnswer } from "../server-search.js";
22
24
  import { calendarStatus } from "../sync.js";
23
25
  import { createEvent, createEventFromText, deleteEvent, EVENT_RESPONSES, IMPORT_MAX, importEvents, moveEvent, respondToEvent, updateEvent, } from "../writes.js";
24
26
  import { CALENDARS_QUERY, declareCalendarAnswers, PEOPLE_QUERY } from "./answers.js";
27
+ import { hitsBody, itemBody, listBody, messagesOf, pathOf } from "./bodies.js";
28
+ import { ICS_MAX_BYTES, readIcsFile, writeIcsFile } from "./files.js";
25
29
  import { localTimesView } from "./local-times.js";
26
30
  import { addRemind } from "./remind.js";
27
31
  import { addSearchAll } from "./search-all.js";
@@ -41,8 +45,6 @@ const REVIEW_UNTRUSTED = ["candidates.title"];
41
45
  const SEARCH_UNTRUSTED = ["hits.title", "hits.location", "hits.excerpt"];
42
46
  const SERVER_SEARCH_UNTRUSTED = ["hits.title", "hits.location"];
43
47
  const IMPORT_UNTRUSTED = ["imported.uid", "imported.entry.title", "skipped.uid", "skipped.reason"];
44
- /** The largest .ics file `events import` reads. */
45
- const ICS_MAX_BYTES = 10 * 1024 * 1024;
46
48
  const eachMarked = (answer, untrusted) => ({
47
49
  ...answer,
48
50
  items: answer.items.map((item) => ({ ...item, untrusted })),
@@ -64,47 +66,42 @@ const minutes = (text) => {
64
66
  throw new InvalidArgumentError("expected 5 to 1440 minutes");
65
67
  return value;
66
68
  };
67
- const addCalendarCommandsDefinition = (program, deps) => {
69
+ /** A search tops up its pending word index before it answers, which is a write; every other read only reads. */
70
+ const readsOnly = (command) => metaOf(command).effects?.local !== "write" && pathOf(command)[0] !== "search";
71
+ const addCalendarCommandsDefinition = (program, options) => {
68
72
  const mounted = new Set(program.commands);
69
- const now = () => deps.now?.() ?? Date.now();
70
- const zone = () => timezoneOf(deps.timezone);
71
- const print = (value, command) => {
72
- const opts = command.optsWithGlobals();
73
- deps.write(value, opts.json ? "json" : opts.jsonl ? "jsonl" : "text", (body) => localTimesView(body ?? value, zone(), outputFor(command).color));
73
+ const now = () => options.now?.() ?? Date.now();
74
+ const zone = () => timezoneOf(options.timezone);
75
+ const answer = (command, body, view) => {
76
+ const output = outputFor(command);
77
+ renderAnswer(command, output, body, view ?? ((shown) => localTimesView(shown, zone(), output.color)));
74
78
  };
75
- const messages = (what) => {
76
- if (!deps.messages)
77
- throw new CliError("configuration_error", `${what} needs the shared store`);
78
- return deps.messages;
79
- };
80
- const personOfEmail = deps.messages
81
- ? async (email) => {
82
- const person = await messages("a person").people.personOf({ provider: "email", id: email });
83
- return person ? `person:${person.uid}` : null;
79
+ /** Opens the profile for `work` and closes it before the answer prints, also when `work` fails. */
80
+ const within = async (command, work, view) => {
81
+ const run = await options.open(command, { readOnly: readsOnly(command) });
82
+ let body;
83
+ try {
84
+ body = await work(run);
84
85
  }
85
- : undefined;
86
- const personId = async (reference) => {
87
- const text = reference.replace(/^person:/u, "");
88
- if (/^[1-9]\d*$/u.test(text))
89
- return Number(text);
90
- const person = await messages("person:").people.personOf({ provider: "email", id: text.trim().toLowerCase() });
91
- return person ? Number(person.uid) : null;
92
- };
93
- const example = (command, ...lines) => annotate(command, { examples: lines.map((line) => `${program.name()} personal ${line}`) });
94
- const source = () => {
95
- if (!deps.source)
96
- throw new CliError("configuration_error", "the calendar provider is unavailable");
97
- return deps.source;
86
+ finally {
87
+ await run.close?.();
88
+ }
89
+ answer(command, body, view);
98
90
  };
91
+ const example = (command, ...lines) => annotate(command, { examples: lines.map((line) => `${options.app.command} personal ${line}`) });
99
92
  const calendars = annotate(program.command("calendars").description("the account's calendars"), { effects: GROUP });
100
93
  withQuery(example(annotate(calendars.command("list"), { effects: FETCH, offline: false }).description("list the account's calendars from the provider, and which of them sync"), "calendars list --json", "calendars list synced:true"), CALENDARS_QUERY).action(async (words, _opts, command) => {
101
94
  const terms = [...queryTerms(words, CALENDARS_QUERY, "calendars list")];
102
- const found = [...(await source().calendars()), ...((await deps.feeds?.source.calendars()) ?? [])];
103
- const listed = await deps.store.saveCalendars(deps.accountId, found, now());
104
- const items = listed
105
- .map(calendarItem)
106
- .filter((item) => terms.every(([field, value]) => String(item[field]) === value));
107
- print({ items, page: 1, limit: items.length, hasMore: false }, command);
95
+ await within(command, async (run) => {
96
+ // WC-CMD-20: the provider's list has no filter for these terms (Google's calendarList.list takes only a minimum
97
+ // access role, and synced is this store's own), and every calendar it answers is saved, so they narrow here.
98
+ const found = [...(await run.source().calendars()), ...((await run.feeds?.().source.calendars()) ?? [])];
99
+ const listed = await run.store.saveCalendars(run.accountId, found, now());
100
+ const items = listed
101
+ .map(calendarItem)
102
+ .filter((item) => terms.every(([field, value]) => String(item[field]) === value));
103
+ return listBody(command, { items, page: 1 });
104
+ });
108
105
  });
109
106
  for (const [name, synced] of [
110
107
  ["sync", true],
@@ -113,50 +110,58 @@ const addCalendarCommandsDefinition = (program, deps) => {
113
110
  example(annotate(calendars.command(name), { effects: LOCAL_WRITE })
114
111
  .description(synced ? "sync this calendar from now on" : "stop syncing this calendar; what is stored stays")
115
112
  .argument("<calendar>", "the calendar's id, the provider's id, or its title"), `calendars ${name} 3`).action(async (reference, _opts, command) => {
116
- const calendar = await calendarIn(deps.store, deps.accountId, reference);
117
- await deps.store.setCalendarSynced(calendar.id, synced, now());
118
- print(calendarItem({ ...calendar, synced }), command);
113
+ await within(command, async (run) => {
114
+ const calendar = await calendarIn(run.store, run.accountId, reference);
115
+ await run.store.setCalendarSynced(calendar.id, synced, now());
116
+ return itemBody(calendarItem({ ...calendar, synced }));
117
+ });
119
118
  });
120
- if (deps.feeds) {
121
- const feeds = () => deps.feeds;
119
+ if (options.feeds) {
120
+ const feedsOf = (run) => {
121
+ if (!run.feeds)
122
+ throw new CliError("configuration_error", "this profile follows no feeds");
123
+ return run.feeds();
124
+ };
122
125
  example(
123
126
  // A fetch, but also a new secret and a new address every later sync reads: the read-only lock refuses it.
124
127
  changesStore(annotate(calendars.command("add"), { effects: FETCH, offline: false, mcp: false }))
125
128
  .description("follow an iCalendar feed, such as a holiday calendar or a published .ics address, as a read-only calendar " +
126
- "of this profile; sync reads it again each time. The address is kept as a secret and never printed")
129
+ "of this profile that sync reads again each time; the address is kept as a secret and never printed")
127
130
  .summary("follow an iCalendar feed as a read-only calendar")
128
131
  .addArgument(secretInput(new Argument("<url>", "the feed's https://, http:// or webcal:// address")))
129
132
  .option("--title <title>", "the calendar's title; the feed's own name if not given"), "calendars add webcal://example.com/holidays.ics --title Holidays").action(async (url, opts, command) => {
130
133
  if (command.optsWithGlobals().offline)
131
134
  throw new CliError("permission_error", "calendars add reads the feed; leave out --offline");
132
- const { calendar, events } = await feeds().add(url, opts.title === undefined ? {} : { title: opts.title });
133
- if (isPlainHttp(url))
134
- outputFor(command).renderer.warn("this feed's address starts with http://, so the feed and any token in its address travel unencrypted; " +
135
- "use https:// if the server offers it");
136
- const [saved] = await deps.store.saveCalendars(deps.accountId, [calendar], now());
137
- print({
138
- ...calendarItem(saved),
139
- events,
140
- hint: `run ${lineOf(command, "sync")} to store its events`,
141
- }, command);
135
+ await within(command, async (run) => {
136
+ const { calendar, events } = await feedsOf(run).add(url, opts.title === undefined ? {} : { title: opts.title });
137
+ if (isPlainHttp(url))
138
+ outputFor(command).renderer.warn("this feed's address starts with http://, so the feed and any token in its address travel unencrypted; " +
139
+ "use https:// if the server offers it");
140
+ const [saved] = await run.store.saveCalendars(run.accountId, [calendar], now());
141
+ return itemBody({
142
+ ...calendarItem(saved),
143
+ events,
144
+ hint: `run ${lineOf(command, "sync")} to store its events`,
145
+ });
146
+ });
142
147
  });
143
148
  example(annotate(calendars.command("remove"), { effects: LOCAL_WRITE, mcp: false })
144
149
  .description("stop following a feed calendars add added: its address is forgotten and its stored events are deleted, " +
145
- "not cancelled. Only a feed; calendars unsync stops a provider's calendar")
150
+ "not cancelled; only a feed, as calendars unsync stops a provider's calendar")
146
151
  .argument("<calendar>", "the feed calendar's id, its ics: id, or its title"), "calendars remove 7").action(async (reference, _opts, command) => {
147
- const calendar = await calendarIn(deps.store, deps.accountId, reference);
148
- if (!isFeedCalendar(calendar))
149
- throw new CliError("validation_error", `calendar ${calendar.id} is not a feed; calendars remove takes only feeds`, {
150
- reason: "feed.not-a-feed",
151
- });
152
- await feeds().remove(calendar.externalId);
153
- const { entries, events } = await deps.store.forgetCalendar(calendar.id, now());
154
- print({ ...calendarItem({ ...calendar, synced: false }), removed: entries, events }, command);
152
+ await within(command, async (run) => {
153
+ const calendar = await calendarIn(run.store, run.accountId, reference);
154
+ if (!isFeedCalendar(calendar))
155
+ throw new CliError("validation_error", `calendar ${calendar.id} is not a feed; calendars remove takes only feeds`, { reason: "feed.not-a-feed" });
156
+ await feedsOf(run).remove(calendar.externalId);
157
+ const { entries, events } = await run.store.forgetCalendar(calendar.id, now());
158
+ return itemBody({ ...calendarItem({ ...calendar, synced: false }), removed: entries, events });
159
+ });
155
160
  });
156
161
  }
157
- if (deps.freeBusy)
162
+ if (options.freeBusy)
158
163
  example(withQuery(annotate(calendars.command("busy"), { effects: REMOTE_READ, offline: false, partial: true }).description("when calendars and people are taken, asked of the provider and not stored; with --duration-minutes, " +
159
- "the free stretches they share"), BUSY_QUERY).addHelpText("after", BUSY_QUERY_NOTE), "calendars busy person:ana@example.com --json", "calendars busy start>=2026-11-02 end<=2026-11-06 person:ana@example.com --duration-minutes 30 " +
164
+ "the free stretches they share"), BUSY_QUERY).addHelpText("after", BUSY_QUERY_NOTE), "calendars busy person:ana@example.com --json", "calendars busy starts_at>=2026-11-02 ends_at<=2026-11-06 person:ana@example.com --duration-minutes 30 " +
160
165
  "--hours 10:00-16:00")
161
166
  .option("--duration-minutes <minutes>", "find free stretches at least this long, 5–1440", minutes)
162
167
  .option("--hours <from-to>", "working hours in your time zone, Monday to Friday, with --duration-minutes", "09:00-17:00")
@@ -166,12 +171,15 @@ const addCalendarCommandsDefinition = (program, deps) => {
166
171
  reason: "busy.offline",
167
172
  });
168
173
  const hours = workingHoursOf(opts.hours);
169
- const query = await busyQuery(words, { store: deps.store, accountId: deps.accountId, zone: zone(), now: now() });
170
- print(await busyAnswer(source(), query, {
171
- zone: zone(),
172
- now: now(),
173
- ...(opts.durationMinutes === undefined ? {} : { slot: { minutes: opts.durationMinutes, hours } }),
174
- }), command);
174
+ await within(command, async (run) => {
175
+ const query = await busyQuery(words, { store: run.store, accountId: run.accountId, zone: zone(), now: now() });
176
+ const { meta, ...busy } = await busyAnswer(run.source(), query, {
177
+ zone: zone(),
178
+ now: now(),
179
+ ...(opts.durationMinutes === undefined ? {} : { slot: { minutes: opts.durationMinutes, hours } }),
180
+ });
181
+ return itemBody(busy, { meta });
182
+ });
175
183
  });
176
184
  example(annotate(program.command("sync"), {
177
185
  effects: FETCH,
@@ -188,133 +196,149 @@ const addCalendarCommandsDefinition = (program, deps) => {
188
196
  if (command.optsWithGlobals().full)
189
197
  throw new CliError("validation_error", "sync starts over with --rescan; --full only widens output");
190
198
  const { signal } = environmentOf(command);
191
- const report = await syncWithFeeds(source(), deps.feeds, deps.store, {
192
- accountId: deps.accountId,
193
- now: now(),
194
- budget: runBudgetOf(command, opts.maxPages),
195
- ...(signal ? { signal } : {}),
196
- ...(opts.rescan ? { full: true } : {}),
197
- ...(deps.expand ? { expand: deps.expand } : {}),
198
- });
199
- if (opts.embed && !deps.embed)
200
- throw new CliError("configuration_error", "this tool cannot embed events");
201
- // A run its budget stopped has no time left for the model; the run that finishes the round embeds.
202
- const embeddings = opts.embed && !report.stopped ? await deps.embed?.(EMBED_MAX) : undefined;
203
- const timeout = command.optsWithGlobals().timeout;
204
- // The same command again, never --rescan: that would drop the round this run leaves in progress. A run that moved
205
- // nothing on would only repeat itself, so it gets no step an agent may run unasked.
206
- const stuck = report.state === "partial" && report.progress.steps === 0 && !report.synced.some((one) => one.error);
207
- const next = report.state === "partial" && !stuck && !report.synced.some((one) => one.error !== undefined)
208
- ? [
209
- {
210
- kind: "command",
211
- step: "calendar.sync",
212
- command: "sync",
213
- args: Object.fromEntries(Object.entries({ max_pages: opts.maxPages, timeout, embed: opts.embed }).filter(([, value]) => value !== undefined)),
214
- why: "the sync stopped before its end to answer in time; running it again goes on from where it stopped",
215
- safe_to_auto_execute: true,
216
- },
217
- ]
218
- : undefined;
219
- const progressAt = lineOf(command, "store status");
220
- report.hint = stuck
221
- ? `this run moved nothing on: raise --timeout or --max-pages, then run sync again; see progress: ${progressAt}`
222
- : report.state === "partial"
223
- ? `run sync again to continue; see progress: ${progressAt}`
224
- : report.hint;
225
- if (report.hint === undefined)
226
- delete report.hint;
227
- const failed = report.synced.filter((one) => one.error !== undefined);
228
- if (failed.length)
229
- // A failure prints nothing on stdout (WC-OUT-14), so the error carries every calendar's result.
230
- throw new CliError("provider_error", `${failed.map((one) => `calendar ${one.calendarId} (${one.title ?? one.externalId}): ${one.error}`).join("; ")}` +
231
- " — run sync again to retry", {
199
+ await within(command, async (run) => {
200
+ if (opts.embed && !run.messages)
201
+ throw new CliError("configuration_error", "this tool cannot embed events");
202
+ const report = await syncWithFeeds(run.source(), run.feeds?.(), run.store, {
203
+ accountId: run.accountId,
204
+ now: now(),
205
+ budget: runBudgetOf(command, opts.maxPages),
206
+ ...(signal ? { signal } : {}),
207
+ ...(opts.rescan ? { full: true } : {}),
208
+ ...(run.expand ? { expand: run.expand } : {}),
209
+ });
210
+ // A run its budget stopped has no time left for the model; the run that finishes the round embeds.
211
+ const embeddings = opts.embed && !report.stopped && run.messages
212
+ ? await embedEvents(run.messages, {
213
+ maxChunks: EMBED_MAX,
214
+ ...(run.env ? { env: run.env } : {}),
215
+ command: options.app.command,
216
+ })
217
+ : undefined;
218
+ const failed = report.synced.filter((one) => one.error !== undefined);
219
+ const timeout = command.optsWithGlobals().timeout;
220
+ // The same command again, never --rescan: that would drop the round this run leaves in progress. A run that
221
+ // moved nothing on would only repeat itself, so it gets no step; a sync sends the provider no write, so
222
+ // running it again is safe.
223
+ const stuck = report.state === "partial" && report.progress.steps === 0 && failed.length === 0;
224
+ const why = failed.length
225
+ ? "a calendar failed to sync; running sync again retries it"
226
+ : "the sync stopped before its end to answer in time; running it again goes on from there";
227
+ const next = failed.length || (report.state === "partial" && !stuck)
228
+ ? [
229
+ {
230
+ kind: "command",
231
+ step: "calendar.sync",
232
+ command: "sync",
233
+ args: Object.fromEntries(Object.entries({ max_pages: opts.maxPages, timeout, embed: opts.embed }).filter(([, value]) => value !== undefined)),
234
+ why,
235
+ safe_to_auto_execute: true,
236
+ },
237
+ ]
238
+ : undefined;
239
+ const progressAt = lineOf(command, "store status");
240
+ report.hint = failed.length
241
+ ? `${failed.map((one) => `calendar ${one.calendarId} (${one.title ?? one.externalId}): ${one.error}`).join("; ")}` +
242
+ " — run sync again to retry"
243
+ : stuck
244
+ ? `this run moved nothing on: raise --timeout or --max-pages, then run sync again; see progress: ${progressAt}`
245
+ : report.state === "partial"
246
+ ? `run sync again to continue; see progress: ${progressAt}`
247
+ : report.hint;
248
+ if (report.hint === undefined)
249
+ delete report.hint;
250
+ // What the healthy calendars brought is saved and answered; each failed one is an issue (WC-OUT-7, WC-OUT-8).
251
+ const issues = failed.map((one) => ({
252
+ code: "provider_error",
232
253
  reason: "sync.partial",
233
- calendars: report.synced.map((one) => ({
234
- calendar_id: String(one.calendarId),
235
- external_id: one.externalId,
236
- title: one.title,
237
- round: one.round,
238
- state: one.state,
239
- saved: one.saved,
240
- removed: one.removed,
241
- occurrences: one.occurrences,
242
- ...(one.error === undefined ? {} : { error: one.error }),
243
- })),
244
- retryable: true,
245
- // A sync sends the provider no write, so running it again changes nothing there.
246
- sent: false,
254
+ source: `calendar:${one.calendarId}`,
255
+ }));
256
+ const partial = report.state === "partial" || issues.length > 0;
257
+ return itemBody({ ...report, ...(embeddings === undefined ? {} : { embeddings }) }, {
258
+ ...(partial ? { meta: { complete: false, ...(issues.length ? { issues } : {}) } } : {}),
259
+ ...(next ? { next } : {}),
247
260
  });
248
- print({ ...report, ...(embeddings === undefined ? {} : { embeddings }), ...(next ? { next } : {}) }, command);
261
+ });
249
262
  });
250
263
  const store = annotate(program.command("store").description("the local store of calendar events"), {
251
264
  effects: GROUP,
252
265
  });
253
266
  example(annotate(store.command("status"), { effects: LOCAL_READ }).description("how far each synced calendar's sync is: entries done, partial or complete, and when it last moved"), "store status --json").action(async (_opts, command) => {
254
- print(await calendarStatus(deps.store, deps.accountId, {
255
- profile: deps.profile ?? `account ${deps.accountId}`,
256
- now: now(),
257
- }), command);
267
+ await within(command, async (run) => listBody(command, await calendarStatus(run.store, run.accountId, { profile: run.profile, now: now() })));
258
268
  });
269
+ const personOfEmail = (run) => {
270
+ const shared = run.messages;
271
+ return shared
272
+ ? async (email) => {
273
+ const person = await shared.people.personOf({ provider: "email", id: email });
274
+ return person ? `person:${person.uid}` : null;
275
+ }
276
+ : undefined;
277
+ };
278
+ const personId = (run) => async (reference) => {
279
+ const text = reference.replace(/^person:/u, "");
280
+ if (/^[1-9]\d*$/u.test(text))
281
+ return Number(text);
282
+ const person = await messagesOf(run, "person:").people.personOf({
283
+ provider: "email",
284
+ id: text.trim().toLowerCase(),
285
+ });
286
+ return person ? Number(person.uid) : null;
287
+ };
259
288
  const events = annotate(program.command("events").description("the account's stored events"), { effects: GROUP });
260
289
  example(withQuery(annotate(events.command("list"), { effects: LOCAL_READ, partial: true }).description("list stored events, earliest first; --source reads several accounts as one list"), EVENTS_QUERY).addHelpText("after", EVENTS_QUERY_NOTE), "events list 'starts_at:[2026-11-02 TO 2026-11-07}' --json", "events list ends_at>=today person:ana@example.com", "events list overlapping:true --source all")
261
290
  .option("--limit <n>", "events per page, 1–1000", integer, 100)
262
291
  .option("--page <n>", "which page, starting at 1", integer, 1)
263
292
  .option("--source <accounts>", "calendar accounts held in the store to read as one list: all, a provider, or a list such as " +
264
- `${program.name()}:work,${program.name()}:personal or group:<name>; the profile's account when absent`)
293
+ `${options.app.command}:work,${options.app.command}:personal or group:<name>; the profile's account when absent`)
265
294
  .action(async (words, opts, command) => {
266
- const own = { id: deps.accountId, key: deps.account };
267
- const { accounts, skipped } = opts.source === undefined
268
- ? { accounts: [own], skipped: [] }
269
- : await sourceAccounts(messages("--source"), deps.account, opts.source, program.name());
270
- if (deps.locked && accounts.some(({ id }) => id !== own.id))
271
- throw new CliError("permission_error", "this process is locked to one profile; --source may name only its own account", { reason: "profile.locked" });
272
- const filter = await eventsFilter(words, {
273
- store: deps.store,
274
- accountId: deps.accountId,
275
- accountIds: accounts.map(({ id }) => id),
276
- zone: zone(),
277
- now: now(),
278
- personOf: personId,
295
+ await within(command, async (run) => {
296
+ const own = { id: run.accountId, key: run.account };
297
+ const { accounts, skipped } = opts.source === undefined
298
+ ? { accounts: [own], skipped: [] }
299
+ : await sourceAccounts(messagesOf(run, "--source"), run.account, opts.source, options.app.command);
300
+ if (run.locked && accounts.some(({ id }) => id !== own.id))
301
+ throw new CliError("permission_error", "this process is locked to one profile; --source may name only its own account", { reason: "profile.locked" });
302
+ const filter = await eventsFilter(words, {
303
+ store: run.store,
304
+ accountId: run.accountId,
305
+ accountIds: accounts.map(({ id }) => id),
306
+ zone: zone(),
307
+ now: now(),
308
+ personOf: personId(run),
309
+ });
310
+ return listBody(command, eachMarked(await eventsAnswer(run.store, filter && { ...filter, limit: opts.limit }, opts.page), EVENT_ITEM_UNTRUSTED), { ...(await accountsMeta(run.store, accounts, skipped, now())), ...clockMeta(now(), zone()) });
279
311
  });
280
- print({
281
- ...eachMarked(await eventsAnswer(deps.store, filter && { ...filter, limit: opts.limit }, opts.page), EVENT_ITEM_UNTRUSTED),
282
- meta: { ...(await accountsMeta(deps.store, accounts, skipped, now())), ...clockMeta(now(), zone()) },
283
- }, command);
284
312
  });
285
- const exporter = example(withQuery(annotate(events.command("export"), { effects: deps.writeFile ? LOCAL_WRITE : LOCAL_READ }).description(`write stored events as iCalendar (.ics), at most ${EXPORT_MAX}: one with id:<id>, or those the query ` +
286
- "finds; each occurrence of a repeating event as an event of its own"), EVENTS_QUERY).addHelpText("after", EVENTS_QUERY_NOTE), ...(deps.writeFile
287
- ? [
288
- "events export id:42 --output planning.ics",
289
- "events export 'starts_at:[2026-11-02 TO 2026-11-09}' NOT event_type:birthday --output week.ics",
290
- ]
291
- : ["events export id:42", "events export 'starts_at:[2026-11-02 TO 2026-11-09}' NOT event_type:birthday"]));
313
+ const exporter = example(withQuery(annotate(events.command("export"), { effects: LOCAL_WRITE }).description(`write stored events as iCalendar (.ics), at most ${EXPORT_MAX}: one with id:<id>, or those the query ` +
314
+ "finds; each occurrence of a repeating event as an event of its own"), EVENTS_QUERY).addHelpText("after", EVENTS_QUERY_NOTE), "events export id:42 --output planning.ics", "events export 'starts_at:[2026-11-02 TO 2026-11-09}' NOT event_type:birthday --output week.ics");
292
315
  // Not over MCP: an agent would choose where on the owner's disk to write. It gets the text in the answer.
293
- if (deps.writeFile)
294
- writesFileOnlyWith(exporter.addOption(ownerOnlyOption("--output <file>", "the file to write; printed in the answer when absent")), "output");
316
+ writesFileOnlyWith(exporter.addOption(ownerOnlyOption("--output <file>", "the file to write; printed in the answer when absent")), "output");
295
317
  exporter.action(async (words, opts, command) => {
296
- const filter = await eventsFilter(words, {
297
- store: deps.store,
298
- accountId: deps.accountId,
299
- zone: zone(),
300
- now: now(),
301
- personOf: personId,
302
- });
303
- const exported = await eventsIcs(deps.store, deps.accountId, filter, { now: now(), product: program.name() });
304
- if (opts.output === undefined) {
305
- print({ ...exported, untrusted: ["content"] }, command);
306
- return;
307
- }
308
- if (!opts.output.trim())
318
+ if (opts.output !== undefined && !opts.output.trim())
309
319
  throw new CliError("validation_error", "--output names no file");
310
- await deps.writeFile?.(opts.output, exported.content);
311
- print({ format: exported.format, events: exported.events, output: opts.output }, command);
320
+ await within(command, async (run) => {
321
+ const filter = await eventsFilter(words, {
322
+ store: run.store,
323
+ accountId: run.accountId,
324
+ zone: zone(),
325
+ now: now(),
326
+ personOf: personId(run),
327
+ });
328
+ const exported = await eventsIcs(run.store, run.accountId, filter, { now: now(), product: options.app.command });
329
+ if (opts.output === undefined)
330
+ return itemBody({ ...exported, untrusted: ["content"] });
331
+ await (options.writeFile ?? writeIcsFile)(opts.output, exported.content);
332
+ return itemBody({ format: exported.format, events: exported.events, output: opts.output });
333
+ });
312
334
  });
313
335
  example(annotate(events.command("show"), { effects: LOCAL_READ })
314
336
  .description("one event: its calendar copies, attendees with their person, Drive files and history")
315
337
  .argument("<event>", "the event's id, or event:<id>"), "events show event:42 --json").action(async (reference, _opts, command) => {
316
- const shown = await eventAnswer(await eventOf(deps.store, deps.accountId, eventIdOf(reference)), personOfEmail);
317
- print({ ...shown, untrusted: CALENDAR_UNTRUSTED_FIELDS }, command);
338
+ await within(command, async (run) => {
339
+ const shown = await eventAnswer(await eventOf(run.store, run.accountId, eventIdOf(reference)), personOfEmail(run));
340
+ return itemBody({ ...shown, untrusted: CALENDAR_UNTRUSTED_FIELDS });
341
+ });
318
342
  });
319
343
  example(annotate(events.command("prepare"), { effects: LOCAL_READ, partial: true })
320
344
  .description("before a meeting: the event, its attendees with their answer, and each one's latest messages, mail and " +
@@ -329,60 +353,48 @@ const addCalendarCommandsDefinition = (program, deps) => {
329
353
  zone: zone(),
330
354
  now: now(),
331
355
  });
332
- const details = reference === undefined
333
- ? await nextMeeting(deps.store, deps.accountId, now())
334
- : await eventOf(deps.store, deps.accountId, eventIdOf(reference));
335
- const { meta, ...prepared } = await eventPreparation(details, {
336
- store: deps.store,
337
- messages: messages("events prepare"),
338
- account: { id: deps.accountId, key: deps.account },
339
- personOf: personOfEmail ?? (async () => null),
340
- now: now(),
341
- ...(opts.limit === undefined ? {} : { limit: opts.limit }),
342
- ...(window.since === undefined ? {} : { since: window.since }),
356
+ await within(command, async (run) => {
357
+ const details = reference === undefined
358
+ ? await nextMeeting(run.store, run.accountId, now())
359
+ : await eventOf(run.store, run.accountId, eventIdOf(reference));
360
+ const { meta, ...prepared } = await eventPreparation(details, {
361
+ store: run.store,
362
+ messages: messagesOf(run, "events prepare"),
363
+ account: { id: run.accountId, key: run.account },
364
+ personOf: personOfEmail(run) ?? (async () => null),
365
+ now: now(),
366
+ ...(opts.limit === undefined ? {} : { limit: opts.limit }),
367
+ ...(window.since === undefined ? {} : { since: window.since }),
368
+ });
369
+ return itemBody(prepared, { meta: { ...meta, ...window.meta, ...clockMeta(now(), zone()) } });
343
370
  });
344
- print({ ...prepared, meta: { ...meta, ...window.meta, ...clockMeta(now(), zone()) } }, command);
345
371
  });
346
- if (deps.writes)
347
- addRemind(events, deps.writes, { deps, now, zone, print, example });
348
- const app = deps.messages ? deps.app : undefined;
349
- const searches = annotate(program
350
- .command("search")
351
- .description(app
352
- ? "find stored things by text: events, or all of messages, notes and events"
353
- : "find stored things by text: events"), { effects: GROUP });
354
- if (app)
355
- addSearchAll(searches, { deps, app, zone, messages, example });
356
- const search = example(annotate(searches.command("events"), { effects: deps.serverSearch ? LOCAL_OR_REMOTE_READ : LOCAL_READ })
357
- .description(deps.serverSearch
372
+ if (options.writes)
373
+ addRemind(events, { within, now, zone, example });
374
+ const searches = annotate(program.command("search").description("find stored things by text: events, or all of messages, notes and events"), { effects: GROUP });
375
+ addSearchAll(searches, { within, app: options.app, zone, example });
376
+ const search = example(annotate(searches.command("events"), { effects: options.serverSearch ? LOCAL_OR_REMOTE_READ : LOCAL_READ })
377
+ .description(options.serverSearch
358
378
  ? "find stored events by words, and by meaning when its model is downloaded; with --backend server, ask " +
359
379
  "the provider instead, or with --backend both, ask both"
360
380
  : "find stored events by words, and by meaning when its model is downloaded")
361
- .argument("<query...>", "what to find in titles, descriptions, places and attendees"), "search events roadmap planning --json", ...(deps.serverSearch
381
+ .argument("<query...>", "what to find in titles, descriptions, places and attendees"), "search events roadmap planning --json", ...(options.serverSearch
362
382
  ? ["search events --backend server quarterly review", "search events --backend both quarterly review"]
363
383
  : []))
364
384
  .option("--limit <n>", "events per page", integer, 20)
365
385
  .option("--cursor <cursor>", "go on from where the last page stopped; page.next gives it", (text) => (text === "0" ? 0 : integer(text)), 0)
366
386
  .option("--exact", "every word as written, no word stems")
367
387
  .option("--newest", "latest start first instead of best first");
368
- if (deps.serverSearch)
388
+ if (options.serverSearch)
369
389
  search.addOption(new Option("--backend <archive|server|both>", "where to search: archive, the local store, or server, the provider's own search over the synced calendars, " +
370
390
  "earliest first; both gives the store's hits, each the provider also found marked server, then the " +
371
391
  "provider's other hits, one page with no cursor")
372
392
  .choices(["archive", "server", "both"])
373
393
  .default("archive"));
374
394
  search.action(async (words, opts, command) => {
375
- const archived = () => searchEvents(messages("search"), words.join(" "), {
376
- accounts: [deps.account],
377
- limit: opts.limit,
378
- offset: opts.cursor,
379
- timezone: zone(),
380
- command: program.name(),
381
- ...(opts.exact ? { exact: true } : {}),
382
- ...(opts.newest ? { newest: true } : {}),
383
- });
384
- if (opts.backend === "server" || opts.backend === "both") {
385
- const flag = `search events --backend ${opts.backend}`;
395
+ const flag = `search events --backend ${opts.backend}`;
396
+ const server = opts.backend === "server" || opts.backend === "both";
397
+ if (server) {
386
398
  if (command.optsWithGlobals().offline)
387
399
  throw new CliError("permission_error", `${flag} asks the provider; leave out --offline`, {
388
400
  reason: "search.offline",
@@ -393,90 +405,111 @@ const addCalendarCommandsDefinition = (program, deps) => {
393
405
  });
394
406
  if (opts.cursor || opts.newest)
395
407
  throw new CliError("validation_error", `${flag} takes neither --cursor nor --newest: the provider ranks and pages its own hits`, { reason: "search.server-options" });
396
- const served = await serverSearchAnswer(source(), deps.store, {
397
- accountId: deps.accountId,
398
- account: deps.account,
408
+ }
409
+ await within(command, async (run) => {
410
+ const archived = () => searchEvents(messagesOf(run, "search"), words.join(" "), {
411
+ accounts: [run.account],
412
+ limit: opts.limit,
413
+ offset: opts.cursor,
414
+ timezone: zone(),
415
+ command: options.app.command,
416
+ ...(opts.exact ? { exact: true } : {}),
417
+ ...(opts.newest ? { newest: true } : {}),
418
+ });
419
+ if (!server)
420
+ return hitsBody(command, { ...(await archived()), untrusted: SEARCH_UNTRUSTED });
421
+ const served = await serverSearchAnswer(run.source(), run.store, {
422
+ accountId: run.accountId,
423
+ account: run.account,
399
424
  query: words.join(" "),
400
425
  limit: opts.limit,
401
426
  });
402
427
  if (opts.backend === "server")
403
- print({ ...served, untrusted: SERVER_SEARCH_UNTRUSTED }, command);
404
- else
405
- print({
406
- ...(await bothSearchAnswer(deps.store, deps.accountId, await archived(), served, opts.limit)),
407
- untrusted: SEARCH_UNTRUSTED,
408
- }, command);
409
- return;
410
- }
411
- print({ ...(await archived()), untrusted: SEARCH_UNTRUSTED }, command);
428
+ return hitsBody(command, { ...served, untrusted: SERVER_SEARCH_UNTRUSTED });
429
+ return hitsBody(command, {
430
+ ...(await bothSearchAnswer(run.store, run.accountId, await archived(), served, opts.limit)),
431
+ untrusted: SEARCH_UNTRUSTED,
432
+ });
433
+ });
412
434
  });
413
- if (deps.writes)
414
- addEventWrites(events, deps.writes, { deps, now, zone, print, example });
435
+ if (options.writes)
436
+ addEventWrites(events, options.writes, { within, now, zone, example, readFile: options.readFile });
415
437
  const people = annotate(program.command("people").description("the people in the account's events"), {
416
438
  effects: GROUP,
417
439
  });
418
440
  withQuery(example(annotate(people.command("list"), { effects: LOCAL_READ }).description("one event's attendees, or everyone attending this week's events"), "people list --json", "people list event:42"), PEOPLE_QUERY).action(async (words, _opts, command) => {
419
441
  const reference = queryTerms(words, PEOPLE_QUERY, "people list").get("event");
420
442
  const event = reference === undefined ? undefined : eventIdOf(reference);
421
- const filter = event === undefined ? weekFilter(deps.accountId, zone(), now()) : undefined;
422
- const people = await peopleAnswer(deps.store, deps.accountId, {
423
- ...(event === undefined ? {} : { event }),
424
- ...(filter ? { filter } : {}),
425
- ...(personOfEmail ? { personOf: personOfEmail } : {}),
443
+ await within(command, async (run) => {
444
+ const filter = event === undefined ? weekFilter(run.accountId, zone(), now()) : undefined;
445
+ const personOf = personOfEmail(run);
446
+ const found = await peopleAnswer(run.store, run.accountId, {
447
+ ...(event === undefined ? {} : { event }),
448
+ ...(filter ? { filter } : {}),
449
+ ...(personOf ? { personOf } : {}),
450
+ });
451
+ return listBody(command, eachMarked(found, ATTENDEE_UNTRUSTED));
426
452
  });
427
- print(eachMarked(people, ATTENDEE_UNTRUSTED), command);
428
453
  });
429
454
  example(annotate(people.command("link"), { effects: LOCAL_WRITE })
430
455
  .description("join an attendee to a person the owner knows elsewhere")
431
456
  .argument("<person>", "an attendee's address as email:<address>, or a person:<id>")
432
457
  .argument("<other>", "the same person in another tool, as <provider>:<person>, or me"), "people link email:ana@example.com telegram:12345").action(async (person, other, _opts, command) => {
433
- const linked = await linkPeople(messages("people link"), deps.account, person, other);
434
- print({
435
- person: `person:${linked.uid}`,
436
- name: linked.name,
437
- identities: linked.identities.map(({ provider, id }) => `${provider}:${id}`),
438
- }, command);
458
+ await within(command, async (run) => {
459
+ const linked = await linkPeople(messagesOf(run, "people link"), run.account, person, other);
460
+ return itemBody({
461
+ person: `person:${linked.uid}`,
462
+ name: linked.name,
463
+ identities: linked.identities.map(({ provider, id }) => `${provider}:${id}`),
464
+ });
465
+ });
439
466
  });
440
467
  const links = annotate(program.command("links").description("calendar copies the rules could not join alone"), {
441
468
  effects: GROUP,
442
469
  });
443
470
  example(annotate(links.command("review"), { effects: LOCAL_READ }).description("copies that fit two or more events equally well, waiting for the owner's choice"), "links review --json").action(async (_opts, command) => {
444
- // Locked to a profile (WC-ACCT-5), or with no shared store to name the others by, only the profile's own account.
445
- const own = deps.locked || !deps.messages;
446
- const answer = await reviewsAnswer(deps.store, own ? [deps.accountId] : undefined);
447
- const accounts = own
448
- ? [`${deps.account.provider}:${deps.account.account}`]
449
- : await calendarAccountNames(deps.store, messages("links review"), deps.account);
450
- print({ ...eachMarked(answer, REVIEW_UNTRUSTED), meta: { accounts, complete: true } }, command);
471
+ await within(command, async (run) => {
472
+ // Locked to a profile (WC-ACCT-5), or with no shared store to name the others by, only the profile's own account.
473
+ const own = run.locked || !run.messages;
474
+ const found = await reviewsAnswer(run.store, own ? [run.accountId] : undefined);
475
+ const accounts = own
476
+ ? [`${run.account.provider}:${run.account.account}`]
477
+ : await calendarAccountNames(run.store, messagesOf(run, "links review"), run.account);
478
+ return listBody(command, eachMarked(found, REVIEW_UNTRUSTED), { accounts, complete: true });
479
+ });
451
480
  });
452
481
  example(annotate(links.command("resolve"), { effects: LOCAL_WRITE })
453
482
  .description("join a copy to the event you choose, or keep it apart")
454
483
  .argument("<review>", "the review's id from links review")
455
484
  .argument("<choice>", "one of its candidates, as event:<id>, or apart"), "links resolve 7 event:42").action(async (review, choice, _opts, command) => {
456
- print(await resolveAnswer(deps.store, deps.accountId, review, choice, now()), command);
485
+ await within(command, async (run) => itemBody(await resolveAnswer(run.store, run.accountId, review, choice, now())));
457
486
  });
458
487
  const leaves = (command) => command.commands.length ? command.commands.flatMap(leaves) : [command];
459
488
  declareCalendarAnswers(program, program.commands.filter((one) => !mounted.has(one)).flatMap(leaves));
460
489
  };
461
490
  const collect = (value, previous = []) => [...previous, value];
462
- const addEventWrites = (events, writes, { deps, now, zone, print, example }) => {
463
- const context = (command) => ({
464
- store: deps.store,
465
- accountId: deps.accountId,
466
- writes,
467
- ask: terminalAsker(command),
468
- now: now(),
469
- zone: zone(),
470
- names: {
471
- notify: "--notify or --no-notify",
472
- id: "--operation-id",
473
- calendar: "--calendar",
474
- series: "--series",
475
- following: "--following",
476
- repeat: "--repeat",
477
- },
478
- });
479
- const printWritten = (value, command) => print({ ...value, untrusted: CALENDAR_UNTRUSTED_FIELDS }, command);
491
+ const addEventWrites = (events, traits, { within, now, zone, example, readFile }) => {
492
+ const contextOf = (run, command) => {
493
+ if (!run.writes)
494
+ throw new CliError("configuration_error", "this profile cannot write to its calendars");
495
+ return {
496
+ store: run.store,
497
+ accountId: run.accountId,
498
+ writes: run.writes(),
499
+ ask: terminalAsker(command),
500
+ now: now(),
501
+ zone: zone(),
502
+ names: {
503
+ notify: "--notify or --no-notify",
504
+ id: "--operation-id",
505
+ calendar: "--calendar",
506
+ series: "--series",
507
+ following: "--following",
508
+ repeat: "--repeat",
509
+ },
510
+ };
511
+ };
512
+ const written = (command, write) => within(command, async (run) => itemBody({ ...(await write(contextOf(run, command))), untrusted: CALENDAR_UNTRUSTED_FIELDS }));
480
513
  // Left out on purpose: with other attendees the owner must choose, so neither switch is the default.
481
514
  const notify = (command) => command
482
515
  .option("--notify", "the provider mails the other attendees; needs events.notify allowed; this or --no-notify is required " +
@@ -508,7 +541,7 @@ const addEventWrites = (events, writes, { deps, now, zone, print, example }) =>
508
541
  const target = (command) => command
509
542
  .argument("<event>", "the event's id, or event:<id>")
510
543
  .option("--calendar <id>", "the calendar, by id or title, when the event is on more than one of the account's");
511
- const quick = writes.writer.quickAdd !== undefined;
544
+ const quick = traits.quickAdd === true;
512
545
  const create = example(notify(fields(write("create"), false)
513
546
  .description(quick
514
547
  ? "add an event to a calendar; with --repeat, one that repeats; with --text, one the provider reads " +
@@ -521,20 +554,18 @@ const addEventWrites = (events, writes, { deps, now, zone, print, example }) =>
521
554
  "--repeat 'FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=12'");
522
555
  if (quick)
523
556
  create.option("--text <text>", "one line the provider reads the title, date, time and place from, in place of the other fields; nobody is " +
524
- "mailed without --notify, and a retry after outcome_unknown may add the event twice");
557
+ "mailed without --notify");
525
558
  create.action(async (calendar, opts, command) => {
526
559
  const { notify: choice, operationId: id, text, ...rest } = opts;
560
+ const options = { ...(choice === undefined ? {} : { notify: choice }), ...(id === undefined ? {} : { id }) };
527
561
  if (text !== undefined) {
528
562
  const given = Object.keys(fieldsOf(rest));
529
- if (given.length || id !== undefined)
530
- throw new CliError("validation_error", `--text takes the whole event from its line; leave out ${[...given.map(flagOf), ...(id === undefined ? [] : ["--operation-id"])].join(", ")}`, { reason: "event.text-with-fields", param: "--text" });
531
- printWritten(await createEventFromText(context(command), calendar, text, choice === undefined ? {} : { notify: choice }), command);
563
+ if (given.length)
564
+ throw new CliError("validation_error", `--text takes the whole event from its line; leave out ${given.map(flagOf).join(", ")}`, { reason: "event.text-with-fields", param: "--text" });
565
+ await written(command, (context) => createEventFromText(context, calendar, text, options));
532
566
  return;
533
567
  }
534
- printWritten(await createEvent(context(command), calendar, fieldsOf(rest), {
535
- ...(choice === undefined ? {} : { notify: choice }),
536
- ...(id === undefined ? {} : { id }),
537
- }), command);
568
+ await written(command, (context) => createEvent(context, calendar, fieldsOf(rest), options));
538
569
  });
539
570
  example(notify(target(fields(write("update"), true))
540
571
  .description("change an event; with --series every occurrence of the repeating event, with --following this " +
@@ -544,44 +575,40 @@ const addEventWrites = (events, writes, { deps, now, zone, print, example }) =>
544
575
  "with the change")
545
576
  .option("--operation-id <id>", "with --following, the operation_id an outcome_unknown named: a retry finds that new series")), "events update event:42 --start-time 2026-11-02T14:00 --end-time 2026-11-02T15:00 --notify", "events update event:42 --series --repeat 'FREQ=WEEKLY;BYDAY=TU;UNTIL=20261231T230000Z' --no-notify", "events update event:42 --following --start-time 2026-11-09T10:00 --end-time 2026-11-09T10:30 --no-notify").action(async (reference, opts, command) => {
546
577
  const { calendar: _calendar, series: _series, following: _following, notify: _notify, operationId: id, ...rest } = opts;
547
- printWritten(await updateEvent(context(command), reference, fieldsOf(rest), {
548
- ...choicesOf(opts),
549
- ...(id === undefined ? {} : { id }),
550
- }), command);
578
+ await written(command, (context) => updateEvent(context, reference, fieldsOf(rest), { ...choicesOf(opts), ...(id === undefined ? {} : { id }) }));
551
579
  });
552
580
  example(notify(target(write("delete"))
553
581
  .description("delete an event; a whole repeating event, or every later occurrence, needs events.delete.series " +
554
582
  "allowed first")
555
583
  .option("--series", "delete the whole repeating event, not only this occurrence")
556
584
  .option("--following", "delete this occurrence and every later one: the repeating event ends before it")), "events delete event:42 --no-notify", "events delete event:42 --following --no-notify").action(async (reference, opts, command) => {
557
- printWritten(await deleteEvent(context(command), reference, choicesOf(opts)), command);
585
+ await written(command, (context) => deleteEvent(context, reference, choicesOf(opts)));
558
586
  });
559
- const readFile = deps.readFile;
560
- if (writes.writer.importEvent && readFile)
587
+ if (traits.importEvent)
561
588
  example(write("import")
562
589
  .description(`add the events of an iCalendar (.ics) file to a calendar, at most ${IMPORT_MAX}, each under its own UID; ` +
563
590
  "nobody is mailed; a changed occurrence of a repeating event (RECURRENCE-ID) is skipped")
564
591
  .argument("<calendar>", "the calendar's id, the provider's id, or its title")
565
592
  .argument("<file>", "the .ics file to read"), "events import Work planning.ics").action(async (calendar, file, _opts, command) => {
566
- const text = await readFile(file);
593
+ const text = await (readFile ?? readIcsFile)(file);
567
594
  if (Buffer.byteLength(text, "utf8") > ICS_MAX_BYTES)
568
595
  throw new CliError("validation_error", `${file} is larger than ${ICS_MAX_BYTES / 1024 / 1024} MB`, {
569
596
  reason: "ics.too-large",
570
597
  });
571
- print({ ...(await importEvents(context(command), calendar, text)), untrusted: IMPORT_UNTRUSTED }, command);
598
+ await within(command, async (run) => itemBody({ ...(await importEvents(contextOf(run, command), calendar, text)), untrusted: IMPORT_UNTRUSTED }));
572
599
  });
573
- if (writes.writer.move)
600
+ if (traits.move)
574
601
  example(notify(target(write("move"))
575
602
  .description("move an event, with its id, to another calendar of the account; a repeating one moves whole")
576
603
  .requiredOption("--to-calendar <id>", "the calendar to move it to: its id, the provider's id, or its title")
577
604
  .option("--series", "move the whole repeating event an occurrence belongs to")), "events move event:42 --to-calendar 4 --no-notify").action(async (reference, opts, command) => {
578
605
  const { series, calendar, notify } = opts;
579
- printWritten(await moveEvent(context(command), reference, opts.toCalendar, choicesOf({ series, calendar, notify })), command);
606
+ await written(command, (context) => moveEvent(context, reference, opts.toCalendar, choicesOf({ series, calendar, notify })));
580
607
  });
581
608
  example(notify(target(write("respond"))
582
609
  .description("answer an invitation; the organiser is told only with --notify")
583
610
  .addArgument(new Argument("<response>", "the answer").choices(EVENT_RESPONSES))), "events respond event:42 accepted --notify").action(async (reference, response, opts, command) => {
584
- printWritten(await respondToEvent(context(command), reference, response, choicesOf(opts)), command);
611
+ await written(command, (context) => respondToEvent(context, reference, response, choicesOf(opts)));
585
612
  });
586
613
  };
587
614
  // honoursDryRun: a host program other than `run` would otherwise let a write through under --dry-run.