pantheon-opencode 1.4.3 → 1.5.0-beta.10

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 (118) hide show
  1. package/.pantheon/code-mode/README.md +93 -0
  2. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  3. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  4. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  5. package/.pantheon/code-mode/compress-inline.py +450 -0
  6. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  7. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  8. package/.pantheon/code-mode/eval-run.py +197 -0
  9. package/.pantheon/code-mode/eval-static.py +321 -0
  10. package/.pantheon/code-mode/example-sync.sh +5 -0
  11. package/.pantheon/code-mode/manifest.json +21 -0
  12. package/.pantheon/code-mode/session-end-save.py +186 -0
  13. package/.pantheon/code-mode/session-end-save.sh +18 -0
  14. package/.pantheon/code-mode/test-allowed.sh +5 -0
  15. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  16. package/.pantheon/code-mode/test-env.sh +7 -0
  17. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  18. package/.pantheon/code-mode/test-timeout.sh +6 -0
  19. package/CHANGELOG.md +1026 -0
  20. package/README.md +292 -1080
  21. package/README.pt-BR.md +308 -0
  22. package/bin/pantheon-init.mjs +207 -31
  23. package/opencode.json +1 -0
  24. package/package.json +34 -14
  25. package/plugin.json +15 -0
  26. package/pyproject.toml +65 -0
  27. package/scripts/_pantheon_paths.py +48 -7
  28. package/scripts/ci-validate-yaml.py +7 -2
  29. package/scripts/code_mode_server.py +451 -44
  30. package/scripts/cost.mjs +30 -14
  31. package/scripts/doctor.mjs +306 -129
  32. package/scripts/generate-preset-docs.mjs +127 -66
  33. package/scripts/generate-prompts.sh +2 -2
  34. package/scripts/install/cli-ui.mjs +23 -10
  35. package/scripts/install/config-migration.mjs +399 -0
  36. package/scripts/install/health-check.mjs +3 -3
  37. package/scripts/install/model-picker.mjs +72 -23
  38. package/scripts/install/opencode-version.mjs +47 -0
  39. package/scripts/install/opencode.mjs +396 -121
  40. package/scripts/install/plugin.mjs +89 -49
  41. package/scripts/install/shared.mjs +55 -159
  42. package/scripts/install/strings.mjs +129 -0
  43. package/scripts/install-mcp.mjs +45 -232
  44. package/scripts/manifest-inventory.mjs +410 -0
  45. package/scripts/mcp_persistence_server.py +798 -60
  46. package/scripts/mcp_resources_server.py +144 -40
  47. package/scripts/memory_mcp_server.py +2 -2
  48. package/scripts/package-evidence.mjs +242 -0
  49. package/scripts/probe-context-rehydrate.mjs +240 -0
  50. package/scripts/probe-pantheon-cost.mjs +313 -0
  51. package/scripts/recover-zenodo-deposition.mjs +146 -0
  52. package/scripts/release-beta-version.mjs +56 -60
  53. package/scripts/sync-artifacts.mjs +169 -0
  54. package/scripts/sync-tui.mjs +135 -42
  55. package/scripts/test-opencode-v1-v2-sandbox.sh +862 -0
  56. package/scripts/uninstall.mjs +298 -954
  57. package/scripts/validate-zenodo-release.mjs +154 -0
  58. package/scripts/validation-policy.mjs +7 -7
  59. package/scripts/version-check.mjs +113 -108
  60. package/scripts/versioning.mjs +230 -86
  61. package/scripts/zenodo-artifact.mjs +21 -0
  62. package/scripts/zenodo-release-state.mjs +53 -0
  63. package/src/agents/mnemosyne.md +1 -1
  64. package/src/agents/zeus.md +27 -16
  65. package/src/mcp/_pantheon_paths.py +48 -7
  66. package/src/mcp/code_mode_server.py +451 -44
  67. package/src/mcp/eval_store.py +1 -1
  68. package/src/mcp/mcp_persistence_server.py +798 -60
  69. package/src/mcp/mcp_resources_server.py +144 -40
  70. package/src/mcp/pantheon_vision_server.py +1 -1
  71. package/src/mcp/toon_codec.py +491 -0
  72. package/src/pantheon/auto-wake.ts +1 -5
  73. package/src/pantheon/background-job-board.ts +110 -2
  74. package/src/pantheon/compaction-handoff.ts +304 -0
  75. package/src/pantheon/cost-command.ts +356 -69
  76. package/src/pantheon/delegate-manager.ts +766 -0
  77. package/src/pantheon/delegation-classifier.ts +307 -0
  78. package/src/pantheon/delegation-enforce.ts +13 -12
  79. package/src/pantheon/delegation-finalize.ts +406 -0
  80. package/src/pantheon/delegation-notify.ts +212 -0
  81. package/src/pantheon/delegation-toast.ts +73 -0
  82. package/src/pantheon/delegation.ts +1337 -0
  83. package/src/pantheon/file-persistence-salvage.ts +64 -0
  84. package/src/pantheon/file-persistence.ts +194 -45
  85. package/src/pantheon/goal-loop.ts +2 -2
  86. package/src/pantheon/goal-store.ts +1 -1
  87. package/src/pantheon/hashline/tool.ts +2 -2
  88. package/src/pantheon/model-command.ts +2 -1
  89. package/src/pantheon/native-probe.ts +234 -0
  90. package/src/pantheon/native-task-status.ts +139 -0
  91. package/src/pantheon/permission-eval.ts +284 -0
  92. package/src/pantheon/provider-capabilities.ts +221 -0
  93. package/src/pantheon/provider-stream-tracker.ts +182 -0
  94. package/src/pantheon/shared-board.ts +37 -0
  95. package/src/pantheon/step-cap.ts +110 -0
  96. package/src/pantheon/task-result-guard.ts +107 -0
  97. package/src/pantheon/token-opt.ts +689 -0
  98. package/src/pantheon/tool-ceiling.ts +159 -0
  99. package/src/pantheon/toon-codec.ts +481 -0
  100. package/src/pantheon/tui-version-check.ts +1 -3
  101. package/src/pantheon/v2-bridge.ts +129 -0
  102. package/src/pantheon/v2-events.ts +148 -0
  103. package/src/pantheon/v2-hooks.ts +287 -0
  104. package/src/pantheon/v2-tool-definitions.ts +331 -0
  105. package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
  106. package/src/plugin-v2/index.ts +21 -0
  107. package/src/plugin-v2.ts +824 -0
  108. package/src/plugin.ts +261 -51
  109. package/src/plugins/pantheon-hooks.ts +51 -0
  110. package/src/plugins/tui/dist/tui.d.ts +116 -24
  111. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  112. package/src/plugins/tui/dist/tui.js +416 -149
  113. package/src/plugins/tui/dist/tui.tsx +456 -87
  114. package/src/plugins/tui/package-lock.json +51 -51
  115. package/src/plugins/tui/package.json +7 -7
  116. package/src/plugins/tui/src/index.tsx +462 -95
  117. package/src/skills/README.md +0 -2
  118. package/src/pantheon/tool-context.ts +0 -41
