@bli-cockpit/cli 0.2.180 → 0.2.183

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,152 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { AgentSetupSchema, SetupNameSchema } from "@bli-cockpit/telemetry-core";
7
+ import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
8
+ const exec = promisify(execFile);
9
+ const files = {
10
+ read: file => fs.readFile(file, "utf8"), list: directory => fs.readdir(directory),
11
+ version: async (tool, platform) => {
12
+ try {
13
+ const { stdout } = await exec(platform === "win32" ? `${tool}.exe` : tool, ["--version"], { timeout: 750, maxBuffer: 4096, windowsHide: true });
14
+ return /(?:^|\s)(\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?)(?:\s|$)/i.exec(stdout)?.[1] ?? null;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ },
20
+ };
21
+ const safeName = (value) => SetupNameSchema.safeParse(value).success ? value : null;
22
+ const names = (values) => [...new Set(values.flatMap(value => safeName(value) ? [value] : []))].sort().slice(0, 500);
23
+ const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
24
+ /** Bounded, explicit locations only. Never recurse through a home or read env files. */
25
+ export async function collectAgentSetup(home, platform = process.platform, io = files, now = new Date()) {
26
+ const paths = platform === "win32" ? path.win32 : path.posix;
27
+ const join = (...parts) => paths.join(home, ...parts);
28
+ const read = async (file) => {
29
+ try {
30
+ const text = await io.read(file);
31
+ return text.length <= 1_000_000 ? text : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ };
37
+ const list = async (...parts) => {
38
+ try {
39
+ return names(await io.list(join(...parts)));
40
+ }
41
+ catch (error) {
42
+ return error.code === "ENOENT" ? [] : null;
43
+ }
44
+ };
45
+ const json = async (...parts) => {
46
+ const text = await read(join(...parts));
47
+ if (text === null)
48
+ return null;
49
+ try {
50
+ return object(JSON.parse(text));
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ };
56
+ const installedVersion = async (tool) => {
57
+ const version = await io.version?.(tool, platform).catch(() => null);
58
+ if (safeName(version))
59
+ return version;
60
+ // npm shims on native Windows cannot be execFile'd without a shell. Read
61
+ // their installed package metadata instead, never the update-available cache.
62
+ const packageName = tool === "claude" ? "@anthropic-ai/claude-code" : "@openai/codex";
63
+ const packageFile = platform === "win32" ? join("AppData", "Roaming", "npm", "node_modules", ...packageName.split("/"), "package.json") :
64
+ paths.join(paths.dirname(process.execPath), "..", "lib", "node_modules", ...packageName.split("/"), "package.json");
65
+ const text = await read(packageFile);
66
+ try {
67
+ return safeName(object(JSON.parse(text ?? "null")).version);
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ };
73
+ const claudeVersion = await installedVersion("claude");
74
+ const codexVersion = await installedVersion("codex");
75
+ const codexHooks = await json(".codex", "hooks.json");
76
+ const claudePlugins = await json(".claude", "plugins", "installed_plugins.json");
77
+ const pluginSkills = [], pluginAgents = [];
78
+ let pluginInventoryUnknown = false;
79
+ for (const [pluginName, installations] of Object.entries(object(claudePlugins?.plugins))) {
80
+ if (!safeName(pluginName) || !Array.isArray(installations))
81
+ continue;
82
+ for (const installation of installations.slice(0, 20)) {
83
+ const directory = object(installation).installPath;
84
+ if (typeof directory !== "string" || !paths.isAbsolute(directory)) {
85
+ pluginInventoryUnknown = true;
86
+ continue;
87
+ }
88
+ for (const category of ["skills", "agents"]) {
89
+ try {
90
+ const entries = names(await io.list(paths.join(directory, category)));
91
+ (category === "skills" ? pluginSkills : pluginAgents).push(...entries.map(name => `${pluginName}.${name}`));
92
+ }
93
+ catch (error) {
94
+ if (error.code !== "ENOENT")
95
+ pluginInventoryUnknown = true;
96
+ }
97
+ }
98
+ }
99
+ }
100
+ const claudeSkills = await list(".claude", "skills");
101
+ const claudeAgents = await list(".claude", "agents");
102
+ const sharedSkills = await list(".agents", "skills");
103
+ const codexSkills = await list(".codex", "skills");
104
+ const systemSkills = await list(".codex", "skills", ".system");
105
+ const claudeSettings = await json(".claude", "settings.json");
106
+ const claudeConfig = await json(".claude.json");
107
+ const toml = await read(join(".codex", "config.toml"));
108
+ // Read only simple quoted scalar values. Complex TOML is unknown, not guessed.
109
+ const rootToml = toml?.split(/^\s*\[/m)[0] ?? "";
110
+ const scalar = (key) => safeName(new RegExp(`^${key}\\s*=\\s*"([^"\\n]+)"\\s*(?:#.*)?$`, "m").exec(rootToml)?.[1]);
111
+ const codexMcp = toml === null ? null : [...toml.matchAll(/^\s*\[mcp_servers\.(?:"([a-z0-9_.@+-]+)"|([a-z0-9_-]+))\]\s*$/gim)].flatMap(match => {
112
+ const name = safeName(match[1] ?? match[2]);
113
+ if (!name)
114
+ return [];
115
+ const block = toml.slice(match.index + match[0].length).split(/^\s*\[/m)[0];
116
+ return [{ name, transport: /^\s*command\s*=/m.test(block) ? "stdio" : /^\s*url\s*=/m.test(block) ? "http" : "unknown" }];
117
+ }).slice(0, 500);
118
+ const claudeServers = [object(claudeConfig?.mcpServers), ...Object.values(object(claudeConfig?.projects)).map(project => object(object(project).mcpServers))];
119
+ const claudeMcp = claudeConfig === null ? null : claudeServers.flatMap(servers => Object.entries(servers)).flatMap(([key, value]) => {
120
+ const name = safeName(key);
121
+ if (!name)
122
+ return [];
123
+ const config = object(value);
124
+ const transport = config.type === "sse" ? "sse" : config.type === "http" || config.url ? "http" : config.command ? "stdio" : "unknown";
125
+ return [{ name, transport }];
126
+ }).slice(0, 500);
127
+ const instructions = await Promise.all(["CLAUDE.md", "AGENTS.md"].map(async (name) => {
128
+ const file = join(name === "CLAUDE.md" ? ".claude" : ".codex", name);
129
+ try {
130
+ const content = await io.read(file);
131
+ return { name, exists: true, sha256: crypto.createHash("sha256").update(content).digest("hex"), bytes: Buffer.byteLength(content) };
132
+ }
133
+ catch (error) {
134
+ return { name, exists: error.code === "ENOENT" ? false : null, sha256: null, bytes: null };
135
+ }
136
+ }));
137
+ const result = AgentSetupSchema.parse({
138
+ checked_at: now.toISOString(), os: platform === "darwin" || platform === "win32" ? platform : "other", cli_version: LOCAL_COLLECTOR_VERSION,
139
+ claude: { version: claudeVersion, model: safeName(claudeSettings?.model), effort: safeName(claudeSettings?.effortLevel),
140
+ skills: claudeSkills === null || pluginInventoryUnknown ? null : names([...claudeSkills, ...pluginSkills]), agents: claudeAgents === null || pluginInventoryUnknown ? null : names([...claudeAgents, ...pluginAgents]),
141
+ plugins: claudePlugins === null ? null : names(Object.keys(object(claudePlugins.plugins))),
142
+ hooks: claudeSettings === null ? null : names(Object.keys(object(claudeSettings.hooks))), mcp: claudeMcp },
143
+ codex: { version: codexVersion, model: scalar("model"), effort: scalar("model_reasoning_effort"),
144
+ skills: codexSkills === null || sharedSkills === null || systemSkills === null ? null : names([...(codexSkills ?? []), ...(sharedSkills ?? []), ...(systemSkills ?? [])]),
145
+ agents: toml === null ? null : names([...toml.matchAll(/^\s*\[agents\.([a-z0-9_-]+)\]\s*$/gim)].map(match => match[1])),
146
+ plugins: toml === null ? null : names([...toml.matchAll(/^\s*\[plugins\."([a-z0-9_@.+-]+)"\]\s*$/gim)].map(match => match[1])),
147
+ hooks: codexHooks === null && !/^notify\s*=/m.test(rootToml) ? null : names([...(codexHooks === null ? [] : Object.keys(object(codexHooks.hooks))), ...(/^notify\s*=/m.test(rootToml) ? ["notify"] : [])]), mcp: codexMcp }, instructions,
148
+ });
149
+ const unknownFields = [result.claude, result.codex].reduce((count, tool) => count + Object.values(tool).filter(value => value === null).length, 0);
150
+ console.error("[setup] inventory read", JSON.stringify({ reason: unknownFields ? "inventory_partial" : "inventory_observed", unknown_fields: unknownFields, os: result.os, mcp_count: (result.claude.mcp?.length ?? 0) + (result.codex.mcp?.length ?? 0) }));
151
+ return result;
152
+ }
@@ -27,6 +27,10 @@ export async function fixCliLatest(context, state) {
27
27
  catch (error) {
28
28
  return fail("cli-latest", selfUpdateFailureCode(error), selfUpdateFailureMessage(error));
29
29
  }
30
+ if (context.command.automaticTick) {
31
+ console.error("[doctor] update installed for the next tick", JSON.stringify({ reason: "restart_next_tick" }));
32
+ return { id: "cli-latest", status: "skipped", code: "restart_next_tick", message: "CLI updated; the next tick uses the new version." };
33
+ }
30
34
  if (context.io.env["COCKPIT_DOCTOR_REEXEC"] === "1") {
31
35
  if (state.code === "stale_cli") {
32
36
  return fail("cli-latest", "stale_after_self_update", "self-update ran but this process still reports the old CLI version; rerun `cockpit doctor`.");
@@ -47,7 +47,7 @@ export async function runDoctorWithDeps(command, io, deps) {
47
47
  try {
48
48
  row = diagnosed.get(invariant.id);
49
49
  const needsCollection = ["backfill-complete", "sync-fresh"].includes(invariant.id);
50
- const prerequisitesReady = !needsCollection || rows.filter((prior) => prior.id === "authed" || prior.id === "roots-ok").every((prior) => prior.status === "ok");
50
+ const prerequisitesReady = !needsCollection || rows.filter((prior) => prior.id === "authed" || (!command.automaticTick && prior.id === "roots-ok")).every((prior) => prior.status === "ok");
51
51
  const canFix = !diagnoseOnly && prerequisitesReady && invariant.fix &&
52
52
  (!invariant.requiresInteractiveFix || isInteractiveDoctorFix(context) || Boolean(command.collectionRoots?.length));
53
53
  let spent = row.status !== "ok" && row.status !== "skipped" && canFix ? budgetSpentRow(context, row) : null;
@@ -117,7 +117,6 @@ export async function runDoctorWithDeps(command, io, deps) {
117
117
  }
118
118
  }
119
119
  // BLI-12828: every repair's receipt says what it found and what it left.
120
- finalizeDoctorRepairs(repairs, diagnosed, rows, attempted);
121
120
  const floor = await maybeReportDoctorEvents(context, rows);
122
121
  if (floor && isSemverBelow(LOCAL_COLLECTOR_VERSION, floor)) {
123
122
  const index = rows.findIndex((row) => row.id === "cli-latest");
@@ -134,8 +133,10 @@ export async function runDoctorWithDeps(command, io, deps) {
134
133
  rows.push({ id: "memory-registered", status: "needs_fix", code: "receipt_read_threw", message: "Run `cockpit doctor` to read the final setup receipt." });
135
134
  }
136
135
  }
136
+ finalizeDoctorRepairs(repairs, diagnosed, rows, attempted);
137
137
  const needsPerson = doctorNeedsPerson(rows, receipt).length;
138
138
  console.error("[doctor] run done", JSON.stringify({ elapsed_ms: Date.now() - runStarted, rows: rows.length, needs_person: needsPerson, check_only: Boolean(diagnoseOnly) }));
139
+ deps.onReceipt?.(rows, repairs, receipt);
139
140
  writeDoctorOutput(command, io, rows, receipt, repairs);
140
141
  return needsPerson === 0 ? 0 : 1;
141
142
  }
@@ -232,6 +233,7 @@ function doctorInvariants() {
232
233
  }
233
234
  function defaultDoctorDeps(hooks) {
234
235
  return {
236
+ onReceipt: hooks.onReceipt,
235
237
  checkAgentRules: checkDoctorAgentRules,
236
238
  fixAgentRules: fixDoctorAgentRules,
237
239
  latestCliVersion: latestCliVersionFromNpm,
@@ -0,0 +1,17 @@
1
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
2
+ import { parseNamedArgs, optionalNonEmpty, optionalUrl } from "./local-arg-values.js";
3
+ export function parseGlossaryArgs(args) {
4
+ const values = parseNamedArgs(args, { allowedFlags: ["--to", "--json", "--home", "--dashboard-url"], valueFlags: ["--to", "--home", "--dashboard-url"] });
5
+ const [action = "list", wrong, ...rest] = values.positionals;
6
+ const to = optionalNonEmpty(values.flags.get("--to"));
7
+ if (!["list", "add", "remove"].includes(action) || rest.length || (action === "list" && (wrong || to)) || (action !== "list" && !wrong) || (action === "add" && !to) || (action === "remove" && to))
8
+ throw new Error("Usage: cockpit glossary list | add <wrong> --to <name> | remove <wrong>");
9
+ return { kind: "glossary", action: action, wrong, to, json: values.booleans.has("--json"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")) };
10
+ }
11
+ export async function runGlossary(command, io) {
12
+ const door = await openAgentDoor("glossary", command, io);
13
+ const answer = await askAgentDoor(door, { path: "/api/glossary", method: command.action === "list" ? "GET" : "POST", label: "glossary", timeoutMs: 30_000, ...(command.action === "list" ? {} : { body: { action: command.action, wrong: command.wrong, to: command.to } }) });
14
+ if (!answer.ok)
15
+ return failAgentDoor(door, "[glossary cli]", answer.reason, answer.detail);
16
+ return emitAgentDoor(door, answer.body);
17
+ }
@@ -19,7 +19,9 @@
19
19
  * The path itself never leaves the machine, exactly as with `cwd_hash` on the
20
20
  * session rows.
21
21
  */
22
+ import { noteDoctorRequest } from "./sync-doctor-request.js";
22
23
  import crypto from "node:crypto";
24
+ import { collectAgentSetup } from "./agent-setup.js";
23
25
  import fs from "node:fs";
24
26
  import os from "node:os";
25
27
  import path from "node:path";
@@ -268,7 +270,7 @@ export async function sendCollectorHeartbeatBestEffort(options) {
268
270
  const heartbeat = buildCollectorHeartbeat({
269
271
  roots: options.roots,
270
272
  facts: options.facts,
271
- setupReceipt,
273
+ setupReceipt: { ...(setupReceipt ?? { schema_version: "setup-receipt.v1", checked_at: new Date().toISOString(), browser: { status: "unknown" }, device: { status: "unknown" }, collector: { autostart: { status: "unknown" } } }), agent_setup: await collectAgentSetup(options.homeDir ?? os.homedir()) },
272
274
  ...(options.now ? { now: options.now } : {}),
273
275
  memoryInstall: memory.receipt,
274
276
  });
@@ -307,6 +309,8 @@ export async function sendCollectorHeartbeatBestEffort(options) {
307
309
  // BLI-4019. The server slides `token_expires_at` out on every call it
308
310
  // authenticates and answers with the result; the sibling records it, so
309
311
  // this machine's own copy follows the server rather than drifting.
312
+ const reply = await response.clone().json().catch(() => null);
313
+ noteDoctorRequest(reply?.device_action);
310
314
  await recordHeartbeatTokenExpiry(options.homeDir, response);
311
315
  console.error("[heartbeat] checked in", JSON.stringify({
312
316
  reason: "heartbeat_recorded",
@@ -332,6 +332,11 @@ function parseTeamDeviceArgs(rest, flags, common) {
332
332
  throw new Error("team device list does not accept positional arguments.");
333
333
  return { kind: "team", action: "device-list", ...common };
334
334
  }
335
+ if (deviceVerb === "doctor") {
336
+ if (!deviceRef)
337
+ throw new Error("team device doctor needs a device id or name.");
338
+ return { kind: "team", action: "device-doctor", deviceRef, ...common };
339
+ }
335
340
  if (deviceVerb === "revoke") {
336
341
  if (!deviceRef)
337
342
  throw new Error("team device revoke needs a device id or name.");
@@ -351,7 +356,7 @@ function parseTeamDeviceArgs(rest, flags, common) {
351
356
  ...common,
352
357
  };
353
358
  }
354
- throw new Error("team device takes one verb: list or revoke <id|name>.");
359
+ throw new Error("team device takes one verb: list, doctor <id|name>, or revoke <id|name>.");
355
360
  }
356
361
  /** `cockpit model [show|set <key>]` — the alias surface for the personal chat model. */
357
362
  export function parseModelArgs(args) {
@@ -37,6 +37,11 @@ export const MEMORY_STORE_ACTIONS = new Set([
37
37
  "save",
38
38
  "update",
39
39
  "forget",
40
+ // BLI-12927: BLI Memory is public to the team until its owner narrows it.
41
+ // `private <id>` / `public <id>` flip one memory; same door as the
42
+ // `set_memory_visibility` tool.
43
+ "private",
44
+ "public",
40
45
  "profile",
41
46
  // BLI-4534: a READ of the nightly consolidation receipts, not a fifth way to
42
47
  // write a memory. It runs no pass and changes nothing — the pass is a
@@ -176,6 +181,16 @@ export function parseMemoryStoreArgs(action, args) {
176
181
  if (action === "consolidation") {
177
182
  return { ...command, action };
178
183
  }
184
+ if (action === "private" || action === "public") {
185
+ // BLI-12927: one memory, named by the id `cockpit memory search` printed.
186
+ const memoryId = optionalNonEmpty(rest[0]);
187
+ if (!memoryId) {
188
+ throw new Error(`memory ${action} needs the memory id \`cockpit memory search\` printed.`);
189
+ }
190
+ if (rest.length > 1)
191
+ throw new Error(`memory ${action} takes one id.`);
192
+ return { ...command, action, memoryId };
193
+ }
179
194
  if (action === "save") {
180
195
  if (rest.length > 0) {
181
196
  throw new Error("memory save reads the memory from stdin or --file, never from the command line: `echo \"...\" | cockpit memory save`.");
@@ -181,7 +181,13 @@ export function parseBriefArgs(args) {
181
181
  * `otter` and `granola` are deliberately absent: Otter's public API is
182
182
  * Enterprise-only and Granola still runs on its own server-side cron.
183
183
  */
184
- const CONNECTABLE_PROVIDERS = ["fellow", "circleback"];
184
+ const CONNECTABLE_PROVIDERS = ["fellow", "circleback", "wisprflow"];
185
+ /**
186
+ * BLI-12926. Providers with no API key at all: the person signs in through
187
+ * their browser and `connect` runs that sign-in itself. `--key-stdin` is
188
+ * optional for these, and carries a pre-built sign-in bundle when given.
189
+ */
190
+ const PROVIDERS_SIGNED_IN_BY_BROWSER = ["wisprflow"];
185
191
  /** The provider whose API is per workspace, so its account needs a subdomain. */
186
192
  const PROVIDERS_NEEDING_A_WORKSPACE = ["fellow"];
187
193
  const NOTES_ACTIONS = new Set([
@@ -202,6 +208,9 @@ const NOTES_ACTIONS = new Set([
202
208
  "detach",
203
209
  "sync",
204
210
  "sync-status",
211
+ // BLI-12926: the alternates of a meeting several notetakers recorded.
212
+ "recordings",
213
+ "recording",
205
214
  // BLI-4394: arm that notetaker's webhook with the vendor's signing secret.
206
215
  "webhook",
207
216
  // Ticket 10718: the Granola cron's own last-several-runs health.
@@ -214,6 +223,9 @@ const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
214
223
  "unshare",
215
224
  "move",
216
225
  "place",
226
+ // BLI-12926: `recordings` names the note, `recording` names the recording.
227
+ "recordings",
228
+ "recording",
217
229
  ]);
218
230
  /**
219
231
  * BLI-4383. Actions whose first positional is a NOTETAKER ACCOUNT id, not a
@@ -277,7 +289,7 @@ export function parseNotesArgs(args) {
277
289
  const first = values.positionals[0];
278
290
  const action = (first === undefined ? "list" : first);
279
291
  if (!NOTES_ACTIONS.has(action)) {
280
- throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, move, place, accounts, connect, detach, sync, sync-status, webhook, or granola-status.`);
292
+ throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, move, place, accounts, connect, detach, sync, sync-status, recordings, recording, webhook, or granola-status.`);
281
293
  }
282
294
  const rest = values.positionals.slice(first === undefined ? 0 : 1);
283
295
  const json = values.booleans.has("--json");
@@ -340,7 +352,7 @@ export function parseNotesArgs(args) {
340
352
  if (workspace !== undefined && !/^[a-z0-9-]{1,63}$/.test(workspace)) {
341
353
  throw new Error("--workspace is a workspace subdomain: lowercase letters, digits and hyphens, 1 to 63 characters.");
342
354
  }
343
- if (!values.booleans.has("--key-stdin")) {
355
+ if (!values.booleans.has("--key-stdin") && !PROVIDERS_SIGNED_IN_BY_BROWSER.includes(provider)) {
344
356
  // Naming the flag is the point: it says out loud that the key is not an
345
357
  // argument, so nobody goes looking for a `--key` that will never exist.
346
358
  throw new Error('notes connect reads the key from stdin. Say so: printf "%s" "<key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
@@ -397,6 +409,7 @@ export function parseNotesArgs(args) {
397
409
  ...(provider ? { provider } : {}),
398
410
  ...(workspace ? { workspace } : {}),
399
411
  ...(showWebhook ? { showWebhook: true } : {}),
412
+ ...(action === "connect" && values.booleans.has("--key-stdin") ? { keyStdin: true } : {}),
400
413
  ...(paths ? { paths } : {}),
401
414
  filePath: optionalNonEmpty(values.flags.get("--file")),
402
415
  name: optionalNonEmpty(values.flags.get("--name")),
@@ -0,0 +1,10 @@
1
+ import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
2
+ export function parseSetupArgs(args) {
3
+ const values = parseNamedArgs(args, { allowedFlags: ["--person", "--compare", "--json", "--home", "--dashboard-url"], valueFlags: ["--person", "--compare", "--home", "--dashboard-url"] });
4
+ if (values.positionals.length)
5
+ throw new Error("setup takes --person or --compare, not a subcommand.");
6
+ const compare = optionalNonEmpty(values.flags.get("--compare"));
7
+ if (compare && (compare.split(",").length !== 2 || compare.split(",").some(value => !value.trim())))
8
+ throw new Error("--compare takes two emails separated by a comma.");
9
+ return { kind: "setup", person: optionalNonEmpty(values.flags.get("--person")), compare, json: values.booleans.has("--json"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")) };
10
+ }
@@ -37,8 +37,10 @@ export { SEARCH_KINDS, parseSearchArgs } from "./local-args-tower-search.js";
37
37
  export { parseModelsArgs } from "./local-args-tower-models.js";
38
38
  export { parseMailArgs } from "./local-args-tower-mail.js";
39
39
  export { parseCalArgs } from "./local-args-tower-cal.js";
40
+ export { parseSetupArgs } from "./local-args-tower-setup.js";
40
41
  export { parseUsageArgs } from "./local-args-tower-usage.js";
41
42
  export { parseLogsArgs } from "./local-args-tower-logs.js";
42
43
  export { parseBrainArgs } from "./local-args-tower-brain.js";
43
44
  export { parseCareersArgs } from './local-args-tower-careers.js';
44
- export { parseRenameArgs } from './local-args-tower-rename.js';
45
+ export { parseRenameArgs } from './local-args-tower-rename.js';
46
+ export { parseGlossaryArgs } from "./glossary.js";
@@ -15,10 +15,11 @@
15
15
  * its own rate. Behavior-preserving extraction throughout: functions moved
16
16
  * verbatim, no logic change.
17
17
  */
18
+ import { parseGlossaryArgs } from "./glossary.js";
18
19
  import { parseRootsArgs } from "./roots.js";
19
20
  import { SURFACE_ALIASES } from "./local-help.js";
20
21
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
21
- import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseLogsArgs, parseBrainArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
22
+ import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseLogsArgs, parseBrainArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
22
23
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
23
24
  // `local-auth.ts` import it from here — so it stays exported from this address
24
25
  // even though it now lives next door. The same goes for the four names the
@@ -109,6 +110,8 @@ export function parseLocalArgs(argv) {
109
110
  return parseCleanArgs(argv.slice(1));
110
111
  case "sources":
111
112
  return parseSourcesArgs(argv.slice(1));
113
+ case "glossary":
114
+ return parseGlossaryArgs(argv.slice(1));
112
115
  case "docs":
113
116
  return parseDocsArgs(argv.slice(1));
114
117
  case "msg":
@@ -131,6 +134,8 @@ export function parseLocalArgs(argv) {
131
134
  return parseCareersArgs(argv.slice(1));
132
135
  case "rename":
133
136
  return parseRenameArgs(argv.slice(1));
137
+ case "setup":
138
+ return parseSetupArgs(argv.slice(1));
134
139
  case "usage":
135
140
  return parseUsageArgs(argv.slice(1));
136
141
  case "logs":
@@ -341,7 +341,7 @@ export const TOWER_COMMAND_HELP = [
341
341
  [
342
342
  "notes",
343
343
  [
344
- "Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|move <id>|accounts|connect|detach <id>|sync <id>|sync-status [<id>]] [flags]",
344
+ "Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|move <id>|accounts|connect|detach <id>|sync <id>|sync-status [<id>]|recordings <note-id>|recording <id>] [flags]",
345
345
  "Audio uploads: MP3, M4A, WAV, WebM and OGG, up to 4 MB. --wait polls transcription for at most 330 seconds.",
346
346
  "",
347
347
  "folders [--tree] [--json]: list folders and visible note counts.",
@@ -362,10 +362,13 @@ export const TOWER_COMMAND_HELP = [
362
362
  "move <id> (--to \"<shelf>\"|--clear-shelf) — put it on a different shelf, or take the shelf off.",
363
363
  "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.",
364
364
  "",
365
- "Your own notetaker (BLI-4383). Fellow and Circleback today; Otter needs an Enterprise plan and Granola still runs on its own cron.",
366
- "accounts [--json] — the notetakers you have connected, with health and when each was last read.",
365
+ "Your own notetaker (BLI-4383). Fellow, Circleback and Wispr Flow today; Otter needs an Enterprise plan and Granola still runs on its own cron.",
366
+ "accounts [--json] — the notetakers you have connected, with health and when each was last read, and under it who on the team feeds Tower with which notetaker (a member with none is named: that is the gap).",
367
+ "recordings <note-id> [--json] — every recording of the meeting this note is. When several notetakers or people recorded one meeting, the library shows ONE note, the recording with the longest transcript; the others are alternates kept by id (BLI-12926).",
368
+ "recording <recording-id> [--json] — one recording, text and all, shown or alternate.",
367
369
  "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.",
368
370
  "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",
371
+ "connect --provider wisprflow [--name <n>] — Wispr Flow has no API key: this prints an address and a code, you approve in your browser (Google, Apple, Microsoft or company single sign-on), and Tower reads your Notetaker meetings from then on. Needs Notetaker and MCP access on your Wispr account (BLI-12926).",
369
372
  "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",
370
373
  "detach <account-id> — take it off. The stored key goes with it; the meetings it already filed stay.",
371
374
  "sync <account-id> — read that notetaker now. Prints the run's own counts and reason label, and exits 1 when the pass failed.",
@@ -380,7 +383,7 @@ export const TOWER_COMMAND_HELP = [
380
383
  [
381
384
  "memory",
382
385
  [
383
- "Usage: cockpit memory [search|save|update|forget|profile|consolidation|requeue|install|status|log] [flags]",
386
+ "Usage: cockpit memory [search|save|update|forget|private|public|profile|consolidation|requeue|install|status|log] [flags]",
384
387
  "",
385
388
  "The STORE, over the same /api/memory/* doors the bli-memory MCP server calls:",
386
389
  " search \"<question>\" [--container <tag>] [--limit 1..20] [--no-recent]",
@@ -395,6 +398,11 @@ export const TOWER_COMMAND_HELP = [
395
398
  " update <id> [--file <path>] replacement text on stdin; the old row is kept as history.",
396
399
  " forget <id> [--reason <label>]",
397
400
  " forget --content-stdin --container <tag> [--reason <label>] forget by exact text.",
401
+ " Memory is public to every member and super admin, but only a memory's owner",
402
+ " (or a super admin) can update or forget it.",
403
+ " private <id> only you and super admins can read this memory.",
404
+ " public <id> every member can read it again; this is how every memory starts.",
405
+ " A teammate's memory in a search says who filed it and on what day.",
398
406
  " profile [--container <tag>] the compiled one-page briefing; a read, never a compile.",
399
407
  " consolidation [--container <tag>] what the nightly tidy-up pass did.",
400
408
  " Clusters looked at, edits by verb, refusals by reason, model and cost.",
@@ -22,10 +22,12 @@ import { TOWER_COMMAND_HELP } from "./local-help-commands-tower.js";
22
22
  import { TOWER_OPS_COMMAND_HELP } from "./local-help-commands-tower-ops.js";
23
23
  export function localSubcommandHelp(command) {
24
24
  const helpByCommand = new Map([
25
+ ["glossary", ["Usage: cockpit glossary list|add <wrong> --to <name>|remove <wrong> [--json]", "Quote phrases. Removing a form prevents future corrections of it."]],
25
26
  ["roots", ["Usage: cockpit roots add <dir> [--allow-home-root] [--json]", "Approves a work directory, saves an approval receipt, and adds it to collection. Run cockpit doctor afterward."]],
26
27
  // BLI-3709: the Tower nouns live next door, split along the same seam the
27
28
  // argument parsers already use — see `local-help-commands-tower.ts`.
28
29
  ...TOWER_COMMAND_HELP,
30
+ ["setup", ["Usage: cockpit setup [--person <email>] [--compare <email,email>] [--json]", "Read agent setups beside 30-day outcomes, or compare two exact emails."]],
29
31
  [
30
32
  "onboard",
31
33
  [
@@ -191,6 +193,7 @@ export function localSubcommandHelp(command) {
191
193
  " cockpit team role <userId> --role <role> [--yes]",
192
194
  " Admin only. Asks first unless --yes. You cannot change your own role.",
193
195
  " cockpit team device [list] [--json]",
196
+ " cockpit team device doctor <id|name> [--json]",
194
197
  " Every paired collector device: name, id, trust state, owner, and why",
195
198
  " (revoked reason, or when it was approved, or that it's still waiting).",
196
199
  " Admin only.",
@@ -66,6 +66,7 @@ export const rootCommandNames = new Set([
66
66
  "memory",
67
67
  "clean",
68
68
  "sources",
69
+ "glossary",
69
70
  "docs",
70
71
  "msg",
71
72
  "issue",
@@ -77,6 +78,7 @@ export const rootCommandNames = new Set([
77
78
  "release",
78
79
  "careers",
79
80
  "rename",
81
+ "setup",
80
82
  "usage",
81
83
  "logs",
82
84
  "brain",
@@ -113,7 +115,7 @@ export function localCommandHelp(command) {
113
115
  " cockpit brief status [--who <person>] [--render] [--dashboard-url <url>] [--json]",
114
116
  " cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
115
117
  " 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]",
116
- " 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)",
118
+ " cockpit notes [accounts [--show-webhook <account-id>]|connect --provider fellow --workspace <sub> --key-stdin [--name <n>]|connect --provider wisprflow|recordings <note-id>|recording <id>|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)",
117
119
  " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--lock-wait <seconds>] [--yes] [--workspace <path>] [--json]",
118
120
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
119
121
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
@@ -133,9 +135,11 @@ export function localCommandHelp(command) {
133
135
  ` cockpit search "<words>" [--kind ${SEARCH_KINDS.join(",")}] [--limit <n>] [--dashboard-url <url>] [--json]`,
134
136
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
135
137
  " cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <passed_on|archived|reopen>|followup <id> [--record-sent-at <iso>]|interview <id> <--at <iso>|--clear>|grade <id> [--now]|resume <id> [--out <path>]|takehome show <role>|takehome set <role> --body-file <path>|settings show|settings set --enabled <true|false> --followup-enabled <true|false> --followup-after-days <n> --takehome-window-hours <n>] [--role <slug>] [--min-score <n>] [--since <date>] [--stage <s>] [--force] [--json]",
138
+ " cockpit glossary list|add <wrong> --to <name>|remove <wrong> [--dashboard-url <url>] [--json]",
136
139
  " cockpit rename [list|resolve <id> --scope <page|everywhere|undo>] [--dashboard-url <url>] [--json]",
137
140
  " cockpit logs <collector|dashboard|railway|railway:<service>|-> [--since 15m|1h|2d|<iso>] [--raw <template-id> [--limit <n>]] [--all-levels] [--table <file>] [--json]",
138
141
  " cockpit brain status [--project <id>] [--show open|running|needs_human|fixed|not_actionable|cleared] [--dashboard-url <url>] [--json]",
142
+ " cockpit setup [--person <email>] [--compare <email,email>] [--json]",
139
143
  " cockpit usage sessions [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--since <window>] [--json]",
140
144
  " cockpit usage sessions --session <id> [--from-window <n>] [--json]",
141
145
  " --by-topic groups work types; --by-subject groups open subjects from session summaries.",
@@ -1,3 +1,4 @@
1
+ import { runGlossary } from "./glossary.js";
1
2
  import { defaultIo, errorMessage, writeLine } from "./cli-io.js";
2
3
  import { isLocalHelpRequest, localCommandHelp, rootCommandNames } from "./local-help.js";
3
4
  import { beginStage, endStage } from "../crash-guard.js";
@@ -11,6 +12,7 @@ import { runLogin } from "./login.js";
11
12
  import { runLogout } from "./logout.js";
12
13
  import { runStart } from "./start.js";
13
14
  import { runSync } from "./sync.js";
15
+ import { runDoctorAfterSync } from "./sync-doctor.js";
14
16
  import { runAnalyze } from "./analyze.js";
15
17
  import { runBackfillCommand } from "./backfill.js";
16
18
  import { runDoctor } from "./doctor.js";
@@ -48,6 +50,7 @@ import { runGithub } from "./github.js";
48
50
  import { runSearch } from "./search.js";
49
51
  import { runCareers } from "./careers.js";
50
52
  import { runRename } from "./rename-scope.js";
53
+ import { runSetup } from "./setup.js";
51
54
  import { runUsage } from "./usage.js";
52
55
  import { runLogs } from "./logs.js";
53
56
  import { runBrain } from "./brain.js";
@@ -61,7 +64,7 @@ export { classifySyncFailureRecords, classifySyncHealthError, redactedSyncErrorD
61
64
  export { assertCollectionRootPersisted } from "./collection-roots.js";
62
65
  export { runSelfUpdate, SelfUpdateError, } from "./install-update.js";
63
66
  import { runRoots } from "./roots.js";
64
- export async function runLocalCockpitCli(argv, io = defaultIo()) {
67
+ export async function runLocalCockpitCli(argv, io = { ...defaultIo(), runDoctorAfterSync }) {
65
68
  // BLI-10450: `--quiet` is a global toggle, stripped here so no per-command
66
69
  // parser's `allowedFlags` needs to learn it, and mirrored onto
67
70
  // `COCKPIT_QUIET` so `tower-client.ts` can react without every call site
@@ -220,6 +223,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
220
223
  return await runClean(command, io);
221
224
  case "sources":
222
225
  return await runSourcesCheck(command, io);
226
+ case "glossary":
227
+ return await runGlossary(command, io);
223
228
  case "docs":
224
229
  return await runDocs(command, io);
225
230
  case "msg":
@@ -242,12 +247,10 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
242
247
  return await runCareers(command, io);
243
248
  case "rename":
244
249
  return await runRename(command, io);
245
- case "usage":
246
- return await runUsage(command, io);
247
- case "logs":
248
- return await runLogs(command, io);
249
- case "brain":
250
- return await runBrain(command, io);
250
+ case "setup": return await runSetup(command, io);
251
+ case "usage": return await runUsage(command, io);
252
+ case "logs": return await runLogs(command, io);
253
+ case "brain": return await runBrain(command, io);
251
254
  }
252
255
  }
253
256
  catch (error) {