auriga-cli 1.18.4 → 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);
@@ -94,34 +91,23 @@ function dirExists(p) {
94
91
  return false;
95
92
  }
96
93
  }
97
- /**
98
- * Dedupe plugins by `id`, merging dual-Agent records into a single
99
- * multi-agent row. Aggregation rules:
100
- *
101
- * agents: union of all agent arrays for this id (claude before codex).
102
- * status: installed ⇔ every agent's record is installed
103
- * not-installed ⇔ every agent's record is not-installed
104
- * otherwise → update-available (partial install or any agent
105
- * with a pending update). One Apply covers all
106
- * gaps because the handler iterates `agents`.
107
- *
108
- * Non-status fields (description, currentVersion, expectedVersion,
109
- * versionSource) come from the first record we see. Today both sides report
110
- * the same description (catalog-driven) and the same versions for any
111
- * registry-pinned plugin, so this is safe; if a future divergence appears
112
- * we'll need a deliberate merge policy.
113
- */
114
94
  export function mergePluginsById(records) {
115
95
  const byId = new Map();
116
- const statusByIdPerAgent = new Map();
96
+ const perAgentByIdEntries = new Map();
117
97
  for (const rec of records) {
98
+ // Pre-merge records are per-agent: their `agents[]` array contains the
99
+ // single Agent this row was scanned for. The merge step below unions
100
+ // those into the final dual-Agent record.
101
+ const recAgent = rec.agents[0];
102
+ const perAgentEntry = recAgent
103
+ ? { agent: recAgent, status: rec.status }
104
+ : null;
118
105
  const existing = byId.get(rec.id);
119
106
  if (!existing) {
120
107
  byId.set(rec.id, { ...rec });
121
- statusByIdPerAgent.set(rec.id, [rec.status]);
108
+ perAgentByIdEntries.set(rec.id, perAgentEntry ? [perAgentEntry] : []);
122
109
  continue;
123
110
  }
124
- // Union agents preserving order: existing first, then any new ones.
125
111
  const seen = new Set(existing.agents);
126
112
  for (const a of rec.agents) {
127
113
  if (!seen.has(a)) {
@@ -129,45 +115,55 @@ export function mergePluginsById(records) {
129
115
  seen.add(a);
130
116
  }
131
117
  }
132
- statusByIdPerAgent.get(rec.id).push(rec.status);
118
+ if (perAgentEntry) {
119
+ perAgentByIdEntries.get(rec.id).push(perAgentEntry);
120
+ }
133
121
  }
134
- // Fold each id's per-agent status list into the aggregated status.
135
- for (const [id, statuses] of statusByIdPerAgent) {
122
+ for (const [id, perAgent] of perAgentByIdEntries) {
136
123
  const rec = byId.get(id);
137
124
  if (!rec)
138
125
  continue;
139
- rec.status = aggregateStatus(statuses);
126
+ const aggregated = aggregateStatus(perAgent);
127
+ rec.status = aggregated.status;
128
+ if (aggregated.missingAgents && aggregated.missingAgents.length > 0) {
129
+ rec.missingAgents = aggregated.missingAgents;
130
+ }
131
+ else {
132
+ delete rec.missingAgents;
133
+ }
140
134
  }
141
135
  return Array.from(byId.values());
142
136
  }
143
- function aggregateStatus(statuses) {
144
- if (statuses.length === 0)
145
- return "not-installed";
146
- if (statuses.every((s) => s === "installed"))
147
- return "installed";
148
- if (statuses.every((s) => s === "not-installed"))
149
- return "not-installed";
150
- // Anything else partial install, pending updates on any agent, mixed
151
- // falls through to update-available so a single Apply backfills the
152
- // missing pieces.
153
- return "update-available";
137
+ function aggregateStatus(records) {
138
+ if (records.length === 0)
139
+ return { status: "not-installed" };
140
+ if (records.every((r) => r.status === "installed"))
141
+ return { status: "installed" };
142
+ if (records.every((r) => r.status === "not-installed"))
143
+ return { status: "not-installed" };
144
+ // Mixed: dual-Agent plugin with some agents installed and some not.
145
+ // User-facing action is "install on the missing side".
146
+ const missingAgents = records
147
+ .filter((r) => r.status === "not-installed")
148
+ .map((r) => r.agent);
149
+ return { status: "partial-install", missingAgents };
154
150
  }
155
151
  // ---------------------------------------------------------------------------
156
152
  // Workflow
157
153
  // ---------------------------------------------------------------------------
158
- 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+\)/;
159
155
  function workflowPathsForScope(scope, projectRoot, home) {
160
156
  if (scope === "user") {
161
157
  return [path.join(home, ".claude", "CLAUDE.md")];
162
158
  }
163
- // Project: <proj>/CLAUDE.md preferred, .claude/CLAUDE.md as fallback.
164
- return [
165
- path.join(projectRoot, "CLAUDE.md"),
166
- path.join(projectRoot, ".claude", "CLAUDE.md"),
167
- ];
159
+ // Project: only `<proj>/CLAUDE.md` the auriga workflow installer
160
+ // (src/workflow.ts) writes here and never to `<proj>/.claude/CLAUDE.md`.
161
+ // The old fallback collapsed onto `$HOME/.claude/CLAUDE.md` when
162
+ // projectRoot === $HOME (user runs `web-ui` from home dir), leaking
163
+ // user-scope content into the project-scope row.
164
+ return [path.join(projectRoot, "CLAUDE.md")];
168
165
  }
169
- function scanWorkflow(scope, projectRoot, home, catalog, warnings) {
170
- const expectedVersion = catalog.workflowVersion;
166
+ function scanWorkflow(scope, projectRoot, home, warnings) {
171
167
  const candidates = workflowPathsForScope(scope, projectRoot, home);
172
168
  let content = null;
173
169
  for (const candidate of candidates) {
@@ -180,31 +176,27 @@ function scanWorkflow(scope, projectRoot, home, catalog, warnings) {
180
176
  }
181
177
  }
182
178
  if (content === null) {
183
- return { status: "not-installed", expectedVersion, observedScope: scope };
179
+ return { status: "not-installed", observedScope: scope };
184
180
  }
185
- // 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.
186
184
  for (const line of content.split(/\r?\n/)) {
187
- const m = WORKFLOW_HEADER_RE.exec(line);
188
- if (m) {
189
- const currentVersion = m[1];
190
- // Empty expectedVersion means scan-catalog couldn't extract the
191
- // shipped workflow's header (auriga-cli's own CLAUDE.md missing or
192
- // malformed at build time). Trust the installed version rather than
193
- // forcing a phantom "update-available" against the empty string.
194
- const status = !expectedVersion || currentVersion === expectedVersion ? "installed" : "update-available";
195
- return { status, expectedVersion, currentVersion, observedScope: scope };
185
+ if (WORKFLOW_HEADER_RE.test(line)) {
186
+ return { status: "installed", observedScope: scope };
196
187
  }
197
188
  if (line.trim().length > 0)
198
189
  break;
199
190
  }
200
- // CLAUDE.md exists but no recognizable auriga marker. Per spec degraded
201
- // path: status remains "installed" (don't clobber user content on apply)
202
- // and emit a workflow-unknown-version warning.
191
+ // CLAUDE.md exists but no recognizable auriga marker. The file is foreign
192
+ // not our workflow. Report `not-installed` honestly; the install path
193
+ // (src/workflow.ts) protects user content by backing it up to
194
+ // `CLAUDE.md.bak` (backup-once: never clobbers a prior .bak).
203
195
  warnings.push({
204
- code: "workflow-unknown-version",
205
- message: `CLAUDE.md present but no auriga-workflow header found; cannot determine installed version.`,
196
+ code: "workflow-foreign-claudemd",
197
+ message: `Foreign CLAUDE.md detected at the workflow path — no auriga-workflow header. Install will back up to CLAUDE.md.bak.`,
206
198
  });
207
- return { status: "installed", expectedVersion, observedScope: scope };
199
+ return { status: "not-installed", observedScope: scope };
208
200
  }
209
201
  // ---------------------------------------------------------------------------
210
202
  // Skills + recommendedSkills
@@ -214,50 +206,38 @@ function skillsRoot(scope, projectRoot, home) {
214
206
  return path.join(home, ".claude", "skills");
215
207
  return path.join(projectRoot, ".claude", "skills");
216
208
  }
217
- /** Classify a single skill by reading its SKILL.md from the scope's skills
218
- * dir. Returns the status + (when readable) the on-disk content hash. The
219
- * `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
220
212
  * missing/unreadable — the caller emits ONE skill-malformed warning per
221
213
  * scan. */
222
- function classifySkillByFile(name, expectedHash, rootDir, malformedSeen) {
214
+ function classifySkillByFile(name, rootDir, malformedSeen) {
223
215
  const skillDir = path.join(rootDir, name);
224
216
  const skillMd = path.join(skillDir, "SKILL.md");
225
- let buf;
226
217
  try {
227
- buf = fs.readFileSync(skillMd);
218
+ fs.readFileSync(skillMd);
219
+ return "installed";
228
220
  }
229
221
  catch {
230
- // SKILL.md unreadable. Two sub-cases:
231
- // (a) skill dir also missing → simply not installed.
232
- // (b) skill dir present but SKILL.md missing → malformed; row stays
233
- // "installed" so the user can repair, plus a warning.
234
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.
235
225
  malformedSeen.add(name);
236
- return { status: "installed" };
226
+ return "installed";
237
227
  }
238
- return { status: "not-installed" };
239
- }
240
- const currentHash = createHash("sha256").update(buf).digest("hex");
241
- if (expectedHash === "" ||
242
- expectedHash === WILDCARD_EXPECTED_HASH ||
243
- currentHash === expectedHash) {
244
- return { status: "installed", currentHash };
228
+ return "not-installed";
245
229
  }
246
- return { status: "update-available", currentHash };
247
230
  }
248
231
  function scanSkills(scope, projectRoot, home, catalogSkills, _recommended, warnings) {
249
232
  const rootDir = skillsRoot(scope, projectRoot, home);
250
233
  const malformed = new Set();
251
234
  const out = [];
252
235
  for (const [name, entry] of Object.entries(catalogSkills)) {
253
- const cls = classifySkillByFile(name, entry.expectedHash, rootDir, malformed);
254
236
  out.push({
255
237
  name,
256
238
  description: entry.description,
257
- status: cls.status,
239
+ status: classifySkillByFile(name, rootDir, malformed),
258
240
  isWorkflow: entry.isWorkflow,
259
- currentHash: cls.currentHash,
260
- expectedHash: entry.expectedHash,
261
241
  observedScope: scope,
262
242
  });
263
243
  }
@@ -274,14 +254,11 @@ function scanRecommendedSkills(scope, projectRoot, home, catalogRec, warnings) {
274
254
  const malformed = new Set();
275
255
  const out = [];
276
256
  for (const [name, entry] of Object.entries(catalogRec)) {
277
- const cls = classifySkillByFile(name, entry.expectedHash, rootDir, malformed);
278
257
  out.push({
279
258
  name,
280
259
  description: entry.description,
281
- status: cls.status,
260
+ status: classifySkillByFile(name, rootDir, malformed),
282
261
  isWorkflow: false,
283
- currentHash: cls.currentHash,
284
- expectedHash: entry.expectedHash,
285
262
  observedScope: scope,
286
263
  });
287
264
  }
@@ -299,15 +276,6 @@ function scanRecommendedSkills(scope, projectRoot, home, catalogRec, warnings) {
299
276
  function filterPluginsByAgent(catalogPlugins, agent) {
300
277
  return Object.entries(catalogPlugins).filter(([, def]) => def.agents.includes(agent));
301
278
  }
302
- /** Normalize a version ref: "v1.2.3" → "1.2.3", "1.2.3" → "1.2.3".
303
- * Anything that is not a strict semver-like triple is returned as-is so the
304
- * caller can detect "this is a branch / tag, not a comparable version". */
305
- function parseRef(ref) {
306
- if (typeof ref !== "string" || ref.length === 0)
307
- return null;
308
- const m = /^v?(\d+\.\d+\.\d+(?:[-+][\w.]+)?)$/.exec(ref);
309
- return m ? m[1] : null;
310
- }
311
279
  async function scanClaudePlugins(scope, entries, execPluginList, warnings) {
312
280
  if (entries.length === 0)
313
281
  return [];
@@ -317,7 +285,7 @@ async function scanClaudePlugins(scope, entries, execPluginList, warnings) {
317
285
  if (!execPluginList) {
318
286
  warnings.push({
319
287
  code: "claude-cli-missing",
320
- 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.",
321
289
  });
322
290
  return entries.map(([id, def]) => degradedClaudeRow(id, def, scope));
323
291
  }
@@ -335,32 +303,22 @@ async function scanClaudePlugins(scope, entries, execPluginList, warnings) {
335
303
  // claude plugins list emits ids in `<plugin>@<marketplace>` form (e.g.
336
304
  // `auriga-go@auriga-cli`). The auriga-cli catalog tracks plugins by bare
337
305
  // name. Index both forms so lookups succeed regardless of which side the
338
- // suffix is on. Same trick for availables — note that the `--available`
339
- // payload uses `pluginId` rather than `id`, so accept both as the key.
340
- const indexBoth = (map, item) => {
306
+ // suffix is on.
307
+ const installedById = new Map();
308
+ for (const item of payload.installed ?? []) {
341
309
  if (!item || typeof item !== "object")
342
- return;
343
- const key = typeof item.id === "string"
344
- ? item.id
345
- : typeof item.pluginId === "string"
346
- ? item.pluginId
347
- : null;
310
+ continue;
311
+ const key = typeof item.id === "string" ? item.id : null;
348
312
  if (!key)
349
- return;
350
- map.set(key, item);
313
+ continue;
314
+ installedById.set(key, item);
351
315
  const at = key.indexOf("@");
352
316
  if (at > 0)
353
- map.set(key.slice(0, at), item);
354
- };
355
- const installedById = new Map();
356
- for (const item of payload.installed ?? [])
357
- indexBoth(installedById, item);
358
- const availableById = new Map();
359
- for (const item of payload.available ?? [])
360
- indexBoth(availableById, item);
317
+ installedById.set(key.slice(0, at), item);
318
+ }
361
319
  const out = [];
362
320
  for (const [id, def] of entries) {
363
- out.push(classifyClaudePlugin(id, def, installedById.get(id), availableById.get(id), scope));
321
+ out.push(classifyClaudePlugin(id, def, installedById.get(id), scope));
364
322
  }
365
323
  return out;
366
324
  }
@@ -370,107 +328,21 @@ function degradedClaudeRow(id, def, scope) {
370
328
  description: def.description,
371
329
  status: "not-installed",
372
330
  agents: ["claude"],
373
- expectedVersion: def.expectedVersion,
374
- versionSource: "upstream-live",
375
331
  observedScope: scope,
376
332
  ...(def.external === true ? { external: true } : {}),
377
333
  };
378
334
  }
379
- function classifyClaudePlugin(id, def, installed, available, scope) {
380
- // `external` propagates onto every return below so the UI can surface the
381
- // EXTERNAL badge regardless of install state.
335
+ function classifyClaudePlugin(id, def, installed, scope) {
382
336
  const externalFlag = def.external === true ? { external: true } : {};
383
- if (!installed || typeof installed.version !== "string") {
384
- return {
385
- id,
386
- description: def.description,
387
- status: "not-installed",
388
- agents: ["claude"],
389
- expectedVersion: typeof available?.source?.ref === "string" ? available.source.ref : def.expectedVersion,
390
- versionSource: "upstream-live",
391
- observedScope: scope,
392
- ...externalFlag,
393
- };
394
- }
395
- const installedVersion = installed.version;
396
- // External plugin short-circuit: we don't own these, so we don't claim
397
- // authority on "what version they should be at". `claude plugins update`
398
- // is the right channel — the scanner just confirms presence. Status stays
399
- // "installed" even if installed.version differs from any signal we have.
400
- // The catalog deliberately omits `expectedVersion` for externals, but we
401
- // double-down with this guard so a future regression that accidentally
402
- // populates expectedVersion still can't flip externals to update-available.
403
- if (def.external === true) {
404
- return {
405
- id,
406
- description: def.description,
407
- status: "installed",
408
- agents: ["claude"],
409
- currentVersion: installedVersion,
410
- // Don't surface any "expected" on externals — the upstream tool owns
411
- // the version conversation.
412
- versionSource: "upstream-live",
413
- observedScope: scope,
414
- ...externalFlag,
415
- };
416
- }
417
- const ref = available?.source?.ref;
418
- const normalizedAvailable = parseRef(typeof ref === "string" ? ref : undefined);
419
- const normalizedInstalled = parseRef(installedVersion);
420
- // Pick the comparison target. The marketplace-live ref wins when it's a
421
- // parseable semver — that's the freshest signal. Otherwise fall back to
422
- // the build-time-baked `def.expectedVersion` (populated from
423
- // plugins/<name>/.claude-plugin/plugin.json by scan-catalog for owned
424
- // plugins). Without the fallback, the common upgrade case is invisible:
425
- // `claude plugins list --available --json` excludes already-installed
426
- // plugins from `.available[]`, so for any plugin the user already has,
427
- // `ref` is undefined and the scanner can't tell whether a newer version
428
- // ships in the marketplace.
429
- const hasLiveRef = normalizedAvailable !== null && typeof ref === "string";
430
- const expectedRaw = hasLiveRef ? ref : def.expectedVersion;
431
- const expectedNormalized = hasLiveRef
432
- ? normalizedAvailable
433
- : parseRef(def.expectedVersion);
434
- const versionSource = hasLiveRef
435
- ? "upstream-live"
436
- : "catalog";
437
- // Fallback rules (no comparable expected version, or unknown installed):
438
- // - installed version "unknown" → trust it's installed.
439
- // - effective expected is null (branch ref + no baked version) → trust installed.
440
- if (installedVersion === "unknown" || expectedNormalized === null) {
441
- return {
442
- id,
443
- description: def.description,
444
- status: "installed",
445
- agents: ["claude"],
446
- currentVersion: installedVersion,
447
- expectedVersion: expectedRaw,
448
- versionSource,
449
- observedScope: scope,
450
- ...externalFlag,
451
- };
452
- }
453
- if (normalizedInstalled !== null && normalizedInstalled === expectedNormalized) {
454
- return {
455
- id,
456
- description: def.description,
457
- status: "installed",
458
- agents: ["claude"],
459
- currentVersion: installedVersion,
460
- expectedVersion: expectedRaw,
461
- versionSource,
462
- observedScope: scope,
463
- ...externalFlag,
464
- };
465
- }
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";
466
341
  return {
467
342
  id,
468
343
  description: def.description,
469
- status: "update-available",
344
+ status,
470
345
  agents: ["claude"],
471
- currentVersion: installedVersion,
472
- expectedVersion: expectedRaw,
473
- versionSource,
474
346
  observedScope: scope,
475
347
  ...externalFlag,
476
348
  };
@@ -521,7 +393,7 @@ async function scanCodexPlugins(entries, readCodexConfig, readCodexPluginsDir, w
521
393
  // both emit `<plugin>@<marketplace>` keys (e.g. "auriga-go@auriga-cli").
522
394
  // Without dual indexing every dual-Agent plugin reports `not-installed` on
523
395
  // the Codex side, which `mergePluginsById` then folds into a permanent
524
- // `update-available` even when both sides are genuinely installed.
396
+ // `partial-install` even when both sides are genuinely installed.
525
397
  const lookupEnabled = (catalogId) => {
526
398
  if (enabledIds.has(catalogId))
527
399
  return true;
@@ -555,75 +427,18 @@ function degradedCodexRow(id, def) {
555
427
  description: def.description,
556
428
  status: "not-installed",
557
429
  agents: ["codex"],
558
- expectedVersion: def.expectedVersion,
559
- versionSource: "catalog",
560
430
  observedScope: "user",
561
431
  ...(def.external === true ? { external: true } : {}),
562
432
  };
563
433
  }
564
434
  function classifyCodexPlugin(id, def, enabled, fsVersion) {
565
- const expectedVersion = def.expectedVersion;
566
435
  const externalFlag = def.external === true ? { external: true } : {};
567
- if (!enabled) {
568
- return {
569
- id,
570
- description: def.description,
571
- status: "not-installed",
572
- agents: ["codex"],
573
- expectedVersion,
574
- versionSource: "catalog",
575
- observedScope: "user",
576
- ...externalFlag,
577
- };
578
- }
579
- if (!fsVersion) {
580
- return {
581
- id,
582
- description: def.description,
583
- status: "not-installed",
584
- agents: ["codex"],
585
- expectedVersion,
586
- versionSource: "catalog",
587
- observedScope: "user",
588
- ...externalFlag,
589
- };
590
- }
591
- // External plugin short-circuit, same rationale as classifyClaudePlugin:
592
- // we defer authority to `codex plugin marketplace update` and never flag
593
- // update-available for upstream-owned plugins.
594
- if (def.external === true) {
595
- return {
596
- id,
597
- description: def.description,
598
- status: "installed",
599
- agents: ["codex"],
600
- currentVersion: fsVersion,
601
- versionSource: "catalog",
602
- observedScope: "user",
603
- ...externalFlag,
604
- };
605
- }
606
- if (!expectedVersion || fsVersion === expectedVersion) {
607
- return {
608
- id,
609
- description: def.description,
610
- status: "installed",
611
- agents: ["codex"],
612
- currentVersion: fsVersion,
613
- expectedVersion,
614
- versionSource: "catalog",
615
- observedScope: "user",
616
- ...externalFlag,
617
- };
618
- }
436
+ const status = enabled && fsVersion ? "installed" : "not-installed";
619
437
  return {
620
438
  id,
621
439
  description: def.description,
622
- status: "update-available",
440
+ status,
623
441
  agents: ["codex"],
624
- currentVersion: fsVersion,
625
- expectedVersion,
626
- versionSource: "catalog",
627
442
  observedScope: "user",
628
443
  ...externalFlag,
629
444
  };
@@ -671,92 +486,38 @@ function settingsPathForScope(scope, projectRoot, home) {
671
486
  return path.join(home, ".claude", "settings.json");
672
487
  return path.join(projectRoot, ".claude", "settings.json");
673
488
  }
674
- /** Walk every settings hook action, returning a map keyed by the action's
675
- * `_marker` sentinel value. Malformed sub-shapes are skipped silently. */
676
- function indexSettingsHooksByMarker(settings) {
677
- 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();
678
496
  if (!settings || typeof settings !== "object" || Array.isArray(settings))
679
497
  return out;
680
498
  const hooksSeg = settings.hooks;
681
499
  if (!hooksSeg || typeof hooksSeg !== "object" || Array.isArray(hooksSeg))
682
500
  return out;
683
- for (const [event, blocks] of Object.entries(hooksSeg)) {
501
+ for (const blocks of Object.values(hooksSeg)) {
684
502
  if (!Array.isArray(blocks))
685
503
  continue;
686
504
  for (const block of blocks) {
687
505
  if (!block || typeof block !== "object" || Array.isArray(block))
688
506
  continue;
689
- const b = block;
690
- const matcher = typeof b.matcher === "string" ? b.matcher : undefined;
691
- const ifExpr = typeof b.if === "string" ? b.if : undefined;
692
- const actions = b.hooks;
507
+ const actions = block.hooks;
693
508
  if (!Array.isArray(actions))
694
509
  continue;
695
510
  for (const action of actions) {
696
511
  if (!action || typeof action !== "object" || Array.isArray(action))
697
512
  continue;
698
- const a = action;
699
- const marker = typeof a._marker === "string" ? a._marker : undefined;
700
- if (!marker)
701
- continue;
702
- out.set(marker, {
703
- event,
704
- matcher,
705
- ifExpr,
706
- command: typeof a.command === "string" ? a.command : undefined,
707
- });
513
+ const marker = action._marker;
514
+ if (typeof marker === "string")
515
+ out.add(marker);
708
516
  }
709
517
  }
710
518
  }
711
519
  return out;
712
520
  }
713
- /** Compute a coarse sha256 signature over a settings hook entry's drift-
714
- * relevant fields (event, matcher, if). Used to fall back to a single-
715
- * field comparison when the catalog hasn't been upgraded to expose
716
- * structured expectedMatcher / expectedEvent / expectedIf. */
717
- function signatureForSettingsEntry(entry) {
718
- const canonical = JSON.stringify({
719
- event: entry.event,
720
- matcher: entry.matcher ?? "",
721
- if: entry.ifExpr ?? "",
722
- });
723
- return createHash("sha256").update(canonical).digest("hex");
724
- }
725
- /** Returns true when the catalog's expectedHash is a wildcard sentinel
726
- * (empty string or the literal "any" placeholder). Wildcard means "no
727
- * drift expectation for this hook — trust marker presence." */
728
- function isWildcardExpectedHash(expectedHash) {
729
- return expectedHash === "" || expectedHash === WILDCARD_EXPECTED_HASH;
730
- }
731
- function detectHookDrift(catalogEntry, settingsEntry) {
732
- // Preferred drift path: structured expectations from catalog.
733
- if (typeof catalogEntry.expectedMatcher === "string" &&
734
- (settingsEntry.matcher ?? "") !== catalogEntry.expectedMatcher) {
735
- return true;
736
- }
737
- if (typeof catalogEntry.expectedEvent === "string" &&
738
- settingsEntry.event !== catalogEntry.expectedEvent) {
739
- return true;
740
- }
741
- if (typeof catalogEntry.expectedIf === "string" &&
742
- (settingsEntry.ifExpr ?? "") !== catalogEntry.expectedIf) {
743
- return true;
744
- }
745
- // Fallback drift signal via expectedHash. When the catalog hasn't been
746
- // populated with structured expected* fields (yet), expectedHash doubles
747
- // as a coarse signature: if non-empty and non-wildcard, the implementation
748
- // computes its own signature over the settings entry and treats any
749
- // divergence as drift. Production scan-catalog.ts can populate this with
750
- // a real settings-entry signature; until then, an explicit non-wildcard
751
- // placeholder in tests (e.g. "expected-new-matcher-signature") deliberately
752
- // triggers drift since it can never equal a sha256 hex digest.
753
- if (!isWildcardExpectedHash(catalogEntry.expectedHash)) {
754
- const sig = signatureForSettingsEntry(settingsEntry);
755
- if (sig !== catalogEntry.expectedHash)
756
- return true;
757
- }
758
- return false;
759
- }
760
521
  function scanHooks(scope, projectRoot, home, catalogHooks, warnings) {
761
522
  const settingsPath = settingsPathForScope(scope, projectRoot, home);
762
523
  let settingsRaw = null;
@@ -788,30 +549,13 @@ function scanHooks(scope, projectRoot, home, catalogHooks, warnings) {
788
549
  message: `Settings file unreadable or corrupt JSON: ${settingsPath}`,
789
550
  });
790
551
  }
791
- const byMarker = indexSettingsHooksByMarker(parsed);
552
+ const markers = indexSettingsMarkers(parsed);
792
553
  const out = [];
793
554
  for (const [name, def] of Object.entries(catalogHooks)) {
794
- const settingsEntry = byMarker.get(name);
795
- if (!settingsEntry) {
796
- out.push({
797
- name,
798
- description: def.description,
799
- status: "not-installed",
800
- expectedHash: def.expectedHash,
801
- observedScope: scope,
802
- });
803
- continue;
804
- }
805
- const drift = detectHookDrift(def, settingsEntry);
806
555
  out.push({
807
556
  name,
808
557
  description: def.description,
809
- status: drift ? "update-available" : "installed",
810
- // Surface a coarse current signature so the UI can show diff details
811
- // if it wants. The exact format is "sha256 of normalized settings
812
- // entry" — opaque to the UI, used only for drift detection.
813
- currentHash: signatureForSettingsEntry(settingsEntry),
814
- expectedHash: def.expectedHash,
558
+ status: markers.has(name) ? "installed" : "not-installed",
815
559
  observedScope: scope,
816
560
  });
817
561
  }
@@ -822,29 +566,20 @@ function scanHooks(scope, projectRoot, home, catalogHooks, warnings) {
822
566
  // injected — server.ts wires these up in production).
823
567
  // ---------------------------------------------------------------------------
824
568
  /** Default: run `claude plugins list --json` (no scope flag — the CLI
825
- * doesn't expose one) plus the `--available` variant, then filter the
826
- * installed records to the requested scope (and current projectRoot for
827
- * project-scope) client-side. Server.ts decides whether to pass this
828
- * 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. */
829
575
  export async function defaultExecPluginList(scope = "user", projectRoot) {
830
- // Run both lookups in parallel via async exec so /api/state doesn't block
831
- // the event loop. `claude plugins list` can take several seconds on cold
832
- // marketplace fetches; sync exec would freeze heartbeats and other
833
- // concurrent /api requests. Note: `claude plugins list` does NOT support
834
- // `--user` / `--project`; each record carries its own `scope` field which
835
- // we filter on below.
836
- const [installedRes, availableRes] = await Promise.all([
837
- execAsync(`claude plugins list --json`, { encoding: "utf8" }),
838
- execAsync(`claude plugins list --available --json`, { encoding: "utf8" }),
839
- ]);
840
- const allInstalled = parseJsonArray(installedRes.stdout);
841
- // `claude plugins list --available --json` returns a wrapped object
842
- // `{ installed: [...], available: [...] }`, NOT a flat array. parseJsonArray
843
- // alone would return `[]` and silently lose every marketplace ref → the
844
- // scanner could never surface "update-available" from upstream-live data.
845
- // Pull `.available` out of the wrapper; tolerate the flat-array form too
846
- // in case Claude CLI's shape regresses.
847
- 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);
848
583
  const installed = allInstalled.filter((rec) => {
849
584
  if (!rec || typeof rec !== "object")
850
585
  return false;
@@ -860,7 +595,7 @@ export async function defaultExecPluginList(scope = "user", projectRoot) {
860
595
  }
861
596
  return true;
862
597
  });
863
- return { installed, available };
598
+ return { installed };
864
599
  }
865
600
  function parseJsonArray(text) {
866
601
  try {
@@ -871,24 +606,6 @@ function parseJsonArray(text) {
871
606
  return [];
872
607
  }
873
608
  }
874
- /** Pull the available-plugins array out of `claude plugins list --available
875
- * --json`'s response. Empirically the CLI returns `{ installed, available }`;
876
- * if a future version regresses to a flat array we keep working. Returns
877
- * `[]` on malformed JSON. */
878
- function extractAvailableArray(text) {
879
- try {
880
- const parsed = JSON.parse(text);
881
- if (Array.isArray(parsed))
882
- return parsed;
883
- if (parsed && typeof parsed === "object" && Array.isArray(parsed.available)) {
884
- return parsed.available;
885
- }
886
- return [];
887
- }
888
- catch {
889
- return [];
890
- }
891
- }
892
609
  function codexHome() {
893
610
  return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
894
611
  }