pantheon-opencode 1.2.0 → 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 +112 -15
- package/bin/pantheon-init.mjs +93 -0
- package/commands/pantheon-vision.md +67 -0
- package/opencode.json +14 -10
- package/package.json +20 -4
- package/scripts/_pantheon_paths.py +0 -2
- package/scripts/changelog-extract.mjs +112 -0
- package/scripts/doctor.mjs +307 -15
- 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/health-check.mjs +101 -1
- package/scripts/install/model-picker.mjs +118 -0
- package/scripts/install/opencode.mjs +155 -43
- package/scripts/install/plugin.mjs +4 -2
- package/scripts/install/venv.mjs +25 -2
- package/scripts/install-mcp.mjs +170 -37
- package/scripts/mcp_persistence_server.py +2 -1
- package/scripts/prune.mjs +359 -0
- package/scripts/secret-scan.mjs +88 -0
- package/scripts/uninstall.mjs +3 -3
- package/scripts/validate-routing.mjs +15 -0
- package/scripts/version-check.mjs +146 -0
- package/scripts/versioning.mjs +13 -14
- package/src/agents/hermes.md +2 -2
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +1 -1
- package/src/mcp/_pantheon_paths.py +0 -2
- package/src/mcp/install-mcp.mjs +6 -913
- package/src/mcp/mcp_persistence_server.py +2 -1
- package/src/mcp/pantheon_vision_server.py +495 -0
- package/src/mcp/requirements-mcp.txt +8 -5
- package/src/mcp/requirements-vision.txt +3 -0
- package/src/pantheon/auto-wake.ts +91 -0
- package/src/pantheon/background-job-board.ts +568 -0
- package/src/pantheon/file-persistence.ts +95 -0
- package/src/pantheon/presets.d.mts +108 -0
- package/src/pantheon/presets.mjs +554 -0
- package/src/pantheon/vision.ts +1437 -0
- package/src/plugin.ts +69 -25
- package/src/plugins/hook-runner.ts +186 -0
- package/src/plugins/pantheon-hooks.ts +153 -0
- package/src/plugins/tui/dist/tui.d.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +812 -223
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +881 -121
- package/src/plugins/tui/package.json +3 -3
- package/src/plugins/tui/src/index.tsx +881 -121
- package/src/routing.yml +148 -0
- package/src/skills/context-compression/SKILL.md +1 -1
- package/src/skills/loop-engineering/SKILL.md +1 -1
- package/src/skills/orchestration-workflow/SKILL.md +11 -11
- package/scripts/scrub_secrets.py +0 -281
- package/src/mcp/requirements-mcp-core.txt +0 -5
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# run-type-check.sh — Pantheon PostToolUse Type Check Hook
|
|
3
|
+
# Runs mypy (Python) or tsc (TypeScript) if config exists.
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
# Only run if relevant config files exist
|
|
7
|
+
if [[ -f "pyproject.toml" ]] || [[ -f "setup.cfg" ]] || [[ -f ".mypy.ini" ]]; then
|
|
8
|
+
if command -v mypy >/dev/null 2>&1; then
|
|
9
|
+
echo "[TYPE CHECK] Running mypy..." >&2
|
|
10
|
+
mypy . --ignore-missing-imports || true
|
|
11
|
+
fi
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
if [[ -f "tsconfig.json" ]]; then
|
|
15
|
+
if command -v tsc >/dev/null 2>&1; then
|
|
16
|
+
echo "[TYPE CHECK] Running tsc --noEmit..." >&2
|
|
17
|
+
tsc --noEmit || true
|
|
18
|
+
fi
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
exit 0
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# scan-secrets.sh — Pantheon PreToolUse Secret Scanning Hook
|
|
3
|
+
# Detects hardcoded secrets in tool input.
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
SECRET_PATTERNS=(
|
|
7
|
+
"AKIA[0-9A-Z]{16}"
|
|
8
|
+
"gh[pousr]_[A-Za-z0-9_]{36,}"
|
|
9
|
+
"glpat-[A-Za-z0-9_\\-]{20}"
|
|
10
|
+
"sk-[a-zA-Z0-9]{20,}"
|
|
11
|
+
"sk_live_[a-zA-Z0-9]{20,}"
|
|
12
|
+
"sk_test_[a-zA-Z0-9]{20,}"
|
|
13
|
+
"xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}"
|
|
14
|
+
"[a-zA-Z0-9_-]*api[_-]?key[a-zA-Z0-9_-]*\s*[:=]\s*[\"']?[a-zA-Z0-9_\\-]{16,}[\"']?"
|
|
15
|
+
"[a-zA-Z0-9_-]*password[a-zA-Z0-9_-]*\s*[:=]\s*[\"'][^\"']{8,}[\"']"
|
|
16
|
+
"[a-zA-Z0-9_-]*secret[a-zA-Z0-9_-]*\s*[:=]\s*[\"'][^\"']{8,}[\"']"
|
|
17
|
+
"bearer\s+[a-zA-Z0-9_\\-\\.]{20,}"
|
|
18
|
+
"eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Keep provider-specific markers assembled so this scanner is not self-matched
|
|
22
|
+
# by the repository secret test.
|
|
23
|
+
BIFROST_HEADER="x""-bf-""vk"
|
|
24
|
+
BIFROST_TOKEN_PREFIX="sk""-bf-"
|
|
25
|
+
SECRET_PATTERNS+=(
|
|
26
|
+
"$BIFROST_HEADER"
|
|
27
|
+
"${BIFROST_TOKEN_PREFIX}[A-Za-z0-9_-]{8,}"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
INPUT="${1:-${TOOL_INPUT:-}}"
|
|
31
|
+
if [[ -z "$INPUT" ]] && [[ ! -t 0 ]]; then
|
|
32
|
+
INPUT=$(cat)
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
if [[ -z "$INPUT" ]]; then
|
|
36
|
+
exit 0
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
FOUND=0
|
|
40
|
+
for pattern in "${SECRET_PATTERNS[@]}"; do
|
|
41
|
+
if echo "$INPUT" | grep -iqE "$pattern"; then
|
|
42
|
+
MATCH=$(echo "$INPUT" | grep -ioE "$pattern" | head -1)
|
|
43
|
+
# Mask the match for safe logging
|
|
44
|
+
MASKED="${MATCH:0:4}****${MATCH: -4}"
|
|
45
|
+
echo "[SECRET SCAN] Potential secret detected: $MASKED (pattern: $pattern)" >&2
|
|
46
|
+
FOUND=1
|
|
47
|
+
fi
|
|
48
|
+
done
|
|
49
|
+
|
|
50
|
+
if [[ $FOUND -eq 1 ]]; then
|
|
51
|
+
echo "[SECRET SCAN] Hardcoded secret detected. Remove it and use environment variables or a vault." >&2
|
|
52
|
+
exit 1
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
exit 0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# validate-post-conditions.sh — Stop hook
|
|
3
|
+
# Checks that Themis review was called after implementation
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
# Read JSON input from stdin
|
|
7
|
+
INPUT=$(cat)
|
|
8
|
+
SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id',''))" 2>/dev/null || echo "")
|
|
9
|
+
|
|
10
|
+
REVIEW_DIR=".pantheon/memory-bank/.tmp"
|
|
11
|
+
if [ -d "$REVIEW_DIR" ]; then
|
|
12
|
+
REVIEW_COUNT=$(ls "$REVIEW_DIR"/REVIEW-* 2>/dev/null | wc -l)
|
|
13
|
+
if [ "$REVIEW_COUNT" -eq 0 ]; then
|
|
14
|
+
echo "[POST-CONDITION] ⚠️ No REVIEW artifact found in $REVIEW_DIR/" >&2
|
|
15
|
+
echo "[POST-CONDITION] 💡 Remember: implementation agents MUST call @themis for review" >&2
|
|
16
|
+
else
|
|
17
|
+
echo "[POST-CONDITION] ✅ $REVIEW_COUNT REVIEW artifact(s) found" >&2
|
|
18
|
+
fi
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
exit 0
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# validate-talos-scope.sh — PreToolUse hook for Talos
|
|
3
|
+
# Receives JSON on stdin, enforces Talos boundaries
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
# Read JSON input from stdin (Claude Code hook protocol)
|
|
7
|
+
INPUT=$(cat)
|
|
8
|
+
|
|
9
|
+
# Extract tool info from JSON input
|
|
10
|
+
TOOL_NAME=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null || echo "")
|
|
11
|
+
TOOL_INPUT=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(d.get('tool_input',{})))" 2>/dev/null || echo "")
|
|
12
|
+
|
|
13
|
+
# Only validate for Talos agent
|
|
14
|
+
AGENT_NAME=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('agent_id',''))" 2>/dev/null || echo "")
|
|
15
|
+
if [ "$AGENT_NAME" != "talos" ]; then
|
|
16
|
+
# Not Talos — allow pass-through
|
|
17
|
+
echo "[TALOS SCOPE] Not Talos agent, skipping" >&2
|
|
18
|
+
exit 0
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
# Get changed files from the tool input
|
|
22
|
+
if echo "$TOOL_INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('command','') or d.get('filePath','') or d.get('file_path',''))" 2>/dev/null | grep -qE '(migration|schema\.sql|alembic|models/.*\.py|auth|security|password|token|secret)'; then
|
|
23
|
+
echo "[TALOS SCOPE] ❌ Blocked: Talos cannot modify schema/security files" >&2
|
|
24
|
+
exit 2
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
# Count files in the edit/write operation
|
|
28
|
+
FILE_COUNT=$(echo "$TOOL_INPUT" | python3 -c "
|
|
29
|
+
import sys, json
|
|
30
|
+
try:
|
|
31
|
+
d = json.load(sys.stdin)
|
|
32
|
+
# Count unique file paths in the input
|
|
33
|
+
files = set()
|
|
34
|
+
for key in ['filePath', 'file_path', 'command', 'content']:
|
|
35
|
+
if key in d and d[key]:
|
|
36
|
+
files.add(str(d[key]))
|
|
37
|
+
print(len(files))
|
|
38
|
+
except:
|
|
39
|
+
print('0')
|
|
40
|
+
" 2>/dev/null || echo "0")
|
|
41
|
+
|
|
42
|
+
echo "[TALOS SCOPE] ✅ Scope check passed" >&2
|
|
43
|
+
exit 0
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# validate-tool-safety.sh — Pantheon PreToolUse Security Hook
|
|
3
|
+
# Blocks destructive commands: rm -rf, DROP TABLE, TRUNCATE, etc.
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
BLOCKED_PATTERNS=(
|
|
7
|
+
# "rm -rf /" appears mid-JSON in the hook stdin protocol (e.g.
|
|
8
|
+
# {"tool_input":{"command":"rm -rf /"}}), so the old `/$` end-of-line
|
|
9
|
+
# anchor never matched. Match "/" followed by a JSON delimiter (", },
|
|
10
|
+
# comma), whitespace, or end of line — catches bare "rm -rf /" anywhere
|
|
11
|
+
# in the payload while keeping "rm -rf /some/path" (safe) unblocked.
|
|
12
|
+
"rm\s+-rf\s+/([\"},\t[:space:]]|$)" # rm -rf / (root only)
|
|
13
|
+
"rm\s+-rf\s+/\*" # rm -rf /*
|
|
14
|
+
"rm\s+-rf\s+~([\",[:space:]]|$)" # rm -rf ~ (home only)
|
|
15
|
+
"rm\s+-rf\s+~/" # rm -rf ~/...
|
|
16
|
+
"rm\s+-rf\s+\.\.?/" # rm -rf ./ or rm -rf ../
|
|
17
|
+
"DROP\s+TABLE"
|
|
18
|
+
"DROP\s+DATABASE"
|
|
19
|
+
"TRUNCATE\s+TABLE"
|
|
20
|
+
"DELETE\s+FROM\s+.*;?\s*$"
|
|
21
|
+
"rm\s+.*\s+--no-preserve-root"
|
|
22
|
+
":(){ :|:& };:"
|
|
23
|
+
">\s*/dev/\(sd[a-z]\|hd[a-z]\|disk[0-9]*\)"
|
|
24
|
+
"dd\s+if=.*of=/dev/"
|
|
25
|
+
"mkfs\."
|
|
26
|
+
"fdisk\s+/dev/"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
INPUT="${1:-${TOOL_INPUT:-}}"
|
|
30
|
+
if [[ -z "$INPUT" ]] && [[ ! -t 0 ]]; then
|
|
31
|
+
INPUT=$(cat)
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
if [[ -z "$INPUT" ]]; then
|
|
35
|
+
exit 0
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
for pattern in "${BLOCKED_PATTERNS[@]}"; do
|
|
39
|
+
if echo "$INPUT" | grep -iqE "$pattern"; then
|
|
40
|
+
echo "[SECURITY BLOCKED] Pattern matched: $pattern" >&2
|
|
41
|
+
echo "[SECURITY BLOCKED] Destructive command detected and blocked by Pantheon hook." >&2
|
|
42
|
+
exit 1
|
|
43
|
+
fi
|
|
44
|
+
done
|
|
45
|
+
|
|
46
|
+
exit 0
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { spawnSync } from 'node:child_process'
|
|
12
|
-
import { existsSync } from 'node:fs'
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
13
13
|
import { join } from 'node:path'
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -24,6 +24,7 @@ import { join } from 'node:path'
|
|
|
24
24
|
export function healthCheck(target, { dryRun = false } = {}) {
|
|
25
25
|
const results = { passed: [], failed: [], warnings: [] }
|
|
26
26
|
const python = findPython(target)
|
|
27
|
+
const visionServer = resolveVisionServer(target)
|
|
27
28
|
|
|
28
29
|
// Check 1: Critical runtime scripts exist
|
|
29
30
|
const scripts = ['mcp_persistence_server.py', '_pantheon_paths.py']
|
|
@@ -38,9 +39,70 @@ export function healthCheck(target, { dryRun = false } = {}) {
|
|
|
38
39
|
})
|
|
39
40
|
}
|
|
40
41
|
}
|
|
42
|
+
if (visionServer) {
|
|
43
|
+
results.passed.push({
|
|
44
|
+
check: visionServer.check,
|
|
45
|
+
detail: visionServer.detail,
|
|
46
|
+
})
|
|
47
|
+
const installedVisionServer = join(target, 'scripts', 'pantheon_vision_server.py')
|
|
48
|
+
const canonicalVisionServer = join(target, 'src', 'mcp', 'pantheon_vision_server.py')
|
|
49
|
+
if (existsSync(installedVisionServer) && existsSync(canonicalVisionServer)) {
|
|
50
|
+
try {
|
|
51
|
+
if (
|
|
52
|
+
readFileSync(installedVisionServer, 'utf8') !==
|
|
53
|
+
readFileSync(canonicalVisionServer, 'utf8')
|
|
54
|
+
) {
|
|
55
|
+
results.failed.push({
|
|
56
|
+
check: 'pantheon-vision source drift',
|
|
57
|
+
detail: 'installed script differs from src/mcp/pantheon_vision_server.py',
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
results.failed.push({
|
|
62
|
+
check: 'pantheon-vision source drift',
|
|
63
|
+
detail: 'could not compare installed script with src/mcp source',
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
results.failed.push({
|
|
69
|
+
check: 'src/mcp/pantheon_vision_server.py',
|
|
70
|
+
detail: 'NOT FOUND — canonical vision server source is unavailable',
|
|
71
|
+
})
|
|
72
|
+
}
|
|
41
73
|
|
|
42
74
|
if (dryRun) return results
|
|
43
75
|
|
|
76
|
+
// Check 1.5: Vision keeps a small, isolated dependency manifest.
|
|
77
|
+
const visionRequirements = [
|
|
78
|
+
join(target, 'src', 'mcp', 'requirements-vision.txt'),
|
|
79
|
+
join(target, 'requirements-vision.txt'),
|
|
80
|
+
join(target, '.opencode', 'requirements-vision.txt'),
|
|
81
|
+
].find((path) => existsSync(path))
|
|
82
|
+
if (!visionRequirements) {
|
|
83
|
+
results.failed.push({
|
|
84
|
+
check: 'requirements-vision.txt',
|
|
85
|
+
detail: 'NOT FOUND — vision dependency isolation is incomplete',
|
|
86
|
+
})
|
|
87
|
+
} else {
|
|
88
|
+
const requirements = readFileSync(visionRequirements, 'utf8').toLowerCase()
|
|
89
|
+
const forbidden = ['pillow', 'paddle', 'gemini', 'torch']
|
|
90
|
+
const unwanted = forbidden.filter((name) =>
|
|
91
|
+
new RegExp(`^${name}(?:[<>=!~]|$)`, 'm').test(requirements),
|
|
92
|
+
)
|
|
93
|
+
if (unwanted.length > 0) {
|
|
94
|
+
results.failed.push({
|
|
95
|
+
check: 'requirements-vision.txt',
|
|
96
|
+
detail: `forbidden MVP dependencies: ${unwanted.join(', ')}`,
|
|
97
|
+
})
|
|
98
|
+
} else {
|
|
99
|
+
results.passed.push({
|
|
100
|
+
check: 'requirements-vision.txt',
|
|
101
|
+
detail: 'isolated and free of heavyweight vision runtimes',
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
44
106
|
// Check 2: Syntax check on each MCP server script
|
|
45
107
|
const mcpScripts = [
|
|
46
108
|
'mcp_persistence_server.py',
|
|
@@ -67,6 +129,22 @@ export function healthCheck(target, { dryRun = false } = {}) {
|
|
|
67
129
|
}
|
|
68
130
|
}
|
|
69
131
|
}
|
|
132
|
+
if (visionServer && python) {
|
|
133
|
+
const result = spawnSync(python, ['-m', 'py_compile', visionServer.path], {
|
|
134
|
+
stdio: 'pipe',
|
|
135
|
+
})
|
|
136
|
+
if (result.status === 0) {
|
|
137
|
+
results.passed.push({
|
|
138
|
+
check: 'syntax:pantheon_vision_server.py',
|
|
139
|
+
detail: 'valid',
|
|
140
|
+
})
|
|
141
|
+
} else {
|
|
142
|
+
results.failed.push({
|
|
143
|
+
check: 'syntax:pantheon_vision_server.py',
|
|
144
|
+
detail: result.stderr.toString().trim().split('\n').slice(-1)[0],
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
}
|
|
70
148
|
|
|
71
149
|
// Check 3: _pantheon_paths import works
|
|
72
150
|
if (python) {
|
|
@@ -150,6 +228,28 @@ export function healthCheck(target, { dryRun = false } = {}) {
|
|
|
150
228
|
return results
|
|
151
229
|
}
|
|
152
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Resolve the canonical source when checking the repository, or the generated
|
|
233
|
+
* runtime copy when checking an installed target.
|
|
234
|
+
* @param {string} target
|
|
235
|
+
* @returns {{path: string, check: string, detail: string}|null}
|
|
236
|
+
*/
|
|
237
|
+
function resolveVisionServer(target) {
|
|
238
|
+
const candidates = [
|
|
239
|
+
{
|
|
240
|
+
path: join(target, 'src', 'mcp', 'pantheon_vision_server.py'),
|
|
241
|
+
check: 'src/mcp/pantheon_vision_server.py',
|
|
242
|
+
detail: 'canonical source exists',
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
path: join(target, 'scripts', 'pantheon_vision_server.py'),
|
|
246
|
+
check: 'scripts/pantheon_vision_server.py',
|
|
247
|
+
detail: 'installed copy exists (deployed from src/mcp)',
|
|
248
|
+
},
|
|
249
|
+
]
|
|
250
|
+
return candidates.find(({ path }) => existsSync(path)) ?? null
|
|
251
|
+
}
|
|
252
|
+
|
|
153
253
|
/**
|
|
154
254
|
* Find the target's venv python, falling back to system python3.
|
|
155
255
|
* @param {string} target
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* model-picker.mjs — interactive model preset selection + persistence
|
|
4
|
+
*
|
|
5
|
+
* Used by the installer (interactive prompt) and the `pantheon-opencode`
|
|
6
|
+
* CLI. Writes the active preset marker to <presetDir>/.pantheon/active-preset.json
|
|
7
|
+
* which the plugin (src/plugin.ts) reads on startup via resolveActivePreset().
|
|
8
|
+
*/
|
|
9
|
+
import { copyFileSync, existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { createInterface } from 'node:readline/promises'
|
|
12
|
+
import { loadPresetDefs } from '../../src/pantheon/presets.mjs'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build the active-preset.json payload (version 1).
|
|
16
|
+
*
|
|
17
|
+
* @param {string} presetName
|
|
18
|
+
* @param {string} [source] 'cli' | 'interactive'
|
|
19
|
+
* @returns {{version: number, preset: string, source: string, updated_at: string}}
|
|
20
|
+
*/
|
|
21
|
+
export function buildActivePresetFile(presetName, source = 'cli') {
|
|
22
|
+
return {
|
|
23
|
+
version: 1,
|
|
24
|
+
preset: presetName,
|
|
25
|
+
source,
|
|
26
|
+
updated_at: new Date().toISOString(),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Persist the active preset marker, backing up any previous file to .bak.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} presetDir install target (project root or global opencode dir)
|
|
34
|
+
* @param {string} presetName
|
|
35
|
+
* @param {{dryRun?: boolean, source?: string, logger?: object}} [opts]
|
|
36
|
+
* @returns {{written: boolean, path: string, backupPath: string|null}}
|
|
37
|
+
*/
|
|
38
|
+
export function writeActivePreset(
|
|
39
|
+
presetDir,
|
|
40
|
+
presetName,
|
|
41
|
+
{ dryRun = false, source = 'cli', logger = console } = {},
|
|
42
|
+
) {
|
|
43
|
+
const dir = join(presetDir, '.pantheon')
|
|
44
|
+
const filePath = join(dir, 'active-preset.json')
|
|
45
|
+
const backupPath = join(dir, 'active-preset.json.bak')
|
|
46
|
+
|
|
47
|
+
if (dryRun) {
|
|
48
|
+
logger.log?.(`[dry-run] Would write model preset "${presetName}" to ${filePath}`)
|
|
49
|
+
return { written: false, path: filePath, backupPath: null }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
mkdirSync(dir, { recursive: true })
|
|
53
|
+
let backupMade = false
|
|
54
|
+
if (existsSync(filePath)) {
|
|
55
|
+
copyFileSync(filePath, backupPath)
|
|
56
|
+
backupMade = true
|
|
57
|
+
}
|
|
58
|
+
writeFileSync(`${filePath}.tmp-${process.pid}`, `${JSON.stringify(buildActivePresetFile(presetName, source), null, 2)}\n`)
|
|
59
|
+
renameSync(`${filePath}.tmp-${process.pid}`, filePath)
|
|
60
|
+
return { written: true, path: filePath, backupPath: backupMade ? backupPath : null }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Interactive model preset picker. Asks the user to choose a preset; persists
|
|
65
|
+
* the choice unless dryRun. `rl` may be injected for tests; otherwise a
|
|
66
|
+
* readline/promises interface on stdin/stdout is created and closed.
|
|
67
|
+
*
|
|
68
|
+
* @param {{presetDir?: string, presets?: object, dryRun?: boolean, logger?: object, rl?: object}} [opts]
|
|
69
|
+
* @returns {Promise<string|null>} chosen preset name or null (skipped/unknown)
|
|
70
|
+
*/
|
|
71
|
+
export async function runModelPicker({
|
|
72
|
+
presetDir,
|
|
73
|
+
presets,
|
|
74
|
+
dryRun = false,
|
|
75
|
+
logger = console,
|
|
76
|
+
rl,
|
|
77
|
+
} = {}) {
|
|
78
|
+
const defs = presets ?? loadPresetDefs()
|
|
79
|
+
const names = Object.keys(defs)
|
|
80
|
+
if (names.length === 0) {
|
|
81
|
+
logger.warn?.('Model presets: no presets defined in routing.yml')
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const list = names.map((n, i) => ` ${i + 1}) ${n} — ${defs[n].description ?? ''}`).join('\n')
|
|
86
|
+
const rli = rl ?? createInterface({ input: process.stdin, output: process.stdout })
|
|
87
|
+
try {
|
|
88
|
+
const answer = await rli.question(
|
|
89
|
+
`\nModel presets (number or name; blank to skip):\n${list}\n> `,
|
|
90
|
+
)
|
|
91
|
+
const trimmed = (answer ?? '').trim()
|
|
92
|
+
if (trimmed === '' || trimmed.toLowerCase() === 'none') {
|
|
93
|
+
logger.log?.('Skipping model preset selection.')
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
let name = null
|
|
97
|
+
if (names.includes(trimmed)) {
|
|
98
|
+
name = trimmed
|
|
99
|
+
} else {
|
|
100
|
+
const idx = Number(trimmed)
|
|
101
|
+
if (Number.isInteger(idx) && idx >= 1 && idx <= names.length) {
|
|
102
|
+
name = names[idx - 1]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!name) {
|
|
106
|
+
logger.warn?.(`Unknown preset "${trimmed}" — skipping model preset selection.`)
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
if (!dryRun) {
|
|
110
|
+
writeActivePreset(presetDir, name, { dryRun: false, source: 'interactive', logger })
|
|
111
|
+
} else {
|
|
112
|
+
logger.log?.(`[dry-run] Would set model preset "${name}"`)
|
|
113
|
+
}
|
|
114
|
+
return name
|
|
115
|
+
} finally {
|
|
116
|
+
if (!rl) rli.close()
|
|
117
|
+
}
|
|
118
|
+
}
|