@wirecat/cli-core 0.538.0 → 0.540.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 (38) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/calendar/busy.js +1 -1
  3. package/dist/calendar/cli/answers.d.ts +36 -0
  4. package/dist/calendar/cli/answers.js +17 -0
  5. package/dist/calendar/cli/index.d.ts +4 -0
  6. package/dist/calendar/cli/index.js +77 -12
  7. package/dist/calendar/ics.d.ts +55 -0
  8. package/dist/calendar/ics.js +314 -0
  9. package/dist/calendar/index.d.ts +1 -0
  10. package/dist/calendar/index.js +1 -0
  11. package/dist/calendar/mcp/index.js +2 -1
  12. package/dist/calendar/prepare.d.ts +2 -0
  13. package/dist/calendar/reads.d.ts +17 -4
  14. package/dist/calendar/reads.js +116 -27
  15. package/dist/calendar/testing/memory.js +5 -0
  16. package/dist/calendar/testing/writer.d.ts +1 -1
  17. package/dist/calendar/testing/writer.js +40 -0
  18. package/dist/calendar/writer.d.ts +15 -1
  19. package/dist/calendar/writer.js +2 -0
  20. package/dist/calendar/writes.d.ts +46 -0
  21. package/dist/calendar/writes.js +98 -0
  22. package/dist/cli/bot/mcp.js +2 -2
  23. package/dist/cli/effects.d.ts +14 -0
  24. package/dist/cli/effects.js +33 -2
  25. package/dist/cli/envelope.d.ts +4 -0
  26. package/dist/cli/index.d.ts +2 -2
  27. package/dist/cli/index.js +2 -2
  28. package/dist/cli/list-query.d.ts +8 -3
  29. package/dist/cli/list-query.js +21 -15
  30. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  31. package/dist/errors/reasons.js +7 -0
  32. package/dist/mcp/agent.js +12 -3
  33. package/dist/mcp/app-server.js +2 -2
  34. package/dist/mcp/server.js +2 -2
  35. package/dist/meetings/event-store.d.ts +6 -0
  36. package/dist/meetings/testing/event-store-contract.js +15 -0
  37. package/dist/store/sqlite/calendar-events.js +15 -0
  38. package/package.json +3 -3
@@ -73,6 +73,46 @@ export const fakeCalendarWriter = (options = {}) => {
73
73
  of(calendar.externalId).set(id, entry);
74
74
  return answer(entry);
75
75
  },
