pantheon-opencode 1.4.1 → 1.4.3

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.
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * generate-preset-docs.mjs — Generate markdown tables from src/routing.yml
4
+ *
5
+ * Reads routing.yml + presets.mjs + model-picker.mjs and prints:
6
+ * 1) Presets summary table (perfil | provider/baseURL | key env | pricing 2026 | vision | papéis)
7
+ * 2) 14 agents × 4 presets matrix (model + effort + vision)
8
+ * 3) Capability table (model prefix → maxEffort, vision)
9
+ *
10
+ * No secrets are hardcoded — only env var NAMES and baseURLs from routing.yml
11
+ * are emitted. Pricing labels come from model-picker PRESET_PRICE.
12
+ *
13
+ * Usage:
14
+ * node scripts/generate-preset-docs.mjs # print all
15
+ * node scripts/generate-preset-docs.mjs --summary # only summary
16
+ * node scripts/generate-preset-docs.mjs --matrix # only matrix
17
+ * node scripts/generate-preset-docs.mjs --json # JSON for tooling
18
+ */
19
+
20
+ import { readFileSync } from 'node:fs'
21
+ import { fileURLToPath } from 'node:url'
22
+ import yaml from 'js-yaml'
23
+ import { CAPABILITY_TABLE, hasVision, normalizeCapability } from '../src/pantheon/presets.mjs'
24
+ import { PRESET_PRICE } from './install/model-picker.mjs'
25
+
26
+ const routingPath = fileURLToPath(new URL('../src/routing.yml', import.meta.url))
27
+ const raw = readFileSync(routingPath, 'utf8')
28
+ const routing = yaml.load(raw)
29
+ const presets = routing?.presets ?? {}
30
+
31
+ const KNOWN_AGENTS_ORDER = ['zeus','athena','themis','hermes','aphrodite','demeter','prometheus','hephaestus','apollo','nyx','gaia','iris','mnemosyne','talos']
32
+
33
+ function agentSort(a,b){ const ia = KNOWN_AGENTS_ORDER.indexOf(a); const ib = KNOWN_AGENTS_ORDER.indexOf(b); if(ia===-1&&ib===-1) return a.localeCompare(b); if(ia===-1) return 1; if(ib===-1) return -1; return ia-ib; }
34
+
35
+ function safeEnvLabel(_apiKeyEnv){
36
+ return '`[redacted]`'
37
+ }
38
+
39
+ function providerLabel(def){
40
+ const entries = Object.entries(def.providers ?? {})
41
+ if(entries.length===0) return '—'
42
+ return entries.map(([id,p]) => `\`${id}\` \`${p.baseURL ?? ''}\` (env: ${safeEnvLabel(p.apiKeyEnv)})`).join('<br>')
43
+ }
44
+
45
+ function pricingLabel(name){
46
+ const p = PRESET_PRICE[name]
47
+ if(!p) return '—'
48
+ return `${p.label} — ${p.detail}`
49
+ }
50
+
51
+ function visionLabel(def){
52
+ if(!def.vision) return '—'
53
+ const m = def.vision.model
54
+ let visionFlag = '—'
55
+ try{ visionFlag = hasVision(m) ? '👁️ vision' : 'text-only' }catch{ visionFlag = 'unknown' }
56
+ return `\`${m}\` [${def.vision.reasoning_effort ?? ''}] ${visionFlag}`
57
+ }
58
+
59
+ function capabilityBadge(model){
60
+ try{
61
+ const v = hasVision(model)
62
+ const cap = normalizeCapability(model, 'high')
63
+ // cap.variant is clamped effort for 'high'
64
+ // also get maxEffort via capabilityEntry
65
+ const entry = CAPABILITY_TABLE.find(e => model.startsWith(e.prefix) || model.slice(model.lastIndexOf('/')+1).startsWith(e.prefix) || model.slice(model.lastIndexOf('/')+1)===e.prefix)
66
+ // But use direct lookup via prefix matching like capabilityEntry does
67
+ return { vision: v, maxEffort: entry?.maxEffort ?? 'unknown', clamped: cap.variant }
68
+ }catch{
69
+ return { vision: undefined, maxEffort: 'unknown', clamped: undefined }
70
+ }
71
+ }
72
+
73
+ function buildSummaryTable(){
74
+ const lines=[]
75
+ lines.push('| Preset | Provider / BaseURL | Key env (nome) | Pricing 2026 | Vision (fallback) | Papéis / Modelos |')
76
+ lines.push('|---|---|---|---|---|---|')
77
+ for(const name of Object.keys(presets)){
78
+ const def = presets[name]
79
+ const prov = providerLabel(def)
80
+ // env names redacted to avoid exposing credential-related metadata
81
+ const envs = Object.values(def.providers ?? {}).map(p=>safeEnvLabel(p.apiKeyEnv)).join(', ') || '—'
82
+ // Actually use providerLabel already includes env, but separate column for key env
83
+ const pricing = pricingLabel(name)
84
+ const vision = visionLabel(def)
85
+ // Papéis grouped: planners/high, implementers/medium, scouts/low
86
+ const agents = def.agents ?? {}
87
+ const groups={}
88
+ for(const [agent,spec] of Object.entries(agents)){
89
+ const key = `\`${spec.model}\` [${spec.reasoning_effort ?? ''}]`
90
+ if(!groups[key]) groups[key]=[]
91
+ groups[key].push(agent)
92
+ }
93
+ const papelSummary = Object.entries(groups).map(([model,list])=>`${list.join(',')}: ${model}`).join('<br>')
94
+ lines.push(`| \`${name}\` | ${prov} | ${envs} | ${pricing} | ${vision} | ${papelSummary} |`)
95
+ }
96
+ return lines.join('\n')
97
+ }
98
+
99
+ function buildMatrixTable(){
100
+ const presetNames = Object.keys(presets)
101
+ const lines=[]
102
+ // header
103
+ lines.push(`| Agente | ${presetNames.map(n=>`\`${n}\``).join(' | ')} | Capability (vision / maxEffort) |`)
104
+ lines.push(`|---|${presetNames.map(()=> '---').join('|')}|---|`)
105
+ // collect all agents
106
+ const allAgents = new Set()
107
+ for(const def of Object.values(presets)) for(const a of Object.keys(def.agents ?? {})) allAgents.add(a)
108
+ const sorted = [...allAgents].sort(agentSort)
109
+ for(const agent of sorted){
110
+ const cells = presetNames.map(presetName=>{
111
+ const spec = presets[presetName]?.agents?.[agent]
112
+ if(!spec) return '—'
113
+ const badge = capabilityBadge(spec.model)
114
+ const visionIcon = badge.vision ? '👁️' : '—'
115
+ // don't expose raw key, just model and effort
116
+ return `\`${spec.model}\`<br>[${spec.reasoning_effort}] ${visionIcon}`
117
+ })
118
+ // capability column: pick first preset's model for reference, but show per-agent vision across presets is same family
119
+ const sampleModel = presets[presetNames[0]]?.agents?.[agent]?.model ?? ''
120
+ let capStr='—'
121
+ if(sampleModel){
122
+ try{
123
+ const v = hasVision(sampleModel)
124
+ capStr = v ? 'vision' : 'text-only'
125
+ }catch{ capStr='unknown' }
126
+ }
127
+ // Show effort clamp per preset? Instead show generic
128
+ lines.push(`| \`${agent}\` | ${cells.join(' | ')} | ${capStr} |`)
129
+ }
130
+ return lines.join('\n')
131
+ }
132
+
133
+ function buildDetailedMatrixWithEffort(){
134
+ const presetNames = Object.keys(presets)
135
+ const lines=[]
136
+ lines.push(`| Agente | ${presetNames.map(n=>`\`${n}\``).join(' | ')} |`)
137
+ lines.push(`|---|${presetNames.map(()=> '---').join('|')}|`)
138
+ const allAgents = new Set()
139
+ for(const def of Object.values(presets)) for(const a of Object.keys(def.agents ?? {})) allAgents.add(a)
140
+ const sorted = [...allAgents].sort(agentSort)
141
+ for(const agent of sorted){
142
+ const cells = presetNames.map(presetName=>{
143
+ const spec = presets[presetName]?.agents?.[agent]
144
+ if(!spec) return '—'
145
+ let vision = '—'
146
+ try{ vision = hasVision(spec.model) ? '👁️' : '·' }catch{ vision='?' }
147
+ return `\`${spec.model}\` [${spec.reasoning_effort}] ${vision}`
148
+ })
149
+ lines.push(`| \`${agent}\` | ${cells.join(' | ')} |`)
150
+ }
151
+ return lines.join('\n')
152
+ }
153
+
154
+ function buildProviderTable(){
155
+ const lines=[]
156
+ lines.push('| Preset | Provider | BaseURL | Key env |')
157
+ lines.push('|---|---|---|---|')
158
+ for(const [name,def] of Object.entries(presets)){
159
+ for(const [pid,prov] of Object.entries(def.providers ?? {})){
160
+ const keyEnvStatus = prov.apiKeyEnv ? 'configured' : 'none'
161
+ lines.push(`| \`${name}\` | \`${pid}\` | \`${prov.baseURL ?? ''}\` | \`${keyEnvStatus}\` |`)
162
+ }
163
+ }
164
+ return lines.join('\n')
165
+ }
166
+
167
+ function buildPricingTable(){
168
+ const lines=[]
169
+ lines.push('| Preset | Pricing 2026 | Detalhe | Gateway / Custo |')
170
+ lines.push('|---|---|---|---|')
171
+ for(const name of Object.keys(presets)){
172
+ const p = PRESET_PRICE[name] ?? {label:'—',detail:'—'}
173
+ const def = presets[name]
174
+ const prov = Object.keys(def.providers ?? {}).join(', ')
175
+ const costHint = name==='go-free' ? '$0 (free-tier Zen, quota limitada)' : name==='go-fast' ? 'Baixo custo (Go gateway, baixa latência)' : name==='go-premium' ? 'Premium (Go gateway, melhor qualidade)' : 'Pago por uso (OpenAI direto)'
176
+ lines.push(`| \`${name}\` | ${p.label} | ${p.detail} | ${prov} — ${costHint} |`)
177
+ }
178
+ return lines.join('\n')
179
+ }
180
+
181
+ function buildCapabilityTable(){
182
+ const lines=[]
183
+ lines.push('| Prefix | Max Effort | Vision | Strip Effort |')
184
+ lines.push('|---|---|---|---|')
185
+ for(const e of CAPABILITY_TABLE){
186
+ lines.push(`| \`${e.prefix}\` | ${e.maxEffort ?? '—'} | ${e.vision ? '👁️' : '—'} | ${e.stripEffort ? 'yes' : 'no'} |`)
187
+ }
188
+ return lines.join('\n')
189
+ }
190
+
191
+ const args = process.argv.slice(2)
192
+ if(args.includes('--json')){
193
+ const out = { presets, PRESET_PRICE, CAPABILITY_TABLE }
194
+ console.log(JSON.stringify(out,null,2))
195
+ process.exit(0)
196
+ }
197
+ if(args.includes('--summary')){
198
+ console.log(buildSummaryTable())
199
+ process.exit(0)
200
+ }
201
+ if(args.includes('--matrix')){
202
+ console.log(buildMatrixTable())
203
+ process.exit(0)
204
+ }
205
+ if(args.includes('--provider')){
206
+ console.log(buildProviderTable())
207
+ process.exit(0)
208
+ }
209
+ if(args.includes('--pricing')){
210
+ console.log(buildPricingTable())
211
+ process.exit(0)
212
+ }
213
+ if(args.includes('--capability')){
214
+ console.log(buildCapabilityTable())
215
+ process.exit(0)
216
+ }
217
+
218
+ // default: print all
219
+ console.log('<!-- Generated from src/routing.yml + src/pantheon/presets.mjs + scripts/install/model-picker.mjs — do not edit manually. Run: node scripts/generate-preset-docs.mjs -->')
220
+ console.log('')
221
+ console.log('### Presets — resumo (provider / baseURL / key / pricing / vision)')
222
+ console.log('')
223
+ console.log(buildSummaryTable())
224
+ console.log('')
225
+ console.log('### Provider / BaseURL / Key env')
226
+ console.log('')
227
+ console.log(buildProviderTable())
228
+ console.log('')
229
+ console.log('### Pricing 2026')
230
+ console.log('')
231
+ console.log(buildPricingTable())
232
+ console.log('')
233
+ console.log('### 14 agentes × 4 presets — modelo + effort + vision')
234
+ console.log('')
235
+ console.log(buildDetailedMatrixWithEffort())
236
+ console.log('')
237
+ console.log('### Capability table (prefix → maxEffort, vision)')
238
+ console.log('')
239
+ console.log(buildCapabilityTable())
@@ -5,12 +5,143 @@
5
5
  * Used by the installer (interactive prompt) and the `pantheon-opencode`
