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.
- package/.github/dependabot.yml +28 -0
- package/.github/workflows/ci.yml +4 -1
- package/.github/workflows/dependency-review.yml +22 -0
- package/CHANGELOG.md +7 -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 +103 -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 +139 -20
- 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 +112 -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 +171 -17
- 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
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
295
|
-
expect(
|
|
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
|
|
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
|
|
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
|
|
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'],
|
package/dist/cli-builder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
113
|
-
|
|
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
|
|
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
|
|
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
|
}
|