@hiai-gg/hiai-opencode 0.1.4 → 0.1.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 (81) hide show
  1. package/.env.example +14 -8
  2. package/AGENTS.md +19 -8
  3. package/ARCHITECTURE.md +7 -6
  4. package/LICENSE.md +0 -1
  5. package/README.md +48 -17
  6. package/assets/cli/hiai-opencode.mjs +590 -7
  7. package/assets/mcp/mempalace.mjs +159 -25
  8. package/config/hiai-opencode.schema.json +82 -148
  9. package/dist/agents/dynamic-agent-core-sections.d.ts +4 -1
  10. package/dist/agents/dynamic-agent-prompt-builder.d.ts +1 -1
  11. package/dist/config/defaults.d.ts +1 -0
  12. package/dist/config/platform-schema.d.ts +275 -10
  13. package/dist/config/schema/categories.d.ts +2 -2
  14. package/dist/config/schema/commands.d.ts +1 -0
  15. package/dist/config/schema/oh-my-opencode-config.d.ts +1 -3
  16. package/dist/config/types.d.ts +22 -5
  17. package/dist/create-tools.d.ts +2 -0
  18. package/dist/features/builtin-commands/templates/doctor.d.ts +1 -0
  19. package/dist/features/builtin-commands/types.d.ts +1 -1
  20. package/dist/features/builtin-skills/skills/hiai-opencode-setup.d.ts +2 -0
  21. package/dist/features/builtin-skills/skills/index.d.ts +1 -0
  22. package/dist/index.js +870 -1711
  23. package/dist/mcp/types.d.ts +1 -1
  24. package/dist/plugin/tool-registry.d.ts +2 -0
  25. package/dist/shared/mcp-static-export.d.ts +22 -0
  26. package/dist/tools/ast-grep/constants.d.ts +1 -1
  27. package/dist/tools/ast-grep/environment-check.d.ts +1 -5
  28. package/dist/tools/ast-grep/language-support.d.ts +0 -1
  29. package/dist/tools/ast-grep/types.d.ts +1 -2
  30. package/dist/tools/skill-mcp/tools.d.ts +2 -0
  31. package/hiai-opencode.json +39 -171
  32. package/package.json +6 -4
  33. package/src/agents/bob/default.ts +6 -1
  34. package/src/agents/bob/gpt-pro.ts +1 -0
  35. package/src/agents/bob.ts +1 -0
  36. package/src/agents/coder/gpt-codex.ts +1 -0
  37. package/src/agents/coder/gpt-pro.ts +1 -0
  38. package/src/agents/coder/gpt.ts +1 -0
  39. package/src/agents/dynamic-agent-core-sections.ts +36 -0
  40. package/src/agents/dynamic-agent-prompt-builder.ts +1 -0
  41. package/src/config/defaults.ts +171 -28
  42. package/src/config/loader.test.ts +16 -1
  43. package/src/config/loader.ts +4 -2
  44. package/src/config/model-slots-and-export.test.ts +55 -0
  45. package/src/config/platform-schema.ts +37 -5
  46. package/src/config/schema/commands.ts +1 -0
  47. package/src/config/schema/oh-my-opencode-config.ts +0 -3
  48. package/src/config/types.ts +34 -5
  49. package/src/create-tools.ts +4 -1
  50. package/src/features/builtin-commands/commands.ts +7 -0
  51. package/src/features/builtin-commands/templates/doctor.ts +43 -0
  52. package/src/features/builtin-commands/types.ts +1 -1
  53. package/src/features/builtin-skills/skills/hiai-opencode-setup.ts +69 -0
  54. package/src/features/builtin-skills/skills/index.ts +1 -0
  55. package/src/features/builtin-skills/skills.ts +10 -1
  56. package/src/index.ts +4 -38
  57. package/src/lsp/index.ts +1 -0
  58. package/src/mcp/registry.ts +6 -1
  59. package/src/plugin/tool-registry.ts +4 -0
  60. package/src/shared/mcp-static-export.ts +121 -0
  61. package/src/tools/ast-grep/constants.ts +1 -1
  62. package/src/tools/ast-grep/environment-check.ts +2 -32
  63. package/src/tools/ast-grep/language-support.ts +0 -3
  64. package/src/tools/ast-grep/types.ts +1 -2
  65. package/src/tools/skill-mcp/tools.test.ts +44 -0
  66. package/src/tools/skill-mcp/tools.ts +45 -7
  67. package/dist/ast-grep-napi.win32-x64-msvc-67c0y8nc.node +0 -0
  68. package/dist/config/loader.test.d.ts +0 -1
  69. package/dist/config/models.d.ts +0 -13
  70. package/dist/internals/plugins/websearch-cited/google.d.ts +0 -38
  71. package/dist/internals/plugins/websearch-cited/index.d.ts +0 -11
  72. package/dist/internals/plugins/websearch-cited/openai.d.ts +0 -9
  73. package/dist/internals/plugins/websearch-cited/openrouter.d.ts +0 -2
  74. package/dist/internals/plugins/websearch-cited/types.d.ts +0 -5
  75. package/src/internals/plugins/websearch-cited/LICENSE +0 -214
  76. package/src/internals/plugins/websearch-cited/codex_prompt.txt +0 -79
  77. package/src/internals/plugins/websearch-cited/google.ts +0 -749
  78. package/src/internals/plugins/websearch-cited/index.ts +0 -301
  79. package/src/internals/plugins/websearch-cited/openai.ts +0 -407
  80. package/src/internals/plugins/websearch-cited/openrouter.ts +0 -190
  81. package/src/internals/plugins/websearch-cited/types.ts +0 -7
