@bli-cockpit/cli 0.2.56 → 0.2.58
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/issue-contracts.js +99 -0
- package/dist/commands/issue-write.js +129 -0
- package/dist/commands/issue.js +189 -0
- package/dist/commands/local-args-tower-docs-msg.js +126 -0
- package/dist/commands/local-args-tower-work.js +178 -0
- package/dist/commands/local-args-tower.js +7 -1
- package/dist/commands/local-args.js +10 -2
- package/dist/commands/local-help.js +70 -0
- package/dist/commands/local.js +12 -0
- package/dist/commands/mcp-bin-resolve.js +102 -0
- package/dist/commands/memory-install-claude.js +13 -5
- package/dist/commands/memory-install-config.js +140 -0
- package/dist/commands/memory-install-report.js +89 -0
- package/dist/commands/memory-install.js +51 -362
- package/dist/commands/msg.js +188 -0
- package/dist/commands/notes-door.js +120 -0
- package/dist/commands/notes-reads.js +134 -0
- package/dist/commands/notes-writes.js +208 -0
- package/dist/commands/notes.js +16 -442
- package/dist/commands/ops-render.js +18 -2
- package/dist/commands/ops.js +9 -2
- package/dist/commands/project.js +38 -0
- 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/repo-identity-fingerprint.js +88 -0
- package/dist/repo-identity-git.js +76 -0
- package/dist/repo-identity-linked-worktrees.js +81 -0
- package/dist/repo-identity.js +5 -222
- 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 +6 -5
|
@@ -13,38 +13,43 @@
|
|
|
13
13
|
* memory-install-claude.ts ~/.claude.json (user-scope MCP) + ~/.claude/settings.json (hooks, allow-list)
|
|
14
14
|
* memory-install-codex.ts ~/.codex/config.toml (one table) + ~/.codex/skills/bli-memory/
|
|
15
15
|
*
|
|
16
|
-
* What this module owns is the
|
|
16
|
+
* What this module owns is the top-level decision sequence
|
|
17
|
+
* (BLI-3717 readability pass, 629 lines to a table of contents). Every other
|
|
18
|
+
* responsibility lives in a `memory-install-*.ts` sibling:
|
|
19
|
+
*
|
|
20
|
+
* - `memory-install-config.ts` — finding `bli-memory-mcp` on this machine
|
|
21
|
+
* (`resolveMemoryMcpBin`, over the shared `mcp-bin-resolve.ts` walk) and
|
|
22
|
+
* deciding which config shape to write (`resolveMemoryConfig`). **No
|
|
23
|
+
* server, no write.** If the bin does not resolve, not one file is opened
|
|
24
|
+
* and the outcome is `skipped bin_missing` — see `no_bin_no_write` there
|
|
25
|
+
* for why a written-but-inert registration is the worse option.
|
|
26
|
+
* `--print-config --claude` wins when the bin answers it; the built-in
|
|
27
|
+
* template of the same shape is the fallback for an older server, and it
|
|
28
|
+
* is only ever used with a bin that actually resolved.
|
|
29
|
+
* - `memory-install-report.ts` — the dashboard URL, aggregating per-target
|
|
30
|
+
* results into one outcome, and rendering it for a log line or a
|
|
31
|
+
* terminal.
|
|
32
|
+
*
|
|
33
|
+
* Two rules bind the whole sequence:
|
|
17
34
|
*
|
|
18
|
-
* - **Where the server is.** `bli-memory-mcp` ships as a DEPENDENCY of
|
|
19
|
-
* `@bli-cockpit/cli`, so the canonical lookup is the `node_modules/.bin` on
|
|
20
|
-
* the way up from THIS MODULE's own file — realpath-ed first, because a
|
|
21
|
-
* global `cockpit` is a symlink and walking up from the symlink finds
|
|
22
|
-
* nothing; PATH is the fallback. Nobody ever installs a second global
|
|
23
|
-
* package. See `resolveMemoryMcpBin`, which carries the receipt.
|
|
24
|
-
* - **No server, no write.** If the bin does not resolve, not one file is
|
|
25
|
-
* opened and the outcome is `skipped bin_missing` — see `no_bin_no_write`
|
|
26
|
-
* below for why a written-but-inert registration is the worse option.
|
|
27
|
-
* - **Which config shape.** `bli-memory-mcp --print-config --claude` wins when
|
|
28
|
-
* the bin answers it; the built-in template of the same shape is the
|
|
29
|
-
* fallback for an older server, and it is only ever used with a bin that
|
|
30
|
-
* actually resolved.
|
|
31
35
|
* - **Never throw at the caller.** The sync tick calls this. A failure is a
|
|
32
36
|
* named receipt, never an exception that could cost a machine its
|
|
33
37
|
* collection tick.
|
|
34
38
|
* - **Never claim an install it did not read back** (BLI-2541). Both halves
|
|
35
39
|
* re-read and re-parse; this module only aggregates what they proved.
|
|
40
|
+
*
|
|
41
|
+
* Every public name is still importable from this file.
|
|
36
42
|
*/
|
|
37
|
-
import fs from "node:fs";
|
|
38
43
|
import os from "node:os";
|
|
39
|
-
import path from "node:path";
|
|
40
|
-
import { fileURLToPath } from "node:url";
|
|
41
44
|
import { writeLine } from "./cli-io.js";
|
|
42
|
-
import {
|
|
45
|
+
import { installTowerIntegration, inspectTowerIntegration } from "./tower-mcp-install.js";
|
|
43
46
|
import { defaultMemoryFileIo, } from "./memory-install-files.js";
|
|
44
47
|
import { installClaudeMemoryIntegration, inspectClaudeMemoryIntegration, } from "./memory-install-claude.js";
|
|
45
48
|
import { installCodexMemoryIntegration, inspectCodexMemoryIntegration, } from "./memory-install-codex.js";
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
49
|
+
import { resolveMemoryConfig } from "./memory-install-config.js";
|
|
50
|
+
import { aggregate, logMemoryOutcome, memoryOutcomeLines, resolveDashboardUrl } from "./memory-install-report.js";
|
|
51
|
+
export { resolveMemoryMcpBin } from "./memory-install-config.js";
|
|
52
|
+
export { memoryOutcomeLines, resolveDashboardUrl } from "./memory-install-report.js";
|
|
48
53
|
export async function runMemoryInstall(command, io, deps = {}) {
|
|
49
54
|
const outcome = command.action === "status"
|
|
50
55
|
? await inspectMemoryIntegration(command, io, deps)
|
|
@@ -80,6 +85,21 @@ export async function installMemoryIntegration(command, io, deps = {}) {
|
|
|
80
85
|
io: fileIo,
|
|
81
86
|
})));
|
|
82
87
|
}
|
|
88
|
+
// BLI-3706: `bli-tower` (docs_*/msg_* over the collector device token)
|
|
89
|
+
// registers beside `bli-memory` on the SAME command — see
|
|
90
|
+
// tower-mcp-install.ts's header for why this rides here rather than a
|
|
91
|
+
// separate, un-invoked verb. Independent of the memory outcome above: a
|
|
92
|
+
// machine with one server but not the other is a real machine.
|
|
93
|
+
targets.push(...(await installTowerIntegration({
|
|
94
|
+
io: fileIo,
|
|
95
|
+
platform,
|
|
96
|
+
homeDir,
|
|
97
|
+
dashboardUrl: await resolveDashboardUrl(command, deps),
|
|
98
|
+
env: io.env ?? process.env,
|
|
99
|
+
fileExists: deps.fileExists,
|
|
100
|
+
cliEntryPoint: deps.cliEntryPoint,
|
|
101
|
+
realpath: deps.realpath,
|
|
102
|
+
}, command.dryRun)));
|
|
83
103
|
const outcome = {
|
|
84
104
|
action: "install",
|
|
85
105
|
...aggregate(targets),
|
|
@@ -112,6 +132,17 @@ export async function inspectMemoryIntegration(command, io, deps = {}) {
|
|
|
112
132
|
io: fileIo,
|
|
113
133
|
})));
|
|
114
134
|
}
|
|
135
|
+
// BLI-3706: see the matching note in `installMemoryIntegration`.
|
|
136
|
+
targets.push(...(await inspectTowerIntegration({
|
|
137
|
+
io: fileIo,
|
|
138
|
+
platform,
|
|
139
|
+
homeDir,
|
|
140
|
+
dashboardUrl: await resolveDashboardUrl(command, deps),
|
|
141
|
+
env: io.env ?? process.env,
|
|
142
|
+
fileExists: deps.fileExists,
|
|
143
|
+
cliEntryPoint: deps.cliEntryPoint,
|
|
144
|
+
realpath: deps.realpath,
|
|
145
|
+
})));
|
|
115
146
|
return {
|
|
116
147
|
action: "status",
|
|
117
148
|
...aggregate(targets),
|
|
@@ -120,346 +151,4 @@ export async function inspectMemoryIntegration(command, io, deps = {}) {
|
|
|
120
151
|
...(resolved.bin_source ? { bin_source: resolved.bin_source } : {}),
|
|
121
152
|
targets,
|
|
122
153
|
};
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* **`no_bin_no_write`.** When `bli-memory-mcp` cannot be resolved, this command
|
|
126
|
-
* writes NOTHING — not the MCP entry, not the hooks, not the Codex table, not
|
|
127
|
-
* the skills.
|
|
128
|
-
*
|
|
129
|
-
* An earlier revision wrote the registration anyway, on the reasoning that a
|
|
130
|
-
* correct shape waiting for the package is better than nothing. It is not:
|
|
131
|
-
* Claude Code RUNS a registered hook. Three hooks pointing at a binary that
|
|
132
|
-
* does not exist would print a hook failure on every SessionStart, every
|
|
133
|
-
* prompt and every Stop, on every intern machine, until the package shipped —
|
|
134
|
-
* a self-inflicted outage in the one surface people look at all day.
|
|
135
|
-
*
|
|
136
|
-
* So an absent bin is `skipped bin_missing`: a receipt, not a write. The daily
|
|
137
|
-
* self-heal retries tomorrow, and the first tick after the package lands does
|
|
138
|
-
* the whole registration at once.
|
|
139
|
-
*/
|
|
140
|
-
async function resolveMemoryConfig(command, io, platform, deps) {
|
|
141
|
-
const dashboardUrl = await resolveDashboardUrl(command, deps);
|
|
142
|
-
const found = await resolveMemoryMcpBin({
|
|
143
|
-
env: envWithNodeRuntimeOnPath(io.env ?? process.env),
|
|
144
|
-
platform,
|
|
145
|
-
fileExists: deps.fileExists,
|
|
146
|
-
cliEntryPoint: deps.cliEntryPoint,
|
|
147
|
-
realpath: deps.realpath,
|
|
148
|
-
});
|
|
149
|
-
if (!found) {
|
|
150
|
-
return {
|
|
151
|
-
config: null,
|
|
152
|
-
source: "none",
|
|
153
|
-
binTarget: {
|
|
154
|
-
target: "bin",
|
|
155
|
-
status: "skipped",
|
|
156
|
-
reason: "bin_missing",
|
|
157
|
-
detail: `${MEMORY_MCP_BIN} is not installed beside this CLI or on PATH; nothing was written, and the next daily run will try again`,
|
|
158
|
-
},
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
if (isUnsafeBinPath(found.path)) {
|
|
162
|
-
// A hook command is a shell string by the platform's design. A path that
|
|
163
|
-
// cannot be quoted safely is not escaped cleverly, and it is not swapped
|
|
164
|
-
// for a bare name that may resolve to something else either — the install
|
|
165
|
-
// refuses and says why.
|
|
166
|
-
return {
|
|
167
|
-
config: null,
|
|
168
|
-
source: "none",
|
|
169
|
-
binTarget: {
|
|
170
|
-
target: "bin",
|
|
171
|
-
status: "failed",
|
|
172
|
-
reason: "bin_path_unsafe",
|
|
173
|
-
detail: "the resolved bin path contains characters that cannot appear in a hook command; nothing was written",
|
|
174
|
-
},
|
|
175
|
-
bin_source: found.source,
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
const printed = await printedMemoryConfig(io, found.path);
|
|
179
|
-
if (printed) {
|
|
180
|
-
// The bin prints a BARE command name — it cannot know where it was
|
|
181
|
-
// installed, and it is nested inside the CLI's node_modules rather than on
|
|
182
|
-
// PATH. Path-qualifying it here is what makes the registration runnable at
|
|
183
|
-
// all; see `withResolvedBinPath`.
|
|
184
|
-
const qualified = withResolvedBinPath(printed, {
|
|
185
|
-
binPath: found.path,
|
|
186
|
-
platform,
|
|
187
|
-
dashboardUrl,
|
|
188
|
-
});
|
|
189
|
-
if (qualified) {
|
|
190
|
-
return {
|
|
191
|
-
config: qualified,
|
|
192
|
-
source: "bin",
|
|
193
|
-
binTarget: { target: "bin", status: "already", reason: "bin_printed_config" },
|
|
194
|
-
bin_source: found.source,
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
// A printed hook command this installer cannot re-point. The template is
|
|
198
|
-
// always path-qualified, so it is the safe answer — and the reason says
|
|
199
|
-
// which of the two fallbacks happened.
|
|
200
|
-
return {
|
|
201
|
-
config: builtinMemoryInstallConfig({ binPath: found.path, platform, dashboardUrl }),
|
|
202
|
-
source: "template",
|
|
203
|
-
binTarget: {
|
|
204
|
-
target: "bin",
|
|
205
|
-
status: "already",
|
|
206
|
-
reason: "bin_printed_config_unqualifiable",
|
|
207
|
-
detail: "the bin printed a hook command this installer could not re-point at the resolved path; the built-in shape was used",
|
|
208
|
-
},
|
|
209
|
-
bin_source: found.source,
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
return {
|
|
213
|
-
config: builtinMemoryInstallConfig({
|
|
214
|
-
binPath: found.path,
|
|
215
|
-
platform,
|
|
216
|
-
dashboardUrl,
|
|
217
|
-
}),
|
|
218
|
-
source: "template",
|
|
219
|
-
binTarget: {
|
|
220
|
-
target: "bin",
|
|
221
|
-
status: "already",
|
|
222
|
-
reason: "bin_present_template_used",
|
|
223
|
-
detail: "the bin did not print a usable --print-config; the built-in shape was used",
|
|
224
|
-
},
|
|
225
|
-
bin_source: found.source,
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
/**
|
|
229
|
-
* Asks the server for its own shape. Arguments go as an ARRAY — nothing is
|
|
230
|
-
* interpolated into a command line — and on Windows the captured runner routes
|
|
231
|
-
* a `.cmd` shim through ComSpec with its own escaping (process-runner.ts).
|
|
232
|
-
*/
|
|
233
|
-
async function printedMemoryConfig(io, binPath) {
|
|
234
|
-
const exec = io.exec;
|
|
235
|
-
if (!exec)
|
|
236
|
-
return null;
|
|
237
|
-
try {
|
|
238
|
-
const result = await exec(binPath, ["--print-config", "--claude"]);
|
|
239
|
-
if (result.code !== 0)
|
|
240
|
-
return null;
|
|
241
|
-
return parsePrintedMemoryInstallConfig(result.stdout);
|
|
242
|
-
}
|
|
243
|
-
catch {
|
|
244
|
-
// Deliberately silent here: the caller reports
|
|
245
|
-
// `bin_present_template_used`, which is the same information with a name
|
|
246
|
-
// on it, and this path is reached on every machine that has an older bin.
|
|
247
|
-
return null;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
/**
|
|
251
|
-
* Two steps, in this order, and the first is the canonical one.
|
|
252
|
-
*
|
|
253
|
-
* **1. Beside the CLI that is running.** `bli-memory-mcp` ships as a DEPENDENCY
|
|
254
|
-
* of `@bli-cockpit/cli`, so installing the CLI installs the server, and npm
|
|
255
|
-
* links its bin into a `node_modules/.bin` on the path from this package up to
|
|
256
|
-
* the install root — `…/@bli-cockpit/cli/node_modules/.bin` when it is nested,
|
|
257
|
-
* `…/lib/node_modules/.bin` when npm hoists it. It is the ONLY lookup that
|
|
258
|
-
* cannot find somebody else's `bli-memory-mcp`. Nobody ever runs `npm i -g` for
|
|
259
|
-
* a second package.
|
|
260
|
-
*
|
|
261
|
-
* **2. PATH, as a fallback**, for a linked checkout or a hand-installed server.
|
|
262
|
-
* Done in this process rather than through `which`/`where`: it spawns nothing,
|
|
263
|
-
* behaves the same on both host families, and is testable without a fixture
|
|
264
|
-
* binary. The npm global bin sits beside the running node binary in the
|
|
265
|
-
* standard layouts, which is why the caller passes a PATH that already includes
|
|
266
|
-
* it (`envWithNodeRuntimeOnPath`) — the launchd tick's PATH is otherwise
|
|
267
|
-
* `/usr/bin:/bin:/usr/sbin:/sbin` and would find nothing.
|
|
268
|
-
*
|
|
269
|
-
* **What step 1 got wrong on the first real machine** (BLI-3580, CLI 0.2.50):
|
|
270
|
-
* it walked up from `process.argv[1]`, resolved with `path.resolve` and no
|
|
271
|
-
* symlink following. For a global install `argv[1]` is the SHIM — on Edward's
|
|
272
|
-
* Mac `/opt/homebrew/bin/cockpit`, a symlink into
|
|
273
|
-
* `/opt/homebrew/lib/node_modules/@bli-cockpit/cli/dist/cli.js`. The walk
|
|
274
|
-
* therefore started in `/opt/homebrew/bin`, went up through `/opt` to `/`, and
|
|
275
|
-
* never came within reach of the nested `.bin` that was sitting right there.
|
|
276
|
-
* Every machine reported `skipped bin_missing` with the server installed.
|
|
277
|
-
*
|
|
278
|
-
* Two changes, both in `besideAnchors` / `resolveBesideCli`:
|
|
279
|
-
*
|
|
280
|
-
* - The anchor is **this module's own file** first (`import.meta.url`), which
|
|
281
|
-
* is inside the installed package by construction and is never a shim. The
|
|
282
|
-
* entry point (injected, or `argv[1]`) stays as a second anchor for a build
|
|
283
|
-
* layout where this module has been bundled somewhere else.
|
|
284
|
-
* - Every anchor is **realpath-ed** before the walk, so a symlinked entry
|
|
285
|
-
* lands in the real tree. A path that will not resolve is used as given —
|
|
286
|
-
* the same bounded silence `container-tag.ts` uses, because a path that
|
|
287
|
-
* cannot be realpath-ed still identifies a directory well enough to look in.
|
|
288
|
-
*/
|
|
289
|
-
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;
|
|
296
|
-
}
|
|
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) {
|
|
390
|
-
if (command.dashboardUrl)
|
|
391
|
-
return command.dashboardUrl;
|
|
392
|
-
const paths = getCollectorRuntimePaths(deps.homeDir ?? command.homeDir);
|
|
393
|
-
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
394
|
-
return config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
|
|
395
|
-
}
|
|
396
|
-
function aggregate(targets) {
|
|
397
|
-
const failed = targets.filter((target) => target.status === "failed");
|
|
398
|
-
if (failed.length > 0) {
|
|
399
|
-
return {
|
|
400
|
-
status: "failed",
|
|
401
|
-
// The first named reason, not a count: an operator needs the reason, and
|
|
402
|
-
// the per-target list beside it carries the rest.
|
|
403
|
-
reason: failed[0]?.reason ?? "unknown_failure",
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
if (targets.some((target) => target.status === "would_install")) {
|
|
407
|
-
return { status: "would_install", reason: "dry_run" };
|
|
408
|
-
}
|
|
409
|
-
if (targets.some((target) => target.status === "installed")) {
|
|
410
|
-
return { status: "installed", reason: "wrote_entry" };
|
|
411
|
-
}
|
|
412
|
-
if (targets.some((target) => target.status === "mismatch" || target.status === "missing")) {
|
|
413
|
-
return { status: "missing", reason: "entry_absent" };
|
|
414
|
-
}
|
|
415
|
-
const skipped = targets.find((target) => target.status === "skipped");
|
|
416
|
-
if (skipped) {
|
|
417
|
-
// Not a failure and not a success: nothing was written, on purpose, and
|
|
418
|
-
// the next daily run will try again. `bin_missing` is the only one today.
|
|
419
|
-
return { status: "skipped", reason: skipped.reason };
|
|
420
|
-
}
|
|
421
|
-
return { status: "already", reason: "already_current" };
|
|
422
|
-
}
|
|
423
|
-
/**
|
|
424
|
-
* Both branches log, and neither carries a path — a home directory names a
|
|
425
|
-
* person, and this line ends up in `sync.err.log` on every machine.
|
|
426
|
-
*/
|
|
427
|
-
function logMemoryOutcome(outcome, platform) {
|
|
428
|
-
const fields = {
|
|
429
|
-
status: outcome.status,
|
|
430
|
-
reason: outcome.reason,
|
|
431
|
-
config_source: outcome.config_source,
|
|
432
|
-
bin_found: outcome.bin_found,
|
|
433
|
-
platform,
|
|
434
|
-
installed_count: outcome.targets.filter((target) => target.status === "installed").length,
|
|
435
|
-
already_count: outcome.targets.filter((target) => target.status === "already").length,
|
|
436
|
-
failed: outcome.targets
|
|
437
|
-
.filter((target) => target.status === "failed")
|
|
438
|
-
.map((target) => `${target.target}:${target.reason}`),
|
|
439
|
-
};
|
|
440
|
-
// stderr on both branches: launchd captures it to sync.err.log, and stdout is
|
|
441
|
-
// reserved for `--json`.
|
|
442
|
-
console.error(outcome.status === "failed"
|
|
443
|
-
? "[memory-install] BLI Memory is not fully registered on this machine"
|
|
444
|
-
: "[memory-install] BLI Memory registration converged", JSON.stringify(fields));
|
|
445
|
-
}
|
|
446
|
-
export function memoryOutcomeLines(outcome) {
|
|
447
|
-
const headline = outcome.status === "installed"
|
|
448
|
-
? "BLI Memory registered on this machine."
|
|
449
|
-
: outcome.status === "already"
|
|
450
|
-
? "BLI Memory is already registered on this machine."
|
|
451
|
-
: outcome.status === "would_install"
|
|
452
|
-
? "BLI Memory would be registered (dry run; nothing was written)."
|
|
453
|
-
: outcome.status === "missing"
|
|
454
|
-
? "BLI Memory is not registered on this machine."
|
|
455
|
-
: outcome.status === "skipped"
|
|
456
|
-
? "BLI Memory was not registered and nothing was written: the bli-memory-mcp server is not on this machine yet."
|
|
457
|
-
: `BLI Memory is not fully registered: ${outcome.reason}.`;
|
|
458
|
-
const lines = [headline];
|
|
459
|
-
for (const target of outcome.targets) {
|
|
460
|
-
const where = target.path ? ` ${target.path}` : "";
|
|
461
|
-
const detail = target.detail ? ` — ${target.detail}` : "";
|
|
462
|
-
lines.push(` ${target.target}: ${target.status} (${target.reason})${where}${detail}`);
|
|
463
|
-
}
|
|
464
|
-
return lines;
|
|
465
154
|
}
|
|
@@ -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
|
+
}
|