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 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) and validates it before syncing. Skillset delivery is not yet
191
- available: a repository with a declaration fails its sync closed
192
- (exit code `6`) rather than receiving the whole library — a declared
193
- repo only ever receives its declared skillset. Keep the declaration
194
- while your team prepares for skillsets, or remove the `skillset`
195
- block to sync your whole library. A `skillrepo.json` without a
196
- `skillset` key is plain configuration and does not change sync.
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 (invalid or nested declaration, reserved `@` version syntax, or skillset delivery not yet available). Fail-closed: nothing was written or removed. |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.11.0",
3
+ "version": "4.12.0",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 !== "skills" && basename !== "skillrepo") {
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 neither "skills" nor "skillrepo".`,
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, and .governance-seen (#2361).
368
- // Whole-directory removal is correct because every child is
369
- // CLI-owned and there's no room for user content.
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
  }),
@@ -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.
@@ -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 skillset delivery not yet
16
- * available). Fail-closed: nothing was written or removed. This
17
- * one code covers the WHOLE D9 fail-closed class, locally AND —
18
- * once H8 (#2365) wires the server the server's typed 422
19
- * `harness_unresolvable` response, so CI scripts get one stable
20
- * "declaration cannot be honored" signal.
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.
@@ -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
- * @param {PlacementTarget} target
294
- * @returns {string}
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
- * @returns {() => string}
303
+ * @param {string} [baseDir]
304
+ * @returns {string}
308
305
  */
309
- function placementRootFn(target) {
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(placementRootFn(target)());
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(placementRootFn(target)());
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(placementRootFn(target)());
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) => join(cwd(), ".claude", "skills", 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 = () => join(cwd(), ".claude", "skills");
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) => join(cwd(), ".agents", "skills", 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 = () => join(cwd(), ".agents", "skills");
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(cwd(), ".gitignore");
138
+ export const gitignorePath = (baseDir) => join(projectBase(baseDir), ".gitignore");
101
139
 
102
140
  // ── Claude Code settings ──────────────────────────────────────────────
103
141
  //