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.
Files changed (63) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +112 -15
  3. package/bin/pantheon-init.mjs +93 -0
  4. package/commands/pantheon-vision.md +67 -0
  5. package/opencode.json +14 -10
  6. package/package.json +20 -4
  7. package/scripts/_pantheon_paths.py +0 -2
  8. package/scripts/changelog-extract.mjs +112 -0
  9. package/scripts/doctor.mjs +307 -15
  10. package/scripts/hooks/audit-imports.sh +43 -0
  11. package/scripts/hooks/format-multi-language.sh +96 -0
  12. package/scripts/hooks/log-session-start.sh +24 -0
  13. package/scripts/hooks/on-subagent-delegation-start.sh +91 -0
  14. package/scripts/hooks/on-subagent-delegation-stop.sh +37 -0
  15. package/scripts/hooks/run-type-check.sh +21 -0
  16. package/scripts/hooks/scan-secrets.sh +55 -0
  17. package/scripts/hooks/validate-post-conditions.sh +21 -0
  18. package/scripts/hooks/validate-talos-scope.sh +43 -0
  19. package/scripts/hooks/validate-tool-safety.sh +46 -0
  20. package/scripts/install/health-check.mjs +101 -1
  21. package/scripts/install/model-picker.mjs +118 -0
  22. package/scripts/install/opencode.mjs +155 -43
  23. package/scripts/install/plugin.mjs +4 -2
  24. package/scripts/install/venv.mjs +25 -2
  25. package/scripts/install-mcp.mjs +170 -37
  26. package/scripts/mcp_persistence_server.py +2 -1
  27. package/scripts/prune.mjs +359 -0
  28. package/scripts/secret-scan.mjs +88 -0
  29. package/scripts/uninstall.mjs +3 -3
  30. package/scripts/validate-routing.mjs +15 -0
  31. package/scripts/version-check.mjs +146 -0
  32. package/scripts/versioning.mjs +13 -14
  33. package/src/agents/hermes.md +2 -2
  34. package/src/agents/mnemosyne.md +1 -1
  35. package/src/agents/zeus.md +1 -1
  36. package/src/mcp/_pantheon_paths.py +0 -2
  37. package/src/mcp/install-mcp.mjs +6 -913
  38. package/src/mcp/mcp_persistence_server.py +2 -1
  39. package/src/mcp/pantheon_vision_server.py +495 -0
  40. package/src/mcp/requirements-mcp.txt +8 -5
  41. package/src/mcp/requirements-vision.txt +3 -0
  42. package/src/pantheon/auto-wake.ts +91 -0
  43. package/src/pantheon/background-job-board.ts +568 -0
  44. package/src/pantheon/file-persistence.ts +95 -0
  45. package/src/pantheon/presets.d.mts +108 -0
  46. package/src/pantheon/presets.mjs +554 -0
  47. package/src/pantheon/vision.ts +1437 -0
  48. package/src/plugin.ts +69 -25
  49. package/src/plugins/hook-runner.ts +186 -0
  50. package/src/plugins/pantheon-hooks.ts +153 -0
  51. package/src/plugins/tui/dist/tui.d.ts +1 -1
  52. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  53. package/src/plugins/tui/dist/tui.js +812 -223
  54. package/src/plugins/tui/dist/tui.js.map +1 -1
  55. package/src/plugins/tui/dist/tui.tsx +881 -121
  56. package/src/plugins/tui/package.json +3 -3
  57. package/src/plugins/tui/src/index.tsx +881 -121
  58. package/src/routing.yml +148 -0
  59. package/src/skills/context-compression/SKILL.md +1 -1
  60. package/src/skills/loop-engineering/SKILL.md +1 -1
  61. package/src/skills/orchestration-workflow/SKILL.md +11 -11
  62. package/scripts/scrub_secrets.py +0 -281
  63. 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
+ }