skillrepo 3.2.0 → 4.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 (53) hide show
  1. package/README.md +137 -27
  2. package/bin/skillrepo.mjs +5 -5
  3. package/package.json +1 -1
  4. package/src/commands/add.mjs +21 -6
  5. package/src/commands/get.mjs +20 -4
  6. package/src/commands/init-cohort-hooks.mjs +127 -0
  7. package/src/commands/init-session-sync.mjs +1 -1
  8. package/src/commands/init.mjs +480 -117
  9. package/src/commands/list.mjs +1 -1
  10. package/src/commands/remove.mjs +10 -2
  11. package/src/commands/uninstall.mjs +13 -2
  12. package/src/commands/update.mjs +112 -19
  13. package/src/lib/agent-hook-merge.mjs +203 -0
  14. package/src/lib/agent-registry.mjs +399 -0
  15. package/src/lib/artifact-registry.mjs +111 -2
  16. package/src/lib/cli-config.mjs +146 -44
  17. package/src/lib/detect-agents.mjs +112 -0
  18. package/src/lib/file-write.mjs +162 -77
  19. package/src/lib/fs-utils.mjs +16 -1
  20. package/src/lib/mcp-merge.mjs +17 -36
  21. package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
  22. package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
  23. package/src/lib/mergers/gitignore.mjs +55 -28
  24. package/src/lib/paths.mjs +27 -25
  25. package/src/lib/prompt-multiselect.mjs +324 -0
  26. package/src/lib/removers/agent-hooks.mjs +83 -0
  27. package/src/lib/sync.mjs +18 -19
  28. package/src/test/commands/add.test.mjs +18 -3
  29. package/src/test/commands/init-picker.test.mjs +144 -0
  30. package/src/test/commands/init.test.mjs +508 -41
  31. package/src/test/commands/remove.test.mjs +4 -1
  32. package/src/test/commands/update.test.mjs +148 -3
  33. package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
  34. package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
  35. package/src/test/e2e/cli-commands.test.mjs +39 -13
  36. package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
  37. package/src/test/integration/file-write.integration.test.mjs +31 -10
  38. package/src/test/lib/agent-hook-merge.test.mjs +172 -0
  39. package/src/test/lib/agent-registry.test.mjs +215 -0
  40. package/src/test/lib/artifact-registry.test.mjs +39 -0
  41. package/src/test/lib/cli-config.test.mjs +222 -38
  42. package/src/test/lib/detect-agents.test.mjs +336 -0
  43. package/src/test/lib/file-write-placement.test.mjs +264 -0
  44. package/src/test/lib/file-write.test.mjs +231 -30
  45. package/src/test/lib/mcp-merge.test.mjs +23 -15
  46. package/src/test/lib/paths.test.mjs +53 -17
  47. package/src/test/lib/prompt-multiselect.test.mjs +448 -0
  48. package/src/test/lib/sync.test.mjs +157 -0
  49. package/src/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
  50. package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
  51. package/src/test/removers/agent-hooks.test.mjs +206 -0
  52. package/src/lib/detect-ides.mjs +0 -44
  53. package/src/test/detect-ides.test.mjs +0 -65
@@ -148,6 +148,61 @@ describe("validateFilePath", () => {
148
148
  const err = validateFilePath("%E0%A4%A");
149
149
  assert.match(err, /URL encoding/);
150
150
  });
