@aron98/load-lock 0.1.2 → 1.0.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 (49) hide show
  1. package/README.md +37 -12
  2. package/demo/demo.sh +1 -1
  3. package/dist/adapters/opencode/plugin.js +39 -18
  4. package/dist/adapters/opencode/tui.d.ts +31 -0
  5. package/dist/adapters/opencode/tui.js +268 -0
  6. package/dist/cli/hook.js +35 -1
  7. package/dist/cli/install.js +708 -24
  8. package/dist/cli/main.js +36 -61
  9. package/dist/cli/top.d.ts +7 -0
  10. package/dist/cli/top.js +98 -0
  11. package/dist/core/daemon.d.ts +25 -4
  12. package/dist/core/daemon.js +67 -30
  13. package/dist/core/persist.js +5 -12
  14. package/dist/core/state.js +7 -0
  15. package/dist/dashboard/feed.d.ts +74 -0
  16. package/dist/dashboard/feed.js +256 -0
  17. package/dist/dashboard/frame.d.ts +12 -0
  18. package/dist/dashboard/frame.js +94 -0
  19. package/dist/dashboard/view.d.ts +148 -0
  20. package/dist/dashboard/view.js +214 -0
  21. package/dist/mcp/server.d.ts +2 -1
  22. package/dist/mcp/server.js +54 -19
  23. package/dist/policy/text.d.ts +1 -1
  24. package/dist/policy/text.js +3 -0
  25. package/dist/shared/client.d.ts +10 -0
  26. package/dist/shared/client.js +41 -14
  27. package/dist/shared/protocol.d.ts +29 -0
  28. package/dist/shared/transport.d.ts +40 -0
  29. package/dist/shared/transport.js +136 -0
  30. package/dist/shared/wait-turn.d.ts +29 -0
  31. package/dist/shared/wait-turn.js +315 -0
  32. package/dist/test/dashboard.test.d.ts +1 -0
  33. package/dist/test/dashboard.test.js +405 -0
  34. package/dist/test/hook.test.js +53 -3
  35. package/dist/test/install.test.d.ts +1 -0
  36. package/dist/test/install.test.js +658 -0
  37. package/dist/test/mcp.test.js +36 -1
  38. package/dist/test/platform.test.d.ts +1 -0
  39. package/dist/test/platform.test.js +193 -0
  40. package/dist/test/testutil.js +4 -1
  41. package/dist/test/wait-turn.test.d.ts +1 -0
  42. package/dist/test/wait-turn.test.js +376 -0
  43. package/docs/ADAPTERS.md +5 -2
  44. package/docs/AGENT_GUIDE.md +33 -1
  45. package/docs/ARCHITECTURE.md +25 -5
  46. package/docs/COMPATIBILITY.md +215 -26
  47. package/docs/OPERATIONS.md +313 -8
  48. package/package.json +25 -1
  49. package/skills/load-lock/SKILL.md +4 -0
@@ -16,6 +16,32 @@
16
16
  * - codex : ~/.codex/hooks.json (SessionStart/SubagentStart/Stop),
17
17
  * [mcp_servers.load-lock] in ~/.codex/config.toml,
18
18
  * ~/.codex/AGENTS.md pointer. Trust must be granted via /hooks.
19
+ * - gemini-cli : mcpServers.load-lock in ~/.gemini/settings.json,
20
+ * marked section in ~/.gemini/GEMINI.md. MCP-fallback
21
+ * tier: Gemini CLI has no hooks and no async push (no
22
+ * idle wakeup) — offers surface at tool boundaries.
23
+ * - copilot-cli : mcpServers.load-lock (type "local" / stdio) in
24
+ * ~/.copilot/mcp-config.json, marked section in the
25
+ * global ~/.copilot/copilot-instructions.md. MCP-fallback
26
+ * tier: Copilot CLI hooks are ecosystem-reported but NOT
27
+ * verified in official docs — we install none.
28
+ * - cline : mcpServers.load-lock in
29
+ * ~/.cline/data/settings/cline_mcp_settings.json
30
+ * (honors CLINE_MCP_SETTINGS_PATH), own rule file in
31
+ * ~/.cline/rules/. MCP-fallback tier: no hooks.
32
+ * - roo-code : project .roo/mcp.json (+ global Roo/Zoo mcp_settings.json
33
+ * when a VS Code globalStorage copy exists), own rule
34
+ * file in ~/.roo/rules/. MCP-fallback tier: no hooks.
35
+ * - goose : extensions.load-lock (type: stdio) merged into
36
+ * ~/.config/goose/config.yaml, marked section in the
37
+ * global hints file ~/.config/goose/.goosehints.
38
+ * MCP-fallback tier: no hooks.
39
+ * - cursor : project-level (per --project) .cursor/mcp.json
40
+ * (mcpServers.load-lock stdio), stop hook in
41
+ * .cursor/hooks.json (hooks v1: {"followup_message":...}
42
+ * continuation = idle wakeup), own Always-apply rule
43
+ * .cursor/rules/load-lock.mdc. Contract-tested against
44
+ * the documented hooks JSON; not live-verified.
19
45
  * - generic : prints an MCP stdio config snippet (any MCP-capable harness).
20
46
  */
21
47
  import fs from "node:fs";
@@ -58,13 +84,24 @@ function writeJson(file, data) {
58
84
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
59
85
  fs.renameSync(tmp, file);
60
86
  }
61
- /** Append a marked section to a markdown file once. */
87
+ /** Atomic plain-text write (used for YAML / hints files). */
88
+ function writeText(file, text) {
89
+ fs.mkdirSync(path.dirname(file), { recursive: true });
90
+ const tmp = file + ".tmp-ll-" + process.pid;
91
+ fs.writeFileSync(tmp, text);
92
+ fs.renameSync(tmp, file);
93
+ }
94
+ /**
95
+ * Append a marked section to a markdown file once. The section must start with
96
+ * `marker`; exactly one newline separates the user's existing bytes from it, so
97
+ * a removeMdSection round-trip restores the original bytes exactly.
98
+ */
62
99
  function mdSection(file, marker, section) {
63
100
  const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
64
101
  if (existing.includes(marker))
65
102
  return false;
66
103
  fs.mkdirSync(path.dirname(file), { recursive: true });
67
- fs.writeFileSync(file, existing + (existing.endsWith("\n") ? "" : "\n") + section);
104
+ fs.writeFileSync(file, existing === "" ? section : existing + "\n" + section);
68
105
  return true;
69
106
  }
