@bli-cockpit/cli 0.2.48 → 0.2.50

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.
Files changed (83) hide show
  1. package/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
  2. package/dist/adapters/raw-evidence-claude-reader.js +108 -0
  3. package/dist/adapters/raw-evidence-codex-reader.js +147 -0
  4. package/dist/adapters/raw-evidence-collection-state.js +199 -0
  5. package/dist/adapters/raw-evidence-facts.js +338 -0
  6. package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
  7. package/dist/adapters/raw-evidence-image-reader.js +107 -0
  8. package/dist/adapters/raw-evidence-sanitize.js +56 -0
  9. package/dist/adapters/raw-evidence-transcript-file.js +182 -0
  10. package/dist/adapters/raw-evidence.js +94 -1203
  11. package/dist/autostart-contract.js +79 -0
  12. package/dist/autostart-darwin-plist.js +265 -0
  13. package/dist/autostart-darwin.js +171 -0
  14. package/dist/autostart-windows-scripts.js +310 -0
  15. package/dist/autostart-windows-task-xml.js +260 -0
  16. package/dist/autostart-windows.js +237 -0
  17. package/dist/autostart-xml.js +23 -0
  18. package/dist/autostart.js +35 -1148
  19. package/dist/commands/agent-rules-command.js +55 -0
  20. package/dist/commands/agent-session-report.js +290 -0
  21. package/dist/commands/analyze.js +131 -0
  22. package/dist/commands/autostart-command.js +105 -0
  23. package/dist/commands/backfill-batches.js +34 -0
  24. package/dist/commands/backfill-candidates.js +54 -0
  25. package/dist/commands/backfill-checkpoint.js +101 -0
  26. package/dist/commands/backfill-command-line.js +70 -0
  27. package/dist/commands/backfill-evidence-outcomes.js +104 -0
  28. package/dist/commands/backfill-issues.js +265 -0
  29. package/dist/commands/backfill-output.js +75 -0
  30. package/dist/commands/backfill-plan.js +71 -0
  31. package/dist/commands/backfill-reasons.js +107 -0
  32. package/dist/commands/backfill-report.js +298 -0
  33. package/dist/commands/backfill-result.js +150 -0
  34. package/dist/commands/backfill-scan.js +274 -0
  35. package/dist/commands/backfill-scope.js +114 -0
  36. package/dist/commands/backfill-session-report.js +145 -0
  37. package/dist/commands/backfill-types.js +1 -0
  38. package/dist/commands/backfill-upload.js +212 -0
  39. package/dist/commands/backfill.js +58 -1705
  40. package/dist/commands/cli-io.js +13 -0
  41. package/dist/commands/doctor.js +57 -0
  42. package/dist/commands/jarvis-trace.js +184 -0
  43. package/dist/commands/jarvis.js +323 -7
  44. package/dist/commands/local-arg-values.js +169 -0
  45. package/dist/commands/local-args-collector.js +604 -0
  46. package/dist/commands/local-args-tower.js +891 -0
  47. package/dist/commands/local-args.js +10 -1549
  48. package/dist/commands/local-help.js +30 -5
  49. package/dist/commands/local.js +21 -1786
  50. package/dist/commands/login.js +53 -0
  51. package/dist/commands/logout.js +66 -0
  52. package/dist/commands/memory-install-claude.js +294 -0
  53. package/dist/commands/memory-install-codex.js +205 -0
  54. package/dist/commands/memory-install-contract.js +231 -0
  55. package/dist/commands/memory-install-files.js +63 -0
  56. package/dist/commands/memory-install-skills.js +121 -0
  57. package/dist/commands/memory-install-toml.js +265 -0
  58. package/dist/commands/memory-install.js +378 -0
  59. package/dist/commands/onboard-receipts.js +66 -0
  60. package/dist/commands/onboard-report.js +274 -0
  61. package/dist/commands/onboard.js +449 -0
  62. package/dist/commands/ops-render.js +36 -0
  63. package/dist/commands/public-root.js +1 -1
  64. package/dist/commands/serve.js +13 -0
  65. package/dist/commands/session-sync.js +513 -534
  66. package/dist/commands/settings-render.js +28 -0
  67. package/dist/commands/settings.js +66 -2
  68. package/dist/commands/start.js +47 -0
  69. package/dist/commands/sync-followups.js +308 -0
  70. package/dist/commands/sync.js +387 -0
  71. package/dist/local-state-attributed-target.js +75 -0
  72. package/dist/local-state-config.js +147 -0
  73. package/dist/local-state-files.js +59 -0
  74. package/dist/local-state-identity.js +73 -0
  75. package/dist/local-state-pairing.js +263 -0
  76. package/dist/local-state-paths.js +61 -0
  77. package/dist/local-state-session.js +68 -0
  78. package/dist/local-state-status.js +163 -0
  79. package/dist/local-state-work-context.js +190 -0
  80. package/dist/local-state.js +34 -848
  81. package/dist/tower-client.js +3 -2
  82. package/dist/tower-stream.js +76 -6
  83. package/package.json +2 -1
