@bli-cockpit/cli 0.2.198 → 0.2.200

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.
@@ -4,7 +4,7 @@ const TAG = "[cal cli]";
4
4
  const DELETE_DEADLINE_MS = 60_000;
5
5
  export async function runCalDelete(command, door) {
6
6
  const answer = await askAgentDoor(door, {
7
- path: `/api/cal/events/${encodeURIComponent(command.subjectId ?? "")}`,
7
+ path: `/api/cal/events/${encodeURIComponent(command.subjectId ?? "")}${command.accountName ? `?account=${encodeURIComponent(command.accountName)}` : ""}`,
8
8
  method: "DELETE",
9
9
  label: "cal delete",
10
10
  timeoutMs: DELETE_DEADLINE_MS,
@@ -18,6 +18,8 @@ export async function runCalDelete(command, door) {
18
18
  writeLine(door.io.stdout, "Tower answered without a result. Nothing to report.");
19
19
  return 0;
20
20
  }
21
+ if (deleted.account_name)
22
+ writeLine(door.io.stdout, `Account: ${deleted.account_name}.`);
21
23
  const alreadyGone = deleted.google === "already_gone_at_google";
22
24
  const stored = deleted.local_store === "removed"
23
25
  ? "Tower's copy is removed."
@@ -52,6 +52,9 @@ export async function runCal(command, io) {
52
52
  return runCalSync(command, door);
53
53
  case "sync-status":
54
54
  return runCalSyncStatus(command, door);
55
+ case "change":
56
+ return changeEvent(command, door);
57
+ case "cancel":
55
58
  case "delete":
56
59
  return runCalDelete(command, door);
57
60
  }
@@ -260,7 +263,7 @@ async function listCalendars(door) {
260
263
  ? (calendar.last_sync_at ? `synced ${calendar.last_sync_at.slice(0, 16).replace("T", " ")}` : "never synced")
261
264
  : `${calendar.status}: ${calendar.status_reason ?? "no reason recorded"}`;
262
265
  const flags = `${calendar.org_visible ? "shared" : "private"}${calendar.selected ? "" : ", hidden"}`;
263
- writeLine(door.io.stdout, `${calendar.name.slice(0, 32).padEnd(32)} ${calendar.provider.padEnd(12)} ${flags.padEnd(16)} ${health}`);
266
+ writeLine(door.io.stdout, `${calendar.account_label ?? calendar.provider} ${calendar.name.slice(0, 32).padEnd(32)} ${calendar.provider.padEnd(12)} ${flags.padEnd(16)} ${health}`);
264
267
  }
265
268
  writeLine(door.io.stdout, "");
266
269
  writeLine(door.io.stdout, `${calendars.length} calendar(s). ids: ${calendars.map((calendar) => calendar.id).join(", ")}`);
@@ -274,6 +277,7 @@ async function create(command, door) {
274
277
  timeoutMs: WRITE_DEADLINE_MS,
275
278
  body: {
276
279
  calendar_id: command.calendarId,
280
+ ...(command.accountName ? { account: command.accountName } : {}),
277
281
  title: command.title,
278
282
  starts_at: command.startsAt,
279
283
  ends_at: command.endsAt,
@@ -299,6 +303,8 @@ async function create(command, door) {
299
303
  }
300
304
  if (event?.html_link)
301
305
  writeLine(door.io.stdout, event.html_link);
306
+ if (event?.account_name)
307
+ writeLine(door.io.stdout, `Account: ${event.account_name}.`);
302
308
  if (command.meet) {
303
309
  writeLine(door.io.stdout, event?.meet_link
304
310
  ? `Google Meet: ${event.meet_link}`
@@ -314,4 +320,14 @@ function meetingLinkText(event) {
314
320
  const meeting = event.meeting_url ? ` ${label}: ${event.meeting_url}` : "";
315
321
  console.error("[cal render] meeting link", JSON.stringify({ reason: event.meeting_url ? "meeting_link_shown" : "no_meeting_link" }));
316
322
  return meeting;
323
+ }
324
+ async function changeEvent(command, door) {
325
+ const answer = await askAgentDoor(door, { path: `/api/cal/events/${encodeURIComponent(command.subjectId)}`, method: "PATCH", label: "cal change", timeoutMs: WRITE_DEADLINE_MS,
326
+ body: { ...(command.accountName ? { account: command.accountName } : {}), ...(command.title ? { title: command.title } : {}), ...(command.startsAt ? { starts_at: command.startsAt } : {}), ...(command.endsAt ? { ends_at: command.endsAt } : {}), ...(command.location ? { location: command.location } : {}) } });
327
+ if (!answer.ok)
328
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
329
+ if (door.json)
330
+ return emitAgentDoor(door, { ok: true, ...answer.body });
331
+ writeLine(door.io.stdout, `Changed event in ${answer.body.event?.account_name ?? "its account"}.`);
332
+ return 0;
317
333
  }
@@ -164,6 +164,7 @@ export function repairReceiptLines(repairs) {
164
164
  }
165
165
  export function doctorNeedsPerson(rows, receipt) {
166
166
  const defaults = {
167
+ "slack-connected": "cockpit slack connect",
167
168
  "cli-latest": "npm i -g @bli-cockpit/cli && cockpit doctor",
168
169
  "authed": "cockpit doctor",
169
170
  "roots-ok": "cockpit doctor",
@@ -0,0 +1,25 @@
1
+ import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
2
+ import { asRecord, callTower, openTower } from "./tower-command.js";
3
+ export async function checkSlackConnected(context) {
4
+ const message = "Slack connected for private-channel coverage";
5
+ try {
6
+ const tower = await openTower("doctor", context.command, context.io);
7
+ const response = await callTower(tower, { path: "/api/slack/connections", label: "doctor-slack", timeoutMs: 5_000 });
8
+ if (!response.ok)
9
+ return { id: "slack-connected", status: "needs_fix", code: response.reason, message: `${message}: could not read connection state.`, nextAction: `${tower.dashboardUrl}/settings/personal` };
10
+ const connections = asRecord(response.body)["connections"];
11
+ if (!connections || connections.length !== 2)
12
+ throw new Error("connection_state_incomplete");
13
+ const missing = connections.filter(row => !row["connected"]);
14
+ const links = missing.map(row => new URL(String(row["connectPath"]), tower.dashboardUrl).toString());
15
+ console.error("[doctor slack] checked", JSON.stringify({ missing: missing.length, reason: missing.length ? "slack_connect_required" : "ok" }));
16
+ return { id: "slack-connected", status: missing.length ? "needs_fix" : "ok", code: missing.length ? "slack_connect_required" : "slack_connected",
17
+ message: missing.length ? `${message}: connect ${missing.map(row => row["workspace"]).join(", ")} at ${links.join(" or ")}` : message,
18
+ ...(missing.length ? { nextAction: links.join(" ") } : {}),
19
+ };
20
+ }
21
+ catch {
22
+ console.error("[doctor slack] refused", JSON.stringify({ reason: "connection_read_failed" }));
23
+ return { id: "slack-connected", status: "skipped", code: "connection_read_failed", message: `${message}: unavailable until this machine is signed in.`, nextAction: `${context.command.dashboardUrl ?? DEFAULT_DASHBOARD_URL}/settings/personal` };
24
+ }
25
+ }
@@ -1,3 +1,4 @@
1
+ import { checkSlackConnected } from "./doctor-slack.js";
1
2
  import { checkUsageTelemetry, fixUsageTelemetry } from "./doctor-usage-telemetry.js";
2
3
  import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
3
4
  import { isSemverBelow } from "../scheduled-self-update.js";
@@ -153,6 +154,7 @@ export function doctorInvariantTable() {
153
154
  function doctorInvariants() {
154
155
  return [
155
156
  { id: "cli-latest", check: checkCliLatest, fix: fixCliLatest },
157
+ { id: "slack-connected", check: context => context.deps.checkSlackConnected ? context.deps.checkSlackConnected(context) : Promise.resolve({ id: "slack-connected", status: "skipped", code: "not_probed", message: "Slack connection check not provided." }) },
156
158
  {
157
159
  id: "authed",
158
160
  check: (context) => context.deps.readAuth(context),
@@ -248,6 +250,7 @@ function defaultDoctorDeps(hooks) {
248
250
  readRootsCoverWork,
249
251
  resolveAndSaveRoots: (context) => hooks.resolveAndSaveRoots(context.command, context.io),
250
252
  checkSingleInstall: checkSingleInstallState,
253
+ checkSlackConnected,
251
254
  checkAutostart: checkAutostartState,
252
255
  fixAutostart: fixAutostartState,
253
256
  checkUsageTelemetry,
@@ -193,11 +193,11 @@ export function parseSlackArgs(args) {
193
193
  ],
194
194
  });
195
195
  if (values.positionals.length > 1) {
196
- throw new Error("slack accepts one action: coverage or read.");
196
+ throw new Error("slack accepts one action: coverage, read, connect or disconnect.");
197
197
  }
198
198
  const rawAction = values.positionals[0] ?? "coverage";
199
- if (rawAction !== "coverage" && rawAction !== "read") {
200
- throw new Error("slack action must be coverage or read.");
199
+ if (!["coverage", "read", "connect", "disconnect"].includes(rawAction)) {
200
+ throw new Error("slack action must be coverage, read, connect or disconnect.");
201
201
  }
202
202
  const base = {
203
203
  homeDir: optionalNonEmpty(values.flags.get("--home")),
@@ -210,6 +210,14 @@ export function parseSlackArgs(args) {
210
210
  if (values.booleans.has("--list") && rawAction !== "coverage") {
211
211
  throw new Error("--list belongs to `cockpit slack coverage`.");
212
212
  }
213
+ if (rawAction === "connect" || rawAction === "disconnect") {
214
+ const workspace = optionalNonEmpty(values.flags.get("--workspace"));
215
+ if (workspace && !SLACK_WORKSPACE_KEYS.includes(workspace))
216
+ throw new Error("Unknown Slack workspace.");
217
+ if (rawAction === "disconnect" && !workspace)
218
+ throw new Error("slack disconnect needs --workspace bli|blue_pearl.");
219
+ return { kind: "slack", action: rawAction, workspace, ...base };
220
+ }
213
221
  if (rawAction === "coverage") {
214
222
  // `--workspace` here is a Slack workspace key, NOT the collection-root
215
223
  // `--workspace <path>` every setup command takes. Named at the point of use
@@ -26,6 +26,7 @@ const CAL_ACTIONS = new Set([
26
26
  "find",
27
27
  "calendars",
28
28
  "add-ical",
29
+ "change", "cancel",
29
30
  "create",
30
31
  "share",
31
32
  "detach",
@@ -34,7 +35,7 @@ const CAL_ACTIONS = new Set([
34
35
  "delete",
35
36
  ]);
36
37
  /** Every verb that names ONE thing first — a calendar id, or (delete) an event id. */
37
- const CAL_ACTIONS_NEEDING_A_SUBJECT = new Set(["sync", "detach", "share", "delete"]);
38
+ const CAL_ACTIONS_NEEDING_A_SUBJECT = new Set(["sync", "detach", "share", "delete", "change", "cancel"]);
38
39
  /** `sync-status [<calendar id>]` — every calendar, or one's. Ticket 10730. */
39
40
  const CAL_ACTIONS_WITH_OPTIONAL_SUBJECT = new Set(["sync-status"]);
40
41
  export function parseCalArgs(args) {
@@ -43,6 +44,7 @@ export function parseCalArgs(args) {
43
44
  "--home",
44
45
  "--dashboard-url",
45
46
  "--calendar",
47
+ "--account",
46
48
  "--tz",
47
49
  "--offset",
48
50
  "--hours",
@@ -68,6 +70,7 @@ export function parseCalArgs(args) {
68
70
  "--home",
69
71
  "--dashboard-url",
70
72
  "--calendar",
73
+ "--account",
71
74
  "--tz",
72
75
  "--offset",
73
76
  "--hours",
@@ -134,9 +137,6 @@ function readCalCreateDetails(action, values) {
134
137
  const startsAt = optionalNonEmpty(values.flags.get("--at"));
135
138
  const endsAt = optionalNonEmpty(values.flags.get("--until"));
136
139
  if (action === "create") {
137
- if (!optionalNonEmpty(values.flags.get("--calendar"))) {
138
- 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.");
139
- }
140
140
  if (!title)
141
141
  throw new Error('cal create needs --title "<what it is>".');
142
142
  if (!startsAt)
@@ -155,7 +155,7 @@ function validateCalSharing(action, values) {
155
155
  }
156
156
  /** `delete` notifies attendees, exactly like `create`'s invitations — never without saying so first. */
157
157
  function validateCalDelete(action, values) {
158
- if (action === "delete" && !values.booleans.has("--yes")) {
158
+ if ((action === "delete" || action === "cancel") && !values.booleans.has("--yes")) {
159
159
  throw new Error("cal delete needs --yes: it removes the event at Google and can email everybody on it. Add --yes once you mean it.");
160
160
  }
161
161
  }
@@ -175,6 +175,7 @@ function buildCalCommand(values, action, subject, query, title, startsAt, endsAt
175
175
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
176
176
  ...(subject ? { subjectId: subject } : {}),
177
177
  ...(query ? { query } : {}),
178
+ accountName: optionalNonEmpty(values.flags.get("--account")),
178
179
  calendarId: optionalNonEmpty(values.flags.get("--calendar")),
179
180
  // An absent `--tz` means "this machine's zone", resolved at run time in
180
181
  // `commands/cal.ts` rather than here, so the parser stays pure.
@@ -30,6 +30,7 @@
30
30
  */
31
31
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
32
32
  const MAIL_ACTIONS = new Set([
33
+ "rename", "enable", "disable", "default", "remove",
33
34
  "accounts",
34
35
  "add-imap",
35
36
  "detach",
@@ -54,6 +55,7 @@ export function parseMailArgs(args) {
54
55
  "--home",
55
56
  "--dashboard-url",
56
57
  "--account",
58
+ "--source",
57
59
  "--address",
58
60
  "--name",
59
61
  "--limit",
@@ -74,6 +76,7 @@ export function parseMailArgs(args) {
74
76
  "--home",
75
77
  "--dashboard-url",
76
78
  "--account",
79
+ "--source",
77
80
  "--address",
78
81
  "--name",
79
82
  "--limit",
@@ -90,6 +93,12 @@ export function parseMailArgs(args) {
90
93
  });
91
94
  const { action, rest } = readMailAction(values);
92
95
  const { subject, query } = readMailSubjectOrQuery(action, rest);
96
+ if (["rename", "enable", "disable", "default", "remove"].includes(action) && !values.flags.get("--account"))
97
+ throw new Error("Name the account with --account <name>.");
98
+ if (action === "rename" && !values.flags.get("--name"))
99
+ throw new Error("Rename needs --name <new name>.");
100
+ if (["enable", "disable"].includes(action) && !["mail", "calendar"].includes(values.flags.get("--source") ?? ""))
101
+ throw new Error("Name --source mail or --source calendar.");
93
102
  const address = readImapAddress(action, values);
94
103
  const { to, cc } = readMailRecipients(action, values);
95
104
  const outPath = readAttachmentPath(action, values);
@@ -146,11 +155,6 @@ function readMailRecipients(action, values) {
146
155
  if (MAIL_ACTIONS_COMPOSING.has(action) && to.length === 0) {
147
156
  throw new Error(`mail ${action} needs --to "someone@example.com" (comma-separated for several).`);
148
157
  }
149
- if (MAIL_ACTIONS_COMPOSING.has(action) && !optionalNonEmpty(values.flags.get("--account"))) {
150
- throw new Error(action === "draft"
151
- ? "mail draft needs --account <id>: with several mailboxes attached, which one the draft is saved in is yours to say. `cockpit mail accounts` lists them."
152
- : "mail send needs --account <id>: with several mailboxes attached, which address this goes out from is yours to say. `cockpit mail accounts` lists them.");
153
- }
154
158
  return { to, cc };
155
159
  }
156
160
  function readAttachmentPath(action, values) {
@@ -194,6 +198,7 @@ function buildMailCommand(values, action, subject, query, address, to, cc, outPa
194
198
  ...(subject ? { subjectId: subject } : {}),
195
199
  ...(query ? { query } : {}),
196
200
  accountId: optionalNonEmpty(values.flags.get("--account")),
201
+ source: values.flags.get("--source"),
197
202
  address,
198
203
  displayName: optionalNonEmpty(values.flags.get("--name")),
199
204
  ...(limit === undefined ? {} : { limit }),
@@ -215,6 +215,7 @@ const NOTES_ACTIONS = new Set([
215
215
  "webhook",
216
216
  // Ticket 10718: the Granola cron's own last-several-runs health.
217
217
  "granola-status",
218
+ "bot-upcoming",
218
219
  ]);
219
220
  /** Actions whose first positional is the note it acts on. */
220
221
  const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
@@ -115,7 +115,8 @@ export const TOWER_COMMAND_HELP = [
115
115
  [
116
116
  "Usage: cockpit mail [accounts|add-imap|inbox|read <thread>|search \"<words>\"|send|draft|attachment <id>|sync <account>|sync-status [<account>]|detach <account>] [flags]",
117
117
  "",
118
- "Every mailbox you attached, in one place. One person, several addresses — nothing here has a \"current\" mailbox.",
118
+ "Every mailbox you attached, in one place. One person, several addresses. Writes use a named account or your default.",
119
+ "rename --account <name> --name <label>; enable|disable --account <name> --source mail|calendar; default|remove --account <name>. Switches keep stored data; remove disconnects and deletes Tower copies.",
119
120
  "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.",
120
121
  "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:",
121
122
  " macOS: printf \"%s\" \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
@@ -124,8 +125,8 @@ export const TOWER_COMMAND_HELP = [
124
125
  "inbox [--account <id>] [--unread] [--label <l>] [--limit <n>] [--before <iso>] — everything across every mailbox, newest first. * is unread, @ has an attachment. Every row (text or --json) carries the thread id — `[thread <id>]` in text — so `mail read <thread>` never costs a second --json call to learn it (BLI-10450).",
125
126
  "read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox` or `cockpit mail search`, in either --json or plain text.",
126
127
  "search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box. Every row (text or --json) carries the thread id.",
127
- "send --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] [--attach <path>]... — the BODY comes from --file or stdin. --account is required: which address this goes out from is yours to say.",
128
- "draft --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] [--attach <path>]... — saves the message as a DRAFT in that mailbox for a person to read, edit and send themselves. NOTHING IS SENT and nobody is notified, so there is no confirmation step. Same flags and same body rule as send (--file or stdin). Only the mailbox's owner can draft into it. --reply-to threads the draft into that conversation the way send does; Gmail files it there only when the subject matches the original's, and the answer says whether it did. Gmail mailboxes only: an IMAP mailbox is refused as imap_drafts_unsupported. --json answers { ok, draft: { account_id, address, provider_draft_id, provider_thread_id, threaded } } (BLI-12530).",
128
+ "send [--account <name|id>] --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] [--attach <path>]... — the BODY comes from --file or stdin. Name --account to override your default; with several accounts and no default, Tower asks which one.",
129
+ "draft [--account <name|id>] --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] [--attach <path>]... — saves the message as a DRAFT in that mailbox for a person to read, edit and send themselves. NOTHING IS SENT and nobody is notified, so there is no confirmation step. Same flags and same body rule as send (--file or stdin). Only the mailbox's owner can draft into it. --reply-to threads the draft into that conversation the way send does; Gmail files it there only when the subject matches the original's, and the answer says whether it did. Gmail mailboxes only: an IMAP mailbox is refused as imap_drafts_unsupported. --json answers { ok, draft: { account_id, address, provider_draft_id, provider_thread_id, threaded } } (BLI-12530).",
129
130
  "--attach <path> (send and draft; repeat it for each file) — a file that rides along with the message: up to 10, and 3,000,000 bytes together (about 3 MB: a request to Tower is capped at 4.5 MB and a file travels as base64, a third bigger than itself). The type comes from the extension (.docx .pdf .png .jpg .jpeg .txt .csv .md .zip and more; anything else goes as application/octet-stream), the name is the file's own. --file is still the BODY and only the body. Refused BEFORE anything is sent, by name and with the numbers: too_many_attachments, attachment_unreadable (a path that is missing, a folder or empty), attachment_too_large. A reply (--reply-to) keeps its thread with files in it. Works on Gmail mailboxes and on IMAP mailboxes for send; a draft is Gmail only. --json adds { attachments: { count, bytes, content_types } } to the answer. The terminal reports from Tower's own answer, not from what it posted: if Tower does not confirm the files (an older Tower), it exits 1 with attachments_not_confirmed and says whether the message went. Names, paths and contents are never logged; a refusal says \"Attachment 2\", not which file (BLI-12661).",
130
131
  "attachment <id> --out <path> — downloads one attachment to a file. Attachments are pointers until you ask; nothing is stored in Tower.",
131
132
  "sync <account> — reads that mailbox now instead of waiting for the cron. Prints what landed, or the reason it did not.",
@@ -152,12 +153,14 @@ export const TOWER_COMMAND_HELP = [
152
153
  ' PowerShell: "https://calendar.google.com/calendar/ical/…/basic.ics" | cockpit cal add-ical --name "Personal"',
153
154
  " 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.",
154
155
  " A work @buildlaunchiterate.ca calendar does not need this: connect Google in the browser and `cockpit cal calendars` will list them.",
155
- 'create --calendar <id> --title "<what>" --at <iso> --until <iso> [--location "<where>"] [--attendee a@x,b@y] [--all-day] [--meet] — creates the event AT GOOGLE, then here. Attendees are invited by Google; --meet asks for a Google Meet room and prints its address (or says meet_not_available and keeps the event).',
156
+ 'create [--account <name>|--calendar <id>] --title "<what>" --at <iso> --until <iso> [--location "<where>"] [--attendee a@x,b@y] [--all-day] [--meet] — creates the event AT GOOGLE, then here. Attendees are invited by Google; --meet asks for a Google Meet room and prints its address (or says meet_not_available and keeps the event).',
156
157
  " 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.",
157
158
  "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.",
158
159
  "sync <id> [--full] — reads that calendar now instead of waiting for the cron. --full ignores the sync token / ETag and re-reads everything.",
159
160
  "sync-status [<id>] — every calendar's health, or one's: last successful sync, the last pass's own reason, how many events it carried, failures in the last 24h, and the lag between an event's own provider clock and when Tower stored it (ticket 10730).",
160
161
  "detach <id> — removes the calendar and its stored events from Tower. The calendar itself is untouched at Google.",
162
+ "change <event id> [--account <name>] [--title <title>] [--at <iso> --until <iso>] [--location <place>] — changes an event on its own account.",
163
+ "cancel <event id> --yes [--account <name>] — same door as delete; a named account must own the event.",
161
164
  "delete <event id> --yes — removes ONE event, AT GOOGLE first, then here. Refuses without --yes: a delete can email everybody on it, exactly like create's invitations do. Google already having it gone (404/410) is answered as success, not a refusal.",
162
165
  "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
163
166
  "A refusal keeps Tower's own reason label — needs_rls_client, calendar_not_found_or_unreadable, calendar_not_yours, event_not_found_or_unreadable, ical_url_invalid, google_oauth_not_configured, write_not_supported_for_provider, and so on.",
@@ -259,7 +262,7 @@ export const TOWER_COMMAND_HELP = [
259
262
  [
260
263
  "slack",
261
264
  [
262
- "Usage: cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [filters] [--format compact|full]] [--json]",
265
+ "Usage: cockpit slack [connect [--workspace bli|blue_pearl] | disconnect --workspace bli|blue_pearl | coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [filters] [--format compact|full]] [--json]",
263
266
  "",
264
267
  " cockpit slack coverage",
265
268
  " What Cockpit's Slack bot can see: how many channels it is in, what each",
@@ -392,6 +395,7 @@ export const TOWER_COMMAND_HELP = [
392
395
  "detach <account-id> — take it off. The stored key goes with it; the meetings it already filed stay.",
393
396
  "sync <account-id> — read that notetaker now. Prints the run's own counts and reason label, and exits 1 when the pass failed.",
394
397
  "sync-status [<account-id>] — every notetaker's health, or one's: last successful sync, the last pass's own reason, how many meetings it carried, failures in the last 24h, and the lag between the newest meeting it has read and when it last ran (ticket 10730).",
398
+ "bot-upcoming [--json] — your own Google Meet meetings today that the bot will join, with claim state. Decline or mark private to opt out.",
395
399
  "granola-status [--limit <n>] [--json] — the Granola cron's own last several runs (ticket 10718): reason, counts, model, spend and which lookback it used. Granola runs on a shared server-side key, not a connected account, so this is its own read rather than `accounts`.",
396
400
  "A large note is read by a model on the server and can take a couple of minutes; the terminal says so before it waits.",
397
401
  "--json writes one machine-readable object to stdout; every reason, receipt and progress line stays on stderr.",
@@ -111,7 +111,7 @@ export function localCommandHelp(command) {
111
111
  " cockpit models [list|show <provider:model>|compare <provider:model> <provider:model> [...]] [--highlight] [--dashboard-url <url>] [--json]",
112
112
  " cockpit scout [board|start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
113
113
  " cockpit ops [status [--job <id>] [--skips] [--memory] [--models] | recompile --person <email|name|id> [--dry-run]] [--dashboard-url <url>] [--json]",
114
- " cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [--person <p>] [--channel <c>] [--query <text> [--word]] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--format compact|full]] [--json]",
114
+ " cockpit slack [connect [--workspace bli|blue_pearl] | disconnect --workspace bli|blue_pearl | coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [--person <p>] [--channel <c>] [--query <text> [--word]] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--format compact|full]] [--json]",
115
115
  " cockpit settings [personal [--chat-model <key>] [--brief-model <key>] | switches [set <key> <value>] | models [set --chat <key>] [--memory <id>] | cli-floor [<version>] | env list|set --project <p> --file <f> --content-stdin|delete --id <uuid> [--yes]] [--json]",
116
116
  " cockpit team [members | invite <email> --role <role> [--team-id <uuid>] | role <userId> --role <role> [--yes] | device [list] | device revoke <id|name> --reason <label> [--note \"<text>\"] [--yes]] [--json]",
117
117
  " cockpit workbook [<project> [<doc>]] [--section <id>] [--markdown] [--width <n>] [--dashboard-url <url>] [--json]",
@@ -0,0 +1,16 @@
1
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
2
+ import { writeLine } from "./cli-io.js";
3
+ export async function runMailAccountPreference(command, door) {
4
+ const removing = command.action === "remove";
5
+ const answer = await askAgentDoor(door, {
6
+ path: removing ? `/api/mail/accounts?account=${encodeURIComponent(command.accountId)}` : "/api/mail/accounts",
7
+ method: removing ? "DELETE" : "PATCH", label: `mail ${command.action}`, timeoutMs: 30_000,
8
+ ...(!removing ? { body: { account: command.accountId, ...(command.action === "rename" ? { account_label: command.displayName } : command.action === "default" ? { is_default: true } : { [`${command.source}_enabled`]: command.action === "enable" }) } } : {}),
9
+ });
10
+ if (!answer.ok)
11
+ return failAgentDoor(door, "[accounts cli]", answer.reason, answer.detail);
12
+ if (door.json)
13
+ return emitAgentDoor(door, { ok: true, ...answer.body });
14
+ writeLine(door.io.stdout, `Account ${command.accountId}: ${command.action} completed.`);
15
+ return 0;
16
+ }
@@ -63,7 +63,7 @@ export async function readMailBody(command, door) {
63
63
  */
64
64
  export function composedMessageBody(command, text, attachments = []) {
65
65
  return {
66
- account_id: command.accountId,
66
+ ...(command.accountId ? /^[0-9a-f-]{36}$/i.test(command.accountId) ? { account_id: command.accountId } : { account: command.accountId } : {}),
67
67
  to: command.to.map((address) => ({ address })),
68
68
  ...(command.cc.length > 0 ? { cc: command.cc.map((address) => ({ address })) } : {}),
69
69
  subject: command.subjectLine ?? "",
@@ -164,7 +164,7 @@ export async function runMailDraft(command, door) {
164
164
  return failAttachmentsUnconfirmed(door, "draft", unconfirmed, draft);
165
165
  if (door.json)
166
166
  return emitAgentDoor(door, { ok: true, draft, ...message.receipt.json });
167
- writeLine(door.io.stdout, `Saved a draft in ${draft?.address ?? "that mailbox"} for ${command.to.join(", ")}. `
167
+ writeLine(door.io.stdout, `Saved a draft in ${draft?.account_name ?? draft?.address ?? "that mailbox"} for ${command.to.join(", ")}. `
168
168
  + "Nothing was sent: it is in Drafts for a person to read, edit and send.");
169
169
  if (message.receipt.sentence)
170
170
  writeLine(door.io.stdout, message.receipt.sentence);
@@ -27,6 +27,7 @@ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./ag
27
27
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
28
28
  import { composeMessage, failAttachmentsUnconfirmed, readMailBody, runMailDraft, unconfirmedAttachments, } from "./mail-compose.js";
29
29
  import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
30
+ import { runMailAccountPreference } from "./mail-account-preferences.js";
30
31
  import { towerRequest } from "../tower-client.js";
31
32
  const TAG = "[mail cli]";
32
33
  const READ_DEADLINE_MS = 30_000;
@@ -37,6 +38,12 @@ const PASSWORD_MAX_CHARS = 200;
37
38
  export async function runMail(command, io) {
38
39
  const door = await openAgentDoor("mail", command, io);
39
40
  switch (command.action) {
41
+ case "rename":
42
+ case "enable":
43
+ case "disable":
44
+ case "default":
45
+ case "remove":
46
+ return runMailAccountPreference(command, door);
40
47
  case "accounts":
41
48
  return listAccounts(door);
42
49
  case "add-imap":
@@ -82,7 +89,7 @@ async function listAccounts(door) {
82
89
  }
83
90
  for (const account of accounts) {
84
91
  const health = account.status === "active" ? account.backfill_state : `${account.status}: ${account.status_reason ?? "no reason recorded"}`;
85
- writeLine(door.io.stdout, `${account.address.padEnd(34)} ${account.provider.padEnd(12)} ${health.padEnd(18)} ${account.last_sync_at ?? "never synced"}`);
92
+ writeLine(door.io.stdout, `${(account.account_label ?? account.address).padEnd(20)} ${account.is_default ? "default" : ""} mail=${account.mail_enabled !== false} calendar=${account.calendar_enabled !== false} ${account.address.padEnd(34)} ${account.provider.padEnd(12)} ${health.padEnd(18)} ${account.last_sync_at ?? "never synced"}`);
86
93
  }
87
94
  writeLine(door.io.stdout, "");
88
95
  writeLine(door.io.stdout, `${accounts.length} mailbox(es). ids: ${accounts.map((account) => account.id).join(", ")}`);
@@ -279,7 +286,7 @@ async function send(command, door) {
279
286
  return failAttachmentsUnconfirmed(door, "send", unconfirmed, sent);
280
287
  if (door.json)
281
288
  return emitAgentDoor(door, { ok: true, sent, ...message.receipt.json });
282
- writeLine(door.io.stdout, `Sent from ${sent?.address ?? "that mailbox"} to ${command.to.join(", ")}${sent?.threaded ? " (as a reply)" : ""}.`);
289
+ writeLine(door.io.stdout, `Sent from ${sent?.account_name ?? sent?.address ?? "that mailbox"} to ${command.to.join(", ")}${sent?.threaded ? " (as a reply)" : ""}.`);
283
290
  if (message.receipt.sentence)
284
291
  writeLine(door.io.stdout, message.receipt.sentence);
285
292
  return 0;
@@ -0,0 +1,16 @@
1
+ import { ask, emit, fail, READ_DEADLINE_MS } from "./notes-door.js";
2
+ import { writeLine } from "./cli-io.js";
3
+ export async function showBotUpcoming(_command, door) {
4
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
5
+ const answer = await ask(door, { path: `/api/notetaker/upcoming?tz=${encodeURIComponent(tz)}`, method: "GET", label: "bot upcoming", timeoutMs: READ_DEADLINE_MS });
6
+ if (!answer.ok)
7
+ return fail(door, answer.reason, answer.detail);
8
+ if (door.json)
9
+ return emit(door, answer.body);
10
+ const body = answer.body;
11
+ if (!body.meetings.length)
12
+ writeLine(door.io.stdout, "No meetings for the bot today.");
13
+ for (const meeting of body.meetings)
14
+ writeLine(door.io.stdout, `${meeting.startsAt} ${meeting.claim.status} ${meeting.meetUrl}`);
15
+ return 0;
16
+ }
@@ -44,6 +44,7 @@ import { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
44
44
  import { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
45
45
  import { runFolderCommand } from "./notes-folders.js";
46
46
  import { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, syncStatusNotetakers, } from "./notes-accounts.js";
47
+ import { showBotUpcoming } from "./notes-bot-upcoming.js";
47
48
  import { showGranolaStatus } from "./notes-granola-status.js";
48
49
  import { listRecordings, showRecording } from "./notes-recordings.js";
49
50
  export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
@@ -106,6 +107,8 @@ export async function runNotes(command, io) {
106
107
  return showRecording(command, door);
107
108
  case "webhook":
108
109
  return armNotesWebhook(command, door);
110
+ case "bot-upcoming":
111
+ return showBotUpcoming(command, door);
109
112
  case "granola-status":
110
113
  return showGranolaStatus(command, door);
111
114
  }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.198");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.200");
25
25
  return 0;
26
26
  }
27
27
 
@@ -24,6 +24,24 @@ import { colorEnabled, dim, writeLine } from "./cli-io.js";
24
24
  import { asRecord, callTower, openTower, writeCommandFailure } from "./tower-command.js";
25
25
  export async function runSlack(command, io) {
26
26
  const tower = await openTower("slack", command, io);
27
+ if (command.action === "connect" || command.action === "disconnect") {
28
+ const result = await callTower(tower, { path: `/api/slack/connections${command.action === "disconnect" ? `?workspace=${command.workspace}` : ""}`,
29
+ method: command.action === "disconnect" ? "DELETE" : "GET", label: "slack-connection" });
30
+ if (!result.ok)
31
+ return writeCommandFailure(io, command.json, result);
32
+ writeLine(io.stderr, `[slack cli] connection answered ${JSON.stringify({ action: command.action, workspace: command.workspace ?? "all", reason: "ok" })}`);
33
+ const body = asRecord(result.body);
34
+ if (command.json)
35
+ writeLine(io.stdout, JSON.stringify(body));
36
+ else if (command.action === "disconnect")
37
+ writeLine(io.stdout, `Disconnected Slack for ${command.workspace}.`);
38
+ else
39
+ for (const connection of (body["connections"] ?? [])) {
40
+ if (!command.workspace || command.workspace === connection["workspace"])
41
+ writeLine(io.stdout, `${connection["workspace"]}: ${connection["connected"] ? "connected" : "Connect Slack"} ${new URL(String(connection["connectPath"]), tower.dashboardUrl).toString()}`);
42
+ }
43
+ return 0;
44
+ }
27
45
  return command.action === "coverage"
28
46
  ? runCoverageAction(command, io, tower)
29
47
  : runReadAction(command, io, tower);
@@ -86,6 +104,12 @@ function coverageLines(coverage, workspaces, staleOnly, list, paint) {
86
104
  for (const workspace of workspaces) {
87
105
  writeWorkspaceCoverageLines(lines, workspace, staleAfter, staleOnly, list, paint);
88
106
  }
107
+ for (const entry of (coverage["privateCoverage"] ?? [])) {
108
+ lines.push(`${entry["workspace"]}: ${entry["botMemberCount"]}/${entry["privateChannelsKnown"]} known private channels have the bot; ${entry["peopleConnected"]} people connected; as of ${entry["asOf"] ?? "no scan yet"}`);
109
+ lines.push(` Not connected: ${(entry["peopleNotConnected"] ?? []).join(", ") || "none"}`);
110
+ }
111
+ if (coverage["privateCoverageError"])
112
+ lines.push(`Private coverage unavailable: ${coverage["privateCoverageError"]}`);
89
113
  writeCoverageFooter(lines, coverage, paint);
90
114
  return lines;
91
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.198",
3
+ "version": "0.2.200",
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.60",
31
- "@bli-cockpit/mcp": "0.1.110",
31
+ "@bli-cockpit/mcp": "0.1.112",
32
32
  "@bli-cockpit/telemetry-core": "0.1.69"
33
33
  },
34
34
  "repository": {