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
@@ -3,6 +3,7 @@ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { cleanupSharedMock, getSharedMock } from './utils/persistent-mock.js';
6
+ import { createOpenCodeMock } from './utils/opencode-mock.js';
6
7
  import { createTestClient, MCPTestClient } from './utils/mcp-client.js';
7
8
 
8
9
  function parseToolJson(content: any): any {
@@ -109,6 +110,13 @@ describe('MCP Contract Tests', () => {
109
110
  'session_id',
110
111
  'workFolder',
111
112
  ]);
113
+ expect(runTool.description).toContain('OpenCode');
114
+ expect(runTool.inputSchema.properties.model.description).toContain('opencode');
115
+ expect(runTool.inputSchema.properties.model.description).toContain('oc-<provider/model>');
116
+ expect(runTool.inputSchema.properties.reasoning_effort.description).toContain('OpenCode do not support reasoning_effort');
117
+ expect(runTool.inputSchema.properties.session_id.description).toBe(
118
+ '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.'
119
+ );
112
120
 
113
121
  const getResultTool = tools.find((tool: any) => tool.name === 'get_result');
114
122
  expect(getResultTool.inputSchema.required).toEqual(['pid']);
@@ -470,6 +478,177 @@ printf '%s\n' '{"type":"system","session_id":"session-verbose-1"}'
470
478
  ).rejects.toThrow(/reasoning_effort is not supported for forge/i);
471
479
  });
472
480
 
