skillrepo 4.8.0 → 4.8.1

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 (41) hide show
  1. package/bin/skillrepo.mjs +6 -3
  2. package/package.json +1 -1
  3. package/src/commands/init.mjs +15 -5
  4. package/src/lib/http.mjs +12 -2
  5. package/src/test/commands/add.test.mjs +78 -1
  6. package/src/test/commands/get.test.mjs +131 -2
  7. package/src/test/commands/init-session-sync.test.mjs +724 -0
  8. package/src/test/commands/init.test.mjs +159 -2
  9. package/src/test/commands/list.test.mjs +573 -1
  10. package/src/test/commands/publish.test.mjs +133 -0
  11. package/src/test/commands/push.test.mjs +280 -1
  12. package/src/test/commands/remove.test.mjs +221 -2
  13. package/src/test/commands/search.test.mjs +203 -1
  14. package/src/test/commands/session-sync.test.mjs +227 -1
  15. package/src/test/commands/uninstall.test.mjs +216 -0
  16. package/src/test/commands/update.test.mjs +218 -0
  17. package/src/test/dispatcher.test.mjs +103 -2
  18. package/src/test/e2e/advertised-surface.test.mjs +207 -0
  19. package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
  20. package/src/test/e2e/update-check-suppression.test.mjs +135 -0
  21. package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
  22. package/src/test/lib/browser-open.test.mjs +43 -0
  23. package/src/test/lib/config.test.mjs +87 -0
  24. package/src/test/lib/crypto-shas.test.mjs +17 -0
  25. package/src/test/lib/file-write.test.mjs +244 -0
  26. package/src/test/lib/fs-utils.test.mjs +259 -0
  27. package/src/test/lib/global-install.test.mjs +134 -0
  28. package/src/test/lib/http-timeout.test.mjs +114 -0
  29. package/src/test/lib/http.test.mjs +615 -0
  30. package/src/test/lib/mcp-merge.test.mjs +157 -0
  31. package/src/test/lib/npm-update-check.test.mjs +180 -0
  32. package/src/test/lib/placement-walk.test.mjs +132 -0
  33. package/src/test/lib/skill-walk.test.mjs +39 -1
  34. package/src/test/lib/sync.test.mjs +139 -5
  35. package/src/test/lib/telemetry.test.mjs +34 -0
  36. package/src/test/mergers/claude-mcp.test.mjs +30 -0
  37. package/src/test/mergers/cursor-mcp.test.mjs +115 -0
  38. package/src/test/mergers/env-local.test.mjs +126 -0
  39. package/src/test/mergers/vscode-mcp.test.mjs +177 -0
  40. package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
  41. package/src/test/resolve-key.test.mjs +33 -0
package/bin/skillrepo.mjs CHANGED
@@ -70,9 +70,12 @@ const COMMANDS = {
70
70
  },
71
71
  push: {
72
72
  description: "Push a local skill directory to your library (create or release new version)",
73
- usage:
74
- "skillrepo push <path> [--version <label>] [--changelog <text>] " +
75
- "[--idempotency-key <key>] [--json]",
73
+ // Versioning is server-side (frontmatter-driven smart upsert), so the CLI
74
+ // does NOT accept --version/--changelog they were removed in the #1455
75
+ // push rework. Do not re-add them here without wiring them into push.mjs:
76
+ // the advertised-surface contract test asserts every flag listed in this
77
+ // string is actually accepted by the command (#1923).
78
+ usage: "skillrepo push <path> [--idempotency-key <key>] [--json]",
76
79
  run: async (argv) => runPush(argv),
77
80
  },
78
81
  publish: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.8.0",
3
+ "version": "4.8.1",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -199,13 +199,23 @@ const BLACK_HOLE_STREAM = {
199
199
  * @param {NodeJS.WritableStream} [io.stdout=process.stdout]
200
200
  * @param {NodeJS.WritableStream} [io.stderr=process.stderr]
201
201
  * @param {object} [deps] - Test-only dependency injection. Production
202
- * callers leave this empty. Forwarded to step 6
203
- * (`installSessionSyncHook`); see `init-session-sync.mjs` for
204
- * the supported keys (`spawn`, `getCliVersion`, `confirmFn`).
202
+ * callers leave this empty. `promptFn` (below) is consumed locally;
203
+ * the remaining keys are forwarded to step 6
204
+ * (`installSessionSyncHook`) see `init-session-sync.mjs` for
205
+ * those (`spawn`, `getCliVersion`, `confirmFn`).
206
+ * @param {Function} [deps.promptFn] - Override for the interactive
207
+ * access-key prompt (`promptWithBrowserOpen`). Lets tests drive
208
+ * the step-1 prompt and the step-2 stale-key re-prompt recovery
209
+ * without a real TTY/stdin. Default: the real
210
+ * `promptWithBrowserOpen`. Same test-seam pattern as `confirmFn`.
205
211
  */
206
212
  export async function runInit(argv, io = {}, deps = {}) {
207
213
  const stdout = io.stdout ?? process.stdout;
208
214
  const stderr = io.stderr ?? process.stderr;
215
+ // Test-seam for the interactive credential prompt. Production uses the
216
+ // real browser-assisted prompt; tests inject a stub to exercise the
217
+ // step-1 and stale-key re-prompt paths deterministically.
218
+ const promptForKey = deps.promptFn ?? promptWithBrowserOpen;
209
219
 
210
220
  const { flags, yes, force, noSessionSync } = parseInitFlags(argv);
211
221
 
@@ -270,7 +280,7 @@ export async function runInit(argv, io = {}, deps = {}) {
270
280
  hint: "Pass --key sk_live_... or set SKILLREPO_ACCESS_KEY.",
271
281
  });
272
282
  }