76
+ /** Reads no date: the event is the whole line as its title, an hour from noon UTC of 2 November 2026. */
77
+ async quickAdd(calendar, text, { notify }) {
78
+ const id = `quick${++version}`;
79
+ calls.push({ write: "quick-add", calendar: calendar.externalId, externalId: id, notify });
80
+ const noon = Date.UTC(2026, 10, 2, 12);
81
+ const entry = stamp({
82
+ externalId: id,
83
+ icalUid: `${id}@example.com`,
84
+ kind: "single",
85
+ status: "confirmed",
86
+ title: text,
87
+ startsAt: noon,
88
+ endsAt: noon + 3_600_000,
89
+ participants: [],
90
+ });
91
+ of(calendar.externalId).set(id, entry);
92
+ return answer(entry);
93
+ },
94
+ /** Keeps one event per UID on a calendar: a UID sent again replaces the event under the same id. */
95
+ async importEvent(calendar, event) {
96
+ const held = [...of(calendar.externalId).values()].find((one) => one.icalUid === event.uid);
97
+ const id = held?.externalId ?? `imported${++version}`;
98
+ calls.push({ write: "import", calendar: calendar.externalId, externalId: id, notify: false });
99
+ const entry = stamp({
100
+ externalId: id,
101
+ icalUid: event.uid,
102
+ kind: event.recurrence ? "series" : "single",
103
+ status: event.status ?? "confirmed",
104
+ title: event.title,
105
+ description: event.description ?? null,
106
+ location: event.location ?? null,
107
+ ...(event.startDate === undefined
108
+ ? { startsAt: event.startsAt ?? null, endsAt: event.endsAt ?? null, timezone: event.timezone ?? null }
109
+ : { allDay: true, startDate: event.startDate, endDate: event.endDate ?? null }),
110
+ ...(event.recurrence ? { recurrence: event.recurrence } : {}),
111
+ participants: (event.attendees ?? []).map(({ email, name }) => ({ email, name: name ?? null })),
112
+ });
113
+ of(calendar.externalId).set(id, entry);
114
+ return answer(entry);
115
+ },
76
116
  async update(calendar, externalId, patch, { scope, etag, notify }) {
77
117
  calls.push({ write: "update", calendar: calendar.externalId, externalId, notify, scope });
78
118
  const entry = stamp({ ...current(calendar, externalId, etag), ...fields(patch) });
@@ -1,6 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import type { EntryInput, ParticipantResponse, StoredCalendar } from "../meetings/index.js";
3
3
  import type { Level, PermissionKey } from "../sends/permissions.js";
4
+ import type { IcsEvent } from "./ics.js";
4
5
  /**
5
6
  * A new event in neutral words: a timed one has `startsAt` and `endsAt` (epoch ms, end exclusive) in `timezone`; an
6
7
  * all-day one has `startDate` and `endDate` (`YYYY-MM-DD`, end exclusive). A repeating one has `recurrence`, RFC 5545
@@ -64,6 +65,19 @@ export interface CalendarWriter {
64
65
  etag: string;
65
66
  notify: boolean;
66
67
  }): Promise<EntryInput>;
68
+ /**
69
+ * Optional: the provider makes an event from one line of text, reading its date, time and place its own way. The
70
+ * provider picks the id, so a retry after `outcome_unknown` may make a second event.
71
+ */
72
+ quickAdd?(calendar: StoredCalendar, text: string, options: {
73
+ notify: boolean;
74
+ }): Promise<EntryInput>;
75
+ /**
76
+ * Optional: adds a private copy of an event read from an iCalendar file, under its own UID, as Google's
77
+ * `events.import`. The provider mails nobody. Sending the same UID again goes to the provider again; what it does
78
+ * with an event it holds already is its own.
79
+ */
80
+ importEvent?(calendar: StoredCalendar, event: IcsEvent): Promise<EntryInput>;
67
81
  /**
68
82
  * Optional: moves the event, keeping its id, to `target`, another calendar of the same account. `etag` is checked
69
83
  * before the move; a provider whose move takes no precondition reads the event first. A retry after
@@ -75,7 +89,7 @@ export interface CalendarWriter {
75
89
  }): Promise<EntryInput>;
76
90
  }
77
91
  /** Which write a command makes, for `CalendarWrites.extraKeys`. */
78
- export type CalendarWrite = "create" | "update" | "delete" | "respond" | "move";
92
+ export type CalendarWrite = "create" | "quick-add" | "import" | "update" | "delete" | "respond" | "move";
79
93
  /** Mailing the other attendees: an agent may write to the calendar and still not to people (WC-PERM-14's spirit). */
80
94
  export declare const EVENTS_NOTIFY_KEY: PermissionKey;
81
95
  export declare const EVENTS_DELETE_SERIES_KEY: PermissionKey;
@@ -10,6 +10,8 @@ export const CALENDAR_WRITE_KEYS = [EVENTS_NOTIFY_KEY, EVENTS_DELETE_SERIES_KEY]
10
10
  */
11
11
  export const CALENDAR_WRITE_DEFAULTS = {
12
12
  "events.create": "allow",
13
+ // One import writes a whole file of events, and may change events the calendar already holds under their UIDs.
14
+ "events.import": "ask",
13
15
  "events.update": "allow",
14
16
  "events.respond": "allow",
15
17
  "events.move": "allow",
@@ -3,6 +3,7 @@ import type { AppIdentity } from "../ports/app.js";
3
3
  import type { Warn } from "../runs/events.js";
4
4
  import { type Asker, type GuardSettings } from "../sends/guard.js";
5
5
  import { type PermissionKey } from "../sends/permissions.js";
6
+ import { entryItem } from "./reads.js";
6
7
  import { type CalendarWrite, type CalendarWriter, type EventPatch, type EventResponse } from "./writer.js";
7
8
  /** What a CLI or an MCP host hands the calendar writes for one profile. */
8
9
  export interface CalendarWrites {
@@ -83,6 +84,51 @@ export declare const createEvent: (context: WriteContext, calendarReference: str
83
84
  } | null;
84
85
  hint?: string | undefined;
85
86
  }>;
87
+ /**
88
+ * An event the provider makes from one line of text, as Google's quickAdd. Nobody is mailed unless `notify` says so:
89
+ * the attendees are known only once the provider has read the text.
90
+ */
91
+ export declare const createEventFromText: (context: WriteContext, calendarReference: string, text: string, options?: {
92
+ notify?: boolean;
93
+ }) => Promise<{
94
+ operation_id: string;
95
+ event: string | null;
96
+ entry: {
97
+ id: string;
98
+ calendar_id: string;
99
+ external_id: string;
100
+ kind: import("../meetings/event-store.js").EntryKind;
101
+ title: string | null;
102
+ status: import("../meetings/event-store.js").EntryStatus;
103
+ event_type: import("../meetings/event-store.js").EntryEventType | null;
104
+ own_response: import("../meetings/event-store.js").ParticipantResponse | null;
105
+ starts_at: string | null;
106
+ ends_at: string | null;
107
+ join_url: string | null;
108
+ url: string | null;
109
+ deleted_at: string | null;
110
+ } | null;
111
+ hint?: string | undefined;
112
+ }>;
113
+ /** The most events one import sends: a file of more is refused before anything goes. */
114
+ export declare const IMPORT_MAX = 500;
115
+ /**
116
+ * The events of an iCalendar text into a calendar, one provider call each, in the file's order. The whole import is
117
+ * one write for the guard: one question, one journal line. A failure part way names the UIDs already imported.
118
+ */
119
+ export declare const importEvents: (context: WriteContext, calendarReference: string, text: string) => Promise<{
120
+ operation_id: string;
121
+ calendar: string;
122
+ imported: {
123
+ uid: string;
124
+ event: string | null;
125
+ entry: ReturnType<typeof entryItem> | null;
126
+ }[];
127
+ skipped: {
128
+ uid: string | null;
129
+ reason: string;
130
+ }[];
131
+ }>;
86
132
  interface ScopeChoice {
87
133
  calendar?: string;
88
134
  series?: boolean;
@@ -5,6 +5,7 @@ import { guardFor } from "../sends/guard.js";
5
5
  import { guardedWrite } from "../sends/guarded.js";
6
6
  import { DEFAULT_PERMISSIONS, levelFor } from "../sends/permissions.js";
7
7
  import { newOperationId } from "../sends/send-id.js";
8
+ import { readIcs } from "./ics.js";
8
9
  import { calendarIn, entryItem, eventIdOf, eventOf, plusDays } from "./reads.js";
9
10
  import { kindBoundLines, repeatRule, ruleCount, ruleEnd, ruleEndingBefore, ruleLine, splitDates } from "./recurrence.js";
10
11
  import { CALENDAR_WRITE_DEFAULTS, EVENTS_DELETE_SERIES_KEY, EVENTS_NOTIFY_KEY, } from "./writer.js";
@@ -12,6 +13,8 @@ export const EVENT_RESPONSES = ["accepted", "tentative", "declined"];
12
13
  /** Each write is checked by its command's path, the key a declared CLI's `commandPermissions` already knows. */
13
14
  const COMMAND_KEY = {
14
15
  create: "events.create",
16
+ "quick-add": "events.create",
17
+ import: "events.import",
15
18
  update: "events.update",
16
19
  delete: "events.delete",
17
20
  respond: "events.respond",
@@ -191,6 +194,101 @@ export const createEvent = async (context, calendarReference, fields, options =
191
194
  const [saved] = await savedAfterCreate(context, id, () => context.store.saveEntries(calendar.id, [entry], context.now));
192
195
  return answerOf(id, saved, draft.recurrence !== undefined);
193
196
  };
197
+ /** The longest line `createEventFromText` sends; a quick add is one sentence, not a description. */
198
+ const TEXT_MAX = 1000;
199
+ /**
200
+ * An event the provider makes from one line of text, as Google's quickAdd. Nobody is mailed unless `notify` says so:
201
+ * the attendees are known only once the provider has read the text.
202
+ */
203
+ export const createEventFromText = async (context, calendarReference, text, options = {}) => {
204
+ const quickAdd = context.writes.writer.quickAdd;
205
+ if (!quickAdd)
206
+ throw new CliError("configuration_error", "this provider cannot make an event from text");
207
+ const line = text.trim();
208
+ if (!line)
209
+ throw new CliError("validation_error", "the text is empty: say what, when and where");
210
+ if (line.length > TEXT_MAX || /[\r\n]/u.test(line))
211
+ throw new CliError("validation_error", `the text is one line of at most ${TEXT_MAX} characters`);
212
+ const calendar = await calendarIn(context.store, context.accountId, calendarReference);
213
+ const notify = options.notify ?? false;
214
+ const guard = writeGuard(context, keysFor(context, "quick-add", notify));
215
+ const id = newEventId();
216
+ let entry;
217
+ try {
218
+ entry = await guardedWrite(guard, { chatId: null, kind: "edit", key: COMMAND_KEY["quick-add"], operationId: id, sendId: id, notify }, () => quickAdd.call(context.writes.writer, calendar, line, { notify }));
219
+ }
220
+ catch (error) {
221
+ if (isCliError(error) && error.code === "outcome_unknown")
222
+ throw new CliError("outcome_unknown", `${error.message} — the provider may have made the event, and a create from text cannot be retried safely: ` +
223
+ "sync, then look with events list changed_at>=today before adding it again", { ...error.details, operation_id: id, retryable: false });
224
+ throw error;
225
+ }
226
+ let saved;
227
+ try {
228
+ ;
229
+ [saved] = await context.store.saveEntries(calendar.id, [entry], context.now);
230
+ }
231
+ catch (error) {
232
+ throw new CliError(isCliError(error) ? error.code : "invalid_response", `the provider made event ${entry.externalId}, but the local store did not save it ` +
233
+ `(${error instanceof Error ? error.message : String(error)}); sync to store it — do not create it again`, { ...(isCliError(error) ? error.details : {}), operation_id: id, id: entry.externalId, retryable: false });
234
+ }
235
+ return answerOf(id, saved, (entry.recurrence?.length ?? 0) > 0);
236
+ };
237
+ /** The most events one import sends: a file of more is refused before anything goes. */
238
+ export const IMPORT_MAX = 500;
239
+ /**
240
+ * The events of an iCalendar text into a calendar, one provider call each, in the file's order. The whole import is
241
+ * one write for the guard: one question, one journal line. A failure part way names the UIDs already imported.
242
+ */
243
+ export const importEvents = async (context, calendarReference, text) => {
244
+ const writer = context.writes.writer;
245
+ if (!writer.importEvent)
246
+ throw new CliError("configuration_error", "this provider cannot import events");
247
+ const { events, skipped } = readIcs(text, context.zone);
248
+ if (events.length === 0)
249
+ throw new CliError("validation_error", skipped.length
250
+ ? `no event in the file can be imported: ${skipped.map(({ reason }) => reason).join("; ")}`
251
+ : "the file holds no event (VEVENT)", { reason: "ics.no-events", skipped: skipped.map(({ uid, reason }) => ({ uid, reason })) });
252
+ if (events.length > IMPORT_MAX)
253
+ throw new CliError("validation_error", `the file holds ${events.length} events; one import takes at most ${IMPORT_MAX}`, { reason: "ics.too-many" });
254
+ const calendar = await calendarIn(context.store, context.accountId, calendarReference);
255
+ const guard = writeGuard(context, keysFor(context, "import", false));
256
+ const id = newEventId();
257
+ const imported = [];
258
+ await guardedWrite(guard, { chatId: null, kind: "edit", key: COMMAND_KEY.import, operationId: id, sendId: id, notify: false }, async () => {
259
+ for (const event of events) {
260
+ try {
261
+ const entry = await writer.importEvent?.call(writer, calendar, event);
262
+ if (!entry)
263
+ throw new CliError("invalid_response", "the provider returned no event");
264
+ const [saved] = await context.store.saveEntries(calendar.id, [entry], context.now);
265
+ imported.push({
266
+ uid: event.uid,
267
+ event: saved?.eventId == null ? null : `event:${saved.eventId}`,
268
+ entry: saved ? entryItem(saved) : null,
269
+ });
270
+ }
271
+ catch (error) {
272
+ const done = imported.map(({ uid }) => uid);
273
+ throw new CliError(isCliError(error) ? error.code : "provider_error", `${error instanceof Error ? error.message : String(error)} — at the event with UID ${event.uid}; ` +
274
+ `${done.length} of ${events.length} were imported before it (details.imported). Running the import ` +
275
+ "again sends every event again under its UID", {
276
+ ...(isCliError(error) ? error.details : {}),
277
+ operation_id: id,
278
+ imported: done,
279
+ failed: event.uid,
280
+ retryable: false,
281
+ });
282
+ }
283
+ }
284
+ });
285
+ return {
286
+ operation_id: id,
287
+ calendar: String(calendar.id),
288
+ imported,
289
+ skipped: skipped.map(({ uid, reason }) => ({ uid, reason })),
290
+ };
291
+ };
194
292
  const targetOf = async (context, reference, { calendar, scope }) => {
195
293
  const details = await eventOf(context.store, context.accountId, eventIdOf(reference));
196
294
  const calendars = await context.store.calendars(context.accountId);
@@ -3,7 +3,7 @@ import { CliError } from "@wirecat/cli-base";
3
3
  import { annotate, describeProgram } from "@wirecat/cli-base/commands";
4
4
  import { Command } from "commander";
5
5
  import { environmentOf } from "../context.js";
6
- import { changesAuthorityAt, fetchesOnlyAt, LOCAL_READ, REMOTE_WRITE } from "../effects.js";
6
+ import { changesAuthorityAt, LOCAL_READ, REMOTE_WRITE, readsOverMcpAt } from "../effects.js";
7
7
  import { itemOf, renderAnswer } from "../envelope.js";
8
8
  import { serverEntry } from "../messenger/mcp-command.js";
9
9
  import { nextStep, rootOf } from "../steps.js";
@@ -37,7 +37,7 @@ export const botMcpCommand = (bot) => {
37
37
  bot,
38
38
  program: { run, commands: describeProgram(rootOf(this)) },
39
39
  authority: changesAuthorityAt(rootOf(this)),
40
- fetchOnly: fetchesOnlyAt(rootOf(this)),
40
+ fetchOnly: readsOverMcpAt(rootOf(this)),
41
41
  settings: context.settings,
42
42
  levels: () => bot.resolveSettings(this.optsWithGlobals(), { env: context.env, kind: "bot" }).permissions,
43
43
  env: context.env,
@@ -50,6 +50,20 @@ export declare const changesStore: <T extends Command>(command: T) => T;
50
50
  export declare const fetchesOnly: (command: Command) => boolean;
51
51
  /** `fetchesOnly` by command path under `root`, as an MCP catalogue asks it; an unknown path is not one. */
52
52
  export declare const fetchesOnlyAt: (root: Command) => (path: readonly string[]) => boolean;
53
+ /**
54
+ * Marks a command whose only local write is the file one owner-only option names (`events export --output`): its
55
+ * effects stay a write, as WC-CMD-17 asks, but a run without the option only reads. An MCP tool never offers the
56
+ * option, so it runs through `read`; `<PREFIX>_READ_ONLY` refuses only a run that gives it. `option` is the option's
57
+ * attribute name, `output`.
58
+ */
59
+ export declare const writesFileOnlyWith: <T extends Command>(command: T, option: string) => T;
60
+ /**
61
+ * What an MCP server runs through `read`: a command that only fetches into the store (`fetchesOnly`), and one whose
62
+ * only write is a file the tool cannot name (`writesFileOnlyWith`).
63
+ */
64
+ export declare const readsOverMcp: (command: Command) => boolean;
65
+ /** `readsOverMcp` by command path under `root`, as an MCP catalogue asks it; an unknown path is not one. */
66
+ export declare const readsOverMcpAt: (root: Command) => (path: readonly string[]) => boolean;
53
67
  /**
54
68
  * Whether `<PREFIX>_READ_ONLY` refuses the command as a change to the store. Every write that stays on this machine
55
69
  * is refused unless `destroysNothing` marks it, so a new one fails closed (WC-PERM-12). A fetch (`remote: "read"`)
@@ -1,4 +1,4 @@
1
- import { annotate, annotateOption, metaOf, stepTargets } from "@wirecat/cli-base/commands";
1
+ import { annotate, annotateOption, metaOf, optionMetaOf, stepTargets, } from "@wirecat/cli-base/commands";
2
2
  import { Option } from "commander";
3
3
  import { readOnlyLockOf, readOnlyRefusal } from "../ports/app.js";
4
4
  import { keyForCommand } from "../sends/permissions.js";
@@ -93,6 +93,36 @@ export const fetchesOnlyAt = (root) => (path) => {
93
93
  at = at?.commands.find((one) => one.name() === word);
94
94
  return at !== undefined && fetchesOnly(at);
95
95
  };
96
+ const FILE_OPTION = new WeakMap();
97
+ /**
98
+ * Marks a command whose only local write is the file one owner-only option names (`events export --output`): its
99
+ * effects stay a write, as WC-CMD-17 asks, but a run without the option only reads. An MCP tool never offers the
100
+ * option, so it runs through `read`; `<PREFIX>_READ_ONLY` refuses only a run that gives it. `option` is the option's
101
+ * attribute name, `output`.
102
+ */
103
+ export const writesFileOnlyWith = (command, option) => {
104
+ const flag = command.options.find((one) => one.attributeName() === option);
105
+ if (!flag || optionMetaOf(flag).mcp !== false)
106
+ throw new Error(`${command.name()}: writesFileOnlyWith needs --${option} added first, as an owner-only option`);
107
+ FILE_OPTION.set(command, option);
108
+ return command;
109
+ };
110
+ const writesFileNow = (command) => {
111
+ const option = FILE_OPTION.get(command);
112
+ return option === undefined || command.opts()[option] !== undefined;
113
+ };
114
+ /**
115
+ * What an MCP server runs through `read`: a command that only fetches into the store (`fetchesOnly`), and one whose
116
+ * only write is a file the tool cannot name (`writesFileOnlyWith`).
117
+ */
118
+ export const readsOverMcp = (command) => fetchesOnly(command) || FILE_OPTION.has(command);
119
+ /** `readsOverMcp` by command path under `root`, as an MCP catalogue asks it; an unknown path is not one. */
120
+ export const readsOverMcpAt = (root) => (path) => {
121
+ let at = root;
122
+ for (const word of path)
123
+ at = at?.commands.find((one) => one.name() === word);
124
+ return at !== undefined && readsOverMcp(at);
125
+ };
96
126
  /**
97
127
  * Whether `<PREFIX>_READ_ONLY` refuses the command as a change to the store. Every write that stays on this machine
98
128
  * is refused unless `destroysNothing` marks it, so a new one fails closed (WC-PERM-12). A fetch (`remote: "read"`)
@@ -103,7 +133,8 @@ export const isStoreChange = (command) => {
103
133
  return ((effects?.remote === "none" || STORE_WRITES.has(command)) &&
104
134
  effects?.local === "write" &&
105
135
  !KEPT_UNDER_LOCK.has(command) &&
106
- !changesAuthority(command));
136
+ !changesAuthority(command) &&
137
+ writesFileNow(command));
107
138
  };
108
139
  export const STORE_CHANGE_REFUSAL = "change the message store";
109
140
  /** ` --dry-run` where the program has it, so an example of a command that calls a provider calls nothing. */
@@ -95,6 +95,8 @@ export declare const answerSchema: (item?: v.GenericSchema) => v.UnionSchema<[v.
95
95
  }, undefined>, undefined>, undefined>;
96
96
  readonly truncated: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
97
97
  readonly timezone: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
98
+ readonly now: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
99
+ readonly now_local: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "an ISO 8601 time with its offset">]>, undefined>;
98
100
  readonly window: v.OptionalSchema<v.StrictObjectSchema<{
99
101
  readonly since: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
100
102
  readonly until: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -148,6 +150,8 @@ export declare const answerSchema: (item?: v.GenericSchema) => v.UnionSchema<[v.
148
150
  }, undefined>, undefined>, undefined>;
149
151
  readonly truncated: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
150
152
  readonly timezone: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
153
+ readonly now: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
154
+ readonly now_local: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "an ISO 8601 time with its offset">]>, undefined>;
151
155
  readonly window: v.OptionalSchema<v.StrictObjectSchema<{
152
156
  readonly since: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
153
157
  readonly until: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -66,7 +66,7 @@ export { sharedCommandTemplate } from "./contract-templates.js";
66
66
  export { type Closeable, withDeadline } from "./deadline.js";
67
67
  export { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
68
68
  export { DISCOVERY_DESCRIPTIONS } from "./discovery-descriptions.js";
69
- export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, previewFlag, REMOTE_READ, REMOTE_WRITE, reachesOtherService, refuseUnderReadOnly, storeChangeRefusal, } from "./effects.js";
69
+ export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, previewFlag, REMOTE_READ, REMOTE_WRITE, reachesOtherService, readsOverMcp, readsOverMcpAt, refuseUnderReadOnly, storeChangeRefusal, writesFileOnlyWith, } from "./effects.js";
70
70
  export { type CliEntry, type EntryContext, type EntryIo, startCli } from "./entry.js";
71
71
  export { type AnswerOutput, answerSchema, type Body, type ItemBody, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, type ListBody, type ListInput, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
72
72
  export { withAbort } from "./execution.js";
@@ -78,7 +78,7 @@ export { type FirstScreen, type FirstScreenLine, firstScreen, installHelp, WIREC
78
78
  export { type HelpSection, inSection, SECTIONS } from "./help/sections.js";
79
79
  export { bufferedInput, type InputPolicy, inputPolicy, MAX_BUFFERED_INPUT, MAX_SECRET_INPUT, provideInputPolicy, } from "./input-policy.js";
80
80
  export { answerFields, checkJson, type EntityFields, entityFields, type JsonFieldsResolver, type JsonRequest, liftJson, } from "./json-fields.js";
81
- export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists, nativeTerms, pagedList, pageOf, type QueryWords, queryArgument, queryHelp, queryTerms, searchText, termField, timeBounds, typedQuery, unfilteredQuery, withQuery, } from "./list-query.js";
81
+ export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists, nativeTerms, negatableOf, pagedList, pageOf, type QueryWords, queryArgument, queryHelp, queryTerms, searchText, termField, timeBounds, typedQuery, unfilteredQuery, withQuery, } from "./list-query.js";
82
82
  export { MCP_HTTP_DEFAULT_PORT, type McpServerOptions, type McpServerTarget, mcpServerCommand, } from "./mcp-server-command.js";
83
83
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
84
84
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
package/dist/cli/index.js CHANGED
@@ -58,7 +58,7 @@ export { sharedCommandTemplate } from "./contract-templates.js";
58
58
  export { withDeadline } from "./deadline.js";
59
59
  export { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
60
60
  export { DISCOVERY_DESCRIPTIONS } from "./discovery-descriptions.js";
61
- export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, previewFlag, REMOTE_READ, REMOTE_WRITE, reachesOtherService, refuseUnderReadOnly, storeChangeRefusal, } from "./effects.js";
61
+ export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, previewFlag, REMOTE_READ, REMOTE_WRITE, reachesOtherService, readsOverMcp, readsOverMcpAt, refuseUnderReadOnly, storeChangeRefusal, writesFileOnlyWith, } from "./effects.js";
62
62
  export { startCli } from "./entry.js";
63
63
  export { answerSchema, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
64
64
  export { withAbort } from "./execution.js";
@@ -70,7 +70,7 @@ export { firstScreen, installHelp, WIRECAT_TOOLS } from "./help/first-screen.js"
70
70
  export { inSection, SECTIONS } from "./help/sections.js";
71
71
  export { bufferedInput, inputPolicy, MAX_BUFFERED_INPUT, MAX_SECRET_INPUT, provideInputPolicy, } from "./input-policy.js";
72
72
  export { answerFields, checkJson, entityFields, liftJson, } from "./json-fields.js";
73
- export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists, nativeTerms, pagedList, pageOf, queryArgument, queryHelp, queryTerms, searchText, termField, timeBounds, typedQuery, unfilteredQuery, withQuery, } from "./list-query.js";
73
+ export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists, nativeTerms, negatableOf, pagedList, pageOf, queryArgument, queryHelp, queryTerms, searchText, termField, timeBounds, typedQuery, unfilteredQuery, withQuery, } from "./list-query.js";
74
74
  export { MCP_HTTP_DEFAULT_PORT, mcpServerCommand, } from "./mcp-server-command.js";
75
75
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
76
76
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
@@ -4,6 +4,8 @@ import * as v from "valibot";
4
4
  import { type ListBody, type ListInput } from "./envelope.js";
5
5
  import type { Settings } from "./settings.js";
6
6
  export declare const LIST_QUERY_GUIDE = "https://github.com/WireCatLabs/cli-core/blob/main/docs/search/query-language.md";
7
+ /** `[query...]`'s help, which points at the Fields section; an MCP tool has none and says the fields instead. */
8
+ export declare const QUERY_ARGUMENT = "field:value terms that narrow the list \u2014 the fields are below";
7
9
  /**
8
10
  * A list that narrows by one query: `[query...]` last, a Fields section in its help, and the fields in
9
11
  * `commands --json`. The registry is checked here, so a field whose example does not run fails at startup.
@@ -31,7 +33,9 @@ export declare const pageOf: ({ limit, page, all }: Pick<Settings, "limit" | "pa
31
33
  /** A page-numbered list's answer; under `--all` its limit is what it holds. */
32
34
  export declare const pagedList: <T>(command: string, { limit, page, all }: Pick<Settings, "limit" | "page" | "all">, { items, has_more, ...rest }: Omit<ListInput<T>, "limit" | "page" | "cursor">) => ListBody<T>;
33
35
  /** An MCP list's `query` argument: the language the CLI list takes after its arguments. */
34
- export declare const queryArgument: (registry: QueryRegistry) => v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MaxLengthAction<string, 8192, undefined>, v.DescriptionAction<string, "field:value terms; this list filters by none yet, so any term is refused" | `field:value terms joined by AND that narrow the list; fields: ${string}; e.g. ${string}` | `field:value terms that narrow the list (Lucene: AND OR NOT, [a TO b], > <); fields: ${string}; e.g. ${string}`>]>, undefined>;
36
+ export declare const queryArgument: (registry: QueryRegistry) => v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MaxLengthAction<string, 8192, undefined>, v.DescriptionAction<string, string>]>, undefined>;
37
+ /** The fields a native list can also leave out, `-field:value`: what `nativeTermLists` keeps under `-field`. */
38
+ export declare const negatableOf: (registry: QueryRegistry) => string[];
35
39
  /**
36
40
  * The terms of a query a provider answers: field:value joined by AND, each field once. Anything else is a
37
41
  * question the source cannot ask, so it is refused rather than answered from a part of the list.
@@ -39,9 +43,10 @@ export declare const queryArgument: (registry: QueryRegistry) => v.OptionalSchem
39
43
  export declare const nativeTerms: (ast: QueryAst | undefined, command: string) => Map<string, Predicate>;
40
44
  /**
41
45
  * As `nativeTerms`, but each `repeatable` field may be named again, every term kept in order: `person:a person:b`
42
- * asks for both, as AND reads.
46
+ * asks for both, as AND reads. A `negatable` field may also be excluded, `-field:value` or `NOT field:value`, as
47
+ * often as wanted; those terms are kept under `-field`.
43
48
  */
44
- export declare const nativeTermLists: (ast: QueryAst | undefined, command: string, repeatable?: readonly string[]) => Map<string, Predicate[]>;
49
+ export declare const nativeTermLists: (ast: QueryAst | undefined, command: string, repeatable?: readonly string[], negatable?: readonly string[]) => Map<string, Predicate[]>;
45
50
  /**
46
51
  * A store that compares whole values: each field's `field:value` or `field:"two words"`, enum values lowercased
47
52
  * as validation already accepts them. A range or wildcard is refused rather than read as a value.
@@ -1,9 +1,11 @@
1
1
  import { annotate } from "@wirecat/cli-base/commands";
2
- import { dateRange, defineQuery, fieldsHelp, fieldsNamed, OPERATORS_BY_TYPE, parseLucene, QUERY_LIMITS, queryError, queryFieldsOf, timezoneOf, validateQuery, } from "@wirecat/cli-base/query";
2
+ import { dateRange, defineQuery, fieldsHelp, fieldsNamed, OPERATORS_BY_TYPE, parseLucene, QUERY_LIMITS, queryDescription, queryError, queryFieldsOf, timezoneOf, validateQuery, } from "@wirecat/cli-base/query";
3
3
  import * as v from "valibot";
4
4
  import { queryWordsAfterOptions } from "../meetings/cli/index.js";
5
5
  import { listOf } from "./envelope.js";
6
6
  export const LIST_QUERY_GUIDE = "https://github.com/WireCatLabs/cli-core/blob/main/docs/search/query-language.md";
7
+ /** `[query...]`'s help, which points at the Fields section; an MCP tool has none and says the fields instead. */
8
+ export const QUERY_ARGUMENT = "field:value terms that narrow the list — the fields are below";
7
9
  /**
8
10
  * A list that narrows by one query: `[query...]` last, a Fields section in its help, and the fields in
9
11
  * `commands --json`. The registry is checked here, so a field whose example does not run fails at startup.
@@ -12,9 +14,7 @@ export const withQuery = (command, registry) => {
12
14
  const query = defineQuery(registry);
13
15
  annotate(command, { query, output: { entity: registry.entity } });
14
16
  const shown = askable(query);
15
- queryWordsAfterOptions(command).argument("[query...]", shown.length > 0
16
- ? "field:value terms that narrow the list — the fields are below"
17
- : "field:value terms; this list filters by none yet");
17
+ queryWordsAfterOptions(command).argument("[query...]", shown.length > 0 ? QUERY_ARGUMENT : "field:value terms; this list filters by none yet");
18
18
  if (shown.length > 0)
19
19
  command.addHelpText("after", `\n${fieldsHelp({ ...query, fields: shown }, LIST_QUERY_GUIDE)}`);
20
20
  return command;
@@ -97,14 +97,11 @@ export const pagedList = (command, { limit, page, all }, { items, has_more, ...r
97
97
  /** The fields a list can be narrowed by: a provider list only those its source answers. */
98
98
  const askable = (registry) => registry.backend === "native" ? registry.fields.filter((field) => field.native) : registry.fields;
99
99
  /** An MCP list's `query` argument: the language the CLI list takes after its arguments. */
100
- export const queryArgument = (registry) => {
101
- const fields = askable(registry);
102
- return v.optional(v.pipe(v.string(), v.maxLength(QUERY_LIMITS.bytes), v.description(fields.length === 0
103
- ? "field:value terms; this list filters by none yet, so any term is refused"
104
- : `${registry.backend === "native"
105
- ? "field:value terms joined by AND that narrow the list"
106
- : "field:value terms that narrow the list (Lucene: AND OR NOT, [a TO b], > <)"}; fields: ${fields.map((field) => field.name).join(", ")}; e.g. ${fields[0]?.example ?? ""}`)));
107
- };
100
+ export const queryArgument = (registry) => v.optional(v.pipe(v.string(), v.maxLength(QUERY_LIMITS.bytes), v.description(queryDescription(registry))));
101
+ /** The fields a native list can also leave out, `-field:value`: what `nativeTermLists` keeps under `-field`. */
102
+ export const negatableOf = (registry) => askable(registry)
103
+ .filter((field) => field.negatable)
104
+ .map((field) => field.name);
108
105
  /**
109
106
  * The terms of a query a provider answers: field:value joined by AND, each field once. Anything else is a
110
107
  * question the source cannot ask, so it is refused rather than answered from a part of the list.
@@ -112,9 +109,10 @@ export const queryArgument = (registry) => {
112
109
  export const nativeTerms = (ast, command) => new Map([...nativeTermLists(ast, command)].map(([field, [node]]) => [field, node]));
113
110
  /**
114
111
  * As `nativeTerms`, but each `repeatable` field may be named again, every term kept in order: `person:a person:b`
115
- * asks for both, as AND reads.
112
+ * asks for both, as AND reads. A `negatable` field may also be excluded, `-field:value` or `NOT field:value`, as
113
+ * often as wanted; those terms are kept under `-field`.
116
114
  */
117
- export const nativeTermLists = (ast, command, repeatable = []) => {
115
+ export const nativeTermLists = (ast, command, repeatable = [], negatable = []) => {
118
116
  const terms = new Map();
119
117
  if (!ast)
120
118
  return terms;
@@ -123,8 +121,16 @@ export const nativeTermLists = (ast, command, repeatable = []) => {
123
121
  const add = (node) => {
124
122
  if (node.kind === "boolean") {
125
123
  for (const clause of node.clauses) {
124
+ const excluded = clause.occur === "must_not" && clause.node.kind === "predicate" && negatable.includes(clause.node.field);
125
+ if (excluded && clause.node.kind === "predicate") {
126
+ const key = `-${clause.node.field}`;
127
+ terms.set(key, [...(terms.get(key) ?? []), clause.node]);
128
+ continue;
129
+ }
126
130
  if (clause.occur !== "must")
127
- queryError("query.unsupported-placement", clause.node.span, "this list takes only field:value terms joined by AND", context);
131
+ queryError("query.unsupported-placement", clause.node.span, negatable.length
132
+ ? `this list takes only field:value terms joined by AND, and -field:value for ${negatable.join(", ")}`
133
+ : "this list takes only field:value terms joined by AND", context);
128
134
  add(clause.node);
129
135
  }
130
136
  return;
@@ -3,9 +3,9 @@ import type { CacheDatabase } from "../../store/driver.js";
3
3
  import type { Messenger } from "./context.js";
4
4
  /** `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. */
5
5
  export declare const storeSummary: (env: NodeJS.ProcessEnv) => Promise<{
6
+ error?: undefined;
6
7
  path: string;
7
8
  exists: boolean;
8
- error?: undefined;
9
9
  } | {
10
10
  path: string;
11
11
  exists: boolean;
@@ -62,6 +62,13 @@ const OTHERS = [
62
62
  validation("event.repeat-split", "the series cannot be split at this occurrence, or was split there already"),
63
63
  validation("event.repeat-count", "a COUNT series is split only with a rule for its new part"),
64
64
  validation("event.unmovable", "the provider keeps this kind of event on its calendar"),
65
+ validation("event.text-with-fields", "--text takes the whole event from its line, so no field option goes with it"),
66
+ validation("calendar.ambiguous", "two or more calendars have this name; name one by its id"),
67
+ validation("export.too-many", "the query finds more events than one export writes"),
68
+ validation("ics.not-icalendar", "the file is not iCalendar"),
69
+ validation("ics.no-events", "the file holds no event that can be imported"),
70
+ validation("ics.too-many", "the file holds more events than one import sends"),
71
+ validation("ics.too-large", "the file is larger than one import reads"),
65
72
  ["calendar.read-only", "permission_error", "the calendar takes no new events from this account"],
66
73
  ["calendar.unsynced", "not_found", "the calendar account has no synced calendar in the store"],
67
74
  ["calendar.sync-incomplete", "not_found", "a synced calendar's sync never finished or is mid-round"],
package/dist/mcp/agent.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { Readable } from "node:stream";
2
2
  import { CliError, captureStreams, errorObjectOf } from "@wirecat/cli-base";
3
3
  import { flatten, } from "@wirecat/cli-base/commands";
4
+ import { queryDescription } from "@wirecat/cli-base/query";
4
5
  import { writes } from "@wirecat/cli-base/standards";
5
6
  import { VOCABULARY } from "@wirecat/cli-base/standards/tables";
6
7
  import * as v from "valibot";
7
8
  import { commandAvailability } from "../cli/command-availability.js";
8
9
  import { changesMessenger } from "../cli/effects.js";
10
+ import { QUERY_ARGUMENT } from "../cli/list-query.js";
9
11
  import { lentConnection } from "../cli/messenger/serve-relay.js";
10
12
  import { newRunId } from "../runs/run.js";
11
13
  import { enclosingTraceId, inRun, traceOf } from "../runs/scope.js";
@@ -78,10 +80,17 @@ const optionField = (option, long, negated) => {
78
80
  },
79
81
  };
80
82
  };
81
- const argumentField = (argument) => {
83
+ /** A list's query names its fields here: the help's Fields section, which its description points to, is not sent. */
84
+ const argumentField = (argument, query) => {
85
+ const own = argument.description || argument.name;
86
+ const about = query && argument.name === "query"
87
+ ? own === QUERY_ARGUMENT
88
+ ? queryDescription(query)
89
+ : `${own}; ${queryDescription(query)}`
90
+ : own;
82
91
  const one = argument.choices
83
92
  ? v.pipe(v.picklist(argument.choices), v.description(argument.description))
84
- : text(argument.description || argument.name);
93
+ : text(about);
85
94
  return {
86
95
  name: snake(argument.name),
87
96
  required: argument.required,
@@ -109,7 +118,7 @@ const stdinField = ({ name, description, json }) => ({
109
118
  argv: () => [],
110
119
  });
111
120
  const fieldsOf = (info, { omit = [], offline = false, stdin }) => {
112
- const fields = info.arguments.map(argumentField);
121
+ const fields = info.arguments.map((argument) => argumentField(argument, info.query));
113
122
  const read = stdin?.(info.path);
114
123
  if (read)
115
124
  fields.push(stdinField(read));
@@ -4,7 +4,7 @@ import { CliError, mcpErrorResult } from "@wirecat/cli-base";
4
4
  import { describeProgram } from "@wirecat/cli-base/commands";
5
5
  import { skillResource } from "@wirecat/cli-base/skill";
6
6
  import { environmentOf } from "../cli/context.js";
7
- import { changesAuthorityAt, fetchesOnlyAt, judgedTargets } from "../cli/effects.js";
7
+ import { changesAuthorityAt, judgedTargets, readsOverMcpAt } from "../cli/effects.js";
8
8
  import { DEFAULT_OUTPUT_BYTES } from "../cli/execution.js";
9
9
  import { rootOf } from "../cli/steps.js";
10
10
  import { mcpUrlOf } from "../http/public-url.js";
@@ -46,7 +46,7 @@ export const createAppServer = (command, context, options) => {
46
46
  currentLevels: levels,
47
47
  readOnly: readOnly(),
48
48
  authority,
49
- fetchOnly: fetchesOnlyAt(root),
49
+ fetchOnly: readsOverMcpAt(root),
50
50
  permissions: options.permissions,
51
51
  });
52
52
  const availability = (one) => () => {