skillrepo 4.11.1 → 4.13.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 +50 -9
- package/package.json +1 -1
- package/src/commands/list.mjs +46 -1
- package/src/commands/uninstall.mjs +7 -2
- package/src/commands/update.mjs +42 -1
- package/src/lib/artifact-registry.mjs +18 -3
- package/src/lib/crypto-shas.mjs +16 -0
- package/src/lib/errors.mjs +9 -8
- package/src/lib/file-write.mjs +35 -31
- package/src/lib/foreign-content.mjs +148 -5
- package/src/lib/gitignore-section.mjs +10 -4
- package/src/lib/global-boundary.mjs +495 -0
- 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 -34
- package/src/lib/sync.mjs +1328 -31
- package/src/test/commands/init.test.mjs +67 -7
- package/src/test/commands/list.test.mjs +130 -0
- package/src/test/commands/uninstall.test.mjs +37 -5
- package/src/test/commands/update.test.mjs +143 -5
- package/src/test/e2e/mock-server.mjs +147 -0
- package/src/test/lib/artifact-registry.test.mjs +4 -0
- package/src/test/lib/foreign-content.test.mjs +45 -0
- package/src/test/lib/global-boundary.test.mjs +607 -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 +1469 -0
- package/src/test/lib/sync.test.mjs +296 -28
package/README.md
CHANGED
|
@@ -119,11 +119,23 @@ and just write the config + gitignore.
|
|
|
119
119
|
"removed": 0,
|
|
120
120
|
"notModified": false,
|
|
121
121
|
"fullSync": true,
|
|
122
|
-
"syncedAt": "2026-05-01T00:00:00.000Z"
|
|
122
|
+
"syncedAt": "2026-05-01T00:00:00.000Z",
|
|
123
|
+
"globalBoundary": {
|
|
124
|
+
"total": 0,
|
|
125
|
+
"counts": { "global_library": 0, "global_foreign": 0, "global_shadowed": 0 },
|
|
126
|
+
"names": [],
|
|
127
|
+
"skillsetDeclared": false
|
|
128
|
+
}
|
|
123
129
|
}
|
|
124
130
|
}
|
|
125
131
|
```
|
|
126
132
|
|
|
133
|
+
`sync.globalBoundary` (4.13.0) appears on every successful
|
|
134
|
+
project-scope sync and describes the personal-scope skills that also
|
|
135
|
+
load in sessions here; it is absent when the scan did not run
|
|
136
|
+
(`--global`, or a scan failure). `names` stays local to your machine —
|
|
137
|
+
sync reporting to your organization carries counts only.
|
|
138
|
+
|
|
127
139
|
Field notes:
|
|
128
140
|
- `vendors` is the resolved canonical-key list, NOT the raw `--agent` input. `--agent agents` produces every cohort vendor (cursor, windsurf, gemini, codex, cline, copilot); `--agent none` produces an empty array.
|
|
129
141
|
- `sessionSync.cohortHooks[]` reports per-vendor outcomes for the auto-refresh hooks installed alongside the Claude Code SessionStart hook (one entry per cohort vendor with a non-null `agentHook` registry spec — Cursor, Gemini CLI, Codex CLI, VS Code + Copilot). `reason` is present only when `action: "failed"`. Empty array when `--no-session-sync` was passed or no cohort vendor was selected.
|
|
@@ -183,17 +195,46 @@ Sync also warns — once per repo, same rules — when a `skillrepo.json`
|
|
|
183
195
|
at the repo root is gitignored: the skillset declaration only works as
|
|
184
196
|
a committed file, so remove it from `.gitignore` and commit it.
|
|
185
197
|
|
|
198
|
+
Project syncs also disclose the personal scope (the global boundary):
|
|
199
|
+
skills under the global folders (`~/.claude/skills/`,
|
|
200
|
+
`~/.agents/skills/`, `~/.codeium/windsurf/skills/`) load in every
|
|
201
|
+
session run inside a project, whether or not they came through a
|
|
202
|
+
skillset. When any exist, a one-line disclosure says how many global
|
|
203
|
+
skills will also load — in a declared repo it also counts how many sit
|
|
204
|
+
outside the repo's skillset and how many collide with a skillset
|
|
205
|
+
member's name. Today the session auto-sync prints that line in Claude
|
|
206
|
+
Code only (the other agents' background sync hooks run silently by
|
|
207
|
+
their hook contracts — see `docs/vendor-paths.md` for each vendor's
|
|
208
|
+
channel); `skillrepo list` shows the same line everywhere. Interactive
|
|
209
|
+
syncs add a one-time warning per finding with the remediation, and
|
|
210
|
+
your organization's sync reporting carries counts only
|
|
211
|
+
(`global_library`, `global_foreign`, `global_shadowed`), never names.
|
|
212
|
+
|
|
186
213
|
A repository can declare a skillset in a root `skillrepo.json`:
|
|
187
214
|
`{"skillset": {"version": 1, "name": "<repo-identity>", "use":
|
|
188
215
|
"owner/skillset-name"}}` (optional `extra: ["owner/skill"]`). The CLI
|
|
189
216
|
finds the file from any subdirectory (it walks up to the repository
|
|
190
|
-
root)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
217
|
+
root), validates it, and syncs that repository against the declared
|
|
218
|
+
skillset instead of your whole library — a declared repo only ever
|
|
219
|
+
receives its declared skillset. Skillset-scoped sync resolves the
|
|
220
|
+
complete set on every pass: skills added to the skillset appear,
|
|
221
|
+
skills removed from it are deleted from the repo's skill folders, and
|
|
222
|
+
everything lands at the repository root no matter which subdirectory
|
|
223
|
+
you run from. Deletes are guarded — a directory the CLI did not write,
|
|
224
|
+
or one you modified locally, is never removed or overwritten; it stays
|
|
225
|
+
put with a warning and shows up in your team's sync reporting instead.
|
|
226
|
+
Declared entries in `extra` that cannot be honored (for example when
|
|
227
|
+
extras are not enabled for your organization) are skipped loudly with
|
|
228
|
+
the reason, never silently. If the registry is unreachable, the repo
|
|
229
|
+
keeps serving its last-verified skills and the sync still exits
|
|
230
|
+
successfully with a warning (the warning escalates once the state is
|
|
231
|
+
more than ~72 hours old). If the declaration itself cannot be honored
|
|
232
|
+
— unknown skillset, skillsets not enabled for your organization, an
|
|
233
|
+
invalid file — the sync fails closed with exit code `6` and nothing is
|
|
234
|
+
written or removed. A `skillrepo.json` without a `skillset` key is
|
|
235
|
+
plain configuration and does not change sync. See
|
|
236
|
+
[`docs/skillset-declaration.md`](docs/skillset-declaration.md) for the
|
|
237
|
+
full file-format reference.
|
|
197
238
|
|
|
198
239
|
### `get` — fetch a single skill
|
|
199
240
|
|
|
@@ -586,7 +627,7 @@ citations on each agent's read paths.
|
|
|
586
627
|
| 3 | Disk error (cannot read or write a file/directory) |
|
|
587
628
|
| 4 | Scope error (key lacks the required `registry:write` scope) |
|
|
588
629
|
| 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
|
|
630
|
+
| 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
631
|
|
|
591
632
|
Pass `--verbose` to any command to print stack traces and retry
|
|
592
633
|
attempts on failure.
|
package/package.json
CHANGED
package/src/commands/list.mjs
CHANGED
|
@@ -43,6 +43,12 @@ import { detectAgents } from "../lib/detect-agents.mjs";
|
|
|
43
43
|
import { walkDetectedPlacements } from "../lib/placement-walk.mjs";
|
|
44
44
|
import { getAgentByKey } from "../lib/agent-registry.mjs";
|
|
45
45
|
import { computeSkillState, rollupState, SKILL_STATE } from "../lib/drift.mjs";
|
|
46
|
+
import {
|
|
47
|
+
scanGlobalBoundary,
|
|
48
|
+
formatGlobalBoundaryDisclosure,
|
|
49
|
+
managedGlobalNamesFrom,
|
|
50
|
+
resolveBoundaryMemberContext,
|
|
51
|
+
} from "../lib/global-boundary.mjs";
|
|
46
52
|
|
|
47
53
|
/**
|
|
48
54
|
* Run `list`. Throws CliError on any failure.
|
|
@@ -56,7 +62,10 @@ export async function runList(argv, io = {}) {
|
|
|
56
62
|
const stdout = io.stdout ?? process.stdout;
|
|
57
63
|
const flags = resolveFlags(argv);
|
|
58
64
|
|
|
59
|
-
// `list` is a read-only drift check — it must NEVER set sync state
|
|
65
|
+
// `list` is a read-only drift check — it must NEVER set sync state,
|
|
66
|
+
// and (#2495) it never writes the governance-seen file either: the
|
|
67
|
+
// boundary disclosure below scans without committing warn-state, so
|
|
68
|
+
// running `list` can't consume a warning `update` owes the user.
|
|
60
69
|
// Use the manifest read (#1832): metadata only, no file bodies, and the
|
|
61
70
|
// server records no delivery for it. Per-skill drift is computed from
|
|
62
71
|
// on-disk SHAs + `.last-sync` below, never from the response body, so
|
|
@@ -122,6 +131,42 @@ export async function runList(argv, io = {}) {
|
|
|
122
131
|
|
|
123
132
|
printTable(augmented, detected, stdout);
|
|
124
133
|
printFooter(augmented, libraryResponse.etag, lastSync, stdout, canUseGlyphs(stdout));
|
|
134
|
+
printGlobalBoundaryDisclosure(detectedKeys, lastSync, stdout);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Global-boundary disclosure for the table surface (#2495): one line,
|
|
139
|
+
* same format as the session-hook disclosure, printed only when the
|
|
140
|
+
* detected vendors' GLOBAL roots hold skill dirs. Read-only to the
|
|
141
|
+
* letter of list's contract above — no seen-state commit, no per-dir
|
|
142
|
+
* warning lines (those are `update`'s interactive job). Best-effort:
|
|
143
|
+
* the drift table must never fail because a disclosure probe did.
|
|
144
|
+
*
|
|
145
|
+
* The declaration is resolved TOLERANTLY, like the throttled sync
|
|
146
|
+
* exit: `list` is a reporting surface, so an invalid `skillrepo.json`
|
|
147
|
+
* stays `update`'s error to raise — here it just means the scan runs
|
|
148
|
+
* without a member set.
|
|
149
|
+
*
|
|
150
|
+
* @param {string[]} vendors - Detected vendor keys.
|
|
151
|
+
* @param {import("../lib/sync.mjs").SyncStateFile | null} lastSync
|
|
152
|
+
* @param {NodeJS.WritableStream} stdout
|
|
153
|
+
*/
|
|
154
|
+
function printGlobalBoundaryDisclosure(vendors, lastSync, stdout) {
|
|
155
|
+
try {
|
|
156
|
+
const { memberNames, baseDir } = resolveBoundaryMemberContext();
|
|
157
|
+
const scan = scanGlobalBoundary({
|
|
158
|
+
vendors,
|
|
159
|
+
memberNames,
|
|
160
|
+
managedGlobalNames: managedGlobalNamesFrom(lastSync?.skills),
|
|
161
|
+
baseDir,
|
|
162
|
+
});
|
|
163
|
+
const disclosure = formatGlobalBoundaryDisclosure(scan);
|
|
164
|
+
// Two-space indent matches every other list line; the line itself
|
|
165
|
+
// is byte-identical to the session-hook disclosure.
|
|
166
|
+
if (disclosure) stdout.write(` ${disclosure}\n`);
|
|
167
|
+
} catch {
|
|
168
|
+
// Disclosure is best-effort on every surface.
|
|
169
|
+
}
|
|
125
170
|
}
|
|
126
171
|
|
|
127
172
|
// ── Per-skill augmentation ─────────────────────────────────────────────
|
|
@@ -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
|
|
package/src/commands/update.mjs
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
*/
|
|
47
47
|
|
|
48
48
|
import { runSync } from "../lib/sync.mjs";
|
|
49
|
+
import { formatGlobalBoundaryDisclosure } from "../lib/global-boundary.mjs";
|
|
49
50
|
import {
|
|
50
51
|
resolveFlags,
|
|
51
52
|
effectiveVendors,
|
|
@@ -64,6 +65,14 @@ import {
|
|
|
64
65
|
* - 304 Not Modified → exit 0, NO output.
|
|
65
66
|
* - 200 with changes → exit 0, ONE line: `[SkillRepo] Library synced: N added, N updated, N removed.`
|
|
66
67
|
* - Any failure → exit 0, ONE line: `[SkillRepo] Sync failed: <reason>.`
|
|
68
|
+
* - Global-boundary disclosure (#2495): when the sync's summary
|
|
69
|
+
* reports global skills that will also load in this session
|
|
70
|
+
* (`globalBoundary.total > 0`), ONE additional line prints on
|
|
71
|
+
* EVERY success path — after the sync line, or alone on the
|
|
72
|
+
* otherwise-silent 304/zero-delta/throttled paths. Plain stdout
|
|
73
|
+
* enters the session's model context, which is the point: the
|
|
74
|
+
* agent itself learns what extra skills are in play. Failure
|
|
75
|
+
* paths never disclose (the failure line stays the single line).
|
|
67
76
|
*
|
|
68
77
|
* The "exit 0 on all errors" contract is non-negotiable: a sync
|
|
69
78
|
* failure must NEVER block a Claude Code session start. Users on a
|
|
@@ -162,10 +171,41 @@ export async function runUpdate(argv, io = {}) {
|
|
|
162
171
|
const skipped = summary.skipped ?? 0;
|
|
163
172
|
const total =
|
|
164
173
|
summary.added + summary.updated + summary.removed + skipped;
|
|
174
|
+
// Global-boundary disclosure (#2495), computed BEFORE the silent
|
|
175
|
+
// branch below: the quiet 304/zero-delta/throttled session is
|
|
176
|
+
// the COMMON session, and it must still disclose — the line
|
|
177
|
+
// exists so the session's model context knows about the global
|
|
178
|
+
// skills loading alongside the synced set, not to report sync
|
|
179
|
+
// work. Success paths only; the catch below never reaches here.
|
|
180
|
+
// Formatting is its OWN failure domain (architect review r1): a
|
|
181
|
+
// formatter defect must degrade to "no line", never fall into
|
|
182
|
+
// the outer catch and report a successful sync as failed —
|
|
183
|
+
// "disclosure must never break a sync" applies to the printer
|
|
184
|
+
// exactly as it does to the scanner.
|
|
185
|
+
let disclosure = null;
|
|
186
|
+
try {
|
|
187
|
+
disclosure =
|
|
188
|
+
summary.globalBoundary && summary.globalBoundary.total > 0
|
|
189
|
+
? formatGlobalBoundaryDisclosure(summary.globalBoundary)
|
|
190
|
+
: null;
|
|
191
|
+
} catch {
|
|
192
|
+
// Degrade to no disclosure line.
|
|
193
|
+
}
|
|
194
|
+
const writeDisclosureLine = () => {
|
|
195
|
+
if (!disclosure) return;
|
|
196
|
+
try {
|
|
197
|
+
stdout.write(`${disclosure}\n`);
|
|
198
|
+
} catch {
|
|
199
|
+
// Same failure domain as the formatter: a write failure on
|
|
200
|
+
// the cosmetic line must not become a "Sync failed" report.
|
|
201
|
+
}
|
|
202
|
+
};
|
|
165
203
|
if (summary.notModified || total === 0) {
|
|
166
204
|
// 304 Not Modified OR 200 with zero deltas — silent by
|
|
167
|
-
// contract
|
|
205
|
+
// contract (the boundary disclosure is the one sanctioned
|
|
206
|
+
// exception). Users should not see "Syncing..." on every
|
|
168
207
|
// session for no visible value.
|
|
208
|
+
writeDisclosureLine();
|
|
169
209
|
return;
|
|
170
210
|
}
|
|
171
211
|
stdout.write(
|
|
@@ -173,6 +213,7 @@ export async function runUpdate(argv, io = {}) {
|
|
|
173
213
|
(skipped > 0 ? `, ${skipped} SKIPPED (could not be written)` : "") +
|
|
174
214
|
`.\n`,
|
|
175
215
|
);
|
|
216
|
+
writeDisclosureLine();
|
|
176
217
|
} catch (err) {
|
|
177
218
|
// The one-line failure message is the user's primary signal
|
|
178
219
|
// that something's wrong. Do not surface a stack trace — the
|
|
@@ -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,14 +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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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.
|
|
23
24
|
*
|
|
24
25
|
* These mirror the documented behavior in #683 and are the contract
|
|
25
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
|
}
|