@bli-cockpit/cli 0.2.49 → 0.2.51
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/adapters/raw-evidence-claude-reader.js +108 -0
- package/dist/adapters/raw-evidence-codex-reader.js +147 -0
- package/dist/adapters/raw-evidence-collection-state.js +199 -0
- package/dist/adapters/raw-evidence-facts.js +338 -0
- package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
- package/dist/adapters/raw-evidence-image-reader.js +107 -0
- package/dist/adapters/raw-evidence-sanitize.js +56 -0
- package/dist/adapters/raw-evidence-transcript-file.js +182 -0
- package/dist/adapters/raw-evidence.js +63 -1183
- package/dist/commands/backfill-batches.js +34 -0
- package/dist/commands/backfill-candidates.js +54 -0
- package/dist/commands/backfill-checkpoint.js +101 -0
- package/dist/commands/backfill-command-line.js +70 -0
- package/dist/commands/backfill-evidence-outcomes.js +104 -0
- package/dist/commands/backfill-issues.js +265 -0
- package/dist/commands/backfill-output.js +75 -0
- package/dist/commands/backfill-plan.js +71 -0
- package/dist/commands/backfill-reasons.js +107 -0
- package/dist/commands/backfill-report.js +298 -0
- package/dist/commands/backfill-result.js +150 -0
- package/dist/commands/backfill-scan.js +274 -0
- package/dist/commands/backfill-scope.js +114 -0
- package/dist/commands/backfill-session-report.js +145 -0
- package/dist/commands/backfill-types.js +1 -0
- package/dist/commands/backfill-upload.js +212 -0
- package/dist/commands/backfill.js +41 -1961
- package/dist/commands/doctor.js +57 -0
- package/dist/commands/jarvis-trace.js +184 -0
- package/dist/commands/jarvis.js +144 -4
- package/dist/commands/local-args-collector.js +26 -0
- package/dist/commands/local-args-tower.js +21 -0
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help.js +19 -2
- package/dist/commands/local.js +3 -0
- package/dist/commands/memory-install-claude.js +294 -0
- package/dist/commands/memory-install-codex.js +205 -0
- package/dist/commands/memory-install-contract.js +286 -0
- package/dist/commands/memory-install-files.js +63 -0
- package/dist/commands/memory-install-skills.js +121 -0
- package/dist/commands/memory-install-toml.js +265 -0
- package/dist/commands/memory-install.js +465 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sync-followups.js +105 -0
- package/dist/commands/sync.js +7 -1
- package/dist/local-state-attributed-target.js +75 -0
- package/dist/local-state-config.js +147 -0
- package/dist/local-state-files.js +59 -0
- package/dist/local-state-identity.js +73 -0
- package/dist/local-state-pairing.js +263 -0
- package/dist/local-state-paths.js +61 -0
- package/dist/local-state-session.js +68 -0
- package/dist/local-state-status.js +163 -0
- package/dist/local-state-work-context.js +190 -0
- package/dist/local-state.js +34 -848
- package/dist/tower-client.js +3 -2
- package/dist/tower-stream.js +57 -3
- package/package.json +2 -1
|
@@ -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
|
+
}
|