@aron98/load-lock 0.1.2 → 1.1.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.
- package/README.md +40 -12
- package/demo/demo.sh +1 -1
- package/dist/adapters/opencode/plugin.js +39 -18
- package/dist/adapters/opencode/tui.d.ts +61 -0
- package/dist/adapters/opencode/tui.js +358 -0
- package/dist/cli/hook.js +35 -1
- package/dist/cli/install.js +708 -24
- package/dist/cli/main.js +130 -61
- package/dist/cli/top.d.ts +7 -0
- package/dist/cli/top.js +99 -0
- package/dist/core/daemon.d.ts +25 -4
- package/dist/core/daemon.js +67 -30
- package/dist/core/persist.js +5 -12
- package/dist/core/state.js +7 -0
- package/dist/dashboard/feed.d.ts +74 -0
- package/dist/dashboard/feed.js +256 -0
- package/dist/dashboard/frame.d.ts +13 -0
- package/dist/dashboard/frame.js +95 -0
- package/dist/dashboard/view.d.ts +149 -0
- package/dist/dashboard/view.js +214 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +54 -19
- package/dist/policy/text.d.ts +1 -1
- package/dist/policy/text.js +3 -0
- package/dist/shared/client.d.ts +10 -0
- package/dist/shared/client.js +41 -14
- package/dist/shared/protocol.d.ts +29 -0
- package/dist/shared/transport.d.ts +40 -0
- package/dist/shared/transport.js +136 -0
- package/dist/shared/wait-turn.d.ts +29 -0
- package/dist/shared/wait-turn.js +315 -0
- package/dist/test/cli.test.d.ts +1 -0
- package/dist/test/cli.test.js +99 -0
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +406 -0
- package/dist/test/hook.test.js +53 -3
- package/dist/test/install.test.d.ts +1 -0
- package/dist/test/install.test.js +658 -0
- package/dist/test/mcp.test.js +36 -1
- package/dist/test/platform.test.d.ts +1 -0
- package/dist/test/platform.test.js +193 -0
- package/dist/test/testutil.js +4 -1
- package/dist/test/tui.test.d.ts +1 -0
- package/dist/test/tui.test.js +89 -0
- package/dist/test/wait-turn.test.d.ts +1 -0
- package/dist/test/wait-turn.test.js +376 -0
- package/docs/ADAPTERS.md +5 -2
- package/docs/AGENT_GUIDE.md +34 -1
- package/docs/ARCHITECTURE.md +25 -5
- package/docs/COMPATIBILITY.md +217 -26
- package/docs/OPERATIONS.md +335 -8
- package/package.json +25 -1
- package/skills/load-lock/SKILL.md +4 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Round-trip tests for `load-lock install/uninstall --harness ...`, run
|
|
3
|
+
* against a temp HOME (and temp XDG_CONFIG_HOME / CLINE_MCP_SETTINGS_PATH)
|
|
4
|
+
* so real user config is never read or damaged.
|
|
5
|
+
*/
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join, dirname, resolve } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const cliMain = join(HERE, "..", "cli", "main.js");
|
|
15
|
+
const mcpMain = resolve(HERE, "..", "mcp", "main.js");
|
|
16
|
+
const MARKER_BEGIN = "<!-- load-lock:begin -->";
|
|
17
|
+
const MARKER_END = "<!-- load-lock:end -->";
|
|
18
|
+
/** Run the compiled CLI in a child process with HOME overridden. */
|
|
19
|
+
function runCli(home, args, extraEnv = {}) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const child = spawn(process.execPath, [cliMain, ...args], {
|
|
22
|
+
env: { ...process.env, HOME: home, ...extraEnv },
|
|
23
|
+
});
|
|
24
|
+
let stdout = "";
|
|
25
|
+
let stderr = "";
|
|
26
|
+
child.stdout.on("data", (b) => (stdout += b));
|
|
27
|
+
child.stderr.on("data", (b) => (stderr += b));
|
|
28
|
+
child.stdin.end();
|
|
29
|
+
child.once("exit", (code) => resolve({ code, stdout, stderr }));
|
|
30
|
+
child.once("error", reject);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function tempHome() {
|
|
34
|
+
return mkdtempSync(join(tmpdir(), "ll-install-home-"));
|
|
35
|
+
}
|
|
36
|
+
function count(haystack, needle) {
|
|
37
|
+
return haystack.split(needle).length - 1;
|
|
38
|
+
}
|
|
39
|
+
test("gemini-cli: install/uninstall round-trip on a populated HOME", async () => {
|
|
40
|
+
const home = tempHome();
|
|
41
|
+
const geminiDir = join(home, ".gemini");
|
|
42
|
+
mkdirSync(geminiDir, { recursive: true });
|
|
43
|
+
// pre-existing user config that must survive untouched
|
|
44
|
+
writeFileSync(join(geminiDir, "settings.json"), JSON.stringify({ theme: "auto", mcpServers: { fetch: { command: "uvx", args: ["mcp-server-fetch"] } } }, null, 2) + "\n");
|
|
45
|
+
writeFileSync(join(geminiDir, "GEMINI.md"), "# My rules\n\nBe terse.\n");
|
|
46
|
+
const settingsFile = join(geminiDir, "settings.json");
|
|
47
|
+
const geminiMd = join(geminiDir, "GEMINI.md");
|
|
48
|
+
// -- install
|
|
49
|
+
let r = await runCli(home, ["install", "--harness", "gemini-cli"]);
|
|
50
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
51
|
+
// honest tier statement in the installer output
|
|
52
|
+
assert.match(r.stdout, /MCP fallback/i, "installer output must state the MCP-fallback tier");
|
|
53
|
+
assert.match(r.stdout, /no idle wakeup/i, "installer output must not claim wakeup support");
|
|
54
|
+
const s = JSON.parse(readFileSync(settingsFile, "utf8"));
|
|
55
|
+
assert.equal(s.theme, "auto", "unrelated user settings must be preserved");
|
|
56
|
+
assert.deepEqual(s.mcpServers.fetch, { command: "uvx", args: ["mcp-server-fetch"] }, "unrelated MCP server must be preserved");
|
|
57
|
+
assert.deepEqual(s.mcpServers["load-lock"], { command: process.execPath, args: [mcpMain] }, "load-lock stdio server with absolute paths");
|
|
58
|
+
const md = readFileSync(geminiMd, "utf8");
|
|
59
|
+
assert.ok(md.includes("# My rules"), "existing GEMINI.md content must be preserved");
|
|
60
|
+
assert.equal(count(md, MARKER_BEGIN), 1, "exactly one marked section");
|
|
61
|
+
assert.equal(count(md, MARKER_END), 1, "exactly one section end marker");
|
|
62
|
+
assert.ok(md.includes("wait_ms"), "section teaches the bounded wait");
|
|
63
|
+
// -- idempotent re-install: no duplicate sections, no config damage
|
|
64
|
+
r = await runCli(home, ["install", "--harness", "gemini-cli"]);
|
|
65
|
+
assert.equal(r.code, 0, `re-install failed: ${r.stderr}`);
|
|
66
|
+
assert.equal(count(readFileSync(geminiMd, "utf8"), MARKER_BEGIN), 1, "re-install must not duplicate the section");
|
|
67
|
+
assert.deepEqual(JSON.parse(readFileSync(settingsFile, "utf8")).mcpServers["load-lock"], {
|
|
68
|
+
command: process.execPath,
|
|
69
|
+
args: [mcpMain],
|
|
70
|
+
});
|
|
71
|
+
// -- uninstall mirrors install exactly
|
|
72
|
+
r = await runCli(home, ["uninstall", "--harness", "gemini-cli"]);
|
|
73
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
74
|
+
const s2 = JSON.parse(readFileSync(settingsFile, "utf8"));
|
|
75
|
+
assert.equal(s2.theme, "auto", "unrelated user settings must survive uninstall");
|
|
76
|
+
assert.ok(!("load-lock" in s2.mcpServers), "our MCP server key removed");
|
|
77
|
+
assert.deepEqual(s2.mcpServers.fetch, { command: "uvx", args: ["mcp-server-fetch"] }, "other MCP servers untouched");
|
|
78
|
+
const md2 = readFileSync(geminiMd, "utf8");
|
|
79
|
+
assert.ok(!md2.includes(MARKER_BEGIN), "marked section removed");
|
|
80
|
+
assert.ok(md2.includes("# My rules"), "user markdown untouched");
|
|
81
|
+
assert.ok(!existsSync(join(geminiDir, "skills")), "no stray files");
|
|
82
|
+
rmSync(home, { recursive: true, force: true });
|
|
83
|
+
});
|
|
84
|
+
test("gemini-cli: install on a bare HOME creates both files; uninstall cleans them", async () => {
|
|
85
|
+
const home = tempHome();
|
|
86
|
+
const geminiDir = join(home, ".gemini");
|
|
87
|
+
const settingsFile = join(geminiDir, "settings.json");
|
|
88
|
+
const geminiMd = join(geminiDir, "GEMINI.md");
|
|
89
|
+
let r = await runCli(home, ["install", "--harness", "gemini-cli"]);
|
|
90
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
91
|
+
const s = JSON.parse(readFileSync(settingsFile, "utf8"));
|
|
92
|
+
assert.deepEqual(s.mcpServers["load-lock"], { command: process.execPath, args: [mcpMain] });
|
|
93
|
+
const md = readFileSync(geminiMd, "utf8");
|
|
94
|
+
assert.ok(md.includes(MARKER_BEGIN) && md.includes(MARKER_END));
|
|
95
|
+
r = await runCli(home, ["uninstall", "--harness", "gemini-cli"]);
|
|
96
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
97
|
+
const s2 = JSON.parse(readFileSync(settingsFile, "utf8"));
|
|
98
|
+
assert.ok(!("load-lock" in (s2.mcpServers ?? {})), "load-lock key gone");
|
|
99
|
+
assert.ok(!readFileSync(geminiMd, "utf8").includes(MARKER_BEGIN), "section gone");
|
|
100
|
+
rmSync(home, { recursive: true, force: true });
|
|
101
|
+
});
|
|
102
|
+
test("gemini-cli: uninstall with nothing installed is a harmless no-op", async () => {
|
|
103
|
+
const home = tempHome();
|
|
104
|
+
const r = await runCli(home, ["uninstall", "--harness", "gemini-cli"]);
|
|
105
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
106
|
+
assert.ok(!existsSync(join(home, ".gemini")), "no files created by uninstall");
|
|
107
|
+
rmSync(home, { recursive: true, force: true });
|
|
108
|
+
});
|
|
109
|
+
test("gemini-cli: aliases `gemini` map to the same installer", async () => {
|
|
110
|
+
const home = tempHome();
|
|
111
|
+
const r = await runCli(home, ["install", "--harness", "gemini"]);
|
|
112
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
113
|
+
assert.ok(existsSync(join(home, ".gemini", "settings.json")));
|
|
114
|
+
rmSync(home, { recursive: true, force: true });
|
|
115
|
+
});
|
|
116
|
+
// ------------------------------------------------------------- copilot-cli
|
|
117
|
+
test("copilot-cli: install/uninstall round-trip on a populated HOME", async () => {
|
|
118
|
+
const home = tempHome();
|
|
119
|
+
const copilotDir = join(home, ".copilot");
|
|
120
|
+
mkdirSync(copilotDir, { recursive: true });
|
|
121
|
+
// pre-existing user config that must survive untouched
|
|
122
|
+
writeFileSync(join(copilotDir, "mcp-config.json"), JSON.stringify({ mcpServers: { fetch: { type: "local", command: "uvx", args: ["mcp-server-fetch"] } } }, null, 2) + "\n");
|
|
123
|
+
writeFileSync(join(copilotDir, "copilot-instructions.md"), "# My instructions\n\nBe terse.\n");
|
|
124
|
+
const cfgFile = join(copilotDir, "mcp-config.json");
|
|
125
|
+
const instructions = join(copilotDir, "copilot-instructions.md");
|
|
126
|
+
// -- install
|
|
127
|
+
let r = await runCli(home, ["install", "--harness", "copilot-cli"]);
|
|
128
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
129
|
+
assert.match(r.stdout, /MCP fallback/i, "installer output must state the MCP-fallback tier");
|
|
130
|
+
assert.match(r.stdout, /no idle wakeup/i, "installer output must not claim wakeup support");
|
|
131
|
+
const s = JSON.parse(readFileSync(cfgFile, "utf8"));
|
|
132
|
+
assert.deepEqual(s.mcpServers.fetch, { type: "local", command: "uvx", args: ["mcp-server-fetch"] }, "unrelated MCP server must be preserved");
|
|
133
|
+
assert.deepEqual(s.mcpServers["load-lock"], { type: "local", command: process.execPath, args: [mcpMain], env: {}, tools: ["*"] }, "load-lock stdio (type: local) server with absolute paths");
|
|
134
|
+
const md = readFileSync(instructions, "utf8");
|
|
135
|
+
assert.ok(md.includes("# My instructions"), "existing instructions must be preserved");
|
|
136
|
+
assert.equal(count(md, MARKER_BEGIN), 1, "exactly one marked section");
|
|
137
|
+
assert.equal(count(md, MARKER_END), 1, "exactly one section end marker");
|
|
138
|
+
assert.ok(md.includes("wait_ms"), "section teaches the bounded wait");
|
|
139
|
+
// -- idempotent re-install
|
|
140
|
+
r = await runCli(home, ["install", "--harness", "copilot-cli"]);
|
|
141
|
+
assert.equal(r.code, 0, `re-install failed: ${r.stderr}`);
|
|
142
|
+
assert.equal(count(readFileSync(instructions, "utf8"), MARKER_BEGIN), 1, "re-install must not duplicate the section");
|
|
143
|
+
// -- uninstall removes exactly our entries
|
|
144
|
+
r = await runCli(home, ["uninstall", "--harness", "copilot-cli"]);
|
|
145
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
146
|
+
const s2 = JSON.parse(readFileSync(cfgFile, "utf8"));
|
|
147
|
+
assert.ok(!("load-lock" in s2.mcpServers), "our MCP server key removed");
|
|
148
|
+
assert.ok(s2.mcpServers.fetch, "other MCP servers untouched");
|
|
149
|
+
const md2 = readFileSync(instructions, "utf8");
|
|
150
|
+
assert.ok(!md2.includes(MARKER_BEGIN), "marked section removed");
|
|
151
|
+
assert.ok(md2.includes("# My instructions"), "user instructions untouched");
|
|
152
|
+
rmSync(home, { recursive: true, force: true });
|
|
153
|
+
});
|
|
154
|
+
test("copilot-cli: bare HOME install via alias `copilot`; uninstall is clean", async () => {
|
|
155
|
+
const home = tempHome();
|
|
156
|
+
let r = await runCli(home, ["install", "--harness", "copilot"]);
|
|
157
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
158
|
+
const cfgFile = join(home, ".copilot", "mcp-config.json");
|
|
159
|
+
const instructions = join(home, ".copilot", "copilot-instructions.md");
|
|
160
|
+
assert.ok(existsSync(cfgFile) && existsSync(instructions), "bare install creates both files");
|
|
161
|
+
assert.ok(JSON.parse(readFileSync(cfgFile, "utf8")).mcpServers["load-lock"], "server entry present");
|
|
162
|
+
r = await runCli(home, ["uninstall", "--harness", "copilot-cli"]);
|
|
163
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
164
|
+
assert.ok(!("load-lock" in JSON.parse(readFileSync(cfgFile, "utf8")).mcpServers), "our key removed");
|
|
165
|
+
assert.ok(!readFileSync(instructions, "utf8").includes(MARKER_BEGIN), "section removed");
|
|
166
|
+
rmSync(home, { recursive: true, force: true });
|
|
167
|
+
});
|
|
168
|
+
test("copilot-cli: uninstall with nothing installed is a harmless no-op", async () => {
|
|
169
|
+
const home = tempHome();
|
|
170
|
+
const r = await runCli(home, ["uninstall", "--harness", "copilot-cli"]);
|
|
171
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
172
|
+
assert.ok(!existsSync(join(home, ".copilot")), "no files created by uninstall");
|
|
173
|
+
rmSync(home, { recursive: true, force: true });
|
|
174
|
+
});
|
|
175
|
+
// -------------------------------------------------------------------- cline
|
|
176
|
+
test("cline: round-trip on the default settings path, other rules preserved", async () => {
|
|
177
|
+
const home = tempHome();
|
|
178
|
+
const settingsDir = join(home, ".cline", "data", "settings");
|
|
179
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
180
|
+
writeFileSync(join(settingsDir, "cline_mcp_settings.json"), JSON.stringify({ mcpServers: { fetch: { command: "uvx", args: ["mcp-server-fetch"], env: {}, disabled: false, autoApprove: [] } } }, null, 2) + "\n");
|
|
181
|
+
mkdirSync(join(home, ".cline", "rules"), { recursive: true });
|
|
182
|
+
writeFileSync(join(home, ".cline", "rules", "user-rule.md"), "# User rule\n");
|
|
183
|
+
const cfgFile = join(settingsDir, "cline_mcp_settings.json");
|
|
184
|
+
const ruleFile = join(home, ".cline", "rules", "load-lock.md");
|
|
185
|
+
// -- install (explicitly clear any host CLINE_MCP_SETTINGS_PATH)
|
|
186
|
+
let r = await runCli(home, ["install", "--harness", "cline"], { CLINE_MCP_SETTINGS_PATH: "" });
|
|
187
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
188
|
+
assert.match(r.stdout, /MCP fallback/i);
|
|
189
|
+
assert.match(r.stdout, /no idle wakeup/i);
|
|
190
|
+
const s = JSON.parse(readFileSync(cfgFile, "utf8"));
|
|
191
|
+
assert.deepEqual(s.mcpServers.fetch.command, "uvx", "unrelated MCP server preserved");
|
|
192
|
+
assert.deepEqual(s.mcpServers["load-lock"], { command: process.execPath, args: [mcpMain], env: {}, disabled: false, autoApprove: [] }, "load-lock stdio entry, Cline shape");
|
|
193
|
+
const rule = readFileSync(ruleFile, "utf8");
|
|
194
|
+
assert.ok(rule.includes("load-lock"), "rule file teaches load-lock");
|
|
195
|
+
assert.ok(existsSync(join(home, ".cline", "rules", "user-rule.md")), "user rule untouched");
|
|
196
|
+
// -- uninstall removes exactly our entries
|
|
197
|
+
r = await runCli(home, ["uninstall", "--harness", "cline"], { CLINE_MCP_SETTINGS_PATH: "" });
|
|
198
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
199
|
+
const s2 = JSON.parse(readFileSync(cfgFile, "utf8"));
|
|
200
|
+
assert.ok(!("load-lock" in s2.mcpServers), "our MCP server key removed");
|
|
201
|
+
assert.ok(s2.mcpServers.fetch, "other MCP servers untouched");
|
|
202
|
+
assert.ok(!existsSync(ruleFile), "our rule file removed");
|
|
203
|
+
assert.ok(existsSync(join(home, ".cline", "rules", "user-rule.md")), "user rule preserved");
|
|
204
|
+
rmSync(home, { recursive: true, force: true });
|
|
205
|
+
});
|
|
206
|
+
test("cline: honors CLINE_MCP_SETTINGS_PATH and prunes empty dirs on uninstall", async () => {
|
|
207
|
+
const home = tempHome();
|
|
208
|
+
const custom = join(home, "custom-mcp-settings.json");
|
|
209
|
+
writeFileSync(custom, JSON.stringify({ mcpServers: {} }) + "\n");
|
|
210
|
+
let r = await runCli(home, ["install", "--harness", "cline"], { CLINE_MCP_SETTINGS_PATH: custom });
|
|
211
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
212
|
+
assert.ok("load-lock" in JSON.parse(readFileSync(custom, "utf8")).mcpServers, "entry written to the overridden path");
|
|
213
|
+
assert.ok(!existsSync(join(home, ".cline", "data")), "default settings path not created when overridden");
|
|
214
|
+
assert.ok(existsSync(join(home, ".cline", "rules", "load-lock.md")), "rule file installed");
|
|
215
|
+
r = await runCli(home, ["uninstall", "--harness", "cline"], { CLINE_MCP_SETTINGS_PATH: custom });
|
|
216
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
217
|
+
assert.ok(!("load-lock" in JSON.parse(readFileSync(custom, "utf8")).mcpServers), "our key removed from the overridden path");
|
|
218
|
+
assert.ok(!existsSync(join(home, ".cline")), "empty ~/.cline tree pruned");
|
|
219
|
+
rmSync(home, { recursive: true, force: true });
|
|
220
|
+
});
|
|
221
|
+
test("cline: uninstall with nothing installed is a harmless no-op", async () => {
|
|
222
|
+
const home = tempHome();
|
|
223
|
+
const r = await runCli(home, ["uninstall", "--harness", "cline"], { CLINE_MCP_SETTINGS_PATH: "" });
|
|
224
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
225
|
+
assert.ok(!existsSync(join(home, ".cline")), "no files created by uninstall");
|
|
226
|
+
rmSync(home, { recursive: true, force: true });
|
|
227
|
+
});
|
|
228
|
+
// ----------------------------------------------------------------- roo-code
|
|
229
|
+
test("roo-code: project .roo/mcp.json round-trip; honest note when no global file exists", async () => {
|
|
230
|
+
const home = tempHome();
|
|
231
|
+
const proj = join(home, "proj");
|
|
232
|
+
mkdirSync(proj, { recursive: true });
|
|
233
|
+
const xdg = join(home, ".config");
|
|
234
|
+
const projMcp = join(proj, ".roo", "mcp.json");
|
|
235
|
+
// -- install with --project
|
|
236
|
+
let r = await runCli(home, ["install", "--harness", "roo-code", "--project", proj], { XDG_CONFIG_HOME: xdg });
|
|
237
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
238
|
+
assert.match(r.stdout, /MCP fallback/i);
|
|
239
|
+
assert.match(r.stdout, /no idle wakeup/i);
|
|
240
|
+
assert.match(r.stdout, /no global Roo\/Zoo/, "must honestly note it did not find a global mcp_settings.json");
|
|
241
|
+
const s = JSON.parse(readFileSync(projMcp, "utf8"));
|
|
242
|
+
assert.deepEqual(s.mcpServers["load-lock"], { command: process.execPath, args: [mcpMain], disabled: false }, "project-level stdio entry");
|
|
243
|
+
assert.ok(existsSync(join(home, ".roo", "rules", "load-lock.md")), "global rule file installed");
|
|
244
|
+
// -- idempotent re-install
|
|
245
|
+
r = await runCli(home, ["install", "--harness", "roo-code", "--project", proj], { XDG_CONFIG_HOME: xdg });
|
|
246
|
+
assert.equal(r.code, 0, `re-install failed: ${r.stderr}`);
|
|
247
|
+
assert.equal(Object.keys(JSON.parse(readFileSync(projMcp, "utf8")).mcpServers).length, 1, "still exactly one server");
|
|
248
|
+
// -- uninstall (same --project)
|
|
249
|
+
r = await runCli(home, ["uninstall", "--harness", "roo-code", "--project", proj], { XDG_CONFIG_HOME: xdg });
|
|
250
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
251
|
+
const s2 = JSON.parse(readFileSync(projMcp, "utf8"));
|
|
252
|
+
assert.ok(!("load-lock" in s2.mcpServers), "our key removed from the project file");
|
|
253
|
+
assert.ok(!existsSync(join(home, ".roo", "rules", "load-lock.md")), "our rule file removed");
|
|
254
|
+
assert.ok(!existsSync(join(home, ".roo")), "empty ~/.roo tree pruned");
|
|
255
|
+
rmSync(home, { recursive: true, force: true });
|
|
256
|
+
});
|
|
257
|
+
test("roo-code: merges into an existing global mcp_settings.json (never creates one)", async () => {
|
|
258
|
+
const home = tempHome();
|
|
259
|
+
const proj = join(home, "proj");
|
|
260
|
+
mkdirSync(proj, { recursive: true });
|
|
261
|
+
const xdg = join(home, ".config");
|
|
262
|
+
const globalMcp = join(xdg, "Code", "User", "globalStorage", "RooCodeInc.roo-code", "settings", "mcp_settings.json");
|
|
263
|
+
mkdirSync(dirname(globalMcp), { recursive: true });
|
|
264
|
+
writeFileSync(globalMcp, JSON.stringify({ mcpServers: { fetch: { command: "uvx", args: ["mcp-server-fetch"] } } }, null, 2) + "\n");
|
|
265
|
+
let r = await runCli(home, ["install", "--harness", "roo-code", "--project", proj], { XDG_CONFIG_HOME: xdg });
|
|
266
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
267
|
+
const g = JSON.parse(readFileSync(globalMcp, "utf8"));
|
|
268
|
+
assert.ok("load-lock" in g.mcpServers, "global file (found, not created) got our server");
|
|
269
|
+
assert.deepEqual(g.mcpServers.fetch.command, "uvx", "unrelated global server preserved");
|
|
270
|
+
r = await runCli(home, ["uninstall", "--harness", "roo-code", "--project", proj], { XDG_CONFIG_HOME: xdg });
|
|
271
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
272
|
+
const g2 = JSON.parse(readFileSync(globalMcp, "utf8"));
|
|
273
|
+
assert.ok(!("load-lock" in g2.mcpServers), "our key removed from the global file");
|
|
274
|
+
assert.ok(g2.mcpServers.fetch, "user's global server untouched");
|
|
275
|
+
rmSync(home, { recursive: true, force: true });
|
|
276
|
+
});
|
|
277
|
+
test("roo-code: aliases `roo`/`zoo-code`/`zoo` map to the same installer", async () => {
|
|
278
|
+
const home = tempHome();
|
|
279
|
+
const proj = join(home, "proj");
|
|
280
|
+
mkdirSync(proj, { recursive: true });
|
|
281
|
+
for (const alias of ["roo", "zoo-code", "zoo"]) {
|
|
282
|
+
const r = await runCli(home, ["install", "--harness", alias, "--project", proj], { XDG_CONFIG_HOME: join(home, ".config") });
|
|
283
|
+
assert.equal(r.code, 0, `install --harness ${alias} failed: ${r.stderr}`);
|
|
284
|
+
}
|
|
285
|
+
const r = await runCli(home, ["uninstall", "--harness", "roo", "--project", proj], { XDG_CONFIG_HOME: join(home, ".config") });
|
|
286
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
287
|
+
assert.ok(!("load-lock" in JSON.parse(readFileSync(join(proj, ".roo", "mcp.json"), "utf8")).mcpServers), "uninstalled via alias");
|
|
288
|
+
rmSync(home, { recursive: true, force: true });
|
|
289
|
+
});
|
|
290
|
+
// -------------------------------------------------------------------- goose
|
|
291
|
+
test("goose: install/uninstall round-trip restores config.yaml and hints exactly", async () => {
|
|
292
|
+
const home = tempHome();
|
|
293
|
+
const xdg = join(home, ".config");
|
|
294
|
+
const gooseDir = join(xdg, "goose");
|
|
295
|
+
mkdirSync(gooseDir, { recursive: true });
|
|
296
|
+
const beforeCfg = "GOOSE_MODE: auto\n\ndefault_cli: true\n";
|
|
297
|
+
const beforeHints = "# My hints\n\nBe terse.\n";
|
|
298
|
+
writeFileSync(join(gooseDir, "config.yaml"), beforeCfg);
|
|
299
|
+
writeFileSync(join(gooseDir, ".goosehints"), beforeHints);
|
|
300
|
+
const cfgFile = join(gooseDir, "config.yaml");
|
|
301
|
+
const hints = join(gooseDir, ".goosehints");
|
|
302
|
+
// -- install
|
|
303
|
+
let r = await runCli(home, ["install", "--harness", "goose"], { XDG_CONFIG_HOME: xdg });
|
|
304
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
305
|
+
assert.match(r.stdout, /MCP fallback/i);
|
|
306
|
+
assert.match(r.stdout, /no idle wakeup/i);
|
|
307
|
+
const cfg = readFileSync(cfgFile, "utf8");
|
|
308
|
+
assert.ok(cfg.includes("GOOSE_MODE: auto") && cfg.includes("default_cli: true"), "user keys preserved");
|
|
309
|
+
assert.match(cfg, /extensions:\s*# load-lock-created/, "our tagged extensions key");
|
|
310
|
+
assert.match(cfg, /^\s{2}load-lock:\s*$/m, "load-lock child present");
|
|
311
|
+
assert.match(cfg, /^\s{4}type: stdio\s*$/m, "stdio extension type");
|
|
312
|
+
assert.match(cfg, new RegExp(`^\\s{4}cmd: ${JSON.stringify(process.execPath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m"), "absolute node path");
|
|
313
|
+
assert.match(cfg, /# load-lock:begin \(managed by `load-lock install`\)/, "managed-block begin marker");
|
|
314
|
+
assert.ok(readFileSync(hints, "utf8").includes("# My hints"), "user hints preserved");
|
|
315
|
+
assert.equal(count(readFileSync(hints, "utf8"), MARKER_BEGIN), 1, "one marked hints section");
|
|
316
|
+
// -- idempotent re-install: byte-identical
|
|
317
|
+
r = await runCli(home, ["install", "--harness", "goose"], { XDG_CONFIG_HOME: xdg });
|
|
318
|
+
assert.equal(r.code, 0, `re-install failed: ${r.stderr}`);
|
|
319
|
+
assert.equal(readFileSync(cfgFile, "utf8"), cfg, "re-install is byte-identical");
|
|
320
|
+
assert.equal(count(readFileSync(hints, "utf8"), MARKER_BEGIN), 1, "no duplicate hints section");
|
|
321
|
+
// -- uninstall restores the user's bytes exactly
|
|
322
|
+
r = await runCli(home, ["uninstall", "--harness", "goose"], { XDG_CONFIG_HOME: xdg });
|
|
323
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
324
|
+
assert.equal(readFileSync(cfgFile, "utf8"), beforeCfg, "config.yaml restored byte-for-byte");
|
|
325
|
+
assert.equal(readFileSync(hints, "utf8"), beforeHints, "hints restored byte-for-byte");
|
|
326
|
+
rmSync(home, { recursive: true, force: true });
|
|
327
|
+
});
|
|
328
|
+
test("goose: replaces a colliding unmarked load-lock child, preserves siblings", async () => {
|
|
329
|
+
const home = tempHome();
|
|
330
|
+
const xdg = join(home, ".config");
|
|
331
|
+
const gooseDir = join(xdg, "goose");
|
|
332
|
+
mkdirSync(gooseDir, { recursive: true });
|
|
333
|
+
writeFileSync(join(gooseDir, "config.yaml"), "GOOSE_MODE: auto\n" +
|
|
334
|
+
"\n" +
|
|
335
|
+
"extensions:\n" +
|
|
336
|
+
" developer:\n" +
|
|
337
|
+
" type: stdio\n" +
|
|
338
|
+
" cmd: /usr/local/bin/my-server\n" +
|
|
339
|
+
" load-lock:\n" +
|
|
340
|
+
" type: stdio\n" +
|
|
341
|
+
" cmd: old-load-lock-server\n");
|
|
342
|
+
const cfgFile = join(gooseDir, "config.yaml");
|
|
343
|
+
let r = await runCli(home, ["install", "--harness", "goose"], { XDG_CONFIG_HOME: xdg });
|
|
344
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
345
|
+
const cfg = readFileSync(cfgFile, "utf8");
|
|
346
|
+
assert.ok(!cfg.includes("old-load-lock-server"), "colliding unmarked load-lock child replaced, not duplicated");
|
|
347
|
+
assert.ok(cfg.includes("/usr/local/bin/my-server"), "sibling extension preserved");
|
|
348
|
+
assert.equal(count(cfg, "\n load-lock:"), 1, "exactly one load-lock child key (no YAML duplicate)");
|
|
349
|
+
r = await runCli(home, ["uninstall", "--harness", "goose"], { XDG_CONFIG_HOME: xdg });
|
|
350
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
351
|
+
const after = readFileSync(cfgFile, "utf8");
|
|
352
|
+
assert.ok(after.includes("/usr/local/bin/my-server"), "sibling extension still there after uninstall");
|
|
353
|
+
assert.ok(!after.includes("load-lock:begin"), "our managed block gone");
|
|
354
|
+
rmSync(home, { recursive: true, force: true });
|
|
355
|
+
});
|
|
356
|
+
test("goose: refuses flow-style extensions instead of guessing", async () => {
|
|
357
|
+
const home = tempHome();
|
|
358
|
+
const xdg = join(home, ".config");
|
|
359
|
+
const gooseDir = join(xdg, "goose");
|
|
360
|
+
mkdirSync(gooseDir, { recursive: true });
|
|
361
|
+
const before = "extensions: { developer: 1 }\n";
|
|
362
|
+
writeFileSync(join(gooseDir, "config.yaml"), before);
|
|
363
|
+
const r = await runCli(home, ["install", "--harness", "goose"], { XDG_CONFIG_HOME: xdg });
|
|
364
|
+
assert.notEqual(r.code, 0, "flow-style extensions must fail the install");
|
|
365
|
+
assert.match(r.stderr + r.stdout, /flow|block mapping/i, "error must explain the refusal");
|
|
366
|
+
assert.equal(readFileSync(join(gooseDir, "config.yaml"), "utf8"), before, "file untouched on refusal");
|
|
367
|
+
rmSync(home, { recursive: true, force: true });
|
|
368
|
+
});
|
|
369
|
+
test("goose: bare HOME install creates files; uninstall cleans them completely", async () => {
|
|
370
|
+
const home = tempHome();
|
|
371
|
+
const xdg = join(home, ".config");
|
|
372
|
+
const gooseDir = join(xdg, "goose");
|
|
373
|
+
let r = await runCli(home, ["install", "--harness", "goose"], { XDG_CONFIG_HOME: xdg });
|
|
374
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
375
|
+
assert.ok(existsSync(join(gooseDir, "config.yaml")), "config.yaml created");
|
|
376
|
+
assert.ok(existsSync(join(gooseDir, ".goosehints")), "global hints created");
|
|
377
|
+
r = await runCli(home, ["uninstall", "--harness", "goose"], { XDG_CONFIG_HOME: xdg });
|
|
378
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
379
|
+
assert.ok(!existsSync(gooseDir), "everything we created removed");
|
|
380
|
+
rmSync(home, { recursive: true, force: true });
|
|
381
|
+
});
|
|
382
|
+
test("goose: uninstall with nothing installed is a harmless no-op", async () => {
|
|
383
|
+
const home = tempHome();
|
|
384
|
+
const r = await runCli(home, ["uninstall", "--harness", "goose"], { XDG_CONFIG_HOME: join(home, ".config") });
|
|
385
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
386
|
+
assert.ok(!existsSync(join(home, ".config", "goose")), "no files created by uninstall");
|
|
387
|
+
rmSync(home, { recursive: true, force: true });
|
|
388
|
+
});
|
|
389
|
+
// --------------------------------------------------------------- claude-code
|
|
390
|
+
test("claude-code: install/uninstall round-trip on a populated HOME; re-install never duplicates hooks", async () => {
|
|
391
|
+
const home = tempHome();
|
|
392
|
+
const claudeDir = join(home, ".claude");
|
|
393
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
394
|
+
// pre-existing user config that must survive untouched
|
|
395
|
+
writeFileSync(join(home, ".claude.json"), JSON.stringify({ mcpServers: { fetch: { type: "stdio", command: "uvx", args: ["mcp-server-fetch"] } } }, null, 2) + "\n");
|
|
396
|
+
writeFileSync(join(claudeDir, "settings.json"), JSON.stringify({
|
|
397
|
+
theme: "dark",
|
|
398
|
+
hooks: {
|
|
399
|
+
PreToolUse: [{ hooks: [{ type: "command", command: "/usr/local/bin/guard" }] }],
|
|
400
|
+
Stop: [{ hooks: [{ type: "command", command: "/usr/local/bin/user-stop" }] }],
|
|
401
|
+
},
|
|
402
|
+
}, null, 2) + "\n");
|
|
403
|
+
writeFileSync(join(claudeDir, "CLAUDE.md"), "# My rules\n\nBe terse.\n");
|
|
404
|
+
const claudeJson = join(home, ".claude.json");
|
|
405
|
+
const settingsFile = join(claudeDir, "settings.json");
|
|
406
|
+
const claudeMd = join(claudeDir, "CLAUDE.md");
|
|
407
|
+
// -- install
|
|
408
|
+
let r = await runCli(home, ["install", "--harness", "claude-code"]);
|
|
409
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
410
|
+
const s = JSON.parse(readFileSync(settingsFile, "utf8"));
|
|
411
|
+
assert.equal(s.theme, "dark", "unrelated user settings preserved");
|
|
412
|
+
assert.equal(s.hooks.PreToolUse.length, 1, "unrelated event untouched");
|
|
413
|
+
assert.equal(s.hooks.SessionStart.length, 2, "our policy hook + await-offer watcher");
|
|
414
|
+
assert.ok(s.hooks.SessionStart[0].hooks[0].command.includes(JSON.stringify(cliMain)), "command embeds the JSON-quoted cli path");
|
|
415
|
+
assert.ok(s.hooks.SessionStart[0].hooks[0].command.endsWith("hook claude-code session-start"));
|
|
416
|
+
assert.equal(s.hooks.SessionStart[1].hooks[0].async, true, "await-offer watcher is async");
|
|
417
|
+
assert.ok(s.hooks.SessionStart[1].hooks[0].command.endsWith("hook claude-code await-offer"));
|
|
418
|
+
assert.equal(s.hooks.Stop.length, 2, "user Stop hook preserved, ours appended");
|
|
419
|
+
assert.equal(s.hooks.Stop[0].hooks[0].command, "/usr/local/bin/user-stop", "user's stop hook untouched");
|
|
420
|
+
assert.ok(s.hooks.Stop[1].hooks[0].command.endsWith("hook claude-code stop"));
|
|
421
|
+
const cj = JSON.parse(readFileSync(claudeJson, "utf8"));
|
|
422
|
+
assert.deepEqual(cj.mcpServers.fetch, { type: "stdio", command: "uvx", args: ["mcp-server-fetch"] }, "user MCP server preserved");
|
|
423
|
+
assert.deepEqual(cj.mcpServers["load-lock"], { type: "stdio", command: process.execPath, args: [mcpMain] }, "our MCP server shape");
|
|
424
|
+
const md = readFileSync(claudeMd, "utf8");
|
|
425
|
+
assert.ok(md.includes("# My rules"), "existing CLAUDE.md content preserved");
|
|
426
|
+
assert.equal(count(md, MARKER_BEGIN), 1, "exactly one marked section");
|
|
427
|
+
assert.ok(existsSync(join(claudeDir, "skills", "load-lock", "SKILL.md")), "skill installed");
|
|
428
|
+
// -- idempotent re-install: no duplicate hook entries (issue #19)
|
|
429
|
+
r = await runCli(home, ["install", "--harness", "claude-code"]);
|
|
430
|
+
assert.equal(r.code, 0, `re-install failed: ${r.stderr}`);
|
|
431
|
+
const s2 = JSON.parse(readFileSync(settingsFile, "utf8"));
|
|
432
|
+
assert.equal(s2.hooks.SessionStart.length, 2, "re-install must not duplicate SessionStart entries");
|
|
433
|
+
assert.equal(s2.hooks.Stop.length, 2, "re-install must not duplicate Stop entries");
|
|
434
|
+
assert.equal(count(readFileSync(claudeMd, "utf8"), MARKER_BEGIN), 1, "re-install must not duplicate the section");
|
|
435
|
+
// -- uninstall removes exactly our entries
|
|
436
|
+
r = await runCli(home, ["uninstall", "--harness", "claude-code"]);
|
|
437
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
438
|
+
const s3 = JSON.parse(readFileSync(settingsFile, "utf8"));
|
|
439
|
+
assert.ok(!("SessionStart" in s3.hooks), "our SessionStart entries removed");
|
|
440
|
+
assert.equal(s3.hooks.Stop.length, 1, "only the user's Stop hook remains");
|
|
441
|
+
assert.equal(s3.hooks.Stop[0].hooks[0].command, "/usr/local/bin/user-stop");
|
|
442
|
+
assert.equal(s3.hooks.PreToolUse.length, 1, "user PreToolUse hook preserved");
|
|
443
|
+
assert.equal(s3.theme, "dark", "unrelated settings survive uninstall");
|
|
444
|
+
const cj2 = JSON.parse(readFileSync(claudeJson, "utf8"));
|
|
445
|
+
assert.ok(!("load-lock" in cj2.mcpServers), "our MCP server key removed");
|
|
446
|
+
assert.ok(cj2.mcpServers.fetch, "user MCP server untouched");
|
|
447
|
+
const md2 = readFileSync(claudeMd, "utf8");
|
|
448
|
+
assert.ok(!md2.includes(MARKER_BEGIN), "marked section removed");
|
|
449
|
+
assert.ok(md2.includes("# My rules"), "user markdown untouched");
|
|
450
|
+
assert.ok(!existsSync(join(claudeDir, "skills")), "our skill removed");
|
|
451
|
+
rmSync(home, { recursive: true, force: true });
|
|
452
|
+
});
|
|
453
|
+
test("claude-code: bare HOME install then uninstall removes exactly what we added", async () => {
|
|
454
|
+
const home = tempHome();
|
|
455
|
+
let r = await runCli(home, ["install", "--harness", "claude-code"]);
|
|
456
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
457
|
+
const settingsFile = join(home, ".claude", "settings.json");
|
|
458
|
+
assert.ok(existsSync(settingsFile), "settings.json created");
|
|
459
|
+
r = await runCli(home, ["uninstall", "--harness", "claude-code"]);
|
|
460
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
461
|
+
assert.ok(!existsSync(settingsFile), "settings.json was ours alone — removed");
|
|
462
|
+
assert.ok(!existsSync(join(home, ".claude", "skills")), "our skill removed");
|
|
463
|
+
const cj = JSON.parse(readFileSync(join(home, ".claude.json"), "utf8"));
|
|
464
|
+
assert.ok(!("load-lock" in cj.mcpServers), "our MCP server key removed");
|
|
465
|
+
rmSync(home, { recursive: true, force: true });
|
|
466
|
+
});
|
|
467
|
+
test("claude-code: uninstall with nothing installed is a harmless no-op", async () => {
|
|
468
|
+
const home = tempHome();
|
|
469
|
+
const r = await runCli(home, ["uninstall", "--harness", "claude-code"]);
|
|
470
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
471
|
+
assert.ok(!existsSync(join(home, ".claude")), "no files created by uninstall");
|
|
472
|
+
assert.ok(!existsSync(join(home, ".claude.json")), "no files created by uninstall");
|
|
473
|
+
rmSync(home, { recursive: true, force: true });
|
|
474
|
+
});
|
|
475
|
+
// --------------------------------------------------------------------- codex
|
|
476
|
+
test("codex: install/uninstall round-trip on a populated HOME; re-install never duplicates hooks", async () => {
|
|
477
|
+
const home = tempHome();
|
|
478
|
+
const codexDir = join(home, ".codex");
|
|
479
|
+
mkdirSync(codexDir, { recursive: true });
|
|
480
|
+
// pre-existing user config that must survive untouched
|
|
481
|
+
writeFileSync(join(codexDir, "hooks.json"), JSON.stringify({
|
|
482
|
+
hooks: {
|
|
483
|
+
PreToolUse: [{ hooks: [{ type: "command", command: "/usr/local/bin/guard" }] }],
|
|
484
|
+
Stop: [{ hooks: [{ type: "command", command: "/usr/local/bin/user-stop" }] }],
|
|
485
|
+
},
|
|
486
|
+
}, null, 2) + "\n");
|
|
487
|
+
writeFileSync(join(codexDir, "config.toml"), 'model = "gpt-5.2"\n');
|
|
488
|
+
writeFileSync(join(codexDir, "AGENTS.md"), "# My codex rules\n");
|
|
489
|
+
const hooksFile = join(codexDir, "hooks.json");
|
|
490
|
+
const tomlFile = join(codexDir, "config.toml");
|
|
491
|
+
const agentsMd = join(codexDir, "AGENTS.md");
|
|
492
|
+
// -- install
|
|
493
|
+
let r = await runCli(home, ["install", "--harness", "codex"], { CODEX_HOME: codexDir });
|
|
494
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
495
|
+
const h = JSON.parse(readFileSync(hooksFile, "utf8"));
|
|
496
|
+
assert.equal(h.hooks.PreToolUse.length, 1, "unrelated event untouched");
|
|
497
|
+
assert.equal(h.hooks.SessionStart.length, 1, "one SessionStart entry (ours)");
|
|
498
|
+
assert.equal(h.hooks.SessionStart[0].matcher, "startup|resume|clear|compact|fork", "SessionStart matcher preserved");
|
|
499
|
+
assert.ok(h.hooks.SessionStart[0].hooks[0].command.includes(JSON.stringify(cliMain)), "command embeds the JSON-quoted cli path");
|
|
500
|
+
assert.ok(h.hooks.SessionStart[0].hooks[0].command.endsWith("hook codex session-start"));
|
|
501
|
+
assert.equal(h.hooks.SubagentStart.length, 1, "one SubagentStart entry (ours)");
|
|
502
|
+
assert.equal(h.hooks.SubagentStart[0].matcher, "*");
|
|
503
|
+
assert.ok(h.hooks.SubagentStart[0].hooks[0].command.endsWith("hook codex subagent-start"));
|
|
504
|
+
assert.equal(h.hooks.Stop.length, 2, "user Stop hook preserved, ours appended");
|
|
505
|
+
assert.equal(h.hooks.Stop[0].hooks[0].command, "/usr/local/bin/user-stop", "user's stop hook untouched");
|
|
506
|
+
assert.ok(h.hooks.Stop[1].hooks[0].command.endsWith("hook codex stop"));
|
|
507
|
+
const toml = readFileSync(tomlFile, "utf8");
|
|
508
|
+
assert.ok(toml.includes('model = "gpt-5.2"'), "user toml keys preserved");
|
|
509
|
+
assert.equal(count(toml, "[mcp_servers.load-lock]"), 1, "exactly one toml block appended");
|
|
510
|
+
const md = readFileSync(agentsMd, "utf8");
|
|
511
|
+
assert.ok(md.includes("# My codex rules"), "existing AGENTS.md content preserved");
|
|
512
|
+
assert.equal(count(md, MARKER_BEGIN), 1, "exactly one marked section");
|
|
513
|
+
// -- idempotent re-install: no duplicate hook entries (issue #19)
|
|
514
|
+
r = await runCli(home, ["install", "--harness", "codex"], { CODEX_HOME: codexDir });
|
|
515
|
+
assert.equal(r.code, 0, `re-install failed: ${r.stderr}`);
|
|
516
|
+
const h2 = JSON.parse(readFileSync(hooksFile, "utf8"));
|
|
517
|
+
assert.equal(h2.hooks.SessionStart.length, 1, "re-install must not duplicate SessionStart entries");
|
|
518
|
+
assert.equal(h2.hooks.SubagentStart.length, 1, "re-install must not duplicate SubagentStart entries");
|
|
519
|
+
assert.equal(h2.hooks.Stop.length, 2, "re-install must not duplicate Stop entries");
|
|
520
|
+
assert.equal(count(readFileSync(agentsMd, "utf8"), MARKER_BEGIN), 1, "re-install must not duplicate the section");
|
|
521
|
+
// -- uninstall removes exactly our entries
|
|
522
|
+
r = await runCli(home, ["uninstall", "--harness", "codex"], { CODEX_HOME: codexDir });
|
|
523
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
524
|
+
const h3 = JSON.parse(readFileSync(hooksFile, "utf8"));
|
|
525
|
+
assert.ok(!("SessionStart" in h3.hooks), "our SessionStart entries removed");
|
|
526
|
+
assert.ok(!("SubagentStart" in h3.hooks), "our SubagentStart entries removed");
|
|
527
|
+
assert.equal(h3.hooks.Stop.length, 1, "only the user's Stop hook remains");
|
|
528
|
+
assert.equal(h3.hooks.Stop[0].hooks[0].command, "/usr/local/bin/user-stop");
|
|
529
|
+
assert.equal(h3.hooks.PreToolUse.length, 1, "user PreToolUse hook preserved");
|
|
530
|
+
const toml2 = readFileSync(tomlFile, "utf8");
|
|
531
|
+
assert.ok(!toml2.includes("[mcp_servers.load-lock]"), "our toml block removed");
|
|
532
|
+
assert.ok(toml2.includes('model = "gpt-5.2"'), "user toml keys survive uninstall");
|
|
533
|
+
const md2 = readFileSync(agentsMd, "utf8");
|
|
534
|
+
assert.ok(!md2.includes(MARKER_BEGIN), "marked section removed");
|
|
535
|
+
assert.ok(md2.includes("# My codex rules"), "user markdown untouched");
|
|
536
|
+
rmSync(home, { recursive: true, force: true });
|
|
537
|
+
});
|
|
538
|
+
test("codex: bare HOME install then uninstall removes exactly what we added", async () => {
|
|
539
|
+
const home = tempHome();
|
|
540
|
+
const codexDir = join(home, ".codex");
|
|
541
|
+
let r = await runCli(home, ["install", "--harness", "codex"], { CODEX_HOME: codexDir });
|
|
542
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
543
|
+
const hooksFile = join(codexDir, "hooks.json");
|
|
544
|
+
assert.ok(existsSync(hooksFile), "hooks.json created");
|
|
545
|
+
r = await runCli(home, ["uninstall", "--harness", "codex"], { CODEX_HOME: codexDir });
|
|
546
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
547
|
+
assert.ok(!existsSync(hooksFile), "hooks.json was ours alone — removed");
|
|
548
|
+
assert.ok(!readFileSync(join(codexDir, "config.toml"), "utf8").includes("[mcp_servers.load-lock]"), "toml block removed");
|
|
549
|
+
assert.ok(!readFileSync(join(codexDir, "AGENTS.md"), "utf8").includes(MARKER_BEGIN), "AGENTS.md section removed");
|
|
550
|
+
rmSync(home, { recursive: true, force: true });
|
|
551
|
+
});
|
|
552
|
+
test("codex: uninstall with nothing installed is a harmless no-op", async () => {
|
|
553
|
+
const home = tempHome();
|
|
554
|
+
const r = await runCli(home, ["uninstall", "--harness", "codex"], { CODEX_HOME: join(home, ".codex") });
|
|
555
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
556
|
+
assert.ok(!existsSync(join(home, ".codex")), "no files created by uninstall");
|
|
557
|
+
rmSync(home, { recursive: true, force: true });
|
|
558
|
+
});
|
|
559
|
+
// ------------------------------------------------------------------ cursor
|
|
560
|
+
test("cursor: project install/uninstall round-trip preserves user config", async () => {
|
|
561
|
+
const home = tempHome();
|
|
562
|
+
const proj = join(home, "proj");
|
|
563
|
+
mkdirSync(proj, { recursive: true });
|
|
564
|
+
const cursorDir = join(proj, ".cursor");
|
|
565
|
+
mkdirSync(cursorDir, { recursive: true });
|
|
566
|
+
// pre-existing user config that must survive untouched
|
|
567
|
+
writeFileSync(join(cursorDir, "mcp.json"), JSON.stringify({ mcpServers: { fetch: { command: "uvx", args: ["mcp-server-fetch"] } } }, null, 2) + "\n");
|
|
568
|
+
writeFileSync(join(cursorDir, "hooks.json"), JSON.stringify({
|
|
569
|
+
version: 1,
|
|
570
|
+
hooks: {
|
|
571
|
+
stop: [{ type: "command", command: "/usr/local/bin/my-checker --staged" }],
|
|
572
|
+
PreToolUse: [{ type: "command", command: "/usr/local/bin/guard" }],
|
|
573
|
+
},
|
|
574
|
+
}, null, 2) + "\n");
|
|
575
|
+
mkdirSync(join(cursorDir, "rules"), { recursive: true });
|
|
576
|
+
writeFileSync(join(cursorDir, "rules", "user-rule.mdc"), "---\nalwaysApply: true\n---\n# User rule\n");
|
|
577
|
+
const mcpFile = join(cursorDir, "mcp.json");
|
|
578
|
+
const hooksFile = join(cursorDir, "hooks.json");
|
|
579
|
+
const ruleFile = join(cursorDir, "rules", "load-lock.mdc");
|
|
580
|
+
// -- install
|
|
581
|
+
let r = await runCli(home, ["install", "--harness", "cursor", "--project", proj]);
|
|
582
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
583
|
+
// honest tier statement: wakeup yes, but contract-tested only
|
|
584
|
+
assert.match(r.stdout, /stop-hook wakeup/i, "installer output must state the wakeup tier");
|
|
585
|
+
assert.match(r.stdout, /not live-verified/i, "installer output must be honest about verification");
|
|
586
|
+
const m = JSON.parse(readFileSync(mcpFile, "utf8"));
|
|
587
|
+
assert.deepEqual(m.mcpServers.fetch, { command: "uvx", args: ["mcp-server-fetch"] }, "unrelated MCP server preserved");
|
|
588
|
+
assert.deepEqual(m.mcpServers["load-lock"], { command: process.execPath, args: [mcpMain] }, "load-lock stdio entry with absolute paths");
|
|
589
|
+
const h = JSON.parse(readFileSync(hooksFile, "utf8"));
|
|
590
|
+
assert.equal(h.version, 1, "hooks v1 version preserved");
|
|
591
|
+
assert.equal(h.hooks.stop.length, 2, "user's stop hook preserved, ours appended");
|
|
592
|
+
assert.equal(h.hooks.stop[0].command, "/usr/local/bin/my-checker --staged", "user's stop hook untouched");
|
|
593
|
+
assert.equal(h.hooks.stop[1].type, "command");
|
|
594
|
+
assert.ok(h.hooks.stop[1].command.includes(JSON.stringify(cliMain)), "our stop hook invokes the load-lock CLI");
|
|
595
|
+
assert.ok(h.hooks.stop[1].command.endsWith("hook cursor stop"), "our stop hook is the cursor stop handler");
|
|
596
|
+
assert.ok(Array.isArray(h.hooks.PreToolUse) && h.hooks.PreToolUse.length === 1, "unrelated event untouched");
|
|
597
|
+
const rule = readFileSync(ruleFile, "utf8");
|
|
598
|
+
assert.ok(rule.startsWith("---\n"), ".mdc YAML frontmatter present");
|
|
599
|
+
assert.ok(rule.includes("alwaysApply: true"), "Always-apply rule (plain .md is ignored by Cursor)");
|
|
600
|
+
assert.ok(rule.includes("load-lock"), "rule teaches load-lock");
|
|
601
|
+
assert.ok(existsSync(join(cursorDir, "rules", "user-rule.mdc")), "user rule untouched");
|
|
602
|
+
// -- idempotent re-install: no duplicate hooks, no config damage
|
|
603
|
+
r = await runCli(home, ["install", "--harness", "cursor", "--project", proj]);
|
|
604
|
+
assert.equal(r.code, 0, `re-install failed: ${r.stderr}`);
|
|
605
|
+
assert.equal(JSON.parse(readFileSync(hooksFile, "utf8")).hooks.stop.length, 2, "re-install must not duplicate our hook");
|
|
606
|
+
assert.deepEqual(JSON.parse(readFileSync(mcpFile, "utf8")).mcpServers["load-lock"], {
|
|
607
|
+
command: process.execPath,
|
|
608
|
+
args: [mcpMain],
|
|
609
|
+
});
|
|
610
|
+
// -- uninstall removes exactly our entries
|
|
611
|
+
r = await runCli(home, ["uninstall", "--harness", "cursor", "--project", proj]);
|
|
612
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
613
|
+
const m2 = JSON.parse(readFileSync(mcpFile, "utf8"));
|
|
614
|
+
assert.ok(!("load-lock" in m2.mcpServers), "our MCP key removed");
|
|
615
|
+
assert.deepEqual(m2.mcpServers.fetch, { command: "uvx", args: ["mcp-server-fetch"] }, "user MCP untouched");
|
|
616
|
+
const h2 = JSON.parse(readFileSync(hooksFile, "utf8"));
|
|
617
|
+
assert.equal(h2.version, 1, "user's version key preserved");
|
|
618
|
+
assert.equal(h2.hooks.stop.length, 1, "only the user's stop hook remains");
|
|
619
|
+
assert.equal(h2.hooks.stop[0].command, "/usr/local/bin/my-checker --staged");
|
|
620
|
+
assert.ok(Array.isArray(h2.hooks.PreToolUse) && h2.hooks.PreToolUse.length === 1, "unrelated event preserved");
|
|
621
|
+
assert.ok(!existsSync(ruleFile), "our rule file removed");
|
|
622
|
+
assert.ok(existsSync(join(cursorDir, "rules", "user-rule.mdc")), "user rule preserved");
|
|
623
|
+
assert.ok(existsSync(cursorDir), "non-empty .cursor dir preserved");
|
|
624
|
+
rmSync(home, { recursive: true, force: true });
|
|
625
|
+
});
|
|
626
|
+
test("cursor: bare project install creates all files; uninstall cleans them completely", async () => {
|
|
627
|
+
const home = tempHome();
|
|
628
|
+
const proj = join(home, "proj");
|
|
629
|
+
mkdirSync(proj, { recursive: true });
|
|
630
|
+
let r = await runCli(home, ["install", "--harness", "cursor", "--project", proj]);
|
|
631
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
632
|
+
const cursorDir = join(proj, ".cursor");
|
|
633
|
+
const mcpFile = join(cursorDir, "mcp.json");
|
|
634
|
+
const hooksFile = join(cursorDir, "hooks.json");
|
|
635
|
+
const ruleFile = join(cursorDir, "rules", "load-lock.mdc");
|
|
636
|
+
assert.ok(existsSync(mcpFile) && existsSync(hooksFile) && existsSync(ruleFile), "all three files created");
|
|
637
|
+
const h = JSON.parse(readFileSync(hooksFile, "utf8"));
|
|
638
|
+
assert.equal(h.version, 1, "hooks v1 version written");
|
|
639
|
+
assert.ok(Array.isArray(h.hooks.stop) && h.hooks.stop.length === 1, "exactly one stop hook");
|
|
640
|
+
assert.ok(JSON.parse(readFileSync(mcpFile, "utf8")).mcpServers["load-lock"], "server entry present");
|
|
641
|
+
r = await runCli(home, ["uninstall", "--harness", "cursor", "--project", proj]);
|
|
642
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
643
|
+
assert.ok(!existsSync(ruleFile), "rule file removed");
|
|
644
|
+
assert.ok(!existsSync(hooksFile), "hooks.json removed (nothing else was in it)");
|
|
645
|
+
assert.ok(!existsSync(mcpFile), "mcp.json removed (nothing else was in it)");
|
|
646
|
+
assert.ok(!existsSync(cursorDir), "empty .cursor dir pruned");
|
|
647
|
+
assert.ok(existsSync(proj), "the project directory itself is never removed");
|
|
648
|
+
rmSync(home, { recursive: true, force: true });
|
|
649
|
+
});
|
|
650
|
+
test("cursor: uninstall with nothing installed is a harmless no-op", async () => {
|
|
651
|
+
const home = tempHome();
|
|
652
|
+
const proj = join(home, "proj");
|
|
653
|
+
mkdirSync(proj, { recursive: true });
|
|
654
|
+
const r = await runCli(home, ["uninstall", "--harness", "cursor", "--project", proj]);
|
|
655
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
656
|
+
assert.ok(!existsSync(join(proj, ".cursor")), "no files created by uninstall");
|
|
657
|
+
rmSync(home, { recursive: true, force: true });
|
|
658
|
+
});
|