skillrepo 2.0.0 → 3.0.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 (49) hide show
  1. package/README.md +215 -150
  2. package/bin/skillrepo.mjs +210 -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 +471 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +167 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/update.mjs +67 -0
  11. package/src/lib/cli-config.mjs +230 -0
  12. package/src/lib/config.mjs +238 -0
  13. package/src/lib/detect-ides.mjs +0 -19
  14. package/src/lib/errors.mjs +264 -0
  15. package/src/lib/file-write.mjs +705 -0
  16. package/src/lib/http.mjs +817 -37
  17. package/src/lib/identifier.mjs +153 -0
  18. package/src/lib/mcp-merge.mjs +275 -0
  19. package/src/lib/mergers/gitignore.mjs +73 -18
  20. package/src/lib/paths.mjs +46 -17
  21. package/src/lib/prompt.mjs +11 -44
  22. package/src/lib/sync.mjs +305 -0
  23. package/src/test/commands/add.test.mjs +285 -0
  24. package/src/test/commands/get.test.mjs +176 -0
  25. package/src/test/commands/init.test.mjs +486 -0
  26. package/src/test/commands/list.test.mjs +172 -0
  27. package/src/test/commands/remove.test.mjs +234 -0
  28. package/src/test/commands/search.test.mjs +204 -0
  29. package/src/test/commands/update.test.mjs +164 -0
  30. package/src/test/detect-ides.test.mjs +9 -14
  31. package/src/test/dispatcher.test.mjs +224 -0
  32. package/src/test/e2e/cli-commands.test.mjs +576 -0
  33. package/src/test/e2e/mock-server.mjs +364 -22
  34. package/src/test/helpers/capture-stream.mjs +48 -0
  35. package/src/test/integration/file-write.integration.test.mjs +279 -0
  36. package/src/test/lib/cli-config.test.mjs +407 -0
  37. package/src/test/lib/config.test.mjs +257 -0
  38. package/src/test/lib/errors.test.mjs +359 -0
  39. package/src/test/lib/file-write.test.mjs +784 -0
  40. package/src/test/lib/http.test.mjs +1198 -0
  41. package/src/test/lib/identifier.test.mjs +157 -0
  42. package/src/test/lib/mcp-merge.test.mjs +345 -0
  43. package/src/test/lib/paths.test.mjs +83 -0
  44. package/src/test/lib/sync.test.mjs +514 -0
  45. package/src/test/mergers/gitignore.test.mjs +145 -20
  46. package/src/lib/write-configs.mjs +0 -202
  47. package/src/test/e2e/HANDOFF.md +0 -223
  48. package/src/test/e2e/cli-init.test.mjs +0 -213
  49. package/src/test/e2e/payload-factory.mjs +0 -22
