skillrepo 4.5.0 → 4.5.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.
@@ -0,0 +1,1565 @@
1
+ /**
2
+ * Cross-command integration: `update` → `list` contract.
3
+ *
4
+ * Why this file exists
5
+ * --------------------
6
+ * This is the test layer the v4.5.0 production-readiness review missed.
7
+ *
8
+ * The 4.5.0 staleness epic shipped two commands that share a contract:
9
+ *
10
+ * `update` WRITES skills to disk under every vendor it targets.
11
+ * `list` READS those placements to compute per-skill drift state.
12
+ *
13
+ * The shared contract is the set of "detected vendors." If `update`
14
+ * writes to a smaller set than `list` walks, the list rolls up as
15
+ * MISSING for every multi-vendor user — exactly the production bug
16
+ * that triggered the 4.5.1 hotfix.
17
+ *
18
+ * Per-command unit tests (update.test.mjs + list.test.mjs) cannot
19
+ * surface this. They each fix one half of the contract and mock the
20
+ * other. Only an end-to-end test that runs `update` THEN reads the
21
+ * resulting `list` output catches a contract divergence. That's what
22
+ * this file is — the test that would have caught 4.5.1 pre-shipping.
23
+ *
24
+ * Test isolation
25
+ * --------------
26
+ * Every test runs inside a sandbox HOME so detection env vars are
27
+ * controlled, on-disk placements live in temp dirs, and the
28
+ * `.last-sync` state file is sandbox-local. The mock server stands in
29
+ * for the real registry. Detection signals are toggled via env vars
30
+ * documented in `agent-registry.mjs`:
31
+ *
32
+ * - claudeCode: CLAUDECODE=1
33
+ * - cursor: CURSOR_AGENT=1
34
+ *
35
+ * Detection via `home`/`project` filesystem signals is also exercised
36
+ * implicitly when a test seeds `.claude/` or `.agents/` directories
37
+ * before invoking `update`.
38
+ */
39
+
40
+ import { describe, it, beforeEach, afterEach } from "node:test";
41
+ import assert from "node:assert/strict";
42
+ import {
43
+ mkdtempSync,
44
+ mkdirSync,
45
+ rmSync,
46
+ existsSync,
47
+ readFileSync,
48
+ writeFileSync,
49
+ } from "node:fs";
50
+ import { join } from "node:path";
51
+ import { tmpdir } from "node:os";
52
+
53
+ import { runUpdate } from "../../commands/update.mjs";
54
+ import { runList } from "../../commands/list.mjs";
55
+ import { runGet } from "../../commands/get.mjs";
56
+ import { runAdd } from "../../commands/add.mjs";
57
+ import { runRemove } from "../../commands/remove.mjs";
58
+ import { resolvePlacementDir } from "../../lib/file-write.mjs";
59
+ import { globalLastSyncPath } from "../../lib/paths.mjs";
60
+ import { readLastSync } from "../../lib/sync.mjs";
61
+ import { createMockServer } from "../e2e/mock-server.mjs";
62
+ import { createCaptureStream } from "../helpers/capture-stream.mjs";
63
+ import {
64
+ captureHome,
65
+ setSandboxHome,
66
+ restoreHome,
67
+ } from "../helpers/sandbox-home.mjs";
68
+
69
+ let sandbox;
70
+ let server;
71
+ let serverUrl;
72
+ let originalCwd;
73
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
74
+ let originalHomeEnv;
75
+ let stdout;
76
+ const VALID_KEY = "sk_live_test";
77
+
78
+ /**
79
+ * Build a registry skill payload with a single SKILL.md file. Same
80
+ * helper the unit tests use; replicated here so changes in either
81
+ * place don't silently break the other.
82
+ */
83
+ function makeSkill(owner, name, version = "1.0.0", updatedAt) {
84
+ const content = `---\nname: ${name}\ndescription: ${name} description\n---\n\nbody\n`;
85
+ return {
86
+ owner,
87
+ name,
88
+ version,
89
+ description: `${name} description`,
90
+ files: [
91
+ {
92
+ path: "SKILL.md",
93
+ content,
94
+ // Server-side SHAs are not used by the CLI for drift detection —
95
+ // the CLI re-computes from content on disk. Any non-empty
96
+ // placeholder works.
97
+ sha256: "x",
98
+ size: content.length,
99
+ contentType: "text/markdown",
100
+ },
101
+ ],
102
+ // Default to "now" so the mock server's `since` filter (mirrors
103
+ // production `gt(skills.updatedAt, since)`) treats fresh fixtures
104
+ // as in-window. Tests that need to simulate "skill unchanged
105
+ // since last sync" pass an older string explicitly. Pre-PR #1575
106
+ // mock-server-tightening hardcoded "2025-01-01" which made every
107
+ // skill look stale once `since` was applied — that masked the
108
+ // real production behavior.
109
+ updatedAt: updatedAt ?? new Date().toISOString(),
110
+ };
111
+ }
112
+
113
+ async function setup() {
114
+ sandbox = mkdtempSync(join(tmpdir(), "cli-int-update-list-"));
115
+ mkdirSync(join(sandbox, "project"), { recursive: true });
116
+ mkdirSync(join(sandbox, "home"), { recursive: true });
117
+ originalCwd = process.cwd();
118
+ originalHomeEnv = captureHome();
119
+ process.chdir(join(sandbox, "project"));
120
+ setSandboxHome(join(sandbox, "home"));
121
+
122
+ // Strip any inherited credentials/detection that would otherwise
123
+ // pollute the test's controlled environment.
124
+ delete process.env.SKILLREPO_ACCESS_KEY;
125
+ delete process.env.SKILLREPO_URL;
126
+ delete process.env.CLAUDECODE;
127
+ delete process.env.CURSOR_AGENT;
128
+ delete process.env.CURSOR_CLI;
129
+
130
+ server = createMockServer({});
131
+ const port = await server.start();
132
+ serverUrl = `http://127.0.0.1:${port}`;
133
+
134
+ stdout = createCaptureStream();
135
+ }
136
+
137
+ async function teardown() {
138
+ if (server) await server.stop();
139
+ process.chdir(originalCwd);
140
+ restoreHome(originalHomeEnv);
141
+ delete process.env.CLAUDECODE;
142
+ delete process.env.CURSOR_AGENT;
143
+ delete process.env.CURSOR_CLI;
144
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
145
+ server = null;
146
+ }
147
+
148
+ // ───────────────────────────────────────────────────────────────────────
149
+ // The 4.5.1 regression suite
150
+ // ───────────────────────────────────────────────────────────────────────
151
+ //
152
+ // The bug: `effectiveVendors` defaulted to `["claudeCode"]` when no
153
+ // `--agent` was passed, so `skillrepo update` (with no flags) wrote
154
+ // ONLY to claudeCode's placement. Meanwhile, `skillrepo list` walked
155
+ // EVERY detected vendor's placement. Result for multi-agent users:
156
+ // every skill rolled up as MISSING for every vendor that wasn't
157
+ // Claude Code.
158
+ //
159
+ // The fix: `effectiveVendors` now defaults to every detected vendor
160
+ // (DI-injectable for testability). These tests prove the new contract
161
+ // end-to-end.
162
+
163
+ describe("update → list cross-command contract (#1574)", () => {
164
+ beforeEach(setup);
165
+ afterEach(teardown);
166
+
167
+ it("multi-vendor: update with no --agent writes to every detected vendor (no MISS in list)", async () => {
168
+ // ── Forcing detection of TWO vendors ─────────────────────────────
169
+ // This is the exact production scenario behind 4.5.1: a user with
170
+ // both Claude Code AND Cursor installed runs `skillrepo update`
171
+ // with no `--agent`. Pre-fix, only claudeCode got the files. We
172
+ // assert both placements receive the skill AND that `list` agrees.
173
+ process.env.CLAUDECODE = "1";
174
+ process.env.CURSOR_AGENT = "1";
175
+
176
+ server.setLibraryResponse({
177
+ skills: [makeSkill("alice", "shared-skill", "1.0.0")],
178
+ removals: [],
179
+ syncedAt: "2026-01-01T00:00:00Z",
180
+ });
181
+ server.setEtag('"v1"');
182
+
183
+ // ── Step 1: update with no --agent ───────────────────────────────
184
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
185
+
186
+ // Both placements must exist on disk — the contract claim.
187
+ const claudeDir = resolvePlacementDir("claudeProject", "shared-skill");
188
+ const agentsDir = resolvePlacementDir("agentsProject", "shared-skill");
189
+ assert.ok(existsSync(claudeDir), "claudeProject placement must be written");
190
+ assert.ok(existsSync(agentsDir), "agentsProject (cursor) placement must be written");
191
+
192
+ // Same bytes in both placements — sync writes identical content,
193
+ // not a stub. A future regression that wrote to multiple targets
194
+ // but produced divergent content would break list's drift check
195
+ // and surface here.
196
+ const claudeBody = readFileSync(join(claudeDir, "SKILL.md"), "utf-8");
197
+ const agentsBody = readFileSync(join(agentsDir, "SKILL.md"), "utf-8");
198
+ assert.equal(claudeBody, agentsBody, "writes to multiple targets must produce identical content");
199
+
200
+ // ── Step 2: list reads the same placements ───────────────────────
201
+ // Use --json so we can assert the per-placement state directly.
202
+ stdout = createCaptureStream();
203
+ await runList(
204
+ ["--key", VALID_KEY, "--url", serverUrl, "--json"],
205
+ { stdout },
206
+ );
207
+ const [item] = JSON.parse(stdout.text());
208
+ assert.equal(item.name, "shared-skill");
209
+ // The rollup: both placements are current → rollup is current.
210
+ // Pre-fix this was "missing" because cursor's placement was empty.
211
+ assert.equal(item.state, "current", "rollup must be `current` when every detected vendor has the skill");
212
+ // Lock in the EXACT vendor count. Without this assertion a
213
+ // regression that over-detects (e.g., a future change that
214
+ // defaults to ALL seven registry entries instead of detected-only)
215
+ // would still pass the per-vendor `state === "current"` checks
216
+ // below because both claudeCode and cursor would land in the
217
+ // list. The `.length === 2` check catches the over-detection
218
+ // direction; the test above already catches under-detection.
219
+ assert.equal(
220
+ item.placements.length,
221
+ 2,
222
+ "list must walk exactly the two detected vendors, not every registered one",
223
+ );
224
+ // Each vendor's placement entry reports current independently.
225
+ const byVendor = Object.fromEntries(item.placements.map((p) => [p.vendor, p]));
226
+ assert.ok(byVendor.claudeCode, "claudeCode placement must appear");
227
+ assert.ok(byVendor.cursor, "cursor placement must appear");
228
+ assert.equal(byVendor.claudeCode.state, "current");
229
+ assert.equal(byVendor.cursor.state, "current");
230
+ });
231
+
232
+ it("single-vendor: update with only cursor detected → list shows current for cursor only", async () => {
233
+ // The narrow contract: only the one detected vendor gets the
234
+ // placement, AND `list` afterwards reports `current` for that
235
+ // vendor — no phantom MISS for claudeCode.
236
+ //
237
+ // This test used to opt out of the `list` assertion because of a
238
+ // companion bug: writing `~/.claude/skillrepo/.last-sync`
239
+ // created `~/.claude/`, which then matched claudeCode's home
240
+ // detection signal. PR #1574's production-readiness audit
241
+ // narrowed that signal to `~/.claude/settings.json` (a file
242
+ // Claude Code itself creates, that SkillRepo never writes) so
243
+ // the false positive no longer fires. This test is now the
244
+ // regression guard: a Cursor-only user must see `list` reporting
245
+ // their skills as `current`, NOT all-MISS for a vendor they
246
+ // don't use.
247
+ process.env.CURSOR_AGENT = "1";
248
+
249
+ server.setLibraryResponse({
250
+ skills: [makeSkill("alice", "cursor-only", "1.0.0")],
251
+ removals: [],
252
+ syncedAt: "2026-01-01T00:00:00Z",
253
+ });
254
+ server.setEtag('"v1"');
255
+
256
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
257
+
258
+ // Only cursor's placement exists.
259
+ assert.ok(
260
+ existsSync(resolvePlacementDir("agentsProject", "cursor-only")),
261
+ "agentsProject placement should exist for cursor",
262
+ );
263
+ assert.equal(
264
+ existsSync(resolvePlacementDir("claudeProject", "cursor-only")),
265
+ false,
266
+ "claudeProject placement should NOT exist when claudeCode is not detected at update-time",
267
+ );
268
+
269
+ // `list` now correctly sees ONE detected vendor and reports
270
+ // `current`. The false-positive claudeCode detection that
271
+ // shipped in 4.5.0/4.5.1 produced all-MISS output here.
272
+ stdout = createCaptureStream();
273
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
274
+ const [item] = JSON.parse(stdout.text());
275
+ assert.equal(item.state, "current");
276
+ assert.equal(
277
+ item.placements.length,
278
+ 1,
279
+ "Cursor-only user must not see a phantom claudeCode placement entry",
280
+ );
281
+ assert.equal(item.placements[0].vendor, "cursor");
282
+ assert.equal(item.placements[0].state, "current");
283
+ });
284
+
285
+ it("no-detection fallback: update with no --agent uses the historical claudeCode default", async () => {
286
+ // The other edge of the fix: if NOTHING is detected (no env vars,
287
+ // no `.claude/` or `.agents/` dirs anywhere), the CLI falls back
288
+ // to writing claudeCode-only — preserving the pre-4.5.1 behavior
289
+ // for first-run users. Without this fallback, a brand-new
290
+ // checkout in a clean shell would silently no-op `update` because
291
+ // detection returned an empty list.
292
+ //
293
+ // We don't set ANY env vars. The sandbox HOME is brand-new
294
+ // (no .claude / no .cursor / no .agents).
295
+
296
+ server.setLibraryResponse({
297
+ skills: [makeSkill("alice", "fallback", "1.0.0")],
298
+ removals: [],
299
+ syncedAt: "2026-01-01T00:00:00Z",
300
+ });
301
+ server.setEtag('"v1"');
302
+
303
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
304
+
305
+ // claudeProject is the documented fallback target.
306
+ assert.ok(
307
+ existsSync(resolvePlacementDir("claudeProject", "fallback")),
308
+ "fallback to claudeProject must hold when nothing is detected",
309
+ );
310
+ });
311
+
312
+ it("explicit --agent narrows the write set even when more vendors are detected", async () => {
313
+ // The explicit-flag escape hatch: even if both vendors are
314
+ // detected, `--agent claude` (or `--agent cursor`) writes only
315
+ // to the named vendor. `effectiveVendors` returns the explicit
316
+ // list verbatim — detection is ONLY consulted when no --agent
317
+ // is passed.
318
+ process.env.CLAUDECODE = "1";
319
+ process.env.CURSOR_AGENT = "1";
320
+
321
+ server.setLibraryResponse({
322
+ skills: [makeSkill("alice", "narrowed", "1.0.0")],
323
+ removals: [],
324
+ syncedAt: "2026-01-01T00:00:00Z",
325
+ });
326
+ server.setEtag('"v1"');
327
+
328
+ await runUpdate(
329
+ ["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude"],
330
+ { stdout },
331
+ );
332
+
333
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", "narrowed")));
334
+ // cursor's placement must NOT be written, even though cursor
335
+ // is detected. Explicit > implicit.
336
+ assert.equal(
337
+ existsSync(resolvePlacementDir("agentsProject", "narrowed")),
338
+ false,
339
+ "--agent claude must NOT incidentally write to cursor's placement",
340
+ );
341
+ });
342
+
343
+ it("explicit --agent ALSO has the listed vendor appear in list when detected", async () => {
344
+ // Defense-in-depth check: a user can run `update --agent claude`
345
+ // on a multi-vendor machine and `list` will still walk EVERY
346
+ // detected vendor (including cursor) and surface cursor's
347
+ // placement as MISS — that's the correct user-visible signal that
348
+ // their explicit-narrow update left a vendor un-updated.
349
+ process.env.CLAUDECODE = "1";
350
+ process.env.CURSOR_AGENT = "1";
351
+
352
+ server.setLibraryResponse({
353
+ skills: [makeSkill("alice", "explicit", "1.0.0")],
354
+ removals: [],
355
+ syncedAt: "2026-01-01T00:00:00Z",
356
+ });
357
+ server.setEtag('"v1"');
358
+
359
+ await runUpdate(
360
+ ["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude"],
361
+ { stdout },
362
+ );
363
+
364
+ stdout = createCaptureStream();
365
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
366
+ const [item] = JSON.parse(stdout.text());
367
+ // Cursor's placement is empty → its per-vendor state is missing
368
+ // → rollup is missing (worst state wins). User sees a clear
369
+ // signal that one of their vendors didn't get the update.
370
+ assert.equal(item.state, "missing");
371
+ const cursor = item.placements.find((p) => p.vendor === "cursor");
372
+ assert.ok(cursor, "list must still include cursor's placement entry");
373
+ assert.equal(cursor.state, "missing");
374
+ });
375
+ });
376
+
377
+ // ───────────────────────────────────────────────────────────────────────
378
+ // .last-sync v1 → v2 migration round-trip
379
+ // ───────────────────────────────────────────────────────────────────────
380
+ //
381
+ // A user upgrading from 4.4.x to 4.5.x has a v1 `.last-sync` on disk.
382
+ // The new CLI must:
383
+ // 1. Read it without crashing.
384
+ // 2. Preserve the etag + syncedAt so the next sync still benefits
385
+ // from a 304 short-circuit.
386
+ // 3. After the next successful sync, write a v2 file with the
387
+ // per-skill SHA map populated.
388
+ //
389
+ // Sync.test.mjs has unit-level coverage. This integration test
390
+ // exercises the path through runSync against the mock server, which
391
+ // is the only way to verify the v2 file that lands on disk has the
392
+ // expected shape AND the ETag carry-forward took effect.
393
+
394
+ describe("v1 → v2 .last-sync migration round-trip", () => {
395
+ beforeEach(setup);
396
+ afterEach(teardown);
397
+
398
+ it("reads v1 .last-sync, performs sync, writes v2 with SHA map populated", async () => {
399
+ // Seed a v1 state file at the documented path. Both fields are
400
+ // strings — that's the v1 shape `readLastSync` migrates from.
401
+ const v1Path = globalLastSyncPath();
402
+ mkdirSync(join(v1Path, ".."), { recursive: true });
403
+ writeFileSync(
404
+ v1Path,
405
+ JSON.stringify({
406
+ schemaVersion: 1,
407
+ etag: '"v1-etag"',
408
+ syncedAt: "2025-12-01T00:00:00Z",
409
+ }),
410
+ );
411
+
412
+ process.env.CLAUDECODE = "1";
413
+ server.setEtag('"v2-etag"');
414
+ server.setLibraryResponse({
415
+ skills: [makeSkill("alice", "migrated", "1.0.0")],
416
+ removals: [],
417
+ syncedAt: "2026-05-19T00:00:00Z",
418
+ });
419
+
420
+ // ── Run update — this triggers the v1→v2 in-memory migration
421
+ // in readLastSync and the v2 write at the end of runSync ──
422
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
423
+
424
+ // The on-disk file must now be v2 and carry the per-skill SHA
425
+ // map. The pre-existing v1 etag/syncedAt are NOT preserved
426
+ // because the sync actually completed and got a fresh ETag from
427
+ // the server. Carry-forward applies to the per-skill SHA map
428
+ // (which was empty in v1), not to the library ETag itself.
429
+ const afterRaw = readFileSync(v1Path, "utf-8");
430
+ const after = JSON.parse(afterRaw);
431
+ assert.equal(after.schemaVersion, 2, "must persist as v2");
432
+ assert.equal(after.etag, '"v2-etag"', "etag must reflect server's response");
433
+ assert.ok(
434
+ after.skills && typeof after.skills === "object",
435
+ "skills map must be populated",
436
+ );
437
+ assert.ok(after.skills["alice/migrated"], "synced skill must appear in the SHA map");
438
+ const entry = after.skills["alice/migrated"];
439
+ assert.equal(entry.version, "1.0.0");
440
+ assert.match(entry.skillMdSha256, /^[a-f0-9]{64}$/, "skillMdSha256 must be a hex SHA-256");
441
+ assert.match(entry.filesSha256, /^[a-f0-9]{64}$/, "filesSha256 must be a hex SHA-256");
442
+ });
443
+
444
+ it("v1 state with NO content changes → first sync after upgrade does a full re-fetch (recovery)", async () => {
445
+ // The recovery path for users upgrading from v1 `.last-sync`
446
+ // (4.4.x and earlier) or any state where the per-skill SHA map
447
+ // is empty. `placementsAreComplete` returns false when the
448
+ // baseline map is empty — that's the "we have an etag but no
449
+ // per-skill cache, so we can't trust the 304" signal. runSync
450
+ // drops `If-None-Match`, fetches the full library, and writes
451
+ // both the skill bytes and the per-skill SHA cache. The "library
452
+ // synced" message replaces "up to date" exactly once per
453
+ // upgrade; subsequent syncs (now with a populated skills map
454
+ // AND placement directories on disk) return to the 304 fast
455
+ // path.
456
+ const v1Path = globalLastSyncPath();
457
+ mkdirSync(join(v1Path, ".."), { recursive: true });
458
+ writeFileSync(
459
+ v1Path,
460
+ JSON.stringify({
461
+ schemaVersion: 1,
462
+ etag: '"unchanged"',
463
+ syncedAt: "2025-12-01T00:00:00Z",
464
+ }),
465
+ );
466
+
467
+ process.env.CLAUDECODE = "1";
468
+ server.setEtag('"unchanged"');
469
+ server.setLibraryResponse({
470
+ skills: [makeSkill("alice", "unmigrated", "1.0.0")],
471
+ removals: [],
472
+ syncedAt: "2026-05-19T00:00:00Z",
473
+ });
474
+
475
+ // First sync after upgrade: empty skills map → placementsAreComplete
476
+ // returns false → full re-fetch. User sees the "synced" message,
477
+ // not "up to date" — recovery is visible.
478
+ server.resetLibraryInspection();
479
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
480
+ const firstOut = stdout.text();
481
+ // Negative assertion: the misleading "up to date" message must
482
+ // not appear during the recovery sync.
483
+ assert.ok(
484
+ !/up to date/.test(firstOut),
485
+ "first sync after upgrade must NOT 304 — empty skills map forces full re-fetch",
486
+ );
487
+ // Positive assertion: the recovery sync must report at least
488
+ // one write. Without this, a future regression where the command
489
+ // crashed before printing anything would also pass the negative
490
+ // assertion above. See QA Gap 4.
491
+ assert.match(
492
+ firstOut,
493
+ /Library sync complete|added|updated/,
494
+ "first sync after upgrade must positively report a sync, not silently no-op",
495
+ );
496
+ // Wire-level: ETag was dropped — placementsAreComplete returned
497
+ // false for the empty baseline. This is the load-bearing
498
+ // mechanism, not just a side-effect of output.
499
+ assert.equal(
500
+ server.getLastLibraryIfNoneMatch(),
501
+ null,
502
+ "v1 migration must drop If-None-Match (placementsAreComplete returns false for empty map)",
503
+ );
504
+
505
+ // After the recovery sync, the on-disk file is v2 with the
506
+ // skills map populated. The skill is on disk in the claudeProject
507
+ // placement — the user-visible recovery.
508
+ const after = readLastSync();
509
+ assert.equal(after.schemaVersion, 2);
510
+ assert.ok(after.skills["alice/unmigrated"]);
511
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", "unmigrated")));
512
+
513
+ // Second sync with the SAME vendor set: now the ETag short-circuit
514
+ // is safe and 304 fires (this proves we didn't accidentally make
515
+ // every sync do a full re-fetch — the fix is targeted).
516
+ stdout = createCaptureStream();
517
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
518
+ assert.match(
519
+ stdout.text(),
520
+ /up to date/,
521
+ "second sync (vendor set now in .last-sync) should 304 normally",
522
+ );
523
+ });
524
+ });
525
+
526
+ // ───────────────────────────────────────────────────────────────────────
527
+ // End-to-end drift state through the real update path
528
+ // ───────────────────────────────────────────────────────────────────────
529
+ //
530
+ // list.test.mjs synthesizes `.last-sync` and disk state by hand to
531
+ // exercise each drift state. Those tests prove computeSkillState is
532
+ // correct given a state. They do NOT prove that the state runSync
533
+ // actually persists, when fed back into list, yields the expected
534
+ // classification.
535
+ //
536
+ // These tests run a real update first, then mutate something, then
537
+ // run list. They lock in the round-trip from update → on-disk state
538
+ // → list → drift verdict — which is the only layer where a mismatch
539
+ // between sync's persisted shape and list's expected shape would
540
+ // surface as a user-visible bug.
541
+
542
+ describe("update → mutate → list: drift verdicts on real persisted state", () => {
543
+ beforeEach(setup);
544
+ afterEach(teardown);
545
+
546
+ it("OK after update: a freshly synced skill is `current` in list with no mutation", async () => {
547
+ process.env.CLAUDECODE = "1";
548
+ server.setEtag('"v1"');
549
+ server.setLibraryResponse({
550
+ skills: [makeSkill("alice", "fresh", "1.0.0")],
551
+ removals: [],
552
+ syncedAt: "2026-05-19T00:00:00Z",
553
+ });
554
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
555
+
556
+ stdout = createCaptureStream();
557
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
558
+ const [item] = JSON.parse(stdout.text());
559
+ assert.equal(item.state, "current");
560
+ });
561
+
562
+ it("EDIT after update: hand-edit a SKILL.md and list shows `edited`", async () => {
563
+ process.env.CLAUDECODE = "1";
564
+ server.setEtag('"v1"');
565
+ server.setLibraryResponse({
566
+ skills: [makeSkill("alice", "tampered", "1.0.0")],
567
+ removals: [],
568
+ syncedAt: "2026-05-19T00:00:00Z",
569
+ });
570
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
571
+
572
+ // Append a line to SKILL.md — the persisted SHA in .last-sync
573
+ // no longer matches the on-disk SHA. drift.mjs's contract is
574
+ // "version match + SHA mismatch → edited."
575
+ const skillPath = join(
576
+ resolvePlacementDir("claudeProject", "tampered"),
577
+ "SKILL.md",
578
+ );
579
+ const original = readFileSync(skillPath, "utf-8");
580
+ writeFileSync(skillPath, original + "\n<!-- user edit -->\n");
581
+
582
+ stdout = createCaptureStream();
583
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
584
+ const [item] = JSON.parse(stdout.text());
585
+ assert.equal(item.state, "edited", "post-update hand-edit must surface as `edited`");
586
+ });
587
+
588
+ it("STALE after update: server bumps version and list shows `stale`", async () => {
589
+ process.env.CLAUDECODE = "1";
590
+ server.setEtag('"v1"');
591
+ server.setLibraryResponse({
592
+ skills: [makeSkill("alice", "aging", "1.0.0")],
593
+ removals: [],
594
+ syncedAt: "2026-05-19T00:00:00Z",
595
+ });
596
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
597
+
598
+ // Server's library moves forward: same skill, new version. No
599
+ // second `update` happens — `.last-sync` still shows 1.0.0 as
600
+ // synced.
601
+ server.setEtag('"v2"');
602
+ server.setLibraryResponse({
603
+ skills: [makeSkill("alice", "aging", "1.1.0")],
604
+ removals: [],
605
+ syncedAt: "2026-05-19T01:00:00Z",
606
+ });
607
+
608
+ stdout = createCaptureStream();
609
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
610
+ const [item] = JSON.parse(stdout.text());
611
+ assert.equal(item.state, "stale", "library moved ahead → `stale`");
612
+ });
613
+
614
+ it("MISSING after update + manual delete: removing a placement makes list show `missing`", async () => {
615
+ process.env.CLAUDECODE = "1";
616
+ server.setEtag('"v1"');
617
+ server.setLibraryResponse({
618
+ skills: [makeSkill("alice", "deletable", "1.0.0")],
619
+ removals: [],
620
+ syncedAt: "2026-05-19T00:00:00Z",
621
+ });
622
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
623
+
624
+ // Delete the placement directory the user just synced. .last-sync
625
+ // still records this skill as present (the SHA map remembers it)
626
+ // but the disk no longer has it — drift.mjs's contract is
627
+ // "baseline exists + on-disk gone → missing."
628
+ rmSync(resolvePlacementDir("claudeProject", "deletable"), {
629
+ recursive: true,
630
+ force: true,
631
+ });
632
+
633
+ stdout = createCaptureStream();
634
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
635
+ const [item] = JSON.parse(stdout.text());
636
+ assert.equal(item.state, "missing");
637
+ });
638
+
639
+ it("multi-vendor mixed drift: claudeCode edited + cursor current rolls up to `edited` (worst-state-wins)", async () => {
640
+ // The rollup contract is "worst state wins" — defined in
641
+ // drift.mjs's `rollupState`. End-to-end coverage proves the
642
+ // contract holds when both placements go through the real
643
+ // update + list path, not just the synthesized state path.
644
+ process.env.CLAUDECODE = "1";
645
+ process.env.CURSOR_AGENT = "1";
646
+
647
+ server.setEtag('"v1"');
648
+ server.setLibraryResponse({
649
+ skills: [makeSkill("alice", "split-drift", "1.0.0")],
650
+ removals: [],
651
+ syncedAt: "2026-05-19T00:00:00Z",
652
+ });
653
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
654
+
655
+ // Edit ONLY the claudeCode placement.
656
+ const claudeFile = join(
657
+ resolvePlacementDir("claudeProject", "split-drift"),
658
+ "SKILL.md",
659
+ );
660
+ writeFileSync(claudeFile, readFileSync(claudeFile, "utf-8") + "// drift\n");
661
+
662
+ stdout = createCaptureStream();
663
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
664
+ const [item] = JSON.parse(stdout.text());
665
+
666
+ // Rollup: edited vs current → edited (worst wins). The user-
667
+ // visible signal is "something needs attention" even though one
668
+ // vendor is fine.
669
+ assert.equal(item.state, "edited");
670
+ const byVendor = Object.fromEntries(item.placements.map((p) => [p.vendor, p]));
671
+ assert.equal(byVendor.claudeCode.state, "edited");
672
+ assert.equal(byVendor.cursor.state, "current");
673
+ });
674
+ });
675
+
676
+ // ───────────────────────────────────────────────────────────────────────
677
+ // `get` / `add` / `remove` write-back to `.last-sync`
678
+ // ───────────────────────────────────────────────────────────────────────
679
+ //
680
+ // The 4.5.1 effective-vendors fix was a write-side contract gap. PR
681
+ // #1574's production-readiness audit surfaced THREE more of the same
682
+ // shape: `get`, `add`, and `remove` write to disk via `writeSkillDir`
683
+ // (or `removeSkillDir`) but never updated `.last-sync` — so the very
684
+ // next `list` reported every freshly-fetched / freshly-added skill as
685
+ // `MISSING` even though it was sitting right there on disk. These
686
+ // tests lock in the closed contract.
687
+
688
+ describe("get → list cross-command contract", () => {
689
+ beforeEach(setup);
690
+ afterEach(teardown);
691
+
692
+ it("after `get`, list reports the skill as current (not MISSING)", async () => {
693
+ process.env.CLAUDECODE = "1";
694
+
695
+ // Prime `.last-sync` with one prior-synced skill so we can also
696
+ // verify that `get` doesn't clobber existing entries.
697
+ server.setEtag('"baseline"');
698
+ server.setLibraryResponse({
699
+ skills: [makeSkill("alice", "prior-sync", "1.0.0")],
700
+ removals: [],
701
+ syncedAt: "2026-01-01T00:00:00Z",
702
+ });
703
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
704
+
705
+ // Now `get` a different skill that's NOT in the library
706
+ // response yet. The mock server serves it from setSkillResponse.
707
+ server.setSkillResponse(
708
+ "bob",
709
+ "fetched",
710
+ makeSkill("bob", "fetched", "1.0.0"),
711
+ );
712
+ stdout = createCaptureStream();
713
+ await runGet(
714
+ ["@bob/fetched", "--key", VALID_KEY, "--url", serverUrl],
715
+ { stdout },
716
+ );
717
+
718
+ // Server's library now reports both skills (the maintainer
719
+ // published `bob/fetched` to the catalog). list must see both
720
+ // as `current` — the freshly-fetched one IS on disk and IS in
721
+ // the baseline.
722
+ server.setEtag('"baseline+1"');
723
+ server.setLibraryResponse({
724
+ skills: [
725
+ makeSkill("alice", "prior-sync", "1.0.0"),
726
+ makeSkill("bob", "fetched", "1.0.0"),
727
+ ],
728
+ removals: [],
729
+ syncedAt: "2026-01-02T00:00:00Z",
730
+ });
731
+
732
+ stdout = createCaptureStream();
733
+ await runList(
734
+ ["--key", VALID_KEY, "--url", serverUrl, "--json"],
735
+ { stdout },
736
+ );
737
+ const parsed = JSON.parse(stdout.text());
738
+ const prior = parsed.find((s) => s.name === "prior-sync");
739
+ const fetched = parsed.find((s) => s.name === "fetched");
740
+ assert.equal(prior.state, "current", "prior-synced skill must stay current");
741
+ assert.equal(
742
+ fetched.state,
743
+ "current",
744
+ "skill fetched via `get` must show as current — get must update .last-sync",
745
+ );
746
+ });
747
+
748
+ it("get preserves the library-level etag (next update can still 304)", async () => {
749
+ // Critical invariant: `get` updates the per-skill SHA entry but
750
+ // leaves the library-level `etag` and `syncedAt` alone. If `get`
751
+ // clobbered the etag, the next `update` would do a wasted full
752
+ // sync. This test guards that behavior.
753
+ process.env.CLAUDECODE = "1";
754
+
755
+ server.setEtag('"original-etag"');
756
+ server.setLibraryResponse({
757
+ skills: [makeSkill("alice", "prior", "1.0.0")],
758
+ removals: [],
759
+ syncedAt: "2026-01-01T00:00:00Z",
760
+ });
761
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
762
+
763
+ server.setSkillResponse("bob", "extra", makeSkill("bob", "extra", "1.0.0"));
764
+ await runGet(
765
+ ["@bob/extra", "--key", VALID_KEY, "--url", serverUrl],
766
+ { stdout },
767
+ );
768
+
769
+ // Read the persisted state directly — the etag/syncedAt must
770
+ // match what the prior update wrote, not what `get` happened to
771
+ // synthesize.
772
+ const after = readLastSync();
773
+ assert.equal(after.etag, '"original-etag"', "get must not clobber the library etag");
774
+ assert.equal(
775
+ after.syncedAt,
776
+ "2026-01-01T00:00:00Z",
777
+ "get must not clobber the library syncedAt",
778
+ );
779
+ // The new skill entry IS in the map alongside the prior entry.
780
+ assert.ok(after.skills["alice/prior"], "prior entry preserved");
781
+ assert.ok(after.skills["bob/extra"], "newly-fetched entry recorded");
782
+ });
783
+ });
784
+
785
+ describe("add → list cross-command contract", () => {
786
+ beforeEach(setup);
787
+ afterEach(teardown);
788
+
789
+ it("after `add`, list reports the skill as current (not MISSING)", async () => {
790
+ process.env.CLAUDECODE = "1";
791
+
792
+ // `add` does POST /library/refs THEN GET /skills/<owner>/<name>.
793
+ // Both need responses on the mock server.
794
+ server.setAddResponseForAny({
795
+ status: 201,
796
+ body: {
797
+ added: {
798
+ owner: "carol",
799
+ name: "added-skill",
800
+ version: "2.0.0",
801
+ addedAt: "2026-01-01T00:00:00Z",
802
+ },
803
+ },
804
+ });
805
+ server.setSkillResponse(
806
+ "carol",
807
+ "added-skill",
808
+ makeSkill("carol", "added-skill", "2.0.0"),
809
+ );
810
+
811
+ await runAdd(
812
+ ["@carol/added-skill", "--key", VALID_KEY, "--url", serverUrl],
813
+ { stdout },
814
+ );
815
+
816
+ // Subsequent list with the catalog now showing the same skill —
817
+ // it should report as `current`.
818
+ server.setEtag('"v1"');
819
+ server.setLibraryResponse({
820
+ skills: [makeSkill("carol", "added-skill", "2.0.0")],
821
+ removals: [],
822
+ syncedAt: "2026-01-01T00:00:00Z",
823
+ });
824
+
825
+ stdout = createCaptureStream();
826
+ await runList(
827
+ ["--key", VALID_KEY, "--url", serverUrl, "--json"],
828
+ { stdout },
829
+ );
830
+ const [item] = JSON.parse(stdout.text());
831
+ assert.equal(item.name, "added-skill");
832
+ assert.equal(
833
+ item.state,
834
+ "current",
835
+ "skill added via `add` must show as current — add must update .last-sync",
836
+ );
837
+ });
838
+ });
839
+
840
+ describe("remove → list cross-command contract", () => {
841
+ beforeEach(setup);
842
+ afterEach(teardown);
843
+
844
+ it("after `remove`, the skill's entry is purged from .last-sync", async () => {
845
+ process.env.CLAUDECODE = "1";
846
+
847
+ // Sync two skills, then remove one.
848
+ server.setEtag('"v1"');
849
+ server.setLibraryResponse({
850
+ skills: [
851
+ makeSkill("alice", "keep", "1.0.0"),
852
+ makeSkill("alice", "drop", "1.0.0"),
853
+ ],
854
+ removals: [],
855
+ syncedAt: "2026-01-01T00:00:00Z",
856
+ });
857
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
858
+
859
+ // Confirm both entries are present in .last-sync.
860
+ let state = readLastSync();
861
+ assert.ok(state.skills["alice/keep"]);
862
+ assert.ok(state.skills["alice/drop"]);
863
+
864
+ server.setRemoveResponseForAny({
865
+ status: 200,
866
+ body: { removed: { owner: "alice", name: "drop" } },
867
+ });
868
+ await runRemove(
869
+ ["@alice/drop", "--key", VALID_KEY, "--url", serverUrl],
870
+ { stdout },
871
+ );
872
+
873
+ // `alice/drop` is now absent from .last-sync; the kept skill is
874
+ // unaffected; the library-level etag is preserved (remove does
875
+ // NOT touch it).
876
+ state = readLastSync();
877
+ assert.ok(state.skills["alice/keep"], "kept skill's entry remains");
878
+ assert.equal(
879
+ state.skills["alice/drop"],
880
+ undefined,
881
+ "removed skill's entry must be purged",
882
+ );
883
+ assert.equal(state.etag, '"v1"', "remove must not clobber the library etag");
884
+ });
885
+
886
+ it("removing a skill that was never in .last-sync is idempotent (no throw, no spurious write)", async () => {
887
+ // Edge case: user runs `skillrepo remove @alice/never-synced`
888
+ // for a skill that was never on disk and was never tracked. The
889
+ // server returns 404 (not-in-library) but `remove` still tries
890
+ // to clean local files (which don't exist) and update
891
+ // `.last-sync` (which has no entry to remove). This must not
892
+ // throw and must not change the etag/syncedAt.
893
+ process.env.CLAUDECODE = "1";
894
+ server.setEtag('"unchanged"');
895
+ server.setLibraryResponse({
896
+ skills: [],
897
+ removals: [],
898
+ syncedAt: "2026-01-01T00:00:00Z",
899
+ });
900
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
901
+
902
+ server.setRemoveResponseForAny({
903
+ status: 404,
904
+ body: { error: "Skill not found", code: "not_found" },
905
+ });
906
+ await assert.doesNotReject(() =>
907
+ runRemove(
908
+ ["@alice/never-synced", "--key", VALID_KEY, "--url", serverUrl],
909
+ { stdout },
910
+ ),
911
+ );
912
+
913
+ const state = readLastSync();
914
+ assert.equal(state.etag, '"unchanged"');
915
+ });
916
+ });
917
+
918
+ // ───────────────────────────────────────────────────────────────────────
919
+ // QA-flagged coverage gaps (from PR #1574 production-readiness audit)
920
+ // ───────────────────────────────────────────────────────────────────────
921
+
922
+ describe("additional coverage from production-readiness audit", () => {
923
+ beforeEach(setup);
924
+ afterEach(teardown);
925
+
926
+ it("corrupt .last-sync survives — runUpdate writes a fresh v2 file and list works", async () => {
927
+ // Real-world failure mode: a power loss mid-write leaves a
928
+ // partially-written `.last-sync` that won't parse as JSON.
929
+ // `readLastSync` returns null on parse failure (documented
930
+ // forward-compat behavior) and runSync performs a full re-sync.
931
+ // This test holds that contract — a future refactor that removes
932
+ // the try/catch and throws on parse error would break every user
933
+ // with a corrupt cache.
934
+ const path = globalLastSyncPath();
935
+ mkdirSync(join(path, ".."), { recursive: true });
936
+ writeFileSync(path, "CORRUPTED-NOT-JSON{{{", "utf-8");
937
+
938
+ process.env.CLAUDECODE = "1";
939
+ server.setEtag('"fresh"');
940
+ server.setLibraryResponse({
941
+ skills: [makeSkill("alice", "recovered", "1.0.0")],
942
+ removals: [],
943
+ syncedAt: "2026-05-19T00:00:00Z",
944
+ });
945
+
946
+ await assert.doesNotReject(
947
+ () => runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
948
+ "runUpdate must survive a corrupt .last-sync and complete the sync",
949
+ );
950
+ const after = readLastSync();
951
+ assert.equal(after.schemaVersion, 2);
952
+ assert.equal(after.etag, '"fresh"');
953
+ assert.ok(after.skills["alice/recovered"]);
954
+ });
955
+
956
+ it("delta sync preserves entries the server didn't return in this round", async () => {
957
+ // The carry-forward contract. Server returns skill A on sync 1,
958
+ // then returns only skill B on sync 2 (delta — A didn't change).
959
+ // After sync 2, `.last-sync` must STILL contain A's SHA entry.
960
+ // Without this carry-forward, every delta sync shrinks the map
961
+ // until only the most-recently-touched skills remain — defeating
962
+ // list's drift detection for stable skills.
963
+ process.env.CLAUDECODE = "1";
964
+
965
+ // Sync 1: both A and B in the library.
966
+ server.setEtag('"sync1"');
967
+ server.setLibraryResponse({
968
+ skills: [
969
+ makeSkill("alice", "stable", "1.0.0"),
970
+ makeSkill("alice", "churning", "1.0.0"),
971
+ ],
972
+ removals: [],
973
+ syncedAt: "2026-01-01T00:00:00Z",
974
+ });
975
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
976
+ let state = readLastSync();
977
+ assert.ok(state.skills["alice/stable"]);
978
+ assert.ok(state.skills["alice/churning"]);
979
+
980
+ // Sync 2: server returns ONLY the changed skill (delta). The
981
+ // stable skill is untouched. Note: production runSync sends
982
+ // `since` based on the persisted syncedAt; the mock server
983
+ // doesn't filter by `since`, so the test mimics the delta-only
984
+ // server response shape directly.
985
+ server.setEtag('"sync2"');
986
+ server.setLibraryResponse({
987
+ skills: [makeSkill("alice", "churning", "1.1.0")],
988
+ removals: [],
989
+ syncedAt: "2026-01-02T00:00:00Z",
990
+ });
991
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
992
+
993
+ state = readLastSync();
994
+ assert.ok(
995
+ state.skills["alice/stable"],
996
+ "stable skill's entry must survive a delta sync that didn't return it",
997
+ );
998
+ assert.equal(state.skills["alice/churning"].version, "1.1.0");
999
+ });
1000
+
1001
+ it("three detected vendors all receive the skill (proves verbatim passthrough)", async () => {
1002
+ // Earlier integration tests only cover two vendors. A hardcoded
1003
+ // `["claudeCode", "cursor"]` impl would pass those — it would
1004
+ // even produce both placements. This test forces three vendors
1005
+ // (claudeCode + cursor + windsurf via CURSOR_AGENT + CLAUDECODE
1006
+ // + the windsurf home signal). Detection returns three entries
1007
+ // and `update` must write to all three.
1008
+ process.env.CLAUDECODE = "1";
1009
+ // Cursor needs its env signal AND its settings.json equivalent
1010
+ // (cursor's home signal is `.cursor` which is a dir — cursor
1011
+ // doesn't have the same `.claude` poisoning issue because we
1012
+ // never write into `~/.cursor`).
1013
+ process.env.CURSOR_AGENT = "1";
1014
+ // Trigger windsurf detection via its home signal
1015
+ // (`~/.codeium/windsurf/`). The dir-existing signal is the
1016
+ // documented detection mechanism per the registry.
1017
+ mkdirSync(join(process.env.HOME, ".codeium", "windsurf"), {
1018
+ recursive: true,
1019
+ });
1020
+
1021
+ server.setEtag('"v1"');
1022
+ server.setLibraryResponse({
1023
+ skills: [makeSkill("alice", "everywhere", "1.0.0")],
1024
+ removals: [],
1025
+ syncedAt: "2026-01-01T00:00:00Z",
1026
+ });
1027
+
1028
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1029
+
1030
+ // All three vendors' placements must exist on disk. claudeCode
1031
+ // writes to `.claude/skills/`. cursor + windsurf both share the
1032
+ // `.agents/skills/` cohort placement target — one physical
1033
+ // write satisfies both (placementTargetsFor dedupes by target).
1034
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", "everywhere")));
1035
+ assert.ok(existsSync(resolvePlacementDir("agentsProject", "everywhere")));
1036
+
1037
+ // List walks every detected vendor independently. claudeCode,
1038
+ // cursor, AND windsurf all have project targets (windsurf's
1039
+ // project target is `agentsProject` per the registry — windsurf
1040
+ // joins the cohort for project-scope skills, even though its
1041
+ // personal-scope placement is at `~/.codeium/windsurf/skills/`).
1042
+ // All three must appear in the JSON output, all `current`.
1043
+ stdout = createCaptureStream();
1044
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
1045
+ const [item] = JSON.parse(stdout.text());
1046
+ assert.equal(item.state, "current");
1047
+ const vendors = item.placements.map((p) => p.vendor).sort();
1048
+ assert.deepEqual(
1049
+ vendors,
1050
+ ["claudeCode", "cursor", "windsurf"],
1051
+ "list must walk every detected vendor (3 in this case — proves no hardcoded pair)",
1052
+ );
1053
+ for (const p of item.placements) {
1054
+ assert.equal(p.state, "current");
1055
+ }
1056
+ });
1057
+ });
1058
+
1059
+ // ───────────────────────────────────────────────────────────────────────
1060
+ // Upgrade-path scenarios — the bugs the fresh-sandbox tests cannot see
1061
+ // ───────────────────────────────────────────────────────────────────────
1062
+ //
1063
+ // The 4.5.0 → 4.5.1 production-readiness audit missed a real user-facing
1064
+ // bug because every test in this file (and every reviewer-spawned
1065
+ // verification) ran in a freshly-created sandbox. The actual user
1066
+ // upgrade path is different: pre-existing `.last-sync` written by the
1067
+ // old CLI, then run the new CLI, expect new behavior to take effect.
1068
+ //
1069
+ // The specific bug: `runSync`'s ETag short-circuit fires when the
1070
+ // server hasn't changed library content since the last sync — but if
1071
+ // the local detected-vendor set has EXPANDED since that sync (the
1072
+ // classic 4.5.0 → 4.5.1 transition: claudeCode-only → all-detected),
1073
+ // the 304 means the new vendors NEVER receive their skills. User sees
1074
+ // "Library is up to date" followed by `list` reporting every skill as
1075
+ // MISS for the newly-detected vendors.
1076
+ //
1077
+ // These tests reproduce that specific scenario by:
1078
+ // 1. Seeding `.last-sync` with a state that mimics the old CLI's
1079
+ // output (single-vendor sync, etag X).
1080
+ // 2. Setting the detection signals for additional vendors.
1081
+ // 3. Running `update` against a server that returns 304 for etag X.
1082
+ // 4. Asserting the new vendors' placements were populated anyway.
1083
+
1084
+ describe("upgrade path: vendor set expands between syncs (regression for the bug 4.5.1 missed)", () => {
1085
+ beforeEach(setup);
1086
+ afterEach(teardown);
1087
+
1088
+ it("new vendor detected after prior sync → ETag is dropped and writes fire for the new vendor", async () => {
1089
+ // STEP 1: simulate the 4.5.0 user's state — only claudeCode
1090
+ // detected, sync completed, `.last-sync` has skill SHAs and an
1091
+ // etag.
1092
+ process.env.CLAUDECODE = "1";
1093
+ server.setEtag('"library-v1"');
1094
+ const skill = makeSkill("alice", "shared", "1.0.0");
1095
+ server.setLibraryResponse({
1096
+ skills: [skill],
1097
+ removals: [],
1098
+ syncedAt: "2026-05-01T00:00:00Z",
1099
+ });
1100
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1101
+
1102
+ // Sanity: only claudeProject got the write at this point.
1103
+ assert.ok(
1104
+ existsSync(resolvePlacementDir("claudeProject", "shared")),
1105
+ "first sync wrote to claudeProject",
1106
+ );
1107
+ assert.equal(
1108
+ existsSync(resolvePlacementDir("agentsProject", "shared")),
1109
+ false,
1110
+ "first sync did NOT write to agentsProject (cursor not detected yet)",
1111
+ );
1112
+
1113
+ // STEP 2: simulate the user installing Cursor — env signal fires
1114
+ // on the NEXT `update` invocation. The library content has not
1115
+ // changed on the server, so the server will return 304.
1116
+ process.env.CURSOR_AGENT = "1";
1117
+
1118
+ // Mock server keeps the same etag → If-None-Match match → 304.
1119
+ // No need to change the library response: the 304 branch in
1120
+ // runSync skips reading the body anyway.
1121
+
1122
+ // STEP 3: run update. Pre-fix: short-circuits on 304, no writes
1123
+ // to cursor, user sees "Library is up to date" but cursor's
1124
+ // placement remains empty.
1125
+ server.resetLibraryInspection();
1126
+ stdout = createCaptureStream();
1127
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1128
+
1129
+ // STEP 4: assert. THIS IS THE LOAD-BEARING CHECK.
1130
+ // The newly-detected cursor vendor's placement MUST exist after
1131
+ // the second `update`, even though the server responded 304.
1132
+ // Pre-fix this assertion fails.
1133
+ assert.ok(
1134
+ existsSync(resolvePlacementDir("agentsProject", "shared")),
1135
+ "agentsProject placement MUST exist after a 304 sync when cursor was newly detected — " +
1136
+ "the ETag short-circuit must not prevent writes to vendors absent from the prior sync",
1137
+ );
1138
+ // Wire-level proof: the client did NOT send If-None-Match — the
1139
+ // placement-presence check correctly forced a full re-fetch.
1140
+ // Without this assertion, a regression that still sent the ETag
1141
+ // but happened to re-write to cursor via some unrelated code
1142
+ // path would pass the disk check above silently. See QA Gap 2.
1143
+ assert.equal(
1144
+ server.getLastLibraryIfNoneMatch(),
1145
+ null,
1146
+ "client must NOT have sent If-None-Match — placement-presence check should have dropped it",
1147
+ );
1148
+
1149
+ // Confirm `list` agrees: every detected vendor reports current.
1150
+ stdout = createCaptureStream();
1151
+ await runList(
1152
+ ["--key", VALID_KEY, "--url", serverUrl, "--json"],
1153
+ { stdout },
1154
+ );
1155
+ const [item] = JSON.parse(stdout.text());
1156
+ assert.equal(
1157
+ item.state,
1158
+ "current",
1159
+ "list must show current after the upgrade-path sync — not MISS",
1160
+ );
1161
+ assert.equal(item.placements.length, 2);
1162
+ for (const p of item.placements) {
1163
+ assert.equal(p.state, "current");
1164
+ }
1165
+ });
1166
+
1167
+ it("vendor set unchanged across syncs → 304 short-circuit still fires (no wasted full re-fetch)", async () => {
1168
+ // Defense-in-depth: the fix must NOT invalidate the 304 unless
1169
+ // the placement set actually changed. A user who runs `update`
1170
+ // twice in a row with the same vendors should still benefit from
1171
+ // the 304 fast path. The assertion is BOTH the user-visible "up
1172
+ // to date" output AND the wire-level `If-None-Match` header —
1173
+ // otherwise a future regression that broke the ETag header but
1174
+ // happened to produce the same output (e.g., full re-fetch with
1175
+ // zero deltas) would pass this test silently. See QA reviewer
1176
+ // Gap 2.
1177
+ process.env.CLAUDECODE = "1";
1178
+ process.env.CURSOR_AGENT = "1";
1179
+ server.setEtag('"library-v1"');
1180
+ server.setLibraryResponse({
1181
+ skills: [makeSkill("alice", "unchanged", "1.0.0")],
1182
+ removals: [],
1183
+ syncedAt: "2026-05-01T00:00:00Z",
1184
+ });
1185
+
1186
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1187
+
1188
+ // Second run with the SAME vendor set — should 304 and produce
1189
+ // the "up to date" message (no writes needed).
1190
+ server.resetLibraryInspection();
1191
+ stdout = createCaptureStream();
1192
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1193
+ assert.match(
1194
+ stdout.text(),
1195
+ /up to date/,
1196
+ "same vendor set + same library = 304 fast path still fires",
1197
+ );
1198
+ // Wire-level proof: the client actually sent If-None-Match.
1199
+ // Without this, a regression that broke the conditional request
1200
+ // but produced the same output would slip past output-only
1201
+ // assertions.
1202
+ assert.equal(
1203
+ server.getLastLibraryIfNoneMatch(),
1204
+ '"library-v1"',
1205
+ "second sync MUST have sent If-None-Match: <prior-etag> — the ETag fast path is load-bearing",
1206
+ );
1207
+ });
1208
+
1209
+ it("--global sync followed by project sync → project placement gets populated", async () => {
1210
+ // Edge case the vendor-set tracking alone misses: same vendors,
1211
+ // different scope. The user runs `update --global` (writes to
1212
+ // ~/.claude/skills/), then runs `update` with no flag (writes to
1213
+ // <cwd>/.claude/skills/). Same vendor key (claudeCode) but
1214
+ // entirely different placement directory. A naive syncedVendors
1215
+ // check would say "set unchanged" → 304 → no writes → project
1216
+ // placement empty → list shows MISS.
1217
+ //
1218
+ // The proper contract: invalidate the ETag whenever the resolved
1219
+ // placement directories for the current sync don't ALL contain
1220
+ // the skills they should. That covers vendor-set expansion AND
1221
+ // scope changes AND cwd switches.
1222
+ process.env.CLAUDECODE = "1";
1223
+ server.setEtag('"library-v1"');
1224
+ server.setLibraryResponse({
1225
+ skills: [makeSkill("alice", "scope-test", "1.0.0")],
1226
+ removals: [],
1227
+ syncedAt: "2026-05-01T00:00:00Z",
1228
+ });
1229
+
1230
+ // First sync: --global → ~/.claude/skills/ (claudeGlobal target).
1231
+ await runUpdate(
1232
+ ["--key", VALID_KEY, "--url", serverUrl, "--global"],
1233
+ { stdout },
1234
+ );
1235
+ assert.ok(
1236
+ existsSync(resolvePlacementDir("claudeGlobal", "scope-test")),
1237
+ "first sync wrote to claudeGlobal",
1238
+ );
1239
+ assert.equal(
1240
+ existsSync(resolvePlacementDir("claudeProject", "scope-test")),
1241
+ false,
1242
+ "first sync did NOT write to claudeProject (no --global flag inverted)",
1243
+ );
1244
+
1245
+ // Second sync: no --global → <cwd>/.claude/skills/ (claudeProject).
1246
+ // Same vendor (claudeCode), same library content (304 from server),
1247
+ // but the destination directory is completely different. Without
1248
+ // the fix, the 304 short-circuit fires and claudeProject stays
1249
+ // empty.
1250
+ stdout = createCaptureStream();
1251
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1252
+
1253
+ // THE LOAD-BEARING CHECK: claudeProject must exist after the
1254
+ // project-scope sync, even though the server 304'd.
1255
+ assert.ok(
1256
+ existsSync(resolvePlacementDir("claudeProject", "scope-test")),
1257
+ "claudeProject placement MUST exist after a 304 sync when the prior sync " +
1258
+ "was --global — the ETag short-circuit must not skip writes when the " +
1259
+ "resolved placement directory is empty",
1260
+ );
1261
+ });
1262
+
1263
+ it("cwd switch between syncs → second project's placement gets populated", async () => {
1264
+ // Same bug class as the --global swap. User runs `update` in
1265
+ // project A, then runs `update` in project B. Both projects use
1266
+ // the same `.last-sync` (lives in HOME, not project) but the
1267
+ // placement directories are cwd-dependent. Without the fix, the
1268
+ // second project sees a 304 and never gets its placements.
1269
+ process.env.CLAUDECODE = "1";
1270
+ server.setEtag('"library-v1"');
1271
+ server.setLibraryResponse({
1272
+ skills: [makeSkill("alice", "cwd-test", "1.0.0")],
1273
+ removals: [],
1274
+ syncedAt: "2026-05-01T00:00:00Z",
1275
+ });
1276
+
1277
+ // First sync in project A.
1278
+ const projectA = process.cwd();
1279
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1280
+ assert.ok(
1281
+ existsSync(resolvePlacementDir("claudeProject", "cwd-test")),
1282
+ "first sync populated project A's placement",
1283
+ );
1284
+
1285
+ // Switch to project B (a separate temp directory inside the same
1286
+ // sandbox so the home-scoped `.last-sync` is shared).
1287
+ const projectB = join(sandbox, "project-b");
1288
+ mkdirSync(projectB, { recursive: true });
1289
+ process.chdir(projectB);
1290
+
1291
+ try {
1292
+ // Same library content, same vendor — server still 304s. Without
1293
+ // the fix, no writes to project B.
1294
+ stdout = createCaptureStream();
1295
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1296
+
1297
+ assert.ok(
1298
+ existsSync(resolvePlacementDir("claudeProject", "cwd-test")),
1299
+ "project B's claudeProject placement MUST exist after sync — " +
1300
+ "ETag short-circuit must not skip writes when the cwd changed",
1301
+ );
1302
+ } finally {
1303
+ process.chdir(projectA);
1304
+ }
1305
+ });
1306
+
1307
+ it("partial baseline recovery: delete 1 of 5 skills' placements → ETag dropped, all re-fetched (QA Gap 1)", async () => {
1308
+ // Most probable failure mode in normal use: user has many
1309
+ // skills, manually deletes one (or a cleanup script trims a
1310
+ // skill they removed), then runs `update`. The library is
1311
+ // unchanged on the server (304), but the placement-presence
1312
+ // check sees the missing dir and forces a full re-fetch. This
1313
+ // restores every skill in one round. The wire-level check
1314
+ // proves the ETag was DROPPED, not silently sent and ignored.
1315
+ process.env.CLAUDECODE = "1";
1316
+ server.setEtag('"library-v1"');
1317
+ server.setLibraryResponse({
1318
+ skills: [
1319
+ makeSkill("alice", "skill-1", "1.0.0"),
1320
+ makeSkill("alice", "skill-2", "1.0.0"),
1321
+ makeSkill("alice", "skill-3", "1.0.0"),
1322
+ makeSkill("alice", "skill-4", "1.0.0"),
1323
+ makeSkill("alice", "skill-5", "1.0.0"),
1324
+ ],
1325
+ removals: [],
1326
+ syncedAt: "2026-05-01T00:00:00Z",
1327
+ });
1328
+
1329
+ // Sync — all 5 land.
1330
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1331
+ for (const n of ["skill-1", "skill-2", "skill-3", "skill-4", "skill-5"]) {
1332
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", n)));
1333
+ }
1334
+
1335
+ // User deletes ONE skill — `skill-3`.
1336
+ rmSync(resolvePlacementDir("claudeProject", "skill-3"), {
1337
+ recursive: true,
1338
+ force: true,
1339
+ });
1340
+
1341
+ server.resetLibraryInspection();
1342
+ stdout = createCaptureStream();
1343
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1344
+
1345
+ // `skill-3` is restored. The other 4 are untouched (idempotent
1346
+ // write — same content, same SHA).
1347
+ assert.ok(
1348
+ existsSync(resolvePlacementDir("claudeProject", "skill-3")),
1349
+ "deleted skill must be restored after self-healing sync",
1350
+ );
1351
+ for (const n of ["skill-1", "skill-2", "skill-4", "skill-5"]) {
1352
+ assert.ok(
1353
+ existsSync(resolvePlacementDir("claudeProject", n)),
1354
+ `${n} must remain present (no collateral damage)`,
1355
+ );
1356
+ }
1357
+ // Wire-level: ETag was dropped — placement-presence check fired.
1358
+ assert.equal(
1359
+ server.getLastLibraryIfNoneMatch(),
1360
+ null,
1361
+ "ETag MUST have been dropped — placement-presence check found the missing skill-3 dir",
1362
+ );
1363
+ });
1364
+
1365
+ it("multi-vendor + manual delete of ONE vendor's placement → next update self-heals", async () => {
1366
+ // Real-world scenario flagged by reviewer: a user with two
1367
+ // vendors syncs successfully, then manually deletes one
1368
+ // vendor's placement directory (rm -rf the cohort dir, or a
1369
+ // hostile cleanup script touched it). The `.last-sync` baseline
1370
+ // and the OTHER vendor's placement are intact, so the server
1371
+ // would 304. Without the placement-presence check, the deleted
1372
+ // vendor's placement would stay empty forever — list would show
1373
+ // MISS for every skill at that vendor.
1374
+ //
1375
+ // The placement-presence check sees the missing claudeProject
1376
+ // SKILL.md and forces a re-fetch, restoring both vendors'
1377
+ // placements in one round. This is the "self-healing" property
1378
+ // of the fix.
1379
+ process.env.CLAUDECODE = "1";
1380
+ process.env.CURSOR_AGENT = "1";
1381
+ server.setEtag('"library-v1"');
1382
+ server.setLibraryResponse({
1383
+ skills: [
1384
+ makeSkill("alice", "a", "1.0.0"),
1385
+ makeSkill("alice", "b", "1.0.0"),
1386
+ ],
1387
+ removals: [],
1388
+ syncedAt: "2026-05-01T00:00:00Z",
1389
+ });
1390
+
1391
+ // Initial sync — both vendors get both skills.
1392
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1393
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", "a")));
1394
+ assert.ok(existsSync(resolvePlacementDir("agentsProject", "a")));
1395
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", "b")));
1396
+ assert.ok(existsSync(resolvePlacementDir("agentsProject", "b")));
1397
+
1398
+ // User manually nukes the entire claudeProject cohort. cursor's
1399
+ // placement at .agents/skills/ remains.
1400
+ rmSync(resolvePlacementDir("claudeProject", "a"), {
1401
+ recursive: true,
1402
+ force: true,
1403
+ });
1404
+ rmSync(resolvePlacementDir("claudeProject", "b"), {
1405
+ recursive: true,
1406
+ force: true,
1407
+ });
1408
+
1409
+ // Next update — server still 304s (same etag) — but the
1410
+ // placement-presence check fires on missing claudeProject
1411
+ // SKILL.md and forces full re-fetch.
1412
+ stdout = createCaptureStream();
1413
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1414
+
1415
+ assert.ok(
1416
+ existsSync(resolvePlacementDir("claudeProject", "a")),
1417
+ "claudeProject `a` must be restored after self-healing sync",
1418
+ );
1419
+ assert.ok(
1420
+ existsSync(resolvePlacementDir("claudeProject", "b")),
1421
+ "claudeProject `b` must be restored after self-healing sync",
1422
+ );
1423
+ // cursor's placements were never touched.
1424
+ assert.ok(existsSync(resolvePlacementDir("agentsProject", "a")));
1425
+ assert.ok(existsSync(resolvePlacementDir("agentsProject", "b")));
1426
+ });
1427
+
1428
+ it("forced re-fetch drops BOTH If-None-Match AND ?since= (BLOCKER from Round 2 code-review)", async () => {
1429
+ // The bug that almost shipped: PR #1575's first iteration dropped
1430
+ // `If-None-Match` when placements were incomplete but kept `since`
1431
+ // unconditionally. Production server filters by
1432
+ // `gt(skills.updatedAt, since)`, so a forced re-fetch would
1433
+ // return an empty delta for any skill not modified within the
1434
+ // window — leaving the newly-discovered missing placement empty
1435
+ // forever. Permanent ETag-miss loop.
1436
+ //
1437
+ // This test makes both wire-level conditions explicit. The
1438
+ // mock server now mirrors production's `since` filter (see
1439
+ // mock-server.mjs change in this commit), so a regression that
1440
+ // re-introduces the bug would fail the FULL-LIBRARY assertion
1441
+ // even before the wire-level check.
1442
+ process.env.CLAUDECODE = "1";
1443
+
1444
+ // Use an OLD updatedAt so the skill would be filtered out by
1445
+ // `since` if the bug regressed. The default "now" timestamp
1446
+ // would mask the issue.
1447
+ server.setEtag('"library-v1"');
1448
+ server.setLibraryResponse({
1449
+ skills: [makeSkill("alice", "stale-fixture", "1.0.0", "2025-01-01T00:00:00Z")],
1450
+ removals: [],
1451
+ syncedAt: "2026-05-01T00:00:00Z",
1452
+ });
1453
+
1454
+ // First sync: writes the skill to claudeProject.
1455
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1456
+ const skillDir = resolvePlacementDir("claudeProject", "stale-fixture");
1457
+ assert.ok(existsSync(join(skillDir, "SKILL.md")));
1458
+
1459
+ // Force the placement-incomplete path: delete the SKILL.md
1460
+ // (placementsAreComplete will return false on next sync).
1461
+ rmSync(skillDir, { recursive: true, force: true });
1462
+
1463
+ // Run update. Pre-fix: ETag dropped, since=2026-05-01 sent,
1464
+ // server filters by `gt(updatedAt='2025-01-01', since='2026-05-01')`,
1465
+ // returns empty skills array, runSync writes nothing, placement
1466
+ // stays empty. Post-fix: BOTH headers dropped, server returns
1467
+ // the full library, runSync re-writes everything.
1468
+ server.resetLibraryInspection();
1469
+ stdout = createCaptureStream();
1470
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1471
+
1472
+ // Outcome assertion: the skill is back on disk.
1473
+ assert.ok(
1474
+ existsSync(join(skillDir, "SKILL.md")),
1475
+ "stale-fixture must be restored — full re-fetch must return it even though updatedAt < syncedAt",
1476
+ );
1477
+
1478
+ // Wire-level assertions: BOTH headers dropped on the forced
1479
+ // re-fetch path. If either is sent, the bug has regressed.
1480
+ assert.equal(
1481
+ server.getLastLibraryIfNoneMatch(),
1482
+ null,
1483
+ "If-None-Match MUST be dropped when placements are incomplete",
1484
+ );
1485
+ assert.equal(
1486
+ server.getLastLibrarySince(),
1487
+ null,
1488
+ "?since= MUST also be dropped — otherwise server filters out unchanged skills " +
1489
+ "and the missing placement stays empty (the BLOCKER from Round 2 code-review)",
1490
+ );
1491
+ });
1492
+
1493
+ it("placement dir exists but SKILL.md is missing (partial placement) → next update self-heals", async () => {
1494
+ // Reviewer-flagged HIGH: `existsSync(dir)` alone would satisfy
1495
+ // the placement-presence check even for an empty/partial
1496
+ // directory. The fix probes for SKILL.md specifically. This
1497
+ // test locks in that behavior: a placement directory that
1498
+ // exists but lacks its SKILL.md is treated as MISSING and
1499
+ // forces a re-fetch.
1500
+ process.env.CLAUDECODE = "1";
1501
+ server.setEtag('"library-v1"');
1502
+ server.setLibraryResponse({
1503
+ skills: [makeSkill("alice", "partial", "1.0.0")],
1504
+ removals: [],
1505
+ syncedAt: "2026-05-01T00:00:00Z",
1506
+ });
1507
+
1508
+ // Initial sync writes SKILL.md.
1509
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1510
+ const dir = resolvePlacementDir("claudeProject", "partial");
1511
+ const skillMd = join(dir, "SKILL.md");
1512
+ assert.ok(existsSync(skillMd));
1513
+
1514
+ // User (or hostile tool) deletes just the SKILL.md, leaving the
1515
+ // directory.
1516
+ rmSync(skillMd);
1517
+ assert.ok(existsSync(dir), "dir still exists");
1518
+ assert.equal(existsSync(skillMd), false, "but SKILL.md is gone");
1519
+
1520
+ // Next update — placement-presence check sees no SKILL.md and
1521
+ // forces re-fetch.
1522
+ stdout = createCaptureStream();
1523
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1524
+ assert.ok(
1525
+ existsSync(skillMd),
1526
+ "SKILL.md must be restored — empty dirs must not satisfy the placement check",
1527
+ );
1528
+ });
1529
+
1530
+ it("vendor REMOVED from detected set → next update still 304s (no over-eager full re-fetch)", async () => {
1531
+ // Edge case: user uninstalls a tool between syncs. The detected
1532
+ // vendor set shrinks. The library content hasn't changed.
1533
+ // Question: should the next update do a full re-fetch?
1534
+ //
1535
+ // Answer (per the fix's design): no. We only invalidate the ETag
1536
+ // when the vendor set EXPANDED (added vendors need writes). A
1537
+ // shrunk set has no new writes to do — the remaining vendors
1538
+ // already have what they need. The orphaned placements (from
1539
+ // the removed vendor) are left in place; cleaning them up is a
1540
+ // separate concern.
1541
+ //
1542
+ // Pre-fix: same behavior (304 fires regardless of vendor change).
1543
+ // Post-fix: explicit policy — only invalidate on expansion.
1544
+ process.env.CLAUDECODE = "1";
1545
+ process.env.CURSOR_AGENT = "1";
1546
+ server.setEtag('"library-v1"');
1547
+ server.setLibraryResponse({
1548
+ skills: [makeSkill("alice", "shrinkable", "1.0.0")],
1549
+ removals: [],
1550
+ syncedAt: "2026-05-01T00:00:00Z",
1551
+ });
1552
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1553
+
1554
+ // "Uninstall" cursor — drop the env signal.
1555
+ delete process.env.CURSOR_AGENT;
1556
+
1557
+ stdout = createCaptureStream();
1558
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1559
+ assert.match(
1560
+ stdout.text(),
1561
+ /up to date/,
1562
+ "shrinking the vendor set should not force a wasteful re-fetch",
1563
+ );
1564
+ });
1565
+ });