@@ -0,0 +1,53 @@
1
+ import { writeLine } from "./cli-io.js";
2
+ import { pairLocalCollectorWithAuthFallback, requestPairingAccessToken, resolveInteractiveLoginEmail, } from "./local-auth.js";
3
+ import { ensureLocalCollectorConfig } from "../local-state.js";
4
+ export async function runLogin(command, io) {
5
+ // Standalone `cockpit login` must work on a fresh machine: bootstrap a
6
+ // minimal rootless config when onboard/install has not run yet, instead of
7
+ // failing on the missing config file.
8
+ const { config, paths, created } = await ensureLocalCollectorConfig({
9
+ homeDir: command.homeDir,
10
+ dashboardUrl: command.dashboardUrl,
11
+ });
12
+ const dashboardUrl = command.dashboardUrl ?? config.dashboard_url;
13
+ if (created && !command.json) {
14
+ writeLine(io.stdout, `First login on this machine; wrote collector config: ${paths.config_file}`);
15
+ }
16
+ const claimedOwnerEmail = await resolveInteractiveLoginEmail(command, io);
17
+ const pairingAccessToken = await requestPairingAccessToken({
18
+ dashboardUrl,
19
+ email: claimedOwnerEmail,
20
+ noAuth: command.noAuth,
21
+ json: command.json,
22
+ }, io);
23
+ const result = await pairLocalCollectorWithAuthFallback({
24
+ homeDir: command.homeDir,
25
+ dashboardUrl,
26
+ claimedOwnerEmail,
27
+ deviceName: command.deviceName,
28
+ pollIntervalMs: command.pollIntervalMs,
29
+ timeoutMs: command.timeoutMs,
30
+ pairingAccessToken,
31
+ fetch: io.fetch,
32
+ onPairStarted: command.json
33
+ ? undefined
34
+ : (request) => {
35
+ writeLine(io.stdout, "Tower device pairing started.");
36
+ writeLine(io.stdout, `Open: ${request.approve_url}`);
37
+ writeLine(io.stdout, `Code: ${request.user_code}`);
38
+ writeLine(io.stdout, "Waiting for dashboard approval...");
39
+ },
40
+ }, io);
41
+ if (command.json) {
42
+ writeLine(io.stdout, JSON.stringify(result, null, 2));
43
+ return 0;
44
+ }
45
+ writeLine(io.stdout, "Tower collector paired.");
46
+ writeLine(io.stdout, `Session: ${result.session_file}`);
47
+ writeLine(io.stdout, `User: ${result.session.email ?? result.session.auth_subject_id}`);
48
+ writeLine(io.stdout, `Device: ${result.session.device_name ?? result.session.device_id ?? "unknown"}`);
49
+ writeLine(io.stdout, config.default_repo_paths.length > 0
50
+ ? "Next: run `cockpit start` inside the repo."
51
+ : "Next: run `cockpit onboard` from your repo root so Tower knows which repos to collect.");
52
+ return 0;
53
+ }
@@ -0,0 +1,66 @@
1
+ import { errorMessage, writeLine } from "./cli-io.js";
2
+ import { asRecord, callTower, openTower } from "./tower-command.js";
3
+ import { getCollectorRuntimePaths, logoutLocalCollector, readLocalCollectorSessionFile, } from "../local-state.js";
4
+ /**
5
+ * `cockpit logout` removes the local session file. `--revoke` also tells Tower
6
+ * to end the device's token (BLI-3461).
7
+ *
8
+ * Those are two different facts, and the difference matters when a laptop is
9
+ * being handed back: without the revoke the machine stops collecting but the
10
+ * token it held is still one the server would honour. The revoke is attempted
11
+ * FIRST, because it needs the token the local logout is about to delete, and a
12
+ * revoke that fails does not stop the local logout — the person asked to be
13
+ * signed out of this machine, and they get that either way. The outcome of each
14
+ * half is reported separately rather than folded into one "ok".
15
+ */
16
+ export async function runLogout(command, io) {
17
+ const revoke = command.revoke ? await revokeThisDevice(command, io) : null;
18
+ const result = await logoutLocalCollector({ homeDir: command.homeDir });
19
+ writeLine(io.stderr, `[logout] done ${JSON.stringify({
20
+ local_session: result.removed ? "removed" : "absent",
21
+ revoke: revoke ? revoke.outcome : "not_requested",
22
+ })}`);
23
+ if (command.json) {
24
+ writeLine(io.stdout, JSON.stringify({ ...result, revoke: revoke ?? { outcome: "not_requested" } }, null, 2));
25
+ return revoke && revoke.outcome !== "revoked" ? 1 : 0;
26
+ }
27
+ writeLine(io.stdout, result.removed
28
+ ? "Tower collector session removed."
29
+ : "No Tower collector session found.");
30
+ if (revoke) {
31
+ writeLine(io.stdout, revoke.outcome === "revoked"
32
+ ? "This device's Tower token is revoked."
33
+ : `This device's Tower token was NOT revoked: ${revoke.detail}`);
34
+ }
35
+ return revoke && revoke.outcome !== "revoked" ? 1 : 0;
36
+ }
37
+ /**
38
+ * Ends this device's own session server-side.
39
+ *
40
+ * The route gates the device arm on `self_revoke_only`, so the device id is
41
+ * read from the paired session rather than accepted as an argument — there is
42
+ * no way to spell "revoke somebody else's laptop" here, and that is deliberate.
43
+ */
44
+ async function revokeThisDevice(command, io) {
45
+ let tower;
46
+ let deviceId;
47
+ try {
48
+ const session = await readLocalCollectorSessionFile(getCollectorRuntimePaths(command.homeDir));
49
+ deviceId = session.device_id;
50
+ tower = await openTower("logout", command, io);
51
+ }
52
+ catch (error) {
53
+ return { outcome: "no_paired_session", detail: errorMessage(error) };
54
+ }
55
+ const result = await callTower(tower, {
56
+ path: `/api/ambient/devices/${encodeURIComponent(deviceId)}/revoke`,
57
+ method: "POST",
58
+ label: "logout revoke",
59
+ body: { reason: "Signed out from the device" },
60
+ });
61
+ if (!result.ok) {
62
+ return { outcome: result.reason, detail: result.detail };
63
+ }
64
+ const body = asRecord(result.body);
65
+ return { outcome: "revoked", detail: String(asRecord(body.device).revoked_at ?? "") };
66
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The Claude Code half of `cockpit memory install` (BLI-3580).
3
+ *
4
+ * TWO files, because Claude Code reads two:
5
+ *
6
+ * ~/.claude.json user-scope MCP servers, under top-level
7
+ * `mcpServers`. This is where `claude mcp add
8
+ * --scope user` puts a server, and — per the
9
+ * BLI-3580 touchpoint survey — the ONLY place a
10
+ * non-plugin server can be registered for every
11
+ * project on the machine. The Supermemory plugin
12
+ * reached Claude Code through its own marketplace
13
+ * `.mcp.json`; we are not a plugin, so we write a
14
+ * direct user-scope entry instead.
15
+ * ~/.claude/settings.json `hooks` (the three recall/save hooks) and
16
+ * `permissions.allow` (auto-approve for the
17
+ * read-only tool, the documented equivalent of the
18
+ * vendor's PreToolUse regex trick).
19
+ *
20
+ * Three rules govern every write here:
21
+ *
22
+ * 1. **Merge, never clobber.** Both files belong to the person. Every other
23
+ * key, every other MCP server and every other hook comes out untouched, and
24
+ * a file that will not parse is a REFUSAL (`config_unreadable`), never an
25
+ * overwrite — the opposite of what `agent-rules.ts` may do to a markdown
26
+ * file, because losing a person's whole Claude configuration is a different
27
+ * order of damage from losing a rules block.
28
+ * 2. **Idempotent by identity.** Our hook entries are recognised by the bin
29
+ * name (`isMemoryHookCommand`), so a re-run replaces exactly our three and
30
+ * appends nothing.
31
+ * 3. **Read back what the platform stored** (BLI-2541). A write that returned
32
+ * without throwing is not proof. The file is re-read, re-parsed and compared
33
+ * field by field; a difference is `read_back_mismatch` and the target is
34
+ * reported failed.
35
+ */
36
+ import path from "node:path";
37
+ import { isMemoryHookCommand, } from "./memory-install-contract.js";
38
+ export function claudeMcpConfigFile(homeDir) {
39
+ return path.join(homeDir, ".claude.json");
40
+ }
41
+ export function claudeSettingsFile(homeDir) {
42
+ return path.join(homeDir, ".claude", "settings.json");
43
+ }
44
+ export async function installClaudeMemoryIntegration(options) {
45
+ return [
46
+ await applyJsonTarget({
47
+ target: "claude_mcp",
48
+ file: claudeMcpConfigFile(options.homeDir),
49
+ options,
50
+ apply: (root) => applyMcpServer(root, options.config),
51
+ matches: (root) => mcpServerMatches(root, options.config),
52
+ }),
53
+ await applyJsonTarget({
54
+ target: "claude_hooks",
55
+ file: claudeSettingsFile(options.homeDir),
56
+ options,
57
+ apply: (root) => applyHooksAndPermissions(root, options.config),
58
+ matches: (root) => hooksAndPermissionsMatch(root, options.config),
59
+ }),
60
+ ];
61
+ }
62
+ export async function inspectClaudeMemoryIntegration(options) {
63
+ return [
64
+ await inspectJsonTarget({
65
+ target: "claude_mcp",
66
+ file: claudeMcpConfigFile(options.homeDir),
67
+ io: options.io,
68
+ present: (root) => readMcpServer(root, options.config.server_id) !== null,
69
+ matches: (root) => mcpServerMatches(root, options.config),
70
+ }),
71
+ await inspectJsonTarget({
72
+ target: "claude_hooks",
73
+ file: claudeSettingsFile(options.homeDir),
74
+ io: options.io,
75
+ present: (root) => ourHookCount(root) > 0,
76
+ matches: (root) => hooksAndPermissionsMatch(root, options.config),
77
+ }),
78
+ ];
79
+ }
80
+ async function applyJsonTarget(input) {
81
+ const { file, options } = input;
82
+ const raw = await input.options.io.readText(file);
83
+ let root;
84
+ if (raw === null || !raw.trim()) {
85
+ root = {};
86
+ }
87
+ else {
88
+ const parsed = parseJsonRecord(raw);
89
+ if (!parsed) {
90
+ // Refuse rather than replace. A `~/.claude.json` that will not parse
91
+ // still holds the person's project history and their other servers.
92
+ return failure(input.target, file, "config_unreadable", "the file is not valid JSON; fix it and re-run");
93
+ }
94
+ root = parsed;
95
+ }
96
+ if (input.matches(root)) {
97
+ return { target: input.target, status: "already", reason: "already_current", path: file };
98
+ }
99
+ const next = input.apply(structuredClone(root));
100
+ const serialized = serializeJson(next, raw ?? "");
101
+ if (options.dryRun) {
102
+ return {
103
+ target: input.target,
104
+ status: "would_install",
105
+ reason: "dry_run",
106
+ path: file,
107
+ };
108
+ }
109
+ try {
110
+ await options.io.writeText(file, serialized);
111
+ }
112
+ catch (error) {
113
+ return failure(input.target, file, "write_failed", error instanceof Error ? error.message : String(error));
114
+ }
115
+ // BLI-2541: what the platform stored, not what we sent it.
116
+ const storedRaw = await options.io.readText(file);
117
+ const stored = storedRaw === null ? null : parseJsonRecord(storedRaw);
118
+ if (!stored || !input.matches(stored)) {
119
+ return failure(input.target, file, "read_back_mismatch", "the file on disk does not contain the entry that was just written");
120
+ }
121
+ return { target: input.target, status: "installed", reason: "wrote_entry", path: file };
122
+ }
123
+ async function inspectJsonTarget(input) {
124
+ const raw = await input.io.readText(input.file);
125
+ if (raw === null || !raw.trim()) {
126
+ return { target: input.target, status: "missing", reason: "file_absent", path: input.file };
127
+ }
128
+ const root = parseJsonRecord(raw);
129
+ if (!root) {
130
+ return failure(input.target, input.file, "config_unreadable", "the file is not valid JSON");
131
+ }
132
+ if (input.matches(root)) {
133
+ return { target: input.target, status: "installed", reason: "already_current", path: input.file };
134
+ }
135
+ const status = input.present(root) ? "mismatch" : "missing";
136
+ return {
137
+ target: input.target,
138
+ status,
139
+ reason: status === "mismatch" ? "entry_differs" : "entry_absent",
140
+ path: input.file,
141
+ };
142
+ }
143
+ function applyMcpServer(root, config) {
144
+ const servers = asRecord(root["mcpServers"]) ?? {};
145
+ servers[config.server_id] = {
146
+ command: config.mcp_server.command,
147
+ args: config.mcp_server.args,
148
+ ...(Object.keys(config.mcp_server.env).length > 0
149
+ ? { env: config.mcp_server.env }
150
+ : {}),
151
+ };
152
+ root["mcpServers"] = servers;
153
+ return root;
154
+ }
155
+ function readMcpServer(root, serverId) {
156
+ const servers = asRecord(root["mcpServers"]);
157
+ if (!servers)
158
+ return null;
159
+ return asRecord(servers[serverId]);
160
+ }
161
+ function mcpServerMatches(root, config) {
162
+ const entry = readMcpServer(root, config.server_id);
163
+ if (!entry)
164
+ return false;
165
+ if (entry["command"] !== config.mcp_server.command)
166
+ return false;
167
+ if (!sameStringArray(entry["args"], config.mcp_server.args))
168
+ return false;
169
+ const env = asRecord(entry["env"]) ?? {};
170
+ for (const [key, value] of Object.entries(config.mcp_server.env)) {
171
+ if (env[key] !== value)
172
+ return false;
173
+ }
174
+ return true;
175
+ }
176
+ /**
177
+ * Claude Code's hook shape, verbatim: `hooks.<Event>` is an ARRAY of matcher
178
+ * groups, each `{ matcher?, hooks: [{ type: "command", command, timeout? }] }`.
179
+ * The matcher is omitted on purpose — all three events want every source.
180
+ */
181
+ function applyHooksAndPermissions(root, config) {
182
+ const hooks = asRecord(root["hooks"]) ?? {};
183
+ for (const spec of config.hooks) {
184
+ const groups = Array.isArray(hooks[spec.event])
185
+ ? hooks[spec.event].slice()
186
+ : [];
187
+ const survivors = groups.filter((group) => !groupIsOurs(group));
188
+ survivors.push({
189
+ hooks: [
190
+ {
191
+ type: "command",
192
+ command: spec.command,
193
+ timeout: spec.timeout_seconds,
194
+ },
195
+ ],
196
+ });
197
+ hooks[spec.event] = survivors;
198
+ }
199
+ root["hooks"] = hooks;
200
+ if (config.permissions_allow.length > 0) {
201
+ const permissions = asRecord(root["permissions"]) ?? {};
202
+ const allow = Array.isArray(permissions["allow"])
203
+ ? permissions["allow"].filter((entry) => typeof entry === "string")
204
+ : [];
205
+ for (const rule of config.permissions_allow) {
206
+ if (!allow.includes(rule))
207
+ allow.push(rule);
208
+ }
209
+ permissions["allow"] = allow;
210
+ root["permissions"] = permissions;
211
+ }
212
+ return root;
213
+ }
214
+ function hooksAndPermissionsMatch(root, config) {
215
+ const hooks = asRecord(root["hooks"]);
216
+ if (!hooks)
217
+ return false;
218
+ for (const spec of config.hooks) {
219
+ const groups = Array.isArray(hooks[spec.event]) ? hooks[spec.event] : [];
220
+ const ours = groups.filter(groupIsOurs);
221
+ if (ours.length !== 1)
222
+ return false;
223
+ const commands = hookCommands(ours[0]);
224
+ if (commands.length !== 1 || commands[0] !== spec.command)
225
+ return false;
226
+ }
227
+ const allow = asRecord(root["permissions"])?.["allow"];
228
+ const allowed = Array.isArray(allow) ? allow : [];
229
+ return config.permissions_allow.every((rule) => allowed.includes(rule));
230
+ }
231
+ function ourHookCount(root) {
232
+ const hooks = asRecord(root["hooks"]);
233
+ if (!hooks)
234
+ return 0;
235
+ let count = 0;
236
+ for (const value of Object.values(hooks)) {
237
+ if (!Array.isArray(value))
238
+ continue;
239
+ count += value.filter(groupIsOurs).length;
240
+ }
241
+ return count;
242
+ }
243
+ function groupIsOurs(group) {
244
+ return hookCommands(group).some(isMemoryHookCommand);
245
+ }
246
+ function hookCommands(group) {
247
+ const record = asRecord(group);
248
+ if (!record || !Array.isArray(record["hooks"]))
249
+ return [];
250
+ return record["hooks"]
251
+ .map((entry) => asRecord(entry)?.["command"])
252
+ .filter((command) => typeof command === "string");
253
+ }
254
+ function failure(target, file, reason, detail) {
255
+ return { target, status: "failed", reason, path: file, detail };
256
+ }
257
+ function parseJsonRecord(raw) {
258
+ try {
259
+ const parsed = JSON.parse(raw);
260
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
261
+ return null;
262
+ return parsed;
263
+ }
264
+ catch {
265
+ return null;
266
+ }
267
+ }
268
+ function asRecord(value) {
269
+ if (!value || typeof value !== "object" || Array.isArray(value))
270
+ return null;
271
+ return value;
272
+ }
273
+ function sameStringArray(value, expected) {
274
+ if (!Array.isArray(value))
275
+ return expected.length === 0;
276
+ return (value.length === expected.length &&
277
+ value.every((entry, index) => entry === expected[index]));
278
+ }
279
+ /**
280
+ * Keeps the file's own indentation. Claude Code rewrites `~/.claude.json`
281
+ * itself, so this is cosmetic — but a diff that reindents a person's whole
282
+ * configuration reads like damage even when it is not.
283
+ */
284
+ function serializeJson(root, previous) {
285
+ const indent = detectIndent(previous);
286
+ return `${JSON.stringify(root, null, indent)}\n`;
287
+ }
288
+ function detectIndent(previous) {
289
+ const match = previous.match(/\n([ \t]+)"/u);
290
+ if (!match)
291
+ return 2;
292
+ const whitespace = match[1] ?? " ";
293
+ return whitespace.startsWith("\t") ? "\t" : whitespace.length;
294
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The Codex half of `cockpit memory install` (BLI-3580).
3
+ *
4
+ * TWO targets:
5
+ *
6
+ * ~/.codex/config.toml one table, `[mcp_servers.bli-memory]`.
7
+ * ~/.codex/skills/bli-memory/ the skill that teaches Codex when to
8
+ * recall, save, update and forget — taking
9
+ * over the job the four `supermemory-*`
10
+ * skills did on Edward's machine.
11
+ *
12
+ * **`config.toml` is somebody's own configuration and is treated that way.**
13
+ * The standing rule for that file is "never change HIS settings"; this respects
14
+ * it literally by touching one table and nothing else. The write is a line-span
15
+ * swap (see `memory-install-toml.ts`), not a parse-and-reserialise, so model,
16
+ * approval policy, sandbox settings, comments and every other MCP server come
17
+ * out byte-identical — which a test asserts by comparing the bytes outside our
18
+ * span before and after.
19
+ *
20
+ * The skill files are ours end to end, so they are written whole and compared
21
+ * whole: a person who edited one gets it replaced on the next install, which is
22
+ * the same deal `cockpit agent-rules` offers for its managed block.
23
+ */
24
+ import path from "node:path";
25
+ import { MEMORY_MCP_SERVER_ID, } from "./memory-install-contract.js";
26
+ import { findTomlTableSpan, readTomlTable, renderTomlTable, upsertTomlTable, } from "./memory-install-toml.js";
27
+ import { memoryCodexSkillFiles } from "./memory-install-skills.js";
28
+ const MEMORY_TOML_PATH = ["mcp_servers", MEMORY_MCP_SERVER_ID];
29
+ export function codexConfigFile(homeDir) {
30
+ return path.join(homeDir, ".codex", "config.toml");
31
+ }
32
+ export function codexSkillDirectory(homeDir) {
33
+ return path.join(homeDir, ".codex", "skills", MEMORY_MCP_SERVER_ID);
34
+ }
35
+ export async function installCodexMemoryIntegration(options) {
36
+ return [
37
+ await applyCodexMcpTable(options),
38
+ await applyCodexSkills(options),
39
+ ];
40
+ }
41
+ export async function inspectCodexMemoryIntegration(options) {
42
+ const file = codexConfigFile(options.homeDir);
43
+ const raw = await options.io.readText(file);
44
+ const mcp = raw === null
45
+ ? { target: "codex_mcp", status: "missing", reason: "file_absent", path: file }
46
+ : tableMatches(raw, options.config)
47
+ ? { target: "codex_mcp", status: "installed", reason: "already_current", path: file }
48
+ : findTomlTableSpan(raw, MEMORY_TOML_PATH)
49
+ ? { target: "codex_mcp", status: "mismatch", reason: "entry_differs", path: file }
50
+ : { target: "codex_mcp", status: "missing", reason: "entry_absent", path: file };
51
+ const directory = codexSkillDirectory(options.homeDir);
52
+ const files = memoryCodexSkillFiles();
53
+ let present = 0;
54
+ let current = 0;
55
+ for (const [relative, contents] of Object.entries(files)) {
56
+ const stored = await options.io.readText(path.join(directory, relative));
57
+ if (stored === null)
58
+ continue;
59
+ present += 1;
60
+ if (stored === contents)
61
+ current += 1;
62
+ }
63
+ const total = Object.keys(files).length;
64
+ const skills = current === total
65
+ ? { target: "codex_skills", status: "installed", reason: "already_current", path: directory }
66
+ : present === 0
67
+ ? { target: "codex_skills", status: "missing", reason: "entry_absent", path: directory }
68
+ : {
69
+ target: "codex_skills",
70
+ status: "mismatch",
71
+ reason: "entry_differs",
72
+ path: directory,
73
+ detail: `${current} of ${total} skill files current`,
74
+ };
75
+ return [mcp, skills];
76
+ }
77
+ async function applyCodexMcpTable(options) {
78
+ const file = codexConfigFile(options.homeDir);
79
+ const raw = (await options.io.readText(file)) ?? "";
80
+ if (tableMatches(raw, options.config)) {
81
+ return { target: "codex_mcp", status: "already", reason: "already_current", path: file };
82
+ }
83
+ const next = upsertTomlTable(raw, MEMORY_TOML_PATH, renderMemoryTable(options.config));
84
+ if (options.dryRun) {
85
+ return { target: "codex_mcp", status: "would_install", reason: "dry_run", path: file };
86
+ }
87
+ try {
88
+ await options.io.writeText(file, next);
89
+ }
90
+ catch (error) {
91
+ return {
92
+ target: "codex_mcp",
93
+ status: "failed",
94
+ reason: "write_failed",
95
+ path: file,
96
+ detail: error instanceof Error ? error.message : String(error),
97
+ };
98
+ }
99
+ // BLI-2541: parse the table back out of the stored bytes the way Codex will
100
+ // read it. A successful write is not a correct registration.
101
+ const stored = await options.io.readText(file);
102
+ if (stored === null || !tableMatches(stored, options.config)) {
103
+ return {
104
+ target: "codex_mcp",
105
+ status: "failed",
106
+ reason: "read_back_mismatch",
107
+ path: file,
108
+ detail: "the table on disk does not parse back to the entry that was written",
109
+ };
110
+ }
111
+ return { target: "codex_mcp", status: "installed", reason: "wrote_entry", path: file };
112
+ }
113
+ async function applyCodexSkills(options) {
114
+ const directory = codexSkillDirectory(options.homeDir);
115
+ const files = memoryCodexSkillFiles();
116
+ const stale = [];
117
+ for (const [relative, contents] of Object.entries(files)) {
118
+ const stored = await options.io.readText(path.join(directory, relative));
119
+ if (stored !== contents)
120
+ stale.push(relative);
121
+ }
122
+ if (stale.length === 0) {
123
+ return {
124
+ target: "codex_skills",
125
+ status: "already",
126
+ reason: "already_current",
127
+ path: directory,
128
+ };
129
+ }
130
+ if (options.dryRun) {
131
+ return {
132
+ target: "codex_skills",
133
+ status: "would_install",
134
+ reason: "dry_run",
135
+ path: directory,
136
+ detail: `${stale.length} of ${Object.keys(files).length} skill files would be written`,
137
+ };
138
+ }
139
+ for (const relative of stale) {
140
+ const target = path.join(directory, relative);
141
+ try {
142
+ await options.io.writeText(target, files[relative]);
143
+ }
144
+ catch (error) {
145
+ return {
146
+ target: "codex_skills",
147
+ status: "failed",
148
+ reason: "write_failed",
149
+ path: directory,
150
+ detail: error instanceof Error ? error.message : String(error),
151
+ };
152
+ }
153
+ }
154
+ for (const relative of stale) {
155
+ const stored = await options.io.readText(path.join(directory, relative));
156
+ if (stored !== files[relative]) {
157
+ return {
158
+ target: "codex_skills",
159
+ status: "failed",
160
+ reason: "read_back_mismatch",
161
+ path: directory,
162
+ detail: `${relative} does not read back as written`,
163
+ };
164
+ }
165
+ }
166
+ return {
167
+ target: "codex_skills",
168
+ status: "installed",
169
+ reason: "wrote_entry",
170
+ path: directory,
171
+ detail: `${stale.length} skill file(s) written`,
172
+ };
173
+ }
174
+ export function renderMemoryTable(config) {
175
+ const entries = [
176
+ ["command", config.mcp_server.command],
177
+ ["args", config.mcp_server.args],
178
+ ];
179
+ if (Object.keys(config.mcp_server.env).length > 0) {
180
+ entries.push(["env", config.mcp_server.env]);
181
+ }
182
+ return renderTomlTable(MEMORY_TOML_PATH, entries);
183
+ }
184
+ function tableMatches(raw, config) {
185
+ const table = readTomlTable(raw, MEMORY_TOML_PATH);
186
+ if (!table)
187
+ return false;
188
+ if (table["command"] !== config.mcp_server.command)
189
+ return false;
190
+ const args = table["args"];
191
+ if (!Array.isArray(args))
192
+ return config.mcp_server.args.length === 0;
193
+ if (args.length !== config.mcp_server.args.length)
194
+ return false;
195
+ if (!args.every((entry, index) => entry === config.mcp_server.args[index]))
196
+ return false;
197
+ const env = table["env"];
198
+ for (const [key, value] of Object.entries(config.mcp_server.env)) {
199
+ if (!env || Array.isArray(env) || typeof env === "string")
200
+ return false;
201
+ if (env[key] !== value)
202
+ return false;
203
+ }
204
+ return true;
205
+ }