273
- apiKey = await promptWithBrowserOpen(
283
+ apiKey = await promptForKey(
274
284
  cliAuthUrl(serverUrl),
275
285
  "Enter your access key (sk_live_...)",
276
286
  );
@@ -343,7 +353,7 @@ export async function runInit(argv, io = {}, deps = {}) {
343
353
  ) {
344
354
  p.warning("Existing config has an invalid key. Re-prompting for a new one.");
345
355
  apiKey = (
346
- await promptWithBrowserOpen(
356
+ await promptForKey(
347
357
  cliAuthUrl(serverUrl),
348
358
  "Enter your access key (sk_live_...)",
349
359
  )
package/src/lib/http.mjs CHANGED
@@ -1118,9 +1118,19 @@ export async function searchSkills(serverUrl, apiKey, opts = {}) {
1118
1118
  }
1119
1119
 
1120
1120
  const body = await parseJsonOrThrow(res, url);
1121
+ const skills = body.skills ?? [];
1121
1122
  return {
1122
- skills: body.skills ?? [],
1123
- pagination: body.pagination ?? { total: 0, limit: opts.limit ?? 20, offset: opts.offset ?? 0 },
1123
+ skills,
1124
+ // When the server omits pagination, default `total` to the number of
1125
+ // skills actually returned — never 0 — so the displayed count can't
1126
+ // contradict the visible rows (a fabricated total:0 made `search`
1127
+ // print "0 results" beneath a result, and `--json` emit a total of 0
1128
+ // alongside a non-empty `results`). #1923.
1129
+ pagination: body.pagination ?? {
1130
+ total: skills.length,
1131
+ limit: opts.limit ?? 20,
1132
+ offset: opts.offset ?? 0,
1133
+ },
1124
1134
  };
1125
1135
  }
1126
1136
 
@@ -4,12 +4,13 @@
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 } from "node:fs";
7
+ import { mkdtempSync, mkdirSync, rmSync, existsSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { tmpdir } from "node:os";
10
10
 
11
11
  import { runAdd } from "../../commands/add.mjs";
12
12
  import { resolvePlacementDir } from "../../lib/file-write.mjs";
13
+ import { writeLastSync } from "../../lib/sync.mjs";
13
14
  import { CliError, EXIT_VALIDATION, EXIT_AUTH, EXIT_SCOPE } from "../../lib/errors.mjs";
14
15
  import { createMockServer } from "../e2e/mock-server.mjs";
15
16
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
@@ -304,3 +305,79 @@ describe("runAdd — error paths", () => {
304
305
  );
305
306
  });
306
307
  });
