skillrepo 4.7.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 (43) 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/lib/sync.mjs +124 -8
  6. package/src/test/commands/add.test.mjs +78 -1
  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 +133 -0
  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/mock-server.mjs +19 -11
  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 +615 -0
  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 +434 -0
  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
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.7.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
 
package/src/lib/sync.mjs CHANGED
@@ -31,6 +31,11 @@
31
31
  * version + content SHAs of every skill written in the last
32
32
  * successful sync. Unblocks per-skill drift detection in `list`
33
33
  * (#1555) and any future `status`-style command.
34
+ * Later (#1911) v2 also carries `cliVersion` — the version of the
35
+ * CLI that last wrote the state file. Additive, not a schema bump:
36
+ * absence is meaningful (a state file with no `cliVersion` was
37
+ * written by a pre-#1911 CLI and triggers the one-time membership
38
+ * heal). See `FULL_RESYNC_FLOOR` / `cliVersionBelowFloor`.
34
39
  *
35
40
  * Forward/backward compatibility
36
41
  * ------------------------------
@@ -88,13 +93,20 @@
88
93
  * @property {number} schemaVersion
89
94
  * @property {string|null} etag
90
95
  * @property {string} syncedAt
96
+ * @property {string} [cliVersion] - v2+ (#1911). Version of the CLI that last
97
+ * wrote this state. Absent on files written
98
+ * by a pre-#1911 CLI; absence triggers the
99
+ * one-time membership heal.
91
100
  * @property {Record<string, SyncedSkillEntry>} skills - v2+. Keyed by `"<owner>/<name>"`.
92
101
  */
93
102
 
94
103
  import { existsSync, readFileSync } from "node:fs";
95
104
  import { join } from "node:path";
96
105
 
106
+ import semver from "semver";
107
+
97
108
  import { getLibrary, postSyncReceipt } from "./http.mjs";
109
+ import { getCliVersion } from "./cli-version.mjs";
98
110
  import {
99
111
  writeSkillDir,
100
112
  removeSkillDir,
@@ -118,6 +130,66 @@ import { computeSkillShas } from "./crypto-shas.mjs";
118
130
  */
119
131
  export const LAST_SYNC_SCHEMA_VERSION = 2;
120
132
 
133
+ /**
134
+ * One-time heal floor (#1911).
135
+ *
136
+ * A `.last-sync` written by a CLI BELOW this version predates the
137
+ * server-side delta-membership fix. Such a state file can be permanently
138
+ * missing skills that were added to the library but never delivered: the
139
+ * old delta filter keyed only on `skills.updatedAt`, so a skill whose
140
+ * content predated the user's `since` was skipped even though the library
141
+ * ETag advanced (count++). The client then cached the advanced ETag and
142
+ * every subsequent `update` short-circuited on a 304 — the skill could
143
+ * never arrive via delta.
144
+ *
145
+ * The server fix makes FUTURE adds correct, but it cannot rescue a client
146
+ * that already cached the advanced ETag (it still matches → 304). So the
147
+ * FIRST sync performed by a CLI at/above this floor forces ONE full fetch
148
+ * (drops `If-None-Match` AND `since`), re-fetching the entire library to
149
+ * fill any gap, then resumes normal delta. `writeLastSync` stamps the
150
+ * running CLI version on every write, so the heal fires at most once per
151
+ * upgrade.
152
+ *
153
+ * MUST equal the CLI version that ships this fix (see packages/cli/package.json).
154
+ */
155
+ export const FULL_RESYNC_FLOOR = "4.8.0";
156
+
157
+ /**
158
+ * True when a `.last-sync`'s recorded writer version is below the heal
159
+ * floor — i.e. the state file was written before the #1911 fix and may be
160
+ * missing never-delivered skills. A missing or unparseable version is
161
+ * treated as below the floor (older CLIs never stamped `cliVersion`), so
162
+ * every pre-fix state file heals exactly once.
163
+ *
164
+ * @param {unknown} recordedVersion - `.last-sync`'s `cliVersion` field.
165
+ * @param {string} floor - The heal floor (a valid semver string).
166
+ * @returns {boolean}
167
+ */
168
+ export function cliVersionBelowFloor(recordedVersion, floor) {
169
+ if (typeof recordedVersion !== "string" || !semver.valid(recordedVersion)) {
170
+ return true;
171
+ }
172
+ return semver.lt(recordedVersion, floor);
173
+ }
174
+
175
+ /**
176
+ * The running CLI's version, or null if it cannot be read. `getCliVersion`
177
+ * throws on a malformed/absent package.json (a broken tarball); persisting
178
+ * last-sync state must never fail on that, so we soft-handle to null here.
179
+ * A null stamp simply means the next run treats this state as pre-floor and
180
+ * heals again — safe, just one extra full fetch in the (production-
181
+ * impossible) broken-tarball case.
182
+ *
183
+ * @returns {string | null}
184
+ */
185
+ function safeCliVersion() {
186
+ try {
187
+ return getCliVersion();
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
121
193
  /**
122
194
  * Check whether every skill in the `.last-sync` baseline already exists
123
195
  * under every placement target the current sync would write to. Returns
@@ -297,17 +369,26 @@ export function readLastSync() {
297
369
  * sync"; consumers should treat absent entries as "unknown" rather
298
370
  * than as "missing from library."
299
371
  *
372
+ * Always stamps `cliVersion` with the running CLI's version (#1911). This
373
+ * is the writer-version record the heal gate (`cliVersionBelowFloor`) reads
374
+ * on the next run; a state file written by the current CLI is therefore
375
+ * never re-healed. A caller-supplied `cliVersion` overrides the stamp (used
376
+ * only by tests to simulate a pre-fix writer); production callers omit it.
377
+ *
300
378
  * @param {object} state
301
379
  * @param {string | null} [state.etag]
302
380
  * @param {string} [state.syncedAt]
303
381
  * @param {Record<string, SyncedSkillEntry>} [state.skills]
382
+ * @param {string | null} [state.cliVersion] - Override the writer-version
383
+ * stamp. Defaults to the running CLI's version.
304
384
  */
305
- export function writeLastSync({ etag, syncedAt, skills } = {}) {
385
+ export function writeLastSync({ etag, syncedAt, skills, cliVersion } = {}) {
306
386
  const path = globalLastSyncPath();
307
387
  const body = {
308
388
  schemaVersion: LAST_SYNC_SCHEMA_VERSION,
309
389
  etag: etag ?? null,
310
390
  syncedAt: syncedAt ?? new Date().toISOString(),
391
+ cliVersion: cliVersion !== undefined ? cliVersion : safeCliVersion(),
311
392
  skills: skills && typeof skills === "object" && !Array.isArray(skills) ? skills : {},
312
393
  };
313
394
  try {
@@ -390,6 +471,15 @@ export function upsertLastSyncEntry(skill) {
390
471
  etag: prior?.etag ?? null,
391
472
  syncedAt: prior?.syncedAt,
392
473
  skills: updated,
474
+ // Preserve the prior writer-version too (#1911). `add`/`get` fetch a
475
+ // SINGLE skill — they do NOT perform the full-library reconciliation
476
+ // the membership heal needs, so they must NOT advance the heal gate.
477
+ // Letting `writeLastSync` default-stamp the running version here would
478
+ // let a stuck user "spend" their one-time heal on a single-skill add,
479
+ // leaving every OTHER never-delivered skill stuck forever. `?? null`
480
+ // carries a pre-fix file's absent version forward as an explicit null
481
+ // so the next `update` still heals. Same rationale as etag/syncedAt.
482
+ cliVersion: prior?.cliVersion ?? null,
393
483
  });
394
484
  }
395
485
 
@@ -413,6 +503,10 @@ export function deleteLastSyncEntry(owner, name) {
413
503
  etag: prior.etag ?? null,
414
504
  syncedAt: prior.syncedAt,
415
505
  skills: rest,
506
+ // Preserve the prior writer-version (#1911) — `remove` is not a full
507
+ // reconciliation, so it must not advance the one-time heal gate.
508
+ // See upsertLastSyncEntry for the full rationale.
509
+ cliVersion: prior.cliVersion ?? null,
416
510
  });
417
511
  }
418
512
 
@@ -527,19 +621,41 @@ export async function runSync(options) {
527
621
  const placementsComplete =
528
622
  lastSync?.etag &&
529
623
  placementsAreComplete(lastSync.skills, vendors, global);
530
- if (placementsComplete) {
624
+
625
+ // One-time membership-heal (#1911). A `.last-sync` written by a CLI below
626
+ // the heal floor predates the server delta-membership fix and may be
627
+ // permanently missing skills that were added to the library but never
628
+ // delivered (their content predated `since`, so the old delta skipped
629
+ // them while the ETag still advanced — the client cached the advanced
630
+ // ETag and every later sync 304'd). The server fix can't rescue such a
631
+ // client: its cached ETag still matches, so it keeps getting 304s. Force
632
+ // ONE full fetch (drop BOTH conditional headers) so the entire library
633
+ // is re-fetched and the gap is filled. `writeLastSync` stamps the running
634
+ // version on the resulting write, so this fires at most once per upgrade.
635
+ //
636
+ // Mixed-version note (benign, do NOT "fix" with a different mechanism): if
637
+ // a pre-4.8.0 CLI shares this same global `.last-sync` and runs after a
638
+ // 4.8.0 heal, its `writeLastSync` re-emits a file with no `cliVersion`, so
639
+ // the next 4.8.0 run heals again. That loop is harmless — a full re-fetch
640
+ // is idempotent — and self-resolves once the older install is upgraded.
641
+ const needsFullResync =
642
+ !!lastSync && cliVersionBelowFloor(lastSync.cliVersion, FULL_RESYNC_FLOOR);
643
+
644
+ if (placementsComplete && !needsFullResync) {
531
645
  opts.ifNoneMatch = lastSync.etag;
532
646
  }
533
- if (lastSync?.syncedAt && placementsComplete) {
647
+ if (lastSync?.syncedAt && placementsComplete && !needsFullResync) {
534
648
  opts.since = lastSync.syncedAt;
535
649
  }
536
650
 
537
651
  // Track whether this is a full or delta sync BEFORE the network
538
- // call, for the returned summary's `fullSync` field. A "full" sync
539
- // is one where no `since` was sent which is exactly when no
540
- // prior last-sync state existed. The distinction matters to
541
- // consumers (init.mjs) that need to tell "empty library" from
542
- // "nothing changed since last sync" in the zero-counters case.
652
+ // call, for the returned summary's `fullSync` field. The documented
653
+ // semantic is "no PRIOR last-sync state existed"NOT "no `since` was
654
+ // sent". A forced full re-fetch (placement-incomplete, or the #1911
655
+ // membership heal) still reports `fullSync:false` because prior state
656
+ // existed; only a genuine first sync reports true. Consumers (init.mjs)
657
+ // rely on this to tell "empty library" from "nothing changed since last
658
+ // sync" in the zero-counters case. Locked by sync.test.mjs.
543
659
  const fullSync = !lastSync?.syncedAt;
544
660
 
545
661
  const result = await getLibrary(serverUrl, apiKey, opts);
@@ -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
+ });