auriga-cli 1.18.5 → 1.20.0
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 +15 -23
- package/README.zh-CN.md +14 -23
- package/dist/api-types.d.ts +11 -25
- package/dist/apply-handlers.js +11 -9
- package/dist/catalog.d.ts +3 -19
- package/dist/catalog.json +21 -28
- package/dist/cli.js +49 -6
- package/dist/guide.js +10 -9
- package/dist/help.js +1 -1
- package/dist/hooks.js +6 -1
- package/dist/plugins.js +182 -7
- package/dist/scan-catalog.js +11 -87
- package/dist/server.js +1 -1
- package/dist/skills.js +0 -3
- package/dist/state.d.ts +15 -34
- package/dist/state.js +85 -399
- package/dist/utils.d.ts +1 -0
- 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);
|
|
@@ -96,12 +93,6 @@ function dirExists(p) {
|
|
|
96
93
|
}
|
|
97
94
|
export function mergePluginsById(records) {
|
|
98
95
|
const byId = 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
96
|
const perAgentByIdEntries = new Map();
|
|
106
97
|
for (const rec of records) {
|
|
107
98
|
// Pre-merge records are per-agent: their `agents[]` array contains the
|
|
@@ -109,7 +100,7 @@ export function mergePluginsById(records) {
|
|
|
109
100
|
// those into the final dual-Agent record.
|
|
110
101
|
const recAgent = rec.agents[0];
|
|
111
102
|
const perAgentEntry = recAgent
|
|
112
|
-
? { agent: recAgent, status: rec.status
|
|
103
|
+
? { agent: recAgent, status: rec.status }
|
|
113
104
|
: null;
|
|
114
105
|
const existing = byId.get(rec.id);
|
|
115
106
|
if (!existing) {
|
|
@@ -117,7 +108,6 @@ export function mergePluginsById(records) {
|
|
|
117
108
|
perAgentByIdEntries.set(rec.id, perAgentEntry ? [perAgentEntry] : []);
|
|
118
109
|
continue;
|
|
119
110
|
}
|
|
120
|
-
// Union agents preserving order: existing first, then any new ones.
|
|
121
111
|
const seen = new Set(existing.agents);
|
|
122
112
|
for (const a of rec.agents) {
|
|
123
113
|
if (!seen.has(a)) {
|
|
@@ -129,8 +119,6 @@ export function mergePluginsById(records) {
|
|
|
129
119
|
perAgentByIdEntries.get(rec.id).push(perAgentEntry);
|
|
130
120
|
}
|
|
131
121
|
}
|
|
132
|
-
// Fold per-agent tuples into the aggregated status, missingAgents, and
|
|
133
|
-
// (when applicable) the corrected currentVersion.
|
|
134
122
|
for (const [id, perAgent] of perAgentByIdEntries) {
|
|
135
123
|
const rec = byId.get(id);
|
|
136
124
|
if (!rec)
|
|
@@ -143,17 +131,6 @@ export function mergePluginsById(records) {
|
|
|
143
131
|
else {
|
|
144
132
|
delete rec.missingAgents;
|
|
145
133
|
}
|
|
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
|
-
}
|
|
157
134
|
}
|
|
158
135
|
return Array.from(byId.values());
|
|
159
136
|
}
|
|
@@ -164,24 +141,17 @@ function aggregateStatus(records) {
|
|
|
164
141
|
return { status: "installed" };
|
|
165
142
|
if (records.every((r) => r.status === "not-installed"))
|
|
166
143
|
return { status: "not-installed" };
|
|
167
|
-
// Mixed
|
|
168
|
-
//
|
|
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).
|
|
144
|
+
// Mixed: dual-Agent plugin with some agents installed and some not.
|
|
145
|
+
// User-facing action is "install on the missing side".
|
|
171
146
|
const missingAgents = records
|
|
172
147
|
.filter((r) => r.status === "not-installed")
|
|
173
148
|
.map((r) => r.agent);
|
|
174
|
-
|
|
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" };
|
|
149
|
+
return { status: "partial-install", missingAgents };
|
|
180
150
|
}
|
|
181
151
|
// ---------------------------------------------------------------------------
|
|
182
152
|
// Workflow
|
|
183
153
|
// ---------------------------------------------------------------------------
|
|
184
|
-
const WORKFLOW_HEADER_RE = /^#\s+auriga\s+Workflow\s*\(v
|
|
154
|
+
const WORKFLOW_HEADER_RE = /^#\s+auriga\s+Workflow\s*\(v\d+\.\d+\.\d+\)/;
|
|
185
155
|
function workflowPathsForScope(scope, projectRoot, home) {
|
|
186
156
|
if (scope === "user") {
|
|
187
157
|
return [path.join(home, ".claude", "CLAUDE.md")];
|
|
@@ -193,8 +163,7 @@ function workflowPathsForScope(scope, projectRoot, home) {
|
|
|
193
163
|
// user-scope content into the project-scope row.
|
|
194
164
|
return [path.join(projectRoot, "CLAUDE.md")];
|
|
195
165
|
}
|
|
196
|
-
function scanWorkflow(scope, projectRoot, home,
|
|
197
|
-
const expectedVersion = catalog.workflowVersion;
|
|
166
|
+
function scanWorkflow(scope, projectRoot, home, warnings) {
|
|
198
167
|
const candidates = workflowPathsForScope(scope, projectRoot, home);
|
|
199
168
|
let content = null;
|
|
200
169
|
for (const candidate of candidates) {
|
|
@@ -207,35 +176,27 @@ function scanWorkflow(scope, projectRoot, home, catalog, warnings) {
|
|
|
207
176
|
}
|
|
208
177
|
}
|
|
209
178
|
if (content === null) {
|
|
210
|
-
return { status: "not-installed",
|
|
179
|
+
return { status: "not-installed", observedScope: scope };
|
|
211
180
|
}
|
|
212
|
-
// 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.
|
|
213
184
|
for (const line of content.split(/\r?\n/)) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const currentVersion = m[1];
|
|
217
|
-
// Empty expectedVersion means scan-catalog couldn't extract the
|
|
218
|
-
// shipped workflow's header (auriga-cli's own CLAUDE.md missing or
|
|
219
|
-
// malformed at build time). Trust the installed version rather than
|
|
220
|
-
// forcing a phantom "update-available" against the empty string.
|
|
221
|
-
const status = !expectedVersion || currentVersion === expectedVersion ? "installed" : "update-available";
|
|
222
|
-
return { status, expectedVersion, currentVersion, observedScope: scope };
|
|
185
|
+
if (WORKFLOW_HEADER_RE.test(line)) {
|
|
186
|
+
return { status: "installed", observedScope: scope };
|
|
223
187
|
}
|
|
224
188
|
if (line.trim().length > 0)
|
|
225
189
|
break;
|
|
226
190
|
}
|
|
227
191
|
// CLAUDE.md exists but no recognizable auriga marker. The file is foreign
|
|
228
192
|
// — not our workflow. Report `not-installed` honestly; the install path
|
|
229
|
-
// (src/workflow.ts)
|
|
230
|
-
// `CLAUDE.md.bak`
|
|
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.
|
|
193
|
+
// (src/workflow.ts) protects user content by backing it up to
|
|
194
|
+
// `CLAUDE.md.bak` (backup-once: never clobbers a prior .bak).
|
|
234
195
|
warnings.push({
|
|
235
196
|
code: "workflow-foreign-claudemd",
|
|
236
197
|
message: `Foreign CLAUDE.md detected at the workflow path — no auriga-workflow header. Install will back up to CLAUDE.md.bak.`,
|
|
237
198
|
});
|
|
238
|
-
return { status: "not-installed",
|
|
199
|
+
return { status: "not-installed", observedScope: scope };
|
|
239
200
|
}
|
|
240
201
|
// ---------------------------------------------------------------------------
|
|
241
202
|
// Skills + recommendedSkills
|
|
@@ -245,50 +206,38 @@ function skillsRoot(scope, projectRoot, home) {
|
|
|
245
206
|
return path.join(home, ".claude", "skills");
|
|
246
207
|
return path.join(projectRoot, ".claude", "skills");
|
|
247
208
|
}
|
|
248
|
-
/** Classify a single skill by
|
|
249
|
-
*
|
|
250
|
-
* `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
|
|
251
212
|
* missing/unreadable — the caller emits ONE skill-malformed warning per
|
|
252
213
|
* scan. */
|
|
253
|
-
function classifySkillByFile(name,
|
|
214
|
+
function classifySkillByFile(name, rootDir, malformedSeen) {
|
|
254
215
|
const skillDir = path.join(rootDir, name);
|
|
255
216
|
const skillMd = path.join(skillDir, "SKILL.md");
|
|
256
|
-
let buf;
|
|
257
217
|
try {
|
|
258
|
-
|
|
218
|
+
fs.readFileSync(skillMd);
|
|
219
|
+
return "installed";
|
|
259
220
|
}
|
|
260
221
|
catch {
|
|
261
|
-
// SKILL.md unreadable. Two sub-cases:
|
|
262
|
-
// (a) skill dir also missing → simply not installed.
|
|
263
|
-
// (b) skill dir present but SKILL.md missing → malformed; row stays
|
|
264
|
-
// "installed" so the user can repair, plus a warning.
|
|
265
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.
|
|
266
225
|
malformedSeen.add(name);
|
|
267
|
-
return
|
|
226
|
+
return "installed";
|
|
268
227
|
}
|
|
269
|
-
return
|
|
270
|
-
}
|
|
271
|
-
const currentHash = createHash("sha256").update(buf).digest("hex");
|
|
272
|
-
if (expectedHash === "" ||
|
|
273
|
-
expectedHash === WILDCARD_EXPECTED_HASH ||
|
|
274
|
-
currentHash === expectedHash) {
|
|
275
|
-
return { status: "installed", currentHash };
|
|
228
|
+
return "not-installed";
|
|
276
229
|
}
|
|
277
|
-
return { status: "update-available", currentHash };
|
|
278
230
|
}
|
|
279
231
|
function scanSkills(scope, projectRoot, home, catalogSkills, _recommended, warnings) {
|
|
280
232
|
const rootDir = skillsRoot(scope, projectRoot, home);
|
|
281
233
|
const malformed = new Set();
|
|
282
234
|
const out = [];
|
|
283
235
|
for (const [name, entry] of Object.entries(catalogSkills)) {
|
|
284
|
-
const cls = classifySkillByFile(name, entry.expectedHash, rootDir, malformed);
|
|
285
236
|
out.push({
|
|
286
237
|
name,
|
|
287
238
|
description: entry.description,
|
|
288
|
-
status:
|
|
239
|
+
status: classifySkillByFile(name, rootDir, malformed),
|
|
289
240
|
isWorkflow: entry.isWorkflow,
|
|
290
|
-
currentHash: cls.currentHash,
|
|
291
|
-
expectedHash: entry.expectedHash,
|
|
292
241
|
observedScope: scope,
|
|
293
242
|
});
|
|
294
243
|
}
|
|
@@ -305,14 +254,11 @@ function scanRecommendedSkills(scope, projectRoot, home, catalogRec, warnings) {
|
|
|
305
254
|
const malformed = new Set();
|
|
306
255
|
const out = [];
|
|
307
256
|
for (const [name, entry] of Object.entries(catalogRec)) {
|
|
308
|
-
const cls = classifySkillByFile(name, entry.expectedHash, rootDir, malformed);
|
|
309
257
|
out.push({
|
|
310
258
|
name,
|
|
311
259
|
description: entry.description,
|
|
312
|
-
status:
|
|
260
|
+
status: classifySkillByFile(name, rootDir, malformed),
|
|
313
261
|
isWorkflow: false,
|
|
314
|
-
currentHash: cls.currentHash,
|
|
315
|
-
expectedHash: entry.expectedHash,
|
|
316
262
|
observedScope: scope,
|
|
317
263
|
});
|
|
318
264
|
}
|
|
@@ -330,15 +276,6 @@ function scanRecommendedSkills(scope, projectRoot, home, catalogRec, warnings) {
|
|
|
330
276
|
function filterPluginsByAgent(catalogPlugins, agent) {
|
|
331
277
|
return Object.entries(catalogPlugins).filter(([, def]) => def.agents.includes(agent));
|
|
332
278
|
}
|
|
333
|
-
/** Normalize a version ref: "v1.2.3" → "1.2.3", "1.2.3" → "1.2.3".
|
|
334
|
-
* Anything that is not a strict semver-like triple is returned as-is so the
|
|
335
|
-
* caller can detect "this is a branch / tag, not a comparable version". */
|
|
336
|
-
function parseRef(ref) {
|
|
337
|
-
if (typeof ref !== "string" || ref.length === 0)
|
|
338
|
-
return null;
|
|
339
|
-
const m = /^v?(\d+\.\d+\.\d+(?:[-+][\w.]+)?)$/.exec(ref);
|
|
340
|
-
return m ? m[1] : null;
|
|
341
|
-
}
|
|
342
279
|
async function scanClaudePlugins(scope, entries, execPluginList, warnings) {
|
|
343
280
|
if (entries.length === 0)
|
|
344
281
|
return [];
|
|
@@ -348,7 +285,7 @@ async function scanClaudePlugins(scope, entries, execPluginList, warnings) {
|
|
|
348
285
|
if (!execPluginList) {
|
|
349
286
|
warnings.push({
|
|
350
287
|
code: "claude-cli-missing",
|
|
351
|
-
message: "Claude CLI not available — plugin
|
|
288
|
+
message: "Claude CLI not available — plugin presence detection disabled. Install `claude` to enable.",
|
|
352
289
|
});
|
|
353
290
|
return entries.map(([id, def]) => degradedClaudeRow(id, def, scope));
|
|
354
291
|
}
|
|
@@ -366,32 +303,22 @@ async function scanClaudePlugins(scope, entries, execPluginList, warnings) {
|
|
|
366
303
|
// claude plugins list emits ids in `<plugin>@<marketplace>` form (e.g.
|
|
367
304
|
// `auriga-go@auriga-cli`). The auriga-cli catalog tracks plugins by bare
|
|
368
305
|
// name. Index both forms so lookups succeed regardless of which side the
|
|
369
|
-
// suffix is on.
|
|
370
|
-
|
|
371
|
-
const
|
|
306
|
+
// suffix is on.
|
|
307
|
+
const installedById = new Map();
|
|
308
|
+
for (const item of payload.installed ?? []) {
|
|
372
309
|
if (!item || typeof item !== "object")
|
|
373
|
-
|
|
374
|
-
const key = typeof item.id === "string"
|
|
375
|
-
? item.id
|
|
376
|
-
: typeof item.pluginId === "string"
|
|
377
|
-
? item.pluginId
|
|
378
|
-
: null;
|
|
310
|
+
continue;
|
|
311
|
+
const key = typeof item.id === "string" ? item.id : null;
|
|
379
312
|
if (!key)
|
|
380
|
-
|
|
381
|
-
|
|
313
|
+
continue;
|
|
314
|
+
installedById.set(key, item);
|
|
382
315
|
const at = key.indexOf("@");
|
|
383
316
|
if (at > 0)
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
const installedById = new Map();
|
|
387
|
-
for (const item of payload.installed ?? [])
|
|
388
|
-
indexBoth(installedById, item);
|
|
389
|
-
const availableById = new Map();
|
|
390
|
-
for (const item of payload.available ?? [])
|
|
391
|
-
indexBoth(availableById, item);
|
|
317
|
+
installedById.set(key.slice(0, at), item);
|
|
318
|
+
}
|
|
392
319
|
const out = [];
|
|
393
320
|
for (const [id, def] of entries) {
|
|
394
|
-
out.push(classifyClaudePlugin(id, def, installedById.get(id),
|
|
321
|
+
out.push(classifyClaudePlugin(id, def, installedById.get(id), scope));
|
|
395
322
|
}
|
|
396
323
|
return out;
|
|
397
324
|
}
|
|
@@ -401,107 +328,21 @@ function degradedClaudeRow(id, def, scope) {
|
|
|
401
328
|
description: def.description,
|
|
402
329
|
status: "not-installed",
|
|
403
330
|
agents: ["claude"],
|
|
404
|
-
expectedVersion: def.expectedVersion,
|
|
405
|
-
versionSource: "upstream-live",
|
|
406
331
|
observedScope: scope,
|
|
407
332
|
...(def.external === true ? { external: true } : {}),
|
|
408
333
|
};
|
|
409
334
|
}
|
|
410
|
-
function classifyClaudePlugin(id, def, installed,
|
|
411
|
-
// `external` propagates onto every return below so the UI can surface the
|
|
412
|
-
// EXTERNAL badge regardless of install state.
|
|
335
|
+
function classifyClaudePlugin(id, def, installed, scope) {
|
|
413
336
|
const externalFlag = def.external === true ? { external: true } : {};
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
status: "not-installed",
|
|
419
|
-
agents: ["claude"],
|
|
420
|
-
expectedVersion: typeof available?.source?.ref === "string" ? available.source.ref : def.expectedVersion,
|
|
421
|
-
versionSource: "upstream-live",
|
|
422
|
-
observedScope: scope,
|
|
423
|
-
...externalFlag,
|
|
424
|
-
};
|
|
425
|
-
}
|
|
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
|
-
}
|
|
448
|
-
const ref = available?.source?.ref;
|
|
449
|
-
const normalizedAvailable = parseRef(typeof ref === "string" ? ref : undefined);
|
|
450
|
-
const normalizedInstalled = parseRef(installedVersion);
|
|
451
|
-
// Pick the comparison target. The marketplace-live ref wins when it's a
|
|
452
|
-
// parseable semver — that's the freshest signal. Otherwise fall back to
|
|
453
|
-
// the build-time-baked `def.expectedVersion` (populated from
|
|
454
|
-
// plugins/<name>/.claude-plugin/plugin.json by scan-catalog for owned
|
|
455
|
-
// plugins). Without the fallback, the common upgrade case is invisible:
|
|
456
|
-
// `claude plugins list --available --json` excludes already-installed
|
|
457
|
-
// plugins from `.available[]`, so for any plugin the user already has,
|
|
458
|
-
// `ref` is undefined and the scanner can't tell whether a newer version
|
|
459
|
-
// ships in the marketplace.
|
|
460
|
-
const hasLiveRef = normalizedAvailable !== null && typeof ref === "string";
|
|
461
|
-
const expectedRaw = hasLiveRef ? ref : def.expectedVersion;
|
|
462
|
-
const expectedNormalized = hasLiveRef
|
|
463
|
-
? normalizedAvailable
|
|
464
|
-
: parseRef(def.expectedVersion);
|
|
465
|
-
const versionSource = hasLiveRef
|
|
466
|
-
? "upstream-live"
|
|
467
|
-
: "catalog";
|
|
468
|
-
// Fallback rules (no comparable expected version, or unknown installed):
|
|
469
|
-
// - installed version "unknown" → trust it's installed.
|
|
470
|
-
// - effective expected is null (branch ref + no baked version) → trust installed.
|
|
471
|
-
if (installedVersion === "unknown" || expectedNormalized === null) {
|
|
472
|
-
return {
|
|
473
|
-
id,
|
|
474
|
-
description: def.description,
|
|
475
|
-
status: "installed",
|
|
476
|
-
agents: ["claude"],
|
|
477
|
-
currentVersion: installedVersion,
|
|
478
|
-
expectedVersion: expectedRaw,
|
|
479
|
-
versionSource,
|
|
480
|
-
observedScope: scope,
|
|
481
|
-
...externalFlag,
|
|
482
|
-
};
|
|
483
|
-
}
|
|
484
|
-
if (normalizedInstalled !== null && normalizedInstalled === expectedNormalized) {
|
|
485
|
-
return {
|
|
486
|
-
id,
|
|
487
|
-
description: def.description,
|
|
488
|
-
status: "installed",
|
|
489
|
-
agents: ["claude"],
|
|
490
|
-
currentVersion: installedVersion,
|
|
491
|
-
expectedVersion: expectedRaw,
|
|
492
|
-
versionSource,
|
|
493
|
-
observedScope: scope,
|
|
494
|
-
...externalFlag,
|
|
495
|
-
};
|
|
496
|
-
}
|
|
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";
|
|
497
341
|
return {
|
|
498
342
|
id,
|
|
499
343
|
description: def.description,
|
|
500
|
-
status
|
|
344
|
+
status,
|
|
501
345
|
agents: ["claude"],
|
|
502
|
-
currentVersion: installedVersion,
|
|
503
|
-
expectedVersion: expectedRaw,
|
|
504
|
-
versionSource,
|
|
505
346
|
observedScope: scope,
|
|
506
347
|
...externalFlag,
|
|
507
348
|
};
|
|
@@ -552,7 +393,7 @@ async function scanCodexPlugins(entries, readCodexConfig, readCodexPluginsDir, w
|
|
|
552
393
|
// both emit `<plugin>@<marketplace>` keys (e.g. "auriga-go@auriga-cli").
|
|
553
394
|
// Without dual indexing every dual-Agent plugin reports `not-installed` on
|
|
554
395
|
// the Codex side, which `mergePluginsById` then folds into a permanent
|
|
555
|
-
// `
|
|
396
|
+
// `partial-install` even when both sides are genuinely installed.
|
|
556
397
|
const lookupEnabled = (catalogId) => {
|
|
557
398
|
if (enabledIds.has(catalogId))
|
|
558
399
|
return true;
|
|
@@ -586,75 +427,18 @@ function degradedCodexRow(id, def) {
|
|
|
586
427
|
description: def.description,
|
|
587
428
|
status: "not-installed",
|
|
588
429
|
agents: ["codex"],
|
|
589
|
-
expectedVersion: def.expectedVersion,
|
|
590
|
-
versionSource: "catalog",
|
|
591
430
|
observedScope: "user",
|
|
592
431
|
...(def.external === true ? { external: true } : {}),
|
|
593
432
|
};
|
|
594
433
|
}
|
|
595
434
|
function classifyCodexPlugin(id, def, enabled, fsVersion) {
|
|
596
|
-
const expectedVersion = def.expectedVersion;
|
|
597
435
|
const externalFlag = def.external === true ? { external: true } : {};
|
|
598
|
-
|
|
599
|
-
return {
|
|
600
|
-
id,
|
|
601
|
-
description: def.description,
|
|
602
|
-
status: "not-installed",
|
|
603
|
-
agents: ["codex"],
|
|
604
|
-
expectedVersion,
|
|
605
|
-
versionSource: "catalog",
|
|
606
|
-
observedScope: "user",
|
|
607
|
-
...externalFlag,
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
if (!fsVersion) {
|
|
611
|
-
return {
|
|
612
|
-
id,
|
|
613
|
-
description: def.description,
|
|
614
|
-
status: "not-installed",
|
|
615
|
-
agents: ["codex"],
|
|
616
|
-
expectedVersion,
|
|
617
|
-
versionSource: "catalog",
|
|
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,
|
|
635
|
-
};
|
|
636
|
-
}
|
|
637
|
-
if (!expectedVersion || fsVersion === expectedVersion) {
|
|
638
|
-
return {
|
|
639
|
-
id,
|
|
640
|
-
description: def.description,
|
|
641
|
-
status: "installed",
|
|
642
|
-
agents: ["codex"],
|
|
643
|
-
currentVersion: fsVersion,
|
|
644
|
-
expectedVersion,
|
|
645
|
-
versionSource: "catalog",
|
|
646
|
-
observedScope: "user",
|
|
647
|
-
...externalFlag,
|
|
648
|
-
};
|
|
649
|
-
}
|
|
436
|
+
const status = enabled && fsVersion ? "installed" : "not-installed";
|
|
650
437
|
return {
|
|
651
438
|
id,
|
|
652
439
|
description: def.description,
|
|
653
|
-
status
|
|
440
|
+
status,
|
|
654
441
|
agents: ["codex"],
|
|
655
|
-
currentVersion: fsVersion,
|
|
656
|
-
expectedVersion,
|
|
657
|
-
versionSource: "catalog",
|
|
658
442
|
observedScope: "user",
|
|
659
443
|
...externalFlag,
|
|
660
444
|
};
|
|
@@ -702,92 +486,38 @@ function settingsPathForScope(scope, projectRoot, home) {
|
|
|
702
486
|
return path.join(home, ".claude", "settings.json");
|
|
703
487
|
return path.join(projectRoot, ".claude", "settings.json");
|
|
704
488
|
}
|
|
705
|
-
/**
|
|
706
|
-
* `
|
|
707
|
-
|
|
708
|
-
|
|
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();
|
|
709
496
|
if (!settings || typeof settings !== "object" || Array.isArray(settings))
|
|
710
497
|
return out;
|
|
711
498
|
const hooksSeg = settings.hooks;
|
|
712
499
|
if (!hooksSeg || typeof hooksSeg !== "object" || Array.isArray(hooksSeg))
|
|
713
500
|
return out;
|
|
714
|
-
for (const
|
|
501
|
+
for (const blocks of Object.values(hooksSeg)) {
|
|
715
502
|
if (!Array.isArray(blocks))
|
|
716
503
|
continue;
|
|
717
504
|
for (const block of blocks) {
|
|
718
505
|
if (!block || typeof block !== "object" || Array.isArray(block))
|
|
719
506
|
continue;
|
|
720
|
-
const
|
|
721
|
-
const matcher = typeof b.matcher === "string" ? b.matcher : undefined;
|
|
722
|
-
const ifExpr = typeof b.if === "string" ? b.if : undefined;
|
|
723
|
-
const actions = b.hooks;
|
|
507
|
+
const actions = block.hooks;
|
|
724
508
|
if (!Array.isArray(actions))
|
|
725
509
|
continue;
|
|
726
510
|
for (const action of actions) {
|
|
727
511
|
if (!action || typeof action !== "object" || Array.isArray(action))
|
|
728
512
|
continue;
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
continue;
|
|
733
|
-
out.set(marker, {
|
|
734
|
-
event,
|
|
735
|
-
matcher,
|
|
736
|
-
ifExpr,
|
|
737
|
-
command: typeof a.command === "string" ? a.command : undefined,
|
|
738
|
-
});
|
|
513
|
+
const marker = action._marker;
|
|
514
|
+
if (typeof marker === "string")
|
|
515
|
+
out.add(marker);
|
|
739
516
|
}
|
|
740
517
|
}
|
|
741
518
|
}
|
|
742
519
|
return out;
|
|
743
520
|
}
|
|
744
|
-
/** Compute a coarse sha256 signature over a settings hook entry's drift-
|
|
745
|
-
* relevant fields (event, matcher, if). Used to fall back to a single-
|
|
746
|
-
* field comparison when the catalog hasn't been upgraded to expose
|
|
747
|
-
* structured expectedMatcher / expectedEvent / expectedIf. */
|
|
748
|
-
function signatureForSettingsEntry(entry) {
|
|
749
|
-
const canonical = JSON.stringify({
|
|
750
|
-
event: entry.event,
|
|
751
|
-
matcher: entry.matcher ?? "",
|
|
752
|
-
if: entry.ifExpr ?? "",
|
|
753
|
-
});
|
|
754
|
-
return createHash("sha256").update(canonical).digest("hex");
|
|
755
|
-
}
|
|
756
|
-
/** Returns true when the catalog's expectedHash is a wildcard sentinel
|
|
757
|
-
* (empty string or the literal "any" placeholder). Wildcard means "no
|
|
758
|
-
* drift expectation for this hook — trust marker presence." */
|
|
759
|
-
function isWildcardExpectedHash(expectedHash) {
|
|
760
|
-
return expectedHash === "" || expectedHash === WILDCARD_EXPECTED_HASH;
|
|
761
|
-
}
|
|
762
|
-
function detectHookDrift(catalogEntry, settingsEntry) {
|
|
763
|
-
// Preferred drift path: structured expectations from catalog.
|
|
764
|
-
if (typeof catalogEntry.expectedMatcher === "string" &&
|
|
765
|
-
(settingsEntry.matcher ?? "") !== catalogEntry.expectedMatcher) {
|
|
766
|
-
return true;
|
|
767
|
-
}
|
|
768
|
-
if (typeof catalogEntry.expectedEvent === "string" &&
|
|
769
|
-
settingsEntry.event !== catalogEntry.expectedEvent) {
|
|
770
|
-
return true;
|
|
771
|
-
}
|
|
772
|
-
if (typeof catalogEntry.expectedIf === "string" &&
|
|
773
|
-
(settingsEntry.ifExpr ?? "") !== catalogEntry.expectedIf) {
|
|
774
|
-
return true;
|
|
775
|
-
}
|
|
776
|
-
// Fallback drift signal via expectedHash. When the catalog hasn't been
|
|
777
|
-
// populated with structured expected* fields (yet), expectedHash doubles
|
|
778
|
-
// as a coarse signature: if non-empty and non-wildcard, the implementation
|
|
779
|
-
// computes its own signature over the settings entry and treats any
|
|
780
|
-
// divergence as drift. Production scan-catalog.ts can populate this with
|
|
781
|
-
// a real settings-entry signature; until then, an explicit non-wildcard
|
|
782
|
-
// placeholder in tests (e.g. "expected-new-matcher-signature") deliberately
|
|
783
|
-
// triggers drift since it can never equal a sha256 hex digest.
|
|
784
|
-
if (!isWildcardExpectedHash(catalogEntry.expectedHash)) {
|
|
785
|
-
const sig = signatureForSettingsEntry(settingsEntry);
|
|
786
|
-
if (sig !== catalogEntry.expectedHash)
|
|
787
|
-
return true;
|
|
788
|
-
}
|
|
789
|
-
return false;
|
|
790
|
-
}
|
|
791
521
|
function scanHooks(scope, projectRoot, home, catalogHooks, warnings) {
|
|
792
522
|
const settingsPath = settingsPathForScope(scope, projectRoot, home);
|
|
793
523
|
let settingsRaw = null;
|
|
@@ -819,30 +549,13 @@ function scanHooks(scope, projectRoot, home, catalogHooks, warnings) {
|
|
|
819
549
|
message: `Settings file unreadable or corrupt JSON: ${settingsPath}`,
|
|
820
550
|
});
|
|
821
551
|
}
|
|
822
|
-
const
|
|
552
|
+
const markers = indexSettingsMarkers(parsed);
|
|
823
553
|
const out = [];
|
|
824
554
|
for (const [name, def] of Object.entries(catalogHooks)) {
|
|
825
|
-
const settingsEntry = byMarker.get(name);
|
|
826
|
-
if (!settingsEntry) {
|
|
827
|
-
out.push({
|
|
828
|
-
name,
|
|
829
|
-
description: def.description,
|
|
830
|
-
status: "not-installed",
|
|
831
|
-
expectedHash: def.expectedHash,
|
|
832
|
-
observedScope: scope,
|
|
833
|
-
});
|
|
834
|
-
continue;
|
|
835
|
-
}
|
|
836
|
-
const drift = detectHookDrift(def, settingsEntry);
|
|
837
555
|
out.push({
|
|
838
556
|
name,
|
|
839
557
|
description: def.description,
|
|
840
|
-
status:
|
|
841
|
-
// Surface a coarse current signature so the UI can show diff details
|
|
842
|
-
// if it wants. The exact format is "sha256 of normalized settings
|
|
843
|
-
// entry" — opaque to the UI, used only for drift detection.
|
|
844
|
-
currentHash: signatureForSettingsEntry(settingsEntry),
|
|
845
|
-
expectedHash: def.expectedHash,
|
|
558
|
+
status: markers.has(name) ? "installed" : "not-installed",
|
|
846
559
|
observedScope: scope,
|
|
847
560
|
});
|
|
848
561
|
}
|
|
@@ -853,29 +566,20 @@ function scanHooks(scope, projectRoot, home, catalogHooks, warnings) {
|
|
|
853
566
|
// injected — server.ts wires these up in production).
|
|
854
567
|
// ---------------------------------------------------------------------------
|
|
855
568
|
/** Default: run `claude plugins list --json` (no scope flag — the CLI
|
|
856
|
-
* doesn't expose one)
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
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. */
|
|
860
575
|
export async function defaultExecPluginList(scope = "user", projectRoot) {
|
|
861
|
-
//
|
|
862
|
-
//
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
|
|
867
|
-
const
|
|
868
|
-
execAsync(`claude plugins list --json`, { encoding: "utf8" }),
|
|
869
|
-
execAsync(`claude plugins list --available --json`, { encoding: "utf8" }),
|
|
870
|
-
]);
|
|
871
|
-
const allInstalled = parseJsonArray(installedRes.stdout);
|
|
872
|
-
// `claude plugins list --available --json` returns a wrapped object
|
|
873
|
-
// `{ installed: [...], available: [...] }`, NOT a flat array. parseJsonArray
|
|
874
|
-
// alone would return `[]` and silently lose every marketplace ref → the
|
|
875
|
-
// scanner could never surface "update-available" from upstream-live data.
|
|
876
|
-
// Pull `.available` out of the wrapper; tolerate the flat-array form too
|
|
877
|
-
// in case Claude CLI's shape regresses.
|
|
878
|
-
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);
|
|
879
583
|
const installed = allInstalled.filter((rec) => {
|
|
880
584
|
if (!rec || typeof rec !== "object")
|
|
881
585
|
return false;
|
|
@@ -891,7 +595,7 @@ export async function defaultExecPluginList(scope = "user", projectRoot) {
|
|
|
891
595
|
}
|
|
892
596
|
return true;
|
|
893
597
|
});
|
|
894
|
-
return { installed
|
|
598
|
+
return { installed };
|
|
895
599
|
}
|
|
896
600
|
function parseJsonArray(text) {
|
|
897
601
|
try {
|
|
@@ -902,24 +606,6 @@ function parseJsonArray(text) {
|
|
|
902
606
|
return [];
|
|
903
607
|
}
|
|
904
608
|
}
|
|
905
|
-
/** Pull the available-plugins array out of `claude plugins list --available
|
|
906
|
-
* --json`'s response. Empirically the CLI returns `{ installed, available }`;
|
|
907
|
-
* if a future version regresses to a flat array we keep working. Returns
|
|
908
|
-
* `[]` on malformed JSON. */
|
|
909
|
-
function extractAvailableArray(text) {
|
|
910
|
-
try {
|
|
911
|
-
const parsed = JSON.parse(text);
|
|
912
|
-
if (Array.isArray(parsed))
|
|
913
|
-
return parsed;
|
|
914
|
-
if (parsed && typeof parsed === "object" && Array.isArray(parsed.available)) {
|
|
915
|
-
return parsed.available;
|
|
916
|
-
}
|
|
917
|
-
return [];
|
|
918
|
-
}
|
|
919
|
-
catch {
|
|
920
|
-
return [];
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
609
|
function codexHome() {
|
|
924
610
|
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
925
611
|
}
|