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
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
|
|
@@ -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/`
|
|
656
|
-
|
|
657
|
-
|
|
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
package/src/commands/init.mjs
CHANGED
|
@@ -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" →
|
|
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
|
|
1081
|
+
" Both paths are gitignored — skills are a per-developer " +
|
|
1081
1082
|
"cache, not committed.\n\n",
|
|
1082
1083
|
);
|
|
1083
1084
|
}
|
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.
|
|
@@ -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
|
|
439
|
-
//
|
|
440
|
-
//
|
|
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
|
|
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 (
|
|
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
|
-
}
|
|
498
|
-
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
87
|
-
* legacy section
|
|
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 —
|
|
112
|
-
|
|
113
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|