auriga-cli 1.18.3 → 1.18.5

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,10 @@
1
- export type ItemStatus = "installed" | "update-available" | "not-installed";
1
+ export type ItemStatus = "installed" | "update-available" | "not-installed"
2
+ /** Dual-Agent plugin where some target Agents have the plugin installed
3
+ * and some don't (e.g. Claude side installed, Codex side missing). Distinct
4
+ * from `update-available` because the action is "install on the missing
5
+ * side", not "upgrade to a newer version". The missing agents are
6
+ * enumerated in `PluginState.missingAgents`. */
7
+ | "partial-install";
2
8
  /**
3
9
  * Per-category scan scope. Each category (workflow / skills / plugins / hooks)
4
10
  * can be independently scanned in either user scope (~/.claude/, ~/.codex/)
@@ -53,9 +59,15 @@ export interface PluginState {
53
59
  * `agents.length === 2` the UI shows a BOTH badge and Apply installs to
54
60
  * each agent in turn. Status is aggregated across all targeted agents:
55
61
  * `installed` ⇔ all agents installed; `not-installed` ⇔ all not-installed;
56
- * any partial state (one side installed, other not) → `update-available`
57
- * so a single Apply backfills the missing side. */
62
+ * partial state (some installed, some not) → `partial-install`; agent-
63
+ * uniform version drift `update-available`. */
58
64
  agents: ApplyAgent[];
65
+ /** Agents that target this plugin but don't have it installed. Populated
66
+ * iff `status === "partial-install"`. Lets the UI render per-agent
67
+ * ✓/✗ marks and tell the user exactly which side needs `auriga-cli`
68
+ * to backfill. Always omitted for `installed` / `not-installed` /
69
+ * `update-available`. */
70
+ missingAgents?: ApplyAgent[];
59
71
  currentVersion?: string;
60
72
  expectedVersion?: string;
61
73
  versionSource: "upstream-live" | "catalog";
@@ -63,6 +75,12 @@ export interface PluginState {
63
75
  * "user" (Codex has no project-scope plugin concept). See WorkflowState
64
76
  * comment on why this is typed optional. */
65
77
  observedScope?: ScanScope;
78
+ /** True for plugins whose source lives in an upstream marketplace, not in
79
+ * this repo (skill-creator / claude-md-management / codex). The scanner
80
+ * short-circuits update-available reporting for these — upgrades go
81
+ * through `claude plugins update`, not us. The UI renders an EXTERNAL
82
+ * badge to make the "not our jurisdiction" signal explicit. */
83
+ external?: boolean;
66
84
  }
