@bli-cockpit/cli 0.2.117 → 0.2.119

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.
@@ -152,6 +152,18 @@ export function parseBriefArgs(args) {
152
152
  json: values.booleans.has("--json"),
153
153
  };
154
154
  }
155
+ /**
156
+ * The notetakers `cockpit notes connect` will actually seal a key for
157
+ * (BLI-4391). It mirrors `CONNECTABLE_NOTES_PROVIDERS` in the dashboard, and
158
+ * the door is still the authority — this list only saves a round trip and a
159
+ * sealed key for a provider with no reader.
160
+ *
161
+ * `otter` and `granola` are deliberately absent: Otter's public API is
162
+ * Enterprise-only and Granola still runs on its own server-side cron.
163
+ */
164
+ const CONNECTABLE_PROVIDERS = ["fellow", "circleback"];
165
+ /** The provider whose API is per workspace, so its account needs a subdomain. */
166
+ const PROVIDERS_NEEDING_A_WORKSPACE = ["fellow"];
155
167
  const NOTES_ACTIONS = new Set([
156
168
  "folders", "mkdir", "rmdir", "rename",
157
169
  "list",
@@ -169,6 +181,8 @@ const NOTES_ACTIONS = new Set([
169
181
  "connect",
170
182
  "detach",
171
183
  "sync",
184
+ // BLI-4394: arm that notetaker's webhook with the vendor's signing secret.
185
+ "webhook",
172
186
  ]);
173
187
  /** Actions whose first positional is the note it acts on. */
174
188
  const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
@@ -183,7 +197,7 @@ const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
183
197
  * note id. `sync` takes one too, and is handled beside these rather than in
184
198
  * this set because its id is checked at the door instead of here.
185
199
  */
186
- const NOTES_ACTIONS_NEEDING_AN_ACCOUNT = new Set(["detach"]);
200
+ const NOTES_ACTIONS_NEEDING_AN_ACCOUNT = new Set(["detach", "webhook"]);
187
201
  export function parseNotesArgs(args) {
188
202
  const values = parseNamedArgs(args, {
189
203
  allowedFlags: [
@@ -209,6 +223,13 @@ export function parseNotesArgs(args) {
209
223
  // read from stdin and never from argv.
210
224
  "--provider",
211
225
  "--key-stdin",
226
+ // BLI-4391. Fellow's API is per WORKSPACE, and a subdomain is a label,
227
+ // not a secret, so this one carries a value while the key never does.
228
+ "--workspace",
229
+ // BLI-4394. `--secret-stdin` is a FLAG and not a value, for the same
230
+ // reason `--key-stdin` is: the signing secret never touches argv.
231
+ "--secret-stdin",
232
+ "--show-webhook",
212
233
  ],
213
234
  valueFlags: [
214
235
  "--home",
@@ -224,6 +245,8 @@ export function parseNotesArgs(args) {
224
245
  "--until",
225
246
  "--limit",
226
247
  "--provider",
248
+ "--workspace",
249
+ "--show-webhook",
227
250
  ],
228
251
  });
229
252
  // Bare `cockpit notes` is the list, which is the thing a person typing it
@@ -231,7 +254,7 @@ export function parseNotesArgs(args) {
231
254
  const first = values.positionals[0];
232
255
  const action = (first === undefined ? "list" : first);
233
256
  if (!NOTES_ACTIONS.has(action)) {
234
- throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, move, place, accounts, connect, detach, or sync.`);
257
+ throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, move, place, accounts, connect, detach, sync, or webhook.`);
235
258
  }
236
259
  const rest = values.positionals.slice(first === undefined ? 0 : 1);
237
260
  const json = values.booleans.has("--json");
@@ -258,14 +281,46 @@ export function parseNotesArgs(args) {
258
281
  throw new Error(`notes ${action} takes one account id, not ${rest.length}.`);
259
282
  }
260
283
  }
284
+ // BLI-4394. `--show-webhook <id>` is a flag on `accounts` rather than a verb
285
+ // of its own, because "what is this one's webhook URL?" is the question a
286
+ // person asks WHILE looking at the list.
287
+ const showWebhook = optionalNonEmpty(values.flags.get("--show-webhook"));
288
+ if (showWebhook !== undefined) {
289
+ if (action !== "accounts") {
290
+ throw new Error("--show-webhook belongs to `cockpit notes accounts --show-webhook <account-id>`.");
291
+ }
292
+ accountId = showWebhook;
293
+ }
294
+ if (action === "webhook") {
295
+ if (!values.booleans.has("--secret-stdin")) {
296
+ // Naming the flag is the point: it says out loud that the signing secret
297
+ // is not an argument, so nobody goes looking for a `--secret` that will
298
+ // never exist.
299
+ throw new Error('notes webhook reads the signing secret from stdin. Say so: printf "%s" "<secret>" | cockpit notes webhook <account-id> --secret-stdin');
300
+ }
301
+ }
302
+ else if (values.booleans.has("--secret-stdin")) {
303
+ throw new Error("--secret-stdin belongs to `cockpit notes webhook <account-id> --secret-stdin`.");
304
+ }
261
305
  const provider = optionalNonEmpty(values.flags.get("--provider"));
306
+ const workspace = optionalNonEmpty(values.flags.get("--workspace"));
262
307
  if (action === "connect") {
263
- if (!provider)
264
- throw new Error("notes connect needs --provider fathom.");
308
+ if (!provider) {
309
+ throw new Error(`notes connect needs --provider ${CONNECTABLE_PROVIDERS.join("|")}.`);
310
+ }
311
+ if (!CONNECTABLE_PROVIDERS.includes(provider)) {
312
+ throw new Error(`notes connect does not know the provider "${provider}". Tower reads ${CONNECTABLE_PROVIDERS.join(" and ")}.`);
313
+ }
314
+ if (PROVIDERS_NEEDING_A_WORKSPACE.includes(provider) && !workspace) {
315
+ throw new Error(`notes connect --provider ${provider} needs --workspace <subdomain>: its API lives at https://<subdomain>.fellow.app, so a key alone cannot address anything.`);
316
+ }
317
+ if (workspace !== undefined && !/^[a-z0-9-]{1,63}$/.test(workspace)) {
318
+ throw new Error("--workspace is a workspace subdomain: lowercase letters, digits and hyphens, 1 to 63 characters.");
319
+ }
265
320
  if (!values.booleans.has("--key-stdin")) {
266
321
  // Naming the flag is the point: it says out loud that the key is not an
267
322
  // argument, so nobody goes looking for a `--key` that will never exist.
268
- throw new Error('notes connect reads the key from stdin. Say so: printf "%s" "<key>" | cockpit notes connect --provider fathom --key-stdin');
323
+ throw new Error('notes connect reads the key from stdin. Say so: printf "%s" "<key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
269
324
  }
270
325
  if (rest.length > 0)
271
326
  throw new Error(`notes connect does not take "${rest[0]}".`);
@@ -316,6 +371,8 @@ export function parseNotesArgs(args) {
316
371
  ...(noteId ? { noteId } : {}),
317
372
  ...(accountId ? { accountId } : {}),
318
373
  ...(provider ? { provider } : {}),
374
+ ...(workspace ? { workspace } : {}),
375
+ ...(showWebhook ? { showWebhook: true } : {}),
319
376
  ...(paths ? { paths } : {}),
320
377
  filePath: optionalNonEmpty(values.flags.get("--file")),
321
378
  name: optionalNonEmpty(values.flags.get("--name")),
@@ -472,9 +472,11 @@ export function localSubcommandHelp(command) {
472
472
  "move <id> (--to \"<shelf>\"|--clear-shelf) — put it on a different shelf, or take the shelf off.",
473
473
  "place <id> [--apply] — say which shelf this note belongs on and why, from the meeting it is part of, who was in the room and its own name. Moves nothing without --apply, and never overrules a shelf somebody typed.",
474
474
  "",
475
- "Your own notetaker (BLI-4383). Fathom today; Granola uses the same account model.",
475
+ "Your own notetaker (BLI-4383). Fellow and Circleback today; Otter needs an Enterprise plan and Granola still runs on its own cron.",
476
476
  "accounts [--json] — the notetakers you have connected, with health and when each was last read.",
477
- "connect --provider fathom --key-stdin [--name <n>]connect one. THE API KEY IS READ FROM STDIN, never a flag: printf \"%s\" \"<key>\" | cockpit notes connect --provider fathom --key-stdin",
477
+ "accounts --show-webhook <account-id> — that account's webhook URL, where to paste it at the vendor, and whether a signing secret is stored (BLI-4394). Until one is, every request to that URL is refused, which is the right way to be off.",
478
+ "webhook <account-id> --secret-stdin — store the VENDOR's webhook signing secret. THE SECRET IS READ FROM STDIN, never a flag: printf \"%s\" \"<signing secret>\" | cockpit notes webhook <account-id> --secret-stdin",
479
+ "connect --provider fellow|circleback [--workspace <subdomain>] --key-stdin [--name <n>] — connect one. Fellow needs --workspace, its API is per workspace. THE API KEY IS READ FROM STDIN, never a flag: printf \"%s\" \"<key>\" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin",
478
480
  "detach <account-id> — take it off. The stored key goes with it; the meetings it already filed stay.",
479
481
  "sync <account-id> — read that notetaker now. Prints the run's own counts and reason label, and exits 1 when the pass failed.",
480
482
  "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.",
@@ -87,7 +87,7 @@ export function localCommandHelp(command) {
87
87
  " cockpit brief status [--who <person>] [--render] [--dashboard-url <url>] [--json]",
88
88
  " cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
89
89
  " cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|move <id>|place <id>] [--folder <path>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--apply] [--yes] [--dashboard-url <url>] [--json]",
90
- " cockpit notes [accounts|connect --provider fathom --key-stdin [--name <n>]|detach <account-id>|sync <account-id>] [--json] (the key is read from stdin, never a flag)",
90
+ " cockpit notes [accounts [--show-webhook <account-id>]|connect --provider fellow --workspace <sub> --key-stdin [--name <n>]|webhook <account-id> --secret-stdin|detach <account-id>|sync <account-id>] [--json] (the key and the signing secret are read from stdin, never a flag)",
91
91
  " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
92
92
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
93
93
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
@@ -37,14 +37,18 @@ export async function listNotesAccounts(command, door) {
37
37
  if (accounts.length === 0) {
38
38
  writeLine(door.io.stdout, "No notetaker is connected yet.");
39
39
  writeLine(door.io.stdout, "");
40
- writeLine(door.io.stdout, 'Connect one: printf "%s" "<your fathom api key>" | cockpit notes connect --provider fathom --key-stdin');
40
+ writeLine(door.io.stdout, 'Connect one: printf "%s" "<your fellow api key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
41
+ writeLine(door.io.stdout, ' or: printf "%s" "<your circleback api key>" | cockpit notes connect --provider circleback --key-stdin');
41
42
  return 0;
42
43
  }
43
44
  for (const account of accounts) {
44
45
  const health = account.status === "active"
45
46
  ? "active"
46
47
  : `${account.status}: ${account.status_reason ?? "no reason recorded"}`;
47
- writeLine(door.io.stdout, `${(account.label ?? account.provider).padEnd(20)} ${account.provider.padEnd(10)} ${health.padEnd(24)} ${account.last_synced_at ?? "never read"}`);
48
+ // The workspace rides beside the provider because a Fellow account that
49
+ // points at the wrong subdomain fails with a 401 and nothing else says so.
50
+ const where = account.workspace ? `${account.provider}/${account.workspace}` : account.provider;
51
+ writeLine(door.io.stdout, `${(account.label ?? account.provider).padEnd(20)} ${where.padEnd(20)} ${health.padEnd(24)} ${account.last_synced_at ?? "never read"}`);
48
52
  }
49
53
  writeLine(door.io.stdout, "");
50
54
  writeLine(door.io.stdout, `${accounts.length} notetaker(s). ids: ${accounts.map((account) => account.id).join(", ")}`);
@@ -58,7 +62,7 @@ export async function connectNotetaker(command, door) {
58
62
  // nothing piped in is told how, rather than left waiting on a prompt that
59
63
  // would echo the secret into the scrollback.
60
64
  if (isInteractiveStdin(door.io)) {
61
- return fail(door, "key_required_on_stdin", 'The API key is read from stdin, never from a flag. Try: printf "%s" "<your key>" | cockpit notes connect --provider fathom --key-stdin');
65
+ return fail(door, "key_required_on_stdin", 'The API key is read from stdin, never from a flag. Try: printf "%s" "<your key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
62
66
  }
63
67
  let apiKey;
64
68
  try {
@@ -79,8 +83,12 @@ export async function connectNotetaker(command, door) {
79
83
  label: "notes connect",
80
84
  timeoutMs: WRITE_DEADLINE_MS,
81
85
  body: {
82
- provider: command.provider ?? "fathom",
86
+ provider: command.provider ?? "fellow",
83
87
  label: command.name ?? null,
88
+ // Absent, not null, when nobody named one: the door treats a missing
89
+ // workspace and an explicit null the same, and a `--workspace` a person
90
+ // did type is never silently dropped.
91
+ ...(command.workspace ? { workspace: command.workspace } : {}),
84
92
  api_key: apiKey,
85
93
  },
86
94
  });
@@ -89,10 +97,93 @@ export async function connectNotetaker(command, door) {
89
97
  const account = answer.body.account;
90
98
  if (door.json)
91
99
  return emit(door, { ok: true, account });
92
- writeLine(door.io.stdout, `Connected ${account?.provider ?? command.provider ?? "fathom"} (${account?.id ?? "no id returned"}).`);
100
+ const named = account?.provider ?? command.provider ?? "fellow";
101
+ writeLine(door.io.stdout, `Connected ${account?.workspace ? `${named}/${account.workspace}` : named} (${account?.id ?? "no id returned"}).`);
93
102
  writeLine(door.io.stdout, "Nothing is read until a sync runs: `cockpit notes sync <id>`, or wait for the half-hourly cron.");
94
103
  return 0;
95
104
  }
105
+ /**
106
+ * `cockpit notes accounts --show-webhook <id>` and
107
+ * `cockpit notes webhook <id> --secret-stdin` (BLI-4394).
108
+ *
109
+ * The URL is SHOWN, because a person cannot register a webhook they cannot
110
+ * read. The signing secret is READ FROM STDIN and never from a flag, for the
111
+ * same reason `connect` reads the API key that way: an argument list is in the
112
+ * process table, in the shell history and in every log that records a command
113
+ * line. There is deliberately no `--secret <value>`, and deliberately no MCP
114
+ * twin for this verb.
115
+ */
116
+ export async function showNotesWebhook(command, door) {
117
+ const accountId = command.accountId ?? "";
118
+ if (accountId === "") {
119
+ return fail(door, "account_required", "Name the notetaker: `cockpit notes accounts --show-webhook <account-id>`. `cockpit notes accounts` lists the ids.");
120
+ }
121
+ const answer = await ask(door, {
122
+ path: `/api/notes/accounts/${encodeURIComponent(accountId)}/webhook`,
123
+ method: "GET",
124
+ label: "notes webhook",
125
+ timeoutMs: READ_DEADLINE_MS,
126
+ });
127
+ if (!answer.ok)
128
+ return fail(door, answer.reason, answer.detail);
129
+ const webhook = answer.body.webhook;
130
+ if (door.json)
131
+ return emit(door, { ok: true, webhook });
132
+ if (!webhook?.url) {
133
+ writeLine(door.io.stdout, "This notetaker has no webhook URL.");
134
+ writeLine(door.io.stdout, "Its key was sealed before webhooks shipped. Detach it and connect it again to mint one.");
135
+ return 0;
136
+ }
137
+ writeLine(door.io.stdout, webhook.url);
138
+ writeLine(door.io.stdout, "");
139
+ writeLine(door.io.stdout, webhook.armed
140
+ ? "A signing secret is stored, so requests to that URL are verified."
141
+ : "NO SIGNING SECRET IS STORED YET, so every request to that URL is refused. That is the right way to be off.");
142
+ if (webhook.where) {
143
+ writeLine(door.io.stdout, "");
144
+ writeLine(door.io.stdout, webhook.where);
145
+ }
146
+ writeLine(door.io.stdout, "");
147
+ writeLine(door.io.stdout, `Store the secret: printf "%s" "<signing secret>" | cockpit notes webhook ${accountId} --secret-stdin`);
148
+ return 0;
149
+ }
150
+ export async function armNotesWebhook(command, door) {
151
+ const accountId = command.accountId ?? "";
152
+ if (accountId === "") {
153
+ return fail(door, "account_required", "Name the notetaker: `cockpit notes webhook <account-id> --secret-stdin`.");
154
+ }
155
+ // The secret comes from stdin and nowhere else, exactly as the API key does.
156
+ if (isInteractiveStdin(door.io)) {
157
+ return fail(door, "secret_required_on_stdin", `The signing secret is read from stdin, never from a flag. Try: printf "%s" "<signing secret>" | cockpit notes webhook ${accountId} --secret-stdin`);
158
+ }
159
+ let secret;
160
+ try {
161
+ secret = (await readPipedText(door.io.stdin, {
162
+ maxChars: KEY_MAX_CHARS,
163
+ overflowMessage: "That is longer than any webhook signing secret; nothing was sent.",
164
+ })).trim();
165
+ }
166
+ catch (error) {
167
+ return fail(door, "secret_unreadable", error instanceof Error ? error.message : String(error));
168
+ }
169
+ if (secret === "") {
170
+ return fail(door, "secret_required_on_stdin", "Nothing arrived on stdin, so nothing was stored.");
171
+ }
172
+ const answer = await ask(door, {
173
+ path: `/api/notes/accounts/${encodeURIComponent(accountId)}/webhook`,
174
+ method: "PUT",
175
+ label: "notes webhook arm",
176
+ timeoutMs: WRITE_DEADLINE_MS,
177
+ body: { signing_secret: secret },
178
+ });
179
+ if (!answer.ok)
180
+ return fail(door, answer.reason, answer.detail);
181
+ if (door.json)
182
+ return emit(door, { ok: true, armed: true, account_id: accountId });
183
+ writeLine(door.io.stdout, `Stored. Requests to ${accountId}'s webhook URL are now verified.`);
184
+ writeLine(door.io.stdout, "Until a meeting is written up nothing changes; the half-hourly sweep still reads this account either way.");
185
+ return 0;
186
+ }
96
187
  export async function detachNotetaker(command, door) {
97
188
  const accountId = command.accountId ?? "";
98
189
  const answer = await ask(door, {
@@ -40,10 +40,10 @@ import { loadPairedSession } from "../tower-client.js";
40
40
  import { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
41
41
  import { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
42
42
  import { runFolderCommand } from "./notes-folders.js";
43
- import { connectNotetaker, detachNotetaker, listNotesAccounts, syncNotetaker, } from "./notes-accounts.js";
43
+ import { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, } from "./notes-accounts.js";
44
44
  export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
45
45
  export { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
46
- export { connectNotetaker, detachNotetaker, listNotesAccounts, syncNotetaker, } from "./notes-accounts.js";
46
+ export { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, } from "./notes-accounts.js";
47
47
  export { ask, emit, fail, sayUpload, sayScope, errorText, TAG, } from "./notes-door.js";
48
48
  export async function runNotes(command, io) {
49
49
  const session = await loadPairedSession("notes", command.homeDir);
@@ -79,12 +79,19 @@ export async function runNotes(command, io) {
79
79
  case "place":
80
80
  return placeNote(command, door);
81
81
  case "accounts":
82
- return listNotesAccounts(command, door);
82
+ // BLI-4394. `--show-webhook <id>` turns the list into one account's
83
+ // webhook URL, because that is the question a person asks WHILE looking
84
+ // at the list rather than a verb of its own.
85
+ return command.showWebhook
86
+ ? showNotesWebhook(command, door)
87
+ : listNotesAccounts(command, door);
83
88
  case "connect":
84
89
  return connectNotetaker(command, door);
85
90
  case "detach":
86
91
  return detachNotetaker(command, door);
87
92
  case "sync":
88
93
  return syncNotetaker(command, door);
94
+ case "webhook":
95
+ return armNotesWebhook(command, door);
89
96
  }
90
97
  }
@@ -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.117");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.119");
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.117",
3
+ "version": "0.2.119",
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.32",
31
- "@bli-cockpit/mcp": "0.1.45",
31
+ "@bli-cockpit/mcp": "0.1.47",
32
32
  "@bli-cockpit/telemetry-core": "0.1.48"
33
33
  }
34
34
  }