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
|
@@ -60,7 +60,7 @@ describe('Process Management Tests', () => {
|
|
|
60
60
|
});
|
|
61
61
|
async function setupServer() {
|
|
62
62
|
const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
|
|
63
|
-
vi.mocked(Server).mockImplementation(()
|
|
63
|
+
vi.mocked(Server).mockImplementation(function () {
|
|
64
64
|
mockServerInstance = {
|
|
65
65
|
setRequestHandler: vi.fn((schema, handler) => {
|
|
66
66
|
handlers.set(schema.name, handler);
|
|
@@ -101,6 +101,165 @@ describe('Process Management Tests', () => {
|
|
|
101
101
|
expect(response.status).toBe('started');
|
|
102
102
|
expect(response.message).toBe('claude process started successfully');
|
|
103
103
|
});
|
|
104
|
+
it('should peek only natural-language messages observed after registration', async () => {
|
|
105
|
+
const { handlers } = await setupServer();
|
|
106
|
+
const mockProcess = new EventEmitter();
|
|
107
|
+
mockProcess.pid = 12345;
|
|
108
|
+
mockProcess.stdout = new EventEmitter();
|
|
109
|
+
mockProcess.stderr = new EventEmitter();
|
|
110
|
+
mockProcess.kill = vi.fn();
|
|
111
|
+
mockSpawn.mockReturnValue(mockProcess);
|
|
112
|
+
const callToolHandler = handlers.get('callTool');
|
|
113
|
+
await callToolHandler({
|
|
114
|
+
params: {
|
|
115
|
+
name: 'run',
|
|
116
|
+
arguments: {
|
|
117
|
+
prompt: 'test prompt',
|
|
118
|
+
workFolder: '/tmp'
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
mockProcess.stdout.emit('data', '{"type":"assistant","message":{"content":[{"type":"text","text":"old message"}]}}\n');
|
|
123
|
+
const peekPromise = callToolHandler({
|
|
124
|
+
params: {
|
|
125
|
+
name: 'peek',
|
|
126
|
+
arguments: {
|
|
127
|
+
pids: [12345, 12345, 99999],
|
|
128
|
+
peek_time_sec: 1,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
mockProcess.stdout.emit('data', '{"type":"assistant","message":{"content":[{"type":"text","text":"new message"},{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/a"}}]}}\n');
|
|
134
|
+
mockProcess.stdout.emit('data', '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"secret"}]}}\n');
|
|
135
|
+
mockProcess.emit('close', 0);
|
|
136
|
+
}, 10);
|
|
137
|
+
const result = await peekPromise;
|
|
138
|
+
const response = JSON.parse(result.content[0].text);
|
|
139
|
+
expect(response.processes).toHaveLength(2);
|
|
140
|
+
expect(response.processes[0]).toMatchObject({
|
|
141
|
+
pid: 12345,
|
|
142
|
+
agent: 'claude',
|
|
143
|
+
status: 'completed',
|
|
144
|
+
messages: [
|
|
145
|
+
{
|
|
146
|
+
ts: expect.any(String),
|
|
147
|
+
text: 'new message',
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
truncated: false,
|
|
151
|
+
error: null,
|
|
152
|
+
});
|
|
153
|
+
expect(response.processes[1]).toEqual({
|
|
154
|
+
pid: 99999,
|
|
155
|
+
agent: null,
|
|
156
|
+
status: 'not_found',
|
|
157
|
+
messages: [],
|
|
158
|
+
truncated: false,
|
|
159
|
+
error: 'process not found',
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
it('should peek OpenCode text events and exclude OpenCode tool output', async () => {
|
|
163
|
+
const { handlers } = await setupServer();
|
|
164
|
+
const mockProcess = new EventEmitter();
|
|
165
|
+
mockProcess.pid = 12346;
|
|
166
|
+
mockProcess.stdout = new EventEmitter();
|
|
167
|
+
mockProcess.stderr = new EventEmitter();
|
|
168
|
+
mockProcess.kill = vi.fn();
|
|
169
|
+
mockSpawn.mockReturnValue(mockProcess);
|
|
170
|
+
const callToolHandler = handlers.get('callTool');
|
|
171
|
+
await callToolHandler({
|
|
172
|
+
params: {
|
|
173
|
+
name: 'run',
|
|
174
|
+
arguments: {
|
|
175
|
+
prompt: 'opencode peek prompt',
|
|
176
|
+
workFolder: '/tmp',
|
|
177
|
+
model: 'opencode',
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
const peekPromise = callToolHandler({
|
|
182
|
+
params: {
|
|
183
|
+
name: 'peek',
|
|
184
|
+
arguments: {
|
|
185
|
+
pids: [12346],
|
|
186
|
+
peek_time_sec: 1,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
setTimeout(() => {
|
|
191
|
+
mockProcess.stdout.emit('data', '{"type":"text","timestamp":1775918783605,"sessionID":"ses-1","part":{"type":"text","text":"OpenCode visible text"}}\n');
|
|
192
|
+
mockProcess.stdout.emit('data', '{"type":"tool_use","timestamp":1775918783606,"sessionID":"ses-1","part":{"type":"tool","state":{"output":"secret command output"},"metadata":{"output":"secret metadata output"}}}\n');
|
|
193
|
+
mockProcess.emit('close', 0);
|
|
194
|
+
}, 10);
|
|
195
|
+
const result = await peekPromise;
|
|
196
|
+
const response = JSON.parse(result.content[0].text);
|
|
197
|
+
expect(response.processes).toHaveLength(1);
|
|
198
|
+
expect(response.processes[0]).toMatchObject({
|
|
199
|
+
pid: 12346,
|
|
200
|
+
agent: 'opencode',
|
|
201
|
+
status: 'completed',
|
|
202
|
+
messages: [
|
|
203
|
+
{
|
|
204
|
+
ts: expect.any(String),
|
|
205
|
+
text: 'OpenCode visible text',
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
truncated: false,
|
|
209
|
+
error: null,
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
it('should peek Gemini assistant message events and exclude tool output', async () => {
|
|
213
|
+
const { handlers } = await setupServer();
|
|
214
|
+
const mockProcess = new EventEmitter();
|
|
215
|
+
mockProcess.pid = 12347;
|
|
216
|
+
mockProcess.stdout = new EventEmitter();
|
|
217
|
+
mockProcess.stderr = new EventEmitter();
|
|
218
|
+
mockProcess.kill = vi.fn();
|
|
219
|
+
mockSpawn.mockReturnValue(mockProcess);
|
|
220
|
+
const callToolHandler = handlers.get('callTool');
|
|
221
|
+
await callToolHandler({
|
|
222
|
+
params: {
|
|
223
|
+
name: 'run',
|
|
224
|
+
arguments: {
|
|
225
|
+
prompt: 'gemini peek prompt',
|
|
226
|
+
workFolder: '/tmp',
|
|
227
|
+
model: 'gemini-2.5-pro',
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
const peekPromise = callToolHandler({
|
|
232
|
+
params: {
|
|
233
|
+
name: 'peek',
|
|
234
|
+
arguments: {
|
|
235
|
+
pids: [12347],
|
|
236
|
+
peek_time_sec: 1,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
setTimeout(() => {
|
|
241
|
+
mockProcess.stdout.emit('data', '{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}\n');
|
|
242
|
+
mockProcess.stdout.emit('data', '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Visible Gemini text","delta":true}\n');
|
|
243
|
+
mockProcess.stdout.emit('data', '{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","status":"success","output":"secret command output"}\n');
|
|
244
|
+
mockProcess.emit('close', 0);
|
|
245
|
+
}, 10);
|
|
246
|
+
const result = await peekPromise;
|
|
247
|
+
const response = JSON.parse(result.content[0].text);
|
|
248
|
+
expect(response.processes).toHaveLength(1);
|
|
249
|
+
expect(response.processes[0]).toMatchObject({
|
|
250
|
+
pid: 12347,
|
|
251
|
+
agent: 'gemini',
|
|
252
|
+
status: 'completed',
|
|
253
|
+
messages: [
|
|
254
|
+
{
|
|
255
|
+
ts: expect.any(String),
|
|
256
|
+
text: 'Visible Gemini text',
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
truncated: false,
|
|
260
|
+
error: null,
|
|
261
|
+
});
|
|
262
|
+
});
|
|
104
263
|
it('should handle process with model parameter', async () => {
|
|
105
264
|
const { handlers } = await setupServer();
|
|
106
265
|
const mockProcess = new EventEmitter();
|
|
@@ -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');
|
|
@@ -378,14 +407,15 @@ describe('ClaudeCodeServer Unit Tests', () => {
|
|
|
378
407
|
// Test the handler
|
|
379
408
|
const handler = listToolsCall[1];
|
|
380
409
|
const result = await handler();
|
|
381
|
-
expect(result.tools).toHaveLength(
|
|
410
|
+
expect(result.tools).toHaveLength(7);
|
|
382
411
|
expect(result.tools[0].name).toBe('run');
|
|
383
412
|
expect(result.tools[0].description).toContain('AI Agent Runner');
|
|
384
413
|
expect(result.tools[1].name).toBe('list_processes');
|
|
385
414
|
expect(result.tools[2].name).toBe('get_result');
|
|
386
415
|
expect(result.tools[3].name).toBe('wait');
|
|
387
|
-
expect(result.tools[4].name).toBe('
|
|
388
|
-
expect(result.tools[5].name).toBe('
|
|
416
|
+
expect(result.tools[4].name).toBe('peek');
|
|
417
|
+
expect(result.tools[5].name).toBe('kill_process');
|
|
418
|
+
expect(result.tools[6].name).toBe('cleanup_processes');
|
|
389
419
|
});
|
|
390
420
|
it('should handle CallToolRequest', async () => {
|
|
391
421
|
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
|
@@ -2,11 +2,13 @@ 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
|
export const CLI_HELP_TEXT = `Usage: ai-cli <command> [options]
|
|
6
7
|
|
|
7
8
|
Commands:
|
|
8
9
|
run Start an AI CLI process in the background
|
|
9
10
|
wait Wait for one or more pids
|
|
11
|
+
peek Observe new natural-language agent messages for a short window
|
|
10
12
|
ps List tracked processes
|
|
11
13
|
result Get the current result for a pid
|
|
12
14
|
kill Terminate a tracked pid
|
|
@@ -24,9 +26,9 @@ Options:
|
|
|
24
26
|
--cwd <path> Working directory
|
|
25
27
|
--prompt <text> Prompt text
|
|
26
28
|
--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
|
|
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
|
|
30
32
|
--help, -h Show this help message
|
|
31
33
|
|
|
32
34
|
Compatibility aliases:
|
|
@@ -53,6 +55,16 @@ Options:
|
|
|
53
55
|
--verbose Return full metadata and detailed parsed output
|
|
54
56
|
--help, -h Show this help message
|
|
55
57
|
`;
|
|
58
|
+
export const PEEK_HELP_TEXT = `Usage: ai-cli peek <pid...> [options]
|
|
59
|
+
|
|
60
|
+
Observe new natural-language agent messages for a short one-shot window.
|
|
61
|
+
In v1, message extraction is supported for Codex, Claude, OpenCode, and Gemini; Forge returns status with messages: [].
|
|
62
|
+
This is not a history API, gapless streaming, or stdout/stderr tailing. No --follow mode is available in v1.
|
|
63
|
+
|
|
64
|
+
Options:
|
|
65
|
+
--time <seconds> Observation window in seconds. Defaults to 10, maximum 60
|
|
66
|
+
--help, -h Show this help message
|
|
67
|
+
`;
|
|
56
68
|
export const KILL_HELP_TEXT = `Usage: ai-cli kill <pid>
|
|
57
69
|
|
|
58
70
|
Terminate a tracked process.
|
|
@@ -83,7 +95,7 @@ Options:
|
|
|
83
95
|
`;
|
|
84
96
|
export const DOCTOR_HELP_TEXT = `Usage: ai-cli doctor
|
|
85
97
|
|
|
86
|
-
Check whether supported AI CLI binaries are available.
|
|
98
|
+
Check whether supported AI CLI binaries are available, including OpenCode.
|
|
87
99
|
|
|
88
100
|
Options:
|
|
89
101
|
--help, -h Show this help message
|
|
@@ -107,6 +119,7 @@ const defaultDeps = {
|
|
|
107
119
|
listProcesses: () => getCliProcessService().listProcesses(),
|
|
108
120
|
getProcessResult: (pid, verbose) => getCliProcessService().getProcessResult(pid, verbose),
|
|
109
121
|
waitForProcesses: (pids, timeoutSeconds, verbose) => getCliProcessService().waitForProcesses(pids, timeoutSeconds, verbose),
|
|
122
|
+
peekProcesses: (pids, peekTimeSec) => getCliProcessService().peekProcesses(pids, peekTimeSec),
|
|
110
123
|
killProcess: (pid) => getCliProcessService().killProcess(pid),
|
|
111
124
|
cleanupProcesses: () => getCliProcessService().cleanupProcesses(),
|
|
112
125
|
getDoctorStatus: () => getCliDoctorStatus(),
|
|
@@ -161,8 +174,11 @@ function writeJson(stdout, value) {
|
|
|
161
174
|
function hasHelpFlag(flags) {
|
|
162
175
|
return 'help' in flags || 'h' in flags;
|
|
163
176
|
}
|
|
177
|
+
function parsePeekCliPids(values) {
|
|
178
|
+
return validatePeekPids(values.map((value) => Number(value)));
|
|
179
|
+
}
|
|
164
180
|
export async function runCli(argv, deps = {}) {
|
|
165
|
-
const { stdout, stderr, startMcpServer, runProcess, listProcesses, getProcessResult, waitForProcesses, killProcess, cleanupProcesses, getDoctorStatus, } = { ...defaultDeps, ...deps };
|
|
181
|
+
const { stdout, stderr, startMcpServer, runProcess, listProcesses, getProcessResult, waitForProcesses, peekProcesses, killProcess, cleanupProcesses, getDoctorStatus, } = { ...defaultDeps, ...deps };
|
|
166
182
|
const [command] = argv;
|
|
167
183
|
if (!command || command === 'help' || command === '--help' || command === '-h') {
|
|
168
184
|
stdout(CLI_HELP_TEXT);
|
|
@@ -258,6 +274,32 @@ export async function runCli(argv, deps = {}) {
|
|
|
258
274
|
writeJson(stdout, await waitForProcesses(pids, timeout, 'verbose' in flags));
|
|
259
275
|
return 0;
|
|
260
276
|
}
|
|
277
|
+
if (command === 'peek') {
|
|
278
|
+
const { positionals, flags } = parseArgs(argv.slice(1));
|
|
279
|
+
if (hasHelpFlag(flags)) {
|
|
280
|
+
stdout(PEEK_HELP_TEXT);
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
283
|
+
if ('follow' in flags) {
|
|
284
|
+
stderr('peek does not support --follow in v1\n');
|
|
285
|
+
stdout(CLI_HELP_TEXT);
|
|
286
|
+
return 1;
|
|
287
|
+
}
|
|
288
|
+
let pids;
|
|
289
|
+
let peekTimeSec;
|
|
290
|
+
try {
|
|
291
|
+
pids = parsePeekCliPids(positionals);
|
|
292
|
+
const timeRaw = getFirstFlag(flags, ['time']);
|
|
293
|
+
peekTimeSec = validatePeekTimeSec(timeRaw === undefined ? undefined : Number(timeRaw));
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
stderr(`${error.message}\n`);
|
|
297
|
+
stdout(CLI_HELP_TEXT);
|
|
298
|
+
return 1;
|
|
299
|
+
}
|
|
300
|
+
writeJson(stdout, await peekProcesses(pids, peekTimeSec));
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
261
303
|
if (command === 'kill') {
|
|
262
304
|
const { positionals, flags } = parseArgs(argv.slice(1));
|
|
263
305
|
if (hasHelpFlag(flags)) {
|
package/dist/app/mcp.js
CHANGED
|
@@ -2,8 +2,9 @@ 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
|
+
import { validatePeekPids, validatePeekTimeSec } from '../peek.js';
|
|
7
8
|
import { ProcessService } from '../process-service.js';
|
|
8
9
|
// Server version - update this when releasing new versions
|
|
9
10
|
const SERVER_VERSION = "2.2.0";
|
|
@@ -59,6 +60,7 @@ export class ClaudeCodeServer {
|
|
|
59
60
|
codexCliPath;
|
|
60
61
|
geminiCliPath;
|
|
61
62
|
forgeCliPath;
|
|
63
|
+
opencodeCliPath;
|
|
62
64
|
processService;
|
|
63
65
|
sigintHandler;
|
|
64
66
|
packageVersion;
|
|
@@ -67,10 +69,12 @@ export class ClaudeCodeServer {
|
|
|
67
69
|
this.codexCliPath = findCodexCli();
|
|
68
70
|
this.geminiCliPath = findGeminiCli();
|
|
69
71
|
this.forgeCliPath = findForgeCli();
|
|
72
|
+
this.opencodeCliPath = findOpencodeCli();
|
|
70
73
|
console.error(`[Setup] Using Claude CLI command/path: ${this.claudeCliPath}`);
|
|
71
74
|
console.error(`[Setup] Using Codex CLI command/path: ${this.codexCliPath}`);
|
|
72
75
|
console.error(`[Setup] Using Gemini CLI command/path: ${this.geminiCliPath}`);
|
|
73
76
|
console.error(`[Setup] Using Forge CLI command/path: ${this.forgeCliPath}`);
|
|
77
|
+
console.error(`[Setup] Using OpenCode CLI command/path: ${this.opencodeCliPath}`);
|
|
74
78
|
this.packageVersion = SERVER_VERSION;
|
|
75
79
|
this.processService = new ProcessService({
|
|
76
80
|
cliPaths: {
|
|
@@ -78,6 +82,7 @@ export class ClaudeCodeServer {
|
|
|
78
82
|
codex: this.codexCliPath,
|
|
79
83
|
gemini: this.geminiCliPath,
|
|
80
84
|
forge: this.forgeCliPath,
|
|
85
|
+
opencode: this.opencodeCliPath,
|
|
81
86
|
},
|
|
82
87
|
});
|
|
83
88
|
this.server = new Server({
|
|
@@ -101,7 +106,7 @@ export class ClaudeCodeServer {
|
|
|
101
106
|
tools: [
|
|
102
107
|
{
|
|
103
108
|
name: 'run',
|
|
104
|
-
description: `AI Agent Runner: Starts a Claude, Codex, Gemini, or
|
|
109
|
+
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
110
|
|
|
106
111
|
• File ops: Create, read, (fuzzy) edit, move, copy, delete, list files, analyze/ocr images, file content analysis
|
|
107
112
|
• Code: Generate / analyse / refactor / fix
|
|
@@ -145,11 +150,11 @@ ${getSupportedModelsDescription()}
|
|
|
145
150
|
},
|
|
146
151
|
reasoning_effort: {
|
|
147
152
|
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
|
|
153
|
+
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
154
|
},
|
|
150
155
|
session_id: {
|
|
151
156
|
type: 'string',
|
|
152
|
-
description: 'Optional session ID to resume a previous session. Supported for
|
|
157
|
+
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
158
|
},
|
|
154
159
|
},
|
|
155
160
|
required: ['workFolder'],
|
|
@@ -204,6 +209,25 @@ ${getSupportedModelsDescription()}
|
|
|
204
209
|
required: ['pids'],
|
|
205
210
|
},
|
|
206
211
|
},
|
|
212
|
+
{
|
|
213
|
+
name: 'peek',
|
|
214
|
+
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: [].',
|
|
215
|
+
inputSchema: {
|
|
216
|
+
type: 'object',
|
|
217
|
+
properties: {
|
|
218
|
+
pids: {
|
|
219
|
+
type: 'array',
|
|
220
|
+
items: { type: 'number' },
|
|
221
|
+
description: 'Process IDs returned by run. Duplicates are deduplicated server-side, preserving first occurrence order. Unknown PIDs are returned per process as not_found.',
|
|
222
|
+
},
|
|
223
|
+
peek_time_sec: {
|
|
224
|
+
type: 'number',
|
|
225
|
+
description: 'Optional positive integer observation window in seconds. Defaults to 10; maximum is 60.',
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
required: ['pids'],
|
|
229
|
+
},
|
|
230
|
+
},
|
|
207
231
|
{
|
|
208
232
|
name: 'kill_process',
|
|
209
233
|
description: 'Terminate a running AI agent process by PID.',
|
|
@@ -241,6 +265,8 @@ ${getSupportedModelsDescription()}
|
|
|
241
265
|
return this.handleGetResult(toolArguments);
|
|
242
266
|
case 'wait':
|
|
243
267
|
return this.handleWait(toolArguments);
|
|
268
|
+
case 'peek':
|
|
269
|
+
return this.handlePeek(toolArguments);
|
|
244
270
|
case 'kill_process':
|
|
245
271
|
return this.handleKillProcess(toolArguments);
|
|
246
272
|
case 'cleanup_processes':
|
|
@@ -322,6 +348,29 @@ ${getSupportedModelsDescription()}
|
|
|
322
348
|
throw new McpError(code, error.message);
|
|
323
349
|
}
|
|
324
350
|
}
|
|
351
|
+
async handlePeek(toolArguments) {
|
|
352
|
+
let pids;
|
|
353
|
+
let peekTimeSec;
|
|
354
|
+
try {
|
|
355
|
+
pids = validatePeekPids(toolArguments.pids);
|
|
356
|
+
peekTimeSec = validatePeekTimeSec(toolArguments.peek_time_sec);
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
throw new McpError(ErrorCode.InvalidParams, error.message);
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
const response = await this.processService.peekProcesses(pids, peekTimeSec);
|
|
363
|
+
return {
|
|
364
|
+
content: [{
|
|
365
|
+
type: 'text',
|
|
366
|
+
text: JSON.stringify(response, null, 2)
|
|
367
|
+
}]
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
throw new McpError(ErrorCode.InternalError, `Failed to peek processes: ${error.message}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
325
374
|
async handleKillProcess(toolArguments) {
|
|
326
375
|
if (!toolArguments.pid || typeof toolArguments.pid !== 'number') {
|
|
327
376
|
throw new McpError(ErrorCode.InvalidParams, 'Missing or invalid required parameter: pid');
|