skillrepo 4.5.0 → 4.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/add.mjs +15 -0
- package/src/commands/get.mjs +16 -0
- 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 +93 -0
- package/src/test/integration/update-list-contract.integration.test.mjs +1018 -0
- package/src/test/lib/cli-config.test.mjs +57 -6
- package/src/test/lib/detect-agents.test.mjs +37 -4
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(
|
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({
|
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
|
@@ -218,6 +218,99 @@ export function writeLastSync({ etag, syncedAt, skills } = {}) {
|
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Add or replace a single skill's entry in `.last-sync` without
|
|
223
|
+
* touching the library-level `etag`/`syncedAt` fields.
|
|
224
|
+
*
|
|
225
|
+
* Why this exists: `get` and `add` write skills to disk one at a time
|
|
226
|
+
* without going through `runSync`. Pre-PR #1574 those commands left
|
|
227
|
+
* `.last-sync` untouched — meaning `list` afterwards couldn't see the
|
|
228
|
+
* skill in its baseline map and rendered it as `MISSING` even though
|
|
229
|
+
* it was sitting right there on disk. This helper closes that
|
|
230
|
+
* contract gap: any command that writes one skill's bytes to disk
|
|
231
|
+
* MUST also record the corresponding SHAs in `.last-sync` so the
|
|
232
|
+
* read-side drift detection can find them.
|
|
233
|
+
*
|
|
234
|
+
* Library-level `etag` and `syncedAt` are preserved verbatim from the
|
|
235
|
+
* prior state because they describe "what library snapshot did we
|
|
236
|
+
* fetch last" — `get` and `add` did not refresh that snapshot, so
|
|
237
|
+
* overwriting those fields would break the 304 short-circuit on the
|
|
238
|
+
* NEXT `update` (the server would think we're already past a state
|
|
239
|
+
* we never reached).
|
|
240
|
+
*
|
|
241
|
+
* Non-fatal on read/write failure: the actual skill files were
|
|
242
|
+
* already written to disk before we got here, so a broken state file
|
|
243
|
+
* should not roll back the user's intent. We throw on `writeLastSync`
|
|
244
|
+
* failure (same as the bulk path) so the caller can surface the
|
|
245
|
+
* warning, but otherwise this function is best-effort.
|
|
246
|
+
*
|
|
247
|
+
* @param {Object} skill - The skill that was just written.
|
|
248
|
+
* @param {string} skill.owner
|
|
249
|
+
* @param {string} skill.name
|
|
250
|
+
* @param {string} skill.version
|
|
251
|
+
* @param {{ path: string, content: string }[]} skill.files
|
|
252
|
+
*/
|
|
253
|
+
export function upsertLastSyncEntry(skill) {
|
|
254
|
+
if (!skill || typeof skill !== "object") return;
|
|
255
|
+
if (typeof skill.owner !== "string" || typeof skill.name !== "string") return;
|
|
256
|
+
if (!Array.isArray(skill.files)) return;
|
|
257
|
+
|
|
258
|
+
const shas = computeSkillShas(skill.files);
|
|
259
|
+
// computeSkillShas returns nulls when there's no SKILL.md; persisting
|
|
260
|
+
// null SHAs would later classify as `edited`, which is wrong for a
|
|
261
|
+
// skill we just installed. Skip the upsert in that pathological case
|
|
262
|
+
// — list.mjs's `MISSING` verdict for an unknown skill is a more
|
|
263
|
+
// accurate signal than a fabricated `current` or `edited`.
|
|
264
|
+
if (shas.skillMdSha256 === null || shas.filesSha256 === null) return;
|
|
265
|
+
|
|
266
|
+
const prior = readLastSync();
|
|
267
|
+
const priorSkills =
|
|
268
|
+
prior && prior.skills && typeof prior.skills === "object"
|
|
269
|
+
? prior.skills
|
|
270
|
+
: {};
|
|
271
|
+
|
|
272
|
+
const key = `${skill.owner}/${skill.name}`;
|
|
273
|
+
const updated = {
|
|
274
|
+
...priorSkills,
|
|
275
|
+
[key]: {
|
|
276
|
+
version: typeof skill.version === "string" ? skill.version : "",
|
|
277
|
+
skillMdSha256: shas.skillMdSha256,
|
|
278
|
+
filesSha256: shas.filesSha256,
|
|
279
|
+
syncedAt: new Date().toISOString(),
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
writeLastSync({
|
|
284
|
+
// Preserve etag and syncedAt — see docstring "why this exists."
|
|
285
|
+
etag: prior?.etag ?? null,
|
|
286
|
+
syncedAt: prior?.syncedAt,
|
|
287
|
+
skills: updated,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Remove a single skill's entry from `.last-sync` without touching
|
|
293
|
+
* library-level fields. Used by `remove` after deleting the on-disk
|
|
294
|
+
* files. Idempotent: if the entry isn't there, no-op.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} owner
|
|
297
|
+
* @param {string} name
|
|
298
|
+
*/
|
|
299
|
+
export function deleteLastSyncEntry(owner, name) {
|
|
300
|
+
if (typeof owner !== "string" || typeof name !== "string") return;
|
|
301
|
+
const prior = readLastSync();
|
|
302
|
+
if (!prior || !prior.skills || typeof prior.skills !== "object") return;
|
|
303
|
+
const key = `${owner}/${name}`;
|
|
304
|
+
if (!(key in prior.skills)) return;
|
|
305
|
+
// eslint-disable-next-line no-unused-vars -- destructure-to-omit pattern
|
|
306
|
+
const { [key]: _removed, ...rest } = prior.skills;
|
|
307
|
+
writeLastSync({
|
|
308
|
+
etag: prior.etag ?? null,
|
|
309
|
+
syncedAt: prior.syncedAt,
|
|
310
|
+
skills: rest,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
221
314
|
/**
|
|
222
315
|
* Run a library sync against the server.
|
|
223
316
|
*
|