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,768 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit/integration tests for src/commands/uninstall.mjs (#885).
|
|
3
|
+
*
|
|
4
|
+
* HOME-ISOLATION SAFETY CONTRACT (architect tightening #2):
|
|
5
|
+
*
|
|
6
|
+
* Every test sets `process.env.HOME` to a `mkdtempSync` sandbox
|
|
7
|
+
* inside `os.tmpdir()` BEFORE running any code that could touch the
|
|
8
|
+
* filesystem. The `beforeEach` hook asserts that contract loudly —
|
|
9
|
+
* if a future test copy-pastes setup() and forgets the HOME
|
|
10
|
+
* override, the assertion fails before any `rmSync` can run against
|
|
11
|
+
* the developer's real `~/.claude/skillrepo/` directory.
|
|
12
|
+
*
|
|
13
|
+
* This matters especially for `--global` tests: uninstall --global
|
|
14
|
+
* does `rmSync(join(homedir(), ".claude", "skillrepo"), { recursive
|
|
15
|
+
* true })`. Without the HOME override, a test run would nuke real
|
|
16
|
+
* user state. The architect-flagged incident from the prior v3.0.0
|
|
17
|
+
* session (a debug script that forgot HOME and wrote a localhost
|
|
18
|
+
* URL into the user's real config) is the failure mode this
|
|
19
|
+
* contract exists to prevent.
|
|
20
|
+
*
|
|
21
|
+
* NEVER remove the `ASSERT_HOME_ISOLATED` check. If it breaks, fix
|
|
22
|
+
* the test's setup — do not silence the assertion.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
26
|
+
import assert from "node:assert/strict";
|
|
27
|
+
import {
|
|
28
|
+
mkdtempSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
rmSync,
|
|
31
|
+
readFileSync,
|
|
32
|
+
writeFileSync,
|
|
33
|
+
existsSync,
|
|
34
|
+
} from "node:fs";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { tmpdir } from "node:os";
|
|
37
|
+
|
|
38
|
+
import { runUninstall } from "../../commands/uninstall.mjs";
|
|
39
|
+
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
40
|
+
|
|
41
|
+
let sandbox;
|
|
42
|
+
let projectDir;
|
|
43
|
+
let homeDir;
|
|
44
|
+
let originalCwd;
|
|
45
|
+
let originalHome;
|
|
46
|
+
let stdout;
|
|
47
|
+
let stderr;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sanity guard: before any remover code can run, assert that
|
|
51
|
+
* `process.env.HOME` points inside `os.tmpdir()`. If a test forgets
|
|
52
|
+
* to override HOME, this check fails loudly BEFORE any destructive
|
|
53
|
+
* operation touches the real home directory. This is the safety net
|
|
54
|
+
* architect tightening #2 asked for.
|
|
55
|
+
*/
|
|
56
|
+
function ASSERT_HOME_ISOLATED() {
|
|
57
|
+
assert.ok(
|
|
58
|
+
process.env.HOME && process.env.HOME.startsWith(tmpdir()),
|
|
59
|
+
`HOME must point inside os.tmpdir() during uninstall tests. ` +
|
|
60
|
+
`Current HOME="${process.env.HOME}" — setup() forgot the override.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function setup() {
|
|
65
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-uninstall-"));
|
|
66
|
+
projectDir = join(sandbox, "project");
|
|
67
|
+
homeDir = join(sandbox, "home");
|
|
68
|
+
mkdirSync(projectDir, { recursive: true });
|
|
69
|
+
mkdirSync(homeDir, { recursive: true });
|
|
70
|
+
|
|
71
|
+
originalCwd = process.cwd();
|
|
72
|
+
originalHome = process.env.HOME;
|
|
73
|
+
process.chdir(projectDir);
|
|
74
|
+
process.env.HOME = homeDir;
|
|
75
|
+
|
|
76
|
+
ASSERT_HOME_ISOLATED();
|
|
77
|
+
|
|
78
|
+
stdout = createCaptureStream();
|
|
79
|
+
stderr = createCaptureStream();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function teardown() {
|
|
83
|
+
process.chdir(originalCwd);
|
|
84
|
+
process.env.HOME = originalHome;
|
|
85
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Seed a project directory with every v3.0.0 artifact `skillrepo init`
|
|
90
|
+
* would write. Each test starts from a clean sandbox and calls this
|
|
91
|
+
* to get a realistic "installed" state to uninstall from.
|
|
92
|
+
*
|
|
93
|
+
* Note: we do NOT call runInit to seed — that would require a running
|
|
94
|
+
* mock server, serial test execution, and couples the uninstall tests
|
|
95
|
+
* to the init flow. Writing the artifacts directly is faster and
|
|
96
|
+
* makes each test's assumed starting state explicit.
|
|
97
|
+
*/
|
|
98
|
+
function seedInstalledProject({
|
|
99
|
+
mcpSkillrepo = true,
|
|
100
|
+
mcpOtherTool = false,
|
|
101
|
+
envLocalHasKey = true,
|
|
102
|
+
envLocalHasOther = false,
|
|
103
|
+
gitignoreHasSection = true,
|
|
104
|
+
gitignoreHasOther = false,
|
|
105
|
+
skillsDir = true,
|
|
106
|
+
settingsSessionHook = false,
|
|
107
|
+
settingsUserHook = false,
|
|
108
|
+
} = {}) {
|
|
109
|
+
// .mcp.json
|
|
110
|
+
const mcpConfig = { mcpServers: {} };
|
|
111
|
+
if (mcpSkillrepo) {
|
|
112
|
+
mcpConfig.mcpServers.skillrepo = {
|
|
113
|
+
type: "http",
|
|
114
|
+
url: "https://skillrepo.dev/api/mcp",
|
|
115
|
+
headers: { Authorization: "Bearer ${SKILLREPO_ACCESS_KEY}" },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (mcpOtherTool) {
|
|
119
|
+
mcpConfig.mcpServers.otherTool = {
|
|
120
|
+
type: "http",
|
|
121
|
+
url: "https://example.com/mcp",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
writeFileSync(
|
|
125
|
+
join(projectDir, ".mcp.json"),
|
|
126
|
+
JSON.stringify(mcpConfig, null, 2),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// .env.local
|
|
130
|
+
const envLines = [];
|
|
131
|
+
if (envLocalHasOther) envLines.push("DATABASE_URL=postgres://localhost");
|
|
132
|
+
if (envLocalHasKey) envLines.push("SKILLREPO_ACCESS_KEY=sk_live_testkey");
|
|
133
|
+
if (envLines.length > 0) {
|
|
134
|
+
writeFileSync(join(projectDir, ".env.local"), envLines.join("\n") + "\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// .gitignore
|
|
138
|
+
const giLines = [];
|
|
139
|
+
if (gitignoreHasOther) giLines.push("node_modules/", "");
|
|
140
|
+
if (gitignoreHasSection) {
|
|
141
|
+
giLines.push(
|
|
142
|
+
"# SkillRepo CLI (added by `skillrepo init`)",
|
|
143
|
+
".env.local",
|
|
144
|
+
".claude/skills/",
|
|
145
|
+
".claude/settings.local.json",
|
|
146
|
+
"",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (giLines.length > 0) {
|
|
150
|
+
writeFileSync(join(projectDir, ".gitignore"), giLines.join("\n"));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// .claude/skills/ directory with one fake skill for child-count detail
|
|
154
|
+
if (skillsDir) {
|
|
155
|
+
mkdirSync(join(projectDir, ".claude", "skills", "fake-skill"), {
|
|
156
|
+
recursive: true,
|
|
157
|
+
});
|
|
158
|
+
writeFileSync(
|
|
159
|
+
join(projectDir, ".claude", "skills", "fake-skill", "SKILL.md"),
|
|
160
|
+
"---\nname: fake-skill\n---\nbody",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// .claude/settings.local.json with a SkillRepo SessionStart hook
|
|
165
|
+
// (forward-declaration for #884 integration; the remover already
|
|
166
|
+
// handles this artifact today).
|
|
167
|
+
if (settingsSessionHook || settingsUserHook) {
|
|
168
|
+
const hooks = [];
|
|
169
|
+
if (settingsUserHook) {
|
|
170
|
+
hooks.push({
|
|
171
|
+
hooks: [{ type: "command", command: "echo user-owned-hook" }],
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (settingsSessionHook) {
|
|
175
|
+
hooks.push({
|
|
176
|
+
hooks: [
|
|
177
|
+
{
|
|
178
|
+
type: "command",
|
|
179
|
+
command: `/usr/local/bin/skillrepo update --session-hook 2>&1 || true`,
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
mkdirSync(join(projectDir, ".claude"), { recursive: true });
|
|
185
|
+
writeFileSync(
|
|
186
|
+
join(projectDir, ".claude", "settings.local.json"),
|
|
187
|
+
JSON.stringify({ hooks: { SessionStart: hooks } }, null, 2),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Seed global state that `skillrepo init` would write with --global.
|
|
194
|
+
*/
|
|
195
|
+
function seedInstalledGlobal() {
|
|
196
|
+
// ~/.claude/skillrepo/config.json + .last-sync
|
|
197
|
+
mkdirSync(join(homeDir, ".claude", "skillrepo"), { recursive: true });
|
|
198
|
+
writeFileSync(
|
|
199
|
+
join(homeDir, ".claude", "skillrepo", "config.json"),
|
|
200
|
+
JSON.stringify(
|
|
201
|
+
{
|
|
202
|
+
schemaVersion: 1,
|
|
203
|
+
apiKey: "sk_live_testkey",
|
|
204
|
+
serverUrl: "https://skillrepo.dev",
|
|
205
|
+
},
|
|
206
|
+
null,
|
|
207
|
+
2,
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
writeFileSync(
|
|
211
|
+
join(homeDir, ".claude", "skillrepo", ".last-sync"),
|
|
212
|
+
JSON.stringify({ schemaVersion: 1, etag: null, syncedAt: new Date().toISOString() }),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
// ~/.claude/skills/ global skill cache
|
|
216
|
+
mkdirSync(join(homeDir, ".claude", "skills", "global-skill"), {
|
|
217
|
+
recursive: true,
|
|
218
|
+
});
|
|
219
|
+
writeFileSync(
|
|
220
|
+
join(homeDir, ".claude", "skills", "global-skill", "SKILL.md"),
|
|
221
|
+
"---\nname: global-skill\n---\nbody",
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// ~/.codeium/windsurf/mcp_config.json
|
|
225
|
+
mkdirSync(join(homeDir, ".codeium", "windsurf"), { recursive: true });
|
|
226
|
+
writeFileSync(
|
|
227
|
+
join(homeDir, ".codeium", "windsurf", "mcp_config.json"),
|
|
228
|
+
JSON.stringify(
|
|
229
|
+
{
|
|
230
|
+
mcpServers: {
|
|
231
|
+
skillrepo: {
|
|
232
|
+
serverUrl: "https://skillrepo.dev/api/mcp",
|
|
233
|
+
headers: { Authorization: "Bearer ${env:SKILLREPO_ACCESS_KEY}" },
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
null,
|
|
238
|
+
2,
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ──────────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
describe("runUninstall — nothing-to-remove", () => {
|
|
246
|
+
beforeEach(setup);
|
|
247
|
+
afterEach(teardown);
|
|
248
|
+
|
|
249
|
+
it("reports Nothing to remove and exits 0 when no artifacts exist", async () => {
|
|
250
|
+
ASSERT_HOME_ISOLATED();
|
|
251
|
+
await runUninstall(["--yes"], { stdout, stderr });
|
|
252
|
+
assert.match(stdout.text(), /Nothing to remove/);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("is idempotent after a prior uninstall", async () => {
|
|
256
|
+
ASSERT_HOME_ISOLATED();
|
|
257
|
+
seedInstalledProject();
|
|
258
|
+
await runUninstall(["--yes"], { stdout, stderr });
|
|
259
|
+
stdout.clear();
|
|
260
|
+
|
|
261
|
+
await runUninstall(["--yes"], { stdout, stderr });
|
|
262
|
+
assert.match(stdout.text(), /Nothing to remove/);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("runUninstall — project happy path", () => {
|
|
267
|
+
beforeEach(setup);
|
|
268
|
+
afterEach(teardown);
|
|
269
|
+
|
|
270
|
+
it("removes every SkillRepo-owned artifact with --yes", async () => {
|
|
271
|
+
ASSERT_HOME_ISOLATED();
|
|
272
|
+
seedInstalledProject();
|
|
273
|
+
|
|
274
|
+
await runUninstall(["--yes"], { stdout, stderr });
|
|
275
|
+
|
|
276
|
+
// Skill directory gone
|
|
277
|
+
assert.ok(!existsSync(join(projectDir, ".claude", "skills")));
|
|
278
|
+
// .mcp.json still exists but no longer has the skillrepo entry
|
|
279
|
+
const mcp = JSON.parse(
|
|
280
|
+
readFileSync(join(projectDir, ".mcp.json"), "utf-8"),
|
|
281
|
+
);
|
|
282
|
+
assert.equal(mcp.mcpServers.skillrepo, undefined);
|
|
283
|
+
// .env.local no longer has the key (file may or may not exist
|
|
284
|
+
// depending on whether it had other content)
|
|
285
|
+
const envPath = join(projectDir, ".env.local");
|
|
286
|
+
if (existsSync(envPath)) {
|
|
287
|
+
const env = readFileSync(envPath, "utf-8");
|
|
288
|
+
assert.doesNotMatch(env, /SKILLREPO_ACCESS_KEY=/);
|
|
289
|
+
}
|
|
290
|
+
// .gitignore no longer has the section
|
|
291
|
+
const gi = readFileSync(join(projectDir, ".gitignore"), "utf-8");
|
|
292
|
+
assert.doesNotMatch(gi, /SkillRepo/);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("preserves non-SkillRepo content in shared files", async () => {
|
|
296
|
+
ASSERT_HOME_ISOLATED();
|
|
297
|
+
seedInstalledProject({
|
|
298
|
+
mcpOtherTool: true,
|
|
299
|
+
envLocalHasOther: true,
|
|
300
|
+
gitignoreHasOther: true,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await runUninstall(["--yes"], { stdout, stderr });
|
|
304
|
+
|
|
305
|
+
// Sibling MCP server survives
|
|
306
|
+
const mcp = JSON.parse(
|
|
307
|
+
readFileSync(join(projectDir, ".mcp.json"), "utf-8"),
|
|
308
|
+
);
|
|
309
|
+
assert.ok(mcp.mcpServers.otherTool, "sibling MCP server must survive");
|
|
310
|
+
// Sibling env var survives
|
|
311
|
+
const env = readFileSync(join(projectDir, ".env.local"), "utf-8");
|
|
312
|
+
assert.match(env, /DATABASE_URL=postgres:\/\/localhost/);
|
|
313
|
+
assert.doesNotMatch(env, /SKILLREPO_ACCESS_KEY=/);
|
|
314
|
+
// User's gitignore lines survive
|
|
315
|
+
const gi = readFileSync(join(projectDir, ".gitignore"), "utf-8");
|
|
316
|
+
assert.match(gi, /node_modules\//);
|
|
317
|
+
assert.doesNotMatch(gi, /SkillRepo/);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("does NOT touch global state by default", async () => {
|
|
321
|
+
ASSERT_HOME_ISOLATED();
|
|
322
|
+
seedInstalledProject();
|
|
323
|
+
seedInstalledGlobal();
|
|
324
|
+
|
|
325
|
+
await runUninstall(["--yes"], { stdout, stderr });
|
|
326
|
+
|
|
327
|
+
// Global config still exists — user's credential survives a
|
|
328
|
+
// project-only uninstall so other projects on the same machine
|
|
329
|
+
// keep working.
|
|
330
|
+
assert.ok(existsSync(join(homeDir, ".claude", "skillrepo", "config.json")));
|
|
331
|
+
assert.ok(existsSync(join(homeDir, ".claude", "skills")));
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe("runUninstall — --global", () => {
|
|
336
|
+
beforeEach(setup);
|
|
337
|
+
afterEach(teardown);
|
|
338
|
+
|
|
339
|
+
it("removes global state when --global is passed", async () => {
|
|
340
|
+
ASSERT_HOME_ISOLATED();
|
|
341
|
+
seedInstalledProject();
|
|
342
|
+
seedInstalledGlobal();
|
|
343
|
+
|
|
344
|
+
await runUninstall(["--yes", "--global"], { stdout, stderr });
|
|
345
|
+
|
|
346
|
+
// Global artifacts gone
|
|
347
|
+
assert.ok(
|
|
348
|
+
!existsSync(join(homeDir, ".claude", "skillrepo")),
|
|
349
|
+
"~/.claude/skillrepo/ must be removed with --global",
|
|
350
|
+
);
|
|
351
|
+
assert.ok(
|
|
352
|
+
!existsSync(join(homeDir, ".claude", "skills")),
|
|
353
|
+
"~/.claude/skills/ must be removed with --global",
|
|
354
|
+
);
|
|
355
|
+
// Windsurf entry removed but file preserved (it may contain
|
|
356
|
+
// other MCP servers — we only delete our key)
|
|
357
|
+
const ws = JSON.parse(
|
|
358
|
+
readFileSync(
|
|
359
|
+
join(homeDir, ".codeium", "windsurf", "mcp_config.json"),
|
|
360
|
+
"utf-8",
|
|
361
|
+
),
|
|
362
|
+
);
|
|
363
|
+
assert.equal(ws.mcpServers.skillrepo, undefined);
|
|
364
|
+
// Project artifacts also gone (project + global = both passes)
|
|
365
|
+
assert.ok(!existsSync(join(projectDir, ".claude", "skills")));
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("removes a SkillRepo SessionStart hook end-to-end (covers settings-session-hook descriptor)", async () => {
|
|
369
|
+
// Command-level integration for the settings remover — closes
|
|
370
|
+
// a coverage gap the code-reviewer flagged in round 1. The
|
|
371
|
+
// remover has its own unit tests, but the orchestrator's
|
|
372
|
+
// handling of this descriptor (scan → execute → JSON output)
|
|
373
|
+
// is only tested here.
|
|
374
|
+
ASSERT_HOME_ISOLATED();
|
|
375
|
+
seedInstalledProject({
|
|
376
|
+
settingsSessionHook: true,
|
|
377
|
+
settingsUserHook: true,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
await runUninstall(["--yes", "--json"], { stdout, stderr });
|
|
381
|
+
|
|
382
|
+
const json = JSON.parse(stdout.text());
|
|
383
|
+
assert.equal(json.action, "uninstalled");
|
|
384
|
+
const settingsRemoval = json.removed.find(
|
|
385
|
+
(r) => r.id === "settings-session-hook",
|
|
386
|
+
);
|
|
387
|
+
assert.ok(
|
|
388
|
+
settingsRemoval,
|
|
389
|
+
"settings-session-hook must appear in the removed list",
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const settings = JSON.parse(
|
|
393
|
+
readFileSync(
|
|
394
|
+
join(projectDir, ".claude", "settings.local.json"),
|
|
395
|
+
"utf-8",
|
|
396
|
+
),
|
|
397
|
+
);
|
|
398
|
+
// User's hook survives — SkillRepo's entry filtered out.
|
|
399
|
+
const allCommands = (settings.hooks?.SessionStart ?? [])
|
|
400
|
+
.flatMap((group) => group.hooks ?? [])
|
|
401
|
+
.map((h) => h.command);
|
|
402
|
+
assert.ok(
|
|
403
|
+
allCommands.some((c) => c.includes("user-owned-hook")),
|
|
404
|
+
"user-authored hook must survive uninstall",
|
|
405
|
+
);
|
|
406
|
+
assert.ok(
|
|
407
|
+
!allCommands.some((c) => c.includes("skillrepo update --session-hook")),
|
|
408
|
+
"SkillRepo hook must be gone",
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("round-trip: real installer writes global hook → real uninstall --global strips it (closes #884 gap)", async () => {
|
|
413
|
+
// Regression guard for the gap the user flagged during #884
|
|
414
|
+
// review. CRITICAL: this test uses the REAL installer
|
|
415
|
+
// (`mergeSessionHook({ global: true })`) to seed the hook, NOT a
|
|
416
|
+
// manual writeFileSync. A manual seed would pass even if the
|
|
417
|
+
// installer's output format drifted away from what the
|
|
418
|
+
// uninstaller expects — false-negative territory. Calling the
|
|
419
|
+
// real installer locks the end-to-end contract: what the
|
|
420
|
+
// installer writes, the uninstaller (via the registry-driven
|
|
421
|
+
// orchestrator) must find and strip.
|
|
422
|
+
//
|
|
423
|
+
// Before #884's uninstall fix, `skillrepo init --global` (which
|
|
424
|
+
// calls mergeSessionHook({ global: true })) would install the
|
|
425
|
+
// hook at `~/.claude/settings.local.json` but `skillrepo
|
|
426
|
+
// uninstall --global` had no registry descriptor for that path
|
|
427
|
+
// — the hook was unreachable. The `settings-session-hook-global`
|
|
428
|
+
// descriptor closes the gap.
|
|
429
|
+
ASSERT_HOME_ISOLATED();
|
|
430
|
+
|
|
431
|
+
// Pre-seed user-authored content in the global settings file
|
|
432
|
+
// so we can verify it survives uninstall.
|
|
433
|
+
mkdirSync(join(homeDir, ".claude"), { recursive: true });
|
|
434
|
+
writeFileSync(
|
|
435
|
+
join(homeDir, ".claude", "settings.local.json"),
|
|
436
|
+
JSON.stringify({ env: { USER_KEPT: "yes" } }, null, 2),
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
// Now use the REAL installer to write the hook. Pass an
|
|
440
|
+
// explicit binaryPath so `which skillrepo` doesn't leak through
|
|
441
|
+
// to the developer's real PATH.
|
|
442
|
+
const { mergeSessionHook } = await import(
|
|
443
|
+
"../../lib/mergers/session-hook.mjs"
|
|
444
|
+
);
|
|
445
|
+
const installResult = mergeSessionHook({
|
|
446
|
+
binaryPath: "/usr/local/bin/skillrepo",
|
|
447
|
+
global: true,
|
|
448
|
+
});
|
|
449
|
+
assert.equal(
|
|
450
|
+
installResult.action,
|
|
451
|
+
"installed",
|
|
452
|
+
"installer must write the hook before uninstall runs",
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
// Sanity check the installer actually wrote to the global path,
|
|
456
|
+
// NOT the project path.
|
|
457
|
+
assert.ok(
|
|
458
|
+
existsSync(join(homeDir, ".claude", "settings.local.json")),
|
|
459
|
+
"installer must target ~/.claude/settings.local.json with global: true",
|
|
460
|
+
);
|
|
461
|
+
assert.ok(
|
|
462
|
+
!existsSync(join(projectDir, ".claude", "settings.local.json")),
|
|
463
|
+
"installer must NOT have written to project-local path",
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
// Now invoke uninstall via the real command path.
|
|
467
|
+
await runUninstall(["--yes", "--global", "--json"], { stdout, stderr });
|
|
468
|
+
|
|
469
|
+
const json = JSON.parse(stdout.text());
|
|
470
|
+
const globalRemoval = json.removed.find(
|
|
471
|
+
(r) => r.id === "settings-session-hook-global",
|
|
472
|
+
);
|
|
473
|
+
assert.ok(
|
|
474
|
+
globalRemoval,
|
|
475
|
+
"settings-session-hook-global must appear in removed[] (registry → remover round-trip)",
|
|
476
|
+
);
|
|
477
|
+
assert.equal(globalRemoval.path, "~/.claude/settings.local.json");
|
|
478
|
+
|
|
479
|
+
// Hook gone; user-authored content preserved.
|
|
480
|
+
const settings = JSON.parse(
|
|
481
|
+
readFileSync(
|
|
482
|
+
join(homeDir, ".claude", "settings.local.json"),
|
|
483
|
+
"utf-8",
|
|
484
|
+
),
|
|
485
|
+
);
|
|
486
|
+
const allCommands = (settings.hooks?.SessionStart ?? [])
|
|
487
|
+
.flatMap((group) => group?.hooks ?? [])
|
|
488
|
+
.map((h) => h?.command);
|
|
489
|
+
assert.ok(
|
|
490
|
+
!allCommands.some((c) => c?.includes("skillrepo update --session-hook")),
|
|
491
|
+
"global SkillRepo hook must be gone after uninstall --global",
|
|
492
|
+
);
|
|
493
|
+
assert.deepEqual(
|
|
494
|
+
settings.env,
|
|
495
|
+
{ USER_KEPT: "yes" },
|
|
496
|
+
"user-authored env must survive the global uninstall",
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("does NOT touch the global settings file without --global (scope isolation)", async () => {
|
|
501
|
+
// Mirror-test: project-only uninstall must never touch the
|
|
502
|
+
// user-wide settings file, even if it has a SkillRepo hook.
|
|
503
|
+
// This protects users on multi-project machines — uninstalling
|
|
504
|
+
// from project A shouldn't remove session-sync for projects
|
|
505
|
+
// B, C, D.
|
|
506
|
+
ASSERT_HOME_ISOLATED();
|
|
507
|
+
mkdirSync(join(homeDir, ".claude"), { recursive: true });
|
|
508
|
+
const originalGlobal = JSON.stringify(
|
|
509
|
+
{
|
|
510
|
+
hooks: {
|
|
511
|
+
SessionStart: [
|
|
512
|
+
{
|
|
513
|
+
hooks: [
|
|
514
|
+
{
|
|
515
|
+
type: "command",
|
|
516
|
+
command:
|
|
517
|
+
"/usr/local/bin/skillrepo update --session-hook 2>&1 || true",
|
|
518
|
+
},
|
|
519
|
+
],
|
|
520
|
+
},
|
|
521
|
+
],
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
null,
|
|
525
|
+
2,
|
|
526
|
+
);
|
|
527
|
+
writeFileSync(
|
|
528
|
+
join(homeDir, ".claude", "settings.local.json"),
|
|
529
|
+
originalGlobal,
|
|
530
|
+
);
|
|
531
|
+
seedInstalledProject({ settingsSessionHook: true });
|
|
532
|
+
|
|
533
|
+
await runUninstall(["--yes"], { stdout, stderr });
|
|
534
|
+
|
|
535
|
+
// Global settings file byte-for-byte unchanged.
|
|
536
|
+
assert.equal(
|
|
537
|
+
readFileSync(
|
|
538
|
+
join(homeDir, ".claude", "settings.local.json"),
|
|
539
|
+
"utf-8",
|
|
540
|
+
),
|
|
541
|
+
originalGlobal,
|
|
542
|
+
"global settings file must be untouched by project-only uninstall",
|
|
543
|
+
);
|
|
544
|
+
// But project-local hook IS gone (happy path continues to work).
|
|
545
|
+
const projectSettings = JSON.parse(
|
|
546
|
+
readFileSync(
|
|
547
|
+
join(projectDir, ".claude", "settings.local.json"),
|
|
548
|
+
"utf-8",
|
|
549
|
+
),
|
|
550
|
+
);
|
|
551
|
+
const projectCommands = (projectSettings.hooks?.SessionStart ?? [])
|
|
552
|
+
.flatMap((g) => g?.hooks ?? [])
|
|
553
|
+
.map((h) => h?.command);
|
|
554
|
+
assert.ok(
|
|
555
|
+
!projectCommands.some((c) =>
|
|
556
|
+
c?.includes("skillrepo update --session-hook"),
|
|
557
|
+
),
|
|
558
|
+
"project-local hook still gets cleaned up",
|
|
559
|
+
);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("does NOT touch the Windsurf config without --global", async () => {
|
|
563
|
+
// Multi-tenant correctness: a project-only uninstall must leave
|
|
564
|
+
// Windsurf's global MCP config untouched. A user with several
|
|
565
|
+
// projects on one machine expects running uninstall in project
|
|
566
|
+
// A to keep project B's Windsurf integration working.
|
|
567
|
+
ASSERT_HOME_ISOLATED();
|
|
568
|
+
seedInstalledProject();
|
|
569
|
+
seedInstalledGlobal();
|
|
570
|
+
|
|
571
|
+
const wsBefore = readFileSync(
|
|
572
|
+
join(homeDir, ".codeium", "windsurf", "mcp_config.json"),
|
|
573
|
+
"utf-8",
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
await runUninstall(["--yes"], { stdout, stderr });
|
|
577
|
+
|
|
578
|
+
const wsAfter = readFileSync(
|
|
579
|
+
join(homeDir, ".codeium", "windsurf", "mcp_config.json"),
|
|
580
|
+
"utf-8",
|
|
581
|
+
);
|
|
582
|
+
assert.equal(wsAfter, wsBefore, "Windsurf config is untouched without --global");
|
|
583
|
+
// And ~/.claude/skillrepo/config.json survives — the user's
|
|
584
|
+
// credential for other projects stays valid.
|
|
585
|
+
assert.ok(existsSync(join(homeDir, ".claude", "skillrepo", "config.json")));
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
describe("runUninstall — --dry-run", () => {
|
|
590
|
+
beforeEach(setup);
|
|
591
|
+
afterEach(teardown);
|
|
592
|
+
|
|
593
|
+
it("does not modify any file", async () => {
|
|
594
|
+
ASSERT_HOME_ISOLATED();
|
|
595
|
+
seedInstalledProject();
|
|
596
|
+
|
|
597
|
+
const mcpBefore = readFileSync(join(projectDir, ".mcp.json"), "utf-8");
|
|
598
|
+
const envBefore = readFileSync(join(projectDir, ".env.local"), "utf-8");
|
|
599
|
+
const giBefore = readFileSync(join(projectDir, ".gitignore"), "utf-8");
|
|
600
|
+
|
|
601
|
+
await runUninstall(["--dry-run"], { stdout, stderr });
|
|
602
|
+
|
|
603
|
+
// All files byte-for-byte unchanged
|
|
604
|
+
assert.equal(
|
|
605
|
+
readFileSync(join(projectDir, ".mcp.json"), "utf-8"),
|
|
606
|
+
mcpBefore,
|
|
607
|
+
);
|
|
608
|
+
assert.equal(
|
|
609
|
+
readFileSync(join(projectDir, ".env.local"), "utf-8"),
|
|
610
|
+
envBefore,
|
|
611
|
+
);
|
|
612
|
+
assert.equal(
|
|
613
|
+
readFileSync(join(projectDir, ".gitignore"), "utf-8"),
|
|
614
|
+
giBefore,
|
|
615
|
+
);
|
|
616
|
+
// Skill directory still exists
|
|
617
|
+
assert.ok(existsSync(join(projectDir, ".claude", "skills", "fake-skill")));
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("lists what would be removed", async () => {
|
|
621
|
+
ASSERT_HOME_ISOLATED();
|
|
622
|
+
seedInstalledProject();
|
|
623
|
+
|
|
624
|
+
await runUninstall(["--dry-run"], { stdout, stderr });
|
|
625
|
+
|
|
626
|
+
assert.match(stdout.text(), /Would remove/);
|
|
627
|
+
assert.match(stdout.text(), /\.mcp\.json/);
|
|
628
|
+
assert.match(stdout.text(), /\.env\.local/);
|
|
629
|
+
assert.match(stdout.text(), /\.gitignore/);
|
|
630
|
+
assert.match(stdout.text(), /\.claude\/skills\//);
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
describe("runUninstall — --json", () => {
|
|
635
|
+
beforeEach(setup);
|
|
636
|
+
afterEach(teardown);
|
|
637
|
+
|
|
638
|
+
it("outputs valid JSON with removed entries for the happy path", async () => {
|
|
639
|
+
ASSERT_HOME_ISOLATED();
|
|
640
|
+
seedInstalledProject();
|
|
641
|
+
|
|
642
|
+
await runUninstall(["--yes", "--json"], { stdout, stderr });
|
|
643
|
+
|
|
644
|
+
const json = JSON.parse(stdout.text());
|
|
645
|
+
assert.equal(json.action, "uninstalled");
|
|
646
|
+
assert.equal(json.scope, "project");
|
|
647
|
+
assert.ok(Array.isArray(json.removed));
|
|
648
|
+
assert.ok(json.removed.length > 0);
|
|
649
|
+
// Each removed entry has id and path
|
|
650
|
+
for (const r of json.removed) {
|
|
651
|
+
assert.ok(typeof r.id === "string");
|
|
652
|
+
assert.ok(typeof r.path === "string");
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("outputs valid JSON for --dry-run with would-remove entries", async () => {
|
|
657
|
+
ASSERT_HOME_ISOLATED();
|
|
658
|
+
seedInstalledProject();
|
|
659
|
+
|
|
660
|
+
await runUninstall(["--dry-run", "--json"], { stdout, stderr });
|
|
661
|
+
|
|
662
|
+
const json = JSON.parse(stdout.text());
|
|
663
|
+
assert.equal(json.action, "dry-run");
|
|
664
|
+
assert.ok(Array.isArray(json["would-remove"]));
|
|
665
|
+
assert.ok(json["would-remove"].length > 0);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("outputs valid JSON for the nothing-to-remove path", async () => {
|
|
669
|
+
ASSERT_HOME_ISOLATED();
|
|
670
|
+
|
|
671
|
+
await runUninstall(["--yes", "--json"], { stdout, stderr });
|
|
672
|
+
|
|
673
|
+
const json = JSON.parse(stdout.text());
|
|
674
|
+
assert.equal(json.action, "nothing-to-remove");
|
|
675
|
+
assert.deepEqual(json.removed, []);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("--json implicitly bypasses the interactive prompt (no --yes needed)", async () => {
|
|
679
|
+
// Architect review round 1 flagged this as a contract gap: the
|
|
680
|
+
// `if (!yes && !json)` gate is intentional (CI scripts use
|
|
681
|
+
// --json and shouldn't hang on stdin), but the behavior was
|
|
682
|
+
// only tested transitively via passing tests that already had
|
|
683
|
+
// --yes. This test locks the "--json means no prompt" contract
|
|
684
|
+
// so a future refactor that accidentally re-introduces prompt
|
|
685
|
+
// gating under --json breaks loudly.
|
|
686
|
+
ASSERT_HOME_ISOLATED();
|
|
687
|
+
seedInstalledProject();
|
|
688
|
+
|
|
689
|
+
// No --yes. --json alone. If the prompt were to fire, readline
|
|
690
|
+
// would block on stdin and this test would hang — node:test's
|
|
691
|
+
// default timeout would then fail it. A clean return-with-
|
|
692
|
+
// uninstalled-action is the proof.
|
|
693
|
+
await runUninstall(["--json"], { stdout, stderr });
|
|
694
|
+
|
|
695
|
+
const json = JSON.parse(stdout.text());
|
|
696
|
+
assert.equal(json.action, "uninstalled");
|
|
697
|
+
assert.ok(json.removed.length > 0);
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
describe("runUninstall — error handling", () => {
|
|
702
|
+
beforeEach(setup);
|
|
703
|
+
afterEach(teardown);
|
|
704
|
+
|
|
705
|
+
it("refuses to recursively remove a directory with a non-skills/skillrepo basename", async () => {
|
|
706
|
+
// Defense-in-depth for a path-resolution bug: if a future
|
|
707
|
+
// refactor changes `claudeSkillsProjectRoot()` to point somewhere
|
|
708
|
+
// dangerous, the inline remover's basename guard must refuse
|
|
709
|
+
// rmSync rather than propagating the bad path. We can't easily
|
|
710
|
+
// induce that kind of bug from the test surface, but we can
|
|
711
|
+
// verify the guard EXISTS by pointing at the code path: if the
|
|
712
|
+
// code below ever accepts a path with basename other than
|
|
713
|
+
// "skills" or "skillrepo", this test fails.
|
|
714
|
+
//
|
|
715
|
+
// We test this by reading the source of removeDirectoryArtifact
|
|
716
|
+
// — imperfect, but catches a refactor that removes the assertion
|
|
717
|
+
// without updating this test. Structural guard, not a runtime
|
|
718
|
+
// invariant exerciser.
|
|
719
|
+
const { readFileSync: rfs } = await import("node:fs");
|
|
720
|
+
const { fileURLToPath } = await import("node:url");
|
|
721
|
+
const src = rfs(
|
|
722
|
+
fileURLToPath(new URL("../../commands/uninstall.mjs", import.meta.url)),
|
|
723
|
+
"utf-8",
|
|
724
|
+
);
|
|
725
|
+
assert.match(
|
|
726
|
+
src,
|
|
727
|
+
/basename[^"]+"skills"[^"]+"skillrepo"/,
|
|
728
|
+
"removeDirectoryArtifact must assert basename in {skills, skillrepo} before rmSync",
|
|
729
|
+
);
|
|
730
|
+
assert.match(
|
|
731
|
+
src,
|
|
732
|
+
/Refusing to recursively remove/,
|
|
733
|
+
"the refusal path must surface a recognizable error message",
|
|
734
|
+
);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it("continues processing when a single file has unparseable JSON", async () => {
|
|
738
|
+
ASSERT_HOME_ISOLATED();
|
|
739
|
+
seedInstalledProject();
|
|
740
|
+
// Corrupt the .mcp.json — the skill directory and .gitignore
|
|
741
|
+
// should still be processed despite this failure.
|
|
742
|
+
writeFileSync(join(projectDir, ".mcp.json"), "{ corrupt json");
|
|
743
|
+
|
|
744
|
+
// runUninstall throws a diskError (EXIT_DISK) when any artifact
|
|
745
|
+
// fails so the dispatcher exits with code 3. The JSON summary is
|
|
746
|
+
// still written to stdout BEFORE the throw — we capture both.
|
|
747
|
+
let thrownError;
|
|
748
|
+
try {
|
|
749
|
+
await runUninstall(["--yes", "--json"], { stdout, stderr });
|
|
750
|
+
} catch (err) {
|
|
751
|
+
thrownError = err;
|
|
752
|
+
}
|
|
753
|
+
assert.ok(thrownError, "uninstall must throw when any artifact fails");
|
|
754
|
+
assert.equal(thrownError.exitCode, 3, "thrown error must carry EXIT_DISK");
|
|
755
|
+
|
|
756
|
+
const json = JSON.parse(stdout.text());
|
|
757
|
+
// The corrupt MCP file shows up in errors
|
|
758
|
+
const mcpError = json.errors.find((e) => e.path === ".mcp.json");
|
|
759
|
+
assert.ok(mcpError, ".mcp.json parse error must be surfaced");
|
|
760
|
+
assert.match(mcpError.error, /parse/i);
|
|
761
|
+
// But the skill directory and gitignore were still processed —
|
|
762
|
+
// continue-with-errors semantics preserved even though the
|
|
763
|
+
// command ultimately exits non-zero.
|
|
764
|
+
assert.ok(!existsSync(join(projectDir, ".claude", "skills")));
|
|
765
|
+
const giSurvivors = readFileSync(join(projectDir, ".gitignore"), "utf-8");
|
|
766
|
+
assert.doesNotMatch(giSurvivors, /SkillRepo/);
|
|
767
|
+
});
|
|
768
|
+
});
|