67
85
  export interface HookState {
68
86
  name: string;
@@ -74,7 +92,12 @@ export interface HookState {
74
92
  observedScope?: ScanScope;
75
93
  }
76
94
  export interface StateWarning {
77
- code: "claude-cli-missing" | "codex-cli-missing" | "marketplace-offline" | "claude-code-not-installed" | "settings-unreadable" | "skill-malformed" | "workflow-unknown-version";
95
+ code: "claude-cli-missing" | "codex-cli-missing" | "marketplace-offline" | "claude-code-not-installed" | "settings-unreadable" | "skill-malformed"
96
+ /** Project-scope CLAUDE.md (or the user-scope fallback when scanning
97
+ * user scope) is present but has no recognizable `# auriga Workflow (vX.Y.Z)`
98
+ * header. The row reports `not-installed`; install will back up the
99
+ * existing file to `CLAUDE.md.bak` and write ours. */
100
+ | "workflow-foreign-claudemd";
78
101
  message: string;
79
102
  }
80
103
  export type ApplyCategory = "workflow" | "skill" | "recommended-skill" | "plugin" | "hook";
package/dist/catalog.d.ts CHANGED
@@ -9,9 +9,29 @@ export interface CatalogEntry {
9
9
  * because `plugins/<name>/.claude-plugin/plugin.json` is NOT shipped in
10
10
  * the npm tarball (see `package.json` `files` field). */
11
11
  expectedVersion?: string;
12
+ /** Build-time-baked agent map for plugin entries. Derived from
13
+ * `.claude/plugins.json` ∪ `.agents/plugins/install.json` — those config
14
+ * files are NOT shipped in the npm tarball, so the scanner can't read
15
+ * them at runtime. Baking here lets `/api/state` correctly classify
16
+ * dual-Agent plugins as `["claude","codex"]` for installed users.
17
+ * Absent on skill / hook entries. */
18
+ agents?: ("claude" | "codex")[];
19
+ /** True for plugins whose source lives in an UPSTREAM marketplace
20
+ * (skill-creator / claude-md-management / codex), not in this repo. The
21
+ * scanner uses this to disable update-available reporting — those
22
+ * plugins update through `claude plugins update`, not through us. UI
23
+ * surfaces an EXTERNAL badge so users know where to look. */
24
+ external?: boolean;
12
25
  }
13
26
  export interface Catalog {
14
27
  generatedAt: string;
28
+ /** Workflow content version baked from `CLAUDE.md`'s `# auriga Workflow (vX.Y.Z)`
29
+ * header at build time. MUST live here rather than be read at runtime
30
+ * because `CLAUDE.md` is NOT in the npm tarball — `package.json` `files`
31
+ * allowlists only `dist/`. Empty string when the header is unparseable;
32
+ * the scanner then degrades to "trust whatever the user has" rather than
33
+ * forcing phantom update-available against an empty expected value. */
34
+ workflowVersion: string;
15
35
  workflowSkills: CatalogEntry[];
16
36
  recommendedSkills: CatalogEntry[];
17
37
  plugins: CatalogEntry[];
package/dist/catalog.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
- "generatedAt": "2026-05-12T13:58:42.583Z",
2
+ "generatedAt": "2026-05-12T16:35:23.439Z",
3
+ "workflowVersion": "1.7.0",
3
4
  "workflowSkills": [
4
5
  {
5
6
  "name": "brainstorming",
@@ -75,34 +76,61 @@
75
76
  "plugins": [
76
77
  {
77
78
  "name": "skill-creator",
78
- "description": "Create and manage custom skills"
79
+ "description": "Create and manage custom skills",
80
+ "agents": [
81
+ "claude"
82
+ ],
83
+ "external": true
79
84
  },
80
85
  {
81
86
  "name": "claude-md-management",
82
- "description": "Audit and improve CLAUDE.md files"
87
+ "description": "Audit and improve CLAUDE.md files",
88
+ "agents": [
89
+ "claude"
90
+ ],
91
+ "external": true
83
92
  },
84
93
  {
85
94
  "name": "codex",
86
- "description": "Cross-model collaboration with Codex"
95
+ "description": "Cross-model collaboration with Codex",
96
+ "agents": [
97
+ "claude"
98
+ ],
99
+ "external": true
87
100
  },
88
101
  {
89
102
  "name": "auriga-go",
90
103
  "description": "(Claude/Codex) Workflow autopilot for the auriga workflow. Reminder-based navigation across CLAUDE.md's phases. Bundles a /goalify skill that plans an autonomous goal from a spec or work-in-progress and dispatches it via Claude Code's built-in /goal command.",
104
+ "agents": [
105
+ "claude",
106
+ "codex"
107
+ ],
91
108
  "expectedVersion": "1.1.0"
92
109
  },
93
110
  {
94
111
  "name": "auriga-git-guards",
95
112
  "description": "(Claude/Codex) Git lifecycle guardrails: commit-reminder + PR-create snapshot inject + PR-ready structural block. Bundles the git-workflow skill (Claude Code + Codex).",
113
+ "agents": [
114
+ "claude",
115
+ "codex"
116
+ ],
96
117
  "expectedVersion": "1.1.0"
97
118
  },
98
119
  {
99
120
  "name": "deep-review",
100
121
  "description": "(Claude/Codex) Multi-dimensional PR review orchestrator. Dispatches parallel reviewers (spec-conformance, correctness, test-quality, docs-sync, robustness, security, ux, performance, structure, code-quality, skill-plugin-quality) and synthesizes findings into an actionable punch list. Supports project-level custom reviewers via docs/rules/review/ and ships a reviewer-creator skill for scaffolding them.",
122
+ "agents": [
123
+ "claude",
124
+ "codex"
125
+ ],
101
126
  "expectedVersion": "0.3.1"
102
127
  },
103
128
  {
104
129
  "name": "session-instructions-loader",
105
130
  "description": "(Codex) Injects extra instruction files on session start",
131
+ "agents": [
132
+ "codex"
133
+ ],
106
134
  "expectedVersion": "1.0.0"
107
135
  }
108
136
  ],
@@ -1,37 +1,28 @@
1
- // Build the scan-time Catalog (the shape src/state.ts consumes) from
2
- // auriga-cli's installed package state. This bridges the build-time
3
- // `dist/catalog.json` (which carries names + descriptions for the menu)
4
- // and the runtime scanner's need for expected hashes + versions.
1
+ // Build the scan-time Catalog (the shape src/state.ts consumes) from the
2
+ // build-time `dist/catalog.json`. This module is intentionally a *thin
3
+ // adapter* it must NOT read any file outside `dist/catalog.json`, because
4
+ // the npm tarball's `files` field allowlists only `dist/`. Reading from
5
+ // `packageRoot/CLAUDE.md`, `packageRoot/.claude/plugins.json`, or
6
+ // `packageRoot/.agents/skills/<name>/SKILL.md` succeeds in dev (where
7
+ // packageRoot === repoRoot) but silently returns empty for npm-installed
8
+ // users, leaving the scanner unable to surface real update signals.
5
9
  //
6
- // Inputs (all under packageRoot):
7
- // dist/catalog.json — names + descriptions for 5 categories
8
- // skills-lock.json — expected SHA256 for every vendored skill
9
- // .claude/plugins.json — Claude plugin entries (agent = "claude")
10
- // .agents/plugins/install.json — Codex plugin entries (agent = "codex")
11
- // .claude/hooks/hooks.json — registers settingsEvents per hook (event /
12
- // matcher / if) used by state.ts for drift
13
- // detection in <scope>/.claude/settings.json
14
- // CLAUDE.md — `# auriga Workflow (vX.Y.Z)` provides
15
- // workflowVersion
10
+ // Anything the scanner needs beyond what's already in catalog.json must
11
+ // first be baked at build time in `src/build/generate-catalog.ts`.
16
12
  //
17
- // Anything missing is treated as "no expectation" (empty hash / version)
18
- // rather than throwing; scanState will still produce a structurally valid
19
- // StateReportitems just classify as not-installed or installed
20
- // depending on whether the user-side data exists.
21
- import { createHash } from "node:crypto";
13
+ // Scope of the current bake (covered fields):
14
+ // - workflowVersion — from CLAUDE.md header
15
+ // - plugin agents map from .claude/plugins.json .agents/plugins/install.json
16
+ // - plugin expectedVersion — from plugins/<name>/.claude-plugin/plugin.json
17
+ // - plugin external flag — derived (no in-tree manifest = external)
18
+ //
19
+ // Out of scope for v1.18.4 (follow-up PRs):
20
+ // - hook expectedEvent / expectedMatcher / expectedIf (from .claude/hooks/hooks.json)
21
+ // - apply-time installer config (the install path reads .claude/plugins.json
22
+ // directly — that needs runWebUi → fetchContentRoot rewire, not bake).
22
23
  import { readFile } from "node:fs/promises";
23
24
  import path from "node:path";
24
25
  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
- const WORKFLOW_VERSION_RE = /^#\s*auriga Workflow\s*\(v([\d.]+)\)/m;
35
26
  async function tryReadFile(p) {
36
27
  try {
37
28
  return await readFile(p, "utf8");
@@ -42,24 +33,21 @@ async function tryReadFile(p) {
42
33
  }
43
34
  export async function buildScanCatalog(packageRoot) {
44
35
  const dist = loadCatalog(packageRoot);
45
- // Workflow version: parse from auriga-cli's own CLAUDE.md template.
46
- // If missing, leave as empty string so workflow always classifies as
47
- // not-installed (no expectation set).
48
- const claudeMd = await tryReadFile(path.join(packageRoot, "CLAUDE.md"));
49
- const m = claudeMd ? WORKFLOW_VERSION_RE.exec(claudeMd) : null;
50
- const workflowVersion = m ? m[1] : "";
51
- // Skills + recommended: sha256 of each shipped SKILL.md. This is the same
52
- // hash the scanner computes for `<scope>/.claude/skills/<name>/SKILL.md`
53
- // at scan time, so a match means "user's installed copy is identical to
54
- // the version auriga-cli ships". skills-lock.json's `computedHash` field
55
- // hashes the entire skill directory (every file, sorted), which doesn't
56
- // line up with the scanner's per-file model — we deliberately ignore it.
57
- const skillsRoot = path.join(packageRoot, ".agents", "skills");
36
+ // Workflow version baked from CLAUDE.md header at build time. See
37
+ // module comment for the "no runtime reads outside dist/" rule.
38
+ const workflowVersion = dist.workflowVersion ?? "";
39
+ // Skills: drift detection deliberately deferred to `npx skills update
40
+ // --project`, which already compares against the skill's own upstream
41
+ // repo HEAD. Our catalog snapshot would only know "what auriga-cli
42
+ // shipped at this CLI release" at best a stale proxy that mis-reports
43
+ // legitimate user-side updates as drift. Setting expectedHash to "" puts
44
+ // classifySkillByFile into wildcard mode: row reports installed if
45
+ // SKILL.md exists, not-installed otherwise; never update-available.
58
46
  const skills = {};
59
47
  for (const entry of dist.workflowSkills) {
60
48
  skills[entry.name] = {
61
49
  description: entry.description,
62
- expectedHash: await sha256SkillMd(skillsRoot, entry.name),
50
+ expectedHash: "",
63
51
  isWorkflow: true,
64
52
  };
65
53
  }
@@ -67,73 +55,37 @@ export async function buildScanCatalog(packageRoot) {
67
55
  for (const entry of dist.recommendedSkills) {
68
56
  recommendedSkills[entry.name] = {
69
57
  description: entry.description,
70
- expectedHash: await sha256SkillMd(skillsRoot, entry.name),
58
+ expectedHash: "",
71
59
  };
72
60
  }
73
- // Plugins: split by agent based on which config file lists them. A
74
- // plugin can appear in both registries (cross-agent plugins like
75
- // auriga-go); we represent it once per agent.
61
+ // Plugins: agents + expectedVersion + external all come from
62
+ // dist/catalog.json now (baked in src/build/generate-catalog.ts). The
63
+ // previous version of this module read .claude/plugins.json +
64
+ // .agents/plugins/install.json at runtime — those files are NOT in the
65
+ // npm tarball, so for installed users every plugin defaulted to a
66
+ // ["claude"] agent classification (root cause of dual-Agent plugin
67
+ // mis-classification in v1.18.x).
76
68
  const plugins = {};
77
- const claudePluginsText = await tryReadFile(path.join(packageRoot, ".claude", "plugins.json"));
78
- const claudeNames = new Set();
79
- if (claudePluginsText) {
80
- try {
81
- const parsed = JSON.parse(claudePluginsText);
82
- for (const p of parsed.plugins ?? []) {
83
- if (p.name)
84
- claudeNames.add(p.name);
85
- }
86
- }
87
- catch {
88
- /* ignore */
89
- }
90
- }
91
- const codexInstallText = await tryReadFile(path.join(packageRoot, ".agents", "plugins", "install.json"));
92
- const codexNames = new Set();
93
- if (codexInstallText) {
94
- try {
95
- const parsed = JSON.parse(codexInstallText);
96
- for (const p of parsed.plugins ?? []) {
97
- if (p.name)
98
- codexNames.add(p.name);
99
- }
100
- }
101
- catch {
102
- /* ignore */
103
- }
104
- }
105
69
  for (const entry of dist.plugins) {
106
- // Collect every agent that registers this plugin. A plugin can ship in
107
- // both registries (cross-agent plugins like auriga-go); we emit it as
108
- // a single multi-agent record so the UI shows one row + BOTH badge and
109
- // Apply installs to each side.
110
- const agents = [];
111
- if (claudeNames.has(entry.name))
112
- agents.push("claude");
113
- if (codexNames.has(entry.name))
114
- agents.push("codex");
115
- if (agents.length === 0)
116
- agents.push("claude"); // unknown defaults to claude
117
- // expectedVersion comes from the build-time-baked field in dist/catalog.json
118
- // (populated by `src/build/generate-catalog.ts` from
119
- // `plugins/<name>/.claude-plugin/plugin.json`). It MUST be baked at build
120
- // time because `plugins/<name>/` is NOT shipped in the npm tarball
121
- // (`package.json` `files` field allowlists only `dist/`), so reading it
122
- // at runtime from packageRoot would silently fail for npm-installed users.
70
+ const agents = Array.isArray(entry.agents) && entry.agents.length > 0
71
+ ? [...entry.agents]
72
+ : ["claude"]; // safety fallback: unknown shape defaults to claude
123
73
  plugins[entry.name] = {
124
74
  description: entry.description,
125
75
  agents,
126
76
  ...(typeof entry.expectedVersion === "string" && entry.expectedVersion.length > 0
127
77
  ? { expectedVersion: entry.expectedVersion }
128
78
  : {}),
79
+ ...(entry.external === true ? { external: true } : {}),
129
80
  };
130
81
  }
131
- // Hooks: the scanner reads <scope>/.claude/settings.json and matches by
132
- // `_marker` (see state.ts scanHooks). Drift detection compares the
133
- // registered event / matcher / if values against the hook's canonical
134
- // settingsEvents[0] from .claude/hooks/hooks.json. We deliberately do NOT
135
- // hash index.mjs the user's installed index.mjs lives at <scope>/.claude/
136
- // hooks/<name>/index.mjs and isn't part of the settings.json drift signal.
82
+ // Hooks: TODO follow-up bake expectedEvent / expectedMatcher / expectedIf
83
+ // into dist/catalog.json the same way agents are baked. Currently the
84
+ // runtime read of packageRoot/.claude/hooks/hooks.json works in dev
85
+ // (packageRoot === repoRoot) but fails silently for npm-installed users
86
+ // `package.json` `files` allowlist doesn't ship `.claude/`. So hook
87
+ // drift detection is correct in dev and degraded (always "installed" if
88
+ // marker present) in production. Follow-up bake closes the dev/prod gap.
137
89
  const hooksJsonPath = path.join(packageRoot, ".claude", "hooks", "hooks.json");
138
90
  const hooksJsonRaw = await tryReadFile(hooksJsonPath);
139
91
  const hooksJson = hooksJsonRaw ? JSON.parse(hooksJsonRaw) : {};
package/dist/state.d.ts CHANGED
@@ -15,6 +15,12 @@ export interface Catalog {
15
15
  /** Agents this plugin can install into. Length 1 or 2. */
16
16
  agents: ("claude" | "codex")[];
17
17
  expectedVersion?: string;
18
+ /** When true, this plugin is published in an UPSTREAM marketplace
19
+ * (skill-creator / claude-md-management / codex), not in this repo.
20
+ * Classifier MUST NOT report `update-available` for external plugins —
21
+ * those upgrade through `claude plugins update`, not us. The UI surfaces
22
+ * an EXTERNAL badge so users know to defer to the upstream tool. */
23
+ external?: boolean;
18
24
  }>;
19
25
  hooks: Record<string, {
20
26
  description: string;
@@ -65,23 +71,6 @@ export interface ScanOptions {
65
71
  homeDir?: string;
66
72
  }
67
73
  export declare function scanState(projectRoot: string, catalog: Catalog, opts?: ScanOptions): Promise<StateReport>;
68
- /**
69
- * Dedupe plugins by `id`, merging dual-Agent records into a single
70
- * multi-agent row. Aggregation rules:
71
- *
72
- * agents: union of all agent arrays for this id (claude before codex).
73
- * status: installed ⇔ every agent's record is installed
74
- * not-installed ⇔ every agent's record is not-installed
75
- * otherwise → update-available (partial install or any agent
76
- * with a pending update). One Apply covers all
77
- * gaps because the handler iterates `agents`.
78
- *
79
- * Non-status fields (description, currentVersion, expectedVersion,
80
- * versionSource) come from the first record we see. Today both sides report
81
- * the same description (catalog-driven) and the same versions for any
82
- * registry-pinned plugin, so this is safe; if a future divergence appears
83
- * we'll need a deliberate merge policy.
84
- */
85
74
  export declare function mergePluginsById(records: PluginState[]): PluginState[];
86
75
  /** Default: run `claude plugins list --json` (no scope flag — the CLI
87
76
  * doesn't expose one) plus the `--available` variant, then filter the
package/dist/state.js CHANGED
@@ -94,31 +94,27 @@ function dirExists(p) {
94
94
  return false;
95
95
  }
96
96
  }
97
- /**
98
- * Dedupe plugins by `id`, merging dual-Agent records into a single
99
- * multi-agent row. Aggregation rules:
100
- *
101
- * agents: union of all agent arrays for this id (claude before codex).
102
- * status: installed ⇔ every agent's record is installed
103
- * not-installed ⇔ every agent's record is not-installed
104
- * otherwise → update-available (partial install or any agent
105
- * with a pending update). One Apply covers all
106
- * gaps because the handler iterates `agents`.
107
- *
108
- * Non-status fields (description, currentVersion, expectedVersion,
109
- * versionSource) come from the first record we see. Today both sides report
110
- * the same description (catalog-driven) and the same versions for any
111
- * registry-pinned plugin, so this is safe; if a future divergence appears
112
- * we'll need a deliberate merge policy.
113
- */
114
97
  export function mergePluginsById(records) {
115
98
  const byId = new Map();
116
- const statusByIdPerAgent = new Map();
99
+ // Per-agent (agent, status, version) tuples preserved across the fold so
100
+ // the aggregation step can emit `partial-install` + `missingAgents` when
101
+ // one side is installed and the other isn't, AND pick the *stale* side's
102
+ // currentVersion when status === "update-available" (otherwise the merge
103
+ // inherited Claude's version, producing the misleading `vX → vX` display
104
+ // in the v1.18.4 verification).
105
+ const perAgentByIdEntries = new Map();
117
106
  for (const rec of records) {
107
+ // Pre-merge records are per-agent: their `agents[]` array contains the
108
+ // single Agent this row was scanned for. The merge step below unions
109
+ // those into the final dual-Agent record.
110
+ const recAgent = rec.agents[0];
111
+ const perAgentEntry = recAgent
112
+ ? { agent: recAgent, status: rec.status, currentVersion: rec.currentVersion }
113
+ : null;
118
114
  const existing = byId.get(rec.id);
119
115
  if (!existing) {
120
116
  byId.set(rec.id, { ...rec });
121
- statusByIdPerAgent.set(rec.id, [rec.status]);
117
+ perAgentByIdEntries.set(rec.id, perAgentEntry ? [perAgentEntry] : []);
122
118
  continue;
123
119
  }
124
120
  // Union agents preserving order: existing first, then any new ones.
@@ -129,28 +125,58 @@ export function mergePluginsById(records) {
129
125
  seen.add(a);
130
126
  }
131
127
  }
132
- statusByIdPerAgent.get(rec.id).push(rec.status);
128
+ if (perAgentEntry) {
129
+ perAgentByIdEntries.get(rec.id).push(perAgentEntry);
130
+ }
133
131
  }
134
- // Fold each id's per-agent status list into the aggregated status.
135
- for (const [id, statuses] of statusByIdPerAgent) {
132
+ // Fold per-agent tuples into the aggregated status, missingAgents, and
133
+ // (when applicable) the corrected currentVersion.
134
+ for (const [id, perAgent] of perAgentByIdEntries) {
136
135
  const rec = byId.get(id);
137
136
  if (!rec)
138
137
  continue;
139
- rec.status = aggregateStatus(statuses);
138
+ const aggregated = aggregateStatus(perAgent);
139
+ rec.status = aggregated.status;
140
+ if (aggregated.missingAgents && aggregated.missingAgents.length > 0) {
141
+ rec.missingAgents = aggregated.missingAgents;
142
+ }
143
+ else {
144
+ delete rec.missingAgents;
145
+ }
146
+ // When status is update-available, surface the version of the *stale*
147
+ // agent (one whose own status was update-available). Otherwise we'd
148
+ // keep whichever agent's version was merged first — Claude's, which
149
+ // may already be at the expected version, producing a `vX → vX`
150
+ // pseudo-upgrade display.
151
+ if (rec.status === "update-available") {
152
+ const stale = perAgent.find((r) => r.status === "update-available");
153
+ if (stale?.currentVersion) {
154
+ rec.currentVersion = stale.currentVersion;
155
+ }
156
+ }
140
157
  }
141
158
  return Array.from(byId.values());
142
159
  }
143
- function aggregateStatus(statuses) {
144
- if (statuses.length === 0)
145
- return "not-installed";
146
- if (statuses.every((s) => s === "installed"))
147
- return "installed";
148
- if (statuses.every((s) => s === "not-installed"))
149
- return "not-installed";
150
- // Anything else partial install, pending updates on any agent, mixed
151
- // — falls through to update-available so a single Apply backfills the
152
- // missing pieces.
153
- return "update-available";
160
+ function aggregateStatus(records) {
161
+ if (records.length === 0)
162
+ return { status: "not-installed" };
163
+ if (records.every((r) => r.status === "installed"))
164
+ return { status: "installed" };
165
+ if (records.every((r) => r.status === "not-installed"))
166
+ return { status: "not-installed" };
167
+ // Mixed. If ANY agent reports not-installed, the row is partially installed
168
+ // — the user-facing action is "install on the missing side". Surfaces as a
169
+ // distinct status from version-drift `update-available` so the UI doesn't
170
+ // render misleading `vX → vX` upgrades (the v1.18.4 deep-review symptom).
171
+ const missingAgents = records
172
+ .filter((r) => r.status === "not-installed")
173
+ .map((r) => r.agent);
174
+ if (missingAgents.length > 0) {
175
+ return { status: "partial-install", missingAgents };
176
+ }
177
+ // Otherwise version drift on at least one targeted agent — single Apply
178
+ // upgrades the stale side(s).
179
+ return { status: "update-available" };
154
180
  }
155
181
  // ---------------------------------------------------------------------------
156
182
  // Workflow
@@ -160,11 +186,12 @@ function workflowPathsForScope(scope, projectRoot, home) {
160
186
  if (scope === "user") {
161
187
  return [path.join(home, ".claude", "CLAUDE.md")];
162
188
  }
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
- ];
189
+ // Project: only `<proj>/CLAUDE.md` the auriga workflow installer
190
+ // (src/workflow.ts) writes here and never to `<proj>/.claude/CLAUDE.md`.
191
+ // The old fallback collapsed onto `$HOME/.claude/CLAUDE.md` when
192
+ // projectRoot === $HOME (user runs `web-ui` from home dir), leaking
193
+ // user-scope content into the project-scope row.
194
+ return [path.join(projectRoot, "CLAUDE.md")];
168
195
  }
169
196
  function scanWorkflow(scope, projectRoot, home, catalog, warnings) {
170
197
  const expectedVersion = catalog.workflowVersion;
@@ -197,14 +224,18 @@ function scanWorkflow(scope, projectRoot, home, catalog, warnings) {
197
224
  if (line.trim().length > 0)
198
225
  break;
199
226
  }
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.
227
+ // CLAUDE.md exists but no recognizable auriga marker. The file is foreign
228
+ // not our workflow. Report `not-installed` honestly; the install path
229
+ // (src/workflow.ts) already protects user content by backing it up to
230
+ // `CLAUDE.md.bak` before overwriting. Conflating "something exists here"
231
+ // with "auriga workflow installed" caused the v1.18.4 verification bug
232
+ // where running web-ui from `~` reported the user's `# Global`-headed
233
+ // `~/.claude/CLAUDE.md` as an installed workflow.
203
234
  warnings.push({
204
- code: "workflow-unknown-version",
205
- message: `CLAUDE.md present but no auriga-workflow header found; cannot determine installed version.`,
235
+ code: "workflow-foreign-claudemd",
236
+ message: `Foreign CLAUDE.md detected at the workflow path — no auriga-workflow header. Install will back up to CLAUDE.md.bak.`,
206
237
  });
207
- return { status: "installed", expectedVersion, observedScope: scope };
238
+ return { status: "not-installed", expectedVersion, observedScope: scope };
208
239
  }
209
240
  // ---------------------------------------------------------------------------
210
241
  // Skills + recommendedSkills
@@ -373,9 +404,13 @@ function degradedClaudeRow(id, def, scope) {
373
404
  expectedVersion: def.expectedVersion,
374
405
  versionSource: "upstream-live",
375
406
  observedScope: scope,
407
+ ...(def.external === true ? { external: true } : {}),
376
408
  };
377
409
  }
378
410
  function classifyClaudePlugin(id, def, installed, available, scope) {
411
+ // `external` propagates onto every return below so the UI can surface the
412
+ // EXTERNAL badge regardless of install state.
413
+ const externalFlag = def.external === true ? { external: true } : {};
379
414
  if (!installed || typeof installed.version !== "string") {
380
415
  return {
381
416
  id,
@@ -385,9 +420,31 @@ function classifyClaudePlugin(id, def, installed, available, scope) {
385
420
  expectedVersion: typeof available?.source?.ref === "string" ? available.source.ref : def.expectedVersion,
386
421
  versionSource: "upstream-live",
387
422
  observedScope: scope,
423
+ ...externalFlag,
388
424
  };
389
425
  }
390
426
  const installedVersion = installed.version;
427
+ // External plugin short-circuit: we don't own these, so we don't claim
428
+ // authority on "what version they should be at". `claude plugins update`
429
+ // is the right channel — the scanner just confirms presence. Status stays
430
+ // "installed" even if installed.version differs from any signal we have.
431
+ // The catalog deliberately omits `expectedVersion` for externals, but we
432
+ // double-down with this guard so a future regression that accidentally
433
+ // populates expectedVersion still can't flip externals to update-available.
434
+ if (def.external === true) {
435
+ return {
436
+ id,
437
+ description: def.description,
438
+ status: "installed",
439
+ agents: ["claude"],
440
+ currentVersion: installedVersion,
441
+ // Don't surface any "expected" on externals — the upstream tool owns
442
+ // the version conversation.
443
+ versionSource: "upstream-live",
444
+ observedScope: scope,
445
+ ...externalFlag,
446
+ };
447
+ }
391
448
  const ref = available?.source?.ref;
392
449
  const normalizedAvailable = parseRef(typeof ref === "string" ? ref : undefined);
393
450
  const normalizedInstalled = parseRef(installedVersion);
@@ -421,6 +478,7 @@ function classifyClaudePlugin(id, def, installed, available, scope) {
421
478
  expectedVersion: expectedRaw,
422
479
  versionSource,
423
480
  observedScope: scope,
481
+ ...externalFlag,
424
482
  };
425
483
  }
426
484
  if (normalizedInstalled !== null && normalizedInstalled === expectedNormalized) {
@@ -433,6 +491,7 @@ function classifyClaudePlugin(id, def, installed, available, scope) {
433
491
  expectedVersion: expectedRaw,
434
492
  versionSource,
435
493
  observedScope: scope,
494
+ ...externalFlag,
436
495
  };
437
496
  }
438
497
  return {
@@ -444,6 +503,7 @@ function classifyClaudePlugin(id, def, installed, available, scope) {
444
503
  expectedVersion: expectedRaw,
445
504
  versionSource,
446
505
  observedScope: scope,
506
+ ...externalFlag,
447
507
  };
448
508
  }
449
509
  // ---------------------------------------------------------------------------
@@ -529,10 +589,12 @@ function degradedCodexRow(id, def) {
529
589
  expectedVersion: def.expectedVersion,
530
590
  versionSource: "catalog",
531
591
  observedScope: "user",
592
+ ...(def.external === true ? { external: true } : {}),
532
593
  };
533
594
  }
534
595
  function classifyCodexPlugin(id, def, enabled, fsVersion) {
535
596
  const expectedVersion = def.expectedVersion;
597
+ const externalFlag = def.external === true ? { external: true } : {};
536
598
  if (!enabled) {
537
599
  return {
538
600
  id,
@@ -542,6 +604,7 @@ function classifyCodexPlugin(id, def, enabled, fsVersion) {
542
604
  expectedVersion,
543
605
  versionSource: "catalog",
544
606
  observedScope: "user",
607
+ ...externalFlag,
545
608
  };
546
609
  }
547
610
  if (!fsVersion) {
@@ -553,6 +616,22 @@ function classifyCodexPlugin(id, def, enabled, fsVersion) {
553
616
  expectedVersion,
554
617
  versionSource: "catalog",
555
618
  observedScope: "user",
619
+ ...externalFlag,
620
+ };
621
+ }
622
+ // External plugin short-circuit, same rationale as classifyClaudePlugin:
623
+ // we defer authority to `codex plugin marketplace update` and never flag
624
+ // update-available for upstream-owned plugins.
625
+ if (def.external === true) {
626
+ return {
627
+ id,
628
+ description: def.description,
629
+ status: "installed",
630
+ agents: ["codex"],
631
+ currentVersion: fsVersion,
632
+ versionSource: "catalog",
633
+ observedScope: "user",
634
+ ...externalFlag,
556
635
  };
557
636
  }
558
637
  if (!expectedVersion || fsVersion === expectedVersion) {
@@ -565,6 +644,7 @@ function classifyCodexPlugin(id, def, enabled, fsVersion) {
565
644
  expectedVersion,
566
645
  versionSource: "catalog",
567
646
  observedScope: "user",
647
+ ...externalFlag,
568
648
  };
569
649
  }
570
650
  return {
@@ -576,6 +656,7 @@ function classifyCodexPlugin(id, def, enabled, fsVersion) {
576
656
  expectedVersion,
577
657
  versionSource: "catalog",
578
658
  observedScope: "user",
659
+ ...externalFlag,
579
660
  };
580
661
  }
581
662
  /** Return the set of plugin ids whose `[plugins."<id>"]` table has
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auriga-cli",
3
- "version": "1.18.3",
3
+ "version": "1.18.5",
4
4
  "description": "Interactive CLI to install Claude Code harness modules (Workflow, Skills, Recommended Skills, Plugins, Hooks)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,8 +25,8 @@
25
25
  "dev": "tsc --watch",
26
26
  "start": "node dist/cli.js",
27
27
  "pretest": "npm run build",
28
- "test": "tsc -p tsconfig.test.json && DEV=1 node --test --experimental-test-module-mocks dist-test/tests/hooks.test.js dist-test/tests/hooks-uninstall.test.js dist-test/tests/skills.test.js dist-test/tests/skills-uninstall.test.js dist-test/tests/catalog.test.js dist-test/tests/cli-parse.test.js dist-test/tests/install-nontty.test.js dist-test/tests/plugins.test.js dist-test/tests/plugins-uninstall.test.js dist-test/tests/content-fetch.test.js dist-test/tests/utils.test.js dist-test/tests/guide.test.js dist-test/tests/validators.test.js dist-test/tests/entrypoint.test.js dist-test/tests/state.test.js dist-test/tests/server.test.js dist-test/tests/server-auth.test.js dist-test/tests/server-apply.test.js dist-test/tests/apply-handlers.test.js dist-test/tests/ui-fetch.test.js dist-test/tests/workflow-uninstall.test.js",
29
- "test:watch": "tsc -p tsconfig.test.json --watch & node --test --watch --experimental-test-module-mocks dist-test/tests/hooks.test.js dist-test/tests/hooks-uninstall.test.js dist-test/tests/skills.test.js dist-test/tests/skills-uninstall.test.js dist-test/tests/catalog.test.js dist-test/tests/cli-parse.test.js dist-test/tests/install-nontty.test.js dist-test/tests/plugins.test.js dist-test/tests/plugins-uninstall.test.js dist-test/tests/content-fetch.test.js dist-test/tests/utils.test.js dist-test/tests/guide.test.js dist-test/tests/validators.test.js dist-test/tests/entrypoint.test.js dist-test/tests/state.test.js dist-test/tests/server.test.js dist-test/tests/server-auth.test.js dist-test/tests/server-apply.test.js dist-test/tests/apply-handlers.test.js dist-test/tests/ui-fetch.test.js dist-test/tests/workflow-uninstall.test.js",
28
+ "test": "tsc -p tsconfig.test.json && DEV=1 node --test --experimental-test-module-mocks dist-test/tests/hooks.test.js dist-test/tests/hooks-uninstall.test.js dist-test/tests/skills.test.js dist-test/tests/skills-uninstall.test.js dist-test/tests/catalog.test.js dist-test/tests/cli-parse.test.js dist-test/tests/install-nontty.test.js dist-test/tests/plugins.test.js dist-test/tests/plugins-uninstall.test.js dist-test/tests/content-fetch.test.js dist-test/tests/utils.test.js dist-test/tests/guide.test.js dist-test/tests/validators.test.js dist-test/tests/entrypoint.test.js dist-test/tests/state.test.js dist-test/tests/server.test.js dist-test/tests/server-auth.test.js dist-test/tests/server-apply.test.js dist-test/tests/apply-handlers.test.js dist-test/tests/ui-fetch.test.js dist-test/tests/workflow-uninstall.test.js dist-test/tests/tarball-shape.test.js",
29
+ "test:watch": "tsc -p tsconfig.test.json --watch & node --test --watch --experimental-test-module-mocks dist-test/tests/hooks.test.js dist-test/tests/hooks-uninstall.test.js dist-test/tests/skills.test.js dist-test/tests/skills-uninstall.test.js dist-test/tests/catalog.test.js dist-test/tests/cli-parse.test.js dist-test/tests/install-nontty.test.js dist-test/tests/plugins.test.js dist-test/tests/plugins-uninstall.test.js dist-test/tests/content-fetch.test.js dist-test/tests/utils.test.js dist-test/tests/guide.test.js dist-test/tests/validators.test.js dist-test/tests/entrypoint.test.js dist-test/tests/state.test.js dist-test/tests/server.test.js dist-test/tests/server-auth.test.js dist-test/tests/server-apply.test.js dist-test/tests/apply-handlers.test.js dist-test/tests/ui-fetch.test.js dist-test/tests/workflow-uninstall.test.js dist-test/tests/tarball-shape.test.js",
30
30
  "pretest:e2e": "npm run build",
31
31
  "test:e2e": "tsc -p tsconfig.test.json && node --test dist-test/tests/e2e-install.test.js",
32
32
  "pretest:web-ui-e2e": "npm run build && npm --prefix ui ci && npm --prefix ui run build",