@aron98/load-lock 1.4.0 → 1.6.0

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 (80) hide show
  1. package/README.md +61 -14
  2. package/dist/adapters/opencode/plugin.js +15 -1
  3. package/dist/adapters/opencode/tui.js +7 -2
  4. package/dist/cli/copies.d.ts +3 -10
  5. package/dist/cli/copies.js +32 -44
  6. package/dist/cli/hook.js +48 -4
  7. package/dist/cli/install.d.ts +2 -0
  8. package/dist/cli/install.js +68 -14
  9. package/dist/cli/main.js +108 -211
  10. package/dist/cli/service.d.ts +56 -0
  11. package/dist/cli/service.js +261 -0
  12. package/dist/cli/setup.d.ts +131 -0
  13. package/dist/cli/setup.js +942 -0
  14. package/dist/cli/update.d.ts +57 -0
  15. package/dist/cli/update.js +287 -0
  16. package/dist/core/daemon.d.ts +29 -3
  17. package/dist/core/daemon.js +111 -11
  18. package/dist/core/persist.d.ts +3 -0
  19. package/dist/core/persist.js +49 -10
  20. package/dist/core/state.d.ts +9 -0
  21. package/dist/core/state.js +41 -3
  22. package/dist/dashboard/feed.d.ts +23 -0
  23. package/dist/dashboard/feed.js +64 -2
  24. package/dist/dashboard/frame.js +2 -0
  25. package/dist/dashboard/view.d.ts +11 -0
  26. package/dist/dashboard/view.js +13 -0
  27. package/dist/mcp/server.d.ts +11 -3
  28. package/dist/mcp/server.js +38 -6
  29. package/dist/policy/text.d.ts +13 -3
  30. package/dist/policy/text.js +14 -3
  31. package/dist/shared/agent-guide.d.ts +3 -0
  32. package/dist/shared/agent-guide.js +16 -0
  33. package/dist/shared/client.js +16 -4
  34. package/dist/shared/drift.d.ts +23 -0
  35. package/dist/shared/drift.js +95 -0
  36. package/dist/shared/identity.d.ts +51 -0
  37. package/dist/shared/identity.js +116 -0
  38. package/dist/shared/installs.d.ts +39 -0
  39. package/dist/shared/installs.js +101 -0
  40. package/dist/shared/pkginfo.d.ts +25 -0
  41. package/dist/shared/pkginfo.js +70 -0
  42. package/dist/shared/protocol.d.ts +22 -0
  43. package/dist/shared/protocol.js +15 -0
  44. package/dist/shared/runtimes.d.ts +41 -0
  45. package/dist/shared/runtimes.js +81 -0
  46. package/dist/shared/version.d.ts +6 -0
  47. package/dist/shared/version.js +9 -1
  48. package/dist/test/ace-mcp.test.d.ts +1 -0
  49. package/dist/test/ace-mcp.test.js +157 -0
  50. package/dist/test/ace-opencode.test.d.ts +1 -0
  51. package/dist/test/ace-opencode.test.js +287 -0
  52. package/dist/test/check-pins.test.d.ts +1 -0
  53. package/dist/test/check-pins.test.js +108 -0
  54. package/dist/test/compat.test.d.ts +1 -0
  55. package/dist/test/compat.test.js +190 -0
  56. package/dist/test/hook.test.js +179 -1
  57. package/dist/test/identity.test.d.ts +1 -0
  58. package/dist/test/identity.test.js +155 -0
  59. package/dist/test/install.test.js +135 -1
  60. package/dist/test/launcher.test.d.ts +1 -0
  61. package/dist/test/launcher.test.js +222 -0
  62. package/dist/test/mcp.test.js +76 -7
  63. package/dist/test/plugin.test.d.ts +1 -0
  64. package/dist/test/plugin.test.js +144 -0
  65. package/dist/test/reconnect.test.d.ts +1 -0
  66. package/dist/test/reconnect.test.js +389 -0
  67. package/dist/test/runtimes.test.d.ts +1 -0
  68. package/dist/test/runtimes.test.js +282 -0
  69. package/dist/test/setup.test.d.ts +1 -0
  70. package/dist/test/setup.test.js +354 -0
  71. package/dist/test/update-command.test.d.ts +1 -0
  72. package/dist/test/update-command.test.js +679 -0
  73. package/dist/test/update.test.js +33 -1
  74. package/docs/ADAPTERS.md +3 -2
  75. package/docs/AGENT_GUIDE.md +48 -2
  76. package/docs/ARCHITECTURE.md +122 -7
  77. package/docs/COMPATIBILITY.md +156 -13
  78. package/docs/OPERATIONS.md +407 -21
  79. package/package.json +3 -2
  80. package/skills/load-lock/SKILL.md +62 -0
