novahiz 0.3.4 → 0.3.6

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.
Files changed (44) hide show
  1. package/README.md +98 -64
  2. package/adapters/claude/agent/novahiz.md +23 -0
  3. package/adapters/opencode/instructions.md +3 -3
  4. package/adapters/opencode/novahiz.ts +29 -8
  5. package/catalog/categories.json +60 -1
  6. package/catalog/providers.json +2 -2
  7. package/dist/cli.js +9 -3
  8. package/dist/commands/doctor.js +457 -15
  9. package/dist/commands/hook.js +204 -0
  10. package/dist/commands/init.js +53 -2
  11. package/dist/gate-repair.js +5 -2
  12. package/dist/hook.js +176 -0
  13. package/dist/impeccable.js +17 -0
  14. package/dist/memory.js +17 -1
  15. package/dist/spec.js +39 -7
  16. package/docs/CONFIGURATION.md +2 -2
  17. package/docs/HARNESSES.md +25 -6
  18. package/docs/INSTALL.md +3 -3
  19. package/docs/PLUGIN.md +9 -1
  20. package/docs/PROVIDERS.md +1 -1
  21. package/docs/ROADMAPS.md +6 -4
  22. package/install/bootstrap.mjs +4 -3
  23. package/install/hooks.mjs +171 -0
  24. package/install/install.mjs +174 -29
  25. package/install/lib.mjs +7 -1
  26. package/install/prompt.mjs +30 -0
  27. package/install/uninstall.mjs +1 -0
  28. package/novahiz.config.example.json +1 -1
  29. package/package.json +3 -1
  30. package/skills/memory/SKILL.md +1 -1
  31. package/skills/novahiz-converge/SKILL.md +10 -0
  32. package/skills/novahiz-init/SKILL.md +6 -0
  33. package/skills/novahiz-memory/SKILL.md +1 -1
  34. package/skills/novahiz-release/SKILL.md +59 -0
  35. package/src/cli.ts +9 -3
  36. package/src/commands/doctor.ts +473 -15
  37. package/src/commands/hook.ts +238 -0
  38. package/src/commands/init.ts +56 -2
  39. package/src/gate-repair.ts +5 -2
  40. package/src/gate.ts +1 -1
  41. package/src/hook.ts +203 -0
  42. package/src/impeccable.ts +18 -0
  43. package/src/memory.ts +19 -1
  44. package/src/spec.ts +38 -7
@@ -1,11 +1,12 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { spawnSync } from "node:child_process";
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { spawn, spawnSync } from "node:child_process";
3
3
  import { homedir } from "node:os";
4
- import { join } from "node:path";
5
- import { dbPathFor, emit } from "./context.js";
6
- import { loadSpec, NovahizHome } from "../spec.js";
4
+ import { join, relative } from "node:path";
5
+ import { dbPathFor, emit, flagOn } from "./context.js";
6
+ import { expandHome, loadSpec, NovahizHome, packageRoot } from "../spec.js";
7
7
  import { openDb, SCHEMA_VERSION } from "../db.js";
8
8
  import { loadInstalledSkills } from "../catalog.js";
9
+ import { findContextFile } from "../impeccable.js";
9
10
  import { evaluateGate } from "../gate.js";
10
11
  import { DEFAULT_LIMIT_CHARS, DEFAULT_LIMIT_LINES, MEMORY_DIR } from "../memory.js";
11
12
  import * as ui from "../render.js";
