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,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
+ });