308
+
309
+ // ── #1911 bypass: add must NOT delta-filter ───────────────────────────
310
+ //
311
+ // add.mjs deliberately uses a single-skill GET (not runSync) precisely
312
+ // so a skill whose content predates the user's last sync still lands —
313
+ // the original #1911 trigger was an established user adding an OLDER
314
+ // catalog skill and relying on delta sync, which silently dropped it.
315
+ // add's whole protective property is "I never apply `since` filtering."
316
+ // This was untested at the command layer; lock it.
317
+ describe("runAdd — #1911 bypass: a skill older than last sync still lands", () => {
318
+ beforeEach(setup);
319
+ afterEach(teardown);
320
+
321
+ it("writes a skill whose updatedAt predates .last-sync.syncedAt, bypassing the delta endpoint", async () => {
322
+ // Seed a RECENT last sync — newer than the skill we're about to add.
323
+ // If `add` ever regressed to delta semantics, `updatedAt < since`
324
+ // would filter this skill out and it would never reach disk.
325
+ writeLastSync({
326
+ etag: '"lib-v9"',
327
+ syncedAt: "2099-01-01T00:00:00Z",
328
+ skills: {},
329
+ });
330
+ server.resetLibraryInspection();
331
+
332
+ // The catalog skill is OLD content (updatedAt years before `since`).
333
+ server.setSkillResponse("alice", "old-skill", {
334
+ ...makeSkill("alice", "old-skill"),
335
+ updatedAt: "2020-01-01T00:00:00Z",
336
+ });
337
+
338
+ await runAdd(["--key", VALID_KEY, "--url", serverUrl, "@alice/old-skill"], { stdout });
339
+
340
+ assert.ok(
341
+ existsSync(join(resolvePlacementDir("claudeProject", "old-skill"), "SKILL.md")),
342
+ "an older-than-last-sync skill MUST land on disk via `add`",
343
+ );
344
+ // The protective invariant: add never touched the delta-filtered
345
+ // library endpoint, so `since` could not have dropped the skill.
346
+ assert.equal(
347
+ server.getLibraryRequestCount(),
348
+ 0,
349
+ "add must bypass GET /library entirely (no `since` filter can apply)",
350
+ );
351
+ assert.match(stdout.text(), /Added @alice\/old-skill/);
352
+ });
353
+ });
354
+
355
+ // ── Best-effort .last-sync write (#1574 contract) ─────────────────────
356
+ describe("runAdd — best-effort .last-sync write failure does not fail the command", () => {
357
+ beforeEach(setup);
358
+ afterEach(teardown);
359
+
360
+ it("adds the skill and exits cleanly even when the .last-sync state write throws", async () => {
361
+ // Same mechanism as get: occupy the global skillrepo state-dir path
362
+ // with a file so the upsertLastSyncEntry → writeLastSync write throws
363
+ // a diskError. add's whole job (POST + fetch + write the skill to the
364
+ // project placement) must still succeed; the state write is
365
+ // best-effort and the throw must be swallowed.
366
+ mkdirSync(join(process.env.HOME, ".claude"), { recursive: true });
367
+ writeFileSync(join(process.env.HOME, ".claude", "skillrepo"), "not-a-dir");
368
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
369
+
370
+ await assert.doesNotReject(
371
+ () =>
372
+ runAdd(
373
+ ["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper", "--agent", "claude"],
374
+ { stdout },
375
+ ),
376
+ "a .last-sync write failure must NOT fail `add` after the skill files landed",
377
+ );
378
+ assert.ok(
379
+ existsSync(join(resolvePlacementDir("claudeProject", "pdf-helper"), "SKILL.md")),
380
+ "the skill files must still be on disk despite the state-write failure",
381
+ );
382
+ });
383
+ });
@@ -4,13 +4,19 @@
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 } from "node:fs";
7
+ import { mkdtempSync, mkdirSync, rmSync, existsSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { tmpdir } from "node:os";
10
10
 
11
11
  import { runGet } from "../../commands/get.mjs";
12
12
  import { resolvePlacementDir } from "../../lib/file-write.mjs";
13
- import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
13
+ import {
14
+ CliError,
15
+ EXIT_VALIDATION,
16
+ EXIT_AUTH,
17
+ EXIT_SCOPE,
18
+ EXIT_NETWORK,
19
+ } from "../../lib/errors.mjs";
14
20
  import { createMockServer } from "../e2e/mock-server.mjs";
15
21
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
16
22
  import {
@@ -180,3 +186,126 @@ describe("runGet — error paths", () => {
180
186
  );
181
187
  });
182
188
  });
