skillrepo 4.4.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.
@@ -1,21 +1,36 @@
1
1
  /**
2
- * `skillrepo list` (#679).
2
+ * `skillrepo list` — list library skills with per-row local drift state (#1555).
3
3
  *
4
- * Lists the authenticated account's library contents. Default output
5
- * is a human-readable table via `cli-table3`. `--json` flag prints
6
- * the raw metadata array for piping into `jq` or other tools.
4
+ * Historically (#679) this only showed the server-side library. As of
5
+ * #1555 the table adds a `Local` column showing how each skill's
6
+ * on-disk placement stacks up against the library + last-sync
7
+ * baseline: `current` / `stale` / `missing` / `edited`. A footer
8
+ * reports the library-level ETag state ("library in sync" vs
9
+ * "library has changed since last sync").
7
10
  *
8
- * Calls GET /api/v1/library and ignores the inlined file content —
9
- * we only need metadata for display. There is no metadata-only
10
- * endpoint; reading the full library payload is the cost of reusing
11
- * the same route the sync engine uses.
11
+ * Pipeline:
12
+ * 1. `getLibrary` returns the current registry skills + ETag.
13
+ * 2. `readLastSync` reads the v2 `.last-sync` map (per-skill SHAs +
14
+ * versions) for the on-disk-vs-synced comparison.
15
+ * 3. `detectAgents` identifies which vendors actually have footprint
16
+ * on this machine / in this project — we only report drift
17
+ * against vendors the user uses.
18
+ * 4. `walkDetectedPlacements` reads each detected vendor's
19
+ * placement dir and computes SHAs from disk.
20
+ * 5. `computeSkillState` + `rollupState` from `drift.mjs` turn the
21
+ * three axes into a per-vendor state and a per-skill rollup.
12
22
  *
13
23
  * Flags:
14
- * --json Pipe-friendly JSON output
15
- * --key/--url Override credentials
24
+ * --json Pipe-friendly JSON output (bare array preserved
25
+ * from #679 — purely additive: new `state` and
26
+ * `placements[]` fields per item, no top-level
27
+ * wrapper).
28
+ * --key/--url Override credentials.
16
29
  *
17
- * No --global / --agent flags `list` is a library-state inspector,
18
- * not a writer.
30
+ * No `--global`, `--detail`, `--vendor`, or `--include-extra` flags
31
+ * in v1 — YAGNI per the epic's decisions log. Project-scope
32
+ * placement only; extra-on-disk skills (in placement but not in
33
+ * library) are hidden by default.
19
34
  */
20
35
 
21
36
  import Table from "cli-table3";
@@ -23,6 +38,11 @@ import Table from "cli-table3";
23
38
  import { getLibrary } from "../lib/http.mjs";
24
39
  import { resolveFlags } from "../lib/cli-config.mjs";
25
40
  import { formatIdentifier } from "../lib/identifier.mjs";
41
+ import { readLastSync } from "../lib/sync.mjs";
42
+ import { detectAgents } from "../lib/detect-agents.mjs";
43
+ import { walkDetectedPlacements } from "../lib/placement-walk.mjs";
44
+ import { getAgentByKey } from "../lib/agent-registry.mjs";
45
+ import { computeSkillState, rollupState, SKILL_STATE } from "../lib/drift.mjs";
26
46
 
