skillrepo 4.17.1 → 4.18.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.
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
@@ -322,6 +332,15 @@ is not a TTY or `NO_COLOR` is set, so piped output stays clean.
322
332
  (worst-wins drift rollup) field is kept for backward compatibility, so
323
333
  existing scripts keep working.
324
334
 
335
+ Each row's `version` is the **installed** (on-disk) version — what your
336
+ last sync wrote — while `servedVersion` is the version the server
337
+ currently serves. They differ whenever you hold a version the server has
338
+ moved off (a recall fallback reads `SERVING`, a stale copy reads
339
+ `BEHIND`), and the reason line for those rows names the served version.
340
+ Before 4.17.1 the top-level `version` reported the *served* version and
341
+ there was no installed-version field, so a script that read `version` as
342
+ "what the server serves" should read `servedVersion` instead.
343
+
325
344
  ### `search` — explore the registry
326
345
 
327
346
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.17.1",
3
+ "version": "4.18.0",
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": {
@@ -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.
@@ -463,6 +494,68 @@ export function writeSkillDir(skill, options = {}) {
463
494
  return { written };
464
495
  }
465
496
 
497
+ /**
498
+ * Run a filesystem mutation, retrying briefly on the transient lock
499
+ * errors Windows raises (EBUSY / EPERM / ENOTEMPTY) when another process
500
+ * — antivirus, an editor, a vendor agent mid-scan — holds a handle on a
501
+ * file inside the target dir. The op typically succeeds a few ms later
502
+ * once the handle is released; POSIX effectively never hits this. After
503
+ * the last delay the ORIGINAL error is rethrown, so a genuine permission
504
+ * failure still surfaces. `removeSkillDir` / `removeManagedDir` /
505
+ * `quarantineSkillDir` route their rm/rename/copy through here — a
506
+ * compliance delete (#2804) must not silently fail on a momentary lock.
507
+ * Delays are injectable so unit tests exercise the retry loop without
508
+ * real sleeps.
509
+ *
510
+ * @template T
511
+ * @param {() => T} op
512
+ * @param {number[]} [delaysMs]
513
+ * @returns {T}
514
+ */
515
+ export function withFsRetry(op, delaysMs = [20, 50, 100, 200]) {
516
+ for (let attempt = 0; ; attempt++) {
517
+ try {
518
+ return op();
519
+ } catch (err) {
520
+ const transient =
521
+ !!err &&
522
+ (err.code === "EBUSY" ||
523
+ err.code === "EPERM" ||
524
+ err.code === "ENOTEMPTY");
525
+ if (!transient || attempt >= delaysMs.length) throw err;
526
+ const ms = delaysMs[attempt];
527
+ // Synchronous sleep: this whole pipeline is sync (rmSync/renameSync),
528
+ // so there is no event loop to yield to. Atomics.wait on a throwaway
529
+ // SharedArrayBuffer blocks the thread for `ms` without a busy spin.
530
+ if (ms > 0) {
531
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
532
+ }
533
+ }
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Recursively remove a single resolved placement directory, with the
539
+ * Windows-lock retry above. Returns true if the dir existed and was
540
+ * removed, false if it was already absent. Throws a typed disk error on
541
+ * a genuine failure. The per-directory primitive shared by
542
+ * `removeSkillDir` (all targets of a name) and the full-sync reconcile
543
+ * (#2804, one target at a time, alongside quarantine).
544
+ *
545
+ * @param {string} dir
546
+ * @returns {boolean}
547
+ */
548
+ export function removeManagedDir(dir) {
549
+ if (!existsSync(dir)) return false;
550
+ try {
551
+ withFsRetry(() => rmSync(dir, { recursive: true, force: true }));
552
+ } catch (err) {
553
+ if (err instanceof CliError) throw err;
554
+ throw diskError(`Failed to remove ${dir}: ${err.message}`, { cause: err });
555
+ }
556
+ return true;
557
+ }
558
+
466
559
  /**
467
560
  * Remove a skill directory from all configured placement targets.
468
561
  *
@@ -487,21 +580,71 @@ export function removeSkillDir(skillName, options = {}) {
487
580
 
488
581
  for (const target of targets) {
489
582
  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 });
583
+ if (removeManagedDir(targetDir)) {
496
584
  removed.push(targetDir);
497
- } catch (err) {
498
- throw diskError(`Failed to remove ${targetDir}: ${err.message}`, { cause: err });
585
+ } else {
586
+ notFound.push(targetDir);
499
587
  }
500
588
  }
501
589
 
502
590
  return { removed, notFound };
503
591
  }
504
592
 
593
+ /**
594
+ * Move a skill's loadable placement directory into the non-loaded
595
+ * quarantine area (`~/.claude/skillrepo/quarantine/`), preserving its
596
+ * contents, and return the destination path.
597
+ *
598
+ * The full-sync reconcile (#2804) calls this for a recalled / removed
599
+ * skill whose on-disk dir the CLI provably wrote (it is in the recorded
600
+ * write baseline) but which has since been locally EDITED. Deleting it
601
+ * would lose the user's edits; leaving it in place would keep the
602
+ * recalled skill loadable by agents — the exact compliance hole recall
603
+ * exists to close. Quarantine resolves both: the loadable copy goes away,
604
+ * the edited content survives outside every vendor's scan path. Unmodified
605
+ * recalled skills are deleted outright (`removeManagedDir`); a dir the CLI
606
+ * never wrote is never quarantined (the reconcile guards on the baseline).
607
+ *
608
+ * Destination names are uniquely stamped so quarantining the same skill
609
+ * from several placement targets in one sync never collides.
610
+ *
611
+ * @param {string} sourceDir Absolute path of the loadable dir to move.
612
+ * @param {object} opts
613
+ * @param {string} opts.skillName Spec-valid skill name (caller-validated).
614
+ * @returns {string} The quarantine destination path.
615
+ */
616
+ export function quarantineSkillDir(sourceDir, { skillName } = {}) {
617
+ // Local state / server payloads are untrusted: never build a path from
618
+ // an invalid name (a traversal shape must never reach `join`).
619
+ if (!isValidSkillName(skillName)) {
620
+ throw validationError(`Invalid skill name: "${skillName}"`);
621
+ }
622
+
623
+ const root = globalQuarantineDir();
624
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
625
+ const dest = join(root, `${skillName}-${stamp}-${randomBytes(4).toString("hex")}`);
626
+
627
+ try {
628
+ withFsRetry(() => mkdirSync(root, { recursive: true }));
629
+ // Copy-then-remove rather than rename. The quarantine root lives under
630
+ // HOME while the placement dir lives under the repo, which can be a
631
+ // different filesystem (a mounted volume) — renameSync can't cross
632
+ // devices (EXDEV). Copying first and removing the loadable copy second
633
+ // is also the fail-safe order: if the remove fails, the edits are
634
+ // already preserved in quarantine (worst case the loadable dir lingers
635
+ // and is re-quarantined next sync), never lost.
636
+ withFsRetry(() => cpSync(sourceDir, dest, { recursive: true }));
637
+ withFsRetry(() => rmSync(sourceDir, { recursive: true, force: true }));
638
+ } catch (err) {
639
+ if (err instanceof CliError) throw err;
640
+ throw diskError(`Failed to quarantine ${sourceDir}: ${err.message}`, {
641
+ cause: err,
642
+ });
643
+ }
644
+
645
+ return dest;
646
+ }
647
+
505
648
  /**
506
649
  * Scan all configured placement roots and remove orphan .tmp/ and .old/
507
650
  * directories left over from a crashed write. Called at the start of
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
package/src/lib/paths.mjs CHANGED
@@ -81,6 +81,18 @@ export const globalGovernanceSeenPath = () =>
81
81
  export const globalRepoStateDir = () =>
82
82
  join(homedir(), ".claude", "skillrepo", "repos");
83
83
 
84
+ /**
85
+ * Quarantine area for the full-sync reconcile (#2804). When a recalled /
86
+ * removed skill's on-disk dir was written by the CLI but has since been
87
+ * locally EDITED, compliance requires it leave the loadable placement
88
+ * dirs, but the user's edits must not be destroyed — so the dir is moved
89
+ * here instead of deleted. NO vendor scans `~/.claude/skillrepo/`, so a
90
+ * quarantined skill is never loaded by an agent. Same parent dir as
91
+ * `.last-sync`, so `rm -rf ~/.claude/skillrepo/` still removes it.
92
+ */
93
+ export const globalQuarantineDir = () =>
94
+ join(homedir(), ".claude", "skillrepo", "quarantine");
95
+
84
96
  // ── Skill placement targets ────────────────────────────────────────────
85
97
  //
86
98
  // Per-vendor placement decisions live in `agent-registry.mjs`. This