pantheon-opencode 1.4.2 → 1.5.0-beta.1

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 (98) hide show
  1. package/.pantheon/code-mode/.gitkeep +0 -0
  2. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +8 -0
  3. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +8 -0
  4. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +8 -0
  5. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +8 -0
  6. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +8 -0
  7. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +8 -0
  8. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +8 -0
  9. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +8 -0
  10. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +8 -0
  11. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +8 -0
  12. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +8 -0
  13. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +8 -0
  14. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +11 -0
  15. package/.pantheon/code-mode/README.md +93 -0
  16. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  17. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  18. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  19. package/.pantheon/code-mode/compress-inline.py +450 -0
  20. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  21. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  22. package/.pantheon/code-mode/eval-run.py +197 -0
  23. package/.pantheon/code-mode/eval-static.py +321 -0
  24. package/.pantheon/code-mode/example-sync.sh +5 -0
  25. package/.pantheon/code-mode/session-end-save.py +186 -0
  26. package/.pantheon/code-mode/session-end-save.sh +18 -0
  27. package/.pantheon/code-mode/test-allowed.sh +5 -0
  28. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  29. package/.pantheon/code-mode/test-env.sh +7 -0
  30. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  31. package/.pantheon/code-mode/test-timeout.sh +6 -0
  32. package/README.md +158 -1189
  33. package/README.pt-BR.md +222 -0
  34. package/bin/pantheon-init.mjs +30 -11
  35. package/opencode.json +1 -0
  36. package/package.json +14 -3
  37. package/plugin.json +15 -0
  38. package/scripts/_pantheon_paths.py +48 -7
  39. package/scripts/ci-validate-yaml.py +7 -2
  40. package/scripts/code_mode_server.py +33 -2
  41. package/scripts/doctor.mjs +135 -127
  42. package/scripts/generate-preset-docs.mjs +127 -66
  43. package/scripts/generate-prompts.sh +2 -2
  44. package/scripts/install/cli-ui.mjs +8 -1
  45. package/scripts/install/config-migration.mjs +401 -0
  46. package/scripts/install/health-check.mjs +3 -3
  47. package/scripts/install/model-picker.mjs +72 -23
  48. package/scripts/install/opencode-version.mjs +47 -0
  49. package/scripts/install/opencode.mjs +261 -88
  50. package/scripts/install/plugin.mjs +112 -42
  51. package/scripts/install/shared.mjs +13 -158
  52. package/scripts/install-mcp.mjs +44 -232
  53. package/scripts/mcp_persistence_server.py +344 -15
  54. package/scripts/mcp_resources_server.py +144 -40
  55. package/scripts/memory_mcp_server.py +2 -2
  56. package/scripts/recover-zenodo-deposition.mjs +146 -0
  57. package/scripts/sync-tui.mjs +32 -7
  58. package/scripts/test-opencode-v1-v2-sandbox.sh +591 -0
  59. package/scripts/uninstall.mjs +298 -954
  60. package/scripts/validate-zenodo-release.mjs +154 -0
  61. package/scripts/zenodo-artifact.mjs +21 -0
  62. package/scripts/zenodo-release-state.mjs +53 -0
  63. package/src/mcp/_pantheon_paths.py +48 -7
  64. package/src/mcp/code_mode_server.py +33 -2
  65. package/src/mcp/eval_store.py +1 -1
  66. package/src/mcp/mcp_persistence_server.py +344 -15
  67. package/src/mcp/mcp_resources_server.py +144 -40
  68. package/src/mcp/pantheon_vision_server.py +1 -1
  69. package/src/mcp/toon_codec.py +491 -0
  70. package/src/pantheon/background-job-board.ts +67 -1
  71. package/src/pantheon/cost-command.ts +6 -2
  72. package/src/pantheon/delegate-manager.ts +384 -0
  73. package/src/pantheon/delegate-task-adapter.ts +444 -0
  74. package/src/pantheon/delegation-notify.ts +35 -20
  75. package/src/pantheon/model-command.ts +86 -32
  76. package/src/pantheon/token-opt.ts +555 -0
  77. package/src/pantheon/toon-codec.ts +481 -0
  78. package/src/pantheon/tui-version-check.ts +1 -3
  79. package/src/pantheon/v2-bridge.ts +129 -0
  80. package/src/pantheon/v2-events.ts +131 -0
  81. package/src/pantheon/v2-hooks.ts +287 -0
  82. package/src/pantheon/v2-tool-definitions.ts +331 -0
  83. package/src/plugin-v2/index.ts +21 -0
  84. package/src/plugin-v2.ts +836 -0
  85. package/src/plugin.ts +57 -10
  86. package/src/plugins/tui/dist/server.d.ts +1 -1
  87. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  88. package/src/plugins/tui/dist/server.js +1 -1
  89. package/src/plugins/tui/dist/server.js.map +1 -1
  90. package/src/plugins/tui/dist/tui.d.ts +10 -7
  91. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui.js +11 -8
  93. package/src/plugins/tui/dist/tui.js.map +1 -1
  94. package/src/plugins/tui/dist/tui.tsx +22 -16
  95. package/src/plugins/tui/package-lock.json +51 -51
  96. package/src/plugins/tui/package.json +7 -7
  97. package/src/plugins/tui/src/index.tsx +22 -16
  98. package/src/skills/README.md +0 -2
