@wirecat/cli-core 0.496.0 → 0.498.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.
@@ -1,9 +1,10 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { annotate, sharedCommand } from "@wirecat/cli-base/commands";
3
3
  import { timezoneOf } from "@wirecat/cli-base/query";
4
- import { Argument, InvalidArgumentError } from "commander";
4
+ import { Argument, InvalidArgumentError, Option } from "commander";
5
5
  import { environmentOf, runBudgetOf } from "../../cli/context.js";
6
6
  import { honoursDryRun } from "../../cli/dry-run-guard.js";
7
+ import { queryTerms, withQuery } from "../../cli/list-query.js";
7
8
  import { terminalAsker } from "../../cli/messenger/ask.js";
8
9
  import { lineOf } from "../../cli/steps.js";
9
10
  import { searchEvents } from "../../services/events-search.js";
@@ -11,12 +12,15 @@ import { linkPeople } from "../../services/people.js";
11
12
  import { BUSY_QUERY_HELP, busyAnswer, busyQuery, workingHoursOf } from "../busy.js";
12
13
  import { CALENDAR_UNTRUSTED_FIELDS, calendarIn, calendarItem, EVENTS_QUERY_HELP, eventAnswer, eventIdOf, eventOf, eventsAnswer, eventsFilter, peopleAnswer, resolveAnswer, reviewsAnswer, } from "../reads.js";
13
14
  import { REPEAT_HELP } from "../recurrence.js";
15
+ import { serverSearchAnswer } from "../server-search.js";
14
16
  import { calendarStatus, syncCalendars } from "../sync.js";
15
17
  import { createEvent, deleteEvent, EVENT_RESPONSES, moveEvent, respondToEvent, updateEvent, } from "../writes.js";
18
+ import { CALENDARS_QUERY, declareCalendarAnswers, PEOPLE_QUERY } from "./answers.js";
16
19
  const LOCAL_READ = { remote: "none", local: "read" };
17
20
  const LOCAL_WRITE = { remote: "none", local: "write" };
18
21
  const FETCH = { remote: "read", local: "write" };
19
22
  const REMOTE_READ = { remote: "read", local: "none" };
23
+ const LOCAL_OR_REMOTE_READ = { remote: "read", local: "read" };
20
24
  const REMOTE_WRITE = { remote: "write", local: "write" };
21
25
  const GROUP = { remote: "none", local: "none" };
22
26
  /** How many pieces one `sync --embed` embeds, as drive's. */
@@ -26,6 +30,7 @@ const EVENT_ITEM_UNTRUSTED = ["title", "description", "location"];
26
30
  const ATTENDEE_UNTRUSTED = ["name"];
27
31
  const REVIEW_UNTRUSTED = ["candidates.title"];
28
32
  const SEARCH_UNTRUSTED = ["hits.title", "hits.location", "hits.excerpt"];
