@bli-cockpit/cli 0.2.55 → 0.2.57

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.
@@ -77,7 +77,13 @@ export async function inspectClaudeMemoryIntegration(options) {
77
77
  }),
78
78
  ];
79
79
  }
80
- async function applyJsonTarget(input) {
80
+ /**
81
+ * Exported for `tower-mcp-claude.ts` (BLI-3706): the read/refuse-if-unparseable
82
+ * /apply/dry-run/write/read-back-verify sequence is identical for a SECOND
83
+ * server's `mcpServers` entry — only which target id and which `apply`/
84
+ * `matches` pair runs differs, both already parameters here.
85
+ */
86
+ export async function applyJsonTarget(input) {
81
87
  const { file, options } = input;
82
88
  const raw = await input.options.io.readText(file);
83
89
  let root;
@@ -120,7 +126,8 @@ async function applyJsonTarget(input) {
120
126
  }
121
127
  return { target: input.target, status: "installed", reason: "wrote_entry", path: file };
122
128
  }
123
- async function inspectJsonTarget(input) {
129
+ /** Exported for `tower-mcp-claude.ts` (BLI-3706) — see `applyJsonTarget`'s note. */
130
+ export async function inspectJsonTarget(input) {
124
131
  const raw = await input.io.readText(input.file);
125
132
  if (raw === null || !raw.trim()) {
126
133
  return { target: input.target, status: "missing", reason: "file_absent", path: input.file };
@@ -140,7 +147,8 @@ async function inspectJsonTarget(input) {
140
147
  path: input.file,
141
148
  };
142
149
  }
143
- function applyMcpServer(root, config) {
150
+ /** Exported for `tower-mcp-claude.ts` (BLI-3706) — the `mcpServers` shape has nothing memory-specific about it. */
151
+ export function applyMcpServer(root, config) {
144
152
  const servers = asRecord(root["mcpServers"]) ?? {};
145
153
  servers[config.server_id] = {
146
154
  command: config.mcp_server.command,
@@ -152,13 +160,13 @@ function applyMcpServer(root, config) {
152
160
  root["mcpServers"] = servers;
153
161
  return root;
154
162
  }
155
- function readMcpServer(root, serverId) {
163
+ export function readMcpServer(root, serverId) {
156
164
  const servers = asRecord(root["mcpServers"]);
157
165
  if (!servers)
158
166
  return null;
159
167
  return asRecord(servers[serverId]);
160
168
  }
161
- function mcpServerMatches(root, config) {
169
+ export function mcpServerMatches(root, config) {
162
170
  const entry = readMcpServer(root, config.server_id);
163
171
  if (!entry)
164
172
  return false;
@@ -34,11 +34,10 @@
34
34
  * - **Never claim an install it did not read back** (BLI-2541). Both halves
35
35
  * re-read and re-parse; this module only aggregates what they proved.
36
36
  */
37
- import fs from "node:fs";
38
37
  import os from "node:os";
39
- import path from "node:path";
40
- import { fileURLToPath } from "node:url";
41
38
  import { writeLine } from "./cli-io.js";
39
+ import { resolveMcpBin } from "./mcp-bin-resolve.js";
40
+ import { installTowerIntegration, inspectTowerIntegration } from "./tower-mcp-install.js";
42
41
  import { builtinMemoryInstallConfig, isUnsafeBinPath, MEMORY_MCP_BIN, parsePrintedMemoryInstallConfig, withResolvedBinPath, } from "./memory-install-contract.js";
43
42
  import { defaultMemoryFileIo, } from "./memory-install-files.js";
44
43
  import { installClaudeMemoryIntegration, inspectClaudeMemoryIntegration, } from "./memory-install-claude.js";
@@ -80,6 +79,21 @@ export async function installMemoryIntegration(command, io, deps = {}) {
80
79
  io: fileIo,
81
80
  })));
82
81
  }
82
+ // BLI-3706: `bli-tower` (docs_*/msg_* over the collector device token)
83
+ // registers beside `bli-memory` on the SAME command — see
84
+ // tower-mcp-install.ts's header for why this rides here rather than a
85
+ // separate, un-invoked verb. Independent of the memory outcome above: a
86
+ // machine with one server but not the other is a real machine.
87
+ targets.push(...(await installTowerIntegration({
88
+ io: fileIo,
89
+ platform,
90
+ homeDir,
91
+ dashboardUrl: await resolveDashboardUrl(command, deps),
92
+ env: io.env ?? process.env,
93
+ fileExists: deps.fileExists,
94
+ cliEntryPoint: deps.cliEntryPoint,
95
+ realpath: deps.realpath,
96
+ }, command.dryRun)));
83
97
  const outcome = {
84
98
  action: "install",
85
99
  ...aggregate(targets),
@@ -112,6 +126,17 @@ export async function inspectMemoryIntegration(command, io, deps = {}) {
112
126
  io: fileIo,
113
127
  })));
114
128
  }
129
+ // BLI-3706: see the matching note in `installMemoryIntegration`.
130
+ targets.push(...(await inspectTowerIntegration({
131
+ io: fileIo,
132
+ platform,
133
+ homeDir,
134
+ dashboardUrl: await resolveDashboardUrl(command, deps),
135
+ env: io.env ?? process.env,
136
+ fileExists: deps.fileExists,
137
+ cliEntryPoint: deps.cliEntryPoint,
138
+ realpath: deps.realpath,
139
+ })));
115
140
  return {
116
141
  action: "status",
117
142
  ...aggregate(targets),
@@ -287,106 +312,10 @@ async function printedMemoryConfig(io, binPath) {
287
312
  * cannot be realpath-ed still identifies a directory well enough to look in.
288
313
  */
289
314
  export async function resolveMemoryMcpBin(options) {
290
- const exists = options.fileExists ?? defaultFileExists;
291
- const beside = await resolveBesideCli(options, exists);
292
- if (beside)
293
- return { path: beside, source: "cli_dependency" };
294
- const onPath = await resolveOnPath(options, exists);
295
- return onPath ? { path: onPath, source: "path" } : null;
315
+ return resolveMcpBin({ ...options, binName: MEMORY_MCP_BIN });
296
316
  }
297
- /**
298
- * Where to start walking, in order of trustworthiness. This module's own
299
- * location first: it is inside the installed package and cannot be a shim.
300
- */
301
- function besideAnchors(options) {
302
- const anchors = [];
303
- const own = currentModulePath();
304
- if (own)
305
- anchors.push(own);
306
- const entry = options.cliEntryPoint ?? process.argv[1];
307
- if (entry)
308
- anchors.push(entry);
309
- return anchors;
310
- }
311
- function currentModulePath() {
312
- try {
313
- return fileURLToPath(import.meta.url);
314
- }
315
- catch {
316
- // A bundler that dropped `import.meta` support. The entry-point anchor
317
- // still covers it, so this is a narrowing rather than a failure.
318
- return null;
319
- }
320
- }
321
- async function resolveBesideCli(options, exists) {
322
- const platformPath = options.platform === "win32" ? path.win32 : path.posix;
323
- const extensions = binExtensions(options.platform);
324
- const realpath = options.realpath ?? defaultRealpath;
325
- for (const anchor of besideAnchors(options)) {
326
- let directory = platformPath.dirname(realpath(platformPath.resolve(anchor)));
327
- // Bounded walk: deep enough for `…/node_modules/@scope/pkg/dist/cli.js`
328
- // plus a hoisted root above it, and it stops at the filesystem root anyway.
329
- for (let depth = 0; depth < 12; depth += 1) {
330
- for (const extension of extensions) {
331
- const candidate = platformPath.join(directory, "node_modules", ".bin", `${MEMORY_MCP_BIN}${extension}`);
332
- if (await exists(candidate))
333
- return candidate;
334
- }
335
- const parent = platformPath.dirname(directory);
336
- if (parent === directory)
337
- break;
338
- directory = parent;
339
- }
340
- }
341
- return null;
342
- }
343
- /**
344
- * `realpathSync.native` follows the symlink npm writes for a global bin. A
345
- * path that does not resolve — a Windows path being reasoned about from a Mac
346
- * in a test, a directory that has since moved — comes back unchanged rather
347
- * than throwing, because the walk above can still look inside it.
348
- */
349
- function defaultRealpath(value) {
350
- try {
351
- return fs.realpathSync.native(value);
352
- }
353
- catch {
354
- return value;
355
- }
356
- }
357
- async function resolveOnPath(options, exists) {
358
- // The TARGET platform's path rules, not the running one's. On a real machine
359
- // they are the same; asking for them explicitly is what lets the Windows
360
- // lookup be tested from a Mac, which is the only Windows proof this repo
361
- // gets before a release (AGENTS.md, supported fleet).
362
- const platformPath = options.platform === "win32" ? path.win32 : path.posix;
363
- const entries = (options.env["PATH"] ?? options.env["Path"] ?? "")
364
- .split(platformPath.delimiter)
365
- .map((entry) => entry.trim())
366
- .filter(Boolean);
367
- for (const entry of entries) {
368
- for (const extension of binExtensions(options.platform)) {
369
- const candidate = platformPath.join(entry, `${MEMORY_MCP_BIN}${extension}`);
370
- if (await exists(candidate))
371
- return candidate;
372
- }
373
- }
374
- return null;
375
- }
376
- /** npm writes `.cmd` (and `.ps1`) shims on Windows; POSIX gets the bare name. */
377
- function binExtensions(platform) {
378
- return platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
379
- }
380
- async function defaultFileExists(file) {
381
- const { stat } = await import("node:fs/promises");
382
- try {
383
- return (await stat(file)).isFile();
384
- }
385
- catch {
386
- return false;
387
- }
388
- }
389
- async function resolveDashboardUrl(command, deps) {
317
+ /** Exported for `tower-mcp-install.ts` (BLI-3706): the two registrations share this machine's one dashboard URL. */
318
+ export async function resolveDashboardUrl(command, deps) {
390
319
  if (command.dashboardUrl)
391
320
  return command.dashboardUrl;
392
321
  const paths = getCollectorRuntimePaths(deps.homeDir ?? command.homeDir);
@@ -412,6 +341,17 @@ function aggregate(targets) {
412
341
  if (targets.some((target) => target.status === "mismatch" || target.status === "missing")) {
413
342
  return { status: "missing", reason: "entry_absent" };
414
343
  }
344
+ // BLI-3706: checked BEFORE `skipped`, not after. `bli-tower` registering
345
+ // beside `bli-memory` on this same command means a machine can be fully
346
+ // "already" registered for one server while the other's bin is not here
347
+ // yet — that machine's OWN state must not read as "skipped" (nothing is
348
+ // happening) when something plainly already is. `skipped` only wins the
349
+ // whole outcome when NOTHING on this machine has ever reached "already"
350
+ // either — the original one-server case (a fresh machine, bin missing,
351
+ // nothing written at all) still returns "skipped" via the fallback below.
352
+ if (targets.some((target) => target.status === "already")) {
353
+ return { status: "already", reason: "already_current" };
354
+ }
415
355
  const skipped = targets.find((target) => target.status === "skipped");
416
356
  if (skipped) {
417
357
  // Not a failure and not a success: nothing was written, on purpose, and
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `cockpit msg` — channels and messages, typed (BLI-3706).
3
+ *
4
+ * Four verbs over `/api/msg/**` (BLI-3654 Wave 1a), which already accepts the
5
+ * collector device token through `resolveCaller({ allowDeviceToken: true })`
6
+ * on every route — no server-side door change was needed for this terminal.
7
+ *
8
+ * `<channel>` is a channel id, or its name with or without a leading `#`,
9
+ * resolved locally against `GET /api/msg/channels` — exact match only, never
10
+ * fuzzy, same discipline `docs.ts` uses for a slug.
11
+ */
12
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
13
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
14
+ const TAG = "[msg cli]";
15
+ const READ_DEADLINE_MS = 30_000;
16
+ const WRITE_DEADLINE_MS = 30_000;
17
+ const CONTENT_MAX_CHARS = 8_000;
18
+ export async function runMsg(command, io) {
19
+ const door = await openAgentDoor("msg", command, io);
20
+ switch (command.action) {
21
+ case "channels":
22
+ return listChannels(door);
23
+ case "read":
24
+ return readChannel(command, door);
25
+ case "send":
26
+ return sendMessage(command, door);
27
+ case "thread":
28
+ return readThread(command, door);
29
+ }
30
+ }
31
+ async function fetchChannels(door) {
32
+ const answer = await askAgentDoor(door, {
33
+ path: "/api/msg/channels",
34
+ method: "GET",
35
+ label: "msg channels",
36
+ timeoutMs: READ_DEADLINE_MS,
37
+ });
38
+ if (!answer.ok)
39
+ return { ok: false, reason: answer.reason, detail: answer.detail };
40
+ return { ok: true, channels: answer.body.channels ?? [] };
41
+ }
42
+ async function listChannels(door) {
43
+ const fetched = await fetchChannels(door);
44
+ if (!fetched.ok)
45
+ return failAgentDoor(door, TAG, fetched.reason, fetched.detail);
46
+ if (door.json)
47
+ return emitAgentDoor(door, { ok: true, channels: fetched.channels });
48
+ if (fetched.channels.length === 0) {
49
+ writeLine(door.io.stdout, "No channels.");
50
+ return 0;
51
+ }
52
+ for (const channel of fetched.channels) {
53
+ const label = channel.is_dm ? "dm" : channel.is_private ? "private" : "public";
54
+ writeLine(door.io.stdout, `${channel.id} ${label.padEnd(7)} ${channel.name ?? "(unnamed)"}`);
55
+ }
56
+ return 0;
57
+ }
58
+ /** Exact id, or exact name with or without a leading `#`. Never fuzzy. */
59
+ async function resolveChannelId(door, ref) {
60
+ const fetched = await fetchChannels(door);
61
+ if (!fetched.ok)
62
+ return { status: "list_failed", reason: fetched.reason, detail: fetched.detail };
63
+ const byId = fetched.channels.find((channel) => channel.id === ref);
64
+ if (byId)
65
+ return { status: "ok", id: byId.id };
66
+ const bare = ref.startsWith("#") ? ref.slice(1) : ref;
67
+ const byName = fetched.channels.find((channel) => channel.name === bare);
68
+ if (byName)
69
+ return { status: "ok", id: byName.id };
70
+ return { status: "not_found" };
71
+ }
72
+ function channelNotFound(door, ref) {
73
+ return failAgentDoor(door, TAG, "channel_not_found_or_unreadable", `That channel does not exist, or you cannot read it: ${ref}`);
74
+ }
75
+ async function readChannel(command, door) {
76
+ const ref = command.channelRef ?? "";
77
+ const resolved = await resolveChannelId(door, ref);
78
+ if (resolved.status === "list_failed")
79
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
80
+ if (resolved.status === "not_found")
81
+ return channelNotFound(door, ref);
82
+ const query = new URLSearchParams();
83
+ if (command.limit !== undefined)
84
+ query.set("limit", String(command.limit));
85
+ if (command.threadId)
86
+ query.set("thread_parent_id", command.threadId);
87
+ const rendered = query.toString();
88
+ const answer = await askAgentDoor(door, {
89
+ path: `/api/msg/channels/${encodeURIComponent(resolved.id)}/messages${rendered ? `?${rendered}` : ""}`,
90
+ method: "GET",
91
+ label: "msg read",
92
+ timeoutMs: READ_DEADLINE_MS,
93
+ });
94
+ if (!answer.ok)
95
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
96
+ const messages = answer.body.messages ?? [];
97
+ if (door.json)
98
+ return emitAgentDoor(door, { ok: true, channelId: resolved.id, messages });
99
+ if (messages.length === 0) {
100
+ writeLine(door.io.stdout, "No messages.");
101
+ return 0;
102
+ }
103
+ for (const message of [...messages].reverse()) {
104
+ const who = message.agent_label ?? message.user_id;
105
+ writeLine(door.io.stdout, `${message.created_at} ${who} ${message.content ?? ""}`);
106
+ }
107
+ return 0;
108
+ }
109
+ async function readThread(command, door) {
110
+ if (!command.channelRef) {
111
+ return failAgentDoor(door, TAG, "invalid_body", "msg thread needs --channel <channel>.");
112
+ }
113
+ const resolved = await resolveChannelId(door, command.channelRef);
114
+ if (resolved.status === "list_failed")
115
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
116
+ if (resolved.status === "not_found")
117
+ return channelNotFound(door, command.channelRef);
118
+ const answer = await askAgentDoor(door, {
119
+ path: `/api/msg/channels/${encodeURIComponent(resolved.id)}/messages?thread_parent_id=${encodeURIComponent(command.threadId ?? "")}`,
120
+ method: "GET",
121
+ label: "msg thread",
122
+ timeoutMs: READ_DEADLINE_MS,
123
+ });
124
+ if (!answer.ok)
125
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
126
+ const messages = answer.body.messages ?? [];
127
+ if (door.json)
128
+ return emitAgentDoor(door, { ok: true, channelId: resolved.id, threadParentId: command.threadId, messages });
129
+ if (messages.length === 0) {
130
+ writeLine(door.io.stdout, "No replies.");
131
+ return 0;
132
+ }
133
+ for (const message of [...messages].reverse()) {
134
+ const who = message.agent_label ?? message.user_id;
135
+ writeLine(door.io.stdout, `${message.created_at} ${who} ${message.content ?? ""}`);
136
+ }
137
+ return 0;
138
+ }
139
+ async function readContent(io) {
140
+ if (isInteractiveStdin(io)) {
141
+ return {
142
+ ok: false,
143
+ reason: "nothing_piped",
144
+ detail: "Pipe the message in, e.g. `echo \"hello\" | cockpit msg send general`. A message never travels on the command line.",
145
+ };
146
+ }
147
+ try {
148
+ const text = await readPipedText(io.stdin, {
149
+ maxChars: CONTENT_MAX_CHARS,
150
+ overflowMessage: `A message is limited to ${CONTENT_MAX_CHARS} characters.`,
151
+ });
152
+ return { ok: true, text: text.trim() };
153
+ }
154
+ catch (error) {
155
+ return { ok: false, reason: "content_too_long", detail: error instanceof Error ? error.message : String(error) };
156
+ }
157
+ }
158
+ async function sendMessage(command, door) {
159
+ const ref = command.channelRef ?? "";
160
+ const resolved = await resolveChannelId(door, ref);
161
+ if (resolved.status === "list_failed")
162
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
163
+ if (resolved.status === "not_found")
164
+ return channelNotFound(door, ref);
165
+ const content = await readContent(door.io);
166
+ if (!content.ok)
167
+ return failAgentDoor(door, TAG, content.reason, content.detail);
168
+ if (content.text === "")
169
+ return failAgentDoor(door, TAG, "invalid_body", "There was nothing to send. Nothing was sent.");
170
+ const answer = await askAgentDoor(door, {
171
+ path: `/api/msg/channels/${encodeURIComponent(resolved.id)}/messages`,
172
+ method: "POST",
173
+ label: "msg send",
174
+ timeoutMs: WRITE_DEADLINE_MS,
175
+ body: {
176
+ content: content.text,
177
+ ...(command.threadId ? { thread_parent_id: command.threadId } : {}),
178
+ },
179
+ });
180
+ if (!answer.ok)
181
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
182
+ const message = answer.body.message ?? null;
183
+ writeLine(door.io.stderr, `${TAG} sent ${JSON.stringify({ channel_id: resolved.id, message_id: message?.id ?? null, chars: content.text.length })}`);
184
+ if (door.json)
185
+ return emitAgentDoor(door, { ok: true, message });
186
+ writeLine(door.io.stdout, `Sent to ${ref} (${message?.id ?? "?"}).`);
187
+ return 0;
188
+ }
@@ -70,7 +70,12 @@ export function renderOpsStatus(payload, dim) {
70
70
  const verdict = verdictWord(row.verdict).padEnd(6);
71
71
  const id = (row.id ?? "?").padEnd(idWidth);
72
72
  const age = ageWord(row.ageHours).padStart(7);
73
- lines.push(`${verdict} ${id} ${age} ${dim(intervalWord(row))}`);
73
+ // BLI-3722: name the device this age belongs to right in the row — a bare
74
+ // `STALE collector-fleet 11h` was already misread once (BLI-3699) as "the
75
+ // fleet", not "one machine in it", and the name is otherwise buried in
76
+ // `detail` below.
77
+ const staleName = row.verdict === "stale" && row.staleDeviceName ? ` ${dim(`(${row.staleDeviceName})`)}` : "";
78
+ lines.push(`${verdict} ${id} ${age}${staleName} ${dim(intervalWord(row))}`);
74
79
  if (row.verdict !== "healthy") {
75
80
  if (row.detail)
76
81
  lines.push(dim(` ${row.detail}`));
@@ -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.55");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.57");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The Claude Code half of `bli-tower`'s registration (BLI-3706) — one entry
3
+ * under `~/.claude.json`'s top-level `mcpServers`, beside `bli-memory`'s.
4
+ *
5
+ * Reuses `memory-install-claude.ts`'s generic JSON-target machinery
6
+ * (`applyJsonTarget`/`inspectJsonTarget`) and its `mcpServers` shape
7
+ * (`applyMcpServer`/`readMcpServer`/`mcpServerMatches`), none of which is
8
+ * memory-specific — see that file's own exports for why. `bli-tower` has no
9
+ * hooks (`tower-mcp-contract.ts`), so there is no `~/.claude/settings.json`
10
+ * half here at all, unlike BLI Memory's `claude_hooks` target.
11
+ */
12
+ import { applyJsonTarget, applyMcpServer, claudeMcpConfigFile, inspectJsonTarget, mcpServerMatches, readMcpServer, } from "./memory-install-claude.js";
13
+ export async function installClaudeTowerIntegration(options) {
14
+ return applyJsonTarget({
15
+ target: "tower_claude_mcp",
16
+ file: claudeMcpConfigFile(options.homeDir),
17
+ options,
18
+ apply: (root) => applyMcpServer(root, options.config),
19
+ matches: (root) => mcpServerMatches(root, options.config),
20
+ });
21
+ }
22
+ export async function inspectClaudeTowerIntegration(options) {
23
+ return inspectJsonTarget({
24
+ target: "tower_claude_mcp",
25
+ file: claudeMcpConfigFile(options.homeDir),
26
+ io: options.io,
27
+ present: (root) => readMcpServer(root, options.config.server_id) !== null,
28
+ matches: (root) => mcpServerMatches(root, options.config),
29
+ });
30
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The Codex half of `bli-tower`'s registration (BLI-3706) — one table,
3
+ * `[mcp_servers.bli-tower]`, in `~/.codex/config.toml`.
4
+ *
5
+ * Deliberately narrower than `memory-install-codex.ts`: `bli-tower` has no
6
+ * Codex skill to teach (its tools are self-describing over MCP, same as any
7
+ * other server Codex discovers), so there is no `codex_skills` target here —
8
+ * only the one table, written the same byte-preserving way BLI Memory's own
9
+ * table is (`memory-install-toml.ts`'s line-span swap, never a
10
+ * parse-and-reserialise, so a person's model/approval/sandbox settings and
11
+ * every other MCP server come out untouched).
12
+ */
13
+ import path from "node:path";
14
+ import { TOWER_MCP_SERVER_ID } from "./tower-mcp-contract.js";
15
+ import { findTomlTableSpan, readTomlTable, renderTomlTable, upsertTomlTable, } from "./memory-install-toml.js";
16
+ const TOWER_TOML_PATH = ["mcp_servers", TOWER_MCP_SERVER_ID];
17
+ export function codexConfigFile(homeDir) {
18
+ return path.join(homeDir, ".codex", "config.toml");
19
+ }
20
+ export async function installCodexTowerIntegration(options) {
21
+ const file = codexConfigFile(options.homeDir);
22
+ const raw = (await options.io.readText(file)) ?? "";
23
+ if (tableMatches(raw, options.config)) {
24
+ return { target: "tower_codex_mcp", status: "already", reason: "already_current", path: file };
25
+ }
26
+ const next = upsertTomlTable(raw, TOWER_TOML_PATH, renderTowerTable(options.config));
27
+ if (options.dryRun) {
28
+ return { target: "tower_codex_mcp", status: "would_install", reason: "dry_run", path: file };
29
+ }
30
+ try {
31
+ await options.io.writeText(file, next);
32
+ }
33
+ catch (error) {
34
+ return {
35
+ target: "tower_codex_mcp",
36
+ status: "failed",
37
+ reason: "write_failed",
38
+ path: file,
39
+ detail: error instanceof Error ? error.message : String(error),
40
+ };
41
+ }
42
+ // BLI-2541: parse the table back out of the stored bytes, same discipline
43
+ // as every other target this installer writes.
44
+ const stored = await options.io.readText(file);
45
+ if (stored === null || !tableMatches(stored, options.config)) {
46
+ return {
47
+ target: "tower_codex_mcp",
48
+ status: "failed",
49
+ reason: "read_back_mismatch",
50
+ path: file,
51
+ detail: "the table on disk does not parse back to the entry that was written",
52
+ };
53
+ }
54
+ return { target: "tower_codex_mcp", status: "installed", reason: "wrote_entry", path: file };
55
+ }
56
+ export async function inspectCodexTowerIntegration(options) {
57
+ const file = codexConfigFile(options.homeDir);
58
+ const raw = await options.io.readText(file);
59
+ if (raw === null) {
60
+ return { target: "tower_codex_mcp", status: "missing", reason: "file_absent", path: file };
61
+ }
62
+ if (tableMatches(raw, options.config)) {
63
+ return { target: "tower_codex_mcp", status: "installed", reason: "already_current", path: file };
64
+ }
65
+ return findTomlTableSpan(raw, TOWER_TOML_PATH)
66
+ ? { target: "tower_codex_mcp", status: "mismatch", reason: "entry_differs", path: file }
67
+ : { target: "tower_codex_mcp", status: "missing", reason: "entry_absent", path: file };
68
+ }
69
+ function renderTowerTable(config) {
70
+ const entries = [
71
+ ["command", config.mcp_server.command],
72
+ ["args", config.mcp_server.args],
73
+ ];
74
+ if (Object.keys(config.mcp_server.env).length > 0) {
75
+ entries.push(["env", config.mcp_server.env]);
76
+ }
77
+ return renderTomlTable(TOWER_TOML_PATH, entries);
78
+ }
79
+ function tableMatches(raw, config) {
80
+ const table = readTomlTable(raw, TOWER_TOML_PATH);
81
+ if (!table)
82
+ return false;
83
+ if (table["command"] !== config.mcp_server.command)
84
+ return false;
85
+ const args = table["args"];
86
+ if (!Array.isArray(args))
87
+ return config.mcp_server.args.length === 0;
88
+ if (args.length !== config.mcp_server.args.length)
89
+ return false;
90
+ if (!args.every((entry, index) => entry === config.mcp_server.args[index]))
91
+ return false;
92
+ const env = table["env"];
93
+ for (const [key, value] of Object.entries(config.mcp_server.env)) {
94
+ if (!env || Array.isArray(env) || typeof env === "string")
95
+ return false;
96
+ if (env[key] !== value)
97
+ return false;
98
+ }
99
+ return true;
100
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The `bli-tower` MCP server's registration shape (BLI-3706).
3
+ *
4
+ * `bli-tower` is the server id `@bli-cockpit/mcp` (bin `bli-cockpit-mcp`)
5
+ * registers under — the `docs_*`/`msg_*` tools over the collector device
6
+ * token, beside `bli-memory` (BLI-3580). Unlike BLI Memory, this server has
7
+ * NO Claude Code hooks: it is tools only, so `hooks: []` and
8
+ * `permissions_allow: []` on every config this module builds — the shared
9
+ * `memory-install-claude.ts`/`memory-install-codex.ts` writers already treat
10
+ * an empty hooks array as "nothing to do" for that half (see their own
11
+ * `config.hooks` loops), so no server-specific hook-writing code exists here.
12
+ *
13
+ * `@bli-cockpit/mcp` does not (yet) print its own `--print-config`, unlike
14
+ * `bli-memory-mcp` — so `builtinTowerInstallConfig` is the ONLY source of the
15
+ * config today; there is no `parsePrintedTowerInstallConfig` counterpart to
16
+ * `memory-install-contract.ts`'s equivalent. Adding one there is a natural
17
+ * follow-up once the server ships its own printer, matching BLI Memory's own
18
+ * shape (see that file's header for why the bin's own answer should win when
19
+ * it can be asked).
20
+ */
21
+ import { memoryMcpServerEntry } from "./memory-install-contract.js";
22
+ /** The published bin name. Both hosts spawn this — `@bli-cockpit/mcp`'s package.json `bin`. */
23
+ export const TOWER_MCP_BIN = "bli-cockpit-mcp";
24
+ /** The MCP server id, as it appears in `mcp__<server>__<tool>`. */
25
+ export const TOWER_MCP_SERVER_ID = "bli-tower";
26
+ /** The one env var the server is handed. A URL, never a token — same contract as BLI Memory's. */
27
+ export const TOWER_DASHBOARD_URL_ENV = "COCKPIT_DASHBOARD_URL";
28
+ export function builtinTowerInstallConfig(options) {
29
+ return {
30
+ server_id: TOWER_MCP_SERVER_ID,
31
+ mcp_server: memoryMcpServerEntry(options),
32
+ // No hooks: bli-tower is tools-only. Left as an empty array rather than
33
+ // omitted so this is still a complete MemoryInstallConfig — the shared
34
+ // Claude/Codex writers are already generic over "a server with zero
35
+ // hooks" (see this file's own header).
36
+ hooks: [],
37
+ permissions_allow: [],
38
+ };
39
+ }