@@ -18,6 +19,251 @@ function hasCommand(name) {
18
19
  export function grantsQuestionIn(agentFile) {
19
20
  return /^\s*question:\s*allow\s*$/m.test(agentFile);
20
21
  }
22
+ // ── MCP configuration health (0.3.6) ───────────────────────────────────────
23
+ // Three real incidents motivated this: a purged context7 npx shim, a broken
24
+ // graft install, and a security-mcp running without its auth secret. All were
25
+ // only discovered mid-session. These checks are read-only and non-blocking —
26
+ // the CLI must stay usable on machines without an OpenCode install.
27
+ /** JSONC → JSON: strips // and block comments plus trailing commas,
28
+ * string-aware so URLs and globs inside values survive. */
29
+ export function parseJsonc(text) {
30
+ let out = "";
31
+ let inString = false;
32
+ let escaped = false;
33
+ for (let i = 0; i < text.length; i++) {
34
+ const ch = text[i];
35
+ if (inString) {
36
+ out += ch;
37
+ if (escaped)
38
+ escaped = false;
39
+ else if (ch === "\\")
40
+ escaped = true;
41
+ else if (ch === '"')
42
+ inString = false;
43
+ continue;
44
+ }
45
+ if (ch === '"') {
46
+ inString = true;
47
+ out += ch;
48
+ continue;
49
+ }
50
+ if (ch === "/" && text[i + 1] === "/") {
51
+ while (i < text.length && text[i] !== "\n")
52
+ i++;
53
+ out += "\n";
54
+ continue;
55
+ }
56
+ if (ch === "/" && text[i + 1] === "*") {
57
+ i += 2;
58
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
59
+ i++;
60
+ i++;
61
+ continue;
62
+ }
63
+ out += ch;
64
+ }
65
+ try {
66
+ return JSON.parse(out);
67
+ }
68
+ catch {
69
+ // Trailing comma pass, only when the strict parse already failed.
70
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
71
+ }
72
+ }
73
+ /** Resolve a command word or path to a full spawnable path, else null.
74
+ * PATH lookups must return the real file: CreateProcess appends .exe for a
75
+ * bare name but refuses .cmd/.bat (npx), so the probe needs the full path. */
76
+ function resolveExecutable(cmd) {
77
+ const looksPath = cmd.includes("/") || cmd.includes("\\") || cmd.startsWith("~");
78
+ if (looksPath) {
79
+ const full = expandHome(cmd);
80
+ return existsSync(full) ? full : null;
81
+ }
82
+ const probe = process.platform === "win32" ? "where" : "which";
83
+ const result = spawnSync(probe, [cmd], { encoding: "utf8", shell: false });
84
+ if (result.status !== 0)
85
+ return null;
86
+ const lines = String(result.stdout ?? "")
87
+ .split(/\r?\n/)
88
+ .map((line) => line.trim())
89
+ .filter((line) => line.length > 0);
90
+ if (lines.length === 0)
91
+ return null;
92
+ if (process.platform === "win32") {
93
+ // `where` also matches extensionless Git-Bash scripts (nodejs\npx,
94
+ // flutter\bin\dart) that CreateProcess refuses — prefer a real binary.
95
+ return lines.find((line) => /\.(exe|cmd|bat|com)$/i.test(line)) ?? null;
96
+ }
97
+ return lines[0];
98
+ }
99
+ /** Pure per-server config problems (exported for tests). */
100
+ export function mcpEntryProblems(name, entry, catalogPackages, env) {
101
+ const problems = [];
102
+ const notes = [];
103
+ if (entry.enabled === false)
104
+ return { problems, notes };
105
+ const command = Array.isArray(entry.command) ? entry.command.map(String) : [];
106
+ if (command.length === 0) {
107
+ problems.push(`${name}: no command`);
108
+ return { problems, notes };
109
+ }
110
+ if (!resolveExecutable(command[0]))
111
+ problems.push(`${name}: executable not found (${command[0]})`);
112
+ // ${VAR} references must resolve in the environment the CLI runs with.
113
+ for (const match of JSON.stringify(entry).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
114
+ if (!env[match[1]])
115
+ problems.push(`${name}: env ${match[1]} not set`);
116
+ }
117
+ const configPkg = command.find((arg) => /@\d/.test(arg) && !arg.startsWith("-"));
118
+ const catalogPkg = catalogPackages.get(name);
119
+ if (catalogPkg && configPkg && configPkg !== catalogPkg) {
120
+ problems.push(`${name}: pinned to ${configPkg}, catalog pins ${catalogPkg}`);
121
+ }
122
+ else if (catalogPkg && !configPkg) {
123
+ // A bare shim can drift from the pinned catalog version — surface, don't fail.
124
+ const catalogBase = catalogPkg.slice(0, catalogPkg.lastIndexOf("@")) || catalogPkg;
125
+ if (command[0] !== "npx" && command[0] !== catalogBase) {
126
+ problems.push(`${name}: runs ${command[0]}, catalog expects ${catalogBase}`);
127
+ }
128
+ else {
129
+ notes.push(`${name}: unpinned (catalog: ${catalogPkg})`);
130
+ }
131
+ }
132
+ if (command[0] === "npx") {
133
+ if (!command.includes("-y"))
134
+ notes.push(`${name}: npx without -y`);
135
+ const pkg = command.slice(1).find((arg) => !arg.startsWith("-"));
136
+ if (pkg && !/@\d/.test(pkg))
137
+ notes.push(`${name}: npx package unpinned (${pkg})`);
138
+ }
139
+ return { problems, notes };
140
+ }
141
+ /** Kill a probed server and its whole tree (cmd.exe → npx → node). */
142
+ function killTree(child) {
143
+ if (child.pid === undefined) {
144
+ child.kill();
145
+ return;
146
+ }
147
+ if (process.platform === "win32") {
148
+ spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { encoding: "utf8" });
149
+ }
150
+ else {
151
+ child.kill("SIGTERM");
152
+ }
153
+ }
154
+ /** One live JSON-RPC `initialize` probe per server. Opt-in via --deep, up to
155
+ * 10 s per server, all servers probed in parallel. stdin is written but kept
156
+ * OPEN: mcp-cron reads stdin asynchronously and exits cleanly (status 0,
157
+ * no output) if the pipe closes before it starts reading. */
158
+ export function probeMcpServer(entry) {
159
+ return new Promise((resolve) => {
160
+ const command = Array.isArray(entry.command) ? entry.command.map(String) : [];
161
+ if (command.length === 0) {
162
+ resolve({ ok: false, detail: "no command" });
163
+ return;
164
+ }
165
+ const resolved = resolveExecutable(command[0]);
166
+ if (!resolved) {
167
+ resolve({ ok: false, detail: `executable not found (${command[0]})` });
168
+ return;
169
+ }
170
+ const args = command.slice(1);
171
+ const init = `${JSON.stringify({
172
+ jsonrpc: "2.0",
173
+ id: 1,
174
+ method: "initialize",
175
+ params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "novahiz-doctor", version: "0" } }
176
+ })}\n`;
177
+ const env = { ...process.env, ...(entry.environment ?? {}) };
178
+ const started = Date.now();
179
+ // Node refuses to exec .cmd/.bat directly (EINVAL). Go through ComSpec
180
+ // verbatim: the whole `"path" args` line gets one extra quote pair, which
181
+ // `cmd /d /s /c` strips back off. No shell flag — tests/exec.test.ts bans
182
+ // the shell option repo-wide — and no Node re-quoting layer
183
+ // (windowsVerbatimArguments).
184
+ const shellRequired = process.platform === "win32" && /\.(cmd|bat)$/i.test(resolved);
185
+ const quote = (arg) => (/\s|"/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg);
186
+ const inner = [`"${resolved}"`, ...args.map(quote)].join(" ");
187
+ let child;
188
+ try {
189
+ child = shellRequired
190
+ ? spawn(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `"${inner}"`], {
191
+ env,
192
+ stdio: ["pipe", "pipe", "pipe"],
193
+ windowsVerbatimArguments: true
194
+ })
195
+ : spawn(resolved, args, { env, stdio: ["pipe", "pipe", "pipe"] });
196
+ }
197
+ catch (error) {
198
+ resolve({ ok: false, detail: `spawn failed: ${error.message}` });
199
+ return;
200
+ }
201
+ let settled = false;
202
+ let buffer = "";
203
+ let errBuffer = "";
204
+ const settle = (ok, detail) => {
205
+ if (settled)
206
+ return;
207
+ settled = true;
208
+ clearTimeout(timer);
209
+ killTree(child);
210
+ resolve({ ok, detail });
211
+ };
212
+ const timer = setTimeout(() => {
213
+ settle(false, `no initialize response in ${Date.now() - started} ms (timeout)`);
214
+ }, 10_000);
215
+ child.stdout?.on("data", (chunk) => {
216
+ buffer += String(chunk);
217
+ for (const line of buffer.split(/\r?\n/)) {
218
+ const trimmed = line.trim();
219
+ if (!trimmed.startsWith("{"))
220
+ continue;
221
+ try {
222
+ const message = JSON.parse(trimmed);
223
+ const elapsed = Date.now() - started;
224
+ if (message.result?.serverInfo) {
225
+ const info = message.result.serverInfo;
226
+ settle(true, `${info.name ?? "?"}${info.version ? `@${info.version}` : ""} (${elapsed} ms)`);
227
+ return;
228
+ }
229
+ if (message.id === 1) {
230
+ settle(true, `initialize acknowledged (${elapsed} ms)`);
231
+ return;
232
+ }
233
+ if (message.error) {
234
+ settle(false, `initialize error: ${JSON.stringify(message.error).slice(0, 120)}`);
235
+ return;
236
+ }
237
+ }
238
+ catch {
239
+ // stdout noise (logs) — keep scanning.
240
+ }
241
+ }
242
+ // Keep memory bounded on chatty servers.
243
+ if (buffer.length > 262_144)
244
+ buffer = buffer.slice(-65_536);
245
+ });
246
+ child.stderr?.on("data", (chunk) => {
247
+ if (errBuffer.length < 16_384)
248
+ errBuffer += String(chunk);
249
+ });
250
+ child.on("error", (error) => {
251
+ settle(false, `spawn failed: ${error.message}`);
252
+ });
253
+ child.on("close", (code) => {
254
+ if (settled)
255
+ return;
256
+ const elapsed = Date.now() - started;
257
+ const tail = errBuffer.trim() ? ` — ${errBuffer.trim().slice(0, 160)}` : "";
258
+ settle(false, `exited (code ${code}) after ${elapsed} ms without responding${tail}`);
259
+ });
260
+ child.stdin?.on("error", () => {
261
+ // Server closed its side first — the close handler reports it.
262
+ });
263
+ child.stdin?.write(init);
264
+ // Intentionally no stdin.end(): keep the pipe open until we have a reply.
265
+ });
266
+ }
21
267
  function referencedSkills(spec) {
22
268
  const ids = new Set();
23
269
  for (const category of spec.categories) {
@@ -37,7 +283,128 @@ function referencedSkills(spec) {
37
283
  // index individually and are referenced through categories and rules above.
38
284
  return [...ids].sort();
39
285
  }
40
- export function commandDoctor(parsed) {
286
+ /** Referenced-skills row. Exported for tests.
287
+ * `indexSkills` is null when the index is unreadable — the index check already
288
+ * reports that, so the row stays quiet instead of double-failing.
289
+ * Only skills the package ships (packageRoot/skills) can make a fresh install
290
+ * broken. The remaining references — the dart/eas/expo/flutter packs and
291
+ * impeccable — are optional delivery downloaded on request and are simply not
292
+ * present on a machine that never asked for them; the gate already degrades
293
+ * them to "not enforced". Blocking on them turned every healthy fresh install
294
+ * red (`missing from index: dart-...` right after novahiz-install --yes). */
295
+ export function referencedSkillsCheck(spec, indexSkills, packageRootDir) {
296
+ const referenced = referencedSkills(spec);
297
+ const absent = indexSkills ? referenced.filter((id) => !indexSkills.has(id)) : [];
298
+ const shipped = new Set();
299
+ const shippedDir = join(packageRootDir, "skills");
300
+ if (existsSync(shippedDir)) {
301
+ for (const entry of readdirSync(shippedDir, { withFileTypes: true })) {
302
+ if (entry.isDirectory())
303
+ shipped.add(entry.name);
304
+ }
305
+ }
306
+ const absentShipped = absent.filter((id) => shipped.has(id));
307
+ const absentPack = absent.length - absentShipped.length;
308
+ return {
309
+ id: "referenced",
310
+ label: "Referenced skills",
311
+ ok: absentShipped.length === 0,
312
+ detail: absent.length === 0
313
+ ? `${referenced.length} present`
314
+ : absentShipped.length > 0
315
+ ? `missing from index: ${absent.join(", ")}`
316
+ : `${referenced.length - absent.length} present, ${absentPack} pack skills not installed (novahiz-install --dart-skills --flutter-skills)`,
317
+ blocking: absentShipped.length > 0
318
+ };
319
+ }
320
+ /** Impeccable design-context checks: PRODUCT.md and DESIGN.md resolution
321
+ * (root -> .agents/context -> docs/). Rows appear only when the impeccable
322
+ * skill is installed — the same opt-in visibility as the claude-* rows — and
323
+ * are never blocking: a missing record is a command to run
324
+ * (`/impeccable init`, `/impeccable document`), not an anomaly. Exported for tests. */
325
+ export function impeccableChecks(cwd, installed) {
326
+ if (!installed)
327
+ return [];
328
+ const rel = (path) => relative(cwd, path).split("\\").join("/");
329
+ const product = findContextFile(cwd, "PRODUCT.md");
330
+ const design = findContextFile(cwd, "DESIGN.md");
331
+ return [
332
+ {
333
+ id: "impeccable-context",
334
+ label: "Impeccable product context",
335
+ ok: product !== null,
336
+ detail: product ? `${rel(product)} present` : "no PRODUCT.md - run /impeccable init to capture audience, goals and constraints",
337
+ blocking: false
338
+ },
339
+ {
340
+ id: "impeccable-design",
341
+ label: "Impeccable design record",
342
+ ok: design !== null,
343
+ detail: design ? `${rel(design)} present` : "no DESIGN.md - run /impeccable document to record an existing visual system",
344
+ blocking: false
345
+ }
346
+ ];
347
+ }
348
+ /** Claude Code harness checks: hooks wiring, agent copy, skills and commands.
349
+ * Returns [] when no Claude config dir exists, so a machine without Claude
350
+ * Code never carries these rows. Exported for tests. */
351
+ export function claudeHarnessChecks(root, claudeDir) {
352
+ if (!existsSync(claudeDir))
353
+ return [];
354
+ const checks = [];
355
+ const settingsPath = join(claudeDir, "settings.json");
356
+ let hooksOk = false;
357
+ let hooksDetail = `${settingsPath} missing`;
358
+ if (existsSync(settingsPath)) {
359
+ try {
360
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
361
+ const groups = settings.hooks?.PreToolUse ?? [];
362
+ hooksOk = groups.some((group) => (group.hooks ?? []).some((handler) => {
363
+ const command = handler.command ?? "";
364
+ // Mirrors install/hooks.mjs isNovahizHandler: our handler points at
365
+ // the novahiz CLI and runs its hook subcommand. Do not test the path
366
+ // for "novahiz" alone — a custom NOVAHIZ_HOME (n4\home, /opt/gov)
367
+ // contains no such segment, and the doctor then reported a healthy
368
+ // install as unwired.
369
+ return command.includes("hook --harness") && (command.includes("novahiz") || command.includes("cli.ts"));
370
+ }));
371
+ hooksDetail = hooksOk ? "novahiz PreToolUse handler wired" : "no novahiz PreToolUse handler - rerun installer";
372
+ }
373
+ catch (error) {
374
+ hooksDetail = `settings.json unreadable: ${error.message}`;
375
+ }
376
+ }
377
+ checks.push({ id: "claude-hooks", label: "Claude hooks", ok: hooksOk, detail: hooksDetail, blocking: false });
378
+ const agentSource = join(root, "adapters", "claude", "agent", "novahiz.md");
379
+ const agentInstalled = join(claudeDir, "agents", "novahiz.md");
380
+ let agentOk = true;
381
+ let agentDetail = "no installed copy";
382
+ if (existsSync(agentSource) && existsSync(agentInstalled)) {
383
+ agentOk = readFileSync(agentSource, "utf8") === readFileSync(agentInstalled, "utf8");
384
+ agentDetail = agentOk ? "agent copy up to date" : "agent copy outdated, rerun installer then restart Claude Code";
385
+ }
386
+ checks.push({ id: "claude-agent", label: "Claude agent copy", ok: agentOk, detail: agentDetail, blocking: false });
387
+ const skillsDir = join(claudeDir, "skills");
388
+ let skillsOk = false;
389
+ let skillsDetail = `${skillsDir} missing`;
390
+ if (existsSync(skillsDir)) {
391
+ const count = readdirSync(skillsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && existsSync(join(skillsDir, entry.name, "SKILL.md"))).length;
392
+ skillsOk = count > 0;
393
+ skillsDetail = `${count} skills with SKILL.md`;
394
+ }
395
+ checks.push({ id: "claude-skills", label: "Claude skills", ok: skillsOk, detail: skillsDetail, blocking: false });
396
+ const commandsDir = join(claudeDir, "commands");
397
+ let commandsOk = false;
398
+ let commandsDetail = `${commandsDir} missing`;
399
+ if (existsSync(commandsDir)) {
400
+ const count = readdirSync(commandsDir, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).length;
401
+ commandsOk = count > 0;
402
+ commandsDetail = `${count} commands`;
403
+ }
404
+ checks.push({ id: "claude-commands", label: "Claude commands", ok: commandsOk, detail: commandsDetail, blocking: false });
405
+ return checks;
406
+ }
407
+ export async function commandDoctor(parsed) {
41
408
  const root = NovahizHome();
42
409
  const spec = loadSpec(root);
43
410
  const checks = [];
@@ -53,18 +420,11 @@ export function commandDoctor(parsed) {
53
420
  detail: index.available ? `${index.skills.size} skills` : "build/installed-skills.json unreadable, run novahiz sync",
54
421
  blocking: true
55
422
  });
56
- const referenced = referencedSkills(spec);
57
- const absent = index.available ? referenced.filter((id) => !index.skills.has(id)) : [];
58
- checks.push({
59
- id: "referenced",
60
- label: "Referenced skills",
61
- ok: absent.length === 0,
62
- detail: absent.length === 0 ? `${referenced.length} present` : `missing from index: ${absent.join(", ")}`,
63
- blocking: absent.length > 0
64
- });
423
+ checks.push(referencedSkillsCheck(spec, index.available ? index.skills : null, packageRoot() ?? root));
65
424
  // SKILL_CLI is intentionally empty: novahiz-web-extract replaced the external
66
425
  // defuddle CLI on the research roadmap (see MEMORY.md). Keep the check so a
67
426
  // future external dependency is wired here again.
427
+ const referenced = referencedSkills(spec);
68
428
  const cliEntries = Object.entries(SKILL_CLI);
69
429
  const missingCli = cliEntries
70
430
  .filter(([skill]) => referenced.includes(skill) && !hasCommand(skill))
@@ -193,6 +553,88 @@ export function commandDoctor(parsed) {
193
553
  : "agent harness copy does not allow question: pipeline cannot query";
194
554
  }
195
555
  checks.push({ id: "agent", label: "Agent harness copy", ok: agentOk, detail: agentDetail, blocking: false });
556
+ // ── MCP configuration health (0.3.6) ────────────────────────────────────
557
+ // Claude Code harness rows appear only when a Claude config dir exists.
558
+ const claudeDir = process.env.CLAUDE_CONFIG_DIR && process.env.CLAUDE_CONFIG_DIR.length > 0
559
+ ? process.env.CLAUDE_CONFIG_DIR
560
+ : join(homedir(), ".claude");
561
+ checks.push(...claudeHarnessChecks(root, claudeDir));
562
+ // Impeccable rows appear only when the skill is installed (opt-in, like the
563
+ // claude-* rows) and only reflect this project's context files.
564
+ checks.push(...impeccableChecks(process.cwd(), index.available && index.skills.has("impeccable")));
565
+ const opencodeConfigPath = join(opencodeDir, "opencode.jsonc");
566
+ const catalogPackages = new Map();
567
+ for (const provider of spec.providers) {
568
+ if (provider.kind !== "mcp" || !provider.command)
569
+ continue;
570
+ const pkg = provider.command.find((arg) => /@\d/.test(arg) && !arg.startsWith("-"));
571
+ if (pkg)
572
+ catalogPackages.set(provider.id, pkg);
573
+ }
574
+ const disabledProviders = spec.config.providers?.disabled ?? [];
575
+ let mcpEntries = [];
576
+ const mcpProblems = [];
577
+ const mcpNotes = [];
578
+ if (!existsSync(opencodeConfigPath)) {
579
+ mcpNotes.push(`no ${opencodeConfigPath}`);
580
+ }
581
+ else {
582
+ try {
583
+ const document = parseJsonc(readFileSync(opencodeConfigPath, "utf8"));
584
+ const servers = document.mcp ?? document.mcpServers ?? {};
585
+ mcpEntries = Object.entries(servers).map(([name, entry]) => ({ name, entry: (entry ?? {}) }));
586
+ for (const { name, entry } of mcpEntries) {
587
+ const result = mcpEntryProblems(name, entry, catalogPackages, process.env);
588
+ mcpProblems.push(...result.problems);
589
+ mcpNotes.push(...result.notes);
590
+ }
591
+ const configured = new Set(mcpEntries.map(({ name }) => name));
592
+ for (const provider of spec.providers) {
593
+ if (provider.kind !== "mcp" || disabledProviders.includes(provider.id) || configured.has(provider.id))
594
+ continue;
595
+ mcpNotes.push(`${provider.id}: not in opencode.jsonc (registered by the plugin at startup)`);
596
+ }
597
+ if (!disabledProviders.includes("security") && !process.env.SECURITY_MCP_SHARED_SECRET) {
598
+ mcpProblems.push("security: SECURITY_MCP_SHARED_SECRET not set — security-mcp runs unauthenticated");
599
+ }
600
+ }
601
+ catch (error) {
602
+ mcpProblems.push(`opencode.jsonc unreadable: ${error.message}`);
603
+ }
604
+ }
605
+ checks.push({
606
+ id: "mcp-config",
607
+ label: "MCP config",
608
+ ok: mcpProblems.length === 0,
609
+ detail: mcpProblems.length > 0
610
+ ? mcpProblems.join("; ")
611
+ : mcpEntries.length === 0
612
+ ? "no server entries"
613
+ : `${mcpEntries.length} servers${mcpNotes.length > 0 ? ` — ${mcpNotes.join("; ")}` : ""}`,
614
+ blocking: false
615
+ });
616
+ // Live probe: explicit opt-in, because npx cold caches can exceed the 10 s
617
+ // per-server budget and a false negative would be worse than no probe.
618
+ if (flagOn(parsed, "deep")) {
619
+ const targets = mcpEntries.filter(({ entry }) => entry.enabled !== false);
620
+ const results = await Promise.all(targets.map(async ({ name, entry }) => ({ name, probe: await probeMcpServer(entry) })));
621
+ const probes = [];
622
+ const probeFailures = [];
623
+ for (const { name, probe } of results) {
624
+ (probe.ok ? probes : probeFailures).push(`${name}: ${probe.detail}`);
625
+ }
626
+ checks.push({
627
+ id: "mcp-probe",
628
+ label: "MCP live probe",
629
+ ok: probeFailures.length === 0 && probes.length > 0,
630
+ detail: probeFailures.length > 0
631
+ ? probeFailures.join("; ")
632
+ : probes.length > 0
633
+ ? probes.join(", ")
634
+ : "no enabled servers to probe",
635
+ blocking: false
636
+ });
637
+ }
196
638
  const failing = checks.filter((check) => !check.ok);
197
639
  const blocking = failing.filter((check) => check.blocking);
198
640
  const value = {