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,157 @@
1
+ /**
2
+ * Unit tests for src/lib/identifier.mjs (PR2 of #646).
3
+ *
4
+ * Covers parseIdentifier and formatIdentifier for every documented
5
+ * accept/reject case. The identifier helper is the entry point for
6
+ * three commands (get, add, remove), so coverage here is the single
7
+ * source of truth for argument-validation correctness.
8
+ */
9
+
10
+ import { describe, it } from "node:test";
11
+ import assert from "node:assert/strict";
12
+
13
+ import { parseIdentifier, formatIdentifier } from "../../lib/identifier.mjs";
14
+ import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
15
+
16
+ describe("parseIdentifier — accept", () => {
17
+ it("@owner/name canonical form", () => {
18
+ assert.deepEqual(parseIdentifier("@alice/pdf-helper"), {
19
+ owner: "alice",
20
+ name: "pdf-helper",
21
+ });
22
+ });
23
+
24
+ it("owner/name without leading @", () => {
25
+ assert.deepEqual(parseIdentifier("alice/pdf-helper"), {
26
+ owner: "alice",
27
+ name: "pdf-helper",
28
+ });
29
+ });
30
+
31
+ it("single-character segments", () => {
32
+ assert.deepEqual(parseIdentifier("@a/b"), { owner: "a", name: "b" });
33
+ });
34
+
35
+ it("digits in segments", () => {
36
+ assert.deepEqual(parseIdentifier("@user123/skill456"), {
37
+ owner: "user123",
38
+ name: "skill456",
39
+ });
40
+ });
41
+
42
+ it("hyphens within segments", () => {
43
+ assert.deepEqual(parseIdentifier("@my-org/my-skill"), {
44
+ owner: "my-org",
45
+ name: "my-skill",
46
+ });
47
+ });
48
+
49
+ it("trims surrounding whitespace", () => {
50
+ assert.deepEqual(parseIdentifier(" @alice/pdf-helper "), {
51
+ owner: "alice",
52
+ name: "pdf-helper",
53
+ });
54
+ });
55
+
56
+ it("owner at the spec-max length (100 chars)", () => {
57
+ const owner = "a".repeat(100);
58
+ assert.deepEqual(parseIdentifier(`@${owner}/skill`), { owner, name: "skill" });
59
+ });
60
+
61
+ it("name at the spec-max length (64 chars)", () => {
62
+ const name = "a".repeat(64);
63
+ assert.deepEqual(parseIdentifier(`@alice/${name}`), { owner: "alice", name });
64
+ });
65
+ });
66
+
67
+ describe("parseIdentifier — reject", () => {
68
+ function expectReject(input, pattern) {
69
+ assert.throws(
70
+ () => parseIdentifier(input),
71
+ (err) => {
72
+ if (!(err instanceof CliError)) return false;
73
+ if (err.exitCode !== EXIT_VALIDATION) return false;
74
+ return pattern.test(err.message);
75
+ },
76
+ `Expected "${input}" to be rejected with /${pattern.source}/`,
77
+ );
78
+ }
79
+
80
+ it("rejects null", () => expectReject(null, /required/));
81
+ it("rejects undefined", () => expectReject(undefined, /required/));
82
+ it("rejects empty string", () => expectReject("", /required/));
83
+ it("rejects whitespace only", () => expectReject(" ", /required/));
84
+ it("rejects non-string (number)", () => expectReject(42, /required/));
85
+
86
+ it("rejects bare name (no slash)", () =>
87
+ expectReject("alice", /missing owner/));
88
+ it("rejects @bare-name", () =>
89
+ expectReject("@alice", /missing owner/));
90
+
91
+ it("rejects too many slashes", () =>
92
+ expectReject("@alice/foo/bar", /too many segments/));
93
+ it("rejects too many slashes (2 slashes)", () =>
94
+ expectReject("alice/foo/bar/baz", /too many segments/));
95
+
96
+ it("rejects empty owner", () =>
97
+ expectReject("@/name", /empty owner/));
98
+ it("rejects empty name", () =>
99
+ expectReject("@alice/", /empty name/));
100
+ it("rejects empty owner and empty name", () =>
101
+ expectReject("@/", /empty owner/));
102
+
103
+ it("rejects uppercase in owner", () =>
104
+ expectReject("@Alice/skill", /lowercase alphanumeric/));
105
+ it("rejects uppercase in name", () =>
106
+ expectReject("@alice/PdfHelper", /lowercase alphanumeric/));
107
+ it("rejects underscore", () =>
108
+ expectReject("@alice/pdf_helper", /lowercase alphanumeric/));
109
+ it("rejects spaces in name", () =>
110
+ expectReject("@alice/pdf helper", /lowercase alphanumeric/));
111
+ it("rejects period", () =>
112
+ expectReject("@alice/pdf.helper", /lowercase alphanumeric/));
113
+
114
+ it("rejects leading hyphen in owner", () =>
115
+ expectReject("@-alice/skill", /must not start or end with a hyphen/));
116
+ it("rejects trailing hyphen in owner", () =>
117
+ expectReject("@alice-/skill", /must not start or end with a hyphen/));
118
+ it("rejects leading hyphen in name", () =>
119
+ expectReject("@alice/-skill", /must not start or end with a hyphen/));
120
+ it("rejects trailing hyphen in name", () =>
121
+ expectReject("@alice/skill-", /must not start or end with a hyphen/));
122
+
123
+ it("rejects consecutive hyphens in owner", () =>
124
+ expectReject("@a--b/skill", /consecutive hyphens/));
125
+ it("rejects consecutive hyphens in name", () =>
126
+ expectReject("@alice/a--b", /consecutive hyphens/));
127
+
128
+ it("rejects owner over 100 chars", () => {
129
+ const owner = "a".repeat(101);
130
+ expectReject(`@${owner}/skill`, /owner exceeds 100/);
131
+ });
132
+ it("rejects name over 64 chars", () => {
133
+ const name = "a".repeat(65);
134
+ expectReject(`@alice/${name}`, /name exceeds 64/);
135
+ });
136
+
137
+ it("rejects path traversal in name", () => {
138
+ // Slash is the segment delimiter so this hits "too many segments"
139
+ // first, but the test confirms there's no path through the
140
+ // validator that would let `..` reach a downstream consumer.
141
+ expectReject("@alice/../etc", /too many segments/);
142
+ });
143
+ });
144
+
145
+ describe("formatIdentifier", () => {
146
+ it("formats canonical @owner/name", () => {
147
+ assert.equal(
148
+ formatIdentifier({ owner: "alice", name: "pdf-helper" }),
149
+ "@alice/pdf-helper",
150
+ );
151
+ });
152
+
153
+ it("round-trips parse + format", () => {
154
+ const parsed = parseIdentifier("@alice/pdf-helper");
155
+ assert.equal(formatIdentifier(parsed), "@alice/pdf-helper");
156
+ });
157
+ });
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Unit tests for src/lib/mcp-merge.mjs (PR3b of #646).
3
+ *
4
+ * Exercises the per-vendor merge + prompt + failure-handling flow.
5
+ * Uses the existing createCaptureStream helper to observe stdout,
6
+ * and monkey-patches the `prompt.mjs` `confirm` function via module
7
+ * import caching to control the user's y/n responses.
8
+ *
9
+ * NOTE on prompt mocking: `prompt.mjs` exports `confirm` which reads
10
+ * from a readline interface by default. To avoid spawning a readline
11
+ * and hanging the test, we always pass `yes: true` in most tests,
12
+ * and for the "user declined" tests we monkey-patch the module's
13
+ * `confirm` export before each test and restore it after.
14
+ */
15
+
16
+ import { describe, it, beforeEach, afterEach } from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { tmpdir } from "node:os";
21
+
22
+ import { mergeMcpForVendors, printManualMcpInstructions } from "../../lib/mcp-merge.mjs";
23
+ import { createCaptureStream } from "../helpers/capture-stream.mjs";
24
+
25
+ let sandbox;
26
+ let originalCwd;
27
+ let originalHome;
28
+ let stdout;
29
+ let stderr;
30
+
31
+ /**
32
+ * Create a `confirmFn` stub that records its calls and returns the
33
+ * configured answer(s). Each call pops the next answer from the
34
+ * provided array; if the array is empty, defaults to `true`.
35
+ */
36
+ function makeConfirmFn(answers = []) {
37
+ const calls = [];
38
+ const fn = async (prompt, defaultYes) => {
39
+ calls.push({ prompt, defaultYes });
40
+ return answers.length > 0 ? answers.shift() : true;
41
+ };
42
+ fn.calls = calls;
43
+ return fn;
44
+ }
45
+
46
+ function setupSandbox() {
47
+ sandbox = mkdtempSync(join(tmpdir(), "cli-mcpmerge-"));
48
+ mkdirSync(join(sandbox, "project"), { recursive: true });
49
+ mkdirSync(join(sandbox, "home"), { recursive: true });
50
+ originalCwd = process.cwd();
51
+ originalHome = process.env.HOME;
52
+ process.chdir(join(sandbox, "project"));
53
+ process.env.HOME = join(sandbox, "home");
54
+ stdout = createCaptureStream();
55
+ stderr = createCaptureStream();
56
+ }
57
+
58
+ function teardownSandbox() {
59
+ process.chdir(originalCwd);
60
+ process.env.HOME = originalHome;
61
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
62
+ }
63
+
64
+ // ── mergeMcpForVendors — happy path with --yes ─────────────────────────
65
+
66
+ describe("mergeMcpForVendors — happy path", () => {
67
+ beforeEach(setupSandbox);
68
+ afterEach(teardownSandbox);
69
+
70
+ it("creates .mcp.json when it does not exist (claudeCode)", async () => {
71
+ const results = await mergeMcpForVendors({
72
+ vendors: ["claudeCode"],
73
+ mcpUrl: "https://skillrepo.dev/api/mcp",
74
+ yes: true,
75
+ io: { stdout, stderr },
76
+ });
77
+
78
+ assert.equal(results.length, 1);
79
+ assert.equal(results[0].outcome, "merged");
80
+ assert.equal(results[0].action, "created");
81
+
82
+ const mcp = JSON.parse(readFileSync(join(process.cwd(), ".mcp.json"), "utf-8"));
83
+ assert.ok(mcp.mcpServers?.skillrepo);
84
+ assert.equal(mcp.mcpServers.skillrepo.type, "http");
85
+ assert.equal(mcp.mcpServers.skillrepo.url, "https://skillrepo.dev/api/mcp");
86
+ });
87
+
88
+ it("merges into existing .mcp.json preserving other servers", async () => {
89
+ writeFileSync(
90
+ join(process.cwd(), ".mcp.json"),
91
+ JSON.stringify({
92
+ mcpServers: {
93
+ "other-server": { type: "http", url: "https://other.example" },
94
+ },
95
+ }),
96
+ );
97
+
98
+ await mergeMcpForVendors({
99
+ vendors: ["claudeCode"],
100
+ mcpUrl: "https://skillrepo.dev/api/mcp",
101
+ yes: true,
102
+ io: { stdout, stderr },
103
+ });
104
+
105
+ const mcp = JSON.parse(readFileSync(join(process.cwd(), ".mcp.json"), "utf-8"));
106
+ assert.ok(mcp.mcpServers.skillrepo);
107
+ assert.ok(mcp.mcpServers["other-server"], "existing server should be preserved");
108
+
109
+ // Preview message should mention the preserved server
110
+ assert.match(stdout.text(), /other-server/);
111
+ });
112
+
113
+ it("handles multiple vendors in one call", async () => {
114
+ const results = await mergeMcpForVendors({
115
+ vendors: ["claudeCode", "cursor"],
116
+ mcpUrl: "https://skillrepo.dev/api/mcp",
117
+ yes: true,
118
+ io: { stdout, stderr },
119
+ });
120
+ assert.equal(results.length, 2);
121
+ assert.equal(results[0].outcome, "merged");
122
+ assert.equal(results[1].outcome, "merged");
123
+ });
124
+
125
+ it("returns empty array for empty vendor list", async () => {
126
+ const results = await mergeMcpForVendors({
127
+ vendors: [],
128
+ mcpUrl: "https://skillrepo.dev/api/mcp",
129
+ yes: true,
130
+ io: { stdout, stderr },
131
+ });
132
+ assert.deepEqual(results, []);
133
+ });
134
+ });
135
+
136
+ // ── mergeMcpForVendors — preview strings ───────────────────────────────
137
+
138
+ describe("mergeMcpForVendors — preview messages", () => {
139
+ beforeEach(setupSandbox);
140
+ afterEach(teardownSandbox);
141
+
142
+ it("previews 'will create' for a missing file", async () => {
143
+ await mergeMcpForVendors({
144
+ vendors: ["claudeCode"],
145
+ mcpUrl: "https://x.com/mcp",
146
+ yes: true,
147
+ io: { stdout, stderr },
148
+ });
149
+ assert.match(stdout.text(), /will create/);
150
+ });
151
+
152
+ it("previews 'will update existing' when skillrepo entry already present", async () => {
153
+ writeFileSync(
154
+ join(process.cwd(), ".mcp.json"),
155
+ JSON.stringify({
156
+ mcpServers: {
157
+ skillrepo: { type: "http", url: "https://old.example" },
158
+ },
159
+ }),
160
+ );
161
+ await mergeMcpForVendors({
162
+ vendors: ["claudeCode"],
163
+ mcpUrl: "https://new.example/mcp",
164
+ yes: true,
165
+ io: { stdout, stderr },
166
+ });
167
+ assert.match(stdout.text(), /update existing/);
168
+ });
169
+
170
+ it("previews warning for invalid JSON (merge will throw)", async () => {
171
+ writeFileSync(join(process.cwd(), ".mcp.json"), "not json {{{");
172
+ const results = await mergeMcpForVendors({
173
+ vendors: ["claudeCode"],
174
+ mcpUrl: "https://x.com/mcp",
175
+ yes: true,
176
+ io: { stdout, stderr },
177
+ });
178
+ assert.match(stdout.text(), /invalid JSON/);
179
+ // The merge should fail, not merge
180
+ assert.equal(results[0].outcome, "failed");
181
+ });
182
+ });
183
+
184
+ // ── mergeMcpForVendors — user declined ─────────────────────────────────
185
+
186
+ describe("mergeMcpForVendors — user declined", () => {
187
+ beforeEach(setupSandbox);
188
+ afterEach(teardownSandbox);
189
+
190
+ it("records 'skipped' outcome when user answers no", async () => {
191
+ const confirmFn = makeConfirmFn([false]);
192
+ const results = await mergeMcpForVendors({
193
+ vendors: ["claudeCode"],
194
+ mcpUrl: "https://x.com/mcp",
195
+ yes: false, // NOT --yes — will call confirmFn
196
+ io: { stdout, stderr },
197
+ confirmFn,
198
+ });
199
+
200
+ assert.equal(results[0].outcome, "skipped");
201
+ assert.equal(results[0].reason, "user declined");
202
+ assert.equal(confirmFn.calls.length, 1);
203
+ });
204
+
205
+ it("processes multiple vendors with mixed answers", async () => {
206
+ // Say "yes" to Claude Code, "no" to Cursor
207
+ const confirmFn = makeConfirmFn([true, false]);
208
+ const results = await mergeMcpForVendors({
209
+ vendors: ["claudeCode", "cursor"],
210
+ mcpUrl: "https://x.com/mcp",
211
+ yes: false,
212
+ io: { stdout, stderr },
213
+ confirmFn,
214
+ });
215
+ assert.equal(results[0].outcome, "merged");
216
+ assert.equal(results[1].outcome, "skipped");
217
+ assert.equal(confirmFn.calls.length, 2);
218
+ });
219
+ });
220
+
221
+ // ── mergeMcpForVendors — failures don't abort ──────────────────────────
222
+
223
+ describe("mergeMcpForVendors — failure handling", () => {
224
+ beforeEach(setupSandbox);
225
+ afterEach(teardownSandbox);
226
+
227
+ it("one vendor failure doesn't abort the others", async () => {
228
+ // Corrupt the claudeCode config so its merger throws
229
+ writeFileSync(join(process.cwd(), ".mcp.json"), "not json {{{");
230
+
231
+ const results = await mergeMcpForVendors({
232
+ vendors: ["claudeCode", "cursor"],
233
+ mcpUrl: "https://x.com/mcp",
234
+ yes: true,
235
+ io: { stdout, stderr },
236
+ });
237
+
238
+ assert.equal(results.length, 2);
239
+ assert.equal(results[0].outcome, "failed");
240
+ assert.equal(results[1].outcome, "merged"); // cursor still succeeds
241
+ });
242
+
243
+ it("records 'failed' with reason for unknown vendor", async () => {
244
+ const results = await mergeMcpForVendors({
245
+ vendors: ["jetbrains"],
246
+ mcpUrl: "https://x.com/mcp",
247
+ yes: true,
248
+ io: { stdout, stderr },
249
+ });
250
+ assert.equal(results[0].outcome, "failed");
251
+ assert.match(results[0].reason, /Unknown vendor/);
252
+ });
253
+
254
+ it("handles the three-way interleaving: merge + fail + skip (round-1 gap fix)", async () => {
255
+ // Architect's round-1 review called out that the most complex
256
+ // scenario — one vendor succeeds, one fails with corrupt JSON,
257
+ // one is declined by the user — was not tested. This test
258
+ // locks that interleaving.
259
+ //
260
+ // Setup:
261
+ // claudeCode → corrupt .mcp.json → merge throws → "failed"
262
+ // cursor → user declines → "skipped"
263
+ // windsurf → no existing file → "merged"
264
+ writeFileSync(join(process.cwd(), ".mcp.json"), "not json {{{");
265
+
266
+ // With `yes: false`, confirmFn IS invoked for every vendor —
267
+ // the merger only throws AFTER the user says yes. The answers
268
+ // below map vendor-by-vendor:
269
+ // claudeCode → yes (then underlying merger throws → failed)
270
+ // cursor → no (user declined → skipped)
271
+ // windsurf → yes (merger succeeds → merged)
272
+ const confirmFn = makeConfirmFn([true, false, true]);
273
+
274
+ const results = await mergeMcpForVendors({
275
+ vendors: ["claudeCode", "cursor", "windsurf"],
276
+ mcpUrl: "https://x.com/mcp",
277
+ yes: false,
278
+ io: { stdout, stderr },
279
+ confirmFn,
280
+ });
281
+
282
+ assert.equal(results.length, 3);
283
+ assert.equal(results[0].vendor, "claudeCode");
284
+ assert.equal(results[0].outcome, "failed");
285
+ assert.match(results[0].reason, /invalid JSON/i);
286
+
287
+ assert.equal(results[1].vendor, "cursor");
288
+ assert.equal(results[1].outcome, "skipped");
289
+ assert.equal(results[1].reason, "user declined");
290
+
291
+ assert.equal(results[2].vendor, "windsurf");
292
+ assert.equal(results[2].outcome, "merged");
293
+
294
+ // confirmFn should have been called 3 times
295
+ assert.equal(confirmFn.calls.length, 3);
296
+ });
297
+
298
+ it("dedupes duplicate vendors (round-1 review fix)", async () => {
299
+ // --ide claude,claude used to run the merger twice. Now dedupes.
300
+ const results = await mergeMcpForVendors({
301
+ vendors: ["claudeCode", "claudeCode", "cursor", "cursor"],
302
+ mcpUrl: "https://x.com/mcp",
303
+ yes: true,
304
+ io: { stdout, stderr },
305
+ });
306
+ assert.equal(results.length, 2, "duplicates should be removed");
307
+ assert.equal(results[0].vendor, "claudeCode");
308
+ assert.equal(results[1].vendor, "cursor");
309
+ });
310
+ });
311
+
312
+ // ── mergeMcpForVendors — input validation ──────────────────────────────
313
+
314
+ describe("mergeMcpForVendors — input validation", () => {
315
+ beforeEach(setupSandbox);
316
+ afterEach(teardownSandbox);
317
+
318
+ it("throws when mcpUrl is missing", async () => {
319
+ await assert.rejects(
320
+ () => mergeMcpForVendors({ vendors: ["claudeCode"], yes: true, io: { stdout, stderr } }),
321
+ (err) => /mcpUrl is required/.test(err.message),
322
+ );
323
+ });
324
+
325
+ it("throws when mcpUrl is empty string", async () => {
326
+ await assert.rejects(
327
+ () => mergeMcpForVendors({ vendors: ["claudeCode"], mcpUrl: "", yes: true, io: { stdout, stderr } }),
328
+ (err) => /mcpUrl is required/.test(err.message),
329
+ );
330
+ });
331
+ });
332
+
333
+ // ── printManualMcpInstructions ─────────────────────────────────────────
334
+
335
+ describe("printManualMcpInstructions", () => {
336
+ it("prints a copy-pasteable MCP config blob", () => {
337
+ const out = createCaptureStream();
338
+ printManualMcpInstructions("https://skillrepo.dev/api/mcp", { stdout: out });
339
+ const text = out.text();
340
+ assert.match(text, /mcpServers/);
341
+ assert.match(text, /skillrepo/);
342
+ assert.match(text, /https:\/\/skillrepo\.dev\/api\/mcp/);
343
+ assert.match(text, /SKILLREPO_ACCESS_KEY/);
344
+ });
345
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Unit tests for the new paths.mjs exports added in PR1 of #646.
3
+ *
4
+ * The pre-existing exports (claudeMcpJson, cursorMcpJson, etc.) are
5
+ * exercised indirectly by the existing mergers tests; this file
6
+ * focuses on the new skill placement + gitignore exports.
7
+ */
8
+
9
+ import { describe, it, beforeEach, afterEach } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { tmpdir, homedir } from "node:os";
14
+
15
+ import {
16
+ claudeSkillsProject,
17
+ claudeSkillsGlobal,
18
+ projectSkillsFallback,
19
+ projectSkillsFallbackRoot,
20
+ claudeSkillsProjectRoot,
21
+ claudeSkillsGlobalRoot,
22
+ gitignorePath,
23
+ } from "../../lib/paths.mjs";
24
+
25
+ let sandbox;
26
+ let originalCwd;
27
+ let originalHome;
28
+
29
+ function setupSandbox() {
30
+ sandbox = mkdtempSync(join(tmpdir(), "cli-paths-"));
31
+ mkdirSync(join(sandbox, "project"), { recursive: true });
32
+ mkdirSync(join(sandbox, "home"), { recursive: true });
33
+ originalCwd = process.cwd();
34
+ originalHome = process.env.HOME;
35
+ process.chdir(join(sandbox, "project"));
36
+ process.env.HOME = join(sandbox, "home");
37
+ }
38
+
39
+ function teardownSandbox() {
40
+ process.chdir(originalCwd);
41
+ process.env.HOME = originalHome;
42
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
43
+ }
44
+
45
+ describe("paths.mjs — skill placement targets", () => {
46
+ beforeEach(setupSandbox);
47
+ afterEach(teardownSandbox);
48
+
49
+ it("claudeSkillsProject is under cwd/.claude/skills/<name>", () => {
50
+ const dir = claudeSkillsProject("pdf-helper");
51
+ assert.equal(dir, join(process.cwd(), ".claude", "skills", "pdf-helper"));
52
+ });
53
+
54
+ it("claudeSkillsGlobal is under HOME/.claude/skills/<name>", () => {
55
+ const dir = claudeSkillsGlobal("pdf-helper");
56
+ // homedir() reads HOME on POSIX so respects our sandbox
57
+ assert.equal(dir, join(homedir(), ".claude", "skills", "pdf-helper"));
58
+ });
59
+
60
+ it("projectSkillsFallback is under cwd/skills/<name>", () => {
61
+ const dir = projectSkillsFallback("pdf-helper");
62
+ assert.equal(dir, join(process.cwd(), "skills", "pdf-helper"));
63
+ });
64
+
65
+ it("claudeSkillsProjectRoot is the parent of project-local skills", () => {
66
+ const root = claudeSkillsProjectRoot();
67
+ assert.equal(root, join(process.cwd(), ".claude", "skills"));
68
+ });
69
+
70
+ it("claudeSkillsGlobalRoot is the parent of personal skills", () => {
71
+ const root = claudeSkillsGlobalRoot();
72
+ assert.equal(root, join(homedir(), ".claude", "skills"));
73
+ });
74
+
75
+ it("projectSkillsFallbackRoot is the parent of fallback skills", () => {
76
+ const root = projectSkillsFallbackRoot();
77
+ assert.equal(root, join(process.cwd(), "skills"));
78
+ });
79
+
80
+ it("gitignorePath is at cwd/.gitignore", () => {
81
+ assert.equal(gitignorePath(), join(process.cwd(), ".gitignore"));
82
+ });
83
+ });