33
+ const SERVER_SEARCH_UNTRUSTED = ["hits.title", "hits.location"];
29
34
  const eachMarked = (answer, untrusted) => ({
30
35
  ...answer,
31
36
  items: answer.items.map((item) => ({ ...item, untrusted })),
@@ -42,16 +47,9 @@ const minutes = (text) => {
42
47
  return value;
43
48
  };
44
49
  const addCalendarCommandsDefinition = (program, deps) => {
50
+ const mounted = new Set(program.commands);
45
51
  const now = () => deps.now?.() ?? Date.now();
46
52
  const zone = () => timezoneOf(deps.timezone);
47
- const output = (command) => command
48
- .option("--json", "one JSON value")
49
- .option("--jsonl", "one JSON object per item")
50
- .hook("preAction", (cmd) => {
51
- const opts = cmd.optsWithGlobals();
52
- if (opts.json && opts.jsonl)
53
- throw new CliError("validation_error", "choose --json or --jsonl");
54
- });
55
53
  const print = (value, command) => {
56
54
  const opts = command.optsWithGlobals();
57
55
  deps.write(value, opts.json ? "json" : opts.jsonl ? "jsonl" : "text");
@@ -81,27 +79,31 @@ const addCalendarCommandsDefinition = (program, deps) => {
81
79
  return deps.source;
82
80
  };
83
81
  const calendars = annotate(program.command("calendars").description("the account's calendars"), { effects: GROUP });
84
- output(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")).action(async (_opts, command) => {
82
+ 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) => {
83
+ const terms = [...queryTerms(words, CALENDARS_QUERY, "calendars list")];
85
84
  const listed = await deps.store.saveCalendars(deps.accountId, await source().calendars(), now());
86
- print({ items: listed.map(calendarItem), page: 1, limit: listed.length, hasMore: false }, command);
85
+ const items = listed
86
+ .map(calendarItem)
87
+ .filter((item) => terms.every(([field, value]) => String(item[field]) === value));
88
+ print({ items, page: 1, limit: items.length, hasMore: false }, command);
87
89
  });
88
90
  for (const [name, synced] of [
89
91
  ["sync", true],
90
92
  ["unsync", false],
91
93
  ])
92
- output(example(annotate(calendars.command(name), { effects: LOCAL_WRITE })
94
+ example(annotate(calendars.command(name), { effects: LOCAL_WRITE })
93
95
  .description(synced ? "sync this calendar from now on" : "stop syncing this calendar; what is stored stays")
94
- .argument("<calendar>", "the calendar's id, or the provider's id"), `calendars ${name} 3`)).action(async (reference, _opts, command) => {
96
+ .argument("<calendar>", "the calendar's id, or the provider's id"), `calendars ${name} 3`).action(async (reference, _opts, command) => {
95
97
  const calendar = await calendarIn(deps.store, deps.accountId, reference);
96
98
  await deps.store.setCalendarSynced(calendar.id, synced, now());
97
99
  print(calendarItem({ ...calendar, synced }), command);
98
100
  });
99
101
  if (deps.freeBusy)
100
- output(example(annotate(calendars.command("busy"), { effects: REMOTE_READ, offline: false })
102
+ example(annotate(calendars.command("busy"), { effects: REMOTE_READ, offline: false })
101
103
  .description("when calendars and people are taken, asked of the provider and not stored; with --duration-minutes, " +
102
104
  "the free stretches they share")
103
105
  .argument("[words...]", "today, week, from:<date>, to:<date>, calendar:<id>, person:<email>")
104
- .addHelpText("after", BUSY_QUERY_HELP), "calendars busy week person:ana@example.com --json", "calendars busy from:2026-11-02 to:2026-11-06 person:ana@example.com --duration-minutes 30 --hours 10:00-16:00"))
106
+ .addHelpText("after", BUSY_QUERY_HELP), "calendars busy week person:ana@example.com --json", "calendars busy from:2026-11-02 to:2026-11-06 person:ana@example.com --duration-minutes 30 --hours 10:00-16:00")
105
107
  .option("--duration-minutes <minutes>", "find free stretches at least this long, 5–1440", minutes)
106
108
  .option("--hours <from-to>", "working hours in your time zone, Monday to Friday, with --duration-minutes", "09:00-17:00")
107
109
  .action(async (words, opts, command) => {
@@ -117,13 +119,13 @@ const addCalendarCommandsDefinition = (program, deps) => {
117
119
  ...(opts.durationMinutes === undefined ? {} : { slot: { minutes: opts.durationMinutes, hours } }),
118
120
  }), command);
119
121
  });
120
- output(example(annotate(program.command("sync"), {
122
+ example(annotate(program.command("sync"), {
121
123
  effects: FETCH,
122
124
  offline: false,
123
125
  cooperative: true,
124
126
  partial: true,
125
127
  }).description("bring the synced calendars up to date: what changed since the last sync, or everything with --rescan; a run " +
126
- "stops before its deadline or --max-pages and the next one goes on from there"), "sync", "sync --rescan --embed", "sync --timeout 2m"))
128
+ "stops before its deadline or --max-pages and the next one goes on from there"), "sync", "sync --rescan --embed", "sync --timeout 2m")
127
129
  .option("--rescan", "list every synced calendar's window again, dropping a round in progress")
128
130
  .option("--max-pages <pages>", "at most this many requests to the provider in this run; as many as fit the deadline if not given", integer)
129
131
  .option("--embed", "also index new events for meaning search, with a model already downloaded")
@@ -191,17 +193,17 @@ const addCalendarCommandsDefinition = (program, deps) => {
191
193
  });
192
194
  print({ ...report, ...(embeddings === undefined ? {} : { embeddings }), ...(next ? { next } : {}) }, command);
193
195
  });
194
- output(example(annotate(program.command("status"), { effects: LOCAL_READ }).description("how far each synced calendar's sync is: entries done, partial or complete, and when it last moved"), "status --json")).action(async (_opts, command) => {
196
+ example(annotate(program.command("status"), { effects: LOCAL_READ }).description("how far each synced calendar's sync is: entries done, partial or complete, and when it last moved"), "status --json").action(async (_opts, command) => {
195
197
  print(await calendarStatus(deps.store, deps.accountId, {
196
198
  profile: deps.profile ?? `account ${deps.accountId}`,
197
199
  now: now(),
198
200
  }), command);
199
201
  });
200
202
  const events = annotate(program.command("events").description("the account's stored events"), { effects: GROUP });
201
- output(example(annotate(events.command("list"), { effects: LOCAL_READ, output: { entity: "event" } })
203
+ example(annotate(events.command("list"), { effects: LOCAL_READ })
202
204
  .description("list stored events, earliest first")
203
205
  .argument("[words...]", "today, week, from:<date>, to:<date>, person:<who>, calendar:<id>")
204
- .addHelpText("after", EVENTS_QUERY_HELP), "events list week --json", "events list from:2026-11-02 to:2026-11-06 person:ana@example.com"))
206
+ .addHelpText("after", EVENTS_QUERY_HELP), "events list week --json", "events list from:2026-11-02 to:2026-11-06 person:ana@example.com")
205
207
  .option("--limit <n>", "events at most, 1–1000", integer, 100)
206
208
  .action(async (words, opts, command) => {
207
209
  const filter = await eventsFilter(words, {
@@ -213,20 +215,48 @@ const addCalendarCommandsDefinition = (program, deps) => {
213
215
  });
214
216
  print(eachMarked(await eventsAnswer(deps.store, filter && { ...filter, limit: opts.limit }), EVENT_ITEM_UNTRUSTED), command);
215
217
  });
216
- output(example(annotate(events.command("show"), { effects: LOCAL_READ })
218
+ example(annotate(events.command("show"), { effects: LOCAL_READ })
217
219
  .description("one event: its calendar copies, attendees with their person, Drive files and history")
218
- .argument("<event>", "the event's id, or event:<id>"), "events show event:42 --json")).action(async (reference, _opts, command) => {
220
+ .argument("<event>", "the event's id, or event:<id>"), "events show event:42 --json").action(async (reference, _opts, command) => {
219
221
  const shown = await eventAnswer(await eventOf(deps.store, deps.accountId, eventIdOf(reference)), personOfEmail);
220
222
  print({ ...shown, untrusted: CALENDAR_UNTRUSTED_FIELDS }, command);
221
223
  });
222
- output(example(annotate(program.command("search"), { effects: LOCAL_READ })
223
- .description("find stored events by words, and by meaning when its model is downloaded")
224
- .argument("<words...>", "what to find in titles, descriptions, places and attendees"), "search roadmap planning --json"))
224
+ const search = example(annotate(program.command("search"), { effects: deps.serverSearch ? LOCAL_OR_REMOTE_READ : LOCAL_READ })
225
+ .description(deps.serverSearch
226
+ ? "find stored events by words, and by meaning when its model is downloaded; with --backend server, ask " +
227
+ "the provider instead"
228
+ : "find stored events by words, and by meaning when its model is downloaded")
229
+ .argument("<words...>", "what to find in titles, descriptions, places and attendees"), "search roadmap planning --json", ...(deps.serverSearch ? ["search --backend server quarterly review"] : []))
225
230
  .option("--limit <n>", "events per page", integer, 20)
226
231
  .option("--cursor <cursor>", "continue from a page's next_offset", (text) => (text === "0" ? 0 : integer(text)), 0)
227
232
  .option("--exact", "every word as written, no word stems")
228
- .option("--newest", "latest start first instead of best first")
229
- .action(async (words, opts, command) => {
233
+ .option("--newest", "latest start first instead of best first");
234
+ if (deps.serverSearch)
235
+ 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, " +
236
+ "earliest first")
237
+ .choices(["archive", "server", "both"])
238
+ .default("archive"));
239
+ search.action(async (words, opts, command) => {
240
+ if (opts.backend === "both")
241
+ throw new CliError("validation_error", "search --backend both is not offered: the store's search and the provider's rank and page events " +
242
+ "differently, so one merged list would have no single order or next page; run --backend archive and " +
243
+ "--backend server", { reason: "search.one-backend" });
244
+ if (opts.backend === "server") {
245
+ if (command.optsWithGlobals().offline)
246
+ throw new CliError("permission_error", "search --backend server asks the provider; leave out --offline", {
247
+ reason: "search.offline",
248
+ });
249
+ if (opts.exact || opts.cursor || opts.newest)
250
+ throw new CliError("validation_error", "search --backend server takes neither --exact, --cursor nor --newest: the provider ranks and pages it", { reason: "search.server-options" });
251
+ const found = await serverSearchAnswer(source(), deps.store, {
252
+ accountId: deps.accountId,
253
+ account: deps.account,
254
+ query: words.join(" "),
255
+ limit: opts.limit,
256
+ });
257
+ print({ ...found, untrusted: SERVER_SEARCH_UNTRUSTED }, command);
258
+ return;
259
+ }
230
260
  const found = await searchEvents(messages("search"), words.join(" "), {
231
261
  accounts: [deps.account],
232
262
  limit: opts.limit,
@@ -239,13 +269,12 @@ const addCalendarCommandsDefinition = (program, deps) => {
239
269
  print({ ...found, untrusted: SEARCH_UNTRUSTED }, command);
240
270
  });
241
271
  if (deps.writes)
242
- addEventWrites(events, deps.writes, { deps, now, zone, output, print, example });
272
+ addEventWrites(events, deps.writes, { deps, now, zone, print, example });
243
273
  const people = annotate(program.command("people").description("the people in the account's events"), {
244
274
  effects: GROUP,
245
275
  });
246
- output(example(annotate(people.command("list"), { effects: LOCAL_READ })
247
- .description("one event's attendees, or everyone attending this week's events")
248
- .argument("[event]", "the event's id, or event:<id>"), "people list --json", "people list event:42")).action(async (reference, _opts, command) => {
276
+ 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) => {
277
+ const reference = queryTerms(words, PEOPLE_QUERY, "people list").get("event");
249
278
  const event = reference === undefined ? undefined : eventIdOf(reference);
250
279
  const filter = event === undefined
251
280
  ? await eventsFilter(["week"], { store: deps.store, accountId: deps.accountId, zone: zone(), now: now() })
@@ -257,10 +286,10 @@ const addCalendarCommandsDefinition = (program, deps) => {
257
286
  });
258
287
  print(eachMarked(people, ATTENDEE_UNTRUSTED), command);
259
288
  });
260
- output(example(annotate(people.command("link"), { effects: LOCAL_WRITE })
289
+ example(annotate(people.command("link"), { effects: LOCAL_WRITE })
261
290
  .description("join an attendee to a person the owner knows elsewhere")
262
291
  .argument("<person>", "an attendee's address as email:<address>, or a person:<id>")
263
- .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) => {
292
+ .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) => {
264
293
  const linked = await linkPeople(messages("people link"), deps.account, person, other);
265
294
  print({
266
295
  person: `person:${linked.uid}`,
@@ -271,18 +300,20 @@ const addCalendarCommandsDefinition = (program, deps) => {
271
300
  const links = annotate(program.command("links").description("calendar copies the rules could not join alone"), {
272
301
  effects: GROUP,
273
302
  });
274
- output(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) => {
303
+ 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) => {
275
304
  print(eachMarked(await reviewsAnswer(deps.store), REVIEW_UNTRUSTED), command);
276
305
  });
277
- output(example(annotate(links.command("resolve"), { effects: LOCAL_WRITE })
306
+ example(annotate(links.command("resolve"), { effects: LOCAL_WRITE })
278
307
  .description("join a copy to the event you choose, or keep it apart")
279
308
  .argument("<review>", "the review's id from links review")
280
- .argument("<choice>", "one of its candidates, as event:<id>, or apart"), "links resolve 7 event:42")).action(async (review, choice, _opts, command) => {
309
+ .argument("<choice>", "one of its candidates, as event:<id>, or apart"), "links resolve 7 event:42").action(async (review, choice, _opts, command) => {
281
310
  print(await resolveAnswer(deps.store, deps.accountId, review, choice, now()), command);
282
311
  });
312
+ const leaves = (command) => command.commands.length ? command.commands.flatMap(leaves) : [command];
313
+ declareCalendarAnswers(program, program.commands.filter((one) => !mounted.has(one)).flatMap(leaves));
283
314
  };
284
315
  const collect = (value, previous = []) => [...previous, value];
285
- const addEventWrites = (events, writes, { deps, now, zone, output, print, example }) => {
316
+ const addEventWrites = (events, writes, { deps, now, zone, print, example }) => {
286
317
  const context = (command) => ({
287
318
  store: deps.store,
288
319
  accountId: deps.accountId,
@@ -330,48 +361,48 @@ const addEventWrites = (events, writes, { deps, now, zone, output, print, exampl
330
361
  const target = (command) => command
331
362
  .argument("<event>", "the event's id, or event:<id>")
332
363
  .option("--calendar <id>", "the calendar, when the event is on more than one of the account's");
333
- output(example(notify(fields(write("create"), false)
364
+ example(notify(fields(write("create"), false)
334
365
  .description("add an event to a calendar; with --repeat, one that repeats")
335
366
  .argument("<calendar>", "the calendar's id, or the provider's id")).option("--id <id>", "the id a create that ended in outcome_unknown named: a retry finds that event"), "events create 3 --title 'Example planning' --start-time 2026-11-02T10:00 --end-time 2026-11-02T11:00", "events create 3 --title 'Example review' --start-date 2026-11-03 --end-date 2026-11-03 " +
336
367
  "--attendee ana@example.com --no-notify", "events create 3 --title 'Example standup' --start-time 2026-11-02T09:30 --end-time 2026-11-02T09:45 " +
337
- "--repeat 'FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=12'")).action(async (calendar, opts, command) => {
368
+ "--repeat 'FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=12'").action(async (calendar, opts, command) => {
338
369
  const { notify: choice, id, ...rest } = opts;
339
370
  printWritten(await createEvent(context(command), calendar, fieldsOf(rest), {
340
371
  ...(choice === undefined ? {} : { notify: choice }),
341
372
  ...(id === undefined ? {} : { id }),
342
373
  }), command);
343
374
  });
344
- output(example(notify(target(fields(write("update"), true))
375
+ example(notify(target(fields(write("update"), true))
345
376
  .description("change an event; with --series every occurrence of the repeating event, with --following this " +
346
377
  "occurrence and every later one; new times or dates switch it between timed and all-day")
347
378
  .option("--series", "change the whole repeating event, not only this occurrence")
348
379
  .option("--following", "change this occurrence and every later one: the repeating event ends before it and a new one starts " +
349
380
  "with the change")
350
- .option("--id <id>", "with --following, the 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) => {
381
+ .option("--id <id>", "with --following, the 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) => {
351
382
  const { calendar: _calendar, series: _series, following: _following, notify: _notify, id, ...rest } = opts;
352
383
  printWritten(await updateEvent(context(command), reference, fieldsOf(rest), {
353
384
  ...choicesOf(opts),
354
385
  ...(id === undefined ? {} : { id }),
355
386
  }), command);
356
387
  });
357
- output(example(notify(target(write("delete"))
388
+ example(notify(target(write("delete"))
358
389
  .description("delete an event; a whole repeating event, or every later occurrence, needs events.delete.series " +
359
390
  "allowed first")
360
391
  .option("--series", "delete the whole repeating event, not only this occurrence")
361
- .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) => {
392
+ .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) => {
362
393
  printWritten(await deleteEvent(context(command), reference, choicesOf(opts)), command);
363
394
  });
364
395
  if (writes.writer.move)
365
- output(example(notify(target(write("move"))
396
+ example(notify(target(write("move"))
366
397
  .description("move an event, with its id, to another calendar of the account; a repeating one moves whole")
367
398
  .requiredOption("--to-calendar <id>", "the calendar to move it to: its id, or the provider's id")
368
- .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) => {
399
+ .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) => {
369
400
  const { series, calendar, notify } = opts;
370
401
  printWritten(await moveEvent(context(command), reference, opts.toCalendar, choicesOf({ series, calendar, notify })), command);
371
402
  });
372
- output(example(notify(target(write("respond"))
403
+ example(notify(target(write("respond"))
373
404
  .description("answer an invitation; the organiser is told only with --notify")
374
- .addArgument(new Argument("<response>", "the answer").choices(EVENT_RESPONSES))), "events respond event:42 accepted --notify")).action(async (reference, response, opts, command) => {
405
+ .addArgument(new Argument("<response>", "the answer").choices(EVENT_RESPONSES))), "events respond event:42 accepted --notify").action(async (reference, response, opts, command) => {
375
406
  printWritten(await respondToEvent(context(command), reference, response, choicesOf(opts)), command);
376
407
  });
377
408
  };
@@ -1,6 +1,6 @@
1
1
  export type { CalendarAccessRole, CalendarInput, EntryAttachment, EntryAttachmentInput, EntryEventType, EntryInput, EntryKind, EntryParticipantInput, EntryRevision, EntryStatus, EntryTransparency, EntryVisibility, EventDetails, EventFilter, EventStore, LinkReview, ParticipantResponse, ReviewDecision, StoredCalendar, StoredEntry, StoredParticipant, } from "../meetings/index.js";
2
2
  export { repeatRule } from "./recurrence.js";
3
- export type { BusyTimes, CalendarExpander, CalendarSource, CalendarSourceTraits, ChangePage, ChangeResult, FreeBusyRequest, SyncWindow, } from "./source.js";
3
+ export type { BusyTimes, CalendarExpander, CalendarSource, CalendarSourceTraits, ChangePage, ChangeResult, FreeBusyRequest, SearchPage, SyncWindow, } from "./source.js";
4
4
  export { type CalendarCursor, type CalendarSyncOptions, type CalendarSyncReport, type CalendarSyncResult, calendarStatus, calendarSyncWindow, type PendingRound, parseCalendarCursor, syncCalendars, } from "./sync.js";
5
5
  export { CALENDAR_WRITE_DEFAULTS, CALENDAR_WRITE_KEYS, type CalendarWrite, type CalendarWriter, calendarConflict, EVENTS_DELETE_SERIES_KEY, EVENTS_NOTIFY_KEY, type EventDraft, type EventPatch, type EventResponse, type FollowingScope, type WriteScope, } from "./writer.js";
6
6
  export type { CalendarWrites } from "./writes.js";
@@ -63,6 +63,13 @@ export declare const eventAnswer: (details: EventDetails, personOf?: PersonOfEma
63
63
  created_at: string;
64
64
  updated_at: string;
65
65
  untrusted?: string[] | undefined;
66
+ change?: "cancelled" | "moved" | "new" | undefined;
67
+ changed_at?: string | null | undefined;
68
+ was?: {
69
+ starts_at: string | null;
70
+ ends_at: string | null;
71
+ } | undefined;
72
+ overlaps?: string[] | undefined;
66
73
  ref: string;
67
74
  entries: {
68
75
  id: string;
@@ -0,0 +1,31 @@
1
+ import type { EventStore } from "../meetings/index.js";
2
+ import type { AccountKey } from "../store/store.js";
3
+ import type { CalendarSource } from "./source.js";
4
+ /**
5
+ * The provider's own search over the calendars the account syncs, earliest first, stored nowhere. A calendar the owner
6
+ * does not sync stays out: he chose which calendars the CLI reads.
7
+ */
8
+ export declare const serverSearchAnswer: (source: CalendarSource, store: EventStore, { accountId, account, query, limit }: {
9
+ accountId: number;
10
+ account: AccountKey;
11
+ query: string;
12
+ limit: number;
13
+ }) => Promise<{
14
+ query: string;
15
+ by: "server";
16
+ hits: {
17
+ provider: string;
18
+ account: string;
19
+ calendar: string;
20
+ external_id: string;
21
+ title: string | null;
22
+ starts_at: string | null;
23
+ ends_at: string | null;
24
+ all_day: boolean;
25
+ status: "confirmed" | "tentative";
26
+ location: string | null;
27
+ url?: string | undefined;
28
+ found_by: "server"[];
29
+ }[];
30
+ has_more: boolean;
31
+ }>;
@@ -0,0 +1,41 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ const iso = (at) => (at == null ? null : new Date(at).toISOString());
3
+ /**
4
+ * The provider's own search over the calendars the account syncs, earliest first, stored nowhere. A calendar the owner
5
+ * does not sync stays out: he chose which calendars the CLI reads.
6
+ */
7
+ export const serverSearchAnswer = async (source, store, { accountId, account, query, limit }) => {
8
+ if (!source.search)
9
+ throw new CliError("configuration_error", "this calendar provider has no search of its own");
10
+ const calendars = (await store.calendars(accountId)).filter(({ synced, deletedAt }) => synced && deletedAt === null);
11
+ if (calendars.length === 0)
12
+ throw new CliError("validation_error", "no calendar syncs yet; run sync, or calendars sync <calendar>", {
13
+ reason: "search.no-calendars",
14
+ });
15
+ const hits = [];
16
+ let more = false;
17
+ for (const calendar of calendars) {
18
+ const page = await source.search(calendar, { query, limit });
19
+ more ||= page.more;
20
+ for (const entry of page.entries) {
21
+ if (entry.status === "cancelled")
22
+ continue;
23
+ hits.push({
24
+ provider: source.provider,
25
+ account: account.account,
26
+ calendar: calendar.externalId,
27
+ external_id: entry.externalId,
28
+ title: entry.title ?? null,
29
+ starts_at: iso(entry.startsAt),
30
+ ends_at: iso(entry.endsAt),
31
+ all_day: entry.allDay === true,
32
+ status: entry.status,
33
+ location: entry.location ?? null,
34
+ ...(entry.url ? { url: entry.url } : {}),
35
+ found_by: ["server"],
36
+ });
37
+ }
38
+ }
39
+ hits.sort((a, b) => (a.starts_at ?? "").localeCompare(b.starts_at ?? ""));
40
+ return { query, by: "server", hits: hits.slice(0, limit), has_more: more || hits.length > limit };
41
+ };
@@ -49,6 +49,12 @@ export interface BusyTimes {
49
49
  /** The provider's reason (`notFound`, `groupTooBig`): its busy times are unknown, not free. */
50
50
  error?: string;
51
51
  }
52
+ /** What the provider's own text search found on one calendar: one page, read and never stored. */
53
+ export interface SearchPage {
54
+ entries: EntryInput[];
55
+ /** The provider has more matches on this calendar than it returned. */
56
+ more: boolean;
57
+ }
52
58
  /** One provider's calendars, as an adapter reads them. The engine never sees a provider word. */
53
59
  export interface CalendarSource {
54
60
  /** The registry name: `google-calendar`, `outlook-calendar`, `caldav`, `ics`. */
@@ -65,6 +71,14 @@ export interface CalendarSource {
65
71
  occurrences?(calendar: StoredCalendar, seriesExternalId: string, window: SyncWindow): Promise<EntryInput[]>;
66
72
  /** When calendars and people are taken, read from the provider and never stored. Absent: no `calendars busy`. */
67
73
  freeBusy?(request: FreeBusyRequest): Promise<BusyTimes[]>;
74
+ /**
75
+ * The provider's own text search over one calendar, at most `limit` entries; it also finds events outside the sync
76
+ * window. Absent: no `search --backend server`.
77
+ */
78
+ search?(calendar: StoredCalendar, request: {
79
+ query: string;
80
+ limit: number;
81
+ }): Promise<SearchPage>;
68
82
  }
69
83
  /**
70
84
  * Core expansion for `expands: "local"`: a series' slots inside the window, as occurrences. A slot its EXDATE lines
@@ -0,0 +1,6 @@
1
+ import type { Command } from "commander";
2
+ /**
3
+ * Parses what a shared calendar command handed its host against the command's entity, as a host prints it: keys in
4
+ * snake_case, a list's items one by one, a search's hits each with the search's `untrusted`, and no `next`.
5
+ */
6
+ export declare const checkCalendarAnswer: (root: Command, argv: readonly string[], value: unknown) => void;
@@ -0,0 +1,37 @@
1
+ import * as v from "valibot";
2
+ import { answerEntitySchema } from "../../cli/answer-entity.js";
3
+ import { snakeKeys } from "../../cli/envelope.js";
4
+ const commandAt = (root, argv) => {
5
+ let at = root;
6
+ for (const word of argv) {
7
+ const next = at.commands.find((one) => one.name() === word);
8
+ if (!next)
9
+ break;
10
+ at = next;
11
+ }
12
+ return at;
13
+ };
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`.
17
+ */
18
+ export const checkCalendarAnswer = (root, argv, value) => {
19
+ const command = commandAt(root, argv);
20
+ const schema = answerEntitySchema(command);
21
+ if (!schema)
22
+ throw new Error(`${argv.join(" ")} declares no answer entity`);
23
+ const { next: _next, ...body } = snakeKeys(value);
24
+ const marker = body.untrusted
25
+ ?.filter((field) => field.startsWith("hits."))
26
+ .map((field) => field.slice("hits.".length));
27
+ const items = Array.isArray(body.items)
28
+ ? body.items
29
+ : Array.isArray(body.hits)
30
+ ? body.hits.map((hit) => ({ ...hit, untrusted: marker }))
31
+ : [body];
32
+ for (const item of items) {
33
+ const parsed = v.safeParse(schema, item);
34
+ if (!parsed.success)
35
+ throw new Error(`${argv.join(" ")}: ${parsed.issues.map((issue) => `${v.getDotPath(issue) ?? ""} ${issue.message}`).join("; ")}`);
36
+ }
37
+ };
@@ -6,6 +6,8 @@ export interface FakeCalendarSourceOptions {
6
6
  calendars?: CalendarInput[];
7
7
  /** Entries per page of `changes()`. */
8
8
  pageSize?: number;
9
+ /** Answer `search()`: an entry whose title, description or place holds the query, case aside, in any window. */
10
+ search?: boolean;
9
11
  }
10
12
  /**
11
13
  * A provider held in memory. Every `put` and `remove` is one change; the cursor names the last change it saw, so a
@@ -86,6 +86,15 @@ export const fakeCalendarSource = (options = {}) => {
86
86
  },
87
87
  }
88
88
  : {}),
89
+ ...(options.search
90
+ ? {
91
+ async search(calendar, { query, limit }) {
92
+ const words = query.toLowerCase();
93
+ const found = [...of(calendar.externalId).values()].filter((entry) => [entry.title, entry.description, entry.location].some((text) => text?.toLowerCase().includes(words)));
94
+ return { entries: found.slice(0, limit).map((entry) => structuredClone(entry)), more: found.length > limit };
95
+ },
96
+ }
97
+ : {}),
89
98
  async freeBusy({ calendars: asked, people: persons, window }) {
90
99
  const taken = (busy) => busy
91
100
  .filter(({ from, to }) => from < window.to && to > window.from)
@@ -96,10 +96,18 @@ const scopeOf = (root, all, path) => {
96
96
  if (!child || !described) {
97
97
  const prefix = selected?.path ?? [];
98
98
  const choices = available.map(({ name }) => name);
99
+ const groups = all
100
+ .filter(({ name, commands }) => commands.length > 0 && name !== "commands")
101
+ .map(({ name }) => name);
102
+ // The groups the caller named come first: they are the list of groups this answer is most likely about.
103
+ const [one, two] = [...new Set([...path.filter((each) => groups.includes(each)), ...groups])].map((group) => `\`${root.name()} commands ${group} --json\``);
99
104
  throw new CliError("validation_error", `unknown command path "${path.join(" ")}" — inspect \`${[root.name(), "commands", ...prefix, "--json"].join(" ")}\`; ` +
100
105
  (choices.length ? `available here: ${choices.join(", ")}. ` : "this command has no subcommands. ") +
101
- `Give one command path per call, not a list of groups: \`${root.name()} commands messages --json\` ` +
102
- `and \`${root.name()} commands chats --json\` are separate calls`, { path, at: word, available: choices });
106
+ (two
107
+ ? `Give one command path per call, not a list of groups: ${one} and ${two} are separate calls`
108
+ : one
109
+ ? `Give one command path per call, such as ${one}`
110
+ : "Give one command path per call"), { path, at: word, available: choices });
103
111
  }
104
112
  if (selected && selected.options.length > 0) {
105
113
  inheritedOptions.push({ path: selected.path, options: [...selected.options] });
@@ -1,6 +1,6 @@
1
1
  import { CliError, pathsAreOverridden } from "@wirecat/cli-base";
2
2
  import { annotate } from "@wirecat/cli-base/commands";
3
- import { Command } from "commander";
3
+ import { Command, Option } from "commander";
4
4
  import { envName } from "../ports/app.js";
5
5
  import { AUTHORITY_KEY, BOT_API_KEY } from "../sends/permissions.js";
6
6
  import { baseContext, environmentOf } from "./context.js";
@@ -16,10 +16,16 @@ import { changeStoreSetting, isStoreSetting, offersStoreSetting, storeSettings,
16
16
  */
17
17
  export const configCommand = (app, config, { permissionKey = config.permissions } = {}) => {
18
18
  const command = new Command("config").description("the settings in force, and where each one came from");
19
- annotate(command.command("show"), { effects: LOCAL_READ, examples: [`${app.command} personal config show`] })
20
- .description("the profile, the profiles that exist, and each setting with where it came from")
21
- .option("--bot", "the settings a bot command on this profile gets, rather than the personal account's")
22
- .action(async function () {
19
+ // Only a messenger has bots, and with them the personal and bot sections of the file. Elsewhere the options stay
20
+ // accepted, so a script that passes one still runs, but help, `commands --json` and MCP leave them out.
21
+ const bots = app.messenger !== false;
22
+ const kindOption = (flags, description) => new Option(flags, description).hideHelp(!bots);
23
+ const show = annotate(command.command("show"), {
24
+ effects: LOCAL_READ,
25
+ examples: [`${app.command} personal config show`],
26
+ }).description("the profile, the profiles that exist, and each setting with where it came from");
27
+ show.addOption(kindOption("--bot", "the settings a bot command on this profile gets, rather than the personal account's"));
28
+ show.action(async function () {
23
29
  const bot = this.opts().bot === true;
24
30
  const context = baseContext(this, (flags, options) => config.resolveSettings(flags, {
25
31
  ...options,
@@ -32,6 +38,9 @@ export const configCommand = (app, config, { permissionKey = config.permissions
32
38
  renderAnswer(this, context, itemOf({
33
39
  profile: settings.sources.profile === NONE_NAMED ? null : settings.profile,
34
40
  profile_from: settings.sources.profile,
41
+ kind: settings.kind,
42
+ permissions: settings.permissions,
43
+ permission_sources: settings.permissionSources,
35
44
  profiles: settings.profilesHere,
36
45
  config_file: settings.configPath,
37
46
  config_found: settings.configFound,
@@ -48,7 +57,8 @@ export const configCommand = (app, config, { permissionKey = config.permissions
48
57
  renderer.warn(`${issue.what}, so it is left out${issue.fix ? ` — \`${issue.fix}\` removes it` : ""}`);
49
58
  if (overridden) {
50
59
  renderer.note(`${envName(app, "CONFIG_DIR")}, ${envName(app, "STATE_DIR")} or ${envName(app, "CACHE_DIR")} is set — ` +
51
- "a profile's keyring entry is not the usual one, so a session made without them reads as none");
60
+ "a session is still read from the usual keyring entry, but a sign-in made with them is kept apart, in a " +
61
+ "temporary entry that session end removes");
52
62
  }
53
63
  });
54
64
  for (const action of ["set", "unset"]) {
@@ -58,8 +68,8 @@ export const configCommand = (app, config, { permissionKey = config.permissions
58
68
  }))
59
69
  .argument("<setting>", `one of: ${[...config.allSettings, ...storeSettingsOf(app)].join(", ")}`)
60
70
  .option("--defaults", "change what every profile gets, rather than this profile")
61
- .option("--personal", "only for personal accounts — the personal section of the file")
62
- .option("--bot", "only for bots — the bot section of the file");
71
+ .addOption(kindOption("--personal", "only for personal accounts — the personal section of the file"))
72
+ .addOption(kindOption("--bot", "only for bots — the bot section of the file"));
63
73
  if (action === "set")
64
74
  sub
65
75
  .argument("<value>", "a number, true or false; for a group tg:work,max:Alice,email:me@x")
@@ -96,7 +96,7 @@ export { httpTokenFile, type McpEnvironment, type McpHost, serverEntry } from ".
96
96
  export { type MessageItem, messageItem, type ReadMessage } from "./messenger/message-items.js";
97
97
  export { type ModeProblem, type PrivateFiles, privateFiles, withSqliteSidecars } from "./messenger/private-files.js";
98
98
  export { buildReport as buildDoctorReport, type Report as DoctorReport, type ReportContext as DoctorReportContext, reportFileName as doctorReportFileName, } from "./messenger/report.js";
99
- export { allAnswer, allHitItem, type HitItem, type MessageHitItem, messageHitItem, type NoteHitItem, noteHitItem, } from "./messenger/search-answers.js";
99
+ export { allAnswer, allHitItem, coverageGaps, type HitItem, type MessageHitItem, messageHitItem, type NoteHitItem, noteHitItem, } from "./messenger/search-answers.js";
100
100
  export { SEARCHES_QUERY } from "./messenger/searches-command.js";
101
101
  export { servingProfiles } from "./messenger/serve-command.js";
102
102
  export { DAY_MS, FIRST_FETCH_MS, type MemberFetches, memberFetches } from "./messenger/serve-members.js";
package/dist/cli/index.js CHANGED
@@ -87,7 +87,7 @@ export { httpTokenFile, serverEntry } from "./messenger/mcp-command.js";
87
87
  export { messageItem } from "./messenger/message-items.js";
88
88
  export { privateFiles, withSqliteSidecars } from "./messenger/private-files.js";
89
89
  export { buildReport as buildDoctorReport, reportFileName as doctorReportFileName, } from "./messenger/report.js";
90
- export { allAnswer, allHitItem, messageHitItem, noteHitItem, } from "./messenger/search-answers.js";
90
+ export { allAnswer, allHitItem, coverageGaps, messageHitItem, noteHitItem, } from "./messenger/search-answers.js";
91
91
  export { SEARCHES_QUERY } from "./messenger/searches-command.js";
92
92
  export { servingProfiles } from "./messenger/serve-command.js";
93
93
  export { DAY_MS, FIRST_FETCH_MS, memberFetches } from "./messenger/serve-members.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;
7
6
  path: string;
8
7
  exists: boolean;
8
+ error?: undefined;
9
9
  } | {
10
10
  path: string;
11
11
  exists: boolean;