@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.
- package/dist/commands/agent-door.js +85 -0
- package/dist/commands/docs.js +227 -0
- package/dist/commands/local-args-tower-docs-msg.js +126 -0
- package/dist/commands/local-args-tower.js +4 -1
- package/dist/commands/local-args.js +5 -1
- package/dist/commands/local-help.js +36 -0
- package/dist/commands/local.js +6 -0
- package/dist/commands/mcp-bin-resolve.js +102 -0
- package/dist/commands/memory-install-claude.js +13 -5
- package/dist/commands/memory-install.js +42 -102
- package/dist/commands/msg.js +188 -0
- package/dist/commands/ops-render.js +6 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/tower-mcp-claude.js +30 -0
- package/dist/commands/tower-mcp-codex.js +100 -0
- package/dist/commands/tower-mcp-contract.js +39 -0
- package/dist/commands/tower-mcp-install.js +75 -0
- package/dist/upload-envelope-build.js +240 -0
- package/dist/upload-envelope-event.js +198 -0
- package/dist/upload-envelope.js +16 -427
- package/dist/upload-ingest-receipt.js +121 -0
- package/dist/upload-session-reports-queue.js +156 -0
- package/dist/upload-session-reports-wire.js +275 -0
- package/dist/upload-session-reports.js +14 -425
- package/dist/upload-sync.js +291 -0
- package/dist/upload.js +24 -396
- package/package.json +3 -2
|
@@ -77,7 +77,13 @@ export async function inspectClaudeMemoryIntegration(options) {
|
|
|
77
77
|
}),
|
|
78
78
|
];
|
|
79
79
|
}
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|