auriga-cli 1.18.5 → 1.19.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/dist/state.js CHANGED
@@ -1,20 +1,23 @@
1
- // scanState — produce a tri-state install report (installed / update-available /
2
- // not-installed) for every category, reading the *actual* Claude Code install
3
- // locations rather than auriga-cli's own dev-repo layout. The truth sources
4
- // (per docs/specs/web-ui-scanner-redesign.md):
1
+ // scanState — produce a presence-only install report for every category,
2
+ // reading the *actual* Claude Code install locations rather than auriga-cli's
3
+ // own dev-repo layout. The truth sources:
5
4
  //
6
5
  // Workflow: ~/.claude/CLAUDE.md (user scope)
7
- // <proj>/CLAUDE.md, fallback .claude/CLAUDE.md (project scope)
6
+ // <proj>/CLAUDE.md (project scope)
8
7
  // Skills: ~/.claude/skills/<name>/SKILL.md (user scope)
9
8
  // <proj>/.claude/skills/<name>/SKILL.md (project scope)
10
9
  // Plugins(Claude): execPluginList(scope) + settings.json enabledPlugins
11
10
  // Plugins(Codex): ~/.codex/config.toml + ~/.codex/plugins/cache (user only)
12
11
  // Hooks: <scope>/.claude/settings.json `hooks` segment, matched by _marker
13
12
  //
13
+ // Scanner is presence-only: states are `installed` / `not-installed` /
14
+ // `partial-install` (dual-Agent half-install). v1.19.0 dropped
15
+ // `update-available` — re-running install is the update path for every
16
+ // category; the scanner no longer compares versions or hashes.
17
+ //
14
18
  // External I/O is either injected via ScanOptions (tests) or done through the
15
19
  // default implementations at the bottom of the file (server.ts production
16
20
  // wiring). See tests/state.test.ts for the full behavioral contract.
17
- import { createHash } from "node:crypto";
18
21
  import { exec as execCallback } from "node:child_process";
19
22
  import { promisify } from "node:util";
20
23
  const execAsync = promisify(execCallback);
@@ -22,12 +25,6 @@ import fs from "node:fs";
22
25
  import os from "node:os";
23
26
  import path from "node:path";
24
27
  import { parse as parseToml } from "smol-toml";
25
- /** Wildcard sentinel for the catalog hook `expectedHash` field. A value
26
- * equal to this string (or the empty string) is treated as "no drift
27
- * expectation, trust marker presence" — useful when the catalog hasn't yet
28
- * been populated with a real settings-entry signature. The test suite uses
29
- * this sentinel explicitly (see tests/state.test.ts assumption A7). */
30
- const WILDCARD_EXPECTED_HASH = "any";
31
28
  /**
32
29
  * Shorten an absolute path by replacing the user's $HOME with `~`. Avoids
33
30
  * leaking the full username in screenshots and keeps the TopBar label
@@ -55,7 +52,7 @@ export async function scanState(projectRoot, catalog, opts = {}) {
55
52
  const warnings = [];
56
53
  const home = opts.homeDir ?? os.homedir();
57
54
  const scopes = { ...DEFAULT_SCOPES, ...(opts.scopes ?? {}) };
58
- const workflow = scanWorkflow(scopes.workflow, projectRoot, home, catalog, warnings);
55
+ const workflow = scanWorkflow(scopes.workflow, projectRoot, home, warnings);
59
56
  const skills = scanSkills(scopes.skills, projectRoot, home, catalog.skills,
60
57
  /* recommended */ false, warnings);
61
58
  const recommendedSkills = scanRecommendedSkills(scopes.skills, projectRoot, home, catalog.recommendedSkills, warnings);
@@ -96,12 +93,6 @@ function dirExists(p) {
96
93
  }