151
+
152
+ // QA cross-PR review (#1252): lock the current behavior of the
153
+ // safety pre-check against backslash + Unicode-encoded traversal
154
+ // patterns. validateFilePath uses an ASCII `..` substring check —
155
+ // backslash variants reduce to ASCII-`..` after decode and ARE
156
+ // caught; the full-width-period U+FF0E variant is NOT caught.
157
+ // Both behaviors are intentional:
158
+ // • Catching the backslash forms is correct and we lock it so a
159
+ // future regex tightening doesn't accidentally drop them.
160
+ // • NOT catching the full-width form is a documented limitation
161
+ // of the path-preserving CLI — the registry-side validator at
162
+ // `src/lib/skills/file-validation.ts` is the authoritative
163
+ // security boundary. Locking the gap with this test forces a
164
+ // future author who tries to "fix" this without the depth
165
+ // check or registry validator backing them up to confront the
166
+ // actual invariant.
167
+ it("rejects backslash-style path traversal (..\\\\etc\\\\passwd)", () => {
168
+ // Backslash-style traversal still contains the literal `..`
169
+ // substring, so the existing ASCII check catches it.
170
+ const err = validateFilePath("..\\etc\\passwd");
171
+ assert.match(err, /traversal/);
172
+ });
173
+
174
+ it("rejects URL-encoded backslash path traversal (..%5Cetc%5Cpasswd)", () => {
175
+ // After decodeURIComponent, this becomes `..\etc\passwd`. The
176
+ // ASCII `..` substring check still fires — locking that this
177
+ // wrapping doesn't bypass the check.
178
+ const err = validateFilePath("..%5Cetc%5Cpasswd");
179
+ assert.match(err, /traversal/);
180
+ });
181
+
182
+ it("does NOT catch full-width period Unicode traversal (../etc/passwd) — known gap", () => {
183
+ // U+FF0E (FULLWIDTH FULL STOP) is visually identical to the
184
+ // ASCII period but is a different codepoint. The ASCII `..`
185
+ // substring check does NOT match this sequence. The CLI's
186
+ // validateFilePath returns null (accepts the path) because:
187
+ // • The substring check fires only on ASCII `..`.
188
+ // • There is no traversal at the resolved-path level — `..`
189
+ // is just a normal directory name on disk.
190
+ // • Depth here is 2 segments, well under MAX_PATH_DEPTH=5.
191
+ //
192
+ // The registry-side validator at
193
+ // `src/lib/skills/file-validation.ts` MUST also reject paths with
194
+ // homoglyph-style traversal attempts. The CLI is path-preserving
195
+ // and is NOT the security boundary; the registry is. This test
196
+ // pins the current behavior and exists as a forcing function:
197
+ // any future contributor who reads it and assumes the CLI is the
198
+ // authority should be redirected to the server-side validator.
199
+ const result = validateFilePath("../etc/passwd");
200
+ assert.equal(
201
+ result,
202
+ null,
203
+ "Locking known limitation: the CLI's ASCII substring check does not catch full-width-period traversal. The server-side validator is the authoritative security boundary. See file-validation.ts.",
204
+ );
205
+ });
151
206
  });
152
207
 
153
208
  // ── isValidSkillName ────────────────────────────────────────────────────