27
47
  /**
28
48
  * Run `list`. Throws CliError on any failure.
@@ -36,59 +56,165 @@ export async function runList(argv, io = {}) {
36
56
  const stdout = io.stdout ?? process.stdout;
37
57
  const flags = resolveFlags(argv);
38
58
 
39
- const result = await getLibrary(flags.serverUrl, flags.apiKey);
59
+ const libraryResponse = await getLibrary(flags.serverUrl, flags.apiKey);
40
60
 
41
- // `list` does not care about removals or sync state — we just want
42
- // the current set. Skills are returned by the same endpoint as the
43
- // sync, but with full file content we discard.
44
- //
45
61
  // Defensive guard: `list` calls getLibrary without an If-None-Match
46
62
  // header so it can't legitimately receive a 304 today. But getLibrary's
47
63
  // contract documents `notModified: true` as a possible return shape,
48
64
  // and a future refactor that adds caching at this layer could
49
- // accidentally start sending the header. Treat `result.skills` as
50
- // possibly empty rather than possibly undefined.
51
- const skills = Array.isArray(result.skills) ? result.skills : [];
65
+ // accidentally start sending the header.
66
+ const skills = Array.isArray(libraryResponse.skills) ? libraryResponse.skills : [];
67
+
68
+ // Detect agents + walk placements regardless of skill count — we
69
+ // need this info for both the table and the JSON shape.
70
+ const detected = detectAgents().filter((d) => d.detected);
71
+
72
+ // Pre-resolve the detected vendor entries ONCE. `getAgentByKey` is
73
+ // O(N) over the registry; doing this per-skill across 50+ skills
74
+ // was wasted work. We also filter to vendors with a non-null
75
+ // projectTarget here so the per-skill loop only iterates vendors
76
+ // that actually contribute a placement.
77
+ const detectedVendorEntries = detected
78
+ .map((d) => getAgentByKey(d.key))
79
+ .filter((entry) => entry !== null && entry.projectTarget !== null);
80
+ const detectedKeys = detectedVendorEntries.map((e) => e.key);
81
+
82
+ const placementsMap = walkDetectedPlacements(detectedKeys);
83
+
84
+ // Read `.last-sync` ONCE. Direct map access on the parsed
85
+ // `skills` payload — no per-skill helper indirection — for both
86
+ // wasted-I/O reasons (N reads of the same file otherwise) AND
87
+ // a within-run snapshot-consistency window (a concurrent
88
+ // `skillrepo update` writing the file mid-list would otherwise
89
+ // produce inconsistent classifications across skills in the
90
+ // same render).
91
+ const lastSync = readLastSync();
92
+ const lastSyncSkillsMap =
93
+ lastSync && lastSync.skills && typeof lastSync.skills === "object"
94
+ ? lastSync.skills
95
+ : {};
96
+
97
+ // Build the augmented per-skill state for every library skill. This
98
+ // is the canonical shape both the table renderer and JSON formatter
99
+ // project from.
100
+ const augmented = skills.map((skill) =>
101
+ augmentSkill(skill, detectedVendorEntries, placementsMap, lastSyncSkillsMap),
102
+ );
52
103
 
53
104
  if (flags.json) {
54
- stdout.write(JSON.stringify(formatJson(skills), null, 2) + "\n");
105
+ stdout.write(JSON.stringify(formatJson(augmented), null, 2) + "\n");
55
106
  return;
56
107
  }
57
108
 
58
- if (skills.length === 0) {
109
+ if (augmented.length === 0) {
59
110
  stdout.write(
60
111
  "\n Your library is empty.\n Use `skillrepo add <@owner/name>` to add a skill.\n\n",
61
112
  );
62
113
  return;
63
114
  }
64
115
 
65
- printTable(skills, stdout);
116
+ printTable(augmented, detected, stdout);
117
+ printFooter(augmented, libraryResponse.etag, lastSync, stdout, canUseGlyphs(stdout));
66
118
  }
67
119
 
120
+ // ── Per-skill augmentation ─────────────────────────────────────────────
121
+
68
122
  /**
69
- * Read the column width from the active output stream, falling back
70
- * to process.stdout's TTY column count if the injected stream
71
- * doesn't carry one (e.g., a test capture stream), then to a 100-col
72
- * default for non-TTY contexts. Reading from `out.columns` first
73
- * means a future test that injects a stream advertising a specific
74
- * width will be honored.
123
+ * Compute `state` (rollup) + `placements[]` for a library skill.
124
+ *
125
+ * For each detected vendor (pre-resolved by the caller) look up the
126
+ * on-disk placement and the last-sync baseline, classify the state,
127
+ * and emit a placement entry. Then roll the per-vendor states up to
128
+ * a single `state` for the table column.
129
+ *
130
+ * Baseline lookups go through the caller-provided `lastSyncSkillsMap`
131
+ * to avoid re-reading `.last-sync` per skill — both a wasted-I/O
132
+ * concern and a within-run snapshot consistency concern (an
133
+ * in-flight `skillrepo update` could otherwise change the file
134
+ * between our N reads).
135
+ *
136
+ * @param {object} skill - Library skill from `getLibrary` response.
137
+ * @param {import("../lib/agent-registry.mjs").AgentEntry[]} detectedVendorEntries
138
+ * Pre-resolved registry entries for the detected vendors that
139
+ * contribute a project placement. Computed once in `runList` so
140
+ * the registry lookup doesn't happen per skill.
141
+ * @param {Map<string, import("../lib/placement-walk.mjs").LocalPlacement>} placementsMap
142
+ * @param {Record<string, import("../lib/sync.mjs").SyncedSkillEntry>} lastSyncSkillsMap
143
+ * The `skills` map from `.last-sync` v2, captured once before the
144
+ * per-skill loop. Lookup is a direct dictionary access, not a
145
+ * re-read of the file.
75
146
  */
