pantheon-opencode 1.5.0-beta.1 → 1.5.0-beta.11

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 (83) hide show
  1. package/.pantheon/code-mode/manifest.json +21 -0
  2. package/CHANGELOG.md +1054 -0
  3. package/README.md +171 -16
  4. package/README.pt-BR.md +99 -13
  5. package/bin/pantheon-init.mjs +178 -21
  6. package/package.json +23 -15
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +65 -0
  9. package/scripts/code_mode_server.py +431 -55
  10. package/scripts/cost.mjs +30 -14
  11. package/scripts/dev-tui.sh +48 -0
  12. package/scripts/doctor.mjs +207 -9
  13. package/scripts/install/cli-ui.mjs +17 -11
  14. package/scripts/install/config-migration.mjs +4 -6
  15. package/scripts/install/opencode.mjs +137 -35
  16. package/scripts/install/plugin.mjs +3 -33
  17. package/scripts/install/shared.mjs +43 -2
  18. package/scripts/install/strings.mjs +129 -0
  19. package/scripts/install-mcp.mjs +1 -0
  20. package/scripts/manifest-inventory.mjs +410 -0
  21. package/scripts/mcp_persistence_server.py +537 -128
  22. package/scripts/package-evidence.mjs +242 -0
  23. package/scripts/probe-context-rehydrate.mjs +240 -0
  24. package/scripts/probe-pantheon-cost.mjs +313 -0
  25. package/scripts/release-beta-version.mjs +56 -60
  26. package/scripts/sync-artifacts.mjs +169 -0
  27. package/scripts/sync-tui.mjs +121 -53
  28. package/scripts/test-opencode-v1-v2-sandbox.sh +367 -96
  29. package/scripts/validation-policy.mjs +7 -7
  30. package/scripts/version-check.mjs +113 -108
  31. package/scripts/versioning.mjs +230 -86
  32. package/src/agents/mnemosyne.md +1 -1
  33. package/src/agents/zeus.md +2 -2
  34. package/src/mcp/code_mode_server.py +431 -55
  35. package/src/mcp/mcp_persistence_server.py +537 -128
  36. package/src/pantheon/auto-wake.ts +1 -5
  37. package/src/pantheon/background-job-board.ts +43 -1
  38. package/src/pantheon/compaction-handoff.ts +304 -0
  39. package/src/pantheon/cost-command.ts +345 -62
  40. package/src/pantheon/delegate-manager.ts +390 -8
  41. package/src/pantheon/delegation-finalize.ts +32 -0
  42. package/src/pantheon/delegation.ts +235 -18
  43. package/src/pantheon/file-persistence-salvage.ts +64 -0
  44. package/src/pantheon/file-persistence.ts +194 -45
  45. package/src/pantheon/native-probe.ts +234 -0
  46. package/src/pantheon/native-task-status.ts +139 -0
  47. package/src/pantheon/provider-capabilities.ts +221 -0
  48. package/src/pantheon/provider-stream-tracker.ts +182 -0
  49. package/src/pantheon/shared-board.ts +37 -0
  50. package/src/pantheon/task-result-guard.ts +107 -0
  51. package/src/pantheon/token-opt.ts +146 -12
  52. package/src/pantheon/tool-ceiling.ts +159 -0
  53. package/src/pantheon/v2-events.ts +18 -1
  54. package/src/plugin-v2.ts +2 -14
  55. package/src/plugin.ts +28 -17
  56. package/src/plugins/pantheon-hooks.ts +51 -0
  57. package/src/plugins/tui/dist/server.d.ts +1 -1
  58. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  59. package/src/plugins/tui/dist/server.js +1 -1
  60. package/src/plugins/tui/dist/server.js.map +1 -1
  61. package/src/plugins/tui/dist/tui.d.ts +137 -27
  62. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  63. package/src/plugins/tui/dist/tui.js +459 -163
  64. package/src/plugins/tui/dist/tui.js.map +1 -1
  65. package/src/plugins/tui/dist/tui.tsx +506 -103
  66. package/src/plugins/tui/package-lock.json +2 -2
  67. package/src/plugins/tui/package.json +1 -1
  68. package/src/plugins/tui/src/index.tsx +506 -103
  69. package/.pantheon/code-mode/.gitkeep +0 -0
  70. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +0 -8
  71. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +0 -8
  72. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +0 -8
  73. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +0 -8
  74. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +0 -8
  75. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +0 -8
  76. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +0 -8
  77. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +0 -8
  78. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +0 -8
  79. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +0 -8
  80. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +0 -8
  81. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +0 -8
  82. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +0 -11
  83. package/src/pantheon/delegate-task-adapter.ts +0 -444
