ai-cli-mcp 2.14.0 → 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 +14 -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 +187 -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 +158 -25
  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 +217 -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 +193 -22
  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,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { parseCodexOutput, parseClaudeOutput, parseForgeOutput } from '../parsers.js';
2
+ import { parseCodexOutput, parseClaudeOutput, parseForgeOutput, parseOpenCodeOutput } from '../parsers.js';
3
3
  describe('parseCodexOutput', () => {
4
4
  it('should parse basic Codex output with message and session_id', () => {
5
5
  const output = `
@@ -132,3 +132,64 @@ still streaming`;
132
132
  expect(parseForgeOutput('plain text')).toBeNull();
133
133
  });
134
134
  });
135
+ describe('parseOpenCodeOutput', () => {
136
+ it('parses a single completed OpenCode step', () => {
137
+ const output = `{"type":"step_start","sessionID":"ses_1"}
138
+ {"type":"text","sessionID":"ses_1","part":{"type":"text","text":"Hello"}}
139
+ {"type":"step_finish","sessionID":"ses_1","part":{"type":"step-finish","tokens":{"total":11833},"cost":0}}`;
140
+ expect(parseOpenCodeOutput(output)).toEqual({
141
+ message: 'Hello',
142
+ session_id: 'ses_1',
143
+ tokens: { total: 11833 },
144
+ cost: 0,
145
+ });
146
+ });
147
+ it('returns the last completed step for multi-step output', () => {
148
+ const output = `{"type":"step_start","sessionID":"ses_2"}
149
+ {"type":"text","sessionID":"ses_2","part":{"type":"text","text":"First"}}
150
+ {"type":"step_finish","sessionID":"ses_2","part":{"type":"step-finish","tokens":{"total":10},"cost":0}}
151
+ {"type":"step_start","sessionID":"ses_2"}
152
+ {"type":"text","sessionID":"ses_2","part":{"type":"text","text":"Second"}}
153
+ {"type":"step_finish","sessionID":"ses_2","part":{"type":"step-finish","tokens":{"total":20},"cost":1}}`;
154
+ expect(parseOpenCodeOutput(output)).toEqual({
155
+ message: 'Second',
156
+ session_id: 'ses_2',
157
+ tokens: { total: 20 },
158
+ cost: 1,
159
+ });
160
+ });
161
+ it('resets the current-step buffer on each step_start', () => {
162
+ const output = `{"type":"step_start","sessionID":"ses_3"}
163
+ {"type":"text","sessionID":"ses_3","part":{"type":"text","text":"Discard me"}}
164
+ {"type":"step_start","sessionID":"ses_3"}
165
+ {"type":"text","sessionID":"ses_3","part":{"type":"text","text":"Keep me"}}
166
+ {"type":"step_finish","sessionID":"ses_3","part":{"type":"step-finish","tokens":{"total":5},"cost":0}}`;
167
+ expect(parseOpenCodeOutput(output)).toEqual({
168
+ message: 'Keep me',
169
+ session_id: 'ses_3',
170
+ tokens: { total: 5 },
171
+ cost: 0,
172
+ });
173
+ });
174
+ it('returns partial output when text exists without step_finish', () => {
175
+ const output = `{"type":"step_start","sessionID":"ses_4"}
176
+ {"type":"text","sessionID":"ses_4","part":{"type":"text","text":"Partial"}}`;
177
+ expect(parseOpenCodeOutput(output)).toEqual({
178
+ message: 'Partial',
179
+ session_id: 'ses_4',
180
+ });
181
+ });
182
+ it('ignores malformed lines and unknown event types', () => {
183
+ const output = `not-json
184
+ {"type":"unknown","sessionID":"ses_5"}
185
+ {"type":"text","sessionID":"ses_5","part":{"type":"text","text":"Hello"}}`;
186
+ expect(parseOpenCodeOutput(output)).toEqual({
187
+ message: 'Hello',
188
+ session_id: 'ses_5',
189
+ });
190
+ });
191
+ it('returns null when no useful OpenCode events exist', () => {
192
+ expect(parseOpenCodeOutput('{"type":"unknown"}')).toBeNull();
193
+ expect(parseOpenCodeOutput('')).toBeNull();
194
+ });
195
+ });
@@ -60,7 +60,7 @@ describe('Process Management Tests', () => {
60
60
  });