@@ -0,0 +1,942 @@
1
+ /**
2
+ * `load-lock setup` (issue #58): read-only inventory of every load-lock
3
+ * integration on this machine, across every harness `load-lock install`
4
+ * supports, plus the runtime copies (#52 / copies.ts).
5
+ *
6
+ * For each harness it lists what it finds — skill copies, MCP
7
+ * registrations, loose hooks, plugins, policy sections, sandbox grants —
8
+ * who wrote each entry (our installer, the harness's plugin system, ACE,
9
+ * or something else), whether an entry points at a load-lock copy that no
10
+ * longer exists, the current rung (plugin / loose / mcp / skill / none),
11
+ * and duplicates.
12
+ *
13
+ * Paths mirror src/cli/install.ts exactly. Everything here only READS;
14
+ * unreadable or unparseable files are skipped, never rewritten.
15
+ */
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { findInstalledCopies, npxCacheRoot, opencodeCacheRoot, thisCopy } from "./copies.js";
20
+ import { loadRuntimes, readDaemonInfo } from "../shared/runtimes.js";
21
+ import { resolveStateDir } from "../shared/transport.js";
22
+ import { PKG_NAME } from "../shared/pkginfo.js";
23
+ /**
24
+ * Name of the Claude Code plugin (decided on #61: the plugin is not called
25
+ * `load-lock`, so it never collides with the standalone skill). Matched as
26
+ * `<name>@<marketplace>` in `enabledPlugins`, whatever the marketplace.
27
+ */
28
+ export const CLAUDE_PLUGIN_NAME = "load-lock-plugin";
29
+ const MD_MARKER = "<!-- load-lock:begin -->";
30
+ const GOOSE_BEGIN = "# load-lock:begin (managed by `load-lock install`)";
31
+ const CODEX_TOML_TAG = "# load-lock (added by `load-lock install`)";
32
+ export const HARNESSES = [
33
+ "claude-code",
34
+ "opencode",
35
+ "codex",
36
+ "cursor",
37
+ "gemini-cli",
38
+ "copilot-cli",
39
+ "cline",
40
+ "roo-code",
41
+ "goose",
42
+ "omac",
43
+ ];
44
+ // ---------------------------------------------------------------- io helpers
45
+ function readJson(file) {
46
+ try {
47
+ return JSON.parse(fs.readFileSync(file, "utf8"));
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
53
+ function readText(file) {
54
+ try {
55
+ return fs.readFileSync(file, "utf8");
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ function isObject(v) {
62
+ return typeof v === "object" && v !== null && !Array.isArray(v);
63
+ }
64
+ /** All string leaves of a value (command / args / cmd, nested or flat). */
65
+ function strings(v, out = []) {
66
+ if (typeof v === "string")
67
+ out.push(v);
68
+ else if (Array.isArray(v))
69
+ for (const x of v)
70
+ strings(x, out);
71
+ else if (isObject(v))
72
+ for (const x of Object.values(v))
73
+ strings(x, out);
74
+ return out;
75
+ }
76
+ /** Package root of an absolute `<root>/dist/<sub>/main.js` path, or null. */
77
+ function copyOfEntry(p, sub) {
78
+ const norm = p.replace(/\\/g, "/");
79
+ if (!norm.endsWith(`/dist/${sub}/main.js`))
80
+ return null;
81
+ return path.resolve(p, "..", "..", "..");
82
+ }
83
+ // ------------------------------------------------------------ entry matchers
84
+ /**
85
+ * Classify an MCP server entry. Returns null when it is not load-lock.
86
+ * The installer's shape is `{ command: <node>, args: ["<root>/dist/mcp/main.js"] }`
87
+ * under the key `load-lock` (goose: `cmd`; opencode: a `command` array).
88
+ */
89
+ function mcpEntry(name, entry) {
90
+ const strs = strings(isObject(entry) ? { command: entry.command, cmd: entry.cmd, args: entry.args } : entry);
91
+ for (const s of strs) {
92
+ const copy = copyOfEntry(s, "mcp");
93
+ if (copy && (name === "load-lock" || /load-lock/.test(s))) {
94
+ return { owner: name === "load-lock" ? "installer" : "other", copy, stale: !fs.existsSync(s) };
95
+ }
96
+ }
97
+ const joined = strs.join(" ");
98
+ if (joined.includes(PKG_NAME) || /(^|[\s/\\])load-lock(-mcp)?(\.cmd)?(\s|$)/.test(joined) || name === "load-lock") {
99
+ return { owner: "other" };
100
+ }
101
+ return null;
102
+ }
103
+ /** Collect every `command` string in a hooks structure (nested or flat). */
104
+ function hookCommands(v, out = []) {
105
+ if (Array.isArray(v))
106
+ for (const x of v)
107
+ hookCommands(x, out);
108
+ else if (isObject(v)) {
109
+ for (const [k, x] of Object.entries(v)) {
110
+ if (k === "command" && typeof x === "string")
111
+ out.push(x);
112
+ else
113
+ hookCommands(x, out);
114
+ }
115
+ }
116
+ return out;
117
+ }
118
+ const INSTALLER_HOOK = /^(.*?) ("(?:[^"\\]|\\.)*") hook (\S+) (\S+)$/;
119
+ /**
120
+ * Classify a hook command. The installer writes
121
+ * `<node> "<root>/dist/cli/main.js" hook <harness> <sub>`; anything else
122
+ * running `load-lock hook <harness> ...` is `other`.
123
+ */
124
+ function hookCommand(cmd, harness) {
125
+ const m = INSTALLER_HOOK.exec(cmd);
126
+ if (m && m[3] === harness) {
127
+ let cli;
128
+ try {
129
+ cli = JSON.parse(m[2]);
130
+ }
131
+ catch {
132
+ cli = undefined;
133
+ }
134
+ const copy = cli ? copyOfEntry(cli, "cli") : null;
135
+ if (cli && copy)
136
+ return { owner: "installer", sub: m[4], copy, stale: !fs.existsSync(cli) };
137
+ }
138
+ const loose = new RegExp(`load-lock\\S*\\s+hook\\s+${harness}\\s+(\\S+)`).exec(cmd);
139
+ if (loose)
140
+ return { owner: "other", sub: loose[1] };
141
+ return null;
142
+ }
143
+ /**
144
+ * load-lock hook entries in one hooks file, one integration per copy (so a
145
+ * second copy's hooks show up as their own line), plus foreign ones.
146
+ */
147
+ function hooksIn(file, hooks, harness, scope) {
148
+ if (!isObject(hooks))
149
+ return [];
150
+ const groups = new Map();
151
+ for (const [event, list] of Object.entries(hooks)) {
152
+ for (const cmd of hookCommands(list)) {
153
+ const h = hookCommand(cmd, harness);
154
+ if (!h)
155
+ continue;
156
+ const key = h.owner + "\0" + (h.copy ?? "");
157
+ const g = groups.get(key) ?? { owner: h.owner, copy: h.copy, stale: h.stale, subs: [] };
158
+ g.subs.push(`${event}:${h.sub}`);
159
+ groups.set(key, g);
160
+ }
161
+ }
162
+ return [...groups.values()].map((g) => ({
163
+ kind: "hooks",
164
+ scope,
165
+ file,
166
+ detail: g.subs.join(", "),
167
+ owner: g.owner,
168
+ ...(g.copy ? { copy: g.copy, stale: g.stale } : {}),
169
+ }));
170
+ }
171
+ /** load-lock entries under `key` (mcpServers / servers / mcp) of a JSON file. */
172
+ function mcpServersIn(file, key, scope, cfg) {
173
+ const c = cfg ?? readJson(file);
174
+ if (!isObject(c) || !isObject(c[key]))
175
+ return [];
176
+ const out = [];
177
+ for (const [name, entry] of Object.entries(c[key])) {
178
+ const m = mcpEntry(name, entry);
179
+ if (m)
180
+ out.push({ kind: "mcp", scope, file, detail: `${key}.${name}`, ...m });
181
+ }
182
+ return out;
183
+ }
184
+ function policyIn(file, scope, what) {
185
+ const text = readText(file);
186
+ if (text === undefined || !text.includes(MD_MARKER))
187
+ return [];
188
+ return [{ kind: "policy", scope, file, detail: what, owner: "installer" }];
189
+ }
190
+ function ruleFile(file, scope, what) {
191
+ return fs.existsSync(file) ? [{ kind: "policy", scope, file, detail: what, owner: "installer" }] : [];
192
+ }
193
+ /** SKILL.md frontmatter `name`, if any. */
194
+ function skillName(text) {
195
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
196
+ return m ? /^name:\s*["']?([^"'\r\n]+?)["']?\s*$/m.exec(m[1])?.[1] : undefined;
197
+ }
198
+ /**
199
+ * Who owns a skill directory: `ace` when ACE stamped it (`.ace.json`),
200
+ * `installer` when it is the dir `load-lock install` writes (a real
201
+ * directory, not a symlink to someone's checkout), else `other`.
202
+ * `install` / `uninstall` only write or remove an `installer` dir (#89).
203
+ */
204
+ export function skillDirOwner(dir, installerDir) {
205
+ if (fs.existsSync(path.join(dir, ".ace.json")))
206
+ return "ace";
207
+ if (dir !== installerDir)
208
+ return "other";
209
+ try {
210
+ return fs.lstatSync(dir).isSymbolicLink() ? "other" : "installer";
211
+ }
212
+ catch {
213
+ return "installer"; // not there yet: install creates it
214
+ }
215
+ }
216
+ /**
217
+ * load-lock skills (and ACE skills-dir plugin bundles) under a skills dir:
218
+ * `<dir>/load-lock/SKILL.md`, or any `<dir>/<x>/SKILL.md` named load-lock.
219
+ */
220
+ function skillsIn(dir, scope, installerDir) {
221
+ let names;
222
+ try {
223
+ names = fs.readdirSync(dir);
224
+ }
225
+ catch {
226
+ return [];
227
+ }
228
+ const out = [];
229
+ for (const n of names.sort()) {
230
+ const d = path.join(dir, n);
231
+ const owner = skillDirOwner(d, installerDir);
232
+ const text = readText(path.join(d, "SKILL.md"));
233
+ if (text !== undefined && (n === "load-lock" || skillName(text) === "load-lock")) {
234
+ out.push({
235
+ kind: "skill",
236
+ scope,
237
+ file: d,
238
+ detail: "SKILL.md",
239
+ owner,
240
+ });
241
+ }
242
+ // ACE installs Claude plugins as skills-dir plugins (#64 Q4).
243
+ const manifest = readJson(path.join(d, ".claude-plugin", "plugin.json"));
244
+ if (isObject(manifest) && manifest.name === CLAUDE_PLUGIN_NAME) {
245
+ out.push({
246
+ kind: "plugin",
247
+ scope,
248
+ file: d,
249
+ detail: `skills-dir plugin "${CLAUDE_PLUGIN_NAME}"`,
250
+ owner: owner === "ace" ? "ace" : "other",
251
+ });
252
+ }
253
+ }
254
+ return out;
255
+ }
256
+ function configHome(c) {
257
+ const xdg = c.env.XDG_CONFIG_HOME;
258
+ return xdg && xdg.trim() ? path.resolve(xdg) : path.join(c.home, ".config");
259
+ }
260
+ function scanClaudeCode(c) {
261
+ const out = [];
262
+ const claudeJson = path.join(c.home, ".claude.json");
263
+ const cj = readJson(claudeJson);
264
+ out.push(...mcpServersIn(claudeJson, "mcpServers", "user", cj));
265
+ // local-scope servers live under projects[<path>] in ~/.claude.json
266
+ const proj = isObject(cj?.projects) ? cj.projects[c.project] : undefined;
267
+ if (isObject(proj))
268
+ out.push(...mcpServersIn(claudeJson, "mcpServers", "project", proj));
269
+ const mcpJson = path.join(c.project, ".mcp.json");
270
+ out.push(...mcpServersIn(mcpJson, "mcpServers", "project"));
271
+ // ACE's MCP install patch uses a `servers` key (#64 Q4)
272
+ out.push(...mcpServersIn(mcpJson, "servers", "project"));
273
+ const settings = [
274
+ [path.join(c.home, ".claude", "settings.json"), "user"],
275
+ [path.join(c.project, ".claude", "settings.json"), "project"],
276
+ ];
277
+ for (const [file, scope] of settings) {
278
+ const s = readJson(file);
279
+ if (!isObject(s))
280
+ continue;
281
+ out.push(...hooksIn(file, s.hooks, "claude-code", scope));
282
+ if (isObject(s.enabledPlugins)) {
283
+ for (const [key, on] of Object.entries(s.enabledPlugins)) {
284
+ if (key.startsWith(CLAUDE_PLUGIN_NAME + "@")) {
285
+ out.push({ kind: "plugin", scope, file, detail: `enabledPlugins["${key}"]`, owner: "harness", enabled: on === true });
286
+ }
287
+ }
288
+ }
289
+ }
290
+ out.push(...policyIn(path.join(c.home, ".claude", "CLAUDE.md"), "user", "CLAUDE.md section"));
291
+ const userSkills = path.join(c.home, ".claude", "skills");
292
+ out.push(...skillsIn(userSkills, "user", path.join(userSkills, "load-lock")));
293
+ out.push(...skillsIn(path.join(c.project, ".claude", "skills"), "project"));
294
+ return out;
295
+ }
296
+ function scanOpenCode(c) {
297
+ const out = [];
298
+ const files = [
299
+ [path.join(c.home, ".config", "opencode", "opencode.json"), "user"],
300
+ [path.join(c.project, "opencode.json"), "project"],
301
+ ];
302
+ const stub = path.join(c.home, ".config", "opencode", "plugins", "load-lock");
303
+ for (const [file, scope] of files) {
304
+ const cfg = readJson(file);
305
+ if (!isObject(cfg))
306
+ continue;
307
+ // the installer writes `plugins`; OpenCode's documented key is `plugin`
308
+ for (const key of ["plugin", "plugins"]) {
309
+ if (!Array.isArray(cfg[key]))
310
+ continue;
311
+ for (const p of cfg[key]) {
312
+ if (typeof p !== "string")
313
+ continue;
314
+ if (p === PKG_NAME || p.startsWith(PKG_NAME + "@")) {
315
+ const owner = p === PKG_NAME && key === "plugins" && scope === "user" ? "installer" : "other";
316
+ out.push({ kind: "plugin", scope, file, detail: `${key}: "${p}"`, owner });
317
+ }
318
+ else if (p === stub) {
319
+ out.push({ kind: "plugin", scope, file, detail: `${key}: legacy stub ${p}`, owner: "installer", stale: !fs.existsSync(p) });
320
+ }
321
+ }
322
+ }
323
+ out.push(...mcpServersIn(file, "mcp", scope, cfg));
324
+ }
325
+ if (fs.existsSync(stub))
326
+ out.push({ kind: "plugin", scope: "user", file: stub, detail: "legacy plugin stub dir", owner: "installer" });
327
+ out.push(...skillsIn(path.join(c.home, ".config", "opencode", "skills"), "user"));
328
+ out.push(...skillsIn(path.join(c.project, ".opencode", "skills"), "project"));
329
+ return out;
330
+ }
331
+ /** `[mcp_servers.<name>]` tables in a config.toml that run load-lock. */
332
+ function codexToml(file) {
333
+ const text = readText(file);
334
+ if (text === undefined)
335
+ return [];
336
+ const lines = text.split(/\r?\n/);
337
+ const out = [];
338
+ for (let i = 0; i < lines.length; i++) {
339
+ const h = /^\[mcp_servers\.("?)([^"\]]+)\1\]\s*$/.exec(lines[i]);
340
+ if (!h)
341
+ continue;
342
+ let j = i + 1;
343
+ while (j < lines.length && !/^\s*\[/.test(lines[j]))
344
+ j++;
345
+ const body = lines.slice(i + 1, j).join("\n");
346
+ const args = /^args\s*=\s*\[\s*("(?:[^"\\]|\\.)*")/m.exec(body);
347
+ const command = /^command\s*=\s*("(?:[^"\\]|\\.)*")/m.exec(body);
348
+ const parse = (s) => {
349
+ try {
350
+ return s ? JSON.parse(s) : undefined;
351
+ }
352
+ catch {
353
+ return undefined;
354
+ }
355
+ };
356
+ const m = mcpEntry(h[2], { command: parse(command?.[1]), args: [parse(args?.[1]) ?? body] });
357
+ if (!m)
358
+ continue;
359
+ const tagged = i > 0 && lines[i - 1] === CODEX_TOML_TAG;
360
+ out.push({
361
+ kind: "mcp",
362
+ scope: "user",
363
+ file,
364
+ detail: `[mcp_servers.${h[2]}]`,
365
+ ...m,
366
+ owner: tagged && m.owner === "installer" ? "installer" : "other",
367
+ });
368
+ }
369
+ return out;
370
+ }
371
+ function scanCodex(c) {
372
+ const codexHome = c.env.CODEX_HOME ?? path.join(c.home, ".codex");
373
+ const hooksFile = path.join(codexHome, "hooks.json");
374
+ return [
375
+ ...hooksIn(hooksFile, readJson(hooksFile)?.hooks, "codex", "user"),
376
+ ...codexToml(path.join(codexHome, "config.toml")),
377
+ ...policyIn(path.join(codexHome, "AGENTS.md"), "user", "AGENTS.md section"),
378
+ ...skillsIn(path.join(codexHome, "skills"), "user"),
379
+ ...skillsIn(path.join(c.project, ".codex", "skills"), "project"),
380
+ ];
381
+ }
382
+ function scanCursor(c) {
383
+ const dir = path.join(c.project, ".cursor");
384
+ const hooksFile = path.join(dir, "hooks.json");
385
+ return [
386
+ ...mcpServersIn(path.join(c.home, ".cursor", "mcp.json"), "mcpServers", "user").map((i) => ({ ...i, owner: "other" })),
387
+ ...mcpServersIn(path.join(dir, "mcp.json"), "mcpServers", "project"),
388
+ ...hooksIn(hooksFile, readJson(hooksFile)?.hooks, "cursor", "project"),
389
+ ...ruleFile(path.join(dir, "rules", "load-lock.mdc"), "project", "rule load-lock.mdc"),
390
+ ];
391
+ }
392
+ function scanGemini(c) {
393
+ return [
394
+ ...mcpServersIn(path.join(c.home, ".gemini", "settings.json"), "mcpServers", "user"),
395
+ ...policyIn(path.join(c.home, ".gemini", "GEMINI.md"), "user", "GEMINI.md section"),
396
+ ];
397
+ }
398
+ function scanCopilot(c) {
399
+ return [
400
+ ...mcpServersIn(path.join(c.home, ".copilot", "mcp-config.json"), "mcpServers", "user"),
401
+ ...policyIn(path.join(c.home, ".copilot", "copilot-instructions.md"), "user", "copilot-instructions.md section"),
402
+ ...skillsIn(path.join(c.project, ".github", "skills"), "project"),
403
+ ];
404
+ }
405
+ function scanCline(c) {
406
+ const file = c.env.CLINE_MCP_SETTINGS_PATH?.trim()
407
+ ? path.resolve(c.env.CLINE_MCP_SETTINGS_PATH)
408
+ : path.join(c.home, ".cline", "data", "settings", "cline_mcp_settings.json");
409
+ return [
410
+ ...mcpServersIn(file, "mcpServers", "user"),
411
+ ...ruleFile(path.join(c.home, ".cline", "rules", "load-lock.md"), "user", "rule load-lock.md"),
412
+ ];
413
+ }
414
+ /** Global Roo/Zoo mcp_settings.json files (same list as the installer). */
415
+ function rooGlobalMcpFiles(c) {
416
+ const appData = c.platform === "darwin" ? path.join(c.home, "Library", "Application Support") : configHome(c);
417
+ const out = [];
418
+ for (const editor of ["Code", "Code - OSS", "VSCodium"]) {
419
+ for (const ext of ["roocode.roo-cline", "RooCodeInc.roo-code", "ZooCodeOrganization.zoo-code"]) {
420
+ const f = path.join(appData, editor, "User", "globalStorage", ext, "settings", "mcp_settings.json");
421
+ if (fs.existsSync(f))
422
+ out.push(f);
423
+ }
424
+ }
425
+ return out;
426
+ }
427
+ function scanRoo(c) {
428
+ return [
429
+ ...mcpServersIn(path.join(c.project, ".roo", "mcp.json"), "mcpServers", "project"),
430
+ ...rooGlobalMcpFiles(c).flatMap((f) => mcpServersIn(f, "mcpServers", "user")),
431
+ ...ruleFile(path.join(c.home, ".roo", "rules", "load-lock.md"), "user", "rule load-lock.md"),
432
+ ];
433
+ }
434
+ function scanGoose(c) {
435
+ const out = [];
436
+ const file = path.join(configHome(c), "goose", "config.yaml");
437
+ const text = readText(file);
438
+ if (text !== undefined) {
439
+ const lines = text.split("\n");
440
+ for (let i = 0; i < lines.length; i++) {
441
+ if (!/^\s+load-lock:\s*(#.*)?$/.test(lines[i]))
442
+ continue;
443
+ const marked = i > 0 && lines[i - 1].trim() === GOOSE_BEGIN;
444
+ const args = /^\s+args:\s*\[\s*("(?:[^"\\]|\\.)*")/.exec(lines.slice(i + 1, i + 8).find((l) => /^\s+args:/.test(l)) ?? "");
445
+ let copy = null;
446
+ let stale;
447
+ try {
448
+ const a = args ? JSON.parse(args[1]) : undefined;
449
+ copy = a ? copyOfEntry(a, "mcp") : null;
450
+ if (a && copy)
451
+ stale = !fs.existsSync(a);
452
+ }
453
+ catch {
454
+ /* unparseable args — report without a copy */
455
+ }
456
+ out.push({
457
+ kind: "mcp",
458
+ scope: "user",
459
+ file,
460
+ detail: "extensions.load-lock",
461
+ owner: marked ? "installer" : "other",
462
+ ...(copy ? { copy, stale } : {}),
463
+ });
464
+ }
465
+ }
466
+ out.push(...policyIn(path.join(configHome(c), "goose", ".goosehints"), "user", ".goosehints section"));
467
+ return out;
468
+ }
469
+ function scanOmac(c) {
470
+ const file = path.join(configHome(c), "omac", "sandbox-profiles", "default.json");
471
+ const p = readJson(file);
472
+ if (!isObject(p))
473
+ return [];
474
+ const stateDir = resolveStateDir(c.platform, c.env);
475
+ const grants = [];
476
+ if (isObject(p.filesystem) && Array.isArray(p.filesystem.allow_unix_dir) && p.filesystem.allow_unix_dir.includes(stateDir)) {
477
+ grants.push("filesystem.allow_unix_dir");
478
+ }
479
+ if (isObject(p.filesystem) && Array.isArray(p.filesystem.read) && p.filesystem.read.some((r) => typeof r === "string" && r.endsWith(path.join("node_modules", PKG_NAME)))) {
480
+ grants.push("filesystem.read");
481
+ }
482
+ if (isObject(p.environment) && Array.isArray(p.environment.allow_vars) && p.environment.allow_vars.includes("LOAD_LOCK_*")) {
483
+ grants.push("environment.allow_vars");
484
+ }
485
+ return grants.length ? [{ kind: "grants", scope: "user", file, detail: grants.join(", "), owner: "installer" }] : [];
486
+ }
487
+ const SCANNERS = {
488
+ "claude-code": scanClaudeCode,
489
+ opencode: scanOpenCode,
490
+ codex: scanCodex,
491
+ cursor: scanCursor,
492
+ "gemini-cli": scanGemini,
493
+ "copilot-cli": scanCopilot,
494
+ cline: scanCline,
495
+ "roo-code": scanRoo,
496
+ goose: scanGoose,
497
+ omac: scanOmac,
498
+ };
499
+ // ------------------------------------------------------------ rung + findings
500
+ /** A plugin entry the harness will actually load (ACE skills-dir plugins are unverified, #63). */
501
+ export function activePlugin(i) {
502
+ return i.kind === "plugin" && i.owner !== "ace" && i.enabled !== false && !i.detail.includes("legacy") && i.stale !== true;
503
+ }
504
+ function rungOf(list) {
505
+ if (list.some(activePlugin))
506
+ return "plugin";
507
+ if (list.some((i) => i.kind === "hooks" || i.kind === "policy" || i.kind === "grants"))
508
+ return "loose";
509
+ if (list.some((i) => i.kind === "mcp"))
510
+ return "mcp";
511
+ if (list.some((i) => i.kind === "skill"))
512
+ return "skill";
513
+ return "none";
514
+ }
515
+ function findingsOf(h, list) {
516
+ const f = [];
517
+ const plugins = list.filter(activePlugin);
518
+ const hooks = list.filter((i) => i.kind === "hooks");
519
+ const mcps = list.filter((i) => i.kind === "mcp");
520
+ const skills = list.filter((i) => i.kind === "skill");
521
+ if (plugins.length && hooks.length) {
522
+ f.push(`loose + plugin double install: hooks in ${[...new Set(hooks.map((i) => i.file))].join(", ")} run next to the plugin's`);
523
+ }
524
+ if (plugins.length > 1)
525
+ f.push(`plugin registered ${plugins.length} times: ${plugins.map((i) => i.detail).join("; ")}`);
526
+ // A Claude Code plugin bundles an MCP server; the OpenCode plugin brings its own tools.
527
+ const toolSources = mcps.length + (plugins.length ? 1 : 0);
528
+ if (toolSources > 1) {
529
+ f.push(`MCP registered ${toolSources} times: ` +
530
+ [...(plugins.length ? [h === "opencode" ? "plugin tools" : "plugin-bundled server"] : []), ...mcps.map((i) => `${i.detail} in ${i.file}`)].join("; "));
531
+ }
532
+ const hookCopies = new Set(hooks.map((i) => i.copy ?? i.owner + i.file));
533
+ if (hookCopies.size > 1)
534
+ f.push(`hooks from ${hookCopies.size} different sources: ${hooks.map((i) => i.copy ?? i.file).join("; ")}`);
535
+ for (const i of hooks) {
536
+ const subs = i.detail.split(", ");
537
+ if (new Set(subs).size !== subs.length)
538
+ f.push(`duplicate hook entries in ${i.file}: ${i.detail}`);
539
+ }
540
+ if (skills.length > 1)
541
+ f.push(`skill installed ${skills.length} times: ${skills.map((i) => i.file).join("; ")}`);
542
+ for (const i of list) {
543
+ if (i.stale)
544
+ f.push(`stale ${i.kind} entry (${i.detail} in ${i.file}) points at a missing copy${i.copy ? ` ${i.copy}` : ""}`);
545
+ if (i.kind === "plugin" && i.enabled === false)
546
+ f.push(`plugin ${i.detail} is installed but disabled`);
547
+ if (i.kind === "plugin" && i.owner === "ace")
548
+ f.push(`ACE skills-dir plugin at ${i.file} (whether Claude Code loads it is unverified, see #63)`);
549
+ }
550
+ return f;
551
+ }
552
+ // ------------------------------------------------------------------ public
553
+ function ctxOf(opts) {
554
+ return {
555
+ home: opts.home ?? os.homedir(),
556
+ project: path.resolve(opts.project),
557
+ env: opts.env ?? process.env,
558
+ platform: opts.platform ?? process.platform,
559
+ };
560
+ }
561
+ function scanHarness(h, c) {
562
+ let integrations;
563
+ try {
564
+ integrations = SCANNERS[h](c);
565
+ }
566
+ catch {
567
+ integrations = []; // best-effort: a scanner never fails the inventory
568
+ }
569
+ return { harness: h, rung: rungOf(integrations), integrations, findings: findingsOf(h, integrations) };
570
+ }
571
+ /** Inventory of one harness only (no runtime copies). */
572
+ export function inventoryHarness(h, opts) {
573
+ return scanHarness(h, ctxOf(opts));
574
+ }
575
+ export function claudeCodeMode(h) {
576
+ const plugin = h.integrations.some(activePlugin);
577
+ const loose = h.integrations.some((i) => i.owner === "installer" && (i.kind === "mcp" || i.kind === "hooks" || i.kind === "policy"));
578
+ return plugin && loose ? "both" : plugin ? "plugin" : loose ? "loose" : "none";
579
+ }
580
+ export function inventory(opts) {
581
+ const c = ctxOf(opts);
582
+ const harnesses = HARNESSES.map((h) => scanHarness(h, c));
583
+ const stateDir = resolveStateDir(c.platform, c.env);
584
+ const copies = findInstalledCopies(c.platform, c.env);
585
+ const dinfo = readDaemonInfo(stateDir);
586
+ const findings = [];
587
+ const versions = new Set(copies.map((x) => x.version).filter((v) => v !== null));
588
+ if (versions.size > 1) {
589
+ findings.push(`multiple load-lock versions installed (${[...versions].sort().join(", ")}); the daemon moves to the newest copy that contacts it`);
590
+ }
591
+ return {
592
+ home: c.home,
593
+ project: c.project,
594
+ harnesses,
595
+ runtime: {
596
+ copies,
597
+ daemon: dinfo?.packageRoot ? { path: dinfo.packageRoot, version: dinfo.version ?? null } : null,
598
+ known: loadRuntimes(stateDir),
599
+ },
600
+ findings,
601
+ };
602
+ }
603
+ const RUNG_LABEL = {
604
+ plugin: "plugin",
605
+ loose: "loose (`load-lock install`)",
606
+ mcp: "MCP only",
607
+ skill: "skill only",
608
+ none: "not installed",
609
+ };
610
+ /** Human-readable inventory. */
611
+ export function formatInventory(inv) {
612
+ const tilde = (p) => (p === inv.home || p.startsWith(inv.home + path.sep) ? "~" + p.slice(inv.home.length) : p);
613
+ const lines = [`load-lock setup: inventory (read-only), project ${tilde(inv.project)}`, ""];
614
+ const width = Math.max(...inv.harnesses.map((h) => h.harness.length));
615
+ for (const h of inv.harnesses) {
616
+ lines.push(`${h.harness.padEnd(width)} ${RUNG_LABEL[h.rung]}`);
617
+ for (const i of h.integrations) {
618
+ const via = i.copy ? ` -> ${tilde(i.copy)}${i.stale ? " (missing)" : ""}` : "";
619
+ const en = i.enabled === false ? " (disabled)" : "";
620
+ lines.push(` ${i.kind.padEnd(6)} ${i.scope.padEnd(7)} ${tilde(i.file)}: ${i.detail}${en} [${i.owner}]${via}`);
621
+ }
622
+ for (const f of h.findings)
623
+ lines.push(` ! ${f.split(inv.home).join("~")}`);
624
+ }
625
+ lines.push("", "runtime copies:");
626
+ for (const x of inv.runtime.copies)
627
+ lines.push(` ${x.source.padEnd(14)} ${x.version ?? "?"} ${tilde(x.path)}`);
628
+ if (inv.runtime.daemon)
629
+ lines.push(` daemon runs ${inv.runtime.daemon.version ?? "?"} ${tilde(inv.runtime.daemon.path)}`);
630
+ for (const k of inv.runtime.known)
631
+ lines.push(` seen by daemon ${k.version} ${tilde(k.path)}`);
632
+ for (const f of inv.findings)
633
+ lines.push(` ! ${f}`);
634
+ return lines.join("\n");
635
+ }
636
+ // ---------------------------------------------------------------- converge
637
+ //
638
+ // `load-lock setup --yes` (issue #59) converges each harness that already
639
+ // has some load-lock wiring to the recommended layout: the highest rung we
640
+ // can reach, exactly one of each. It only uses moves the installers can
641
+ // undo — `load-lock install` (merge-only) and removing entries in the exact
642
+ // shape `install` writes — so `install` / `uninstall` reverse it. Entries
643
+ // written by anyone else (the harness's plugin system, ACE, manual edits)
644
+ // are never touched: the plan prints what to do instead.
645
+ /** What `load-lock install --harness <h>` provides, per harness. */
646
+ const EXPECTED = {
647
+ "claude-code": ["mcp", "hooks", "policy", "skill"],
648
+ opencode: ["plugin"],
649
+ codex: ["mcp", "hooks", "policy"],
650
+ cursor: ["mcp", "hooks", "policy"],
651
+ "gemini-cli": ["mcp", "policy"],
652
+ "copilot-cli": ["mcp", "policy"],
653
+ cline: ["mcp", "policy"],
654
+ "roo-code": ["mcp", "policy"],
655
+ goose: ["mcp", "policy"],
656
+ omac: ["grants"],
657
+ };
658
+ /** The MCP slot `install` writes (an `other` entry there would be overwritten). */
659
+ const INSTALLER_MCP_SLOT = {
660
+ "claude-code": "mcpServers.load-lock",
661
+ codex: "[mcp_servers.load-lock]",
662
+ cursor: "mcpServers.load-lock",
663
+ "gemini-cli": "mcpServers.load-lock",
664
+ "copilot-cli": "mcpServers.load-lock",
665
+ cline: "mcpServers.load-lock",
666
+ "roo-code": "mcpServers.load-lock",
667
+ goose: "extensions.load-lock",
668
+ };
669
+ /** Harnesses whose installer writes into the project, not the user's config. */
670
+ const PROJECT_SCOPED = ["cursor", "roo-code"];
671
+ function installerOwned(list, kind) {
672
+ return list.filter((i) => i.kind === kind && i.owner === "installer");
673
+ }
674
+ function needsRefresh(h) {
675
+ const L = h.integrations;
676
+ // any working OpenCode plugin entry will do (e.g. a pinned one the user chose)
677
+ if (h.harness === "opencode")
678
+ return !L.some(activePlugin) || L.some((i) => i.detail.includes("legacy"));
679
+ if (EXPECTED[h.harness].some((k) => installerOwned(L, k).length === 0))
680
+ return true;
681
+ if (L.some((i) => i.owner === "installer" && i.stale))
682
+ return true;
683
+ const hooks = installerOwned(L, "hooks");
684
+ if (new Set(hooks.map((i) => i.copy)).size > 1)
685
+ return true;
686
+ return hooks.some((i) => new Set(i.detail.split(", ")).size !== i.detail.split(", ").length);
687
+ }
688
+ /** Manual steps for entries we will not touch. */
689
+ function foreignSteps(h, keepForeignMcp, why) {
690
+ const steps = [];
691
+ const L = h.integrations;
692
+ const mcps = L.filter((i) => i.kind === "mcp" && i.owner !== "installer");
693
+ // with a plugin or our own MCP entry in place, any other registration is a duplicate
694
+ if (!keepForeignMcp) {
695
+ for (const i of mcps)
696
+ steps.push({ harness: h.harness, action: "manual", detail: `remove ${i.detail} from ${i.file} (${why})` });
697
+ }
698
+ for (const i of L.filter((x) => x.kind === "hooks" && x.owner !== "installer")) {
699
+ steps.push({ harness: h.harness, action: "manual", detail: `remove the load-lock hooks (${i.detail}) from ${i.file} (${why})` });
700
+ }
701
+ const skills = L.filter((i) => i.kind === "skill");
702
+ if (skills.length > 1) {
703
+ steps.push({
704
+ harness: h.harness,
705
+ action: "manual",
706
+ detail: `skill installed ${skills.length} times (${skills.map((i) => i.file).join("; ")}): keep one`,
707
+ });
708
+ }
709
+ const plugins = L.filter(activePlugin);
710
+ if (plugins.length > 1) {
711
+ steps.push({
712
+ harness: h.harness,
713
+ action: "manual",
714
+ detail: h.harness === "claude-code"
715
+ ? `plugin enabled in ${plugins.length} places (${plugins.map((i) => i.file).join("; ")}): keep one with /plugin`
716
+ : `plugin listed ${plugins.length} times (${plugins.map((i) => `${i.detail} in ${i.file}`).join("; ")}): keep one`,
717
+ });
718
+ }
719
+ return steps;
720
+ }
721
+ /** The running copy, and whether it sits in the npx / opencode cache. */
722
+ export function selfCopy(platform = process.platform, env = process.env) {
723
+ const self = thisCopy()?.path ?? null;
724
+ const caches = [npxCacheRoot(platform, env), opencodeCacheRoot(platform, env)].filter((c) => !!c);
725
+ let real = self;
726
+ try {
727
+ real = self ? fs.realpathSync(self) : null;
728
+ }
729
+ catch {
730
+ /* keep the unresolved path */
731
+ }
732
+ const inside = (p, root) => !!p && !path.relative(root, p).startsWith("..") && !path.isAbsolute(path.relative(root, p));
733
+ return { selfCopy: self, selfEphemeral: caches.some((c) => inside(self, c) || inside(real, c)) };
734
+ }
735
+ export function planConverge(inv, opts) {
736
+ const steps = [];
737
+ const hints = [];
738
+ for (const h of inv.harnesses) {
739
+ const L = h.integrations;
740
+ if (h.rung === "none")
741
+ continue;
742
+ // Claude Code with its plugin active: the plugin replaces the loose wiring.
743
+ if (h.harness === "claude-code" && h.rung === "plugin") {
744
+ const loose = L.filter((i) => i.owner === "installer" && (i.kind === "mcp" || i.kind === "hooks" || i.kind === "policy"));
745
+ if (loose.length) {
746
+ steps.push({
747
+ harness: h.harness,
748
+ action: "remove-loose",
749
+ detail: `remove the loose ${[...new Set(loose.map((i) => i.kind))].join(", ")} from \`load-lock install\` ` +
750
+ "(the plugin provides them; the skill stays). Undo: load-lock install --harness claude-code --legacy",
751
+ });
752
+ }
753
+ steps.push(...foreignSteps(h, false, "the plugin bundles its own MCP server and hooks"));
754
+ for (const i of L.filter((x) => x.kind === "plugin" && x.enabled === false)) {
755
+ steps.push({ harness: h.harness, action: "manual", detail: `${i.detail} in ${i.file} is disabled: enable it with /plugin, or remove it with /plugin uninstall` });
756
+ }
757
+ continue;
758
+ }
759
+ // Everything else converges onto the installer's wiring.
760
+ const slot = INSTALLER_MCP_SLOT[h.harness];
761
+ const occupied = slot ? L.find((i) => i.kind === "mcp" && i.owner !== "installer" && i.detail === slot) : undefined;
762
+ const aceSkill = h.harness === "claude-code" ? L.find((i) => i.kind === "skill" && i.owner === "ace" && i.scope === "user" && path.basename(i.file) === "load-lock") : undefined;
763
+ const projectOnly = PROJECT_SCOPED.includes(h.harness) && !L.some((i) => i.owner === "installer" && i.scope === "project");
764
+ let installed = false;
765
+ if (needsRefresh(h) && !projectOnly) {
766
+ if (occupied) {
767
+ steps.push({
768
+ harness: h.harness,
769
+ action: "manual",
770
+ detail: `${occupied.detail} in ${occupied.file} was not written by \`load-lock install\`; replace it with \`load-lock install --harness ${h.harness}\` or keep it`,
771
+ });
772
+ }
773
+ else if (aceSkill) {
774
+ steps.push({
775
+ harness: h.harness,
776
+ action: "manual",
777
+ detail: `the skill at ${aceSkill.file} is managed by ACE (\`load-lock install --harness claude-code\` leaves it in place); run that yourself if you want the loose wiring`,
778
+ });
779
+ }
780
+ else if (opts.selfEphemeral) {
781
+ steps.push({
782
+ harness: h.harness,
783
+ action: "manual",
784
+ detail: `this load-lock runs from a cache copy (${opts.selfCopy ?? "?"}) that npm may prune; install it globally ` +
785
+ `(npm i -g ${PKG_NAME}) and re-run \`load-lock setup\` to wire ${h.harness}`,
786
+ });
787
+ }
788
+ else {
789
+ steps.push({ harness: h.harness, action: "install", detail: `load-lock install --harness ${h.harness}` });
790
+ installed = true;
791
+ }
792
+ }
793
+ const ownMcp = installed || installerOwned(L, "mcp").length > 0 || L.some(activePlugin);
794
+ steps.push(...foreignSteps(h, !ownMcp, ownMcp ? "load-lock is already registered" : ""));
795
+ }
796
+ const cc = inv.harnesses.find((h) => h.harness === "claude-code");
797
+ if (cc && cc.rung !== "none" && cc.rung !== "plugin") {
798
+ hints.push("Claude Code: the plugin adds the same wiring as one toggleable unit: " +
799
+ `/plugin marketplace add aron98/load-lock, then /plugin install ${CLAUDE_PLUGIN_NAME}@load-lock; re-run load-lock setup afterwards`);
800
+ }
801
+ const none = inv.harnesses.filter((h) => h.rung === "none").map((h) => h.harness);
802
+ if (none.length)
803
+ hints.push(`not installed: ${none.join(", ")} (load-lock install --harness <name> adds one)`);
804
+ return { steps, hints };
805
+ }
806
+ // ------------------------------------------------------------------- apply
807
+ function writeJsonFile(file, data) {
808
+ const tmp = file + ".tmp-ll-" + process.pid;
809
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
810
+ fs.renameSync(tmp, file);
811
+ }
812
+ /**
813
+ * Remove every hook entry in `file` whose command has the installer's shape
814
+ * for `harness`, whichever copy it runs. Drops emptied events / `hooks`;
815
+ * removes the file when only the installer's bare shape is left.
816
+ */
817
+ function stripInstallerHooks(file, harness) {
818
+ const cfg = readJson(file);
819
+ if (!isObject(cfg) || !isObject(cfg.hooks))
820
+ return false;
821
+ let changed = false;
822
+ for (const [ev, list] of Object.entries(cfg.hooks)) {
823
+ if (!Array.isArray(list))
824
+ continue;
825
+ const kept = list.filter((e) => !hookCommands(e).some((c) => hookCommand(c, harness)?.owner === "installer"));
826
+ if (kept.length === list.length)
827
+ continue;
828
+ changed = true;
829
+ if (kept.length)
830
+ cfg.hooks[ev] = kept;
831
+ else
832
+ delete cfg.hooks[ev];
833
+ }
834
+ if (!changed)
835
+ return false;
836
+ if (Object.keys(cfg.hooks).length === 0)
837
+ delete cfg.hooks;
838
+ const keys = Object.keys(cfg);
839
+ if (keys.length === 0 || (harness === "cursor" && keys.length === 1 && cfg.version === 1))
840
+ fs.rmSync(file);
841
+ else
842
+ writeJsonFile(file, cfg);
843
+ return true;
844
+ }
845
+ /** Remove the installer's tagged `[mcp_servers.load-lock]` block (same match as uninstall). */
846
+ function stripCodexBlock(file) {
847
+ const toml = readText(file);
848
+ const re = /\n?# load-lock \(added by `load-lock install`\)\n\[mcp_servers\.load-lock\]\n[^\n]*\n[^\n]*\n/;
849
+ if (toml === undefined || !re.test(toml))
850
+ return false;
851
+ fs.writeFileSync(file, toml.replace(re, ""));
852
+ return true;
853
+ }
854
+ /** Remove a marked section the way the installer's uninstall does. */
855
+ function removeMdSection(file) {
856
+ const text = readText(file);
857
+ if (text === undefined)
858
+ return false;
859
+ const idx = text.indexOf(MD_MARKER);
860
+ if (idx === -1)
861
+ return false;
862
+ const next = text.indexOf(MD_MARKER, idx + MD_MARKER.length);
863
+ const end = next === -1 ? text.length : next;
864
+ const start = idx > 0 && text[idx - 1] === "\n" ? idx - 1 : idx;
865
+ fs.writeFileSync(file, text.slice(0, start) + text.slice(end));
866
+ return true;
867
+ }
868
+ /**
869
+ * Drop the loose Claude Code wiring (installer-shaped entries only); keep
870
+ * the skill. Shared by `setup --yes` (`remove-loose`) and `install` when
871
+ * the plugin is enabled (#62).
872
+ */
873
+ export function removeClaudeLoose(h) {
874
+ const done = [];
875
+ for (const i of h.integrations) {
876
+ if (i.owner !== "installer")
877
+ continue;
878
+ if (i.kind === "mcp" && i.detail === "mcpServers.load-lock" && i.scope === "user") {
879
+ const cj = readJson(i.file);
880
+ if (isObject(cj?.mcpServers) && "load-lock" in cj.mcpServers) {
881
+ delete cj.mcpServers["load-lock"];
882
+ writeJsonFile(i.file, cj);
883
+ done.push(`removed mcpServers.load-lock from ${i.file}`);
884
+ }
885
+ }
886
+ else if (i.kind === "hooks") {
887
+ if (stripInstallerHooks(i.file, "claude-code"))
888
+ done.push(`removed load-lock hooks from ${i.file}`);
889
+ }
890
+ else if (i.kind === "policy") {
891
+ if (removeMdSection(i.file))
892
+ done.push(`removed the load-lock section from ${i.file}`);
893
+ }
894
+ }
895
+ return done;
896
+ }
897
+ /** Apply the non-manual steps in order; returns what was done. */
898
+ export async function applyPlan(inv, plan, opts) {
899
+ const { installCommand } = await import("./install.js");
900
+ const done = [];
901
+ for (const step of plan.steps) {
902
+ const h = inv.harnesses.find((x) => x.harness === step.harness);
903
+ if (step.action === "remove-loose") {
904
+ done.push(...removeClaudeLoose(h).map((d) => `${h.harness}: ${d}`));
905
+ }
906
+ else if (step.action === "install") {
907
+ for (const file of new Set(installerOwned(h.integrations, "hooks").map((i) => i.file))) {
908
+ if (stripInstallerHooks(file, h.harness))
909
+ done.push(`${h.harness}: removed load-lock hooks from ${file} before re-installing`);
910
+ }
911
+ for (const i of installerOwned(h.integrations, "mcp")) {
912
+ if (h.harness === "codex" && i.stale && stripCodexBlock(i.file))
913
+ done.push(`${h.harness}: removed stale [mcp_servers.load-lock] from ${i.file}`);
914
+ }
915
+ await installCommand({ harness: h.harness, project: opts.project, json: true, global: true, binDir: opts.binDir });
916
+ done.push(`${h.harness}: ran load-lock install --harness ${h.harness}`);
917
+ }
918
+ }
919
+ return done;
920
+ }
921
+ /** Human-readable plan. */
922
+ export function formatPlan(plan, applied) {
923
+ const lines = [""];
924
+ if (!plan.steps.length)
925
+ lines.push("plan: nothing to change, every installed harness has the recommended layout");
926
+ else {
927
+ lines.push(applied ? "plan (applied):" : "plan (dry run, re-run with --yes to apply):");
928
+ const width = Math.max(...plan.steps.map((s) => s.harness.length));
929
+ for (const s of plan.steps)
930
+ lines.push(` ${s.harness.padEnd(width)} ${s.action.padEnd(12)} ${s.detail}`);
931
+ }
932
+ if (applied) {
933
+ lines.push("", "done:");
934
+ for (const d of applied)
935
+ lines.push(" - " + d);
936
+ if (plan.steps.some((s) => s.action === "manual"))
937
+ lines.push(" (manual steps above were not applied)");
938
+ }
939
+ for (const h of plan.hints)
940
+ lines.push("", "hint: " + h);
941
+ return lines.join("\n");
942
+ }