189
+
190
+ // ── Coverage gap closure: --agent vectors + transport errors ───────────
191
+ //
192
+ // These mirror the equivalent vectors already locked in add.test.mjs
193
+ // (`--agent none` rejection, `--agent cursor` placement, 401/403/network
194
+ // transport mapping). `get` and `add` share `requireVendorTargets` and
195
+ // the `http.mjs` error mapping, so the assertions here assert the SAME
196
+ // advertised contract: exit 5 for the no-op flag, exit 2/4 for auth/scope,
197
+ // exit 1 for a network failure, and the cohort `.agents/skills/` placement
198
+ // for `--agent cursor`.
199
+ describe("runGet — --agent flag + transport error paths", () => {
200
+ beforeEach(setup);
201
+ afterEach(teardown);
202
+
203
+ it("rejects --agent none with a validation error (no targets to write)", async () => {
204
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
205
+ await assert.rejects(
206
+ () =>
207
+ runGet(
208
+ ["--key", VALID_KEY, "--url", serverUrl, "--agent", "none", "@alice/pdf-helper"],
209
+ { stdout },
210
+ ),
211
+ (err) =>
212
+ err instanceof CliError &&
213
+ err.exitCode === EXIT_VALIDATION &&
214
+ /--agent none has no effect on `skillrepo get`/.test(err.message),
215
+ );
216
+ });
217
+
218
+ it("--agent cursor writes to the cohort .agents/skills/ placement, NOT the claude placement", async () => {
219
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
220
+ await runGet(
221
+ ["--key", VALID_KEY, "--url", serverUrl, "--agent", "cursor", "@alice/pdf-helper"],
222
+ { stdout },
223
+ );
224
+
225
+ // Landed in cursor's project target (.agents/skills/).
226
+ const agentsDir = resolvePlacementDir("agentsProject", "pdf-helper");
227
+ assert.ok(
228
+ existsSync(join(agentsDir, "SKILL.md")),
229
+ "--agent cursor must write the skill into .agents/skills/",
230
+ );
231
+
232
+ // Did NOT also write the Claude Code placement — the flag is scoped.
233
+ // This couples the assertion to the placement behavior: a regression
234
+ // that ignored --agent and defaulted to claudeProject would fail here.
235
+ const claudeDir = resolvePlacementDir("claudeProject", "pdf-helper");
236
+ assert.ok(
237
+ !existsSync(claudeDir),
238
+ "--agent cursor must NOT write the claude placement",
239
+ );
240
+ });
241
+
242
+ it("401 auth error exits 2", async () => {
243
+ // The single-skill GET route has no per-skill error slot, so force
244
+ // the next request (getSkill) to return 401 — the same outcome
245
+ // add.test.mjs reaches via setAddResponse(status: 401).
246
+ server.setForcedStatus(401, { error: "Invalid access key" });
247
+ await assert.rejects(
248
+ () => runGet(["--key", VALID_KEY, "--url", serverUrl, "@alice/auth-test"], { stdout }),
249
+ (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
250
+ );
251
+ });
252
+
253
+ it("403 scope-required maps to scopeError (exit 4)", async () => {
254
+ // Mirror add.test.mjs's 403-scope response shape (code: scope_required).
255
+ server.setForcedStatus(403, { error: "Insufficient scope", code: "scope_required" });
256
+ await assert.rejects(
257
+ () => runGet(["--key", VALID_KEY, "--url", serverUrl, "@alice/scope-test"], { stdout }),
258
+ (err) => err instanceof CliError && err.exitCode === EXIT_SCOPE,
259
+ );
260
+ });
261
+
262
+ it("network failure exits 1 (network)", async () => {
263
+ // Point at a port nothing is listening on — fetch rejects with a
264
+ // connection error that safeFetch wraps as networkError (exit 1).
265
+ await assert.rejects(
266
+ () =>
267
+ runGet(
268
+ ["--key", VALID_KEY, "--url", "http://127.0.0.1:1", "@alice/pdf-helper"],
269
+ { stdout },
270
+ ),
271
+ (err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
272
+ );
273
+ });
274
+ });
275
+
276
+ // ── Best-effort .last-sync write (#1574 contract) ─────────────────────
277
+ //
278
+ // get writes the skill bytes to disk, THEN records the skill in
279
+ // .last-sync so `list` can see it. That second write is best-effort: if
280
+ // it fails, the user already has the skill on disk, so the command must
281
+ // NOT turn a successful fetch into a failure. get.mjs wraps it in a
282
+ // try/catch; lock that the swallow actually happens.
283
+ describe("runGet — best-effort .last-sync write failure does not fail the command", () => {
284
+ beforeEach(setup);
285
+ afterEach(teardown);
286
+
287
+ it("writes the skill and exits cleanly even when the .last-sync state write throws", async () => {
288
+ // Occupy the global skillrepo state-dir PATH with a regular file, so
289
+ // writeLastSync (via upsertLastSyncEntry) can't create the directory
290
+ // and throws a diskError. Cross-platform: a file where a directory is
291
+ // expected fails identically on POSIX and Windows (no chmod needed).
292
+ // The skill itself lands in the PROJECT placement (a different path),
293
+ // so the command's real work succeeds.
294
+ mkdirSync(join(process.env.HOME, ".claude"), { recursive: true });
295
+ writeFileSync(join(process.env.HOME, ".claude", "skillrepo"), "not-a-dir");
296
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
297
+
298
+ await assert.doesNotReject(
299
+ () =>
300
+ runGet(
301
+ ["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper", "--agent", "claude"],
302
+ { stdout },
303
+ ),
304
+ "a .last-sync write failure must NOT fail `get` after the skill files landed",
305
+ );
306
+ assert.ok(
307
+ existsSync(join(resolvePlacementDir("claudeProject", "pdf-helper"), "SKILL.md")),
308
+ "the skill files must still be on disk despite the state-write failure",
309
+ );
310
+ });
311
+ });