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 +27 -8
- package/package.json +1 -1
- package/src/commands/update.mjs +13 -0
- package/src/lib/file-write.mjs +151 -8
- package/src/lib/http.mjs +23 -1
- package/src/lib/paths.mjs +12 -0
- package/src/lib/sync.mjs +320 -58
- package/src/test/commands/update.test.mjs +37 -3
- package/src/test/e2e/mock-server.mjs +8 -0
- package/src/test/lib/file-write.test.mjs +168 -0
- package/src/test/lib/http.test.mjs +55 -2
- package/src/test/lib/sync-skillset.test.mjs +176 -11
- package/src/test/lib/sync.test.mjs +492 -22
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
package/src/commands/update.mjs
CHANGED
|
@@ -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
|
}
|
package/src/lib/file-write.mjs
CHANGED
|
@@ -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 (
|
|
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
|
-
}
|
|
498
|
-
|
|
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
|