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 +23 -11
- package/package.json +1 -1
- package/src/commands/init.mjs +3 -2
- package/src/commands/update.mjs +13 -0
- package/src/lib/file-write.mjs +157 -12
- package/src/lib/gitignore-section.mjs +190 -24
- package/src/lib/http.mjs +23 -1
- package/src/lib/mergers/gitignore.mjs +14 -7
- 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 +212 -0
- package/src/test/lib/gitignore-section.test.mjs +269 -2
- 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/src/test/mergers/gitignore.test.mjs +48 -0
|
@@ -14,8 +14,13 @@
|
|
|
14
14
|
* entries through the same core, so every CLI-authored gitignore line
|
|
15
15
|
* lives in the one marked section the uninstall remover strips (#2361).
|
|
16
16
|
*
|
|
17
|
-
* Idempotent: entries already present are skipped
|
|
18
|
-
*
|
|
17
|
+
* Idempotent: entries already present are skipped, and so are the two
|
|
18
|
+
* skill-root entries when an ignore rule git honours already covers them
|
|
19
|
+
* (`git check-ignore`, #2903). The always-added entries never take that
|
|
20
|
+
* shortcut — `.env.local` holds a live key, and its line in the TRACKED
|
|
21
|
+
* .gitignore is what protects every clone (see `isCoverageProbed` in
|
|
22
|
+
* `../gitignore-section.mjs`). A legacy sync-time section is healed into
|
|
23
|
+
* the marked section as a side effect.
|
|
19
24
|
*/
|
|
20
25
|
|
|
21
26
|
import { placementTargetsFor } from "../file-write.mjs";
|
|
@@ -64,11 +69,13 @@ function entriesFor(options) {
|
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
/**
|
|
67
|
-
* Ensure the init-required paths are
|
|
68
|
-
*
|
|
69
|
-
* required entry is already present
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
+
* Ensure the init-required paths are gitignored. Creates the file if it
|
|
73
|
+
* doesn't exist and something needs writing. Idempotent — returns
|
|
74
|
+
* `"skipped"` if every required entry is already present (or, for the
|
|
75
|
+
* skill roots only, covered by an existing ignore rule) and no legacy
|
|
76
|
+
* section needed healing, `"created"` if the file didn't exist,
|
|
77
|
+
* `"updated"` if it did and at least one entry was missing (or a legacy
|
|
78
|
+
* section was healed).
|
|
72
79
|
*
|
|
73
80
|
* @param {{ vendors?: string[], global?: boolean }} [options]
|
|
74
81
|
* @returns {{ path: string; action: "created" | "updated" | "skipped"; added: string[] }}
|
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
|
package/src/lib/sync.mjs
CHANGED
|
@@ -77,7 +77,15 @@
|
|
|
77
77
|
* @typedef {Object} SyncSummary
|
|
78
78
|
* @property {number} added - Skills newly written that were not on disk
|
|
79
79
|
* @property {number} updated - Skills overwritten on disk
|
|
80
|
-
* @property {number} removed -
|
|
80
|
+
* @property {number} removed - Skills removed from the loadable set,
|
|
81
|
+
* counted PER SKILL: delta tombstones plus
|
|
82
|
+
* the full-sync orphan reconcile (#2804). A
|
|
83
|
+
* quarantine counts here too (it left the
|
|
84
|
+
* loadable set).
|
|
85
|
+
* @property {number} [quarantined] - Subset of `removed` MOVED to quarantine
|
|
86
|
+
* (an edited copy of a removed skill)
|
|
87
|
+
* rather than deleted, so the summary can
|
|
88
|
+
* show edits were preserved (#2804)
|
|
81
89
|
* @property {boolean} notModified - True if 304 short-circuit fired
|
|
82
90
|
* @property {boolean} [throttled] - True when the SessionStart staleness
|
|
83
91
|
* throttle skipped this run entirely — no
|
|
@@ -221,8 +229,11 @@ import { getCliVersion } from "./cli-version.mjs";
|
|
|
221
229
|
import {
|
|
222
230
|
writeSkillDir,
|
|
223
231
|
removeSkillDir,
|
|
232
|
+
removeManagedDir,
|
|
233
|
+
quarantineSkillDir,
|
|
224
234
|
cleanupOrphans,
|
|
225
235
|
placementTargetsFor,
|
|
236
|
+
placementTargetsForScopeLenient,
|
|
226
237
|
resolvePlacementDir,
|
|
227
238
|
describePlacementTarget,
|
|
228
239
|
escapeControlChars,
|
|
@@ -1152,6 +1163,19 @@ async function runWholeLibrarySync({
|
|
|
1152
1163
|
opts.since = lastSync.syncedAt;
|
|
1153
1164
|
}
|
|
1154
1165
|
|
|
1166
|
+
// #2804: the ACTUAL full-fetch wire decision. The server returns the
|
|
1167
|
+
// complete authoritative set (no `since` delta filter) exactly when
|
|
1168
|
+
// `opts.since` is absent — so absence from `result.skills` on such a
|
|
1169
|
+
// response means the skill has genuinely left the library and must be
|
|
1170
|
+
// reconciled off disk. This is deliberately NOT `summary.fullSync` below
|
|
1171
|
+
// (= "no prior state existed"): a forced full re-fetch from incomplete
|
|
1172
|
+
// placements or the #1911 upgrade heal still reports `fullSync:false`, yet
|
|
1173
|
+
// its response IS a complete set that must be reconciled. Keying the orphan
|
|
1174
|
+
// reconcile off `since`-absence is load-bearing — key it off `fullSync` and
|
|
1175
|
+
// the routine forced-full-fetch cases (new vendor, CLI upgrade) never clean
|
|
1176
|
+
// up a recalled skill.
|
|
1177
|
+
const fullFetch = opts.since === undefined;
|
|
1178
|
+
|
|
1155
1179
|
// Track whether this is a full or delta sync BEFORE the network
|
|
1156
1180
|
// call, for the returned summary's `fullSync` field. The documented
|
|
1157
1181
|
// semantic is "no PRIOR last-sync state existed" — NOT "no `since` was
|
|
@@ -1262,6 +1286,10 @@ async function runWholeLibrarySync({
|
|
|
1262
1286
|
added: 0,
|
|
1263
1287
|
updated: 0,
|
|
1264
1288
|
removed: 0,
|
|
1289
|
+
// Subset of `removed` that was MOVED to quarantine (an edited copy of a
|
|
1290
|
+
// removed skill) rather than deleted (#2804). Surfaced so the summary
|
|
1291
|
+
// can tell the user their edits were preserved, not destroyed.
|
|
1292
|
+
quarantined: 0,
|
|
1265
1293
|
// Skills the server sent that could not be written locally (e.g. a
|
|
1266
1294
|
// stored path that fails client-side validation). Counted rather than
|
|
1267
1295
|
// fatal — see the quarantine-and-continue block in the apply loop.
|
|
@@ -1298,6 +1326,43 @@ async function runWholeLibrarySync({
|
|
|
1298
1326
|
// machine A, this is machine B) is not an error.
|
|
1299
1327
|
}
|
|
1300
1328
|
|
|
1329
|
+
// #2804: full-sync orphan reconcile (also closes #2532's full-sync case).
|
|
1330
|
+
// On a full fetch the server sends the complete served set with
|
|
1331
|
+
// removals:[] (route.ts:624), so a skill that has LEFT the library —
|
|
1332
|
+
// recalled to absence with no fallback (#2367), owner-deleted (#2532),
|
|
1333
|
+
// membership-removed, pin-rolled-back — is in NEITHER skills[] NOR
|
|
1334
|
+
// removals[], and the tombstone loop above never touches it. Treat the
|
|
1335
|
+
// full response's skills[] as authoritative and reconcile every prior
|
|
1336
|
+
// baseline entry absent from it off disk, guarded by the recorded write
|
|
1337
|
+
// baseline: unmodified → deleted; CLI-written-then-EDITED → quarantined
|
|
1338
|
+
// (not destroyed); never-written → never visited (the reconcile iterates
|
|
1339
|
+
// the CLI's own `.last-sync` baseline). DELTA fetches carry a PARTIAL
|
|
1340
|
+
// skills[] and MUST NEVER be reconciled by absence — hence the fullFetch
|
|
1341
|
+
// gate (see the `fullFetch` derivation above).
|
|
1342
|
+
/** @type {string[]} */
|
|
1343
|
+
let reconciledKeys = [];
|
|
1344
|
+
if (fullFetch) {
|
|
1345
|
+
const servedKeys = new Set(
|
|
1346
|
+
result.skills.map((skill) => `${skill.owner}/${skill.name}`),
|
|
1347
|
+
);
|
|
1348
|
+
const servedNames = new Set(result.skills.map((skill) => skill.name));
|
|
1349
|
+
const reconcile = reconcileWholeLibraryOrphans({
|
|
1350
|
+
baselineSkills: lastSync?.skills,
|
|
1351
|
+
servedKeys,
|
|
1352
|
+
servedNames,
|
|
1353
|
+
vendors,
|
|
1354
|
+
global,
|
|
1355
|
+
stderr,
|
|
1356
|
+
});
|
|
1357
|
+
// A quarantine counts toward `removed` too (it left the loadable set,
|
|
1358
|
+
// so a quarantine-only reconcile can't leave added+updated+removed at 0
|
|
1359
|
+
// and go silent on the hook — the #2413 lesson) AND toward `quarantined`
|
|
1360
|
+
// so the move stays distinguishable from a delete in the summary.
|
|
1361
|
+
summary.removed += reconcile.removed;
|
|
1362
|
+
summary.quarantined += reconcile.quarantined;
|
|
1363
|
+
reconciledKeys = reconcile.reconciledKeys;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1301
1366
|
// Apply skills. We don't have on-disk SHAs to compute "unchanged"
|
|
1302
1367
|
// efficiently — `unchanged` stays 0 unless a future enhancement
|
|
1303
1368
|
// adds disk-side hashing. For the user-facing summary, "added" vs
|
|
@@ -1353,6 +1418,13 @@ async function runWholeLibrarySync({
|
|
|
1353
1418
|
const key = `${tombstone.owner}/${tombstone.name}`;
|
|
1354
1419
|
delete skillsMap[key];
|
|
1355
1420
|
}
|
|
1421
|
+
// Drop reconciled orphans (#2804) from the persisted map too, so
|
|
1422
|
+
// `.last-sync` stops listing a skill we just removed from disk — mirrors
|
|
1423
|
+
// the tombstone purge above. Absent from the map, it is not carried
|
|
1424
|
+
// forward and `list` will not report it as a phantom managed skill.
|
|
1425
|
+
for (const key of reconciledKeys) {
|
|
1426
|
+
delete skillsMap[key];
|
|
1427
|
+
}
|
|
1356
1428
|
|
|
1357
1429
|
// Placement configuration is validated ONCE, before any skill is
|
|
1358
1430
|
// written. `placementTargetsFor` depends only on `vendors` and
|
|
@@ -1854,6 +1926,8 @@ async function runSkillsetScopedSync({
|
|
|
1854
1926
|
added: 0,
|
|
1855
1927
|
updated: 0,
|
|
1856
1928
|
removed: 0,
|
|
1929
|
+
// Subset of `removed` moved to quarantine rather than deleted (#2804).
|
|
1930
|
+
quarantined: 0,
|
|
1857
1931
|
skipped: 0,
|
|
1858
1932
|
notModified: false,
|
|
1859
1933
|
// Scoped syncs have no delta mode; `fullSync` keeps its documented
|
|
@@ -1866,6 +1940,7 @@ async function runSkillsetScopedSync({
|
|
|
1866
1940
|
const servedKeys = new Set(
|
|
1867
1941
|
result.skills.map((skill) => `${skill.owner}/${skill.name}`),
|
|
1868
1942
|
);
|
|
1943
|
+
const servedNames = new Set(result.skills.map((skill) => skill.name));
|
|
1869
1944
|
const priorResolved = prior?.resolved ?? [];
|
|
1870
1945
|
|
|
1871
1946
|
// Composed-state guard evidence (#2365 prod-readiness HIGH finding;
|
|
@@ -1898,18 +1973,24 @@ async function runSkillsetScopedSync({
|
|
|
1898
1973
|
for (const entry of priorResolved) {
|
|
1899
1974
|
const key = `${entry.owner}/${entry.name}`;
|
|
1900
1975
|
if (servedKeys.has(key)) continue;
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1976
|
+
// Re-attribution guard (mirrors reconcileWholeLibraryOrphans): a member
|
|
1977
|
+
// whose bare NAME is still served — under a DIFFERENT owner — was
|
|
1978
|
+
// re-keyed, not removed (a skillset response's owner is the live
|
|
1979
|
+
// `attributionSlug`, which a claim/rename changes while membership is
|
|
1980
|
+
// stable). Skip the reconcile so an edited member isn't spuriously
|
|
1981
|
+
// quarantined moments before the apply loop rewrites it under the new
|
|
1982
|
+
// key; the per-repo manifest is rebuilt from the served set below, so
|
|
1983
|
+
// the stale entry drops on its own.
|
|
1984
|
+
if (servedNames.has(entry.name)) continue;
|
|
1985
|
+
const outcome = reconcileDeleteGuarded({
|
|
1986
|
+
entry,
|
|
1987
|
+
targets,
|
|
1988
|
+
rootDir,
|
|
1989
|
+
stderr,
|
|
1990
|
+
bulkBaselines,
|
|
1991
|
+
});
|
|
1992
|
+
if (outcome.changed) summary.removed++;
|
|
1993
|
+
if (outcome.quarantined) summary.quarantined++;
|
|
1913
1994
|
}
|
|
1914
1995
|
|
|
1915
1996
|
// Full-set apply with the #2108-class serve guard: a placement dir
|
|
@@ -1922,6 +2003,17 @@ async function runSkillsetScopedSync({
|
|
|
1922
2003
|
const priorByKey = new Map(
|
|
1923
2004
|
priorResolved.map((entry) => [`${entry.owner}/${entry.name}`, entry]),
|
|
1924
2005
|
);
|
|
2006
|
+
// Name-keyed fallback for the re-attribution case (#2804 review): after a
|
|
2007
|
+
// claim/rename the served owner changes but the placement dir (name-keyed)
|
|
2008
|
+
// and the per-repo baseline (recorded under the OLD owner key) do not — so
|
|
2009
|
+
// the owner-keyed lookup misses, and for a skillset-only repo `.last-sync`
|
|
2010
|
+
// has no entry either, which would make `decideScopedWrite` mislabel a
|
|
2011
|
+
// CLI-authored-then-edited member as "unmanaged" instead of "modified"
|
|
2012
|
+
// (a materially different, wrong compliance signal). Names are unique
|
|
2013
|
+
// within a repo's manifest, so this never collides.
|
|
2014
|
+
const priorByName = new Map(
|
|
2015
|
+
priorResolved.map((entry) => [entry.name, entry]),
|
|
2016
|
+
);
|
|
1925
2017
|
|
|
1926
2018
|
for (const skill of result.skills) {
|
|
1927
2019
|
const version = typeof skill.version === "string" ? skill.version : "";
|
|
@@ -1944,7 +2036,9 @@ async function runSkillsetScopedSync({
|
|
|
1944
2036
|
skill,
|
|
1945
2037
|
targets,
|
|
1946
2038
|
rootDir,
|
|
1947
|
-
priorEntry:
|
|
2039
|
+
priorEntry:
|
|
2040
|
+
priorByKey.get(`${skill.owner}/${skill.name}`) ??
|
|
2041
|
+
priorByName.get(skill.name),
|
|
1948
2042
|
bulkBaselines,
|
|
1949
2043
|
});
|
|
1950
2044
|
|
|
@@ -2277,46 +2371,191 @@ function decideScopedWrite({ skill, targets, rootDir, priorEntry, bulkBaselines
|
|
|
2277
2371
|
}
|
|
2278
2372
|
|
|
2279
2373
|
/**
|
|
2280
|
-
*
|
|
2281
|
-
*
|
|
2282
|
-
*
|
|
2283
|
-
*
|
|
2284
|
-
*
|
|
2285
|
-
*
|
|
2286
|
-
*
|
|
2287
|
-
*
|
|
2288
|
-
*
|
|
2289
|
-
*
|
|
2290
|
-
*
|
|
2291
|
-
*
|
|
2374
|
+
* Full-sync orphan reconcile for the whole-library path (#2804; also
|
|
2375
|
+
* closes #2532's full-sync case). Given the authoritative served-key set
|
|
2376
|
+
* from a FULL response, every prior `.last-sync` baseline entry ABSENT
|
|
2377
|
+
* from it has left the library and is reconciled off disk, guarded by the
|
|
2378
|
+
* per-entry write baseline the CLI itself recorded:
|
|
2379
|
+
*
|
|
2380
|
+
* • disk matches the entry's baseline SHAs (exactly what the CLI wrote)
|
|
2381
|
+
* → deleted;
|
|
2382
|
+
* • disk diverged (the CLI wrote it — it IS in `.last-sync` — and the
|
|
2383
|
+
* user has since edited or replaced it) → moved to quarantine, not
|
|
2384
|
+
* destroyed (compliance: it leaves the loadable folder; safety: the
|
|
2385
|
+
* content survives outside every vendor's scan path);
|
|
2386
|
+
* • a name the CLI never wrote is never visited — this iterates the
|
|
2387
|
+
* CLI's OWN baseline, so hand-authored dirs (no `.last-sync` entry)
|
|
2388
|
+
* are structurally out of scope; no separate foreign guard is needed,
|
|
2389
|
+
* unlike the skillset path whose per-repo/bulk stores can disagree.
|
|
2390
|
+
*
|
|
2391
|
+
* Deletions and quarantines are counted PER SKILL (once each, never once
|
|
2392
|
+
* per placement target — the tombstone and skillset-reconcile paths count
|
|
2393
|
+
* the same way); a quarantine also counts as `removed` (it left the
|
|
2394
|
+
* loadable set, so the SessionStart hook is never silent) and additionally
|
|
2395
|
+
* as `quarantined` so the move stays legible even when the per-skill
|
|
2396
|
+
* warning goes to a black-holed hook stderr. A skill still served under a
|
|
2397
|
+
* DIFFERENT owner (a re-attributed Skillbot claim) is NOT a removal and is
|
|
2398
|
+
* skipped. The state KEY is dropped only when the skill is no longer on
|
|
2399
|
+
* disk in EITHER scope: `.last-sync` is machine-global and does not record
|
|
2400
|
+
* which scope wrote a skill, so purging a `--global`-placed skill's key
|
|
2401
|
+
* during a project sync (which cleans only project dirs) would strand its
|
|
2402
|
+
* global dir loadable forever. The caller runs this ONLY when `opts.since`
|
|
2403
|
+
* was absent (a genuine full fetch); a delta response's `skills[]` is
|
|
2404
|
+
* partial and absence there is not removal.
|
|
2405
|
+
*
|
|
2406
|
+
* @param {object} args
|
|
2407
|
+
* @param {Record<string, SyncedSkillEntry> | undefined} args.baselineSkills
|
|
2408
|
+
* Prior `.last-sync` skills map (`owner/name` → entry with SHAs).
|
|
2409
|
+
* @param {Set<string>} args.servedKeys `owner/name` keys in the full response.
|
|
2410
|
+
* @param {Set<string>} args.servedNames Bare skill names in the full response.
|
|
2411
|
+
* A library has one owner per name, so a served name means the same
|
|
2412
|
+
* skill (used to skip a re-attributed skill rather than remove it).
|
|
2413
|
+
* @param {string[]} [args.vendors]
|
|
2414
|
+
* @param {boolean} [args.global]
|
|
2415
|
+
* @param {NodeJS.WritableStream} args.stderr
|
|
2416
|
+
* @returns {{ removed: number, quarantined: number, reconciledKeys: string[] }}
|
|
2417
|
+
*/
|
|
2418
|
+
function reconcileWholeLibraryOrphans({
|
|
2419
|
+
baselineSkills,
|
|
2420
|
+
servedKeys,
|
|
2421
|
+
servedNames,
|
|
2422
|
+
vendors,
|
|
2423
|
+
global,
|
|
2424
|
+
stderr,
|
|
2425
|
+
}) {
|
|
2426
|
+
/** @type {string[]} */
|
|
2427
|
+
const reconciledKeys = [];
|
|
2428
|
+
let removed = 0;
|
|
2429
|
+
let quarantined = 0;
|
|
2430
|
+
if (!baselineSkills || typeof baselineSkills !== "object") {
|
|
2431
|
+
return { removed, quarantined, reconciledKeys };
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
// Clean only the CURRENT sync's scope; the other scope is PROBED
|
|
2435
|
+
// read-only for the key-purge guard below (never cleaned — `.last-sync`
|
|
2436
|
+
// doesn't record which scope wrote a skill, so cleaning the other scope
|
|
2437
|
+
// could move hand-authored content the CLI never wrote there). The probe
|
|
2438
|
+
// is lenient so a vendor with no global scope contributes no target
|
|
2439
|
+
// instead of throwing.
|
|
2440
|
+
const currentTargets = placementTargetsFor({ vendors, global: global === true });
|
|
2441
|
+
const otherTargets = placementTargetsForScopeLenient(vendors, global !== true);
|
|
2442
|
+
|
|
2443
|
+
for (const [key, entry] of Object.entries(baselineSkills)) {
|
|
2444
|
+
if (!entry || typeof entry !== "object") continue;
|
|
2445
|
+
if (servedKeys.has(key)) continue; // still served — keep
|
|
2446
|
+
|
|
2447
|
+
// `.last-sync` is local data, not trusted input: never resolve an
|
|
2448
|
+
// invalid name into a path (a traversal shape must never reach `join`).
|
|
2449
|
+
const slashAt = key.indexOf("/");
|
|
2450
|
+
const name = slashAt >= 0 ? key.slice(slashAt + 1) : key;
|
|
2451
|
+
if (!isValidSkillName(name)) continue;
|
|
2452
|
+
|
|
2453
|
+
// Re-attribution guard: a skill whose NAME is still served — under a
|
|
2454
|
+
// different owner — was re-keyed, not removed (e.g. a Skillbot skill
|
|
2455
|
+
// `skillrepo/foo` claimed to `alice/foo` changes the owner slug but not
|
|
2456
|
+
// the name-keyed placement dir). The server enforces one owner per name
|
|
2457
|
+
// within a library, so a served name is the same skill: leave its dir
|
|
2458
|
+
// for the apply loop to rewrite under the new key, and just drop the
|
|
2459
|
+
// stale old key so state doesn't carry a phantom.
|
|
2460
|
+
if (servedNames.has(name)) {
|
|
2461
|
+
reconciledKeys.push(key);
|
|
2462
|
+
continue;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
let leftLoadable = false;
|
|
2466
|
+
let wasQuarantined = false;
|
|
2467
|
+
for (const target of currentTargets) {
|
|
2468
|
+
const dir = resolvePlacementDir(target, name);
|
|
2469
|
+
if (!existsSync(dir)) continue;
|
|
2470
|
+
const diskShas = computeShasFromDir(dir);
|
|
2471
|
+
if (
|
|
2472
|
+
shasMatch(diskShas, {
|
|
2473
|
+
skillMdSha256: entry.skillMdSha256,
|
|
2474
|
+
filesSha256: entry.filesSha256,
|
|
2475
|
+
})
|
|
2476
|
+
) {
|
|
2477
|
+
// Exactly what the CLI wrote — delete outright.
|
|
2478
|
+
if (removeManagedDir(dir)) leftLoadable = true;
|
|
2479
|
+
} else {
|
|
2480
|
+
// The CLI wrote this name (it is in our baseline) but the content
|
|
2481
|
+
// diverged: a local edit or a same-named replacement. Compliance
|
|
2482
|
+
// requires it leave the loadable folder; quarantine preserves it.
|
|
2483
|
+
const dest = quarantineSkillDir(dir, { skillName: name });
|
|
2484
|
+
leftLoadable = true;
|
|
2485
|
+
wasQuarantined = true;
|
|
2486
|
+
const displayPath = `${describePlacementTarget(target)}${escapeControlChars(name)}`;
|
|
2487
|
+
stderr.write(
|
|
2488
|
+
` warning: ${displayPath}/ is no longer in your library but has local edits — ` +
|
|
2489
|
+
`moved to quarantine (${dest}) instead of deleted, so it stops loading in your agents ` +
|
|
2490
|
+
`while your changes are preserved. Recover them from that folder if needed.\n`,
|
|
2491
|
+
);
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
// Count ONCE per skill (not once per target).
|
|
2495
|
+
if (leftLoadable) removed++;
|
|
2496
|
+
if (wasQuarantined) quarantined++;
|
|
2497
|
+
|
|
2498
|
+
// Drop the state key only when the skill is no longer on disk in the
|
|
2499
|
+
// OTHER scope either — otherwise a `--global`-placed skill reconciled
|
|
2500
|
+
// during a project sync (its dir in `otherTargets`, which this pass
|
|
2501
|
+
// does not clean) would be stranded loadable forever once its key is
|
|
2502
|
+
// gone. Leaving the key lets the next same-scope sync reconcile it. A
|
|
2503
|
+
// true phantom (no dir in either scope) still drops.
|
|
2504
|
+
const stillInOtherScope = otherTargets.some((target) =>
|
|
2505
|
+
existsSync(resolvePlacementDir(target, name)),
|
|
2506
|
+
);
|
|
2507
|
+
if (!stillInOtherScope) reconciledKeys.push(key);
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
return { removed, quarantined, reconciledKeys };
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
/**
|
|
2514
|
+
* Guarded reconcile for one manifest entry absent from the served set —
|
|
2515
|
+
* the skillset-scoped counterpart to `reconcileWholeLibraryOrphans`.
|
|
2516
|
+
* Classifies each existing placement dir against the recorded write
|
|
2517
|
+
* baseline (the per-repo manifest's OR the machine-global `.last-sync`
|
|
2518
|
+
* map's — #2365 composed-state, same shape as `decideScopedWrite`):
|
|
2519
|
+
*
|
|
2520
|
+
* • matches a baseline (exactly what the CLI wrote) → deleted;
|
|
2521
|
+
* • diverged AND the PER-REPO manifest recorded writing this repo's dir
|
|
2522
|
+
* (positive proof the CLI wrote it here, and the user then edited it)
|
|
2523
|
+
* → moved to quarantine, not destroyed (#2804 compliance: a removed
|
|
2524
|
+
* skill must leave the loadable folder; the edits are preserved);
|
|
2525
|
+
* • diverged with only a BULK `.last-sync` baseline (proves the CLI
|
|
2526
|
+
* wrote SOME placement — possibly the global one — not necessarily
|
|
2527
|
+
* this repo's dir) OR no baseline at all (hand-authored) → kept
|
|
2528
|
+
* untouched with a warning. This is the surviving half of the #2108
|
|
2529
|
+
* invariant: the owner's remove-and-quarantine decision covers a
|
|
2530
|
+
* CLI-authored-then-edited skill, NEVER content whose authorship of
|
|
2531
|
+
* THIS repo's dir the CLI cannot prove.
|
|
2532
|
+
*
|
|
2533
|
+
* Returns `{changed, quarantined}`: `changed` is true when at least one
|
|
2534
|
+
* dir was removed or quarantined (the caller's per-skill `removed`
|
|
2535
|
+
* increment); `quarantined` is true when any was MOVED to quarantine
|
|
2536
|
+
* rather than deleted (a subset of `changed`, surfaced for the summary).
|
|
2292
2537
|
*
|
|
2293
2538
|
* @param {object} args
|
|
2294
2539
|
* @param {import("./repo-sync-state.mjs").RepoResolvedEntry} args.entry
|
|
2295
2540
|
* @param {string[]} args.targets
|
|
2296
2541
|
* @param {string} args.rootDir
|
|
2297
|
-
* @param {string[]} [args.vendors]
|
|
2298
2542
|
* @param {NodeJS.WritableStream} args.stderr
|
|
2299
2543
|
* @param {Map<string, {skillMdSha256: string, filesSha256: string}>} args.bulkBaselines
|
|
2300
2544
|
* Name-keyed `.last-sync` write baselines, read once per sync.
|
|
2301
|
-
* @returns {boolean}
|
|
2545
|
+
* @returns {{ changed: boolean, quarantined: boolean }}
|
|
2302
2546
|
*/
|
|
2303
|
-
function reconcileDeleteGuarded({ entry, targets, rootDir,
|
|
2547
|
+
function reconcileDeleteGuarded({ entry, targets, rootDir, stderr, bulkBaselines }) {
|
|
2304
2548
|
// A state file is local data, not a trusted input: an entry whose
|
|
2305
2549
|
// name fails spec validation is never resolved into a path (the
|
|
2306
2550
|
// join would otherwise honor traversal shapes like "../x").
|
|
2307
|
-
if (!isValidSkillName(entry.name)) return false;
|
|
2551
|
+
if (!isValidSkillName(entry.name)) return { changed: false, quarantined: false };
|
|
2308
2552
|
|
|
2309
2553
|
const hasPerRepoBaseline =
|
|
2310
2554
|
typeof entry.skillMdSha256 === "string" &&
|
|
2311
2555
|
typeof entry.filesSha256 === "string";
|
|
2312
2556
|
const bulkEntry = bulkBaselines.get(entry.name);
|
|
2313
2557
|
|
|
2314
|
-
const
|
|
2315
|
-
existsSync(resolvePlacementDir(target, entry.name, rootDir)),
|
|
2316
|
-
);
|
|
2317
|
-
if (existing.length === 0) return false; // nothing on disk — just drops from state
|
|
2318
|
-
|
|
2319
|
-
const mismatched = existing.filter((target) => {
|
|
2558
|
+
const matchesBaseline = (target) => {
|
|
2320
2559
|
const diskShas = computeShasFromDir(
|
|
2321
2560
|
resolvePlacementDir(target, entry.name, rootDir),
|
|
2322
2561
|
);
|
|
@@ -2327,34 +2566,57 @@ function reconcileDeleteGuarded({ entry, targets, rootDir, vendors, stderr, bulk
|
|
|
2327
2566
|
filesSha256: entry.filesSha256,
|
|
2328
2567
|
})
|
|
2329
2568
|
) {
|
|
2330
|
-
return
|
|
2569
|
+
return true;
|
|
2331
2570
|
}
|
|
2332
|
-
return
|
|
2333
|
-
}
|
|
2571
|
+
return !!(bulkEntry && shasMatch(diskShas, bulkEntry));
|
|
2572
|
+
};
|
|
2334
2573
|
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2574
|
+
const existing = targets.filter((target) =>
|
|
2575
|
+
existsSync(resolvePlacementDir(target, entry.name, rootDir)),
|
|
2576
|
+
);
|
|
2577
|
+
if (existing.length === 0) return { changed: false, quarantined: false }; // nothing on disk — just drops from state
|
|
2578
|
+
|
|
2579
|
+
const classified = existing.map((target) => ({
|
|
2580
|
+
target,
|
|
2581
|
+
match: matchesBaseline(target),
|
|
2582
|
+
}));
|
|
2583
|
+
|
|
2584
|
+
let changed = false;
|
|
2585
|
+
let quarantined = false;
|
|
2586
|
+
for (const { target, match } of classified) {
|
|
2587
|
+
const dir = resolvePlacementDir(target, entry.name, rootDir);
|
|
2588
|
+
const displayPath = `${describePlacementTarget(target)}${escapeControlChars(entry.name)}`;
|
|
2589
|
+
if (match) {
|
|
2590
|
+
// Exactly what the CLI wrote — safe to delete outright.
|
|
2591
|
+
if (removeManagedDir(dir)) changed = true;
|
|
2592
|
+
} else if (hasPerRepoBaseline) {
|
|
2593
|
+
// The per-repo manifest proves the CLI wrote THIS repo's placement
|
|
2594
|
+
// and it has since been edited: quarantine (compliance + no data
|
|
2595
|
+
// loss). A bulk-only baseline is deliberately NOT sufficient here —
|
|
2596
|
+
// it proves the CLI wrote SOME placement, not this repo's dir, so a
|
|
2597
|
+
// divergence could be hand-authored project content.
|
|
2598
|
+
const dest = quarantineSkillDir(dir, { skillName: entry.name });
|
|
2599
|
+
changed = true;
|
|
2600
|
+
quarantined = true;
|
|
2339
2601
|
stderr.write(
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2602
|
+
` warning: ${displayPath}/ was removed from this repo's skillset but has local edits — ` +
|
|
2603
|
+
`moved to quarantine (${dest}) instead of deleted, so it stops loading in your agents ` +
|
|
2604
|
+
`while your changes are preserved. Recover them from that folder if needed.\n`,
|
|
2605
|
+
);
|
|
2606
|
+
} else {
|
|
2607
|
+
// Diverged with no per-repo authorship proof — either genuinely
|
|
2608
|
+
// hand-authored (no baseline anywhere) or the #2365 composed-state
|
|
2609
|
+
// where only a bulk `.last-sync` baseline exists, which proves the CLI
|
|
2610
|
+
// wrote SOME placement but not THIS repo's dir. Same safe action
|
|
2611
|
+
// either way: never move content the CLI cannot prove it wrote here.
|
|
2612
|
+
stderr.write(
|
|
2613
|
+
` warning: ${displayPath}/ was removed from this repo's skillset but was not written ` +
|
|
2614
|
+
`by this CLI, so it was not deleted. Push it to your library with ` +
|
|
2615
|
+
`'skillrepo push ${displayPath}', or remove the directory yourself.\n`,
|
|
2347
2616
|
);
|
|
2348
2617
|
}
|
|
2349
|
-
return false;
|
|
2350
2618
|
}
|
|
2351
|
-
|
|
2352
|
-
const removeResult = removeSkillDir(entry.name, {
|
|
2353
|
-
vendors,
|
|
2354
|
-
global: false,
|
|
2355
|
-
baseDir: rootDir,
|
|
2356
|
-
});
|
|
2357
|
-
return removeResult.removed.length > 0;
|
|
2619
|
+
return { changed, quarantined };
|
|
2358
2620
|
}
|
|
2359
2621
|
|
|
2360
2622
|
/**
|
|
@@ -126,9 +126,12 @@ describe("runUpdate — summary printer (removed line)", () => {
|
|
|
126
126
|
it("prints the removed line in the human summary when a tombstone deletes a skill", async () => {
|
|
127
127
|
// printSummary only emits `− N removed` when `s.removed > 0`. Every
|
|
128
128
|
// other human-mode test produces added/updated-only summaries, so the
|
|
129
|
-
// `if (s.removed > 0)` true branch was never taken. Sync a skill
|
|
130
|
-
//
|
|
131
|
-
//
|
|
129
|
+
// `if (s.removed > 0)` true branch was never taken. Sync a skill (with
|
|
130
|
+
// an etag baseline so the next sync is a genuine DELTA), then re-sync
|
|
131
|
+
// with it tombstoned so `removed` is non-zero and the line renders.
|
|
132
|
+
// (A tombstone only rides a DELTA — a full sync carries removals:[],
|
|
133
|
+
// #2804 — so the etag is load-bearing here.)
|
|
134
|
+
server.setEtag('"v1"');
|
|
132
135
|
server.setLibraryResponse({
|
|
133
136
|
skills: [makeSkill("doomed")],
|
|
134
137
|
removals: [],
|
|
@@ -139,6 +142,7 @@ describe("runUpdate — summary printer (removed line)", () => {
|
|
|
139
142
|
|
|
140
143
|
stdout.clear();
|
|
141
144
|
server.resetLibraryInspection?.();
|
|
145
|
+
server.setEtag('"v2"');
|
|
142
146
|
server.setLibraryResponse({
|
|
143
147
|
skills: [],
|
|
144
148
|
removals: [{ owner: "alice", name: "doomed", removedAt: "x" }],
|
|
@@ -154,6 +158,36 @@ describe("runUpdate — summary printer (removed line)", () => {
|
|
|
154
158
|
"the tombstoned skill is gone from disk",
|
|
155
159
|
);
|
|
156
160
|
});
|
|
161
|
+
|
|
162
|
+
it("surfaces the quarantine in the summary when an EDITED removed skill is moved (#2804)", async () => {
|
|
163
|
+
// Sync a skill, edit it locally, then recall it on a full fetch so the
|
|
164
|
+
// reconcile QUARANTINES rather than deletes. The summary must say so —
|
|
165
|
+
// otherwise the user thinks their edits were deleted.
|
|
166
|
+
server.setEtag('"v1"');
|
|
167
|
+
server.setLibraryResponse({ skills: [makeSkill("edited")], removals: [], syncedAt: "x" });
|
|
168
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude"], { stdout });
|
|
169
|
+
const dir = resolvePlacementDir("claudeProject", "edited");
|
|
170
|
+
writeFileSync(join(dir, "SKILL.md"), "---\nname: edited\n---\nlocally changed\n");
|
|
171
|
+
|
|
172
|
+
// Force a full fetch (pre-floor cliVersion) that recalls the skill.
|
|
173
|
+
const prior = readLastSync();
|
|
174
|
+
writeLastSync({
|
|
175
|
+
etag: prior.etag,
|
|
176
|
+
syncedAt: prior.syncedAt,
|
|
177
|
+
skills: prior.skills,
|
|
178
|
+
cliVersion: "4.7.0",
|
|
179
|
+
});
|
|
180
|
+
server.setEtag('"v2"');
|
|
181
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "y" });
|
|
182
|
+
|
|
183
|
+
stdout.clear();
|
|
184
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude"], { stdout });
|
|
185
|
+
|
|
186
|
+
const out = stdout.text();
|
|
187
|
+
assert.match(out, /−\s*1 removed/, "still counts as removed (left the loadable set)");
|
|
188
|
+
assert.match(out, /quarantine/, "and tells the user it was MOVED, not deleted");
|
|
189
|
+
assert.ok(!existsSync(dir), "the loadable copy is gone");
|
|
190
|
+
});
|
|
157
191
|
});
|
|
158
192
|
|
|
159
193
|
describe("runUpdate — credential resolution", () => {
|
|
@@ -689,6 +689,14 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
689
689
|
const filteredResponse = {
|
|
690
690
|
...libraryResponse,
|
|
691
691
|
skills: respondedSkills,
|
|
692
|
+
// Match production (src/app/api/v1/library/route.ts:624): a FULL
|
|
693
|
+
// library sync (no `since`) NEVER carries removals — a departed
|
|
694
|
+
// skill is expressed by ABSENCE from the complete `skills[]`, and
|
|
695
|
+
// the client reconciles it (#2804). Only delta syncs (with `since`)
|
|
696
|
+
// carry tombstones. The mock previously spread `removals` through
|
|
697
|
+
// unfiltered, so a "full-sync tombstone" test passed against a shape
|
|
698
|
+
// production never sends and the #2804 orphan gap had NO coverage.
|
|
699
|
+
removals: sinceParam ? (libraryResponse.removals ?? []) : [],
|
|
692
700
|
};
|
|
693
701
|
|
|
694
702
|
const headers = { "Content-Type": "application/json" };
|