97
94
  export function mergePluginsById(records) {
98
95
  const byId = new Map();
99
- // Per-agent (agent, status, version) tuples preserved across the fold so
100
- // the aggregation step can emit `partial-install` + `missingAgents` when
101
- // one side is installed and the other isn't, AND pick the *stale* side's
102
- // currentVersion when status === "update-available" (otherwise the merge
103
- // inherited Claude's version, producing the misleading `vX → vX` display
104
- // in the v1.18.4 verification).
105
96
  const perAgentByIdEntries = new Map();
106
97
  for (const rec of records) {
107
98
  // Pre-merge records are per-agent: their `agents[]` array contains the
@@ -109,7 +100,7 @@ export function mergePluginsById(records) {
109
100
  // those into the final dual-Agent record.
110
101
  const recAgent = rec.agents[0];
111
102
  const perAgentEntry = recAgent
112
- ? { agent: recAgent, status: rec.status, currentVersion: rec.currentVersion }
103
+ ? { agent: recAgent, status: rec.status }
113
104
  : null;
114
105
  const existing = byId.get(rec.id);
115
106
  if (!existing) {
@@ -117,7 +108,6 @@ export function mergePluginsById(records) {
117
108
  perAgentByIdEntries.set(rec.id, perAgentEntry ? [perAgentEntry] : []);
118
109
  continue;
119
110
  }
120
- // Union agents preserving order: existing first, then any new ones.
121
111
  const seen = new Set(existing.agents);
122
112
  for (const a of rec.agents) {
123
113
  if (!seen.has(a)) {
@@ -129,8 +119,6 @@ export function mergePluginsById(records) {
129
119
  perAgentByIdEntries.get(rec.id).push(perAgentEntry);
130
120
  }
131
121
  }
132
- // Fold per-agent tuples into the aggregated status, missingAgents, and
133
- // (when applicable) the corrected currentVersion.
134
122
  for (const [id, perAgent] of perAgentByIdEntries) {
135
123
  const rec = byId.get(id);
136
124
  if (!rec)
@@ -143,17 +131,6 @@ export function mergePluginsById(records) {
143
131
  else {
144
132
  delete rec.missingAgents;
145
133
  }
146
- // When status is update-available, surface the version of the *stale*
147
- // agent (one whose own status was update-available). Otherwise we'd
148
- // keep whichever agent's version was merged first — Claude's, which
149
- // may already be at the expected version, producing a `vX → vX`
150
- // pseudo-upgrade display.
151
- if (rec.status === "update-available") {
152
- const stale = perAgent.find((r) => r.status === "update-available");
153
- if (stale?.currentVersion) {
154
- rec.currentVersion = stale.currentVersion;
155
- }
156
- }
157
134
  }
158
135
  return Array.from(byId.values());
159
136
  }
@@ -164,24 +141,17 @@ function aggregateStatus(records) {
164
141
  return { status: "installed" };
165
142
  if (records.every((r) => r.status === "not-installed"))
166
143
  return { status: "not-installed" };
167
- // Mixed. If ANY agent reports not-installed, the row is partially installed
168
- // — the user-facing action is "install on the missing side". Surfaces as a
169
- // distinct status from version-drift `update-available` so the UI doesn't
170
- // render misleading `vX → vX` upgrades (the v1.18.4 deep-review symptom).
144
+ // Mixed: dual-Agent plugin with some agents installed and some not.
145
+ // User-facing action is "install on the missing side".
171
146
  const missingAgents = records
172
147
  .filter((r) => r.status === "not-installed")
173
148
  .map((r) => r.agent);
174
- if (missingAgents.length > 0) {
175
- return { status: "partial-install", missingAgents };
176
- }
177
- // Otherwise version drift on at least one targeted agent — single Apply
178
- // upgrades the stale side(s).
179
- return { status: "update-available" };
149
+ return { status: "partial-install", missingAgents };
180
150
  }
181
151
  // ---------------------------------------------------------------------------
182
152
  // Workflow
183
153
  // ---------------------------------------------------------------------------
184
- const WORKFLOW_HEADER_RE = /^#\s+auriga\s+Workflow\s*\(v(\d+\.\d+\.\d+)\)/;
154
+ const WORKFLOW_HEADER_RE = /^#\s+auriga\s+Workflow\s*\(v\d+\.\d+\.\d+\)/;
185
155
  function workflowPathsForScope(scope, projectRoot, home) {
186
156
  if (scope === "user") {
187
157
  return [path.join(home, ".claude", "CLAUDE.md")];
@@ -193,8 +163,7 @@ function workflowPathsForScope(scope, projectRoot, home) {
193
163
  // user-scope content into the project-scope row.
194
164
  return [path.join(projectRoot, "CLAUDE.md")];
195
165
  }
196
- function scanWorkflow(scope, projectRoot, home, catalog, warnings) {
197
- const expectedVersion = catalog.workflowVersion;
166
+ function scanWorkflow(scope, projectRoot, home, warnings) {
198
167
  const candidates = workflowPathsForScope(scope, projectRoot, home);
199
168
  let content = null;
200
169
  for (const candidate of candidates) {
@@ -207,35 +176,27 @@ function scanWorkflow(scope, projectRoot, home, catalog, warnings) {
207
176
  }
208
177
  }
209
178
  if (content === null) {
210
- return { status: "not-installed", expectedVersion, observedScope: scope };
179
+ return { status: "not-installed", observedScope: scope };
211
180
  }
212
- // Walk the first non-blank lines looking for the auriga header.
181
+ // Walk the first non-blank lines looking for the auriga header. We only
182
+ // need to know "is this our CLAUDE.md or foreign" — the actual version
183
+ // string is unused since v1.19.0 dropped update-available status.
213
184
  for (const line of content.split(/\r?\n/)) {
214
- const m = WORKFLOW_HEADER_RE.exec(line);
215
- if (m) {
216
- const currentVersion = m[1];
217
- // Empty expectedVersion means scan-catalog couldn't extract the
218
- // shipped workflow's header (auriga-cli's own CLAUDE.md missing or
219
- // malformed at build time). Trust the installed version rather than
220
- // forcing a phantom "update-available" against the empty string.
221
- const status = !expectedVersion || currentVersion === expectedVersion ? "installed" : "update-available";
222
- return { status, expectedVersion, currentVersion, observedScope: scope };
185
+ if (WORKFLOW_HEADER_RE.test(line)) {
186
+ return { status: "installed", observedScope: scope };
223
187
  }
224
188
  if (line.trim().length > 0)
225
189
  break;
226
190
  }
227
191
  // CLAUDE.md exists but no recognizable auriga marker. The file is foreign
228
192
  // — not our workflow. Report `not-installed` honestly; the install path
229
- // (src/workflow.ts) already protects user content by backing it up to
230
- // `CLAUDE.md.bak` before overwriting. Conflating "something exists here"
231
- // with "auriga workflow installed" caused the v1.18.4 verification bug
232
- // where running web-ui from `~` reported the user's `# Global`-headed
233
- // `~/.claude/CLAUDE.md` as an installed workflow.
193
+ // (src/workflow.ts) protects user content by backing it up to
194
+ // `CLAUDE.md.bak` (backup-once: never clobbers a prior .bak).
234
195
  warnings.push({
235
196
  code: "workflow-foreign-claudemd",
236
197
  message: `Foreign CLAUDE.md detected at the workflow path — no auriga-workflow header. Install will back up to CLAUDE.md.bak.`,
237
198
  });
238
- return { status: "not-installed", expectedVersion, observedScope: scope };
199
+ return { status: "not-installed", observedScope: scope };
239
200
  }
240
201
  // ---------------------------------------------------------------------------
241
202
  // Skills + recommendedSkills
@@ -245,50 +206,38 @@ function skillsRoot(scope, projectRoot, home) {
245
206
  return path.join(home, ".claude", "skills");
246
207
  return path.join(projectRoot, ".claude", "skills");
247
208
  }
248
- /** Classify a single skill by reading its SKILL.md from the scope's skills
249
- * dir. Returns the status + (when readable) the on-disk content hash. The
250
- * `malformedSeen` set is mutated when a skill dir exists but SKILL.md is
209
+ /** Classify a single skill by presence of its SKILL.md. Returns
210
+ * `installed` if SKILL.md is readable, `not-installed` otherwise.
211
+ * `malformedSeen` is mutated when a skill dir exists but SKILL.md is
251
212
  * missing/unreadable — the caller emits ONE skill-malformed warning per
252
213
  * scan. */
253
- function classifySkillByFile(name, expectedHash, rootDir, malformedSeen) {
214
+ function classifySkillByFile(name, rootDir, malformedSeen) {
254
215
  const skillDir = path.join(rootDir, name);
255
216
  const skillMd = path.join(skillDir, "SKILL.md");
256
- let buf;
257
217
  try {
258
- buf = fs.readFileSync(skillMd);
218
+ fs.readFileSync(skillMd);
219
+ return "installed";
259
220
  }
260
221
  catch {
261
- // SKILL.md unreadable. Two sub-cases:
262
- // (a) skill dir also missing → simply not installed.
263
- // (b) skill dir present but SKILL.md missing → malformed; row stays
264
- // "installed" so the user can repair, plus a warning.
265
222
  if (dirExists(skillDir)) {
223
+ // skill dir present but SKILL.md missing → malformed; row stays
224
+ // "installed" so the user can repair, plus a warning.
266
225
  malformedSeen.add(name);
267
- return { status: "installed" };
226
+ return "installed";
268
227
  }
269
- return { status: "not-installed" };
270
- }
271
- const currentHash = createHash("sha256").update(buf).digest("hex");
272
- if (expectedHash === "" ||
273
- expectedHash === WILDCARD_EXPECTED_HASH ||
274
- currentHash === expectedHash) {
275
- return { status: "installed", currentHash };
228
+ return "not-installed";
276
229
  }
277
- return { status: "update-available", currentHash };
278
230
  }
279
231
  function scanSkills(scope, projectRoot, home, catalogSkills, _recommended, warnings) {
280
232
  const rootDir = skillsRoot(scope, projectRoot, home);
281
233
  const malformed = new Set();
282
234
  const out = [];
283
235
  for (const [name, entry] of Object.entries(catalogSkills)) {
284
- const cls = classifySkillByFile(name, entry.expectedHash, rootDir, malformed);
285
236
  out.push({
286
237
  name,
287
238
  description: entry.description,
288
- status: cls.status,
239
+ status: classifySkillByFile(name, rootDir, malformed),
289
240
  isWorkflow: entry.isWorkflow,
290
- currentHash: cls.currentHash,
291
- expectedHash: entry.expectedHash,
292
241
  observedScope: scope,
293
242
  });
294
243
  }
@@ -305,14 +254,11 @@ function scanRecommendedSkills(scope, projectRoot, home, catalogRec, warnings) {
305
254
  const malformed = new Set();
306
255
  const out = [];
307
256
  for (const [name, entry] of Object.entries(catalogRec)) {
308
- const cls = classifySkillByFile(name, entry.expectedHash, rootDir, malformed);
309
257
  out.push({
310
258
  name,
311
259
  description: entry.description,
312
- status: cls.status,
260
+ status: classifySkillByFile(name, rootDir, malformed),
313
261
  isWorkflow: false,
314
- currentHash: cls.currentHash,
315
- expectedHash: entry.expectedHash,
316
262
  observedScope: scope,
317
263
  });
318
264
  }
@@ -330,15 +276,6 @@ function scanRecommendedSkills(scope, projectRoot, home, catalogRec, warnings) {
330
276
  function filterPluginsByAgent(catalogPlugins, agent) {
331
277
  return Object.entries(catalogPlugins).filter(([, def]) => def.agents.includes(agent));
332
278
  }
333
- /** Normalize a version ref: "v1.2.3" → "1.2.3", "1.2.3" → "1.2.3".
334
- * Anything that is not a strict semver-like triple is returned as-is so the
335
- * caller can detect "this is a branch / tag, not a comparable version". */
336
- function parseRef(ref) {
337
- if (typeof ref !== "string" || ref.length === 0)
338
- return null;
339
- const m = /^v?(\d+\.\d+\.\d+(?:[-+][\w.]+)?)$/.exec(ref);
340
- return m ? m[1] : null;
341
- }
342
279
  async function scanClaudePlugins(scope, entries, execPluginList, warnings) {
343
280
  if (entries.length === 0)
344
281
  return [];
@@ -348,7 +285,7 @@ async function scanClaudePlugins(scope, entries, execPluginList, warnings) {
348
285
  if (!execPluginList) {
349
286
  warnings.push({
350
287
  code: "claude-cli-missing",
351
- message: "Claude CLI not available — plugin update detection disabled. Install `claude` to enable update checks.",
288
+ message: "Claude CLI not available — plugin presence detection disabled. Install `claude` to enable.",
352
289
  });
353
290
  return entries.map(([id, def]) => degradedClaudeRow(id, def, scope));
354
291
  }
@@ -366,32 +303,22 @@ async function scanClaudePlugins(scope, entries, execPluginList, warnings) {
366
303
  // claude plugins list emits ids in `<plugin>@<marketplace>` form (e.g.
367
304
  // `auriga-go@auriga-cli`). The auriga-cli catalog tracks plugins by bare
368
305
  // name. Index both forms so lookups succeed regardless of which side the
369
- // suffix is on. Same trick for availables — note that the `--available`
370
- // payload uses `pluginId` rather than `id`, so accept both as the key.
371
- const indexBoth = (map, item) => {
306
+ // suffix is on.
307
+ const installedById = new Map();
308
+ for (const item of payload.installed ?? []) {
372
309
  if (!item || typeof item !== "object")
373
- return;
374
- const key = typeof item.id === "string"
375
- ? item.id
376
- : typeof item.pluginId === "string"
377
- ? item.pluginId
378
- : null;
310
+ continue;
311
+ const key = typeof item.id === "string" ? item.id : null;
379
312
  if (!key)
380
- return;
381
- map.set(key, item);
313
+ continue;
314
+ installedById.set(key, item);
382
315
  const at = key.indexOf("@");
383
316
  if (at > 0)
384
- map.set(key.slice(0, at), item);
385
- };
386
- const installedById = new Map();
387
- for (const item of payload.installed ?? [])
388
- indexBoth(installedById, item);
389
- const availableById = new Map();
390
- for (const item of payload.available ?? [])
391
- indexBoth(availableById, item);
317
+ installedById.set(key.slice(0, at), item);
318
+ }
392
319
  const out = [];
393
320
  for (const [id, def] of entries) {
394
- out.push(classifyClaudePlugin(id, def, installedById.get(id), availableById.get(id), scope));
321
+ out.push(classifyClaudePlugin(id, def, installedById.get(id), scope));
395
322
  }
396
323
  return out;
397
324
  }
@@ -401,107 +328,21 @@ function degradedClaudeRow(id, def, scope) {
401
328
  description: def.description,
402
329
  status: "not-installed",
403
330
  agents: ["claude"],
404
- expectedVersion: def.expectedVersion,
405
- versionSource: "upstream-live",
406
331
  observedScope: scope,
407
332
  ...(def.external === true ? { external: true } : {}),
408
333
  };
409
334
  }
410
- function classifyClaudePlugin(id, def, installed, available, scope) {
411
- // `external` propagates onto every return below so the UI can surface the
412
- // EXTERNAL badge regardless of install state.
335
+ function classifyClaudePlugin(id, def, installed, scope) {
413
336
  const externalFlag = def.external === true ? { external: true } : {};
414
- if (!installed || typeof installed.version !== "string") {
415
- return {
416
- id,
417
- description: def.description,
418
- status: "not-installed",
419
- agents: ["claude"],
420
- expectedVersion: typeof available?.source?.ref === "string" ? available.source.ref : def.expectedVersion,
421
- versionSource: "upstream-live",
422
- observedScope: scope,
423
- ...externalFlag,
424
- };
425
- }
426
- const installedVersion = installed.version;
427
- // External plugin short-circuit: we don't own these, so we don't claim
428
- // authority on "what version they should be at". `claude plugins update`
429
- // is the right channel — the scanner just confirms presence. Status stays
430
- // "installed" even if installed.version differs from any signal we have.
431
- // The catalog deliberately omits `expectedVersion` for externals, but we
432
- // double-down with this guard so a future regression that accidentally
433
- // populates expectedVersion still can't flip externals to update-available.
434
- if (def.external === true) {
435
- return {
436
- id,
437
- description: def.description,
438
- status: "installed",
439
- agents: ["claude"],
440
- currentVersion: installedVersion,
441
- // Don't surface any "expected" on externals — the upstream tool owns
442
- // the version conversation.
443
- versionSource: "upstream-live",
444
- observedScope: scope,
445
- ...externalFlag,
446
- };
447
- }
448
- const ref = available?.source?.ref;
449
- const normalizedAvailable = parseRef(typeof ref === "string" ? ref : undefined);
450
- const normalizedInstalled = parseRef(installedVersion);
451
- // Pick the comparison target. The marketplace-live ref wins when it's a
452
- // parseable semver — that's the freshest signal. Otherwise fall back to
453
- // the build-time-baked `def.expectedVersion` (populated from
454
- // plugins/<name>/.claude-plugin/plugin.json by scan-catalog for owned
455
- // plugins). Without the fallback, the common upgrade case is invisible:
456
- // `claude plugins list --available --json` excludes already-installed
457
- // plugins from `.available[]`, so for any plugin the user already has,
458
- // `ref` is undefined and the scanner can't tell whether a newer version
459
- // ships in the marketplace.
460
- const hasLiveRef = normalizedAvailable !== null && typeof ref === "string";
461
- const expectedRaw = hasLiveRef ? ref : def.expectedVersion;
462
- const expectedNormalized = hasLiveRef
463
- ? normalizedAvailable
464
- : parseRef(def.expectedVersion);
465
- const versionSource = hasLiveRef
466
- ? "upstream-live"
467
- : "catalog";
468
- // Fallback rules (no comparable expected version, or unknown installed):
469
- // - installed version "unknown" → trust it's installed.
470
- // - effective expected is null (branch ref + no baked version) → trust installed.
471
- if (installedVersion === "unknown" || expectedNormalized === null) {
472
- return {
473
- id,
474
- description: def.description,
475
- status: "installed",
476
- agents: ["claude"],
477
- currentVersion: installedVersion,
478
- expectedVersion: expectedRaw,
479
- versionSource,
480
- observedScope: scope,
481
- ...externalFlag,
482
- };
483
- }
484
- if (normalizedInstalled !== null && normalizedInstalled === expectedNormalized) {
485
- return {
486
- id,
487
- description: def.description,
488
- status: "installed",
489
- agents: ["claude"],
490
- currentVersion: installedVersion,
491
- expectedVersion: expectedRaw,
492
- versionSource,
493
- observedScope: scope,
494
- ...externalFlag,
495
- };
496
- }
337
+ // Presence-only since v1.19.0: any matching installed record counts.
338
+ // Don't require a `version` field — the field may go away in a future
339
+ // `claude plugins list --json` shape and we no longer compare versions.
340
+ const status = installed ? "installed" : "not-installed";
497
341
  return {
498
342
  id,
499
343
  description: def.description,
500
- status: "update-available",
344
+ status,
501
345
  agents: ["claude"],
502
- currentVersion: installedVersion,
503
- expectedVersion: expectedRaw,
504
- versionSource,
505
346
  observedScope: scope,
506
347
  ...externalFlag,
507
348
  };
@@ -552,7 +393,7 @@ async function scanCodexPlugins(entries, readCodexConfig, readCodexPluginsDir, w
552
393
  // both emit `<plugin>@<marketplace>` keys (e.g. "auriga-go@auriga-cli").
553
394
  // Without dual indexing every dual-Agent plugin reports `not-installed` on
554
395
  // the Codex side, which `mergePluginsById` then folds into a permanent
555
- // `update-available` even when both sides are genuinely installed.
396
+ // `partial-install` even when both sides are genuinely installed.
556
397
  const lookupEnabled = (catalogId) => {
557
398
  if (enabledIds.has(catalogId))
558
399
  return true;
@@ -586,75 +427,18 @@ function degradedCodexRow(id, def) {
586
427
  description: def.description,
587
428
  status: "not-installed",
588
429
  agents: ["codex"],
589
- expectedVersion: def.expectedVersion,
590
- versionSource: "catalog",
591
430
  observedScope: "user",
592
431
  ...(def.external === true ? { external: true } : {}),
593
432
  };
594
433
  }
595
434
  function classifyCodexPlugin(id, def, enabled, fsVersion) {
596
- const expectedVersion = def.expectedVersion;
597
435
  const externalFlag = def.external === true ? { external: true } : {};
598
- if (!enabled) {
599
- return {
600
- id,
601
- description: def.description,
602
- status: "not-installed",
603
- agents: ["codex"],
604
- expectedVersion,
605
- versionSource: "catalog",
606
- observedScope: "user",
607
- ...externalFlag,
608
- };
609
- }
610
- if (!fsVersion) {
611
- return {
612
- id,
613
- description: def.description,
614
- status: "not-installed",
615
- agents: ["codex"],
616
- expectedVersion,
617
- versionSource: "catalog",
618
- observedScope: "user",
619
- ...externalFlag,
620
- };
621
- }
622
- // External plugin short-circuit, same rationale as classifyClaudePlugin:
623
- // we defer authority to `codex plugin marketplace update` and never flag
624
- // update-available for upstream-owned plugins.
625
- if (def.external === true) {
626
- return {
627
- id,
628
- description: def.description,
629
- status: "installed",
630
- agents: ["codex"],
631
- currentVersion: fsVersion,
632
- versionSource: "catalog",
633
- observedScope: "user",
634
- ...externalFlag,
635
- };
636
- }
637
- if (!expectedVersion || fsVersion === expectedVersion) {
638
- return {
639
- id,
640
- description: def.description,
641
- status: "installed",
642
- agents: ["codex"],
643
- currentVersion: fsVersion,
644
- expectedVersion,
645
- versionSource: "catalog",
646
- observedScope: "user",
647
- ...externalFlag,
648
- };
649
- }
436
+ const status = enabled && fsVersion ? "installed" : "not-installed";
650
437
  return {
651
438
  id,
652
439
  description: def.description,
653
- status: "update-available",
440
+ status,
654
441
  agents: ["codex"],
655
- currentVersion: fsVersion,
656
- expectedVersion,
657
- versionSource: "catalog",
658
442
  observedScope: "user",
659
443
  ...externalFlag,
660
444
  };
@@ -702,92 +486,38 @@ function settingsPathForScope(scope, projectRoot, home) {
702
486
  return path.join(home, ".claude", "settings.json");
703
487
  return path.join(projectRoot, ".claude", "settings.json");
704
488
  }
705
- /** Walk every settings hook action, returning a map keyed by the action's
706
- * `_marker` sentinel value. Malformed sub-shapes are skipped silently. */
707
- function indexSettingsHooksByMarker(settings) {
708
- const out = new Map();
489
+ /** Returns the set of `_marker` sentinel values present in the settings
490
+ * `hooks` segment. Malformed sub-shapes are skipped silently. v1.19.0
491
+ * reduced this from a full {event, matcher, if, command} record (used for
492
+ * drift detection) to a presence-only Set — re-install is the update
493
+ * path now, so the scanner doesn't need to compare entry shapes. */
494
+ function indexSettingsMarkers(settings) {
495
+ const out = new Set();
709
496
  if (!settings || typeof settings !== "object" || Array.isArray(settings))
710
497
  return out;
711
498
  const hooksSeg = settings.hooks;
712
499
  if (!hooksSeg || typeof hooksSeg !== "object" || Array.isArray(hooksSeg))
713
500
  return out;
714
- for (const [event, blocks] of Object.entries(hooksSeg)) {
501
+ for (const blocks of Object.values(hooksSeg)) {
715
502
  if (!Array.isArray(blocks))
716
503
  continue;
717
504
  for (const block of blocks) {
718
505
  if (!block || typeof block !== "object" || Array.isArray(block))
719
506
  continue;
720
- const b = block;
721
- const matcher = typeof b.matcher === "string" ? b.matcher : undefined;
722
- const ifExpr = typeof b.if === "string" ? b.if : undefined;
723
- const actions = b.hooks;
507
+ const actions = block.hooks;
724
508
  if (!Array.isArray(actions))
725
509
  continue;
726
510
  for (const action of actions) {
727
511
  if (!action || typeof action !== "object" || Array.isArray(action))
728
512
  continue;
729
- const a = action;
730
- const marker = typeof a._marker === "string" ? a._marker : undefined;
731
- if (!marker)
732
- continue;
733
- out.set(marker, {
734
- event,
735
- matcher,
736
- ifExpr,
737
- command: typeof a.command === "string" ? a.command : undefined,
738
- });
513
+ const marker = action._marker;
514
+ if (typeof marker === "string")
515
+ out.add(marker);
739
516
  }
740
517
  }
741
518
  }
742
519
  return out;
743
520
  }
744
- /** Compute a coarse sha256 signature over a settings hook entry's drift-
745
- * relevant fields (event, matcher, if). Used to fall back to a single-
746
- * field comparison when the catalog hasn't been upgraded to expose
747
- * structured expectedMatcher / expectedEvent / expectedIf. */
748
- function signatureForSettingsEntry(entry) {
749
- const canonical = JSON.stringify({
750
- event: entry.event,
751
- matcher: entry.matcher ?? "",
752
- if: entry.ifExpr ?? "",
753
- });
754
- return createHash("sha256").update(canonical).digest("hex");
755
- }
756
- /** Returns true when the catalog's expectedHash is a wildcard sentinel
757
- * (empty string or the literal "any" placeholder). Wildcard means "no
758
- * drift expectation for this hook — trust marker presence." */
759
- function isWildcardExpectedHash(expectedHash) {
760
- return expectedHash === "" || expectedHash === WILDCARD_EXPECTED_HASH;
761
- }
762
- function detectHookDrift(catalogEntry, settingsEntry) {
763
- // Preferred drift path: structured expectations from catalog.
764
- if (typeof catalogEntry.expectedMatcher === "string" &&
765
- (settingsEntry.matcher ?? "") !== catalogEntry.expectedMatcher) {
766
- return true;
767
- }
768
- if (typeof catalogEntry.expectedEvent === "string" &&
769
- settingsEntry.event !== catalogEntry.expectedEvent) {
770
- return true;
771
- }
772
- if (typeof catalogEntry.expectedIf === "string" &&
773
- (settingsEntry.ifExpr ?? "") !== catalogEntry.expectedIf) {
774
- return true;
775
- }
776
- // Fallback drift signal via expectedHash. When the catalog hasn't been
777
- // populated with structured expected* fields (yet), expectedHash doubles
778
- // as a coarse signature: if non-empty and non-wildcard, the implementation
779
- // computes its own signature over the settings entry and treats any
780
- // divergence as drift. Production scan-catalog.ts can populate this with
781
- // a real settings-entry signature; until then, an explicit non-wildcard
782
- // placeholder in tests (e.g. "expected-new-matcher-signature") deliberately
783
- // triggers drift since it can never equal a sha256 hex digest.
784
- if (!isWildcardExpectedHash(catalogEntry.expectedHash)) {
785
- const sig = signatureForSettingsEntry(settingsEntry);
786
- if (sig !== catalogEntry.expectedHash)
787
- return true;
788
- }
789
- return false;
790
- }
791
521
  function scanHooks(scope, projectRoot, home, catalogHooks, warnings) {
792
522
  const settingsPath = settingsPathForScope(scope, projectRoot, home);
793
523
  let settingsRaw = null;
@@ -819,30 +549,13 @@ function scanHooks(scope, projectRoot, home, catalogHooks, warnings) {
819
549
  message: `Settings file unreadable or corrupt JSON: ${settingsPath}`,
820
550
  });
821
551
  }
822
- const byMarker = indexSettingsHooksByMarker(parsed);
552
+ const markers = indexSettingsMarkers(parsed);
823
553
  const out = [];
824
554
  for (const [name, def] of Object.entries(catalogHooks)) {
825
- const settingsEntry = byMarker.get(name);
826
- if (!settingsEntry) {
827
- out.push({
828
- name,
829
- description: def.description,
830
- status: "not-installed",
831
- expectedHash: def.expectedHash,
832
- observedScope: scope,
833
- });
834
- continue;
835
- }
836
- const drift = detectHookDrift(def, settingsEntry);
837
555
  out.push({
838
556
  name,
839
557
  description: def.description,
840
- status: drift ? "update-available" : "installed",
841
- // Surface a coarse current signature so the UI can show diff details
842
- // if it wants. The exact format is "sha256 of normalized settings
843
- // entry" — opaque to the UI, used only for drift detection.
844
- currentHash: signatureForSettingsEntry(settingsEntry),
845
- expectedHash: def.expectedHash,
558
+ status: markers.has(name) ? "installed" : "not-installed",
846
559
  observedScope: scope,
847
560
  });
848
561
  }
@@ -853,29 +566,20 @@ function scanHooks(scope, projectRoot, home, catalogHooks, warnings) {
853
566
  // injected — server.ts wires these up in production).
854
567
  // ---------------------------------------------------------------------------
855
568
  /** Default: run `claude plugins list --json` (no scope flag — the CLI
856
- * doesn't expose one) plus the `--available` variant, then filter the
857
- * installed records to the requested scope (and current projectRoot for
858
- * project-scope) client-side. Server.ts decides whether to pass this
859
- * function based on `which claude`. */
569
+ * doesn't expose one), then filter the installed records to the requested
570
+ * scope (and current projectRoot for project-scope) client-side. Server.ts
571
+ * decides whether to pass this function based on `which claude`.
572
+ *
573
+ * v1.19.0 dropped the parallel `--available` fetch — the scanner no longer
574
+ * compares versions, so there's no use for the upstream-live ref. */
860
575
  export async function defaultExecPluginList(scope = "user", projectRoot) {
861
- // Run both lookups in parallel via async exec so /api/state doesn't block
862
- // the event loop. `claude plugins list` can take several seconds on cold
863
- // marketplace fetches; sync exec would freeze heartbeats and other
864
- // concurrent /api requests. Note: `claude plugins list` does NOT support
865
- // `--user` / `--project`; each record carries its own `scope` field which
866
- // we filter on below.
867
- const [installedRes, availableRes] = await Promise.all([
868
- execAsync(`claude plugins list --json`, { encoding: "utf8" }),
869
- execAsync(`claude plugins list --available --json`, { encoding: "utf8" }),
870
- ]);
871
- const allInstalled = parseJsonArray(installedRes.stdout);
872
- // `claude plugins list --available --json` returns a wrapped object
873
- // `{ installed: [...], available: [...] }`, NOT a flat array. parseJsonArray
874
- // alone would return `[]` and silently lose every marketplace ref → the
875
- // scanner could never surface "update-available" from upstream-live data.
876
- // Pull `.available` out of the wrapper; tolerate the flat-array form too
877
- // in case Claude CLI's shape regresses.
878
- const available = extractAvailableArray(availableRes.stdout);
576
+ // Async exec so /api/state doesn't block the event loop. `claude plugins
577
+ // list` can take several seconds on cold marketplace fetches; sync exec
578
+ // would freeze heartbeats and other concurrent /api requests. Note:
579
+ // `claude plugins list` does NOT support `--user` / `--project`; each
580
+ // record carries its own `scope` field which we filter on below.
581
+ const { stdout } = await execAsync(`claude plugins list --json`, { encoding: "utf8" });
582
+ const allInstalled = parseJsonArray(stdout);
879
583
  const installed = allInstalled.filter((rec) => {
880
584
  if (!rec || typeof rec !== "object")
881
585
  return false;
@@ -891,7 +595,7 @@ export async function defaultExecPluginList(scope = "user", projectRoot) {
891
595
  }
892
596
  return true;
893
597
  });
894
- return { installed, available };
598
+ return { installed };
895
599
  }
896
600
  function parseJsonArray(text) {
897
601
  try {
@@ -902,24 +606,6 @@ function parseJsonArray(text) {
902
606
  return [];
903
607
  }
904
608
  }
905
- /** Pull the available-plugins array out of `claude plugins list --available
906
- * --json`'s response. Empirically the CLI returns `{ installed, available }`;
907
- * if a future version regresses to a flat array we keep working. Returns
908
- * `[]` on malformed JSON. */
909
- function extractAvailableArray(text) {
910
- try {
911
- const parsed = JSON.parse(text);
912
- if (Array.isArray(parsed))
913
- return parsed;
914
- if (parsed && typeof parsed === "object" && Array.isArray(parsed.available)) {
915
- return parsed.available;
916
- }
917
- return [];
918
- }
919
- catch {
920
- return [];
921
- }
922
- }
923
609
  function codexHome() {
924
610
  return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
925
611
  }