70
107
  function removeMdSection(file, marker) {
@@ -76,9 +113,43 @@ function removeMdSection(file, marker) {
76
113
  return;
77
114
  const next = text.indexOf(marker, idx + marker.length);
78
115
  const end = next === -1 ? text.length : next;
79
- fs.writeFileSync(file, text.slice(0, idx) + text.slice(end));
116
+ // mdSection put exactly one newline before our section; take it back.
117
+ const start = idx > 0 && text[idx - 1] === "\n" ? idx - 1 : idx;
118
+ fs.writeFileSync(file, text.slice(0, start) + text.slice(end));
119
+ }
120
+ /**
121
+ * Collect every string value of `key` anywhere in a nested object/array
122
+ * (hook entries nest the command inside a `hooks` array for Claude Code and
123
+ * Codex; Cursor hooks v1 keep it at the top level).
124
+ */
125
+ function collectCommandStrings(e, key = "command", out = []) {
126
+ if (Array.isArray(e)) {
127
+ for (const v of e)
128
+ collectCommandStrings(v, key, out);
129
+ }
130
+ else if (typeof e === "object" && e !== null) {
131
+ for (const [k, v] of Object.entries(e)) {
132
+ if (k === key && typeof v === "string")
133
+ out.push(v);
134
+ else
135
+ collectCommandStrings(v, key, out);
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+ /**
141
+ * Is this hooks entry ours? Our hook commands are
142
+ * `<node> "<...>/dist/cli/main.js" hook <harness> <sub>`, matched on the
143
+ * JSON-quoted cli path plus the ` hook <harness> ` marker INSIDE the actual
144
+ * command string. Substring-matching a re-serialized entry with an un-quoted
145
+ * path never matches our own entries — re-serialization escapes the path's
146
+ * closing quote (`main.js\" hook`) — which made install duplicate hook
147
+ * entries and uninstall leave them behind (issue #19).
148
+ */
149
+ function isOurHook(e, cliPath, harness) {
150
+ const tag = ` hook ${harness} `;
151
+ return collectCommandStrings(e).some((c) => c.includes(JSON.stringify(cliPath)) && c.includes(tag));
80
152
  }
81
- const LL_HOOK_TAG = "dist/cli/main.js hook"; // how we recognize our hook entries
82
153
  // ------------------------------------------------------------------ opencode
83
154
  function installOpenCode(opts, P) {
84
155
  const actions = [];
@@ -158,10 +229,9 @@ function installClaudeCode(opts, P) {
158
229
  const s = readJson(settingsFile) ?? {};
159
230
  if (!s.hooks || typeof s.hooks !== "object")
160
231
  s.hooks = {};
161
- const ours = (list) => list.filter((e) => JSON.stringify(e).includes(LL_HOOK_TAG));
162
232
  const cc = (event, entries) => {
163
233
  const prev = Array.isArray(s.hooks[event]) ? s.hooks[event] : [];
164
- const kept = prev.filter((e) => !ours([e]).length);
234
+ const kept = prev.filter((e) => !isOurHook(e, P.cli, "claude-code"));
165
235
  s.hooks[event] = [...kept, ...entries];
166
236
  };
167
237
  cc("SessionStart", [hookEntry(P, "session-start")]);
@@ -173,7 +243,7 @@ function installClaudeCode(opts, P) {
173
243
  // 3. CLAUDE.md pointer
174
244
  const md = path.join(HOME, ".claude", "CLAUDE.md");
175
245
  const marker = "<!-- load-lock:begin -->";
176
- if (mdSection(md, marker, `\n${marker}\n## load-lock: machine-wide heavy-work budget\n\n` +
246
+ if (mdSection(md, marker, `${marker}\n## load-lock: machine-wide heavy-work budget\n\n` +
177
247
  `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine:\n\n` +
178
248
  `1. Call the MCP tool \`mcp__load-lock__acquire\` (reason + op_key) — it grants a permit or queues you.\n` +
179
249
  `2. While queued, keep doing light work. When an offer arrives (hook wakeup or \`mcp__load-lock__pending\`), claim it via \`mcp__load-lock__claim\` within the window (default 30s).\n` +
@@ -190,6 +260,7 @@ function installClaudeCode(opts, P) {
190
260
  }
191
261
  function uninstallClaudeCode() {
192
262
  const actions = [];
263
+ const P = pkgPaths(); // cli path for our-entry matching below
193
264
  const claudeJson = path.join(HOME, ".claude.json");
194
265
  const cj = readJson(claudeJson);
195
266
  if (cj?.mcpServers && typeof cj.mcpServers === "object" && "load-lock" in cj.mcpServers) {
@@ -200,23 +271,39 @@ function uninstallClaudeCode() {
200
271
  const settingsFile = path.join(HOME, ".claude", "settings.json");
201
272
  const s = readJson(settingsFile);
202
273
  if (s?.hooks && typeof s.hooks === "object") {
274
+ let changed = false;
203
275
  for (const ev of Object.keys(s.hooks)) {
204
276
  const list = s.hooks[ev];
205
277
  if (Array.isArray(list)) {
206
- const kept = list.filter((e) => !JSON.stringify(e).includes(LL_HOOK_TAG));
207
- if (kept.length)
208
- s.hooks[ev] = kept;
209
- else
210
- delete s.hooks[ev];
278
+ const kept = list.filter((e) => !isOurHook(e, P.cli, "claude-code"));
279
+ if (kept.length !== list.length) {
280
+ changed = true;
281
+ if (kept.length)
282
+ s.hooks[ev] = kept;
283
+ else
284
+ delete s.hooks[ev];
285
+ }
286
+ }
287
+ }
288
+ if (Object.keys(s.hooks).length === 0)
289
+ delete s.hooks;
290
+ if (changed) {
291
+ // If nothing but our bare-install shape remains, the file is ours.
292
+ if (Object.keys(s).length === 0) {
293
+ fs.rmSync(settingsFile);
294
+ actions.push(`removed ${settingsFile} (nothing else was in it)`);
295
+ }
296
+ else {
297
+ writeJson(settingsFile, s);
298
+ actions.push(`removed hooks from ${settingsFile}`);
211
299
  }
212
300
  }
213
- writeJson(settingsFile, s);
214
- actions.push(`removed hooks from ${settingsFile}`);
215
301
  }
216
302
  const md = path.join(HOME, ".claude", "CLAUDE.md");
217
303
  removeMdSection(md, "<!-- load-lock:begin -->");
218
304
  actions.push(`removed CLAUDE.md section`);
219
305
  fs.rmSync(path.join(HOME, ".claude", "skills", "load-lock"), { recursive: true, force: true });
306
+ pruneEmptyDirs(path.join(HOME, ".claude", "skills"), path.join(HOME, ".claude"));
220
307
  actions.push("removed skill");
221
308
  return actions;
222
309
  }
@@ -243,7 +330,7 @@ function installCodex(opts, P) {
243
330
  ];
244
331
  const mergeEv = (event, entries) => {
245
332
  const prev = Array.isArray(h.hooks[event]) ? h.hooks[event] : [];
246
- h.hooks[event] = [...prev.filter((e) => !JSON.stringify(e).includes(LL_HOOK_TAG)), ...entries];
333
+ h.hooks[event] = [...prev.filter((e) => !isOurHook(e, P.cli, "codex")), ...entries];
247
334
  };
248
335
  mergeEv("SessionStart", [{ matcher: "startup|resume|clear|compact|fork", hooks: [{ type: "command", command: `${P.node} ${JSON.stringify(P.cli)} hook codex session-start` }] }]);
249
336
  mergeEv("SubagentStart", [{ matcher: "*", hooks: [{ type: "command", command: `${P.node} ${JSON.stringify(P.cli)} hook codex subagent-start` }] }]);
@@ -270,7 +357,7 @@ function installCodex(opts, P) {
270
357
  }
271
358
  // 3. AGENTS.md pointer
272
359
  const agentsMd = path.join(codexHome, "AGENTS.md");
273
- if (mdSection(agentsMd, "<!-- load-lock:begin -->", `\n<!-- load-lock:begin -->\n## load-lock: machine-wide heavy-work budget\n\n` +
360
+ if (mdSection(agentsMd, "<!-- load-lock:begin -->", `<!-- load-lock:begin -->\n## load-lock: machine-wide heavy-work budget\n\n` +
274
361
  `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine via the \`load-lock\` MCP tools (mcp__load-lock__acquire / claim / release / run_with_permit / pending / status / inspect_machine / configure). ` +
275
362
  `Acquire before heavy work, keep doing light work while queued, claim offers promptly (they expire), release when done. Full guide: \`load-lock agent-guide\`.\n<!-- load-lock:end -->\n`))
276
363
  actions.push(`AGENTS.md section in ${agentsMd}`);
@@ -279,22 +366,38 @@ function installCodex(opts, P) {
279
366
  }
280
367
  function uninstallCodex() {
281
368
  const actions = [];
369
+ const P = pkgPaths(); // cli path for our-entry matching below
282
370
  const codexHome = process.env.CODEX_HOME ?? path.join(HOME, ".codex");
283
371
  const hooksFile = path.join(codexHome, "hooks.json");
284
372
  const h = readJson(hooksFile);
285
373
  if (h?.hooks && typeof h.hooks === "object") {
374
+ let changed = false;
286
375
  for (const ev of Object.keys(h.hooks)) {
287
376
  const list = h.hooks[ev];
288
377
  if (Array.isArray(list)) {
289
- const kept = list.filter((e) => !JSON.stringify(e).includes(LL_HOOK_TAG));
290
- if (kept.length)
291
- h.hooks[ev] = kept;
292
- else
293
- delete h.hooks[ev];
378
+ const kept = list.filter((e) => !isOurHook(e, P.cli, "codex"));
379
+ if (kept.length !== list.length) {
380
+ changed = true;
381
+ if (kept.length)
382
+ h.hooks[ev] = kept;
383
+ else
384
+ delete h.hooks[ev];
385
+ }
386
+ }
387
+ }
388
+ if (Object.keys(h.hooks).length === 0)
389
+ delete h.hooks;
390
+ if (changed) {
391
+ // If nothing but our bare-install shape remains, the file is ours.
392
+ if (Object.keys(h).length === 0) {
393
+ fs.rmSync(hooksFile);
394
+ actions.push(`removed ${hooksFile} (nothing else was in it)`);
395
+ }
396
+ else {
397
+ writeJson(hooksFile, h);
398
+ actions.push(`removed hooks from ${hooksFile}`);
294
399
  }
295
400
  }
296
- writeJson(hooksFile, h);
297
- actions.push(`removed hooks from ${hooksFile}`);
298
401
  }
299
402
  const tomlFile = path.join(codexHome, "config.toml");
300
403
  if (fs.existsSync(tomlFile)) {
@@ -310,6 +413,541 @@ function uninstallCodex() {
310
413
  actions.push("removed AGENTS.md section");
311
414
  return actions;
312
415
  }
416
+ // ---------------------------------------------------------------- gemini-cli
417
+ function installGeminiCli(opts, P) {
418
+ const actions = [];
419
+ // 1. mcpServers.load-lock in ~/.gemini/settings.json (merge; replace only
420
+ // our own entry). Gemini CLI's stdio shape is { command, args } — the
421
+ // presence of `command` selects the stdio transport.
422
+ const settingsFile = path.join(HOME, ".gemini", "settings.json");
423
+ const s = readJson(settingsFile) ?? {};
424
+ if (typeof s !== "object" || s === null || Array.isArray(s)) {
425
+ throw new Error(`${settingsFile} is not a JSON object; refusing to overwrite`);
426
+ }
427
+ if (!s.mcpServers || typeof s.mcpServers !== "object" || s.mcpServers === null)
428
+ s.mcpServers = {};
429
+ s.mcpServers["load-lock"] = { command: P.node, args: [P.mcp] };
430
+ writeJson(settingsFile, s);
431
+ actions.push(`MCP server "load-lock" (stdio) in ${settingsFile}`);
432
+ // 2. marked policy section in ~/.gemini/GEMINI.md (global instructions file)
433
+ const geminiMd = path.join(HOME, ".gemini", "GEMINI.md");
434
+ const marker = "<!-- load-lock:begin -->";
435
+ if (mdSection(geminiMd, marker, `${marker}\n## load-lock: machine-wide heavy-work budget\n\n` +
436
+ `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine:\n\n` +
437
+ `1. Call the \`load-lock\` MCP \`acquire\` tool (reason + op_key) — it grants a permit or queues you. Pass \`wait_ms\` for a bounded blocking wait, or keep doing light work while queued.\n` +
438
+ `2. Offers surface in every tool result (tool-boundary delivery) — check \`pending\` and claim promptly via \`claim\` within the window (default 30s); expired offers pass capacity to the next waiter.\n` +
439
+ `3. Only start heavy work after acquire/claim succeeds. Prefer \`run_with_permit\` for full lifecycle ownership (auto-release), or \`attach_pid\` / \`mark_started\` for processes you already started.\n` +
440
+ `4. Release (or cancel queued work) promptly when done. Inspect the machine with \`inspect_machine\`; adjust capacity via \`configure\` when warranted.\n\n` +
441
+ `Note: Gemini CLI has no hooks and no async push — there is NO idle wakeup. You discover offers at your next tool call or via a bounded \`wait_ms\`.\n` +
442
+ `Full guide: \`load-lock agent-guide\`.\n<!-- load-lock:end -->\n`))
443
+ actions.push(`GEMINI.md section in ${geminiMd}`);
444
+ // 3. be honest about the tier
445
+ actions.push("tier: MCP fallback — offers arrive at tool boundaries (tool-result delivery + bounded wait_ms); " +
446
+ "Gemini CLI has no hooks and no async push, so there is no idle wakeup");
447
+ actions.push("restart Gemini CLI to pick up the MCP server");
448
+ return actions;
449
+ }
450
+ function uninstallGeminiCli() {
451
+ const actions = [];
452
+ const settingsFile = path.join(HOME, ".gemini", "settings.json");
453
+ const s = readJson(settingsFile);
454
+ if (s?.mcpServers && typeof s.mcpServers === "object" && "load-lock" in s.mcpServers) {
455
+ delete s.mcpServers["load-lock"];
456
+ writeJson(settingsFile, s);
457
+ actions.push(`removed MCP server from ${settingsFile}`);
458
+ }
459
+ const geminiMd = path.join(HOME, ".gemini", "GEMINI.md");
460
+ removeMdSection(geminiMd, "<!-- load-lock:begin -->");
461
+ actions.push("removed GEMINI.md section");
462
+ return actions;
463
+ }
464
+ // ------------------------------------------------- shared MCP-fallback bits
465
+ /** XDG-ish config home (~/.config on Linux/macOS default). */
466
+ function configHome() {
467
+ const xdg = process.env.XDG_CONFIG_HOME;
468
+ return xdg && xdg.trim() ? path.resolve(xdg) : path.join(HOME, ".config");
469
+ }
470
+ /**
471
+ * Policy section for MCP-fallback-tier harnesses (no hooks, no async push).
472
+ * `note` states the harness-specific honesty line (never claim wakeup).
473
+ */
474
+ function fallbackSection(note) {
475
+ return (`## load-lock: machine-wide heavy-work budget\n\n` +
476
+ `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine:\n\n` +
477
+ `1. Call the \`load-lock\` MCP \`acquire\` tool (reason + op_key) — it grants a permit or queues you. Pass \`wait_ms\` for a bounded blocking wait, or keep doing light work while queued.\n` +
478
+ `2. Offers surface in every tool result (tool-boundary delivery) — check \`pending\` and claim promptly via \`claim\` within the window (default 30s); expired offers pass capacity to the next waiter.\n` +
479
+ `3. Only start heavy work after acquire/claim succeeds. Prefer \`run_with_permit\` for full lifecycle ownership (auto-release), or \`attach_pid\` / \`mark_started\` for processes you already started.\n` +
480
+ `4. Release (or cancel queued work) promptly when done. Inspect the machine with \`inspect_machine\`; adjust capacity via \`configure\` when warranted.\n\n` +
481
+ `Note: ${note}\n` +
482
+ `Full guide: \`load-lock agent-guide\`.\n`);
483
+ }
484
+ const NO_WAKEUP_NOTE = (h) => `${h} has no hooks and no async push — there is NO idle wakeup. You discover offers at your next tool call or via a bounded \`wait_ms\`.`;
485
+ /** Merge `key` under `obj.mcpServers` in a JSON settings file (create-safe). */
486
+ function mergeMcpServer(file, entry, action) {
487
+ const cfg = readJson(file) ?? {};
488
+ if (typeof cfg !== "object" || cfg === null || Array.isArray(cfg)) {
489
+ throw new Error(`${file} is not a JSON object; refusing to overwrite`);
490
+ }
491
+ if (!cfg.mcpServers || typeof cfg.mcpServers !== "object" || cfg.mcpServers === null) {
492
+ cfg.mcpServers = {};
493
+ }
494
+ cfg.mcpServers["load-lock"] = entry;
495
+ writeJson(file, cfg);
496
+ return [action];
497
+ }
498
+ /** Remove our `mcpServers["load-lock"]` from a JSON settings file, if present. */
499
+ function removeMcpServer(file, action) {
500
+ const actions = [];
501
+ const cfg = readJson(file);
502
+ if (cfg?.mcpServers && typeof cfg.mcpServers === "object" && "load-lock" in cfg.mcpServers) {
503
+ delete cfg.mcpServers["load-lock"];
504
+ writeJson(file, cfg);
505
+ actions.push(action);
506
+ }
507
+ return actions;
508
+ }
509
+ /**
510
+ * rmdir empty directories from `startDir` upward, never leaving `stopDir`'s
511
+ * parent (only dirs that are empty because we created them disappear).
512
+ */
513
+ function pruneEmptyDirs(startDir, stopDir) {
514
+ if (path.relative(stopDir, startDir).startsWith(".."))
515
+ return;
516
+ let up = startDir;
517
+ while (path.relative(stopDir, up) !== "..") {
518
+ try {
519
+ fs.rmdirSync(up); // only succeeds when empty
520
+ }
521
+ catch {
522
+ break;
523
+ }
524
+ up = path.dirname(up);
525
+ }
526
+ }
527
+ // --------------------------------------------------------------- copilot-cli
528
+ function installCopilotCli(opts, P) {
529
+ const actions = [];
530
+ // 1. mcpServers.load-lock (type "local" == stdio) in ~/.copilot/mcp-config.json
531
+ // (the file `copilot mcp add` writes; per official Copilot CLI docs).
532
+ const cfgFile = path.join(HOME, ".copilot", "mcp-config.json");
533
+ actions.push(...mergeMcpServer(cfgFile, { type: "local", command: P.node, args: [P.mcp], env: {}, tools: ["*"] }, `MCP server "load-lock" (type: local / stdio) in ${cfgFile}`));
534
+ // 2. marked policy section in the global personal instructions file
535
+ const instructions = path.join(HOME, ".copilot", "copilot-instructions.md");
536
+ const marker = "<!-- load-lock:begin -->";
537
+ if (mdSection(instructions, marker, `${marker}\n` +
538
+ fallbackSection(`Copilot CLI hook support is ecosystem-reported but NOT verified in official docs — load-lock installs no hooks here; there is no idle wakeup. You discover offers at your next tool call or via a bounded \`wait_ms\`.`) +
539
+ `<!-- load-lock:end -->\n`))
540
+ actions.push(`marked section in ${instructions} (global personal instructions)`);
541
+ // 3. be honest about the tier
542
+ actions.push("tier: MCP fallback — Copilot CLI hooks are ecosystem-reported but not verified in official docs; " +
543
+ "we install none, so there is no idle wakeup — offers surface at tool boundaries");
544
+ actions.push("verify with `copilot mcp list` (or `/mcp` inside Copilot CLI)");
545
+ return actions;
546
+ }
547
+ function uninstallCopilotCli() {
548
+ const actions = [];
549
+ const cfgFile = path.join(HOME, ".copilot", "mcp-config.json");
550
+ actions.push(...removeMcpServer(cfgFile, `removed MCP server from ${cfgFile}`));
551
+ removeMdSection(path.join(HOME, ".copilot", "copilot-instructions.md"), "<!-- load-lock:begin -->");
552
+ actions.push("removed copilot-instructions.md section");
553
+ return actions;
554
+ }
555
+ // -------------------------------------------------------------------- cline
556
+ function clineMcpSettingsFile() {
557
+ // Official Cline docs: CLI reads ~/.cline/data/settings/cline_mcp_settings.json,
558
+ // overridable via CLINE_MCP_SETTINGS_PATH.
559
+ return process.env.CLINE_MCP_SETTINGS_PATH?.trim()
560
+ ? path.resolve(process.env.CLINE_MCP_SETTINGS_PATH)
561
+ : path.join(HOME, ".cline", "data", "settings", "cline_mcp_settings.json");
562
+ }
563
+ function installCline(opts, P) {
564
+ const actions = [];
565
+ // 1. mcpServers.load-lock (stdio) in the MCP settings file
566
+ const cfgFile = clineMcpSettingsFile();
567
+ actions.push(...mergeMcpServer(cfgFile, { command: P.node, args: [P.mcp], env: {}, disabled: false, autoApprove: [] }, `MCP server "load-lock" (stdio) in ${cfgFile}`));
568
+ // 2. own rule file in the global rules dir (whole file is ours → trivially clean uninstall)
569
+ const ruleFile = path.join(HOME, ".cline", "rules", "load-lock.md");
570
+ fs.mkdirSync(path.dirname(ruleFile), { recursive: true });
571
+ fs.writeFileSync(ruleFile, `# load-lock: machine-wide heavy-work budget\n\n` +
572
+ `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine via the \`load-lock\` MCP tools (acquire / claim / release / cancel / rejoin / pending / status / run_with_permit / attach_pid / mark_started / inspect_machine / configure). ` +
573
+ `Acquire before heavy work, keep doing light work while queued, claim offers promptly (they expire), release when done.\n\n` +
574
+ `${NO_WAKEUP_NOTE("Cline")}\n` +
575
+ `Full guide: \`load-lock agent-guide\`.\n`);
576
+ actions.push(`global rule file ${ruleFile}`);
577
+ // 3. be honest about the tier
578
+ actions.push("tier: MCP fallback — Cline has no hooks and no async push, so there is no idle wakeup; offers surface at tool boundaries");
579
+ actions.push("restart Cline (or reload the window) to pick up the MCP server");
580
+ return actions;
581
+ }
582
+ function uninstallCline() {
583
+ const actions = [];
584
+ const cfgFile = clineMcpSettingsFile();
585
+ actions.push(...removeMcpServer(cfgFile, `removed MCP server from ${cfgFile}`));
586
+ const ruleFile = path.join(HOME, ".cline", "rules", "load-lock.md");
587
+ if (fs.existsSync(ruleFile)) {
588
+ fs.rmSync(ruleFile);
589
+ actions.push(`removed ${ruleFile}`);
590
+ pruneEmptyDirs(path.dirname(ruleFile), path.join(HOME, ".cline"));
591
+ }
592
+ return actions;
593
+ }
594
+ // ----------------------------------------------------------------- roo-code
595
+ /**
596
+ * Existing global Roo Code / Zoo Code `mcp_settings.json` files (VS Code keeps
597
+ * them in per-extension globalStorage; the extension id changed across
598
+ * roo-cline → roo-code → zoo-code releases, and the editor may be Code,
599
+ * Code - OSS or VSCodium). We merge into every one we FIND; we never create
600
+ * one — if it is absent the user manages the global file via the Roo/Zoo
601
+ * settings UI ("Edit Global MCP") and we write the project file instead.
602
+ */
603
+ function rooGlobalMcpFiles() {
604
+ const appData = process.platform === "darwin" ? path.join(HOME, "Library", "Application Support") : configHome();
605
+ const out = [];
606
+ for (const editor of ["Code", "Code - OSS", "VSCodium"]) {
607
+ for (const ext of ["roocode.roo-cline", "RooCodeInc.roo-code", "ZooCodeOrganization.zoo-code"]) {
608
+ const f = path.join(appData, editor, "User", "globalStorage", ext, "settings", "mcp_settings.json");
609
+ if (fs.existsSync(f))
610
+ out.push(f);
611
+ }
612
+ }
613
+ return out;
614
+ }
615
+ function installRooCode(opts, P) {
616
+ const actions = [];
617
+ // 1. project-level .roo/mcp.json (documented, auto-loaded, shareable)
618
+ const projMcp = path.join(opts.project, ".roo", "mcp.json");
619
+ actions.push(...mergeMcpServer(projMcp, { command: P.node, args: [P.mcp], disabled: false }, `MCP server "load-lock" (stdio) in ${projMcp} (project-level; Roo/Zoo auto-loads it)`));
620
+ // 2. own rule file in the global rules dir
621
+ const ruleFile = path.join(HOME, ".roo", "rules", "load-lock.md");
622
+ fs.mkdirSync(path.dirname(ruleFile), { recursive: true });
623
+ fs.writeFileSync(ruleFile, `# load-lock: machine-wide heavy-work budget\n\n` +
624
+ `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine via the \`load-lock\` MCP tools (acquire / claim / release / cancel / rejoin / pending / status / run_with_permit / attach_pid / mark_started / inspect_machine / configure). ` +
625
+ `Acquire before heavy work, keep doing light work while queued, claim offers promptly (they expire), release when done.\n\n` +
626
+ `${NO_WAKEUP_NOTE("Roo Code")}\n` +
627
+ `Full guide: \`load-lock agent-guide\`.\n`);
628
+ actions.push(`global rule file ${ruleFile}`);
629
+ // 3. global mcp_settings.json copies that already exist
630
+ const globals = rooGlobalMcpFiles();
631
+ for (const f of globals) {
632
+ actions.push(...mergeMcpServer(f, { command: P.node, args: [P.mcp], disabled: false }, `MCP server "load-lock" in existing global ${f}`));
633
+ }
634
+ if (!globals.length) {
635
+ actions.push("no global Roo/Zoo mcp_settings.json found — to add the server globally, use " +
636
+ "\"Edit Global MCP\" in the Roo/Zoo MCP settings UI and paste the same stdio entry");
637
+ }
638
+ // 4. be honest about the tier
639
+ actions.push("tier: MCP fallback — Roo Code has no hooks and no async push, so there is no idle wakeup; offers surface at tool boundaries");
640
+ return actions;
641
+ }
642
+ function uninstallRooCode(opts) {
643
+ const actions = [];
644
+ const projMcp = path.join(opts.project, ".roo", "mcp.json");
645
+ actions.push(...removeMcpServer(projMcp, `removed MCP server from ${projMcp}`));
646
+ for (const f of rooGlobalMcpFiles()) {
647
+ actions.push(...removeMcpServer(f, `removed MCP server from ${f}`));
648
+ }
649
+ const ruleFile = path.join(HOME, ".roo", "rules", "load-lock.md");
650
+ if (fs.existsSync(ruleFile)) {
651
+ fs.rmSync(ruleFile);
652
+ actions.push(`removed ${ruleFile}`);
653
+ pruneEmptyDirs(path.dirname(ruleFile), path.join(HOME, ".roo"));
654
+ }
655
+ return actions;
656
+ }
657
+ // -------------------------------------------------------------------- goose
658
+ // goose config.yaml is YAML. Rather than a full parser (a new dependency, and
659
+ // a formatting-destroying one), we do a careful line-oriented block-mapping
660
+ // merge for the one key we touch (`extensions`), and refuse anything we do
661
+ // not understand (flow style) instead of guessing.
662
+ const GOOSE_BEGIN = "# load-lock:begin (managed by `load-lock install`)";
663
+ const GOOSE_END = "# load-lock:end";
664
+ const GOOSE_HEADER_TAG = "# load-lock-created (managed by `load-lock install`)";
665
+ function gooseConfigFile() {
666
+ // Official goose docs: macOS/Linux ~/.config/goose/config.yaml
667
+ // (Windows: %APPDATA%\Block\goose\config\config.yaml — not supported by load-lock).
668
+ return path.join(configHome(), "goose", "config.yaml");
669
+ }
670
+ function gooseBlockLines(P) {
671
+ return [
672
+ ` ${GOOSE_BEGIN}`,
673
+ " load-lock:",
674
+ " type: stdio",
675
+ " name: load-lock",
676
+ " enabled: true",
677
+ ` cmd: ${JSON.stringify(P.node)}`,
678
+ ` args: [${JSON.stringify(P.mcp)}]`,
679
+ ` ${GOOSE_END}`,
680
+ ];
681
+ }
682
+ /** Remove our marked child block(s) from a goose config.yaml text. */
683
+ function gooseRemoveMarked(lines) {
684
+ const out = [];
685
+ let skipping = false;
686
+ for (const line of lines) {
687
+ if (/^\s+# load-lock:begin \(managed by `load-lock install`\)$/.test(line)) {
688
+ skipping = true;
689
+ continue;
690
+ }
691
+ if (skipping && /^\s+# load-lock:end$/.test(line)) {
692
+ skipping = false;
693
+ continue;
694
+ }
695
+ if (!skipping)
696
+ out.push(line);
697
+ }
698
+ return out;
699
+ }
700
+ /**
701
+ * Remove an unmarked `load-lock:` child block under `extensions:` (pre-existing
702
+ * name collision) so we never write a duplicate YAML key. Only touches the
703
+ * `load-lock` child; sibling extensions are preserved.
704
+ */
705
+ function gooseRemoveCollidingChild(lines, extIdx) {
706
+ const childRe = /^(\s+)load-lock:\s*(#.*)?$/;
707
+ for (let i = extIdx + 1; i < lines.length; i++) {
708
+ if (/^\S/.test(lines[i]))
709
+ break; // left the extensions block
710
+ const m = childRe.exec(lines[i]);
711
+ if (m) {
712
+ const indent = m[1].length;
713
+ let j = i + 1;
714
+ while (j < lines.length && (lines[j].trim() === "" || new RegExp(`^\\s{${indent + 1},}\\S`).test(lines[j])))
715
+ j++;
716
+ while (j > i + 1 && lines[j - 1].trim() === "")
717
+ j--; // keep trailing blank separators
718
+ lines.splice(i, j - i);
719
+ break;
720
+ }
721
+ }
722
+ return lines;
723
+ }
724
+ /**
725
+ * If our tagged `extensions:` header has no other children, remove it — and the
726
+ * separator blank we own immediately before it, when the header sits at end of
727
+ * file — so an install/uninstall round-trip restores the user's bytes exactly.
728
+ */
729
+ function gooseDropChildlessTaggedHeader(lines) {
730
+ const taggedIdx = lines.findIndex((l) => l === `extensions: ${GOOSE_HEADER_TAG}`);
731
+ if (taggedIdx === -1)
732
+ return lines;
733
+ let hasChild = false;
734
+ for (let i = taggedIdx + 1; i < lines.length && !/^\S/.test(lines[i]); i++) {
735
+ if (/\S/.test(lines[i]) && !/^\s*#/.test(lines[i]))
736
+ hasChild = true;
737
+ }
738
+ if (!hasChild) {
739
+ lines.splice(taggedIdx, 1);
740
+ if (taggedIdx > 0 &&
741
+ lines[taggedIdx - 1].trim() === "" &&
742
+ lines.slice(taggedIdx).every((l) => l.trim() === "")) {
743
+ lines.splice(taggedIdx - 1, 1);
744
+ }
745
+ }
746
+ return lines;
747
+ }
748
+ /** Merge/replace the `extensions.load-lock` stdio entry in goose config.yaml text. */
749
+ function gooseUpsertExtension(text, P) {
750
+ // NOTE: [ \t]* (not \s*) — \s would span the newline and misread the first
751
+ // block-mapping child as a flow-style value on the header line.
752
+ const flow = /^extensions:[ \t]*[^\s#]/m.exec(text);
753
+ if (flow) {
754
+ throw new Error("goose config.yaml uses a non-block-style `extensions:` value; load-lock refuses to guess — " +
755
+ "convert it to a block mapping or add the load-lock extension manually");
756
+ }
757
+ let lines = gooseRemoveMarked(text.split("\n"));
758
+ lines = gooseDropChildlessTaggedHeader(lines);
759
+ const block = gooseBlockLines(P);
760
+ const extIdx = lines.findIndex((l) => /^extensions:\s*(#.*)?$/.test(l));
761
+ if (extIdx === -1) {
762
+ // no extensions key yet → append one, tagged as ours
763
+ if (lines.length && lines[lines.length - 1] === "")
764
+ lines.pop(); // normalize trailing newline
765
+ if (lines.length)
766
+ lines.push(""); // separator blank (ours; removed on uninstall)
767
+ lines.push(`extensions: ${GOOSE_HEADER_TAG}`, ...block, "");
768
+ }
769
+ else {
770
+ lines = gooseRemoveCollidingChild(lines, extIdx);
771
+ lines.splice(extIdx + 1, 0, ...block);
772
+ }
773
+ return lines.join("\n");
774
+ }
775
+ /** Remove our extension from a goose config.yaml text (idempotent, no-op safe). */
776
+ function gooseRemoveExtension(text) {
777
+ let lines = gooseRemoveMarked(text.split("\n"));
778
+ lines = gooseDropChildlessTaggedHeader(lines);
779
+ return lines.join("\n");
780
+ }
781
+ function installGoose(opts, P) {
782
+ const actions = [];
783
+ const cfgFile = gooseConfigFile();
784
+ fs.mkdirSync(path.dirname(cfgFile), { recursive: true });
785
+ const prev = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, "utf8") : "";
786
+ writeText(cfgFile, gooseUpsertExtension(prev, P));
787
+ actions.push(`extensions.load-lock (type: stdio) in ${cfgFile}`);
788
+ // 2. marked section in the global hints file (~/.config/goose/.goosehints)
789
+ const hints = path.join(configHome(), "goose", ".goosehints");
790
+ const marker = "<!-- load-lock:begin -->";
791
+ if (mdSection(hints, marker, `${marker}\n` + fallbackSection(NO_WAKEUP_NOTE("Goose")) + `<!-- load-lock:end -->\n`))
792
+ actions.push(`marked section in ${hints} (global hints)`);
793
+ // 3. be honest about the tier
794
+ actions.push("tier: MCP fallback — Goose has no hooks and no async push, so there is no idle wakeup; offers surface at tool boundaries");
795
+ actions.push("restart goose sessions to pick up the extension");
796
+ return actions;
797
+ }
798
+ function uninstallGoose() {
799
+ const actions = [];
800
+ const cfgFile = gooseConfigFile();
801
+ if (fs.existsSync(cfgFile)) {
802
+ const prev = fs.readFileSync(cfgFile, "utf8");
803
+ const next = gooseRemoveExtension(prev);
804
+ if (next !== prev)
805
+ writeText(cfgFile, next);
806
+ if (next.trim() === "") {
807
+ fs.rmSync(cfgFile);
808
+ actions.push(`removed ${cfgFile} (nothing else was in it)`);
809
+ }
810
+ else {
811
+ actions.push(`removed extensions.load-lock from ${cfgFile}`);
812
+ }
813
+ }
814
+ const hints = path.join(configHome(), "goose", ".goosehints");
815
+ removeMdSection(hints, "<!-- load-lock:begin -->");
816
+ actions.push("removed hints section");
817
+ if (fs.existsSync(hints) && fs.readFileSync(hints, "utf8").trim() === "") {
818
+ fs.rmSync(hints);
819
+ actions.push(`removed ${hints} (nothing else was in it)`);
820
+ }
821
+ pruneEmptyDirs(path.dirname(cfgFile), path.dirname(cfgFile));
822
+ return actions;
823
+ }
824
+ // ------------------------------------------------------------------ cursor
825
+ /**
826
+ * Policy text for the dedicated .cursor/rules/load-lock.mdc rule file
827
+ * (YAML frontmatter with alwaysApply: true — plain .md is ignored by Cursor).
828
+ */
829
+ function cursorRuleText() {
830
+ return (`---\nalwaysApply: true\n---\n` +
831
+ `# load-lock: machine-wide heavy-work budget\n\n` +
832
+ `Before resource-intensive local work (full builds, big test suites), coordinate with other agents on this machine via the \`load-lock\` MCP tools (acquire / claim / release / cancel / rejoin / pending / status / run_with_permit / attach_pid / mark_started / inspect_machine / configure). ` +
833
+ `Acquire before heavy work, keep doing light work while queued, claim offers promptly (they expire), release when done.\n\n` +
834
+ `When you are queued and idle, a load-lock stop hook wakes you: Cursor auto-submits a follow-up message telling you which offer_token to claim — claim it immediately (it expires) and start your heavy work right away.\n\n` +
835
+ `Full guide: \`load-lock agent-guide\`.\n`);
836
+ }
837
+ /**
838
+ * Cursor (hooks v1, documented at https://cursor.com/docs/hooks — no longer
839
+ * beta). Everything is project-level under --project/.cursor/:
840
+ * - mcp.json: mcpServers.load-lock stdio entry
841
+ * - hooks.json: { version: 1, hooks: { stop: [ ... ] } } — FLAT entries
842
+ * ({type:"command", command}), merged; only our own entries are replaced.
843
+ * - rules/load-lock.mdc: our own Always-apply rule (clean uninstall).
844
+ */
845
+ function installCursor(opts, P) {
846
+ const actions = [];
847
+ const cursorDir = path.join(opts.project, ".cursor");
848
+ // 1. MCP server (stdio) in the project .cursor/mcp.json (documented path)
849
+ const mcpFile = path.join(cursorDir, "mcp.json");
850
+ actions.push(...mergeMcpServer(mcpFile, { command: P.node, args: [P.mcp] }, `MCP server "load-lock" (stdio) in ${mcpFile} (project-level)`));
851
+ // 2. stop hook in the project .cursor/hooks.json (merge; replace only ours)
852
+ const hooksFile = path.join(cursorDir, "hooks.json");
853
+ const h = readJson(hooksFile) ?? {};
854
+ if (typeof h !== "object" || h === null || Array.isArray(h)) {
855
+ throw new Error(`${hooksFile} is not a JSON object; refusing to overwrite`);
856
+ }
857
+ if (h.version === undefined)
858
+ h.version = 1; // required positive integer per docs
859
+ if (h.hooks === undefined) {
860
+ h.hooks = {};
861
+ }
862
+ else if (typeof h.hooks !== "object" || h.hooks === null || Array.isArray(h.hooks)) {
863
+ throw new Error(`${hooksFile} has a non-object "hooks" key; refusing to overwrite`);
864
+ }
865
+ if (h.hooks.stop === undefined) {
866
+ h.hooks.stop = [];
867
+ }
868
+ else if (!Array.isArray(h.hooks.stop)) {
869
+ throw new Error(`${hooksFile} has a non-array "hooks.stop" key; refusing to overwrite`);
870
+ }
871
+ h.hooks.stop = [
872
+ ...h.hooks.stop.filter((e) => !isOurHook(e, P.cli, "cursor")),
873
+ { type: "command", command: `${P.node} ${JSON.stringify(P.cli)} hook cursor stop` },
874
+ ];
875
+ writeJson(hooksFile, h);
876
+ actions.push(`stop hook in ${hooksFile} (hooks v1, project-level)`);
877
+ // 3. own Always-apply rule file (whole file is ours → trivially clean uninstall)
878
+ const ruleFile = path.join(cursorDir, "rules", "load-lock.mdc");
879
+ fs.mkdirSync(path.dirname(ruleFile), { recursive: true });
880
+ fs.writeFileSync(ruleFile, cursorRuleText());
881
+ actions.push(`Always-apply rule file ${ruleFile} (.cursor/rules, survives compaction)`);
882
+ // 4. be honest about the tier and verification status
883
+ actions.push("tier: MCP + stop-hook wakeup — the stop hook returns {\"followup_message\":...} so Cursor continues " +
884
+ "the session when a load-lock offer arrives; contract-tested against the documented Cursor hooks " +
885
+ "JSON (https://cursor.com/docs/hooks), not live-verified");
886
+ actions.push("restart Cursor (or reload the window) to pick up the MCP server and hooks");
887
+ return actions;
888
+ }
889
+ function uninstallCursor(opts) {
890
+ const actions = [];
891
+ const cursorDir = path.join(opts.project, ".cursor");
892
+ const P = pkgPaths(); // only needed for our-entry matching below
893
+ // 1. MCP server
894
+ const mcpFile = path.join(cursorDir, "mcp.json");
895
+ actions.push(...removeMcpServer(mcpFile, `removed MCP server from ${mcpFile}`));
896
+ const mcpAfter = readJson(mcpFile);
897
+ if (mcpAfter &&
898
+ typeof mcpAfter === "object" &&
899
+ Object.keys(mcpAfter).length === 1 &&
900
+ typeof mcpAfter.mcpServers === "object" &&
901
+ Object.keys(mcpAfter.mcpServers).length === 0) {
902
+ fs.rmSync(mcpFile); // we created it; nothing user-owned was ever in it
903
+ actions.push(`removed ${mcpFile} (nothing else was in it)`);
904
+ }
905
+ // 2. hooks: remove exactly our entries from every event
906
+ const hooksFile = path.join(cursorDir, "hooks.json");
907
+ const h = readJson(hooksFile);
908
+ if (h && typeof h === "object" && h !== null) {
909
+ let changed = false;
910
+ if (h.hooks && typeof h.hooks === "object" && !Array.isArray(h.hooks)) {
911
+ for (const ev of Object.keys(h.hooks)) {
912
+ if (!Array.isArray(h.hooks[ev]))
913
+ continue;
914
+ const list = h.hooks[ev];
915
+ const kept = list.filter((e) => !isOurHook(e, P.cli, "cursor"));
916
+ if (kept.length !== list.length) {
917
+ changed = true;
918
+ if (kept.length)
919
+ h.hooks[ev] = kept;
920
+ else
921
+ delete h.hooks[ev];
922
+ }
923
+ }
924
+ if (Object.keys(h.hooks).length === 0)
925
+ delete h.hooks;
926
+ }
927
+ if (changed) {
928
+ // If only our bare-install shape remains ({version:1} or {}), the file
929
+ // is ours — remove it. Anything user-owned stays untouched.
930
+ if (Object.keys(h).length === 0 || (Object.keys(h).length === 1 && h.version === 1)) {
931
+ fs.rmSync(hooksFile);
932
+ actions.push(`removed ${hooksFile} (nothing else was in it)`);
933
+ }
934
+ else {
935
+ writeJson(hooksFile, h);
936
+ actions.push(`removed stop hook from ${hooksFile}`);
937
+ }
938
+ }
939
+ }
940
+ // 3. our rule file
941
+ const ruleFile = path.join(cursorDir, "rules", "load-lock.mdc");
942
+ if (fs.existsSync(ruleFile)) {
943
+ fs.rmSync(ruleFile);
944
+ actions.push(`removed ${ruleFile}`);
945
+ pruneEmptyDirs(path.dirname(ruleFile), path.dirname(ruleFile)); // rules/ if now empty
946
+ }
947
+ // .cursor/ itself, if we left it empty
948
+ pruneEmptyDirs(cursorDir, cursorDir);
949
+ return actions;
950
+ }
313
951
  // -------------------------------------------------------------------- public
314
952
  export async function installCommand(opts) {
315
953
  const P = pkgPaths(opts.binDir);
@@ -318,7 +956,7 @@ export async function installCommand(opts) {
318
956
  }
319
957
  const harness = opts.harness;
320
958
  if (!harness)
321
- throw new Error("install: --harness <opencode|claude-code|codex|generic> is required");
959
+ throw new Error("install: --harness <opencode|claude-code|codex|cursor|gemini-cli|copilot-cli|cline|roo-code|goose|generic> is required");
322
960
  let actions;
323
961
  switch (harness) {
324
962
  case "opencode":
@@ -331,6 +969,29 @@ export async function installCommand(opts) {
331
969
  case "codex":
332
970
  actions = installCodex(opts, P);
333
971
  break;
972
+ case "cursor":
973
+ actions = installCursor(opts, P);
974
+ break;
975
+ case "gemini-cli":
976
+ case "gemini":
977
+ actions = installGeminiCli(opts, P);
978
+ break;
979
+ case "copilot-cli":
980
+ case "copilot":
981
+ actions = installCopilotCli(opts, P);
982
+ break;
983
+ case "cline":
984
+ actions = installCline(opts, P);
985
+ break;
986
+ case "roo-code":
987
+ case "roo":
988
+ case "zoo-code":
989
+ case "zoo":
990
+ actions = installRooCode(opts, P);
991
+ break;
992
+ case "goose":
993
+ actions = installGoose(opts, P);
994
+ break;
334
995
  case "generic":
335
996
  case "all":
336
997
  default:
@@ -360,6 +1021,29 @@ export async function uninstallCommand(opts) {
360
1021
  case "codex":
361
1022
  actions = uninstallCodex();
362
1023
  break;
1024
+ case "cursor":
1025
+ actions = uninstallCursor(opts);
1026
+ break;
1027
+ case "gemini-cli":
1028
+ case "gemini":
1029
+ actions = uninstallGeminiCli();
1030
+ break;
1031
+ case "copilot-cli":
1032
+ case "copilot":
1033
+ actions = uninstallCopilotCli();
1034
+ break;
1035
+ case "cline":
1036
+ actions = uninstallCline();
1037
+ break;
1038
+ case "roo-code":
1039
+ case "roo":
1040
+ case "zoo-code":
1041
+ case "zoo":
1042
+ actions = uninstallRooCode(opts);
1043
+ break;
1044
+ case "goose":
1045
+ actions = uninstallGoose();
1046
+ break;
363
1047
  default:
364
1048
  throw new Error(`uninstall: unknown harness "${opts.harness}"`);
365
1049
  }