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.
- package/.github/dependabot.yml +28 -0
- package/.github/workflows/ci.yml +4 -1
- package/.github/workflows/dependency-review.yml +22 -0
- package/CHANGELOG.md +14 -0
- package/README.ja.md +25 -6
- package/README.md +25 -7
- package/dist/__tests__/app-cli.test.js +24 -4
- package/dist/__tests__/cli-bin-smoke.test.js +43 -0
- package/dist/__tests__/cli-builder.test.js +92 -14
- package/dist/__tests__/cli-process-service.test.js +187 -0
- package/dist/__tests__/cli-utils.test.js +31 -0
- package/dist/__tests__/e2e.test.js +77 -51
- package/dist/__tests__/mcp-contract.test.js +154 -0
- package/dist/__tests__/parsers.test.js +62 -1
- package/dist/__tests__/process-management.test.js +1 -1
- package/dist/__tests__/server.test.js +35 -6
- package/dist/__tests__/utils/opencode-mock.js +91 -0
- package/dist/__tests__/validation.test.js +40 -2
- package/dist/app/cli.js +4 -4
- package/dist/app/mcp.js +8 -4
- package/dist/cli-builder.js +66 -27
- package/dist/cli-parse.js +11 -5
- package/dist/cli-process-service.js +158 -25
- package/dist/cli-utils.js +14 -23
- package/dist/cli.js +6 -4
- package/dist/model-catalog.js +13 -1
- package/dist/parsers.js +57 -26
- package/dist/process-result.js +9 -2
- package/dist/process-service.js +23 -17
- package/dist/server.js +1 -2
- package/package.json +9 -6
- package/src/__tests__/app-cli.test.ts +24 -4
- package/src/__tests__/cli-bin-smoke.test.ts +62 -1
- package/src/__tests__/cli-builder.test.ts +110 -14
- package/src/__tests__/cli-process-service.test.ts +217 -0
- package/src/__tests__/cli-utils.test.ts +34 -0
- package/src/__tests__/e2e.test.ts +85 -54
- package/src/__tests__/mcp-contract.test.ts +179 -0
- package/src/__tests__/parsers.test.ts +73 -1
- package/src/__tests__/process-management.test.ts +1 -1
- package/src/__tests__/server.test.ts +45 -10
- package/src/__tests__/utils/opencode-mock.ts +108 -0
- package/src/__tests__/validation.test.ts +48 -2
- package/src/app/cli.ts +4 -4
- package/src/app/mcp.ts +8 -4
- package/src/cli-builder.ts +90 -31
- package/src/cli-parse.ts +11 -5
- package/src/cli-process-service.ts +193 -22
- package/src/cli-utils.ts +37 -33
- package/src/cli.ts +6 -4
- package/src/model-catalog.ts +24 -1
- package/src/parsers.ts +77 -31
- package/src/process-result.ts +11 -2
- package/src/process-service.ts +28 -15
- package/src/server.ts +2 -2
- 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
|
|
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 =
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
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
|
|
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
|
|
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'],
|