skillrepo 2.0.0 → 3.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.
Files changed (72) hide show
  1. package/README.md +276 -145
  2. package/bin/skillrepo.mjs +224 -36
  3. package/package.json +6 -3
  4. package/src/commands/add.mjs +176 -0
  5. package/src/commands/get.mjs +116 -0
  6. package/src/commands/init.mjs +589 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +162 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/session-sync.mjs +152 -0
  11. package/src/commands/uninstall.mjs +484 -0
  12. package/src/commands/update.mjs +184 -0
  13. package/src/lib/artifact-registry.mjs +265 -0
  14. package/src/lib/cli-config.mjs +230 -0
  15. package/src/lib/config.mjs +238 -0
  16. package/src/lib/detect-ides.mjs +0 -19
  17. package/src/lib/errors.mjs +264 -0
  18. package/src/lib/file-write.mjs +705 -0
  19. package/src/lib/fs-utils.mjs +83 -1
  20. package/src/lib/http.mjs +817 -37
  21. package/src/lib/identifier.mjs +153 -0
  22. package/src/lib/mcp-merge.mjs +275 -0
  23. package/src/lib/mergers/gitignore.mjs +73 -18
  24. package/src/lib/mergers/session-hook.mjs +298 -0
  25. package/src/lib/paths.mjs +67 -17
  26. package/src/lib/prompt.mjs +11 -44
  27. package/src/lib/removers/claude-mcp.mjs +67 -0
  28. package/src/lib/removers/cursor-mcp.mjs +60 -0
  29. package/src/lib/removers/env-local.mjs +55 -0
  30. package/src/lib/removers/gitignore.mjs +108 -0
  31. package/src/lib/removers/settings.mjs +183 -0
  32. package/src/lib/removers/vscode-mcp.mjs +87 -0
  33. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  34. package/src/lib/sync.mjs +305 -0
  35. package/src/test/commands/add.test.mjs +285 -0
  36. package/src/test/commands/get.test.mjs +176 -0
  37. package/src/test/commands/init.test.mjs +697 -0
  38. package/src/test/commands/list.test.mjs +172 -0
  39. package/src/test/commands/remove.test.mjs +234 -0
  40. package/src/test/commands/search.test.mjs +204 -0
  41. package/src/test/commands/session-sync.test.mjs +350 -0
  42. package/src/test/commands/uninstall.test.mjs +768 -0
  43. package/src/test/commands/update.test.mjs +322 -0
  44. package/src/test/detect-ides.test.mjs +9 -14
  45. package/src/test/dispatcher.test.mjs +224 -0
  46. package/src/test/e2e/cli-commands.test.mjs +576 -0
  47. package/src/test/e2e/mock-server.mjs +364 -22
  48. package/src/test/helpers/capture-stream.mjs +48 -0
  49. package/src/test/integration/file-write.integration.test.mjs +279 -0
  50. package/src/test/lib/artifact-registry.test.mjs +268 -0
  51. package/src/test/lib/cli-config.test.mjs +407 -0
  52. package/src/test/lib/config.test.mjs +257 -0
  53. package/src/test/lib/errors.test.mjs +359 -0
  54. package/src/test/lib/file-write.test.mjs +784 -0
  55. package/src/test/lib/http.test.mjs +1198 -0
  56. package/src/test/lib/identifier.test.mjs +157 -0
  57. package/src/test/lib/mcp-merge.test.mjs +345 -0
  58. package/src/test/lib/paths.test.mjs +83 -0
  59. package/src/test/lib/sync.test.mjs +514 -0
  60. package/src/test/mergers/gitignore.test.mjs +145 -20
  61. package/src/test/mergers/session-hook.test.mjs +745 -0
  62. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  63. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  64. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  65. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  66. package/src/test/mergers/uninstall-settings.test.mjs +285 -0
  67. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  68. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
  69. package/src/lib/write-configs.mjs +0 -202
  70. package/src/test/e2e/HANDOFF.md +0 -223
  71. package/src/test/e2e/cli-init.test.mjs +0 -213
  72. package/src/test/e2e/payload-factory.mjs +0 -22
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Unit tests for src/lib/removers/settings.mjs (#885).
3
+ *
4
+ * The settings remover filters the SessionStart hook array by the
5
+ * shared SESSION_HOOK_FINGERPRINT constant. It must:
6
+ *
7
+ * 1. Remove the SkillRepo hook when present
8
+ * 2. Preserve user-authored hooks (different commands)
9
+ * 3. Preserve hook GROUPS that contain only user-authored hooks
10
+ * 4. Clean up empty containers after the removal so the file
11
+ * doesn't accumulate dead structure
12
+ * 5. Leave malformed/unexpected shapes alone — only touch entries
13
+ * we can definitively identify as SkillRepo-owned
14
+ */
15
+
16
+ import { describe, it, beforeEach, afterEach } from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { tmpdir } from "node:os";
21
+
22
+ import { removeSettingsSessionHook } from "../../lib/removers/settings.mjs";
23
+ import { SESSION_HOOK_FINGERPRINT } from "../../lib/artifact-registry.mjs";
24
+
25
+ let sandbox;
26
+ let originalCwd;
27
+
28
+ function setup() {
29
+ sandbox = mkdtempSync(join(tmpdir(), "cli-unrm-settings-"));
30
+ originalCwd = process.cwd();
31
+ process.chdir(sandbox);
32
+ mkdirSync(join(sandbox, ".claude"), { recursive: true });
33
+ }
34
+
35
+ function teardown() {
36
+ process.chdir(originalCwd);
37
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
38
+ }
39
+
40
+ describe("removeSettingsSessionHook", () => {
41
+ beforeEach(setup);
42
+ afterEach(teardown);
43
+
44
+ it("is a no-op when settings.local.json does not exist", () => {
45
+ const result = removeSettingsSessionHook();
46
+ assert.equal(result.action, "skipped");
47
+ });
48
+
49
+ it("returns 'unchanged' when file exists with no hooks section (semantic fix, architect round-1)", () => {
50
+ // #884 round-1 review normalized the action values: "skipped"
51
+ // now means "couldn't run" (file missing, unparseable), while
52
+ // "unchanged" means "ran successfully, nothing to remove." A
53
+ // file that exists but has no hooks section is the latter case
54
+ // — the operation was a successful no-op, not a skip.
55
+ const content = JSON.stringify({ env: {} }, null, 2);
56
+ writeFileSync(".claude/settings.local.json", content);
57
+
58
+ const result = removeSettingsSessionHook();
59
+ assert.equal(result.action, "unchanged");
60
+ assert.equal(readFileSync(".claude/settings.local.json", "utf-8"), content);
61
+ });
62
+
63
+ it("removes the SkillRepo hook and cleans up the empty containers", () => {
64
+ writeFileSync(
65
+ ".claude/settings.local.json",
66
+ JSON.stringify(
67
+ {
68
+ hooks: {
69
+ SessionStart: [
70
+ {
71
+ hooks: [
72
+ {
73
+ type: "command",
74
+ command: `/usr/local/bin/skillrepo update --session-hook 2>&1 || true`,
75
+ },
76
+ ],
77
+ },
78
+ ],
79
+ },
80
+ },
81
+ null,
82
+ 2,
83
+ ),
84
+ );
85
+
86
+ const result = removeSettingsSessionHook();
87
+ assert.equal(result.action, "removed");
88
+
89
+ const parsed = JSON.parse(
90
+ readFileSync(".claude/settings.local.json", "utf-8"),
91
+ );
92
+ // With the only hook removed, the hooks object itself is
93
+ // cleaned up — leaves a minimally-clean file.
94
+ assert.equal(parsed.hooks, undefined);
95
+ });
96
+
97
+ it("preserves user-authored hooks in the same group", () => {
98
+ writeFileSync(
99
+ ".claude/settings.local.json",
100
+ JSON.stringify(
101
+ {
102
+ hooks: {
103
+ SessionStart: [
104
+ {
105
+ hooks: [
106
+ {
107
+ type: "command",
108
+ command: `echo "user-authored"`,
109
+ },
110
+ {
111
+ type: "command",
112
+ command: `/bin/skillrepo update --session-hook 2>&1 || true`,
113
+ },
114
+ ],
115
+ },
116
+ ],
117
+ },
118
+ },
119
+ null,
120
+ 2,
121
+ ),
122
+ );
123
+
124
+ const result = removeSettingsSessionHook();
125
+ assert.equal(result.action, "removed");
126
+
127
+ const parsed = JSON.parse(
128
+ readFileSync(".claude/settings.local.json", "utf-8"),
129
+ );
130
+ assert.equal(parsed.hooks.SessionStart.length, 1);
131
+ assert.equal(parsed.hooks.SessionStart[0].hooks.length, 1);
132
+ assert.match(
133
+ parsed.hooks.SessionStart[0].hooks[0].command,
134
+ /user-authored/,
135
+ );
136
+ });
137
+
138
+ it("preserves user-authored groups alongside SkillRepo's group", () => {
139
+ writeFileSync(
140
+ ".claude/settings.local.json",
141
+ JSON.stringify(
142
+ {
143
+ hooks: {
144
+ SessionStart: [
145
+ {
146
+ hooks: [
147
+ { type: "command", command: "user-group-1-hook" },
148
+ ],
149
+ },
150
+ {
151
+ hooks: [
152
+ {
153
+ type: "command",
154
+ command: `/bin/skillrepo update --session-hook 2>&1 || true`,
155
+ },
156
+ ],
157
+ },
158
+ {
159
+ hooks: [
160
+ { type: "command", command: "user-group-3-hook" },
161
+ ],
162
+ },
163
+ ],
164
+ },
165
+ },
166
+ null,
167
+ 2,
168
+ ),
169
+ );
170
+
171
+ const result = removeSettingsSessionHook();
172
+ assert.equal(result.action, "removed");
173
+
174
+ const parsed = JSON.parse(
175
+ readFileSync(".claude/settings.local.json", "utf-8"),
176
+ );
177
+ assert.equal(parsed.hooks.SessionStart.length, 2);
178
+ assert.equal(
179
+ parsed.hooks.SessionStart[0].hooks[0].command,
180
+ "user-group-1-hook",
181
+ );
182
+ assert.equal(
183
+ parsed.hooks.SessionStart[1].hooks[0].command,
184
+ "user-group-3-hook",
185
+ );
186
+ });
187
+
188
+ it("returns 'unchanged' when no hook command contains the fingerprint", () => {
189
+ // File has hooks, but none of them are SkillRepo-owned. The
190
+ // operation ran; nothing to remove. "unchanged" not "skipped"
191
+ // per the architect-requested normalization.
192
+ const content = JSON.stringify(
193
+ {
194
+ hooks: {
195
+ SessionStart: [
196
+ {
197
+ hooks: [
198
+ { type: "command", command: "some-other-tool" },
199
+ ],
200
+ },
201
+ ],
202
+ },
203
+ },
204
+ null,
205
+ 2,
206
+ );
207
+ writeFileSync(".claude/settings.local.json", content);
208
+
209
+ const result = removeSettingsSessionHook();
210
+ assert.equal(result.action, "unchanged");
211
+ assert.equal(
212
+ readFileSync(".claude/settings.local.json", "utf-8"),
213
+ content,
214
+ );
215
+ });
216
+
217
+ it("returns 'unchanged' on an empty (zero-byte) settings file", () => {
218
+ // Code-reviewer round-1 flagged the empty-file case: a zero-byte
219
+ // settings.local.json (some tools create the file as a touch
220
+ // target) was producing "Cannot parse" errors. The guard at
221
+ // `raw.trim().length === 0` makes this explicit: empty file is
222
+ // a valid "unchanged" state, not an error.
223
+ writeFileSync(".claude/settings.local.json", "");
224
+
225
+ const result = removeSettingsSessionHook();
226
+ assert.equal(result.action, "unchanged");
227
+ assert.ok(!result.error, "empty file must not be treated as parse error");
228
+ });
229
+
230
+ it("tolerates malformed entries without throwing", () => {
231
+ // A hook entry missing the `command` field, or a non-object
232
+ // in the array, must not crash the remover — just be preserved
233
+ // untouched.
234
+ writeFileSync(
235
+ ".claude/settings.local.json",
236
+ JSON.stringify(
237
+ {
238
+ hooks: {
239
+ SessionStart: [
240
+ {
241
+ hooks: [
242
+ { type: "command" /* no command field */ },
243
+ "not even an object",
244
+ {
245
+ type: "command",
246
+ command: `/bin/skillrepo update --session-hook`,
247
+ },
248
+ ],
249
+ },
250
+ ],
251
+ },
252
+ },
253
+ null,
254
+ 2,
255
+ ),
256
+ );
257
+
258
+ const result = removeSettingsSessionHook();
259
+ assert.equal(result.action, "removed");
260
+
261
+ const parsed = JSON.parse(
262
+ readFileSync(".claude/settings.local.json", "utf-8"),
263
+ );
264
+ // SkillRepo hook gone; the two malformed entries survive.
265
+ assert.equal(parsed.hooks.SessionStart[0].hooks.length, 2);
266
+ });
267
+
268
+ it("imports the same fingerprint constant the installer would", () => {
269
+ // This test locks in the single-source-of-truth contract: if a
270
+ // refactor ever moves the fingerprint elsewhere or drops it,
271
+ // this import breaks loudly and the architect tightening #3
272
+ // (bidirectional registry/merger mapping) fails at import time.
273
+ assert.equal(typeof SESSION_HOOK_FINGERPRINT, "string");
274
+ assert.ok(SESSION_HOOK_FINGERPRINT.length > 0);
275
+ assert.match(SESSION_HOOK_FINGERPRINT, /skillrepo/);
276
+ });
277
+
278
+ it("returns a structured error on unparseable JSON", () => {
279
+ writeFileSync(".claude/settings.local.json", "{ bad");
280
+
281
+ const result = removeSettingsSessionHook();
282
+ assert.equal(result.action, "skipped");
283
+ assert.ok(result.error);
284
+ });
285
+ });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Unit tests for src/lib/removers/vscode-mcp.mjs (#885).
3
+ *
4
+ * VS Code's mcp.json is the complex case: two artifacts (server
5
+ * entry + input prompt) live in one file under different sections.
6
+ * Both must be removed; sibling entries in both sections must
7
+ * survive.
8
+ */
9
+
10
+ import { describe, it, beforeEach, afterEach } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+
16
+ import { removeVscodeMcp } from "../../lib/removers/vscode-mcp.mjs";
17
+
18
+ let sandbox;
19
+ let originalCwd;
20
+
21
+ function setup() {
22
+ sandbox = mkdtempSync(join(tmpdir(), "cli-unrm-vscode-"));
23
+ originalCwd = process.cwd();
24
+ process.chdir(sandbox);
25
+ mkdirSync(join(sandbox, ".vscode"), { recursive: true });
26
+ }
27
+
28
+ function teardown() {
29
+ process.chdir(originalCwd);
30
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
31
+ }
32
+
33
+ describe("removeVscodeMcp", () => {
34
+ beforeEach(setup);
35
+ afterEach(teardown);
36
+
37
+ it("is a no-op when .vscode/mcp.json does not exist", () => {
38
+ const result = removeVscodeMcp();
39
+ assert.equal(result.action, "skipped");
40
+ });
41
+
42
+ it("removes both servers.skillrepo AND the inputs entry in one pass", () => {
43
+ writeFileSync(
44
+ ".vscode/mcp.json",
45
+ JSON.stringify(
46
+ {
47
+ inputs: [
48
+ {
49
+ id: "skillrepo-api-key",
50
+ type: "promptString",
51
+ description: "SkillRepo key",
52
+ password: true,
53
+ },
54
+ {
55
+ id: "some-other-tool-key",
56
+ type: "promptString",
57
+ description: "Other tool key",
58
+ password: true,
59
+ },
60
+ ],
61
+ servers: {
62
+ skillrepo: {
63
+ type: "http",
64
+ url: "https://skillrepo.dev/api/mcp",
65
+ headers: { Authorization: "Bearer ${input:skillrepo-api-key}" },
66
+ },
67
+ anotherTool: {
68
+ type: "http",
69
+ url: "https://example.com",
70
+ },
71
+ },
72
+ },
73
+ null,
74
+ 2,
75
+ ),
76
+ );
77
+
78
+ const result = removeVscodeMcp();
79
+ assert.equal(result.action, "removed");
80
+ assert.ok(result.removed.includes("servers.skillrepo"));
81
+ assert.ok(result.removed.includes("inputs[skillrepo-api-key]"));
82
+
83
+ const parsed = JSON.parse(readFileSync(".vscode/mcp.json", "utf-8"));
84
+ assert.equal(parsed.servers.skillrepo, undefined);
85
+ assert.ok(parsed.servers.anotherTool, "sibling server survives");
86
+ assert.equal(parsed.inputs.length, 1);
87
+ assert.equal(parsed.inputs[0].id, "some-other-tool-key");
88
+ });
89
+
90
+ it("removes the server entry only when the input is already absent", () => {
91
+ // A half-present state can happen if the user manually deleted
92
+ // the input prompt. The remover should still strip the server
93
+ // entry and report only what it actually removed.
94
+ writeFileSync(
95
+ ".vscode/mcp.json",
96
+ JSON.stringify(
97
+ {
98
+ inputs: [
99
+ { id: "some-other-tool-key", type: "promptString" },
100
+ ],
101
+ servers: {
102
+ skillrepo: { type: "http", url: "x" },
103
+ },
104
+ },
105
+ null,
106
+ 2,
107
+ ),
108
+ );
109
+
110
+ const result = removeVscodeMcp();
111
+ assert.equal(result.action, "removed");
112
+ assert.ok(result.removed.includes("servers.skillrepo"));
113
+ assert.ok(!result.removed.includes("inputs[skillrepo-api-key]"));
114
+ });
115
+
116
+ it("removes the input entry only when the server is already absent", () => {
117
+ writeFileSync(
118
+ ".vscode/mcp.json",
119
+ JSON.stringify(
120
+ {
121
+ inputs: [
122
+ { id: "skillrepo-api-key", type: "promptString" },
123
+ { id: "keep-me", type: "promptString" },
124
+ ],
125
+ servers: {},
126
+ },
127
+ null,
128
+ 2,
129
+ ),
130
+ );
131
+
132
+ const result = removeVscodeMcp();
133
+ assert.equal(result.action, "removed");
134
+ assert.ok(!result.removed.includes("servers.skillrepo"));
135
+ assert.ok(result.removed.includes("inputs[skillrepo-api-key]"));
136
+
137
+ const parsed = JSON.parse(readFileSync(".vscode/mcp.json", "utf-8"));
138
+ assert.equal(parsed.inputs.length, 1);
139
+ assert.equal(parsed.inputs[0].id, "keep-me");
140
+ });
141
+
142
+ it("is a no-op when neither artifact is present", () => {
143
+ const content = JSON.stringify(
144
+ {
145
+ inputs: [{ id: "other", type: "promptString" }],
146
+ servers: { anotherTool: { type: "http", url: "x" } },
147
+ },
148
+ null,
149
+ 2,
150
+ );
151
+ writeFileSync(".vscode/mcp.json", content);
152
+
153
+ const result = removeVscodeMcp();
154
+ assert.equal(result.action, "skipped");
155
+ assert.equal(readFileSync(".vscode/mcp.json", "utf-8"), content);
156
+ });
157
+
158
+ it("returns a structured error on unparseable JSON", () => {
159
+ writeFileSync(".vscode/mcp.json", "not json at all");
160
+
161
+ const result = removeVscodeMcp();
162
+ assert.equal(result.action, "skipped");
163
+ assert.ok(result.error);
164
+ });
165
+
166
+ it("tolerates missing inputs array entirely", () => {
167
+ // A config that only has `servers` is valid per spec.
168
+ writeFileSync(
169
+ ".vscode/mcp.json",
170
+ JSON.stringify({ servers: { skillrepo: { url: "x" } } }, null, 2),
171
+ );
172
+
173
+ const result = removeVscodeMcp();
174
+ assert.equal(result.action, "removed");
175
+ assert.deepEqual(result.removed, ["servers.skillrepo"]);
176
+ });
177
+
178
+ it("is idempotent — second call is a no-op", () => {
179
+ writeFileSync(
180
+ ".vscode/mcp.json",
181
+ JSON.stringify(
182
+ {
183
+ inputs: [{ id: "skillrepo-api-key", type: "promptString" }],
184
+ servers: { skillrepo: { url: "x" } },
185
+ },
186
+ null,
187
+ 2,
188
+ ),
189
+ );
190
+
191
+ const first = removeVscodeMcp();
192
+ assert.equal(first.action, "removed");
193
+
194
+ const second = removeVscodeMcp();
195
+ assert.equal(second.action, "skipped");
196
+ });
197
+
198
+ it("dryRun does not modify the file and reports both removals in preview", () => {
199
+ const content = JSON.stringify(
200
+ {
201
+ inputs: [{ id: "skillrepo-api-key", type: "promptString" }],
202
+ servers: { skillrepo: { url: "x" } },
203
+ },
204
+ null,
205
+ 2,
206
+ );
207
+ writeFileSync(".vscode/mcp.json", content);
208
+
209
+ const result = removeVscodeMcp({ dryRun: true });
210
+ assert.equal(result.action, "would-remove");
211
+ assert.ok(result.removed.includes("servers.skillrepo"));
212
+ assert.ok(result.removed.includes("inputs[skillrepo-api-key]"));
213
+ assert.equal(readFileSync(".vscode/mcp.json", "utf-8"), content);
214
+ });
215
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Unit tests for src/lib/removers/windsurf-mcp.mjs (#885).
3
+ *
4
+ * Windsurf is global-scope — the config lives at
5
+ * `~/.codeium/windsurf/mcp_config.json` regardless of project. Tests
6
+ * stub HOME so the remover targets a sandbox rather than the real
7
+ * user directory. The sandboxing is CRITICAL: without the HOME
8
+ * override a test run that creates a fake mcp_config.json would
9
+ * persist after teardown and the test itself would touch the
10
+ * developer's real Windsurf config.
11
+ */
12
+
13
+ import { describe, it, beforeEach, afterEach } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import {
16
+ mkdtempSync,
17
+ mkdirSync,
18
+ rmSync,
19
+ readFileSync,
20
+ writeFileSync,
21
+ } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { tmpdir } from "node:os";
24
+
25
+ import { removeWindsurfMcp } from "../../lib/removers/windsurf-mcp.mjs";
26
+
27
+ let sandbox;
28
+ let originalHome;
29
+ let mcpConfigPath;
30
+
31
+ function setup() {
32
+ sandbox = mkdtempSync(join(tmpdir(), "cli-unrm-windsurf-"));
33
+ originalHome = process.env.HOME;
34
+ process.env.HOME = sandbox;
35
+ // Sanity guard: asserting the override worked BEFORE any remover
36
+ // runs is cheap insurance against forgetting to restore HOME in a
37
+ // previous test's teardown or a parallel test cross-contamination.
38
+ assert.ok(
39
+ process.env.HOME.startsWith(tmpdir()),
40
+ "HOME must point inside tmpdir before any filesystem write",
41
+ );
42
+ mcpConfigPath = join(sandbox, ".codeium", "windsurf", "mcp_config.json");
43
+ mkdirSync(join(sandbox, ".codeium", "windsurf"), { recursive: true });
44
+ }
45
+
46
+ function teardown() {
47
+ process.env.HOME = originalHome;
48
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
49
+ }
50
+
51
+ describe("removeWindsurfMcp", () => {
52
+ beforeEach(setup);
53
+ afterEach(teardown);
54
+
55
+ it("is a no-op when the global config does not exist", () => {
56
+ // Don't create the file; the directory exists but not the file.
57
+ const result = removeWindsurfMcp();
58
+ assert.equal(result.action, "skipped");
59
+ });
60
+
61
+ it("removes only skillrepo, preserves siblings", () => {
62
+ writeFileSync(
63
+ mcpConfigPath,
64
+ JSON.stringify(
65
+ {
66
+ mcpServers: {
67
+ skillrepo: { serverUrl: "x", headers: {} },
68
+ anotherTool: { serverUrl: "y" },
69
+ },
70
+ },
71
+ null,
72
+ 2,
73
+ ),
74
+ );
75
+
76
+ const result = removeWindsurfMcp();
77
+ assert.equal(result.action, "removed");
78
+ assert.equal(result.path, "~/.codeium/windsurf/mcp_config.json");
79
+
80
+ const parsed = JSON.parse(readFileSync(mcpConfigPath, "utf-8"));
81
+ assert.equal(parsed.mcpServers.skillrepo, undefined);
82
+ assert.ok(parsed.mcpServers.anotherTool);
83
+ });
84
+
85
+ it("returns a structured error on unparseable JSON", () => {
86
+ writeFileSync(mcpConfigPath, "{ broken");
87
+
88
+ const result = removeWindsurfMcp();
89
+ assert.equal(result.action, "skipped");
90
+ assert.ok(result.error);
91
+ });
92
+
93
+ it("is idempotent — second call is a no-op", () => {
94
+ writeFileSync(
95
+ mcpConfigPath,
96
+ JSON.stringify(
97
+ { mcpServers: { skillrepo: { serverUrl: "x", headers: {} } } },
98
+ null,
99
+ 2,
100
+ ),
101
+ );
102
+
103
+ const first = removeWindsurfMcp();
104
+ assert.equal(first.action, "removed");
105
+
106
+ const second = removeWindsurfMcp();
107
+ assert.equal(second.action, "skipped");
108
+ });
109
+
110
+ it("dryRun does not modify the file", () => {
111
+ const content = JSON.stringify(
112
+ { mcpServers: { skillrepo: { serverUrl: "x" } } },
113
+ null,
114
+ 2,
115
+ );
116
+ writeFileSync(mcpConfigPath, content);
117
+
118
+ const result = removeWindsurfMcp({ dryRun: true });
119
+ assert.equal(result.action, "would-remove");
120
+ assert.equal(readFileSync(mcpConfigPath, "utf-8"), content);
121
+ });
122
+ });