@@ -0,0 +1,401 @@
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:')
180
+ ? result.package.slice(6)
181
+ : result.package
182
+ delete result.package
183
+ }
184
+
185
+ // settings.baseURL → api
186
+ if (result.settings?.baseURL !== undefined) {
187
+ result.api = result.settings.baseURL
188
+ const { baseURL, ...restSettings } = result.settings
189
+ if (Object.keys(restSettings).length > 0) {
190
+ result.settings = restSettings
191
+ } else {
192
+ delete result.settings
193
+ }
194
+ }
195
+
196
+ // settings.apiKey → options.apiKey
197
+ if (result.settings?.apiKey !== undefined) {
198
+ if (!result.options) result.options = {}
199
+ result.options.apiKey = result.settings.apiKey
200
+ const { apiKey, ...restSettings } = result.settings || {}
201
+ if (Object.keys(restSettings).length > 0) {
202
+ result.settings = restSettings
203
+ } else {
204
+ delete result.settings
205
+ }
206
+ }
207
+
208
+ // Model key with #suffix → variant field
209
+ // Also rename model-level "media" → "attachment"
210
+ if (result.models) {
211
+ const newModels = {}
212
+ for (const [modelKey, modelConfig] of Object.entries(result.models)) {
213
+ const { media, ...restConfig } = modelConfig || {}
214
+ const finalConfig = { ...restConfig }
215
+ if (media !== undefined) {
216
+ finalConfig.attachment = media
217
+ }
218
+ if (modelKey.includes('#')) {
219
+ const [baseName, variant] = modelKey.split('#', 2)
220
+ newModels[baseName] = { ...finalConfig, variant }
221
+ } else {
222
+ newModels[modelKey] = finalConfig
223
+ }
224
+ }
225
+ result.models = newModels
226
+ }
227
+
228
+ return result
229
+ }
230
+
231
+ /**
232
+ * Convert V1 MCP config to V2.
233
+ *
234
+ * V1: { bifrost: { type: "remote", url: "...", enabled: true, timeout: 30000 } }
235
+ * V2 installer output: { bifrost: { type: "remote", url: "...", enabled: true } }
236
+ *
237
+ * OpenCode 1.18.18 does not have an `mcp.servers` wrapper. It interprets
238
+ * `servers` as the name of an MCP server and then rejects it with
239
+ * `Missing key mcp.servers.enabled`. Keep MCP in the documented shape and
240
+ * normalize old generated V2 entries back to it.
241
+ */
242
+ function convertMcpV1toV2(mcpConfig) {
243
+ if (!mcpConfig || typeof mcpConfig !== 'object') return mcpConfig
244
+
245
+ const servers = {}
246
+ const source =
247
+ mcpConfig.servers && typeof mcpConfig.servers === 'object' && !Array.isArray(mcpConfig.servers)
248
+ ? { ...mcpConfig, ...mcpConfig.servers }
249
+ : mcpConfig
250
+
251
+ for (const [name, serverConfig] of Object.entries(source)) {
252
+ if (name === 'servers') continue
253
+ // Installer versions that seeded the legacy wrapper also wrote a boolean
254
+ // feature toggle (`servers: { enabled: true }`). After unwrapping, that
255
+ // toggle surfaces as a non-object entry and is not a server; drop it.
256
+ if (serverConfig === null || typeof serverConfig !== 'object') continue
257
+ const srv = { ...serverConfig }
258
+
259
+ // Normalize legacy V2's disabled flag to OpenCode 1.18.x's enabled flag.
260
+ if ('disabled' in srv) {
261
+ srv.enabled = !srv.disabled
262
+ delete srv.disabled
263
+ }
264
+
265
+ // Normalize legacy V2's split timeout to the V1/V2-compatible number.
266
+ if (srv.timeout !== undefined && srv.timeout !== null) {
267
+ if (typeof srv.timeout === 'object') {
268
+ srv.timeout = srv.timeout.execution || srv.timeout.catalog || 30000
269
+ }
270
+ }
271
+
272
+ // A shorthand inherited-server override is valid only when named directly.
273
+ // Runtime MCP entries always have a complete config, so leave other values
274
+ // untouched and avoid dropping user-owned servers.
275
+ servers[name] = srv
276
+ }
277
+
278
+ return servers
279
+ }
280
+
281
+ /**
282
+ * Convert V2 MCP config back to V1.
283
+ */
284
+ function convertMcpV2toV1(mcpConfig) {
285
+ if (!mcpConfig || typeof mcpConfig !== 'object') return mcpConfig
286
+
287
+ return convertMcpV1toV2(mcpConfig)
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Top-level key mappings
292
+ // ---------------------------------------------------------------------------
293
+
294
+ const V1_TO_V2_RENAMES = {
295
+ provider: 'providers',
296
+ agent: 'agents',
297
+ command: 'commands',
298
+ reference: 'references',
299
+ snapshot: 'snapshots',
300
+ attachment: 'media',
301
+ permission: 'permissions',
302
+ }
303
+
304
+ /** Build reverse map */
305
+ const V2_TO_V1_RENAMES = Object.fromEntries(
306
+ Object.entries(V1_TO_V2_RENAMES).map(([k, v]) => [v, k])
307
+ )
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Public API
311
+ // ---------------------------------------------------------------------------
312
+
313
+ /**
314
+ * Migrate a V1 opencode.json config to V2 format.
315
+ *
316
+ * @param {object} config - V1 config object (not mutated)
317
+ * @returns {object} V2 config object
318
+ */
319
+ export function migrateV1toV2(config) {
320
+ const c = deepClone(config)
321
+ const result = {}
322
+
323
+ for (const [key, value] of Object.entries(c)) {
324
+ const v2Key = V1_TO_V2_RENAMES[key] || key
325
+
326
+ if (key === 'permission' && typeof value === 'object' && value !== null) {
327
+ result.permissions = convertPermissionsV1toV2(value)
328
+ } else if (key === 'agent' && typeof value === 'object' && value !== null) {
329
+ const agents = {}
330
+ for (const [agentName, agentConfig] of Object.entries(value)) {
331
+ const ac = { ...agentConfig }
332
+ if (ac.permission && typeof ac.permission === 'object') {
333
+ ac.permissions = convertPermissionsV1toV2(ac.permission)
334
+ delete ac.permission
335
+ }
336
+ agents[agentName] = ac
337
+ }
338
+ result.agents = agents
339
+ } else if (key === 'provider' && typeof value === 'object' && value !== null) {
340
+ const providers = {}
341
+ for (const [provName, provConfig] of Object.entries(value)) {
342
+ const renamed = PROVIDER_RENAME[provName] || provName
343
+ providers[renamed] = convertProviderV1toV2(provName, provConfig)
344
+ }
345
+ result.providers = providers
346
+ } else if (key === 'mcp' && typeof value === 'object' && value !== null) {
347
+ result.mcp = convertMcpV1toV2(value)
348
+ } else if (v2Key !== key) {
349
+ result[v2Key] = value
350
+ } else {
351
+ result[key] = value
352
+ }
353
+ }
354
+
355
+ return result
356
+ }
357
+
358
+ /**
359
+ * Migrate a V2 opencode.json config back to V1 format.
360
+ *
361
+ * @param {object} config - V2 config object (not mutated)
362
+ * @returns {object} V1 config object
363
+ */
364
+ export function migrateV2toV1(config) {
365
+ const c = deepClone(config)
366
+ const result = {}
367
+
368
+ for (const [key, value] of Object.entries(c)) {
369
+ const v1Key = V2_TO_V1_RENAMES[key] || key
370
+
371
+ if (key === 'permissions' && Array.isArray(value)) {
372
+ result.permission = convertPermissionsV2toV1(value)
373
+ } else if (key === 'agents' && typeof value === 'object' && value !== null) {
374
+ const agents = {}
375
+ for (const [agentName, agentConfig] of Object.entries(value)) {
376
+ const ac = { ...agentConfig }
377
+ if (Array.isArray(ac.permissions)) {
378
+ ac.permission = convertPermissionsV2toV1(ac.permissions)
379
+ delete ac.permissions
380
+ }
381
+ agents[agentName] = ac
382
+ }
383
+ result.agent = agents
384
+ } else if (key === 'providers' && typeof value === 'object' && value !== null) {
385
+ const providers = {}
386
+ for (const [provName, provConfig] of Object.entries(value)) {
387
+ const renamed = PROVIDER_RENAME_REVERSE[provName] || provName
388
+ providers[renamed] = convertProviderV2toV1(provName, provConfig)
389
+ }
390
+ result.provider = providers
391
+ } else if (key === 'mcp' && typeof value === 'object' && value !== null) {
392
+ result.mcp = convertMcpV2toV1(value)
393
+ } else if (v1Key !== key) {
394
+ result[v1Key] = value
395
+ } else {
396
+ result[key] = value
397
+ }
398
+ }
399
+
400
+ return result
401
+ }
@@ -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
+ }