auriga-cli 1.18.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.
@@ -1,4 +1,13 @@
1
1
  export type ItemStatus = "installed" | "update-available" | "not-installed";
2
+ /**
3
+ * Per-category scan scope. Each category (workflow / skills / plugins / hooks)
4
+ * can be independently scanned in either user scope (~/.claude/, ~/.codex/)
5
+ * or project scope (<proj>/.claude/). The Web UI's per-column scope picker
6
+ * carries these through the `/api/state` query so the scanner reads the
7
+ * right truth source per category. Codex plugins are user-scope only by
8
+ * design and ignore this field.
9
+ */
10
+ export type ScanScope = "user" | "project";
2
11
  export interface StateReport {
3
12
  /** Absolute path to the project the server was launched against. Surfaced
4
13
  * in the UI's top bar so users know where Apply will write project-scope
@@ -16,6 +25,13 @@ export interface WorkflowState {
16
25
  status: ItemStatus;
17
26
  currentVersion?: string;
18
27
  expectedVersion: string;
28
+ /** Which scope the scanner read to produce this row. Reflects the scope
29
+ * scanned, not where the file was found — e.g. when scope=user and
30
+ * ~/.claude/CLAUDE.md is absent, observedScope is still "user". The
31
+ * scanner ALWAYS sets this field at runtime; it is typed optional only
32
+ * so the mergePluginsById regression helper (which carries over from the
33
+ * pre-rewrite suite without an explicit scope) continues to compile. */
34
+ observedScope?: ScanScope;
19
35
  }
