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