skillrepo 4.17.2 → 4.18.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.
package/README.md CHANGED
@@ -166,11 +166,18 @@ skillrepo update [--global] [--agent <list>] [--json] [--silent]
166
166
  ```
167
167
 
168
168
  Pulls the latest state of your library from the server using a delta
169
- sync. Writes new and updated skills, removes skills that were removed
170
- from the library, and skips skills that are unchanged. In a repo that
171
- declares a skillset, `update` instead reconciles that declared set (a
172
- full-set resolve, not a delta). Uses ETag caching so repeat runs return
173
- `304 Not Modified` when nothing has changed.
169
+ sync. Writes new and updated skills, removes skills that left your
170
+ library, and skips skills that are unchanged. Removals apply on both the
171
+ delta sync and the periodic full re-sync (the one that runs on a first
172
+ sync, after a CLI upgrade, or when a new editor is detected), so a skill
173
+ your team recalled or deleted stops loading everywhere rather than
174
+ lingering on disk. If you had edited a removed skill locally, its folder
175
+ isn't discarded — it is moved to a quarantine area
176
+ (`~/.claude/skillrepo/quarantine/`) with a warning, so it leaves the
177
+ folders your agents load while your changes are preserved. In a repo
178
+ that declares a skillset, `update` instead reconciles that declared set
179
+ (a full-set resolve, not a delta). Uses ETag caching so repeat runs
180
+ return `304 Not Modified` when nothing has changed.
174
181
 
175
182
  `--silent` suppresses normal output and writes a single `{}` line to
176
183
  stdout on success. Designed for SessionStart hooks that pipe stdout
@@ -221,9 +228,12 @@ receives its declared skillset. Skillset-scoped sync resolves the
221
228
  complete set on every pass: skills added to the skillset appear,
222
229
  skills removed from it are deleted from the repo's skill folders, and
223
230
  everything lands at the repository root no matter which subdirectory
224
- you run from. Deletes are guarded — a directory the CLI did not write,
225
- or one you modified locally, is never removed or overwritten; it stays
226
- put with a warning and shows up in your team's sync reporting instead.
231
+ you run from. Deletes are guarded — a directory the CLI did not write is
232
+ never removed or overwritten; it stays put with a warning and shows up
233
+ in your team's sync reporting instead. A skill the CLI wrote that you
234
+ then edited is moved to the quarantine area
235
+ (`~/.claude/skillrepo/quarantine/`) rather than deleted when it leaves
236
+ the set, so it stops loading while your edits are preserved.
227
237
  Declared entries in `extra` that cannot be honored (for example when
228
238
  extras are not enabled for your organization) are skipped loudly with
229
239
  the reason, never silently. If the registry is unreachable, the repo
@@ -652,9 +662,11 @@ Skills land at one of two project paths, depending on the agent:
652
662
  | Cursor / Windsurf / Gemini CLI / Codex CLI / Cline / GitHub Copilot | `.agents/skills/<name>/` | `~/.agents/skills/<name>/` |
653
663
  | Windsurf (personal-scope override) | — | `~/.codeium/windsurf/skills/<name>/` |
654
664
 
655
- The CLI keeps `.claude/skills/` and `.agents/skills/` listed in the
656
- SkillRepo section of your project `.gitignore`, re-checked on every
657
- sync — skills are a per-developer cache, not committed content. The `--agent` flag overrides detection (e.g.
665
+ The CLI keeps `.claude/skills/` and `.agents/skills/` gitignored,
666
+ re-checked on every sync: an entry is added to the SkillRepo section of
667
+ your project `.gitignore` only when no existing rule already ignores
668
+ that path (a repo that ignores `.claude/` gets no `.claude/skills/`
669
+ line) — skills are a per-developer cache, not committed content. The `--agent` flag overrides detection (e.g.
658
670
  `--agent claude,agents` writes both paths). See
659
671
  [`docs/vendor-paths.md`](docs/vendor-paths.md) for primary-source
660
672
  citations on each agent's read paths.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.17.2",
3
+ "version": "4.18.1",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library, synced across your dev environment",
5
5
  "type": "module",
6
6
  "bin": {
@@ -443,7 +443,8 @@ export async function runInit(argv, io = {}, deps = {}) {
443
443
  `.gitignore updated (added: ${gitignoreResult.added.join(", ")})`,
444
444
  );
445
445
  }
446
- // "skipped" → all entries already present, no output needed
446
+ // "skipped" → every entry already present (or, for the skill roots,
447
+ // already ignored by an existing rule — #2903), no output needed
447
448
  } catch (err) {
448
449
  p.warning(
449
450
  `Could not update .gitignore: ${err?.message ?? String(err)}. ` +
@@ -1077,7 +1078,7 @@ async function runDetectionPicker({ yes, json, stdout, detections }) {
1077
1078
  " .agents/skills/ (for Cursor, Windsurf, Gemini CLI, Codex CLI, " +
1078
1079
  "Cline, Copilot, and others)\n" +
1079
1080
  "\n" +
1080
- " Both paths are added to .gitignore — skills are a per-developer " +
1081
+ " Both paths are gitignored — skills are a per-developer " +
1081
1082
  "cache, not committed.\n\n",
1082
1083
  );
1083
1084
  }
@@ -172,6 +172,11 @@ export async function runUpdate(argv, io = {}) {
172
172
  // gates output ignores skips, a quarantined skill produces no output
173
173
  // anywhere and exits 0 (#2413 adversarial review).
174
174
  const skipped = summary.skipped ?? 0;
175
+ // Quarantines (#2804) are a subset of `removed`, so they're already in
176
+ // `total`; surfaced separately below so this black-holed hook vector —
177
+ // where the per-skill quarantine warning is lost — still tells the
178
+ // user a removed skill they'd EDITED was moved (not deleted) and where.
179
+ const quarantined = summary.quarantined ?? 0;
175
180
  const total =
176
181
  summary.added + summary.updated + summary.removed + skipped;
177
182
  const subject = syncSubject(summary);
@@ -236,6 +241,9 @@ export async function runUpdate(argv, io = {}) {
236
241
  }
237
242
  stdout.write(
238
243
  `[SkillRepo] ${subject} synced: ${summary.added} added, ${summary.updated} updated, ${summary.removed} removed` +
244
+ (quarantined > 0
245
+ ? ` (${quarantined} edited, moved to ~/.claude/skillrepo/quarantine/)`
246
+ : "") +
239
247
  (skipped > 0 ? `, ${skipped} SKIPPED (could not be written)` : "") +
240
248
  `.\n`,
241
249
  );
@@ -366,6 +374,11 @@ function printSummary(s, out) {
366
374
  if (s.added > 0) out.write(` + ${s.added} added\n`);
367
375
  if (s.updated > 0) out.write(` ↻ ${s.updated} updated\n`);
368
376
  if (s.removed > 0) out.write(` − ${s.removed} removed\n`);
377
+ const quarantined = s.quarantined ?? 0;
378
+ if (quarantined > 0)
379
+ out.write(
380
+ ` (${quarantined} of those were edited — moved to ~/.claude/skillrepo/quarantine/, your changes preserved)\n`,
381
+ );
369
382
  if (skipped > 0) out.write(` ! ${skipped} skipped (see warnings above)\n`);
370
383
  out.write("\n");
371
384
  }
@@ -51,9 +51,11 @@ import {
51
51
  writeFileSync,
52
52
  renameSync,
53
53
  rmSync,
54
+ cpSync,
54
55
  readdirSync,
55
56
  statSync,
56
57
  } from "node:fs";
58
+ import { randomBytes } from "node:crypto";
57
59
  import { dirname, join, isAbsolute, relative } from "node:path";
58
60
 
59
61
  import {
@@ -68,6 +70,7 @@ import {
68
70
  windsurfSkillsGlobal,
69
71
  windsurfSkillsGlobalRoot,
70
72
  gitignorePath,
73
+ globalQuarantineDir,
71
74
  } from "./paths.mjs";
72
75
  import { AGENT_REGISTRY, getAgentByKey } from "./agent-registry.mjs";
73
76
  import { ensureManagedRootIgnores } from "./gitignore-section.mjs";
@@ -372,6 +375,34 @@ export function placementTargetsFor({ vendors, global = false }) {
372
375
  return targets;
373
376
  }
374
377
 
378
+ /**
379
+ * Placement targets for `vendors` in the given scope, SKIPPING any vendor
380
+ * that doesn't support it — unlike `placementTargetsFor`, which throws.
381
+ * For READ-ONLY probing where a partial answer is correct and an
382
+ * unsupported vendor should contribute nothing rather than fail the whole
383
+ * resolve: the full-sync reconcile (#2804) uses it to check whether a
384
+ * recalled skill still lives in the OTHER scope (`.last-sync` is
385
+ * machine-global and scope-agnostic) before dropping its state key, and a
386
+ * vendor with no global scope (e.g. VS Code + Copilot) simply has no
387
+ * global dir to probe. Never use this to decide WRITES/DELETES — those
388
+ * must use the strict `placementTargetsFor` so a misconfigured scope fails
389
+ * loudly.
390
+ *
391
+ * @param {string[]} vendors
392
+ * @param {boolean} global
393
+ * @returns {PlacementTarget[]}
394
+ */
395
+ export function placementTargetsForScopeLenient(vendors, global) {
396
+ const targets = [];
397
+ for (const vendor of Array.isArray(vendors) ? vendors : []) {
398
+ const entry = getAgentByKey(vendor);
399
+ if (!entry) continue;
400
+ const target = global ? entry.globalTarget : entry.projectTarget;
401
+ if (target && !targets.includes(target)) targets.push(target);
402
+ }
403
+ return targets;
404
+ }
405
+
375
406
  /**
376
407
  * Find the SKILL.md file inside a skill's files array and return its
377
408
  * frontmatter `name` value, or null if none could be parsed.
@@ -435,11 +466,13 @@ export function writeSkillDir(skill, options = {}) {
435
466
  const targets = placementTargetsFor(options);
436
467
 
437
468
  // Pre-flight: if any target writes to a project-scope managed root
438
- // (.claude/skills/, .agents/skills/), ensure the root's blanket
439
- // ignore is present in the CLI's marked .gitignore section before
440
- // starting any writes (#2361 — heal on every sync, both roots; the
469
+ // (.claude/skills/, .agents/skills/), ensure the root is gitignored
470
+ // before starting any writes — the blanket entry lands in the CLI's
471
+ // marked .gitignore section only when no existing rule already covers
472
+ // the root (#2903: a repo ignoring `.claude/` gets no redundant
473
+ // `.claude/skills/` line). #2361 — heal on every sync, both roots; the
441
474
  // pre-4.10.0 code covered only .agents/skills/ under a separate
442
- // header). A failure here is recoverable (user fixes their
475
+ // header. A failure here is recoverable (user fixes their
443
476
  // .gitignore and re-runs) because nothing has hit disk yet. A
444
477
  // failure here AFTER a successful write to an earlier target would
445
478
  // leave the user with a half-applied state and an error message
@@ -463,6 +496,68 @@ export function writeSkillDir(skill, options = {}) {
463
496
  return { written };
464
497
  }
465
498
 
499
+ /**
500
+ * Run a filesystem mutation, retrying briefly on the transient lock
501
+ * errors Windows raises (EBUSY / EPERM / ENOTEMPTY) when another process
502
+ * — antivirus, an editor, a vendor agent mid-scan — holds a handle on a
503
+ * file inside the target dir. The op typically succeeds a few ms later
504
+ * once the handle is released; POSIX effectively never hits this. After
505
+ * the last delay the ORIGINAL error is rethrown, so a genuine permission
506
+ * failure still surfaces. `removeSkillDir` / `removeManagedDir` /
507
+ * `quarantineSkillDir` route their rm/rename/copy through here — a
508
+ * compliance delete (#2804) must not silently fail on a momentary lock.
509
+ * Delays are injectable so unit tests exercise the retry loop without
510
+ * real sleeps.
511
+ *
512
+ * @template T
513
+ * @param {() => T} op
514
+ * @param {number[]} [delaysMs]
515
+ * @returns {T}
516
+ */
517
+ export function withFsRetry(op, delaysMs = [20, 50, 100, 200]) {
518
+ for (let attempt = 0; ; attempt++) {
519
+ try {
520
+ return op();
521
+ } catch (err) {
522
+ const transient =
523
+ !!err &&
524
+ (err.code === "EBUSY" ||
525
+ err.code === "EPERM" ||
526
+ err.code === "ENOTEMPTY");
527
+ if (!transient || attempt >= delaysMs.length) throw err;
528
+ const ms = delaysMs[attempt];
529
+ // Synchronous sleep: this whole pipeline is sync (rmSync/renameSync),
530
+ // so there is no event loop to yield to. Atomics.wait on a throwaway
531
+ // SharedArrayBuffer blocks the thread for `ms` without a busy spin.
532
+ if (ms > 0) {
533
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
534
+ }
535
+ }
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Recursively remove a single resolved placement directory, with the
541
+ * Windows-lock retry above. Returns true if the dir existed and was
542
+ * removed, false if it was already absent. Throws a typed disk error on
543
+ * a genuine failure. The per-directory primitive shared by
544
+ * `removeSkillDir` (all targets of a name) and the full-sync reconcile
545
+ * (#2804, one target at a time, alongside quarantine).
546
+ *
547
+ * @param {string} dir
548
+ * @returns {boolean}
549
+ */
550
+ export function removeManagedDir(dir) {
551
+ if (!existsSync(dir)) return false;
552
+ try {
553
+ withFsRetry(() => rmSync(dir, { recursive: true, force: true }));
554
+ } catch (err) {
555
+ if (err instanceof CliError) throw err;
556
+ throw diskError(`Failed to remove ${dir}: ${err.message}`, { cause: err });
557
+ }
558
+ return true;
559
+ }
560
+
466
561
  /**
467
562
  * Remove a skill directory from all configured placement targets.
468
563
  *
@@ -487,21 +582,71 @@ export function removeSkillDir(skillName, options = {}) {
487
582
 
488
583
  for (const target of targets) {
489
584
  const targetDir = resolvePlacementDir(target, skillName, options.baseDir);
490
- if (!existsSync(targetDir)) {
491
- notFound.push(targetDir);
492
- continue;
493
- }
494
- try {
495
- rmSync(targetDir, { recursive: true, force: true });
585
+ if (removeManagedDir(targetDir)) {
496
586
  removed.push(targetDir);
497
- } catch (err) {
498
- throw diskError(`Failed to remove ${targetDir}: ${err.message}`, { cause: err });
587
+ } else {
588
+ notFound.push(targetDir);
499
589
  }
500
590
  }
501
591
 
502
592
  return { removed, notFound };
503
593
  }
504
594
 
595
+ /**
596
+ * Move a skill's loadable placement directory into the non-loaded
597
+ * quarantine area (`~/.claude/skillrepo/quarantine/`), preserving its
598
+ * contents, and return the destination path.
599
+ *
600
+ * The full-sync reconcile (#2804) calls this for a recalled / removed
601
+ * skill whose on-disk dir the CLI provably wrote (it is in the recorded
602
+ * write baseline) but which has since been locally EDITED. Deleting it
603
+ * would lose the user's edits; leaving it in place would keep the
604
+ * recalled skill loadable by agents — the exact compliance hole recall
605
+ * exists to close. Quarantine resolves both: the loadable copy goes away,
606
+ * the edited content survives outside every vendor's scan path. Unmodified
607
+ * recalled skills are deleted outright (`removeManagedDir`); a dir the CLI
608
+ * never wrote is never quarantined (the reconcile guards on the baseline).
609
+ *
610
+ * Destination names are uniquely stamped so quarantining the same skill
611
+ * from several placement targets in one sync never collides.
612
+ *
613
+ * @param {string} sourceDir Absolute path of the loadable dir to move.
614
+ * @param {object} opts
615
+ * @param {string} opts.skillName Spec-valid skill name (caller-validated).
616
+ * @returns {string} The quarantine destination path.
617
+ */
618
+ export function quarantineSkillDir(sourceDir, { skillName } = {}) {
619
+ // Local state / server payloads are untrusted: never build a path from
620
+ // an invalid name (a traversal shape must never reach `join`).
621
+ if (!isValidSkillName(skillName)) {
622
+ throw validationError(`Invalid skill name: "${skillName}"`);
623
+ }
624
+
625
+ const root = globalQuarantineDir();
626
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
627
+ const dest = join(root, `${skillName}-${stamp}-${randomBytes(4).toString("hex")}`);
628
+
629
+ try {
630
+ withFsRetry(() => mkdirSync(root, { recursive: true }));
631
+ // Copy-then-remove rather than rename. The quarantine root lives under
632
+ // HOME while the placement dir lives under the repo, which can be a
633
+ // different filesystem (a mounted volume) — renameSync can't cross
634
+ // devices (EXDEV). Copying first and removing the loadable copy second
635
+ // is also the fail-safe order: if the remove fails, the edits are
636
+ // already preserved in quarantine (worst case the loadable dir lingers
637
+ // and is re-quarantined next sync), never lost.
638
+ withFsRetry(() => cpSync(sourceDir, dest, { recursive: true }));
639
+ withFsRetry(() => rmSync(sourceDir, { recursive: true, force: true }));
640
+ } catch (err) {
641
+ if (err instanceof CliError) throw err;
642
+ throw diskError(`Failed to quarantine ${sourceDir}: ${err.message}`, {
643
+ cause: err,
644
+ });
645
+ }
646
+
647
+ return dest;
648
+ }
649
+
505
650
  /**
506
651
  * Scan all configured placement roots and remove orphan .tmp/ and .old/
507
652
  * directories left over from a crashed write. Called at the start of
@@ -14,13 +14,21 @@
14
14
  * surfaced by the sync-time foreign-content scan (`foreign-content.mjs`)
15
15
  * — never something the ignore machinery accommodates.
16
16
  *
17
- * Idempotent: entries already present anywhere in the file are skipped;
18
- * missing entries are inserted INTO the existing marked section when one
19
- * exists (never a duplicate header), else appended as a new section.
20
- * Every call also heals the retired pre-4.10.0 sync-time section shape
21
- * (see `healLegacySection`).
17
+ * Idempotent: entries already present anywhere in the file are skipped.
18
+ * The managed-root entries are also skipped when an existing ignore rule
19
+ * already covers them — asked of git itself via `git check-ignore`
20
+ * (#2903), so a repo that ignores `.claude/` never gets a redundant
21
+ * `.claude/skills/` line; the init-only entries stay on the literal
22
+ * scan (see `isCoverageProbed` for why). Genuinely missing entries are
23
+ * inserted INTO the existing marked section when one exists (never a
24
+ * duplicate header), else appended as a new section. Nothing is written
25
+ * when nothing is missing. Every call also heals the retired pre-4.10.0
26
+ * sync-time section shape (see `healLegacySection`).
22
27
  */
23
28
 
29
+ import { spawnSync } from "node:child_process";
30
+ import { dirname } from "node:path";
31
+
24
32
  import { readFileSafe, writeFileSafe } from "./fs-utils.mjs";
25
33
  import { gitignorePath } from "./paths.mjs";
26
34
  import { diskError } from "./errors.mjs";
@@ -79,12 +87,18 @@ export function ensureManagedRootIgnores(targets, options = {}) {
79
87
  }
80
88
 
81
89
  /**
82
- * Core ensure: required entries end up line-exact in the file, inside
83
- * the CLI's marked section, with the legacy sync-time section healed
84
- * away. Single writer for every gitignore mutation the CLI performs.
90
+ * Core ensure: every required entry ends up ignored — either it is
91
+ * already present line-exact somewhere in the file, an existing ignore
92
+ * rule already covers it (managed roots only — `git check-ignore`, see
93
+ * `isCoverageProbed` / `isCoveredByGit`), or it is written into the
94
+ * CLI's marked section — with the legacy sync-time section healed away.
95
+ * Single writer for every gitignore mutation the CLI performs; at most
96
+ * ONE write per call.
85
97
  *
86
- * Returns `"skipped"` if every required entry is already present and no
87
- * legacy section needed healing, `"created"` if the file didn't exist,
98
+ * Returns `"skipped"` if nothing needed writing (every required entry
99
+ * present or covered, no legacy section to heal) — the file is not
100
+ * touched at all in that case, not even for a comment — `"created"` if
101
+ * the file didn't exist and at least one entry needed writing,
88
102
  * `"updated"` otherwise.
89
103
  *
90
104
  * @param {string[]} requiredEntries
@@ -107,28 +121,52 @@ export function ensureGitignoreEntries(requiredEntries, options = {}) {
107
121
  });
108
122
  }
109
123
 
124
+ const dir = dirname(filePath);
125
+
110
126
  if (existing === null) {
111
- // Fresh .gitignore — write all required entries as one section.
112
- const content =
113
- GITIGNORE_SECTION_HEADER + "\n" + requiredEntries.join("\n") + "\n";
127
+ // Fresh .gitignore — one section holding every entry that still
128
+ // needs a line: the init-only entries always, a managed root only
129
+ // when no other ignore source (a nested .gitignore, info/exclude,
130
+ // a global excludes file) already covers it. Nothing to add →
131
+ // nothing to create.
132
+ const toAdd = dropCoveredEntries(requiredEntries, dir);
133
+ if (toAdd.length === 0) {
134
+ return { path: ".gitignore", action: "skipped", added: [] };
135
+ }
136
+ const content = GITIGNORE_SECTION_HEADER + "\n" + toAdd.join("\n") + "\n";
114
137
  writeSectionFile(filePath, content);
115
- return {
116
- path: ".gitignore",
117
- action: "created",
118
- added: [...requiredEntries],
119
- };
138
+ return { path: ".gitignore", action: "created", added: toAdd };
120
139
  }
121
140
 
122
141
  // Preserve the original line-ending style throughout.
123
142
  const lineEnding = existing.includes("\r\n") ? "\r\n" : "\n";
124
143
 
125
- const { lines, healed } = healLegacySection(existing.split(/\r?\n/));
144
+ const { lines, healed, removed } = healLegacySection(
145
+ existing.split(/\r?\n/),
146
+ );
126
147
 
127
148
  // Line-exact match check. `.env.local` as a line is distinct from
128
149
  // `.env.local.backup` or a comment `# .env.local` — compare trimmed
