ai-cli-mcp 2.14.1 → 2.16.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 +83 -6
- package/README.md +83 -7
- package/dist/__tests__/app-cli.test.js +80 -5
- package/dist/__tests__/cli-bin-smoke.test.js +43 -0
- package/dist/__tests__/cli-builder.test.js +93 -15
- package/dist/__tests__/cli-process-service.test.js +162 -0
- package/dist/__tests__/cli-utils.test.js +31 -0
- package/dist/__tests__/e2e.test.js +79 -52
- package/dist/__tests__/mcp-contract.test.js +162 -0
- package/dist/__tests__/parsers.test.js +224 -1
- package/dist/__tests__/peek.test.js +35 -0
- package/dist/__tests__/process-management.test.js +160 -1
- package/dist/__tests__/server.test.js +39 -9
- package/dist/__tests__/utils/opencode-mock.js +91 -0
- package/dist/__tests__/validation.test.js +40 -2
- package/dist/app/cli.js +47 -5
- package/dist/app/mcp.js +53 -4
- package/dist/cli-builder.js +67 -28
- package/dist/cli-parse.js +11 -5
- package/dist/cli-process-service.js +241 -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 +242 -28
- package/dist/peek.js +56 -0
- package/dist/process-result.js +9 -2
- package/dist/process-service.js +103 -17
- package/dist/server.js +1 -2
- package/package.json +9 -6
- package/src/__tests__/app-cli.test.ts +95 -4
- package/src/__tests__/cli-bin-smoke.test.ts +62 -1
- package/src/__tests__/cli-builder.test.ts +111 -15
- package/src/__tests__/cli-process-service.test.ts +180 -0
- package/src/__tests__/cli-utils.test.ts +34 -0
- package/src/__tests__/e2e.test.ts +87 -55
- package/src/__tests__/mcp-contract.test.ts +188 -0
- package/src/__tests__/parsers.test.ts +260 -1
- package/src/__tests__/peek.test.ts +43 -0
- package/src/__tests__/process-management.test.ts +185 -1
- package/src/__tests__/server.test.ts +49 -13
- package/src/__tests__/utils/opencode-mock.ts +108 -0
- package/src/__tests__/validation.test.ts +48 -2
- package/src/app/cli.ts +52 -4
- package/src/app/mcp.ts +54 -4
- package/src/cli-builder.ts +91 -32
- package/src/cli-parse.ts +11 -5
- package/src/cli-process-service.ts +304 -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 +299 -33
- package/src/peek.ts +88 -0
- package/src/process-result.ts +11 -2
- package/src/process-service.ts +134 -15
- package/src/server.ts +2 -2
- package/vitest.config.unit.ts +2 -3
|
@@ -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
|
@@ -2,12 +2,14 @@ import { runMcpServer } from './mcp.js';
|
|
|
2
2
|
import { CliProcessService } from '../cli-process-service.js';
|
|
3
3
|
import { getCliDoctorStatus } from '../cli-utils.js';
|
|
4
4
|
import { getModelsPayload } from '../model-catalog.js';
|
|
5
|
+
import { validatePeekPids, validatePeekTimeSec } from '../peek.js';
|
|
5
6
|
|
|
6
7
|
export const CLI_HELP_TEXT = `Usage: ai-cli <command> [options]
|
|
7
8
|
|
|
8
9
|
Commands:
|
|
9
10
|
run Start an AI CLI process in the background
|
|
10
11
|
wait Wait for one or more pids
|
|
12
|
+
peek Observe new natural-language agent messages for a short window
|
|
11
13
|
ps List tracked processes
|
|
12
14
|
result Get the current result for a pid
|
|
13
15
|
kill Terminate a tracked pid
|
|
@@ -26,9 +28,9 @@ Options:
|
|
|
26
28
|
--cwd <path> Working directory
|
|
27
29
|
--prompt <text> Prompt text
|
|
28
30
|
--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
|
|
31
|
+
--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)
|
|
32
|
+
--session-id <id> Resume a previous session, including OpenCode in-place resumes
|
|
33
|
+
--reasoning-effort <level> Reasoning level for Claude/Codex only; unsupported for Gemini, Forge, and OpenCode
|
|
32
34
|
--help, -h Show this help message
|
|
33
35
|
|
|
34
36
|
Compatibility aliases:
|
|
@@ -58,6 +60,17 @@ Options:
|
|
|
58
60
|
--help, -h Show this help message
|
|
59
61
|
`;
|
|
60
62
|
|
|
63
|
+
export const PEEK_HELP_TEXT = `Usage: ai-cli peek <pid...> [options]
|
|
64
|
+
|
|
65
|
+
Observe new natural-language agent messages for a short one-shot window.
|
|
66
|
+
In v1, message extraction is supported for Codex, Claude, OpenCode, and Gemini; Forge returns status with messages: [].
|
|
67
|
+
This is not a history API, gapless streaming, or stdout/stderr tailing. No --follow mode is available in v1.
|
|
68
|
+
|
|
69
|
+
Options:
|
|
70
|
+
--time <seconds> Observation window in seconds. Defaults to 10, maximum 60
|
|
71
|
+
--help, -h Show this help message
|
|
72
|
+
`;
|
|
73
|
+
|
|
61
74
|
export const KILL_HELP_TEXT = `Usage: ai-cli kill <pid>
|
|
62
75
|
|
|
63
76
|
Terminate a tracked process.
|
|
@@ -92,7 +105,7 @@ Options:
|
|
|
92
105
|
|
|
93
106
|
export const DOCTOR_HELP_TEXT = `Usage: ai-cli doctor
|
|
94
107
|
|
|
95
|
-
Check whether supported AI CLI binaries are available.
|
|
108
|
+
Check whether supported AI CLI binaries are available, including OpenCode.
|
|
96
109
|
|
|
97
110
|
Options:
|
|
98
111
|
--help, -h Show this help message
|
|
@@ -118,6 +131,7 @@ interface CliDeps {
|
|
|
118
131
|
listProcesses: () => Promise<any>;
|
|
119
132
|
getProcessResult: (pid: number, verbose: boolean) => Promise<any>;
|
|
120
133
|
waitForProcesses: (pids: number[], timeoutSeconds?: number, verbose?: boolean) => Promise<any>;
|
|
134
|
+
peekProcesses: (pids: number[], peekTimeSec?: number) => Promise<any>;
|
|
121
135
|
killProcess: (pid: number) => Promise<any>;
|
|
122
136
|
cleanupProcesses: () => Promise<any>;
|
|
123
137
|
getDoctorStatus: () => any;
|
|
@@ -140,6 +154,7 @@ const defaultDeps: CliDeps = {
|
|
|
140
154
|
listProcesses: () => getCliProcessService().listProcesses(),
|
|
141
155
|
getProcessResult: (pid, verbose) => getCliProcessService().getProcessResult(pid, verbose),
|
|
142
156
|
waitForProcesses: (pids, timeoutSeconds, verbose) => getCliProcessService().waitForProcesses(pids, timeoutSeconds, verbose),
|
|
157
|
+
peekProcesses: (pids, peekTimeSec) => getCliProcessService().peekProcesses(pids, peekTimeSec),
|
|
143
158
|
killProcess: (pid) => getCliProcessService().killProcess(pid),
|
|
144
159
|
cleanupProcesses: () => getCliProcessService().cleanupProcesses(),
|
|
145
160
|
getDoctorStatus: () => getCliDoctorStatus(),
|
|
@@ -204,6 +219,10 @@ function hasHelpFlag(flags: Record<string, string>): boolean {
|
|
|
204
219
|
return 'help' in flags || 'h' in flags;
|
|
205
220
|
}
|
|
206
221
|
|
|
222
|
+
function parsePeekCliPids(values: string[]): number[] {
|
|
223
|
+
return validatePeekPids(values.map((value) => Number(value)));
|
|
224
|
+
}
|
|
225
|
+
|
|
207
226
|
export async function runCli(argv: string[], deps: Partial<CliDeps> = {}): Promise<number> {
|
|
208
227
|
const {
|
|
209
228
|
stdout,
|
|
@@ -213,6 +232,7 @@ export async function runCli(argv: string[], deps: Partial<CliDeps> = {}): Promi
|
|
|
213
232
|
listProcesses,
|
|
214
233
|
getProcessResult,
|
|
215
234
|
waitForProcesses,
|
|
235
|
+
peekProcesses,
|
|
216
236
|
killProcess,
|
|
217
237
|
cleanupProcesses,
|
|
218
238
|
getDoctorStatus,
|
|
@@ -323,6 +343,34 @@ export async function runCli(argv: string[], deps: Partial<CliDeps> = {}): Promi
|
|
|
323
343
|
return 0;
|
|
324
344
|
}
|
|
325
345
|
|
|
346
|
+
if (command === 'peek') {
|
|
347
|
+
const { positionals, flags } = parseArgs(argv.slice(1));
|
|
348
|
+
if (hasHelpFlag(flags)) {
|
|
349
|
+
stdout(PEEK_HELP_TEXT);
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
if ('follow' in flags) {
|
|
353
|
+
stderr('peek does not support --follow in v1\n');
|
|
354
|
+
stdout(CLI_HELP_TEXT);
|
|
355
|
+
return 1;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
let pids: number[];
|
|
359
|
+
let peekTimeSec: number;
|
|
360
|
+
try {
|
|
361
|
+
pids = parsePeekCliPids(positionals);
|
|
362
|
+
const timeRaw = getFirstFlag(flags, ['time']);
|
|
363
|
+
peekTimeSec = validatePeekTimeSec(timeRaw === undefined ? undefined : Number(timeRaw));
|
|
364
|
+
} catch (error: any) {
|
|
365
|
+
stderr(`${error.message}\n`);
|
|
366
|
+
stdout(CLI_HELP_TEXT);
|
|
367
|
+
return 1;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
writeJson(stdout, await peekProcesses(pids, peekTimeSec));
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
|
|
326
374
|
if (command === 'kill') {
|
|
327
375
|
const { positionals, flags } = parseArgs(argv.slice(1));
|
|
328
376
|
if (hasHelpFlag(flags)) {
|
package/src/app/mcp.ts
CHANGED
|
@@ -8,8 +8,9 @@ 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
|
+
import { validatePeekPids, validatePeekTimeSec } from '../peek.js';
|
|
13
14
|
import { ProcessService } from '../process-service.js';
|
|
14
15
|
|
|
15
16
|
// Server version - update this when releasing new versions
|
|
@@ -73,6 +74,7 @@ export class ClaudeCodeServer {
|
|
|
73
74
|
private codexCliPath: string;
|
|
74
75
|
private geminiCliPath: string;
|
|
75
76
|
private forgeCliPath: string;
|
|
77
|
+
private opencodeCliPath: string;
|
|
76
78
|
private processService: ProcessService;
|
|
77
79
|
private sigintHandler?: () => Promise<void>;
|
|
78
80
|
private packageVersion: string;
|
|
@@ -82,10 +84,12 @@ export class ClaudeCodeServer {
|
|
|
82
84
|
this.codexCliPath = findCodexCli();
|
|
83
85
|
this.geminiCliPath = findGeminiCli();
|
|
84
86
|
this.forgeCliPath = findForgeCli();
|
|
87
|
+
this.opencodeCliPath = findOpencodeCli();
|
|
85
88
|
console.error(`[Setup] Using Claude CLI command/path: ${this.claudeCliPath}`);
|
|
86
89
|
console.error(`[Setup] Using Codex CLI command/path: ${this.codexCliPath}`);
|
|
87
90
|
console.error(`[Setup] Using Gemini CLI command/path: ${this.geminiCliPath}`);
|
|
88
91
|
console.error(`[Setup] Using Forge CLI command/path: ${this.forgeCliPath}`);
|
|
92
|
+
console.error(`[Setup] Using OpenCode CLI command/path: ${this.opencodeCliPath}`);
|
|
89
93
|
this.packageVersion = SERVER_VERSION;
|
|
90
94
|
this.processService = new ProcessService({
|
|
91
95
|
cliPaths: {
|
|
@@ -93,6 +97,7 @@ export class ClaudeCodeServer {
|
|
|
93
97
|
codex: this.codexCliPath,
|
|
94
98
|
gemini: this.geminiCliPath,
|
|
95
99
|
forge: this.forgeCliPath,
|
|
100
|
+
opencode: this.opencodeCliPath,
|
|
96
101
|
},
|
|
97
102
|
});
|
|
98
103
|
|
|
@@ -123,7 +128,7 @@ export class ClaudeCodeServer {
|
|
|
123
128
|
tools: [
|
|
124
129
|
{
|
|
125
130
|
name: 'run',
|
|
126
|
-
description: `AI Agent Runner: Starts a Claude, Codex, Gemini, or
|
|
131
|
+
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
132
|
|
|
128
133
|
• File ops: Create, read, (fuzzy) edit, move, copy, delete, list files, analyze/ocr images, file content analysis
|
|
129
134
|
• Code: Generate / analyse / refactor / fix
|
|
@@ -167,11 +172,11 @@ ${getSupportedModelsDescription()}
|
|
|
167
172
|
},
|
|
168
173
|
reasoning_effort: {
|
|
169
174
|
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
|
|
175
|
+
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
176
|
},
|
|
172
177
|
session_id: {
|
|
173
178
|
type: 'string',
|
|
174
|
-
description: 'Optional session ID to resume a previous session. Supported for
|
|
179
|
+
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
180
|
},
|
|
176
181
|
},
|
|
177
182
|
required: ['workFolder'],
|
|
@@ -226,6 +231,25 @@ ${getSupportedModelsDescription()}
|
|
|
226
231
|
required: ['pids'],
|
|
227
232
|
},
|
|
228
233
|
},
|
|
234
|
+
{
|
|
235
|
+
name: 'peek',
|
|
236
|
+
description: 'One-shot short observation window for running child agents. Returns only natural-language agent messages observed during this call; not a history API, not gapless streaming, and not stdout/stderr tailing. In v1, message extraction is supported for Codex, Claude, OpenCode, and Gemini; Forge returns status with messages: [].',
|
|
237
|
+
inputSchema: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
properties: {
|
|
240
|
+
pids: {
|
|
241
|
+
type: 'array',
|
|
242
|
+
items: { type: 'number' },
|
|
243
|
+
description: 'Process IDs returned by run. Duplicates are deduplicated server-side, preserving first occurrence order. Unknown PIDs are returned per process as not_found.',
|
|
244
|
+
},
|
|
245
|
+
peek_time_sec: {
|
|
246
|
+
type: 'number',
|
|
247
|
+
description: 'Optional positive integer observation window in seconds. Defaults to 10; maximum is 60.',
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
required: ['pids'],
|
|
251
|
+
},
|
|
252
|
+
},
|
|
229
253
|
{
|
|
230
254
|
name: 'kill_process',
|
|
231
255
|
description: 'Terminate a running AI agent process by PID.',
|
|
@@ -266,6 +290,8 @@ ${getSupportedModelsDescription()}
|
|
|
266
290
|
return this.handleGetResult(toolArguments);
|
|
267
291
|
case 'wait':
|
|
268
292
|
return this.handleWait(toolArguments);
|
|
293
|
+
case 'peek':
|
|
294
|
+
return this.handlePeek(toolArguments);
|
|
269
295
|
case 'kill_process':
|
|
270
296
|
return this.handleKillProcess(toolArguments);
|
|
271
297
|
case 'cleanup_processes':
|
|
@@ -355,6 +381,30 @@ ${getSupportedModelsDescription()}
|
|
|
355
381
|
}
|
|
356
382
|
}
|
|
357
383
|
|
|
384
|
+
private async handlePeek(toolArguments: any): Promise<ServerResult> {
|
|
385
|
+
let pids: number[];
|
|
386
|
+
let peekTimeSec: number;
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
pids = validatePeekPids(toolArguments.pids);
|
|
390
|
+
peekTimeSec = validatePeekTimeSec(toolArguments.peek_time_sec);
|
|
391
|
+
} catch (error: any) {
|
|
392
|
+
throw new McpError(ErrorCode.InvalidParams, error.message);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
const response = await this.processService.peekProcesses(pids, peekTimeSec);
|
|
397
|
+
return {
|
|
398
|
+
content: [{
|
|
399
|
+
type: 'text',
|
|
400
|
+
text: JSON.stringify(response, null, 2)
|
|
401
|
+
}]
|
|
402
|
+
};
|
|
403
|
+
} catch (error: any) {
|
|
404
|
+
throw new McpError(ErrorCode.InternalError, `Failed to peek processes: ${error.message}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
358
408
|
private async handleKillProcess(toolArguments: any): Promise<ServerResult> {
|
|
359
409
|
if (!toolArguments.pid || typeof toolArguments.pid !== 'number') {
|
|
360
410
|
throw new McpError(ErrorCode.InvalidParams, 'Missing or invalid required parameter: pid');
|
package/src/cli-builder.ts
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { resolve as pathResolve, isAbsolute } from 'node:path';
|
|
3
|
+
import type { CliPaths } from './cli-utils.js';
|
|
3
4
|
import { MODEL_ALIASES } from './model-catalog.js';
|
|
4
5
|
|
|
5
6
|
export const ALLOWED_REASONING_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh']);
|
|
6
7
|
const CLAUDE_REASONING_EFFORTS = new Set(['low', 'medium', 'high']);
|
|
8
|
+
const OPENCODE_MODEL_ERROR = 'Invalid OpenCode model. Expected exact syntax oc-<provider/model>.';
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
type Agent = 'codex' | 'claude' | 'gemini' | 'forge' | 'opencode';
|
|
11
|
+
|
|
12
|
+
interface ModelSelection {
|
|
13
|
+
agent: Agent;
|
|
14
|
+
resolvedModel: string;
|
|
15
|
+
openCodeModel: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getStandardAgentForModel(model: string): Exclude<Agent, 'opencode'> {
|
|
9
19
|
if (model === 'forge') {
|
|
10
20
|
return 'forge';
|
|
11
21
|
}
|
|
@@ -18,20 +28,63 @@ function getAgentForModel(model: string): 'codex' | 'claude' | 'gemini' | 'forge
|
|
|
18
28
|
return 'claude';
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
function isPotentialOpenCodeExplicitModel(rawModel: string): boolean {
|
|
32
|
+
return rawModel.startsWith('oc-') || rawModel.trim().startsWith('oc-');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extractOpenCodeModel(rawModel: string): string {
|
|
36
|
+
if (rawModel !== rawModel.trim()) {
|
|
37
|
+
throw new Error(OPENCODE_MODEL_ERROR);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!rawModel.startsWith('oc-')) {
|
|
41
|
+
throw new Error(OPENCODE_MODEL_ERROR);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const remainder = rawModel.slice(3);
|
|
45
|
+
const slashIndex = remainder.indexOf('/');
|
|
46
|
+
if (slashIndex === -1) {
|
|
47
|
+
throw new Error(OPENCODE_MODEL_ERROR);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const provider = remainder.slice(0, slashIndex);
|
|
51
|
+
const model = remainder.slice(slashIndex + 1);
|
|
52
|
+
if (!provider || !model) {
|
|
53
|
+
throw new Error(OPENCODE_MODEL_ERROR);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return remainder;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveModelSelection(rawModel: string): ModelSelection {
|
|
60
|
+
if (rawModel === 'opencode') {
|
|
61
|
+
return {
|
|
62
|
+
agent: 'opencode',
|
|
63
|
+
resolvedModel: rawModel,
|
|
64
|
+
openCodeModel: null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (isPotentialOpenCodeExplicitModel(rawModel)) {
|
|
69
|
+
return {
|
|
70
|
+
agent: 'opencode',
|
|
71
|
+
resolvedModel: rawModel,
|
|
72
|
+
openCodeModel: extractOpenCodeModel(rawModel),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const resolvedModel = resolveModelAlias(rawModel);
|
|
77
|
+
return {
|
|
78
|
+
agent: getStandardAgentForModel(resolvedModel),
|
|
79
|
+
resolvedModel,
|
|
80
|
+
openCodeModel: null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
26
84
|
export function resolveModelAlias(model: string): string {
|
|
27
85
|
return MODEL_ALIASES[model] || model;
|
|
28
86
|
}
|
|
29
87
|
|
|
30
|
-
/**
|
|
31
|
-
* Validates and normalizes reasoning effort parameter.
|
|
32
|
-
* @returns normalized reasoning effort string, or '' if not applicable
|
|
33
|
-
* @throws Error for invalid values (plain Error, not MCP-specific)
|
|
34
|
-
*/
|
|
35
88
|
export function getReasoningEffort(model: string, rawValue: unknown): string {
|
|
36
89
|
if (typeof rawValue !== 'string') {
|
|
37
90
|
return '';
|
|
@@ -40,13 +93,18 @@ export function getReasoningEffort(model: string, rawValue: unknown): string {
|
|
|
40
93
|
if (!trimmed) {
|
|
41
94
|
return '';
|
|
42
95
|
}
|
|
96
|
+
|
|
97
|
+
if (model === 'opencode' || model.startsWith('oc-')) {
|
|
98
|
+
throw new Error('reasoning_effort is not supported for opencode.');
|
|
99
|
+
}
|
|
100
|
+
|
|
43
101
|
const normalized = trimmed.toLowerCase();
|
|
44
102
|
if (!ALLOWED_REASONING_EFFORTS.has(normalized)) {
|
|
45
103
|
throw new Error(
|
|
46
104
|
`Invalid reasoning_effort: ${rawValue}. Allowed values: low, medium, high, xhigh.`
|
|
47
105
|
);
|
|
48
106
|
}
|
|
49
|
-
const agent =
|
|
107
|
+
const agent = getStandardAgentForModel(model);
|
|
50
108
|
if (agent === 'forge') {
|
|
51
109
|
throw new Error('reasoning_effort is not supported for forge.');
|
|
52
110
|
}
|
|
@@ -67,7 +125,7 @@ export interface CliCommand {
|
|
|
67
125
|
cliPath: string;
|
|
68
126
|
args: string[];
|
|
69
127
|
cwd: string;
|
|
70
|
-
agent:
|
|
128
|
+
agent: Agent;
|
|
71
129
|
prompt: string;
|
|
72
130
|
resolvedModel: string;
|
|
73
131
|
}
|
|
@@ -79,21 +137,14 @@ export interface BuildCliCommandOptions {
|
|
|
79
137
|
model?: string;
|
|
80
138
|
session_id?: string;
|
|
81
139
|
reasoning_effort?: string;
|
|
82
|
-
cliPaths:
|
|
140
|
+
cliPaths: CliPaths;
|
|
83
141
|
}
|
|
84
142
|
|
|
85
|
-
/**
|
|
86
|
-
* Build a CLI command from the given options.
|
|
87
|
-
* This is a pure function (aside from filesystem reads for prompt_file / workFolder validation).
|
|
88
|
-
* @throws Error on validation failures
|
|
89
|
-
*/
|
|
90
143
|
export function buildCliCommand(options: BuildCliCommandOptions): CliCommand {
|
|
91
|
-
// Validate workFolder
|
|
92
144
|
if (!options.workFolder || typeof options.workFolder !== 'string') {
|
|
93
145
|
throw new Error('Missing or invalid required parameter: workFolder');
|
|
94
146
|
}
|
|
95
147
|
|
|
96
|
-
// Validate prompt / prompt_file
|
|
97
148
|
const hasPrompt = !!options.prompt && typeof options.prompt === 'string' && options.prompt.trim() !== '';
|
|
98
149
|
const hasPromptFile = !!options.prompt_file && typeof options.prompt_file === 'string' && options.prompt_file.trim() !== '';
|
|
99
150
|
|
|
@@ -105,7 +156,6 @@ export function buildCliCommand(options: BuildCliCommandOptions): CliCommand {
|
|
|
105
156
|
throw new Error('Cannot specify both prompt and prompt_file. Please use only one.');
|
|
106
157
|
}
|
|
107
158
|
|
|
108
|
-
// Determine prompt
|
|
109
159
|
let prompt: string;
|
|
110
160
|
if (hasPrompt) {
|
|
111
161
|
prompt = options.prompt!;
|
|
@@ -125,18 +175,14 @@ export function buildCliCommand(options: BuildCliCommandOptions): CliCommand {
|
|
|
125
175
|
}
|
|
126
176
|
}
|
|
127
177
|
|
|
128
|
-
// Resolve workFolder
|
|
129
178
|
const cwd = pathResolve(options.workFolder);
|
|
130
179
|
if (!existsSync(cwd)) {
|
|
131
180
|
throw new Error(`Working folder does not exist: ${options.workFolder}`);
|
|
132
181
|
}
|
|
133
182
|
|
|
134
|
-
// Resolve model
|
|
135
183
|
const rawModel = options.model || '';
|
|
136
|
-
const resolvedModel =
|
|
137
|
-
const agent = getAgentForModel(resolvedModel);
|
|
184
|
+
const { agent, resolvedModel, openCodeModel } = resolveModelSelection(rawModel);
|
|
138
185
|
|
|
139
|
-
// Special handling for ultra aliases: default to higher reasoning if not specified
|
|
140
186
|
let reasoningEffortArg: string | undefined = options.reasoning_effort;
|
|
141
187
|
if (!reasoningEffortArg) {
|
|
142
188
|
if (rawModel === 'codex-ultra') {
|
|
@@ -146,9 +192,11 @@ export function buildCliCommand(options: BuildCliCommandOptions): CliCommand {
|
|
|
146
192
|
}
|
|
147
193
|
}
|
|
148
194
|
|
|
149
|
-
const
|
|
195
|
+
const reasoningTargetModel = rawModel === 'opencode' || rawModel.startsWith('oc-')
|
|
196
|
+
? rawModel
|
|
197
|
+
: (resolvedModel || rawModel);
|
|
198
|
+
const reasoningEffort = getReasoningEffort(reasoningTargetModel, reasoningEffortArg);
|
|
150
199
|
|
|
151
|
-
// Build CLI path and args
|
|
152
200
|
let cliPath: string;
|
|
153
201
|
let args: string[];
|
|
154
202
|
|
|
@@ -169,10 +217,9 @@ export function buildCliCommand(options: BuildCliCommandOptions): CliCommand {
|
|
|
169
217
|
}
|
|
170
218
|
|
|
171
219
|
args.push('--skip-git-repo-check', '--full-auto', '--json', prompt);
|
|
172
|
-
|
|
173
220
|
} else if (agent === 'gemini') {
|
|
174
221
|
cliPath = options.cliPaths.gemini;
|
|
175
|
-
args = ['-y', '--output-format', 'json'];
|
|
222
|
+
args = ['-y', '--output-format', 'stream-json'];
|
|
176
223
|
|
|
177
224
|
if (options.session_id && typeof options.session_id === 'string') {
|
|
178
225
|
args.push('-r', options.session_id);
|
|
@@ -183,7 +230,6 @@ export function buildCliCommand(options: BuildCliCommandOptions): CliCommand {
|
|
|
183
230
|
}
|
|
184
231
|
|
|
185
232
|
args.push(prompt);
|
|
186
|
-
|
|
187
233
|
} else if (agent === 'forge') {
|
|
188
234
|
cliPath = options.cliPaths.forge;
|
|
189
235
|
args = ['-C', cwd];
|
|
@@ -193,6 +239,19 @@ export function buildCliCommand(options: BuildCliCommandOptions): CliCommand {
|
|
|
193
239
|
}
|
|
194
240
|
|
|
195
241
|
args.push('-p', prompt);
|
|
242
|
+
} else if (agent === 'opencode') {
|
|
243
|
+
cliPath = options.cliPaths.opencode;
|
|
244
|
+
args = ['run', '--format', 'json', '--dir', cwd];
|
|
245
|
+
|
|
246
|
+
if (options.session_id && typeof options.session_id === 'string') {
|
|
247
|
+
args.push('--session', options.session_id);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (openCodeModel) {
|
|
251
|
+
args.push('--model', openCodeModel);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
args.push(prompt);
|
|
196
255
|
} else {
|
|
197
256
|
cliPath = options.cliPaths.claude;
|
|
198
257
|
args = ['--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose'];
|