@@ -220,31 +275,34 @@ describe("placementTargetsFor", () => {
220
275
  beforeEach(setupSandbox);
221
276
  afterEach(teardownSandbox);
222
277
 
223
- it("returns [claudeGlobal] for --global", () => {
224
- assert.deepEqual(placementTargetsFor({ global: true }), ["claudeGlobal"]);
278
+ it("returns [claudeGlobal] for --global claudeCode", () => {
279
+ assert.deepEqual(
280
+ placementTargetsFor({ global: true, vendors: ["claudeCode"] }),
281
+ ["claudeGlobal"],
282
+ );
225
283
  });
226
284
 
227
285
  it("returns [claudeProject] for vendor=claudeCode only", () => {
228
286
  assert.deepEqual(placementTargetsFor({ vendors: ["claudeCode"] }), ["claudeProject"]);
229
287
  });
230
288
 
231
- it("returns [projectFallback] for vendor=cursor only", () => {
232
- assert.deepEqual(placementTargetsFor({ vendors: ["cursor"] }), ["projectFallback"]);
289
+ it("returns [agentsProject] for vendor=cursor only", () => {
290
+ assert.deepEqual(placementTargetsFor({ vendors: ["cursor"] }), ["agentsProject"]);
233
291
  });
234
292
 
235
- it("returns [claudeProject, projectFallback] for both", () => {
293
+ it("returns [claudeProject, agentsProject] for both", () => {
236
294
  assert.deepEqual(
237
295
  placementTargetsFor({ vendors: ["claudeCode", "cursor"] }),
238
- ["claudeProject", "projectFallback"],
296
+ ["claudeProject", "agentsProject"],
239
297
  );
240
298
  });
241
299
 
242
- it("dedupes the fallback when multiple non-claude vendors are present", () => {
300
+ it("dedupes the agents target when multiple cohort vendors are present", () => {
243
301
  const targets = placementTargetsFor({
244
- vendors: ["cursor", "windsurf", "vscode"],
302
+ vendors: ["cursor", "windsurf", "copilot"],
245
303
  });
246
- // Only one projectFallback — not three
247
- assert.equal(targets.filter((t) => t === "projectFallback").length, 1);
304
+ // Only one agentsProject — not three
305
+ assert.equal(targets.filter((t) => t === "agentsProject").length, 1);
248
306
  });
249
307
 
250
308
  it("throws on empty vendors without --global", () => {
@@ -282,9 +340,9 @@ describe("resolvePlacementDir", () => {
282
340
  );
283
341
  });
284
342
 
285
- it("resolves projectFallback under cwd /skills/", () => {
286
- const dir = resolvePlacementDir("projectFallback", "pdf-helper");
287
- assert.equal(dir, join(process.cwd(), "skills", "pdf-helper"));
343
+ it("resolves agentsProject under cwd/.agents/skills/<name>", () => {
344
+ const dir = resolvePlacementDir("agentsProject", "pdf-helper");
345
+ assert.equal(dir, join(process.cwd(), ".agents", "skills", "pdf-helper"));
288
346
  });
289
347
 
290
348
  it("throws on unknown target", () => {
@@ -455,35 +513,39 @@ describe("writeSkillDir — happy path", () => {
455
513
  }
456
514
  });
457
515
 
458
- it("writes to global home dir with --global", () => {
516
+ it("writes to global home dir with --global --agent claudeCode", () => {
459
517
  const skill = minimalSkill();
460
- const result = writeSkillDir(skill, { global: true });
518
+ const result = writeSkillDir(skill, {
519
+ global: true,
520
+ vendors: ["claudeCode"],
521
+ });
461
522
  assert.equal(result.written.length, 1);
462
523
  assert.ok(result.written[0].startsWith(process.env.HOME));
463
524
  });
464
525
 
465
- it("creates /skills/ fallback when only non-claude vendor is detected", () => {
526
+ it("writes to .agents/skills/ when only a cohort vendor is requested", () => {
466
527
  const skill = minimalSkill();
467
528
  const result = writeSkillDir(skill, { vendors: ["cursor"] });
468
529
  assert.equal(result.written.length, 1);
469
- // Use join() for the expected fragment so Windows's "\" separator
470
- // matches. The forward-slash literal would fail on windows-latest.
471
- assert.ok(result.written[0].includes(join("skills", "pdf-helper")));
472
- // .gitignore should now contain /skills/ (that literal — it's the
473
- // gitignore pattern, not a filesystem path and gitignore uses
474
- // forward slashes even on Windows, per git's own conventions).
530
+ assert.ok(
531
+ result.written[0].includes(join(".agents", "skills", "pdf-helper")),
532
+ `expected dir under .agents/skills/, got ${result.written[0]}`,
533
+ );
534
+ // .gitignore should now contain `.agents/skills/`the gitignore
535
+ // pattern uses forward slashes on every platform per git's
536
+ // conventions.
475
537
  const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
476
- assert.match(gi, /\/skills\//);
538
+ assert.match(gi, /\.agents\/skills\//);
477
539
  assert.match(gi, /SkillRepo/);
478
540
  });
479
541
 
480
- it("idempotent .gitignore management — does not double-add /skills/", () => {
542
+ it("idempotent .gitignore management — does not double-add .agents/skills/", () => {
481
543
  const skill = minimalSkill();
482
544
  writeSkillDir(skill, { vendors: ["cursor"] });
483
545
  writeSkillDir(skill, { vendors: ["cursor"] });
484
546
  const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
485
- const matches = gi.match(/\/skills\//g) || [];
486
- assert.equal(matches.length, 1, ".gitignore should have exactly one /skills/ entry");
547
+ const matches = gi.match(/\.agents\/skills\//g) || [];
548
+ assert.equal(matches.length, 1, ".gitignore should have exactly one .agents/skills/ entry");
487
549
  });
488
550
 
489
551
  it("appends to an existing .gitignore without clobbering", () => {
@@ -492,7 +554,7 @@ describe("writeSkillDir — happy path", () => {
492
554
  const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
493
555
  assert.match(gi, /node_modules/);
494
556
  assert.match(gi, /\.env/);
495
- assert.match(gi, /\/skills\//);
557
+ assert.match(gi, /\.agents\/skills\//);
496
558
  });
497
559
  });
498
560
 
@@ -601,6 +663,95 @@ describe("writeSkillDir — update path", () => {
601
663
  assert.ok(existsSync(join(result.written[0], "SKILL.md")));
602
664
  assert.ok(!existsSync(`${targetDir}.tmp`));
603
665
  });
666
+
667
+ // QA cross-PR review (#1252): the existing tests cover stale `.tmp/`
668
+ // recovery (a previous crash before the rename dance completed) and
669
+ // the `.tmp/`-occupied-by-a-file edge case, but NOT the symmetric
670
+ // case where `.old/` is left behind by a previous crash. The .old/
671
+ // dir is created in step 2 of the rename dance and removed in
672
+ // step 4 — a crash between the two leaves it stranded. The next
673
+ // writeSkillDir call detects-and-cleans it via the
674
+ // `if (existsSync(oldDir))` branch in writeSkillToDir.
675
+ //
676
+ // Skipping on Windows: the POSIX-only rename dance is the code path
677
+ // that exposes the stale-`.old/` branch. Windows uses a different
678
+ // `rmSync(targetDir) → renameSync(tmpDir, targetDir)` flow with no
679
+ // intermediate `.old/` step at all — there's nothing equivalent to
680
+ // verify.
681
+ it("succeeds when a stale .old/ from a prior crash is present", { skip: process.platform === "win32" }, () => {
682
+ // Pre-create the live target with v1 content.
683
+ writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
684
+
685
+ const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
686
+ const oldDir = `${targetDir}.old`;
687
+
688
+ // Simulate a previous crash: a stale `.old/` survives next to the
689
+ // live target. The next write must detect-and-clean it before
690
+ // staging the current target into place. The crash artifact is a
691
+ // directory with a marker file we'll confirm is gone after the
692
+ // write; if the cleanup branch were skipped, the stale `.old/`
693
+ // would block the renameSync(targetDir, oldDir) step (POSIX
694
+ // rename onto an existing directory fails with ENOTEMPTY).
695
+ mkdirSync(oldDir, { recursive: true });
696
+ writeFileSync(join(oldDir, "ghost.txt"), "leftover from a prior crash");
697
+
698
+ // The actual write — should clean the stale `.old/` and complete.
699
+ const updated = minimalSkill({
700
+ files: [
701
+ {
702
+ path: "SKILL.md",
703
+ content: "---\nname: pdf-helper\ndescription: After recovery.\n---\n\nv2.\n",
704
+ },
705
+ ],
706
+ });
707
+ const result = writeSkillDir(updated, { vendors: ["claudeCode"] });
708
+
709
+ // The new content is at the live target.
710
+ const skillMd = readFileSync(join(result.written[0], "SKILL.md"), "utf-8");
711
+ assert.match(skillMd, /v2/);
712
+
713
+ // The stale `.old/` is gone — including the ghost file marker.
714
+ assert.ok(!existsSync(oldDir), "stale .old/ must be cleaned during the next write");
715
+ // Step 4 of the rename dance also cleans the post-rename .old/,
716
+ // so the post-state is "no .old/ at all" regardless of which
717
+ // branch fired. We assert the absence; the marker-file path
718
+ // proves the cleanup was the pre-flight branch (a no-op cleanup
719
+ // would have left ghost.txt in place because the live target's
720
+ // own .old/ is fresh content from THIS write, not the marker).
721
+ });
722
+
723
+ it("succeeds when both stale .tmp/ AND stale .old/ from a prior crash are present", { skip: process.platform === "win32" }, () => {
724
+ // Compound recovery scenario: a prior write crashed mid-dance,
725
+ // leaving BOTH a stale .tmp/ (from step 1, populating new files)
726
+ // AND a stale .old/ (from step 2, after the live target was
727
+ // moved aside but step 3's rename never landed). The next write
728
+ // must clean both before proceeding.
729
+ writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
730
+
731
+ const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
732
+ const tmpDir = `${targetDir}.tmp`;
733
+ const oldDir = `${targetDir}.old`;
734
+
735
+ mkdirSync(tmpDir, { recursive: true });
736
+ writeFileSync(join(tmpDir, "tmp-leftover.txt"), "from a crashed step 1");
737
+ mkdirSync(oldDir, { recursive: true });
738
+ writeFileSync(join(oldDir, "old-leftover.txt"), "from a crashed step 2");
739
+
740
+ const updated = minimalSkill({
741
+ files: [
742
+ {
743
+ path: "SKILL.md",
744
+ content: "---\nname: pdf-helper\ndescription: Recovered.\n---\n\nv3.\n",
745
+ },
746
+ ],
747
+ });
748
+ const result = writeSkillDir(updated, { vendors: ["claudeCode"] });
749
+
750
+ const skillMd = readFileSync(join(result.written[0], "SKILL.md"), "utf-8");
751
+ assert.match(skillMd, /v3/);
752
+ assert.ok(!existsSync(tmpDir), "stale .tmp/ must be cleaned");
753
+ assert.ok(!existsSync(oldDir), "stale .old/ must be cleaned");
754
+ });
604
755
  });
605
756
 
606
757
  describe("writeSkillDir — return shape", () => {
@@ -770,14 +921,14 @@ describe("cleanupOrphans", () => {
770
921
  // Drop one orphan in each root we know about. .tmp/ entries need
771
922
  // a live sibling so the safety invariant doesn't preserve them.
772
923
  const claudeRoot = join(process.cwd(), ".claude", "skills");
773
- const fallbackRoot = join(process.cwd(), "skills");
924
+ const agentsRoot = join(process.cwd(), ".agents", "skills");
774
925
  const globalRoot = join(process.env.HOME, ".claude", "skills");
775
926
  mkdirSync(claudeRoot, { recursive: true });
776
- mkdirSync(fallbackRoot, { recursive: true });
927
+ mkdirSync(agentsRoot, { recursive: true });
777
928
  mkdirSync(globalRoot, { recursive: true });
778
929
  mkdirSync(join(claudeRoot, "ghost")); // live sibling for the .tmp below
779
930
  mkdirSync(join(claudeRoot, "ghost.tmp"));
780
- mkdirSync(join(fallbackRoot, "ghost.old")); // .old has no invariant — always cleaned
931
+ mkdirSync(join(agentsRoot, "ghost.old")); // .old has no invariant — always cleaned
781
932
  mkdirSync(join(globalRoot, "ghost")); // live sibling for the .tmp below
782
933
  mkdirSync(join(globalRoot, "ghost.tmp"));
783
934
 
@@ -795,4 +946,54 @@ describe("cleanupOrphans", () => {
795
946
  const result = cleanupOrphans({ vendors: ["claudeCode"] });
796
947
  assert.deepEqual(result.cleaned, []);
797
948
  });
949
+
950
+ // QA cross-PR review (#1252): the permutation where BOTH .tmp/ and
951
+ // .old/ exist but the live target is MISSING was uncovered. This
952
+ // tests the divergence between the two recovery invariants:
953
+ // • .tmp/ — the user's only recovered copy of a crashed-mid-write
954
+ // skill when live target is missing → MUST be preserved
955
+ // (invariant #2 in the cleanupOrphans header docstring).
956
+ // • .old/ — transient state of the rename dance with no recovery
957
+ // value when the live target is missing AND a .tmp/ exists
958
+ // (the .tmp/ IS the recovery) → must be cleaned.
959
+ //
960
+ // If this test fails because cleanupOrphans cleans .tmp/ when
961
+ // live target is missing, that's a real regression of invariant #2
962
+ // — DO NOT change the test to match wrong behavior.
963
+ it("preserves .tmp/ but cleans .old/ when live target is missing", () => {
964
+ const root = join(process.cwd(), ".claude", "skills");
965
+ mkdirSync(root, { recursive: true });
966
+
967
+ // Stage the permutation: <name>.tmp/ + <name>.old/ + NO live <name>/
968
+ const tmpDir = join(root, "ghost.tmp");
969
+ const oldDir = join(root, "ghost.old");
970
+ mkdirSync(tmpDir);
971
+ writeFileSync(join(tmpDir, "tmp-content.txt"), "user's only copy of a crashed-mid-write skill");
972
+ mkdirSync(oldDir);
973
+ writeFileSync(join(oldDir, "old-content.txt"), "transient state with no recovery value");
974
+
975
+ // Live target is intentionally absent
976
+ assert.ok(!existsSync(join(root, "ghost")), "precondition: no live target");
977
+
978
+ const result = cleanupOrphans({ vendors: ["claudeCode"] });
979
+
980
+ // .tmp/ is preserved (recovery invariant)
981
+ assert.ok(
982
+ existsSync(tmpDir),
983
+ ".tmp/ must be preserved when live target is missing — it is the user's only copy",
984
+ );
985
+ assert.ok(
986
+ existsSync(join(tmpDir, "tmp-content.txt")),
987
+ "the .tmp/ contents must survive intact",
988
+ );
989
+
990
+ // .old/ is cleaned (no recovery value because the .tmp/ IS the
991
+ // recovery; .old/ would be the pre-replacement live target,
992
+ // which is precisely what's missing here)
993
+ assert.ok(!existsSync(oldDir), ".old/ must be cleaned regardless of live-target presence");
994
+
995
+ // The summary reports exactly the .old/ cleanup
996
+ assert.equal(result.cleaned.length, 1, "exactly one orphan cleaned (.old/, not .tmp/)");
997
+ assert.match(result.cleaned[0], /ghost\.old$/);
998
+ });
798
999
  });
@@ -19,7 +19,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "nod
19
19
  import { join } from "node:path";
20
20
  import { tmpdir } from "node:os";
21
21
 
22
- import { mergeMcpForVendors, printManualMcpInstructions } from "../../lib/mcp-merge.mjs";
22
+ import { mergeMcpForVendors } from "../../lib/mcp-merge.mjs";
23
23
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
24
24
  import {
25
25
  captureHome,
@@ -302,7 +302,8 @@ describe("mergeMcpForVendors — failure handling", () => {
302
302
  });
303
303
 
304
304
  it("dedupes duplicate vendors (round-1 review fix)", async () => {
305
- // --ide claude,claude used to run the merger twice. Now dedupes.
305
+ // --agent claude,claude used to run the merger twice. Now dedupes
306
+ // both at parse time and inside mergeMcpForVendors as defense-in-depth.
306
307
  const results = await mergeMcpForVendors({
307
308
  vendors: ["claudeCode", "claudeCode", "cursor", "cursor"],
308
309
  mcpUrl: "https://x.com/mcp",
@@ -313,6 +314,26 @@ describe("mergeMcpForVendors — failure handling", () => {
313
314
  assert.equal(results[0].vendor, "claudeCode");
314
315
  assert.equal(results[1].vendor, "cursor");
315
316
  });
317
+
318
+ it("silently skips registry vendors with hasMcp:false (gemini, codex, cline)", async () => {
319
+ // Cohort vendors without a documented MCP merger must NOT report
320
+ // 'failed' — they're a deliberate registry classification.
321
+ const results = await mergeMcpForVendors({
322
+ vendors: ["claudeCode", "gemini", "codex", "cline", "cursor"],
323
+ mcpUrl: "https://x.com/mcp",
324
+ yes: true,
325
+ io: { stdout, stderr },
326
+ });
327
+ const reportedKeys = results.map((r) => r.vendor);
328
+ assert.deepEqual(
329
+ reportedKeys,
330
+ ["claudeCode", "cursor"],
331
+ "only the two MCP-supported vendors should appear in results",
332
+ );
333
+ for (const r of results) {
334
+ assert.equal(r.outcome, "merged", `${r.vendor} should be merged, got ${r.outcome}`);
335
+ }
336
+ });
316
337
  });
317
338
 
318
339
  // ── mergeMcpForVendors — input validation ──────────────────────────────
@@ -336,16 +357,3 @@ describe("mergeMcpForVendors — input validation", () => {
336
357
  });
337
358
  });
338
359
 
339
- // ── printManualMcpInstructions ─────────────────────────────────────────
340
-
341
- describe("printManualMcpInstructions", () => {
342
- it("prints a copy-pasteable MCP config blob", () => {
343
- const out = createCaptureStream();
344
- printManualMcpInstructions("https://skillrepo.dev/api/mcp", { stdout: out });
345
- const text = out.text();
346
- assert.match(text, /mcpServers/);
347
- assert.match(text, /skillrepo/);
348
- assert.match(text, /https:\/\/skillrepo\.dev\/api\/mcp/);
349
- assert.match(text, /SKILLREPO_ACCESS_KEY/);
350
- });
351
- });
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Unit tests for the new paths.mjs exports added in PR1 of #646.
2
+ * Unit tests for the skill placement path resolvers in paths.mjs.
3
3
  *
4
4
  * The pre-existing exports (claudeMcpJson, cursorMcpJson, etc.) are
5
5
  * exercised indirectly by the existing mergers tests; this file
6
- * focuses on the new skill placement + gitignore exports.
6
+ * focuses on the skill placement + gitignore exports.
7
7
  */
8
8
 
9
9
  import { describe, it, beforeEach, afterEach } from "node:test";
@@ -15,10 +15,14 @@ import { tmpdir, homedir } from "node:os";
15
15
  import {
16
16
  claudeSkillsProject,
17
17
  claudeSkillsGlobal,
18
- projectSkillsFallback,
19
- projectSkillsFallbackRoot,
20
18
  claudeSkillsProjectRoot,
21
19
  claudeSkillsGlobalRoot,
20
+ agentsSkillsProject,
21
+ agentsSkillsProjectRoot,
22
+ agentsSkillsGlobal,
23
+ agentsSkillsGlobalRoot,
24
+ windsurfSkillsGlobal,
25
+ windsurfSkillsGlobalRoot,
22
26
  gitignorePath,
23
27
  } from "../../lib/paths.mjs";
24
28
  import {
@@ -59,28 +63,60 @@ describe("paths.mjs — skill placement targets", () => {
59
63
 
60
64
  it("claudeSkillsGlobal is under HOME/.claude/skills/<name>", () => {
61
65
  const dir = claudeSkillsGlobal("pdf-helper");
62
- // homedir() reads HOME on POSIX so respects our sandbox
63
66
  assert.equal(dir, join(homedir(), ".claude", "skills", "pdf-helper"));
64
67
  });
65
68
 
66
- it("projectSkillsFallback is under cwd/skills/<name>", () => {
67
- const dir = projectSkillsFallback("pdf-helper");
68
- assert.equal(dir, join(process.cwd(), "skills", "pdf-helper"));
69
- });
70
-
71
69
  it("claudeSkillsProjectRoot is the parent of project-local skills", () => {
72
- const root = claudeSkillsProjectRoot();
73
- assert.equal(root, join(process.cwd(), ".claude", "skills"));
70
+ assert.equal(
71
+ claudeSkillsProjectRoot(),
72
+ join(process.cwd(), ".claude", "skills"),
73
+ );
74
74
  });
75
75
 
76
76
  it("claudeSkillsGlobalRoot is the parent of personal skills", () => {
77
- const root = claudeSkillsGlobalRoot();
78
- assert.equal(root, join(homedir(), ".claude", "skills"));
77
+ assert.equal(claudeSkillsGlobalRoot(), join(homedir(), ".claude", "skills"));
78
+ });
79
+
80
+ it("agentsSkillsProject is under cwd/.agents/skills/<name>", () => {
81
+ assert.equal(
82
+ agentsSkillsProject("pdf-helper"),
83
+ join(process.cwd(), ".agents", "skills", "pdf-helper"),
84
+ );
85
+ });
86
+
87
+ it("agentsSkillsProjectRoot is the parent of cohort skills", () => {
88
+ assert.equal(
89
+ agentsSkillsProjectRoot(),
90
+ join(process.cwd(), ".agents", "skills"),
91
+ );
92
+ });
93
+
94
+ it("agentsSkillsGlobal is under HOME/.agents/skills/<name>", () => {
95
+ assert.equal(
96
+ agentsSkillsGlobal("pdf-helper"),
97
+ join(homedir(), ".agents", "skills", "pdf-helper"),
98
+ );
99
+ });
100
+
101
+ it("agentsSkillsGlobalRoot is the parent of personal cohort skills", () => {
102
+ assert.equal(
103
+ agentsSkillsGlobalRoot(),
104
+ join(homedir(), ".agents", "skills"),
105
+ );
106
+ });
107
+
108
+ it("windsurfSkillsGlobal is under HOME/.codeium/windsurf/skills/<name>", () => {
109
+ assert.equal(
110
+ windsurfSkillsGlobal("pdf-helper"),
111
+ join(homedir(), ".codeium", "windsurf", "skills", "pdf-helper"),
112
+ );
79
113
  });
80
114
 
81
- it("projectSkillsFallbackRoot is the parent of fallback skills", () => {
82
- const root = projectSkillsFallbackRoot();
83
- assert.equal(root, join(process.cwd(), "skills"));
115
+ it("windsurfSkillsGlobalRoot is the parent of Windsurf personal skills", () => {
116
+ assert.equal(
117
+ windsurfSkillsGlobalRoot(),
118
+ join(homedir(), ".codeium", "windsurf", "skills"),
119
+ );
84
120
  });
85
121
 
86
122
  it("gitignorePath is at cwd/.gitignore", () => {