@@ -1,12 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawnSync } from "node:child_process"
4
- import { existsSync, readFileSync } from "node:fs"
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
5
5
  import { dirname, join } from "node:path"
6
+ import { fileURLToPath } from "node:url"
6
7
  import { homedir } from "node:os"
7
8
  import { parse } from "jsonc-parser"
9
+ import { createHash } from "node:crypto"
10
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js"
11
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
8
12
 
9
13
  const DEFAULT_RAG_URL = "http://localhost:9002/tools/search"
14
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..")
15
+ const MCP_EXPORT_MARKER = "hiai-opencode"
10
16
 
11
17
  const MCP_REGISTRY = {
12
18
  playwright: {
@@ -52,10 +58,14 @@ function usage() {
52
58
  console.log(`hiai-opencode
53
59
 
54
60
  Usage:
61
+ hiai-opencode doctor
55
62
  hiai-opencode mcp-status
63
+ hiai-opencode export-mcp [path]
56
64
 
57
65
  Commands:
66
+ doctor Full install/runtime diagnostic: MCP status + static export freshness + provider/skills/agents/LSP checks + MCP tool probes.
58
67
  mcp-status Check hiai-opencode MCP configuration, keys, and local runtimes.
68
+ export-mcp Write a static .mcp.json for hosts whose mcp list ignores plugin runtime MCP.
59
69
  `)
60
70
  }
61
71
 
@@ -106,6 +116,254 @@ function loadConfig() {
106
116
  return { path: null, config: {} }
107
117
  }
108
118
 
119
+ function candidateOpenCodeConfigPaths() {
120
+ const cwd = process.cwd()
121
+ const paths = [
122
+ join(cwd, ".opencode", "opencode.json"),
123
+ join(cwd, ".opencode", "opencode.jsonc"),
124
+ join(homedir(), ".config", "opencode", "opencode.json"),
125
+ join(homedir(), ".config", "opencode", "opencode.jsonc"),
126
+ ]
127
+
128
+ if (process.platform === "win32" && process.env.APPDATA) {
129
+ paths.push(join(process.env.APPDATA, "opencode", "opencode.json"))
130
+ paths.push(join(process.env.APPDATA, "opencode", "opencode.jsonc"))
131
+ }
132
+
133
+ return paths
134
+ }
135
+
136
+ function checkOpenCodePluginRegistration() {
137
+ for (const path of candidateOpenCodeConfigPaths()) {
138
+ if (!existsSync(path)) continue
139
+ try {
140
+ const parsed = parse(readFileSync(path, "utf-8")) ?? {}
141
+ const pluginEntries = Array.isArray(parsed?.plugin) ? parsed.plugin : []
142
+ const plugins = pluginEntries
143
+ .map((entry) => typeof entry === "string" ? entry : Array.isArray(entry) ? entry[0] : "")
144
+ .filter((entry) => typeof entry === "string" && entry.length > 0)
145
+
146
+ if (plugins.includes("list")) {
147
+ return {
148
+ level: "warn",
149
+ detail: `${path} contains plugin: [\"list\"] which can block MCP loading. Replace with [\"@hiai-gg/hiai-opencode\"].`,
150
+ }
151
+ }
152
+
153
+ if (plugins.includes("@hiai-gg/hiai-opencode")) {
154
+ return {
155
+ level: "ok",
156
+ detail: `plugin registered in ${path}`,
157
+ }
158
+ }
159
+
160
+ return {
161
+ level: "warn",
162
+ detail: `${path} found but @hiai-gg/hiai-opencode is not registered`,
163
+ }
164
+ } catch (error) {
165
+ return {
166
+ level: "warn",
167
+ detail: `failed to parse ${path}: ${error instanceof Error ? error.message : String(error)}`,
168
+ }
169
+ }
170
+ }
171
+
172
+ return {
173
+ level: "warn",
174
+ detail: "no opencode.json/opencode.jsonc found in project or global config paths",
175
+ }
176
+ }
177
+
178
+ function enabled(config, name) {
179
+ return config?.mcp?.[name]?.enabled ?? MCP_REGISTRY[name]?.defaultEnabled ?? true
180
+ }
181
+
182
+ function assetPath(...segments) {
183
+ return join(PACKAGE_ROOT, "assets", ...segments)
184
+ }
185
+
186
+ function createMcpExport(config) {
187
+ const servers = {}
188
+
189
+ if (enabled(config, "playwright")) {
190
+ servers.playwright = {
191
+ command: "node",
192
+ args: [assetPath("mcp", "playwright.mjs")],
193
+ }
194
+ }
195
+
196
+ if (enabled(config, "stitch")) {
197
+ servers.stitch = {
198
+ type: "http",
199
+ url: "https://stitch.googleapis.com/mcp",
200
+ headers: {
201
+ "X-Goog-Api-Key": config?.auth?.stitch || "${STITCH_AI_API_KEY}",
202
+ },
203
+ }
204
+ }
205
+
206
+ if (enabled(config, "sequential-thinking")) {
207
+ servers["sequential-thinking"] = {
208
+ command: "node",
209
+ args: [
210
+ assetPath("runtime", "npm-package-runner.mjs"),
211
+ "@modelcontextprotocol/server-sequential-thinking",
212
+ ],
213
+ }
214
+ }
215
+
216
+ if (enabled(config, "firecrawl")) {
217
+ servers.firecrawl = {
218
+ command: "node",
219
+ args: [assetPath("runtime", "npm-package-runner.mjs"), "firecrawl-mcp"],
220
+ env: {
221
+ FIRECRAWL_API_KEY: config?.auth?.firecrawl || "${FIRECRAWL_API_KEY}",
222
+ },
223
+ }
224
+ }
225
+
226
+ if (enabled(config, "rag")) {
227
+ servers.rag = {
228
+ command: "node",
229
+ args: [assetPath("mcp", "rag.mjs")],
230
+ env: {
231
+ OPENCODE_RAG_URL:
232
+ process.env.OPENCODE_RAG_URL
233
+ || resolveEnvTemplate(config?.mcp?.rag?.environment?.OPENCODE_RAG_URL)
234
+ || DEFAULT_RAG_URL,
235
+ },
236
+ }
237
+ }
238
+
239
+ if (enabled(config, "mempalace")) {
240
+ const mempalacePython =
241
+ process.env.MEMPALACE_PYTHON?.trim()
242
+ || resolveEnvTemplate(config?.mcp?.mempalace?.pythonPath?.trim())
243
+
244
+ servers.mempalace = {
245
+ command: "node",
246
+ args: [assetPath("mcp", "mempalace.mjs"), "--palace", "./.opencode/palace"],
247
+ env: mempalacePython
248
+ ? { MEMPALACE_PYTHON: mempalacePython }
249
+ : undefined,
250
+ }
251
+ }
252
+
253
+ if (enabled(config, "context7")) {
254
+ servers.context7 = {
255
+ type: "http",
256
+ url: "https://mcp.context7.com/mcp",
257
+ headers: config?.auth?.context7 || process.env.CONTEXT7_API_KEY
258
+ ? { "X-API-KEY": config?.auth?.context7 || "${CONTEXT7_API_KEY}" }
259
+ : undefined,
260
+ }
261
+ }
262
+
263
+ return {
264
+ _meta: {
265
+ generatedBy: MCP_EXPORT_MARKER,
266
+ version: 1,
267
+ generatedAt: new Date().toISOString(),
268
+ },
269
+ mcpServers: Object.fromEntries(
270
+ Object.entries(servers).map(([name, value]) => [
271
+ name,
272
+ Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined)),
273
+ ]),
274
+ ),
275
+ }
276
+ }
277
+
278
+ function isManagedStaticMcpFile(path) {
279
+ if (!existsSync(path)) return false
280
+
281
+ try {
282
+ const parsed = JSON.parse(readFileSync(path, "utf-8"))
283
+ return parsed?._meta?.generatedBy === MCP_EXPORT_MARKER
284
+ } catch {
285
+ return false
286
+ }
287
+ }
288
+
289
+ function stableHash(value) {
290
+ return createHash("sha256").update(value).digest("hex")
291
+ }
292
+
293
+ function readExistingStaticMcp(path) {
294
+ if (!existsSync(path)) return null
295
+ try {
296
+ return JSON.parse(readFileSync(path, "utf-8"))
297
+ } catch {
298
+ return null
299
+ }
300
+ }
301
+
302
+ function checkStaticMcpFreshness(outputPath, config) {
303
+ const expected = createMcpExport(config)
304
+ const existing = readExistingStaticMcp(outputPath)
305
+ if (!existing) {
306
+ return { status: "missing", detail: `${outputPath} is missing` }
307
+ }
308
+
309
+ const expectedNormalized = {
310
+ ...expected,
311
+ _meta: { ...expected._meta, generatedAt: "<normalized>" },
312
+ }
313
+ const existingNormalized = {
314
+ ...existing,
315
+ _meta: existing._meta ? { ...existing._meta, generatedAt: "<normalized>" } : undefined,
316
+ }
317
+
318
+ const expectedHash = stableHash(JSON.stringify(expectedNormalized))
319
+ const existingHash = stableHash(JSON.stringify(existingNormalized))
320
+ const managed = existing?._meta?.generatedBy === MCP_EXPORT_MARKER
321
+
322
+ if (expectedHash === existingHash) {
323
+ return {
324
+ status: "fresh",
325
+ detail: `${outputPath} is up to date${managed ? " (managed)" : ""}`,
326
+ }
327
+ }
328
+
329
+ if (!managed) {
330
+ return {
331
+ status: "drift-unmanaged",
332
+ detail: `${outputPath} differs and is not managed by ${MCP_EXPORT_MARKER}`,
333
+ }
334
+ }
335
+
336
+ return {
337
+ status: "stale",
338
+ detail: `${outputPath} is stale (run: hiai-opencode export-mcp ${outputPath})`,
339
+ }
340
+ }
341
+
342
+ function exportMcp(outputPath = join(process.cwd(), ".mcp.json")) {
343
+ const { path, config, error } = loadConfig()
344
+ if (error) {
345
+ console.error(`Config parse warning: ${error}`)
346
+ }
347
+
348
+ const output = createMcpExport(config)
349
+ const mode = process.env.HIAI_OPENCODE_EXPORT_MCP_MODE?.trim().toLowerCase() || "safe"
350
+ const force = mode === "force"
351
+
352
+ if (existsSync(outputPath) && !isManagedStaticMcpFile(outputPath) && !force) {
353
+ console.error(
354
+ `Refusing to overwrite non-managed ${outputPath}. ` +
355
+ `Set HIAI_OPENCODE_EXPORT_MCP_MODE=force to override.`,
356
+ )
357
+ process.exit(1)
358
+ }
359
+
360
+ mkdirSync(dirname(outputPath), { recursive: true })
361
+ writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`)
362
+ console.log(`Wrote ${outputPath}`)
363
+ console.log(`Source config: ${path ?? "defaults"}`)
364
+ console.log(`Servers: ${Object.keys(output.mcpServers).join(", ") || "(none)"}`)
365
+ }
366
+
109
367
  function hasCommand(command, args = ["--version"]) {
110
368
  const result = spawnSync(command, args, {
111
369
  stdio: "ignore",
@@ -133,10 +391,41 @@ function checkNodeNpx() {
133
391
  }
134
392
  }
135
393
 
136
- function pythonCandidates() {
394
+ function resolveVenvPythonCandidates(basePath) {
395
+ if (!basePath) return []
396
+ if (process.platform === "win32") {
397
+ return [
398
+ join(basePath, ".venv", "Scripts", "python.exe"),
399
+ join(basePath, "venv", "Scripts", "python.exe"),
400
+ ]
401
+ }
402
+
403
+ return [
404
+ join(basePath, ".venv", "bin", "python"),
405
+ join(basePath, ".venv", "bin", "python3"),
406
+ join(basePath, "venv", "bin", "python"),
407
+ join(basePath, "venv", "bin", "python3"),
408
+ ]
409
+ }
410
+
411
+ function pythonCandidates(config) {
137
412
  const configured = process.env.MEMPALACE_PYTHON?.trim()
413
+ || resolveEnvTemplate(config?.mcp?.mempalace?.pythonPath?.trim())
138
414
  const candidates = []
139
415
  if (configured) candidates.push(configured)
416
+
417
+ for (const candidate of resolveVenvPythonCandidates(process.cwd())) {
418
+ if (existsSync(candidate)) candidates.push(candidate)
419
+ }
420
+
421
+ for (const candidate of resolveVenvPythonCandidates(PACKAGE_ROOT)) {
422
+ if (existsSync(candidate)) candidates.push(candidate)
423
+ }
424
+
425
+ for (const candidate of resolveVenvPythonCandidates(join(homedir(), ".config", "opencode", "plugins", "hiai-opencode"))) {
426
+ if (existsSync(candidate)) candidates.push(candidate)
427
+ }
428
+
140
429
  if (process.platform === "win32") {
141
430
  candidates.push("py", "python", "python3")
142
431
  } else {
@@ -158,18 +447,18 @@ function canImportMempalace(command) {
158
447
  return result.status === 0
159
448
  }
160
449
 
161
- function checkMempalace() {
450
+ function checkMempalace(config) {
162
451
  if (hasCommand(process.platform === "win32" ? "uv.exe" : "uv")) {
163
452
  return { level: "ok", detail: "uv available" }
164
453
  }
165
454
 
166
- for (const candidate of pythonCandidates()) {
455
+ for (const candidate of pythonCandidates(config)) {
167
456
  if (canImportMempalace(candidate)) {
168
457
  return { level: "ok", detail: `${candidate} with mempalace available` }
169
458
  }
170
459
  }
171
460
 
172
- const hasPython = pythonCandidates().some((candidate) =>
461
+ const hasPython = pythonCandidates(config).some((candidate) =>
173
462
  hasCommand(candidate, candidate === "py" ? ["-3", "--version"] : ["--version"]),
174
463
  )
175
464
 
@@ -211,6 +500,247 @@ function checkRemoteOptionalKey(_config, name) {
211
500
  return { level: "ok", detail: "remote endpoint configured" }
212
501
  }
213
502
 
503
+ function detectMempalacePythonSource(config) {
504
+ const envPython = process.env.MEMPALACE_PYTHON?.trim()
505
+ if (envPython) {
506
+ return { source: "env:MEMPALACE_PYTHON", value: envPython }
507
+ }
508
+
509
+ const cfgPython = resolveEnvTemplate(config?.mcp?.mempalace?.pythonPath?.trim())
510
+ if (cfgPython) {
511
+ return { source: "config:mcp.mempalace.pythonPath", value: cfgPython }
512
+ }
513
+
514
+ const candidates = pythonCandidates(config)
515
+ if (candidates.length > 0) {
516
+ const resolved = candidates.find((candidate) =>
517
+ hasCommand(candidate, candidate === "py" ? ["-3", "--version"] : ["--version"]),
518
+ )
519
+ if (resolved) {
520
+ return { source: "auto-detect", value: resolved }
521
+ }
522
+ }
523
+
524
+ return { source: "none", value: "" }
525
+ }
526
+
527
+ function parseProviderFromModelId(modelId) {
528
+ if (!modelId || typeof modelId !== "string") return null
529
+ const normalized = modelId.trim()
530
+ if (!normalized) return null
531
+ const directProvider = normalized.split("/")[0]
532
+ if (directProvider === "openrouter") {
533
+ const routedProvider = normalized.split("/")[1]
534
+ return routedProvider || "openrouter"
535
+ }
536
+ return directProvider
537
+ }
538
+
539
+ function collectConfiguredProviders(config) {
540
+ const providerSet = new Set()
541
+ const modelEntries = Object.values(config?.models ?? {})
542
+ for (const entry of modelEntries) {
543
+ const modelId = typeof entry === "string" ? entry : entry?.model
544
+ const provider = parseProviderFromModelId(modelId)
545
+ if (provider) providerSet.add(provider)
546
+ }
547
+ return [...providerSet].sort()
548
+ }
549
+
550
+ function checkOpenCodeConnectVisibility(config) {
551
+ const providers = collectConfiguredProviders(config)
552
+ const opencodeBinary = process.platform === "win32" ? "opencode.cmd" : "opencode"
553
+ const opencodeAvailable = hasCommand(opencodeBinary, ["--version"])
554
+ if (!opencodeAvailable) {
555
+ return {
556
+ level: "warn",
557
+ detail: `opencode binary not available in PATH; cannot inspect Connect providers. Configured model providers: ${providers.join(", ") || "(none)"}`,
558
+ }
559
+ }
560
+
561
+ const commands = [
562
+ [opencodeBinary, ["connect", "list", "--json"]],
563
+ [opencodeBinary, ["connect", "list"]],
564
+ ]
565
+
566
+ for (const [binary, args] of commands) {
567
+ const result = spawnSync(binary, args, { encoding: "utf-8", timeout: 15000, shell: process.platform === "win32" })
568
+ if (result.status === 0) {
569
+ const out = (result.stdout || "").trim()
570
+ const summary = out ? out.split("\n").slice(0, 3).join(" | ") : "connect list ok"
571
+ return {
572
+ level: "ok",
573
+ detail: `OpenCode Connect visible. Configured model providers: ${providers.join(", ") || "(none)"}; connect summary: ${summary}`,
574
+ }
575
+ }
576
+ }
577
+
578
+ return {
579
+ level: "warn",
580
+ detail: `Could not read OpenCode Connect state. Configured model providers: ${providers.join(", ") || "(none)"}`,
581
+ }
582
+ }
583
+
584
+ function getSkillRegistryPath() {
585
+ if (process.platform === "win32" && process.env.APPDATA) {
586
+ return join(process.env.APPDATA, "opencode", ".hiai", "skill-registry.json")
587
+ }
588
+ return join(homedir(), ".config", "opencode", ".hiai", "skill-registry.json")
589
+ }
590
+
591
+ function checkSkillMaterialization() {
592
+ const registryPath = getSkillRegistryPath()
593
+ if (!existsSync(registryPath)) {
594
+ return { level: "warn", detail: `skill registry missing: ${registryPath}` }
595
+ }
596
+
597
+ try {
598
+ const parsed = JSON.parse(readFileSync(registryPath, "utf-8"))
599
+ const summary = parsed?.summary
600
+ const total = summary?.total ?? 0
601
+ const builtin = summary?.builtin ?? 0
602
+ const plugin = summary?.plugin ?? 0
603
+ return {
604
+ level: "ok",
605
+ detail: `materialized skills: total=${total}, builtin=${builtin}, plugin=${plugin}`,
606
+ }
607
+ } catch (error) {
608
+ return {
609
+ level: "warn",
610
+ detail: `failed to parse skill registry: ${error instanceof Error ? error.message : String(error)}`,
611
+ }
612
+ }
613
+ }
614
+
615
+ function getAgentSummary(config) {
616
+ const visible = [
617
+ "Bob",
618
+ "Coder",
619
+ "Strategist",
620
+ "Guard",
621
+ "Critic",
622
+ "Designer",
623
+ "Researcher",
624
+ "Manager",
625
+ "Brainstormer",
626
+ "Vision",
627
+ ]
628
+ const hidden = ["Agent Skills", "Sub", "build", "plan"]
629
+ const modelCount = Object.keys(config?.models ?? {}).length
630
+ return {
631
+ level: modelCount >= 10 ? "ok" : "warn",
632
+ detail: `visible=${visible.length} [${visible.join(", ")}]; hidden=${hidden.length} [${hidden.join(", ")}]; model slots configured=${modelCount}/10`,
633
+ }
634
+ }
635
+
636
+ function getLspDefaults() {
637
+ return {
638
+ typescript: ["typescript-language-server", "--stdio"],
639
+ svelte: ["svelteserver", "--stdio"],
640
+ eslint: ["node", join(PACKAGE_ROOT, "assets", "runtime", "npm-package-runner.mjs"), "eslint-lsp", "--stdio"],
641
+ bash: ["node", join(PACKAGE_ROOT, "assets", "runtime", "npm-package-runner.mjs"), "bash-language-server", "start"],
642
+ pyright: ["pyright-langserver", "--stdio"],
643
+ }
644
+ }
645
+
646
+ function checkLspAvailability(config) {
647
+ const defaults = getLspDefaults()
648
+ const results = []
649
+
650
+ for (const [name, command] of Object.entries(defaults)) {
651
+ const enabled = config?.lsp?.[name]?.enabled ?? true
652
+ if (!enabled) {
653
+ results.push(`⚪ ${name}: disabled`)
654
+ continue
655
+ }
656
+ const binary = command[0]
657
+ const args = binary === "node" ? ["--version"] : ["--version"]
658
+ const ok = hasCommand(binary, args)
659
+ results.push(`${ok ? "✅" : "⚠️ "} ${name}: ${ok ? "runtime available" : `${binary} not found`}`)
660
+ }
661
+
662
+ return {
663
+ level: results.some((line) => line.startsWith("⚠️")) ? "warn" : "ok",
664
+ detail: results.join(" | "),
665
+ }
666
+ }
667
+
668
+ async function probeStdioMcp(serverName, serverConfig) {
669
+ const command = serverConfig?.command
670
+ const args = serverConfig?.args ?? []
671
+ if (!command) {
672
+ return { level: "warn", detail: `${serverName}: missing command` }
673
+ }
674
+
675
+ const env = { ...process.env, ...(serverConfig?.env ?? {}) }
676
+ const client = new Client(
677
+ { name: "hiai-opencode-doctor", version: "0.1.0" },
678
+ { capabilities: { tools: {}, prompts: {}, resources: {} } },
679
+ )
680
+
681
+ const transport = new StdioClientTransport({
682
+ command,
683
+ args,
684
+ env,
685
+ stderr: "pipe",
686
+ })
687
+
688
+ const timeoutMs = 12000
689
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), timeoutMs))
690
+
691
+ try {
692
+ await Promise.race([client.connect(transport), timeout])
693
+ const toolsResponse = await Promise.race([client.listTools(), timeout])
694
+ const count = toolsResponse?.tools?.length ?? 0
695
+ await client.close()
696
+ return { level: "ok", detail: `${serverName}: reachable, tools=${count}` }
697
+ } catch (error) {
698
+ try { await client.close() } catch {}
699
+ return {
700
+ level: "warn",
701
+ detail: `${serverName}: probe failed (${error instanceof Error ? error.message : String(error)})`,
702
+ }
703
+ }
704
+ }
705
+
706
+ async function probeRemoteMcp(serverName, serverConfig) {
707
+ const url = serverConfig?.url
708
+ if (!url) return { level: "warn", detail: `${serverName}: missing url` }
709
+
710
+ try {
711
+ const controller = new AbortController()
712
+ const timeout = setTimeout(() => controller.abort(), 4000)
713
+ const response = await fetch(url, { method: "GET", signal: controller.signal })
714
+ clearTimeout(timeout)
715
+ if (response.status < 500) {
716
+ return { level: "ok", detail: `${serverName}: endpoint reachable (${response.status})` }
717
+ }
718
+ return { level: "warn", detail: `${serverName}: endpoint returned ${response.status}` }
719
+ } catch (error) {
720
+ return {
721
+ level: "warn",
722
+ detail: `${serverName}: endpoint probe failed (${error instanceof Error ? error.message : String(error)})`,
723
+ }
724
+ }
725
+ }
726
+
727
+ async function probeMcpServers(config) {
728
+ const payload = createMcpExport(config)
729
+ const results = []
730
+ for (const [name, server] of Object.entries(payload.mcpServers ?? {})) {
731
+ if (server.command) {
732
+ results.push(await probeStdioMcp(name, server))
733
+ continue
734
+ }
735
+ if (server.type === "http") {
736
+ results.push(await probeRemoteMcp(name, server))
737
+ continue
738
+ }
739
+ results.push({ level: "warn", detail: `${name}: unsupported server shape` })
740
+ }
741
+ return results
742
+ }
743
+
214
744
  function hasEnvOrAuth(config, envName, authKey) {
215
745
  if (process.env[envName]?.trim()) return true
216
746
  if (authKey && config?.auth?.[authKey]?.trim()) return true
@@ -223,11 +753,13 @@ function statusIcon(level) {
223
753
  return "❌"
224
754
  }
225
755
 
226
- async function mcpStatus() {
756
+ async function mcpStatus(options = {}) {
227
757
  const { path, config, error } = loadConfig()
228
- console.log("hiai-opencode mcp-status")
758
+ const staticMcpPath = join(process.cwd(), ".mcp.json")
759
+ console.log(options.doctor ? "hiai-opencode doctor" : "hiai-opencode mcp-status")
229
760
  console.log(`Config: ${path ?? "not found; using defaults"}`)
230
761
  if (error) console.log(`Config parse warning: ${error}`)
762
+ console.log(`Static MCP export: ${staticMcpPath}`)
231
763
  console.log("")
232
764
  console.log("MCP Servers:")
233
765
 
@@ -251,6 +783,47 @@ async function mcpStatus() {
251
783
  const result = await entry.check(config, name)
252
784
  console.log(`${statusIcon(result.level)} ${name.padEnd(20)} - ${result.detail}`)
253
785
  }
786
+
787
+ if (options.doctor) {
788
+ console.log("")
789
+ console.log("Doctor Checks:")
790
+
791
+ const freshness = checkStaticMcpFreshness(staticMcpPath, config)
792
+ const freshIcon = freshness.status === "fresh" ? "✅" : freshness.status === "missing" ? "⚠️ " : "❌"
793
+ console.log(`${freshIcon} static .mcp.json freshness - ${freshness.detail}`)
794
+
795
+ const connect = checkOpenCodeConnectVisibility(config)
796
+ console.log(`${statusIcon(connect.level)} OpenCode Connect visibility - ${connect.detail}`)
797
+
798
+ const pluginRegistration = checkOpenCodePluginRegistration()
799
+ console.log(`${statusIcon(pluginRegistration.level)} OpenCode plugin registration - ${pluginRegistration.detail}`)
800
+
801
+ const skills = checkSkillMaterialization()
802
+ console.log(`${statusIcon(skills.level)} Skill materialization - ${skills.detail}`)
803
+
804
+ const agents = getAgentSummary(config)
805
+ console.log(`${statusIcon(agents.level)} Agent count and naming - ${agents.detail}`)
806
+
807
+ const lsp = checkLspAvailability(config)
808
+ console.log(`${statusIcon(lsp.level)} LSP runtime availability - ${lsp.detail}`)
809
+
810
+ const mempalacePython = detectMempalacePythonSource(config)
811
+ const mempalacePythonIcon = mempalacePython.value ? "✅" : "⚠️ "
812
+ console.log(`${mempalacePythonIcon} MemPalace python selection - ${mempalacePython.source}${mempalacePython.value ? ` (${mempalacePython.value})` : ""}`)
813
+
814
+ console.log("")
815
+ console.log("MCP Tool Probes:")
816
+ const probeResults = await probeMcpServers(config)
817
+ for (const probe of probeResults) {
818
+ console.log(`${statusIcon(probe.level)} ${probe.detail}`)
819
+ }
820
+
821
+ console.log("")
822
+ console.log("Recommended follow-ups:")
823
+ console.log(" - hiai-opencode export-mcp .mcp.json")
824
+ console.log(" - opencode debug config")
825
+ console.log(" - opencode mcp list --print-logs --log-level INFO")
826
+ }
254
827
  }
255
828
 
256
829
  async function main() {
@@ -265,6 +838,16 @@ async function main() {
265
838
  return
266
839
  }
267
840
 
841
+ if (command === "doctor") {
842
+ await mcpStatus({ doctor: true })
843
+ return
844
+ }
845
+
846
+ if (command === "export-mcp") {
847
+ exportMcp(process.argv[3])
848
+ return
849
+ }
850
+
268
851
  console.error(`Unknown command: ${command}`)
269
852
  usage()
270
853
  process.exit(1)