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.
- package/README.md +175 -147
- package/bin/pantheon-init.mjs +7 -0
- package/commands/pantheon-model.md +34 -0
- package/package.json +2 -2
- package/scripts/generate-preset-docs.mjs +239 -0
- package/scripts/install/model-picker.mjs +367 -2
- package/scripts/install/opencode.mjs +150 -137
- package/src/agents/zeus.md +14 -25
- package/src/pantheon/cost-command.ts +7 -7
- package/src/pantheon/delegation-enforce.ts +12 -13
- package/src/pantheon/goal-loop.ts +2 -2
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/hashline/tool.ts +2 -2
- package/src/pantheon/model-command.ts +911 -0
- package/src/pantheon/presets.mjs +30 -33
- package/src/pantheon/tool-context.ts +41 -0
- package/src/plugin.ts +45 -193
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/dist/tui.tsx +13 -2
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +21 -8
- package/src/plugins/tui/tsdown.config.ts +11 -0
- package/src/routing.yml +71 -123
- package/src/pantheon/delegation-classifier.ts +0 -307
- package/src/pantheon/delegation-finalize.ts +0 -374
- package/src/pantheon/delegation-notify.ts +0 -197
- package/src/pantheon/delegation-toast.ts +0 -73
- package/src/pantheon/delegation.ts +0 -1110
- package/src/pantheon/permission-eval.ts +0 -284
- package/src/pantheon/step-cap.ts +0 -110
- package/src/pantheon/zeus-delegate-with-retry.ts +0 -401
|
@@ -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
|
|
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
|
+
}
|