61
61
  async function setupServer() {
62
62
  const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
63
- vi.mocked(Server).mockImplementation(() => {
63
+ vi.mocked(Server).mockImplementation(function () {
64
64
  mockServerInstance = {
65
65
  setRequestHandler: vi.fn((schema, handler) => {
66
66
  handlers.set(schema.name, handler);
@@ -174,6 +174,37 @@ describe('ClaudeCodeServer Unit Tests', () => {
174
174
  expect(() => findClaudeCli()).toThrow('Invalid CLAUDE_CLI_NAME: Relative paths are not allowed');
175
175
  });
176
176
  });
177
+ describe('findOpencodeCli function', () => {
178
+ it('should fallback to PATH for OpenCode when no override is configured', async () => {
179
+ mockHomedir.mockReturnValue('/home/user');
180
+ mockExistsSync.mockImplementation((path) => path === '/usr/bin/opencode');
181
+ mockAccessSync.mockImplementation((filePath) => {
182
+ if (filePath === '/usr/bin/opencode')
183
+ return undefined;
184
+ throw new Error('not executable');
185
+ });
186
+ process.env.PATH = '/usr/bin';
187
+ const module = await import('../server.js');
188
+ // @ts-ignore
189
+ const findOpencodeCli = module.default?.findOpencodeCli || module.findOpencodeCli;
190
+ expect(findOpencodeCli()).toBe('/usr/bin/opencode');
191
+ });
192
+ it('should use custom name from OPENCODE_CLI_NAME', async () => {
193
+ process.env.OPENCODE_CLI_NAME = 'opencode-custom';
194
+ mockHomedir.mockReturnValue('/home/user');
195
+ mockExistsSync.mockImplementation((path) => path === '/usr/bin/opencode-custom');
196
+ mockAccessSync.mockImplementation((filePath) => {
197
+ if (filePath === '/usr/bin/opencode-custom')
198
+ return undefined;
199
+ throw new Error('not executable');
200
+ });
201
+ process.env.PATH = '/usr/bin';
202
+ const module = await import('../server.js');
203
+ // @ts-ignore
204
+ const findOpencodeCli = module.default?.findOpencodeCli || module.findOpencodeCli;
205
+ expect(findOpencodeCli()).toBe('opencode-custom');
206
+ });
207
+ });
177
208
  describe('spawnAsync function', () => {
178
209
  let mockProcess;
179
210
  beforeEach(() => {
@@ -276,13 +307,11 @@ describe('ClaudeCodeServer Unit Tests', () => {
276
307
  const server = new ClaudeCodeServer();
277
308
  expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('[Setup] Using Claude CLI command/path:'));
278
309
  });
279
- it('should set up tool handlers', async () => {
310
+ it('should include OpenCode in setup logging', async () => {
280
311
  mockHomedir.mockReturnValue('/home/user');
281
312
  mockExistsSync.mockReturnValue(true);
282
- const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
283
- const mockSetRequestHandler = vi.fn();
284
313
  vi.mocked(Server).mockImplementation(function () {
285
- this.setRequestHandler = mockSetRequestHandler;
314
+ this.setRequestHandler = vi.fn();
286
315
  this.connect = vi.fn();
287
316
  this.close = vi.fn();
288
317
  this.onerror = undefined;
@@ -291,8 +320,8 @@ describe('ClaudeCodeServer Unit Tests', () => {
291
320
  const module = await import('../server.js');
292
321
  // @ts-ignore
293
322
  const { ClaudeCodeServer } = module;
294
- const server = new ClaudeCodeServer();
295
- expect(mockSetRequestHandler).toHaveBeenCalled();
323
+ new ClaudeCodeServer();
324
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('[Setup] Using OpenCode CLI command/path:'));
296
325
  });
297
326
  it('should set up error handler', async () => {
298
327
  mockHomedir.mockReturnValue('/home/user');
@@ -0,0 +1,91 @@
1
+ import { chmodSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ export function createOpenCodeMock(dir, options = {}) {
4
+ const scriptPath = join(dir, 'mock-opencode');
5
+ const defaultSessionId = options.defaultSessionId || 'ses-opencode-default';
6
+ const argsLogPath = options.argsLogPath;
7
+ const argsLogSection = argsLogPath
8
+ ? `printf '%s\n' "$*" >> "${argsLogPath}"\n`
9
+ : '';
10
+ writeFileSync(scriptPath, `#!/bin/bash
11
+ set -euo pipefail
12
+
13
+ prompt=""
14
+ session_id=""
15
+ session_provided=0
16
+ model=""
17
+ work_dir=""
18
+
19
+ ${argsLogSection}if [[ "\${1:-}" == "run" ]]; then
20
+ shift
21
+ fi
22
+
23
+ while [[ $# -gt 0 ]]; do
24
+ case "$1" in
25
+ --format)
26
+ shift 2
27
+ ;;
28
+ --dir)
29
+ work_dir="$2"
30
+ shift 2
31
+ ;;
32
+ --session)
33
+ session_id="$2"
34
+ session_provided=1
35
+ shift 2
36
+ ;;
37
+ --model)
38
+ model="$2"
39
+ shift 2
40
+ ;;
41
+ *)
42
+ prompt="$1"
43
+ shift
44
+ ;;
45
+ esac
46
+ done
47
+
48
+ if [[ -z "$session_id" ]]; then
49
+ session_id="${defaultSessionId}"
50
+ fi
51
+
52
+ if [[ "$prompt" == *"sleep"* ]]; then
53
+ sleep 5
54
+ fi
55
+
56
+ if [[ "$prompt" == *"fail"* ]]; then
57
+ printf '{"type":"step_start","sessionID":"%s"}\n' "$session_id"
58
+ printf '{"type":"text","sessionID":"%s","part":{"type":"text","text":"Partial failure output"}}\n' "$session_id"
59
+ printf '{"type":"step_finish","sessionID":"%s","part":{"type":"step-finish","tokens":{"total":42},"cost":0}}\n' "$session_id"
60
+ printf 'OpenCode failed for %s in %s\n' "$model" "$work_dir" >&2
61
+ exit 7
62
+ fi
63
+
64
+ if [[ "$prompt" == *"multi-step"* ]]; then
65
+ printf '{"type":"step_start","sessionID":"%s"}\n' "$session_id"
66
+ printf '{"type":"text","sessionID":"%s","part":{"type":"text","text":"First step"}}\n' "$session_id"
67
+ printf '{"type":"step_finish","sessionID":"%s","part":{"type":"step-finish","tokens":{"total":11},"cost":0}}\n' "$session_id"
68
+ printf '{"type":"step_start","sessionID":"%s"}\n' "$session_id"
69
+ printf '{"type":"text","sessionID":"%s","part":{"type":"text","text":"Second step"}}\n' "$session_id"
70
+ printf '{"type":"step_finish","sessionID":"%s","part":{"type":"step-finish","tokens":{"total":22},"cost":1}}\n' "$session_id"
71
+ exit 0
72
+ fi
73
+
74
+ message_prefix="Initial"
75
+ if [[ $session_provided -eq 1 ]]; then
76
+ message_prefix="Resumed"
77
+ fi
78
+ if [[ -n "$model" ]]; then
79
+ message_prefix="Model $model"
80
+ if [[ $session_provided -eq 1 ]]; then
81
+ message_prefix="Resumed model $model"
82
+ fi
83
+ fi
84
+
85
+ printf '{"type":"step_start","sessionID":"%s"}\n' "$session_id"
86
+ printf '{"type":"text","sessionID":"%s","part":{"type":"text","text":"%s: %s"}}\n' "$session_id" "$message_prefix" "$prompt"
87
+ printf '{"type":"step_finish","sessionID":"%s","part":{"type":"step-finish","tokens":{"total":11833},"cost":0}}\n' "$session_id"
88
+ `, 'utf8');
89
+ chmodSync(scriptPath, 0o755);
90
+ return { scriptPath, argsLogPath };
91
+ }
@@ -63,7 +63,6 @@ describe('Argument Validation Tests', () => {
63
63
  beforeEach(() => {
64
64
  vi.clearAllMocks();
65
65
  vi.resetModules();
66
- vi.unmock('../server.js');
67
66
  consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
68
67
  // Set up process.env
69
68
  process.env = { ...process.env };
@@ -73,6 +72,7 @@ describe('Argument Validation Tests', () => {
73
72
  mockHomedir.mockReturnValue('/home/user');
74
73
  mockExistsSync.mockReturnValue(true);
75
74
  setupServerMock();
75
+ vi.doUnmock('../server.js');
76
76
  const module = await import('../server.js');
77
77
  // @ts-ignore
78
78
  const { ClaudeCodeServer } = module;
@@ -99,6 +99,7 @@ describe('Argument Validation Tests', () => {
99
99
  mockHomedir.mockReturnValue('/home/user');
100
100
  mockExistsSync.mockReturnValue(true);
101
101
  setupServerMock();
102
+ vi.doUnmock('../server.js');
102
103
  const module = await import('../server.js');
103
104
  // @ts-ignore
104
105
  const { ClaudeCodeServer } = module;
@@ -183,7 +184,7 @@ describe('Argument Validation Tests', () => {
183
184
  vi.mocked(existsSync).mockReturnValue(true);
184
185
  vi.mocked(homedir).mockReturnValue('/home/user');
185
186
  const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
186
- vi.mocked(Server).mockImplementation(() => {
187
+ vi.mocked(Server).mockImplementation(function () {
187
188
  mockServerInstance = {
188
189
  setRequestHandler: vi.fn((schema, handler) => {
189
190
  handlers.set(schema.name, handler);
@@ -194,6 +195,7 @@ describe('Argument Validation Tests', () => {
194
195
  };
195
196
  return mockServerInstance;
196
197
  });
198
+ vi.doUnmock('../server.js');
197
199
  const module = await import('../server.js');
198
200
  // @ts-ignore
199
201
  const { ClaudeCodeServer } = module;
@@ -266,5 +268,41 @@ describe('Argument Validation Tests', () => {
266
268
  }
267
269
  })).rejects.toThrow(/reasoning_effort/i);
268
270
  });
271
+ it.each([
272
+ 'oc-',
273
+ 'oc-openai',
274
+ 'oc-/gpt-5.4',
275
+ 'oc-openai/',
276
+ ' oc-openai/gpt-5.4',
277
+ 'oc-openai/gpt-5.4 ',
278
+ ])('should reject malformed OpenCode model syntax at runtime: %s', async (model) => {
279
+ await setupServer();
280
+ const handler = handlers.get('callTool');
281
+ await expect(handler({
282
+ params: {
283
+ name: 'run',
284
+ arguments: {
285
+ prompt: 'test',
286
+ workFolder: '/tmp',
287
+ model,
288
+ }
289
+ }
290
+ })).rejects.toThrow('Invalid OpenCode model. Expected exact syntax oc-<provider/model>.');
291
+ });
292
+ it('should reject reasoning_effort for OpenCode runtime requests', async () => {
293
+ await setupServer();
294
+ const handler = handlers.get('callTool');
295
+ await expect(handler({
296
+ params: {
297
+ name: 'run',
298
+ arguments: {
299
+ prompt: 'test',
300
+ workFolder: '/tmp',
301
+ model: 'opencode',
302
+ reasoning_effort: 'high',
303
+ }
304
+ }
305
+ })).rejects.toThrow('reasoning_effort is not supported for opencode.');
306
+ });
269
307
  });
270
308
  });
package/dist/app/cli.js CHANGED
@@ -24,9 +24,9 @@ Options:
24
24
  --cwd <path> Working directory
25
25
  --prompt <text> Prompt text
26
26
  --prompt-file <path> Path to a prompt file
27
- --model <model> Model name or alias (e.g. sonnet, claude-ultra, gpt-5.2-codex, codex-ultra, gemini-2.5-pro, gemini-ultra, forge)
28
- --session-id <id> Resume a previous session
29
- --reasoning-effort <level> Reasoning level for Claude/Codex only
27
+ --model <model> Model name or alias (e.g. sonnet, claude-ultra, gpt-5.2-codex, codex-ultra, gemini-2.5-pro, gemini-ultra, forge, opencode, oc-openai/gpt-5.4)
28
+ --session-id <id> Resume a previous session, including OpenCode in-place resumes
29
+ --reasoning-effort <level> Reasoning level for Claude/Codex only; unsupported for Gemini, Forge, and OpenCode
30
30
  --help, -h Show this help message
31
31
 
32
32
  Compatibility aliases:
@@ -83,7 +83,7 @@ Options:
83
83
  `;
84
84
  export const DOCTOR_HELP_TEXT = `Usage: ai-cli doctor
85
85
 
86
- Check whether supported AI CLI binaries are available.
86
+ Check whether supported AI CLI binaries are available, including OpenCode.
87
87
 
88
88
  Options:
89
89
  --help, -h Show this help message
package/dist/app/mcp.js CHANGED
@@ -2,7 +2,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { spawn } from 'node:child_process';
5
- import { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from '../cli-utils.js';
5
+ import { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from '../cli-utils.js';
6
6
  import { getModelParameterDescription, getSupportedModelsDescription } from '../model-catalog.js';
7
7
  import { ProcessService } from '../process-service.js';
8
8
  // Server version - update this when releasing new versions
@@ -59,6 +59,7 @@ export class ClaudeCodeServer {
59
59
  codexCliPath;
60
60
  geminiCliPath;
61
61
  forgeCliPath;
62
+ opencodeCliPath;
62
63
  processService;
63
64
  sigintHandler;
64
65
  packageVersion;
@@ -67,10 +68,12 @@ export class ClaudeCodeServer {
67
68
  this.codexCliPath = findCodexCli();
68
69
  this.geminiCliPath = findGeminiCli();
69
70
  this.forgeCliPath = findForgeCli();
71
+ this.opencodeCliPath = findOpencodeCli();
70
72
  console.error(`[Setup] Using Claude CLI command/path: ${this.claudeCliPath}`);
71
73
  console.error(`[Setup] Using Codex CLI command/path: ${this.codexCliPath}`);
72
74
  console.error(`[Setup] Using Gemini CLI command/path: ${this.geminiCliPath}`);
73
75
  console.error(`[Setup] Using Forge CLI command/path: ${this.forgeCliPath}`);
76
+ console.error(`[Setup] Using OpenCode CLI command/path: ${this.opencodeCliPath}`);
74
77
  this.packageVersion = SERVER_VERSION;
75
78
  this.processService = new ProcessService({
76
79
  cliPaths: {
@@ -78,6 +81,7 @@ export class ClaudeCodeServer {
78
81
  codex: this.codexCliPath,
79
82
  gemini: this.geminiCliPath,
80
83
  forge: this.forgeCliPath,
84
+ opencode: this.opencodeCliPath,
81
85
  },
82
86
  });
83
87
  this.server = new Server({
@@ -101,7 +105,7 @@ export class ClaudeCodeServer {
101
105
  tools: [
102
106
  {
103
107
  name: 'run',
104
- description: `AI Agent Runner: Starts a Claude, Codex, Gemini, or Forge CLI process in the background and returns a PID immediately. Use list_processes and get_result to monitor progress.
108
+ description: `AI Agent Runner: Starts a Claude, Codex, Gemini, Forge, or OpenCode CLI process in the background and returns a PID immediately. Use list_processes and get_result to monitor progress.
105
109
 
106
110
  • File ops: Create, read, (fuzzy) edit, move, copy, delete, list files, analyze/ocr images, file content analysis
107
111
  • Code: Generate / analyse / refactor / fix
@@ -145,11 +149,11 @@ ${getSupportedModelsDescription()}
145
149
  },
146
150
  reasoning_effort: {
147
151
  type: 'string',
148
- description: 'Reasoning control for Claude and Codex. Claude uses --effort with "low", "medium", "high". Codex uses model_reasoning_effort with "low", "medium", "high", "xhigh". Forge does not support reasoning_effort in this integration.',
152
+ description: 'Reasoning control for Claude and Codex. Claude uses --effort with "low", "medium", "high". Codex uses model_reasoning_effort with "low", "medium", "high", "xhigh". Gemini, Forge, and OpenCode do not support reasoning_effort in this integration.',
149
153
  },
150
154
  session_id: {
151
155
  type: 'string',
152
- description: 'Optional session ID to resume a previous session. Supported for: haiku, sonnet, opus, gemini-2.5-pro, gemini-2.5-flash, gemini-3.1-pro-preview, gemini-3-pro-preview, gemini-3-flash-preview, forge.',
156
+ description: 'Optional session ID to resume a previous session. Supported for Claude, Codex, Gemini, Forge, and OpenCode. OpenCode resumes in-place via --session and may also be combined with explicit oc-<provider/model> selection.',
153
157
  },
154
158
  },
155
159
  required: ['workFolder'],
@@ -3,7 +3,8 @@ import { resolve as pathResolve, isAbsolute } from 'node:path';
3
3
  import { MODEL_ALIASES } from './model-catalog.js';
4
4
  export const ALLOWED_REASONING_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh']);
5
5
  const CLAUDE_REASONING_EFFORTS = new Set(['low', 'medium', 'high']);
6
- function getAgentForModel(model) {
6
+ const OPENCODE_MODEL_ERROR = 'Invalid OpenCode model. Expected exact syntax oc-<provider/model>.';
7
+ function getStandardAgentForModel(model) {
7
8
  if (model === 'forge') {
8
9
  return 'forge';
9
10
  }
@@ -15,19 +16,53 @@ function getAgentForModel(model) {
15
16
  }
16
17
  return 'claude';
17
18
  }
18
- /**
19
- * Resolves model aliases to their full model names
20
- * @param model - The model name or alias to resolve
21
- * @returns The full model name, or the original value if no alias exists
22
- */
19
+ function isPotentialOpenCodeExplicitModel(rawModel) {
20
+ return rawModel.startsWith('oc-') || rawModel.trim().startsWith('oc-');
21
+ }
22
+ function extractOpenCodeModel(rawModel) {
23
+ if (rawModel !== rawModel.trim()) {
24
+ throw new Error(OPENCODE_MODEL_ERROR);
25
+ }
26
+ if (!rawModel.startsWith('oc-')) {
27
+ throw new Error(OPENCODE_MODEL_ERROR);
28
+ }
29
+ const remainder = rawModel.slice(3);
30
+ const slashIndex = remainder.indexOf('/');
31
+ if (slashIndex === -1) {
32
+ throw new Error(OPENCODE_MODEL_ERROR);
33
+ }
34
+ const provider = remainder.slice(0, slashIndex);
35
+ const model = remainder.slice(slashIndex + 1);
36
+ if (!provider || !model) {
37
+ throw new Error(OPENCODE_MODEL_ERROR);
38
+ }
39
+ return remainder;
40
+ }
41
+ function resolveModelSelection(rawModel) {
42
+ if (rawModel === 'opencode') {
43
+ return {
44
+ agent: 'opencode',
45
+ resolvedModel: rawModel,
46
+ openCodeModel: null,
47
+ };
48
+ }
49
+ if (isPotentialOpenCodeExplicitModel(rawModel)) {
50
+ return {
51
+ agent: 'opencode',
52
+ resolvedModel: rawModel,
53
+ openCodeModel: extractOpenCodeModel(rawModel),
54
+ };
55
+ }
56
+ const resolvedModel = resolveModelAlias(rawModel);
57
+ return {
58
+ agent: getStandardAgentForModel(resolvedModel),
59
+ resolvedModel,
60
+ openCodeModel: null,
61
+ };
62
+ }
23
63
  export function resolveModelAlias(model) {
24
64
  return MODEL_ALIASES[model] || model;
25
65
  }
26
- /**
27
- * Validates and normalizes reasoning effort parameter.
28
- * @returns normalized reasoning effort string, or '' if not applicable
29
- * @throws Error for invalid values (plain Error, not MCP-specific)
30
- */
31
66
  export function getReasoningEffort(model, rawValue) {
32
67
  if (typeof rawValue !== 'string') {
33
68
  return '';
@@ -36,11 +71,14 @@ export function getReasoningEffort(model, rawValue) {
36
71
  if (!trimmed) {
37
72
  return '';
38
73
  }
74
+ if (model === 'opencode' || model.startsWith('oc-')) {
75
+ throw new Error('reasoning_effort is not supported for opencode.');
76
+ }
39
77
  const normalized = trimmed.toLowerCase();
40
78
  if (!ALLOWED_REASONING_EFFORTS.has(normalized)) {
41
79
  throw new Error(`Invalid reasoning_effort: ${rawValue}. Allowed values: low, medium, high, xhigh.`);
42
80
  }
43
- const agent = getAgentForModel(model);
81
+ const agent = getStandardAgentForModel(model);
44
82
  if (agent === 'forge') {
45
83
  throw new Error('reasoning_effort is not supported for forge.');
46
84
  }
@@ -52,17 +90,10 @@ export function getReasoningEffort(model, rawValue) {
52
90
  }
53
91
  return normalized;
54
92
  }
55
- /**
56
- * Build a CLI command from the given options.
57
- * This is a pure function (aside from filesystem reads for prompt_file / workFolder validation).
58
- * @throws Error on validation failures
59
- */
60
93
  export function buildCliCommand(options) {
61
- // Validate workFolder
62
94
  if (!options.workFolder || typeof options.workFolder !== 'string') {
63
95
  throw new Error('Missing or invalid required parameter: workFolder');
64
96
  }
65
- // Validate prompt / prompt_file
66
97
  const hasPrompt = !!options.prompt && typeof options.prompt === 'string' && options.prompt.trim() !== '';
67
98
  const hasPromptFile = !!options.prompt_file && typeof options.prompt_file === 'string' && options.prompt_file.trim() !== '';
68
99
  if (!hasPrompt && !hasPromptFile) {
@@ -71,7 +102,6 @@ export function buildCliCommand(options) {
71
102
  if (hasPrompt && hasPromptFile) {
72
103
  throw new Error('Cannot specify both prompt and prompt_file. Please use only one.');
73
104
  }
74
- // Determine prompt
75
105
  let prompt;
76
106
  if (hasPrompt) {
77
107
  prompt = options.prompt;
@@ -90,16 +120,12 @@ export function buildCliCommand(options) {
90
120
  throw new Error(`Failed to read prompt file: ${error.message}`);
91
121
  }
92
122
  }
93
- // Resolve workFolder
94
123
  const cwd = pathResolve(options.workFolder);
95
124
  if (!existsSync(cwd)) {
96
125
  throw new Error(`Working folder does not exist: ${options.workFolder}`);
97
126
  }
98
- // Resolve model
99
127
  const rawModel = options.model || '';
100
- const resolvedModel = resolveModelAlias(rawModel);
101
- const agent = getAgentForModel(resolvedModel);
102
- // Special handling for ultra aliases: default to higher reasoning if not specified
128
+ const { agent, resolvedModel, openCodeModel } = resolveModelSelection(rawModel);
103
129
  let reasoningEffortArg = options.reasoning_effort;
104
130
  if (!reasoningEffortArg) {
105
131
  if (rawModel === 'codex-ultra') {
@@ -109,8 +135,10 @@ export function buildCliCommand(options) {
109
135
  reasoningEffortArg = 'high';
110
136
  }
111
137
  }
112
- const reasoningEffort = getReasoningEffort(resolvedModel, reasoningEffortArg);
113
- // Build CLI path and args
138
+ const reasoningTargetModel = rawModel === 'opencode' || rawModel.startsWith('oc-')
139
+ ? rawModel
140
+ : (resolvedModel || rawModel);
141
+ const reasoningEffort = getReasoningEffort(reasoningTargetModel, reasoningEffortArg);
114
142
  let cliPath;
115
143
  let args;
116
144
  if (agent === 'codex') {
@@ -148,6 +176,17 @@ export function buildCliCommand(options) {
148
176
  }
149
177
  args.push('-p', prompt);
150
178
  }
179
+ else if (agent === 'opencode') {
180
+ cliPath = options.cliPaths.opencode;
181
+ args = ['run', '--format', 'json', '--dir', cwd];
182
+ if (options.session_id && typeof options.session_id === 'string') {
183
+ args.push('--session', options.session_id);
184
+ }
185
+ if (openCodeModel) {
186
+ args.push('--model', openCodeModel);
187
+ }
188
+ args.push(prompt);
189
+ }
151
190
  else {
152
191
  cliPath = options.cliPaths.claude;
153
192
  args = ['--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose'];
package/dist/cli-parse.js CHANGED
@@ -1,18 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
3
- const AGENTS = ['claude', 'codex', 'gemini', 'forge'];
4
- const USAGE = `Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini|forge>
2
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput } from './parsers.js';
3
+ const AGENTS = ['claude', 'codex', 'gemini', 'forge', 'opencode'];
4
+ const USAGE = `Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini|forge|opencode>
5
5
 
6
6
  Reads raw CLI output from stdin and outputs parsed JSON to stdout.
7
7
 
8
8
  Options:
9
- --agent Agent type: claude, codex, gemini, or forge (required)
9
+ --agent Agent type: claude, codex, gemini, forge, or opencode (required)
10
10
  --help Show this help message
11
11
 
12
12
  Examples:
13
13
  npm run -s cli.run -- --model sonnet --workFolder /tmp --prompt "hi" > raw.txt
14
14
  npm run -s cli.run.parse -- --agent claude < raw.txt
15
15
 
16
+ npm run -s cli.run -- --model opencode --workFolder /tmp --prompt "hi" > raw.txt
17
+ npm run -s cli.run.parse -- --agent opencode < raw.txt
18
+
16
19
  # Or pipe directly
17
20
  npm run -s cli.run -- --model sonnet --workFolder /tmp --prompt "hi" | npm run -s cli.run.parse -- --agent claude
18
21
  `;
@@ -56,7 +59,7 @@ async function main() {
56
59
  }
57
60
  const agent = args.agent;
58
61
  if (!agent || !AGENTS.includes(agent)) {
59
- process.stderr.write(`Error: --agent is required (claude, codex, gemini, or forge)\n\n`);
62
+ process.stderr.write(`Error: --agent is required (claude, codex, gemini, forge, or opencode)\n\n`);
60
63
  process.stderr.write(USAGE);
61
64
  process.exit(1);
62
65
  }
@@ -79,6 +82,9 @@ async function main() {
79
82
  case 'forge':
80
83
  parsed = parseForgeOutput(input);
81
84
  break;
85
+ case 'opencode':
86
+ parsed = parseOpenCodeOutput(input);
87
+ break;
82
88
  }
83
89
  process.stdout.write(JSON.stringify(parsed, null, 2) + '\n');
84
90
  }