skillrepo 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +215 -150
  2. package/bin/skillrepo.mjs +210 -36
  3. package/package.json +6 -3
  4. package/src/commands/add.mjs +176 -0
  5. package/src/commands/get.mjs +116 -0
  6. package/src/commands/init.mjs +471 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +167 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/update.mjs +67 -0
  11. package/src/lib/cli-config.mjs +230 -0
  12. package/src/lib/config.mjs +238 -0
  13. package/src/lib/detect-ides.mjs +0 -19
  14. package/src/lib/errors.mjs +264 -0
  15. package/src/lib/file-write.mjs +705 -0
  16. package/src/lib/http.mjs +817 -37
  17. package/src/lib/identifier.mjs +153 -0
  18. package/src/lib/mcp-merge.mjs +275 -0
  19. package/src/lib/mergers/gitignore.mjs +73 -18
  20. package/src/lib/paths.mjs +46 -17
  21. package/src/lib/prompt.mjs +11 -44
  22. package/src/lib/sync.mjs +305 -0
  23. package/src/test/commands/add.test.mjs +285 -0
  24. package/src/test/commands/get.test.mjs +176 -0
  25. package/src/test/commands/init.test.mjs +486 -0
  26. package/src/test/commands/list.test.mjs +172 -0
  27. package/src/test/commands/remove.test.mjs +234 -0
  28. package/src/test/commands/search.test.mjs +204 -0
  29. package/src/test/commands/update.test.mjs +164 -0
  30. package/src/test/detect-ides.test.mjs +9 -14
  31. package/src/test/dispatcher.test.mjs +224 -0
  32. package/src/test/e2e/cli-commands.test.mjs +576 -0
  33. package/src/test/e2e/mock-server.mjs +364 -22
  34. package/src/test/helpers/capture-stream.mjs +48 -0
  35. package/src/test/integration/file-write.integration.test.mjs +279 -0
  36. package/src/test/lib/cli-config.test.mjs +407 -0
  37. package/src/test/lib/config.test.mjs +257 -0
  38. package/src/test/lib/errors.test.mjs +359 -0
  39. package/src/test/lib/file-write.test.mjs +784 -0
  40. package/src/test/lib/http.test.mjs +1198 -0
  41. package/src/test/lib/identifier.test.mjs +157 -0
  42. package/src/test/lib/mcp-merge.test.mjs +345 -0
  43. package/src/test/lib/paths.test.mjs +83 -0
  44. package/src/test/lib/sync.test.mjs +514 -0
  45. package/src/test/mergers/gitignore.test.mjs +145 -20
  46. package/src/lib/write-configs.mjs +0 -202
  47. package/src/test/e2e/HANDOFF.md +0 -223
  48. package/src/test/e2e/cli-init.test.mjs +0 -213
  49. package/src/test/e2e/payload-factory.mjs +0 -22
