@bli-cockpit/cli 0.2.57 → 0.2.59
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/browser-open.js +88 -0
- package/dist/commands/docs.js +27 -6
- package/dist/commands/doctor-report.js +17 -1
- package/dist/commands/doctor.js +12 -2
- package/dist/commands/heartbeat.js +65 -1
- 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/jarvis-answer-envelope.js +80 -0
- package/dist/commands/jarvis-turn.js +16 -1
- package/dist/commands/jarvis.js +3 -0
- package/dist/commands/local-args-collector-setup.js +17 -0
- package/dist/commands/local-args-tower-admin.js +12 -2
- package/dist/commands/local-args-tower-docs-msg.js +95 -6
- package/dist/commands/local-args-tower-search.js +50 -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 +29 -14
- package/dist/commands/local-command-shapes.js +12 -0
- package/dist/commands/local-help-commands.js +643 -0
- package/dist/commands/local-help.js +12 -551
- package/dist/commands/local.js +9 -0
- package/dist/commands/login.js +91 -8
- package/dist/commands/memory-install-claude.js +35 -15
- package/dist/commands/memory-install-codex-hooks.js +200 -0
- package/dist/commands/memory-install-codex.js +12 -2
- package/dist/commands/memory-install-config.js +140 -0
- package/dist/commands/memory-install-receipt.js +222 -0
- package/dist/commands/memory-install-report.js +113 -0
- package/dist/commands/memory-install.js +99 -276
- package/dist/commands/msg.js +85 -2
- 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/onboard-completion.js +47 -0
- package/dist/commands/onboard-setup.js +82 -2
- package/dist/commands/ops-render-memory.js +76 -0
- package/dist/commands/ops-render.js +13 -1
- package/dist/commands/ops.js +65 -3
- package/dist/commands/project.js +38 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/search.js +122 -0
- package/dist/commands/setup-receipt-lines.js +71 -0
- package/dist/commands/setup-receipt.js +241 -0
- package/dist/commands/status.js +20 -1
- package/dist/local-state-pairing-code.js +200 -0
- package/dist/local-state.js +6 -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/package.json +7 -7
|
@@ -13,47 +13,77 @@
|
|
|
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
|
*/
|
|
43
|
+
import fs from "node:fs/promises";
|
|
37
44
|
import os from "node:os";
|
|
38
45
|
import { writeLine } from "./cli-io.js";
|
|
39
|
-
import { resolveMcpBin } from "./mcp-bin-resolve.js";
|
|
40
46
|
import { installTowerIntegration, inspectTowerIntegration } from "./tower-mcp-install.js";
|
|
41
|
-
import { builtinMemoryInstallConfig, isUnsafeBinPath, MEMORY_MCP_BIN, parsePrintedMemoryInstallConfig, withResolvedBinPath, } from "./memory-install-contract.js";
|
|
42
47
|
import { defaultMemoryFileIo, } from "./memory-install-files.js";
|
|
43
48
|
import { installClaudeMemoryIntegration, inspectClaudeMemoryIntegration, } from "./memory-install-claude.js";
|
|
44
49
|
import { installCodexMemoryIntegration, inspectCodexMemoryIntegration, } from "./memory-install-codex.js";
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
50
|
+
import { resolveMemoryConfig } from "./memory-install-config.js";
|
|
51
|
+
import { expectedCodexTrustRows, readCodexHookTrustFromDisk, } from "./memory-install-codex-hooks.js";
|
|
52
|
+
import { buildMemoryInstallReceipt, writeMemoryReceiptFile, } from "./memory-install-receipt.js";
|
|
53
|
+
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
54
|
+
import { aggregate, logMemoryOutcome, memoryOutcomeLines, resolveDashboardUrl } from "./memory-install-report.js";
|
|
55
|
+
import { refreshSetupReceipt } from "./setup-receipt.js";
|
|
56
|
+
import { setupReceiptBlock } from "./setup-receipt-lines.js";
|
|
57
|
+
export { resolveMemoryMcpBin } from "./memory-install-config.js";
|
|
58
|
+
export { memoryOutcomeLines, resolveDashboardUrl } from "./memory-install-report.js";
|
|
59
|
+
export { buildMemoryInstallReceipt, memoryReceiptLine, readMemoryReceiptFile, writeMemoryReceiptFile, MEMORY_RECEIPT_FILE, } from "./memory-install-receipt.js";
|
|
60
|
+
export { codexHooksFile, readCodexHookTrust, } from "./memory-install-codex-hooks.js";
|
|
47
61
|
export async function runMemoryInstall(command, io, deps = {}) {
|
|
48
62
|
const outcome = command.action === "status"
|
|
49
63
|
? await inspectMemoryIntegration(command, io, deps)
|
|
50
64
|
: await installMemoryIntegration(command, io, deps);
|
|
65
|
+
// BLI-3731. Read the WHOLE machine back after acting on part of it, so the
|
|
66
|
+
// heartbeat's cache is fresh and a person sees the same seven words here as
|
|
67
|
+
// on the ops board. A dry run reads nothing and caches nothing: `--dry-run`
|
|
68
|
+
// says what WOULD happen, which is not a reading of anything.
|
|
69
|
+
const receipt = command.dryRun
|
|
70
|
+
? null
|
|
71
|
+
: await refreshSetupReceipt(io, {
|
|
72
|
+
...(command.homeDir ? { homeDir: command.homeDir } : {}),
|
|
73
|
+
...(command.dashboardUrl ? { dashboardUrl: command.dashboardUrl } : {}),
|
|
74
|
+
});
|
|
51
75
|
if (command.json) {
|
|
52
|
-
writeLine(io.stdout, JSON.stringify(outcome, null, 2));
|
|
76
|
+
writeLine(io.stdout, JSON.stringify({ ...outcome, setup_receipt: receipt?.receipt ?? null }, null, 2));
|
|
53
77
|
}
|
|
54
78
|
else {
|
|
55
79
|
for (const line of memoryOutcomeLines(outcome))
|
|
56
80
|
writeLine(io.stdout, line);
|
|
81
|
+
if (receipt) {
|
|
82
|
+
writeLine(io.stdout, "Connected:");
|
|
83
|
+
for (const line of setupReceiptBlock(receipt, { indent: " " })) {
|
|
84
|
+
writeLine(io.stdout, line);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
57
87
|
}
|
|
58
88
|
return outcome.status === "failed" ? 1 : 0;
|
|
59
89
|
}
|
|
@@ -101,10 +131,49 @@ export async function installMemoryIntegration(command, io, deps = {}) {
|
|
|
101
131
|
bin_found: resolved.config !== null,
|
|
102
132
|
...(resolved.bin_source ? { bin_source: resolved.bin_source } : {}),
|
|
103
133
|
targets,
|
|
134
|
+
receipt: buildMemoryInstallReceipt({
|
|
135
|
+
targets,
|
|
136
|
+
binFound: resolved.config !== null,
|
|
137
|
+
codexTrust: await codexTrustFor(homeDir, fileIo),
|
|
138
|
+
expectedTrustRows: resolved.config ? expectedCodexTrustRows(resolved.config) : 0,
|
|
139
|
+
}),
|
|
104
140
|
};
|
|
105
141
|
logMemoryOutcome(outcome, platform);
|
|
142
|
+
// The heartbeat reads the cache, not the machine — see the receipt module's
|
|
143
|
+
// "Where the receipt lives between runs". A dry run caches nothing: it proves
|
|
144
|
+
// what WOULD happen and nothing about what is on disk.
|
|
145
|
+
if (!command.dryRun) {
|
|
146
|
+
await cacheReceipt(homeDir ?? command.homeDir, outcome.receipt);
|
|
147
|
+
}
|
|
106
148
|
return outcome;
|
|
107
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Reading the trust state never fails an install. A `config.toml` we cannot
|
|
152
|
+
* read is `config_unreadable`, which the receipt reports as `unknown` for
|
|
153
|
+
* `codex.hooks` rather than as an absent installation.
|
|
154
|
+
*/
|
|
155
|
+
async function codexTrustFor(homeDir, io) {
|
|
156
|
+
return readCodexHookTrustFromDisk({ homeDir, io }).catch(() => ({
|
|
157
|
+
feature: "config_unreadable",
|
|
158
|
+
trustedRows: 0,
|
|
159
|
+
disabledRows: 0,
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
async function cacheReceipt(homeDir, receipt) {
|
|
163
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
164
|
+
await writeMemoryReceiptFile({
|
|
165
|
+
stateDir: paths.state_dir,
|
|
166
|
+
receipt,
|
|
167
|
+
io: {
|
|
168
|
+
mkdir: async (dir) => {
|
|
169
|
+
await fs.mkdir(dir, { recursive: true });
|
|
170
|
+
},
|
|
171
|
+
writeFile: async (file, body) => {
|
|
172
|
+
await fs.writeFile(file, body, "utf8");
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
108
177
|
export async function inspectMemoryIntegration(command, io, deps = {}) {
|
|
109
178
|
const homeDir = deps.homeDir ?? command.homeDir ?? os.homedir();
|
|
110
179
|
const platform = deps.platform ?? process.platform;
|
|
@@ -137,269 +206,23 @@ export async function inspectMemoryIntegration(command, io, deps = {}) {
|
|
|
137
206
|
cliEntryPoint: deps.cliEntryPoint,
|
|
138
207
|
realpath: deps.realpath,
|
|
139
208
|
})));
|
|
140
|
-
|
|
209
|
+
const outcome = {
|
|
141
210
|
action: "status",
|
|
142
211
|
...aggregate(targets),
|
|
143
212
|
config_source: resolved.source,
|
|
144
213
|
bin_found: resolved.config !== null,
|
|
145
214
|
...(resolved.bin_source ? { bin_source: resolved.bin_source } : {}),
|
|
146
215
|
targets,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
* the skills.
|
|
153
|
-
*
|
|
154
|
-
* An earlier revision wrote the registration anyway, on the reasoning that a
|
|
155
|
-
* correct shape waiting for the package is better than nothing. It is not:
|
|
156
|
-
* Claude Code RUNS a registered hook. Three hooks pointing at a binary that
|
|
157
|
-
* does not exist would print a hook failure on every SessionStart, every
|
|
158
|
-
* prompt and every Stop, on every intern machine, until the package shipped —
|
|
159
|
-
* a self-inflicted outage in the one surface people look at all day.
|
|
160
|
-
*
|
|
161
|
-
* So an absent bin is `skipped bin_missing`: a receipt, not a write. The daily
|
|
162
|
-
* self-heal retries tomorrow, and the first tick after the package lands does
|
|
163
|
-
* the whole registration at once.
|
|
164
|
-
*/
|
|
165
|
-
async function resolveMemoryConfig(command, io, platform, deps) {
|
|
166
|
-
const dashboardUrl = await resolveDashboardUrl(command, deps);
|
|
167
|
-
const found = await resolveMemoryMcpBin({
|
|
168
|
-
env: envWithNodeRuntimeOnPath(io.env ?? process.env),
|
|
169
|
-
platform,
|
|
170
|
-
fileExists: deps.fileExists,
|
|
171
|
-
cliEntryPoint: deps.cliEntryPoint,
|
|
172
|
-
realpath: deps.realpath,
|
|
173
|
-
});
|
|
174
|
-
if (!found) {
|
|
175
|
-
return {
|
|
176
|
-
config: null,
|
|
177
|
-
source: "none",
|
|
178
|
-
binTarget: {
|
|
179
|
-
target: "bin",
|
|
180
|
-
status: "skipped",
|
|
181
|
-
reason: "bin_missing",
|
|
182
|
-
detail: `${MEMORY_MCP_BIN} is not installed beside this CLI or on PATH; nothing was written, and the next daily run will try again`,
|
|
183
|
-
},
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
if (isUnsafeBinPath(found.path)) {
|
|
187
|
-
// A hook command is a shell string by the platform's design. A path that
|
|
188
|
-
// cannot be quoted safely is not escaped cleverly, and it is not swapped
|
|
189
|
-
// for a bare name that may resolve to something else either — the install
|
|
190
|
-
// refuses and says why.
|
|
191
|
-
return {
|
|
192
|
-
config: null,
|
|
193
|
-
source: "none",
|
|
194
|
-
binTarget: {
|
|
195
|
-
target: "bin",
|
|
196
|
-
status: "failed",
|
|
197
|
-
reason: "bin_path_unsafe",
|
|
198
|
-
detail: "the resolved bin path contains characters that cannot appear in a hook command; nothing was written",
|
|
199
|
-
},
|
|
200
|
-
bin_source: found.source,
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
const printed = await printedMemoryConfig(io, found.path);
|
|
204
|
-
if (printed) {
|
|
205
|
-
// The bin prints a BARE command name — it cannot know where it was
|
|
206
|
-
// installed, and it is nested inside the CLI's node_modules rather than on
|
|
207
|
-
// PATH. Path-qualifying it here is what makes the registration runnable at
|
|
208
|
-
// all; see `withResolvedBinPath`.
|
|
209
|
-
const qualified = withResolvedBinPath(printed, {
|
|
210
|
-
binPath: found.path,
|
|
211
|
-
platform,
|
|
212
|
-
dashboardUrl,
|
|
213
|
-
});
|
|
214
|
-
if (qualified) {
|
|
215
|
-
return {
|
|
216
|
-
config: qualified,
|
|
217
|
-
source: "bin",
|
|
218
|
-
binTarget: { target: "bin", status: "already", reason: "bin_printed_config" },
|
|
219
|
-
bin_source: found.source,
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
// A printed hook command this installer cannot re-point. The template is
|
|
223
|
-
// always path-qualified, so it is the safe answer — and the reason says
|
|
224
|
-
// which of the two fallbacks happened.
|
|
225
|
-
return {
|
|
226
|
-
config: builtinMemoryInstallConfig({ binPath: found.path, platform, dashboardUrl }),
|
|
227
|
-
source: "template",
|
|
228
|
-
binTarget: {
|
|
229
|
-
target: "bin",
|
|
230
|
-
status: "already",
|
|
231
|
-
reason: "bin_printed_config_unqualifiable",
|
|
232
|
-
detail: "the bin printed a hook command this installer could not re-point at the resolved path; the built-in shape was used",
|
|
233
|
-
},
|
|
234
|
-
bin_source: found.source,
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
return {
|
|
238
|
-
config: builtinMemoryInstallConfig({
|
|
239
|
-
binPath: found.path,
|
|
240
|
-
platform,
|
|
241
|
-
dashboardUrl,
|
|
216
|
+
receipt: buildMemoryInstallReceipt({
|
|
217
|
+
targets,
|
|
218
|
+
binFound: resolved.config !== null,
|
|
219
|
+
codexTrust: await codexTrustFor(homeDir, fileIo),
|
|
220
|
+
expectedTrustRows: resolved.config ? expectedCodexTrustRows(resolved.config) : 0,
|
|
242
221
|
}),
|
|
243
|
-
source: "template",
|
|
244
|
-
binTarget: {
|
|
245
|
-
target: "bin",
|
|
246
|
-
status: "already",
|
|
247
|
-
reason: "bin_present_template_used",
|
|
248
|
-
detail: "the bin did not print a usable --print-config; the built-in shape was used",
|
|
249
|
-
},
|
|
250
|
-
bin_source: found.source,
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
/**
|
|
254
|
-
* Asks the server for its own shape. Arguments go as an ARRAY — nothing is
|
|
255
|
-
* interpolated into a command line — and on Windows the captured runner routes
|
|
256
|
-
* a `.cmd` shim through ComSpec with its own escaping (process-runner.ts).
|
|
257
|
-
*/
|
|
258
|
-
async function printedMemoryConfig(io, binPath) {
|
|
259
|
-
const exec = io.exec;
|
|
260
|
-
if (!exec)
|
|
261
|
-
return null;
|
|
262
|
-
try {
|
|
263
|
-
const result = await exec(binPath, ["--print-config", "--claude"]);
|
|
264
|
-
if (result.code !== 0)
|
|
265
|
-
return null;
|
|
266
|
-
return parsePrintedMemoryInstallConfig(result.stdout);
|
|
267
|
-
}
|
|
268
|
-
catch {
|
|
269
|
-
// Deliberately silent here: the caller reports
|
|
270
|
-
// `bin_present_template_used`, which is the same information with a name
|
|
271
|
-
// on it, and this path is reached on every machine that has an older bin.
|
|
272
|
-
return null;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
/**
|
|
276
|
-
* Two steps, in this order, and the first is the canonical one.
|
|
277
|
-
*
|
|
278
|
-
* **1. Beside the CLI that is running.** `bli-memory-mcp` ships as a DEPENDENCY
|
|
279
|
-
* of `@bli-cockpit/cli`, so installing the CLI installs the server, and npm
|
|
280
|
-
* links its bin into a `node_modules/.bin` on the path from this package up to
|
|
281
|
-
* the install root — `…/@bli-cockpit/cli/node_modules/.bin` when it is nested,
|
|
282
|
-
* `…/lib/node_modules/.bin` when npm hoists it. It is the ONLY lookup that
|
|
283
|
-
* cannot find somebody else's `bli-memory-mcp`. Nobody ever runs `npm i -g` for
|
|
284
|
-
* a second package.
|
|
285
|
-
*
|
|
286
|
-
* **2. PATH, as a fallback**, for a linked checkout or a hand-installed server.
|
|
287
|
-
* Done in this process rather than through `which`/`where`: it spawns nothing,
|
|
288
|
-
* behaves the same on both host families, and is testable without a fixture
|
|
289
|
-
* binary. The npm global bin sits beside the running node binary in the
|
|
290
|
-
* standard layouts, which is why the caller passes a PATH that already includes
|
|
291
|
-
* it (`envWithNodeRuntimeOnPath`) — the launchd tick's PATH is otherwise
|
|
292
|
-
* `/usr/bin:/bin:/usr/sbin:/sbin` and would find nothing.
|
|
293
|
-
*
|
|
294
|
-
* **What step 1 got wrong on the first real machine** (BLI-3580, CLI 0.2.50):
|
|
295
|
-
* it walked up from `process.argv[1]`, resolved with `path.resolve` and no
|
|
296
|
-
* symlink following. For a global install `argv[1]` is the SHIM — on Edward's
|
|
297
|
-
* Mac `/opt/homebrew/bin/cockpit`, a symlink into
|
|
298
|
-
* `/opt/homebrew/lib/node_modules/@bli-cockpit/cli/dist/cli.js`. The walk
|
|
299
|
-
* therefore started in `/opt/homebrew/bin`, went up through `/opt` to `/`, and
|
|
300
|
-
* never came within reach of the nested `.bin` that was sitting right there.
|
|
301
|
-
* Every machine reported `skipped bin_missing` with the server installed.
|
|
302
|
-
*
|
|
303
|
-
* Two changes, both in `besideAnchors` / `resolveBesideCli`:
|
|
304
|
-
*
|
|
305
|
-
* - The anchor is **this module's own file** first (`import.meta.url`), which
|
|
306
|
-
* is inside the installed package by construction and is never a shim. The
|
|
307
|
-
* entry point (injected, or `argv[1]`) stays as a second anchor for a build
|
|
308
|
-
* layout where this module has been bundled somewhere else.
|
|
309
|
-
* - Every anchor is **realpath-ed** before the walk, so a symlinked entry
|
|
310
|
-
* lands in the real tree. A path that will not resolve is used as given —
|
|
311
|
-
* the same bounded silence `container-tag.ts` uses, because a path that
|
|
312
|
-
* cannot be realpath-ed still identifies a directory well enough to look in.
|
|
313
|
-
*/
|
|
314
|
-
export async function resolveMemoryMcpBin(options) {
|
|
315
|
-
return resolveMcpBin({ ...options, binName: MEMORY_MCP_BIN });
|
|
316
|
-
}
|
|
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) {
|
|
319
|
-
if (command.dashboardUrl)
|
|
320
|
-
return command.dashboardUrl;
|
|
321
|
-
const paths = getCollectorRuntimePaths(deps.homeDir ?? command.homeDir);
|
|
322
|
-
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
323
|
-
return config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
|
|
324
|
-
}
|
|
325
|
-
function aggregate(targets) {
|
|
326
|
-
const failed = targets.filter((target) => target.status === "failed");
|
|
327
|
-
if (failed.length > 0) {
|
|
328
|
-
return {
|
|
329
|
-
status: "failed",
|
|
330
|
-
// The first named reason, not a count: an operator needs the reason, and
|
|
331
|
-
// the per-target list beside it carries the rest.
|
|
332
|
-
reason: failed[0]?.reason ?? "unknown_failure",
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
if (targets.some((target) => target.status === "would_install")) {
|
|
336
|
-
return { status: "would_install", reason: "dry_run" };
|
|
337
|
-
}
|
|
338
|
-
if (targets.some((target) => target.status === "installed")) {
|
|
339
|
-
return { status: "installed", reason: "wrote_entry" };
|
|
340
|
-
}
|
|
341
|
-
if (targets.some((target) => target.status === "mismatch" || target.status === "missing")) {
|
|
342
|
-
return { status: "missing", reason: "entry_absent" };
|
|
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
|
-
}
|
|
355
|
-
const skipped = targets.find((target) => target.status === "skipped");
|
|
356
|
-
if (skipped) {
|
|
357
|
-
// Not a failure and not a success: nothing was written, on purpose, and
|
|
358
|
-
// the next daily run will try again. `bin_missing` is the only one today.
|
|
359
|
-
return { status: "skipped", reason: skipped.reason };
|
|
360
|
-
}
|
|
361
|
-
return { status: "already", reason: "already_current" };
|
|
362
|
-
}
|
|
363
|
-
/**
|
|
364
|
-
* Both branches log, and neither carries a path — a home directory names a
|
|
365
|
-
* person, and this line ends up in `sync.err.log` on every machine.
|
|
366
|
-
*/
|
|
367
|
-
function logMemoryOutcome(outcome, platform) {
|
|
368
|
-
const fields = {
|
|
369
|
-
status: outcome.status,
|
|
370
|
-
reason: outcome.reason,
|
|
371
|
-
config_source: outcome.config_source,
|
|
372
|
-
bin_found: outcome.bin_found,
|
|
373
|
-
platform,
|
|
374
|
-
installed_count: outcome.targets.filter((target) => target.status === "installed").length,
|
|
375
|
-
already_count: outcome.targets.filter((target) => target.status === "already").length,
|
|
376
|
-
failed: outcome.targets
|
|
377
|
-
.filter((target) => target.status === "failed")
|
|
378
|
-
.map((target) => `${target.target}:${target.reason}`),
|
|
379
222
|
};
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
export function memoryOutcomeLines(outcome) {
|
|
387
|
-
const headline = outcome.status === "installed"
|
|
388
|
-
? "BLI Memory registered on this machine."
|
|
389
|
-
: outcome.status === "already"
|
|
390
|
-
? "BLI Memory is already registered on this machine."
|
|
391
|
-
: outcome.status === "would_install"
|
|
392
|
-
? "BLI Memory would be registered (dry run; nothing was written)."
|
|
393
|
-
: outcome.status === "missing"
|
|
394
|
-
? "BLI Memory is not registered on this machine."
|
|
395
|
-
: outcome.status === "skipped"
|
|
396
|
-
? "BLI Memory was not registered and nothing was written: the bli-memory-mcp server is not on this machine yet."
|
|
397
|
-
: `BLI Memory is not fully registered: ${outcome.reason}.`;
|
|
398
|
-
const lines = [headline];
|
|
399
|
-
for (const target of outcome.targets) {
|
|
400
|
-
const where = target.path ? ` ${target.path}` : "";
|
|
401
|
-
const detail = target.detail ? ` — ${target.detail}` : "";
|
|
402
|
-
lines.push(` ${target.target}: ${target.status} (${target.reason})${where}${detail}`);
|
|
403
|
-
}
|
|
404
|
-
return lines;
|
|
223
|
+
// `status` is a pure read and its reading is exactly as good as an install's
|
|
224
|
+
// — both re-parse the stored files — so it refreshes the cache too. A person
|
|
225
|
+
// who runs `cockpit memory status` has just made the fleet board fresher.
|
|
226
|
+
await cacheReceipt(homeDir ?? command.homeDir, outcome.receipt);
|
|
227
|
+
return outcome;
|
|
405
228
|
}
|
package/dist/commands/msg.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `cockpit msg` — channels and messages, typed (BLI-3706).
|
|
2
|
+
* `cockpit msg` — channels and messages, typed (BLI-3706, BLI-3749).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Six verbs over `/api/msg/**` (BLI-3654 Wave 1a), which already accepts the
|
|
5
5
|
* collector device token through `resolveCaller({ allowDeviceToken: true })`
|
|
6
6
|
* on every route — no server-side door change was needed for this terminal.
|
|
7
7
|
*
|
|
8
8
|
* `<channel>` is a channel id, or its name with or without a leading `#`,
|
|
9
9
|
* resolved locally against `GET /api/msg/channels` — exact match only, never
|
|
10
10
|
* fuzzy, same discipline `docs.ts` uses for a slug.
|
|
11
|
+
*
|
|
12
|
+
* `create` and `dm` (BLI-3749) name PEOPLE by email and never by uuid: the
|
|
13
|
+
* door resolves each address exactly (`lib/msg/people.ts`), so the terminal
|
|
14
|
+
* carries no directory of its own and a typo is refused by name rather than
|
|
15
|
+
* silently adding a stranger. `dm` does not have to name the caller — the
|
|
16
|
+
* door unions them in.
|
|
11
17
|
*/
|
|
12
18
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
13
19
|
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
@@ -26,6 +32,10 @@ export async function runMsg(command, io) {
|
|
|
26
32
|
return sendMessage(command, door);
|
|
27
33
|
case "thread":
|
|
28
34
|
return readThread(command, door);
|
|
35
|
+
case "create":
|
|
36
|
+
return createChannel(command, door);
|
|
37
|
+
case "dm":
|
|
38
|
+
return openDm(command, door);
|
|
29
39
|
}
|
|
30
40
|
}
|
|
31
41
|
async function fetchChannels(door) {
|
|
@@ -185,4 +195,77 @@ async function sendMessage(command, door) {
|
|
|
185
195
|
return emitAgentDoor(door, { ok: true, message });
|
|
186
196
|
writeLine(door.io.stdout, `Sent to ${ref} (${message?.id ?? "?"}).`);
|
|
187
197
|
return 0;
|
|
198
|
+
}
|
|
199
|
+
async function createChannel(command, door) {
|
|
200
|
+
const name = command.channelName ?? "";
|
|
201
|
+
if (name === "")
|
|
202
|
+
return failAgentDoor(door, TAG, "invalid_body", "msg create needs a channel name.");
|
|
203
|
+
const answer = await askAgentDoor(door, {
|
|
204
|
+
path: "/api/msg/channels",
|
|
205
|
+
method: "POST",
|
|
206
|
+
label: "msg create",
|
|
207
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
208
|
+
body: {
|
|
209
|
+
name,
|
|
210
|
+
is_private: command.isPrivate,
|
|
211
|
+
...(command.description ? { description: command.description } : {}),
|
|
212
|
+
...(command.memberEmails ? { member_emails: command.memberEmails } : {}),
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
if (!answer.ok)
|
|
216
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
217
|
+
const body = answer.body;
|
|
218
|
+
const channel = body.channel ?? null;
|
|
219
|
+
const membersAdded = body.members_added ?? [];
|
|
220
|
+
const membersFailed = body.members_failed ?? [];
|
|
221
|
+
const requestedMembers = command.memberEmails?.length ?? 0;
|
|
222
|
+
writeLine(door.io.stderr, `${TAG} created ${JSON.stringify({
|
|
223
|
+
channel_id: channel?.id ?? null,
|
|
224
|
+
is_private: command.isPrivate,
|
|
225
|
+
members_requested: requestedMembers,
|
|
226
|
+
members_added: membersAdded.length,
|
|
227
|
+
members_failed: membersFailed.length,
|
|
228
|
+
})}`);
|
|
229
|
+
if (door.json) {
|
|
230
|
+
return emitAgentDoor(door, { ok: true, channel, membersAdded, membersFailed });
|
|
231
|
+
}
|
|
232
|
+
writeLine(door.io.stdout, `Created #${channel?.name ?? name} (${channel?.id ?? "?"}).`);
|
|
233
|
+
if (membersAdded.length > 0) {
|
|
234
|
+
writeLine(door.io.stdout, `${membersAdded.length} member(s) added.`);
|
|
235
|
+
}
|
|
236
|
+
// A CLI newer than the deployment is a normal fleet state, and a Tower that
|
|
237
|
+
// predates BLI-3749 STRIPS `member_emails` rather than refusing it — the
|
|
238
|
+
// create succeeds and the members quietly never happen. Say so; a silent
|
|
239
|
+
// success is the failure mode this whole ticket exists to end.
|
|
240
|
+
if (requestedMembers > 0 && membersAdded.length === 0 && membersFailed.length === 0) {
|
|
241
|
+
writeLine(door.io.stdout, `Tower reported no members added, though ${requestedMembers} were asked for. It may be older than this CLI — add them from the channel, or retry after the next deploy.`);
|
|
242
|
+
}
|
|
243
|
+
// A member the channel could not take is said out loud, not left to a log:
|
|
244
|
+
// the channel exists, so this is the only place a person would learn it.
|
|
245
|
+
for (const failure of membersFailed) {
|
|
246
|
+
writeLine(door.io.stdout, `Not added (${failure.reason}): ${failure.userId}`);
|
|
247
|
+
}
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
async function openDm(command, door) {
|
|
251
|
+
const email = command.dmEmail ?? "";
|
|
252
|
+
if (email === "")
|
|
253
|
+
return failAgentDoor(door, TAG, "invalid_body", "msg dm needs an email address.");
|
|
254
|
+
const answer = await askAgentDoor(door, {
|
|
255
|
+
path: "/api/msg/channels",
|
|
256
|
+
method: "POST",
|
|
257
|
+
label: "msg dm",
|
|
258
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
259
|
+
body: { dm_participant_emails: [email] },
|
|
260
|
+
});
|
|
261
|
+
if (!answer.ok)
|
|
262
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
263
|
+
const channel = answer.body.channel ?? null;
|
|
264
|
+
writeLine(door.io.stderr, `${TAG} dm resolved ${JSON.stringify({ channel_id: channel?.id ?? null })}`);
|
|
265
|
+
if (door.json)
|
|
266
|
+
return emitAgentDoor(door, { ok: true, channel });
|
|
267
|
+
// Idempotent by contract (`get_or_create_dm`), so the wording says what is
|
|
268
|
+
// true either way rather than claiming a creation that may not have happened.
|
|
269
|
+
writeLine(door.io.stdout, `Direct message with ${email}: ${channel?.id ?? "?"}`);
|
|
270
|
+
return 0;
|
|
188
271
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit notes` plumbing: one HTTP request through the paired session, and
|
|
3
|
+
* the shared answer/refusal shapes `notes-reads.ts` and `notes-writes.ts`
|
|
4
|
+
* both render. Sibling of `notes.ts`, named in its header.
|
|
5
|
+
*/
|
|
6
|
+
import { writeLine } from "./cli-io.js";
|
|
7
|
+
import { towerFailureDetail, towerRequest } from "../tower-client.js";
|
|
8
|
+
import { readResponseJson } from "../upload-http.js";
|
|
9
|
+
export const TAG = "[notes cli]";
|
|
10
|
+
/** Every read in this surface shares one timeout; every write earns its own
|
|
11
|
+
* (a paste or an upload can run far longer). */
|
|
12
|
+
export const READ_DEADLINE_MS = 60_000;
|
|
13
|
+
/**
|
|
14
|
+
* One request, and a refusal that keeps the ROUTE'S own words.
|
|
15
|
+
*
|
|
16
|
+
* `towerJsonRequest` maps a non-2xx through `responseErrorMessage`, which looks
|
|
17
|
+
* for `message` or `error` — the shape the ingest routes answer with. Every
|
|
18
|
+
* notes door answers in the browser's shape instead (`headline` plus `lines`,
|
|
19
|
+
* with a `reason` label beside them), because the same body is what the page
|
|
20
|
+
* renders. Reading it here rather than widening `responseErrorMessage` keeps one
|
|
21
|
+
* meaning per field: the sentence is written where the outcome is known, and the
|
|
22
|
+
* terminal relays it rather than inventing a second wording for the same thing.
|
|
23
|
+
*/
|
|
24
|
+
export async function ask(door, options) {
|
|
25
|
+
const result = await towerRequest({
|
|
26
|
+
dashboardUrl: door.dashboardUrl,
|
|
27
|
+
path: options.path,
|
|
28
|
+
deviceToken: door.deviceToken,
|
|
29
|
+
fetch: door.io.fetch,
|
|
30
|
+
method: options.method,
|
|
31
|
+
label: options.label,
|
|
32
|
+
timeoutMs: options.timeoutMs,
|
|
33
|
+
...(options.body === undefined ? {} : { body: options.body }),
|
|
34
|
+
log: (line) => writeLine(door.io.stderr, line),
|
|
35
|
+
});
|
|
36
|
+
if (!result.ok) {
|
|
37
|
+
const failure = result;
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
reason: failure.reason,
|
|
41
|
+
detail: towerFailureDetail(failure.reason, failure.detail),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const body = await readResponseJson(result.response);
|
|
45
|
+
if (!result.response.ok) {
|
|
46
|
+
const status = result.response.status;
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
reason: refusalReason(body) ?? `http_${status}`,
|
|
50
|
+
detail: refusalSentence(body) ?? `Tower answered ${status} and said nothing about why.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, body };
|
|
54
|
+
}
|
|
55
|
+
function refusalReason(body) {
|
|
56
|
+
if (!body || typeof body !== "object")
|
|
57
|
+
return null;
|
|
58
|
+
const record = body;
|
|
59
|
+
for (const key of ["reason", "error", "code"]) {
|
|
60
|
+
const value = record[key];
|
|
61
|
+
if (typeof value === "string" && value.trim() !== "")
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
function refusalSentence(body) {
|
|
67
|
+
if (!body || typeof body !== "object")
|
|
68
|
+
return null;
|
|
69
|
+
const record = body;
|
|
70
|
+
const headline = typeof record["headline"] === "string" ? record["headline"] : null;
|
|
71
|
+
const message = typeof record["message"] === "string" ? record["message"] : null;
|
|
72
|
+
const lines = Array.isArray(record["lines"])
|
|
73
|
+
? record["lines"].filter((line) => typeof line === "string")
|
|
74
|
+
: [];
|
|
75
|
+
const said = [headline ?? message, ...lines].filter(Boolean);
|
|
76
|
+
return said.length > 0 ? said.join(" ") : null;
|
|
77
|
+
}
|
|
78
|
+
/** One machine-readable object on stdout, and nothing else on it. */
|
|
79
|
+
export function emit(door, body, exitCode = 0) {
|
|
80
|
+
writeLine(door.io.stdout, JSON.stringify(body));
|
|
81
|
+
return exitCode;
|
|
82
|
+
}
|
|
83
|
+
export function fail(door, reason, detail) {
|
|
84
|
+
writeLine(door.io.stderr, `${TAG} refused ${JSON.stringify({ reason })}`);
|
|
85
|
+
if (door.json) {
|
|
86
|
+
writeLine(door.io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
writeLine(door.io.stderr, detail);
|
|
90
|
+
}
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The route's own words for what happened. Never rephrased here — the sentences
|
|
95
|
+
* are written where the outcome is known, and a second wording in the terminal
|
|
96
|
+
* would be a second thing to keep in step.
|
|
97
|
+
*/
|
|
98
|
+
export function sayUpload(door, body) {
|
|
99
|
+
writeLine(door.io.stdout, body.headline ?? "Tower answered without a sentence.");
|
|
100
|
+
for (const line of body.lines ?? [])
|
|
101
|
+
writeLine(door.io.stdout, line);
|
|
102
|
+
if (body.noteId)
|
|
103
|
+
writeLine(door.io.stdout, `Note id: ${body.noteId}`);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Says out loud when an answer is narrower than the one a browser would give.
|
|
107
|
+
*
|
|
108
|
+
* A degraded read is still a real answer and is never withheld — but a person
|
|
109
|
+
* who cannot see their own unshared note has to be told that is why, not left
|
|
110
|
+
* to conclude it was never stored.
|
|
111
|
+
*/
|
|
112
|
+
export function sayScope(door, body) {
|
|
113
|
+
if (!body.degradedBecause)
|
|
114
|
+
return;
|
|
115
|
+
writeLine(door.io.stderr, `${TAG} narrowed ${JSON.stringify({ scope: body.scope ?? null, reason: body.degradedBecause })}`);
|
|
116
|
+
writeLine(door.io.stderr, body.degradedNote ?? "This answer is narrower than the browser's.");
|
|
117
|
+
}
|
|
118
|
+
export function errorText(error) {
|
|
119
|
+
return error instanceof Error ? error.message : String(error);
|
|
120
|
+
}
|