skillrepo 4.8.0 → 4.8.2

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 (43) hide show
  1. package/bin/skillrepo.mjs +6 -3
  2. package/package.json +1 -1
  3. package/src/commands/add.mjs +2 -1
  4. package/src/commands/init.mjs +15 -5
  5. package/src/lib/http.mjs +25 -11
  6. package/src/test/commands/add.test.mjs +79 -2
  7. package/src/test/commands/get.test.mjs +131 -2
  8. package/src/test/commands/init-session-sync.test.mjs +724 -0
  9. package/src/test/commands/init.test.mjs +159 -2
  10. package/src/test/commands/list.test.mjs +573 -1
  11. package/src/test/commands/publish.test.mjs +136 -3
  12. package/src/test/commands/push.test.mjs +280 -1
  13. package/src/test/commands/remove.test.mjs +221 -2
  14. package/src/test/commands/search.test.mjs +203 -1
  15. package/src/test/commands/session-sync.test.mjs +227 -1
  16. package/src/test/commands/uninstall.test.mjs +216 -0
  17. package/src/test/commands/update.test.mjs +218 -0
  18. package/src/test/dispatcher.test.mjs +103 -2
  19. package/src/test/e2e/advertised-surface.test.mjs +207 -0
  20. package/src/test/e2e/cli-commands.test.mjs +1 -1
  21. package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
  22. package/src/test/e2e/update-check-suppression.test.mjs +135 -0
  23. package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
  24. package/src/test/lib/browser-open.test.mjs +43 -0
  25. package/src/test/lib/config.test.mjs +87 -0
  26. package/src/test/lib/crypto-shas.test.mjs +17 -0
  27. package/src/test/lib/file-write.test.mjs +244 -0
  28. package/src/test/lib/fs-utils.test.mjs +259 -0
  29. package/src/test/lib/global-install.test.mjs +134 -0
  30. package/src/test/lib/http-timeout.test.mjs +114 -0
  31. package/src/test/lib/http.test.mjs +616 -1
  32. package/src/test/lib/mcp-merge.test.mjs +157 -0
  33. package/src/test/lib/npm-update-check.test.mjs +180 -0
  34. package/src/test/lib/placement-walk.test.mjs +132 -0
  35. package/src/test/lib/skill-walk.test.mjs +39 -1
  36. package/src/test/lib/sync.test.mjs +139 -5
  37. package/src/test/lib/telemetry.test.mjs +34 -0
  38. package/src/test/mergers/claude-mcp.test.mjs +30 -0
  39. package/src/test/mergers/cursor-mcp.test.mjs +115 -0
  40. package/src/test/mergers/env-local.test.mjs +126 -0
  41. package/src/test/mergers/vscode-mcp.test.mjs +177 -0
  42. package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
  43. package/src/test/resolve-key.test.mjs +33 -0
@@ -4,13 +4,27 @@
4
4
 
5
5
  import { describe, it, beforeEach, afterEach } from "node:test";
6
6
  import assert from "node:assert/strict";
7
- import { mkdtempSync, mkdirSync, rmSync, existsSync, writeFileSync } from "node:fs";
7
+ import {
8
+ mkdtempSync,
9
+ mkdirSync,
10
+ rmSync,
11
+ existsSync,
12
+ writeFileSync,
13
+ chmodSync,
14
+ } from "node:fs";
8
15
  import { join } from "node:path";
9
16
  import { tmpdir } from "node:os";
10
17
 
11
18
  import { runRemove } from "../../commands/remove.mjs";
12
19
  import { writeSkillDir, resolvePlacementDir } from "../../lib/file-write.mjs";
13
- import { CliError, EXIT_VALIDATION, EXIT_AUTH, EXIT_SCOPE } from "../../lib/errors.mjs";
20
+ import { writeLastSync, readLastSync } from "../../lib/sync.mjs";
21
+ import {
22
+ CliError,
23
+ EXIT_VALIDATION,
24
+ EXIT_AUTH,
25
+ EXIT_SCOPE,
26
+ EXIT_NETWORK,
27
+ } from "../../lib/errors.mjs";
14
28
  import { createMockServer } from "../e2e/mock-server.mjs";
15
29
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
16
30
  import {
@@ -241,3 +255,208 @@ describe("runRemove — error paths", () => {
241
255
  );
242
256
  });
243
257
  });
