skillrepo 4.11.0 → 4.12.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 +22 -8
- package/package.json +1 -1
- package/src/commands/uninstall.mjs +7 -2
- package/src/lib/artifact-registry.mjs +18 -3
- package/src/lib/crypto-shas.mjs +16 -0
- package/src/lib/errors.mjs +9 -6
- package/src/lib/file-write.mjs +35 -31
- package/src/lib/foreign-content.mjs +7 -3
- package/src/lib/gitignore-section.mjs +10 -4
- package/src/lib/http.mjs +80 -1
- package/src/lib/paths.mjs +43 -5
- package/src/lib/placement-walk.mjs +8 -1
- package/src/lib/repo-sync-state.mjs +306 -0
- package/src/lib/skillset-declaration.mjs +14 -33
- package/src/lib/sync.mjs +1082 -25
- package/src/test/commands/init.test.mjs +67 -7
- package/src/test/commands/uninstall.test.mjs +37 -5
- package/src/test/commands/update.test.mjs +37 -5
- package/src/test/e2e/mock-server.mjs +147 -0
- package/src/test/lib/artifact-registry.test.mjs +4 -0
- package/src/test/lib/repo-sync-state.test.mjs +270 -0
- package/src/test/lib/skillset-declaration.test.mjs +4 -27
- package/src/test/lib/sync-skillset.test.mjs +1231 -0
- package/src/test/lib/sync.test.mjs +58 -28
package/README.md
CHANGED
|
@@ -187,13 +187,27 @@ A repository can declare a skillset in a root `skillrepo.json`:
|
|
|
187
187
|
`{"skillset": {"version": 1, "name": "<repo-identity>", "use":
|
|
188
188
|
"owner/skillset-name"}}` (optional `extra: ["owner/skill"]`). The CLI
|
|
189
189
|
finds the file from any subdirectory (it walks up to the repository
|
|
190
|
-
root)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
190
|
+
root), validates it, and syncs that repository against the declared
|
|
191
|
+
skillset instead of your whole library — a declared repo only ever
|
|
192
|
+
receives its declared skillset. Skillset-scoped sync resolves the
|
|
193
|
+
complete set on every pass: skills added to the skillset appear,
|
|
194
|
+
skills removed from it are deleted from the repo's skill folders, and
|
|
195
|
+
everything lands at the repository root no matter which subdirectory
|
|
196
|
+
you run from. Deletes are guarded — a directory the CLI did not write,
|
|
197
|
+
or one you modified locally, is never removed or overwritten; it stays
|
|
198
|
+
put with a warning and shows up in your team's sync reporting instead.
|
|
199
|
+
Declared entries in `extra` that cannot be honored (for example when
|
|
200
|
+
extras are not enabled for your organization) are skipped loudly with
|
|
201
|
+
the reason, never silently. If the registry is unreachable, the repo
|
|
202
|
+
keeps serving its last-verified skills and the sync still exits
|
|
203
|
+
successfully with a warning (the warning escalates once the state is
|
|
204
|
+
more than ~72 hours old). If the declaration itself cannot be honored
|
|
205
|
+
— unknown skillset, skillsets not enabled for your organization, an
|
|
206
|
+
invalid file — the sync fails closed with exit code `6` and nothing is
|
|
207
|
+
written or removed. A `skillrepo.json` without a `skillset` key is
|
|
208
|
+
plain configuration and does not change sync. See
|
|
209
|
+
[`docs/skillset-declaration.md`](docs/skillset-declaration.md) for the
|
|
210
|
+
full file-format reference.
|
|
197
211
|
|
|
198
212
|
### `get` — fetch a single skill
|
|
199
213
|
|
|
@@ -586,7 +600,7 @@ citations on each agent's read paths.
|
|
|
586
600
|
| 3 | Disk error (cannot read or write a file/directory) |
|
|
587
601
|
| 4 | Scope error (key lacks the required `registry:write` scope) |
|
|
588
602
|
| 5 | Validation error (bad flag, malformed identifier, unknown vendor) |
|
|
589
|
-
| 6 | Unresolvable skillset declaration — the repo's root `skillrepo.json` declares a skillset that cannot be honored
|
|
603
|
+
| 6 | Unresolvable skillset declaration — the repo's root `skillrepo.json` declares a skillset that cannot be honored: an invalid or nested declaration, reserved `@` version syntax, an unknown skillset, or skillsets not enabled for your organization. Fail-closed: nothing was written or removed. A registry that is merely unreachable is NOT this class — the sync serves the last-verified skillset and exits `0` with a warning. |
|
|
590
604
|
|
|
591
605
|
Pass `--verbose` to any command to print stack traces and retry
|
|
592
606
|
attempts on failure.
|
package/package.json
CHANGED
|
@@ -102,6 +102,7 @@ const FILE_REMOVERS = Object.freeze({
|
|
|
102
102
|
// Directory artifacts — handled inline.
|
|
103
103
|
"skills-dir-project": null,
|
|
104
104
|
"skills-dir-global": null,
|
|
105
|
+
"repo-sync-state-dir": null,
|
|
105
106
|
"global-config-dir": null,
|
|
106
107
|
});
|
|
107
108
|
|
|
@@ -141,12 +142,16 @@ function removeDirectoryArtifact(descriptor, { dryRun }) {
|
|
|
141
142
|
error: `Cannot resolve ${path}: ${err.message}. Refusing to rmSync a path that doesn't resolve.`,
|
|
142
143
|
};
|
|
143
144
|
}
|
|
145
|
+
// "skills" = the placement caches, "skillrepo" = the global config
|
|
146
|
+
// dir, "repos" = the per-repo skillset state dir (#2365,
|
|
147
|
+
// repo-sync-state-dir descriptor).
|
|
148
|
+
const ALLOWED_DIR_BASENAMES = new Set(["skills", "skillrepo", "repos"]);
|
|
144
149
|
const basename = realPath.split(/[\\/]/).filter(Boolean).pop();
|
|
145
|
-
if (basename
|
|
150
|
+
if (!ALLOWED_DIR_BASENAMES.has(basename)) {
|
|
146
151
|
return {
|
|
147
152
|
path: displayPath,
|
|
148
153
|
action: "skipped",
|
|
149
|
-
error: `Refusing to recursively remove ${path} (resolved to ${realPath}): basename "${basename}" is
|
|
154
|
+
error: `Refusing to recursively remove ${path} (resolved to ${realPath}): basename "${basename}" is not a CLI-owned directory name (${[...ALLOWED_DIR_BASENAMES].join(", ")}).`,
|
|
150
155
|
};
|
|
151
156
|
}
|
|
152
157
|
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
gitignorePath,
|
|
40
40
|
claudeSkillsProjectRoot,
|
|
41
41
|
claudeSkillsGlobalRoot,
|
|
42
|
+
globalRepoStateDir,
|
|
42
43
|
} from "./paths.mjs";
|
|
43
44
|
import { join } from "node:path";
|
|
44
45
|
import { homedir } from "node:os";
|
|
@@ -359,14 +360,28 @@ export const ARTIFACT_REGISTRY = Object.freeze([
|
|
|
359
360
|
pathFn: claudeSkillsGlobalRoot,
|
|
360
361
|
displayPath: "~/.claude/skills/",
|
|
361
362
|
}),
|
|
363
|
+
Object.freeze({
|
|
364
|
+
// Per-repo skillset sync state (#2365, design doc D12): one JSON
|
|
365
|
+
// file per declared repo, keyed by realpath hash. Listed BEFORE
|
|
366
|
+
// its parent `global-config-dir` so the uninstall preview
|
|
367
|
+
// discloses the state class explicitly and the child is removed
|
|
368
|
+
// first (the parent sweep then finds it already gone — removal
|
|
369
|
+
// stays idempotent either way).
|
|
370
|
+
id: "repo-sync-state-dir",
|
|
371
|
+
scope: "global",
|
|
372
|
+
kind: "directory",
|
|
373
|
+
pathFn: globalRepoStateDir,
|
|
374
|
+
displayPath: "~/.claude/skillrepo/repos/",
|
|
375
|
+
}),
|
|
362
376
|
Object.freeze({
|
|
363
377
|
id: "global-config-dir",
|
|
364
378
|
scope: "global",
|
|
365
379
|
kind: "directory",
|
|
366
380
|
// `~/.claude/skillrepo/` — the parent dir of config.json,
|
|
367
|
-
// .last-sync, .npm-version-check,
|
|
368
|
-
//
|
|
369
|
-
// CLI-owned and there's no room
|
|
381
|
+
// .last-sync, .npm-version-check, .governance-seen (#2361), and
|
|
382
|
+
// the repos/ state dir above (#2365). Whole-directory removal is
|
|
383
|
+
// correct because every child is CLI-owned and there's no room
|
|
384
|
+
// for user content.
|
|
370
385
|
pathFn: () => join(homedir(), ".claude", "skillrepo"),
|
|
371
386
|
displayPath: "~/.claude/skillrepo/",
|
|
372
387
|
}),
|
package/src/lib/crypto-shas.mjs
CHANGED
|
@@ -56,6 +56,22 @@
|
|
|
56
56
|
|
|
57
57
|
import { createHash } from "node:crypto";
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Hex SHA-256 of a single UTF-8 string. The shared low-level digest
|
|
61
|
+
* this module is built on, exported (#2365) so the per-repo state
|
|
62
|
+
* store can hash its realpath-derived state key without hand-rolling
|
|
63
|
+
* a second `createHash` call site (shared-logic rule). Pure — no I/O.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} value
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export function sha256Hex(value) {
|
|
69
|
+
if (typeof value !== "string") {
|
|
70
|
+
throw new TypeError("sha256Hex: value must be a string");
|
|
71
|
+
}
|
|
72
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
73
|
+
}
|
|
74
|
+
|
|
59
75
|
/**
|
|
60
76
|
* @typedef {Object} SkillFileLike
|
|
61
77
|
* @property {string} path - Canonical POSIX path relative to skill root.
|
package/src/lib/errors.mjs
CHANGED
|
@@ -12,12 +12,15 @@
|
|
|
12
12
|
* 6 — unresolvable skillset declaration (#2362, design doc D9): the
|
|
13
13
|
* repo's `skillrepo.json` skillset declaration cannot be honored
|
|
14
14
|
* (unparseable file, invalid sub-schema, nested declarations,
|
|
15
|
-
* reserved `@` version syntax, or
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
15
|
+
* reserved `@` version syntax, or a server-determined cause —
|
|
16
|
+
* unknown skillset, skillsets not enabled for the organization,
|
|
17
|
+
* declaration owner mismatch). Fail-closed: nothing was written
|
|
18
|
+
* or removed. This one code covers the WHOLE D9 fail-closed
|
|
19
|
+
* class, locally AND the server's typed 422
|
|
20
|
+
* `skillset_unresolvable` response consumed by the scoped sync
|
|
21
|
+
* (#2365; H6 #2363 emitter — named per the D19 extension, never
|
|
22
|
+
* `harness_unresolvable`, owner decision 2026-08-11), so CI
|
|
23
|
+
* scripts get one stable "declaration cannot be honored" signal.
|
|
21
24
|
*
|
|
22
25
|
* These mirror the documented behavior in #683 and are the contract
|
|
23
26
|
* shell users and CI scripts can rely on.
|
package/src/lib/file-write.mjs
CHANGED
|
@@ -235,18 +235,24 @@ export function validateFilePath(rawPath) {
|
|
|
235
235
|
* Resolve the absolute filesystem directory for a skill at a given
|
|
236
236
|
* placement target. The directory does not need to exist yet.
|
|
237
237
|
*
|
|
238
|
+
* `baseDir` (#2365) anchors PROJECT-scope targets at a skillset repo's
|
|
239
|
+
* detected root instead of cwd; global targets ignore it (they live in
|
|
240
|
+
* the user's home regardless of where the sync runs). Omitted →
|
|
241
|
+
* cwd-anchored, byte-identical to the pre-#2365 behavior (D2).
|
|
242
|
+
*
|
|
238
243
|
* @param {PlacementTarget} target
|
|
239
244
|
* @param {string} skillName
|
|
245
|
+
* @param {string} [baseDir]
|
|
240
246
|
* @returns {string}
|
|
241
247
|
*/
|
|
242
|
-
export function resolvePlacementDir(target, skillName) {
|
|
248
|
+
export function resolvePlacementDir(target, skillName, baseDir) {
|
|
243
249
|
switch (target) {
|
|
244
250
|
case "claudeProject":
|
|
245
|
-
return claudeSkillsProject(skillName);
|
|
251
|
+
return claudeSkillsProject(skillName, baseDir);
|
|
246
252
|
case "claudeGlobal":
|
|
247
253
|
return claudeSkillsGlobal(skillName);
|
|
248
254
|
case "agentsProject":
|
|
249
|
-
return agentsSkillsProject(skillName);
|
|
255
|
+
return agentsSkillsProject(skillName, baseDir);
|
|
250
256
|
case "agentsGlobal":
|
|
251
257
|
return agentsSkillsGlobal(skillName);
|
|
252
258
|
case "windsurfGlobal":
|
|
@@ -290,34 +296,25 @@ export function describePlacementTarget(target) {
|
|
|
290
296
|
* detection in #1555) can enumerate skill placements without
|
|
291
297
|
* duplicating the target→root mapping that already lives here.
|
|
292
298
|
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*/
|
|
296
|
-
export function resolvePlacementRoot(target) {
|
|
297
|
-
return placementRootFn(target)();
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Map a `PlacementTarget` to its parent root resolver. Used by
|
|
302
|
-
* `cleanupOrphans` to know which directories to scan for `.tmp`/`.old`
|
|
303
|
-
* siblings, and by `resolvePlacementRoot` above for read-side
|
|
304
|
-
* consumers.
|
|
299
|
+
* `baseDir` anchors project-scope roots (#2365) — same contract as
|
|
300
|
+
* `resolvePlacementDir` above.
|
|
305
301
|
*
|
|
306
302
|
* @param {PlacementTarget} target
|
|
307
|
-
* @
|
|
303
|
+
* @param {string} [baseDir]
|
|
304
|
+
* @returns {string}
|
|
308
305
|
*/
|
|
309
|
-
function
|
|
306
|
+
export function resolvePlacementRoot(target, baseDir) {
|
|
310
307
|
switch (target) {
|
|
311
308
|
case "claudeProject":
|
|
312
|
-
return claudeSkillsProjectRoot;
|
|
309
|
+
return claudeSkillsProjectRoot(baseDir);
|
|
313
310
|
case "claudeGlobal":
|
|
314
|
-
return claudeSkillsGlobalRoot;
|
|
311
|
+
return claudeSkillsGlobalRoot();
|
|
315
312
|
case "agentsProject":
|
|
316
|
-
return agentsSkillsProjectRoot;
|
|
313
|
+
return agentsSkillsProjectRoot(baseDir);
|
|
317
314
|
case "agentsGlobal":
|
|
318
|
-
return agentsSkillsGlobalRoot;
|
|
315
|
+
return agentsSkillsGlobalRoot();
|
|
319
316
|
case "windsurfGlobal":
|
|
320
|
-
return windsurfSkillsGlobalRoot;
|
|
317
|
+
return windsurfSkillsGlobalRoot();
|
|
321
318
|
default: {
|
|
322
319
|
throw validationError(`Unknown placement target: ${target}`);
|
|
323
320
|
}
|
|
@@ -412,6 +409,9 @@ export function readFrontmatterName(files) {
|
|
|
412
409
|
* @param {object} options
|
|
413
410
|
* @param {string[]} [options.vendors] - Vendor keys; required unless `global`.
|
|
414
411
|
* @param {boolean} [options.global] - If true, write to global Claude Code dir.
|
|
412
|
+
* @param {string} [options.baseDir] - Project-scope anchor (#2365): a
|
|
413
|
+
* skillset-scoped sync writes at the repo's detected root, not
|
|
414
|
+
* cwd. Applies to placement dirs AND the .gitignore heal.
|
|
415
415
|
* @returns {{ written: string[] }} Per-target absolute directory paths.
|
|
416
416
|
*
|
|
417
417
|
* Note: `writeSkillDir` does NOT short-circuit when an existing skill on
|
|
@@ -444,12 +444,12 @@ export function writeSkillDir(skill, options = {}) {
|
|
|
444
444
|
// failure here AFTER a successful write to an earlier target would
|
|
445
445
|
// leave the user with a half-applied state and an error message
|
|
446
446
|
// that doesn't reflect what's actually on disk.
|
|
447
|
-
ensureProjectGitignore(targets);
|
|
447
|
+
ensureProjectGitignore(targets, options.baseDir);
|
|
448
448
|
|
|
449
449
|
const written = [];
|
|
450
450
|
|
|
451
451
|
for (const target of targets) {
|
|
452
|
-
const targetDir = resolvePlacementDir(target, skill.name);
|
|
452
|
+
const targetDir = resolvePlacementDir(target, skill.name, options.baseDir);
|
|
453
453
|
|
|
454
454
|
try {
|
|
455
455
|
writeSkillToDir(skill, targetDir);
|
|
@@ -473,6 +473,7 @@ export function writeSkillDir(skill, options = {}) {
|
|
|
473
473
|
* @param {object} options
|
|
474
474
|
* @param {string[]} [options.vendors]
|
|
475
475
|
* @param {boolean} [options.global]
|
|
476
|
+
* @param {string} [options.baseDir] - Project-scope anchor (#2365).
|
|
476
477
|
* @returns {{ removed: string[], notFound: string[] }}
|
|
477
478
|
*/
|
|
478
479
|
export function removeSkillDir(skillName, options = {}) {
|
|
@@ -485,7 +486,7 @@ export function removeSkillDir(skillName, options = {}) {
|
|
|
485
486
|
const notFound = [];
|
|
486
487
|
|
|
487
488
|
for (const target of targets) {
|
|
488
|
-
const targetDir = resolvePlacementDir(target, skillName);
|
|
489
|
+
const targetDir = resolvePlacementDir(target, skillName, options.baseDir);
|
|
489
490
|
if (!existsSync(targetDir)) {
|
|
490
491
|
notFound.push(targetDir);
|
|
491
492
|
continue;
|
|
@@ -533,6 +534,7 @@ export function removeSkillDir(skillName, options = {}) {
|
|
|
533
534
|
* @param {object} options
|
|
534
535
|
* @param {string[]} [options.vendors]
|
|
535
536
|
* @param {boolean} [options.global]
|
|
537
|
+
* @param {string} [options.baseDir] - Project-scope anchor (#2365).
|
|
536
538
|
* @returns {{ cleaned: string[] }}
|
|
537
539
|
*/
|
|
538
540
|
export function cleanupOrphans(options = {}) {
|
|
@@ -543,19 +545,19 @@ export function cleanupOrphans(options = {}) {
|
|
|
543
545
|
global: !!options.global,
|
|
544
546
|
});
|
|
545
547
|
for (const target of targets) {
|
|
546
|
-
roots.add(
|
|
548
|
+
roots.add(resolvePlacementRoot(target, options.baseDir));
|
|
547
549
|
}
|
|
548
550
|
} else if (options.global) {
|
|
549
551
|
// No vendors under --global — sweep every known global root so a
|
|
550
552
|
// stale orphan from any prior --global write is cleaned.
|
|
551
553
|
for (const target of GLOBAL_TARGETS) {
|
|
552
|
-
roots.add(
|
|
554
|
+
roots.add(resolvePlacementRoot(target, options.baseDir));
|
|
553
555
|
}
|
|
554
556
|
} else {
|
|
555
557
|
// No vendors and no --global — sweep every root we know about so
|
|
556
558
|
// any orphan from any prior run is cleaned.
|
|
557
559
|
for (const target of ALL_TARGETS) {
|
|
558
|
-
roots.add(
|
|
560
|
+
roots.add(resolvePlacementRoot(target, options.baseDir));
|
|
559
561
|
}
|
|
560
562
|
}
|
|
561
563
|
|
|
@@ -836,13 +838,15 @@ function writeSkillToDir(skill, targetDir) {
|
|
|
836
838
|
* suspenders wrap here only converts a non-CliError escape.
|
|
837
839
|
*
|
|
838
840
|
* @param {PlacementTarget[]} targets
|
|
841
|
+
* @param {string} [baseDir] - Project-scope anchor (#2365): the heal
|
|
842
|
+
* targets the repo root's .gitignore on a skillset-scoped sync.
|
|
839
843
|
*/
|
|
840
|
-
function ensureProjectGitignore(targets) {
|
|
844
|
+
function ensureProjectGitignore(targets, baseDir) {
|
|
841
845
|
try {
|
|
842
|
-
ensureManagedRootIgnores(targets);
|
|
846
|
+
ensureManagedRootIgnores(targets, { baseDir });
|
|
843
847
|
} catch (err) {
|
|
844
848
|
if (err instanceof CliError) throw err;
|
|
845
|
-
throw diskError(`Cannot update ${gitignorePath()}: ${err.message}`, {
|
|
849
|
+
throw diskError(`Cannot update ${gitignorePath(baseDir)}: ${err.message}`, {
|
|
846
850
|
cause: err,
|
|
847
851
|
});
|
|
848
852
|
}
|
|
@@ -105,10 +105,14 @@ export const UNMANAGED_CONTENT_CATEGORY = "unmanaged";
|
|
|
105
105
|
* @param {Record<string, unknown> | null | undefined} options.managedSkills
|
|
106
106
|
* The `.last-sync` `skills` map (post-sync state) — keys are
|
|
107
107
|
* `"<owner>/<name>"`. Only the name half is used: placement
|
|
108
|
-
* dirs are keyed by skill name alone.
|
|
108
|
+
* dirs are keyed by skill name alone. A skillset-scoped sync
|
|
109
|
+
* (#2365) passes its per-repo resolved set in the same shape.
|
|
110
|
+
* @param {string} [options.baseDir] - Project-scope anchor (#2365): a
|
|
111
|
+
* skillset-scoped sync scans the placement roots at the repo's
|
|
112
|
+
* detected root, not cwd. Omitted → cwd (unchanged behavior).
|
|
109
113
|
* @returns {ForeignScanResult}
|
|
110
114
|
*/
|
|
111
|
-
export function scanForeignContent({ vendors, global, managedSkills }) {
|
|
115
|
+
export function scanForeignContent({ vendors, global, managedSkills, baseDir }) {
|
|
112
116
|
/** @type {ForeignScanResult} */
|
|
113
117
|
const result = { roots: [], foreignCount: 0 };
|
|
114
118
|
|
|
@@ -137,7 +141,7 @@ export function scanForeignContent({ vendors, global, managedSkills }) {
|
|
|
137
141
|
// Normalized (realpath + win32 case-fold) because `root` doubles
|
|
138
142
|
// as the seen-state key — see normalizeStateKey. The D12 sibling
|
|
139
143
|
// state store already uses realpath-keyed state; same reasoning.
|
|
140
|
-
root = normalizeStateKey(resolvePlacementRoot(target));
|
|
144
|
+
root = normalizeStateKey(resolvePlacementRoot(target, baseDir));
|
|
141
145
|
} catch {
|
|
142
146
|
continue;
|
|
143
147
|
}
|
|
@@ -59,9 +59,13 @@ export const MANAGED_ROOT_ENTRIES = Object.freeze({
|
|
|
59
59
|
* repo content, so there is nothing to ignore.
|
|
60
60
|
*
|
|
61
61
|
* @param {string[]} targets - PlacementTarget values for the current write.
|
|
62
|
+
* @param {object} [options]
|
|
63
|
+
* @param {string} [options.baseDir] - Project-scope anchor (#2365): a
|
|
64
|
+
* skillset-scoped sync heals the .gitignore at the repo's
|
|
65
|
+
* detected root, not cwd. Omitted → cwd (unchanged behavior).
|
|
62
66
|
* @returns {{ path: string; action: "created" | "updated" | "skipped"; added: string[] }}
|
|
63
67
|
*/
|
|
64
|
-
export function ensureManagedRootIgnores(targets) {
|
|
68
|
+
export function ensureManagedRootIgnores(targets, options = {}) {
|
|
65
69
|
const entries = [];
|
|
66
70
|
if (Array.isArray(targets)) {
|
|
67
71
|
for (const [target, entry] of Object.entries(MANAGED_ROOT_ENTRIES)) {
|
|
@@ -71,7 +75,7 @@ export function ensureManagedRootIgnores(targets) {
|
|
|
71
75
|
if (entries.length === 0) {
|
|
72
76
|
return { path: ".gitignore", action: "skipped", added: [] };
|
|
73
77
|
}
|
|
74
|
-
return ensureGitignoreEntries(entries);
|
|
78
|
+
return ensureGitignoreEntries(entries, options);
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
/**
|
|
@@ -84,10 +88,12 @@ export function ensureManagedRootIgnores(targets) {
|
|
|
84
88
|
* `"updated"` otherwise.
|
|
85
89
|
*
|
|
86
90
|
* @param {string[]} requiredEntries
|
|
91
|
+
* @param {object} [options]
|
|
92
|
+
* @param {string} [options.baseDir] - Project-scope anchor (#2365).
|
|
87
93
|
* @returns {{ path: string; action: "created" | "updated" | "skipped"; added: string[] }}
|
|
88
94
|
*/
|
|
89
|
-
export function ensureGitignoreEntries(requiredEntries) {
|
|
90
|
-
const filePath = gitignorePath();
|
|
95
|
+
export function ensureGitignoreEntries(requiredEntries, options = {}) {
|
|
96
|
+
const filePath = gitignorePath(options.baseDir);
|
|
91
97
|
let existing;
|
|
92
98
|
try {
|
|
93
99
|
existing = readFileSafe(filePath);
|
package/src/lib/http.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
authError,
|
|
27
27
|
networkError,
|
|
28
28
|
scopeError,
|
|
29
|
+
unresolvableError,
|
|
29
30
|
validationError,
|
|
30
31
|
withRetry,
|
|
31
32
|
} from "./errors.mjs";
|
|
@@ -370,6 +371,20 @@ async function mapErrorResponse(res, url) {
|
|
|
370
371
|
if (res.status === 404) {
|
|
371
372
|
return null; // Caller decides
|
|
372
373
|
}
|
|
374
|
+
if (res.status === 422 && code === "skillset_unresolvable") {
|
|
375
|
+
// H6 #2363's typed fail-closed response for a skillset-scoped sync
|
|
376
|
+
// (design doc D9, wire name locked by the 2026-08-11 owner
|
|
377
|
+
// decision — never `harness_unresolvable`). Every cause — malformed
|
|
378
|
+
// params, skillsets not enabled for the organization, declaration
|
|
379
|
+
// owner mismatch, unknown skillset name — maps onto the ONE stable
|
|
380
|
+
// EXIT_UNRESOLVABLE (6) via the machine `code` field, never a
|
|
381
|
+
// message string-compare. The server's `error` text is the
|
|
382
|
+
// complete actionable copy and is surfaced VERBATIM: no CLI-side
|
|
383
|
+
// hint, nothing appended. The server never falls back to a
|
|
384
|
+
// whole-library payload on this class, so the caller writes and
|
|
385
|
+
// deletes nothing (#2365).
|
|
386
|
+
return unresolvableError(message);
|
|
387
|
+
}
|
|
373
388
|
if (res.status === 422 && code === "handle_required") {
|
|
374
389
|
// #2310: the push would CREATE a skill while the account's Author ID
|
|
375
390
|
// is still an unchosen placeholder. Line 1 is the server's message
|
|
@@ -530,12 +545,28 @@ export async function validateAccessKey(serverUrl, apiKey, source = "validate")
|
|
|
530
545
|
*/
|
|
531
546
|
|
|
532
547
|
/**
|
|
548
|
+
* @typedef {Object} SkippedExtra
|
|
549
|
+
* @property {string} skill - `"owner/name"` of the unhonorable extra.
|
|
550
|
+
* @property {string} reason - Stable machine enum (`extras_disabled` |
|
|
551
|
+
* `not_in_library`). The CLI categorizes and stores by this field only.
|
|
552
|
+
* @property {string} message - Canonical remediation copy — printed
|
|
553
|
+
* VERBATIM, one warn line per entry (E5-as-revised), never persisted
|
|
554
|
+
* or echoed upstream (presentation-only).
|
|
555
|
+
*
|
|
556
|
+
* @typedef {Object} SkillsetBlock
|
|
557
|
+
* @property {string} name
|
|
558
|
+
* @property {string} updatedAt
|
|
559
|
+
* @property {SkippedExtra[]} skippedExtras
|
|
560
|
+
*
|
|
533
561
|
* @typedef {Object} LibrarySyncResult
|
|
534
562
|
* @property {SyncSkill[]} skills
|
|
535
563
|
* @property {Removal[]} removals
|
|
536
564
|
* @property {string} syncedAt
|
|
537
565
|
* @property {string|null} etag
|
|
538
566
|
* @property {boolean} notModified - True if the server returned 304
|
|
567
|
+
* @property {SkillsetBlock|null} skillset - Present only on a
|
|
568
|
+
* skillset-scoped 200 (#2365, H6 #2363 contract); null on the bulk
|
|
569
|
+
* path and on 304.
|
|
539
570
|
*/
|
|
540
571
|
|
|
541
572
|
/**
|
|
@@ -563,6 +594,15 @@ export async function validateAccessKey(serverUrl, apiKey, source = "validate")
|
|
|
563
594
|
* the server returns empty `files` for every skill and records NO
|
|
564
595
|
* delivery. Used by `skillrepo list` for a read-only drift check that
|
|
565
596
|
* must not set sync state (#1832).
|
|
597
|
+
* @param {string} [opts.skillset] - Skillset-scoped sync (#2365, H6
|
|
598
|
+
* #2363 contract): the declaration's `use` pointer (`"owner/name"`).
|
|
599
|
+
* When present the request carries `skillset`, `repo`, and repeated
|
|
600
|
+
* `extra` params — callers must NOT combine with `since` (the server
|
|
601
|
+
* ignores it on this path; D10 full-set semantics).
|
|
602
|
+
* @param {string} [opts.repo] - The declaration's `name`.
|
|
603
|
+
* REQUIRED by the server whenever `skillset` is present.
|
|
604
|
+
* @param {string[]} [opts.extra] - Declaration `extra` entries,
|
|
605
|
+
* appended verbatim in declaration order (server dedups).
|
|
566
606
|
* @returns {Promise<LibrarySyncResult>}
|
|
567
607
|
*/
|
|
568
608
|
export async function getLibrary(serverUrl, apiKey, opts = {}) {
|
|
@@ -573,6 +613,17 @@ export async function getLibrary(serverUrl, apiKey, opts = {}) {
|
|
|
573
613
|
// `skillrepo list`. A peek must not set sync state, so the server
|
|
574
614
|
// returns empty `files` and records no delivery for this request.
|
|
575
615
|
if (opts.manifest) params.set("manifest", "1");
|
|
616
|
+
// Skillset-scoped params (#2365). Wire identity (D2): a caller that
|
|
617
|
+
// omits `skillset` produces a byte-identical request to the
|
|
618
|
+
// pre-skillset CLI — these branches add params only when the scoped
|
|
619
|
+
// sync explicitly asks for them.
|
|
620
|
+
if (opts.skillset) {
|
|
621
|
+
params.set("skillset", opts.skillset);
|
|
622
|
+
if (opts.repo !== undefined) params.set("repo", opts.repo);
|
|
623
|
+
for (const extra of Array.isArray(opts.extra) ? opts.extra : []) {
|
|
624
|
+
params.append("extra", extra);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
576
627
|
const url = params.toString()
|
|
577
628
|
? `${base}/api/v1/library?${params.toString()}`
|
|
578
629
|
: `${base}/api/v1/library`;
|
|
@@ -593,6 +644,7 @@ export async function getLibrary(serverUrl, apiKey, opts = {}) {
|
|
|
593
644
|
syncedAt: new Date().toISOString(),
|
|
594
645
|
etag: opts.ifNoneMatch || null,
|
|
595
646
|
notModified: true,
|
|
647
|
+
skillset: null,
|
|
596
648
|
};
|
|
597
649
|
}
|
|
598
650
|
|
|
@@ -609,8 +661,24 @@ export async function getLibrary(serverUrl, apiKey, opts = {}) {
|
|
|
609
661
|
skills: body.skills ?? [],
|
|
610
662
|
removals: body.removals ?? [],
|
|
611
663
|
syncedAt: body.syncedAt ?? new Date().toISOString(),
|
|
664
|
+
// Verbatim as received (#2365 ETag rule): Vercel serves strong
|
|
665
|
+
// ETags as weak (`W/"ss-…"`) — the value is OPAQUE, stored and
|
|
666
|
+
// round-tripped exactly, never parsed or normalized.
|
|
612
667
|
etag: res.headers.get("etag"),
|
|
613
668
|
notModified: false,
|
|
669
|
+
// Skillset block (#2365): present only on a scoped 200. Normalized
|
|
670
|
+
// to null (not undefined) so consumers can branch without optional
|
|
671
|
+
// chaining surprises; skippedExtras normalizes to [].
|
|
672
|
+
skillset:
|
|
673
|
+
body.skillset && typeof body.skillset === "object"
|
|
674
|
+
? {
|
|
675
|
+
name: body.skillset.name,
|
|
676
|
+
updatedAt: body.skillset.updatedAt,
|
|
677
|
+
skippedExtras: Array.isArray(body.skillset.skippedExtras)
|
|
678
|
+
? body.skillset.skippedExtras
|
|
679
|
+
: [],
|
|
680
|
+
}
|
|
681
|
+
: null,
|
|
614
682
|
};
|
|
615
683
|
}
|
|
616
684
|
|
|
@@ -633,6 +701,14 @@ export async function getLibrary(serverUrl, apiKey, opts = {}) {
|
|
|
633
701
|
* @param {{owner: string, name: string, version: string}[]} receipt.skills
|
|
634
702
|
* The skills written to disk this sync. Empty array is a valid no-op.
|
|
635
703
|
* @param {string} receipt.syncedAt - ISO timestamp of the sync.
|
|
704
|
+
* @param {object} [receipt.repo] - Skillset-scoped repo block (#2365,
|
|
705
|
+
* H7 #2364 wire contract): `{ name, skillset, resolved, and optional
|
|
706
|
+
* skippedExtras / violations }`. OMITTED ENTIRELY on no-file repos —
|
|
707
|
+
* the D2 wire-identity contract requires a plain sync's receipt body
|
|
708
|
+
* to stay byte-identical to the pre-skillset CLI, so the key is only
|
|
709
|
+
* serialized when a scoped sync supplies it. The block is assembled
|
|
710
|
+
* by sync.mjs (`buildRepoReceiptBlock`) — this layer passes it
|
|
711
|
+
* through verbatim.
|
|
636
712
|
* @returns {Promise<{recorded: number}>}
|
|
637
713
|
*/
|
|
638
714
|
export async function postSyncReceipt(serverUrl, apiKey, receipt) {
|
|
@@ -646,9 +722,12 @@ export async function postSyncReceipt(serverUrl, apiKey, receipt) {
|
|
|
646
722
|
body: JSON.stringify({
|
|
647
723
|
syncedAt: receipt.syncedAt,
|
|
648
724
|
skills: receipt.skills,
|
|
725
|
+
...(receipt.repo ? { repo: receipt.repo } : {}),
|
|
649
726
|
}),
|
|
650
727
|
// Idempotent server-side (dedup bucket + idempotent UPDATE), so a
|
|
651
|
-
// transient 5xx / network blip is safe to retry.
|
|
728
|
+
// transient 5xx / network blip is safe to retry. Receipts carrying
|
|
729
|
+
// the `repo` block are idempotent by construction too (#2364:
|
|
730
|
+
// change-only event append server-side), so the same posture holds.
|
|
652
731
|
retry: true,
|
|
653
732
|
});
|
|
654
733
|
if (!res.ok) {
|
package/src/lib/paths.mjs
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-platform path resolution for all config files the CLI writes.
|
|
3
3
|
* Uses Node built-ins only — no dependencies.
|
|
4
|
+
*
|
|
5
|
+
* Project-scope resolvers accept an optional `baseDir` (#2365): a
|
|
6
|
+
* skillset-scoped sync anchors every repo-scoped path at the walk-up's
|
|
7
|
+
* DETECTED ROOT (H5 `rootDir`, design doc D6), not cwd. Omitting the
|
|
8
|
+
* argument resolves against `process.cwd()` — byte-identical to the
|
|
9
|
+
* pre-#2365 behavior, which is what keeps undeclared repos on today's
|
|
10
|
+
* cwd-anchored path (D2).
|
|
4
11
|
*/
|
|
5
12
|
|
|
6
13
|
import { join } from "node:path";
|
|
@@ -8,6 +15,18 @@ import { homedir } from "node:os";
|
|
|
8
15
|
|
|
9
16
|
const cwd = () => process.cwd();
|
|
10
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Resolve a project-scope base directory: the caller-supplied anchor
|
|
20
|
+
* (a skillset repo's detected root) or the process cwd. A non-string /
|
|
21
|
+
* empty override falls back to cwd rather than producing a relative
|
|
22
|
+
* join off `""`.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} [baseDir]
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
const projectBase = (baseDir) =>
|
|
28
|
+
typeof baseDir === "string" && baseDir.length > 0 ? baseDir : cwd();
|
|
29
|
+
|
|
11
30
|
// Claude Code
|
|
12
31
|
export const claudeMcpJson = () => join(cwd(), ".mcp.json");
|
|
13
32
|
export const claudeDir = () => join(cwd(), ".claude");
|
|
@@ -49,6 +68,19 @@ export const globalNpmVersionCheckPath = () =>
|
|
|
49
68
|
export const globalGovernanceSeenPath = () =>
|
|
50
69
|
join(homedir(), ".claude", "skillrepo", ".governance-seen");
|
|
51
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Per-repo skillset sync state directory (#2365, design doc D12).
|
|
73
|
+
* One JSON file per declared repo, named by the sha-256 of the
|
|
74
|
+
* repo root's normalized realpath — see `repo-sync-state.mjs` for
|
|
75
|
+
* the schema and keying rules. Separate SUBDIRECTORY (not another
|
|
76
|
+
* dot-file) because the population is unbounded (one file per repo)
|
|
77
|
+
* and uninstall / support flows want to sweep it as a unit. Same
|
|
78
|
+
* parent dir as `.last-sync` so `rm -rf ~/.claude/skillrepo/` still
|
|
79
|
+
* removes every piece of machine state.
|
|
80
|
+
*/
|
|
81
|
+
export const globalRepoStateDir = () =>
|
|
82
|
+
join(homedir(), ".claude", "skillrepo", "repos");
|
|
83
|
+
|
|
52
84
|
// ── Skill placement targets ────────────────────────────────────────────
|
|
53
85
|
//
|
|
54
86
|
// Per-vendor placement decisions live in `agent-registry.mjs`. This
|
|
@@ -58,22 +90,26 @@ export const globalGovernanceSeenPath = () =>
|
|
|
58
90
|
// the verified vendor-by-vendor reference and primary-source citations.
|
|
59
91
|
|
|
60
92
|
/** Claude Code project-local skill directory for a specific skill name. */
|
|
61
|
-
export const claudeSkillsProject = (name) =>
|
|
93
|
+
export const claudeSkillsProject = (name, baseDir) =>
|
|
94
|
+
join(projectBase(baseDir), ".claude", "skills", name);
|
|
62
95
|
|
|
63
96
|
/** Claude Code personal/global skill directory for a specific skill name. */
|
|
64
97
|
export const claudeSkillsGlobal = (name) => join(homedir(), ".claude", "skills", name);
|
|
65
98
|
|
|
66
99
|
/** Parent directory of the project-local Claude Code skills (used by orphan cleanup). */
|
|
67
|
-
export const claudeSkillsProjectRoot = () =>
|
|
100
|
+
export const claudeSkillsProjectRoot = (baseDir) =>
|
|
101
|
+
join(projectBase(baseDir), ".claude", "skills");
|
|
68
102
|
|
|
69
103
|
/** Parent directory of the personal/global Claude Code skills (used by orphan cleanup). */
|
|
70
104
|
export const claudeSkillsGlobalRoot = () => join(homedir(), ".claude", "skills");
|
|
71
105
|
|
|
72
106
|
/** Cross-vendor `.agents/skills/<name>/` project-local placement (cursor, windsurf, gemini, codex, cline, copilot). */
|
|
73
|
-
export const agentsSkillsProject = (name) =>
|
|
107
|
+
export const agentsSkillsProject = (name, baseDir) =>
|
|
108
|
+
join(projectBase(baseDir), ".agents", "skills", name);
|
|
74
109
|
|
|
75
110
|
/** Parent of the project-local `.agents/skills/` cohort root (used by orphan cleanup). */
|
|
76
|
-
export const agentsSkillsProjectRoot = () =>
|
|
111
|
+
export const agentsSkillsProjectRoot = (baseDir) =>
|
|
112
|
+
join(projectBase(baseDir), ".agents", "skills");
|
|
77
113
|
|
|
78
114
|
/** Cross-vendor personal `.agents/skills/<name>/` placement (cursor, gemini, codex, cline). */
|
|
79
115
|
export const agentsSkillsGlobal = (name) => join(homedir(), ".agents", "skills", name);
|
|
@@ -96,8 +132,10 @@ export const envLocal = () => join(cwd(), ".env.local");
|
|
|
96
132
|
/**
|
|
97
133
|
* Project .gitignore — used by the file-write pipeline to ensure the
|
|
98
134
|
* `.agents/skills/` cohort directory is gitignored on first write.
|
|
135
|
+
* `baseDir` (#2365): a skillset-scoped sync heals the .gitignore at
|
|
136
|
+
* the repo's detected root, not cwd.
|
|
99
137
|
*/
|
|
100
|
-
export const gitignorePath = () => join(
|
|
138
|
+
export const gitignorePath = (baseDir) => join(projectBase(baseDir), ".gitignore");
|
|
101
139
|
|
|
102
140
|
// ── Claude Code settings ──────────────────────────────────────────────
|
|
103
141
|
//
|