skillrepo 4.5.0 → 4.5.2
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/package.json +1 -1
- package/src/commands/add.mjs +17 -2
- package/src/commands/get.mjs +17 -1
- package/src/commands/list.mjs +28 -9
- package/src/commands/remove.mjs +13 -0
- package/src/lib/agent-registry.mjs +15 -1
- package/src/lib/cli-config.mjs +32 -5
- package/src/lib/detect-agents.mjs +42 -5
- package/src/lib/sync.mjs +253 -3
- package/src/test/e2e/mock-server.mjs +92 -2
- package/src/test/integration/update-list-contract.integration.test.mjs +1565 -0
- package/src/test/lib/cli-config.test.mjs +57 -6
- package/src/test/lib/detect-agents.test.mjs +37 -4
- package/src/test/lib/sync.test.mjs +220 -0
package/package.json
CHANGED
package/src/commands/add.mjs
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
effectiveVendors,
|
|
47
47
|
requireVendorTargets,
|
|
48
48
|
} from "../lib/cli-config.mjs";
|
|
49
|
+
import { upsertLastSyncEntry } from "../lib/sync.mjs";
|
|
49
50
|
import { parseIdentifier, formatIdentifier } from "../lib/identifier.mjs";
|
|
50
51
|
import { validationError } from "../lib/errors.mjs";
|
|
51
52
|
|
|
@@ -158,6 +159,20 @@ export async function runAdd(argv, io = {}) {
|
|
|
158
159
|
|
|
159
160
|
writeSkillDir(skill, { vendors, global: flags.global });
|
|
160
161
|
|
|
162
|
+
// Update `.last-sync` so `list` immediately recognizes this skill
|
|
163
|
+
// as `current`. Without this the user adds a skill, then runs
|
|
164
|
+
// `list`, and sees their freshly-added skill marked MISS — the
|
|
165
|
+
// exact same class of contract gap that PR #1574 fixed for the
|
|
166
|
+
// multi-vendor case in `update`/`list`. See `upsertLastSyncEntry`
|
|
167
|
+
// docstring for the etag-preservation rationale.
|
|
168
|
+
try {
|
|
169
|
+
upsertLastSyncEntry(skill);
|
|
170
|
+
} catch {
|
|
171
|
+
// Non-fatal: skill is on disk, the state file write failure
|
|
172
|
+
// does not change that. Same semantics as runSync's warn-and-
|
|
173
|
+
// proceed for last-sync persistence failures.
|
|
174
|
+
}
|
|
175
|
+
|
|
161
176
|
if (flags.json) {
|
|
162
177
|
stdout.write(
|
|
163
178
|
JSON.stringify(
|
|
@@ -181,11 +196,11 @@ export async function runAdd(argv, io = {}) {
|
|
|
181
196
|
.join(", ")})`;
|
|
182
197
|
if (wasNewlyAdded) {
|
|
183
198
|
stdout.write(
|
|
184
|
-
`\n ✓ Added ${formatIdentifier({ owner, name })} to your library (${skill.files.length} files) → ${where}\n\n`,
|
|
199
|
+
`\n ✓ Added ${formatIdentifier({ owner, name })} to your library (${skill.files.length} file${skill.files.length === 1 ? "" : "s"}) → ${where}\n\n`,
|
|
185
200
|
);
|
|
186
201
|
} else {
|
|
187
202
|
stdout.write(
|
|
188
|
-
`\n ✓ ${formatIdentifier({ owner, name })} was already in your library — refreshed (${skill.files.length} files) → ${where}\n\n`,
|
|
203
|
+
`\n ✓ ${formatIdentifier({ owner, name })} was already in your library — refreshed (${skill.files.length} file${skill.files.length === 1 ? "" : "s"}) → ${where}\n\n`,
|
|
189
204
|
);
|
|
190
205
|
}
|
|
191
206
|
}
|
package/src/commands/get.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
effectiveVendors,
|
|
37
37
|
requireVendorTargets,
|
|
38
38
|
} from "../lib/cli-config.mjs";
|
|
39
|
+
import { upsertLastSyncEntry } from "../lib/sync.mjs";
|
|
39
40
|
import { parseIdentifier, formatIdentifier } from "../lib/identifier.mjs";
|
|
40
41
|
import { validationError } from "../lib/errors.mjs";
|
|
41
42
|
|
|
@@ -109,6 +110,21 @@ export async function runGet(argv, io = {}) {
|
|
|
109
110
|
|
|
110
111
|
writeSkillDir(skill, { vendors, global: flags.global });
|
|
111
112
|
|
|
113
|
+
// Update `.last-sync` so `list` recognizes this skill as `current`
|
|
114
|
+
// afterward instead of reporting a stale `MISSING`. Best-effort:
|
|
115
|
+
// a state-file write failure is non-fatal (the files are already
|
|
116
|
+
// on disk), but writeLastSync throws diskError if it cannot
|
|
117
|
+
// persist — we catch and continue. Pre-PR #1574 `get` skipped
|
|
118
|
+
// this entirely, which surfaced every freshly-fetched skill as
|
|
119
|
+
// MISS in the very next `list` invocation.
|
|
120
|
+
try {
|
|
121
|
+
upsertLastSyncEntry(skill);
|
|
122
|
+
} catch {
|
|
123
|
+
// Same failure semantics as runSync's "couldn't persist last
|
|
124
|
+
// sync state" path: warn and proceed, the user's intent (files
|
|
125
|
+
// on disk) was honored.
|
|
126
|
+
}
|
|
127
|
+
|
|
112
128
|
if (flags.json) {
|
|
113
129
|
stdout.write(
|
|
114
130
|
JSON.stringify({
|
|
@@ -127,6 +143,6 @@ export async function runGet(argv, io = {}) {
|
|
|
127
143
|
.map(describePlacementTarget)
|
|
128
144
|
.join(", ")})`;
|
|
129
145
|
stdout.write(
|
|
130
|
-
`\n ✓ Fetched ${formatIdentifier({ owner, name })} (${skill.files.length} files) → ${where}\n\n`,
|
|
146
|
+
`\n ✓ Fetched ${formatIdentifier({ owner, name })} (${skill.files.length} file${skill.files.length === 1 ? "" : "s"}) → ${where}\n\n`,
|
|
131
147
|
);
|
|
132
148
|
}
|
package/src/commands/list.mjs
CHANGED
|
@@ -243,9 +243,13 @@ function printTable(augmented, detected, out) {
|
|
|
243
243
|
.join(", ");
|
|
244
244
|
out.write(`\n Detected: ${detectedLabel}\n`);
|
|
245
245
|
} else {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
246
|
+
// Earlier copy said "drift cannot be reported" but the table
|
|
247
|
+
// below DOES report drift — every skill rolls up as MISSING
|
|
248
|
+
// because no placement exists to compare against. The simpler
|
|
249
|
+
// statement of fact is what users need; the footer ("No sync
|
|
250
|
+
// history…" / "library has changed…") tells them what to do
|
|
251
|
+
// next.
|
|
252
|
+
out.write("\n No agents detected in this project.\n");
|
|
249
253
|
}
|
|
250
254
|
|
|
251
255
|
const sorted = augmented.slice().sort(sortByOwnerAndName);
|
|
@@ -337,12 +341,27 @@ function printFooter(augmented, libraryEtag, lastSync, out, useGlyphs) {
|
|
|
337
341
|
}
|
|
338
342
|
|
|
339
343
|
if (etagMatches && driftCount > 0) {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
344
|
+
// Distinguish EDITED (running update loses user changes) from
|
|
345
|
+
// MISS/STALE (running update is non-destructive — fills gaps or
|
|
346
|
+
// pulls a newer version). The earlier copy said "Run `skillrepo
|
|
347
|
+
// update` to refresh" for every drift case, which silently
|
|
348
|
+
// destroyed user edits when EDITED rows were present. Now we
|
|
349
|
+
// warn explicitly when edits are at risk.
|
|
350
|
+
const editedCount = augmented.filter(
|
|
351
|
+
(s) => s.state === SKILL_STATE.EDITED,
|
|
352
|
+
).length;
|
|
353
|
+
const driftPhrase = `${driftCount} skill${driftCount === 1 ? "" : "s"} show${driftCount === 1 ? "s" : ""}`;
|
|
354
|
+
if (editedCount > 0) {
|
|
355
|
+
out.write(
|
|
356
|
+
`\n ${warn} library in sync — but ${driftPhrase} local drift, including ${editedCount} with local edit${editedCount === 1 ? "" : "s"}.\n` +
|
|
357
|
+
" Run `skillrepo update` to refresh — this will OVERWRITE local edits.\n\n",
|
|
358
|
+
);
|
|
359
|
+
} else {
|
|
360
|
+
out.write(
|
|
361
|
+
`\n ${warn} library in sync — but ${driftPhrase} local drift.\n` +
|
|
362
|
+
" Run `skillrepo update` to refresh.\n\n",
|
|
363
|
+
);
|
|
364
|
+
}
|
|
346
365
|
return;
|
|
347
366
|
}
|
|
348
367
|
|
package/src/commands/remove.mjs
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
effectiveVendors,
|
|
44
44
|
requireVendorTargets,
|
|
45
45
|
} from "../lib/cli-config.mjs";
|
|
46
|
+
import { deleteLastSyncEntry } from "../lib/sync.mjs";
|
|
46
47
|
import { parseIdentifier, formatIdentifier } from "../lib/identifier.mjs";
|
|
47
48
|
import { validationError } from "../lib/errors.mjs";
|
|
48
49
|
|
|
@@ -111,6 +112,18 @@ export async function runRemove(argv, io = {}) {
|
|
|
111
112
|
// owner-namespacing caveat.
|
|
112
113
|
const localResult = removeSkillDir(name, { vendors, global: flags.global });
|
|
113
114
|
|
|
115
|
+
// Step 2.5: purge the `.last-sync` entry for this skill so a future
|
|
116
|
+
// re-add of the same identifier doesn't compare against a stale
|
|
117
|
+
// baseline. Idempotent: no-op if the entry isn't there. Non-fatal
|
|
118
|
+
// on write failure — the user's primary intent (skill gone from
|
|
119
|
+
// library + disk) was honored; a stale baseline is recoverable
|
|
120
|
+
// via the next `update`.
|
|
121
|
+
try {
|
|
122
|
+
deleteLastSyncEntry(owner, name);
|
|
123
|
+
} catch {
|
|
124
|
+
// Same warn-and-proceed semantics as runSync's last-sync writes.
|
|
125
|
+
}
|
|
126
|
+
|
|
114
127
|
if (flags.json) {
|
|
115
128
|
stdout.write(
|
|
116
129
|
JSON.stringify(
|
|
@@ -140,7 +140,21 @@ export const AGENT_REGISTRY = Object.freeze([
|
|
|
140
140
|
agentHook: null,
|
|
141
141
|
detectionSignals: Object.freeze([
|
|
142
142
|
Object.freeze({ type: "env", value: "CLAUDECODE" }),
|
|
143
|
-
|
|
143
|
+
// Home signal MUST be a file Claude Code creates that SkillRepo
|
|
144
|
+
// itself never writes. The earlier `.claude` (bare dir) signal
|
|
145
|
+
// false-positive-detected on any user who'd ever run a SkillRepo
|
|
146
|
+
// command — because `init` writes config to
|
|
147
|
+
// `~/.claude/skillrepo/config.json` and every `runSync` writes
|
|
148
|
+
// `.last-sync` to the same parent, both of which create the
|
|
149
|
+
// `~/.claude/` directory as a side effect. Cursor-only users
|
|
150
|
+
// (or any non-Claude-Code user) were then incorrectly detected
|
|
151
|
+
// as Claude Code users on every subsequent `list` invocation,
|
|
152
|
+
// producing MISS for every skill at the claudeProject placement
|
|
153
|
+
// that was never populated. Probing `settings.json` — created by
|
|
154
|
+
// Claude Code on first run and never touched by SkillRepo — is
|
|
155
|
+
// the load-bearing distinction. See PR #1574 production-
|
|
156
|
+
// readiness audit for the full chain.
|
|
157
|
+
Object.freeze({ type: "home", value: ".claude/settings.json" }),
|
|
144
158
|
Object.freeze({ type: "project", value: ".claude" }),
|
|
145
159
|
]),
|
|
146
160
|
}),
|
package/src/lib/cli-config.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
AGENTS_COHORT_KEYS,
|
|
26
26
|
getAgentByAlias,
|
|
27
27
|
} from "./agent-registry.mjs";
|
|
28
|
+
import { detectAgents } from "./detect-agents.mjs";
|
|
28
29
|
import { authError, validationError } from "./errors.mjs";
|
|
29
30
|
|
|
30
31
|
const ALL_VENDOR_KEYS = AGENT_REGISTRY.map((entry) => entry.key);
|
|
@@ -204,22 +205,48 @@ export function resolveFlags(argv, opts = {}) {
|
|
|
204
205
|
* always wins. Both flags propagate together, so `--global
|
|
205
206
|
* --agent windsurf` correctly resolves to `["windsurf"]` and
|
|
206
207
|
* placementTargetsFor maps that to `windsurfGlobal`.
|
|
207
|
-
* 2. No `--agent` defaults to
|
|
208
|
-
*
|
|
209
|
-
*
|
|
208
|
+
* 2. No `--agent` defaults to ALL DETECTED vendors (4.5.1+) — every
|
|
209
|
+
* agent whose detection signal fires in the current cwd / HOME /
|
|
210
|
+
* env. This matches `init`'s picker behavior and `list`'s drift
|
|
211
|
+
* detection model: the CLI assumes you want to keep every
|
|
212
|
+
* agent on your machine in sync.
|
|
213
|
+
* 3. Fallback to `["claudeCode"]` when nothing is detected (fresh
|
|
214
|
+
* machine with no agent installed yet — extremely rare, but
|
|
215
|
+
* preserves the historical default so `skillrepo init` from
|
|
216
|
+
* scratch still has a meaningful behavior).
|
|
217
|
+
*
|
|
218
|
+
* History: 4.4.0 and earlier defaulted to `["claudeCode"]` only,
|
|
219
|
+
* which created a mismatch with `list`'s drift detection in 4.5.0:
|
|
220
|
+
* `list` reported drift against all detected vendors while
|
|
221
|
+
* `update`/`add`/`remove`/`get` only wrote to Claude Code's
|
|
222
|
+
* placement. Multi-agent users saw every skill as `MISS` even after
|
|
223
|
+
* a full sync because the cohort `.agents/skills/` directory was
|
|
224
|
+
* never written to. See #1573 follow-up.
|
|
210
225
|
*
|
|
211
226
|
* The empty-array `--agent none` sentinel is returned verbatim —
|
|
212
227
|
* callers detect "no placement" via `vendors.length === 0`. The
|
|
213
228
|
* default-fallback branch uses an explicit null/undefined check so
|
|
214
229
|
* the empty array survives.
|
|
215
230
|
*/
|
|
216
|
-
export function effectiveVendors(flags) {
|
|
231
|
+
export function effectiveVendors(flags, { detect = defaultDetectVendors } = {}) {
|
|
217
232
|
if (flags.vendors === null || flags.vendors === undefined) {
|
|
218
|
-
|
|
233
|
+
const detected = detect();
|
|
234
|
+
return detected.length > 0 ? detected : ["claudeCode"];
|
|
219
235
|
}
|
|
220
236
|
return flags.vendors;
|
|
221
237
|
}
|
|
222
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Default detection callable for `effectiveVendors`. Exposed for tests
|
|
241
|
+
* that want to inject a deterministic vendor list via the second-arg
|
|
242
|
+
* `{ detect }` option — production callers should not pass anything.
|
|
243
|
+
*/
|
|
244
|
+
function defaultDetectVendors() {
|
|
245
|
+
return detectAgents()
|
|
246
|
+
.filter((d) => d.detected)
|
|
247
|
+
.map((d) => d.key);
|
|
248
|
+
}
|
|
249
|
+
|
|
223
250
|
/**
|
|
224
251
|
* Reject `--agent none` for commands that have no meaningful behavior
|
|
225
252
|
* without a placement target. `init` and `update` accept `--agent none`
|
|
@@ -34,12 +34,51 @@
|
|
|
34
34
|
* so probes are sandbox-isolated on every platform.
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
-
import { existsSync } from "node:fs";
|
|
37
|
+
import { existsSync, statSync } from "node:fs";
|
|
38
38
|
import { homedir } from "node:os";
|
|
39
39
|
import { join } from "node:path";
|
|
40
40
|
|
|
41
41
|
import { AGENT_REGISTRY } from "./agent-registry.mjs";
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Returns true when `path` exists and is reachable. Wraps `existsSync`
|
|
45
|
+
* for symmetry with the file-vs-dir formatters below; kept as a
|
|
46
|
+
* separate helper so a future refinement (e.g., disallow broken
|
|
47
|
+
* symlinks) can land in one place.
|
|
48
|
+
*/
|
|
49
|
+
function probeFsPath(path) {
|
|
50
|
+
return existsSync(path);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Format the `reason` for a fired `home` signal. Bare-directory signals
|
|
55
|
+
* (`.cursor`, `~/.codeium/windsurf`) get a trailing slash; file-shaped
|
|
56
|
+
* signals (`.claude/settings.json`) do not, because rendering a file
|
|
57
|
+
* path with a trailing slash misleads the user in init's
|
|
58
|
+
* "(detected: …)" hint string. We `statSync` to distinguish — that
|
|
59
|
+
* avoids brittle string heuristics (we cannot just look for a dot in
|
|
60
|
+
* the last segment, because directories with dots in their names are
|
|
61
|
+
* permitted by every supported filesystem).
|
|
62
|
+
*/
|
|
63
|
+
function formatHomeReason(value, path) {
|
|
64
|
+
return isFile(path) ? `~/${value}` : `~/${value}/`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatProjectReason(value, path) {
|
|
68
|
+
return isFile(path) ? value : `${value}/`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isFile(path) {
|
|
72
|
+
try {
|
|
73
|
+
return statSync(path).isFile();
|
|
74
|
+
} catch {
|
|
75
|
+
// statSync after a successful existsSync is unlikely to throw, but
|
|
76
|
+
// a TOCTOU between the two would crash the picker. Default to the
|
|
77
|
+
// directory format on any read error.
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
43
82
|
/**
|
|
44
83
|
* @typedef {Object} AgentDetection
|
|
45
84
|
* @property {string} key - Canonical agent key.
|
|
@@ -72,13 +111,11 @@ function probeSignal(signal) {
|
|
|
72
111
|
}
|
|
73
112
|
if (signal.type === "home") {
|
|
74
113
|
const path = join(homedir(), signal.value);
|
|
75
|
-
|
|
76
|
-
return `~/${signal.value}/`;
|
|
114
|
+
return probeFsPath(path) ? formatHomeReason(signal.value, path) : null;
|
|
77
115
|
}
|
|
78
116
|
if (signal.type === "project") {
|
|
79
117
|
const path = join(process.cwd(), signal.value);
|
|
80
|
-
|
|
81
|
-
return `${signal.value}/`;
|
|
118
|
+
return probeFsPath(path) ? formatProjectReason(signal.value, path) : null;
|
|
82
119
|
}
|
|
83
120
|
// Unknown type — defensive guard. Callers should never see this
|
|
84
121
|
// because the registry's frozen entries are typed.
|
package/src/lib/sync.mjs
CHANGED
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
*/
|
|
86
86
|
|
|
87
87
|
import { existsSync, readFileSync } from "node:fs";
|
|
88
|
+
import { join } from "node:path";
|
|
88
89
|
|
|
89
90
|
import { getLibrary } from "./http.mjs";
|
|
90
91
|
import {
|
|
@@ -110,6 +111,103 @@ import { computeSkillShas } from "./crypto-shas.mjs";
|
|
|
110
111
|
*/
|
|
111
112
|
export const LAST_SYNC_SCHEMA_VERSION = 2;
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Check whether every skill in the `.last-sync` baseline already exists
|
|
116
|
+
* under every placement target the current sync would write to. Returns
|
|
117
|
+
* true when the 304 short-circuit is safe (skipping writes won't leave
|
|
118
|
+
* any placement empty), false when at least one (skill, target) pair
|
|
119
|
+
* is missing on disk.
|
|
120
|
+
*
|
|
121
|
+
* This is the load-bearing guard for the ETag fast path. The contract:
|
|
122
|
+
* "library content unchanged AND every expected placement already has
|
|
123
|
+
* its content" — the second clause is what this function enforces.
|
|
124
|
+
*
|
|
125
|
+
* See the call site in runSync for the full motivation (upgrade-path,
|
|
126
|
+
* --global ↔ project swap, and cwd-switch scenarios all funnel through
|
|
127
|
+
* this gate).
|
|
128
|
+
*
|
|
129
|
+
* @param {Record<string, {skillMdSha256: string, filesSha256: string, version: string}> | null | undefined} skillsMap
|
|
130
|
+
* The `.skills` map from `.last-sync` — keyed by `"<owner>/<name>"`.
|
|
131
|
+
* @param {string[] | undefined} vendors
|
|
132
|
+
* The current sync's vendor list (output of `effectiveVendors`).
|
|
133
|
+
* @param {boolean | undefined} global
|
|
134
|
+
* Whether the current sync is `--global` mode.
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
export function placementsAreComplete(skillsMap, vendors, global) {
|
|
138
|
+
// Empty baseline: we cannot trust the ETag short-circuit. Two
|
|
139
|
+
// legitimate code paths land here:
|
|
140
|
+
// 1. Genuine fresh install — no `.last-sync` file at all; this
|
|
141
|
+
// function isn't called because runSync's caller checks
|
|
142
|
+
// `lastSync?.etag` before invoking. So we never see this
|
|
143
|
+
// branch in the fresh-install case.
|
|
144
|
+
// 2. v1 → v2 migration — `readLastSync` synthesizes an empty
|
|
145
|
+
// `skills` map for a v1 file (which had no per-skill SHA
|
|
146
|
+
// cache). The etag IS present but the baseline is empty;
|
|
147
|
+
// a 304 would tell us "library unchanged" but we'd still
|
|
148
|
+
// have no per-skill cache, so `list` would render every
|
|
149
|
+
// skill as MISS. Forcing a re-fetch here populates the
|
|
150
|
+
// cache and surfaces the actual library state.
|
|
151
|
+
// The conservative direction is "force re-fetch" — wire cost
|
|
152
|
+
// happens once per upgrade, not every time.
|
|
153
|
+
if (
|
|
154
|
+
!skillsMap ||
|
|
155
|
+
typeof skillsMap !== "object" ||
|
|
156
|
+
Object.keys(skillsMap).length === 0
|
|
157
|
+
) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// No vendors: this is the `--agent none` case or a degenerate config.
|
|
162
|
+
// requireVendorTargets will catch this downstream; from here, treat
|
|
163
|
+
// as "no placements to verify" → trivially complete.
|
|
164
|
+
if (!Array.isArray(vendors) || vendors.length === 0) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let targets;
|
|
169
|
+
try {
|
|
170
|
+
targets = placementTargetsFor({ vendors, global: global === true });
|
|
171
|
+
} catch {
|
|
172
|
+
// placementTargetsFor throws on an invalid vendor + scope combo
|
|
173
|
+
// (e.g., `--global` with a vendor that has no `globalTarget`).
|
|
174
|
+
// The downstream runSync write loop will surface the same error
|
|
175
|
+
// with the correct typed exception; conservatively treat as
|
|
176
|
+
// "placement incomplete" so we drop the ETag and force the
|
|
177
|
+
// full-fetch path (which is what we'd want under any error).
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const key of Object.keys(skillsMap)) {
|
|
182
|
+
// Skill keys are `"<owner>/<name>"`. We only need the name half
|
|
183
|
+
// because placement directories are keyed by skill name alone.
|
|
184
|
+
const slashAt = key.indexOf("/");
|
|
185
|
+
if (slashAt < 0) continue; // malformed entry — skip rather than crash
|
|
186
|
+
const skillName = key.slice(slashAt + 1);
|
|
187
|
+
if (!skillName) continue;
|
|
188
|
+
|
|
189
|
+
for (const target of targets) {
|
|
190
|
+
const dir = resolvePlacementDir(target, skillName);
|
|
191
|
+
// Probe `SKILL.md` rather than the bare directory. An empty or
|
|
192
|
+
// partial directory left behind by a hostile filesystem
|
|
193
|
+
// condition (manual mkdir, third-party tool, interrupted write
|
|
194
|
+
// before the atomic rename — rare but possible) would otherwise
|
|
195
|
+
// satisfy `existsSync(dir)` and let the 304 fire, leaving the
|
|
196
|
+
// user with a placement that contains no usable skill content.
|
|
197
|
+
// SKILL.md is the spec-required entry point; its presence is
|
|
198
|
+
// the load-bearing invariant the placement-presence check is
|
|
199
|
+
// really asserting.
|
|
200
|
+
if (!existsSync(join(dir, "SKILL.md"))) {
|
|
201
|
+
// ONE missing placement is enough — full re-fetch will re-write
|
|
202
|
+
// EVERY skill to EVERY target, restoring the invariant in one
|
|
203
|
+
// round.
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
113
211
|
/**
|
|
114
212
|
* Read the persisted last-sync state from ~/.claude/skillrepo/.last-sync.
|
|
115
213
|
* Returns null if the file doesn't exist, is malformed, or has a
|
|
@@ -218,6 +316,99 @@ export function writeLastSync({ etag, syncedAt, skills } = {}) {
|
|
|
218
316
|
}
|
|
219
317
|
}
|
|
220
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Add or replace a single skill's entry in `.last-sync` without
|
|
321
|
+
* touching the library-level `etag`/`syncedAt` fields.
|
|
322
|
+
*
|
|
323
|
+
* Why this exists: `get` and `add` write skills to disk one at a time
|
|
324
|
+
* without going through `runSync`. Pre-PR #1574 those commands left
|
|
325
|
+
* `.last-sync` untouched — meaning `list` afterwards couldn't see the
|
|
326
|
+
* skill in its baseline map and rendered it as `MISSING` even though
|
|
327
|
+
* it was sitting right there on disk. This helper closes that
|
|
328
|
+
* contract gap: any command that writes one skill's bytes to disk
|
|
329
|
+
* MUST also record the corresponding SHAs in `.last-sync` so the
|
|
330
|
+
* read-side drift detection can find them.
|
|
331
|
+
*
|
|
332
|
+
* Library-level `etag` and `syncedAt` are preserved verbatim from the
|
|
333
|
+
* prior state because they describe "what library snapshot did we
|
|
334
|
+
* fetch last" — `get` and `add` did not refresh that snapshot, so
|
|
335
|
+
* overwriting those fields would break the 304 short-circuit on the
|
|
336
|
+
* NEXT `update` (the server would think we're already past a state
|
|
337
|
+
* we never reached).
|
|
338
|
+
*
|
|
339
|
+
* Non-fatal on read/write failure: the actual skill files were
|
|
340
|
+
* already written to disk before we got here, so a broken state file
|
|
341
|
+
* should not roll back the user's intent. We throw on `writeLastSync`
|
|
342
|
+
* failure (same as the bulk path) so the caller can surface the
|
|
343
|
+
* warning, but otherwise this function is best-effort.
|
|
344
|
+
*
|
|
345
|
+
* @param {Object} skill - The skill that was just written.
|
|
346
|
+
* @param {string} skill.owner
|
|
347
|
+
* @param {string} skill.name
|
|
348
|
+
* @param {string} skill.version
|
|
349
|
+
* @param {{ path: string, content: string }[]} skill.files
|
|
350
|
+
*/
|
|
351
|
+
export function upsertLastSyncEntry(skill) {
|
|
352
|
+
if (!skill || typeof skill !== "object") return;
|
|
353
|
+
if (typeof skill.owner !== "string" || typeof skill.name !== "string") return;
|
|
354
|
+
if (!Array.isArray(skill.files)) return;
|
|
355
|
+
|
|
356
|
+
const shas = computeSkillShas(skill.files);
|
|
357
|
+
// computeSkillShas returns nulls when there's no SKILL.md; persisting
|
|
358
|
+
// null SHAs would later classify as `edited`, which is wrong for a
|
|
359
|
+
// skill we just installed. Skip the upsert in that pathological case
|
|
360
|
+
// — list.mjs's `MISSING` verdict for an unknown skill is a more
|
|
361
|
+
// accurate signal than a fabricated `current` or `edited`.
|
|
362
|
+
if (shas.skillMdSha256 === null || shas.filesSha256 === null) return;
|
|
363
|
+
|
|
364
|
+
const prior = readLastSync();
|
|
365
|
+
const priorSkills =
|
|
366
|
+
prior && prior.skills && typeof prior.skills === "object"
|
|
367
|
+
? prior.skills
|
|
368
|
+
: {};
|
|
369
|
+
|
|
370
|
+
const key = `${skill.owner}/${skill.name}`;
|
|
371
|
+
const updated = {
|
|
372
|
+
...priorSkills,
|
|
373
|
+
[key]: {
|
|
374
|
+
version: typeof skill.version === "string" ? skill.version : "",
|
|
375
|
+
skillMdSha256: shas.skillMdSha256,
|
|
376
|
+
filesSha256: shas.filesSha256,
|
|
377
|
+
syncedAt: new Date().toISOString(),
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
writeLastSync({
|
|
382
|
+
// Preserve etag and syncedAt — see docstring "why this exists."
|
|
383
|
+
etag: prior?.etag ?? null,
|
|
384
|
+
syncedAt: prior?.syncedAt,
|
|
385
|
+
skills: updated,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Remove a single skill's entry from `.last-sync` without touching
|
|
391
|
+
* library-level fields. Used by `remove` after deleting the on-disk
|
|
392
|
+
* files. Idempotent: if the entry isn't there, no-op.
|
|
393
|
+
*
|
|
394
|
+
* @param {string} owner
|
|
395
|
+
* @param {string} name
|
|
396
|
+
*/
|
|
397
|
+
export function deleteLastSyncEntry(owner, name) {
|
|
398
|
+
if (typeof owner !== "string" || typeof name !== "string") return;
|
|
399
|
+
const prior = readLastSync();
|
|
400
|
+
if (!prior || !prior.skills || typeof prior.skills !== "object") return;
|
|
401
|
+
const key = `${owner}/${name}`;
|
|
402
|
+
if (!(key in prior.skills)) return;
|
|
403
|
+
// eslint-disable-next-line no-unused-vars -- destructure-to-omit pattern
|
|
404
|
+
const { [key]: _removed, ...rest } = prior.skills;
|
|
405
|
+
writeLastSync({
|
|
406
|
+
etag: prior.etag ?? null,
|
|
407
|
+
syncedAt: prior.syncedAt,
|
|
408
|
+
skills: rest,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
221
412
|
/**
|
|
222
413
|
* Run a library sync against the server.
|
|
223
414
|
*
|
|
@@ -277,8 +468,61 @@ export async function runSync(options) {
|
|
|
277
468
|
|
|
278
469
|
// Step 3: fetch with conditional headers
|
|
279
470
|
const opts = {};
|
|
280
|
-
|
|
281
|
-
|
|
471
|
+
// The ETag short-circuit is only safe when EVERY skill in
|
|
472
|
+
// `.last-sync`'s baseline is present in EVERY placement directory
|
|
473
|
+
// the current sync would write to. The 304 means "library content
|
|
474
|
+
// unchanged" — but the local placement set can change between
|
|
475
|
+
// syncs even when content does not:
|
|
476
|
+
//
|
|
477
|
+
// - User upgrades from 4.5.0 (single-vendor default) to 4.5.1+
|
|
478
|
+
// (all-detected default) — new vendors detected, their
|
|
479
|
+
// placement dirs are empty.
|
|
480
|
+
// - User runs `update --global` once, then `update` (no flag)
|
|
481
|
+
// later — same vendor, but the resolved placement dir is
|
|
482
|
+
// project-scoped now instead of global.
|
|
483
|
+
// - User runs `update` in project A, then in project B — same
|
|
484
|
+
// vendor and scope, but `<cwd>/.claude/skills/` resolves to a
|
|
485
|
+
// different directory in B than in A.
|
|
486
|
+
//
|
|
487
|
+
// Without this check, any of those cases produces 304 → no writes
|
|
488
|
+
// → all-MISS output in `list`. The fix is a placement-presence
|
|
489
|
+
// check: for every (skill, target) pair the current sync would
|
|
490
|
+
// touch, the skill's directory MUST already exist under the
|
|
491
|
+
// target's root. If ANY is missing, drop `If-None-Match` so the
|
|
492
|
+
// server returns the full library and we populate the empty
|
|
493
|
+
// placement.
|
|
494
|
+
//
|
|
495
|
+
// Performance: N targets × M skills `existsSync` calls. For a 14-
|
|
496
|
+
// skill library with 2 targets, that's 28 stat calls — well under
|
|
497
|
+
// a millisecond. The conditional-fetch wire savings on a clean
|
|
498
|
+
// repeat sync still applies; this is the safety guard above it.
|
|
499
|
+
// BOTH the ETag short-circuit AND the `since` delta-query optimization
|
|
500
|
+
// depend on the same trust invariant: "the local placement set has
|
|
501
|
+
// every skill we expect." If a placement is missing, dropping ONLY
|
|
502
|
+
// `If-None-Match` is insufficient — the server filters `result.skills`
|
|
503
|
+
// by `updatedAt > since`, so a server response that we trigger by
|
|
504
|
+
// dropping the ETag will STILL omit any skill whose content hasn't
|
|
505
|
+
// changed on the server since the last sync. The missing placement
|
|
506
|
+
// would never be repopulated, producing a permanent ETag-miss loop
|
|
507
|
+
// that the user can only escape by `rm ~/.claude/skillrepo/.last-sync`.
|
|
508
|
+
//
|
|
509
|
+
// Verified against the production server at
|
|
510
|
+
// src/lib/queries/library.ts (`gt(skills.updatedAt, since)`): `since`
|
|
511
|
+
// alone is enough to filter unchanged skills out of the response.
|
|
512
|
+
//
|
|
513
|
+
// Fix: gate BOTH headers on the same `placementsAreComplete` check.
|
|
514
|
+
// When placements are incomplete, drop both — server returns the
|
|
515
|
+
// full library, runSync writes every skill to every detected
|
|
516
|
+
// vendor's placement, recovery is complete in one round.
|
|
517
|
+
const placementsComplete =
|
|
518
|
+
lastSync?.etag &&
|
|
519
|
+
placementsAreComplete(lastSync.skills, vendors, global);
|
|
520
|
+
if (placementsComplete) {
|
|
521
|
+
opts.ifNoneMatch = lastSync.etag;
|
|
522
|
+
}
|
|
523
|
+
if (lastSync?.syncedAt && placementsComplete) {
|
|
524
|
+
opts.since = lastSync.syncedAt;
|
|
525
|
+
}
|
|
282
526
|
|
|
283
527
|
// Track whether this is a full or delta sync BEFORE the network
|
|
284
528
|
// call, for the returned summary's `fullSync` field. A "full" sync
|
|
@@ -427,7 +671,13 @@ export async function runSync(options) {
|
|
|
427
671
|
}
|
|
428
672
|
}
|
|
429
673
|
|
|
430
|
-
// Step 7: persist new ETag + skills map (only if the response was
|
|
674
|
+
// Step 7: persist new ETag + skills map (only if the response was
|
|
675
|
+
// complete). The placement-presence check in `placementsAreComplete`
|
|
676
|
+
// (called above before the conditional request) handles the safety
|
|
677
|
+
// gate for the ETag short-circuit — no need to persist a separate
|
|
678
|
+
// vendor-set field; the on-disk placements themselves are the
|
|
679
|
+
// source of truth for "did the local placement set change in a way
|
|
680
|
+
// that would invalidate 304?"
|
|
431
681
|
if (!anyIncomplete && result.etag) {
|
|
432
682
|
try {
|
|
433
683
|
writeLastSync({
|