@@ -12,6 +12,7 @@
12
12
  * isolation is handled at runtime via OPENCODE_DB.
13
13
  */
14
14
 
15
+ import { createHash } from 'node:crypto'
15
16
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
16
17
  import { homedir } from 'node:os'
17
18
  import { dirname, isAbsolute, join, resolve } from 'node:path'
@@ -34,6 +35,7 @@ import { detectVersion, runMigrations } from './migrate.mjs'
34
35
  import { resolveOpenCodeVersion } from './opencode-version.mjs'
35
36
  import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
36
37
  import {
38
+ checkRuntimePrerequisites,
37
39
  collectSkillNames,
38
40
  copyFiles,
39
41
  installSkills,
@@ -42,8 +44,10 @@ import {
42
44
  sourceDirValid,
43
45
  summary,
44
46
  syncDir,
47
+ writeConfigWithBackup,
45
48
  writeIfChanged,
46
49
  } from './shared.mjs'
50
+ import { strings } from './strings.mjs'
47
51
 
48
52
  import { setupVenv, venvPythonPath } from './venv.mjs'
49
53
 
@@ -61,6 +65,34 @@ function deepClone(value) {
61
65
  return JSON.parse(JSON.stringify(value))
62
66
  }
63
67
 
68
+ /**
69
+ * (Re)generate the code-mode manifest with SHA-256 digests for every bundled
70
+ * script. This is the explicit installation opt-in: the MCP server only
71
+ * executes scripts listed here whose on-disk hash matches. Missing or empty
72
+ * directories approve nothing (0) — execution is never implicit.
73
+ */
74
+ function writeCodeModeManifest(dir, dryRun) {
75
+ if (!existsSync(dir)) return 0
76
+ let names
77
+ try {
78
+ names = readdirSync(dir)
79
+ .filter((name) => /\.(sh|py)$/i.test(name) && !name.startsWith('.'))
80
+ .sort()
81
+ } catch {
82
+ return 0
83
+ }
84
+ if (names.length === 0) return 0
85
+ const scripts = {}
86
+ for (const name of names) {
87
+ scripts[name] = createHash('sha256')
88
+ .update(readFileSync(join(dir, name)))
89
+ .digest('hex')
90
+ }
91
+ const payload = `${JSON.stringify({ version: 1, scripts }, null, 2)}\n`
92
+ writeIfChanged(join(dir, 'manifest.json'), payload, dryRun)
93
+ return names.length
94
+ }
95
+
64
96
  function readJsonConfig(filePath) {
65
97
  if (!existsSync(filePath)) return {}
66
98
 
@@ -185,6 +217,21 @@ function managedPluginIdentity(ref) {
185
217
  if (absolutePath === normalizePluginPath(resolve(ROOT, PANTHEON_V2_LEGACY_FILE))) {
186
218
  return PANTHEON_V2_PLUGIN
187
219
  }
220
+ // beta.5: stale refs from OTHER installed copies of this package (older
221
+ // nvm prefixes, npx cache hashes). Only paths inside
222
+ // `node_modules/pantheon-opencode/` count as installed copies — a dev
223
+ // checkout elsewhere (e.g. a vendor folder named pantheon-opencode) remains
224
+ // protected by the third-party rule above.
225
+ const installedMarker = '/node_modules/pantheon-opencode/'
226
+ const withSlashes = normalizePluginPath(normalized)
227
+ const markerIndex = withSlashes.lastIndexOf(installedMarker)
228
+ if (markerIndex !== -1) {
229
+ const suffix = withSlashes.slice(markerIndex + installedMarker.length)
230
+ if (suffix === PANTHEON_V1_PLUGIN) return PANTHEON_V1_PLUGIN
231
+ if (suffix === PANTHEON_V1_HOOKS) return PANTHEON_V1_HOOKS
232
+ if (suffix === PANTHEON_V2_PLUGIN) return PANTHEON_V2_PLUGIN
233
+ if (suffix === PANTHEON_V2_LEGACY_FILE) return PANTHEON_V2_PLUGIN
234
+ }
188
235
  }
189
236
 
190
237
  return null
@@ -343,6 +390,45 @@ export function syncTuiRegistration(target, { isGlobal = false, dryRun = false }
343
390
  return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
344
391
  }
345
392
 
393
+ /**
394
+ * beta.5: set up the Python venv + run the health check. Never throws —
395
+ * failures are reported as warnings and return false so the caller omits
396
+ * MCP entries from the config (entries pointing at a missing venv would
397
+ * make every MCP fail to launch). `deps` is injectable for tests.
398
+ */
399
+ export function setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats, deps = {}) {
400
+ const S = strings()
401
+ const setup = deps.setupVenv ?? setupVenv
402
+ const check = deps.healthCheck ?? healthCheck
403
+ try {
404
+ const venvSpinner = spinner(S.settingUpRuntime)
405
+ setup(target, { dryRun, force: clean })
406
+ venvSpinner(true)
407
+ const runtimeTarget = isGlobal ? target : join(target, '.opencode')
408
+ const health = check(runtimeTarget, { dryRun, pythonTarget: target })
409
+
410
+ section(`\uD83D\uDD0D ${S.healthSection}`)
411
+ const healthStep = step(S.runningHealthChecks)
412
+ for (const p of health.passed) success(`${p.check}: ${p.detail}`)
413
+ for (const w of health.warnings) warning(`${w.check}: ${w.detail}`)
414
+ for (const f of health.failed) error(`${f.check}: ${f.detail}`)
415
+
416
+ if (health.failed.length > 0) {
417
+ healthStep(false)
418
+ warning(`${health.failed.length} check(s) failed — review above`)
419
+ stats.warnings += health.failed.length
420
+ return false
421
+ }
422
+ healthStep(true)
423
+ return true
424
+ } catch (err) {
425
+ error(S.runtimeFailed(err.message))
426
+ warning(S.runtimeSkippedMcp)
427
+ stats.warnings += 1
428
+ return false
429
+ }
430
+ }
431
+
346
432
  export async function installOpenCode(
347
433
  target,
348
434
  dryRun = false,
@@ -357,6 +443,7 @@ export async function installOpenCode(
357
443
 
358
444
  const componentSet = new Set(components)
359
445
  const stats = summary.opencode
446
+ const S = strings()
360
447
 
361
448
  // V1/V2 dual-version awareness (Phase 3): the installer builds a unified
362
449
  // config, then applies V2-native migration (migrateV1toV2) when
@@ -405,6 +492,17 @@ export async function installOpenCode(
405
492
  const config = readJsonConfig(targetConfigPath)
406
493
  const pantheonConfig = readJsonConfig(pantheonConfigPath)
407
494
 
495
+ // beta.5 preflight: fail BEFORE any files are written when the runtime
496
+ // component needs toolchain pieces that are missing. Nothing has been
497
+ // copied yet at this point, so the failure is clean.
498
+ if (componentSet.has('runtime') && !dryRun) {
499
+ const missing = checkRuntimePrerequisites()
500
+ if (missing.length > 0) {
501
+ for (const problem of missing) error(problem)
502
+ throw new Error(S.prereqMissing)
503
+ }
504
+ }
505
+
408
506
  // Determine layout based on install scope.
409
507
  // Global config dir (~/.opencode or ~/.config/opencode) uses a flat layout:
410
508
  // agents/ skills/ commands/ plugins/
@@ -417,7 +515,7 @@ export async function installOpenCode(
417
515
  configure({ dryRun })
418
516
 
419
517
  if (isGlobal) {
420
- info('Global config directory detected — using flat layout (agents/, skills/, commands/)')
518
+ info(S.globalLayout)
421
519
  }
422
520
 
423
521
  // -----------------------------------------------------------------------
@@ -430,7 +528,7 @@ export async function installOpenCode(
430
528
  warning(`Agent source directory not found: ${srcDir}`)
431
529
  stats.errors++
432
530
  } else {
433
- const agentStep = step('Installing agents')
531
+ const agentStep = step(S.installingAgents)
434
532
  const dstDir = isGlobal ? join(target, 'agents') : join(target, '.opencode', 'agents')
435
533
  if (!dryRun) mkdirSync(dstDir, { recursive: true })
436
534
  if (clean && existsSync(dstDir) && !dryRun) {
@@ -665,6 +763,13 @@ export async function installOpenCode(
665
763
  stats.created++
666
764
  }
667
765
 
766
+ // B3-08: explicit installation mode — seed the manifest with SHA-256
767
+ // digests so the server never executes a script implicitly.
768
+ const approvedScripts = writeCodeModeManifest(dstCodeModeDir, dryRun)
769
+ if (approvedScripts > 0) {
770
+ info(`Code-mode manifest: ${approvedScripts} script(s) approved`)
771
+ }
772
+
668
773
  // ── tiers.json ──
669
774
  const srcTiers = join(ROOT, '.pantheon', 'tiers.json')
670
775
  const dstTiers = join(runtimeTarget, '.pantheon', 'tiers.json')
@@ -676,6 +781,17 @@ export async function installOpenCode(
676
781
  }
677
782
  }
678
783
 
784
+ // -----------------------------------------------------------------------
785
+ // 2.11 Setup virtual environment + health check (--components runtime)
786
+ // beta.5: runs BEFORE the config write and is no longer fatal — on failure
787
+ // the install completes without MCP entries (which would point at a venv
788
+ // that does not exist) instead of aborting a half-done installation.
789
+ // -----------------------------------------------------------------------
790
+ let runtimeHealthy = !componentSet.has('runtime')
791
+ if (componentSet.has('runtime')) {
792
+ runtimeHealthy = setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats)
793
+ }
794
+
679
795
  // -----------------------------------------------------------------------
680
796
  // 3. Create/update opencode.json (always runs)
681
797
  // Reads TARGET's existing config first, then merges Pantheon settings
@@ -1017,8 +1133,10 @@ export async function installOpenCode(
1017
1133
 
1018
1134
  // --------------------------------------------------------------------
1019
1135
  // F.5 MCP server entries (for runtime-deployed scripts)
1136
+ // beta.5: skipped when the Python runtime setup failed — entries pointing
1137
+ // at a venv that does not exist would make every MCP fail to launch.
1020
1138
  // --------------------------------------------------------------------
1021
- if (componentSet.has('runtime')) {
1139
+ if (componentSet.has('runtime') && runtimeHealthy) {
1022
1140
  config.mcp = config.mcp || {}
1023
1141
  // MCP stays a flat named-server map for BOTH V1 and V2: OpenCode 1.18.x
1024
1142
  // has no `mcp.servers` wrapper and rejects one with
@@ -1102,12 +1220,12 @@ export async function installOpenCode(
1102
1220
  // before writing. V1 path is untouched (zero regression).
1103
1221
  let configToWrite = config
1104
1222
  if (version === 'v2') {
1105
- info('Migrating config to V2 native format...')
1223
+ info(S.v2Migrating)
1106
1224
  configToWrite = migrateV1toV2(config)
1107
1225
  }
1108
1226
 
1109
1227
  const configContent = `${JSON.stringify(configToWrite, null, 2)}\n`
1110
- const status = writeIfChanged(targetConfigPath, configContent, dryRun)
1228
+ const status = writeConfigWithBackup(targetConfigPath, configContent, dryRun)
1111
1229
  if (status === 'created') stats.created++
1112
1230
  else stats.skipped++
1113
1231
 
@@ -1128,40 +1246,15 @@ export async function installOpenCode(
1128
1246
  }
1129
1247
 
1130
1248
  // -----------------------------------------------------------------------
1131
- // 4. Setup virtual environment + health check (--components runtime)
1249
+ // 4. (runtime setup moved to 2.11 — before the config write — so MCP
1250
+ // entries are only added when the Python runtime is actually healthy)
1132
1251
  // -----------------------------------------------------------------------
1133
- if (componentSet.has('runtime')) {
1134
- try {
1135
- const venvSpinner = spinner('Setting up Python virtual environment')
1136
- setupVenv(target, { dryRun, force: clean })
1137
- venvSpinner(true)
1138
- const runtimeTarget = isGlobal ? target : join(target, '.opencode')
1139
- const health = healthCheck(runtimeTarget, { dryRun, pythonTarget: target })
1140
-
1141
- // Print health summary
1142
- section('\uD83D\uDD0D Health Check')
1143
- const healthStep = step('Running health checks')
1144
- for (const p of health.passed) success(`${p.check}: ${p.detail}`)
1145
- for (const w of health.warnings) warning(`${w.check}: ${w.detail}`)
1146
- for (const f of health.failed) error(`${f.check}: ${f.detail}`)
1147
-
1148
- if (health.failed.length > 0) {
1149
- healthStep(false)
1150
- warning(`${health.failed.length} check(s) failed — review above`)
1151
- stats.errors += health.failed.length
1152
- } else {
1153
- healthStep(true)
1154
- }
1155
- } catch (err) {
1156
- error(`Setup failed: ${err.message}`)
1157
- throw err // Fatal — abort installation
1158
- }
1159
- }
1160
1252
 
1161
1253
  if (interactive && !dryRun) {
1162
1254
  const created = stats.created
1163
1255
  const skipped = stats.skipped
1164
1256
  const errors = stats.errors
1257
+ const warnings = stats.warnings ?? 0
1165
1258
 
1166
1259
  process.stdout.write('\n')
1167
1260
  process.stdout.write(
@@ -1179,11 +1272,14 @@ export async function installOpenCode(
1179
1272
  process.stdout.write(` ${colors.dim('\u2014')} ${skipped} already up-to-date\n`)
1180
1273
  if (errors > 0)
1181
1274
  process.stdout.write(` ${colors.yellow('\u26a0')} ${errors} error(s) encountered\n`)
1275
+ if (warnings > 0)
1276
+ process.stdout.write(
1277
+ ` ${colors.yellow('\u26a0')} ${warnings} warning(s) — run 'pantheon-opencode doctor' for details\n`,
1278
+ )
1182
1279
  process.stdout.write('\n')
1183
1280
  process.stdout.write(` ${colors.bold('Next steps:')}\n`)
1184
1281
  process.stdout.write(` ${colors.dim('\u2022')} Configure agents in opencode.json\n`)
1185
- process.stdout.write(` ${colors.dim('\u2022')} Add MCP servers in mcp.json\n`)
1186
- process.stdout.write(` ${colors.dim('\u2022')} Run 'opencode doctor' to verify\n`)
1282
+ process.stdout.write(` ${colors.dim('\u2022')} Run 'pantheon-opencode doctor' to verify\n`)
1187
1283
  process.stdout.write(
1188
1284
  ` ${colors.dim('\u2022')} Run 'opencode' to start using Pantheon agents\n`,
1189
1285
  )
@@ -1207,4 +1303,10 @@ export async function installOpenCode(
1207
1303
  const { writeActivePreset } = await import('./model-picker.mjs')
1208
1304
  writeActivePreset(target, opts.preset, { source: 'cli' })
1209
1305
  }
1306
+
1307
+ // Fail-closed: accumulated stats.errors (bad sources, missing requirements,
1308
+ // failed health checks) must never masquerade as a successful installation.
1309
+ if (stats.errors > 0) {
1310
+ throw new Error(`installation completed with ${stats.errors} error(s); see output above`)
1311
+ }
1210
1312
  }
@@ -5,7 +5,6 @@
5
5
  import { execSync } from 'node:child_process'
6
6
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
7
7
  import { isAbsolute, join, resolve } from 'node:path'
8
- import { warning } from './cli-ui.mjs'
9
8
  import { ROOT, writeIfChanged } from './shared.mjs'
10
9
 
11
10
  /**
@@ -95,31 +94,9 @@ export function installPlugin(srcDir, dstDir, { dryRun = false, clean = false }
95
94
  result.skipped += copyResult.skipped
96
95
 
97
96
  // Install plugin dependencies (@opentui/core, @opentui/solid, solid-js).
98
- // npm ci is deterministic when the copied lockfile is present. The legacy
99
- // fallback is opt-in because npm install may rewrite the dependency graph.
97
+ // Lockfile installation is the only accepted dependency path.
100
98
  if (!dryRun) {
101
- try {
102
- execSync('npm ci --omit=dev --no-audit --no-fund', { cwd: dstDir, stdio: 'pipe' })
103
- } catch (e) {
104
- if (process.env.PANTHEON_ALLOW_NPM_INSTALL_FALLBACK === '1') {
105
- warning(`npm ci failed; explicit fallback enabled: ${e.message}`)
106
- try {
107
- execSync('npm install --omit=dev --no-audit --no-fund --package-lock=false', {
108
- cwd: dstDir,
109
- stdio: 'pipe',
110
- })
111
- } catch (fallbackError) {
112
- warning(`Failed to install TUI plugin dependencies: ${fallbackError.message}`)
113
- result.errors++
114
- }
115
- } else {
116
- warning(
117
- `npm ci failed; dependencies were not changed. Set ` +
118
- `PANTHEON_ALLOW_NPM_INSTALL_FALLBACK=1 to explicitly allow npm install: ${e.message}`,
119
- )
120
- result.errors++
121
- }
122
- }
99
+ execSync('npm ci --omit=dev --no-audit --no-fund', { cwd: dstDir, stdio: 'pipe' })
123
100
  }
124
101
 
125
102
  return result
@@ -133,14 +110,7 @@ export function installPlugin(srcDir, dstDir, { dryRun = false, clean = false }
133
110
  */
134
111
  export function installPluginDependencies(pluginDir, { dryRun = false } = {}) {
135
112
  if (dryRun) return
136
- try {
137
- execSync('npm ci --omit=dev --no-audit --no-fund', { cwd: pluginDir, stdio: 'pipe' })
138
- } catch (e) {
139
- warning(
140
- `npm ci failed; dependencies were not changed. Set ` +
141
- `PANTHEON_ALLOW_NPM_INSTALL_FALLBACK=1 to explicitly allow npm install: ${e.message}`,
142
- )
143
- }
113
+ execSync('npm ci --omit=dev --no-audit --no-fund', { cwd: pluginDir, stdio: 'pipe' })
144
114
  }
145
115
 
146
116
  /**
@@ -2,12 +2,15 @@
2
2
  * shared.mjs — Shared utilities for the OpenCode installer
3
3
  */
4
4
 
5
+ import { spawnSync } from 'node:child_process'
5
6
  import {
7
+ copyFileSync,
6
8
  cpSync,
7
9
  existsSync,
8
10
  mkdirSync,
9
11
  readdirSync,
10
12
  readFileSync,
13
+ renameSync,
11
14
  rmSync,
12
15
  statSync,
13
16
  writeFileSync,
@@ -41,7 +44,7 @@ export function getAgentNames() {
41
44
  // Cached constant for backward compatibility
42
45
  export const AGENT_NAMES = getAgentNames()
43
46
 
44
- export const summary = { opencode: { created: 0, skipped: 0, errors: 0 } }
47
+ export const summary = { opencode: { created: 0, skipped: 0, errors: 0, warnings: 0 } }
45
48
 
46
49
  export function showHelp() {
47
50
  console.log(`
@@ -260,11 +263,49 @@ export function writeIfChanged(filePath, content, dryRun) {
260
263
  return 'skipped'
261
264
  }
262
265
  if (!dryRun) {
263
- writeFileSync(filePath, content, 'utf8')
266
+ // Atomic write: write a temp sibling and rename over the target so a
267
+ // crash mid-write can never leave a truncated file behind (rename is
268
+ // atomic within the same directory).
269
+ const tmpPath = `${filePath}.tmp-${process.pid}`
270
+ writeFileSync(tmpPath, content, 'utf8')
271
+ renameSync(tmpPath, filePath)
264
272
  }
265
273
  return 'created'
266
274
  }
267
275
 
276
+ /**
277
+ * beta.5: write the user's opencode.json with a same-directory backup of the
278
+ * previous content. The installer rewrites the whole config; a `.bak` gives
279
+ * the user a one-step recovery if a merge ever goes wrong.
280
+ */
281
+ export function writeConfigWithBackup(filePath, content, dryRun) {
282
+ if (!dryRun && existsSync(filePath)) {
283
+ copyFileSync(filePath, `${filePath}.bak`)
284
+ }
285
+ return writeIfChanged(filePath, content, dryRun)
286
+ }
287
+
288
+ /**
289
+ * beta.5 preflight: verify the toolchain pieces the runtime component needs
290
+ * BEFORE any files are written, so a missing python3/npm fails in seconds
291
+ * with a clear message instead of aborting a half-done install.
292
+ * Returns a list of human-readable problems (empty = all good).
293
+ */
294
+ export function checkRuntimePrerequisites(env = process.env) {
295
+ const problems = []
296
+ const checks = [
297
+ { cmd: 'python3', hint: 'install Python 3.10+ (https://www.python.org/downloads/)' },
298
+ { cmd: 'npm', hint: 'install Node.js 22+ (https://nodejs.org) — npm ships with it' },
299
+ ]
300
+ for (const { cmd, hint } of checks) {
301
+ const probe = spawnSync(cmd, ['--version'], { encoding: 'utf8', timeout: 10_000, env })
302
+ if (probe.error || probe.status !== 0) {
303
+ problems.push(`${cmd} not found or not runnable — ${hint}`)
304
+ }
305
+ }
306
+ return problems
307
+ }
308
+
268
309
  export function collectSkillNames() {
269
310
  const skillsDir = join(ROOT, 'src', 'skills')
270
311
  if (!existsSync(skillsDir)) return []
@@ -0,0 +1,129 @@
1
+ /**
2
+ * strings.mjs — installer strings with locale auto-detection.
3
+ *
4
+ * LANG/LC_ALL/LC_MESSAGES starting with "pt" selects the PT-BR table;
5
+ * everything else (and the absence of a locale) selects EN. A plain string
6
+ * table — deliberately no i18n framework (YAGNI).
7
+ *
8
+ * Scope: the user journey (banner → install steps → health → summary →
9
+ * next steps). Deep diagnostics (per-file copy detail, doctor output) and
10
+ * the model-picker wizard remain single-language by design.
11
+ */
12
+
13
+ const TABLE = {
14
+ en: {
15
+ installing: 'Installing...',
16
+ dryRun: 'DRY RUN',
17
+ canceled: 'Installation canceled — nothing was broken; run init again anytime.',
18
+ canceledShort: 'Installation canceled.',
19
+ installFailed: (msg) => `❌ Installation failed: ${msg}`,
20
+ failHintNoMcp:
21
+ ' Run with --no-mcp to skip Python dependencies:\n npx pantheon-opencode init --no-mcp',
22
+ failHintForce:
23
+ ' Or retry with --force to recreate the venv:\n npx pantheon-opencode init --force',
24
+ runningHealthCheck: 'Running health check...',
25
+ installedTitle: (v) => `✅ Pantheon OpenCode v${v} installed!`,
26
+ nextSteps: 'Next steps:',
27
+ nextVerify: '1. Verify installation:\n npx pantheon-opencode doctor',
28
+ nextLaunch: '2. Launch OpenCode',
29
+ nextAgents: '3. Invoke agents with @agent-name in chat',
30
+ nextProject: '4. For project-local install:\n npx pantheon-opencode init --project',
31
+ // install steps
32
+ globalLayout:
33
+ 'Global config directory detected — using flat layout (agents/, skills/, commands/)',
34
+ installingAgents: 'Installing agents',
35
+ installingSkills: 'Installing skills',
36
+ installingInstructions: 'Installing instructions',
37
+ installingPrompts: 'Installing prompts',
38
+ installingCommands: 'Installing commands',
39
+ installingPlugins: 'Installing plugins',
40
+ settingUpRuntime: 'Setting up Python virtual environment',
41
+ healthSection: 'Health Check',
42
+ runningHealthChecks: 'Running health checks',
43
+ runtimeFailed: (msg) => `Python runtime setup failed: ${msg}`,
44
+ runtimeSkippedMcp:
45
+ 'MCP servers were NOT configured because the Python runtime is unavailable. ' +
46
+ 'Fix python3 (or free disk space) and re-run init to add them; everything else was installed.',
47
+ prereqMissing:
48
+ 'runtime prerequisites missing — install the tools above, or re-run with --no-mcp to install without Python MCP servers',
49
+ v2Migrating: 'Migrating config to V2 native format...',
50
+ // summary (printSummary)
51
+ summaryInstalled: (target) => `OpenCode installed in ${target}`,
52
+ summaryWithErrors: (target) => `Installation finished WITH ERRORS in ${target}`,
53
+ summaryComponents: (created, skipped) =>
54
+ ` Components:\t${created} installed, ${skipped} skipped`,
55
+ summaryErrors: (n) => ` ${n} error(s) found`,
56
+ summaryWarnings: (n) => ` ${n} warning(s) — run 'doctor' for details`,
57
+ summaryNext: ' Next steps:',
58
+ summaryNextAgents: ' • Configure your agents in opencode.json',
59
+ summaryNextMcp: ' • Add MCP servers in opencode.json (mcp section)',
60
+ summaryNextDoctor: " • Run 'opencode doctor' to verify the installation",
61
+ },
62
+ pt: {
63
+ installing: 'Instalando...',
64
+ dryRun: 'SIMULAÇÃO',
65
+ canceled: 'Instalação cancelada — nada foi quebrado; rode init de novo quando quiser.',
66
+ canceledShort: 'Instalação cancelada.',
67
+ installFailed: (msg) => `❌ Instalação falhou: ${msg}`,
68
+ failHintNoMcp:
69
+ ' Rode com --no-mcp para pular dependências Python:\n npx pantheon-opencode init --no-mcp',
70
+ failHintForce:
71
+ ' Ou tente de novo com --force para recriar a venv:\n npx pantheon-opencode init --force',
72
+ runningHealthCheck: 'Rodando verificação de saúde...',
73
+ installedTitle: (v) => `✅ Pantheon OpenCode v${v} instalado!`,
74
+ nextSteps: 'Próximos passos:',
75
+ nextVerify: '1. Verifique a instalação:\n npx pantheon-opencode doctor',
76
+ nextLaunch: '2. Abra o OpenCode',
77
+ nextAgents: '3. Invoc agentes com @nome-do-agente no chat',
78
+ nextProject: '4. Para instalar no projeto:\n npx pantheon-opencode init --project',
79
+ // install steps
80
+ globalLayout:
81
+ 'Diretório de config global detectado — layout plano (agents/, skills/, commands/)',
82
+ installingAgents: 'Instalando agentes',
83
+ installingSkills: 'Instalando skills',
84
+ installingInstructions: 'Instalando instruções',
85
+ installingPrompts: 'Instalando prompts',
86
+ installingCommands: 'Instalando comandos',
87
+ installingPlugins: 'Instalando plugins',
88
+ settingUpRuntime: 'Configurando ambiente virtual Python',
89
+ healthSection: 'Verificação de saúde',
90
+ runningHealthChecks: 'Rodando verificações de saúde',
91
+ runtimeFailed: (msg) => `Falha ao configurar o runtime Python: ${msg}`,
92
+ runtimeSkippedMcp:
93
+ 'Os servidores MCP NÃO foram configurados porque o runtime Python está indisponível. ' +
94
+ 'Corrija o python3 (ou libere espaço em disco) e rode init de novo para adicioná-los; todo o resto foi instalado.',
95
+ prereqMissing:
96
+ 'pré-requisitos do runtime ausentes — instale as ferramentas acima, ou rode de novo com --no-mcp para instalar sem os MCPs Python',
97
+ v2Migrating: 'Migrando config para o formato nativo V2...',
98
+ // summary (printSummary)
99
+ summaryInstalled: (target) => `OpenCode instalado em ${target}`,
100
+ summaryWithErrors: (target) => `Instalação concluída COM ERROS em ${target}`,
101
+ summaryComponents: (created, skipped) =>
102
+ ` Componentes:\t${created} instalados, ${skipped} pulados`,
103
+ summaryErrors: (n) => ` ${n} erro(s) encontrados`,
104
+ summaryWarnings: (n) => ` ${n} aviso(s) — rode 'doctor' para detalhes`,
105
+ summaryNext: ' Próximos passos:',
106
+ summaryNextAgents: ' • Configure seus agentes em opencode.json',
107
+ summaryNextMcp: ' • Adicione MCP servers em opencode.json (seção mcp)',
108
+ summaryNextDoctor: " • Rode 'opencode doctor' para verificar a instalação",
109
+ },
110
+ }
111
+
112
+ /** Pure locale resolution (exported for tests). */
113
+ export function detectLocale(env = process.env) {
114
+ const raw = env.LC_ALL ?? env.LC_MESSAGES ?? env.LANG ?? ''
115
+ return /^pt/i.test(raw) ? 'pt' : 'en'
116
+ }
117
+
118
+ /** Pure table access (exported for tests and explicit-locale callers). */
119
+ export function stringsFor(locale) {
120
+ return TABLE[locale] ?? TABLE.en
121
+ }
122
+
123
+ let cached = null
124
+
125
+ /** Locale-resolved string table for the current process. */
126
+ export function strings(env = process.env) {
127
+ if (cached === null) cached = stringsFor(detectLocale(env))
128
+ return cached
129
+ }
@@ -221,6 +221,7 @@ export const MCPS = {
221
221
  type: 'local',
222
222
  command: VENV_PYTHON,
223
223
  args: [CODE_MODE_SERVER],
224
+ cwd: '.',
224
225
  },
225
226
  },
226
227
  env: [],