auriga-cli 1.17.0 → 1.18.2

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,9 +1,19 @@
1
- // scanState — read the user's project + (optionally) live CLIs to produce
2
- // a tri-state report per category. Pure-ish: all external I/O is either
3
- // injected via `ScanOptions` (for tests) or done through the default
4
- // filesystem / child-process implementations declared at the bottom of
5
- // this file. See docs/architecture/web-ui.md §6.3 + §10.4 for the judgment rules
6
- // and tests/state.test.ts for the full behavioral contract.
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):
5
+ //
6
+ // Workflow: ~/.claude/CLAUDE.md (user scope)
7
+ // <proj>/CLAUDE.md, fallback .claude/CLAUDE.md (project scope)
8
+ // Skills: ~/.claude/skills/<name>/SKILL.md (user scope)
9
+ // <proj>/.claude/skills/<name>/SKILL.md (project scope)
10
+ // Plugins(Claude): execPluginList(scope) + settings.json enabledPlugins
11
+ // Plugins(Codex): ~/.codex/config.toml + ~/.codex/plugins/cache (user only)
12
+ // Hooks: <scope>/.claude/settings.json `hooks` segment, matched by _marker
13
+ //
14
+ // External I/O is either injected via ScanOptions (tests) or done through the
15
+ // default implementations at the bottom of the file (server.ts production
16
+ // wiring). See tests/state.test.ts for the full behavioral contract.
7
17
  import { createHash } from "node:crypto";
8
18
  import { exec as execCallback } from "node:child_process";
9
19
  import { promisify } from "node:util";
@@ -12,14 +22,19 @@ import fs from "node:fs";
12
22
  import os from "node:os";
13
23
  import path from "node:path";
14
24
  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";
15
31
  /**
16
32
  * Shorten an absolute path by replacing the user's $HOME with `~`. Avoids
17
33
  * leaking the full username in screenshots and keeps the TopBar label
18
34
  * readable. Falls back to the original path when HOME is unset or the path
19
35
  * doesn't sit under it.
20
36
  */
