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