481
+ it('covers OpenCode end-to-end through the MCP process path', async () => {
482
+ await client.disconnect();
483
+
484
+ const opencodeArgsLogPath = join(testDir, 'opencode-args.log');
485
+ const { scriptPath: openCodeMockPath } = createOpenCodeMock(testDir, {
486
+ argsLogPath: opencodeArgsLogPath,
487
+ defaultSessionId: 'ses-opencode-contract',
488
+ });
489
+
490
+ client = createTestClient({
491
+ debug: false,
492
+ env: {
493
+ OPENCODE_CLI_NAME: openCodeMockPath,
494
+ },
495
+ });
496
+ await client.connect();
497
+
498
+ const initialRunResponse = await client.callTool('run', {
499
+ prompt: 'opencode-initial-prompt',
500
+ workFolder: testDir,
501
+ model: 'opencode',
502
+ });
503
+ const initialRunData = parseToolJson(initialRunResponse);
504
+
505
+ expect(initialRunData).toEqual({
506
+ pid: expect.any(Number),
507
+ status: 'started',
508
+ agent: 'opencode',
509
+ message: expect.any(String),
510
+ });
511
+
512
+ const initialWaitResponse = await client.callTool('wait', { pids: [initialRunData.pid], timeout: 5 });
513
+ const initialWaitData = parseToolJson(initialWaitResponse);
514
+
515
+ expect(initialWaitData).toHaveLength(1);
516
+ expect(initialWaitData[0]).toMatchObject({
517
+ pid: initialRunData.pid,
518
+ agent: 'opencode',
519
+ status: 'completed',
520
+ exitCode: 0,
521
+ model: 'opencode',
522
+ session_id: 'ses-opencode-contract',
523
+ agentOutput: {
524
+ message: 'Initial: opencode-initial-prompt',
525
+ session_id: 'ses-opencode-contract',
526
+ tokens: { total: 11833 },
527
+ cost: 0,
528
+ },
529
+ });
530
+
531
+ const resumedDefaultRunResponse = await client.callTool('run', {
532
+ prompt: 'opencode-resume-default',
533
+ workFolder: testDir,
534
+ model: 'opencode',
535
+ session_id: 'ses-opencode-contract',
536
+ });
537
+ const resumedDefaultRunData = parseToolJson(resumedDefaultRunResponse);
538
+
539
+ const resumedDefaultWaitResponse = await client.callTool('wait', { pids: [resumedDefaultRunData.pid], timeout: 5 });
540
+ const resumedDefaultWaitData = parseToolJson(resumedDefaultWaitResponse);
541
+
542
+ expect(resumedDefaultWaitData).toHaveLength(1);
543
+ expect(resumedDefaultWaitData[0]).toMatchObject({
544
+ pid: resumedDefaultRunData.pid,
545
+ agent: 'opencode',
546
+ status: 'completed',
547
+ exitCode: 0,
548
+ model: 'opencode',
549
+ session_id: 'ses-opencode-contract',
550
+ agentOutput: {
551
+ message: 'Resumed: opencode-resume-default',
552
+ session_id: 'ses-opencode-contract',
553
+ tokens: { total: 11833 },
554
+ cost: 0,
555
+ },
556
+ });
557
+
558
+ const resumedExplicitRunResponse = await client.callTool('run', {
559
+ prompt: 'opencode-resume-explicit',
560
+ workFolder: testDir,
561
+ model: 'oc-openai/gpt-5.4',
562
+ session_id: 'ses-opencode-contract',
563
+ });
564
+ const resumedExplicitRunData = parseToolJson(resumedExplicitRunResponse);
565
+
566
+ const resumedExplicitWaitResponse = await client.callTool('wait', { pids: [resumedExplicitRunData.pid], timeout: 5 });
567
+ const resumedExplicitWaitData = parseToolJson(resumedExplicitWaitResponse);
568
+
569
+ expect(resumedExplicitWaitData).toHaveLength(1);
570
+ expect(resumedExplicitWaitData[0]).toMatchObject({
571
+ pid: resumedExplicitRunData.pid,
572
+ agent: 'opencode',
573
+ status: 'completed',
574
+ exitCode: 0,
575
+ model: 'oc-openai/gpt-5.4',
576
+ session_id: 'ses-opencode-contract',
577
+ agentOutput: {
578
+ message: 'Resumed model openai/gpt-5.4: opencode-resume-explicit',
579
+ session_id: 'ses-opencode-contract',
580
+ tokens: { total: 11833 },
581
+ cost: 0,
582
+ },
583
+ });
584
+
585
+ const failedRunResponse = await client.callTool('run', {
586
+ prompt: 'please fail',
587
+ workFolder: testDir,
588
+ model: 'oc-openai/gpt-5.4',
589
+ });
590
+ const failedRunData = parseToolJson(failedRunResponse);
591
+
592
+ const compactFailedWait = parseToolJson(await client.callTool('wait', { pids: [failedRunData.pid], timeout: 5 }));
593
+ expect(compactFailedWait).toHaveLength(1);
594
+ expect(compactFailedWait[0]).toMatchObject({
595
+ pid: failedRunData.pid,
596
+ agent: 'opencode',
597
+ status: 'failed',
598
+ exitCode: 7,
599
+ model: 'oc-openai/gpt-5.4',
600
+ session_id: 'ses-opencode-contract',
601
+ stdout: expect.stringContaining('Partial failure output'),
602
+ stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
603
+ });
604
+ expect(compactFailedWait[0]).not.toHaveProperty('agentOutput');
605
+
606
+ const verboseFailedResult = parseToolJson(await client.callTool('get_result', { pid: failedRunData.pid, verbose: true }));
607
+ expect(verboseFailedResult).toMatchObject({
608
+ pid: failedRunData.pid,
609
+ agent: 'opencode',
610
+ status: 'failed',
611
+ exitCode: 7,
612
+ model: 'oc-openai/gpt-5.4',
613
+ session_id: 'ses-opencode-contract',
614
+ stdout: expect.stringContaining('Partial failure output'),
615
+ stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
616
+ agentOutput: {
617
+ message: 'Partial failure output',
618
+ session_id: 'ses-opencode-contract',
619
+ tokens: { total: 42 },
620
+ cost: 0,
621
+ },
622
+ });
623
+
624
+ const openCodeInvocations = readFileSync(opencodeArgsLogPath, 'utf-8').trim().split('\n');
625
+ expect(openCodeInvocations).toHaveLength(4);
626
+ expect(openCodeInvocations[0]).toContain('run --format json');
627
+ expect(openCodeInvocations[0]).toContain(`--dir ${testDir}`);
628
+ expect(openCodeInvocations[0]).not.toContain('--session');
629
+ expect(openCodeInvocations[0]).not.toContain('--model');
630
+
631
+ expect(openCodeInvocations[1]).toContain(`--dir ${testDir}`);
632
+ expect(openCodeInvocations[1]).toContain('--session ses-opencode-contract');
633
+ expect(openCodeInvocations[1]).not.toContain('--model');
634
+
635
+ expect(openCodeInvocations[2]).toContain(`--dir ${testDir}`);
636
+ expect(openCodeInvocations[2]).toContain('--session ses-opencode-contract');
637
+ expect(openCodeInvocations[2]).toContain('--model openai/gpt-5.4');
638
+
639
+ expect(openCodeInvocations[3]).toContain(`--dir ${testDir}`);
640
+ expect(openCodeInvocations[3]).toContain('--model openai/gpt-5.4');
641
+
642
+ await expect(
643
+ client.callTool('run', {
644
+ prompt: 'opencode-invalid-reasoning',
645
+ workFolder: testDir,
646
+ model: 'opencode',
647
+ reasoning_effort: 'high',
648
+ })
649
+ ).rejects.toThrow(/reasoning_effort is not supported for opencode/i);
650
+ });
651
+
473
652
  it('keeps key invalid-input errors stable', async () => {
474
653
  await expect(
475
654
  client.callTool('run', {
@@ -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
 
4
4
  describe('parseCodexOutput', () => {
5
5
  it('should parse basic Codex output with message and session_id', () => {
@@ -149,3 +149,75 @@ still streaming`;
149
149
  expect(parseForgeOutput('plain text')).toBeNull();
150
150
  });
151
151
  });
152
+
153
+ describe('parseOpenCodeOutput', () => {
154
+ it('parses a single completed OpenCode step', () => {
155
+ const output = `{"type":"step_start","sessionID":"ses_1"}
156
+ {"type":"text","sessionID":"ses_1","part":{"type":"text","text":"Hello"}}
157
+ {"type":"step_finish","sessionID":"ses_1","part":{"type":"step-finish","tokens":{"total":11833},"cost":0}}`;
158
+
159
+ expect(parseOpenCodeOutput(output)).toEqual({
160
+ message: 'Hello',
161
+ session_id: 'ses_1',
162
+ tokens: { total: 11833 },
163
+ cost: 0,
164
+ });
165
+ });
166
+
167
+ it('returns the last completed step for multi-step output', () => {
168
+ const output = `{"type":"step_start","sessionID":"ses_2"}
169
+ {"type":"text","sessionID":"ses_2","part":{"type":"text","text":"First"}}
170
+ {"type":"step_finish","sessionID":"ses_2","part":{"type":"step-finish","tokens":{"total":10},"cost":0}}
171
+ {"type":"step_start","sessionID":"ses_2"}
172
+ {"type":"text","sessionID":"ses_2","part":{"type":"text","text":"Second"}}
173
+ {"type":"step_finish","sessionID":"ses_2","part":{"type":"step-finish","tokens":{"total":20},"cost":1}}`;
174
+
175
+ expect(parseOpenCodeOutput(output)).toEqual({
176
+ message: 'Second',
177
+ session_id: 'ses_2',
178
+ tokens: { total: 20 },
179
+ cost: 1,
180
+ });
181
+ });
182
+
183
+ it('resets the current-step buffer on each step_start', () => {
184
+ const output = `{"type":"step_start","sessionID":"ses_3"}
185
+ {"type":"text","sessionID":"ses_3","part":{"type":"text","text":"Discard me"}}
186
+ {"type":"step_start","sessionID":"ses_3"}
187
+ {"type":"text","sessionID":"ses_3","part":{"type":"text","text":"Keep me"}}
188
+ {"type":"step_finish","sessionID":"ses_3","part":{"type":"step-finish","tokens":{"total":5},"cost":0}}`;
189
+
190
+ expect(parseOpenCodeOutput(output)).toEqual({
191
+ message: 'Keep me',
192
+ session_id: 'ses_3',
193
+ tokens: { total: 5 },
194
+ cost: 0,
195
+ });
196
+ });
197
+
198
+ it('returns partial output when text exists without step_finish', () => {
199
+ const output = `{"type":"step_start","sessionID":"ses_4"}
200
+ {"type":"text","sessionID":"ses_4","part":{"type":"text","text":"Partial"}}`;
201
+
202
+ expect(parseOpenCodeOutput(output)).toEqual({
203
+ message: 'Partial',
204
+ session_id: 'ses_4',
205
+ });
206
+ });
207
+
208
+ it('ignores malformed lines and unknown event types', () => {
209
+ const output = `not-json
210
+ {"type":"unknown","sessionID":"ses_5"}
211
+ {"type":"text","sessionID":"ses_5","part":{"type":"text","text":"Hello"}}`;
212
+
213
+ expect(parseOpenCodeOutput(output)).toEqual({
214
+ message: 'Hello',
215
+ session_id: 'ses_5',
216
+ });
217
+ });
218
+
219
+ it('returns null when no useful OpenCode events exist', () => {
220
+ expect(parseOpenCodeOutput('{"type":"unknown"}')).toBeNull();
221
+ expect(parseOpenCodeOutput('')).toBeNull();
222
+ });
223
+ });
@@ -68,7 +68,7 @@ describe('Process Management Tests', () => {
68
68
  async function setupServer() {
69
69
  const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
70
70
 
71
- vi.mocked(Server).mockImplementation(() => {
71
+ vi.mocked(Server).mockImplementation(function(this: any) {
72
72
  mockServerInstance = {
73
73
  setRequestHandler: vi.fn((schema: any, handler: Function) => {
74
74
  handlers.set(schema.name, handler);
@@ -201,6 +201,41 @@ describe('ClaudeCodeServer Unit Tests', () => {
201
201
  });
202
202
  });
203
203
 
204
+ describe('findOpencodeCli function', () => {
205
+ it('should fallback to PATH for OpenCode when no override is configured', async () => {
206
+ mockHomedir.mockReturnValue('/home/user');
207
+ mockExistsSync.mockImplementation((path) => path === '/usr/bin/opencode');
208
+ mockAccessSync.mockImplementation((filePath) => {
209
+ if (filePath === '/usr/bin/opencode') return undefined;
210
+ throw new Error('not executable');
211
+ });
212
+ process.env.PATH = '/usr/bin';
213
+
214
+ const module = await import('../server.js');
215
+ // @ts-ignore
216
+ const findOpencodeCli = module.default?.findOpencodeCli || module.findOpencodeCli;
217
+
218
+ expect(findOpencodeCli()).toBe('/usr/bin/opencode');
219
+ });
220
+
221
+ it('should use custom name from OPENCODE_CLI_NAME', async () => {
222
+ process.env.OPENCODE_CLI_NAME = 'opencode-custom';
223
+ mockHomedir.mockReturnValue('/home/user');
224
+ mockExistsSync.mockImplementation((path) => path === '/usr/bin/opencode-custom');
225
+ mockAccessSync.mockImplementation((filePath) => {
226
+ if (filePath === '/usr/bin/opencode-custom') return undefined;
227
+ throw new Error('not executable');
228
+ });
229
+ process.env.PATH = '/usr/bin';
230
+
231
+ const module = await import('../server.js');
232
+ // @ts-ignore
233
+ const findOpencodeCli = module.default?.findOpencodeCli || module.findOpencodeCli;
234
+
235
+ expect(findOpencodeCli()).toBe('opencode-custom');
236
+ });
237
+ });
238
+
204
239
  describe('spawnAsync function', () => {
205
240
  let mockProcess: any;
206
241
 
@@ -333,29 +368,29 @@ describe('ClaudeCodeServer Unit Tests', () => {
333
368
  );
334
369
  });
335
370
 
336
- it('should set up tool handlers', async () => {
371
+ it('should include OpenCode in setup logging', async () => {
337
372
  mockHomedir.mockReturnValue('/home/user');
338
373
  mockExistsSync.mockReturnValue(true);
339
-
340
- const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
341
- const mockSetRequestHandler = vi.fn();
374
+
342
375
  vi.mocked(Server).mockImplementation(function(this: any) {
343
- this.setRequestHandler = mockSetRequestHandler;
376
+ this.setRequestHandler = vi.fn();
344
377
  this.connect = vi.fn();
345
378
  this.close = vi.fn();
346
379
  this.onerror = undefined;
347
380
  return this;
348
381
  });
349
-
382
+
350
383
  const module = await import('../server.js');
351
384
  // @ts-ignore
352
385
  const { ClaudeCodeServer } = module;
353
-
354
- const server = new ClaudeCodeServer();
355
-
356
- expect(mockSetRequestHandler).toHaveBeenCalled();
386
+ new ClaudeCodeServer();
387
+
388
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
389
+ expect.stringContaining('[Setup] Using OpenCode CLI command/path:')
390
+ );
357
391
  });
358
392
 
393
+
359
394
  it('should set up error handler', async () => {
360
395
  mockHomedir.mockReturnValue('/home/user');
361
396
  mockExistsSync.mockReturnValue(true);
@@ -0,0 +1,108 @@
1
+ import { chmodSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export interface OpenCodeMockOptions {
5
+ argsLogPath?: string;
6
+ defaultSessionId?: string;
7
+ }
8
+
9
+ export interface OpenCodeMockResult {
10
+ scriptPath: string;
11
+ argsLogPath?: string;
12
+ }
13
+
14
+ export function createOpenCodeMock(dir: string, options: OpenCodeMockOptions = {}): OpenCodeMockResult {
15
+ const scriptPath = join(dir, 'mock-opencode');
16
+ const defaultSessionId = options.defaultSessionId || 'ses-opencode-default';
17
+ const argsLogPath = options.argsLogPath;
18
+ const argsLogSection = argsLogPath
19
+ ? `printf '%s\n' "$*" >> "${argsLogPath}"\n`
20
+ : '';
21
+
22
+ writeFileSync(
23
+ scriptPath,
24
+ `#!/bin/bash
25
+ set -euo pipefail
26
+
27
+ prompt=""
28
+ session_id=""
29
+ session_provided=0
30
+ model=""
31
+ work_dir=""
32
+
33
+ ${argsLogSection}if [[ "\${1:-}" == "run" ]]; then
34
+ shift
35
+ fi
36
+
37
+ while [[ $# -gt 0 ]]; do
38
+ case "$1" in
39
+ --format)
40
+ shift 2
41
+ ;;
42
+ --dir)
43
+ work_dir="$2"
44
+ shift 2
45
+ ;;
46
+ --session)
47
+ session_id="$2"
48
+ session_provided=1
49
+ shift 2
50
+ ;;
51
+ --model)
52
+ model="$2"
53
+ shift 2
54
+ ;;
55
+ *)
56
+ prompt="$1"
57
+ shift
58
+ ;;
59
+ esac
60
+ done
61
+
62
+ if [[ -z "$session_id" ]]; then
63
+ session_id="${defaultSessionId}"
64
+ fi
65
+
66
+ if [[ "$prompt" == *"sleep"* ]]; then
67
+ sleep 5
68
+ fi
69
+
70
+ if [[ "$prompt" == *"fail"* ]]; then
71
+ printf '{"type":"step_start","sessionID":"%s"}\n' "$session_id"
72
+ printf '{"type":"text","sessionID":"%s","part":{"type":"text","text":"Partial failure output"}}\n' "$session_id"
73
+ printf '{"type":"step_finish","sessionID":"%s","part":{"type":"step-finish","tokens":{"total":42},"cost":0}}\n' "$session_id"
74
+ printf 'OpenCode failed for %s in %s\n' "$model" "$work_dir" >&2
75
+ exit 7
76
+ fi
77
+
78
+ if [[ "$prompt" == *"multi-step"* ]]; then
79
+ printf '{"type":"step_start","sessionID":"%s"}\n' "$session_id"
80
+ printf '{"type":"text","sessionID":"%s","part":{"type":"text","text":"First step"}}\n' "$session_id"
81
+ printf '{"type":"step_finish","sessionID":"%s","part":{"type":"step-finish","tokens":{"total":11},"cost":0}}\n' "$session_id"
82
+ printf '{"type":"step_start","sessionID":"%s"}\n' "$session_id"
83
+ printf '{"type":"text","sessionID":"%s","part":{"type":"text","text":"Second step"}}\n' "$session_id"
84
+ printf '{"type":"step_finish","sessionID":"%s","part":{"type":"step-finish","tokens":{"total":22},"cost":1}}\n' "$session_id"
85
+ exit 0
86
+ fi
87
+
88
+ message_prefix="Initial"
89
+ if [[ $session_provided -eq 1 ]]; then
90
+ message_prefix="Resumed"
91
+ fi
92
+ if [[ -n "$model" ]]; then
93
+ message_prefix="Model $model"
94
+ if [[ $session_provided -eq 1 ]]; then
95
+ message_prefix="Resumed model $model"
96
+ fi
97
+ fi
98
+
99
+ printf '{"type":"step_start","sessionID":"%s"}\n' "$session_id"
100
+ printf '{"type":"text","sessionID":"%s","part":{"type":"text","text":"%s: %s"}}\n' "$session_id" "$message_prefix" "$prompt"
101
+ printf '{"type":"step_finish","sessionID":"%s","part":{"type":"step-finish","tokens":{"total":11833},"cost":0}}\n' "$session_id"
102
+ `,
103
+ 'utf8',
104
+ );
105
+ chmodSync(scriptPath, 0o755);
106
+
107
+ return { scriptPath, argsLogPath };
108
+ }
@@ -69,7 +69,6 @@ describe('Argument Validation Tests', () => {
69
69
  beforeEach(() => {
70
70
  vi.clearAllMocks();
71
71
  vi.resetModules();
72
- vi.unmock('../server.js');
73
72
  consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
74
73
  // Set up process.env
75
74
  process.env = { ...process.env };
@@ -80,6 +79,7 @@ describe('Argument Validation Tests', () => {
80
79
  mockHomedir.mockReturnValue('/home/user');
81
80
  mockExistsSync.mockReturnValue(true);
82
81
  setupServerMock();
82
+ vi.doUnmock('../server.js');
83
83
  const module = await import('../server.js');
84
84
  // @ts-ignore
85
85
  const { ClaudeCodeServer } = module;
@@ -114,6 +114,7 @@ describe('Argument Validation Tests', () => {
114
114
  mockHomedir.mockReturnValue('/home/user');
115
115
  mockExistsSync.mockReturnValue(true);
116
116
  setupServerMock();
117
+ vi.doUnmock('../server.js');
117
118
  const module = await import('../server.js');
118
119
  // @ts-ignore
119
120
  const { ClaudeCodeServer } = module;
@@ -216,7 +217,7 @@ describe('Argument Validation Tests', () => {
216
217
 
217
218
  const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
218
219
 
219
- vi.mocked(Server).mockImplementation(() => {
220
+ vi.mocked(Server).mockImplementation(function(this: any) {
220
221
  mockServerInstance = {
221
222
  setRequestHandler: vi.fn((schema: any, handler: Function) => {
222
223
  handlers.set(schema.name, handler);
@@ -228,6 +229,7 @@ describe('Argument Validation Tests', () => {
228
229
  return mockServerInstance as any;
229
230
  });
230
231
 
232
+ vi.doUnmock('../server.js');
231
233
  const module = await import('../server.js');
232
234
  // @ts-ignore
233
235
  const { ClaudeCodeServer } = module;
@@ -319,5 +321,49 @@ describe('Argument Validation Tests', () => {
319
321
  })
320
322
  ).rejects.toThrow(/reasoning_effort/i);
321
323
  });
324
+
325
+ it.each([
326
+ 'oc-',
327
+ 'oc-openai',
328
+ 'oc-/gpt-5.4',
329
+ 'oc-openai/',
330
+ ' oc-openai/gpt-5.4',
331
+ 'oc-openai/gpt-5.4 ',
332
+ ])('should reject malformed OpenCode model syntax at runtime: %s', async (model) => {
333
+ await setupServer();
334
+ const handler = handlers.get('callTool')!;
335
+
336
+ await expect(
337
+ handler({
338
+ params: {
339
+ name: 'run',
340
+ arguments: {
341
+ prompt: 'test',
342
+ workFolder: '/tmp',
343
+ model,
344
+ }
345
+ }
346
+ })
347
+ ).rejects.toThrow('Invalid OpenCode model. Expected exact syntax oc-<provider/model>.');
348
+ });
349
+
350
+ it('should reject reasoning_effort for OpenCode runtime requests', async () => {
351
+ await setupServer();
352
+ const handler = handlers.get('callTool')!;
353
+
354
+ await expect(
355
+ handler({
356
+ params: {
357
+ name: 'run',
358
+ arguments: {
359
+ prompt: 'test',
360
+ workFolder: '/tmp',
361
+ model: 'opencode',
362
+ reasoning_effort: 'high',
363
+ }
364
+ }
365
+ })
366
+ ).rejects.toThrow('reasoning_effort is not supported for opencode.');
367
+ });
322
368
  });
323
369
  });
package/src/app/cli.ts CHANGED
@@ -26,9 +26,9 @@ Options:
26
26
  --cwd <path> Working directory
27
27
  --prompt <text> Prompt text
28
28
  --prompt-file <path> Path to a prompt file
29
- --model <model> Model name or alias (e.g. sonnet, claude-ultra, gpt-5.2-codex, codex-ultra, gemini-2.5-pro, gemini-ultra, forge)
30
- --session-id <id> Resume a previous session
31
- --reasoning-effort <level> Reasoning level for Claude/Codex only
29
+ --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)
30
+ --session-id <id> Resume a previous session, including OpenCode in-place resumes
31
+ --reasoning-effort <level> Reasoning level for Claude/Codex only; unsupported for Gemini, Forge, and OpenCode
32
32
  --help, -h Show this help message
33
33
 
34
34
  Compatibility aliases:
@@ -92,7 +92,7 @@ Options:
92
92
 
93
93
  export const DOCTOR_HELP_TEXT = `Usage: ai-cli doctor
94
94
 
95
- Check whether supported AI CLI binaries are available.
95
+ Check whether supported AI CLI binaries are available, including OpenCode.
96
96
 
97
97
  Options:
98
98
  --help, -h Show this help message
package/src/app/mcp.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  type ServerResult,
9
9
  } from '@modelcontextprotocol/sdk/types.js';
10
10
  import { spawn } from 'node:child_process';
11
- import { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from '../cli-utils.js';
11
+ import { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from '../cli-utils.js';
12
12
  import { getModelParameterDescription, getSupportedModelsDescription } from '../model-catalog.js';
13
13
  import { ProcessService } from '../process-service.js';
14
14
 
@@ -73,6 +73,7 @@ export class ClaudeCodeServer {
73
73
  private codexCliPath: string;
74
74
  private geminiCliPath: string;
75
75
  private forgeCliPath: string;
76
+ private opencodeCliPath: string;
76
77
  private processService: ProcessService;
77
78
  private sigintHandler?: () => Promise<void>;
78
79
  private packageVersion: string;
@@ -82,10 +83,12 @@ export class ClaudeCodeServer {
82
83
  this.codexCliPath = findCodexCli();
83
84
  this.geminiCliPath = findGeminiCli();
84
85
  this.forgeCliPath = findForgeCli();
86
+ this.opencodeCliPath = findOpencodeCli();
85
87
  console.error(`[Setup] Using Claude CLI command/path: ${this.claudeCliPath}`);
86
88
  console.error(`[Setup] Using Codex CLI command/path: ${this.codexCliPath}`);
87
89
  console.error(`[Setup] Using Gemini CLI command/path: ${this.geminiCliPath}`);
88
90
  console.error(`[Setup] Using Forge CLI command/path: ${this.forgeCliPath}`);
91
+ console.error(`[Setup] Using OpenCode CLI command/path: ${this.opencodeCliPath}`);
89
92
  this.packageVersion = SERVER_VERSION;
90
93
  this.processService = new ProcessService({
91
94
  cliPaths: {
@@ -93,6 +96,7 @@ export class ClaudeCodeServer {
93
96
  codex: this.codexCliPath,
94
97
  gemini: this.geminiCliPath,
95
98
  forge: this.forgeCliPath,
99
+ opencode: this.opencodeCliPath,
96
100
  },
97
101
  });
98
102
 
@@ -123,7 +127,7 @@ export class ClaudeCodeServer {
123
127
  tools: [
124
128
  {
125
129
  name: 'run',
126
- 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.
130
+ 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.
127
131
 
128
132
  • File ops: Create, read, (fuzzy) edit, move, copy, delete, list files, analyze/ocr images, file content analysis
129
133
  • Code: Generate / analyse / refactor / fix
@@ -167,11 +171,11 @@ ${getSupportedModelsDescription()}
167
171
  },
168
172
  reasoning_effort: {
169
173
  type: 'string',
170
- 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.',
174
+ 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.',
171
175
  },
172
176
  session_id: {
173
177
  type: 'string',
174
- 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.',
178
+ 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.',
175
179
  },
176
180
  },
177
181
  required: ['workFolder'],