ai-cli-mcp 2.11.0 → 2.13.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/workflows/publish.yml +25 -0
- package/CHANGELOG.md +23 -0
- package/README.ja.md +112 -8
- package/README.md +112 -9
- package/dist/__tests__/app-cli.test.js +293 -0
- package/dist/__tests__/cli-bin-smoke.test.js +58 -0
- package/dist/__tests__/cli-builder.test.js +37 -0
- package/dist/__tests__/cli-process-service.test.js +279 -0
- package/dist/__tests__/cli-utils.test.js +140 -0
- package/dist/__tests__/error-cases.test.js +2 -1
- package/dist/__tests__/mcp-contract.test.js +343 -0
- package/dist/__tests__/parsers.test.js +37 -1
- package/dist/__tests__/process-management.test.js +15 -8
- package/dist/__tests__/server.test.js +29 -3
- package/dist/__tests__/wait.test.js +31 -0
- package/dist/app/cli.js +304 -0
- package/dist/app/mcp.js +366 -0
- package/dist/bin/ai-cli-mcp.js +6 -0
- package/dist/bin/ai-cli.js +10 -0
- package/dist/cli-builder.js +15 -6
- package/dist/cli-parse.js +8 -5
- package/dist/cli-process-service.js +332 -0
- package/dist/cli-utils.js +159 -88
- package/dist/cli.js +4 -3
- package/dist/model-catalog.js +53 -0
- package/dist/parsers.js +55 -0
- package/dist/process-service.js +201 -0
- package/dist/server.js +4 -578
- package/docs/cli-architecture.md +275 -0
- package/package.json +4 -3
- package/server.json +1 -1
- package/src/__tests__/app-cli.test.ts +370 -0
- package/src/__tests__/cli-bin-smoke.test.ts +75 -0
- package/src/__tests__/cli-builder.test.ts +47 -0
- package/src/__tests__/cli-process-service.test.ts +334 -0
- package/src/__tests__/cli-utils.test.ts +166 -0
- package/src/__tests__/error-cases.test.ts +3 -4
- package/src/__tests__/mcp-contract.test.ts +422 -0
- package/src/__tests__/parsers.test.ts +44 -1
- package/src/__tests__/process-management.test.ts +15 -9
- package/src/__tests__/server.test.ts +27 -6
- package/src/__tests__/wait.test.ts +38 -0
- package/src/app/cli.ts +373 -0
- package/src/app/mcp.ts +402 -0
- package/src/bin/ai-cli-mcp.ts +7 -0
- package/src/bin/ai-cli.ts +11 -0
- package/src/cli-builder.ts +19 -10
- package/src/cli-parse.ts +8 -5
- package/src/cli-process-service.ts +418 -0
- package/src/cli-utils.ts +205 -99
- package/src/cli.ts +4 -3
- package/src/model-catalog.ts +64 -0
- package/src/parsers.ts +61 -0
- package/src/process-service.ts +263 -0
- package/src/server.ts +4 -668
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { cleanupSharedMock, getSharedMock } from './utils/persistent-mock.js';
|
|
6
|
+
import { createTestClient, MCPTestClient } from './utils/mcp-client.js';
|
|
7
|
+
|
|
8
|
+
function parseToolJson(content: any): any {
|
|
9
|
+
expect(content).toHaveLength(1);
|
|
10
|
+
expect(content[0].type).toBe('text');
|
|
11
|
+
return JSON.parse(content[0].text);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function expectProcessSummaryShape(processInfo: any): void {
|
|
15
|
+
expect(processInfo).toEqual({
|
|
16
|
+
pid: expect.any(Number),
|
|
17
|
+
agent: expect.any(String),
|
|
18
|
+
status: expect.any(String),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createForgeMockScript(dir: string, argsLogPath: string): string {
|
|
23
|
+
const scriptPath = join(dir, 'mock-forge');
|
|
24
|
+
writeFileSync(
|
|
25
|
+
scriptPath,
|
|
26
|
+
`#!/bin/bash
|
|
27
|
+
set -euo pipefail
|
|
28
|
+
|
|
29
|
+
log_file="${argsLogPath}"
|
|
30
|
+
prompt=""
|
|
31
|
+
conversation_id=""
|
|
32
|
+
|
|
33
|
+
printf '%s\\n' "$*" >> "$log_file"
|
|
34
|
+
|
|
35
|
+
while [[ $# -gt 0 ]]; do
|
|
36
|
+
case "$1" in
|
|
37
|
+
-C)
|
|
38
|
+
shift 2
|
|
39
|
+
;;
|
|
40
|
+
-p)
|
|
41
|
+
prompt="$2"
|
|
42
|
+
shift 2
|
|
43
|
+
;;
|
|
44
|
+
--conversation-id)
|
|
45
|
+
conversation_id="$2"
|
|
46
|
+
shift 2
|
|
47
|
+
;;
|
|
48
|
+
*)
|
|
49
|
+
shift
|
|
50
|
+
;;
|
|
51
|
+
esac
|
|
52
|
+
done
|
|
53
|
+
|
|
54
|
+
if [[ -n "$conversation_id" ]]; then
|
|
55
|
+
printf '● [21:09:33] Continue %s\\n' "$conversation_id"
|
|
56
|
+
printf 'Resumed: %s\\n' "$prompt"
|
|
57
|
+
printf '● [21:09:37] Finished %s\\n' "$conversation_id"
|
|
58
|
+
else
|
|
59
|
+
printf '● [21:09:01] Initialize forge-session-1\\n'
|
|
60
|
+
printf 'Initial: %s\\n' "$prompt"
|
|
61
|
+
printf '● [21:09:08] Finished forge-session-1\\n'
|
|
62
|
+
fi
|
|
63
|
+
`
|
|
64
|
+
);
|
|
65
|
+
chmodSync(scriptPath, 0o755);
|
|
66
|
+
return scriptPath;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('MCP Contract Tests', () => {
|
|
70
|
+
let client: MCPTestClient;
|
|
71
|
+
let testDir: string;
|
|
72
|
+
|
|
73
|
+
beforeEach(async () => {
|
|
74
|
+
await getSharedMock();
|
|
75
|
+
testDir = mkdtempSync(join(tmpdir(), 'ai-cli-mcp-contract-'));
|
|
76
|
+
client = createTestClient({ debug: false });
|
|
77
|
+
await client.connect();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterEach(async () => {
|
|
81
|
+
await client.disconnect();
|
|
82
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterAll(async () => {
|
|
86
|
+
await cleanupSharedMock();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('registers the current MCP tool contract', async () => {
|
|
90
|
+
const tools = await client.listTools();
|
|
91
|
+
const toolNames = tools.map((tool: any) => tool.name).sort();
|
|
92
|
+
|
|
93
|
+
expect(toolNames).toEqual([
|
|
94
|
+
'cleanup_processes',
|
|
95
|
+
'get_result',
|
|
96
|
+
'kill_process',
|
|
97
|
+
'list_processes',
|
|
98
|
+
'run',
|
|
99
|
+
'wait',
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
const runTool = tools.find((tool: any) => tool.name === 'run');
|
|
103
|
+
expect(runTool.inputSchema.required).toEqual(['workFolder']);
|
|
104
|
+
expect(Object.keys(runTool.inputSchema.properties).sort()).toEqual([
|
|
105
|
+
'model',
|
|
106
|
+
'prompt',
|
|
107
|
+
'prompt_file',
|
|
108
|
+
'reasoning_effort',
|
|
109
|
+
'session_id',
|
|
110
|
+
'workFolder',
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const getResultTool = tools.find((tool: any) => tool.name === 'get_result');
|
|
114
|
+
expect(getResultTool.inputSchema.required).toEqual(['pid']);
|
|
115
|
+
expect(Object.keys(getResultTool.inputSchema.properties).sort()).toEqual([
|
|
116
|
+
'pid',
|
|
117
|
+
'verbose',
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
const waitTool = tools.find((tool: any) => tool.name === 'wait');
|
|
121
|
+
expect(waitTool.inputSchema.required).toEqual(['pids']);
|
|
122
|
+
expect(Object.keys(waitTool.inputSchema.properties).sort()).toEqual([
|
|
123
|
+
'pids',
|
|
124
|
+
'timeout',
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('preserves the stdio MCP smoke flow and response shapes', async () => {
|
|
129
|
+
const runResponse = await client.callTool('run', {
|
|
130
|
+
prompt: 'create a file called contract.txt with content "hello"',
|
|
131
|
+
workFolder: testDir,
|
|
132
|
+
model: 'haiku',
|
|
133
|
+
});
|
|
134
|
+
const runData = parseToolJson(runResponse);
|
|
135
|
+
|
|
136
|
+
expect(runData).toEqual({
|
|
137
|
+
pid: expect.any(Number),
|
|
138
|
+
status: 'started',
|
|
139
|
+
agent: 'claude',
|
|
140
|
+
message: expect.any(String),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const listResponse = await client.callTool('list_processes', {});
|
|
144
|
+
const listData = parseToolJson(listResponse);
|
|
145
|
+
const listedRun = listData.find((entry: any) => entry.pid === runData.pid);
|
|
146
|
+
|
|
147
|
+
expect(Array.isArray(listData)).toBe(true);
|
|
148
|
+
expect(listedRun).toBeTruthy();
|
|
149
|
+
expectProcessSummaryShape(listedRun);
|
|
150
|
+
|
|
151
|
+
const getResultResponse = await client.callTool('get_result', { pid: runData.pid });
|
|
152
|
+
const getResultData = parseToolJson(getResultResponse);
|
|
153
|
+
|
|
154
|
+
expect(getResultData).toMatchObject({
|
|
155
|
+
pid: runData.pid,
|
|
156
|
+
agent: 'claude',
|
|
157
|
+
status: expect.any(String),
|
|
158
|
+
startTime: expect.any(String),
|
|
159
|
+
workFolder: testDir,
|
|
160
|
+
prompt: 'create a file called contract.txt with content "hello"',
|
|
161
|
+
model: 'haiku',
|
|
162
|
+
stdout: expect.any(String),
|
|
163
|
+
stderr: expect.any(String),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const waitResponse = await client.callTool('wait', { pids: [runData.pid], timeout: 5 });
|
|
167
|
+
const waitData = parseToolJson(waitResponse);
|
|
168
|
+
|
|
169
|
+
expect(Array.isArray(waitData)).toBe(true);
|
|
170
|
+
expect(waitData).toHaveLength(1);
|
|
171
|
+
expect(waitData[0].pid).toBe(runData.pid);
|
|
172
|
+
expect(waitData[0].agent).toBe('claude');
|
|
173
|
+
expect(waitData[0].status).toBe('completed');
|
|
174
|
+
|
|
175
|
+
const cleanupResponse = await client.callTool('cleanup_processes', {});
|
|
176
|
+
const cleanupData = parseToolJson(cleanupResponse);
|
|
177
|
+
|
|
178
|
+
expect(cleanupData).toEqual({
|
|
179
|
+
removed: expect.any(Number),
|
|
180
|
+
removedPids: expect.any(Array),
|
|
181
|
+
message: expect.any(String),
|
|
182
|
+
});
|
|
183
|
+
expect(cleanupData.removedPids).toContain(runData.pid);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('accepts prompt_file and keeps the run response shape stable', async () => {
|
|
187
|
+
const promptFile = join(testDir, 'prompt.txt');
|
|
188
|
+
writeFileSync(promptFile, 'create a file called from-file.txt');
|
|
189
|
+
|
|
190
|
+
const runResponse = await client.callTool('run', {
|
|
191
|
+
prompt_file: promptFile,
|
|
192
|
+
workFolder: testDir,
|
|
193
|
+
});
|
|
194
|
+
const runData = parseToolJson(runResponse);
|
|
195
|
+
|
|
196
|
+
expect(runData).toEqual({
|
|
197
|
+
pid: expect.any(Number),
|
|
198
|
+
status: 'started',
|
|
199
|
+
agent: 'claude',
|
|
200
|
+
message: expect.any(String),
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('covers forge end-to-end through the MCP process path', async () => {
|
|
205
|
+
await client.disconnect();
|
|
206
|
+
|
|
207
|
+
const forgeArgsLogPath = join(testDir, 'forge-args.log');
|
|
208
|
+
const forgeMockPath = createForgeMockScript(testDir, forgeArgsLogPath);
|
|
209
|
+
|
|
210
|
+
client = createTestClient({
|
|
211
|
+
debug: false,
|
|
212
|
+
env: {
|
|
213
|
+
FORGE_CLI_NAME: forgeMockPath,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
await client.connect();
|
|
217
|
+
|
|
218
|
+
const initialRunResponse = await client.callTool('run', {
|
|
219
|
+
prompt: 'forge-initial-prompt',
|
|
220
|
+
workFolder: testDir,
|
|
221
|
+
model: 'forge',
|
|
222
|
+
});
|
|
223
|
+
const initialRunData = parseToolJson(initialRunResponse);
|
|
224
|
+
|
|
225
|
+
expect(initialRunData).toEqual({
|
|
226
|
+
pid: expect.any(Number),
|
|
227
|
+
status: 'started',
|
|
228
|
+
agent: 'forge',
|
|
229
|
+
message: expect.any(String),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const initialWaitResponse = await client.callTool('wait', { pids: [initialRunData.pid], timeout: 5 });
|
|
233
|
+
const initialWaitData = parseToolJson(initialWaitResponse);
|
|
234
|
+
|
|
235
|
+
expect(initialWaitData).toHaveLength(1);
|
|
236
|
+
expect(initialWaitData[0]).toMatchObject({
|
|
237
|
+
pid: initialRunData.pid,
|
|
238
|
+
agent: 'forge',
|
|
239
|
+
status: 'completed',
|
|
240
|
+
session_id: 'forge-session-1',
|
|
241
|
+
agentOutput: {
|
|
242
|
+
message: 'Initial: forge-initial-prompt',
|
|
243
|
+
session_id: 'forge-session-1',
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const initialResultResponse = await client.callTool('get_result', { pid: initialRunData.pid });
|
|
248
|
+
const initialResultData = parseToolJson(initialResultResponse);
|
|
249
|
+
|
|
250
|
+
expect(initialResultData).toMatchObject({
|
|
251
|
+
pid: initialRunData.pid,
|
|
252
|
+
agent: 'forge',
|
|
253
|
+
status: 'completed',
|
|
254
|
+
session_id: 'forge-session-1',
|
|
255
|
+
agentOutput: {
|
|
256
|
+
message: 'Initial: forge-initial-prompt',
|
|
257
|
+
session_id: 'forge-session-1',
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const resumedRunResponse = await client.callTool('run', {
|
|
262
|
+
prompt: 'forge-resume-prompt',
|
|
263
|
+
workFolder: testDir,
|
|
264
|
+
model: 'forge',
|
|
265
|
+
session_id: 'forge-session-1',
|
|
266
|
+
});
|
|
267
|
+
const resumedRunData = parseToolJson(resumedRunResponse);
|
|
268
|
+
|
|
269
|
+
expect(resumedRunData).toEqual({
|
|
270
|
+
pid: expect.any(Number),
|
|
271
|
+
status: 'started',
|
|
272
|
+
agent: 'forge',
|
|
273
|
+
message: expect.any(String),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const resumedWaitResponse = await client.callTool('wait', { pids: [resumedRunData.pid], timeout: 5 });
|
|
277
|
+
const resumedWaitData = parseToolJson(resumedWaitResponse);
|
|
278
|
+
|
|
279
|
+
expect(resumedWaitData).toHaveLength(1);
|
|
280
|
+
expect(resumedWaitData[0]).toMatchObject({
|
|
281
|
+
pid: resumedRunData.pid,
|
|
282
|
+
agent: 'forge',
|
|
283
|
+
status: 'completed',
|
|
284
|
+
session_id: 'forge-session-1',
|
|
285
|
+
agentOutput: {
|
|
286
|
+
message: 'Resumed: forge-resume-prompt',
|
|
287
|
+
session_id: 'forge-session-1',
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const resumedResultResponse = await client.callTool('get_result', { pid: resumedRunData.pid });
|
|
292
|
+
const resumedResultData = parseToolJson(resumedResultResponse);
|
|
293
|
+
|
|
294
|
+
expect(resumedResultData).toMatchObject({
|
|
295
|
+
pid: resumedRunData.pid,
|
|
296
|
+
agent: 'forge',
|
|
297
|
+
status: 'completed',
|
|
298
|
+
session_id: 'forge-session-1',
|
|
299
|
+
agentOutput: {
|
|
300
|
+
message: 'Resumed: forge-resume-prompt',
|
|
301
|
+
session_id: 'forge-session-1',
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const forgeInvocations = readFileSync(forgeArgsLogPath, 'utf-8').trim().split('\n');
|
|
306
|
+
expect(forgeInvocations).toHaveLength(2);
|
|
307
|
+
expect(forgeInvocations[0]).toContain(`-C ${testDir}`);
|
|
308
|
+
expect(forgeInvocations[0]).toContain('-p forge-initial-prompt');
|
|
309
|
+
expect(forgeInvocations[0]).not.toContain('--model');
|
|
310
|
+
expect(forgeInvocations[0]).not.toContain('--agent');
|
|
311
|
+
expect(forgeInvocations[0]).not.toContain('--conversation-id');
|
|
312
|
+
|
|
313
|
+
expect(forgeInvocations[1]).toContain(`-C ${testDir}`);
|
|
314
|
+
expect(forgeInvocations[1]).toContain('--conversation-id forge-session-1');
|
|
315
|
+
expect(forgeInvocations[1]).toContain('-p forge-resume-prompt');
|
|
316
|
+
expect(forgeInvocations[1]).not.toContain('--model');
|
|
317
|
+
expect(forgeInvocations[1]).not.toContain('--agent');
|
|
318
|
+
|
|
319
|
+
await expect(
|
|
320
|
+
client.callTool('run', {
|
|
321
|
+
prompt: 'forge-invalid-reasoning',
|
|
322
|
+
workFolder: testDir,
|
|
323
|
+
model: 'forge',
|
|
324
|
+
reasoning_effort: 'high',
|
|
325
|
+
})
|
|
326
|
+
).rejects.toThrow(/reasoning_effort is not supported for forge/i);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('keeps key invalid-input errors stable', async () => {
|
|
330
|
+
await expect(
|
|
331
|
+
client.callTool('run', {
|
|
332
|
+
prompt: 'missing workFolder',
|
|
333
|
+
})
|
|
334
|
+
).rejects.toThrow(/workFolder/i);
|
|
335
|
+
|
|
336
|
+
await expect(
|
|
337
|
+
client.callTool('run', {
|
|
338
|
+
prompt: 'bad dir',
|
|
339
|
+
workFolder: join(testDir, 'missing-dir'),
|
|
340
|
+
})
|
|
341
|
+
).rejects.toThrow(/does not exist/i);
|
|
342
|
+
|
|
343
|
+
const promptFile = join(testDir, 'both.txt');
|
|
344
|
+
writeFileSync(promptFile, 'test');
|
|
345
|
+
|
|
346
|
+
await expect(
|
|
347
|
+
client.callTool('run', {
|
|
348
|
+
prompt: 'hello',
|
|
349
|
+
prompt_file: promptFile,
|
|
350
|
+
workFolder: testDir,
|
|
351
|
+
})
|
|
352
|
+
).rejects.toThrow(/both prompt and prompt_file/i);
|
|
353
|
+
|
|
354
|
+
await expect(
|
|
355
|
+
client.callTool('run', {
|
|
356
|
+
workFolder: testDir,
|
|
357
|
+
})
|
|
358
|
+
).rejects.toThrow(/prompt or prompt_file/i);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('keeps unknown PID errors stable for get_result, wait, and kill_process', async () => {
|
|
362
|
+
await expect(
|
|
363
|
+
client.callTool('get_result', { pid: 999999 })
|
|
364
|
+
).rejects.toThrow(/PID 999999 not found/i);
|
|
365
|
+
|
|
366
|
+
await expect(
|
|
367
|
+
client.callTool('wait', { pids: [999999] })
|
|
368
|
+
).rejects.toThrow(/PID 999999 not found/i);
|
|
369
|
+
|
|
370
|
+
await expect(
|
|
371
|
+
client.callTool('kill_process', { pid: 999999 })
|
|
372
|
+
).rejects.toThrow(/PID 999999 not found/i);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('preserves kill_process response shape for a running process', async () => {
|
|
376
|
+
await client.disconnect();
|
|
377
|
+
|
|
378
|
+
const slowMockPath = join(testDir, 'slow-claude');
|
|
379
|
+
writeFileSync(
|
|
380
|
+
slowMockPath,
|
|
381
|
+
`#!/bin/bash
|
|
382
|
+
prompt=""
|
|
383
|
+
while [[ $# -gt 0 ]]; do
|
|
384
|
+
case "$1" in
|
|
385
|
+
-p|--prompt)
|
|
386
|
+
prompt="$2"
|
|
387
|
+
shift 2
|
|
388
|
+
;;
|
|
389
|
+
*)
|
|
390
|
+
shift
|
|
391
|
+
;;
|
|
392
|
+
esac
|
|
393
|
+
done
|
|
394
|
+
|
|
395
|
+
if [[ "$prompt" == *"sleep"* ]]; then
|
|
396
|
+
sleep 5
|
|
397
|
+
fi
|
|
398
|
+
|
|
399
|
+
echo "Command executed successfully"
|
|
400
|
+
`
|
|
401
|
+
);
|
|
402
|
+
chmodSync(slowMockPath, 0o755);
|
|
403
|
+
|
|
404
|
+
client = createTestClient({ claudeCliName: slowMockPath, debug: false });
|
|
405
|
+
await client.connect();
|
|
406
|
+
|
|
407
|
+
const runResponse = await client.callTool('run', {
|
|
408
|
+
prompt: 'sleep for contract kill test',
|
|
409
|
+
workFolder: testDir,
|
|
410
|
+
});
|
|
411
|
+
const runData = parseToolJson(runResponse);
|
|
412
|
+
|
|
413
|
+
const killResponse = await client.callTool('kill_process', { pid: runData.pid });
|
|
414
|
+
const killData = parseToolJson(killResponse);
|
|
415
|
+
|
|
416
|
+
expect(killData).toEqual({
|
|
417
|
+
pid: runData.pid,
|
|
418
|
+
status: 'terminated',
|
|
419
|
+
message: expect.any(String),
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseCodexOutput, parseClaudeOutput } from '../parsers.js';
|
|
2
|
+
import { parseCodexOutput, parseClaudeOutput, parseForgeOutput } from '../parsers.js';
|
|
3
3
|
|
|
4
4
|
describe('parseCodexOutput', () => {
|
|
5
5
|
it('should parse basic Codex output with message and session_id', () => {
|
|
@@ -106,3 +106,46 @@ INVALID_LINE
|
|
|
106
106
|
expect(result.message).toBe("Success");
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
describe('parseForgeOutput', () => {
|
|
111
|
+
it('should parse initialized forge output with a conversation id', () => {
|
|
112
|
+
const output = `● [21:09:01] Initialize 123e4567-e89b-12d3-a456-426614174000
|
|
113
|
+
Hello from Forge
|
|
114
|
+
● [21:09:08] Finished 123e4567-e89b-12d3-a456-426614174000
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
expect(parseForgeOutput(output)).toEqual({
|
|
118
|
+
message: 'Hello from Forge',
|
|
119
|
+
session_id: '123e4567-e89b-12d3-a456-426614174000',
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should parse resumed forge output with multiline assistant content', () => {
|
|
124
|
+
const output = `● [21:09:33] Continue conv-123
|
|
125
|
+
Line one
|
|
126
|
+
|
|
127
|
+
Line three
|
|
128
|
+
● [21:09:37] Finished conv-123
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
expect(parseForgeOutput(output)).toEqual({
|
|
132
|
+
message: 'Line one\n\nLine three',
|
|
133
|
+
session_id: 'conv-123',
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should return the current message while forge output is still in progress', () => {
|
|
138
|
+
const output = `● [21:09:33] Continue conv-456
|
|
139
|
+
Partial answer
|
|
140
|
+
still streaming`;
|
|
141
|
+
|
|
142
|
+
expect(parseForgeOutput(output)).toEqual({
|
|
143
|
+
message: 'Partial answer\nstill streaming',
|
|
144
|
+
session_id: 'conv-456',
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should return null for unrelated forge output', () => {
|
|
149
|
+
expect(parseForgeOutput('plain text')).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -329,15 +329,21 @@ Unicodeテスト: 🎌 🗾 ✨
|
|
|
329
329
|
mockSpawn.mockReturnValue(mockProcess);
|
|
330
330
|
|
|
331
331
|
const callToolHandler = handlers.get('callTool')!;
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
332
|
+
try {
|
|
333
|
+
await callToolHandler!({
|
|
334
|
+
params: {
|
|
335
|
+
name: 'run',
|
|
336
|
+
arguments: {
|
|
337
|
+
prompt: 'test prompt',
|
|
338
|
+
workFolder: '/tmp/test'
|
|
339
|
+
}
|
|
338
340
|
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
+
});
|
|
342
|
+
expect.fail('Should have thrown');
|
|
343
|
+
} catch (error: any) {
|
|
344
|
+
expect(error.message).toContain('Failed to start claude CLI process');
|
|
345
|
+
expect(error.code).toBe('InternalError');
|
|
346
|
+
}
|
|
341
347
|
});
|
|
342
348
|
});
|
|
343
349
|
|
|
@@ -767,4 +773,4 @@ Unicodeテスト: 🎌 🗾 ✨
|
|
|
767
773
|
expect(processInfo.stderr).toContain('Process error: spawn error');
|
|
768
774
|
});
|
|
769
775
|
});
|
|
770
|
-
});
|
|
776
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
3
|
+
import { accessSync, existsSync } from 'node:fs';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { resolve as pathResolve } from 'node:path';
|
|
6
6
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
@@ -50,6 +50,7 @@ vi.mock('../../package.json', () => ({
|
|
|
50
50
|
|
|
51
51
|
// Re-import after mocks
|
|
52
52
|
const mockExistsSync = vi.mocked(existsSync);
|
|
53
|
+
const mockAccessSync = vi.mocked(accessSync);
|
|
53
54
|
const mockSpawn = vi.mocked(spawn);
|
|
54
55
|
const mockHomedir = vi.mocked(homedir);
|
|
55
56
|
const mockPathResolve = vi.mocked(pathResolve);
|
|
@@ -70,6 +71,12 @@ describe('ClaudeCodeServer Unit Tests', () => {
|
|
|
70
71
|
originalEnv = { ...process.env };
|
|
71
72
|
// Reset env
|
|
72
73
|
process.env = { ...originalEnv };
|
|
74
|
+
mockAccessSync.mockImplementation((filePath) => {
|
|
75
|
+
if (typeof filePath === 'string' && mockExistsSync(filePath)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
throw new Error('not executable');
|
|
79
|
+
});
|
|
73
80
|
});
|
|
74
81
|
|
|
75
82
|
afterEach(() => {
|
|
@@ -111,6 +118,10 @@ describe('ClaudeCodeServer Unit Tests', () => {
|
|
|
111
118
|
if (path === '/home/user/.claude/local/claude') return true;
|
|
112
119
|
return false;
|
|
113
120
|
});
|
|
121
|
+
mockAccessSync.mockImplementation((filePath) => {
|
|
122
|
+
if (filePath === '/home/user/.claude/local/claude') return undefined;
|
|
123
|
+
throw new Error('not executable');
|
|
124
|
+
});
|
|
114
125
|
|
|
115
126
|
const module = await import('../server.js');
|
|
116
127
|
// @ts-ignore
|
|
@@ -123,6 +134,9 @@ describe('ClaudeCodeServer Unit Tests', () => {
|
|
|
123
134
|
it('should fallback to PATH when local does not exist', async () => {
|
|
124
135
|
mockHomedir.mockReturnValue('/home/user');
|
|
125
136
|
mockExistsSync.mockReturnValue(false);
|
|
137
|
+
mockAccessSync.mockImplementation(() => {
|
|
138
|
+
throw new Error('not executable');
|
|
139
|
+
});
|
|
126
140
|
|
|
127
141
|
const module = await import('../server.js');
|
|
128
142
|
// @ts-ignore
|
|
@@ -130,15 +144,18 @@ describe('ClaudeCodeServer Unit Tests', () => {
|
|
|
130
144
|
|
|
131
145
|
const result = findClaudeCli();
|
|
132
146
|
expect(result).toBe('claude');
|
|
133
|
-
expect(consoleWarnSpy).
|
|
134
|
-
expect.stringContaining('Claude CLI not found at ~/.claude/local/claude')
|
|
135
|
-
);
|
|
147
|
+
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
|
136
148
|
});
|
|
137
149
|
|
|
138
150
|
it('should use custom name from CLAUDE_CLI_NAME', async () => {
|
|
139
151
|
process.env.CLAUDE_CLI_NAME = 'my-claude';
|
|
140
152
|
mockHomedir.mockReturnValue('/home/user');
|
|
141
|
-
mockExistsSync.
|
|
153
|
+
mockExistsSync.mockImplementation((path) => path === '/usr/bin/my-claude');
|
|
154
|
+
mockAccessSync.mockImplementation((filePath) => {
|
|
155
|
+
if (filePath === '/usr/bin/my-claude') return undefined;
|
|
156
|
+
throw new Error('not executable');
|
|
157
|
+
});
|
|
158
|
+
process.env.PATH = '/usr/bin';
|
|
142
159
|
|
|
143
160
|
const module = await import('../server.js');
|
|
144
161
|
// @ts-ignore
|
|
@@ -150,6 +167,10 @@ describe('ClaudeCodeServer Unit Tests', () => {
|
|
|
150
167
|
|
|
151
168
|
it('should use absolute path from CLAUDE_CLI_NAME', async () => {
|
|
152
169
|
process.env.CLAUDE_CLI_NAME = '/absolute/path/to/claude';
|
|
170
|
+
mockAccessSync.mockImplementation((filePath) => {
|
|
171
|
+
if (filePath === '/absolute/path/to/claude') return undefined;
|
|
172
|
+
throw new Error('not executable');
|
|
173
|
+
});
|
|
153
174
|
|
|
154
175
|
const module = await import('../server.js');
|
|
155
176
|
// @ts-ignore
|
|
@@ -960,4 +981,4 @@ describe('ClaudeCodeServer Unit Tests', () => {
|
|
|
960
981
|
}
|
|
961
982
|
});
|
|
962
983
|
});
|
|
963
|
-
});
|
|
984
|
+
});
|
|
@@ -88,6 +88,7 @@ describe('Wait Tool Tests', () => {
|
|
|
88
88
|
|
|
89
89
|
afterEach(() => {
|
|
90
90
|
vi.clearAllMocks();
|
|
91
|
+
vi.useRealTimers();
|
|
91
92
|
});
|
|
92
93
|
|
|
93
94
|
const createMockProcess = (pid: number) => {
|
|
@@ -216,6 +217,43 @@ describe('Wait Tool Tests', () => {
|
|
|
216
217
|
expect(response.find((r: any) => r.pid === 102).status).toBe('completed');
|
|
217
218
|
});
|
|
218
219
|
|
|
220
|
+
it('should clear timeout timers after wait resolves', async () => {
|
|
221
|
+
vi.useFakeTimers();
|
|
222
|
+
|
|
223
|
+
const callToolHandler = handlers.get('callTool')!;
|
|
224
|
+
const mockProcess = createMockProcess(12348);
|
|
225
|
+
mockSpawn.mockReturnValue(mockProcess);
|
|
226
|
+
|
|
227
|
+
await callToolHandler({
|
|
228
|
+
params: {
|
|
229
|
+
name: 'run',
|
|
230
|
+
arguments: {
|
|
231
|
+
prompt: 'test prompt',
|
|
232
|
+
workFolder: '/tmp'
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const waitPromise = callToolHandler({
|
|
238
|
+
params: {
|
|
239
|
+
name: 'wait',
|
|
240
|
+
arguments: {
|
|
241
|
+
pids: [12348],
|
|
242
|
+
timeout: 180
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
mockProcess.emit('close', 0);
|
|
248
|
+
await vi.runAllTicks();
|
|
249
|
+
|
|
250
|
+
const result = await waitPromise;
|
|
251
|
+
const response = JSON.parse(result.content[0].text);
|
|
252
|
+
|
|
253
|
+
expect(response[0].status).toBe('completed');
|
|
254
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
255
|
+
});
|
|
256
|
+
|
|
219
257
|
it('should throw error for non-existent PID', async () => {
|
|
220
258
|
const callToolHandler = handlers.get('callTool')!;
|
|
221
259
|
|