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,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
|
+
}
|