76
- function streamColumns(out) {
77
- if (out && typeof out.columns === "number" && out.columns > 0) {
78
- return out.columns;
147
+ function augmentSkill(skill, detectedVendorEntries, placementsMap, lastSyncSkillsMap) {
148
+ const baselineKey = `${skill.owner}/${skill.name}`;
149
+ const baseline = validateBaselineShape(lastSyncSkillsMap[baselineKey]);
150
+ const placements = [];
151
+
152
+ for (const entry of detectedVendorEntries) {
153
+ const placementKey = `${entry.key}::project::${skill.name}`;
154
+ const onDisk = placementsMap.get(placementKey) ?? null;
155
+
156
+ const localPlacement = onDisk
157
+ ? {
158
+ present: true,
159
+ skillMdSha256: onDisk.skillMdSha256,
160
+ filesSha256: onDisk.filesSha256,
161
+ }
162
+ : { present: false, skillMdSha256: null, filesSha256: null };
163
+
164
+ const state = computeSkillState({
165
+ libraryVersion: skill.version ?? null,
166
+ lastSyncEntry: baseline,
167
+ localPlacement,
168
+ });
169
+
170
+ placements.push({
171
+ vendor: entry.key,
172
+ scope: "project",
173
+ state,
174
+ // `localVersion` is the version that was synced (from
175
+ // `.last-sync`) — NOT a version pulled from on-disk
176
+ // frontmatter, which the spec doesn't mandate. When there's no
177
+ // baseline, this is null and the `state` will already be
178
+ // `missing`, so the field is honest about what we know.
179
+ localVersion: baseline?.version ?? null,
180
+ });
79
181
  }
80
- if (process.stdout.columns && process.stdout.columns > 0) {
81
- return process.stdout.columns;
182
+
183
+ const state = rollupState(placements.map((p) => p.state));
184
+
185
+ return { ...skill, state, placements };
186
+ }
187
+
188
+ /**
189
+ * Reject a `.last-sync` entry that's missing required string fields.
190
+ * A tampered or partially-written entry returns null, which
191
+ * `computeSkillState` treats as "no baseline → missing" — the
192
+ * conservative verdict for cache state we can't trust.
193
+ */
194
+ function validateBaselineShape(entry) {
195
+ if (!entry || typeof entry !== "object") return null;
196
+ if (
197
+ typeof entry.version !== "string" ||
198
+ typeof entry.skillMdSha256 !== "string" ||
199
+ typeof entry.filesSha256 !== "string"
200
+ ) {
201
+ return null;
82
202
  }
83
- return 100;
203
+ return entry;
84
204
  }
85
205
 
206
+ // ── JSON formatter ─────────────────────────────────────────────────────
207
+
86
208
  /**
87
- * Strip file content and reduce to the JSON shape that scripts care
88
- * about. Kept stable as a public-ish contract for `--json` consumers.
209
+ * Pipe-friendly bare array, additive on top of the #679 shape.
210
+ * Existing scripts that read `[0].owner`, etc. keep working. New
211
+ * fields: `state` (rollup) and `placements[]` (per-vendor truth).
212
+ *
213
+ * The footer never appears in JSON — `list --json` is consumed by
214
+ * scripts, and the ETag-state footer is a human-only signal.
89
215
  */
90
- function formatJson(skills) {
91
- return skills
216
+ function formatJson(augmented) {
217
+ return augmented
92
218
  .slice()
93
219
  .sort(sortByOwnerAndName)
94
220
  .map((s) => ({
@@ -98,17 +224,34 @@ function formatJson(skills) {
98
224
  description: s.description,
99
225
  updatedAt: s.updatedAt,
100
226
  filesIncomplete: s.filesIncomplete ?? false,
227
+ state: s.state,
228
+ placements: s.placements,
101
229
  }));
102
230
  }
103
231
 
104
- function printTable(skills, out) {
105
- const sorted = skills.slice().sort(sortByOwnerAndName);
232
+ // ── Human-output rendering ─────────────────────────────────────────────
233
+
234
+ function printTable(augmented, detected, out) {
235
+ const useGlyphs = canUseGlyphs(out);
236
+
237
+ // "Detected:" line uses displayName for polish. Empty when no
238
+ // vendors detected; that situation also produces all-`missing`
239
+ // states, which the footer covers.
240
+ if (detected.length > 0) {
241
+ const detectedLabel = detected
242
+ .map((d) => `${d.displayName} (project)`)
243
+ .join(", ");
244
+ out.write(`\n Detected: ${detectedLabel}\n`);
245
+ } else {
246
+ out.write(
247
+ "\n No agents detected in this project — drift cannot be reported.\n",
248
+ );
249
+ }
250
+
251
+ const sorted = augmented.slice().sort(sortByOwnerAndName);
106
252
 
107
- // cli-table3 supports word wrapping but we manually truncate
108
- // descriptions so the table stays readable on standard 80-col
109
- // terminals. The full description is available via --json.
110
253
  const table = new Table({
111
- head: ["Skill", "Version", "Updated", "Description"],
254
+ head: ["Skill", "Library", "Local", "Updated", "Description"],
112
255
  colWidths: computeColWidths(streamColumns(out)),
113
256
  wordWrap: true,
114
257
  style: { head: ["bold"] },
@@ -118,26 +261,156 @@ function printTable(skills, out) {
118
261
  table.push([
119
262
  formatIdentifier(s),
120
263
  s.version || "—",
264
+ renderLocalCell(s.state, useGlyphs),
121
265
  formatRelativeDate(s.updatedAt),
122
266
  truncate(s.description || "", 60),
123
267
  ]);
124
268
  }
125
269
 
126
270
  out.write("\n" + table.toString() + "\n\n");
127
- out.write(` ${sorted.length} skill${sorted.length === 1 ? "" : "s"} in your library.\n\n`);
271
+
272
+ const driftCount = sorted.filter((s) => s.state !== SKILL_STATE.CURRENT).length;
273
+ if (driftCount === 0) {
274
+ out.write(
275
+ ` ${sorted.length} skill${sorted.length === 1 ? "" : "s"} in your library.\n`,
276
+ );
277
+ } else {
278
+ out.write(
279
+ ` ${sorted.length} skill${sorted.length === 1 ? "" : "s"} in your library. ` +
280
+ `${driftCount} need${driftCount === 1 ? "s" : ""} attention.\n`,
281
+ );
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Library footer — answers "is my local copy in sync with the
287
+ * registry's manifest right now?" Distinct from per-row drift,
288
+ * which answers "is each individual skill's on-disk content
289
+ * current?"
290
+ *
291
+ * Four cases — order matters for the "least confusing precedence":
292
+ *
293
+ * - No `.last-sync` at all → "no sync history." The user has
294
+ * never run `update`; per-row drift is all `missing` anyway.
295
+ * A clear "you need to run update" line is the right message.
296
+ *
297
+ * - ETag matches AND every skill is current → fully in sync.
298
+ * The reassuring case.
299
+ *
300
+ * - ETag matches BUT some rows show drift → library hasn't
301
+ * changed but local placements have. User ran update at some
302
+ * point but then either edited files or has missing
303
+ * placements. "Library in sync, run update to refresh"
304
+ * reflects this without lying about what's wrong.
305
+ *
306
+ * - ETag differs → library has new content since last sync.
307
+ * "Library has changed, run update."
308
+ *
309
+ * The footer NEVER appears in `--json` output (caller already
310
+ * handles that in `runList`).
311
+ */
312
+ function printFooter(augmented, libraryEtag, lastSync, out, useGlyphs) {
313
+ if (augmented.length === 0) return; // Empty-library branch already wrote its own message.
314
+
315
+ // Match the renderLocalCell glyph/ASCII policy. Pre-#1555 the footer
316
+ // used Unicode unconditionally even when the table fell back to
317
+ // ASCII for non-TTY / NO_COLOR. Inconsistency made piped output
318
+ // half-styled.
319
+ const ok = useGlyphs ? "✓" : "[ok]";
320
+ const warn = useGlyphs ? "⚠" : "[!]";
321
+
322
+ const driftCount = augmented.filter((s) => s.state !== SKILL_STATE.CURRENT).length;
323
+
324
+ if (!lastSync || !lastSync.etag) {
325
+ out.write(
326
+ "\n No sync history on this machine — run `skillrepo update` to fetch your skills.\n\n",
327
+ );
328
+ return;
329
+ }
330
+
331
+ const etagMatches =
332
+ typeof libraryEtag === "string" && libraryEtag !== "" && libraryEtag === lastSync.etag;
333
+
334
+ if (etagMatches && driftCount === 0) {
335
+ out.write(`\n ${ok} library in sync — local skills up to date.\n\n`);
336
+ return;
337
+ }
338
+
339
+ if (etagMatches && driftCount > 0) {
340
+ out.write(
341
+ `\n ${warn} library in sync — but ${driftCount} skill${
342
+ driftCount === 1 ? "" : "s"
343
+ } show${driftCount === 1 ? "s" : ""} local drift.\n` +
344
+ " Run `skillrepo update` to refresh.\n\n",
345
+ );
346
+ return;
347
+ }
348
+
349
+ // ETag differs (or one side is missing the etag — treat as differs,
350
+ // since we can't claim sync).
351
+ out.write(
352
+ `\n ${warn} library has changed since last sync — run \`skillrepo update\`.\n\n`,
353
+ );
354
+ }
355
+
356
+ // ── Rendering helpers ──────────────────────────────────────────────────
357
+
358
+ /** Glyphs vs ASCII tokens, per NO_COLOR / non-TTY. */
359
+ function canUseGlyphs(stream) {
360
+ if (process.env.NO_COLOR) return false;
361
+ // `isTTY` is undefined on capture streams; treat as "no TTY → ASCII"
362
+ // since CI and pipe consumers would otherwise see escape codes /
363
+ // glyphs they can't render uniformly.
364
+ return stream && stream.isTTY === true;
365
+ }
366
+
367
+ function renderLocalCell(state, useGlyphs) {
368
+ switch (state) {
369
+ case SKILL_STATE.CURRENT:
370
+ return useGlyphs ? "✓" : "OK";
371
+ case SKILL_STATE.STALE:
372
+ return useGlyphs ? "⚠ stale" : "STALE";
373
+ case SKILL_STATE.MISSING:
374
+ return useGlyphs ? "✗ miss" : "MISS";
375
+ case SKILL_STATE.EDITED:
376
+ return useGlyphs ? "✎ edit" : "EDIT";
377
+ default:
378
+ return useGlyphs ? "?" : "?";
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Read the column width from the active output stream, falling back
384
+ * to process.stdout's TTY column count if the injected stream
385
+ * doesn't carry one (e.g., a test capture stream), then to a 100-col
386
+ * default for non-TTY contexts.
387
+ */
388
+ function streamColumns(out) {
389
+ if (out && typeof out.columns === "number" && out.columns > 0) {
390
+ return out.columns;
391
+ }
392
+ if (process.stdout.columns && process.stdout.columns > 0) {
393
+ return process.stdout.columns;
394
+ }
395
+ return 100;
128
396
  }
129
397
 
130
398
  function computeColWidths(terminalColumns) {
131
- // Cap at 120 so the table doesn't get unreadably wide on
132
- // ultra-wide terminals; floor at 100 so the description still has
133
- // room when the terminal is narrower than typical.
399
+ // Cap at 120 so the table doesn't get unreadably wide on ultra-
400
+ // wide terminals; floor at 100 so the description still has room.
401
+ // The Local column is fixed-width (10 chars — biggest glyph cell
402
+ // is "✎ edit" plus padding).
134
403
  const total = terminalColumns > 60 ? Math.min(terminalColumns, 120) : 100;
135
- // Skill ~30, version ~10, updated ~14, description gets the rest
136
- const skillCol = 32;
137
- const versionCol = 12;
138
- const updatedCol = 14;
139
- const descCol = Math.max(20, total - skillCol - versionCol - updatedCol - 6); // -6 for borders
140
- return [skillCol, versionCol, updatedCol, descCol];
404
+ const skillCol = 30;
405
+ const libraryCol = 10;
406
+ const localCol = 10;
407
+ const updatedCol = 12;
408
+ // -8 accounts for the 6 between-column borders
409
+ const descCol = Math.max(
410
+ 20,
411
+ total - skillCol - libraryCol - localCol - updatedCol - 8,
412
+ );
413
+ return [skillCol, libraryCol, localCol, updatedCol, descCol];
141
414
  }
142
415
 
143
416
  function truncate(s, n) {
@@ -164,7 +437,6 @@ function formatRelativeDate(iso) {
164
437
  if (days < 7) return `${days}d ago`;
165
438
  const weeks = Math.floor(days / 7);
166
439
  if (weeks < 26) return `${weeks}w ago`;
167
- // Older than ~6 months: fall back to a date
168
440
  return new Date(iso).toISOString().slice(0, 10);
169
441
  }
170
442
 
@@ -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`