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