@bli-cockpit/cli 0.2.115 → 0.2.117

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.
@@ -164,6 +164,11 @@ const NOTES_ACTIONS = new Set([
164
164
  "unshare",
165
165
  "move",
166
166
  "place",
167
+ // BLI-4383: the notetaker a person connects, and the meetings it records.
168
+ "accounts",
169
+ "connect",
170
+ "detach",
171
+ "sync",
167
172
  ]);
168
173
  /** Actions whose first positional is the note it acts on. */
169
174
  const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
@@ -173,6 +178,12 @@ const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
173
178
  "move",
174
179
  "place",
175
180
  ]);
181
+ /**
182
+ * BLI-4383. Actions whose first positional is a NOTETAKER ACCOUNT id, not a
183
+ * note id. `sync` takes one too, and is handled beside these rather than in
184
+ * this set because its id is checked at the door instead of here.
185
+ */
186
+ const NOTES_ACTIONS_NEEDING_AN_ACCOUNT = new Set(["detach"]);
176
187
  export function parseNotesArgs(args) {
177
188
  const values = parseNamedArgs(args, {
178
189
  allowedFlags: [
@@ -194,6 +205,10 @@ export function parseNotesArgs(args) {
194
205
  "--yes",
195
206
  "--json",
196
207
  "--tree",
208
+ // BLI-4383. `--key-stdin` is a FLAG and not a value: the key itself is
209
+ // read from stdin and never from argv.
210
+ "--provider",
211
+ "--key-stdin",
197
212
  ],
198
213
  valueFlags: [
199
214
  "--home",
@@ -208,6 +223,7 @@ export function parseNotesArgs(args) {
208
223
  "--since",
209
224
  "--until",
210
225
  "--limit",
226
+ "--provider",
211
227
  ],
212
228
  });
213
229
  // Bare `cockpit notes` is the list, which is the thing a person typing it
@@ -215,7 +231,7 @@ export function parseNotesArgs(args) {
215
231
  const first = values.positionals[0];
216
232
  const action = (first === undefined ? "list" : first);
217
233
  if (!NOTES_ACTIONS.has(action)) {
218
- throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, move, or place.`);
234
+ throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, move, place, accounts, connect, detach, or sync.`);
219
235
  }
220
236
  const rest = values.positionals.slice(first === undefined ? 0 : 1);
221
237
  const json = values.booleans.has("--json");
@@ -232,6 +248,28 @@ export function parseNotesArgs(args) {
232
248
  throw new Error(`notes ${action} takes one note id, not ${rest.length}.`);
233
249
  }
234
250
  }
251
+ let accountId;
252
+ if (NOTES_ACTIONS_NEEDING_AN_ACCOUNT.has(action) || action === "sync") {
253
+ accountId = optionalNonEmpty(rest[0]);
254
+ if (!accountId && NOTES_ACTIONS_NEEDING_AN_ACCOUNT.has(action)) {
255
+ throw new Error(`notes ${action} needs a notetaker account id.`);
256
+ }
257
+ if (rest.length > 1) {
258
+ throw new Error(`notes ${action} takes one account id, not ${rest.length}.`);
259
+ }
260
+ }
261
+ const provider = optionalNonEmpty(values.flags.get("--provider"));
262
+ if (action === "connect") {
263
+ if (!provider)
264
+ throw new Error("notes connect needs --provider fathom.");
265
+ if (!values.booleans.has("--key-stdin")) {
266
+ // Naming the flag is the point: it says out loud that the key is not an
267
+ // 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');
269
+ }
270
+ if (rest.length > 0)
271
+ throw new Error(`notes connect does not take "${rest[0]}".`);
272
+ }
235
273
  let paths;
236
274
  if (action === "upload") {
237
275
  // Explicit paths only. No globbing happens here — a shell that expands
@@ -245,7 +283,12 @@ export function parseNotesArgs(args) {
245
283
  if (rest.length !== 1 || !rest[0]?.trim())
246
284
  throw new Error(`notes ${action} needs one folder path.`);
247
285
  }
248
- else if (action !== "paste" && !NOTES_ACTIONS_NEEDING_A_NOTE.has(action) && rest.length > 0) {
286
+ else if (action !== "paste"
287
+ && action !== "connect"
288
+ && action !== "sync"
289
+ && !NOTES_ACTIONS_NEEDING_A_NOTE.has(action)
290
+ && !NOTES_ACTIONS_NEEDING_AN_ACCOUNT.has(action)
291
+ && rest.length > 0) {
249
292
  throw new Error(`notes ${action} does not take "${rest[0]}".`);
250
293
  }
251
294
  if (action === "rename" && !optionalNonEmpty(values.flags.get("--name")))
@@ -271,6 +314,8 @@ export function parseNotesArgs(args) {
271
314
  homeDir: optionalNonEmpty(values.flags.get("--home")),
272
315
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
273
316
  ...(noteId ? { noteId } : {}),
317
+ ...(accountId ? { accountId } : {}),
318
+ ...(provider ? { provider } : {}),
274
319
  ...(paths ? { paths } : {}),
275
320
  filePath: optionalNonEmpty(values.flags.get("--file")),
276
321
  name: optionalNonEmpty(values.flags.get("--name")),
@@ -471,6 +471,12 @@ export function localSubcommandHelp(command) {
471
471
  "unshare <id> — take it back. Never asks: it only ever narrows who can read.",
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
+ "",
475
+ "Your own notetaker (BLI-4383). Fathom today; Granola uses the same account model.",
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",
478
+ "detach <account-id> — take it off. The stored key goes with it; the meetings it already filed stay.",
479
+ "sync <account-id> — read that notetaker now. Prints the run's own counts and reason label, and exits 1 when the pass failed.",
474
480
  "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.",
475
481
  "--json writes one machine-readable object to stdout; every reason, receipt and progress line stays on stderr.",
476
482
  "Sharing and moving need a signed-in session the database can see. If this deployment cannot mint one, they are refused as needs_signed_in_session rather than done with no permission check — and reads say when they came back narrower than the browser's.",
@@ -87,6 +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
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]",
91
92
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
92
93
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `cockpit notes accounts | connect | detach | sync` — the notetaker a person
3
+ * connects, at a terminal (BLI-4383). Sibling of `notes.ts`, named in its
4
+ * header.
5
+ *
6
+ * Four verbs over the same `/api/notes/accounts` doors the Settings section
7
+ * calls, so nothing a browser can do here is unreachable from a terminal.
8
+ *
9
+ * **THE KEY NEVER TOUCHES ARGV.** `connect` reads it from stdin, exactly as
10
+ * `cockpit mail add-imap` reads a Google app password, and for the same
11
+ * reason: an argument list is in the process table, in the shell history and
12
+ * in every log that records a command line. There is deliberately no
13
+ * `--key <value>` flag, and there is deliberately no MCP twin for this one
14
+ * verb (`verb-census.ts`'s `TERMINAL_ONLY` says so in full).
15
+ *
16
+ * stdout is the answer, stderr is the commentary — the same split the rest of
17
+ * `cockpit notes` keeps.
18
+ */
19
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
20
+ import { ask, emit, fail, READ_DEADLINE_MS } from "./notes-door.js";
21
+ /** A pass can take a while; a read never should. */
22
+ const WRITE_DEADLINE_MS = 120_000;
23
+ /** An API key is long; anything past this is not one, and is refused by size. */
24
+ const KEY_MAX_CHARS = 500;
25
+ export async function listNotesAccounts(command, door) {
26
+ const answer = await ask(door, {
27
+ path: "/api/notes/accounts",
28
+ method: "GET",
29
+ label: "notes accounts",
30
+ timeoutMs: READ_DEADLINE_MS,
31
+ });
32
+ if (!answer.ok)
33
+ return fail(door, answer.reason, answer.detail);
34
+ const accounts = answer.body.accounts ?? [];
35
+ if (door.json)
36
+ return emit(door, { ok: true, accounts });
37
+ if (accounts.length === 0) {
38
+ writeLine(door.io.stdout, "No notetaker is connected yet.");
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');
41
+ return 0;
42
+ }
43
+ for (const account of accounts) {
44
+ const health = account.status === "active"
45
+ ? "active"
46
+ : `${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
+ }
49
+ writeLine(door.io.stdout, "");
50
+ writeLine(door.io.stdout, `${accounts.length} notetaker(s). ids: ${accounts.map((account) => account.id).join(", ")}`);
51
+ // `command` is unused beyond the door it already carries; naming it keeps
52
+ // every verb in this file one shape.
53
+ void command;
54
+ return 0;
55
+ }
56
+ export async function connectNotetaker(command, door) {
57
+ // The key comes from stdin and nowhere else. An interactive terminal with
58
+ // nothing piped in is told how, rather than left waiting on a prompt that
59
+ // would echo the secret into the scrollback.
60
+ 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');
62
+ }
63
+ let apiKey;
64
+ try {
65
+ apiKey = (await readPipedText(door.io.stdin, {
66
+ maxChars: KEY_MAX_CHARS,
67
+ overflowMessage: "That is longer than any notetaker API key; nothing was sent.",
68
+ })).trim();
69
+ }
70
+ catch (error) {
71
+ return fail(door, "key_unreadable", error instanceof Error ? error.message : String(error));
72
+ }
73
+ if (apiKey === "") {
74
+ return fail(door, "key_required_on_stdin", "Nothing arrived on stdin, so nothing was connected.");
75
+ }
76
+ const answer = await ask(door, {
77
+ path: "/api/notes/accounts",
78
+ method: "POST",
79
+ label: "notes connect",
80
+ timeoutMs: WRITE_DEADLINE_MS,
81
+ body: {
82
+ provider: command.provider ?? "fathom",
83
+ label: command.name ?? null,
84
+ api_key: apiKey,
85
+ },
86
+ });
87
+ if (!answer.ok)
88
+ return fail(door, answer.reason, answer.detail);
89
+ const account = answer.body.account;
90
+ if (door.json)
91
+ return emit(door, { ok: true, account });
92
+ writeLine(door.io.stdout, `Connected ${account?.provider ?? command.provider ?? "fathom"} (${account?.id ?? "no id returned"}).`);
93
+ writeLine(door.io.stdout, "Nothing is read until a sync runs: `cockpit notes sync <id>`, or wait for the half-hourly cron.");
94
+ return 0;
95
+ }
96
+ export async function detachNotetaker(command, door) {
97
+ const accountId = command.accountId ?? "";
98
+ const answer = await ask(door, {
99
+ path: `/api/notes/accounts?account_id=${encodeURIComponent(accountId)}`,
100
+ method: "DELETE",
101
+ label: "notes detach",
102
+ timeoutMs: WRITE_DEADLINE_MS,
103
+ });
104
+ if (!answer.ok)
105
+ return fail(door, answer.reason, answer.detail);
106
+ if (door.json)
107
+ return emit(door, { ok: true, detached: accountId });
108
+ writeLine(door.io.stdout, `Detached ${accountId}. Its stored key went with it.`);
109
+ writeLine(door.io.stdout, "The meetings it already filed stay where they are.");
110
+ return 0;
111
+ }
112
+ export async function syncNotetaker(command, door) {
113
+ const accountId = command.accountId ?? "";
114
+ if (accountId === "") {
115
+ // One account per call on purpose: the sweep over ALL of them is the cron,
116
+ // and a terminal asking for "everything" would hide which one failed.
117
+ return fail(door, "account_required", "Name the notetaker to sync: `cockpit notes sync <account-id>`. `cockpit notes accounts` lists the ids.");
118
+ }
119
+ const answer = await ask(door, {
120
+ path: `/api/notes/accounts/${encodeURIComponent(accountId)}/sync`,
121
+ method: "POST",
122
+ label: "notes sync",
123
+ timeoutMs: WRITE_DEADLINE_MS,
124
+ });
125
+ if (!answer.ok)
126
+ return fail(door, answer.reason, answer.detail);
127
+ const run = answer.body.run;
128
+ if (door.json)
129
+ return emit(door, { ok: true, run });
130
+ if (!run) {
131
+ writeLine(door.io.stdout, "Tower answered without a run.");
132
+ return 0;
133
+ }
134
+ // The ledger row's own words, relayed rather than rephrased.
135
+ writeLine(door.io.stdout, `${run.reason}: ${run.notesAdded} stored, ${run.notesUpdated} unchanged, ${run.notesSkipped} skipped.`);
136
+ if (run.detail)
137
+ writeLine(door.io.stdout, run.detail);
138
+ return run.ok ? 0 : 1;
139
+ }
@@ -30,6 +30,9 @@
30
30
  * - `notes-writes.ts` — upload, paste, share/unshare, move, place.
31
31
  * - `notes-door.ts` — the one HTTP request, and the shared answer / refusal /
32
32
  * emit / say-* rendering both halves above call.
33
+ * - `notes-accounts.ts` — BLI-4383: the notetaker a person connects, and the
34
+ * meetings it records. `connect` reads the API key from STDIN and there is
35
+ * no flag that would take one.
33
36
  *
34
37
  * Every public name is still importable from this file.
35
38
  */
@@ -37,8 +40,10 @@ import { loadPairedSession } from "../tower-client.js";
37
40
  import { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
38
41
  import { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
39
42
  import { runFolderCommand } from "./notes-folders.js";
43
+ import { connectNotetaker, detachNotetaker, listNotesAccounts, syncNotetaker, } from "./notes-accounts.js";
40
44
  export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
41
45
  export { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
46
+ export { connectNotetaker, detachNotetaker, listNotesAccounts, syncNotetaker, } from "./notes-accounts.js";
42
47
  export { ask, emit, fail, sayUpload, sayScope, errorText, TAG, } from "./notes-door.js";
43
48
  export async function runNotes(command, io) {
44
49
  const session = await loadPairedSession("notes", command.homeDir);
@@ -73,5 +78,13 @@ export async function runNotes(command, io) {
73
78
  return moveNote(command, door);
74
79
  case "place":
75
80
  return placeNote(command, door);
81
+ case "accounts":
82
+ return listNotesAccounts(command, door);
83
+ case "connect":
84
+ return connectNotetaker(command, door);
85
+ case "detach":
86
+ return detachNotetaker(command, door);
87
+ case "sync":
88
+ return syncNotetaker(command, door);
76
89
  }
77
90
  }
@@ -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.115");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.117");
19
19
  return 0;
20
20
  }
21
21
 
@@ -43,7 +43,7 @@ export async function runUsage(command, io) {
43
43
  if (!email)
44
44
  return failAgentDoor(door, "[usage]", "caller_email_unavailable", "The paired session has no email. Sign in again or pass --person <email>.");
45
45
  body.people = (body.people ?? []).filter((person) => person.email?.toLowerCase() === email.toLowerCase());
46
- body.coverage = { sessions_labelled: body.people.reduce((total, person) => total + (person.sessions_labelled ?? 0), 0), sessions_extracted: body.people.reduce((total, person) => total + person.sessions_extracted, 0), sessions_observed: body.people.reduce((total, person) => total + person.sessions_observed, 0), sessions_counted_at_upload: body.people.reduce((total, person) => total + (person.sessions_counted_at_upload ?? 0), 0), sessions_counted_by_server: body.people.reduce((total, person) => total + (person.sessions_counted_by_server ?? 0), 0), sessions_counted_by_ladder: body.people.reduce((total, person) => total + (person.sessions_counted_by_ladder ?? 0), 0) };
46
+ body.coverage = { sessions_labelled: body.people.reduce((total, person) => total + (person.sessions_labelled ?? 0), 0), sessions_extracted: body.people.reduce((total, person) => total + person.sessions_extracted, 0), sessions_observed: body.people.reduce((total, person) => total + person.sessions_observed, 0), sessions_counted_at_upload: body.people.reduce((total, person) => total + (person.sessions_counted_at_upload ?? 0), 0), sessions_counted_by_server: body.people.reduce((total, person) => total + (person.sessions_counted_by_server ?? 0), 0), sessions_counted_by_ladder: body.people.reduce((total, person) => total + (person.sessions_counted_by_ladder ?? 0), 0), sessions_awaiting_count: body.people.reduce((total, person) => total + (person.sessions_awaiting_count ?? 0), 0), sessions_automated_excluded: body.people.reduce((total, person) => total + (person.sessions_automated_excluded ?? 0), 0) };
47
47
  }
48
48
  if (command.byRepo && body.people?.some((person) => !Array.isArray(person.repos))) {
49
49
  return failAgentDoor(door, "[usage]", "repo_grouping_unavailable", "The dashboard returned person totals without project rows. Deploy the dashboard project split before using --by-repo.");
@@ -104,6 +104,14 @@ export async function runUsage(command, io) {
104
104
  writeLine(io.stdout, "");
105
105
  writeLine(io.stdout, body.api_list_price_equivalent_label ?? "API list-price equivalent (not actual spend)");
106
106
  writeLine(io.stdout, `${body.coverage?.sessions_extracted ?? 0} of ${body.coverage?.sessions_observed ?? 0} sessions extracted`);
107
+ // BLI-4356: the gap says why. A session observed with no tokens is either
108
+ // waiting on a count that has not run yet, or an automated one this view
109
+ // leaves out on purpose; never an unexplained hole in a person's week.
110
+ const waiting = body.coverage?.sessions_awaiting_count ?? 0;
111
+ const automatedExcluded = body.coverage?.sessions_automated_excluded ?? 0;
112
+ if (waiting > 0 || automatedExcluded > 0) {
113
+ writeLine(io.stdout, `${waiting} waiting on a token count, ${automatedExcluded} automated and excluded`);
114
+ }
107
115
  // BLI-4341: a session should arrive counted. This line is how a person sees
108
116
  // whether that is happening, or whether something else is doing the work.
109
117
  // BLI-4351 added the third counter: the ladder, counting out of Storage the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.115",
3
+ "version": "0.2.117",
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.43",
31
+ "@bli-cockpit/mcp": "0.1.45",
32
32
  "@bli-cockpit/telemetry-core": "0.1.48"
33
33
  }
34
34
  }