@bli-cockpit/cli 0.2.67 → 0.2.68

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.
@@ -0,0 +1,364 @@
1
+ /**
2
+ * `cockpit cal` — the calendars a person attached, from a terminal
3
+ * (BLI-3709).
4
+ *
5
+ * Ten verbs over `/api/cal/**`, the same doors the browser will use when the
6
+ * UI lands. Every one of those routes already accepts the collector device
7
+ * token (`resolveCaller({ allowDeviceToken: true })`), so this command is a
8
+ * terminal in front of an existing door and adds no authority of its own.
9
+ *
10
+ * The CLI is the FIRST surface for this wave, deliberately (AGENTS.md, "CLI is
11
+ * king"): "when is standup" is a question with a one-line answer, and every
12
+ * bug fixed here is fixed for the browser and MCP halves that share the doors.
13
+ *
14
+ * TWO RULES WORTH KNOWING BEFORE READING THE CODE:
15
+ *
16
+ * - **The secret address never touches argv.** `add-ical` reads it from STDIN
17
+ * and there is no flag that would take it (`local-args-tower-cal.ts` says
18
+ * why in full). It is the one credential this surface handles.
19
+ * - **The zone is always sent.** Every read passes `--tz`, or this machine's
20
+ * own zone from `Intl`, because a window computed server-side would be
21
+ * computed in UTC and "today" would end at 5pm in Vancouver.
22
+ */
23
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
24
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
25
+ const TAG = "[cal cli]";
26
+ const READ_DEADLINE_MS = 30_000;
27
+ const WRITE_DEADLINE_MS = 60_000;
28
+ /** A first read of a crowded calendar can take a while. */
29
+ const SYNC_DEADLINE_MS = 120_000;
30
+ const URL_MAX_CHARS = 2_000;
31
+ export async function runCal(command, io) {
32
+ const door = await openAgentDoor("cal", command, io);
33
+ switch (command.action) {
34
+ case "today":
35
+ case "week":
36
+ case "next":
37
+ return window(command, door);
38
+ case "find":
39
+ return find(command, door);
40
+ case "calendars":
41
+ return listCalendars(door);
42
+ case "add-ical":
43
+ return addIcal(command, door);
44
+ case "create":
45
+ return create(command, door);
46
+ case "share":
47
+ return share(command, door);
48
+ case "detach":
49
+ return detach(command, door);
50
+ case "sync":
51
+ return sync(command, door);
52
+ }
53
+ }
54
+ /**
55
+ * This machine's own zone, which is what a person means by "today" unless they
56
+ * say otherwise. `Intl` knows it; there is nothing to configure and nothing to
57
+ * get out of step with the operating system.
58
+ */
59
+ export function localTimeZone() {
60
+ try {
61
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
62
+ }
63
+ catch {
64
+ return "UTC";
65
+ }
66
+ }
67
+ function windowQuery(command) {
68
+ const query = new URLSearchParams({ tz: command.timeZone ?? localTimeZone() });
69
+ if (command.calendarId)
70
+ query.set("calendar_id", command.calendarId);
71
+ if (command.limit !== undefined)
72
+ query.set("limit", String(command.limit));
73
+ if (command.includeDeselected)
74
+ query.set("all", "1");
75
+ if (command.offset !== undefined)
76
+ query.set("offset", String(command.offset));
77
+ if (command.hours !== undefined)
78
+ query.set("hours", String(command.hours));
79
+ if (command.from)
80
+ query.set("from", command.from);
81
+ if (command.to)
82
+ query.set("to", command.to);
83
+ return query;
84
+ }
85
+ async function window(command, door) {
86
+ // `--from`/`--to` beat a named window, on any of the three verbs: somebody
87
+ // who named two dates meant them.
88
+ const path = command.from && command.to ? "events" : command.action;
89
+ const answer = await askAgentDoor(door, {
90
+ path: `/api/cal/${path}?${windowQuery(command).toString()}`,
91
+ method: "GET",
92
+ label: `cal ${command.action}`,
93
+ timeoutMs: READ_DEADLINE_MS,
94
+ });
95
+ if (!answer.ok)
96
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
97
+ const body = answer.body;
98
+ if (door.json)
99
+ return emitAgentDoor(door, { ok: true, ...body });
100
+ const zone = body.window?.time_zone ?? localTimeZone();
101
+ const events = body.events ?? [];
102
+ if (events.length === 0) {
103
+ writeLine(door.io.stdout, (body.calendars ?? []).length === 0
104
+ ? "No calendars attached yet. `cockpit cal add-ical` for a personal one, or connect Google in the browser."
105
+ : `Nothing on ${describeWindow(body, zone)}.`);
106
+ return 0;
107
+ }
108
+ writeLine(door.io.stdout, describeWindow(body, zone));
109
+ writeLine(door.io.stdout, "");
110
+ let day = "";
111
+ for (const event of events) {
112
+ const eventDay = dayLabel(event.starts_at, zone);
113
+ if (eventDay !== day) {
114
+ if (day !== "")
115
+ writeLine(door.io.stdout, "");
116
+ writeLine(door.io.stdout, eventDay);
117
+ day = eventDay;
118
+ }
119
+ writeLine(door.io.stdout, eventLine(event, zone));
120
+ }
121
+ writeLine(door.io.stdout, "");
122
+ writeLine(door.io.stdout, `${events.length} event(s) across ${(body.calendars ?? []).length} calendar(s)${body.truncated ? " — more than fitted in one page" : ""}.`);
123
+ // Never silent about a series this Tower could not expand.
124
+ for (const note of body.notes ?? []) {
125
+ writeLine(door.io.stdout, `Note: "${note.title}" repeats on a rule Tower does not expand (${note.detail.join(", ")}), so only its first occurrence is shown.`);
126
+ }
127
+ return 0;
128
+ }
129
+ function describeWindow(body, zone) {
130
+ if (!body.window)
131
+ return "That window";
132
+ const from = dayLabel(body.window.from, zone);
133
+ const to = dayLabel(new Date(new Date(body.window.to).getTime() - 1).toISOString(), zone);
134
+ if (body.window.label === "next")
135
+ return `Next, from now (${zone})`;
136
+ return from === to ? `${from} (${zone})` : `${from} to ${to} (${zone})`;
137
+ }
138
+ function eventLine(event, zone) {
139
+ const when = event.all_day ? "all day" : `${clock(event.starts_at, zone)}–${clock(event.ends_at, zone)}`;
140
+ const marks = `${event.recurring ? "↻" : " "}${event.status === "tentative" ? "?" : " "}`;
141
+ const where = event.location ? ` @ ${event.location.slice(0, 40)}` : "";
142
+ const who = event.attendee_count > 0 ? ` (${event.attendee_count})` : "";
143
+ return `${marks} ${when.padEnd(13)} ${event.title.slice(0, 52).padEnd(52)}${where}${who}`;
144
+ }
145
+ /** `Mon 7 Sep`, in the reader's zone. */
146
+ function dayLabel(instant, zone) {
147
+ try {
148
+ return new Intl.DateTimeFormat("en-GB", {
149
+ timeZone: zone,
150
+ weekday: "short",
151
+ day: "numeric",
152
+ month: "short",
153
+ }).format(new Date(instant));
154
+ }
155
+ catch {
156
+ return instant.slice(0, 10);
157
+ }
158
+ }
159
+ function clock(instant, zone) {
160
+ try {
161
+ return new Intl.DateTimeFormat("en-GB", {
162
+ timeZone: zone,
163
+ hour: "2-digit",
164
+ minute: "2-digit",
165
+ hour12: false,
166
+ }).format(new Date(instant));
167
+ }
168
+ catch {
169
+ return instant.slice(11, 16);
170
+ }
171
+ }
172
+ async function find(command, door) {
173
+ const query = new URLSearchParams({ q: command.query ?? "" });
174
+ if (command.calendarId)
175
+ query.set("calendar_id", command.calendarId);
176
+ if (command.limit !== undefined)
177
+ query.set("limit", String(command.limit));
178
+ const answer = await askAgentDoor(door, {
179
+ path: `/api/cal/find?${query.toString()}`,
180
+ method: "GET",
181
+ label: "cal find",
182
+ timeoutMs: READ_DEADLINE_MS,
183
+ });
184
+ if (!answer.ok)
185
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
186
+ const matches = answer.body.matches ?? [];
187
+ if (door.json)
188
+ return emitAgentDoor(door, { ok: true, query: command.query, matches });
189
+ if (matches.length === 0) {
190
+ writeLine(door.io.stdout, `Nothing in your calendars matched "${command.query}".`);
191
+ return 0;
192
+ }
193
+ const zone = command.timeZone ?? localTimeZone();
194
+ for (const match of matches) {
195
+ const when = match.next_occurrence
196
+ ? `${dayLabel(match.next_occurrence, zone)} ${clock(match.next_occurrence, zone)}`
197
+ : `last on ${dayLabel(match.starts_at, zone)}`;
198
+ writeLine(door.io.stdout, `${match.recurring ? "↻" : " "} ${when.padEnd(20)} ${match.title.slice(0, 50).padEnd(50)} ${match.calendar_name}`);
199
+ }
200
+ writeLine(door.io.stdout, "");
201
+ writeLine(door.io.stdout, `${matches.length} match(es). Times are ${zone}.`);
202
+ return 0;
203
+ }
204
+ async function listCalendars(door) {
205
+ const answer = await askAgentDoor(door, {
206
+ path: "/api/cal/calendars",
207
+ method: "GET",
208
+ label: "cal calendars",
209
+ timeoutMs: READ_DEADLINE_MS,
210
+ });
211
+ if (!answer.ok)
212
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
213
+ const calendars = answer.body.calendars ?? [];
214
+ if (door.json)
215
+ return emitAgentDoor(door, { ok: true, calendars });
216
+ if (calendars.length === 0) {
217
+ writeLine(door.io.stdout, "No calendars attached yet.");
218
+ writeLine(door.io.stdout, "");
219
+ writeLine(door.io.stdout, "A personal calendar: printf \"%s\" \"<secret iCal address>\" | cockpit cal add-ical");
220
+ writeLine(door.io.stdout, "A work calendar: connect Google in the browser, then `cockpit cal calendars` again.");
221
+ return 0;
222
+ }
223
+ for (const calendar of calendars) {
224
+ const health = calendar.status === "active"
225
+ ? (calendar.last_sync_at ? `synced ${calendar.last_sync_at.slice(0, 16).replace("T", " ")}` : "never synced")
226
+ : `${calendar.status}: ${calendar.status_reason ?? "no reason recorded"}`;
227
+ const flags = `${calendar.org_visible ? "shared" : "private"}${calendar.selected ? "" : ", hidden"}`;
228
+ writeLine(door.io.stdout, `${calendar.name.slice(0, 32).padEnd(32)} ${calendar.provider.padEnd(12)} ${flags.padEnd(16)} ${health}`);
229
+ }
230
+ writeLine(door.io.stdout, "");
231
+ writeLine(door.io.stdout, `${calendars.length} calendar(s). ids: ${calendars.map((calendar) => calendar.id).join(", ")}`);
232
+ return 0;
233
+ }
234
+ async function addIcal(command, door) {
235
+ // The address comes from stdin and nowhere else. An interactive terminal
236
+ // with nothing piped in is told how, rather than left waiting on a prompt
237
+ // that would echo a permanent credential into the scrollback.
238
+ if (isInteractiveStdin(door.io)) {
239
+ return failAgentDoor(door, TAG, "address_required_on_stdin", 'The calendar\'s secret address is read from stdin, never from a flag. Try: printf "%s" "https://calendar.google.com/calendar/ical/…/basic.ics" | cockpit cal add-ical --name "Personal"');
240
+ }
241
+ let icalUrl;
242
+ try {
243
+ icalUrl = (await readPipedText(door.io.stdin, {
244
+ maxChars: URL_MAX_CHARS,
245
+ overflowMessage: "That is longer than any calendar address; nothing was attached.",
246
+ })).trim();
247
+ }
248
+ catch (error) {
249
+ return failAgentDoor(door, TAG, "address_unreadable", error instanceof Error ? error.message : String(error));
250
+ }
251
+ if (icalUrl === "") {
252
+ return failAgentDoor(door, TAG, "address_required_on_stdin", "Nothing arrived on stdin, so nothing was attached.");
253
+ }
254
+ const answer = await askAgentDoor(door, {
255
+ path: "/api/cal/calendars",
256
+ method: "POST",
257
+ label: "cal add-ical",
258
+ timeoutMs: WRITE_DEADLINE_MS,
259
+ body: {
260
+ ical_url: icalUrl,
261
+ name: command.name ?? null,
262
+ ...(command.orgVisible ? { org_visible: true } : {}),
263
+ },
264
+ });
265
+ if (!answer.ok)
266
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
267
+ const calendar = answer.body.calendar;
268
+ if (door.json)
269
+ return emitAgentDoor(door, { ok: true, calendar });
270
+ writeLine(door.io.stdout, `Attached (${calendar?.id ?? "no id returned"}).`);
271
+ writeLine(door.io.stdout, `Nothing is read until a sync runs: \`cockpit cal sync ${calendar?.id ?? "<id>"}\`, or wait for the cron.`);
272
+ return 0;
273
+ }
274
+ async function create(command, door) {
275
+ const answer = await askAgentDoor(door, {
276
+ path: "/api/cal/events",
277
+ method: "POST",
278
+ label: "cal create",
279
+ timeoutMs: WRITE_DEADLINE_MS,
280
+ body: {
281
+ calendar_id: command.calendarId,
282
+ title: command.title,
283
+ starts_at: command.startsAt,
284
+ ends_at: command.endsAt,
285
+ ...(command.location ? { location: command.location } : {}),
286
+ ...(command.allDay ? { all_day: true } : {}),
287
+ ...(command.attendees.length > 0 ? { attendees: command.attendees } : {}),
288
+ time_zone: command.timeZone ?? localTimeZone(),
289
+ },
290
+ });
291
+ if (!answer.ok)
292
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
293
+ const event = answer.body.event;
294
+ if (door.json)
295
+ return emitAgentDoor(door, { ok: true, event });
296
+ const zone = command.timeZone ?? localTimeZone();
297
+ writeLine(door.io.stdout, `Created "${event?.title ?? command.title}" on ${event ? `${dayLabel(event.starts_at, zone)} ${clock(event.starts_at, zone)}` : "that calendar"}.`);
298
+ if (event?.html_link)
299
+ writeLine(door.io.stdout, event.html_link);
300
+ if (command.attendees.length > 0) {
301
+ writeLine(door.io.stdout, `Invitations went to ${command.attendees.join(", ")} from Google.`);
302
+ }
303
+ return 0;
304
+ }
305
+ async function share(command, door) {
306
+ const orgVisible = command.orgVisible && !command.makePrivate;
307
+ const answer = await askAgentDoor(door, {
308
+ path: `/api/cal/calendars?calendar_id=${encodeURIComponent(command.subjectId ?? "")}`,
309
+ method: "PATCH",
310
+ label: "cal share",
311
+ timeoutMs: WRITE_DEADLINE_MS,
312
+ body: { org_visible: orgVisible },
313
+ });
314
+ if (!answer.ok)
315
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
316
+ const calendar = answer.body.calendar;
317
+ if (door.json)
318
+ return emitAgentDoor(door, { ok: true, calendar });
319
+ writeLine(door.io.stdout, orgVisible
320
+ ? `"${calendar?.name ?? command.subjectId}" is now readable by everyone at BLI. Nobody but you can change it.`
321
+ : `"${calendar?.name ?? command.subjectId}" is private again — only you can read it.`);
322
+ return 0;
323
+ }
324
+ async function detach(command, door) {
325
+ const answer = await askAgentDoor(door, {
326
+ path: `/api/cal/calendars?calendar_id=${encodeURIComponent(command.subjectId ?? "")}`,
327
+ method: "DELETE",
328
+ label: "cal detach",
329
+ timeoutMs: WRITE_DEADLINE_MS,
330
+ });
331
+ if (!answer.ok)
332
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
333
+ if (door.json)
334
+ return emitAgentDoor(door, { ok: true, detached: command.subjectId });
335
+ writeLine(door.io.stdout, `Detached ${command.subjectId}. Its stored events went with it; the calendar itself is untouched at Google.`);
336
+ return 0;
337
+ }
338
+ async function sync(command, door) {
339
+ const answer = await askAgentDoor(door, {
340
+ path: `/api/cal/calendars/${encodeURIComponent(command.subjectId ?? "")}/sync${command.full ? "?full=1" : ""}`,
341
+ method: "POST",
342
+ label: "cal sync",
343
+ timeoutMs: SYNC_DEADLINE_MS,
344
+ });
345
+ if (!answer.ok)
346
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
347
+ const run = answer.body.run;
348
+ if (door.json)
349
+ return emitAgentDoor(door, { ok: true, run });
350
+ if (!run) {
351
+ writeLine(door.io.stdout, "Tower answered without a run. Nothing to report.");
352
+ return 0;
353
+ }
354
+ if (run.reason === "ok") {
355
+ writeLine(door.io.stdout, run.detail === "feed_unchanged"
356
+ ? "Nothing has changed on that calendar since the last read."
357
+ : `Synced: ${run.eventsAdded} new, ${run.eventsUpdated} updated, ${run.eventsDeleted} gone.`);
358
+ return 0;
359
+ }
360
+ // A failure names itself; the exit code says it failed.
361
+ writeLine(door.io.stderr, `${TAG} sync ${JSON.stringify({ reason: run.reason, detail: run.detail })}`);
362
+ writeLine(door.io.stdout, `That calendar did not sync: ${run.reason}${run.detail ? ` (${run.detail})` : ""}.`);
363
+ return 1;
364
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `cockpit cal` argument parsing (BLI-3709) — the calendar surface's half of
3
+ * `local-args-tower.ts`, a sibling of `local-args-tower-mail.ts` for the same
4
+ * reason that file is one: a calendar has its own vocabulary (a window, a
5
+ * zone, a series, a secret address) and folding it into another family's
6
+ * parser would stretch that family's doc comment past the truth.
7
+ *
8
+ * ONE SECRET NEVER TRAVELS ON ARGV, and it is refused here rather than at the
9
+ * door: **the secret iCal address**. `cockpit cal add-ical` reads it from
10
+ * STDIN, always, and there is no `--url` flag to forget about. That address is
11
+ * a permanent, unauthenticated, read-anything-on-that-calendar credential;
12
+ * argv is world-readable on a shared machine (`ps`), lands in shell history,
13
+ * and is captured by this very product's own session harvester. Same rule as
14
+ * `mail add-imap`, same reasoning, and the same reason neither has an MCP
15
+ * twin.
16
+ *
17
+ * THE ZONE IS ALWAYS SENT. Every read verb passes `--tz` (or the machine's own
18
+ * zone, read from `Intl`) to the door, because a window computed on the server
19
+ * would be computed in UTC — "today" would end at 5pm in Vancouver.
20
+ */
21
+ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
22
+ const CAL_ACTIONS = new Set([
23
+ "today",
24
+ "week",
25
+ "next",
26
+ "find",
27
+ "calendars",
28
+ "add-ical",
29
+ "create",
30
+ "share",
31
+ "detach",
32
+ "sync",
33
+ ]);
34
+ /** Every verb that names ONE calendar first. */
35
+ const CAL_ACTIONS_NEEDING_A_SUBJECT = new Set(["sync", "detach", "share"]);
36
+ export function parseCalArgs(args) {
37
+ const values = parseNamedArgs(args, {
38
+ allowedFlags: [
39
+ "--home",
40
+ "--dashboard-url",
41
+ "--calendar",
42
+ "--tz",
43
+ "--offset",
44
+ "--hours",
45
+ "--from",
46
+ "--to",
47
+ "--limit",
48
+ "--all",
49
+ "--name",
50
+ "--title",
51
+ "--at",
52
+ "--until",
53
+ "--location",
54
+ "--attendee",
55
+ "--all-day",
56
+ "--org-visible",
57
+ "--private",
58
+ "--full",
59
+ "--json",
60
+ ],
61
+ valueFlags: [
62
+ "--home",
63
+ "--dashboard-url",
64
+ "--calendar",
65
+ "--tz",
66
+ "--offset",
67
+ "--hours",
68
+ "--from",
69
+ "--to",
70
+ "--limit",
71
+ "--name",
72
+ "--title",
73
+ "--at",
74
+ "--until",
75
+ "--location",
76
+ "--attendee",
77
+ ],
78
+ });
79
+ const first = values.positionals[0];
80
+ const action = (first === undefined ? "today" : first);
81
+ if (!CAL_ACTIONS.has(action)) {
82
+ throw new Error(`Unknown cal command: ${first}. Try today, week, next, find, calendars, add-ical, create, share, detach, or sync.`);
83
+ }
84
+ const rest = values.positionals.slice(first === undefined ? 0 : 1);
85
+ let subject;
86
+ let query;
87
+ if (CAL_ACTIONS_NEEDING_A_SUBJECT.has(action)) {
88
+ subject = optionalNonEmpty(rest[0]);
89
+ if (!subject)
90
+ throw new Error(`cal ${action} needs a calendar id — take one from \`cockpit cal calendars\`.`);
91
+ if (rest.length > 1)
92
+ throw new Error(`cal ${action} takes one id, not ${rest.length}.`);
93
+ }
94
+ else if (action === "find") {
95
+ query = optionalNonEmpty(rest.join(" "));
96
+ if (!query)
97
+ throw new Error('cal find needs words: cockpit cal find "standup".');
98
+ }
99
+ else if (rest.length > 0) {
100
+ throw new Error(`cal ${action} does not take "${rest[0]}".`);
101
+ }
102
+ const title = optionalNonEmpty(values.flags.get("--title"));
103
+ const startsAt = optionalNonEmpty(values.flags.get("--at"));
104
+ const endsAt = optionalNonEmpty(values.flags.get("--until"));
105
+ if (action === "create") {
106
+ if (!optionalNonEmpty(values.flags.get("--calendar"))) {
107
+ throw new Error("cal create needs --calendar <id>: with several calendars attached, which one this goes on is yours to say. `cockpit cal calendars` lists them.");
108
+ }
109
+ if (!title)
110
+ throw new Error('cal create needs --title "<what it is>".');
111
+ if (!startsAt)
112
+ throw new Error("cal create needs --at <when it starts> (an ISO instant, or a date with --all-day).");
113
+ if (!endsAt)
114
+ throw new Error("cal create needs --until <when it ends>.");
115
+ }
116
+ if (action === "share" && !values.booleans.has("--org-visible") && !values.booleans.has("--private")) {
117
+ throw new Error("cal share needs --org-visible (every member may read it) or --private (only you). Sharing is a decision, not a default.");
118
+ }
119
+ const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
120
+ const offsetRaw = optionalNonEmpty(values.flags.get("--offset"));
121
+ const offset = offsetRaw === undefined ? undefined : Number.parseInt(offsetRaw, 10);
122
+ if (offset !== undefined && !Number.isFinite(offset)) {
123
+ throw new Error("--offset takes a whole number of days (today/next) or weeks (week).");
124
+ }
125
+ return {
126
+ kind: "cal",
127
+ action,
128
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
129
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
130
+ ...(subject ? { subjectId: subject } : {}),
131
+ ...(query ? { query } : {}),
132
+ calendarId: optionalNonEmpty(values.flags.get("--calendar")),
133
+ // An absent `--tz` means "this machine's zone", resolved at run time in
134
+ // `commands/cal.ts` rather than here, so the parser stays pure.
135
+ timeZone: optionalNonEmpty(values.flags.get("--tz")),
136
+ ...(offset === undefined ? {} : { offset }),
137
+ ...(optionalPositiveInteger(values.flags.get("--hours"), "--hours") === undefined
138
+ ? {}
139
+ : { hours: optionalPositiveInteger(values.flags.get("--hours"), "--hours") }),
140
+ from: optionalNonEmpty(values.flags.get("--from")),
141
+ to: optionalNonEmpty(values.flags.get("--to")),
142
+ ...(limit === undefined ? {} : { limit }),
143
+ includeDeselected: values.booleans.has("--all"),
144
+ name: optionalNonEmpty(values.flags.get("--name")),
145
+ title,
146
+ startsAt,
147
+ endsAt,
148
+ location: optionalNonEmpty(values.flags.get("--location")),
149
+ attendees: splitAddresses(values.flags.get("--attendee")),
150
+ allDay: values.booleans.has("--all-day"),
151
+ orgVisible: values.booleans.has("--org-visible"),
152
+ makePrivate: values.booleans.has("--private"),
153
+ full: values.booleans.has("--full"),
154
+ json: values.booleans.has("--json"),
155
+ };
156
+ }
157
+ /** `a@x.com,b@y.com` → two attendees. Exact, never fuzzy; empties dropped. */
158
+ function splitAddresses(raw) {
159
+ if (!raw)
160
+ return [];
161
+ return raw
162
+ .split(",")
163
+ .map((value) => value.trim())
164
+ .filter((value) => value !== "");
165
+ }
@@ -21,6 +21,7 @@
21
21
  * local-args-tower-work.ts issue, project — the issue tracker
22
22
  * (BLI-3716)
23
23
  * local-args-tower-mail.ts mail — the mailboxes (BLI-3708)
24
+ * local-args-tower-cal.ts cal — the calendars (BLI-3709)
24
25
  * local-args-tower-search.ts search — one bar over all five
25
26
  * corpora (BLI-3728)
26
27
  *
@@ -32,4 +33,5 @@ export { SCOUT_MIN_PREFIX_LENGTH, parseScoutArgs, parseOpsArgs, SLACK_WORKSPACE_
32
33
  export { parseDocsArgs, parseMsgArgs, } from "./local-args-tower-docs-msg.js";
33
34
  export { ISSUE_STATES, parseIssueArgs, parseProjectArgs, } from "./local-args-tower-work.js";
34
35
  export { SEARCH_KINDS, parseSearchArgs } from "./local-args-tower-search.js";
35
- export { parseMailArgs } from "./local-args-tower-mail.js";
36
+ export { parseMailArgs } from "./local-args-tower-mail.js";
37
+ export { parseCalArgs } from "./local-args-tower-cal.js";
@@ -16,7 +16,7 @@
16
16
  * verbatim, no logic change.
17
17
  */
18
18
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
19
- import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
19
+ import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
20
20
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
21
21
  // `local-auth.ts` import it from here — so it stays exported from this address
22
22
  // even though it now lives next door. The same goes for the four names the
@@ -105,6 +105,8 @@ export function parseLocalArgs(argv) {
105
105
  return parseIssueArgs(argv.slice(1));
106
106
  case "mail":
107
107
  return parseMailArgs(argv.slice(1));
108
+ case "cal":
109
+ return parseCalArgs(argv.slice(1));
108
110
  case "project":
109
111
  return parseProjectArgs(argv.slice(1));
110
112
  case "search":
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The long-form `cockpit <command> --help` text for the TOWER surfaces —
3
+ * documents, messages, issues, projects, mail, calendar and search (BLI-3709).
4
+ *
5
+ * Split out of `local-help-commands.ts` when that file crossed the 700-line
6
+ * readability ceiling, and split along the same seam the ARGUMENT parsers
7
+ * already use (`local-args-tower*.ts`): the collector's own commands — onboard,
8
+ * sync, backfill, autostart, clean — change for one set of reasons, and the
9
+ * Tower nouns a person reads and writes through change for another.
10
+ *
11
+ * Every string moved verbatim. Help output is what an intern pastes back when
12
+ * something breaks, so it is a user-visible contract like any other.
13
+ */
14
+ /** One entry per Tower noun, in the order `cockpit --help` lists them. */
15
+ export const TOWER_COMMAND_HELP = [
16
+ [
17
+ "docs",
18
+ [
19
+ "Usage: cockpit docs [list|tree|read <id|slug>|create|update <id>] [flags]",
20
+ "",
21
+ "The Tower document library, typed. Bare `cockpit docs` lists every document you may read.",
22
+ "list [--parent <id>|root] [--query <text>] [--limit <n>] — every document you may see: id, visibility, body size, slug, title. Metadata only — a list never carries a body, so it stays cheap enough for an agent to call (--json was 604 KB for 155 documents before BLI-3737, and is now a few tens of KB).",
23
+ " --parent <id> lists the documents filed directly under that document; --parent root lists the top of the tree.",
24
+ " --query <text> keeps the documents whose title OR body contains that text, matched case-insensitively in the database — never fuzzy, and the body still never comes back.",
25
+ " --limit <n> caps the rows (1-1000). Narrow with these before reading: `cockpit docs list --query onboarding --json` then `cockpit docs read <slug>`.",
26
+ "tree — the same documents nested under their parent, for a sidebar-shaped view.",
27
+ "read <id|slug> — one document's title, slug, visibility, and its body. This is the ONLY verb that returns a body.",
28
+ "create --title \"<title>\" [--parent <id>] [--visibility org|private] [--file <path>|--body-stdin] — the body comes from --file, or stdin (`cat body.md | cockpit docs create --title \"...\"`); neither means an empty body, matching the browser's own default.",
29
+ "update <id|slug> [--title \"<t>\"] [--visibility org|private] [--parent <id>|--clear-parent] [--file <path>|--body-stdin] [--allow-empty] — only the fields you pass change; a `--parent` change IS a move, there is no separate move verb.",
30
+ " --allow-empty clears the page on purpose. A body that would empty a document that holds text is refused (refused_empty_body) unless you say so, because far more often it is a surface that lost the content than a person who meant it.",
31
+ "A document body is never accepted on the command line — --file (safest on Windows) or a pipe only, same discipline as `cockpit notes paste`.",
32
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
33
+ "A refusal keeps Tower's own reason label — needs_rls_client, document_not_found_or_unreadable, document_not_writable, slug_taken, circular_parent, and so on.",
34
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
35
+ ],
36
+ ],
37
+ [
38
+ "msg",
39
+ [
40
+ "Usage: cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [flags]",
41
+ "",
42
+ "Channels and messages, typed. <channel> is a channel id, or its name with or without a leading #.",
43
+ "channels — every channel you are a member of (or, as a super_admin, every channel).",
44
+ "create <name> [--private] [--members a@x.test,b@y.test] [--description \"<text>\"] — makes a channel and prints its id. A leading # is fine; --private means membership decides who may read it, and --members names who joins at birth BY EMAIL. An address Tower does not carry refuses the whole create — no half-built channel.",
45
+ "dm <email> — opens (or re-opens) the direct message with one person. Idempotent: the same address always resolves to the same channel, and you never have to name yourself.",
46
+ "read <channel> [--limit <n>] [--thread <id>] — the channel's most recent top-level messages, oldest first; --thread <id> reads one thread's replies instead.",
47
+ "send <channel> [--thread <id>] — posts a message. The content is never accepted on the command line: pipe it in, e.g. `echo \"hello\" | cockpit msg send general`.",
48
+ "thread <id> --channel <channel> — one thread's replies by the parent message's id.",
49
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
50
+ "A refusal keeps Tower's own reason label — needs_rls_client, channel_not_found_or_unreadable, content_too_long, and so on.",
51
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
52
+ ],
53
+ ],
54
+ [
55
+ "issue",
56
+ [
57
+ "Usage: cockpit issue [list|show <id>|create|update <id>|move <id> <state>|comment <id>|history <id>] [flags]",
58
+ "",
59
+ "Tower's issue tracker. <id> is a BLI-#### identifier (BLI-3654) or an issue's uuid — both work everywhere an issue is named.",
60
+ "list [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] — the issues you may see, most recently updated first.",
61
+ "show <id> — one issue: title, state, priority, assignee, description, and every comment on it.",
62
+ "create --title \"<title>\" [--project <name|id>] [--priority 0-4] [--assignee me|<uuid>] [--parent <id>] [--file <path>] — the DESCRIPTION comes from --file or stdin (`cat plan.md | cockpit issue create --title \"...\"`); neither means no description.",
63
+ "update <id> [--title \"<t>\"] [--priority 0-4] [--assignee me|<uuid>] [--project <name|id>] [--parent <id>] [--file <path>|--body-stdin] — only the fields you pass change. State does NOT move here; use move.",
64
+ "move <id> <state> — moves an issue and records the move. States: backlog, todo, in_progress, in_review, done, canceled.",
65
+ "comment <id> — posts a comment. The body is never taken on the command line: `echo \"shipped\" | cockpit issue comment BLI-3654`.",
66
+ "history <id> [--limit <n>] — every recorded state move and reassignment, oldest first.",
67
+ "A project may be named instead of id'd; the name is matched exactly (case-insensitively) against `cockpit project list`.",
68
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
69
+ "A refusal keeps Tower's own reason label — needs_rls_client, issue_not_found_or_unreadable, issue_not_writable, invalid_state, comment_too_long, and so on.",
70
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
71
+ ],
72
+ ],
73
+ [
74
+ "mail",
75
+ [
76
+ "Usage: cockpit mail [accounts|add-imap|inbox|read <thread>|search \"<words>\"|send|attachment <id>|sync <account>|detach <account>] [flags]",
77
+ "",
78
+ "Every mailbox you attached, in one place. One person, several addresses — nothing here has a \"current\" mailbox.",
79
+ "accounts — every mailbox, its provider (gmail_oauth or imap), its health and when it last synced. The ids other verbs take are on the last line.",
80
+ "add-imap --address <you@gmail.com> [--name \"<display name>\"] — attaches a personal Google account over IMAP + SMTP. The APP PASSWORD is read from stdin and there is no flag for it:",
81
+ " macOS: printf \"%s\" \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
82
+ " PowerShell: \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
83
+ " Make one at myaccount.google.com/apppasswords (2-Step Verification has to be on). A work @buildlaunchiterate.ca address connects in the browser instead.",
84
+ "inbox [--account <id>] [--unread] [--label <l>] [--limit <n>] [--before <iso>] — everything across every mailbox, newest first. * is unread, @ has an attachment.",
85
+ "read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox --json`.",
86
+ "search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box.",
87
+ "send --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] — the BODY comes from --file or stdin. --account is required: which address this goes out from is yours to say.",
88
+ "attachment <id> --out <path> — downloads one attachment to a file. Attachments are pointers until you ask; nothing is stored in Tower.",
89
+ "sync <account> — reads that mailbox now instead of waiting for the cron. Prints what landed, or the reason it did not.",
90
+ "detach <account> — removes the mailbox, its stored mail and its credential.",
91
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
92
+ "A refusal keeps Tower's own reason label — needs_rls_client, account_not_found_or_unreadable, account_not_active, google_oauth_not_configured, credential_unavailable, and so on.",
93
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
94
+ ],
95
+ ],
96
+ [
97
+ "cal",
98
+ [
99
+ 'Usage: cockpit cal [today|week|next|find "<words>"|calendars|add-ical|create|share <id>|sync <id>|detach <id>] [flags]',
100
+ "",
101
+ "Every calendar you attached, in one place — plus any calendar a colleague marked shared.",
102
+ "today [--offset 1] [--tz <zone>] — what is on today, in YOUR zone (this machine's, unless --tz says otherwise). --offset 1 is tomorrow, -1 yesterday.",
103
+ "week [--offset 1] — Monday to Sunday. The week starts on Monday here.",
104
+ "next [--hours 12] — what is coming, from RIGHT NOW rather than from midnight. Defaults to the next 72 hours, so a Friday evening still answers.",
105
+ 'find "<words>" — full text over titles, locations and descriptions. A repeating event is answered with the NEXT time it happens, not its first one in 2024.',
106
+ "calendars — every calendar, its provider (google_oauth or ical_url), whether it is shared, and when it last synced. The ids other verbs take are on the last line.",
107
+ "add-ical [--name \"<what to call it>\"] — attaches a personal calendar by its SECRET iCal ADDRESS, read from stdin. There is no flag for it:",
108
+ ' macOS: printf "%s" "https://calendar.google.com/calendar/ical/…/basic.ics" | cockpit cal add-ical --name "Personal"',
109
+ ' PowerShell: "https://calendar.google.com/calendar/ical/…/basic.ics" | cockpit cal add-ical --name "Personal"',
110
+ " Find it at calendar.google.com -> the calendar's Settings -> \"Secret address in iCal format\". Anybody who has that link can read the calendar, which is why it never goes on a command line.",
111
+ " A work @buildlaunchiterate.ca calendar does not need this: connect Google in the browser and `cockpit cal calendars` will list them.",
112
+ 'create --calendar <id> --title "<what>" --at <iso> --until <iso> [--location "<where>"] [--attendee a@x,b@y] [--all-day] — creates the event AT GOOGLE, then here. Attendees are invited by Google.',
113
+ " Only a Google calendar can be written to: an iCal secret address is a read address, and `write_not_supported_for_provider` says so by name.",
114
+ "share <id> --org-visible|--private — makes one of YOUR calendars readable by everyone at BLI, or private again. Sharing never lets anybody else change it.",
115
+ "sync <id> [--full] — reads that calendar now instead of waiting for the cron. --full ignores the sync token / ETag and re-reads everything.",
116
+ "detach <id> — removes the calendar and its stored events from Tower. The calendar itself is untouched at Google.",
117
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
118
+ "A refusal keeps Tower's own reason label — needs_rls_client, calendar_not_found_or_unreadable, calendar_not_yours, ical_url_invalid, google_oauth_not_configured, write_not_supported_for_provider, and so on.",
119
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
120
+ ],
121
+ ],
122
+ [
123
+ "project",
124
+ [
125
+ "Usage: cockpit project [list] [--archived] [--json]",
126
+ "",
127
+ "The projects issues are filed under. Bare `cockpit project` lists them.",
128
+ "list [--archived] — id, active/archived, name. --archived includes archived projects.",
129
+ "There is no create/update/delete verb: `GET /api/work/projects` is the whole of Tower's project door today.",
130
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
131
+ ],
132
+ ],
133
+ [
134
+ "search",
135
+ [
136
+ 'Usage: cockpit search "<words>" [--kind doc,msg,issue,note,memory] [--limit <n>] [--json]',
137
+ "",
138
+ "One bar over five corpora: documents, messages, issues, meeting notes and memory.",
139
+ "It presses the SAME door the browser's search bar presses (GET /api/search), so what you",
140
+ "read here is the same row, the same ranking and the same snippet a person sees in Tower.",
141
+ "",
142
+ "The words are positional and do not need quoting unless they contain shell metacharacters:",
143
+ '`cockpit search storage ceiling` and `cockpit search "storage ceiling"` are the same search.',
144
+ "Quoted phrases and -word work inside the query itself — the query goes to Postgres's",
145
+ "websearch parser, which shrugs at anything a person can type instead of raising.",
146
+ "",
147
+ "--kind narrows to one or more corpora, comma separated. Omit it and all five are searched.",
148
+ "--limit caps the number of results (default 20, maximum 50).",
149
+ "--json writes the door's whole answer to stdout, hits, per-kind counts, failures and all.",
150
+ "",
151
+ "Every result is scoped by what YOU may read: four of the five corpora are searched on your",
152
+ "own database session, so a document or a channel you cannot open is not in the list.",
153
+ "",
154
+ "A corpus that could not ANSWER gets its own line, separately from \"nothing matched\" —",
155
+ "those are different facts and folding them together would let a broken search read as silence.",
156
+ "A memory result has no page to open; the text printed under it is the whole record.",
157
+ "",
158
+ "A refusal keeps Tower's own reason label — needs_rls_client, query_too_short, query_too_long,",
159
+ "unknown_kind, read_failed.",
160
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
161
+ ],
162
+ ],
163
+ ];
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * The long-form `cockpit <command> --help` text — one entry per command.
3
3
  *
4
+ * The collector's OWN commands live here; the Tower nouns (docs, msg, issue,
5
+ * project, mail, cal, search) live in `local-help-commands-tower.ts`, split off
6
+ * by BLI-3709 when this file crossed the same ceiling — along the seam
7
+ * `local-args-tower*.ts` already draws.
8
+ *
4
9
  * Split out of `local-help.ts` (BLI-3728) when that file crossed the 700-line
5
10
  * readability ceiling. The split is by RESPONSIBILITY, not by size: this file
6
11
  * is the per-command manual, `local-help.ts` is the command NAME registry plus
@@ -13,8 +18,12 @@
13
18
  */
14
19
  import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
15
20
  import { localCommandHelp } from "./local-help.js";
21
+ import { TOWER_COMMAND_HELP } from "./local-help-commands-tower.js";
16
22
  export function localSubcommandHelp(command) {
17
23
  const helpByCommand = new Map([
24
+ // BLI-3709: the Tower nouns live next door, split along the same seam the
25
+ // argument parsers already use — see `local-help-commands-tower.ts`.
26
+ ...TOWER_COMMAND_HELP,
18
27
  [
19
28
  "onboard",
20
29
  [
@@ -544,127 +553,6 @@ export function localSubcommandHelp(command) {
544
553
  "right now.",
545
554
  ],
546
555
  ],
547
- [
548
- "docs",
549
- [
550
- "Usage: cockpit docs [list|tree|read <id|slug>|create|update <id>] [flags]",
551
- "",
552
- "The Tower document library, typed. Bare `cockpit docs` lists every document you may read.",
553
- "list [--parent <id>|root] [--query <text>] [--limit <n>] — every document you may see: id, visibility, body size, slug, title. Metadata only — a list never carries a body, so it stays cheap enough for an agent to call (--json was 604 KB for 155 documents before BLI-3737, and is now a few tens of KB).",
554
- " --parent <id> lists the documents filed directly under that document; --parent root lists the top of the tree.",
555
- " --query <text> keeps the documents whose title OR body contains that text, matched case-insensitively in the database — never fuzzy, and the body still never comes back.",
556
- " --limit <n> caps the rows (1-1000). Narrow with these before reading: `cockpit docs list --query onboarding --json` then `cockpit docs read <slug>`.",
557
- "tree — the same documents nested under their parent, for a sidebar-shaped view.",
558
- "read <id|slug> — one document's title, slug, visibility, and its body. This is the ONLY verb that returns a body.",
559
- "create --title \"<title>\" [--parent <id>] [--visibility org|private] [--file <path>|--body-stdin] — the body comes from --file, or stdin (`cat body.md | cockpit docs create --title \"...\"`); neither means an empty body, matching the browser's own default.",
560
- "update <id|slug> [--title \"<t>\"] [--visibility org|private] [--parent <id>|--clear-parent] [--file <path>|--body-stdin] [--allow-empty] — only the fields you pass change; a `--parent` change IS a move, there is no separate move verb.",
561
- " --allow-empty clears the page on purpose. A body that would empty a document that holds text is refused (refused_empty_body) unless you say so, because far more often it is a surface that lost the content than a person who meant it.",
562
- "A document body is never accepted on the command line — --file (safest on Windows) or a pipe only, same discipline as `cockpit notes paste`.",
563
- "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
564
- "A refusal keeps Tower's own reason label — needs_rls_client, document_not_found_or_unreadable, document_not_writable, slug_taken, circular_parent, and so on.",
565
- "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
566
- ],
567
- ],
568
- [
569
- "msg",
570
- [
571
- "Usage: cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [flags]",
572
- "",
573
- "Channels and messages, typed. <channel> is a channel id, or its name with or without a leading #.",
574
- "channels — every channel you are a member of (or, as a super_admin, every channel).",
575
- "create <name> [--private] [--members a@x.test,b@y.test] [--description \"<text>\"] — makes a channel and prints its id. A leading # is fine; --private means membership decides who may read it, and --members names who joins at birth BY EMAIL. An address Tower does not carry refuses the whole create — no half-built channel.",
576
- "dm <email> — opens (or re-opens) the direct message with one person. Idempotent: the same address always resolves to the same channel, and you never have to name yourself.",
577
- "read <channel> [--limit <n>] [--thread <id>] — the channel's most recent top-level messages, oldest first; --thread <id> reads one thread's replies instead.",
578
- "send <channel> [--thread <id>] — posts a message. The content is never accepted on the command line: pipe it in, e.g. `echo \"hello\" | cockpit msg send general`.",
579
- "thread <id> --channel <channel> — one thread's replies by the parent message's id.",
580
- "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
581
- "A refusal keeps Tower's own reason label — needs_rls_client, channel_not_found_or_unreadable, content_too_long, and so on.",
582
- "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
583
- ],
584
- ],
585
- [
586
- "issue",
587
- [
588
- "Usage: cockpit issue [list|show <id>|create|update <id>|move <id> <state>|comment <id>|history <id>] [flags]",
589
- "",
590
- "Tower's issue tracker. <id> is a BLI-#### identifier (BLI-3654) or an issue's uuid — both work everywhere an issue is named.",
591
- "list [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] — the issues you may see, most recently updated first.",
592
- "show <id> — one issue: title, state, priority, assignee, description, and every comment on it.",
593
- "create --title \"<title>\" [--project <name|id>] [--priority 0-4] [--assignee me|<uuid>] [--parent <id>] [--file <path>] — the DESCRIPTION comes from --file or stdin (`cat plan.md | cockpit issue create --title \"...\"`); neither means no description.",
594
- "update <id> [--title \"<t>\"] [--priority 0-4] [--assignee me|<uuid>] [--project <name|id>] [--parent <id>] [--file <path>|--body-stdin] — only the fields you pass change. State does NOT move here; use move.",
595
- "move <id> <state> — moves an issue and records the move. States: backlog, todo, in_progress, in_review, done, canceled.",
596
- "comment <id> — posts a comment. The body is never taken on the command line: `echo \"shipped\" | cockpit issue comment BLI-3654`.",
597
- "history <id> [--limit <n>] — every recorded state move and reassignment, oldest first.",
598
- "A project may be named instead of id'd; the name is matched exactly (case-insensitively) against `cockpit project list`.",
599
- "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
600
- "A refusal keeps Tower's own reason label — needs_rls_client, issue_not_found_or_unreadable, issue_not_writable, invalid_state, comment_too_long, and so on.",
601
- "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
602
- ],
603
- ],
604
- [
605
- "mail",
606
- [
607
- "Usage: cockpit mail [accounts|add-imap|inbox|read <thread>|search \"<words>\"|send|attachment <id>|sync <account>|detach <account>] [flags]",
608
- "",
609
- "Every mailbox you attached, in one place. One person, several addresses — nothing here has a \"current\" mailbox.",
610
- "accounts — every mailbox, its provider (gmail_oauth or imap), its health and when it last synced. The ids other verbs take are on the last line.",
611
- "add-imap --address <you@gmail.com> [--name \"<display name>\"] — attaches a personal Google account over IMAP + SMTP. The APP PASSWORD is read from stdin and there is no flag for it:",
612
- " macOS: printf \"%s\" \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
613
- " PowerShell: \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
614
- " Make one at myaccount.google.com/apppasswords (2-Step Verification has to be on). A work @buildlaunchiterate.ca address connects in the browser instead.",
615
- "inbox [--account <id>] [--unread] [--label <l>] [--limit <n>] [--before <iso>] — everything across every mailbox, newest first. * is unread, @ has an attachment.",
616
- "read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox --json`.",
617
- "search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box.",
618
- "send --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] — the BODY comes from --file or stdin. --account is required: which address this goes out from is yours to say.",
619
- "attachment <id> --out <path> — downloads one attachment to a file. Attachments are pointers until you ask; nothing is stored in Tower.",
620
- "sync <account> — reads that mailbox now instead of waiting for the cron. Prints what landed, or the reason it did not.",
621
- "detach <account> — removes the mailbox, its stored mail and its credential.",
622
- "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
623
- "A refusal keeps Tower's own reason label — needs_rls_client, account_not_found_or_unreadable, account_not_active, google_oauth_not_configured, credential_unavailable, and so on.",
624
- "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
625
- ],
626
- ],
627
- [
628
- "project",
629
- [
630
- "Usage: cockpit project [list] [--archived] [--json]",
631
- "",
632
- "The projects issues are filed under. Bare `cockpit project` lists them.",
633
- "list [--archived] — id, active/archived, name. --archived includes archived projects.",
634
- "There is no create/update/delete verb: `GET /api/work/projects` is the whole of Tower's project door today.",
635
- "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
636
- ],
637
- ],
638
- [
639
- "search",
640
- [
641
- 'Usage: cockpit search "<words>" [--kind doc,msg,issue,note,memory] [--limit <n>] [--json]',
642
- "",
643
- "One bar over five corpora: documents, messages, issues, meeting notes and memory.",
644
- "It presses the SAME door the browser's search bar presses (GET /api/search), so what you",
645
- "read here is the same row, the same ranking and the same snippet a person sees in Tower.",
646
- "",
647
- "The words are positional and do not need quoting unless they contain shell metacharacters:",
648
- '`cockpit search storage ceiling` and `cockpit search "storage ceiling"` are the same search.',
649
- "Quoted phrases and -word work inside the query itself — the query goes to Postgres's",
650
- "websearch parser, which shrugs at anything a person can type instead of raising.",
651
- "",
652
- "--kind narrows to one or more corpora, comma separated. Omit it and all five are searched.",
653
- "--limit caps the number of results (default 20, maximum 50).",
654
- "--json writes the door's whole answer to stdout, hits, per-kind counts, failures and all.",
655
- "",
656
- "Every result is scoped by what YOU may read: four of the five corpora are searched on your",
657
- "own database session, so a document or a channel you cannot open is not in the list.",
658
- "",
659
- "A corpus that could not ANSWER gets its own line, separately from \"nothing matched\" —",
660
- "those are different facts and folding them together would let a broken search read as silence.",
661
- "A memory result has no page to open; the text printed under it is the whole record.",
662
- "",
663
- "A refusal keeps Tower's own reason label — needs_rls_client, query_too_short, query_too_long,",
664
- "unknown_kind, read_failed.",
665
- "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
666
- ],
667
- ],
668
556
  [
669
557
  "release",
670
558
  [
@@ -46,6 +46,7 @@ export const rootCommandNames = new Set([
46
46
  "msg",
47
47
  "issue",
48
48
  "mail",
49
+ "cal",
49
50
  "project",
50
51
  "search",
51
52
  "release",
@@ -91,6 +92,7 @@ export function localCommandHelp(command) {
91
92
  " cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--dashboard-url <url>] [--json]",
92
93
  " cockpit issue [list|show <BLI-id>|create --title <t>|update <BLI-id>|move <BLI-id> <state>|comment <BLI-id>|history <BLI-id>] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] [--priority 0-4] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
93
94
  " cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--dashboard-url <url>] [--json]",
95
+ " cockpit cal [today|week|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
94
96
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
95
97
  " cockpit search \"<words>\" [--kind doc,msg,issue,note,memory] [--limit <n>] [--dashboard-url <url>] [--json]",
96
98
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
@@ -35,6 +35,7 @@ import { runClean } from "./clean.js";
35
35
  import { runDocs } from "./docs.js";
36
36
  import { runMsg } from "./msg.js";
37
37
  import { runIssue } from "./issue.js";
38
+ import { runCal } from "./cal.js";
38
39
  import { runMail } from "./mail.js";
39
40
  import { runProject } from "./project.js";
40
41
  import { runSearch } from "./search.js";
@@ -143,6 +144,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
143
144
  return await runMsg(command, io);
144
145
  case "issue":
145
146
  return await runIssue(command, io);
147
+ case "cal":
148
+ return runCal(command, io);
146
149
  case "mail":
147
150
  return await runMail(command, io);
148
151
  case "project":
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.67");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.68");
19
19
  return 0;
20
20
  }
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.67",
3
+ "version": "0.2.68",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.9",
31
- "@bli-cockpit/mcp": "0.1.9",
31
+ "@bli-cockpit/mcp": "0.1.10",
32
32
  "@bli-cockpit/telemetry-core": "0.1.30"
33
33
  }
34
34
  }