@@ -0,0 +1,514 @@
1
+ /**
2
+ * Unit tests for src/lib/sync.mjs (PR2 of #646).
3
+ *
4
+ * Strategy: spin up an in-process HTTP mock server with mutable
5
+ * library response, point sync.runSync at it, assert on the resulting
6
+ * filesystem state and the returned summary.
7
+ *
8
+ * Coverage:
9
+ * • Empty library → no writes, summary all zeros
10
+ * • Single skill → writeSkillDir called, added++
11
+ * • Existing skill → writeSkillDir overwrites, updated++
12
+ * • Tombstone → removeSkillDir called, removed++
13
+ * • Both writes and tombstones in one sync
14
+ * • ETag round-trip → second call short-circuits 304
15
+ * • Last-sync state file persisted with correct schema
16
+ * • Last-sync state file read on second call
17
+ * • Corrupt last-sync state file is tolerated
18
+ * • filesIncomplete skill is skipped AND ETag is NOT persisted
19
+ * • Removals applied BEFORE writes (ordering matters for re-add)
20
+ * • runSync rejects missing serverUrl/apiKey
21
+ * • cleanupOrphans runs as part of sync (orphans gone after)
22
+ */
23
+
24
+ import { describe, it, beforeEach, afterEach } from "node:test";
25
+ import assert from "node:assert/strict";
26
+ import {
27
+ mkdtempSync,
28
+ rmSync,
29
+ mkdirSync,
30
+ writeFileSync,
31
+ existsSync,
32
+ readFileSync,
33
+ } from "node:fs";
34
+ import { join } from "node:path";
35
+ import { tmpdir } from "node:os";
36
+
37
+ import { runSync, readLastSync, writeLastSync, LAST_SYNC_SCHEMA_VERSION } from "../../lib/sync.mjs";
38
+ import { resolvePlacementDir } from "../../lib/file-write.mjs";
39
+ import { globalLastSyncPath } from "../../lib/paths.mjs";
40
+ import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
41
+ import { createMockServer } from "../e2e/mock-server.mjs";
42
+
43
+ let sandbox;
44
+ let originalCwd;
45
+ let originalHome;
46
+ let server;
47
+ let serverUrl;
48
+ const VALID_KEY = "sk_live_test123";
49
+
50
+ function makeSkill(name, content = `# ${name}\n`) {
51
+ return {
52
+ owner: "alice",
53
+ name,
54
+ version: "1.0.0",
55
+ description: `${name} skill`,
56
+ files: [
57
+ {
58
+ path: "SKILL.md",
59
+ content: `---\nname: ${name}\ndescription: Test skill ${name}\n---\n\n${content}`,
60
+ sha256: "x",
61
+ size: content.length,
62
+ contentType: "text/markdown",
63
+ },
64
+ ],
65
+ updatedAt: new Date().toISOString(),
66
+ };
67
+ }
68
+
69
+ async function setupServer() {
70
+ sandbox = mkdtempSync(join(tmpdir(), "cli-sync-"));
71
+ mkdirSync(join(sandbox, "project"), { recursive: true });
72
+ mkdirSync(join(sandbox, "home"), { recursive: true });
73
+ originalCwd = process.cwd();
74
+ originalHome = process.env.HOME;
75
+ process.chdir(join(sandbox, "project"));
76
+ process.env.HOME = join(sandbox, "home");
77
+
78
+ server = createMockServer({});
79
+ const port = await server.start();
80
+ serverUrl = `http://127.0.0.1:${port}`;
81
+ }
82
+
83
+ async function teardownServer() {
84
+ if (server) await server.stop();
85
+ process.chdir(originalCwd);
86
+ process.env.HOME = originalHome;
87
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
88
+ server = null;
89
+ }
90
+
91
+ // ── Last-sync state file ───────────────────────────────────────────────
92
+
93
+ describe("readLastSync / writeLastSync", () => {
94
+ beforeEach(setupServer);
95
+ afterEach(teardownServer);
96
+
97
+ it("returns null when state file does not exist", () => {
98
+ assert.equal(readLastSync(), null);
99
+ });
100
+
101
+ it("round-trips a valid state file", () => {
102
+ writeLastSync({ etag: '"abc123"', syncedAt: "2025-01-01T00:00:00Z" });
103
+ const result = readLastSync();
104
+ assert.equal(result.etag, '"abc123"');
105
+ assert.equal(result.syncedAt, "2025-01-01T00:00:00Z");
106
+ assert.equal(result.schemaVersion, LAST_SYNC_SCHEMA_VERSION);
107
+ });
108
+
109
+ it("returns null on corrupt JSON", () => {
110
+ const path = globalLastSyncPath();
111
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
112
+ writeFileSync(path, "not json {{{");
113
+ assert.equal(readLastSync(), null);
114
+ });
115
+
116
+ it("returns null on unknown schema version", () => {
117
+ writeLastSync({ etag: "x", syncedAt: "x" });
118
+ const path = globalLastSyncPath();
119
+ const obj = JSON.parse(readFileSync(path, "utf-8"));
120
+ obj.schemaVersion = 999;
121
+ writeFileSync(path, JSON.stringify(obj));
122
+ assert.equal(readLastSync(), null);
123
+ });
124
+
125
+ it("creates the parent directory if missing", () => {
126
+ rmSync(join(process.env.HOME, ".claude"), { recursive: true, force: true });
127
+ writeLastSync({ etag: "x", syncedAt: "x" });
128
+ assert.ok(existsSync(globalLastSyncPath()));
129
+ });
130
+ });
131
+
132
+ // ── runSync — empty library ────────────────────────────────────────────
133
+
134
+ describe("runSync — empty library", () => {
135
+ beforeEach(setupServer);
136
+ afterEach(teardownServer);
137
+
138
+ it("returns all-zero summary for empty server response", async () => {
139
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
140
+ const result = await runSync({
141
+ serverUrl,
142
+ apiKey: VALID_KEY,
143
+ vendors: ["claudeCode"],
144
+ });
145
+ assert.equal(result.added, 0);
146
+ assert.equal(result.updated, 0);
147
+ assert.equal(result.removed, 0);
148
+ assert.equal(result.notModified, false);
149
+ });
150
+ });
151
+
152
+ // ── runSync — write skills ─────────────────────────────────────────────
153
+
154
+ describe("runSync — write skills", () => {
155
+ beforeEach(setupServer);
156
+ afterEach(teardownServer);
157
+
158
+ it("writes a new skill and increments added", async () => {
159
+ server.setLibraryResponse({
160
+ skills: [makeSkill("pdf-helper")],
161
+ removals: [],
162
+ syncedAt: "2025-01-01T00:00:00Z",
163
+ });
164
+ const result = await runSync({
165
+ serverUrl,
166
+ apiKey: VALID_KEY,
167
+ vendors: ["claudeCode"],
168
+ });
169
+ assert.equal(result.added, 1);
170
+ assert.equal(result.updated, 0);
171
+
172
+ const dir = resolvePlacementDir("claudeProject", "pdf-helper");
173
+ assert.ok(existsSync(join(dir, "SKILL.md")));
174
+ });
175
+
176
+ it("overwrites an existing skill and increments updated", async () => {
177
+ // First sync writes the skill
178
+ server.setLibraryResponse({
179
+ skills: [makeSkill("pdf-helper", "version 1")],
180
+ removals: [],
181
+ syncedAt: "2025-01-01T00:00:00Z",
182
+ });
183
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
184
+
185
+ // Clear state so the second sync doesn't 304-short-circuit
186
+ rmSync(globalLastSyncPath(), { force: true });
187
+
188
+ // Second sync overwrites with new content
189
+ server.setLibraryResponse({
190
+ skills: [makeSkill("pdf-helper", "version 2")],
191
+ removals: [],
192
+ syncedAt: "2025-01-02T00:00:00Z",
193
+ });
194
+ const result = await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
195
+ assert.equal(result.added, 0);
196
+ assert.equal(result.updated, 1);
197
+
198
+ const dir = resolvePlacementDir("claudeProject", "pdf-helper");
199
+ const skillMd = readFileSync(join(dir, "SKILL.md"), "utf-8");
200
+ assert.match(skillMd, /version 2/);
201
+ });
202
+
203
+ it("writes multiple skills in one sync", async () => {
204
+ server.setLibraryResponse({
205
+ skills: [makeSkill("first"), makeSkill("second"), makeSkill("third")],
206
+ removals: [],
207
+ syncedAt: "x",
208
+ });
209
+ const result = await runSync({
210
+ serverUrl,
211
+ apiKey: VALID_KEY,
212
+ vendors: ["claudeCode"],
213
+ });
214
+ assert.equal(result.added, 3);
215
+ });
216
+
217
+ it("skips a filesIncomplete skill", async () => {
218
+ const incomplete = makeSkill("incomplete");
219
+ incomplete.filesIncomplete = true;
220
+ server.setLibraryResponse({
221
+ skills: [incomplete, makeSkill("complete")],
222
+ removals: [],
223
+ syncedAt: "x",
224
+ });
225
+ const result = await runSync({
226
+ serverUrl,
227
+ apiKey: VALID_KEY,
228
+ vendors: ["claudeCode"],
229
+ });
230
+ // Only `complete` was written
231
+ assert.equal(result.added, 1);
232
+ const incompleteDir = resolvePlacementDir("claudeProject", "incomplete");
233
+ assert.ok(!existsSync(incompleteDir));
234
+ const completeDir = resolvePlacementDir("claudeProject", "complete");
235
+ assert.ok(existsSync(completeDir));
236
+ });
237
+
238
+ it("does NOT persist ETag when any skill is filesIncomplete", async () => {
239
+ const incomplete = makeSkill("incomplete");
240
+ incomplete.filesIncomplete = true;
241
+ server.setEtag('"v1"');
242
+ server.setLibraryResponse({
243
+ skills: [incomplete],
244
+ removals: [],
245
+ syncedAt: "x",
246
+ });
247
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
248
+ // The next sync would re-fetch instead of short-circuiting
249
+ assert.equal(readLastSync(), null);
250
+ });
251
+ });
252
+
253
+ // ── runSync — tombstones ───────────────────────────────────────────────
254
+
255
+ describe("runSync — tombstones", () => {
256
+ beforeEach(setupServer);
257
+ afterEach(teardownServer);
258
+
259
+ it("applies a tombstone and increments removed", async () => {
260
+ // Pre-write a skill
261
+ server.setLibraryResponse({
262
+ skills: [makeSkill("doomed")],
263
+ removals: [],
264
+ syncedAt: "2025-01-01T00:00:00Z",
265
+ });
266
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
267
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", "doomed")));
268
+
269
+ // Now the server says it's removed (the response has the tombstone
270
+ // AND the skill is no longer in skills[])
271
+ rmSync(globalLastSyncPath(), { force: true });
272
+ server.setLibraryResponse({
273
+ skills: [],
274
+ removals: [{ owner: "alice", name: "doomed", removedAt: "2025-01-02T00:00:00Z" }],
275
+ syncedAt: "2025-01-02T00:00:00Z",
276
+ });
277
+ const result = await runSync({
278
+ serverUrl,
279
+ apiKey: VALID_KEY,
280
+ vendors: ["claudeCode"],
281
+ });
282
+ assert.equal(result.removed, 1);
283
+ assert.ok(!existsSync(resolvePlacementDir("claudeProject", "doomed")));
284
+ });
285
+
286
+ it("applies removals BEFORE writes (re-add scenario)", async () => {
287
+ // The server returns a tombstone AND a skill with the same name
288
+ // (the user removed and re-added in the same window). The CLI
289
+ // must apply the tombstone first so the new skill survives.
290
+ server.setLibraryResponse({
291
+ skills: [makeSkill("phoenix", "freshly re-added")],
292
+ removals: [{ owner: "alice", name: "phoenix", removedAt: "x" }],
293
+ syncedAt: "x",
294
+ });
295
+ await runSync({
296
+ serverUrl,
297
+ apiKey: VALID_KEY,
298
+ vendors: ["claudeCode"],
299
+ });
300
+ // The phoenix skill should EXIST because the write came after
301
+ // the removal, not the other way around.
302
+ const dir = resolvePlacementDir("claudeProject", "phoenix");
303
+ assert.ok(existsSync(join(dir, "SKILL.md")));
304
+ const skillMd = readFileSync(join(dir, "SKILL.md"), "utf-8");
305
+ assert.match(skillMd, /freshly re-added/);
306
+ });
307
+ });
308
+
309
+ // ── runSync — ETag short-circuit ───────────────────────────────────────
310
+
311
+ describe("runSync — ETag round-trip", () => {
312
+ beforeEach(setupServer);
313
+ afterEach(teardownServer);
314
+
315
+ it("first sync persists ETag, second sync 304s", async () => {
316
+ server.setEtag('"v1"');
317
+ server.setLibraryResponse({
318
+ skills: [makeSkill("first")],
319
+ removals: [],
320
+ syncedAt: "2025-01-01T00:00:00Z",
321
+ });
322
+
323
+ const first = await runSync({
324
+ serverUrl,
325
+ apiKey: VALID_KEY,
326
+ vendors: ["claudeCode"],
327
+ });
328
+ assert.equal(first.added, 1);
329
+ assert.equal(first.notModified, false);
330
+
331
+ // Second sync with same ETag → 304
332
+ const second = await runSync({
333
+ serverUrl,
334
+ apiKey: VALID_KEY,
335
+ vendors: ["claudeCode"],
336
+ });
337
+ assert.equal(second.notModified, true);
338
+ assert.equal(second.added, 0);
339
+ assert.equal(second.updated, 0);
340
+ assert.equal(second.removed, 0);
341
+ });
342
+
343
+ it("304 short-circuit does not delete or modify on-disk skills", async () => {
344
+ server.setEtag('"v1"');
345
+ server.setLibraryResponse({
346
+ skills: [makeSkill("staying")],
347
+ removals: [],
348
+ syncedAt: "x",
349
+ });
350
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
351
+
352
+ const dir = resolvePlacementDir("claudeProject", "staying");
353
+ const before = readFileSync(join(dir, "SKILL.md"), "utf-8");
354
+
355
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
356
+ const after = readFileSync(join(dir, "SKILL.md"), "utf-8");
357
+ assert.equal(before, after);
358
+ });
359
+ });
360
+
361
+ // ── runSync — argument validation ──────────────────────────────────────
362
+
363
+ describe("runSync — input validation", () => {
364
+ beforeEach(setupServer);
365
+ afterEach(teardownServer);
366
+
367
+ it("throws validationError when serverUrl is missing", async () => {
368
+ await assert.rejects(
369
+ () => runSync({ serverUrl: "", apiKey: VALID_KEY, vendors: ["claudeCode"] }),
370
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
371
+ );
372
+ });
373
+
374
+ it("throws validationError when apiKey is missing", async () => {
375
+ await assert.rejects(
376
+ () => runSync({ serverUrl, apiKey: "", vendors: ["claudeCode"] }),
377
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
378
+ );
379
+ });
380
+
381
+ it("accepts io: null without crashing (round-2 review fix)", async () => {
382
+ // Regression for the round-2 finding both reviewers caught: the
383
+ // destructure default `io = {}` only handles `undefined`, so an
384
+ // explicit `io: null` would crash on the .stderr access. The
385
+ // sync.mjs fix coalesces null → {} via `io ?? {}`. This test
386
+ // confirms it.
387
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
388
+ await runSync({
389
+ serverUrl,
390
+ apiKey: VALID_KEY,
391
+ vendors: ["claudeCode"],
392
+ io: null,
393
+ });
394
+ // No throw → pass
395
+ });
396
+
397
+ it("accepts io: undefined as if io was omitted", async () => {
398
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
399
+ await runSync({
400
+ serverUrl,
401
+ apiKey: VALID_KEY,
402
+ vendors: ["claudeCode"],
403
+ io: undefined,
404
+ });
405
+ });
406
+ });
407
+
408
+ describe("runSync — io.stderr injection (round-3 coverage fix)", () => {
409
+ beforeEach(setupServer);
410
+ afterEach(teardownServer);
411
+
412
+ it("forwards the writeLastSync failure warning to io.stderr (not process.stderr)", { skip: process.platform === "win32" || process.getuid?.() === 0 }, async () => {
413
+ // Round-3 review found that the previous io: null test didn't
414
+ // actually exercise the writeLastSync failure path because the
415
+ // fixture had no ETag, so the `stderr.write(...)` branch was
416
+ // never reached. This test arranges for ALL of:
417
+ // 1. Server response has an ETag (so the persist branch runs)
418
+ // 2. writeLastSync is forced to fail (state dir is read-only)
419
+ // 3. io.stderr is an injected capture stream
420
+ // and asserts the warning lands on the injection target, NOT
421
+ // on process.stderr.
422
+
423
+ const { chmodSync } = await import("node:fs");
424
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
425
+ const { globalLastSyncPath } = await import("../../lib/paths.mjs");
426
+ const { dirname } = await import("node:path");
427
+
428
+ // Pre-create the state dir, then make it read-only so writeLastSync
429
+ // fails on the writeFileSync call.
430
+ const stateDir = dirname(globalLastSyncPath());
431
+ const { mkdirSync } = await import("node:fs");
432
+ mkdirSync(stateDir, { recursive: true });
433
+ chmodSync(stateDir, 0o555);
434
+
435
+ server.setEtag('"v1"');
436
+ server.setLibraryResponse({
437
+ skills: [makeSkill("with-etag")],
438
+ removals: [],
439
+ syncedAt: "2025-01-01T00:00:00Z",
440
+ });
441
+
442
+ const stderr = createCaptureStream();
443
+ try {
444
+ await runSync({
445
+ serverUrl,
446
+ apiKey: VALID_KEY,
447
+ vendors: ["claudeCode"],
448
+ io: { stderr },
449
+ });
450
+ } finally {
451
+ // Restore writable permissions so afterEach can clean up
452
+ chmodSync(stateDir, 0o755);
453
+ }
454
+
455
+ // The warning must have landed on the injected stream
456
+ assert.match(stderr.text(), /failed to persist last-sync state/);
457
+ assert.match(stderr.text(), /Next sync will be a full fetch/);
458
+ });
459
+
460
+ it("falls back to process.stderr when io.stderr is not provided", async () => {
461
+ // The flip side: when no io is passed, the warning still goes
462
+ // somewhere (process.stderr) rather than crashing. We don't
463
+ // assert on process.stderr's contents (that would require
464
+ // monkey-patching, which conflicts with node:test's IPC), but
465
+ // we do assert that runSync completes successfully.
466
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
467
+ await runSync({
468
+ serverUrl,
469
+ apiKey: VALID_KEY,
470
+ vendors: ["claudeCode"],
471
+ });
472
+ // No throw → pass; the happy-path uses no warning so this is
473
+ // also covered by all the other tests, but explicit is good.
474
+ });
475
+ });
476
+
477
+ // ── runSync — orphan cleanup ───────────────────────────────────────────
478
+
479
+ describe("runSync — orphan cleanup", () => {
480
+ beforeEach(setupServer);
481
+ afterEach(teardownServer);
482
+
483
+ it("cleans orphan .tmp/ with a live sibling before writing", async () => {
484
+ // Pre-write a skill so a stale .tmp/ has a live sibling
485
+ server.setLibraryResponse({
486
+ skills: [makeSkill("clean-me")],
487
+ removals: [],
488
+ syncedAt: "x",
489
+ });
490
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
491
+
492
+ // Inject a stale .tmp/ from a "previous crash"
493
+ const root = join(process.cwd(), ".claude", "skills");
494
+ mkdirSync(join(root, "clean-me.tmp"));
495
+ writeFileSync(join(root, "clean-me.tmp", "garbage.txt"), "x");
496
+
497
+ // Run sync again — the orphan cleanup should fire first
498
+ rmSync(globalLastSyncPath(), { force: true });
499
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
500
+ assert.ok(!existsSync(join(root, "clean-me.tmp")));
501
+ });
502
+
503
+ it("preserves orphan .tmp/ with NO live sibling (Windows recovery)", async () => {
504
+ // No live sibling — this .tmp/ is the user's only copy of a
505
+ // crashed-mid-rename skill. cleanupOrphans must preserve it.
506
+ const root = join(process.cwd(), ".claude", "skills");
507
+ mkdirSync(join(root, "recoverable.tmp"), { recursive: true });
508
+ writeFileSync(join(root, "recoverable.tmp", "SKILL.md"), "x");
509
+
510
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
511
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
512
+ assert.ok(existsSync(join(root, "recoverable.tmp", "SKILL.md")));
513
+ });
514
+ });