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,576 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E tests for the PR2 read commands (#646).
|
|
3
|
+
*
|
|
4
|
+
* Spawns the real CLI binary as a subprocess against the in-process
|
|
5
|
+
* mock server. Asserts on stdout, stderr, exit code, and post-state
|
|
6
|
+
* file tree. Same pattern as the existing cli-init.test.mjs harness.
|
|
7
|
+
*
|
|
8
|
+
* Coverage:
|
|
9
|
+
* • Full lifecycle: update → list → get → search
|
|
10
|
+
* • Per-command --json output via subprocess
|
|
11
|
+
* • Error exit codes propagated from CliError
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, before, after, beforeEach, afterEach } from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { mkdtempSync, mkdirSync, rmSync, existsSync } from "node:fs";
|
|
17
|
+
import { join, dirname, resolve } from "node:path";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
import { createMockServer } from "./mock-server.mjs";
|
|
23
|
+
|
|
24
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const CLI_BIN = resolve(__dirname, "../../../bin/skillrepo.mjs");
|
|
26
|
+
const VALID_KEY = "sk_live_test_e2e";
|
|
27
|
+
|
|
28
|
+
let server;
|
|
29
|
+
let port;
|
|
30
|
+
let tempDir;
|
|
31
|
+
let tempHome;
|
|
32
|
+
|
|
33
|
+
function makeSkill(owner, name, content = `body of ${name}`) {
|
|
34
|
+
return {
|
|
35
|
+
owner,
|
|
36
|
+
name,
|
|
37
|
+
version: "1.0.0",
|
|
38
|
+
description: `${name} description`,
|
|
39
|
+
files: [
|
|
40
|
+
{
|
|
41
|
+
path: "SKILL.md",
|
|
42
|
+
content: `---\nname: ${name}\ndescription: ${name} description\n---\n\n${content}\n`,
|
|
43
|
+
sha256: "x",
|
|
44
|
+
size: 100,
|
|
45
|
+
contentType: "text/markdown",
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
updatedAt: "2025-01-01T00:00:00Z",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run the CLI as a subprocess from `tempDir` with `tempHome` as HOME.
|
|
54
|
+
* Always resolves with { stdout, stderr, status }.
|
|
55
|
+
*/
|
|
56
|
+
function runCli(args, extraEnv = {}) {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
execFile(
|
|
59
|
+
process.execPath,
|
|
60
|
+
[CLI_BIN, ...args],
|
|
61
|
+
{
|
|
62
|
+
cwd: tempDir,
|
|
63
|
+
encoding: "utf-8",
|
|
64
|
+
timeout: 15_000,
|
|
65
|
+
env: {
|
|
66
|
+
...process.env,
|
|
67
|
+
HOME: tempHome,
|
|
68
|
+
NO_COLOR: "1",
|
|
69
|
+
NODE_NO_WARNINGS: "1",
|
|
70
|
+
SKILLREPO_ACCESS_KEY: "",
|
|
71
|
+
SKILLREPO_TIMEOUT_MS: "5000",
|
|
72
|
+
...extraEnv,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
(err, stdout, stderr) => {
|
|
76
|
+
resolve({
|
|
77
|
+
stdout: stdout ?? "",
|
|
78
|
+
stderr: stderr ?? "",
|
|
79
|
+
status: err ? err.code ?? 1 : 0,
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("CLI E2E — read commands", () => {
|
|
87
|
+
before(async () => {
|
|
88
|
+
server = createMockServer({});
|
|
89
|
+
port = await server.start();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
after(async () => {
|
|
93
|
+
if (server) await server.stop();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
tempDir = mkdtempSync(join(tmpdir(), "cli-e2e-cmds-"));
|
|
98
|
+
tempHome = mkdtempSync(join(tmpdir(), "cli-e2e-home-"));
|
|
99
|
+
// Reset all mock-server slots between tests
|
|
100
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
101
|
+
server.setEtag(null);
|
|
102
|
+
server.clearSkillResponses();
|
|
103
|
+
server.setSearchResponse({ skills: [], pagination: { total: 0, limit: 20, offset: 0 } });
|
|
104
|
+
server.clearAddResponses();
|
|
105
|
+
server.clearRemoveResponses();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
|
110
|
+
if (tempHome) rmSync(tempHome, { recursive: true, force: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── Lifecycle: update → list → get → search ─────────────────────────
|
|
114
|
+
|
|
115
|
+
it("update on empty library prints up-to-date and exits 0", async () => {
|
|
116
|
+
const r = await runCli([
|
|
117
|
+
"update",
|
|
118
|
+
"--key", VALID_KEY,
|
|
119
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
120
|
+
]);
|
|
121
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
122
|
+
assert.match(r.stdout, /up to date/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("update writes a single skill and prints the summary", async () => {
|
|
126
|
+
server.setLibraryResponse({
|
|
127
|
+
skills: [makeSkill("alice", "pdf-helper")],
|
|
128
|
+
removals: [],
|
|
129
|
+
syncedAt: "x",
|
|
130
|
+
});
|
|
131
|
+
const r = await runCli([
|
|
132
|
+
"update",
|
|
133
|
+
"--key", VALID_KEY,
|
|
134
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
135
|
+
]);
|
|
136
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
137
|
+
assert.match(r.stdout, /1 added/);
|
|
138
|
+
assert.ok(existsSync(join(tempDir, ".claude", "skills", "pdf-helper", "SKILL.md")));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("update --json outputs structured summary", async () => {
|
|
142
|
+
server.setLibraryResponse({
|
|
143
|
+
skills: [makeSkill("alice", "pdf-helper")],
|
|
144
|
+
removals: [],
|
|
145
|
+
syncedAt: "x",
|
|
146
|
+
});
|
|
147
|
+
const r = await runCli([
|
|
148
|
+
"update",
|
|
149
|
+
"--key", VALID_KEY,
|
|
150
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
151
|
+
"--json",
|
|
152
|
+
]);
|
|
153
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
154
|
+
const json = JSON.parse(r.stdout);
|
|
155
|
+
assert.equal(json.added, 1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("list renders an empty-state hint when library is empty", async () => {
|
|
159
|
+
const r = await runCli([
|
|
160
|
+
"list",
|
|
161
|
+
"--key", VALID_KEY,
|
|
162
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
163
|
+
]);
|
|
164
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
165
|
+
assert.match(r.stdout, /library is empty/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("list renders skills as a table after they're added", async () => {
|
|
169
|
+
server.setLibraryResponse({
|
|
170
|
+
skills: [makeSkill("alice", "pdf-helper"), makeSkill("bob", "code-review")],
|
|
171
|
+
removals: [],
|
|
172
|
+
syncedAt: "x",
|
|
173
|
+
});
|
|
174
|
+
const r = await runCli([
|
|
175
|
+
"list",
|
|
176
|
+
"--key", VALID_KEY,
|
|
177
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
178
|
+
]);
|
|
179
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
180
|
+
assert.match(r.stdout, /pdf-helper/);
|
|
181
|
+
assert.match(r.stdout, /code-review/);
|
|
182
|
+
assert.match(r.stdout, /2 skills/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("get fetches a single skill and writes it to disk", async () => {
|
|
186
|
+
server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
|
|
187
|
+
const r = await runCli([
|
|
188
|
+
"get",
|
|
189
|
+
"--key", VALID_KEY,
|
|
190
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
191
|
+
"@alice/pdf-helper",
|
|
192
|
+
]);
|
|
193
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
194
|
+
assert.match(r.stdout, /Fetched/);
|
|
195
|
+
assert.ok(existsSync(join(tempDir, ".claude", "skills", "pdf-helper", "SKILL.md")));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("get with non-existent skill exits 5 with a clean error", async () => {
|
|
199
|
+
const r = await runCli([
|
|
200
|
+
"get",
|
|
201
|
+
"--key", VALID_KEY,
|
|
202
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
203
|
+
"@alice/missing",
|
|
204
|
+
]);
|
|
205
|
+
assert.equal(r.status, 5);
|
|
206
|
+
assert.match(r.stderr, /not found/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("search renders results from the registry", async () => {
|
|
210
|
+
server.setSearchResponse({
|
|
211
|
+
skills: [
|
|
212
|
+
{
|
|
213
|
+
owner: "alice",
|
|
214
|
+
name: "pdf-helper",
|
|
215
|
+
description: "Test skill",
|
|
216
|
+
version: "1.0.0",
|
|
217
|
+
license: null,
|
|
218
|
+
compatibility: null,
|
|
219
|
+
installs: 100,
|
|
220
|
+
avgRating: null,
|
|
221
|
+
safetyGrade: null,
|
|
222
|
+
publishedAt: null,
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
pagination: { total: 1, limit: 20, offset: 0 },
|
|
226
|
+
});
|
|
227
|
+
const r = await runCli([
|
|
228
|
+
"search",
|
|
229
|
+
"--key", VALID_KEY,
|
|
230
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
231
|
+
"pdf",
|
|
232
|
+
]);
|
|
233
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
234
|
+
assert.match(r.stdout, /pdf-helper/);
|
|
235
|
+
assert.match(r.stdout, /Results for "pdf"/);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("search with no results prints helpful message", async () => {
|
|
239
|
+
const r = await runCli([
|
|
240
|
+
"search",
|
|
241
|
+
"--key", VALID_KEY,
|
|
242
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
243
|
+
"obscure",
|
|
244
|
+
]);
|
|
245
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
246
|
+
assert.match(r.stdout, /No skills found matching/);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ── Error paths ─────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
it("invalid access key exits 2 (auth error)", async () => {
|
|
252
|
+
server.setForcedStatus(401, { error: "Invalid access key" });
|
|
253
|
+
const r = await runCli([
|
|
254
|
+
"list",
|
|
255
|
+
"--key", VALID_KEY,
|
|
256
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
257
|
+
]);
|
|
258
|
+
assert.equal(r.status, 2);
|
|
259
|
+
assert.match(r.stderr, /Invalid access key|access key/);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("missing access key exits 2 (no key configured)", async () => {
|
|
263
|
+
const r = await runCli([
|
|
264
|
+
"list",
|
|
265
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
266
|
+
]);
|
|
267
|
+
assert.equal(r.status, 2);
|
|
268
|
+
assert.match(r.stderr, /No access key/);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("unreachable server exits 1 (network error)", async () => {
|
|
272
|
+
const r = await runCli([
|
|
273
|
+
"list",
|
|
274
|
+
"--key", VALID_KEY,
|
|
275
|
+
"--url", "http://127.0.0.1:1",
|
|
276
|
+
]);
|
|
277
|
+
assert.equal(r.status, 1);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("get with malformed identifier exits 5 (validation)", async () => {
|
|
281
|
+
const r = await runCli([
|
|
282
|
+
"get",
|
|
283
|
+
"--key", VALID_KEY,
|
|
284
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
285
|
+
"not-an-identifier",
|
|
286
|
+
]);
|
|
287
|
+
assert.equal(r.status, 5);
|
|
288
|
+
assert.match(r.stderr, /missing owner/);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("search without query exits 5 (validation)", async () => {
|
|
292
|
+
const r = await runCli([
|
|
293
|
+
"search",
|
|
294
|
+
"--key", VALID_KEY,
|
|
295
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
296
|
+
]);
|
|
297
|
+
assert.equal(r.status, 5);
|
|
298
|
+
assert.match(r.stderr, /query is required/);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// ── Multi-command lifecycle ─────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
// ── PR3a: add + remove ──────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
it("add writes the library + local files", async () => {
|
|
306
|
+
server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
|
|
307
|
+
const r = await runCli([
|
|
308
|
+
"add",
|
|
309
|
+
"--key", VALID_KEY,
|
|
310
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
311
|
+
"@alice/pdf-helper",
|
|
312
|
+
]);
|
|
313
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
314
|
+
assert.match(r.stdout, /Added @alice\/pdf-helper/);
|
|
315
|
+
assert.ok(existsSync(join(tempDir, ".claude", "skills", "pdf-helper", "SKILL.md")));
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("add with already-in-library refreshes local files (idempotent)", async () => {
|
|
319
|
+
server.setAddResponse("alice", "pdf-helper", {
|
|
320
|
+
status: 409,
|
|
321
|
+
body: { code: "already_in_library" },
|
|
322
|
+
});
|
|
323
|
+
server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
|
|
324
|
+
|
|
325
|
+
const r = await runCli([
|
|
326
|
+
"add",
|
|
327
|
+
"--key", VALID_KEY,
|
|
328
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
329
|
+
"@alice/pdf-helper",
|
|
330
|
+
]);
|
|
331
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
332
|
+
assert.match(r.stdout, /already in your library — refreshed/);
|
|
333
|
+
assert.ok(existsSync(join(tempDir, ".claude", "skills", "pdf-helper", "SKILL.md")));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("add with 404 exits 5 (skill not found)", async () => {
|
|
337
|
+
server.setAddResponse("alice", "missing", {
|
|
338
|
+
status: 404,
|
|
339
|
+
body: { error: "Skill not found", code: "not_found" },
|
|
340
|
+
});
|
|
341
|
+
const r = await runCli([
|
|
342
|
+
"add",
|
|
343
|
+
"--key", VALID_KEY,
|
|
344
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
345
|
+
"@alice/missing",
|
|
346
|
+
]);
|
|
347
|
+
assert.equal(r.status, 5);
|
|
348
|
+
assert.match(r.stderr, /not found/);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("add with 403 scope_required exits 4", async () => {
|
|
352
|
+
server.setAddResponseForAny({
|
|
353
|
+
status: 403,
|
|
354
|
+
body: { error: "Scope required", code: "scope_required" },
|
|
355
|
+
});
|
|
356
|
+
const r = await runCli([
|
|
357
|
+
"add",
|
|
358
|
+
"--key", VALID_KEY,
|
|
359
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
360
|
+
"@alice/any",
|
|
361
|
+
]);
|
|
362
|
+
assert.equal(r.status, 4);
|
|
363
|
+
assert.match(r.stderr, /write-scoped key|scope/);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("add with 403 plan_limit exits 5 with billing hint", async () => {
|
|
367
|
+
server.setAddResponseForAny({
|
|
368
|
+
status: 403,
|
|
369
|
+
body: { error: "Your free plan allows up to 5 library skills.", code: "plan_limit" },
|
|
370
|
+
});
|
|
371
|
+
const r = await runCli([
|
|
372
|
+
"add",
|
|
373
|
+
"--key", VALID_KEY,
|
|
374
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
375
|
+
"@alice/too-many",
|
|
376
|
+
]);
|
|
377
|
+
assert.equal(r.status, 5);
|
|
378
|
+
assert.match(r.stderr, /plan/);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("remove deletes from library + local files", async () => {
|
|
382
|
+
// Pre-populate a local skill
|
|
383
|
+
server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
|
|
384
|
+
await runCli([
|
|
385
|
+
"get",
|
|
386
|
+
"--key", VALID_KEY,
|
|
387
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
388
|
+
"@alice/pdf-helper",
|
|
389
|
+
]);
|
|
390
|
+
assert.ok(existsSync(join(tempDir, ".claude", "skills", "pdf-helper")));
|
|
391
|
+
|
|
392
|
+
const r = await runCli([
|
|
393
|
+
"remove",
|
|
394
|
+
"--key", VALID_KEY,
|
|
395
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
396
|
+
"@alice/pdf-helper",
|
|
397
|
+
]);
|
|
398
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
399
|
+
assert.match(r.stdout, /Removed @alice\/pdf-helper/);
|
|
400
|
+
assert.ok(!existsSync(join(tempDir, ".claude", "skills", "pdf-helper")));
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("remove 404 not-in-library still exits 0 (idempotent)", async () => {
|
|
404
|
+
server.setRemoveResponseForAny({
|
|
405
|
+
status: 404,
|
|
406
|
+
body: { code: "not_in_library" },
|
|
407
|
+
});
|
|
408
|
+
const r = await runCli([
|
|
409
|
+
"remove",
|
|
410
|
+
"--key", VALID_KEY,
|
|
411
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
412
|
+
"@alice/ghost",
|
|
413
|
+
]);
|
|
414
|
+
assert.equal(r.status, 0);
|
|
415
|
+
assert.match(r.stdout, /nothing to do|wasn't in your library/);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("remove 403 scope_required exits 4", async () => {
|
|
419
|
+
server.setRemoveResponseForAny({
|
|
420
|
+
status: 403,
|
|
421
|
+
body: { error: "Scope required", code: "scope_required" },
|
|
422
|
+
});
|
|
423
|
+
const r = await runCli([
|
|
424
|
+
"remove",
|
|
425
|
+
"--key", VALID_KEY,
|
|
426
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
427
|
+
"@alice/any",
|
|
428
|
+
]);
|
|
429
|
+
assert.equal(r.status, 4);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ── PR3b: init ──────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
it("init writes config + MCP + runs first sync (happy path)", async () => {
|
|
435
|
+
// Create a .claude/ marker so detectIdes finds claudeCode
|
|
436
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
437
|
+
|
|
438
|
+
const r = await runCli([
|
|
439
|
+
"init",
|
|
440
|
+
"--key", VALID_KEY,
|
|
441
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
442
|
+
"--yes",
|
|
443
|
+
]);
|
|
444
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
445
|
+
assert.match(r.stdout, /SkillRepo is ready/);
|
|
446
|
+
|
|
447
|
+
// Config persisted under the tempHome
|
|
448
|
+
assert.ok(existsSync(join(tempHome, ".claude", "skillrepo", "config.json")));
|
|
449
|
+
// MCP config in project
|
|
450
|
+
assert.ok(existsSync(join(tempDir, ".mcp.json")));
|
|
451
|
+
// .env.local in project
|
|
452
|
+
assert.ok(existsSync(join(tempDir, ".env.local")));
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("init --json outputs structured summary", async () => {
|
|
456
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
457
|
+
|
|
458
|
+
const r = await runCli([
|
|
459
|
+
"init",
|
|
460
|
+
"--key", VALID_KEY,
|
|
461
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
462
|
+
"--yes",
|
|
463
|
+
"--json",
|
|
464
|
+
]);
|
|
465
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
466
|
+
const json = JSON.parse(r.stdout);
|
|
467
|
+
assert.equal(json.action, "initialized");
|
|
468
|
+
assert.equal(json.account.slug, "mock");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("init --ide claude works in empty dir (headless CI scenario)", async () => {
|
|
472
|
+
const r = await runCli([
|
|
473
|
+
"init",
|
|
474
|
+
"--key", VALID_KEY,
|
|
475
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
476
|
+
"--yes",
|
|
477
|
+
"--ide", "claude",
|
|
478
|
+
]);
|
|
479
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
480
|
+
assert.ok(existsSync(join(tempDir, ".mcp.json")));
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("init with no IDEs detected and no --ide exits 5", async () => {
|
|
484
|
+
// No .claude marker, no --ide flag
|
|
485
|
+
const r = await runCli([
|
|
486
|
+
"init",
|
|
487
|
+
"--key", VALID_KEY,
|
|
488
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
489
|
+
"--yes",
|
|
490
|
+
]);
|
|
491
|
+
assert.equal(r.status, 5);
|
|
492
|
+
assert.match(r.stderr, /No IDEs detected/);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("init with 401 from validate exits 2", async () => {
|
|
496
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
497
|
+
server.setForcedStatus(401, { error: "Invalid access key" });
|
|
498
|
+
const r = await runCli([
|
|
499
|
+
"init",
|
|
500
|
+
"--key", VALID_KEY,
|
|
501
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
502
|
+
"--yes",
|
|
503
|
+
]);
|
|
504
|
+
assert.equal(r.status, 2);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("full lifecycle: init → update → list → get → add → remove → search", async () => {
|
|
508
|
+
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
509
|
+
|
|
510
|
+
const endpoint = `http://127.0.0.1:${port}`;
|
|
511
|
+
|
|
512
|
+
// 0. Init — creates the config, MCP, and runs first sync
|
|
513
|
+
let r = await runCli(["init", "--key", VALID_KEY, "--url", endpoint, "--yes"]);
|
|
514
|
+
assert.equal(r.status, 0, `init stderr: ${r.stderr}`);
|
|
515
|
+
assert.ok(existsSync(join(tempHome, ".claude", "skillrepo", "config.json")));
|
|
516
|
+
assert.ok(existsSync(join(tempDir, ".mcp.json")));
|
|
517
|
+
|
|
518
|
+
// Subsequent calls can omit --key since init wrote the config.
|
|
519
|
+
// But they still need --url since the config's serverUrl is
|
|
520
|
+
// now the mock-server URL; a different port across this test
|
|
521
|
+
// run would collide with the stored config. That's fine — the
|
|
522
|
+
// subsequent calls inherit the same URL from the config.
|
|
523
|
+
|
|
524
|
+
// 1. Empty update
|
|
525
|
+
r = await runCli(["update", "--key", VALID_KEY, "--url", endpoint]);
|
|
526
|
+
assert.equal(r.status, 0);
|
|
527
|
+
|
|
528
|
+
// 2. List on empty library
|
|
529
|
+
r = await runCli(["list", "--key", VALID_KEY, "--url", endpoint]);
|
|
530
|
+
assert.equal(r.status, 0);
|
|
531
|
+
assert.match(r.stdout, /library is empty/);
|
|
532
|
+
|
|
533
|
+
// 3. Get a skill (server has it registered)
|
|
534
|
+
server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
|
|
535
|
+
r = await runCli(["get", "--key", VALID_KEY, "--url", endpoint, "@alice/pdf-helper"]);
|
|
536
|
+
assert.equal(r.status, 0);
|
|
537
|
+
assert.ok(existsSync(join(tempDir, ".claude", "skills", "pdf-helper", "SKILL.md")));
|
|
538
|
+
|
|
539
|
+
// 4. Add a skill (PR3a): POST default 201 + follow-up GET + local write
|
|
540
|
+
server.setSkillResponse("alice", "code-review", makeSkill("alice", "code-review"));
|
|
541
|
+
r = await runCli(["add", "--key", VALID_KEY, "--url", endpoint, "@alice/code-review"]);
|
|
542
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
543
|
+
assert.match(r.stdout, /Added @alice\/code-review/);
|
|
544
|
+
assert.ok(existsSync(join(tempDir, ".claude", "skills", "code-review", "SKILL.md")));
|
|
545
|
+
|
|
546
|
+
// 5. Remove a skill (PR3a): DELETE default 200 + local delete
|
|
547
|
+
r = await runCli(["remove", "--key", VALID_KEY, "--url", endpoint, "@alice/code-review"]);
|
|
548
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
549
|
+
assert.match(r.stdout, /Removed @alice\/code-review/);
|
|
550
|
+
assert.ok(!existsSync(join(tempDir, ".claude", "skills", "code-review")));
|
|
551
|
+
// pdf-helper from the `get` step is still there
|
|
552
|
+
assert.ok(existsSync(join(tempDir, ".claude", "skills", "pdf-helper", "SKILL.md")));
|
|
553
|
+
|
|
554
|
+
// 6. Search works
|
|
555
|
+
server.setSearchResponse({
|
|
556
|
+
skills: [
|
|
557
|
+
{
|
|
558
|
+
owner: "alice",
|
|
559
|
+
name: "pdf-helper",
|
|
560
|
+
description: "test",
|
|
561
|
+
version: "1.0.0",
|
|
562
|
+
license: null,
|
|
563
|
+
compatibility: null,
|
|
564
|
+
installs: 1,
|
|
565
|
+
avgRating: null,
|
|
566
|
+
safetyGrade: null,
|
|
567
|
+
publishedAt: null,
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
pagination: { total: 1, limit: 20, offset: 0 },
|
|
571
|
+
});
|
|
572
|
+
r = await runCli(["search", "--key", VALID_KEY, "--url", endpoint, "pdf"]);
|
|
573
|
+
assert.equal(r.status, 0);
|
|
574
|
+
assert.match(r.stdout, /pdf-helper/);
|
|
575
|
+
});
|
|
576
|
+
});
|