skillrepo 3.0.0 → 3.1.1

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