129
- // lines so we match the entry literally.
150
+ // lines so we match the entry literally. A managed-root entry not
151
+ // present line-exact may still be covered by a broader rule
152
+ // (`.claude/` covers `.claude/skills/`); git is the authority on that
153
+ // — except for the line the heal just removed, which git still sees
154
+ // on disk (`removed`, see `dropCoveredEntries`).
155
+ //
156
+ // Whatever the heal removed is re-homed even when this call did not
157
+ // ask for that root (a Claude-only sync in a repo still carrying the
158
+ // legacy `.agents/skills/` line): the heal migrates protection, never
159
+ // drops it. Before #2903 that line was silently lost in this case.
160
+ const wanted =
161
+ removed.length > 0
162
+ ? [...new Set([...requiredEntries, ...removed])]
163
+ : requiredEntries;
130
164
  const trimmed = lines.map((l) => l.trim());
131
- const missing = requiredEntries.filter((entry) => !trimmed.includes(entry));
165
+ const missing = dropCoveredEntries(
166
+ wanted.filter((entry) => !trimmed.includes(entry)),
167
+ dir,
168
+ removed,
169
+ );
132
170
 
133
171
  if (missing.length === 0 && !healed) {
134
172
  return {
@@ -195,6 +233,125 @@ function writeSectionFile(filePath, content) {
195
233
  hint: GITIGNORE_HINT,
196
234
  });
197
235
  }
