ai-cli-mcp 2.14.1 → 2.15.0

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.
Files changed (56) hide show
  1. package/.github/dependabot.yml +28 -0
  2. package/.github/workflows/ci.yml +4 -1
  3. package/.github/workflows/dependency-review.yml +22 -0
  4. package/CHANGELOG.md +7 -0
  5. package/README.ja.md +25 -6
  6. package/README.md +25 -7
  7. package/dist/__tests__/app-cli.test.js +24 -4
  8. package/dist/__tests__/cli-bin-smoke.test.js +43 -0
  9. package/dist/__tests__/cli-builder.test.js +92 -14
  10. package/dist/__tests__/cli-process-service.test.js +103 -0
  11. package/dist/__tests__/cli-utils.test.js +31 -0
  12. package/dist/__tests__/e2e.test.js +77 -51
  13. package/dist/__tests__/mcp-contract.test.js +154 -0
  14. package/dist/__tests__/parsers.test.js +62 -1
  15. package/dist/__tests__/process-management.test.js +1 -1
  16. package/dist/__tests__/server.test.js +35 -6
  17. package/dist/__tests__/utils/opencode-mock.js +91 -0
  18. package/dist/__tests__/validation.test.js +40 -2
  19. package/dist/app/cli.js +4 -4
  20. package/dist/app/mcp.js +8 -4
  21. package/dist/cli-builder.js +66 -27
  22. package/dist/cli-parse.js +11 -5
  23. package/dist/cli-process-service.js +139 -20
  24. package/dist/cli-utils.js +14 -23
  25. package/dist/cli.js +6 -4
  26. package/dist/model-catalog.js +13 -1
  27. package/dist/parsers.js +57 -26
  28. package/dist/process-result.js +9 -2
  29. package/dist/process-service.js +23 -17
  30. package/dist/server.js +1 -2
  31. package/package.json +9 -6
  32. package/src/__tests__/app-cli.test.ts +24 -4
  33. package/src/__tests__/cli-bin-smoke.test.ts +62 -1
  34. package/src/__tests__/cli-builder.test.ts +110 -14
  35. package/src/__tests__/cli-process-service.test.ts +112 -0
  36. package/src/__tests__/cli-utils.test.ts +34 -0
  37. package/src/__tests__/e2e.test.ts +85 -54
  38. package/src/__tests__/mcp-contract.test.ts +179 -0
  39. package/src/__tests__/parsers.test.ts +73 -1
  40. package/src/__tests__/process-management.test.ts +1 -1
  41. package/src/__tests__/server.test.ts +45 -10
  42. package/src/__tests__/utils/opencode-mock.ts +108 -0
  43. package/src/__tests__/validation.test.ts +48 -2
  44. package/src/app/cli.ts +4 -4
  45. package/src/app/mcp.ts +8 -4
  46. package/src/cli-builder.ts +90 -31
  47. package/src/cli-parse.ts +11 -5
  48. package/src/cli-process-service.ts +171 -17
  49. package/src/cli-utils.ts +37 -33
  50. package/src/cli.ts +6 -4
  51. package/src/model-catalog.ts +24 -1
  52. package/src/parsers.ts +77 -31
  53. package/src/process-result.ts +11 -2
  54. package/src/process-service.ts +28 -15
  55. package/src/server.ts +2 -2
  56. package/vitest.config.unit.ts +2 -3
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import {
3
+ chmodSync,
3
4
  closeSync,
4
5
  existsSync,
5
6
  mkdirSync,
@@ -15,8 +16,8 @@ import {
15
16
  import { join, basename, dirname } from 'node:path';
16
17
  import { homedir } from 'node:os';
17
18
  import { buildCliCommand, type BuildCliCommandOptions } from './cli-builder.js';
18
- import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
19
- import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
19
+ import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from './cli-utils.js';
20
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput } from './parsers.js';
20
21
  import { buildProcessResult } from './process-result.js';
21
22
  import type { AgentType, ProcessListItem } from './process-service.js';
22
23
 
@@ -31,6 +32,12 @@ interface StoredProcess {
31
32
  stdoutPath: string;
32
33
  stderrPath: string;
33
34
  status: 'running' | 'completed' | 'failed';
35
+ exitCode?: number;
36
+ }
37
+
38
+ interface StoredExitStatus {
39
+ status: 'completed' | 'failed';
40
+ exitCode?: number;
34
41
  }
35
42
 
36
43
  interface CliProcessServiceOptions {
@@ -70,6 +77,30 @@ function normalizeCwdForStorage(cwd: string): string {
70
77
  .join('');
71
78
  }
72
79
 
80
+ function parseAgentOutput(agent: AgentType, stdout: string, stderr: string): any {
81
+ if (agent === 'codex') {
82
+ return parseCodexOutput(`${stdout}\n${stderr}`);
83
+ }
84
+
85
+ if (!stdout) {
86
+ return null;
87
+ }
88
+
89
+ if (agent === 'claude') {
90
+ return parseClaudeOutput(stdout);
91
+ }
92
+ if (agent === 'gemini') {
93
+ return parseGeminiOutput(stdout);
94
+ }
95
+ if (agent === 'forge') {
96
+ return parseForgeOutput(stdout);
97
+ }
98
+ if (agent === 'opencode') {
99
+ return parseOpenCodeOutput(stdout);
100
+ }
101
+ return null;
102
+ }
103
+
73
104
  export class CliProcessService {
74
105
  private readonly stateDir: string;
75
106
  private readonly cliPaths: BuildCliCommandOptions['cliPaths'];
@@ -81,6 +112,7 @@ export class CliProcessService {
81
112
  codex: findCodexCli(),
82
113
  gemini: findGeminiCli(),
83
114
  forge: findForgeCli(),
115
+ opencode: findOpencodeCli(),
84
116
  };
85
117
  mkdirSync(this.stateDir, { recursive: true });
86
118
  }
@@ -96,6 +128,10 @@ export class CliProcessService {
96
128
  cliPaths: this.cliPaths,
97
129
  });
98
130
 
131
+ if (cmd.agent === 'opencode') {
132
+ return this.startDetachedOpenCodeProcess(cmd, options.model);
133
+ }
134
+
99
135
  const stdoutPath = this.resolveStdoutPathForPidPlaceholder();
100
136
  const stderrPath = this.resolveStderrPathForPidPlaceholder();
101
137
  let stdoutFd: number | undefined;
@@ -172,25 +208,13 @@ export class CliProcessService {
172
208
  const refreshed = this.refreshStatus(storedProcess);
173
209
  const stdout = this.readTextFileSafe(refreshed.stdoutPath);
174
210
  const stderr = this.readTextFileSafe(refreshed.stderrPath);
175
-
176
- let agentOutput: any = null;
177
- if (refreshed.toolType === 'codex') {
178
- agentOutput = parseCodexOutput(`${stdout}\n${stderr}`);
179
- } else if (stdout) {
180
- if (refreshed.toolType === 'claude') {
181
- agentOutput = parseClaudeOutput(stdout);
182
- } else if (refreshed.toolType === 'gemini') {
183
- agentOutput = parseGeminiOutput(stdout);
184
- } else if (refreshed.toolType === 'forge') {
185
- agentOutput = parseForgeOutput(stdout);
186
- }
187
- }
211
+ const agentOutput = parseAgentOutput(refreshed.toolType, stdout, stderr);
188
212
 
189
213
  return buildProcessResult({
190
214
  pid,
191
215
  agent: refreshed.toolType,
192
216
  status: refreshed.status,
193
- exitCode: undefined,
217
+ exitCode: refreshed.exitCode,
194
218
  startTime: refreshed.startTime,
195
219
  workFolder: refreshed.workFolder,
196
220
  prompt: refreshed.prompt,
@@ -277,6 +301,59 @@ export class CliProcessService {
277
301
  };
278
302
  }
279
303
 
304
+ private async startDetachedOpenCodeProcess(
305
+ cmd: Awaited<ReturnType<typeof buildCliCommand>>,
306
+ model: string | undefined,
307
+ ): Promise<{ pid: number; status: 'started'; agent: AgentType; message: string }> {
308
+ const cwdKey = this.resolveCwdKey(cmd.cwd);
309
+ const wrapperPath = this.ensureOpenCodeWrapperScript();
310
+
311
+ const childProcess = spawn(wrapperPath, [this.stateDir, cwdKey, cmd.cliPath, ...cmd.args], {
312
+ cwd: cmd.cwd,
313
+ detached: true,
314
+ stdio: 'ignore',
315
+ });
316
+
317
+ const pid = childProcess.pid;
318
+ childProcess.unref();
319
+
320
+ if (!pid) {
321
+ throw new Error(`Failed to start ${cmd.agent} CLI process`);
322
+ }
323
+
324
+ const processDir = this.resolveProcessDir(cmd.cwd, pid);
325
+ mkdirSync(processDir, { recursive: true });
326
+ const stdoutPath = this.resolveStdoutPath(processDir);
327
+ const stderrPath = this.resolveStderrPath(processDir);
328
+ if (!existsSync(stdoutPath)) {
329
+ writeFileSync(stdoutPath, '');
330
+ }
331
+ if (!existsSync(stderrPath)) {
332
+ writeFileSync(stderrPath, '');
333
+ }
334
+
335
+ const storedProcess: StoredProcess = {
336
+ pid,
337
+ prompt: cmd.prompt,
338
+ workFolder: cmd.cwd,
339
+ cwdKey,
340
+ model,
341
+ toolType: cmd.agent,
342
+ startTime: new Date().toISOString(),
343
+ stdoutPath,
344
+ stderrPath,
345
+ status: 'running',
346
+ };
347
+ this.writeProcess(storedProcess);
348
+
349
+ return {
350
+ pid,
351
+ status: 'started',
352
+ agent: cmd.agent,
353
+ message: `${cmd.agent} process started successfully`,
354
+ };
355
+ }
356
+
280
357
  private readAllProcesses(): StoredProcess[] {
281
358
  const cwdsDir = this.resolveCwdsDir();
282
359
  if (!existsSync(cwdsDir)) {
@@ -320,13 +397,47 @@ export class CliProcessService {
320
397
  }
321
398
 
322
399
  private refreshStatus(process: StoredProcess): StoredProcess {
323
- if (process.status === 'running' && !isProcessRunning(process.pid)) {
400
+ if (process.status !== 'running') {
401
+ return process;
402
+ }
403
+
404
+ const persistedExitStatus = this.readExitStatus(process);
405
+ if (persistedExitStatus) {
406
+ process.status = persistedExitStatus.status;
407
+ process.exitCode = persistedExitStatus.exitCode;
408
+ this.writeProcess(process);
409
+ return process;
410
+ }
411
+
412
+ if (!isProcessRunning(process.pid)) {
324
413
  process.status = 'completed';
325
414
  this.writeProcess(process);
326
415
  }
327
416
  return process;
328
417
  }
329
418
 
419
+ private readExitStatus(process: StoredProcess): StoredExitStatus | null {
420
+ if (process.toolType !== 'opencode') {
421
+ return null;
422
+ }
423
+
424
+ const exitMetaPath = this.resolveExitStatusPath(this.resolveStoredProcessDir(process));
425
+ if (!existsSync(exitMetaPath)) {
426
+ return null;
427
+ }
428
+
429
+ try {
430
+ const parsed = JSON.parse(readFileSync(exitMetaPath, 'utf-8')) as StoredExitStatus;
431
+ if (parsed.status === 'completed' || parsed.status === 'failed') {
432
+ return parsed;
433
+ }
434
+ } catch {
435
+ return null;
436
+ }
437
+
438
+ return null;
439
+ }
440
+
330
441
  private readTextFileSafe(filePath: string): string {
331
442
  if (!existsSync(filePath)) {
332
443
  return '';
@@ -365,6 +476,14 @@ export class CliProcessService {
365
476
  return join(processDir, 'stderr.log');
366
477
  }
367
478
 
479
+ private resolveExitStatusPath(processDir: string): string {
480
+ return join(processDir, 'exit-status.json');
481
+ }
482
+
483
+ private resolveOpenCodeWrapperPath(): string {
484
+ return join(this.stateDir, 'opencode-detached-wrapper.sh');
485
+ }
486
+
368
487
  private resolveStdoutPathForPidPlaceholder(): string {
369
488
  return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stdout.log`);
370
489
  }
@@ -373,6 +492,41 @@ export class CliProcessService {
373
492
  return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stderr.log`);
374
493
  }
375
494
 
495
+ private ensureOpenCodeWrapperScript(): string {
496
+ const wrapperPath = this.resolveOpenCodeWrapperPath();
497
+ if (existsSync(wrapperPath)) {
498
+ return wrapperPath;
499
+ }
500
+
501
+ writeFileSync(
502
+ wrapperPath,
503
+ `#!/bin/sh
504
+ set +e
505
+ state_dir="$1"
506
+ cwd_key="$2"
507
+ shift 2
508
+ pid="$$"
509
+ process_dir="$state_dir/cwds/$cwd_key/$pid"
510
+ stdout_path="$process_dir/stdout.log"
511
+ stderr_path="$process_dir/stderr.log"
512
+ exit_meta_path="$process_dir/exit-status.json"
513
+ mkdir -p "$process_dir"
514
+ : > "$stdout_path"
515
+ : > "$stderr_path"
516
+ "$@" >> "$stdout_path" 2>> "$stderr_path"
517
+ exit_code="$?"
518
+ status="completed"
519
+ if [ "$exit_code" -ne 0 ]; then
520
+ status="failed"
521
+ fi
522
+ printf '{\n "status": "%s",\n "exitCode": %s\n}\n' "$status" "$exit_code" > "$exit_meta_path"
523
+ exit "$exit_code"
524
+ `,
525
+ );
526
+ chmodSync(wrapperPath, 0o755);
527
+ return wrapperPath;
528
+ }
529
+
376
530
  private renamePlaceholderFile(fromPath: string, toPath: string): void {
377
531
  renameSync(fromPath, toPath);
378
532
  }
package/src/cli-utils.ts CHANGED
@@ -3,10 +3,8 @@ import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import * as path from 'path';
5
5
 
6
- // Define debugMode globally using const
7
6
  const debugMode = process.env.MCP_CLAUDE_DEBUG === 'true';
8
7
 
9
- // Dedicated debug logging function
10
8
  export function debugLog(message?: any, ...optionalParams: any[]): void {
11
9
  if (debugMode) {
12
10
  console.error(message, ...optionalParams);
@@ -21,6 +19,24 @@ export interface CliBinaryStatus {
21
19
  error?: string;
22
20
  }
23
21
 
22
+ export type CliBinaryName = 'claude' | 'codex' | 'gemini' | 'forge' | 'opencode';
23
+
24
+ export interface CliPaths {
25
+ claude: string;
26
+ codex: string;
27
+ gemini: string;
28
+ forge: string;
29
+ opencode: string;
30
+ }
31
+
32
+ export interface CliDoctorStatus {
33
+ claude: CliBinaryStatus;
34
+ codex: CliBinaryStatus;
35
+ gemini: CliBinaryStatus;
36
+ forge: CliBinaryStatus;
37
+ opencode: CliBinaryStatus;
38
+ }
39
+
24
40
  function getPathDelimiter(): string {
25
41
  return process.platform === 'win32' ? ';' : ':';
26
42
  }
@@ -75,7 +91,7 @@ function inspectCliBinary(options: {
75
91
  envVarName: string;
76
92
  customCliName: string | undefined;
77
93
  defaultCliName: string;
78
- localInstallPath: string;
94
+ localInstallPath?: string;
79
95
  }): CliBinaryStatus {
80
96
  const configuredCommand = options.customCliName || options.defaultCliName;
81
97
 
@@ -109,7 +125,7 @@ function inspectCliBinary(options: {
109
125
  };
110
126
  }
111
127
 
112
- if (isExecutableFile(options.localInstallPath)) {
128
+ if (options.localInstallPath && isExecutableFile(options.localInstallPath)) {
113
129
  return {
114
130
  configuredCommand,
115
131
  resolvedPath: options.localInstallPath,
@@ -148,13 +164,11 @@ function isExecutableFile(filePath: string): boolean {
148
164
  }
149
165
  }
150
166
 
151
- type CliBinaryName = 'claude' | 'codex' | 'gemini' | 'forge';
152
-
153
167
  function getCliBinaryConfig(name: CliBinaryName): {
154
168
  envVarName: string;
155
169
  customCliName: string | undefined;
156
170
  defaultCliName: string;
157
- localInstallPath: string;
171
+ localInstallPath?: string;
158
172
  } {
159
173
  if (name === 'claude') {
160
174
  return {
@@ -183,6 +197,14 @@ function getCliBinaryConfig(name: CliBinaryName): {
183
197
  };
184
198
  }
185
199
 
200
+ if (name === 'opencode') {
201
+ return {
202
+ envVarName: 'OPENCODE_CLI_NAME',
203
+ customCliName: process.env.OPENCODE_CLI_NAME,
204
+ defaultCliName: 'opencode',
205
+ };
206
+ }
207
+
186
208
  return {
187
209
  envVarName: 'GEMINI_CLI_NAME',
188
210
  customCliName: process.env.GEMINI_CLI_NAME,
@@ -195,58 +217,40 @@ function getCliBinaryStatus(name: CliBinaryName): CliBinaryStatus {
195
217
  return inspectCliBinary(getCliBinaryConfig(name));
196
218
  }
197
219
 
198
- export function getCliDoctorStatus(): {
199
- claude: CliBinaryStatus;
200
- codex: CliBinaryStatus;
201
- gemini: CliBinaryStatus;
202
- forge: CliBinaryStatus;
203
- } {
220
+ export function getCliDoctorStatus(): CliDoctorStatus {
204
221
  return {
205
222
  claude: getCliBinaryStatus('claude'),
206
223
  codex: getCliBinaryStatus('codex'),
207
224
  gemini: getCliBinaryStatus('gemini'),
208
225
  forge: getCliBinaryStatus('forge'),
226
+ opencode: getCliBinaryStatus('opencode'),
209
227
  };
210
228
  }
211
229
 
212
- /**
213
- * Determine the Gemini CLI command/path.
214
- * Similar to findClaudeCli but for Gemini
215
- */
216
230
  export function findGeminiCli(): string {
217
231
  debugLog('[Debug] Attempting to find Gemini CLI...');
218
232
  const status = getCliBinaryStatus('gemini');
219
233
  return getCliCommandOrThrow(status);
220
234
  }
221
235
 
222
- /**
223
- * Determine the Codex CLI command/path.
224
- * Similar to findClaudeCli but for Codex
225
- */
226
236
  export function findCodexCli(): string {
227
237
  debugLog('[Debug] Attempting to find Codex CLI...');
228
238
  const status = getCliBinaryStatus('codex');
229
239
  return getCliCommandOrThrow(status);
230
240
  }
231
241
 
232
- /**
233
- * Determine the Forge CLI command/path.
234
- */
235
242
  export function findForgeCli(): string {
236
243
  debugLog('[Debug] Attempting to find Forge CLI...');
237
244
  const status = getCliBinaryStatus('forge');
238
245
  return getCliCommandOrThrow(status);
239
246
  }
240
247
 
241
- /**
242
- * Determine the Claude CLI command/path.
243
- * 1. Checks for CLAUDE_CLI_NAME environment variable:
244
- * - If absolute path, uses it directly
245
- * - If relative path, throws error
246
- * - If simple name, continues with path resolution
247
- * 2. Checks for Claude CLI at the local user path: ~/.claude/local/claude.
248
- * 3. If not found, defaults to the CLI name (or 'claude'), relying on the system's PATH for lookup.
249
- */
248
+ export function findOpencodeCli(): string {
249
+ debugLog('[Debug] Attempting to find OpenCode CLI...');
250
+ const status = getCliBinaryStatus('opencode');
251
+ return getCliCommandOrThrow(status);
252
+ }
253
+
250
254
  export function findClaudeCli(): string {
251
255
  debugLog('[Debug] Attempting to find Claude CLI...');
252
256
  const status = getCliBinaryStatus('claude');
package/src/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
3
  import { buildCliCommand } from './cli-builder.js';
4
- import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
4
+ import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from './cli-utils.js';
5
5
 
6
6
  /**
7
7
  * Minimal argv parser. No external dependencies.
@@ -35,17 +35,18 @@ function parseArgs(argv: string[]): Record<string, string> {
35
35
  const USAGE = `Usage: npm run -s cli.run -- --model <model> --workFolder <path> --prompt "..." [options]
36
36
 
37
37
  Options:
38
- --model Model name or alias (e.g. sonnet, opus, gpt-5.2-codex, gemini-2.5-pro, forge)
38
+ --model Model name or alias (e.g. sonnet, opus, gpt-5.2-codex, gemini-2.5-pro, forge, opencode, oc-openai/gpt-5.4)
39
39
  --workFolder Working directory (absolute path)
40
40
  --prompt Prompt string (mutually exclusive with --prompt_file)
41
41
  --prompt_file Path to a file containing the prompt
42
- --session_id Session ID to resume
43
- --reasoning_effort Claude/Codex only: Claude=low|medium|high, Codex=low|medium|high|xhigh
42
+ --session_id Session ID to resume, including OpenCode in-place resumes
43
+ --reasoning_effort Claude/Codex only: Claude=low|medium|high, Codex=low|medium|high|xhigh; unsupported for Gemini, Forge, and OpenCode
44
44
  --help Show this help message
45
45
 
46
46
  Raw CLI output goes to stdout. Use cli.run.parse to parse the output:
47
47
  npm run -s cli.run -- ... > raw.txt
48
48
  npm run -s cli.run.parse -- --agent claude < raw.txt
49
+ npm run -s cli.run.parse -- --agent opencode < raw.txt
49
50
  `;
50
51
 
51
52
  async function main(): Promise<void> {
@@ -74,6 +75,7 @@ async function main(): Promise<void> {
74
75
  codex: findCodexCli(),
75
76
  gemini: findGeminiCli(),
76
77
  forge: findForgeCli(),
78
+ opencode: findOpencodeCli(),
77
79
  };
78
80
 
79
81
  // Build command
@@ -20,6 +20,7 @@ export const GEMINI_MODELS = [
20
20
  'gemini-3-flash-preview',
21
21
  ] as const;
22
22
  export const FORGE_MODELS = ['forge'] as const;
23
+ export const OPENCODE_MODELS = ['opencode'] as const;
23
24
 
24
25
  export const MODEL_ALIASES: Record<string, string> = {
25
26
  'claude-ultra': 'opus',
@@ -33,6 +34,13 @@ export const MODEL_ALIAS_DETAILS = [
33
34
  { name: 'gemini-ultra', resolvesTo: 'gemini-3.1-pro-preview', agent: 'gemini' },
34
35
  ] as const;
35
36
 
37
+ export interface DynamicModelBackendDescription {
38
+ explicitPrefix: string;
39
+ explicitPattern: string;
40
+ discoveryCommand: string;
41
+ modelsAreDynamic: boolean;
42
+ }
43
+
36
44
  export function getSupportedModelsDescription(): string {
37
45
  return [
38
46
  '"claude-ultra", "codex-ultra", "gemini-ultra"',
@@ -40,11 +48,13 @@ export function getSupportedModelsDescription(): string {
40
48
  ...CODEX_MODELS.map((model) => `"${model}"`),
41
49
  ...GEMINI_MODELS.map((model) => `"${model}"`),
42
50
  ...FORGE_MODELS.map((model) => `"${model}"`),
51
+ ...OPENCODE_MODELS.map((model) => `"${model}"`),
52
+ '"oc-<provider/model>"',
43
53
  ].join(', ');
44
54
  }
45
55
 
46
56
  export function getModelParameterDescription(): string {
47
- return `The model to use. Aliases: "claude-ultra" (auto high effort), "codex-ultra" (auto xhigh reasoning), "gemini-ultra". Standard: ${[...CLAUDE_MODELS, ...CODEX_MODELS, ...GEMINI_MODELS, ...FORGE_MODELS].map((model) => `"${model}"`).join(', ')}. "forge" is a provider key, not a Forge model family selector.`;
57
+ return `The model to use. Aliases: "claude-ultra" (auto high effort), "codex-ultra" (auto xhigh reasoning), "gemini-ultra". Standard: ${[...CLAUDE_MODELS, ...CODEX_MODELS, ...GEMINI_MODELS, ...FORGE_MODELS, ...OPENCODE_MODELS].map((model) => `"${model}"`).join(', ')}. OpenCode also accepts explicit dynamic models using "oc-<provider/model>". "forge" is a provider key, not a Forge model family selector.`;
48
58
  }
49
59
 
50
60
  export function getModelsPayload(): {
@@ -53,6 +63,10 @@ export function getModelsPayload(): {
53
63
  codex: ReadonlyArray<string>;
54
64
  gemini: ReadonlyArray<string>;
55
65
  forge: ReadonlyArray<string>;
66
+ opencode: ReadonlyArray<string>;
67
+ dynamicModelBackends: {
68
+ opencode: DynamicModelBackendDescription;
69
+ };
56
70
  } {
57
71
  return {
58
72
  aliases: MODEL_ALIAS_DETAILS,
@@ -60,5 +74,14 @@ export function getModelsPayload(): {
60
74
  codex: CODEX_MODELS,
61
75
  gemini: GEMINI_MODELS,
62
76
  forge: FORGE_MODELS,
77
+ opencode: OPENCODE_MODELS,
78
+ dynamicModelBackends: {
79
+ opencode: {
80
+ explicitPrefix: 'oc-',
81
+ explicitPattern: 'oc-<provider/model>',
82
+ discoveryCommand: 'opencode models',
83
+ modelsAreDynamic: true,
84
+ },
85
+ },
63
86
  };
64
87
  }