pantheon-opencode 1.2.1 → 1.2.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/AGENTS.md +9 -0
- package/README.md +12 -13
- package/bin/pantheon-init.mjs +14 -0
- package/opencode.json +1 -1
- package/package.json +13 -4
- package/scripts/changelog-extract.mjs +112 -0
- package/scripts/doctor.mjs +303 -13
- package/scripts/hooks/audit-imports.sh +43 -0
- package/scripts/hooks/format-multi-language.sh +96 -0
- package/scripts/hooks/log-session-start.sh +24 -0
- package/scripts/hooks/on-subagent-delegation-start.sh +91 -0
- package/scripts/hooks/on-subagent-delegation-stop.sh +37 -0
- package/scripts/hooks/run-type-check.sh +21 -0
- package/scripts/hooks/scan-secrets.sh +55 -0
- package/scripts/hooks/validate-post-conditions.sh +21 -0
- package/scripts/hooks/validate-talos-scope.sh +43 -0
- package/scripts/hooks/validate-tool-safety.sh +46 -0
- package/scripts/install/opencode.mjs +58 -29
- package/scripts/install/plugin.mjs +4 -2
- package/scripts/install-mcp.mjs +104 -56
- package/scripts/mcp_persistence_server.py +1 -0
- package/scripts/prune.mjs +359 -0
- package/scripts/version-check.mjs +146 -0
- package/scripts/versioning.mjs +13 -14
- package/src/mcp/requirements-mcp.txt +8 -5
- package/src/mcp/requirements-vision.txt +3 -3
- package/src/plugins/hook-runner.ts +186 -0
- package/src/plugins/pantheon-hooks.ts +153 -0
- package/src/plugins/tui/dist/tui.d.ts +8 -0
- package/src/plugins/tui/dist/tui.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui.js +1198 -0
- package/src/plugins/tui/dist/tui.js.map +1 -0
- package/src/plugins/tui/dist/tui.tsx +1204 -0
- package/src/plugins/tui/package.json +2 -2
- package/src/plugins/tui/src/index.tsx +15 -0
- package/src/mcp/requirements-mcp-core.txt +0 -5
package/AGENTS.md
CHANGED
|
@@ -29,6 +29,15 @@ See [INSTALLATION.md](docs/INSTALLATION.md) for setup instructions.
|
|
|
29
29
|
- Test: `npm test`
|
|
30
30
|
- Lint: `npm run lint`
|
|
31
31
|
|
|
32
|
+
## Teste de Instalação Global (sandbox)
|
|
33
|
+
|
|
34
|
+
Para validar a instalação global do pacote pantheon-opencode COMO UM USUÁRIO REAL, use o sandbox isolado em `~/pantheon-sandbox/` (fora do repo, HOME + prefix npm + venv próprios). O ambiente de dev mistura 3 instalações + config global + venv — NÃO serve para testar instalação/empacotamento.
|
|
35
|
+
|
|
36
|
+
- Rodar: `bash ~/pantheon-sandbox/run-test.sh` (opencode mcp list 5/5 connected + doctor 0 erros + abre TUI isolado)
|
|
37
|
+
- Regra para agentes: ao validar instalação global (npm pack, `init`, MCPs, hooks), usar o sandbox — NUNCA testar no ambiente de dev
|
|
38
|
+
- Descarte: `rm -rf ~/pantheon-sandbox`
|
|
39
|
+
- Detalhes: ver `~/pantheon-sandbox/README.md`
|
|
40
|
+
|
|
32
41
|
## Conventions
|
|
33
42
|
|
|
34
43
|
- TDD: Write failing test first, then implement
|
package/README.md
CHANGED
|
@@ -737,19 +737,18 @@ Edit README.md line 11: agents-17 → agents-18
|
|
|
737
737
|
- Hardcoded secret detection
|
|
738
738
|
- Minimum 80% test coverage (hard block)
|
|
739
739
|
|
|
740
|
-
**Agent hooks enforce at runtime (`scripts/hooks/` +
|
|
741
|
-
- `scan-secrets.sh` — detects hardcoded secrets and credentials (
|
|
742
|
-
- `validate-tool-safety.sh` — blocks destructive operations (
|
|
743
|
-
- `validate-talos-scope.sh` — restricts Talos hotfix scope (
|
|
744
|
-
- `on-subagent-delegation-start.sh` — tracks delegation start (
|
|
745
|
-
- `format-multi-language.sh` — auto-formats modified files (
|
|
746
|
-
- `log-session-start.sh` — audit trail of sessions (
|
|
747
|
-
- `on-subagent-delegation-stop.sh` — delegation cleanup (
|
|
748
|
-
- `validate-post-conditions.sh` — post-condition validation (event)
|
|
749
|
-
- `audit-imports.sh`
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
The `.opencode/plugins/pantheon-hooks.ts` plugin bridges these shell scripts to OpenCode events. OpenCode auto-discovers plugins from `.opencode/plugins/` when running from the project directory.
|
|
740
|
+
**Agent hooks enforce at runtime (`scripts/hooks/` + `src/plugins/pantheon-hooks.ts`):**
|
|
741
|
+
- `scan-secrets.sh` — detects hardcoded secrets and credentials (tool.execute.before)
|
|
742
|
+
- `validate-tool-safety.sh` — blocks destructive operations (tool.execute.before)
|
|
743
|
+
- `validate-talos-scope.sh` — restricts Talos hotfix scope (tool.execute.before)
|
|
744
|
+
- `on-subagent-delegation-start.sh` — tracks delegation start (tool.execute.before, delegation tools only)
|
|
745
|
+
- `format-multi-language.sh` — auto-formats modified files (tool.execute.after)
|
|
746
|
+
- `log-session-start.sh` — audit trail of sessions (tool.execute.after + event session.created)
|
|
747
|
+
- `on-subagent-delegation-stop.sh` — delegation cleanup (tool.execute.after, delegation tools only)
|
|
748
|
+
- `validate-post-conditions.sh` — post-condition validation (event session.created)
|
|
749
|
+
- `audit-imports.sh` and `run-type-check.sh` also live in `scripts/hooks/` but are not wired into the plugin.
|
|
750
|
+
|
|
751
|
+
The `src/plugins/pantheon-hooks.ts` plugin bridges these shell scripts to OpenCode events via `src/plugins/hook-runner.ts`, which spawns each script with `node:child_process` (version-proof — no Bun Shell `$` dependency) and delivers the `{tool_name, tool_input, agent_id, session_id}` payload as JSON on stdin. Register it explicitly in `opencode.json`: `"plugin": ["<path>/src/plugins/pantheon-hooks.ts"]` (absolute path). It is **not** auto-discovered from `.opencode/plugins/`.
|
|
753
752
|
|
|
754
753
|
### Pre-commit hooks (local secret gate)
|
|
755
754
|
|
package/bin/pantheon-init.mjs
CHANGED
|
@@ -37,6 +37,8 @@ function printUsage() {
|
|
|
37
37
|
console.log(' npx pantheon-opencode init --preset <name> # Install and activate model preset');
|
|
38
38
|
console.log(' npx pantheon-opencode set-tier <name> # Set active model preset (global)');
|
|
39
39
|
console.log(' npx pantheon-opencode set-tier <name> --project # Set active model preset (project)');
|
|
40
|
+
console.log(' npx pantheon-opencode prune # List legacy artifacts (dry-run)');
|
|
41
|
+
console.log(' npx pantheon-opencode prune --apply # Remove stale backups');
|
|
40
42
|
console.log(' npx pantheon-opencode --help # Show this help');
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -44,6 +46,18 @@ async function main() {
|
|
|
44
46
|
const args = process.argv.slice(2);
|
|
45
47
|
const command = args[0];
|
|
46
48
|
|
|
49
|
+
// prune handles its own --help (delegated to scripts/prune.mjs), so its
|
|
50
|
+
// dispatch must run BEFORE the generic --help check.
|
|
51
|
+
if (command === 'prune') {
|
|
52
|
+
const { spawnSync } = await import('child_process');
|
|
53
|
+
const pruneScript = path.join(ROOT, 'scripts', 'prune.mjs');
|
|
54
|
+
const result = spawnSync(process.execPath, [pruneScript, ...args.slice(1)], {
|
|
55
|
+
stdio: 'inherit',
|
|
56
|
+
cwd: ROOT,
|
|
57
|
+
});
|
|
58
|
+
process.exit(result.status ?? 1);
|
|
59
|
+
}
|
|
60
|
+
|
|
47
61
|
if (args.includes('--help')) { printUsage(); process.exit(0); }
|
|
48
62
|
|
|
49
63
|
if (command === 'set-tier') {
|
package/opencode.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon-opencode",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"postinstall": "node scripts/postinstall.mjs",
|
|
11
|
+
"prepare": "husky",
|
|
11
12
|
"setup": "node bin/pantheon-init.mjs init",
|
|
12
13
|
"setup:lite": "node bin/pantheon-init.mjs init --clean --components agents,skills,instructions",
|
|
13
14
|
"doctor": "node scripts/doctor.mjs",
|
|
15
|
+
"prune": "node scripts/prune.mjs",
|
|
14
16
|
"init": "node bin/pantheon-init.mjs init",
|
|
15
17
|
"opencode": "OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode",
|
|
16
18
|
"start": "OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode",
|
|
@@ -18,6 +20,7 @@
|
|
|
18
20
|
"postpublish": "node -e \"console.log('✅ Published pantheon-opencode v' + require('./package.json').version + ' to npm')\"",
|
|
19
21
|
"version:status": "node scripts/versioning.mjs status",
|
|
20
22
|
"version:apply": "node scripts/versioning.mjs apply",
|
|
23
|
+
"version:check": "node scripts/version-check.mjs",
|
|
21
24
|
"release:dry-run": "node scripts/versioning.mjs status && npm pack --dry-run",
|
|
22
25
|
"publish:next": "npm publish --tag next --access public",
|
|
23
26
|
"lint": "npx biome ci .",
|
|
@@ -26,7 +29,8 @@
|
|
|
26
29
|
"secret-scan": "node scripts/secret-scan.mjs",
|
|
27
30
|
"hooks:install": "pre-commit install",
|
|
28
31
|
"hooks:update": "pre-commit autoupdate",
|
|
29
|
-
"test:security": "npm run secret-scan && node tests/test_install_mcp_vision.mjs",
|
|
32
|
+
"test:security": "npm run secret-scan && node tests/test_install_mcp_vision.mjs && node tests/test_doctor_layers.mjs",
|
|
33
|
+
"test:node": "node --test tests/*.mjs",
|
|
30
34
|
"test": "python3 -m pytest tests/ -q 2>&1 || echo \"No tests found\"",
|
|
31
35
|
"test:ci": "python3 -m pytest tests/ -q --tb=short 2>&1 || true"
|
|
32
36
|
},
|
|
@@ -55,6 +59,8 @@
|
|
|
55
59
|
"src/mcp/*.sh",
|
|
56
60
|
"src/routing.yml",
|
|
57
61
|
"src/pantheon/**",
|
|
62
|
+
"src/plugins/**",
|
|
63
|
+
"scripts/hooks/**",
|
|
58
64
|
"src/mcp/requirements*",
|
|
59
65
|
"src/plugins/tui/**",
|
|
60
66
|
"src/skills/**"
|
|
@@ -66,13 +72,16 @@
|
|
|
66
72
|
"@commitlint/cli": "^19.8.0",
|
|
67
73
|
"@commitlint/config-conventional": "^19.8.0",
|
|
68
74
|
"@opencode-ai/plugin": "1.18.11",
|
|
69
|
-
"husky": "^9.1.7"
|
|
70
|
-
"pantheon-opencode": "^1.2.0-beta.9.7bef03e"
|
|
75
|
+
"husky": "^9.1.7"
|
|
71
76
|
},
|
|
72
77
|
"repository": {
|
|
73
78
|
"type": "git",
|
|
74
79
|
"url": "https://github.com/ils15/pantheon-opencode.git"
|
|
75
80
|
},
|
|
81
|
+
"homepage": "https://github.com/ils15/pantheon-opencode",
|
|
82
|
+
"bugs": {
|
|
83
|
+
"url": "https://github.com/ils15/pantheon-opencode/issues"
|
|
84
|
+
},
|
|
76
85
|
"keywords": [
|
|
77
86
|
"github",
|
|
78
87
|
"copilot",
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* changelog-extract.mjs — print a version section from CHANGELOG.md
|
|
4
|
+
*
|
|
5
|
+
* The release workflow needs the CHANGELOG entry for the version it is about
|
|
6
|
+
* to tag. This script extracts the body of `## [<version>] - YYYY-MM-DD`
|
|
7
|
+
* (leading `v` tolerated, date optional) and prints it to stdout. When the
|
|
8
|
+
* section is missing it exits 1, so the workflow fails loudly: "add the
|
|
9
|
+
* section in the version-bump PR".
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node scripts/changelog-extract.mjs 1.2.1
|
|
13
|
+
* node scripts/changelog-extract.mjs v1.2.1 --changelog /path/to/CHANGELOG.md
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from 'node:fs'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
19
|
+
|
|
20
|
+
const DEFAULT_CHANGELOG = join(dirname(fileURLToPath(import.meta.url)), '..', 'CHANGELOG.md')
|
|
21
|
+
|
|
22
|
+
/** Escape regex metacharacters so a version like 1.0.0 matches literally. */
|
|
23
|
+
function escapeRegExp(text) {
|
|
24
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extract the body of a version section from changelog text.
|
|
29
|
+
*
|
|
30
|
+
* Finds the heading `## [<version>] - YYYY-MM-DD` (leading `v` and the date
|
|
31
|
+
* are optional) and returns everything after the heading line up to the next
|
|
32
|
+
* `## [` heading (or end of text). The `## [Unreleased]` heading is never
|
|
33
|
+
* matched, even when asked for explicitly.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} changelogText - full CHANGELOG.md content
|
|
36
|
+
* @param {string} version - e.g. "1.2.1" or "v1.2.1"
|
|
37
|
+
* @returns {{found: boolean, body: string|null}} body is null when not found
|
|
38
|
+
*/
|
|
39
|
+
export function extractSection(changelogText, version) {
|
|
40
|
+
const normalized = String(version).trim().replace(/^v/i, '')
|
|
41
|
+
if (!normalized || /^unreleased$/i.test(normalized)) {
|
|
42
|
+
return { found: false, body: null }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const escaped = escapeRegExp(normalized)
|
|
46
|
+
// Single-line heading: `## [v?1.2.1]` optionally followed by ` - YYYY-MM-DD`.
|
|
47
|
+
// [ \t] (not \s) keeps the match from crossing newlines; the lookahead
|
|
48
|
+
// requires end-of-line or whitespace so `[1.0.0-beta]` can never match `1.0.0`.
|
|
49
|
+
const headingRe = new RegExp(
|
|
50
|
+
`^##[ \\t]+\\[v?${escaped}\\](?:[ \\t]*-[ \\t]*\\d{4}-\\d{2}-\\d{2})?(?=[ \\t]|$)`,
|
|
51
|
+
'm',
|
|
52
|
+
)
|
|
53
|
+
const match = headingRe.exec(changelogText)
|
|
54
|
+
if (!match) return { found: false, body: null }
|
|
55
|
+
|
|
56
|
+
// Advance past the heading line (match.index may point mid-line).
|
|
57
|
+
const newline = changelogText.indexOf('\n', match.index)
|
|
58
|
+
const start = newline === -1 ? changelogText.length : newline + 1
|
|
59
|
+
|
|
60
|
+
// Body ends at the next `## [` heading or end of text.
|
|
61
|
+
const nextHeading = /^##[ \t]+\[/m.exec(changelogText.slice(start))
|
|
62
|
+
const end = nextHeading ? start + nextHeading.index : changelogText.length
|
|
63
|
+
|
|
64
|
+
return { found: true, body: changelogText.slice(start, end).trim() }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
|
68
|
+
|
|
69
|
+
if (isDirectRun) {
|
|
70
|
+
const args = process.argv.slice(2)
|
|
71
|
+
let changelogPath = DEFAULT_CHANGELOG
|
|
72
|
+
let version = null
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
75
|
+
const arg = args[i]
|
|
76
|
+
if (arg === '--changelog') {
|
|
77
|
+
changelogPath = args[i + 1]
|
|
78
|
+
i += 1
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if (arg.startsWith('--changelog=')) {
|
|
82
|
+
changelogPath = arg.slice('--changelog='.length)
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
version = arg
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!version) {
|
|
89
|
+
console.error('Usage: node scripts/changelog-extract.mjs <version> [--changelog <path>]')
|
|
90
|
+
process.exitCode = 2
|
|
91
|
+
} else {
|
|
92
|
+
let text
|
|
93
|
+
try {
|
|
94
|
+
text = readFileSync(changelogPath, 'utf-8')
|
|
95
|
+
} catch {
|
|
96
|
+
console.error(`changelog-extract: cannot read ${changelogPath}`)
|
|
97
|
+
process.exitCode = 1
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (text !== undefined) {
|
|
101
|
+
const { found, body } = extractSection(text, version)
|
|
102
|
+
if (!found) {
|
|
103
|
+
console.error(`changelog-extract: section [${version}] not found in ${changelogPath}`)
|
|
104
|
+
console.error('Add the section in the version-bump PR — the release workflow requires it.')
|
|
105
|
+
process.exitCode = 1
|
|
106
|
+
} else {
|
|
107
|
+
process.stdout.write(`${body}\n`)
|
|
108
|
+
process.exitCode = 0
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
package/scripts/doctor.mjs
CHANGED
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
* 2 = errors (needs attention)
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { spawnSync } from 'node:child_process'
|
|
27
|
+
import { spawn as spawnAsync, spawnSync } from 'node:child_process'
|
|
28
28
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
29
|
-
import {
|
|
29
|
+
import { homedir } from 'node:os'
|
|
30
|
+
import { basename, dirname, join } from 'node:path'
|
|
30
31
|
import { fileURLToPath } from 'node:url'
|
|
31
32
|
|
|
32
33
|
// ---------------------------------------------------------------------------
|
|
@@ -158,10 +159,13 @@ function showHelp() {
|
|
|
158
159
|
|
|
159
160
|
Checks:
|
|
160
161
|
A. Agent Files — canonical vs platform sync
|
|
161
|
-
B. MCP Configuration — opencode.json MCP settings
|
|
162
|
+
B. MCP Configuration — opencode.json MCP settings (Config layer)
|
|
163
|
+
B.5 MCP Spawn Paths — local MCP exe/args resolve to existing files
|
|
164
|
+
B.6 MCP Runtime Smoke — spawn each local MCP + JSON-RPC initialize handshake
|
|
162
165
|
C. Permission Checks — frontmatter validation
|
|
163
166
|
D. Sync Status — npm run sync freshness
|
|
164
167
|
E. Git Status — uncommitted changes
|
|
168
|
+
F. Runtime Layer — venv python + pinned pip dependencies
|
|
165
169
|
|
|
166
170
|
Exit codes:
|
|
167
171
|
0 — all checks pass
|
|
@@ -314,12 +318,12 @@ function checkAgentFiles(args) {
|
|
|
314
318
|
// Check B: MCP Configuration
|
|
315
319
|
// ---------------------------------------------------------------------------
|
|
316
320
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Collect every reachable opencode.json (project → repository → user config)
|
|
323
|
+
* with its parsed content, for the config, spawn-path and runtime layers.
|
|
324
|
+
* @returns {{label:string, path:string, data:object}[]}
|
|
325
|
+
*/
|
|
326
|
+
function collectMcpConfigs(args) {
|
|
323
327
|
const candidates = [
|
|
324
328
|
{ label: 'project root', path: join(args.target, 'opencode.json') },
|
|
325
329
|
{ label: 'repository root', path: join(ROOT, 'opencode.json') },
|
|
@@ -336,7 +340,6 @@ function checkMcpConfig(args) {
|
|
|
336
340
|
candidates.push({ label: 'user config', path: homeConfig })
|
|
337
341
|
}
|
|
338
342
|
|
|
339
|
-
/** @type {{label:string, data:object}[]} */
|
|
340
343
|
const configs = []
|
|
341
344
|
for (const c of candidates) {
|
|
342
345
|
const data = readJson(c.path)
|
|
@@ -344,6 +347,13 @@ function checkMcpConfig(args) {
|
|
|
344
347
|
configs.push({ label: c.label, data, path: c.path })
|
|
345
348
|
}
|
|
346
349
|
}
|
|
350
|
+
return configs
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function checkMcpConfig(args) {
|
|
354
|
+
section('B. MCP Configuration')
|
|
355
|
+
|
|
356
|
+
const configs = collectMcpConfigs(args)
|
|
347
357
|
|
|
348
358
|
if (configs.length === 0) {
|
|
349
359
|
warn('No opencode.json found in target or user config')
|
|
@@ -412,6 +422,155 @@ function checkMcpConfig(args) {
|
|
|
412
422
|
if (args.verbose) {
|
|
413
423
|
checkAgentMcpReferences(cfg)
|
|
414
424
|
}
|
|
425
|
+
|
|
426
|
+
// 5. Hermetic spawn check — every local MCP command must resolve to an
|
|
427
|
+
// existing executable and existing absolute script paths. Catches the
|
|
428
|
+
// ENOENT-on-spawn failure class (dead hardcoded path, ambiguous relative
|
|
429
|
+
// path) before opencode tries to spawn the server.
|
|
430
|
+
for (const [name, mcpEntry] of Object.entries(mcp)) {
|
|
431
|
+
if (!mcpEntry || mcpEntry.type !== 'local') continue
|
|
432
|
+
const cmd = mcpEntry.command
|
|
433
|
+
if (!Array.isArray(cmd) || cmd.length === 0) {
|
|
434
|
+
warn(`${cfg.label}: MCP "${name}" has no command array`)
|
|
435
|
+
continue
|
|
436
|
+
}
|
|
437
|
+
const exe = cmd[0]
|
|
438
|
+
if (!exe.includes('/')) {
|
|
439
|
+
// PATH-resolved executable — cannot verify statically; surface for manual check
|
|
440
|
+
info(`${cfg.label}: MCP "${name}" uses PATH executable "${exe}" (verify with \`which ${exe}\`)`)
|
|
441
|
+
continue
|
|
442
|
+
}
|
|
443
|
+
if (!existsSync(exe)) {
|
|
444
|
+
error(`${cfg.label}: MCP "${name}" executable does not exist: ${exe}`)
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
const deadArgs = cmd.slice(1).filter((a) => a.startsWith('/') && !existsSync(a))
|
|
448
|
+
if (deadArgs.length > 0) {
|
|
449
|
+
error(`${cfg.label}: MCP "${name}" has missing script path(s): ${deadArgs.join(', ')}`)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// Check B.6: MCP Runtime Smoke (JSON-RPC initialize handshake)
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// The static spawn-path check (B.5) proves the files exist; this layer proves
|
|
459
|
+
// the server actually STARTS and answers the JSON-RPC initialize handshake.
|
|
460
|
+
// Catches ENOENT-on-spawn, Python import errors and runtime crashes that the
|
|
461
|
+
// static check cannot see — with a hard timeout so a hung server fails the
|
|
462
|
+
// check instead of blocking the doctor run.
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Spawn one local MCP server, send a JSON-RPC `initialize` request and wait
|
|
466
|
+
* for a well-formed response.
|
|
467
|
+
* @param {string[]} command - argv of the MCP server (exe + args)
|
|
468
|
+
* @param {string} cwd - working directory (relative script args resolve here)
|
|
469
|
+
* @param {number} timeoutMs - hard timeout for the handshake
|
|
470
|
+
* @returns {Promise<{ok:boolean, serverName?:string, reason?:string}>}
|
|
471
|
+
*/
|
|
472
|
+
function mcpInitializeSmoke(command, cwd, timeoutMs) {
|
|
473
|
+
const payload = JSON.stringify({
|
|
474
|
+
jsonrpc: '2.0',
|
|
475
|
+
id: 1,
|
|
476
|
+
method: 'initialize',
|
|
477
|
+
params: {
|
|
478
|
+
protocolVersion: '2024-11-05',
|
|
479
|
+
capabilities: {},
|
|
480
|
+
clientInfo: { name: 'pantheon-doctor', version: '0.0.0' },
|
|
481
|
+
},
|
|
482
|
+
})
|
|
483
|
+
return new Promise((resolve) => {
|
|
484
|
+
let proc
|
|
485
|
+
try {
|
|
486
|
+
proc = spawnAsync(command[0], command.slice(1), { cwd, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
487
|
+
} catch (err) {
|
|
488
|
+
resolve({ ok: false, reason: `spawn threw: ${err.message}` })
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
let stdout = ''
|
|
492
|
+
let stderr = ''
|
|
493
|
+
let done = false
|
|
494
|
+
const finish = (result) => {
|
|
495
|
+
if (done) return
|
|
496
|
+
done = true
|
|
497
|
+
clearTimeout(timer)
|
|
498
|
+
try {
|
|
499
|
+
proc.kill('SIGKILL')
|
|
500
|
+
} catch {
|
|
501
|
+
/* already gone */
|
|
502
|
+
}
|
|
503
|
+
resolve(result)
|
|
504
|
+
}
|
|
505
|
+
const timer = setTimeout(
|
|
506
|
+
() => finish({ ok: false, reason: `timeout after ${timeoutMs}ms (no initialize response)` }),
|
|
507
|
+
timeoutMs,
|
|
508
|
+
)
|
|
509
|
+
proc.stdout.on('data', (chunk) => {
|
|
510
|
+
stdout += chunk.toString()
|
|
511
|
+
const line = stdout.split('\n').find((l) => l.includes('"id":1'))
|
|
512
|
+
if (!line) return
|
|
513
|
+
try {
|
|
514
|
+
const msg = JSON.parse(line.trim())
|
|
515
|
+
if (msg.id === 1 && msg.result) {
|
|
516
|
+
const info = msg.result.serverInfo
|
|
517
|
+
const label = info?.name ? `${info.name}${info.version ? ` ${info.version}` : ''}` : undefined
|
|
518
|
+
finish({ ok: true, serverName: label })
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
if (msg.error) {
|
|
522
|
+
finish({ ok: false, reason: `initialize error: ${msg.error.code ?? ''} ${msg.error.message ?? ''}` })
|
|
523
|
+
}
|
|
524
|
+
} catch {
|
|
525
|
+
/* not JSON yet — keep buffering */
|
|
526
|
+
}
|
|
527
|
+
})
|
|
528
|
+
proc.stderr.on('data', (chunk) => {
|
|
529
|
+
stderr += chunk.toString()
|
|
530
|
+
if (stderr.length > 4096) stderr = stderr.slice(-4096)
|
|
531
|
+
})
|
|
532
|
+
proc.on('error', (err) =>
|
|
533
|
+
finish({ ok: false, reason: `spawn failed: ${err.code ?? err.message}` }),
|
|
534
|
+
)
|
|
535
|
+
proc.on('exit', (code, signal) => {
|
|
536
|
+
if (done) return
|
|
537
|
+
const tail = stderr.trim().split('\n').filter(Boolean).slice(-3).join(' | ')
|
|
538
|
+
finish({
|
|
539
|
+
ok: false,
|
|
540
|
+
reason: `process exited early (code=${code} signal=${signal})${tail ? ` — ${tail}` : ''}`,
|
|
541
|
+
})
|
|
542
|
+
})
|
|
543
|
+
proc.stdin.write(`${payload}\n`)
|
|
544
|
+
proc.stdin.end()
|
|
545
|
+
})
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function checkMcpRuntimeSmoke(args) {
|
|
549
|
+
section('B.6 MCP Runtime Smoke')
|
|
550
|
+
|
|
551
|
+
const local = []
|
|
552
|
+
for (const cfg of collectMcpConfigs(args)) {
|
|
553
|
+
for (const [name, entry] of Object.entries(cfg.data.mcp ?? {})) {
|
|
554
|
+
if (!entry || entry.type !== 'local') continue
|
|
555
|
+
if (!Array.isArray(entry.command) || entry.command.length === 0) continue
|
|
556
|
+
local.push({ name, command: entry.command, cwd: dirname(cfg.path), source: cfg.label })
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (local.length === 0) {
|
|
561
|
+
warn('No local MCP servers found in any config — nothing to smoke-test')
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
info(`Smoke-testing ${local.length} local MCP server(s) via JSON-RPC initialize`)
|
|
566
|
+
|
|
567
|
+
for (const { name, command, cwd, source } of local) {
|
|
568
|
+
const result = await mcpInitializeSmoke(command, cwd, 10000)
|
|
569
|
+
if (result.ok) {
|
|
570
|
+
pass(`MCP "${name}" (${source}): initialize handshake OK${result.serverName ? ` — ${result.serverName}` : ''}`)
|
|
571
|
+
} else {
|
|
572
|
+
error(`MCP "${name}" (${source}): ${result.reason}`)
|
|
573
|
+
}
|
|
415
574
|
}
|
|
416
575
|
}
|
|
417
576
|
|
|
@@ -635,6 +794,132 @@ function checkGitStatus(args) {
|
|
|
635
794
|
}
|
|
636
795
|
}
|
|
637
796
|
|
|
797
|
+
// ---------------------------------------------------------------------------
|
|
798
|
+
// Check F: Runtime Layer — Venv, Python & pinned dependencies
|
|
799
|
+
// ---------------------------------------------------------------------------
|
|
800
|
+
// The MCP servers run on the venv Python under ~/.config/opencode/.venv
|
|
801
|
+
// (same resolution as scripts/install-mcp.mjs). This layer verifies the venv
|
|
802
|
+
// exists, the interpreter responds, and the installed packages satisfy the
|
|
803
|
+
// EXACT pins in src/mcp/requirements-mcp.txt and requirements-vision.txt
|
|
804
|
+
// (issue #18 / #21).
|
|
805
|
+
|
|
806
|
+
/** Parse `pip freeze` output into a normalized name → version map. */
|
|
807
|
+
function parsePipFreeze(text) {
|
|
808
|
+
const map = {}
|
|
809
|
+
for (const line of text.split('\n')) {
|
|
810
|
+
const m = line.trim().match(/^([A-Za-z0-9_.-]+)==(\S+)/)
|
|
811
|
+
if (!m) continue
|
|
812
|
+
map[m[1].toLowerCase().replace(/-/g, '_')] = m[2]
|
|
813
|
+
}
|
|
814
|
+
return map
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Split a version string into comparable numeric segments. */
|
|
818
|
+
function parseVersion(v) {
|
|
819
|
+
const m = String(v).match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?/)
|
|
820
|
+
if (!m) return []
|
|
821
|
+
return [m[1], m[2] ?? '0', m[3] ?? '0', m[4] ?? '0'].map(Number)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** Compare two versions numerically. Returns <0, 0 or >0. */
|
|
825
|
+
function compareVersions(a, b) {
|
|
826
|
+
const pa = parseVersion(a)
|
|
827
|
+
const pb = parseVersion(b)
|
|
828
|
+
for (let i = 0; i < 4; i++) {
|
|
829
|
+
const da = pa[i] ?? 0
|
|
830
|
+
const db = pb[i] ?? 0
|
|
831
|
+
if (da !== db) return da - db
|
|
832
|
+
}
|
|
833
|
+
return 0
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Check one requirement line (name + operator + version) against installed.
|
|
838
|
+
* Emits pass/error through the doctor counters.
|
|
839
|
+
*/
|
|
840
|
+
function checkRequirementLine(line, installed) {
|
|
841
|
+
const m = line.match(/^\s*([A-Za-z0-9_.-]+)\s*(==|>=|<=|>|<|~=|!=)\s*([^\s#]+)/)
|
|
842
|
+
if (!m) return
|
|
843
|
+
const name = m[1].toLowerCase().replace(/-/g, '_')
|
|
844
|
+
const op = m[2]
|
|
845
|
+
const want = m[3]
|
|
846
|
+
const have = installed[name]
|
|
847
|
+
if (have === undefined) {
|
|
848
|
+
error(`Dependency "${m[1]}" not installed (required ${op}${want})`)
|
|
849
|
+
return
|
|
850
|
+
}
|
|
851
|
+
const cmp = compareVersions(have, want)
|
|
852
|
+
let ok = false
|
|
853
|
+
switch (op) {
|
|
854
|
+
case '==': ok = cmp === 0; break
|
|
855
|
+
case '>=': ok = cmp >= 0; break
|
|
856
|
+
case '<=': ok = cmp <= 0; break
|
|
857
|
+
case '>': ok = cmp > 0; break
|
|
858
|
+
case '<': ok = cmp < 0; break
|
|
859
|
+
case '!=': ok = cmp !== 0; break
|
|
860
|
+
case '~=': {
|
|
861
|
+
const spec = parseVersion(want)
|
|
862
|
+
const got = parseVersion(have)
|
|
863
|
+
ok = got[0] === spec[0] && got[1] === spec[1] && cmp >= 0
|
|
864
|
+
break
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (ok) {
|
|
868
|
+
pass(`Dependency ${m[1]}==${have} satisfies ${m[1]}${op}${want}`)
|
|
869
|
+
} else {
|
|
870
|
+
error(`Dependency ${m[1]} version mismatch: installed ${have}, required ${op}${want}`)
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function checkVenvLayer() {
|
|
875
|
+
section('F. Runtime Layer — Venv, Python & Dependencies')
|
|
876
|
+
|
|
877
|
+
const configDir = join(homedir(), '.config', 'opencode')
|
|
878
|
+
const venvPython = join(configDir, '.venv', 'bin', 'python3')
|
|
879
|
+
|
|
880
|
+
if (!existsSync(venvPython)) {
|
|
881
|
+
error(`Venv python not found: ${venvPython} (run \`pantheon-opencode init\` to create it)`)
|
|
882
|
+
return
|
|
883
|
+
}
|
|
884
|
+
pass(`Venv python exists: ${venvPython}`)
|
|
885
|
+
|
|
886
|
+
const probe = spawn(venvPython, ['-c', 'import sys; print(sys.version.split()[0])'])
|
|
887
|
+
if (probe.status !== 0) {
|
|
888
|
+
error(`Venv python does not respond (exit ${probe.status}): ${probe.stderr}`)
|
|
889
|
+
return
|
|
890
|
+
}
|
|
891
|
+
pass(`Venv python responds (Python ${probe.stdout})`)
|
|
892
|
+
|
|
893
|
+
const freeze = spawn(venvPython, ['-m', 'pip', 'freeze'])
|
|
894
|
+
if (freeze.status !== 0) {
|
|
895
|
+
warn(`pip freeze failed (exit ${freeze.status}) — cannot verify pinned versions`)
|
|
896
|
+
return
|
|
897
|
+
}
|
|
898
|
+
const installed = parsePipFreeze(freeze.stdout)
|
|
899
|
+
|
|
900
|
+
const reqFiles = [
|
|
901
|
+
join(ROOT, 'src', 'mcp', 'requirements-mcp.txt'),
|
|
902
|
+
join(ROOT, 'src', 'mcp', 'requirements-vision.txt'),
|
|
903
|
+
]
|
|
904
|
+
for (const reqFile of reqFiles) {
|
|
905
|
+
if (!existsSync(reqFile)) {
|
|
906
|
+
warn(`requirements file not found: ${reqFile}`)
|
|
907
|
+
continue
|
|
908
|
+
}
|
|
909
|
+
const base = basename(reqFile)
|
|
910
|
+
info(`Checking ${base} pins against installed packages`)
|
|
911
|
+
const lines = readFileSync(reqFile, 'utf8').split('\n')
|
|
912
|
+
let checked = 0
|
|
913
|
+
for (const line of lines) {
|
|
914
|
+
const trimmed = line.trim()
|
|
915
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
916
|
+
checked++
|
|
917
|
+
checkRequirementLine(trimmed, installed)
|
|
918
|
+
}
|
|
919
|
+
if (checked === 0) warn(`${base}: no requirement entries found`)
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
638
923
|
// ---------------------------------------------------------------------------
|
|
639
924
|
// Summary
|
|
640
925
|
// ---------------------------------------------------------------------------
|
|
@@ -665,7 +950,7 @@ function printSummary(targetPath) {
|
|
|
665
950
|
// Main
|
|
666
951
|
// ---------------------------------------------------------------------------
|
|
667
952
|
|
|
668
|
-
function main() {
|
|
953
|
+
async function main() {
|
|
669
954
|
const args = parseArgs(process.argv)
|
|
670
955
|
|
|
671
956
|
if (args.help) {
|
|
@@ -695,9 +980,11 @@ function main() {
|
|
|
695
980
|
join(args.target, 'scripts', 'doctor.mjs') === join(ROOT, 'scripts', 'doctor.mjs') ||
|
|
696
981
|
existsSync(join(args.target, 'scripts', 'doctor.mjs')) === false
|
|
697
982
|
|
|
698
|
-
// Run checks
|
|
983
|
+
// Run checks (layer order: Config → Venv → spawn paths → runtime smoke)
|
|
699
984
|
checkAgentFiles(args)
|
|
700
985
|
checkMcpConfig(args)
|
|
986
|
+
checkVenvLayer(args)
|
|
987
|
+
await checkMcpRuntimeSmoke(args)
|
|
701
988
|
checkPermissionMismatches(args)
|
|
702
989
|
checkSyncStatus(args)
|
|
703
990
|
checkGitStatus(args)
|
|
@@ -707,4 +994,7 @@ function main() {
|
|
|
707
994
|
process.exit(exitCode)
|
|
708
995
|
}
|
|
709
996
|
|
|
710
|
-
main()
|
|
997
|
+
main().catch((err) => {
|
|
998
|
+
console.error(`❌ Doctor crashed: ${err.stack ?? err.message}`)
|
|
999
|
+
process.exit(2)
|
|
1000
|
+
})
|