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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.5.0",
3
+ "version": "4.5.1",
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": {
@@ -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(
@@ -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({
@@ -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
- Object.freeze({ type: "home", value: ".claude" }),
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
  }),
@@ -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 `["claudeCode"]`, regardless of
208
- * `--global`. The v3.0.0 CLI deliberately does NOT silently
209
- * fall back to `[claudeCode, cursor]` like v2.0.0 did.
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
- return ["claudeCode"];
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
- if (!existsSync(path)) return null;
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
- if (!existsSync(path)) return null;
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
  *