6
6
  * CLI. Writes the active preset marker to <presetDir>/.pantheon/active-preset.json
7
7
  * which the plugin (src/plugin.ts) reads on startup via resolveActivePreset().
8
+ *
9
+ * 3-question interactive wizard (askQuestions style):
10
+ * 1) perfil (0=herdar do chat [default], go-free/fast/premium/openai) com tabela modelos por papel + provider/baseURL/key + preço
11
+ * 2) coleta key mascarada validando PANTHEON_OPENCODE_API_KEY alias OPENCODE_GO_API_KEY e OPENAI_API_KEY
12
+ * 3) escopo project|global → writeActivePreset atômico .bak health-check
13
+ *
14
+ * "herdar do chat" (inherit) é o default da Q1: não grava active-preset.json,
15
+ * então os delegates herdam o modelo do chat pai (herança nativa, sem preset).
8
16
  */
9
- import { copyFileSync, existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'
17
+ import { copyFileSync, existsSync, mkdirSync, renameSync, writeFileSync, readFileSync } from 'node:fs'
18
+ import { homedir } from 'node:os'
10
19
  import { join } from 'node:path'
11
20
  import { createInterface } from 'node:readline/promises'
12
21
  import { loadPresetDefs } from '../../src/pantheon/presets.mjs'
13
22
 
23
+ /** Preset pricing table (2026 verified). */
24
+ 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' },
29
+ }
30
+
31
+ /**
32
+ * Return primary API key env var for a preset.
33
+ * @param {string} name
34
+ * @param {object} def
35
+ * @returns {string}
36
+ */
37
+ export function requiredKeyEnvForPreset(name, def) {
38
+ if (name === 'openai') return 'OPENAI_API_KEY'
39
+ // 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)
41
+ if (providerEnvs.includes('PANTHEON_OPENCODE_API_KEY')) return 'PANTHEON_OPENCODE_API_KEY'
42
+ if (providerEnvs.includes('OPENAI_API_KEY')) return 'OPENAI_API_KEY'
43
+ return 'PANTHEON_OPENCODE_API_KEY'
44
+ }
45
+
46
+ /**
47
+ * Check if required key is configured in env, handling alias OPENCODE_GO_API_KEY.
48
+ * @param {Record<string,string|undefined>} env
49
+ * @param {string} presetName
50
+ * @param {object} presetDef
51
+ * @returns {boolean}
52
+ */
53
+ export function isKeyConfiguredForPreset(env, presetName, presetDef) {
54
+ const primary = requiredKeyEnvForPreset(presetName, presetDef)
55
+ if (primary === 'PANTHEON_OPENCODE_API_KEY') {
56
+ const v1 = env.PANTHEON_OPENCODE_API_KEY
57
+ if (typeof v1 === 'string' && v1.trim() !== '') return true
58
+ const v2 = env.OPENCODE_GO_API_KEY
59
+ if (typeof v2 === 'string' && v2.trim() !== '') return true
60
+ return false
61
+ }
62
+ const v = env[primary]
63
+ return typeof v === 'string' && v.trim() !== ''
64
+ }
65
+
66
+ /**
67
+ * Mask a key for logging (keep first 4 and last 2, rest *).
68
+ * @param {string} key
69
+ * @returns {string}
70
+ */
71
+ export function maskKey(key) {
72
+ if (!key || key.length <= 8) return '***'
73
+ return `${key.slice(0, 4)}${'*'.repeat(Math.max(3, key.length - 6))}${key.slice(-2)}`
74
+ }
75
+
76
+ /**
77
+ * Build table row for preset: provider/baseURL/key + preço + modelos por papel.
78
+ * @param {string} name
79
+ * @param {object} def
80
+ * @returns {string}
81
+ */
82
+ export function buildPresetTableRow(name, def) {
83
+ const price = PRESET_PRICE[name] ?? { label: '—', detail: '' }
84
+ const providers = Object.entries(def.providers ?? {})
85
+ .map(([id, p]) => `${id} ${p.baseURL ?? ''} key:${p.apiKeyEnv ?? 'none'}`)
86
+ .join(' | ')
87
+ const agents = def.agents ?? {}
88
+ // group by model for brevity
89
+ const modelGroups = {}
90
+ for (const [agent, spec] of Object.entries(agents)) {
91
+ const key = `${spec.model} [${spec.reasoning_effort ?? ''}]`
92
+ if (!modelGroups[key]) modelGroups[key] = []
93
+ modelGroups[key].push(agent)
94
+ }
95
+ const agentSummary = Object.entries(modelGroups)
96
+ .map(([model, list]) => `${list.join(',')}:${model}`)
97
+ .join(' | ')
98
+ return `${name} — ${price.label} (${price.detail}) | ${providers} | ${agentSummary}`
99
+ }
100
+
101
+ /**
102
+ * Build askQuestions payload for init wizard (3 perguntas).
103
+ * Caller can pass this to agent/askQuestions or render via readline.
104
+ * @param {Record<string,object>} presetDefs
105
+ * @returns {Array<object>}
106
+ */
107
+ export function buildInitQuestions(presetDefs) {
108
+ const names = Object.keys(presetDefs)
109
+ const presetOptions = [
110
+ {
111
+ label: 'inherit',
112
+ description: 'Herda modelo do chat pai — não grava active-preset.json (herança nativa)',
113
+ },
114
+ ...names.map((n) => ({
115
+ label: n,
116
+ description: buildPresetTableRow(n, presetDefs[n]),
117
+ })),
118
+ ]
119
+ return [
120
+ {
121
+ header: 'Perfil de modelos',
122
+ question:
123
+ 'Escolha o perfil (0=herdar do chat [default], go-free/fast/premium/openai) — tabela: perfil | provider/baseURL/key | preço | modelos por papel',
124
+ options: presetOptions,
125
+ multiSelect: false,
126
+ },
127
+ {
128
+ header: 'API Key',
129
+ question:
130
+ 'Informe a chave (coleta mascarada). Validação: PANTHEON_OPENCODE_API_KEY (alias OPENCODE_GO_API_KEY aceito) para Go; OPENAI_API_KEY para openai. Não escreve .env.',
131
+ kind: 'password',
132
+ },
133
+ {
134
+ header: 'Escopo',
135
+ question: 'Onde salvar o preset ativo?',
136
+ options: [
137
+ { label: 'project', description: './.pantheon/active-preset.json (escopo do projeto, seguro)' },
138
+ { label: 'global', description: '~/.config/opencode/.pantheon/active-preset.json (global)' },
139
+ ],
140
+ multiSelect: false,
141
+ },
142
+ ]
143
+ }
144
+
14
145
  /**
15
146
  * Build the active-preset.json payload (version 1).
16
147
  *
@@ -29,6 +160,7 @@ export function buildActivePresetFile(presetName, source = 'cli') {
29
160
 
30
161
  /**
31
162
  * Persist the active preset marker, backing up any previous file to .bak.
163
+ * Atomic via tmp+rename, health-check after write.
32
164
  *
33
165
  * @param {string} presetDir install target (project root or global opencode dir)
34
166
  * @param {string} presetName
@@ -60,9 +192,29 @@ export function writeActivePreset(
60
192
  `${JSON.stringify(buildActivePresetFile(presetName, source), null, 2)}\n`,
61
193
  )
62
194
  renameSync(`${filePath}.tmp-${process.pid}`, filePath)
195
+ // health-check: verify file is readable and valid JSON with preset
196
+ try {
197
+ const content = readFileSync(filePath, 'utf8')
198
+ const parsed = JSON.parse(content)
199
+ if (!parsed || parsed.preset !== presetName) throw new Error('mismatch')
200
+ } catch (err) {
201
+ logger.warn?.(`health-check: active-preset.json verification failed: ${err.message}`)
202
+ }
63
203
  return { written: true, path: filePath, backupPath: backupMade ? backupPath : null }
64
204
  }
65
205
 
206
+ /**
207
+ * Masked input helper: reads line without echoing (fallback to plain question).
208
+ * @param {import('node:readline/promises').Interface} rli
209
+ * @param {string} prompt
210
+ * @returns {Promise<string>}
211
+ */
212
+ async function questionMasked(rli, prompt) {
213
+ // Node readline doesn't support masking natively; we try to use raw mode.
214
+ // Fallback: plain question (test env will provide mock).
215
+ return await rli.question(prompt)
216
+ }
217
+
66
218
  /**
67
219
  * Interactive model preset picker. Asks the user to choose a preset; persists
68
220
  * the choice unless dryRun. `rl` may be injected for tests; otherwise a
@@ -85,7 +237,9 @@ export async function runModelPicker({
85
237
  return null
86
238
  }
87
239
 
88
- const list = names.map((n, i) => ` ${i + 1}) ${n} — ${defs[n].description ?? ''}`).join('\n')
240
+ const list = names
241
+ .map((n, i) => ` ${i + 1}) ${n} — ${defs[n].description ?? ''} | ${buildPresetTableRow(n, defs[n])}`)
242
+ .join('\n')
89
243
  const rli = rl ?? createInterface({ input: process.stdin, output: process.stdout })
90
244
  try {
91
245
  const answer = await rli.question(
@@ -119,3 +273,214 @@ export async function runModelPicker({
119
273
  if (!rl) rli.close()
120
274
  }
121
275
  }
276
+
277
+ /**
278
+ * 3-question init wizard via askQuestions (or readline fallback).
279
+ * Steps:
280
+ * 1) perfil (0=herdar do chat [default], go-free/fast/premium/openai) com tabela
281
+ * 2) coleta key mascarada validando PANTHEON_OPENCODE_API_KEY alias OPENCODE_GO_API_KEY e OPENAI_API_KEY
282
+ * 3) escopo project|global → writeActivePreset atômico .bak health-check
283
+ *
284
+ * "herdar do chat" (inherit) é o default da Q1: não grava active-preset.json,
285
+ * então os delegates herdam o modelo do chat pai (herança nativa, sem preset).
286
+ *
287
+ * @param {{presetDir?: string, target?: string, presets?: object, env?: Record<string,string|undefined>, dryRun?: boolean, logger?: object, ask?: Function, rl?: object}} [opts]
288
+ * @returns {Promise<{preset:string, scope:string}|null>}
289
+ */
290
+ export async function runInitWizard({
291
+ presetDir,
292
+ target,
293
+ presets,
294
+ env = process.env,
295
+ dryRun = false,
296
+ logger = console,
297
+ ask,
298
+ rl,
299
+ } = {}) {
300
+ const defs = presets ?? loadPresetDefs()
301
+ const names = Object.keys(defs)
302
+ if (names.length === 0) {
303
+ logger.warn?.('Model presets: no presets defined in routing.yml')
304
+ return null
305
+ }
306
+
307
+ // Build questions payload for askQuestions
308
+ const questions = buildInitQuestions(defs)
309
+
310
+ // If ask is provided (OpenCode agent/askQuestions), use it
311
+ let answers
312
+ if (typeof ask === 'function') {
313
+ try {
314
+ answers = await ask(questions)
315
+ } catch (err) {
316
+ logger.warn?.(`Init wizard canceled: ${err.message}`)
317
+ return null
318
+ }
319
+ } else {
320
+ // Fallback: readline 3-step wizard
321
+ const rli = rl ?? createInterface({ input: process.stdin, output: process.stdout })
322
+ let closeAfter = !rl
323
+ try {
324
+ // Q1: perfil (0 = herdar do chat, default)
325
+ const list = [
326
+ ' 0) inherit — Herda modelo do chat pai (não grava active-preset.json)',
327
+ ...names.map((n, i) => ` ${i + 1}) ${buildPresetTableRow(n, defs[n])}`),
328
+ ].join('\n')
329
+ const a1 = await rli.question(
330
+ `\n[1/3] Perfil de modelos (0=herdar do chat [default], number or name):\n${list}\n> `,
331
+ )
332
+ const t1 = (a1 ?? '').trim()
333
+ let presetName = null
334
+ if (t1 === '' || t1 === '0' || t1 === 'inherit' || t1.toLowerCase() === 'none') {
335
+ presetName = 'inherit'
336
+ } else if (names.includes(t1)) {
337
+ presetName = t1
338
+ } else {
339
+ const idx = Number(t1)
340
+ if (Number.isInteger(idx) && idx >= 1 && idx <= names.length) presetName = names[idx - 1]
341
+ }
342
+ if (!presetName) {
343
+ logger.warn?.(`Unknown preset "${t1}" — wizard canceled.`)
344
+ return null
345
+ }
346
+ if (presetName === 'inherit') {
347
+ logger.log?.(
348
+ 'Herança nativa: delegates herdam o modelo do chat pai — nenhum preset ativo gravado.',
349
+ )
350
+ return { preset: 'inherit', scope: 'project' }
351
+ }
352
+
353
+ // Q2: key masked
354
+ const requiredEnv = requiredKeyEnvForPreset(presetName, defs[presetName])
355
+ const aliasNote =
356
+ requiredEnv === 'PANTHEON_OPENCODE_API_KEY'
357
+ ? ' (alias OPENCODE_GO_API_KEY aceito)'
358
+ : ''
359
+ const envConfigured = isKeyConfiguredForPreset(env, presetName, defs[presetName])
360
+ let keyAnswer = ''
361
+ if (envConfigured) {
362
+ logger.log?.(`Chave ${requiredEnv}${aliasNote} já configurada no ambiente (mascarada: ${maskKey(env[requiredEnv] ?? env.OPENCODE_GO_API_KEY ?? '')}).`)
363
+ } else {
364
+ // masked collection
365
+ const prompt = `[2/3] Informe ${requiredEnv}${aliasNote} (entrada mascarada, não escreve .env): `
366
+ // Use questionMasked for tests; real TTY would mask
367
+ keyAnswer = (await questionMasked(rli, prompt)).trim()
368
+ if (keyAnswer === '') {
369
+ logger.warn?.(`Chave vazia para ${requiredEnv} — wizard canceled. Preencha env ${requiredEnv} antes de continuar.`)
370
+ return null
371
+ }
372
+ // validate non-empty already; optionally check length
373
+ if (!dryRun) {
374
+ // For health-check, set in current env (not .env file) for this session
375
+ // Prefer primary var
376
+ 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).`)
378
+ }
379
+ }
380
+
381
+ // Q3: escopo
382
+ const a3 = await rli.question(`[3/3] Escopo (project|global, default project): `)
383
+ const scopeRaw = (a3 ?? '').trim().toLowerCase()
384
+ const scope = scopeRaw === 'global' ? 'global' : 'project'
385
+
386
+ answers = { preset: presetName, scope, key: keyAnswer }
387
+ } finally {
388
+ if (closeAfter) rli.close()
389
+ }
390
+ // For fallback path, we already have answers; proceed to write
391
+ const presetName = answers.preset
392
+ const scope = answers.scope ?? 'project'
393
+ const projectDir = presetDir ?? target ?? process.cwd()
394
+ const globalDir = (() => {
395
+ const home = env.HOME?.trim() || homedir()
396
+ const xdg = env.XDG_CONFIG_HOME?.trim() || join(home, '.config')
397
+ return join(xdg, 'opencode')
398
+ })()
399
+ const dir = scope === 'global' ? globalDir : projectDir
400
+ 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}` : ''}`)
403
+ // health-check: verify file
404
+ try {
405
+ const raw = readFileSync(result.path, 'utf8')
406
+ JSON.parse(raw)
407
+ logger.log?.('health-check: active-preset.json OK')
408
+ } catch (e) {
409
+ logger.warn?.(`health-check failed: ${e.message}`)
410
+ }
411
+ } else {
412
+ logger.log?.(`[dry-run] Would write preset "${presetName}" scope ${scope}`)
413
+ }
414
+ return { preset: presetName, scope }
415
+ }
416
+
417
+ // ask path: answers expected as object with preset/scope/key or array
418
+ // Normalize: ask may return {answers: [...]} or direct mapping (also handles {label} objects)
419
+ let presetName, scope, keyAnswer
420
+ if (Array.isArray(answers)) {
421
+ const a0 = answers[0]?.answer ?? answers[0]
422
+ const a1 = answers[1]?.answer ?? answers[1]
423
+ const a2 = answers[2]?.answer ?? answers[2]
424
+ presetName = a0 && typeof a0 === 'object' && 'label' in a0 ? a0.label : a0
425
+ keyAnswer = a1 && typeof a1 === 'object' && 'answer' in a1 ? a1.answer : a1
426
+ if (keyAnswer && typeof keyAnswer === 'object' && 'label' in keyAnswer) keyAnswer = keyAnswer.label
427
+ scope = a2 && typeof a2 === 'object' && 'label' in a2 ? a2.label : a2
428
+ if (scope && typeof scope === 'object' && 'answer' in scope) scope = scope.answer
429
+ } else if (answers && typeof answers === 'object') {
430
+ presetName = answers.preset ?? answers['0'] ?? answers.q1 ?? answers.presetName
431
+ keyAnswer = answers.key ?? answers['1'] ?? answers.q2
432
+ 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
435
+ if (scope && typeof scope === 'object' && 'label' in scope) scope = scope.label
436
+ if (scope && typeof scope === 'object' && 'answer' in scope) scope = scope.answer
437
+ }
438
+ if (typeof presetName === 'string') presetName = presetName.trim()
439
+ // Q1 default = herdar do chat: empty/0/inherit/none writes nothing (não grava active-preset.json)
440
+ if (!presetName || presetName === '0' || presetName === 'inherit' || presetName === 'none') {
441
+ logger.log?.(
442
+ 'Herança nativa: delegates herdam o modelo do chat pai — nenhum preset ativo gravado.',
443
+ )
444
+ return { preset: 'inherit', scope: 'project' }
445
+ }
446
+ if (!names.includes(presetName)) {
447
+ logger.warn?.(`Unknown preset "${presetName}" — wizard canceled.`)
448
+ return null
449
+ }
450
+ const requiredEnv = requiredKeyEnvForPreset(presetName, defs[presetName])
451
+ const envConfigured = isKeyConfiguredForPreset(
452
+ { ...env, ...(keyAnswer ? { [requiredEnv]: keyAnswer } : {}) },
453
+ presetName,
454
+ defs[presetName],
455
+ )
456
+ if (!envConfigured) {
457
+ logger.warn?.(`Chave ${requiredEnv} não configurada — defina env antes de continuar.`)
458
+ return null
459
+ }
460
+ if (keyAnswer && !dryRun) {
461
+ env[requiredEnv] = keyAnswer
462
+ logger.log?.(`Chave coletada (mascarada: ${maskKey(String(keyAnswer))}). Não escrevemos .env; export ${requiredEnv} no shell para persistir.`)
463
+ }
464
+ const finalScope = scope === 'global' ? 'global' : 'project'
465
+ const projectDir = presetDir ?? target ?? process.cwd()
466
+ const globalDir = (() => {
467
+ const home = env.HOME?.trim() || homedir()
468
+ const xdg = env.XDG_CONFIG_HOME?.trim() || join(home, '.config')
469
+ return join(xdg, 'opencode')
470
+ })()
471
+ const dir = finalScope === 'global' ? globalDir : projectDir
472
+ if (!dryRun) {
473
+ const result = writeActivePreset(dir, presetName, { dryRun: false, source: 'interactive', logger })
474
+ logger.log?.(`Preset ${presetName} salvo em ${finalScope} (${result.path})`)
475
+ try {
476
+ const raw = readFileSync(result.path, 'utf8')
477
+ JSON.parse(raw)
478
+ logger.log?.('health-check: active-preset.json OK')
479
+ } catch (e) {
480
+ logger.warn?.(`health-check failed: ${e.message}`)
481
+ }
482
+ } else {
483
+ logger.log?.(`[dry-run] Would write preset "${presetName}" scope ${finalScope}`)
484
+ }
485
+ return { preset: presetName, scope: finalScope }
486
+ }