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,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
+ });