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.
Files changed (55) hide show
  1. package/.github/workflows/publish.yml +25 -0
  2. package/CHANGELOG.md +23 -0
  3. package/README.ja.md +112 -8
  4. package/README.md +112 -9
  5. package/dist/__tests__/app-cli.test.js +293 -0
  6. package/dist/__tests__/cli-bin-smoke.test.js +58 -0
  7. package/dist/__tests__/cli-builder.test.js +37 -0
  8. package/dist/__tests__/cli-process-service.test.js +279 -0
  9. package/dist/__tests__/cli-utils.test.js +140 -0
  10. package/dist/__tests__/error-cases.test.js +2 -1
  11. package/dist/__tests__/mcp-contract.test.js +343 -0
  12. package/dist/__tests__/parsers.test.js +37 -1
  13. package/dist/__tests__/process-management.test.js +15 -8
  14. package/dist/__tests__/server.test.js +29 -3
  15. package/dist/__tests__/wait.test.js +31 -0
  16. package/dist/app/cli.js +304 -0
  17. package/dist/app/mcp.js +366 -0
  18. package/dist/bin/ai-cli-mcp.js +6 -0
  19. package/dist/bin/ai-cli.js +10 -0
  20. package/dist/cli-builder.js +15 -6
  21. package/dist/cli-parse.js +8 -5
  22. package/dist/cli-process-service.js +332 -0
  23. package/dist/cli-utils.js +159 -88
  24. package/dist/cli.js +4 -3
  25. package/dist/model-catalog.js +53 -0
  26. package/dist/parsers.js +55 -0
  27. package/dist/process-service.js +201 -0
  28. package/dist/server.js +4 -578
  29. package/docs/cli-architecture.md +275 -0
  30. package/package.json +4 -3
  31. package/server.json +1 -1
  32. package/src/__tests__/app-cli.test.ts +370 -0
  33. package/src/__tests__/cli-bin-smoke.test.ts +75 -0
  34. package/src/__tests__/cli-builder.test.ts +47 -0
  35. package/src/__tests__/cli-process-service.test.ts +334 -0
  36. package/src/__tests__/cli-utils.test.ts +166 -0
  37. package/src/__tests__/error-cases.test.ts +3 -4
  38. package/src/__tests__/mcp-contract.test.ts +422 -0
  39. package/src/__tests__/parsers.test.ts +44 -1
  40. package/src/__tests__/process-management.test.ts +15 -9
  41. package/src/__tests__/server.test.ts +27 -6
  42. package/src/__tests__/wait.test.ts +38 -0
  43. package/src/app/cli.ts +373 -0
  44. package/src/app/mcp.ts +402 -0
  45. package/src/bin/ai-cli-mcp.ts +7 -0
  46. package/src/bin/ai-cli.ts +11 -0
  47. package/src/cli-builder.ts +19 -10
  48. package/src/cli-parse.ts +8 -5
  49. package/src/cli-process-service.ts +418 -0
  50. package/src/cli-utils.ts +205 -99
  51. package/src/cli.ts +4 -3
  52. package/src/model-catalog.ts +64 -0
  53. package/src/parsers.ts +61 -0
  54. package/src/process-service.ts +263 -0
  55. 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
- await expect(callToolHandler!({
333
- params: {
334
- name: 'run',
335
- arguments: {
336
- prompt: 'test prompt',
337
- workFolder: '/tmp/test'
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
- })).rejects.toThrow('Failed to start claude CLI process');
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).toHaveBeenCalledWith(
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.mockReturnValue(false);
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