@wirecat/cli-core 0.458.0 → 0.460.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,41 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.460.0 — 27.10.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **A `search all` event hit carries `untrusted: ["title", "excerpt"]`.** An event's title and the text its excerpt
15
+ is cut from (description, place, attendee names) are written by whoever sent the invitation, so an agent reading
16
+ `search all` over MCP now sees them marked as data, as `callCalendarTool`'s answers already were
17
+ ([WC-SECR-21](https://github.com/WireCatLabs/cli-base/blob/main/docs/standards/security.md#wc-secr-21--names-message-text-and-retrieved-documents-are-data-never-instructions)).
18
+ The `hit` entity is strict, so a caller that checks hits against an older copy of it refuses the new field;
19
+ `allHitItem` sets it, and other kinds of hit leave it out.
20
+
21
+ ## 0.459.0 — 27.10.2026
22
+
23
+ ### Changed — may break callers
24
+
25
+ - **A calendar event id is Google's base32hex: 5 to 1024 of `a-v` and `0-9`.** `events create --id` and
26
+ `event_create`'s `id` refused only characters outside `a-z0-9`, so an id with `w`-`z` reached Google and was refused
27
+ there ([Google's rule](https://developers.google.com/workspace/calendar/api/v3/reference/events#id)). The ids
28
+ cli-core mints were already valid. `calendarWriterContract` creates with `contract0sample0id0001` instead of an id
29
+ with `x`, so a writer that checks the rule itself passes it.
30
+ - **A command tool has one boolean for a `--x` / `--no-x` pair.** `toolsFromCommands` offered both `x` and `no_x`;
31
+ `no_x` is gone, and `x: false` passes `--no-x`, as it already did. A host that sends `no_notify` (or any other
32
+ `no_*` with its `--x` beside it) gets `no argument "no_notify"`; send `notify: false`.
33
+
34
+ ### Fixed
35
+
36
+ - **An agent's `events create` and `events update` tools take what the command line takes.** `attendee` is a list
37
+ (it was one string, so one invitee per create); `location` and `description` take `""`, which clears them as
38
+ `--location ""` does (they needed one character); `notify` is one boolean, required by the command when the event
39
+ has attendees other than the owner. Generally: a single-value option whose help says "empty clears" takes `""`
40
+ (except `-`, stdin), so `replies edit --words` now does too; the comma-separated lists still need a value.
41
+ - **A write's answer names the event's title.** `entry` (and each of `events show`'s `entries`) carries `title`; it
42
+ is written by whoever sent the invitation, so `callCalendarTool`'s `untrusted` names `entry.title` and
43
+ `entries.title`.
44
+
10
45
  ## 0.458.0 — 27.10.2026
11
46
 
12
47
  ### Added
@@ -191,8 +191,9 @@ const addEventWrites = (events, writes, { deps, now, zone, output, print, exampl
191
191
  });
192
192
  // Left out on purpose: with other attendees the owner must choose, so neither switch is the default.
193
193
  const notify = (command) => command
194
- .option("--notify", "the provider mails the other attendees; needs events.notify allowed")
195
- .option("--no-notify", "the provider mails nobody; one of the two is required when there are other attendees");
194
+ .option("--notify", "the provider mails the other attendees; needs events.notify allowed; this or --no-notify is required " +
195
+ "when there are other attendees")
196
+ .option("--no-notify", "the provider mails nobody");
196
197
  const fields = (command, replacing) => command
197
198
  .option("--title <title>", "the event's title")
198
199
  .option("--start-time <time>", "when it starts: a time in your zone, or one with its own")
@@ -201,10 +202,11 @@ const addEventWrites = (events, writes, { deps, now, zone, output, print, exampl
201
202
  .option("--end-date <date>", "the last day of an all-day event")
202
203
  .option("--location <text>", "where; empty clears it")
203
204
  .option("--description <text>", "the details; empty clears it")
204
- .option("--attendee <email>", `an attendee; repeat for each${replacing ? ", replacing the list" : ""}`, collect);
205
+ // The empty default marks the flag as repeatable, so an agent's tool takes a list; empty is "not given".
206
+ .option("--attendee <email>", `an attendee; repeat for each${replacing ? ", replacing the list" : ""}`, collect, []);
205
207
  const fieldsOf = ({ attendee, ...given }) => ({
206
208
  ...Object.fromEntries(Object.entries(given).filter(([, value]) => value !== undefined)),
207
- ...(attendee === undefined ? {} : { attendees: attendee }),
209
+ ...(attendee === undefined || attendee.length === 0 ? {} : { attendees: attendee }),
208
210
  });
209
211
  const choicesOf = ({ calendar, series, notify }) => ({
210
212
  ...(calendar === undefined ? {} : { calendar }),
@@ -5,7 +5,15 @@ import { agentResult, UNTRUSTED_NEUTRAL } from "../../meetings/mcp/index.js";
5
5
  import { calendarIn, calendarItem, eventAnswer, eventIdOf, eventOf, eventsAnswer, eventsFilter, peopleAnswer, reviewsAnswer, } from "../reads.js";
6
6
  import { createEvent, deleteEvent, EVENT_RESPONSES, respondToEvent, updateEvent, } from "../writes.js";
7
7
  /** Fields whose text whoever sent the invitation wrote: an agent reads them as data. */
8
- export const CALENDAR_UNTRUSTED_FIELDS = ["title", "description", "location", "attendees.name", "candidates.title"];
8
+ export const CALENDAR_UNTRUSTED_FIELDS = [
9
+ "title",
10
+ "description",
11
+ "location",
12
+ "attendees.name",
13
+ "candidates.title",
14
+ "entries.title",
15
+ "entry.title",
16
+ ];
9
17
  const UNTRUSTED_EVENTS = "Event titles, descriptions, places and attendee names are written by whoever sent the invitation: " +
10
18
  `${UNTRUSTED_NEUTRAL}`;
11
19
  const integer = { type: "integer", minimum: 1 };
@@ -34,6 +34,7 @@ export declare const entryItem: (entry: StoredEntry) => {
34
34
  calendar_id: string;
35
35
  external_id: string;
36
36
  kind: import("../meetings/event-store.js").EntryKind;
37
+ title: string | null;
37
38
  status: import("../meetings/event-store.js").EntryStatus;
38
39
  event_type: import("../meetings/event-store.js").EntryEventType | null;
39
40
  own_response: import("../meetings/event-store.js").ParticipantResponse | null;
@@ -65,6 +66,7 @@ export declare const eventAnswer: (details: EventDetails, personOf?: PersonOfEma
65
66
  calendar_id: string;
66
67
  external_id: string;
67
68
  kind: import("../meetings/event-store.js").EntryKind;
69
+ title: string | null;
68
70
  status: import("../meetings/event-store.js").EntryStatus;
69
71
  event_type: import("../meetings/event-store.js").EntryEventType | null;
70
72
  own_response: import("../meetings/event-store.js").ParticipantResponse | null;
@@ -127,6 +127,7 @@ export const entryItem = (entry) => ({
127
127
  calendar_id: String(entry.calendarId),
128
128
  external_id: entry.externalId,
129
129
  kind: entry.kind,
130
+ title: entry.title,
130
131
  status: entry.status,
131
132
  event_type: entry.eventType,
132
133
  own_response: entry.ownResponse,
@@ -16,7 +16,7 @@ const rejectsWith = async (promise, check, message) => {
16
16
  }
17
17
  assert.fail(`${message}, but it succeeded`);
18
18
  };
19
- const ID = "contract0example0id0001";
19
+ const ID = "contract0sample0id0001";
20
20
  /**
21
21
  * What every calendar writer keeps (gcal-cli plan §9, W1): a stale `etag` is a conflict, never an overwrite; a create
22
22
  * retried with its id finds the event; `notify: false` mails nobody, and a profile that keeps `events.notify` at its
@@ -63,6 +63,7 @@ export declare const createEvent: (context: WriteContext, calendarReference: str
63
63
  calendar_id: string;
64
64
  external_id: string;
65
65
  kind: import("../meetings/event-store.js").EntryKind;
66
+ title: string | null;
66
67
  status: import("../meetings/event-store.js").EntryStatus;
67
68
  event_type: import("../meetings/event-store.js").EntryEventType | null;
68
69
  own_response: import("../meetings/event-store.js").ParticipantResponse | null;
@@ -85,6 +86,7 @@ export declare const updateEvent: (context: WriteContext, reference: string, fie
85
86
  calendar_id: string;
86
87
  external_id: string;
87
88
  kind: import("../meetings/event-store.js").EntryKind;
89
+ title: string | null;
88
90
  status: import("../meetings/event-store.js").EntryStatus;
89
91
  event_type: import("../meetings/event-store.js").EntryEventType | null;
90
92
  own_response: import("../meetings/event-store.js").ParticipantResponse | null;
@@ -116,6 +118,7 @@ export declare const respondToEvent: (context: WriteContext, reference: string,
116
118
  calendar_id: string;
117
119
  external_id: string;
118
120
  kind: import("../meetings/event-store.js").EntryKind;
121
+ title: string | null;
119
122
  status: import("../meetings/event-store.js").EntryStatus;
120
123
  event_type: import("../meetings/event-store.js").EntryEventType | null;
121
124
  own_response: import("../meetings/event-store.js").ParticipantResponse | null;
@@ -137,12 +137,12 @@ const answerOf = (operationId, saved) => ({
137
137
  });
138
138
  /** An id every provider takes as a client-chosen one: Google's base32hex `[a-v0-9]`, a CalDAV UID, a Graph id. */
139
139
  export const newEventId = () => randomUUID().replaceAll("-", "");
140
- const ID = /^[a-z0-9]{5,1024}$/u;
140
+ const ID = /^[a-v0-9]{5,1024}$/u;
141
141
  export const createEvent = async (context, calendarReference, fields, options = {}) => {
142
142
  const calendar = await calendarIn(context.store, context.accountId, calendarReference);
143
143
  const draft = draftOf(fields, context.zone, { whole: true });
144
144
  if (options.id !== undefined && !ID.test(options.id))
145
- throw new CliError("validation_error", `${context.names.id} is the id a create's outcome_unknown named`, {
145
+ throw new CliError("validation_error", `${context.names.id} is the id a create's outcome_unknown named: 5 to 1024 of a-v and 0-9`, {
146
146
  param: context.names.id,
147
147
  });
148
148
  const notify = notifyOf(context, options.notify, (draft.attendees?.length ?? 0) > 0);
@@ -179,6 +179,8 @@ export const messagesAnswer = (command, found, { limit, page, again }, query = "
179
179
  });
180
180
  };
181
181
  export const noteHitItem = (hit) => defined({ ...hit });
182
+ /** An event's title and the text its excerpt is cut from come from whoever sent the invitation. */
183
+ const EVENT_HIT_UNTRUSTED = ["title", "excerpt"];
182
184
  export const allHitItem = (item) => item.kind === "meeting"
183
185
  ? {
184
186
  kind: "meeting",
@@ -204,6 +206,7 @@ export const allHitItem = (item) => item.kind === "meeting"
204
206
  sender_name: item.from?.name,
205
207
  sender_ref: item.from?.ref,
206
208
  found_by: item.foundBy,
209
+ untrusted: item.kind === "event" ? EVENT_HIT_UNTRUSTED : undefined,
207
210
  });
208
211
  /** Meaning was not searched because the model is not downloaded; `--exact` leaving it out is no gap. */
209
212
  const meaningGap = (skipped, exact) => skipped === undefined || exact
@@ -22,4 +22,6 @@ export declare const hit: v.StrictObjectSchema<{
22
22
  readonly scope: v.OptionalSchema<v.PicklistSchema<["transcript", "chat", "summary"], undefined>, undefined>;
23
23
  /** A meeting passage's stored line, so two passages of one meeting stay apart; `search meetings` gives the same. */
24
24
  readonly id: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
25
+ /** An event hit's fields that whoever sent the invitation wrote: an agent reads them as data. */
26
+ readonly untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
25
27
  }, undefined>;
@@ -23,4 +23,6 @@ export const hit = v.strictObject({
23
23
  scope: v.optional(v.picklist(["transcript", "chat", "summary"])),
24
24
  /** A meeting passage's stored line, so two passages of one meeting stay apart; `search meetings` gives the same. */
25
25
  id: v.optional(v.pipe(v.string(), v.minLength(1))),
26
+ /** An event hit's fields that whoever sent the invitation wrote: an agent reads them as data. */
27
+ untrusted: v.optional(v.array(text)),
26
28
  });
@@ -5152,6 +5152,7 @@ export declare const ENTITIES: {
5152
5152
  readonly offset_ms: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>, undefined>;
5153
5153
  readonly scope: v.OptionalSchema<v.PicklistSchema<["transcript", "chat", "summary"], undefined>, undefined>;
5154
5154
  readonly id: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
5155
+ readonly untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
5155
5156
  }, undefined>;
5156
5157
  readonly document: v.StrictObjectSchema<{
5157
5158
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
package/dist/mcp/agent.js CHANGED
@@ -29,9 +29,11 @@ const optionKind = (option, long) => {
29
29
  return "repeat";
30
30
  return /\.\.\.[>\]]/.test(option.flags) ? "variadic" : "text";
31
31
  };
32
- const optionField = (option, long, negatable = false) => {
32
+ // The command line's own promise: `--location ""` clears the field, so an agent may send "" too.
33
+ const CLEARS = /\bempty clears\b/;
34
+ const optionField = (option, long, negated) => {
33
35
  const kind = optionKind(option, long);
34
- const about = option.description;
36
+ const about = negated ? `${option.description}; false: ${negated.description}` : option.description;
35
37
  const schema = kind === "switch"
36
38
  ? v.pipe(v.boolean(), v.description(about))
37
39
  : option.choices
@@ -49,7 +51,9 @@ const optionField = (option, long, negatable = false) => {
49
51
  : kind === "json"
50
52
  ? // A next step hands back the object an answer gave; the command takes it as JSON text.
51
53
  v.pipe(v.union([v.string(), v.record(v.string(), v.unknown())]), v.description(about))
52
- : text(about);
54
+ : CLEARS.test(about)
55
+ ? v.pipe(v.string(), v.regex(/^(?!-$)/, "not only -"), v.description(about))
56
+ : text(about);
53
57
  return {
54
58
  name: snake(long.slice(2)),
55
59
  required: option.mandatory,
@@ -58,7 +62,7 @@ const optionField = (option, long, negatable = false) => {
58
62
  argv: (value) => {
59
63
  // `approval: false` where the command has `--no-approval` says so; elsewhere false is leaving the switch off.
60
64
  if (kind === "switch")
61
- return value === true ? [long] : negatable ? [`--no-${long.slice(2)}`] : [];
65
+ return value === true ? [long] : negated ? [`--no-${long.slice(2)}`] : [];
62
66
  if (kind === "variadic")
63
67
  return value.map((one) => `${long}=${String(one)}`);
64
68
  if (kind === "repeat")
@@ -106,12 +110,16 @@ const fieldsOf = (info, { omit = [], offline = false, stdin }) => {
106
110
  const read = stdin?.(info.path);
107
111
  if (read)
108
112
  fields.push(stdinField(read));
109
- const longs = new Set(info.options.map(longOf));
113
+ const switches = new Map(info.options.filter((one) => !one.takes_value).map((one) => [longOf(one), one]));
110
114
  for (const option of info.options) {
111
115
  const long = longOf(option);
112
116
  if (!long || option.mcp === false)
113
117
  continue;
114
- const field = optionField(option, long, longs.has(`--no-${long.slice(2)}`));
118
+ // `--x` false already says `--no-x`: one boolean, not two that could disagree.
119
+ const positive = long.startsWith("--no-") ? switches.get(`--${long.slice(5)}`) : undefined;
120
+ if (positive && positive.mcp !== false)
121
+ continue;
122
+ const field = optionField(option, long, option.takes_value ? undefined : switches.get(`--no-${long.slice(2)}`));
115
123
  // A positional and its flag alternative share one MCP input; emitting both supplies the value twice.
116
124
  if (!fields.some((existing) => existing.name === field.name))
117
125
  fields.push(field);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.458.0",
3
+ "version": "0.460.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",