@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,354 @@
1
+ /**
2
+ * `load-lock setup` inventory (issue #58), run against a temp HOME / project /
3
+ * state dir so real user config is never read or damaged. Fixtures are
4
+ * planted with the real installers (`load-lock install --harness ...`) plus
5
+ * hand-written plugin / ACE / foreign entries.
6
+ */
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join, dirname, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { spawn } from "node:child_process";
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+ const cliMain = join(HERE, "..", "cli", "main.js");
16
+ const repoRoot = resolve(HERE, "..", "..");
17
+ const TMP_BASE = process.env.LL_TEST_TMP ?? tmpdir();
18
+ mkdirSync(TMP_BASE, { recursive: true });
19
+ function sandbox() {
20
+ const root = mkdtempSync(join(TMP_BASE, "ll-setup-"));
21
+ const home = join(root, "home");
22
+ const project = join(root, "proj");
23
+ mkdirSync(home, { recursive: true });
24
+ mkdirSync(project, { recursive: true });
25
+ const env = {};
26
+ for (const [k, v] of Object.entries(process.env)) {
27
+ if (v !== undefined && !["XDG_CONFIG_HOME", "CODEX_HOME", "CLINE_MCP_SETTINGS_PATH", "LOAD_LOCK_SOCKET"].includes(k))
28
+ env[k] = v;
29
+ }
30
+ Object.assign(env, {
31
+ HOME: home,
32
+ LOAD_LOCK_STATE_DIR: join(root, "state"),
33
+ XDG_CACHE_HOME: join(root, "cache"),
34
+ npm_config_cache: join(root, "npm-cache"),
35
+ });
36
+ return { root, home, project, env };
37
+ }
38
+ function runCli(sb, args) {
39
+ return new Promise((res, rej) => {
40
+ const child = spawn(process.execPath, [cliMain, ...args], { env: sb.env, cwd: sb.project });
41
+ let stdout = "";
42
+ let stderr = "";
43
+ child.stdout.on("data", (b) => (stdout += b));
44
+ child.stderr.on("data", (b) => (stderr += b));
45
+ child.stdin.end();
46
+ child.once("exit", (code) => res({ code, stdout, stderr }));
47
+ child.once("error", rej);
48
+ });
49
+ }
50
+ /** `setup --json` output: { inventory, plan }. */
51
+ async function setupOut(sb) {
52
+ const r = await runCli(sb, ["setup", "--json", "--project", sb.project]);
53
+ assert.equal(r.code, 0, `setup failed: ${r.stderr}`);
54
+ return JSON.parse(r.stdout);
55
+ }
56
+ async function setupJson(sb) {
57
+ return (await setupOut(sb)).inventory;
58
+ }
59
+ async function install(sb, harness, ...extra) {
60
+ const r = await runCli(sb, ["install", "--harness", harness, "--project", sb.project, ...extra]);
61
+ assert.equal(r.code, 0, `install ${harness} failed: ${r.stderr}`);
62
+ }
63
+ /** Every file under dir with its bytes (to prove setup never writes). */
64
+ function snapshot(dir, out = {}) {
65
+ for (const n of readdirSync(dir)) {
66
+ const p = join(dir, n);
67
+ if (statSync(p).isDirectory())
68
+ snapshot(p, out);
69
+ else
70
+ out[p] = readFileSync(p, "utf8");
71
+ }
72
+ return out;
73
+ }
74
+ function harness(inv, name) {
75
+ const h = inv.harnesses.find((x) => x.harness === name);
76
+ assert.ok(h, `harness ${name} missing from inventory`);
77
+ return h;
78
+ }
79
+ function writeJsonFile(file, data) {
80
+ mkdirSync(dirname(file), { recursive: true });
81
+ writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
82
+ }
83
+ const ALL = ["claude-code", "opencode", "codex", "cursor", "gemini-cli", "copilot-cli", "cline", "roo-code", "goose", "omac"];
84
+ test("setup: bare HOME lists every supported harness as not installed and writes nothing", async () => {
85
+ const sb = sandbox();
86
+ const before = snapshot(sb.root);
87
+ const inv = await setupJson(sb);
88
+ assert.deepEqual(inv.harnesses.map((h) => h.harness), ALL);
89
+ for (const h of inv.harnesses) {
90
+ assert.equal(h.rung, "none", `${h.harness} rung`);
91
+ assert.deepEqual(h.integrations, []);
92
+ assert.deepEqual(h.findings, []);
93
+ }
94
+ assert.ok(inv.runtime.copies.some((c) => c.source === "cli" && c.path === repoRoot), "lists the running copy");
95
+ const human = await runCli(sb, ["setup", "--project", sb.project]);
96
+ assert.equal(human.code, 0);
97
+ assert.match(human.stdout, /inventory \(read-only\)/);
98
+ assert.match(human.stdout, /goose\s+not installed/);
99
+ assert.deepEqual(snapshot(sb.root), before, "setup must not write anything");
100
+ rmSync(sb.root, { recursive: true, force: true });
101
+ });
102
+ test("setup: each installer's wiring is inventoried with its rung, owner and copy; setup stays read-only", async () => {
103
+ const sb = sandbox();
104
+ for (const h of ALL)
105
+ await install(sb, h);
106
+ const before = snapshot(sb.root);
107
+ const inv = await setupJson(sb);
108
+ const expect = {
109
+ "claude-code": { rung: "loose", kinds: ["hooks", "mcp", "policy", "skill"] },
110
+ opencode: { rung: "plugin", kinds: ["plugin"] },
111
+ codex: { rung: "loose", kinds: ["hooks", "mcp", "policy"] },
112
+ cursor: { rung: "loose", kinds: ["hooks", "mcp", "policy"] },
113
+ "gemini-cli": { rung: "loose", kinds: ["mcp", "policy"] },
114
+ "copilot-cli": { rung: "loose", kinds: ["mcp", "policy"] },
115
+ cline: { rung: "loose", kinds: ["mcp", "policy"] },
116
+ "roo-code": { rung: "loose", kinds: ["mcp", "policy"] },
117
+ goose: { rung: "loose", kinds: ["mcp", "policy"] },
118
+ omac: { rung: "loose", kinds: ["grants"] },
119
+ };
120
+ for (const [name, e] of Object.entries(expect)) {
121
+ const h = harness(inv, name);
122
+ assert.equal(h.rung, e.rung, `${name} rung`);
123
+ assert.deepEqual([...new Set(h.integrations.map((i) => i.kind))].sort(), e.kinds, `${name} kinds`);
124
+ for (const i of h.integrations) {
125
+ assert.equal(i.owner, "installer", `${name} ${i.kind} owner`);
126
+ if (i.copy !== undefined) {
127
+ assert.equal(i.copy, repoRoot, `${name} ${i.kind} points at this copy`);
128
+ assert.equal(i.stale, false);
129
+ }
130
+ }
131
+ assert.deepEqual(h.findings, [], `${name}: a clean install has no findings`);
132
+ }
133
+ const cc = harness(inv, "claude-code");
134
+ assert.deepEqual(cc.integrations.find((i) => i.kind === "hooks").detail.split(", ").sort(), ["SessionStart:await-offer", "SessionStart:session-start", "Stop:stop"]);
135
+ assert.deepEqual(snapshot(sb.root), before, "setup must not write anything");
136
+ rmSync(sb.root, { recursive: true, force: true });
137
+ });
138
+ test("setup: flags loose + plugin double install and MCP registered more than once (Claude Code)", async () => {
139
+ const sb = sandbox();
140
+ await install(sb, "claude-code");
141
+ const settingsFile = join(sb.home, ".claude", "settings.json");
142
+ const s = JSON.parse(readFileSync(settingsFile, "utf8"));
143
+ s.enabledPlugins = { "load-lock-plugin@load-lock": true, "other@market": true };
144
+ writeJsonFile(settingsFile, s);
145
+ // an ACE-style project MCP patch (`servers` key, pinned npx)
146
+ writeJsonFile(join(sb.project, ".mcp.json"), {
147
+ servers: { "load-lock": { command: "npx", args: ["-y", "@aron98/load-lock@1.5.0", "mcp"] } },
148
+ });
149
+ const cc = harness(await setupJson(sb), "claude-code");
150
+ assert.equal(cc.rung, "plugin");
151
+ const plugin = cc.integrations.find((i) => i.kind === "plugin");
152
+ assert.equal(plugin.detail, 'enabledPlugins["load-lock-plugin@load-lock"]');
153
+ assert.equal(plugin.owner, "harness");
154
+ assert.equal(plugin.enabled, true);
155
+ const ace = cc.integrations.find((i) => i.kind === "mcp" && i.scope === "project");
156
+ assert.equal(ace.owner, "other");
157
+ assert.equal(ace.detail, "servers.load-lock");
158
+ assert.ok(cc.findings.some((f) => f.startsWith("loose + plugin double install")), cc.findings.join("\n"));
159
+ assert.ok(cc.findings.some((f) => f.startsWith("MCP registered 3 times")), cc.findings.join("\n"));
160
+ // a disabled plugin does not count as the active rung
161
+ s.enabledPlugins["load-lock-plugin@load-lock"] = false;
162
+ writeJsonFile(settingsFile, s);
163
+ const cc2 = harness(await setupJson(sb), "claude-code");
164
+ assert.equal(cc2.rung, "loose");
165
+ assert.ok(cc2.findings.some((f) => /installed but disabled/.test(f)));
166
+ rmSync(sb.root, { recursive: true, force: true });
167
+ });
168
+ test("setup: hooks from a second (missing) copy are flagged as stale duplicates", async () => {
169
+ const sb = sandbox();
170
+ await install(sb, "codex");
171
+ const hooksFile = join(sb.home, ".codex", "hooks.json");
172
+ const h = JSON.parse(readFileSync(hooksFile, "utf8"));
173
+ const gone = join(sb.root, "gone", "node_modules", "@aron98", "load-lock", "dist", "cli", "main.js");
174
+ h.hooks.Stop.push({ hooks: [{ type: "command", command: `/usr/bin/node ${JSON.stringify(gone)} hook codex stop` }] });
175
+ writeJsonFile(hooksFile, h);
176
+ const cx = harness(await setupJson(sb), "codex");
177
+ const hooks = cx.integrations.filter((i) => i.kind === "hooks");
178
+ assert.equal(hooks.length, 2, "one hooks line per copy");
179
+ const staleOne = hooks.find((i) => i.stale);
180
+ assert.equal(staleOne.copy, resolve(gone, "..", "..", ".."));
181
+ assert.equal(staleOne.owner, "installer");
182
+ assert.ok(cx.findings.some((f) => f.startsWith("hooks from 2 different sources")), cx.findings.join("\n"));
183
+ assert.ok(cx.findings.some((f) => f.startsWith("stale hooks entry")), cx.findings.join("\n"));
184
+ rmSync(sb.root, { recursive: true, force: true });
185
+ });
186
+ test("setup: OpenCode plugin listed twice plus an MCP entry; ACE skill and skills-dir plugin are attributed", async () => {
187
+ const sb = sandbox();
188
+ await install(sb, "opencode");
189
+ const cfgFile = join(sb.home, ".config", "opencode", "opencode.json");
190
+ const cfg = JSON.parse(readFileSync(cfgFile, "utf8"));
191
+ cfg.plugin = ["@aron98/load-lock@1.5.0"];
192
+ cfg.mcp = { "load-lock": { type: "local", command: ["npx", "-y", "@aron98/load-lock@1.5.0", "mcp"] }, other: { type: "local", command: ["x"] } };
193
+ writeJsonFile(cfgFile, cfg);
194
+ // ACE-installed skill and Claude plugin (each with an .ace.json stamp)
195
+ const skills = join(sb.home, ".claude", "skills");
196
+ mkdirSync(join(skills, "load-lock"), { recursive: true });
197
+ writeFileSync(join(skills, "load-lock", "SKILL.md"), "---\nname: load-lock\ndescription: x\n---\n# load-lock\n");
198
+ writeFileSync(join(skills, "load-lock", ".ace.json"), "{}\n");
199
+ writeJsonFile(join(skills, "load-lock-plugin", ".claude-plugin", "plugin.json"), { name: "load-lock-plugin" });
200
+ writeFileSync(join(skills, "load-lock-plugin", ".ace.json"), "{}\n");
201
+ const inv = await setupJson(sb);
202
+ const oc = harness(inv, "opencode");
203
+ assert.equal(oc.rung, "plugin");
204
+ assert.equal(oc.integrations.filter((i) => i.kind === "plugin").length, 2);
205
+ assert.equal(oc.integrations.filter((i) => i.kind === "mcp").length, 1, "only the load-lock MCP entry");
206
+ assert.ok(oc.findings.some((f) => f.startsWith("plugin registered 2 times")), oc.findings.join("\n"));
207
+ assert.ok(oc.findings.some((f) => f.startsWith("MCP registered 2 times")), oc.findings.join("\n"));
208
+ const cc = harness(inv, "claude-code");
209
+ assert.equal(cc.rung, "skill", "an ACE skills-dir plugin is not counted as an active plugin (unverified, #63)");
210
+ assert.equal(cc.integrations.find((i) => i.kind === "skill").owner, "ace");
211
+ assert.equal(cc.integrations.find((i) => i.kind === "plugin").owner, "ace");
212
+ assert.ok(cc.findings.some((f) => /ACE skills-dir plugin/.test(f)));
213
+ rmSync(sb.root, { recursive: true, force: true });
214
+ });
215
+ // ------------------------------------------------------------ converge (#59)
216
+ async function setupYes(sb) {
217
+ const r = await runCli(sb, ["setup", "--yes", "--json", "--project", sb.project]);
218
+ assert.equal(r.code, 0, `setup --yes failed: ${r.stderr}`);
219
+ return JSON.parse(r.stdout);
220
+ }
221
+ async function uninstall(sb, h) {
222
+ const r = await runCli(sb, ["uninstall", "--harness", h, "--project", sb.project]);
223
+ assert.equal(r.code, 0, `uninstall ${h} failed: ${r.stderr}`);
224
+ }
225
+ const steps = (out, h) => out.plan.steps.filter((s) => s.harness === h).map((s) => s.action);
226
+ test("setup converge: dry run is the default and writes nothing", async () => {
227
+ const sb = sandbox();
228
+ await install(sb, "codex");
229
+ const hooksFile = join(sb.home, ".codex", "hooks.json");
230
+ const h = JSON.parse(readFileSync(hooksFile, "utf8"));
231
+ h.hooks.Stop.push({ hooks: [{ type: "command", command: `/usr/bin/node ${JSON.stringify(join(sb.root, "gone", "dist", "cli", "main.js"))} hook codex stop` }] });
232
+ writeJsonFile(hooksFile, h);
233
+ const before = snapshot(sb.root);
234
+ const r = await runCli(sb, ["setup", "--project", sb.project]);
235
+ assert.equal(r.code, 0, r.stderr);
236
+ assert.match(r.stdout, /plan \(dry run, re-run with --yes to apply\)/);
237
+ assert.match(r.stdout, /codex\s+install\s+load-lock install --harness codex/);
238
+ const j = await setupOut(sb);
239
+ assert.deepEqual(steps(j, "codex"), ["install"]);
240
+ assert.deepEqual(snapshot(sb.root), before, "a dry run must not write anything");
241
+ rmSync(sb.root, { recursive: true, force: true });
242
+ });
243
+ test("setup converge: Claude Code plugin + loose install -> loose removed (skill kept); install/uninstall reverse it", async () => {
244
+ const sb = sandbox();
245
+ const claudeDir = join(sb.home, ".claude");
246
+ // the user's own config, including the plugin they enabled with /plugin
247
+ writeJsonFile(join(sb.home, ".claude.json"), { mcpServers: { fetch: { type: "stdio", command: "uvx", args: ["mcp-server-fetch"] } } });
248
+ writeJsonFile(join(claudeDir, "settings.json"), {
249
+ theme: "dark",
250
+ enabledPlugins: { "load-lock-plugin@load-lock": true },
251
+ hooks: { Stop: [{ hooks: [{ type: "command", command: "/usr/local/bin/user-stop" }] }] },
252
+ });
253
+ writeFileSync(join(claudeDir, "CLAUDE.md"), "# My rules\n");
254
+ // an ACE MCP patch the user merged into the project
255
+ writeJsonFile(join(sb.project, ".mcp.json"), { servers: { "load-lock": { command: "npx", args: ["-y", "@aron98/load-lock@1.5.0", "mcp"] } } });
256
+ const original = snapshot(sb.root);
257
+ // with the plugin enabled, only --legacy writes the loose wiring (#62)
258
+ await install(sb, "claude-code", "--legacy");
259
+ const out = await setupYes(sb);
260
+ assert.deepEqual(steps(out, "claude-code"), ["remove-loose", "manual"]);
261
+ assert.match(out.plan.steps.find((s) => s.action === "manual").detail, /servers\.load-lock/);
262
+ const cc = harness(out.inventory, "claude-code");
263
+ assert.equal(cc.rung, "plugin");
264
+ assert.deepEqual(cc.integrations.map((i) => `${i.kind}:${i.owner}`).sort(), ["mcp:other", "plugin:harness", "skill:installer"], "only the plugin, the skill and the foreign project entry remain");
265
+ assert.ok(!cc.findings.some((f) => f.startsWith("loose + plugin")));
266
+ const s = JSON.parse(readFileSync(join(claudeDir, "settings.json"), "utf8"));
267
+ assert.deepEqual(s.enabledPlugins, { "load-lock-plugin@load-lock": true }, "harness-owned plugin state untouched");
268
+ assert.equal(readFileSync(join(sb.project, ".mcp.json"), "utf8"), original[join(sb.project, ".mcp.json")], "foreign entry untouched");
269
+ // idempotent: a second run has only the manual step left
270
+ assert.deepEqual(steps(await setupYes(sb), "claude-code"), ["manual"]);
271
+ // reversible: install --legacy brings the loose wiring back ...
272
+ await install(sb, "claude-code", "--legacy");
273
+ assert.equal(harness(await setupJson(sb), "claude-code").integrations.filter((i) => i.owner === "installer").length, 4);
274
+ // ... and uninstall restores the user's original bytes
275
+ await uninstall(sb, "claude-code");
276
+ assert.deepEqual(snapshot(sb.root), original);
277
+ rmSync(sb.root, { recursive: true, force: true });
278
+ });
279
+ test("setup converge: codex with a second copy's hooks and a stale MCP block -> one current set; uninstall restores", async () => {
280
+ const sb = sandbox();
281
+ const codexDir = join(sb.home, ".codex");
282
+ writeJsonFile(join(codexDir, "hooks.json"), { hooks: { Stop: [{ hooks: [{ type: "command", command: "/usr/local/bin/user-stop" }] }] } });
283
+ mkdirSync(codexDir, { recursive: true });
284
+ writeFileSync(join(codexDir, "config.toml"), 'model = "o3"\n');
285
+ writeFileSync(join(codexDir, "AGENTS.md"), "# Mine\n");
286
+ const original = snapshot(sb.root);
287
+ await install(sb, "codex");
288
+ const gone = join(sb.root, "gone", "node_modules", "@aron98", "load-lock");
289
+ const hooksFile = join(codexDir, "hooks.json");
290
+ const h = JSON.parse(readFileSync(hooksFile, "utf8"));
291
+ h.hooks.Stop.push({ hooks: [{ type: "command", command: `/usr/bin/node ${JSON.stringify(join(gone, "dist", "cli", "main.js"))} hook codex stop` }] });
292
+ writeJsonFile(hooksFile, h);
293
+ const toml = join(codexDir, "config.toml");
294
+ const mcpMain = join(repoRoot, "dist", "mcp", "main.js");
295
+ writeFileSync(toml, readFileSync(toml, "utf8").replace(JSON.stringify(mcpMain), JSON.stringify(join(gone, "dist", "mcp", "main.js"))));
296
+ assert.ok(harness(await setupJson(sb), "codex").findings.length >= 2, "messy state is flagged");
297
+ const out = await setupYes(sb);
298
+ assert.deepEqual(steps(out, "codex"), ["install"]);
299
+ const cx = harness(out.inventory, "codex");
300
+ assert.deepEqual(cx.findings, [], cx.findings.join("\n"));
301
+ for (const i of cx.integrations.filter((x) => x.copy))
302
+ assert.equal(i.copy, repoRoot);
303
+ assert.equal(cx.integrations.filter((i) => i.kind === "hooks").length, 1);
304
+ assert.equal(readFileSync(toml, "utf8").split("[mcp_servers.load-lock]").length, 2, "exactly one MCP block");
305
+ await uninstall(sb, "codex");
306
+ assert.deepEqual(snapshot(sb.root), original);
307
+ rmSync(sb.root, { recursive: true, force: true });
308
+ });
309
+ test("setup converge: MCP-only cline is upgraded to the installer's wiring; the foreign entry is left for the user", async () => {
310
+ const sb = sandbox();
311
+ const file = join(sb.home, ".cline", "data", "settings", "cline_mcp_settings.json");
312
+ writeJsonFile(file, {
313
+ mcpServers: {
314
+ ll: { command: "npx", args: ["-y", "@aron98/load-lock@1.5.0", "mcp"] },
315
+ fetch: { command: "uvx", args: ["mcp-server-fetch"] },
316
+ },
317
+ });
318
+ const original = snapshot(sb.root);
319
+ assert.equal(harness(await setupJson(sb), "cline").rung, "mcp");
320
+ const out = await setupYes(sb);
321
+ assert.deepEqual(steps(out, "cline"), ["install", "manual"]);
322
+ assert.match(out.plan.steps.find((s) => s.action === "manual").detail, /remove mcpServers\.ll/);
323
+ const cl = harness(out.inventory, "cline");
324
+ assert.equal(cl.rung, "loose");
325
+ assert.ok(cl.integrations.some((i) => i.detail === "mcpServers.ll"), "foreign entry untouched");
326
+ await uninstall(sb, "cline");
327
+ assert.deepEqual(snapshot(sb.root), original);
328
+ rmSync(sb.root, { recursive: true, force: true });
329
+ });
330
+ test("setup converge: never overwrites a foreign entry in the installer's slot, an ACE skill, or from a cache copy", async () => {
331
+ const sb = sandbox();
332
+ writeJsonFile(join(sb.home, ".gemini", "settings.json"), {
333
+ mcpServers: { "load-lock": { command: "npx", args: ["-y", "@aron98/load-lock@1.5.0", "mcp"] } },
334
+ });
335
+ const skill = join(sb.home, ".claude", "skills", "load-lock");
336
+ mkdirSync(skill, { recursive: true });
337
+ writeFileSync(join(skill, "SKILL.md"), "---\nname: load-lock\ndescription: x\n---\n");
338
+ writeFileSync(join(skill, ".ace.json"), "{}\n");
339
+ const before = snapshot(sb.root);
340
+ const out = await setupYes(sb);
341
+ assert.deepEqual(steps(out, "gemini-cli"), ["manual"]);
342
+ assert.deepEqual(steps(out, "claude-code"), ["manual"]);
343
+ assert.deepEqual(out.applied, []);
344
+ assert.deepEqual(snapshot(sb.root), before);
345
+ // a copy running from the npx cache never wires harnesses to itself
346
+ const { inventory, planConverge } = await import("../cli/setup.js");
347
+ writeJsonFile(join(sb.home, ".copilot", "mcp-config.json"), { mcpServers: { x: { command: "npx", args: ["@aron98/load-lock", "mcp"] } } });
348
+ const inv = inventory({ project: sb.project, home: sb.home, env: sb.env });
349
+ const plan = planConverge(inv, { selfCopy: "/c/_npx/abc/node_modules/@aron98/load-lock", selfEphemeral: true });
350
+ const cp = plan.steps.filter((s) => s.harness === "copilot-cli");
351
+ assert.deepEqual(cp.map((s) => s.action), ["manual"]);
352
+ assert.match(cp[0].detail, /cache copy/);
353
+ rmSync(sb.root, { recursive: true, force: true });
354
+ });
@@ -0,0 +1 @@
1
+ export {};