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.
- package/README.md +215 -150
- package/bin/skillrepo.mjs +210 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +471 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +167 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/update.mjs +67 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/paths.mjs +46 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/sync.mjs +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +486 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -0
- package/src/test/commands/update.test.mjs +164 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- 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
|
+
});
|