258
+
259
+ // ── Coverage gap closure: --agent vectors + transport errors ───────────
260
+ //
261
+ // `remove` shares `requireVendorTargets` with add/get and the same
262
+ // http.mjs error mapping. These lock the vectors that were untested at
263
+ // the command layer: the `--agent none` no-op rejection (exit 5), the
264
+ // vendor-scoped local delete (only the cursor `.agents/skills/` placement
265
+ // is removed, the claude placement survives), and the network-failure
266
+ // exit 1. `removeSkillFromLibrary` is a single-shot (no retry) DELETE, so
267
+ // a connection refusal surfaces immediately as networkError.
268
+ describe("runRemove — --agent flag + transport error paths", () => {
269
+ beforeEach(setup);
270
+ afterEach(teardown);
271
+
272
+ it("rejects --agent none with a validation error (no targets to delete from)", async () => {
273
+ await assert.rejects(
274
+ () =>
275
+ runRemove(
276
+ ["--key", VALID_KEY, "--url", serverUrl, "--agent", "none", "@alice/pdf-helper"],
277
+ { stdout },
278
+ ),
279
+ (err) =>
280
+ err instanceof CliError &&
281
+ err.exitCode === EXIT_VALIDATION &&
282
+ /--agent none has no effect on `skillrepo remove`/.test(err.message),
283
+ );
284
+ });
285
+
286
+ it("--agent cursor performs a vendor-scoped delete: only the .agents/skills/ placement is removed", async () => {
287
+ // Seed the SAME skill into BOTH the cursor (.agents/skills/) and the
288
+ // claude (.claude/skills/) placements.
289
+ writeSkillDir(makeSkill("alice", "pdf-helper"), { vendors: ["cursor"] });
290
+ writeSkillDir(makeSkill("alice", "pdf-helper"), { vendors: ["claudeCode"] });
291
+ const agentsDir = resolvePlacementDir("agentsProject", "pdf-helper");
292
+ const claudeDir = resolvePlacementDir("claudeProject", "pdf-helper");
293
+ assert.ok(existsSync(agentsDir), "precondition: cursor placement exists");
294
+ assert.ok(existsSync(claudeDir), "precondition: claude placement exists");
295
+
296
+ // Default server response is 200 removed. --agent cursor scopes the
297
+ // local delete to the cohort target only.
298
+ await runRemove(
299
+ ["--key", VALID_KEY, "--url", serverUrl, "--agent", "cursor", "@alice/pdf-helper"],
300
+ { stdout },
301
+ );
302
+
303
+ // The cursor placement is gone …
304
+ assert.ok(!existsSync(agentsDir), "--agent cursor must delete the .agents/skills/ placement");
305
+ // … but the claude placement is untouched (proves the scoping; a
306
+ // regression that deleted every placement would fail here).
307
+ assert.ok(
308
+ existsSync(claudeDir),
309
+ "--agent cursor must NOT delete the claude placement",
310
+ );
311
+ });
312
+
313
+ it("network failure exits 1 (network)", async () => {
314
+ // Connection-refused on the DELETE call → safeFetch wraps it as
315
+ // networkError (exit 1). removeSkillFromLibrary does not retry.
316
+ await assert.rejects(
317
+ () =>
318
+ runRemove(
319
+ ["--key", VALID_KEY, "--url", "http://127.0.0.1:1", "@alice/pdf-helper"],
320
+ { stdout },
321
+ ),
322
+ (err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
323
+ );
324
+ });
325
+
326
+ it("removed + 2 local placements → pluralized 'directories' summary", async () => {
327
+ // Branch coverage: every prior test deletes exactly ONE directory,
328
+ // so the plural side of `director{y|ies}` on the
329
+ // `wasRemovedFromLibrary && localCount > 0` summary (remove.mjs
330
+ // line ~160) was never exercised. Seed the SAME skill into two
331
+ // distinct placement dirs (claude → .claude/skills/, cursor →
332
+ // .agents/skills/) and remove with both targets so localCount === 2.
333
+ writeSkillDir(makeSkill("alice", "pdf-helper"), { vendors: ["claudeCode"] });
334
+ writeSkillDir(makeSkill("alice", "pdf-helper"), { vendors: ["cursor"] });
335
+ const claudeDir = resolvePlacementDir("claudeProject", "pdf-helper");
336
+ const agentsDir = resolvePlacementDir("agentsProject", "pdf-helper");
337
+ assert.ok(existsSync(claudeDir) && existsSync(agentsDir), "precondition: both placements exist");
338
+
339
+ // Default server response is 200 removed.
340
+ await runRemove(
341
+ ["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude,cursor", "@alice/pdf-helper"],
342
+ { stdout },
343
+ );
344
+
345
+ assert.ok(!existsSync(claudeDir) && !existsSync(agentsDir), "both placements deleted");
346
+ assert.match(
347
+ stdout.text(),
348
+ /deleted 2 local directories/,
349
+ "two removed dirs must pluralize as 'directories'",
350
+ );
351
+ });
352
+
353
+ // NOTE: the `io.stdout ?? process.stdout` default (remove.mjs line 59) is
354
+ // intentionally NOT unit-tested. The only way to exercise it is to stub
355
+ // `process.stdout.write` across an `await`, which collides with node:test's
356
+ // TAP-over-stdout IPC and DEADLOCKS the runner (a Windows-only hang that
357
+ // burned a 10-minute CI job). See capture-stream.mjs's docstring. The
358
+ // default is a one-token defensive fallback; it isn't worth that pattern.
359
+
360
+ it("not-in-library + 2 local placements → pluralized 'directories' summary", async () => {
361
+ // Plural side of the OTHER summary branch (remove.mjs line ~176):
362
+ // the 404 not-in-library path with more than one local placement.
363
+ writeSkillDir(makeSkill("alice", "orphan"), { vendors: ["claudeCode"] });
364
+ writeSkillDir(makeSkill("alice", "orphan"), { vendors: ["cursor"] });
365
+
366
+ server.setRemoveResponse("alice", "orphan", {
367
+ status: 404,
368
+ body: { code: "not_in_library" },
369
+ });
370
+
371
+ await runRemove(
372
+ ["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude,cursor", "@alice/orphan"],
373
+ { stdout },
374
+ );
375
+
376
+ assert.ok(!existsSync(resolvePlacementDir("claudeProject", "orphan")));
377
+ assert.ok(!existsSync(resolvePlacementDir("agentsProject", "orphan")));
378
+ assert.match(
379
+ stdout.text(),
380
+ /wasn't in your library — deleted 2 local directories/,
381
+ "two removed dirs on the not-in-library path must pluralize as 'directories'",
382
+ );
383
+ });
384
+ });
385
+
386
+ // ── Coverage gap closure: the deleteLastSyncEntry catch swallow ─────────
387
+ //
388
+ // remove.mjs step 2.5 wraps `deleteLastSyncEntry` in try/catch and
389
+ // swallows any throw (lines 121-125): a stale `.last-sync` baseline is
390
+ // recoverable via the next `update`, so a write failure there must NOT
391
+ // fail the command after the user's primary intent (skill gone from
392
+ // library + disk) was already honored.
393
+ //
394
+ // The catch runs ONLY when `deleteLastSyncEntry` throws, which requires
395
+ // readLastSync to SUCCEED (a valid `.last-sync` WITH this skill's entry)
396
+ // AND writeLastSync to FAIL. We force the write failure by making the
397
+ // `~/.claude/skillrepo/` directory read+execute only (0o555): the read
398
+ // succeeds (the file is already there) but the atomic write's temp-file
399
+ // creation inside that dir fails with EACCES.
400
+ describe("runRemove — deleteLastSyncEntry write failure is swallowed", () => {
401
+ beforeEach(setup);
402
+ afterEach(teardown);
403
+
404
+ it("does not fail the command when purging the .last-sync entry throws (POSIX)", async (t) => {
405
+ if (process.platform === "win32") {
406
+ // chmod 0o555 does not block writes on Windows (the mode bits
407
+ // don't map to the ACL layer — see fs-utils.mjs writeFileAtomic),
408
+ // so this fault-injection technique can't reproduce the EACCES.
409
+ // Skipped rather than silently passing for the wrong reason.
410
+ t.skip("chmod does not gate writes on win32");
411
+ return;
412
+ }
413
+
414
+ // Seed a valid `.last-sync` that contains this skill's entry so
415
+ // readLastSync succeeds and the key IS present (both preconditions
416
+ // for deleteLastSyncEntry to reach its writeLastSync call).
417
+ writeLastSync({
418
+ etag: "etag-1",
419
+ syncedAt: new Date().toISOString(),
420
+ skills: {
421
+ "alice/pdf-helper": { version: "1.0.0", shas: { "SKILL.md": "x" } },
422
+ },
423
+ });
424
+ assert.ok(
425
+ readLastSync().skills["alice/pdf-helper"],
426
+ "precondition: .last-sync must carry the skill entry",
427
+ );
428
+
429
+ // Pre-write the local skill so the happy-path delete still reports
430
+ // success (proving the command completed despite the swallowed throw).
431
+ writeSkillDir(makeSkill("alice", "pdf-helper"), { vendors: ["claudeCode"] });
432
+ const dir = resolvePlacementDir("claudeProject", "pdf-helper");
433
+ assert.ok(existsSync(dir));
434
+
435
+ // Make the `.last-sync` parent dir read+execute only so the atomic
436
+ // temp-file write fails with EACCES → writeLastSync throws →
437
+ // deleteLastSyncEntry throws → remove.mjs's catch swallows it.
438
+ const skillrepoDir = join(process.env.HOME, ".claude", "skillrepo");
439
+ chmodSync(skillrepoDir, 0o555);
440
+ try {
441
+ // Default server response is 200 removed. Must resolve (not reject)
442
+ // — the swallowed throw must not surface to the caller.
443
+ await assert.doesNotReject(
444
+ runRemove(
445
+ ["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper"],
446
+ { stdout },
447
+ ),
448
+ );
449
+ } finally {
450
+ // Restore write perms so teardown's rmSync can clean the sandbox.
451
+ chmodSync(skillrepoDir, 0o755);
452
+ }
453
+
454
+ // The command still reported success and deleted the local files.
455
+ assert.ok(!existsSync(dir), "local files must still be deleted");
456
+ assert.match(
457
+ stdout.text(),
458
+ /Removed @alice\/pdf-helper from your library/,
459
+ "the command must report success despite the swallowed .last-sync write failure",
460
+ );
461
+ });
462
+ });
@@ -9,7 +9,7 @@ import { join } from "node:path";
9
9
  import { tmpdir } from "node:os";
10
10
 
11
11
  import { runSearch } from "../../commands/search.mjs";
12
- import { CliError, EXIT_VALIDATION, EXIT_AUTH } from "../../lib/errors.mjs";
12
+ import { CliError, EXIT_VALIDATION, EXIT_AUTH, EXIT_NETWORK } from "../../lib/errors.mjs";
13
13
  import { createMockServer } from "../e2e/mock-server.mjs";
14
14
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
15
15
  import {
@@ -160,6 +160,194 @@ describe("runSearch — happy path", () => {
160
160
  await runSearch(["--key", VALID_KEY, "--url", serverUrl, "--limit", "1", "test"], { stdout });
161
161
  assert.match(stdout.text(), /Showing 1 of 100/);
162
162
  });
163
+
164
+ it("--json --semantic sets semantic:true / semanticSupported:false and writes NO note to stderr", async () => {
165
+ // In JSON mode runSearch writes the structured object and returns
166
+ // BEFORE the `if (semantic)` stderr-note branch (search.mjs ~83-111).
167
+ // So a script consuming --json sees the no-op surfaced in the JSON
168
+ // fields, and stderr stays clean (no human-prose note interleaved
169
+ // with machine output). Assert BOTH halves of the contract.
170
+ server.setSearchResponse({
171
+ skills: [makeResult("alice", "pdf-helper")],
172
+ pagination: { total: 1, limit: 20, offset: 0 },
173
+ });
174
+ await runSearch(
175
+ ["--key", VALID_KEY, "--url", serverUrl, "--json", "--semantic", "pdf"],
176
+ { stdout, stderr },
177
+ );
178
+ const json = JSON.parse(stdout.text());
179
+ assert.equal(json.semantic, true);
180
+ assert.equal(json.semanticSupported, false);
181
+ // The non-JSON branch is the ONLY place the note is emitted, so
182
+ // JSON mode must leave stderr empty of it.
183
+ assert.doesNotMatch(stderr.text(), /reserved for v1\.1/);
184
+ assert.equal(stderr.text(), "");
185
+ });
186
+
187
+ it("missing pagination → footer total falls back to the number of skills shown", async () => {
188
+ // Regression guard for the #1923 search-undercount fix. The production
189
+ // server route normally emits `pagination: {total,...}`
190
+ // (src/app/api/v1/skills/search/route.ts), but a pagination-less
191
+ // response used to make `search` print "0 results" beneath a visible
192
+ // row: searchSkills (http.mjs) defaulted absent pagination to
193
+ // `{ total: 0, ... }`, fabricating a zero count. http.mjs now defaults
194
+ // `total` to `skills.length` (http.mjs ~1129), so a pagination-less
195
+ // response renders the honest count. This asserts the corrected
196
+ // behavior (1 row ⇒ "1 result", never "0 results"); it goes RED again
197
+ // only if that normalization regresses.
198
+ server.setSearchResponse({
199
+ skills: [makeResult("alice", "only-one", 42)],
200
+ pagination: null,
201
+ });
202
+ await runSearch(["--key", VALID_KEY, "--url", serverUrl, "solo"], { stdout });
203
+ const out = stdout.text();
204
+ // Must not crash and must render the row.
205
+ assert.match(out, /only-one/);
206
+ // One skill shown → exactly one result reported.
207
+ assert.match(out, /\b1 result\b/);
208
+ // And it must NOT under-report as zero.
209
+ assert.doesNotMatch(out, /\b0 results\b/);
210
+ });
211
+
212
+ it("renders an em-dash for a result with a non-number install count", async () => {
213
+ // formatInstallCount (search.mjs ~178) returns "—" for anything
214
+ // that isn't a finite number. A result whose `installs` is null
215
+ // must render the dash in that column, not "null"/"0"/NaN.
216
+ const result = makeResult("alice", "no-installs", 0);
217
+ result.installs = null;
218
+ server.setSearchResponse({
219
+ skills: [result],
220
+ pagination: { total: 1, limit: 20, offset: 0 },
221
+ });
222
+ await runSearch(["--key", VALID_KEY, "--url", serverUrl, "test"], { stdout });
223
+ const out = stdout.text();
224
+ assert.match(out, /no-installs/);
225
+ assert.match(out, /—/);
226
+ // Sanity: the raw null must not have leaked into the table.
227
+ assert.doesNotMatch(out, /\bnull\b/);
228
+ });
229
+
230
+ it("truncates a description longer than the 60-char column budget", async () => {
231
+ // truncate (search.mjs ~185) returns the input unchanged when it
232
+ // fits, but slices to n-1 chars + "…" when it exceeds the budget.
233
+ // The existing happy-path results all use the short
234
+ // `${name} description` string (< 60 chars), so only the no-op arm
235
+ // ran. A >60-char description exercises the truncating arm.
236
+ const result = makeResult("alice", "verbose", 10);
237
+ result.description = "x".repeat(200);
238
+ server.setSearchResponse({
239
+ skills: [result],
240
+ pagination: { total: 1, limit: 20, offset: 0 },
241
+ });
242
+ await runSearch(["--key", VALID_KEY, "--url", serverUrl, "test"], { stdout });
243
+ const out = stdout.text();
244
+ assert.match(out, /verbose/);
245
+ // The ellipsis marker proves the truncating branch ran.
246
+ assert.match(out, /…/);
247
+ });
248
+
249
+ it("renders the em-dash version + empty description for a result missing both", async () => {
250
+ // printTable (search.mjs ~135/137) falls back to "—" when
251
+ // `s.version` is empty and to "" when `s.description` is empty.
252
+ // The makeResult helper always supplies both, so the false sides
253
+ // of those `||` defaults never ran. A result with empty version
254
+ // and description exercises them.
255
+ const result = makeResult("alice", "bare", 5);
256
+ result.version = "";
257
+ result.description = "";
258
+ server.setSearchResponse({
259
+ skills: [result],
260
+ pagination: { total: 1, limit: 20, offset: 0 },
261
+ });
262
+ await runSearch(["--key", VALID_KEY, "--url", serverUrl, "test"], { stdout });
263
+ const out = stdout.text();
264
+ assert.match(out, /bare/);
265
+ // Version column shows the em-dash placeholder.
266
+ assert.match(out, /—/);
267
+ });
268
+ });
269
+
270
+ // ── streamColumns coverage ──────────────────────────────────────────────
271
+ //
272
+ // streamColumns (search.mjs ~168) mirrors list.mjs: it reads the injected
273
+ // stream's numeric `.columns` first, then falls back to
274
+ // process.stdout.columns, then to a 100-col default. The happy-path tests
275
+ // use the plain capture stream (no `.columns`) with process.stdout.columns
276
+ // unset in the test runner, so only the final `return 100` default ran.
277
+ // These two tests cover the first two arms.
278
+
279
+ /** A capture stream that carries a fixed numeric column width. */
280
+ function createWidthCaptureStream(columns = 120) {
281
+ const stream = createCaptureStream();
282
+ stream.columns = columns;
283
+ return stream;
284
+ }
285
+
286
+ describe("runSearch — streamColumns", () => {
287
+ beforeEach(setup);
288
+ afterEach(teardown);
289
+
290
+ it("uses the injected stream's .columns when present", async () => {
291
+ const wideStdout = createWidthCaptureStream(120);
292
+ server.setSearchResponse({
293
+ skills: [makeResult("alice", "pdf-helper", 500)],
294
+ pagination: { total: 1, limit: 20, offset: 0 },
295
+ });
296
+ await runSearch(["--key", VALID_KEY, "--url", serverUrl, "pdf"], {
297
+ stdout: wideStdout,
298
+ });
299
+ // First streamColumns arm (out.columns numeric > 0) ran; table still
300
+ // renders correctly.
301
+ assert.match(wideStdout.text(), /pdf-helper/);
302
+ });
303
+
304
+ it("falls back to the 100-col default for a narrow (<=60) terminal width", async () => {
305
+ // computeColWidths (search.mjs ~155) uses `terminalColumns > 60 ?
306
+ // Math.min(..., 120) : 100`. A stream reporting a width <= 60
307
+ // exercises the `: 100` arm — the narrow-terminal floor — which the
308
+ // wide-stream and default-path tests don't reach.
309
+ const narrowStdout = createWidthCaptureStream(40);
310
+ server.setSearchResponse({
311
+ skills: [makeResult("alice", "pdf-helper", 500)],
312
+ pagination: { total: 1, limit: 20, offset: 0 },
313
+ });
314
+ await runSearch(["--key", VALID_KEY, "--url", serverUrl, "pdf"], {
315
+ stdout: narrowStdout,
316
+ });
317
+ assert.match(narrowStdout.text(), /pdf-helper/);
318
+ });
319
+ });
320
+
321
+ describe("runSearch — streamColumns process.stdout fallback", () => {
322
+ let stdoutColumnsSnapshot;
323
+ let hadOwnColumns;
324
+
325
+ beforeEach(async () => {
326
+ await setup();
327
+ // The injected capture stream has no `.columns`, so streamColumns
328
+ // falls through to process.stdout.columns. Force a numeric value
329
+ // there for the duration of this test to cover that second arm.
330
+ hadOwnColumns = Object.prototype.hasOwnProperty.call(process.stdout, "columns");
331
+ stdoutColumnsSnapshot = process.stdout.columns;
332
+ process.stdout.columns = 90;
333
+ });
334
+ afterEach(async () => {
335
+ if (hadOwnColumns) {
336
+ process.stdout.columns = stdoutColumnsSnapshot;
337
+ } else {
338
+ delete process.stdout.columns;
339
+ }
340
+ await teardown();
341
+ });
342
+
343
+ it("uses process.stdout.columns when the injected stream has none", async () => {
344
+ server.setSearchResponse({
345
+ skills: [makeResult("alice", "pdf-helper", 500)],
346
+ pagination: { total: 1, limit: 20, offset: 0 },
347
+ });
348
+ await runSearch(["--key", VALID_KEY, "--url", serverUrl, "pdf"], { stdout });
349
+ assert.match(stdout.text(), /pdf-helper/);
350
+ });
163
351
  });
164
352
 
165
353
  describe("runSearch — error paths", () => {
@@ -207,4 +395,18 @@ describe("runSearch — error paths", () => {
207
395
  (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
208
396
  );
209
397
  });
398
+
399
+ it("rejects with a network CliError when GET /skills/search returns 500", async () => {
400
+ // search goes straight to searchSkills — no prior validate call —
401
+ // so the one-shot forced status fires on the search GET itself.
402
+ // searchSkills uses retry:true, but 500 is NOT transient
403
+ // (TRANSIENT_STATUS_CODES = 429/502/503/504), so withRetry
404
+ // returns it immediately and mapErrorResponse maps `status >= 500`
405
+ // to networkError → EXIT_NETWORK (exit 1). Real mapped value.
406
+ server.setForcedStatus(500, { error: "boom" });
407
+ await assert.rejects(
408
+ () => runSearch(["--key", VALID_KEY, "--url", serverUrl, "test"], { stdout, stderr }),
409
+ (err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
410
+ );
411
+ });
210
412
  });