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.
- package/README.md +276 -145
- package/bin/skillrepo.mjs +224 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +589 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +162 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/session-sync.mjs +152 -0
- package/src/commands/uninstall.mjs +484 -0
- package/src/commands/update.mjs +184 -0
- package/src/lib/artifact-registry.mjs +265 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/fs-utils.mjs +83 -1
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/mergers/session-hook.mjs +298 -0
- package/src/lib/paths.mjs +67 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/removers/claude-mcp.mjs +67 -0
- package/src/lib/removers/cursor-mcp.mjs +60 -0
- package/src/lib/removers/env-local.mjs +55 -0
- package/src/lib/removers/gitignore.mjs +108 -0
- package/src/lib/removers/settings.mjs +183 -0
- package/src/lib/removers/vscode-mcp.mjs +87 -0
- package/src/lib/removers/windsurf-mcp.mjs +65 -0
- package/src/lib/sync.mjs +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +697 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -0
- package/src/test/commands/session-sync.test.mjs +350 -0
- package/src/test/commands/uninstall.test.mjs +768 -0
- package/src/test/commands/update.test.mjs +322 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- package/src/test/mergers/session-hook.test.mjs +745 -0
- package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
- package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
- package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
- package/src/test/mergers/uninstall-settings.test.mjs +285 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- 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
|
+
});
|