@@ -0,0 +1,784 @@
1
+ /**
2
+ * Unit tests for src/lib/file-write.mjs (PR1 of #646).
3
+ *
4
+ * Covers:
5
+ * - Path validation (safety only — no layout enforcement)
6
+ * - Skill name validation
7
+ * - Frontmatter name parsing
8
+ * - Placement target resolution
9
+ * - placementTargetsFor() decision logic
10
+ * - validateSkill rejection paths via writeSkillDir
11
+ * - writeSkillDir + readback (path-preserving via lib/helper.py)
12
+ * - Update path (atomic POSIX dance)
13
+ * - removeSkillDir
14
+ * - cleanupOrphans
15
+ * - .gitignore management for the project /skills/ fallback
16
+ *
17
+ * Each test uses a temp cwd and HOME so vendor placement targets resolve
18
+ * to throwaway directories.
19
+ */
20
+
21
+ import { describe, it, beforeEach, afterEach } from "node:test";
22
+ import assert from "node:assert/strict";
23
+ import {
24
+ mkdtempSync,
25
+ rmSync,
26
+ mkdirSync,
27
+ writeFileSync,
28
+ existsSync,
29
+ readFileSync,
30
+ chmodSync,
31
+ } from "node:fs";
32
+ import { join } from "node:path";
33
+ import { tmpdir } from "node:os";
34
+
35
+ import {
36
+ validateFilePath,
37
+ isValidSkillName,
38
+ readFrontmatterName,
39
+ resolvePlacementDir,
40
+ placementTargetsFor,
41
+ writeSkillDir,
42
+ removeSkillDir,
43
+ cleanupOrphans,
44
+ MAX_PATH_DEPTH,
45
+ BLOCKED_EXTENSIONS,
46
+ } from "../../lib/file-write.mjs";
47
+ import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
48
+
49
+ // ── Test sandbox helpers ────────────────────────────────────────────────
50
+
51
+ let sandbox;
52
+ let originalCwd;
53
+ let originalHome;
54
+
55
+ function setupSandbox() {
56
+ sandbox = mkdtempSync(join(tmpdir(), "cli-fw-"));
57
+ mkdirSync(join(sandbox, "project"), { recursive: true });
58
+ mkdirSync(join(sandbox, "home"), { recursive: true });
59
+ originalCwd = process.cwd();
60
+ originalHome = process.env.HOME;
61
+ process.chdir(join(sandbox, "project"));
62
+ process.env.HOME = join(sandbox, "home");
63
+ }
64
+
65
+ function teardownSandbox() {
66
+ process.chdir(originalCwd);
67
+ process.env.HOME = originalHome;
68
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
69
+ }
70
+
71
+ // A minimal valid skill payload for happy-path tests
72
+ function minimalSkill(overrides = {}) {
73
+ return {
74
+ owner: "alice",
75
+ name: "pdf-helper",
76
+ files: [
77
+ {
78
+ path: "SKILL.md",
79
+ content: "---\nname: pdf-helper\ndescription: Test skill.\n---\n\nBody.\n",
80
+ },
81
+ ],
82
+ ...overrides,
83
+ };
84
+ }
85
+
86
+ // ── validateFilePath ────────────────────────────────────────────────────
87
+
88
+ describe("validateFilePath", () => {
89
+ it("accepts a plain SKILL.md", () => {
90
+ assert.equal(validateFilePath("SKILL.md"), null);
91
+ });
92
+
93
+ it("accepts a script under scripts/", () => {
94
+ assert.equal(validateFilePath("scripts/extract.py"), null);
95
+ });
96
+
97
+ it("accepts a non-spec top-level dir (Option B path-preserving)", () => {
98
+ // This is the central proof of Defect A's fix — the CLI does NOT
99
+ // enforce the spec layout. lib/helper.py is path-preserving and OK.
100
+ assert.equal(validateFilePath("lib/helper.py"), null);
101
+ assert.equal(validateFilePath("utils/format.js"), null);
102
+ assert.equal(validateFilePath("data/lookup.json"), null);
103
+ });
104
+
105
+ it("rejects path traversal", () => {
106
+ assert.match(validateFilePath("../etc/passwd"), /traversal/);
107
+ assert.match(validateFilePath("scripts/../../../etc/passwd"), /traversal/);
108
+ });
109
+
110
+ it("rejects URL-encoded path traversal", () => {
111
+ assert.match(validateFilePath("..%2Fetc%2Fpasswd"), /traversal/);
112
+ });
113
+
114
+ it("rejects absolute paths (POSIX)", () => {
115
+ assert.match(validateFilePath("/etc/passwd"), /absolute/);
116
+ });
117
+
118
+ it("rejects absolute paths (Windows drive letter)", () => {
119
+ assert.match(validateFilePath("C:/Windows/System32/cmd.exe"), /absolute/);
120
+ });
121
+
122
+ it("rejects paths exceeding MAX_PATH_DEPTH", () => {
123
+ const tooDeep = Array(MAX_PATH_DEPTH + 1).fill("a").join("/") + ".py";
124
+ const err = validateFilePath(tooDeep);
125
+ assert.match(err, /nesting depth/);
126
+ });
127
+
128
+ it("accepts paths exactly at MAX_PATH_DEPTH", () => {
129
+ const atLimit = Array(MAX_PATH_DEPTH).fill("a").join("/") + ".py";
130
+ // depth = 5 segments separated by / — exactly the limit
131
+ assert.equal(validateFilePath(atLimit), null);
132
+ });
133
+
134
+ it("rejects blocked extensions", () => {
135
+ for (const ext of BLOCKED_EXTENSIONS) {
136
+ const err = validateFilePath(`scripts/payload${ext}`);
137
+ assert.match(err, /Blocked file type/, `Expected ${ext} to be blocked`);
138
+ }
139
+ });
140
+
141
+ it("rejects malformed URL encoding", () => {
142
+ const err = validateFilePath("%E0%A4%A");
143
+ assert.match(err, /URL encoding/);
144
+ });
145
+ });
146
+
147
+ // ── isValidSkillName ────────────────────────────────────────────────────
148
+
149
+ describe("isValidSkillName", () => {
150
+ it("accepts canonical names", () => {
151
+ assert.equal(isValidSkillName("pdf-helper"), true);
152
+ assert.equal(isValidSkillName("a"), true);
153
+ assert.equal(isValidSkillName("skill123"), true);
154
+ assert.equal(isValidSkillName("a-very-long-but-valid-name"), true);
155
+ });
156
+
157
+ it("rejects empty and oversize names", () => {
158
+ assert.equal(isValidSkillName(""), false);
159
+ assert.equal(isValidSkillName("x".repeat(65)), false);
160
+ });
161
+
162
+ it("rejects uppercase letters", () => {
163
+ assert.equal(isValidSkillName("PDF-Helper"), false);
164
+ });
165
+
166
+ it("rejects leading or trailing hyphens", () => {
167
+ assert.equal(isValidSkillName("-pdf"), false);
168
+ assert.equal(isValidSkillName("pdf-"), false);
169
+ });
170
+
171
+ it("rejects consecutive hyphens", () => {
172
+ assert.equal(isValidSkillName("pdf--helper"), false);
173
+ });
174
+
175
+ it("rejects non-string input", () => {
176
+ assert.equal(isValidSkillName(undefined), false);
177
+ assert.equal(isValidSkillName(null), false);
178
+ assert.equal(isValidSkillName(42), false);
179
+ });
180
+ });
181
+
182
+ // ── readFrontmatterName ─────────────────────────────────────────────────
183
+
184
+ describe("readFrontmatterName", () => {
185
+ it("extracts the name field from a SKILL.md", () => {
186
+ const files = [{ path: "SKILL.md", content: "---\nname: pdf-helper\n---\nBody" }];
187
+ assert.equal(readFrontmatterName(files), "pdf-helper");
188
+ });
189
+
190
+ it("strips quotes around the name value", () => {
191
+ const files = [{ path: "SKILL.md", content: '---\nname: "pdf-helper"\n---\nBody' }];
192
+ assert.equal(readFrontmatterName(files), "pdf-helper");
193
+ });
194
+
195
+ it("returns null when frontmatter is missing", () => {
196
+ const files = [{ path: "SKILL.md", content: "Plain markdown" }];
197
+ assert.equal(readFrontmatterName(files), null);
198
+ });
199
+
200
+ it("returns null when SKILL.md is missing", () => {
201
+ const files = [{ path: "scripts/extract.py", content: "print()" }];
202
+ assert.equal(readFrontmatterName(files), null);
203
+ });
204
+
205
+ it("returns null when name field is missing inside frontmatter", () => {
206
+ const files = [{ path: "SKILL.md", content: "---\ndescription: foo\n---\nBody" }];
207
+ assert.equal(readFrontmatterName(files), null);
208
+ });
209
+ });
210
+
211
+ // ── resolvePlacementDir + placementTargetsFor ───────────────────────────
212
+
213
+ describe("placementTargetsFor", () => {
214
+ beforeEach(setupSandbox);
215
+ afterEach(teardownSandbox);
216
+
217
+ it("returns [claudeGlobal] for --global", () => {
218
+ assert.deepEqual(placementTargetsFor({ global: true }), ["claudeGlobal"]);
219
+ });
220
+
221
+ it("returns [claudeProject] for vendor=claudeCode only", () => {
222
+ assert.deepEqual(placementTargetsFor({ vendors: ["claudeCode"] }), ["claudeProject"]);
223
+ });
224
+
225
+ it("returns [projectFallback] for vendor=cursor only", () => {
226
+ assert.deepEqual(placementTargetsFor({ vendors: ["cursor"] }), ["projectFallback"]);
227
+ });
228
+
229
+ it("returns [claudeProject, projectFallback] for both", () => {
230
+ assert.deepEqual(
231
+ placementTargetsFor({ vendors: ["claudeCode", "cursor"] }),
232
+ ["claudeProject", "projectFallback"],
233
+ );
234
+ });
235
+
236
+ it("dedupes the fallback when multiple non-claude vendors are present", () => {
237
+ const targets = placementTargetsFor({
238
+ vendors: ["cursor", "windsurf", "vscode"],
239
+ });
240
+ // Only one projectFallback — not three
241
+ assert.equal(targets.filter((t) => t === "projectFallback").length, 1);
242
+ });
243
+
244
+ it("throws on empty vendors without --global", () => {
245
+ assert.throws(
246
+ () => placementTargetsFor({ vendors: [] }),
247
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
248
+ );
249
+ });
250
+
251
+ it("throws on unknown vendor", () => {
252
+ assert.throws(
253
+ () => placementTargetsFor({ vendors: ["jetbrains"] }),
254
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
255
+ );
256
+ });
257
+ });
258
+
259
+ describe("resolvePlacementDir", () => {
260
+ beforeEach(setupSandbox);
261
+ afterEach(teardownSandbox);
262
+
263
+ it("resolves claudeProject under cwd", () => {
264
+ const dir = resolvePlacementDir("claudeProject", "pdf-helper");
265
+ assert.ok(dir.endsWith("/.claude/skills/pdf-helper"));
266
+ assert.ok(dir.startsWith(process.cwd()));
267
+ });
268
+
269
+ it("resolves claudeGlobal under HOME", () => {
270
+ const dir = resolvePlacementDir("claudeGlobal", "pdf-helper");
271
+ assert.ok(dir.endsWith("/.claude/skills/pdf-helper"));
272
+ assert.ok(dir.startsWith(process.env.HOME));
273
+ });
274
+
275
+ it("resolves projectFallback under cwd /skills/", () => {
276
+ const dir = resolvePlacementDir("projectFallback", "pdf-helper");
277
+ assert.equal(dir, join(process.cwd(), "skills", "pdf-helper"));
278
+ });
279
+
280
+ it("throws on unknown target", () => {
281
+ assert.throws(
282
+ () => resolvePlacementDir("invalid", "pdf-helper"),
283
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
284
+ );
285
+ });
286
+ });
287
+
288
+ // ── writeSkillDir validation rejections ─────────────────────────────────
289
+
290
+ describe("writeSkillDir — validation", () => {
291
+ beforeEach(setupSandbox);
292
+ afterEach(teardownSandbox);
293
+
294
+ it("rejects skills with no SKILL.md", () => {
295
+ const skill = minimalSkill({ files: [{ path: "scripts/x.py", content: "print()" }] });
296
+ assert.throws(
297
+ () => writeSkillDir(skill, { vendors: ["claudeCode"] }),
298
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION && /SKILL\.md/.test(err.message),
299
+ );
300
+ });
301
+
302
+ it("rejects skills with name/parent mismatch in frontmatter", () => {
303
+ const skill = minimalSkill({
304
+ name: "pdf-helper",
305
+ files: [
306
+ { path: "SKILL.md", content: "---\nname: not-pdf-helper\n---\nBody" },
307
+ ],
308
+ });
309
+ assert.throws(
310
+ () => writeSkillDir(skill, { vendors: ["claudeCode"] }),
311
+ (err) =>
312
+ err instanceof CliError &&
313
+ err.exitCode === EXIT_VALIDATION &&
314
+ /does not match/.test(err.message),
315
+ );
316
+ });
317
+
318
+ it("rejects skills with invalid skill name", () => {
319
+ const skill = minimalSkill({ name: "PDF-Helper" });
320
+ assert.throws(
321
+ () => writeSkillDir(skill, { vendors: ["claudeCode"] }),
322
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
323
+ );
324
+ });
325
+
326
+ it("rejects skills with no owner", () => {
327
+ const skill = minimalSkill();
328
+ delete skill.owner;
329
+ assert.throws(
330
+ () => writeSkillDir(skill, { vendors: ["claudeCode"] }),
331
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
332
+ );
333
+ });
334
+
335
+ it("rejects skills with empty files array", () => {
336
+ const skill = minimalSkill({ files: [] });
337
+ assert.throws(
338
+ () => writeSkillDir(skill, { vendors: ["claudeCode"] }),
339
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
340
+ );
341
+ });
342
+
343
+ it("rejects skills with file containing path traversal", () => {
344
+ const skill = minimalSkill({
345
+ files: [
346
+ ...minimalSkill().files,
347
+ { path: "../escape.py", content: "x" },
348
+ ],
349
+ });
350
+ assert.throws(
351
+ () => writeSkillDir(skill, { vendors: ["claudeCode"] }),
352
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
353
+ );
354
+ });
355
+
356
+ it("rejects skills with duplicate file paths", () => {
357
+ const skill = minimalSkill({
358
+ files: [
359
+ { path: "SKILL.md", content: "---\nname: pdf-helper\n---\n" },
360
+ { path: "SKILL.md", content: "---\nname: pdf-helper\n---\n duplicate" },
361
+ ],
362
+ });
363
+ assert.throws(
364
+ () => writeSkillDir(skill, { vendors: ["claudeCode"] }),
365
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
366
+ );
367
+ });
368
+
369
+ it("rejects skills with non-string file content", () => {
370
+ const skill = minimalSkill({
371
+ files: [
372
+ ...minimalSkill().files,
373
+ { path: "scripts/binary.dat", content: Buffer.from([0, 1, 2]) },
374
+ ],
375
+ });
376
+ assert.throws(
377
+ () => writeSkillDir(skill, { vendors: ["claudeCode"] }),
378
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
379
+ );
380
+ });
381
+ });
382
+
383
+ // ── writeSkillDir happy path + path preservation (Defect A fix) ─────────
384
+
385
+ describe("writeSkillDir — happy path", () => {
386
+ beforeEach(setupSandbox);
387
+ afterEach(teardownSandbox);
388
+
389
+ it("writes a skill with SKILL.md only to claudeCode project dir", () => {
390
+ const skill = minimalSkill();
391
+ const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
392
+
393
+ assert.equal(result.written.length, 1);
394
+ const dir = result.written[0];
395
+ const skillMd = readFileSync(join(dir, "SKILL.md"), "utf-8");
396
+ assert.match(skillMd, /name: pdf-helper/);
397
+ });
398
+
399
+ it("preserves non-spec top-level directories (Defect A fix)", () => {
400
+ // This is the central proof: the CLI is path-preserving. A file at
401
+ // lib/helper.py must end up at <skill>/lib/helper.py on disk.
402
+ const skill = minimalSkill({
403
+ files: [
404
+ ...minimalSkill().files,
405
+ { path: "lib/helper.py", content: "def help(): pass\n" },
406
+ { path: "utils/format.js", content: "export const fmt = x => x;\n" },
407
+ ],
408
+ });
409
+ const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
410
+ const dir = result.written[0];
411
+
412
+ assert.equal(
413
+ readFileSync(join(dir, "lib/helper.py"), "utf-8"),
414
+ "def help(): pass\n",
415
+ );
416
+ assert.equal(
417
+ readFileSync(join(dir, "utils/format.js"), "utf-8"),
418
+ "export const fmt = x => x;\n",
419
+ );
420
+ });
421
+
422
+ it("writes spec-compliant subdirs (scripts/, references/, assets/)", () => {
423
+ const skill = minimalSkill({
424
+ files: [
425
+ ...minimalSkill().files,
426
+ { path: "scripts/extract.py", content: "print('hi')\n" },
427
+ { path: "references/REFERENCE.md", content: "# Reference\n" },
428
+ { path: "assets/template.json", content: "{}\n" },
429
+ ],
430
+ });
431
+ const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
432
+ const dir = result.written[0];
433
+
434
+ assert.equal(readFileSync(join(dir, "scripts/extract.py"), "utf-8"), "print('hi')\n");
435
+ assert.equal(readFileSync(join(dir, "references/REFERENCE.md"), "utf-8"), "# Reference\n");
436
+ assert.equal(readFileSync(join(dir, "assets/template.json"), "utf-8"), "{}\n");
437
+ });
438
+
439
+ it("writes to multiple targets when claudeCode + cursor detected", () => {
440
+ const skill = minimalSkill();
441
+ const result = writeSkillDir(skill, { vendors: ["claudeCode", "cursor"] });
442
+ assert.equal(result.written.length, 2);
443
+ for (const dir of result.written) {
444
+ assert.ok(existsSync(join(dir, "SKILL.md")));
445
+ }
446
+ });
447
+
448
+ it("writes to global home dir with --global", () => {
449
+ const skill = minimalSkill();
450
+ const result = writeSkillDir(skill, { global: true });
451
+ assert.equal(result.written.length, 1);
452
+ assert.ok(result.written[0].startsWith(process.env.HOME));
453
+ });
454
+
455
+ it("creates /skills/ fallback when only non-claude vendor is detected", () => {
456
+ const skill = minimalSkill();
457
+ const result = writeSkillDir(skill, { vendors: ["cursor"] });
458
+ assert.equal(result.written.length, 1);
459
+ assert.ok(result.written[0].includes("/skills/pdf-helper"));
460
+ // .gitignore should now contain /skills/
461
+ const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
462
+ assert.match(gi, /\/skills\//);
463
+ assert.match(gi, /SkillRepo/);
464
+ });
465
+
466
+ it("idempotent .gitignore management — does not double-add /skills/", () => {
467
+ const skill = minimalSkill();
468
+ writeSkillDir(skill, { vendors: ["cursor"] });
469
+ writeSkillDir(skill, { vendors: ["cursor"] });
470
+ const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
471
+ const matches = gi.match(/\/skills\//g) || [];
472
+ assert.equal(matches.length, 1, ".gitignore should have exactly one /skills/ entry");
473
+ });
474
+
475
+ it("appends to an existing .gitignore without clobbering", () => {
476
+ writeFileSync(join(process.cwd(), ".gitignore"), "node_modules\n.env\n");
477
+ writeSkillDir(minimalSkill(), { vendors: ["cursor"] });
478
+ const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
479
+ assert.match(gi, /node_modules/);
480
+ assert.match(gi, /\.env/);
481
+ assert.match(gi, /\/skills\//);
482
+ });
483
+ });
484
+
485
+ // ── writeSkillDir update path (atomic) ──────────────────────────────────
486
+
487
+ describe("writeSkillDir — update path", () => {
488
+ beforeEach(setupSandbox);
489
+ afterEach(teardownSandbox);
490
+
491
+ it("overwrites an existing skill atomically", () => {
492
+ // First write
493
+ writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
494
+
495
+ // Second write with changed content
496
+ const updated = minimalSkill({
497
+ files: [
498
+ {
499
+ path: "SKILL.md",
500
+ content: "---\nname: pdf-helper\ndescription: Updated.\n---\n\nUpdated body.\n",
501
+ },
502
+ { path: "scripts/new.py", content: "print('new')\n" },
503
+ ],
504
+ });
505
+ const result = writeSkillDir(updated, { vendors: ["claudeCode"] });
506
+
507
+ const dir = result.written[0];
508
+ const skillMd = readFileSync(join(dir, "SKILL.md"), "utf-8");
509
+ assert.match(skillMd, /Updated/);
510
+ assert.equal(readFileSync(join(dir, "scripts/new.py"), "utf-8"), "print('new')\n");
511
+ });
512
+
513
+ it("removes files no longer in the skill (full overwrite semantics)", () => {
514
+ // First write with two files
515
+ writeSkillDir(minimalSkill({
516
+ files: [
517
+ { path: "SKILL.md", content: "---\nname: pdf-helper\n---\n" },
518
+ { path: "scripts/old.py", content: "print('old')\n" },
519
+ ],
520
+ }), { vendors: ["claudeCode"] });
521
+
522
+ // Second write without the old file
523
+ const result = writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
524
+ const dir = result.written[0];
525
+ assert.ok(!existsSync(join(dir, "scripts/old.py")), "old file should be gone");
526
+ });
527
+
528
+ it("cleans up stale .tmp/ from a previous crash before populating a new one", () => {
529
+ const skill = minimalSkill();
530
+ // Pre-create a stale .tmp/ to simulate a crashed run
531
+ const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
532
+ mkdirSync(`${targetDir}.tmp`, { recursive: true });
533
+ writeFileSync(`${targetDir}.tmp/leftover.txt`, "garbage");
534
+
535
+ // Now run the write — should clean and succeed
536
+ const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
537
+ assert.ok(existsSync(join(result.written[0], "SKILL.md")));
538
+ assert.ok(!existsSync(join(result.written[0], "leftover.txt")));
539
+ });
540
+
541
+ it("successful update overwrites content and leaves no .tmp/.old residue", { skip: process.platform === "win32" }, () => {
542
+ // KNOWN COVERAGE GAP: this test exercises the happy path of the
543
+ // atomic .tmp/.old rename dance — write twice, confirm the second
544
+ // write replaces the first and leaves no orphan state. It does NOT
545
+ // exercise the rollback branch in writeSkillToDir at the
546
+ // `renameSync(tmpDir, targetDir)` failure path. Triggering that
547
+ // branch deterministically requires either root permissions to
548
+ // chmod a parent directory, a symlink trick, or an injected fault
549
+ // — none of which are stable across CI runners.
550
+ //
551
+ // The rollback branch IS reachable in production (file system
552
+ // races, EINVAL on unusual filesystems, etc.) and IS implemented
553
+ // correctly per code review, but it is not covered by an automated
554
+ // test. The next time someone touches that branch, manual
555
+ // verification is required — see the comment at the rollback site
556
+ // in file-write.mjs for the exact branch.
557
+ writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
558
+
559
+ const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
560
+ const oldDir = `${targetDir}.old`;
561
+
562
+ const updated = {
563
+ owner: "alice",
564
+ name: "pdf-helper",
565
+ files: [
566
+ { path: "SKILL.md", content: "---\nname: pdf-helper\n---\nNew content.\n" },
567
+ ],
568
+ };
569
+ writeSkillDir(updated, { vendors: ["claudeCode"] });
570
+ const skillMd = readFileSync(join(targetDir, "SKILL.md"), "utf-8");
571
+ assert.match(skillMd, /New content/);
572
+ assert.ok(!existsSync(`${targetDir}.tmp`));
573
+ assert.ok(!existsSync(oldDir));
574
+ });
575
+
576
+ it("throws diskError when the .tmp path is occupied by a regular file", { skip: process.platform === "win32" }, () => {
577
+ const skill = minimalSkill();
578
+ const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
579
+ // Pre-create the .tmp path as a FILE (not a directory) — the
580
+ // pre-flight cleanup will rmSync it and re-create as a dir, so
581
+ // this test verifies the rmSync path works on a file.
582
+ mkdirSync(join(process.cwd(), ".claude", "skills"), { recursive: true });
583
+ writeFileSync(`${targetDir}.tmp`, "I am a file, not a dir");
584
+
585
+ // Should successfully clean the file and proceed
586
+ const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
587
+ assert.ok(existsSync(join(result.written[0], "SKILL.md")));
588
+ assert.ok(!existsSync(`${targetDir}.tmp`));
589
+ });
590
+ });
591
+
592
+ describe("writeSkillDir — return shape", () => {
593
+ beforeEach(setupSandbox);
594
+ afterEach(teardownSandbox);
595
+
596
+ it("returns only `written`, never `skipped` (architect B1 fix)", () => {
597
+ const skill = minimalSkill();
598
+ const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
599
+ assert.deepEqual(Object.keys(result).sort(), ["written"]);
600
+ });
601
+ });
602
+
603
+ describe("ensureFallbackGitignore — error surfacing", () => {
604
+ beforeEach(setupSandbox);
605
+ afterEach(teardownSandbox);
606
+
607
+ it("throws diskError when .gitignore is read-only", { skip: process.platform === "win32" || process.getuid?.() === 0 }, () => {
608
+ // Pre-create a read-only .gitignore
609
+ const gi = join(process.cwd(), ".gitignore");
610
+ writeFileSync(gi, "node_modules\n");
611
+ chmodSync(gi, 0o444);
612
+
613
+ try {
614
+ assert.throws(
615
+ () => writeSkillDir(minimalSkill(), { vendors: ["cursor"] }),
616
+ (err) => err instanceof CliError && err.exitCode === 3, // EXIT_DISK
617
+ );
618
+ } finally {
619
+ // Restore writable so afterEach can clean up
620
+ chmodSync(gi, 0o644);
621
+ }
622
+ });
623
+
624
+ it("read-only .gitignore aborts ALL writes (no partial state) when both claudeCode + cursor requested", { skip: process.platform === "win32" || process.getuid?.() === 0 }, () => {
625
+ // Architect re-review found that ensureFallbackGitignore() throwing
626
+ // mid-loop would leave a successful claudeCode write + a thrown
627
+ // cursor failure = partial state. The fix moves the .gitignore
628
+ // pre-flight before the loop. This test locks that fix in.
629
+ const gi = join(process.cwd(), ".gitignore");
630
+ writeFileSync(gi, "node_modules\n");
631
+ chmodSync(gi, 0o444);
632
+
633
+ try {
634
+ assert.throws(
635
+ () => writeSkillDir(minimalSkill(), { vendors: ["claudeCode", "cursor"] }),
636
+ (err) => err instanceof CliError && err.exitCode === 3,
637
+ );
638
+ // Critical: the claudeCode write must NOT have executed because
639
+ // the pre-flight failed before any disk work began.
640
+ const claudeDir = resolvePlacementDir("claudeProject", "pdf-helper");
641
+ assert.ok(!existsSync(claudeDir), "claudeCode skill must not be partially written");
642
+ } finally {
643
+ chmodSync(gi, 0o644);
644
+ }
645
+ });
646
+ });
647
+
648
+ describe("cleanupOrphans — Windows recovery invariant", () => {
649
+ beforeEach(setupSandbox);
650
+ afterEach(teardownSandbox);
651
+
652
+ it("preserves .tmp/ whose live target is missing (recovery from crashed Windows rename)", () => {
653
+ // Inject a .tmp/ with NO sibling live target — the CLI's only copy
654
+ // of a recoverable skill from a crashed Windows update.
655
+ const root = join(process.cwd(), ".claude", "skills");
656
+ mkdirSync(join(root, "pdf-helper.tmp"), { recursive: true });
657
+ writeFileSync(join(root, "pdf-helper.tmp", "SKILL.md"), "---\nname: pdf-helper\n---\nRecoverable.\n");
658
+
659
+ const result = cleanupOrphans({ vendors: ["claudeCode"] });
660
+ assert.equal(result.cleaned.length, 0, ".tmp with no live sibling should be preserved");
661
+ assert.ok(existsSync(join(root, "pdf-helper.tmp", "SKILL.md")), "user's only copy must survive");
662
+ });
663
+
664
+ it("removes .tmp/ when a live sibling exists (post-successful-write cleanup)", () => {
665
+ // Both live target and .tmp present — .tmp is stale state from a
666
+ // future-crashed write that the user has since recovered from.
667
+ const root = join(process.cwd(), ".claude", "skills");
668
+ writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
669
+ mkdirSync(join(root, "pdf-helper.tmp"));
670
+ writeFileSync(join(root, "pdf-helper.tmp", "stale.txt"), "x");
671
+
672
+ const result = cleanupOrphans({ vendors: ["claudeCode"] });
673
+ assert.equal(result.cleaned.length, 1, "stale .tmp with live sibling should be cleaned");
674
+ assert.ok(!existsSync(join(root, "pdf-helper.tmp")));
675
+ // Live skill untouched
676
+ assert.ok(existsSync(join(root, "pdf-helper", "SKILL.md")));
677
+ });
678
+ });
679
+
680
+ // ── removeSkillDir ──────────────────────────────────────────────────────
681
+
682
+ describe("removeSkillDir", () => {
683
+ beforeEach(setupSandbox);
684
+ afterEach(teardownSandbox);
685
+
686
+ it("removes an existing skill from claudeCode project dir", () => {
687
+ writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
688
+ const dir = resolvePlacementDir("claudeProject", "pdf-helper");
689
+ assert.ok(existsSync(dir));
690
+
691
+ const result = removeSkillDir("pdf-helper", { vendors: ["claudeCode"] });
692
+ assert.equal(result.removed.length, 1);
693
+ assert.equal(result.notFound.length, 0);
694
+ assert.ok(!existsSync(dir));
695
+ });
696
+
697
+ it("returns notFound when the skill directory doesn't exist", () => {
698
+ const result = removeSkillDir("ghost", { vendors: ["claudeCode"] });
699
+ assert.equal(result.removed.length, 0);
700
+ assert.equal(result.notFound.length, 1);
701
+ });
702
+
703
+ it("removes from multiple targets", () => {
704
+ writeSkillDir(minimalSkill(), { vendors: ["claudeCode", "cursor"] });
705
+ const result = removeSkillDir("pdf-helper", { vendors: ["claudeCode", "cursor"] });
706
+ assert.equal(result.removed.length, 2);
707
+ });
708
+
709
+ it("rejects invalid skill names", () => {
710
+ assert.throws(
711
+ () => removeSkillDir("BAD", { vendors: ["claudeCode"] }),
712
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
713
+ );
714
+ });
715
+ });
716
+
717
+ // ── cleanupOrphans ──────────────────────────────────────────────────────
718
+
719
+ describe("cleanupOrphans", () => {
720
+ beforeEach(setupSandbox);
721
+ afterEach(teardownSandbox);
722
+
723
+ it("removes .tmp/ orphans whose live sibling exists (post-successful-write residue)", () => {
724
+ // The cleanupOrphans safety invariant preserves a `.tmp/` whose live
725
+ // target is missing (Windows recovery path). To test that orphan
726
+ // cleanup actually fires, we need a live sibling so the .tmp is
727
+ // confirmed safe to delete.
728
+ const root = join(process.cwd(), ".claude", "skills");
729
+ mkdirSync(join(root, "ghost"), { recursive: true }); // live sibling
730
+ mkdirSync(join(root, "ghost.tmp"));
731
+ writeFileSync(join(root, "ghost.tmp", "x.txt"), "garbage");
732
+
733
+ const result = cleanupOrphans({ vendors: ["claudeCode"] });
734
+ assert.equal(result.cleaned.length, 1);
735
+ assert.ok(!existsSync(join(root, "ghost.tmp")));
736
+ });
737
+
738
+ it("removes .old/ orphans under claudeProject root", () => {
739
+ const root = join(process.cwd(), ".claude", "skills");
740
+ mkdirSync(root, { recursive: true });
741
+ mkdirSync(join(root, "ghost.old"));
742
+
743
+ const result = cleanupOrphans({ vendors: ["claudeCode"] });
744
+ assert.equal(result.cleaned.length, 1);
745
+ });
746
+
747
+ it("leaves regular skill directories alone", () => {
748
+ writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
749
+ const result = cleanupOrphans({ vendors: ["claudeCode"] });
750
+ assert.equal(result.cleaned.length, 0);
751
+ const dir = resolvePlacementDir("claudeProject", "pdf-helper");
752
+ assert.ok(existsSync(dir));
753
+ });
754
+
755
+ it("scans all known roots when no vendors specified", () => {
756
+ // Drop one orphan in each root we know about. .tmp/ entries need
757
+ // a live sibling so the safety invariant doesn't preserve them.
758
+ const claudeRoot = join(process.cwd(), ".claude", "skills");
759
+ const fallbackRoot = join(process.cwd(), "skills");
760
+ const globalRoot = join(process.env.HOME, ".claude", "skills");
761
+ mkdirSync(claudeRoot, { recursive: true });
762
+ mkdirSync(fallbackRoot, { recursive: true });
763
+ mkdirSync(globalRoot, { recursive: true });
764
+ mkdirSync(join(claudeRoot, "ghost")); // live sibling for the .tmp below
765
+ mkdirSync(join(claudeRoot, "ghost.tmp"));
766
+ mkdirSync(join(fallbackRoot, "ghost.old")); // .old has no invariant — always cleaned
767
+ mkdirSync(join(globalRoot, "ghost")); // live sibling for the .tmp below
768
+ mkdirSync(join(globalRoot, "ghost.tmp"));
769
+
770
+ const result = cleanupOrphans({});
771
+ assert.equal(result.cleaned.length, 3);
772
+ });
773
+
774
+ it("is idempotent when there are no orphans", () => {
775
+ const result = cleanupOrphans({ vendors: ["claudeCode"] });
776
+ assert.deepEqual(result.cleaned, []);
777
+ });
778
+
779
+ it("handles missing roots gracefully", () => {
780
+ // No roots exist at all
781
+ const result = cleanupOrphans({ vendors: ["claudeCode"] });
782
+ assert.deepEqual(result.cleaned, []);
783
+ });
784
+ });