21
- function homeReducedPath(p) {
22
- const home = os.homedir();
37
+ function homeReducedPath(p, home) {
23
38
  if (!home)
24
39
  return p;
25
40
  if (p === home)
@@ -30,19 +45,39 @@ function homeReducedPath(p) {
30
45
  }
31
46
  return p;
32
47
  }
48
+ const DEFAULT_SCOPES = {
49
+ workflow: "project",
50
+ skills: "project",
51
+ plugins: "user",
52
+ hooks: "user",
53
+ };
33
54
  export async function scanState(projectRoot, catalog, opts = {}) {
34
55
  const warnings = [];
35
- const workflow = scanWorkflow(projectRoot, catalog);
36
- const lock = readSkillsLock(projectRoot);
37
- const skills = scanSkills(catalog.skills, lock);
38
- const recommendedSkills = scanRecommendedSkills(catalog.recommendedSkills, lock);
39
- const hooks = scanHooks(projectRoot, catalog.hooks);
56
+ const home = opts.homeDir ?? os.homedir();
57
+ const scopes = { ...DEFAULT_SCOPES, ...(opts.scopes ?? {}) };
58
+ const workflow = scanWorkflow(scopes.workflow, projectRoot, home, catalog, warnings);
59
+ const skills = scanSkills(scopes.skills, projectRoot, home, catalog.skills,
60
+ /* recommended */ false, warnings);
61
+ const recommendedSkills = scanRecommendedSkills(scopes.skills, projectRoot, home, catalog.recommendedSkills, warnings);
62
+ const hooks = scanHooks(scopes.hooks, projectRoot, home, catalog.hooks, warnings);
40
63
  const claudePluginEntries = filterPluginsByAgent(catalog.plugins, "claude");
41
64
  const codexPluginEntries = filterPluginsByAgent(catalog.plugins, "codex");
42
- const claudePlugins = await scanClaudePlugins(claudePluginEntries, opts.execPluginList, warnings);
65
+ const claudePlugins = await scanClaudePlugins(scopes.plugins, claudePluginEntries, opts.execPluginList, warnings);
43
66
  const codexPlugins = await scanCodexPlugins(codexPluginEntries, opts.readCodexConfig ?? defaultReadCodexConfig, opts.readCodexPluginsDir ?? defaultReadCodexPluginsDir, warnings);
67
+ // Aggregate `claude-code-not-installed`: emit ONCE if neither ~/.claude/
68
+ // nor <proj>/.claude/ exists, regardless of how many user-scope categories
69
+ // were scanned. We check after the per-category scans so we can detect the
70
+ // condition just once at the end.
71
+ if (!dirExists(path.join(home, ".claude")) && !dirExists(path.join(projectRoot, ".claude"))) {
72
+ if (!warnings.some((w) => w.code === "claude-code-not-installed")) {
73
+ warnings.push({
74
+ code: "claude-code-not-installed",
75
+ message: "No Claude Code install detected (~/.claude/ and <project>/.claude/ both absent). Install Claude Code first.",
76
+ });
77
+ }
78
+ }
44
79
  return {
45
- cwd: homeReducedPath(projectRoot),
80
+ cwd: homeReducedPath(projectRoot, home),
46
81
  workflow,
47
82
  skills,
48
83
  recommendedSkills,
@@ -51,6 +86,14 @@ export async function scanState(projectRoot, catalog, opts = {}) {
51
86
  warnings,
52
87
  };
53
88
  }
89
+ function dirExists(p) {
90
+ try {
91
+ return fs.statSync(p).isDirectory();
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
54
97
  /**
55
98
  * Dedupe plugins by `id`, merging dual-Agent records into a single
56
99
  * multi-agent row. Aggregation rules:
@@ -106,81 +149,108 @@ function aggregateStatus(statuses) {
106
149
  return "not-installed";
107
150
  // Anything else — partial install, pending updates on any agent, mixed
108
151
  // — falls through to update-available so a single Apply backfills the
109
- // missing pieces. This is the boundary the user asked for: "一边装一边
110
- // 没装" surfaces as a yellow UPDATE pill rather than a misleading green.
152
+ // missing pieces.
111
153
  return "update-available";
112
154
  }
113
155
  // ---------------------------------------------------------------------------
114
156
  // Workflow
115
157
  // ---------------------------------------------------------------------------
116
158
  const WORKFLOW_HEADER_RE = /^#\s+auriga\s+Workflow\s*\(v(\d+\.\d+\.\d+)\)/;
117
- function scanWorkflow(projectRoot, catalog) {
159
+ function workflowPathsForScope(scope, projectRoot, home) {
160
+ if (scope === "user") {
161
+ return [path.join(home, ".claude", "CLAUDE.md")];
162
+ }
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
+ ];
168
+ }
169
+ function scanWorkflow(scope, projectRoot, home, catalog, warnings) {
118
170
  const expectedVersion = catalog.workflowVersion;
119
- const claudeMdPath = path.join(projectRoot, "CLAUDE.md");
120
- let content;
121
- try {
122
- content = fs.readFileSync(claudeMdPath, "utf8");
171
+ const candidates = workflowPathsForScope(scope, projectRoot, home);
172
+ let content = null;
173
+ for (const candidate of candidates) {
174
+ try {
175
+ content = fs.readFileSync(candidate, "utf8");
176
+ break;
177
+ }
178
+ catch {
179
+ // try next candidate
180
+ }
123
181
  }
124
- catch {
125
- return { status: "not-installed", expectedVersion };
182
+ if (content === null) {
183
+ return { status: "not-installed", expectedVersion, observedScope: scope };
126
184
  }
127
- // Match the canonical header anywhere in the first few lines; the spec
128
- // and tests put it on the first line, but tolerate leading blank lines /
129
- // BOM by scanning every line until we either find a match or run out.
185
+ // Walk the first non-blank lines looking for the auriga header.
130
186
  for (const line of content.split(/\r?\n/)) {
131
187
  const m = WORKFLOW_HEADER_RE.exec(line);
132
188
  if (m) {
133
189
  const currentVersion = m[1];
134
- const status = currentVersion === expectedVersion ? "installed" : "update-available";
135
- return { status, expectedVersion, currentVersion };
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 };
136
196
  }
137
- // Bail at the first non-blank line — the header must be a top heading.
138
197
  if (line.trim().length > 0)
139
198
  break;
140
199
  }
141
- // File exists but no parseable header assumption #1 in state.test.ts:
142
- // prefer reinstall over false-positive "installed" with unknown version.
143
- return { status: "not-installed", expectedVersion };
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.
203
+ warnings.push({
204
+ code: "workflow-unknown-version",
205
+ message: `CLAUDE.md present but no auriga-workflow header found; cannot determine installed version.`,
206
+ });
207
+ return { status: "installed", expectedVersion, observedScope: scope };
144
208
  }
145
- /** Return the parsed lockfile, or null if absent / unparseable. The "null"
146
- * path is the degraded mode: skills still show up as catalog rows but their
147
- * `currentHash` is undefined and they are reported as not-installed. */
148
- function readSkillsLock(projectRoot) {
149
- const lockPath = path.join(projectRoot, "skills-lock.json");
150
- let text;
209
+ // ---------------------------------------------------------------------------
210
+ // Skills + recommendedSkills
211
+ // ---------------------------------------------------------------------------
212
+ function skillsRoot(scope, projectRoot, home) {
213
+ if (scope === "user")
214
+ return path.join(home, ".claude", "skills");
215
+ return path.join(projectRoot, ".claude", "skills");
216
+ }
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
220
+ * missing/unreadable — the caller emits ONE skill-malformed warning per
221
+ * scan. */
222
+ function classifySkillByFile(name, expectedHash, rootDir, malformedSeen) {
223
+ const skillDir = path.join(rootDir, name);
224
+ const skillMd = path.join(skillDir, "SKILL.md");
225
+ let buf;
151
226
  try {
152
- text = fs.readFileSync(lockPath, "utf8");
227
+ buf = fs.readFileSync(skillMd);
153
228
  }
154
229
  catch {
155
- return null;
156
- }
157
- try {
158
- const parsed = JSON.parse(text);
159
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
160
- return parsed;
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
+ if (dirExists(skillDir)) {
235
+ malformedSeen.add(name);
236
+ return { status: "installed" };
161
237
  }
162
- return null;
163
- }
164
- catch {
165
- // Per state.test.ts "corrupt skills-lock" case: don't throw; degrade.
166
- return null;
167
- }
168
- }
169
- function classifySkill(expectedHash, lock, name) {
170
- const entry = lock?.skills?.[name];
171
- const currentHash = entry?.computedHash;
172
- if (typeof currentHash !== "string" || currentHash.length === 0) {
173
238
  return { status: "not-installed" };
174
239
  }
175
- if (currentHash === expectedHash) {
240
+ const currentHash = createHash("sha256").update(buf).digest("hex");
241
+ if (expectedHash === "" ||
242
+ expectedHash === WILDCARD_EXPECTED_HASH ||
243
+ currentHash === expectedHash) {
176
244
  return { status: "installed", currentHash };
177
245
  }
178
246
  return { status: "update-available", currentHash };
179
247
  }
180
- function scanSkills(catalogSkills, lock) {
248
+ function scanSkills(scope, projectRoot, home, catalogSkills, _recommended, warnings) {
249
+ const rootDir = skillsRoot(scope, projectRoot, home);
250
+ const malformed = new Set();
181
251
  const out = [];
182
252
  for (const [name, entry] of Object.entries(catalogSkills)) {
183
- const cls = classifySkill(entry.expectedHash, lock, name);
253
+ const cls = classifySkillByFile(name, entry.expectedHash, rootDir, malformed);
184
254
  out.push({
185
255
  name,
186
256
  description: entry.description,
@@ -188,21 +258,37 @@ function scanSkills(catalogSkills, lock) {
188
258
  isWorkflow: entry.isWorkflow,
189
259
  currentHash: cls.currentHash,
190
260
  expectedHash: entry.expectedHash,
261
+ observedScope: scope,
262
+ });
263
+ }
264
+ if (malformed.size > 0) {
265
+ warnings.push({
266
+ code: "skill-malformed",
267
+ message: `Skill directory present but SKILL.md missing or unreadable: ${[...malformed].join(", ")}`,
191
268
  });
192
269
  }
193
270
  return out;
194
271
  }
195
- function scanRecommendedSkills(catalogRec, lock) {
272
+ function scanRecommendedSkills(scope, projectRoot, home, catalogRec, warnings) {
273
+ const rootDir = skillsRoot(scope, projectRoot, home);
274
+ const malformed = new Set();
196
275
  const out = [];
197
276
  for (const [name, entry] of Object.entries(catalogRec)) {
198
- const cls = classifySkill(entry.expectedHash, lock, name);
277
+ const cls = classifySkillByFile(name, entry.expectedHash, rootDir, malformed);
199
278
  out.push({
200
279
  name,
201
280
  description: entry.description,
202
281
  status: cls.status,
203
- isWorkflow: false, // recommended skills are by definition opt-in utilities
282
+ isWorkflow: false,
204
283
  currentHash: cls.currentHash,
205
284
  expectedHash: entry.expectedHash,
285
+ observedScope: scope,
286
+ });
287
+ }
288
+ if (malformed.size > 0) {
289
+ warnings.push({
290
+ code: "skill-malformed",
291
+ message: `Skill directory present but SKILL.md missing or unreadable: ${[...malformed].join(", ")}`,
206
292
  });
207
293
  }
208
294
  return out;
@@ -222,50 +308,63 @@ function parseRef(ref) {
222
308
  const m = /^v?(\d+\.\d+\.\d+(?:[-+][\w.]+)?)$/.exec(ref);
223
309
  return m ? m[1] : null;
224
310
  }
225
- async function scanClaudePlugins(entries, execPluginList, warnings) {
311
+ async function scanClaudePlugins(scope, entries, execPluginList, warnings) {
226
312
  if (entries.length === 0)
227
313
  return [];
228
- // Degraded path 1: no exec injected AND no default. We expose a default
229
- // implementation below that wraps `claude plugins list --available --json`,
230
- // but the test contract treats "execPluginList undefined" as "Claude CLI
231
- // missing" — we honor that by NOT silently falling back to the default
232
- // when the caller leaves it undefined. (server.ts will pass the default
233
- // explicitly when it confirms `claude` is on PATH.)
314
+ // Degraded path 1: no exec injected. The default implementation runs
315
+ // `claude plugins list`; server.ts decides whether to pass it based on
316
+ // `which claude`. When undefined assume `claude` is missing.
234
317
  if (!execPluginList) {
235
318
  warnings.push({
236
319
  code: "claude-cli-missing",
237
320
  message: "Claude CLI not available — plugin update detection disabled. Install `claude` to enable update checks.",
238
321
  });
239
- return entries.map(([id, def]) => degradedClaudeRow(id, def));
322
+ return entries.map(([id, def]) => degradedClaudeRow(id, def, scope));
240
323
  }
241
324
  let payload;
242
325
  try {
243
- payload = await execPluginList();
326
+ payload = await execPluginList(scope);
244
327
  }
245
328
  catch (err) {
246
329
  warnings.push({
247
330
  code: "claude-cli-missing",
248
331
  message: `Claude CLI plugin list failed: ${err.message}`,
249
332
  });
250
- return entries.map(([id, def]) => degradedClaudeRow(id, def));
251
- }
333
+ return entries.map(([id, def]) => degradedClaudeRow(id, def, scope));
334
+ }
335
+ // claude plugins list emits ids in `<plugin>@<marketplace>` form (e.g.
336
+ // `auriga-go@auriga-cli`). The auriga-cli catalog tracks plugins by bare
337
+ // 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) => {
341
+ 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;
348
+ if (!key)
349
+ return;
350
+ map.set(key, item);
351
+ const at = key.indexOf("@");
352
+ if (at > 0)
353
+ map.set(key.slice(0, at), item);
354
+ };
252
355
  const installedById = new Map();
253
- for (const item of payload.installed ?? []) {
254
- if (item && typeof item.id === "string")
255
- installedById.set(item.id, item);
256
- }
356
+ for (const item of payload.installed ?? [])
357
+ indexBoth(installedById, item);
257
358
  const availableById = new Map();
258
- for (const item of payload.available ?? []) {
259
- if (item && typeof item.id === "string")
260
- availableById.set(item.id, item);
261
- }
359
+ for (const item of payload.available ?? [])
360
+ indexBoth(availableById, item);
262
361
  const out = [];
263
362
  for (const [id, def] of entries) {
264
- out.push(classifyClaudePlugin(id, def, installedById.get(id), availableById.get(id)));
363
+ out.push(classifyClaudePlugin(id, def, installedById.get(id), availableById.get(id), scope));
265
364
  }
266
365
  return out;
267
366
  }
268
- function degradedClaudeRow(id, def) {
367
+ function degradedClaudeRow(id, def, scope) {
269
368
  return {
270
369
  id,
271
370
  description: def.description,
@@ -273,10 +372,10 @@ function degradedClaudeRow(id, def) {
273
372
  agents: ["claude"],
274
373
  expectedVersion: def.expectedVersion,
275
374
  versionSource: "upstream-live",
375
+ observedScope: scope,
276
376
  };
277
377
  }
278
- function classifyClaudePlugin(id, def, installed, available) {
279
- // Not installed at all — easy case.
378
+ function classifyClaudePlugin(id, def, installed, available, scope) {
280
379
  if (!installed || typeof installed.version !== "string") {
281
380
  return {
282
381
  id,
@@ -285,39 +384,55 @@ function classifyClaudePlugin(id, def, installed, available) {
285
384
  agents: ["claude"],
286
385
  expectedVersion: typeof available?.source?.ref === "string" ? available.source.ref : def.expectedVersion,
287
386
  versionSource: "upstream-live",
387
+ observedScope: scope,
288
388
  };
289
389
  }
290
390
  const installedVersion = installed.version;
291
391
  const ref = available?.source?.ref;
292
392
  const normalizedAvailable = parseRef(typeof ref === "string" ? ref : undefined);
293
393
  const normalizedInstalled = parseRef(installedVersion);
294
- // Fallback rule 1: installed version "unknown" trust it's installed.
295
- // Fallback rule 2: available.ref is a branch / non-semver trust it.
296
- // Fallback rule 3: available info is missing entirely → trust it (we know
297
- // it's installed, we just can't say if there's a newer one).
298
- if (installedVersion === "unknown" ||
299
- normalizedAvailable === null ||
300
- !available) {
394
+ // Pick the comparison target. The marketplace-live ref wins when it's a
395
+ // parseable semver that's the freshest signal. Otherwise fall back to
396
+ // the build-time-baked `def.expectedVersion` (populated from
397
+ // plugins/<name>/.claude-plugin/plugin.json by scan-catalog for owned
398
+ // plugins). Without the fallback, the common upgrade case is invisible:
399
+ // `claude plugins list --available --json` excludes already-installed
400
+ // plugins from `.available[]`, so for any plugin the user already has,
401
+ // `ref` is undefined and the scanner can't tell whether a newer version
402
+ // ships in the marketplace.
403
+ const hasLiveRef = normalizedAvailable !== null && typeof ref === "string";
404
+ const expectedRaw = hasLiveRef ? ref : def.expectedVersion;
405
+ const expectedNormalized = hasLiveRef
406
+ ? normalizedAvailable
407
+ : parseRef(def.expectedVersion);
408
+ const versionSource = hasLiveRef
409
+ ? "upstream-live"
410
+ : "catalog";
411
+ // Fallback rules (no comparable expected version, or unknown installed):
412
+ // - installed version "unknown" → trust it's installed.
413
+ // - effective expected is null (branch ref + no baked version) → trust installed.
414
+ if (installedVersion === "unknown" || expectedNormalized === null) {
301
415
  return {
302
416
  id,
303
417
  description: def.description,
304
418
  status: "installed",
305
419
  agents: ["claude"],
306
420
  currentVersion: installedVersion,
307
- expectedVersion: typeof ref === "string" ? ref : def.expectedVersion,
308
- versionSource: "upstream-live",
421
+ expectedVersion: expectedRaw,
422
+ versionSource,
423
+ observedScope: scope,
309
424
  };
310
425
  }
311
- // Both sides comparable.
312
- if (normalizedInstalled !== null && normalizedInstalled === normalizedAvailable) {
426
+ if (normalizedInstalled !== null && normalizedInstalled === expectedNormalized) {
313
427
  return {
314
428
  id,
315
429
  description: def.description,
316
430
  status: "installed",
317
431
  agents: ["claude"],
318
432
  currentVersion: installedVersion,
319
- expectedVersion: typeof ref === "string" ? ref : undefined,
320
- versionSource: "upstream-live",
433
+ expectedVersion: expectedRaw,
434
+ versionSource,
435
+ observedScope: scope,
321
436
  };
322
437
  }
323
438
  return {
@@ -326,8 +441,9 @@ function classifyClaudePlugin(id, def, installed, available) {
326
441
  status: "update-available",
327
442
  agents: ["claude"],
328
443
  currentVersion: installedVersion,
329
- expectedVersion: typeof ref === "string" ? ref : undefined,
330
- versionSource: "upstream-live",
444
+ expectedVersion: expectedRaw,
445
+ versionSource,
446
+ observedScope: scope,
331
447
  };
332
448
  }
333
449
  // ---------------------------------------------------------------------------
@@ -359,8 +475,6 @@ async function scanCodexPlugins(entries, readCodexConfig, readCodexPluginsDir, w
359
475
  enabledIds = parseCodexEnabledPluginIds(tomlContent);
360
476
  }
361
477
  catch {
362
- // Corrupt TOML — surface a warning but keep classifying as not-installed
363
- // for each catalog entry rather than dropping rows.
364
478
  warnings.push({
365
479
  code: "codex-cli-missing",
366
480
  message: "Codex config.toml is unparseable — treating no plugins as installed",
@@ -373,9 +487,36 @@ async function scanCodexPlugins(entries, readCodexConfig, readCodexPluginsDir, w
373
487
  catch {
374
488
  fsVersions = new Map();
375
489
  }
490
+ // Mirror the Claude side: catalog tracks bare names (e.g. "auriga-go") but
491
+ // ~/.codex/config.toml [plugins.*] sections and defaultReadCodexPluginsDir
492
+ // both emit `<plugin>@<marketplace>` keys (e.g. "auriga-go@auriga-cli").
493
+ // Without dual indexing every dual-Agent plugin reports `not-installed` on
494
+ // the Codex side, which `mergePluginsById` then folds into a permanent
495
+ // `update-available` even when both sides are genuinely installed.
496
+ const lookupEnabled = (catalogId) => {
497
+ if (enabledIds.has(catalogId))
498
+ return true;
499
+ for (const id of enabledIds) {
500
+ const at = id.indexOf("@");
501
+ if (at > 0 && id.slice(0, at) === catalogId)
502
+ return true;
503
+ }
504
+ return false;
505
+ };
506
+ const lookupFsVersion = (catalogId) => {
507
+ const direct = fsVersions.get(catalogId);
508
+ if (direct)
509
+ return direct;
510
+ for (const [id, v] of fsVersions) {
511
+ const at = id.indexOf("@");
512
+ if (at > 0 && id.slice(0, at) === catalogId)
513
+ return v;
514
+ }
515
+ return undefined;
516
+ };
376
517
  const out = [];
377
518
  for (const [id, def] of entries) {
378
- out.push(classifyCodexPlugin(id, def, enabledIds.has(id), fsVersions.get(id)));
519
+ out.push(classifyCodexPlugin(id, def, lookupEnabled(id), lookupFsVersion(id)));
379
520
  }
380
521
  return out;
381
522
  }
@@ -387,6 +528,7 @@ function degradedCodexRow(id, def) {
387
528
  agents: ["codex"],
388
529
  expectedVersion: def.expectedVersion,
389
530
  versionSource: "catalog",
531
+ observedScope: "user",
390
532
  };
391
533
  }
392
534
  function classifyCodexPlugin(id, def, enabled, fsVersion) {
@@ -399,10 +541,9 @@ function classifyCodexPlugin(id, def, enabled, fsVersion) {
399
541
  agents: ["codex"],
400
542
  expectedVersion,
401
543
  versionSource: "catalog",
544
+ observedScope: "user",
402
545
  };
403
546
  }
404
- // Enabled in config but missing from fs — assumption #2 row contract:
405
- // row present, status is NOT "installed".
406
547
  if (!fsVersion) {
407
548
  return {
408
549
  id,
@@ -411,10 +552,9 @@ function classifyCodexPlugin(id, def, enabled, fsVersion) {
411
552
  agents: ["codex"],
412
553
  expectedVersion,
413
554
  versionSource: "catalog",
555
+ observedScope: "user",
414
556
  };
415
557
  }
416
- // Compare fs version to catalog expectation. If catalog gives no
417
- // expectedVersion, trust it as installed.
418
558
  if (!expectedVersion || fsVersion === expectedVersion) {
419
559
  return {
420
560
  id,
@@ -424,6 +564,7 @@ function classifyCodexPlugin(id, def, enabled, fsVersion) {
424
564
  currentVersion: fsVersion,
425
565
  expectedVersion,
426
566
  versionSource: "catalog",
567
+ observedScope: "user",
427
568
  };
428
569
  }
429
570
  return {
@@ -434,6 +575,7 @@ function classifyCodexPlugin(id, def, enabled, fsVersion) {
434
575
  currentVersion: fsVersion,
435
576
  expectedVersion,
436
577
  versionSource: "catalog",
578
+ observedScope: "user",
437
579
  };
438
580
  }
439
581
  /** Return the set of plugin ids whose `[plugins."<id>"]` table has
@@ -454,75 +596,173 @@ function parseCodexEnabledPluginIds(tomlContent) {
454
596
  }
455
597
  return ids;
456
598
  }
457
- function readHooksConfig(projectRoot) {
458
- const configPath = path.join(projectRoot, ".claude", "hooks", "hooks.json");
459
- let text;
460
- try {
461
- text = fs.readFileSync(configPath, "utf8");
462
- }
463
- catch {
464
- return { config: null, corrupt: false };
465
- }
466
- try {
467
- const parsed = JSON.parse(text);
468
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
469
- return { config: parsed, corrupt: false };
599
+ // ---------------------------------------------------------------------------
600
+ // Hooks read from <scope>/.claude/settings.json `hooks` segment, matched by
601
+ // `_marker` sentinel against catalog hook names. Settings.json shape (Claude
602
+ // Code convention):
603
+ //
604
+ // {
605
+ // "hooks": {
606
+ // "<EventName>": [
607
+ // {
608
+ // "matcher": "<pattern>",
609
+ // "if": "<optional Claude-Code filter>",
610
+ // "hooks": [
611
+ // { "type": "command", "command": "...", "_marker": "<name>" }
612
+ // ]
613
+ // }
614
+ // ]
615
+ // }
616
+ // }
617
+ //
618
+ // ---------------------------------------------------------------------------
619
+ function settingsPathForScope(scope, projectRoot, home) {
620
+ if (scope === "user")
621
+ return path.join(home, ".claude", "settings.json");
622
+ return path.join(projectRoot, ".claude", "settings.json");
623
+ }
624
+ /** Walk every settings hook action, returning a map keyed by the action's
625
+ * `_marker` sentinel value. Malformed sub-shapes are skipped silently. */
626
+ function indexSettingsHooksByMarker(settings) {
627
+ const out = new Map();
628
+ if (!settings || typeof settings !== "object" || Array.isArray(settings))
629
+ return out;
630
+ const hooksSeg = settings.hooks;
631
+ if (!hooksSeg || typeof hooksSeg !== "object" || Array.isArray(hooksSeg))
632
+ return out;
633
+ for (const [event, blocks] of Object.entries(hooksSeg)) {
634
+ if (!Array.isArray(blocks))
635
+ continue;
636
+ for (const block of blocks) {
637
+ if (!block || typeof block !== "object" || Array.isArray(block))
638
+ continue;
639
+ const b = block;
640
+ const matcher = typeof b.matcher === "string" ? b.matcher : undefined;
641
+ const ifExpr = typeof b.if === "string" ? b.if : undefined;
642
+ const actions = b.hooks;
643
+ if (!Array.isArray(actions))
644
+ continue;
645
+ for (const action of actions) {
646
+ if (!action || typeof action !== "object" || Array.isArray(action))
647
+ continue;
648
+ const a = action;
649
+ const marker = typeof a._marker === "string" ? a._marker : undefined;
650
+ if (!marker)
651
+ continue;
652
+ out.set(marker, {
653
+ event,
654
+ matcher,
655
+ ifExpr,
656
+ command: typeof a.command === "string" ? a.command : undefined,
657
+ });
658
+ }
470
659
  }
471
- return { config: null, corrupt: true };
472
- }
473
- catch {
474
- return { config: null, corrupt: true };
475
660
  }
661
+ return out;
662
+ }
663
+ /** Compute a coarse sha256 signature over a settings hook entry's drift-
664
+ * relevant fields (event, matcher, if). Used to fall back to a single-
665
+ * field comparison when the catalog hasn't been upgraded to expose
666
+ * structured expectedMatcher / expectedEvent / expectedIf. */
667
+ function signatureForSettingsEntry(entry) {
668
+ const canonical = JSON.stringify({
669
+ event: entry.event,
670
+ matcher: entry.matcher ?? "",
671
+ if: entry.ifExpr ?? "",
672
+ });
673
+ return createHash("sha256").update(canonical).digest("hex");
674
+ }
675
+ /** Returns true when the catalog's expectedHash is a wildcard sentinel
676
+ * (empty string or the literal "any" placeholder). Wildcard means "no
677
+ * drift expectation for this hook — trust marker presence." */
678
+ function isWildcardExpectedHash(expectedHash) {
679
+ return expectedHash === "" || expectedHash === WILDCARD_EXPECTED_HASH;
680
+ }
681
+ function detectHookDrift(catalogEntry, settingsEntry) {
682
+ // Preferred drift path: structured expectations from catalog.
683
+ if (typeof catalogEntry.expectedMatcher === "string" &&
684
+ (settingsEntry.matcher ?? "") !== catalogEntry.expectedMatcher) {
685
+ return true;
686
+ }
687
+ if (typeof catalogEntry.expectedEvent === "string" &&
688
+ settingsEntry.event !== catalogEntry.expectedEvent) {
689
+ return true;
690
+ }
691
+ if (typeof catalogEntry.expectedIf === "string" &&
692
+ (settingsEntry.ifExpr ?? "") !== catalogEntry.expectedIf) {
693
+ return true;
694
+ }
695
+ // Fallback drift signal via expectedHash. When the catalog hasn't been
696
+ // populated with structured expected* fields (yet), expectedHash doubles
697
+ // as a coarse signature: if non-empty and non-wildcard, the implementation
698
+ // computes its own signature over the settings entry and treats any
699
+ // divergence as drift. Production scan-catalog.ts can populate this with
700
+ // a real settings-entry signature; until then, an explicit non-wildcard
701
+ // placeholder in tests (e.g. "expected-new-matcher-signature") deliberately
702
+ // triggers drift since it can never equal a sha256 hex digest.
703
+ if (!isWildcardExpectedHash(catalogEntry.expectedHash)) {
704
+ const sig = signatureForSettingsEntry(settingsEntry);
705
+ if (sig !== catalogEntry.expectedHash)
706
+ return true;
707
+ }
708
+ return false;
476
709
  }
477
- function hashHookIndex(projectRoot, name) {
478
- const indexPath = path.join(projectRoot, ".claude", "hooks", name, "index.mjs");
710
+ function scanHooks(scope, projectRoot, home, catalogHooks, warnings) {
711
+ const settingsPath = settingsPathForScope(scope, projectRoot, home);
712
+ let settingsRaw = null;
713
+ let settingsErr = null;
479
714
  try {
480
- const buf = fs.readFileSync(indexPath);
481
- return createHash("sha256").update(buf).digest("hex");
715
+ settingsRaw = fs.readFileSync(settingsPath, "utf8");
482
716
  }
483
- catch {
484
- return undefined;
717
+ catch (err) {
718
+ if (err && err.code === "ENOENT") {
719
+ settingsErr = "absent";
720
+ }
721
+ else {
722
+ settingsErr = "unreadable";
723
+ }
485
724
  }
486
- }
487
- function scanHooks(projectRoot, catalogHooks) {
488
- const { config } = readHooksConfig(projectRoot);
489
- const registeredNames = new Set();
490
- if (config?.hooks && Array.isArray(config.hooks)) {
491
- for (const entry of config.hooks) {
492
- if (entry && typeof entry.name === "string")
493
- registeredNames.add(entry.name);
725
+ let parsed = null;
726
+ if (settingsRaw !== null) {
727
+ try {
728
+ parsed = JSON.parse(settingsRaw);
729
+ }
730
+ catch {
731
+ settingsErr = "unreadable";
732
+ parsed = null;
494
733
  }
495
734
  }
735
+ if (settingsErr === "unreadable") {
736
+ warnings.push({
737
+ code: "settings-unreadable",
738
+ message: `Settings file unreadable or corrupt JSON: ${settingsPath}`,
739
+ });
740
+ }
741
+ const byMarker = indexSettingsHooksByMarker(parsed);
496
742
  const out = [];
497
743
  for (const [name, def] of Object.entries(catalogHooks)) {
498
- if (!registeredNames.has(name)) {
744
+ const settingsEntry = byMarker.get(name);
745
+ if (!settingsEntry) {
499
746
  out.push({
500
747
  name,
501
748
  description: def.description,
502
749
  status: "not-installed",
503
750
  expectedHash: def.expectedHash,
751
+ observedScope: scope,
504
752
  });
505
753
  continue;
506
754
  }
507
- const currentHash = hashHookIndex(projectRoot, name);
508
- if (currentHash === undefined) {
509
- // Registered but index.mjs missing — assumption #2: row present, not
510
- // "installed", currentHash undefined so the UI can prompt repair.
511
- out.push({
512
- name,
513
- description: def.description,
514
- status: "not-installed",
515
- expectedHash: def.expectedHash,
516
- });
517
- continue;
518
- }
519
- const status = currentHash === def.expectedHash ? "installed" : "update-available";
755
+ const drift = detectHookDrift(def, settingsEntry);
520
756
  out.push({
521
757
  name,
522
758
  description: def.description,
523
- status,
524
- currentHash,
759
+ status: drift ? "update-available" : "installed",
760
+ // Surface a coarse current signature so the UI can show diff details
761
+ // if it wants. The exact format is "sha256 of normalized settings
762
+ // entry" — opaque to the UI, used only for drift detection.
763
+ currentHash: signatureForSettingsEntry(settingsEntry),
525
764
  expectedHash: def.expectedHash,
765
+ observedScope: scope,
526
766
  });
527
767
  }
528
768
  return out;
@@ -531,20 +771,45 @@ function scanHooks(projectRoot, catalogHooks) {
531
771
  // Default external-I/O implementations (used when ScanOptions are not
532
772
  // injected — server.ts wires these up in production).
533
773
  // ---------------------------------------------------------------------------
534
- /** Default: run `claude plugins list --json` and `claude plugins list
535
- * --available --json`. Returns null is NOT an option here — server.ts
536
- * decides whether to pass this function based on `which claude`. */
537
- export async function defaultExecPluginList() {
774
+ /** Default: run `claude plugins list --json` (no scope flag — the CLI
775
+ * doesn't expose one) plus the `--available` variant, then filter the
776
+ * installed records to the requested scope (and current projectRoot for
777
+ * project-scope) client-side. Server.ts decides whether to pass this
778
+ * function based on `which claude`. */
779
+ export async function defaultExecPluginList(scope = "user", projectRoot) {
538
780
  // Run both lookups in parallel via async exec so /api/state doesn't block
539
781
  // the event loop. `claude plugins list` can take several seconds on cold
540
782
  // marketplace fetches; sync exec would freeze heartbeats and other
541
- // concurrent /api requests.
783
+ // concurrent /api requests. Note: `claude plugins list` does NOT support
784
+ // `--user` / `--project`; each record carries its own `scope` field which
785
+ // we filter on below.
542
786
  const [installedRes, availableRes] = await Promise.all([
543
- execAsync("claude plugins list --json", { encoding: "utf8" }),
544
- execAsync("claude plugins list --available --json", { encoding: "utf8" }),
787
+ execAsync(`claude plugins list --json`, { encoding: "utf8" }),
788
+ execAsync(`claude plugins list --available --json`, { encoding: "utf8" }),
545
789
  ]);
546
- const installed = parseJsonArray(installedRes.stdout);
547
- const available = parseJsonArray(availableRes.stdout);
790
+ const allInstalled = parseJsonArray(installedRes.stdout);
791
+ // `claude plugins list --available --json` returns a wrapped object
792
+ // `{ installed: [...], available: [...] }`, NOT a flat array. parseJsonArray
793
+ // alone would return `[]` and silently lose every marketplace ref → the
794
+ // scanner could never surface "update-available" from upstream-live data.
795
+ // Pull `.available` out of the wrapper; tolerate the flat-array form too
796
+ // in case Claude CLI's shape regresses.
797
+ const available = extractAvailableArray(availableRes.stdout);
798
+ const installed = allInstalled.filter((rec) => {
799
+ if (!rec || typeof rec !== "object")
800
+ return false;
801
+ if (rec.scope !== scope)
802
+ return false;
803
+ // Project-scope records may match multiple projects (`projectPath`
804
+ // differs). If projectRoot was provided, narrow to records bound to
805
+ // the current cwd. When omitted, fall back to "any project-scope
806
+ // record" — better than dropping all project records on a malformed
807
+ // call.
808
+ if (scope === "project" && projectRoot && typeof rec.projectPath === "string") {
809
+ return path.resolve(rec.projectPath) === path.resolve(projectRoot);
810
+ }
811
+ return true;
812
+ });
548
813
  return { installed, available };
549
814
  }
550
815
  function parseJsonArray(text) {
@@ -556,6 +821,24 @@ function parseJsonArray(text) {
556
821
  return [];
557
822
  }
558
823
  }
824
+ /** Pull the available-plugins array out of `claude plugins list --available
825
+ * --json`'s response. Empirically the CLI returns `{ installed, available }`;
826
+ * if a future version regresses to a flat array we keep working. Returns
827
+ * `[]` on malformed JSON. */
828
+ function extractAvailableArray(text) {
829
+ try {
830
+ const parsed = JSON.parse(text);
831
+ if (Array.isArray(parsed))
832
+ return parsed;
833
+ if (parsed && typeof parsed === "object" && Array.isArray(parsed.available)) {
834
+ return parsed.available;
835
+ }
836
+ return [];
837
+ }
838
+ catch {
839
+ return [];
840
+ }
841
+ }
559
842
  function codexHome() {
560
843
  return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
561
844
  }