20
36
  export interface SkillState {
21
37
  name: string;
@@ -24,6 +40,8 @@ export interface SkillState {
24
40
  isWorkflow: boolean;
25
41
  currentHash?: string;
26
42
  expectedHash: string;
43
+ /** Scope the scanner read to produce this row. See WorkflowState comment. */
44
+ observedScope?: ScanScope;
27
45
  }
28
46
  export type ApplyAgent = "claude" | "codex";
29
47
  export interface PluginState {
@@ -41,6 +59,10 @@ export interface PluginState {
41
59
  currentVersion?: string;
42
60
  expectedVersion?: string;
43
61
  versionSource: "upstream-live" | "catalog";
62
+ /** Scope the scanner read to produce this row. Codex plugins are always
63
+ * "user" (Codex has no project-scope plugin concept). See WorkflowState
64
+ * comment on why this is typed optional. */
65
+ observedScope?: ScanScope;
44
66
  }
45
67
  export interface HookState {
46
68
  name: string;
@@ -48,9 +70,11 @@ export interface HookState {
48
70
  status: ItemStatus;
49
71
  currentHash?: string;
50
72
  expectedHash: string;
73
+ /** Scope the scanner read to produce this row. See WorkflowState comment. */
74
+ observedScope?: ScanScope;
51
75
  }
52
76
  export interface StateWarning {
53
- code: "claude-cli-missing" | "codex-cli-missing" | "marketplace-offline";
77
+ code: "claude-cli-missing" | "codex-cli-missing" | "marketplace-offline" | "claude-code-not-installed" | "settings-unreadable" | "skill-malformed" | "workflow-unknown-version";
54
78
  message: string;
55
79
  }
56
80
  export type ApplyCategory = "workflow" | "skill" | "recommended-skill" | "plugin" | "hook";
package/dist/catalog.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "generatedAt": "2026-05-12T11:46:16.926Z",
2
+ "generatedAt": "2026-05-12T13:41:34.719Z",
3
3
  "workflowSkills": [
4
4
  {
5
5
  "name": "brainstorming",
package/dist/cli.js CHANGED
@@ -562,7 +562,12 @@ async function dispatchInstaller(category, packageRoot, opts) {
562
562
  // ---------------------------------------------------------------------------
563
563
  const UI_DEFAULT_PORT = 4747;
564
564
  const UI_PORT_RANGE = 10; // 4747..4756
565
- const UI_HEARTBEAT_TIMEOUT_MS = 15_000;
565
+ // 2 minutes covers Chrome's "intensive throttling" of background tabs
566
+ // (kicks in after ~5 min of being hidden, drops setInterval to ~1 ping/min).
567
+ // At 15s the browser tab being switched away for a moment would tear down
568
+ // the server — bad UX. Closing the browser now takes up to 2 min to release
569
+ // the port; users who care can ctrl+C the CLI for immediate exit.
570
+ const UI_HEARTBEAT_TIMEOUT_MS = 120_000;
566
571
  async function runUi(p, version) {
567
572
  // Lazy-load the server-side deps so the install / guide paths stay light.
568
573
  const { randomBytes } = await import("node:crypto");
@@ -8,7 +8,9 @@
8
8
  // skills-lock.json — expected SHA256 for every vendored skill
9
9
  // .claude/plugins.json — Claude plugin entries (agent = "claude")
10
10
  // .agents/plugins/install.json — Codex plugin entries (agent = "codex")
11
- // .claude/hooks/<name>/index.mjsruntime SHA256 = expected hash
11
+ // .claude/hooks/hooks.jsonregisters settingsEvents per hook (event /
12
+ // matcher / if) used by state.ts for drift
13
+ // detection in <scope>/.claude/settings.json
12
14
  // CLAUDE.md — `# auriga Workflow (vX.Y.Z)` provides
13
15
  // workflowVersion
14
16
  //
@@ -20,6 +22,39 @@ import { createHash } from "node:crypto";
20
22
  import { readFile } from "node:fs/promises";
21
23
  import path from "node:path";
22
24
  import { loadCatalog } from "./catalog.js";
25
+ async function sha256SkillMd(skillsRoot, name) {
26
+ try {
27
+ const buf = await readFile(path.join(skillsRoot, name, "SKILL.md"));
28
+ return createHash("sha256").update(buf).digest("hex");
29
+ }
30
+ catch {
31
+ return "";
32
+ }
33
+ }
34
+ /** Read `plugins/<name>/.claude-plugin/plugin.json` (or `.codex-plugin/plugin.json`
35
+ * as fallback) and return the `version` field. Returns "" when no manifest
36
+ * exists or the JSON is malformed — the scanner then leaves expectedVersion
37
+ * unset, which means external-marketplace plugins (whose source lives
38
+ * upstream, not in this repo) get the "trust installed" classification. */
39
+ async function readPluginManifestVersion(packageRoot, name) {
40
+ const candidates = [
41
+ path.join(packageRoot, "plugins", name, ".claude-plugin", "plugin.json"),
42
+ path.join(packageRoot, "plugins", name, ".codex-plugin", "plugin.json"),
43
+ ];
44
+ for (const p of candidates) {
45
+ try {
46
+ const raw = await readFile(p, "utf8");
47
+ const parsed = JSON.parse(raw);
48
+ if (typeof parsed.version === "string" && parsed.version.length > 0) {
49
+ return parsed.version;
50
+ }
51
+ }
52
+ catch {
53
+ /* try next candidate */
54
+ }
55
+ }
56
+ return "";
57
+ }
23
58
  const WORKFLOW_VERSION_RE = /^#\s*auriga Workflow\s*\(v([\d.]+)\)/m;
24
59
  async function tryReadFile(p) {
25
60
  try {
@@ -29,10 +64,6 @@ async function tryReadFile(p) {
29
64
  return null;
30
65
  }
31
66
  }
32
- async function sha256File(p) {
33
- const bytes = await readFile(p);
34
- return createHash("sha256").update(bytes).digest("hex");
35
- }
36
67
  export async function buildScanCatalog(packageRoot) {
37
68
  const dist = loadCatalog(packageRoot);
38
69
  // Workflow version: parse from auriga-cli's own CLAUDE.md template.
@@ -41,22 +72,18 @@ export async function buildScanCatalog(packageRoot) {
41
72
  const claudeMd = await tryReadFile(path.join(packageRoot, "CLAUDE.md"));
42
73
  const m = claudeMd ? WORKFLOW_VERSION_RE.exec(claudeMd) : null;
43
74
  const workflowVersion = m ? m[1] : "";
44
- // Skills + recommended: hashes from skills-lock.json.
45
- let lock = {};
46
- const lockText = await tryReadFile(path.join(packageRoot, "skills-lock.json"));
47
- if (lockText) {
48
- try {
49
- lock = JSON.parse(lockText);
50
- }
51
- catch {
52
- // corrupted lock → no expectations; user state still classifies safely
53
- }
54
- }
75
+ // Skills + recommended: sha256 of each shipped SKILL.md. This is the same
76
+ // hash the scanner computes for `<scope>/.claude/skills/<name>/SKILL.md`
77
+ // at scan time, so a match means "user's installed copy is identical to
78
+ // the version auriga-cli ships". skills-lock.json's `computedHash` field
79
+ // hashes the entire skill directory (every file, sorted), which doesn't
80
+ // line up with the scanner's per-file model — we deliberately ignore it.
81
+ const skillsRoot = path.join(packageRoot, ".agents", "skills");
55
82
  const skills = {};
56
83
  for (const entry of dist.workflowSkills) {
57
84
  skills[entry.name] = {
58
85
  description: entry.description,
59
- expectedHash: lock.skills?.[entry.name]?.computedHash ?? "",
86
+ expectedHash: await sha256SkillMd(skillsRoot, entry.name),
60
87
  isWorkflow: true,
61
88
  };
62
89
  }
@@ -64,7 +91,7 @@ export async function buildScanCatalog(packageRoot) {
64
91
  for (const entry of dist.recommendedSkills) {
65
92
  recommendedSkills[entry.name] = {
66
93
  description: entry.description,
67
- expectedHash: lock.skills?.[entry.name]?.computedHash ?? "",
94
+ expectedHash: await sha256SkillMd(skillsRoot, entry.name),
68
95
  };
69
96
  }
70
97
  // Plugins: split by agent based on which config file lists them. A
@@ -111,22 +138,49 @@ export async function buildScanCatalog(packageRoot) {
111
138
  agents.push("codex");
112
139
  if (agents.length === 0)
113
140
  agents.push("claude"); // unknown defaults to claude
114
- plugins[entry.name] = { description: entry.description, agents };
141
+ // Bake expectedVersion from the owned plugin's manifest. For Claude-side
142
+ // plugins prefer plugins/<name>/.claude-plugin/plugin.json; fall back to
143
+ // .codex-plugin/plugin.json for codex-only plugins (e.g.
144
+ // session-instructions-loader). External-marketplace plugins (skill-creator,
145
+ // claude-md-management, codex) have no local manifest — they install from
146
+ // their upstream marketplace, so we deliberately leave expectedVersion
147
+ // undefined. The scanner then falls through to "trust whatever is installed",
148
+ // which matches what the user agreed to when they registered the upstream.
149
+ const expectedVersion = await readPluginManifestVersion(packageRoot, entry.name);
150
+ plugins[entry.name] = {
151
+ description: entry.description,
152
+ agents,
153
+ ...(expectedVersion ? { expectedVersion } : {}),
154
+ };
155
+ }
156
+ // Hooks: the scanner reads <scope>/.claude/settings.json and matches by
157
+ // `_marker` (see state.ts scanHooks). Drift detection compares the
158
+ // registered event / matcher / if values against the hook's canonical
159
+ // settingsEvents[0] from .claude/hooks/hooks.json. We deliberately do NOT
160
+ // hash index.mjs — the user's installed index.mjs lives at <scope>/.claude/
161
+ // hooks/<name>/index.mjs and isn't part of the settings.json drift signal.
162
+ const hooksJsonPath = path.join(packageRoot, ".claude", "hooks", "hooks.json");
163
+ const hooksJsonRaw = await tryReadFile(hooksJsonPath);
164
+ const hooksJson = hooksJsonRaw ? JSON.parse(hooksJsonRaw) : {};
165
+ const settingsEventsByName = new Map();
166
+ for (const h of hooksJson.hooks ?? []) {
167
+ if (typeof h.name === "string" && h.settingsEvents?.length) {
168
+ settingsEventsByName.set(h.name, h.settingsEvents[0]);
169
+ }
115
170
  }
116
- // Hooks: runtime SHA256 of each hook's index.mjs serves as the expected
117
- // hash. If the file can't be read, leave the expectation empty so the
118
- // hook classifies as not-installed.
119
171
  const hooks = {};
120
172
  for (const entry of dist.hooks) {
121
- const hookEntry = path.join(packageRoot, ".claude", "hooks", entry.name, "index.mjs");
122
- let expectedHash = "";
123
- try {
124
- expectedHash = await sha256File(hookEntry);
125
- }
126
- catch {
127
- /* missing or unreadable hook payload; leave hash empty */
128
- }
129
- hooks[entry.name] = { description: entry.description, expectedHash };
173
+ const ev = settingsEventsByName.get(entry.name);
174
+ hooks[entry.name] = {
175
+ description: entry.description,
176
+ // Empty expectedHash flips state.ts into wildcard mode — drift judged
177
+ // purely from the structured expected* fields below, not from a
178
+ // settings-entry content hash.
179
+ expectedHash: "",
180
+ ...(typeof ev?.event === "string" ? { expectedEvent: ev.event } : {}),
181
+ ...(typeof ev?.matcher === "string" ? { expectedMatcher: ev.matcher } : {}),
182
+ ...(typeof ev?.if === "string" ? { expectedIf: ev.if } : {}),
183
+ };
130
184
  }
131
185
  return {
132
186
  workflowVersion,
package/dist/server.js CHANGED
@@ -7,13 +7,14 @@
7
7
  //
8
8
  // Public contract is anchored in docs/architecture/web-ui.md §4 (server
9
9
  // surface), §6 (data flow + types), §7 (errors).
10
+ import { spawnSync } from "node:child_process";
10
11
  import { createServer } from "node:http";
11
12
  import { Buffer } from "node:buffer";
12
13
  import { randomBytes, timingSafeEqual } from "node:crypto";
13
14
  import { readFile } from "node:fs/promises";
14
15
  import path from "node:path";
15
16
  import { buildScanCatalog } from "./scan-catalog.js";
16
- import { scanState } from "./state.js";
17
+ import { defaultExecPluginList, scanState } from "./state.js";
17
18
  // Body parsing cap. /api/apply payloads are tiny (an array of item refs);
18
19
  // 1 MiB is generously above the largest realistic batch and small enough that
19
20
  // abusive clients can't pin memory.
@@ -576,7 +577,7 @@ export async function startServer(opts) {
576
577
  return;
577
578
  }
578
579
  if (pathname === "/api/state" && method === "GET") {
579
- await routeState(opts.cwd, opts.packageRoot ?? opts.cwd, res);
580
+ await routeState(opts.cwd, opts.packageRoot ?? opts.cwd, searchParams, res);
580
581
  return;
581
582
  }
582
583
  if (pathname === "/api/apply" && method === "POST") {
@@ -705,10 +706,21 @@ export async function startServer(opts) {
705
706
  // ---------------------------------------------------------------------------
706
707
  // Route: GET /api/state
707
708
  // ---------------------------------------------------------------------------
708
- async function routeState(cwd, packageRoot, res) {
709
+ async function routeState(cwd, packageRoot, searchParams, res) {
709
710
  try {
710
711
  const catalog = await buildScanCatalog(packageRoot);
711
- const report = await scanState(cwd, catalog);
712
+ const scopes = parseScopesParam(searchParams);
713
+ // Only wire `defaultExecPluginList` if the `claude` CLI is on PATH —
714
+ // otherwise the scanner's degraded-path warning ("claude-cli-missing")
715
+ // is what we want the UI to surface. Cache the result per process so we
716
+ // don't re-`which` on every /api/state poll.
717
+ const execPluginList = isClaudeOnPath()
718
+ ? (scope) => defaultExecPluginList(scope, cwd)
719
+ : undefined;
720
+ const report = await scanState(cwd, catalog, {
721
+ ...(scopes ? { scopes } : {}),
722
+ ...(execPluginList ? { execPluginList } : {}),
723
+ });
712
724
  sendJson(res, 200, report);
713
725
  }
714
726
  catch {
@@ -717,6 +729,40 @@ async function routeState(cwd, packageRoot, res) {
717
729
  sendJson(res, 500, { error: "scan-failed" });
718
730
  }
719
731
  }
732
+ let claudeOnPathCache = null;
733
+ function isClaudeOnPath() {
734
+ if (claudeOnPathCache !== null)
735
+ return claudeOnPathCache;
736
+ const res = spawnSync("which", ["claude"], { stdio: "ignore" });
737
+ claudeOnPathCache = res.status === 0;
738
+ return claudeOnPathCache;
739
+ }
740
+ /**
741
+ * Parses the /api/state `scopes` query into a per-category map for
742
+ * `scanState`. The query string shape is comma-separated `category:scope`
743
+ * pairs, e.g. `?scopes=workflow:user,skills:project,plugins:user`. Unknown
744
+ * categories and unknown scope values are dropped (rather than throwing) so
745
+ * the scanner falls back to install defaults on garbled input — keeps the
746
+ * Web UI degraded-but-functional rather than 500-ing on a stale link.
747
+ *
748
+ * Returns `null` when the param is absent so the caller can omit `opts.scopes`
749
+ * entirely and let `scanState` apply its built-in defaults.
750
+ */
751
+ function parseScopesParam(searchParams) {
752
+ const raw = searchParams.get("scopes");
753
+ if (!raw)
754
+ return null;
755
+ const allowedCategories = new Set(["workflow", "skills", "plugins", "hooks"]);
756
+ const allowedScopes = new Set(["user", "project"]);
757
+ const out = {};
758
+ for (const pair of raw.split(",")) {
759
+ const [cat, scope] = pair.split(":");
760
+ if (allowedCategories.has(cat) && allowedScopes.has(scope)) {
761
+ out[cat] = scope;
762
+ }
763
+ }
764
+ return Object.keys(out).length > 0 ? out : null;
765
+ }
720
766
  // ---------------------------------------------------------------------------
721
767
  // Route: GET /api/catalog
722
768
  // ---------------------------------------------------------------------------
package/dist/state.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PluginState, StateReport } from "./api-types.js";
1
+ import type { PluginState, ScanScope, StateReport } from "./api-types.js";
2
2
  export interface Catalog {
3
3
  workflowVersion: string;
4
4
  skills: Record<string, {
@@ -18,16 +18,51 @@ export interface Catalog {
18
18
  }>;
19
19
  hooks: Record<string, {
20
20
  description: string;
21
+ /** Coarse drift signal. The current production scan-catalog still
22
+ * populates this with sha256(index.mjs) for back-compat with the v0.x
23
+ * scanner that hashed the user's installed script. The new
24
+ * settings.json-based scanner ignores it for drift comparison unless
25
+ * the catalog also exposes the structured expected* fields below. */
21
26
  expectedHash: string;
27
+ /** Settings.json event name (e.g. "PostToolUse", "Notification"). When
28
+ * set, the scanner flags drift if the on-disk settings entry registers
29
+ * under a different event. Optional; left undefined the scanner trusts
30
+ * whatever event the marker was found under. */
31
+ expectedEvent?: string;
32
+ /** Settings.json `matcher` field (e.g. "Write|Edit" for PostToolUse).
33
+ * Empty string means "no matcher" (e.g. Notification hooks). When set,
34
+ * the scanner flags drift if the on-disk value differs. */
35
+ expectedMatcher?: string;
36
+ /** Settings.json `if` field (Claude-Code-specific filter expression).
37
+ * Same drift semantics as expectedMatcher. */
38
+ expectedIf?: string;
22
39
  }>;
23
40
  }
24
41
  export interface ScanOptions {
25
- execPluginList?: () => Promise<{
42
+ /** Run `claude plugins list` for the given scope. The scope argument is
43
+ * required so server.ts can pass --user / --project through to the CLI
44
+ * per opts.scopes.plugins. Implementations may accept a zero-arg legacy
45
+ * form for back-compat but MUST honor a scope argument when given. */
46
+ execPluginList?: (scope: ScanScope) => Promise<{
26
47
  installed: any[];
27
48
  available: any[];
28
49
  }>;
29
50
  readCodexConfig?: () => Promise<string | null>;
30
51
  readCodexPluginsDir?: () => Promise<Map<string, string>>;
52
+ /** Per-category scope picker. Each field is independently routed to the
53
+ * right truth source. Defaults match the Web UI's per-column picker:
54
+ * workflow = 'project', skills = 'project',
55
+ * plugins = 'user', hooks = 'user'. */
56
+ scopes?: {
57
+ workflow?: ScanScope;
58
+ skills?: ScanScope;
59
+ plugins?: ScanScope;
60
+ hooks?: ScanScope;
61
+ };
62
+ /** Test-time HOME override. When unset the scanner reads os.homedir()
63
+ * (which itself consults process.env.HOME / USERPROFILE), so tests that
64
+ * redirect HOME via env vars also work. */
65
+ homeDir?: string;
31
66
  }
32
67
  export declare function scanState(projectRoot: string, catalog: Catalog, opts?: ScanOptions): Promise<StateReport>;
33
68
  /**
@@ -48,10 +83,12 @@ export declare function scanState(projectRoot: string, catalog: Catalog, opts?:
48
83
  * we'll need a deliberate merge policy.
49
84
  */
50
85
  export declare function mergePluginsById(records: PluginState[]): PluginState[];
51
- /** Default: run `claude plugins list --json` and `claude plugins list
52
- * --available --json`. Returns null is NOT an option here — server.ts
53
- * decides whether to pass this function based on `which claude`. */
54
- export declare function defaultExecPluginList(): Promise<{
86
+ /** Default: run `claude plugins list --json` (no scope flag — the CLI
87
+ * doesn't expose one) plus the `--available` variant, then filter the
88
+ * installed records to the requested scope (and current projectRoot for
89
+ * project-scope) client-side. Server.ts decides whether to pass this
90
+ * function based on `which claude`. */
91
+ export declare function defaultExecPluginList(scope?: ScanScope, projectRoot?: string): Promise<{
55
92
  installed: any[];
56
93
  available: any[];
57
94
  }>;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auriga-cli",
3
- "version": "1.18.0",
3
+ "version": "1.18.2",
4
4
  "description": "Interactive CLI to install Claude Code harness modules (Workflow, Skills, Recommended Skills, Plugins, Hooks)",
5
5
  "license": "MIT",
6
6
  "repository": {