@@ -0,0 +1,399 @@
1
+ /**
2
+ * config-migration.mjs — Bidirectional V1↔V2 config format migration
3
+ *
4
+ * Converts opencode.json between:
5
+ * - V1 (object-based): `permission`, `agent`, `provider`, `mcp` with flat keys
6
+ * - V2 (array-based): `permissions`, `agents`, `providers`; MCP remains the
7
+ * named top-level server map because OpenCode 1.18.x validates that shape for
8
+ * both the V1 and V2 installer paths.
9
+ *
10
+ * Rules:
11
+ * 1. Deep clone before mutating — never modifies input
12
+ * 2. Unknown fields pass through untouched
13
+ * 3. console.warn for ambiguities
14
+ * 4. Handles nested objects recursively
15
+ *
16
+ * @module config-migration
17
+ */
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** Deep clone a JSON-serializable value */
24
+ function deepClone(obj) {
25
+ return JSON.parse(JSON.stringify(obj))
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // V1 → V2 helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /** Action name mapping for permissions (V1 key → V2 action) */
33
+ const V1_PERMISSION_TO_ACTION = {
34
+ bash: 'shell',
35
+ skill: 'skill',
36
+ edit: 'edit',
37
+ websearch: 'websearch',
38
+ // passthrough unknowns
39
+ }
40
+
41
+ /**
42
+ * Convert V1 permission object to V2 permissions array.
43
+ *
44
+ * V1 shape:
45
+ * { bash: { "git *": "allow" }, edit: "allow", skill: { "*": "allow" }, websearch: "deny" }
46
+ *
47
+ * V2 shape:
48
+ * [ { action: "shell", resource: "git *", effect: "allow" }, ... ]
49
+ */
50
+ function convertPermissionsV1toV2(permObj) {
51
+ const result = []
52
+ for (const [key, value] of Object.entries(permObj)) {
53
+ const action = V1_PERMISSION_TO_ACTION[key] || key
54
+ if (typeof value === 'string') {
55
+ // Simple: { edit: "allow" } → [{ action: "edit", resource: "*", effect: "allow" }]
56
+ result.push({ action, resource: '*', effect: value })
57
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
58
+ // Nested: { bash: { "git *": "allow" } } → [{ action: "shell", resource: "git *", effect: "allow" }]
59
+ for (const [resource, effect] of Object.entries(value)) {
60
+ result.push({ action, resource, effect })
61
+ }
62
+ } else {
63
+ console.warn(`[config-migration] Unexpected permission value for "${key}":`, value)
64
+ }
65
+ }
66
+ return result
67
+ }
68
+
69
+ /**
70
+ * Convert V2 permissions array back to V1 permission object.
71
+ */
72
+ function convertPermissionsV2toV1(permArray) {
73
+ const result = {}
74
+ // Reverse action mapping: V2 action → V1 key
75
+ const ACTION_TO_V1 = Object.fromEntries(
76
+ Object.entries(V1_PERMISSION_TO_ACTION).map(([k, v]) => [v, k]),
77
+ )
78
+
79
+ for (const { action, resource, effect } of permArray) {
80
+ const v1Key = ACTION_TO_V1[action] || action
81
+ if (resource === '*') {
82
+ result[v1Key] = effect
83
+ } else {
84
+ if (!result[v1Key] || typeof result[v1Key] === 'string') {
85
+ // Convert scalar to object
86
+ if (typeof result[v1Key] === 'string') {
87
+ const prevEffect = result[v1Key]
88
+ result[v1Key] = { '*': prevEffect }
89
+ } else {
90
+ result[v1Key] = {}
91
+ }
92
+ }
93
+ result[v1Key][resource] = effect
94
+ }
95
+ }
96
+ return result
97
+ }
98
+
99
+ /** Provider name consolidation: V1 → V2 */
100
+ const PROVIDER_RENAME = {
101
+ 'azure-cognitive-services': 'azure',
102
+ 'google-vertex-anthropic': 'google-vertex',
103
+ }
104
+
105
+ /** Reverse: V2 → V1 */
106
+ const PROVIDER_RENAME_REVERSE = Object.fromEntries(
107
+ Object.entries(PROVIDER_RENAME).map(([k, v]) => [v, k]),
108
+ )
109
+
110
+ /**
111
+ * Convert a single V1 provider config to V2.
112
+ *
113
+ * V1 shape:
114
+ * { npm: "ai-sdk-openai", api: "https://...", options: { apiKey: "..." }, models: { ... } }
115
+ *
116
+ * V2 shape:
117
+ * { package: "aisdk:ai-sdk-openai", settings: { baseURL: "...", apiKey: "..." }, models: { ... } }
118
+ */
119
+ function convertProviderV1toV2(name, config) {
120
+ const result = { ...config }
121
+
122
+ // npm → package (with aisdk: prefix)
123
+ if (result.npm !== undefined) {
124
+ const pkg = result.npm.startsWith('aisdk:') ? result.npm : `aisdk:${result.npm}`
125
+ result.package = pkg
126
+ delete result.npm
127
+ }
128
+
129
+ // api → settings.baseURL
130
+ if (result.api !== undefined) {
131
+ if (!result.settings) result.settings = {}
132
+ result.settings.baseURL = result.api
133
+ delete result.api
134
+ }
135
+
136
+ // options.apiKey → settings.apiKey
137
+ if (result.options?.apiKey !== undefined) {
138
+ if (!result.settings) result.settings = {}
139
+ result.settings.apiKey = result.options.apiKey
140
+ const { apiKey, ...restOptions } = result.options
141
+ if (Object.keys(restOptions).length > 0) {
142
+ result.options = restOptions
143
+ } else {
144
+ delete result.options
145
+ }
146
+ }
147
+
148
+ // Model variant: { variant: "high" } → key suffix #high
149
+ // Also rename model-level "attachment" → "media"
150
+ if (result.models) {
151
+ const newModels = {}
152
+ for (const [modelKey, modelConfig] of Object.entries(result.models)) {
153
+ const { variant, attachment, ...restConfig } = modelConfig || {}
154
+ const finalConfig = { ...restConfig }
155
+ if (attachment !== undefined) {
156
+ finalConfig.media = attachment
157
+ }
158
+ if (variant) {
159
+ const newKey = `${modelKey}#${variant}`
160
+ newModels[newKey] = finalConfig
161
+ } else {
162
+ newModels[modelKey] = finalConfig
163
+ }
164
+ }
165
+ result.models = newModels
166
+ }
167
+
168
+ return result
169
+ }
170
+
171
+ /**
172
+ * Convert a single V2 provider config back to V1.
173
+ */
174
+ function convertProviderV2toV1(name, config) {
175
+ const result = { ...config }
176
+
177
+ // package → npm (strip aisdk: prefix)
178
+ if (result.package !== undefined) {
179
+ result.npm = result.package.startsWith('aisdk:') ? result.package.slice(6) : result.package
180
+ delete result.package
181
+ }
182
+
183
+ // settings.baseURL → api
184
+ if (result.settings?.baseURL !== undefined) {
185
+ result.api = result.settings.baseURL
186
+ const { baseURL, ...restSettings } = result.settings
187
+ if (Object.keys(restSettings).length > 0) {
188
+ result.settings = restSettings
189
+ } else {
190
+ delete result.settings
191
+ }
192
+ }
193
+
194
+ // settings.apiKey → options.apiKey
195
+ if (result.settings?.apiKey !== undefined) {
196
+ if (!result.options) result.options = {}
197
+ result.options.apiKey = result.settings.apiKey
198
+ const { apiKey, ...restSettings } = result.settings || {}
199
+ if (Object.keys(restSettings).length > 0) {
200
+ result.settings = restSettings
201
+ } else {
202
+ delete result.settings
203
+ }
204
+ }
205
+
206
+ // Model key with #suffix → variant field
207
+ // Also rename model-level "media" → "attachment"
208
+ if (result.models) {
209
+ const newModels = {}
210
+ for (const [modelKey, modelConfig] of Object.entries(result.models)) {
211
+ const { media, ...restConfig } = modelConfig || {}
212
+ const finalConfig = { ...restConfig }
213
+ if (media !== undefined) {
214
+ finalConfig.attachment = media
215
+ }
216
+ if (modelKey.includes('#')) {
217
+ const [baseName, variant] = modelKey.split('#', 2)
218
+ newModels[baseName] = { ...finalConfig, variant }
219
+ } else {
220
+ newModels[modelKey] = finalConfig
221
+ }
222
+ }
223
+ result.models = newModels
224
+ }
225
+
226
+ return result
227
+ }
228
+
229
+ /**
230
+ * Convert V1 MCP config to V2.
231
+ *
232
+ * V1: { bifrost: { type: "remote", url: "...", enabled: true, timeout: 30000 } }
233
+ * V2 installer output: { bifrost: { type: "remote", url: "...", enabled: true } }
234
+ *
235
+ * OpenCode 1.18.18 does not have an `mcp.servers` wrapper. It interprets
236
+ * `servers` as the name of an MCP server and then rejects it with
237
+ * `Missing key mcp.servers.enabled`. Keep MCP in the documented shape and
238
+ * normalize old generated V2 entries back to it.
239
+ */
240
+ function convertMcpV1toV2(mcpConfig) {
241
+ if (!mcpConfig || typeof mcpConfig !== 'object') return mcpConfig
242
+
243
+ const servers = {}
244
+ const source =
245
+ mcpConfig.servers && typeof mcpConfig.servers === 'object' && !Array.isArray(mcpConfig.servers)
246
+ ? { ...mcpConfig, ...mcpConfig.servers }
247
+ : mcpConfig
248
+
249
+ for (const [name, serverConfig] of Object.entries(source)) {
250
+ if (name === 'servers') continue
251
+ // Installer versions that seeded the legacy wrapper also wrote a boolean
252
+ // feature toggle (`servers: { enabled: true }`). After unwrapping, that
253
+ // toggle surfaces as a non-object entry and is not a server; drop it.
254
+ if (serverConfig === null || typeof serverConfig !== 'object') continue
255
+ const srv = { ...serverConfig }
256
+
257
+ // Normalize legacy V2's disabled flag to OpenCode 1.18.x's enabled flag.
258
+ if ('disabled' in srv) {
259
+ srv.enabled = !srv.disabled
260
+ delete srv.disabled
261
+ }
262
+
263
+ // Normalize legacy V2's split timeout to the V1/V2-compatible number.
264
+ if (srv.timeout !== undefined && srv.timeout !== null) {
265
+ if (typeof srv.timeout === 'object') {
266
+ srv.timeout = srv.timeout.execution || srv.timeout.catalog || 30000
267
+ }
268
+ }
269
+
270
+ // A shorthand inherited-server override is valid only when named directly.
271
+ // Runtime MCP entries always have a complete config, so leave other values
272
+ // untouched and avoid dropping user-owned servers.
273
+ servers[name] = srv
274
+ }
275
+
276
+ return servers
277
+ }
278
+
279
+ /**
280
+ * Convert V2 MCP config back to V1.
281
+ */
282
+ function convertMcpV2toV1(mcpConfig) {
283
+ if (!mcpConfig || typeof mcpConfig !== 'object') return mcpConfig
284
+
285
+ return convertMcpV1toV2(mcpConfig)
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // Top-level key mappings
290
+ // ---------------------------------------------------------------------------
291
+
292
+ const V1_TO_V2_RENAMES = {
293
+ provider: 'providers',
294
+ agent: 'agents',
295
+ command: 'commands',
296
+ reference: 'references',
297
+ snapshot: 'snapshots',
298
+ attachment: 'media',
299
+ permission: 'permissions',
300
+ }
301
+
302
+ /** Build reverse map */
303
+ const V2_TO_V1_RENAMES = Object.fromEntries(
304
+ Object.entries(V1_TO_V2_RENAMES).map(([k, v]) => [v, k]),
305
+ )
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Public API
309
+ // ---------------------------------------------------------------------------
310
+
311
+ /**
312
+ * Migrate a V1 opencode.json config to V2 format.
313
+ *
314
+ * @param {object} config - V1 config object (not mutated)
315
+ * @returns {object} V2 config object
316
+ */
317
+ export function migrateV1toV2(config) {
318
+ const c = deepClone(config)
319
+ const result = {}
320
+
321
+ for (const [key, value] of Object.entries(c)) {
322
+ const v2Key = V1_TO_V2_RENAMES[key] || key
323
+
324
+ if (key === 'permission' && typeof value === 'object' && value !== null) {
325
+ result.permissions = convertPermissionsV1toV2(value)
326
+ } else if (key === 'agent' && typeof value === 'object' && value !== null) {
327
+ const agents = {}
328
+ for (const [agentName, agentConfig] of Object.entries(value)) {
329
+ const ac = { ...agentConfig }
330
+ if (ac.permission && typeof ac.permission === 'object') {
331
+ ac.permissions = convertPermissionsV1toV2(ac.permission)
332
+ delete ac.permission
333
+ }
334
+ agents[agentName] = ac
335
+ }
336
+ result.agents = agents
337
+ } else if (key === 'provider' && typeof value === 'object' && value !== null) {
338
+ const providers = {}
339
+ for (const [provName, provConfig] of Object.entries(value)) {
340
+ const renamed = PROVIDER_RENAME[provName] || provName
341
+ providers[renamed] = convertProviderV1toV2(provName, provConfig)
342
+ }
343
+ result.providers = providers
344
+ } else if (key === 'mcp' && typeof value === 'object' && value !== null) {
345
+ result.mcp = convertMcpV1toV2(value)
346
+ } else if (v2Key !== key) {
347
+ result[v2Key] = value
348
+ } else {
349
+ result[key] = value
350
+ }
351
+ }
352
+
353
+ return result
354
+ }
355
+
356
+ /**
357
+ * Migrate a V2 opencode.json config back to V1 format.
358
+ *
359
+ * @param {object} config - V2 config object (not mutated)
360
+ * @returns {object} V1 config object
361
+ */
362
+ export function migrateV2toV1(config) {
363
+ const c = deepClone(config)
364
+ const result = {}
365
+
366
+ for (const [key, value] of Object.entries(c)) {
367
+ const v1Key = V2_TO_V1_RENAMES[key] || key
368
+
369
+ if (key === 'permissions' && Array.isArray(value)) {
370
+ result.permission = convertPermissionsV2toV1(value)
371
+ } else if (key === 'agents' && typeof value === 'object' && value !== null) {
372
+ const agents = {}
373
+ for (const [agentName, agentConfig] of Object.entries(value)) {
374
+ const ac = { ...agentConfig }
375
+ if (Array.isArray(ac.permissions)) {
376
+ ac.permission = convertPermissionsV2toV1(ac.permissions)
377
+ delete ac.permissions
378
+ }
379
+ agents[agentName] = ac
380
+ }
381
+ result.agent = agents
382
+ } else if (key === 'providers' && typeof value === 'object' && value !== null) {
383
+ const providers = {}
384
+ for (const [provName, provConfig] of Object.entries(value)) {
385
+ const renamed = PROVIDER_RENAME_REVERSE[provName] || provName
386
+ providers[renamed] = convertProviderV2toV1(provName, provConfig)
387
+ }
388
+ result.provider = providers
389
+ } else if (key === 'mcp' && typeof value === 'object' && value !== null) {
390
+ result.mcp = convertMcpV2toV1(value)
391
+ } else if (v1Key !== key) {
392
+ result[v1Key] = value
393
+ } else {
394
+ result[key] = value
395
+ }
396
+ }
397
+
398
+ return result
399
+ }
@@ -18,12 +18,12 @@ import { join } from 'node:path'
18
18
  * NON-FATAL — all checks are reported, none throw.
19
19
  *
20
20
  * @param {string} target - Installation directory
21
- * @param {{ dryRun?: boolean }} [options]
21
+ * @param {{ dryRun?: boolean; pythonTarget?: string }} [options]
22
22
  * @returns {{ passed: Array<{check: string, detail: string}>, failed: Array<{check: string, detail: string}>, warnings: Array<{check: string, detail: string}> }}
23
23
  */
24
- export function healthCheck(target, { dryRun = false } = {}) {
24
+ export function healthCheck(target, { dryRun = false, pythonTarget = target } = {}) {
25
25
  const results = { passed: [], failed: [], warnings: [] }
26
- const python = findPython(target)
26
+ const python = findPython(pythonTarget)
27
27
  const visionServer = resolveVisionServer(target)
28
28
 
29
29
  // Check 1: Critical runtime scripts exist
@@ -14,7 +14,14 @@
14
14
  * "herdar do chat" (inherit) é o default da Q1: não grava active-preset.json,
15
15
  * então os delegates herdam o modelo do chat pai (herança nativa, sem preset).
16
16
  */
17
- import { copyFileSync, existsSync, mkdirSync, renameSync, writeFileSync, readFileSync } from 'node:fs'
17
+ import {
18
+ copyFileSync,
19
+ existsSync,
20
+ mkdirSync,
21
+ readFileSync,
22
+ renameSync,
23
+ writeFileSync,
24
+ } from 'node:fs'
18
25
  import { homedir } from 'node:os'
19
26
  import { join } from 'node:path'
20
27
  import { createInterface } from 'node:readline/promises'
@@ -22,10 +29,22 @@ import { loadPresetDefs } from '../../src/pantheon/presets.mjs'
22
29
 
23
30
  /** Preset pricing table (2026 verified). */
24
31
  export const PRESET_PRICE = {
25
- 'go-free': { label: 'Gratuito', detail: 'Zen free – sem custo, quota limitada, só modelos -free' },
26
- 'go-fast': { label: 'Baixo custo', detail: 'Go gateway – baixa latência, kimi-k2.7-code / glm-5.3-flash' },
27
- 'go-premium': { label: 'Premium', detail: 'Go gateway – melhor qualidade, gpt-5.6-sol / qwen3.8-max / deepseek-v4-pro' },
28
- openai: { label: 'Pago (OpenAI)', detail: 'Direto OpenAI – https://api.openai.com/v1, gpt-5.6 family' },
32
+ 'go-free': {
33
+ label: 'Gratuito',
34
+ detail: 'Zen free – sem custo, quota limitada, só modelos -free',
35
+ },
36
+ 'go-fast': {
37
+ label: 'Baixo custo',
38
+ detail: 'Go gateway – baixa latência, kimi-k2.7-code / glm-5.3-flash',
39
+ },
40
+ 'go-premium': {
41
+ label: 'Premium',
42
+ detail: 'Go gateway – melhor qualidade, gpt-5.6-sol / qwen3.8-max / deepseek-v4-pro',
43
+ },
44
+ openai: {
45
+ label: 'Pago (OpenAI)',
46
+ detail: 'Direto OpenAI – https://api.openai.com/v1, gpt-5.6 family',
47
+ },
29
48
  }
30
49
 
31
50
  /**
@@ -37,7 +56,9 @@ export const PRESET_PRICE = {
37
56
  export function requiredKeyEnvForPreset(name, def) {
38
57
  if (name === 'openai') return 'OPENAI_API_KEY'
39
58
  // all Go presets use PANTHEON_OPENCODE_API_KEY (alias OPENCODE_GO_API_KEY)
40
- const providerEnvs = Object.values(def?.providers ?? {}).map((p) => p.apiKeyEnv).filter(Boolean)
59
+ const providerEnvs = Object.values(def?.providers ?? {})
60
+ .map((p) => p.apiKeyEnv)
61
+ .filter(Boolean)
41
62
  if (providerEnvs.includes('PANTHEON_OPENCODE_API_KEY')) return 'PANTHEON_OPENCODE_API_KEY'
42
63
  if (providerEnvs.includes('OPENAI_API_KEY')) return 'OPENAI_API_KEY'
43
64
  return 'PANTHEON_OPENCODE_API_KEY'
@@ -134,8 +155,14 @@ export function buildInitQuestions(presetDefs) {
134
155
  header: 'Escopo',
135
156
  question: 'Onde salvar o preset ativo?',
136
157
  options: [
137
- { label: 'project', description: './.pantheon/active-preset.json (escopo do projeto, seguro)' },
138
- { label: 'global', description: '~/.config/opencode/.pantheon/active-preset.json (global)' },
158
+ {
159
+ label: 'project',
160
+ description: './.pantheon/active-preset.json (escopo do projeto, seguro)',
161
+ },
162
+ {
163
+ label: 'global',
164
+ description: '~/.config/opencode/.pantheon/active-preset.json (global)',
165
+ },
139
166
  ],
140
167
  multiSelect: false,
141
168
  },
@@ -238,7 +265,10 @@ export async function runModelPicker({
238
265
  }
239
266
 
240
267
  const list = names
241
- .map((n, i) => ` ${i + 1}) ${n} — ${defs[n].description ?? ''} | ${buildPresetTableRow(n, defs[n])}`)
268
+ .map(
269
+ (n, i) =>
270
+ ` ${i + 1}) ${n} — ${defs[n].description ?? ''} | ${buildPresetTableRow(n, defs[n])}`,
271
+ )
242
272
  .join('\n')
243
273
  const rli = rl ?? createInterface({ input: process.stdin, output: process.stdout })
244
274
  try {
@@ -319,7 +349,7 @@ export async function runInitWizard({
319
349
  } else {
320
350
  // Fallback: readline 3-step wizard
321
351
  const rli = rl ?? createInterface({ input: process.stdin, output: process.stdout })
322
- let closeAfter = !rl
352
+ const closeAfter = !rl
323
353
  try {
324
354
  // Q1: perfil (0 = herdar do chat, default)
325
355
  const list = [
@@ -353,20 +383,22 @@ export async function runInitWizard({
353
383
  // Q2: key masked
354
384
  const requiredEnv = requiredKeyEnvForPreset(presetName, defs[presetName])
355
385
  const aliasNote =
356
- requiredEnv === 'PANTHEON_OPENCODE_API_KEY'
357
- ? ' (alias OPENCODE_GO_API_KEY aceito)'
358
- : ''
386
+ requiredEnv === 'PANTHEON_OPENCODE_API_KEY' ? ' (alias OPENCODE_GO_API_KEY aceito)' : ''
359
387
  const envConfigured = isKeyConfiguredForPreset(env, presetName, defs[presetName])
360
388
  let keyAnswer = ''
361
389
  if (envConfigured) {
362
- logger.log?.(`Chave ${requiredEnv}${aliasNote} já configurada no ambiente (mascarada: ${maskKey(env[requiredEnv] ?? env.OPENCODE_GO_API_KEY ?? '')}).`)
390
+ logger.log?.(
391
+ `Chave ${requiredEnv}${aliasNote} já configurada no ambiente (mascarada: ${maskKey(env[requiredEnv] ?? env.OPENCODE_GO_API_KEY ?? '')}).`,
392
+ )
363
393
  } else {
364
394
  // masked collection
365
395
  const prompt = `[2/3] Informe ${requiredEnv}${aliasNote} (entrada mascarada, não escreve .env): `
366
396
  // Use questionMasked for tests; real TTY would mask
367
397
  keyAnswer = (await questionMasked(rli, prompt)).trim()
368
398
  if (keyAnswer === '') {
369
- logger.warn?.(`Chave vazia para ${requiredEnv} — wizard canceled. Preencha env ${requiredEnv} antes de continuar.`)
399
+ logger.warn?.(
400
+ `Chave vazia para ${requiredEnv} — wizard canceled. Preencha env ${requiredEnv} antes de continuar.`,
401
+ )
370
402
  return null
371
403
  }
372
404
  // validate non-empty already; optionally check length
@@ -374,7 +406,9 @@ export async function runInitWizard({
374
406
  // For health-check, set in current env (not .env file) for this session
375
407
  // Prefer primary var
376
408
  env[requiredEnv] = keyAnswer
377
- logger.log?.(`Chave coletada (mascarada: ${maskKey(keyAnswer)}). Defina export ${requiredEnv}='${maskKey(keyAnswer)}' no seu shell para persistir (não escrevemos .env).`)
409
+ logger.log?.(
410
+ `Chave coletada (mascarada: ${maskKey(keyAnswer)}). Defina export ${requiredEnv}='${maskKey(keyAnswer)}' no seu shell para persistir (não escrevemos .env).`,
411
+ )
378
412
  }
379
413
  }
380
414
 
@@ -398,8 +432,14 @@ export async function runInitWizard({
398
432
  })()
399
433
  const dir = scope === 'global' ? globalDir : projectDir
400
434
  if (!dryRun) {
401
- const result = writeActivePreset(dir, presetName, { dryRun: false, source: 'interactive', logger })
402
- logger.log?.(`Preset ${presetName} salvo em ${scope} (${result.path})${result.backupPath ? ` backup: ${result.backupPath}` : ''}`)
435
+ const result = writeActivePreset(dir, presetName, {
436
+ dryRun: false,
437
+ source: 'interactive',
438
+ logger,
439
+ })
440
+ logger.log?.(
441
+ `Preset ${presetName} salvo em ${scope} (${result.path})${result.backupPath ? ` backup: ${result.backupPath}` : ''}`,
442
+ )
403
443
  // health-check: verify file
404
444
  try {
405
445
  const raw = readFileSync(result.path, 'utf8')
@@ -423,15 +463,18 @@ export async function runInitWizard({
423
463
  const a2 = answers[2]?.answer ?? answers[2]
424
464
  presetName = a0 && typeof a0 === 'object' && 'label' in a0 ? a0.label : a0
425
465
  keyAnswer = a1 && typeof a1 === 'object' && 'answer' in a1 ? a1.answer : a1
426
- if (keyAnswer && typeof keyAnswer === 'object' && 'label' in keyAnswer) keyAnswer = keyAnswer.label
466
+ if (keyAnswer && typeof keyAnswer === 'object' && 'label' in keyAnswer)
467
+ keyAnswer = keyAnswer.label
427
468
  scope = a2 && typeof a2 === 'object' && 'label' in a2 ? a2.label : a2
428
469
  if (scope && typeof scope === 'object' && 'answer' in scope) scope = scope.answer
429
470
  } else if (answers && typeof answers === 'object') {
430
471
  presetName = answers.preset ?? answers['0'] ?? answers.q1 ?? answers.presetName
431
472
  keyAnswer = answers.key ?? answers['1'] ?? answers.q2
432
473
  scope = answers.scope ?? answers['2'] ?? answers.q3
433
- if (presetName && typeof presetName === 'object' && 'label' in presetName) presetName = presetName.label
434
- if (presetName && typeof presetName === 'object' && 'answer' in presetName) presetName = presetName.answer
474
+ if (presetName && typeof presetName === 'object' && 'label' in presetName)
475
+ presetName = presetName.label
476
+ if (presetName && typeof presetName === 'object' && 'answer' in presetName)
477
+ presetName = presetName.answer
435
478
  if (scope && typeof scope === 'object' && 'label' in scope) scope = scope.label
436
479
  if (scope && typeof scope === 'object' && 'answer' in scope) scope = scope.answer
437
480
  }
@@ -459,7 +502,9 @@ export async function runInitWizard({
459
502
  }
460
503
  if (keyAnswer && !dryRun) {
461
504
  env[requiredEnv] = keyAnswer
462
- logger.log?.(`Chave coletada (mascarada: ${maskKey(String(keyAnswer))}). Não escrevemos .env; export ${requiredEnv} no shell para persistir.`)
505
+ logger.log?.(
506
+ `Chave coletada (mascarada: ${maskKey(String(keyAnswer))}). Não escrevemos .env; export ${requiredEnv} no shell para persistir.`,
507
+ )
463
508
  }
464
509
  const finalScope = scope === 'global' ? 'global' : 'project'
465
510
  const projectDir = presetDir ?? target ?? process.cwd()
@@ -470,7 +515,11 @@ export async function runInitWizard({
470
515
  })()
471
516
  const dir = finalScope === 'global' ? globalDir : projectDir
472
517
  if (!dryRun) {
473
- const result = writeActivePreset(dir, presetName, { dryRun: false, source: 'interactive', logger })
518
+ const result = writeActivePreset(dir, presetName, {
519
+ dryRun: false,
520
+ source: 'interactive',
521
+ logger,
522
+ })
474
523
  logger.log?.(`Preset ${presetName} salvo em ${finalScope} (${result.path})`)
475
524
  try {
476
525
  const raw = readFileSync(result.path, 'utf8')
@@ -0,0 +1,47 @@
1
+ /** OpenCode major-version selection shared by the CLI and installer. */
2
+
3
+ const VALID_VERSIONS = new Set(['v1', 'v2', 'auto'])
4
+
5
+ /** @param {string[]} args @returns {'v1'|'v2'|'auto'|null} */
6
+ export function parseOpenCodeVersion(args) {
7
+ for (let index = 0; index < args.length; index += 1) {
8
+ const arg = args[index]
9
+ if (arg.startsWith('--opencode-version=')) return normalize(arg.split('=', 2)[1])
10
+ if (arg === '--opencode-version') return normalize(args[index + 1])
11
+ // --version v1|v2 was supported by 1.4.x. Bare --version remains the
12
+ // package version command and is deliberately not interpreted here.
13
+ if (arg.startsWith('--version=')) return normalize(arg.split('=', 2)[1])
14
+ if (arg === '--version' && args[index + 1]?.startsWith('v')) {
15
+ // Preserve bare --version/package-version compatibility, while making
16
+ // unsupported OpenCode selectors (for example v3) fail loudly.
17
+ return normalize(args[index + 1])
18
+ }
19
+ }
20
+ return null
21
+ }
22
+
23
+ function normalize(value) {
24
+ if (!VALID_VERSIONS.has(value)) {
25
+ throw new Error(`Invalid OpenCode version "${value}" — expected v1, v2, or auto`)
26
+ }
27
+ return value
28
+ }
29
+
30
+ /**
31
+ * Resolve auto conservatively: only an explicit hint selects V2.
32
+ * @param {'v1'|'v2'|'auto'|null|undefined} requested
33
+ * @param {{env?: Record<string, string|undefined>, binary?: string}} [options]
34
+ * @returns {'v1'|'v2'}
35
+ */
36
+ export function resolveOpenCodeVersion(requested = 'v1', options = {}) {
37
+ if (!VALID_VERSIONS.has(requested)) {
38
+ throw new Error(`Invalid OpenCode version "${requested}" — expected v1, v2, or auto`)
39
+ }
40
+ if (requested === 'v1' || requested === 'v2') return requested
41
+ const env = options.env ?? process.env
42
+ if (env.OPENCODE_VERSION === 'v1' || env.OPENCODE_VERSION === 'v2') {
43
+ return env.OPENCODE_VERSION
44
+ }
45
+ const binary = options.binary ?? env.OPENCODE_BIN ?? ''
46
+ return /(?:^|[\\/])opencode2(?:\.exe)?$/i.test(binary) ? 'v2' : 'v1'
47
+ }