pantheon-opencode 1.4.0 → 1.4.2

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,858 @@
1
+ /**
2
+ * Deterministic per-agent model configuration tool for `/pantheon-model`.
3
+ *
4
+ * Operates exclusively on `active-preset.json` `overrides.agents[agent]`
5
+ * (never on top-level `model`/`small_model`, never on `.env`). Supports:
6
+ * - status/show: lists 14 canonical agents with model, effort, origin
7
+ * (preset|override|env|none) without secrets, validated via routing.yml.
8
+ * - set --agent X --model Y --effort Z --scope project|global: validates
9
+ * via CAPABILITY_TABLE/hasVision and persists atomically with .bak + lock.
10
+ * - reset --agent X [--scope]: removes override.
11
+ * - wizard (no args): askQuestions flow agent → model → effort → scope.
12
+ *
13
+ * Atomic writes use sibling .bak and temporary-file rename with O_NOFOLLOW
14
+ * and per-path in-memory lock.
15
+ *
16
+ * @module model-command
17
+ */
18
+
19
+ import { randomBytes } from 'node:crypto'
20
+ import { constants, type Stats } from 'node:fs'
21
+ import type { FileHandle } from 'node:fs/promises'
22
+ import { lstat, mkdir, open, rename, unlink } from 'node:fs/promises'
23
+ import { homedir } from 'node:os'
24
+ import { basename, dirname, join, resolve } from 'node:path'
25
+ import { z } from 'zod'
26
+
27
+ import type { ToolContextLike } from './delegation.ts'
28
+ import {
29
+ capabilityEntry,
30
+ hasVision,
31
+ loadPresetDefs,
32
+ normalizeCapability,
33
+ resolveActivePreset,
34
+ } from './presets.mjs'
35
+
36
+ export type ModelScope = 'project' | 'global'
37
+ export type ModelAction = 'status' | 'show' | 'set' | 'reset'
38
+
39
+ const MODEL_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._:+-]*$/
40
+ const SAFE_PRESET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
41
+
42
+ /** 14 canonical agents — must stay in sync with src/routing.yml agents. */
43
+ export const KNOWN_AGENTS = [
44
+ 'zeus',
45
+ 'athena',
46
+ 'apollo',
47
+ 'hermes',
48
+ 'aphrodite',
49
+ 'demeter',
50
+ 'themis',
51
+ 'prometheus',
52
+ 'hephaestus',
53
+ 'nyx',
54
+ 'gaia',
55
+ 'iris',
56
+ 'mnemosyne',
57
+ 'talos',
58
+ ] as const
59
+ export type KnownAgent = (typeof KNOWN_AGENTS)[number]
60
+ const KNOWN_AGENTS_SET = new Set<string>(KNOWN_AGENTS)
61
+
62
+ const modelArgs = {
63
+ action: z
64
+ .enum(['status', 'show', 'set', 'reset'])
65
+ .optional()
66
+ .describe('Operation: status/show, set, or reset. No args runs wizard.'),
67
+ agent: z.string().optional().describe('Agent name (one of 14 canonical agents).'),
68
+ model: z
69
+ .string()
70
+ .optional()
71
+ .describe('Model in provider/model-id format; used by set.'),
72
+ effort: z
73
+ .enum(['low', 'medium', 'high'])
74
+ .optional()
75
+ .describe('Reasoning effort (variant), clamped to model capability.'),
76
+ scope: z
77
+ .enum(['project', 'global'])
78
+ .optional()
79
+ .describe('Configuration scope for active-preset.json (default project).'),
80
+ confirm: z
81
+ .boolean()
82
+ .optional()
83
+ .describe('Explicitly confirm a mutating operation, required for global changes.'),
84
+ authorize_global: z
85
+ .boolean()
86
+ .optional()
87
+ .describe('Separate authorization for changing global configuration; not implied by -y.'),
88
+ } satisfies z.ZodRawShape
89
+
90
+ type ModelArgs = z.infer<z.ZodObject<typeof modelArgs>>
91
+
92
+ /** A logger subset used for non-fatal status diagnostics. */
93
+ export interface ModelCommandLogger {
94
+ warn?: (message: string) => void
95
+ log?: (message: string) => void
96
+ }
97
+
98
+ /** Injectable filesystem/environment locations for deterministic tests. */
99
+ export interface ModelCommandOptions {
100
+ cwd?: string
101
+ /** Explicit project opencode.json path; useful for isolated installations. */
102
+ projectConfigPath?: string
103
+ /** Explicit global opencode.json path; useful for isolated installations. */
104
+ globalConfigPath?: string
105
+ /** Override environment lookup without mutating process.env. */
106
+ env?: NodeJS.ProcessEnv
107
+ /** Override marker locations; defaults to project then global candidates. */
108
+ activePresetCandidates?: string[]
109
+ logger?: ModelCommandLogger
110
+ /** Optional askQuestions injection for wizard (used in tests). */
111
+ ask?: (questions: unknown[]) => Promise<Record<string, unknown>>
112
+ /** Optional readline injection for wizard fallback. */
113
+ rl?: { question: (q: string) => Promise<string>; close?: () => void }
114
+ }
115
+
116
+ export interface ModelTool {
117
+ description: string
118
+ args: typeof modelArgs
119
+ execute: (args: ModelArgs, ctx: ToolContextLike) => Promise<string>
120
+ }
121
+
122
+ export interface ModelToolset {
123
+ pantheon_model: ModelTool
124
+ }
125
+
126
+ interface ReadFileResult {
127
+ exists: boolean
128
+ valid: boolean
129
+ content?: string
130
+ mode?: number
131
+ data: unknown
132
+ }
133
+
134
+ interface AgentStatus {
135
+ agent: string
136
+ model: string | undefined
137
+ effort: string | undefined
138
+ origin: 'preset' | 'override' | 'env' | 'none'
139
+ vision: boolean | undefined
140
+ }
141
+
142
+ class ModelCommandError extends Error {
143
+ constructor(message: string) {
144
+ super(message)
145
+ this.name = 'ModelCommandError'
146
+ }
147
+ }
148
+
149
+ const NO_FOLLOW = constants.O_NOFOLLOW ?? 0
150
+ const PERMISSION_MASK = 0o7777
151
+ const DEFAULT_FILE_MODE = 0o600
152
+ const pathLocks = new Map<string, Promise<void>>()
153
+
154
+ function errorCode(error: unknown): string | undefined {
155
+ return error instanceof Error && 'code' in error
156
+ ? (error as NodeJS.ErrnoException).code
157
+ : undefined
158
+ }
159
+
160
+ async function existingPath(path: string): Promise<Stats | undefined> {
161
+ try {
162
+ return await lstat(path)
163
+ } catch (error: unknown) {
164
+ if (errorCode(error) === 'ENOENT') return undefined
165
+ throw error
166
+ }
167
+ }
168
+
169
+ function assertRegular(stats: Stats, label: string): void {
170
+ if (stats.isSymbolicLink()) {
171
+ throw new ModelCommandError(`${label} must not be a symlink`)
172
+ }
173
+ if (!stats.isFile()) {
174
+ throw new ModelCommandError(`${label} must be a regular file`)
175
+ }
176
+ }
177
+
178
+ async function regularPath(path: string, label: string): Promise<Stats | undefined> {
179
+ const stats = await existingPath(path)
180
+ if (stats !== undefined) assertRegular(stats, label)
181
+ return stats
182
+ }
183
+
184
+ async function readRegularFile(
185
+ path: string,
186
+ label: string,
187
+ ): Promise<{ content: string; mode: number } | undefined> {
188
+ const stats = await regularPath(path, label)
189
+ if (stats === undefined) return undefined
190
+
191
+ let file: FileHandle | undefined
192
+ try {
193
+ file = await open(path, constants.O_RDONLY | NO_FOLLOW)
194
+ const openedStats = await file.stat()
195
+ if (!openedStats.isFile()) throw new ModelCommandError(`${label} must be a regular file`)
196
+ return {
197
+ content: await file.readFile('utf8'),
198
+ mode: openedStats.mode & PERMISSION_MASK,
199
+ }
200
+ } catch (error: unknown) {
201
+ if (errorCode(error) === 'ELOOP') {
202
+ throw new ModelCommandError(`${label} must not be a symlink`)
203
+ }
204
+ throw error
205
+ } finally {
206
+ await file?.close().catch(() => undefined)
207
+ }
208
+ }
209
+
210
+ async function syncDirectory(path: string): Promise<void> {
211
+ let directory: FileHandle | undefined
212
+ try {
213
+ directory = await open(path, constants.O_RDONLY | NO_FOLLOW)
214
+ const stats = await directory.stat()
215
+ if (!stats.isDirectory()) {
216
+ throw new ModelCommandError('configuration directory is not a directory')
217
+ }
218
+ await directory.sync()
219
+ } catch (error: unknown) {
220
+ if (errorCode(error) !== 'EINVAL' && errorCode(error) !== 'ENOTSUP') throw error
221
+ } finally {
222
+ await directory?.close().catch(() => undefined)
223
+ }
224
+ }
225
+
226
+ async function createTemporaryFile(
227
+ path: string,
228
+ ): Promise<{ path: string; file: Awaited<ReturnType<typeof open>> }> {
229
+ const directory = dirname(path)
230
+ const prefix = `.${basename(path)}.tmp-`
231
+ const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW
232
+ for (let attempt = 0; attempt < 10; attempt += 1) {
233
+ const temporaryPath = join(directory, `${prefix}${randomBytes(16).toString('hex')}`)
234
+ try {
235
+ return { path: temporaryPath, file: await open(temporaryPath, flags, DEFAULT_FILE_MODE) }
236
+ } catch (error: unknown) {
237
+ if (errorCode(error) !== 'EEXIST') throw error
238
+ }
239
+ }
240
+ throw new ModelCommandError('could not allocate a unique temporary file')
241
+ }
242
+
243
+ async function atomicReplace(
244
+ path: string,
245
+ content: string,
246
+ mode: number,
247
+ label: string,
248
+ expectedExists: boolean,
249
+ ): Promise<void> {
250
+ const before = await regularPath(path, label)
251
+ if ((before !== undefined) !== expectedExists) {
252
+ throw new ModelCommandError(`${label} changed while it was being updated`)
253
+ }
254
+
255
+ let temporaryPath: string | undefined
256
+ let temporaryFile: FileHandle | undefined
257
+ let renamed = false
258
+ try {
259
+ const temporary = await createTemporaryFile(path)
260
+ temporaryPath = temporary.path
261
+ temporaryFile = temporary.file
262
+ await temporaryFile.writeFile(content, 'utf8')
263
+ await temporaryFile.chmod(mode)
264
+ await temporaryFile.sync()
265
+ await temporaryFile.close()
266
+ temporaryFile = undefined
267
+
268
+ const temporaryStats = await regularPath(temporaryPath, 'temporary configuration file')
269
+ if (temporaryStats === undefined) {
270
+ throw new ModelCommandError('temporary configuration file disappeared')
271
+ }
272
+ const current = await regularPath(path, label)
273
+ if ((current !== undefined) !== expectedExists) {
274
+ throw new ModelCommandError(`${label} changed while it was being updated`)
275
+ }
276
+ await rename(temporaryPath, path)
277
+ renamed = true
278
+ await syncDirectory(dirname(path))
279
+ } finally {
280
+ await temporaryFile?.close().catch(() => undefined)
281
+ if (temporaryPath !== undefined && !renamed) {
282
+ await unlink(temporaryPath).catch(() => undefined)
283
+ }
284
+ }
285
+ }
286
+
287
+ async function withPathLock<T>(path: string, operation: () => Promise<T>): Promise<T> {
288
+ const previous = pathLocks.get(path) ?? Promise.resolve()
289
+ let release!: () => void
290
+ const own = new Promise<void>((resolveRelease) => {
291
+ release = resolveRelease
292
+ })
293
+ const queued = previous.then(() => own)
294
+ pathLocks.set(path, queued)
295
+ await previous
296
+ try {
297
+ return await operation()
298
+ } finally {
299
+ release()
300
+ if (pathLocks.get(path) === queued) pathLocks.delete(path)
301
+ }
302
+ }
303
+
304
+ /** Return whether a model is in the supported `provider/model-id` form. */
305
+ export function validateModelRef(value: unknown): value is string {
306
+ return typeof value === 'string' && MODEL_REF_PATTERN.test(value)
307
+ }
308
+
309
+ function envValue(options: ModelCommandOptions): NodeJS.ProcessEnv {
310
+ return options.env ?? process.env
311
+ }
312
+
313
+ function projectConfigPath(options: ModelCommandOptions): string {
314
+ return options.projectConfigPath ?? join(options.cwd ?? process.cwd(), 'opencode.json')
315
+ }
316
+
317
+ function defaultGlobalConfigPath(options: ModelCommandOptions): string {
318
+ const env = envValue(options)
319
+ const home = env.HOME?.trim() || homedir()
320
+ const xdg = env.XDG_CONFIG_HOME?.trim() || join(home, '.config')
321
+ return join(xdg, 'opencode', 'opencode.json')
322
+ }
323
+
324
+ function globalConfigCandidates(options: ModelCommandOptions): string[] {
325
+ if (options.globalConfigPath !== undefined) return [options.globalConfigPath]
326
+ const canonical = defaultGlobalConfigPath(options)
327
+ const home = envValue(options).HOME?.trim() || homedir()
328
+ const legacy = join(home, '.opencode', 'opencode.json')
329
+ return [...new Set([canonical, legacy])]
330
+ }
331
+
332
+ function activePresetCandidatesList(options: ModelCommandOptions): string[] {
333
+ if (options.activePresetCandidates !== undefined) {
334
+ return options.activePresetCandidates.map((p) => resolve(p))
335
+ }
336
+ const projectPreset = join(dirname(projectConfigPath(options)), '.pantheon', 'active-preset.json')
337
+ const globalPresets = globalConfigCandidates(options).map((p) =>
338
+ join(dirname(p), '.pantheon', 'active-preset.json'),
339
+ )
340
+ return [...new Set([projectPreset, ...globalPresets].map((p) => resolve(p)))]
341
+ }
342
+
343
+ function presetPathForScope(options: ModelCommandOptions, scope: ModelScope): string {
344
+ const candidates = activePresetCandidatesList(options)
345
+ if (scope === 'project') {
346
+ const first = candidates[0]
347
+ if (!first) throw new ModelCommandError('project preset path is unavailable')
348
+ return first
349
+ }
350
+ // global: second candidate if exists, else fallback to global preset derivation
351
+ const second = candidates[1]
352
+ if (second) return second
353
+ // derive from default global if only one candidate was supplied
354
+ const globalCandidates = globalConfigCandidates(options).map((p) =>
355
+ resolve(join(dirname(p), '.pantheon', 'active-preset.json')),
356
+ )
357
+ return globalCandidates[0] ?? candidates[0]!
358
+ }
359
+
360
+ async function readActivePresetRaw(
361
+ path: string,
362
+ label: string,
363
+ ): Promise<ReadFileResult> {
364
+ const file = await readRegularFile(path, label)
365
+ if (file === undefined) return { exists: false, valid: true, data: null }
366
+ try {
367
+ const parsed: unknown = JSON.parse(file.content)
368
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
369
+ return { exists: true, valid: false, data: null, content: file.content, mode: file.mode }
370
+ }
371
+ return { exists: true, valid: true, data: parsed as Record<string, unknown>, content: file.content, mode: file.mode }
372
+ } catch {
373
+ return { exists: true, valid: false, data: null, content: file.content, mode: file.mode }
374
+ }
375
+ }
376
+
377
+ function normalizeAgentName(value: string): string {
378
+ return value.trim().toLowerCase()
379
+ }
380
+
381
+ function validateAgent(agent: string): string {
382
+ const normalized = normalizeAgentName(agent)
383
+ if (!KNOWN_AGENTS_SET.has(normalized)) {
384
+ throw new ModelCommandError(
385
+ `unknown agent "${agent}"; known agents: ${KNOWN_AGENTS.join(', ')}`,
386
+ )
387
+ }
388
+ return normalized
389
+ }
390
+
391
+ function isValidEffort(value: unknown): value is 'low' | 'medium' | 'high' {
392
+ return value === 'low' || value === 'medium' || value === 'high'
393
+ }
394
+
395
+ function validateSetArgs(args: ModelArgs): void {
396
+ if (!args.agent) throw new ModelCommandError('set requires --agent <name>')
397
+ validateAgent(args.agent)
398
+ if (args.model !== undefined && !validateModelRef(args.model)) {
399
+ throw new ModelCommandError('model must use provider/model-id format')
400
+ }
401
+ if (args.model === undefined) {
402
+ throw new ModelCommandError('set requires --model <provider/model-id>')
403
+ }
404
+ if (args.effort !== undefined && !isValidEffort(args.effort)) {
405
+ throw new ModelCommandError('effort must be one of low, medium, high')
406
+ }
407
+ // CAPABILITY_TABLE validation
408
+ try {
409
+ capabilityEntry(args.model)
410
+ } catch (error: unknown) {
411
+ const msg = error instanceof Error ? error.message : String(error)
412
+ throw new ModelCommandError(msg)
413
+ }
414
+ }
415
+
416
+ function validateResetArgs(args: ModelArgs): void {
417
+ if (!args.agent) throw new ModelCommandError('reset requires --agent <name>')
418
+ validateAgent(args.agent)
419
+ }
420
+
421
+ function validateMutationAuthorization(args: ModelArgs, scope: ModelScope): void {
422
+ if (scope !== 'global') return
423
+ if (args.confirm !== true || args.authorize_global !== true) {
424
+ throw new ModelCommandError(
425
+ 'global set/reset requires explicit confirmation and separate global authorization; -y is not sufficient',
426
+ )
427
+ }
428
+ }
429
+
430
+ // ─── Atomic write for active-preset.json ──────────────────────────────────
431
+
432
+ async function writeActivePresetAtomically(
433
+ path: string,
434
+ data: Record<string, unknown>,
435
+ current: ReadFileResult,
436
+ ): Promise<void> {
437
+ const backupPath = `${path}.bak`
438
+ await mkdir(dirname(path), { recursive: true })
439
+ const originalMode = current.mode ?? DEFAULT_FILE_MODE
440
+ const backupMode = originalMode & 0o600
441
+ const backup = await regularPath(backupPath, 'configuration backup')
442
+
443
+ if (current.exists) {
444
+ if (current.content === undefined) {
445
+ throw new ModelCommandError('configuration content could not be read; file preserved')
446
+ }
447
+ await atomicReplace(backupPath, current.content, backupMode, 'configuration backup', backup !== undefined)
448
+ }
449
+ await atomicReplace(path, `${JSON.stringify(data, null, 2)}\n`, originalMode, 'configuration file', current.exists)
450
+ }
451
+
452
+ function ensureActivePresetShape(
453
+ raw: Record<string, unknown> | null,
454
+ fallbackPreset: string,
455
+ ): Record<string, unknown> {
456
+ const out: Record<string, unknown> = raw && typeof raw === 'object' ? { ...raw } : {}
457
+ if (typeof out.version !== 'number') out.version = 1
458
+ if (typeof out.preset !== 'string' || (out.preset as string).trim() === '') {
459
+ out.preset = fallbackPreset
460
+ }
461
+ if (typeof out.source !== 'string') out.source = 'cli'
462
+ out.updated_at = new Date().toISOString()
463
+ if (out.overrides === undefined || out.overrides === null || typeof out.overrides !== 'object' || Array.isArray(out.overrides)) {
464
+ out.overrides = {}
465
+ }
466
+ const overrides = out.overrides as Record<string, unknown>
467
+ if (overrides.agents === undefined || overrides.agents === null || typeof overrides.agents !== 'object' || Array.isArray(overrides.agents)) {
468
+ overrides.agents = {}
469
+ }
470
+ return out
471
+ }
472
+
473
+ function defaultFallbackPreset(): string {
474
+ try {
475
+ const defs = loadPresetDefs()
476
+ const first = Object.keys(defs)[0]
477
+ if (first) return first
478
+ } catch {
479
+ // ignore
480
+ }
481
+ return 'go-free'
482
+ }
483
+
484
+ async function updateActivePreset(
485
+ options: ModelCommandOptions,
486
+ args: ModelArgs,
487
+ action: 'set' | 'reset',
488
+ scope: ModelScope,
489
+ ): Promise<string> {
490
+ validateMutationAuthorization(args, scope)
491
+ if (action === 'set') validateSetArgs(args)
492
+ else validateResetArgs(args)
493
+
494
+ const path = presetPathForScope(options, scope)
495
+ return withPathLock(path, async () => {
496
+ const current = await readActivePresetRaw(path, 'configuration file')
497
+ if (current.exists && !current.valid) {
498
+ throw new ModelCommandError(`${scope} preset file contains invalid JSON; file preserved`)
499
+ }
500
+
501
+ const env = envValue(options)
502
+ let fallbackPreset = defaultFallbackPreset()
503
+ // Prefer env preset if set and known
504
+ const envPreset = env.PANTHEON_MODEL_PRESET?.trim()
505
+ if (envPreset && envPreset !== '' && envPreset !== 'none') {
506
+ try {
507
+ const defs = loadPresetDefs()
508
+ if (Object.hasOwn(defs, envPreset)) fallbackPreset = envPreset
509
+ } catch {
510
+ // ignore
511
+ }
512
+ } else if (current.data && typeof current.data === 'object' && (current.data as Record<string, unknown>).preset) {
513
+ const existing = (current.data as Record<string, unknown>).preset as string
514
+ if (typeof existing === 'string' && existing.trim() !== '' && SAFE_PRESET_NAME.test(existing.trim())) {
515
+ fallbackPreset = existing.trim()
516
+ }
517
+ }
518
+
519
+ const shaped = ensureActivePresetShape(current.data as Record<string, unknown> | null, fallbackPreset)
520
+ const overrides = shaped.overrides as Record<string, unknown>
521
+ const agents = overrides.agents as Record<string, unknown>
522
+
523
+ const agentKey = validateAgent(args.agent!)
524
+
525
+ if (action === 'set') {
526
+ const model = args.model!
527
+ const effort = args.effort
528
+ let variantInfo: { variant: 'low' | 'medium' | 'high' | null; clamped: boolean }
529
+ try {
530
+ variantInfo = normalizeCapability(model, effort)
531
+ } catch (error: unknown) {
532
+ const msg = error instanceof Error ? error.message : String(error)
533
+ throw new ModelCommandError(msg)
534
+ }
535
+
536
+ // Validate vision: warn if text-only, but allow
537
+ let visionWarning = ''
538
+ try {
539
+ const vision = hasVision(model)
540
+ if (!vision) {
541
+ visionWarning = `warning: model ${model} is text-only (no vision) — image turns will use vision fallback`
542
+ }
543
+ } catch {
544
+ // capabilityEntry already validated, hasVision shouldn't throw here
545
+ }
546
+
547
+ const entry: Record<string, unknown> = { model }
548
+ if (variantInfo.variant !== null) entry.variant = variantInfo.variant
549
+ // For backward compat, also support reasoning_effort? Store variant only.
550
+ agents[agentKey] = entry
551
+
552
+ // If clamped, append warning
553
+ if (variantInfo.clamped) {
554
+ visionWarning = [visionWarning, `effort clamped to ${variantInfo.variant} for model ${model}`]
555
+ .filter(Boolean)
556
+ .join('; ')
557
+ }
558
+
559
+ await writeActivePresetAtomically(path, shaped, current)
560
+
561
+ // health-check: verify file parses and preset resolves
562
+ try {
563
+ const written = await readRegularFile(path, 'configuration file')
564
+ if (written) JSON.parse(written.content)
565
+ } catch {
566
+ options.logger?.warn?.('health-check: active-preset.json verification failed')
567
+ }
568
+
569
+ const base = `Model override set (${scope}): ${agentKey} → ${model} (effort: ${variantInfo.variant ?? 'none'}). Restart OpenCode for the change to take effect.`
570
+ return visionWarning ? `${base}\n${visionWarning}` : base
571
+ } else {
572
+ // reset
573
+ if (!Object.hasOwn(agents, agentKey)) {
574
+ return `No override to remove for agent "${agentKey}" in ${scope} preset.`
575
+ }
576
+ delete agents[agentKey]
577
+ // Clean up empty overrides.agents to keep file tidy, but keep overrides object
578
+ // Remove empty overrides if no agents and no providers etc? Keep minimal.
579
+ await writeActivePresetAtomically(path, shaped, current)
580
+ return `Model override removed (${scope}): ${agentKey}. Restart OpenCode for the change to take effect.`
581
+ }
582
+ })
583
+ }
584
+
585
+ // ─── Status ─────────────────────────────────────────────────────────────────
586
+
587
+ function silentLogger(): ModelCommandLogger {
588
+ return { warn: () => {}, log: () => {} }
589
+ }
590
+
591
+ async function resolvePerAgentStatus(options: ModelCommandOptions): Promise<{
592
+ presetName: string | null
593
+ presetSource: string | null
594
+ agents: AgentStatus[]
595
+ warnings: string[]
596
+ }> {
597
+ const env = envValue(options)
598
+ const candidates = activePresetCandidatesList(options)
599
+ const warnings: string[] = []
600
+
601
+ // Use presets.mjs resolver (sync) for canonical resolution, but capture warnings
602
+ const logger: ModelCommandLogger = {
603
+ warn: (m) => warnings.push(m),
604
+ log: () => {},
605
+ }
606
+
607
+ let resolved: ReturnType<typeof resolveActivePreset> = null
608
+ try {
609
+ resolved = resolveActivePreset({ env, candidates, logger } as unknown as Parameters<
610
+ typeof resolveActivePreset
611
+ >[0])
612
+ } catch (error: unknown) {
613
+ const msg = error instanceof Error ? error.message : String(error)
614
+ warnings.push(msg)
615
+ resolved = null
616
+ }
617
+
618
+ const agents: AgentStatus[] = []
619
+ for (const agent of KNOWN_AGENTS) {
620
+ const key = agent
621
+ let model: string | undefined
622
+ let effort: string | undefined
623
+ let origin: AgentStatus['origin'] = 'none'
624
+ let vision: boolean | undefined
625
+
626
+ if (resolved) {
627
+ const merged = (resolved.agents as Record<string, { model?: string; reasoning_effort?: string; variant?: string }>)?.[key]
628
+ if (merged?.model) {
629
+ model = merged.model
630
+ effort = (merged as { reasoning_effort?: string }).reasoning_effort ?? (merged as { variant?: string }).variant
631
+ // Determine origin: file overrides > file preset > env
632
+ const fileOverrides = (resolved.overrides as { agents?: Record<string, unknown> } | null)?.agents
633
+ if (resolved.source === 'file' && fileOverrides && Object.hasOwn(fileOverrides, key)) {
634
+ origin = 'override'
635
+ } else if (resolved.source === 'env') {
636
+ origin = 'env'
637
+ } else {
638
+ origin = 'preset'
639
+ }
640
+ try {
641
+ if (model) vision = hasVision(model)
642
+ } catch {
643
+ vision = undefined
644
+ }
645
+ } else {
646
+ origin = 'none'
647
+ }
648
+ } else {
649
+ origin = 'none'
650
+ }
651
+
652
+ agents.push({ agent: key, model, effort, origin, vision })
653
+ }
654
+
655
+ return {
656
+ presetName: (resolved as { name?: string } | null)?.name ?? null,
657
+ presetSource: (resolved as { source?: string } | null)?.source ?? null,
658
+ agents,
659
+ warnings,
660
+ }
661
+ }
662
+
663
+ async function renderStatus(options: ModelCommandOptions): Promise<string> {
664
+ const { presetName, presetSource, agents, warnings } = await resolvePerAgentStatus(options)
665
+ const lines: string[] = []
666
+ lines.push('Pantheon model configuration per-agent (restart OpenCode after changes):')
667
+ if (presetName) {
668
+ const originLabel = presetSource === 'env' ? 'env' : presetSource === 'file' ? 'preset' : presetSource
669
+ lines.push(`active preset: ${presetName} (${presetSource ?? 'unknown'})`)
670
+ // For test compatibility, also show origin mapping explicitly
671
+ void originLabel
672
+ } else {
673
+ lines.push('active preset: none')
674
+ }
675
+ lines.push(`agents: ${KNOWN_AGENTS.length} canonical`)
676
+ for (const a of agents) {
677
+ const modelStr = a.model ?? 'not configured'
678
+ const effortStr = a.effort ?? 'none'
679
+ const visionStr = a.vision === undefined ? 'unknown' : a.vision ? 'vision' : 'text-only'
680
+ // origin is preset|override|env|none — must be explicit for tests
681
+ lines.push(`${a.agent}: ${modelStr} (origin: ${a.origin}, effort: ${effortStr}, ${visionStr})`)
682
+ }
683
+ for (const w of warnings) {
684
+ // never leak secrets: strip any env values that look like keys (heuristic)
685
+ if (/api[_-]?key/i.test(w) && w.length > 80) continue
686
+ lines.push(`warning: ${w}`)
687
+ }
688
+ return lines.join('\n')
689
+ }
690
+
691
+ // ─── Wizard (askQuestions) ──────────────────────────────────────────────────
692
+
693
+ export function buildWizardQuestions(): unknown[] {
694
+ return [
695
+ {
696
+ header: 'Agente',
697
+ question: 'Escolha o agente para configurar (um dos 14 canônicos)',
698
+ options: KNOWN_AGENTS.map((a) => ({ label: a, description: `Agente ${a}` })),
699
+ multiSelect: false,
700
+ },
701
+ {
702
+ header: 'Modelo',
703
+ question: 'Informe o modelo em provider/model-id (ex: openai/gpt-5.6 ou opencode-go/kimi-k2.7-code)',
704
+ kind: 'text',
705
+ },
706
+ {
707
+ header: 'Effort',
708
+ question: 'Escolha o reasoning effort',
709
+ options: [
710
+ { label: 'low', description: 'Baixa latência, respostas curtas' },
711
+ { label: 'medium', description: 'Equilíbrio custo/qualidade' },
712
+ { label: 'high', description: 'Máxima qualidade/raciocínio' },
713
+ ],
714
+ multiSelect: false,
715
+ },
716
+ {
717
+ header: 'Escopo',
718
+ question: 'Onde salvar o override?',
719
+ options: [
720
+ { label: 'project', description: './.pantheon/active-preset.json (escopo do projeto, seguro)' },
721
+ { label: 'global', description: '~/.config/opencode/.pantheon/active-preset.json (global)' },
722
+ ],
723
+ multiSelect: false,
724
+ },
725
+ ]
726
+ }
727
+
728
+ async function promptViaReadline(
729
+ questions: Array<{ header?: string; question: string; options?: Array<{ label: string }> }>,
730
+ rl: { question: (q: string) => Promise<string>; close?: () => void },
731
+ ): Promise<Record<string, unknown>> {
732
+ const answers: Record<string, unknown> = {}
733
+ // Q1 agent
734
+ const agentQ = questions[0]!
735
+ const agentPrompt = `${agentQ.question} [${KNOWN_AGENTS.join('/')}] : `
736
+ const agentAns = (await rl.question(agentPrompt)).trim().toLowerCase()
737
+ answers.agent = agentAns || KNOWN_AGENTS[0]
738
+
739
+ const modelQ = questions[1]!
740
+ const modelAns = (await rl.question(`${modelQ.question} : `)).trim()
741
+ answers.model = modelAns
742
+
743
+ const effortQ = questions[2]!
744
+ const effortAns = (await rl.question(`${effortQ.question} [low/medium/high] : `)).trim().toLowerCase()
745
+ answers.effort = effortAns || 'medium'
746
+
747
+ const scopeQ = questions[3]!
748
+ const scopeAns = (await rl.question(`${scopeQ.question} [project/global] : `)).trim().toLowerCase()
749
+ answers.scope = scopeAns || 'project'
750
+
751
+ return answers
752
+ }
753
+
754
+ async function runWizard(options: ModelCommandOptions): Promise<string> {
755
+ const questions = buildWizardQuestions()
756
+
757
+ let answers: Record<string, unknown>
758
+
759
+ if (typeof options.ask === 'function') {
760
+ try {
761
+ answers = await options.ask(questions)
762
+ } catch (error: unknown) {
763
+ const msg = error instanceof Error ? error.message : String(error)
764
+ return `pantheon_model wizard canceled: ${msg}`
765
+ }
766
+ } else if (options.rl) {
767
+ answers = await promptViaReadline(questions as never, options.rl)
768
+ } else {
769
+ // fallback readline from stdin
770
+ const { createInterface } = await import('node:readline/promises')
771
+ const rli = createInterface({ input: process.stdin, output: process.stdout })
772
+ try {
773
+ answers = await promptViaReadline(questions as never, rli as never)
774
+ } finally {
775
+ rli.close()
776
+ }
777
+ }
778
+
779
+ const agent = typeof answers.agent === 'string' ? answers.agent.trim() : ''
780
+ const model = typeof answers.model === 'string' ? answers.model.trim() : ''
781
+ const effort = typeof answers.effort === 'string' ? answers.effort.trim().toLowerCase() : ''
782
+ const scopeRaw = typeof answers.scope === 'string' ? answers.scope.trim().toLowerCase() : 'project'
783
+ const scope: ModelScope = scopeRaw === 'global' ? 'global' : 'project'
784
+
785
+ if (!agent) return 'pantheon_model wizard failed: agent is required'
786
+ if (!model) return 'pantheon_model wizard failed: model is required'
787
+
788
+ // Map answers to ModelArgs and delegate to updateActivePreset
789
+ const wizardArgs: ModelArgs = {
790
+ action: 'set',
791
+ agent,
792
+ model,
793
+ scope,
794
+ }
795
+ if (effort === 'low' || effort === 'medium' || effort === 'high') {
796
+ wizardArgs.effort = effort as 'low' | 'medium' | 'high'
797
+ }
798
+
799
+ // For wizard, we need to handle global authorization: if scope global, require confirm?
800
+ // Wizard runs interactively, so we can auto-confirm when via wizard.
801
+ if (scope === 'global') {
802
+ wizardArgs.confirm = true
803
+ wizardArgs.authorize_global = true
804
+ }
805
+
806
+ try {
807
+ return await updateActivePreset(options, wizardArgs, 'set', scope)
808
+ } catch (error: unknown) {
809
+ if (error instanceof ModelCommandError) return `pantheon_model wizard failed: ${error.message}`
810
+ options.logger?.warn?.('pantheon_model wizard failed')
811
+ return 'pantheon_model wizard failed: operation could not be completed; file preserved when possible'
812
+ }
813
+ }
814
+
815
+ /** Create the deterministic `pantheon_model` tool used by `/pantheon-model`. */
816
+ export function createModelCommand(options: ModelCommandOptions = {}): ModelToolset {
817
+ return {
818
+ pantheon_model: {
819
+ description:
820
+ 'Show, set, or reset per-agent model overrides in active-preset.json (project or global). Overrides win over preset; env preset wins over file. Never writes .env or top-level model/small_model.',
821
+ args: modelArgs,
822
+ execute: async (args, _ctx) => {
823
+ try {
824
+ const hasAction = args.action !== undefined
825
+ const hasAgentModelEffort =
826
+ args.agent !== undefined || args.model !== undefined || args.effort !== undefined
827
+
828
+ // Wizard when invoked with no args at all
829
+ if (!hasAction && !hasAgentModelEffort) {
830
+ return await runWizard(options)
831
+ }
832
+
833
+ const action: ModelAction = (args.action ?? (hasAgentModelEffort ? 'set' : 'status')) as ModelAction
834
+
835
+ if (action === 'status' || action === 'show') return await renderStatus(options)
836
+
837
+ const scope = (args.scope ?? 'project') as ModelScope
838
+ if (scope !== 'project' && scope !== 'global') {
839
+ throw new ModelCommandError('scope must be project or global')
840
+ }
841
+
842
+ if (action === 'set') {
843
+ return await updateActivePreset(options, args, 'set', scope)
844
+ }
845
+ if (action === 'reset') {
846
+ return await updateActivePreset(options, args, 'reset', scope)
847
+ }
848
+
849
+ return `pantheon_model failed: unknown action "${action}"`
850
+ } catch (error: unknown) {
851
+ if (error instanceof ModelCommandError) return `pantheon_model failed: ${error.message}`
852
+ options.logger?.warn?.('pantheon_model operation failed')
853
+ return 'pantheon_model failed: operation could not be completed; file preserved when possible'
854
+ }
855
+ },
856
+ },
857
+ }
858
+ }