236
+ forgetCoverage(dirname(filePath));
237
+ }
238
+
239
+ /**
240
+ * Per-process memo of `isCoveredByGit` answers, keyed by
241
+ * `<dir>\0<entry>`. The sync pre-flight runs once per written skill, so
242
+ * without it a sync writing N skills would spawn git N times for every
243
+ * entry that is covered-but-not-literal (~10 ms each). Every write this
244
+ * module makes to a directory's .gitignore forgets that directory's
245
+ * answers (`forgetCoverage`), so a memoized answer never outlives the
246
+ * file state it was computed from. Anything else editing the file
247
+ * mid-process is outside this module's contract — this is the only
248
+ * writer of that section.
249
+ */
250
+ const coverageMemo = new Map();
251
+
252
+ /**
253
+ * Forget every memoized answer for `dir`. Called after each write.
254
+ *
255
+ * @param {string} dir
256
+ */
257
+ function forgetCoverage(dir) {
258
+ const prefix = `${dir}\0`;
259
+ for (const key of coverageMemo.keys()) {
260
+ if (key.startsWith(prefix)) coverageMemo.delete(key);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Only the managed skill roots consult git for coverage. They are
266
+ * per-checkout cache directories the CLI alone populates, so any ignore
267
+ * source git honours in THIS checkout (the repo's .gitignore files,
268
+ * `.git/info/exclude`, the global excludes file) makes an entry
269
+ * redundant here, and every other developer's own sync heals their own
270
+ * checkout. The init-only entries are a different contract: `.env.local`
271
+ * holds a live access key and `.claude/settings.local.json` per-developer
272
+ * settings, and their line in the TRACKED .gitignore is what protects
273
+ * every clone — it must not be withheld because the one developer running
274
+ * `init` happens to have a personal global excludes rule that covers it.
275
+ * They stay on the literal scan, exactly as before #2903.
276
+ *
277
+ * Considered and rejected for them: probing every entry but counting
278
+ * only in-tree `.gitignore` sources (parse `git check-ignore -v -z` for
279
+ * the matching rule's source). It would also quiet the one-time,
280
+ * init-only redundancy when a TRACKED `.env*` rule already covers
281
+ * `.env.local`, at the cost of output parsing and a second probe shape
282
+ * for a cosmetic gain. Revisit if that redundancy ever matters.
283
+ *
284
+ * @param {string} entry
285
+ * @returns {boolean}
286
+ */
287
+ function isCoverageProbed(entry) {
288
+ return Object.values(MANAGED_ROOT_ENTRIES).includes(entry);
289
+ }
290
+
291
+ /**
292
+ * Ask git whether `entry` is already ignored in the work tree that owns
293
+ * the `.gitignore` at `dir` — the authoritative answer to "would adding
294
+ * this line be redundant?", honouring every ignore source git does
295
+ * (that `.gitignore`, nested ones, `.git/info/exclude`, the global
296
+ * excludes file). Per-checkout sources count deliberately for the
297
+ * entries this is asked about — see `isCoverageProbed`.
298
+ *
299
+ * `--no-index`: pattern coverage regardless of whether a tracked cache
300
+ * happens to sit under the root (#2361's tracked-cache case) — a rule
301
+ * that is already there is still redundant to add. The trailing slash
302
+ * on directory entries is load-bearing: `git check-ignore .agents/skills`
303
+ * (no slash) does NOT match an `.agents/skills/` rule while the
304
+ * directory does not exist yet, and on a first sync it does not. The
305
+ * probe runs WITH `dir` as its cwd because git resolves the path against
306
+ * its cwd — asked from a subdirectory, an unanchored `.claude/` rule
307
+ * would match `sub/.claude/skills/` and an anchored `/.claude/` would
308
+ * not, both wrong answers about the root.
309
+ *
310
+ * @param {string} dir - Directory holding the managed `.gitignore`.
311
+ * @param {string} entry - A `GITIGNORE_REQUIRED_ENTRIES` line.
312
+ * @returns {boolean | null} `true` covered, `false` not covered, `null`
313
+ * no signal — git absent (`spawnSync` reports ENOENT via
314
+ * `error`, it does not throw), not a work tree (exit 128), or
315
+ * the probe timed out. Callers fall back to the literal scan.
316
+ */
317
+ function isCoveredByGit(dir, entry) {
318
+ const key = `${dir}\0${entry}`;
319
+ if (coverageMemo.has(key)) return coverageMemo.get(key);
320
+ const probe = spawnSync(
321
+ "git",
322
+ ["check-ignore", "-q", "--no-index", "--", entry],
323
+ { cwd: dir, stdio: "ignore", windowsHide: true, timeout: 5000 },
324
+ );
325
+ let covered = null;
326
+ if (!probe.error && probe.status === 0) covered = true;
327
+ else if (!probe.error && probe.status === 1) covered = false;
328
+ coverageMemo.set(key, covered);
329
+ return covered;
330
+ }
331
+
332
+ /**
333
+ * Drop the managed-root entries git reports as already ignored. Entries
334
+ * that are not probed (`isCoverageProbed`) and entries in `exempt` stay.
335
+ * `exempt` names the line a pending legacy heal removed from the
336
+ * in-memory lines but not yet from disk: git would report that entry
337
+ * covered by the very line about to disappear, so it is re-homed
338
+ * unconditionally — the heal's whole purpose. With no signal from git
339
+ * (`null`) the literal scan the caller already ran is the only evidence,
340
+ * so the entry stays — outside a git work tree nothing can be committed
341
+ * anyway, and the pre-#2903 behaviour is preserved exactly.
342
+ *
343
+ * @param {string[]} entries - Entries not present line-exact in the file.
344
+ * @param {string} dir
345
+ * @param {string[]} [exempt]
346
+ * @returns {string[]}
347
+ */
348
+ function dropCoveredEntries(entries, dir, exempt = []) {
349
+ return entries.filter(
350
+ (entry) =>
351
+ !isCoverageProbed(entry) ||
352
+ exempt.includes(entry) ||
353
+ isCoveredByGit(dir, entry) !== true,
354
+ );
198
355
  }
199
356
 
200
357
  /**
@@ -210,20 +367,29 @@ function writeSectionFile(filePath, content) {
210
367
  * legacy header — survives verbatim.
211
368
  *
212
369
  * @param {string[]} lines
213
- * @returns {{ lines: string[]; healed: boolean }}
370
+ * @returns {{ lines: string[]; healed: boolean; removed: string[] }}
371
+ * `removed` names the entry line dropped (at most the
372
+ * `.agents/skills/` root) so the caller re-homes it without
373
+ * asking git, which still sees it on disk.
214
374
  */
215
375
  function healLegacySection(lines) {
216
376
  const headerIdx = lines.findIndex(
217
377
  (l) => l.trim() === LEGACY_GITIGNORE_SECTION_HEADER,
218
378
  );
219
- if (headerIdx === -1) return { lines, healed: false };
379
+ if (headerIdx === -1) return { lines, healed: false, removed: [] };
220
380
 
221
381
  let end = headerIdx + 1;
222
- if (end < lines.length && lines[end].trim() === ".agents/skills/") {
382
+ const removed = [];
383
+ if (
384
+ end < lines.length &&
385
+ lines[end].trim() === MANAGED_ROOT_ENTRIES.agentsProject
386
+ ) {
387
+ removed.push(MANAGED_ROOT_ENTRIES.agentsProject);
223
388
  end += 1;
224
389
  }
225
390
  return {
226
391
  lines: [...lines.slice(0, headerIdx), ...lines.slice(end)],
227
392
  healed: true,
393
+ removed,
228
394
  };
229
395
  }
package/src/lib/http.mjs CHANGED
@@ -662,8 +662,30 @@ export async function getLibrary(serverUrl, apiKey, opts = {}) {
662
662
  }
663
663
 
664
664
  const body = await parseJsonOrThrow(res, url);
665
+ // #2804 / #2879 data-safety: a 200 whose JSON lacks a top-level `skills`
666
+ // ARRAY is a malformed library response (an upstream proxy/CDN body, an
667
+ // API-gateway health envelope, or a changed server contract). Before #2804
668
+ // the `?? []` collapse was a harmless no-op; now a FULL-sync reconcile
669
+ // treats an empty served set as AUTHORITATIVE and removes every local skill
670
+ // absent from it. Refuse to read a missing/non-array `skills` as "empty
671
+ // library" — fail loud instead of silently reconciling the user's whole
672
+ // library off disk. A genuine empty library sends `skills: []` (a real
673
+ // array), so this never fires on the legitimate empty-library reconcile.
674
+ // `--session-hook` callers catch this and exit 0 WITHOUT reconciling
675
+ // (getLibrary throws before the reconcile is reached); `list` degrades to
676
+ // its EXIT_NETWORK disk fallback. The transient legit-empty `skills: []`
677
+ // case (replica lag / transient empty accessible set) is NOT this guard's
678
+ // job — it needs a server-side guarantee, tracked in #2880.
679
+ if (!Array.isArray(body.skills)) {
680
+ throw networkError(
681
+ `Server returned a ${res.status} library response with no skills[] array from ${url}`,
682
+ {
683
+ hint: "An upstream proxy or CDN may have replaced the response body, or the server contract changed. The CLI refuses to treat a malformed library response as an authoritative empty library. Check the URL or retry.",
684
+ },
685
+ );
686
+ }
665
687
  return {
666
- skills: body.skills ?? [],
688
+ skills: body.skills,
667
689
  removals: body.removals ?? [],
668
690
  syncedAt: body.syncedAt ?? new Date().toISOString(),
669
691
  // Verbatim as received (#2365 ETag rule): Vercel serves strong