ai-cli-mcp 2.10.0 → 2.12.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/watch-session-prs.yml +276 -0
- package/CHANGELOG.md +17 -0
- package/README.ja.md +104 -5
- package/README.md +104 -5
- package/dist/__tests__/app-cli.test.js +285 -0
- package/dist/__tests__/cli-bin-smoke.test.js +54 -0
- package/dist/__tests__/cli-builder.test.js +49 -2
- package/dist/__tests__/cli-process-service.test.js +233 -0
- package/dist/__tests__/cli-utils.test.js +109 -0
- package/dist/__tests__/error-cases.test.js +2 -1
- package/dist/__tests__/mcp-contract.test.js +195 -0
- package/dist/__tests__/process-management.test.js +15 -8
- package/dist/__tests__/server.test.js +29 -3
- package/dist/__tests__/validation.test.js +2 -2
- package/dist/__tests__/wait.test.js +31 -0
- package/dist/app/cli.js +304 -0
- package/dist/app/mcp.js +362 -0
- package/dist/bin/ai-cli-mcp.js +6 -0
- package/dist/bin/ai-cli.js +10 -0
- package/dist/cli-builder.js +29 -22
- package/dist/cli-process-service.js +328 -0
- package/dist/cli-utils.js +142 -88
- package/dist/cli.js +1 -1
- package/dist/model-catalog.js +50 -0
- package/dist/process-service.js +198 -0
- package/dist/server.js +3 -577
- package/docs/cli-architecture.md +275 -0
- package/package.json +3 -2
- package/src/__tests__/app-cli.test.ts +362 -0
- package/src/__tests__/cli-bin-smoke.test.ts +71 -0
- package/src/__tests__/cli-builder.test.ts +62 -3
- package/src/__tests__/cli-process-service.test.ts +278 -0
- package/src/__tests__/cli-utils.test.ts +132 -0
- package/src/__tests__/error-cases.test.ts +3 -4
- package/src/__tests__/mcp-contract.test.ts +250 -0
- package/src/__tests__/process-management.test.ts +15 -9
- package/src/__tests__/server.test.ts +27 -6
- package/src/__tests__/validation.test.ts +2 -2
- package/src/__tests__/wait.test.ts +38 -0
- package/src/app/cli.ts +373 -0
- package/src/app/mcp.ts +398 -0
- package/src/bin/ai-cli-mcp.ts +7 -0
- package/src/bin/ai-cli.ts +11 -0
- package/src/cli-builder.ts +32 -22
- package/src/cli-process-service.ts +415 -0
- package/src/cli-utils.ts +185 -99
- package/src/cli.ts +1 -1
- package/src/model-catalog.ts +60 -0
- package/src/process-service.ts +261 -0
- package/src/server.ts +3 -667
- package/.github/workflows/watch-codex-fork-pr.yml +0 -98
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { accessSync } from 'node:fs';
|
|
3
|
+
vi.mock('node:fs', () => ({
|
|
4
|
+
accessSync: vi.fn(),
|
|
5
|
+
constants: { X_OK: 1 },
|
|
6
|
+
}));
|
|
7
|
+
const mockAccessSync = vi.mocked(accessSync);
|
|
8
|
+
describe('cli-utils doctor status', () => {
|
|
9
|
+
const originalEnv = process.env;
|
|
10
|
+
const originalPlatform = process.platform;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.resetModules();
|
|
13
|
+
mockAccessSync.mockReset();
|
|
14
|
+
process.env = { ...originalEnv };
|
|
15
|
+
delete process.env.CLAUDE_CLI_NAME;
|
|
16
|
+
delete process.env.CODEX_CLI_NAME;
|
|
17
|
+
delete process.env.GEMINI_CLI_NAME;
|
|
18
|
+
process.env.PATH = '/mock/bin:/usr/bin';
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
process.env = originalEnv;
|
|
22
|
+
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
23
|
+
});
|
|
24
|
+
it('marks PATH binaries available when they are executable', async () => {
|
|
25
|
+
mockAccessSync.mockImplementation((filePath) => {
|
|
26
|
+
if (filePath === '/mock/bin/claude') {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
throw new Error('not executable');
|
|
30
|
+
});
|
|
31
|
+
const { getCliDoctorStatus } = await import('../cli-utils.js');
|
|
32
|
+
const status = getCliDoctorStatus();
|
|
33
|
+
expect(status.claude).toEqual({
|
|
34
|
+
configuredCommand: 'claude',
|
|
35
|
+
resolvedPath: '/mock/bin/claude',
|
|
36
|
+
available: true,
|
|
37
|
+
lookup: 'path',
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
it('does not mark non-executable PATH entries as available', async () => {
|
|
41
|
+
mockAccessSync.mockImplementation(() => {
|
|
42
|
+
throw new Error('not executable');
|
|
43
|
+
});
|
|
44
|
+
const { getCliDoctorStatus } = await import('../cli-utils.js');
|
|
45
|
+
const status = getCliDoctorStatus();
|
|
46
|
+
expect(status.claude).toEqual({
|
|
47
|
+
configuredCommand: 'claude',
|
|
48
|
+
resolvedPath: null,
|
|
49
|
+
available: false,
|
|
50
|
+
lookup: 'path',
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
it('reports invalid relative env paths as doctor errors', async () => {
|
|
54
|
+
process.env.CLAUDE_CLI_NAME = './relative/claude';
|
|
55
|
+
const { getCliDoctorStatus } = await import('../cli-utils.js');
|
|
56
|
+
const status = getCliDoctorStatus();
|
|
57
|
+
expect(status.claude.available).toBe(false);
|
|
58
|
+
expect(status.claude.lookup).toBe('env');
|
|
59
|
+
expect(status.claude.error).toContain('Invalid CLAUDE_CLI_NAME');
|
|
60
|
+
});
|
|
61
|
+
it('reports missing absolute env paths as unavailable', async () => {
|
|
62
|
+
process.env.CLAUDE_CLI_NAME = '/missing/claude';
|
|
63
|
+
mockAccessSync.mockImplementation(() => {
|
|
64
|
+
throw new Error('missing');
|
|
65
|
+
});
|
|
66
|
+
const { getCliDoctorStatus } = await import('../cli-utils.js');
|
|
67
|
+
const status = getCliDoctorStatus();
|
|
68
|
+
expect(status.claude).toEqual({
|
|
69
|
+
configuredCommand: '/missing/claude',
|
|
70
|
+
resolvedPath: '/missing/claude',
|
|
71
|
+
available: false,
|
|
72
|
+
lookup: 'env',
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
it('falls back cleanly when PATH is empty', async () => {
|
|
76
|
+
process.env.PATH = '';
|
|
77
|
+
mockAccessSync.mockImplementation(() => {
|
|
78
|
+
throw new Error('missing');
|
|
79
|
+
});
|
|
80
|
+
const { getCliDoctorStatus } = await import('../cli-utils.js');
|
|
81
|
+
const status = getCliDoctorStatus();
|
|
82
|
+
expect(status.codex).toEqual({
|
|
83
|
+
configuredCommand: 'codex',
|
|
84
|
+
resolvedPath: null,
|
|
85
|
+
available: false,
|
|
86
|
+
lookup: 'path',
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
it('supports Windows commands that already include an executable suffix', async () => {
|
|
90
|
+
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
91
|
+
process.env.PATHEXT = '.EXE;.CMD';
|
|
92
|
+
process.env.CLAUDE_CLI_NAME = 'claude.cmd';
|
|
93
|
+
process.env.PATH = '/mock/bin';
|
|
94
|
+
mockAccessSync.mockImplementation((filePath) => {
|
|
95
|
+
if (filePath === '/mock/bin/claude.cmd') {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
throw new Error('not executable');
|
|
99
|
+
});
|
|
100
|
+
const { getCliDoctorStatus } = await import('../cli-utils.js');
|
|
101
|
+
const status = getCliDoctorStatus();
|
|
102
|
+
expect(status.claude).toEqual({
|
|
103
|
+
configuredCommand: 'claude.cmd',
|
|
104
|
+
resolvedPath: '/mock/bin/claude.cmd',
|
|
105
|
+
available: true,
|
|
106
|
+
lookup: 'env',
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -271,7 +271,8 @@ describe('Error Handling Tests', () => {
|
|
|
271
271
|
// @ts-ignore
|
|
272
272
|
const { ClaudeCodeServer } = module;
|
|
273
273
|
const server = new ClaudeCodeServer();
|
|
274
|
-
expect(
|
|
274
|
+
expect(server).toBeDefined();
|
|
275
|
+
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
|
275
276
|
consoleWarnSpy.mockRestore();
|
|
276
277
|
});
|
|
277
278
|
it('should handle server connection errors', async () => {
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { chmodSync, mkdtempSync, 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 } from './utils/mcp-client.js';
|
|
7
|
+
function parseToolJson(content) {
|
|
8
|
+
expect(content).toHaveLength(1);
|
|
9
|
+
expect(content[0].type).toBe('text');
|
|
10
|
+
return JSON.parse(content[0].text);
|
|
11
|
+
}
|
|
12
|
+
function expectProcessSummaryShape(processInfo) {
|
|
13
|
+
expect(processInfo).toEqual({
|
|
14
|
+
pid: expect.any(Number),
|
|
15
|
+
agent: expect.any(String),
|
|
16
|
+
status: expect.any(String),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
describe('MCP Contract Tests', () => {
|
|
20
|
+
let client;
|
|
21
|
+
let testDir;
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
await getSharedMock();
|
|
24
|
+
testDir = mkdtempSync(join(tmpdir(), 'ai-cli-mcp-contract-'));
|
|
25
|
+
client = createTestClient({ debug: false });
|
|
26
|
+
await client.connect();
|
|
27
|
+
});
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
await client.disconnect();
|
|
30
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
afterAll(async () => {
|
|
33
|
+
await cleanupSharedMock();
|
|
34
|
+
});
|
|
35
|
+
it('registers the current MCP tool contract', async () => {
|
|
36
|
+
const tools = await client.listTools();
|
|
37
|
+
const toolNames = tools.map((tool) => tool.name).sort();
|
|
38
|
+
expect(toolNames).toEqual([
|
|
39
|
+
'cleanup_processes',
|
|
40
|
+
'get_result',
|
|
41
|
+
'kill_process',
|
|
42
|
+
'list_processes',
|
|
43
|
+
'run',
|
|
44
|
+
'wait',
|
|
45
|
+
]);
|
|
46
|
+
const runTool = tools.find((tool) => tool.name === 'run');
|
|
47
|
+
expect(runTool.inputSchema.required).toEqual(['workFolder']);
|
|
48
|
+
expect(Object.keys(runTool.inputSchema.properties).sort()).toEqual([
|
|
49
|
+
'model',
|
|
50
|
+
'prompt',
|
|
51
|
+
'prompt_file',
|
|
52
|
+
'reasoning_effort',
|
|
53
|
+
'session_id',
|
|
54
|
+
'workFolder',
|
|
55
|
+
]);
|
|
56
|
+
const getResultTool = tools.find((tool) => tool.name === 'get_result');
|
|
57
|
+
expect(getResultTool.inputSchema.required).toEqual(['pid']);
|
|
58
|
+
expect(Object.keys(getResultTool.inputSchema.properties).sort()).toEqual([
|
|
59
|
+
'pid',
|
|
60
|
+
'verbose',
|
|
61
|
+
]);
|
|
62
|
+
const waitTool = tools.find((tool) => tool.name === 'wait');
|
|
63
|
+
expect(waitTool.inputSchema.required).toEqual(['pids']);
|
|
64
|
+
expect(Object.keys(waitTool.inputSchema.properties).sort()).toEqual([
|
|
65
|
+
'pids',
|
|
66
|
+
'timeout',
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
it('preserves the stdio MCP smoke flow and response shapes', async () => {
|
|
70
|
+
const runResponse = await client.callTool('run', {
|
|
71
|
+
prompt: 'create a file called contract.txt with content "hello"',
|
|
72
|
+
workFolder: testDir,
|
|
73
|
+
model: 'haiku',
|
|
74
|
+
});
|
|
75
|
+
const runData = parseToolJson(runResponse);
|
|
76
|
+
expect(runData).toEqual({
|
|
77
|
+
pid: expect.any(Number),
|
|
78
|
+
status: 'started',
|
|
79
|
+
agent: 'claude',
|
|
80
|
+
message: expect.any(String),
|
|
81
|
+
});
|
|
82
|
+
const listResponse = await client.callTool('list_processes', {});
|
|
83
|
+
const listData = parseToolJson(listResponse);
|
|
84
|
+
const listedRun = listData.find((entry) => entry.pid === runData.pid);
|
|
85
|
+
expect(Array.isArray(listData)).toBe(true);
|
|
86
|
+
expect(listedRun).toBeTruthy();
|
|
87
|
+
expectProcessSummaryShape(listedRun);
|
|
88
|
+
const getResultResponse = await client.callTool('get_result', { pid: runData.pid });
|
|
89
|
+
const getResultData = parseToolJson(getResultResponse);
|
|
90
|
+
expect(getResultData).toMatchObject({
|
|
91
|
+
pid: runData.pid,
|
|
92
|
+
agent: 'claude',
|
|
93
|
+
status: expect.any(String),
|
|
94
|
+
startTime: expect.any(String),
|
|
95
|
+
workFolder: testDir,
|
|
96
|
+
prompt: 'create a file called contract.txt with content "hello"',
|
|
97
|
+
model: 'haiku',
|
|
98
|
+
stdout: expect.any(String),
|
|
99
|
+
stderr: expect.any(String),
|
|
100
|
+
});
|
|
101
|
+
const waitResponse = await client.callTool('wait', { pids: [runData.pid], timeout: 5 });
|
|
102
|
+
const waitData = parseToolJson(waitResponse);
|
|
103
|
+
expect(Array.isArray(waitData)).toBe(true);
|
|
104
|
+
expect(waitData).toHaveLength(1);
|
|
105
|
+
expect(waitData[0].pid).toBe(runData.pid);
|
|
106
|
+
expect(waitData[0].agent).toBe('claude');
|
|
107
|
+
expect(waitData[0].status).toBe('completed');
|
|
108
|
+
const cleanupResponse = await client.callTool('cleanup_processes', {});
|
|
109
|
+
const cleanupData = parseToolJson(cleanupResponse);
|
|
110
|
+
expect(cleanupData).toEqual({
|
|
111
|
+
removed: expect.any(Number),
|
|
112
|
+
removedPids: expect.any(Array),
|
|
113
|
+
message: expect.any(String),
|
|
114
|
+
});
|
|
115
|
+
expect(cleanupData.removedPids).toContain(runData.pid);
|
|
116
|
+
});
|
|
117
|
+
it('accepts prompt_file and keeps the run response shape stable', async () => {
|
|
118
|
+
const promptFile = join(testDir, 'prompt.txt');
|
|
119
|
+
writeFileSync(promptFile, 'create a file called from-file.txt');
|
|
120
|
+
const runResponse = await client.callTool('run', {
|
|
121
|
+
prompt_file: promptFile,
|
|
122
|
+
workFolder: testDir,
|
|
123
|
+
});
|
|
124
|
+
const runData = parseToolJson(runResponse);
|
|
125
|
+
expect(runData).toEqual({
|
|
126
|
+
pid: expect.any(Number),
|
|
127
|
+
status: 'started',
|
|
128
|
+
agent: 'claude',
|
|
129
|
+
message: expect.any(String),
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
it('keeps key invalid-input errors stable', async () => {
|
|
133
|
+
await expect(client.callTool('run', {
|
|
134
|
+
prompt: 'missing workFolder',
|
|
135
|
+
})).rejects.toThrow(/workFolder/i);
|
|
136
|
+
await expect(client.callTool('run', {
|
|
137
|
+
prompt: 'bad dir',
|
|
138
|
+
workFolder: join(testDir, 'missing-dir'),
|
|
139
|
+
})).rejects.toThrow(/does not exist/i);
|
|
140
|
+
const promptFile = join(testDir, 'both.txt');
|
|
141
|
+
writeFileSync(promptFile, 'test');
|
|
142
|
+
await expect(client.callTool('run', {
|
|
143
|
+
prompt: 'hello',
|
|
144
|
+
prompt_file: promptFile,
|
|
145
|
+
workFolder: testDir,
|
|
146
|
+
})).rejects.toThrow(/both prompt and prompt_file/i);
|
|
147
|
+
await expect(client.callTool('run', {
|
|
148
|
+
workFolder: testDir,
|
|
149
|
+
})).rejects.toThrow(/prompt or prompt_file/i);
|
|
150
|
+
});
|
|
151
|
+
it('keeps unknown PID errors stable for get_result, wait, and kill_process', async () => {
|
|
152
|
+
await expect(client.callTool('get_result', { pid: 999999 })).rejects.toThrow(/PID 999999 not found/i);
|
|
153
|
+
await expect(client.callTool('wait', { pids: [999999] })).rejects.toThrow(/PID 999999 not found/i);
|
|
154
|
+
await expect(client.callTool('kill_process', { pid: 999999 })).rejects.toThrow(/PID 999999 not found/i);
|
|
155
|
+
});
|
|
156
|
+
it('preserves kill_process response shape for a running process', async () => {
|
|
157
|
+
await client.disconnect();
|
|
158
|
+
const slowMockPath = join(testDir, 'slow-claude');
|
|
159
|
+
writeFileSync(slowMockPath, `#!/bin/bash
|
|
160
|
+
prompt=""
|
|
161
|
+
while [[ $# -gt 0 ]]; do
|
|
162
|
+
case "$1" in
|
|
163
|
+
-p|--prompt)
|
|
164
|
+
prompt="$2"
|
|
165
|
+
shift 2
|
|
166
|
+
;;
|
|
167
|
+
*)
|
|
168
|
+
shift
|
|
169
|
+
;;
|
|
170
|
+
esac
|
|
171
|
+
done
|
|
172
|
+
|
|
173
|
+
if [[ "$prompt" == *"sleep"* ]]; then
|
|
174
|
+
sleep 5
|
|
175
|
+
fi
|
|
176
|
+
|
|
177
|
+
echo "Command executed successfully"
|
|
178
|
+
`);
|
|
179
|
+
chmodSync(slowMockPath, 0o755);
|
|
180
|
+
client = createTestClient({ claudeCliName: slowMockPath, debug: false });
|
|
181
|
+
await client.connect();
|
|
182
|
+
const runResponse = await client.callTool('run', {
|
|
183
|
+
prompt: 'sleep for contract kill test',
|
|
184
|
+
workFolder: testDir,
|
|
185
|
+
});
|
|
186
|
+
const runData = parseToolJson(runResponse);
|
|
187
|
+
const killResponse = await client.callTool('kill_process', { pid: runData.pid });
|
|
188
|
+
const killData = parseToolJson(killResponse);
|
|
189
|
+
expect(killData).toEqual({
|
|
190
|
+
pid: runData.pid,
|
|
191
|
+
status: 'terminated',
|
|
192
|
+
message: expect.any(String),
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -263,15 +263,22 @@ Unicodeテスト: 🎌 🗾 ✨
|
|
|
263
263
|
mockProcess.stderr = new EventEmitter();
|
|
264
264
|
mockSpawn.mockReturnValue(mockProcess);
|
|
265
265
|
const callToolHandler = handlers.get('callTool');
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
266
|
+
try {
|
|
267
|
+
await callToolHandler({
|
|
268
|
+
params: {
|
|
269
|
+
name: 'run',
|
|
270
|
+
arguments: {
|
|
271
|
+
prompt: 'test prompt',
|
|
272
|
+
workFolder: '/tmp/test'
|
|
273
|
+
}
|
|
272
274
|
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
+
});
|
|
276
|
+
expect.fail('Should have thrown');
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
expect(error.message).toContain('Failed to start claude CLI process');
|
|
280
|
+
expect(error.code).toBe('InternalError');
|
|
281
|
+
}
|
|
275
282
|
});
|
|
276
283
|
});
|
|
277
284
|
describe('list_processes tool', () => {
|
|
@@ -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 { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
@@ -46,6 +46,7 @@ vi.mock('../../package.json', () => ({
|
|
|
46
46
|
}));
|
|
47
47
|
// Re-import after mocks
|
|
48
48
|
const mockExistsSync = vi.mocked(existsSync);
|
|
49
|
+
const mockAccessSync = vi.mocked(accessSync);
|
|
49
50
|
const mockSpawn = vi.mocked(spawn);
|
|
50
51
|
const mockHomedir = vi.mocked(homedir);
|
|
51
52
|
const mockPathResolve = vi.mocked(pathResolve);
|
|
@@ -63,6 +64,12 @@ describe('ClaudeCodeServer Unit Tests', () => {
|
|
|
63
64
|
originalEnv = { ...process.env };
|
|
64
65
|
// Reset env
|
|
65
66
|
process.env = { ...originalEnv };
|
|
67
|
+
mockAccessSync.mockImplementation((filePath) => {
|
|
68
|
+
if (typeof filePath === 'string' && mockExistsSync(filePath)) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
throw new Error('not executable');
|
|
72
|
+
});
|
|
66
73
|
});
|
|
67
74
|
afterEach(() => {
|
|
68
75
|
consoleErrorSpy.mockRestore();
|
|
@@ -99,6 +106,11 @@ describe('ClaudeCodeServer Unit Tests', () => {
|
|
|
99
106
|
return true;
|
|
100
107
|
return false;
|
|
101
108
|
});
|
|
109
|
+
mockAccessSync.mockImplementation((filePath) => {
|
|
110
|
+
if (filePath === '/home/user/.claude/local/claude')
|
|
111
|
+
return undefined;
|
|
112
|
+
throw new Error('not executable');
|
|
113
|
+
});
|
|
102
114
|
const module = await import('../server.js');
|
|
103
115
|
// @ts-ignore
|
|
104
116
|
const findClaudeCli = module.default?.findClaudeCli || module.findClaudeCli;
|
|
@@ -108,17 +120,26 @@ describe('ClaudeCodeServer Unit Tests', () => {
|
|
|
108
120
|
it('should fallback to PATH when local does not exist', async () => {
|
|
109
121
|
mockHomedir.mockReturnValue('/home/user');
|
|
110
122
|
mockExistsSync.mockReturnValue(false);
|
|
123
|
+
mockAccessSync.mockImplementation(() => {
|
|
124
|
+
throw new Error('not executable');
|
|
125
|
+
});
|
|
111
126
|
const module = await import('../server.js');
|
|
112
127
|
// @ts-ignore
|
|
113
128
|
const findClaudeCli = module.default?.findClaudeCli || module.findClaudeCli;
|
|
114
129
|
const result = findClaudeCli();
|
|
115
130
|
expect(result).toBe('claude');
|
|
116
|
-
expect(consoleWarnSpy).
|
|
131
|
+
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
|
117
132
|
});
|
|
118
133
|
it('should use custom name from CLAUDE_CLI_NAME', async () => {
|
|
119
134
|
process.env.CLAUDE_CLI_NAME = 'my-claude';
|
|
120
135
|
mockHomedir.mockReturnValue('/home/user');
|
|
121
|
-
mockExistsSync.
|
|
136
|
+
mockExistsSync.mockImplementation((path) => path === '/usr/bin/my-claude');
|
|
137
|
+
mockAccessSync.mockImplementation((filePath) => {
|
|
138
|
+
if (filePath === '/usr/bin/my-claude')
|
|
139
|
+
return undefined;
|
|
140
|
+
throw new Error('not executable');
|
|
141
|
+
});
|
|
142
|
+
process.env.PATH = '/usr/bin';
|
|
122
143
|
const module = await import('../server.js');
|
|
123
144
|
// @ts-ignore
|
|
124
145
|
const findClaudeCli = module.default?.findClaudeCli || module.findClaudeCli;
|
|
@@ -127,6 +148,11 @@ describe('ClaudeCodeServer Unit Tests', () => {
|
|
|
127
148
|
});
|
|
128
149
|
it('should use absolute path from CLAUDE_CLI_NAME', async () => {
|
|
129
150
|
process.env.CLAUDE_CLI_NAME = '/absolute/path/to/claude';
|
|
151
|
+
mockAccessSync.mockImplementation((filePath) => {
|
|
152
|
+
if (filePath === '/absolute/path/to/claude')
|
|
153
|
+
return undefined;
|
|
154
|
+
throw new Error('not executable');
|
|
155
|
+
});
|
|
130
156
|
const module = await import('../server.js');
|
|
131
157
|
// @ts-ignore
|
|
132
158
|
const findClaudeCli = module.default?.findClaudeCli || module.findClaudeCli;
|
|
@@ -251,7 +251,7 @@ describe('Argument Validation Tests', () => {
|
|
|
251
251
|
}
|
|
252
252
|
})).rejects.toThrow(/reasoning_effort/i);
|
|
253
253
|
});
|
|
254
|
-
it('should reject reasoning_effort for
|
|
254
|
+
it('should reject reasoning_effort for unsupported model families', async () => {
|
|
255
255
|
await setupServer();
|
|
256
256
|
const handler = handlers.get('callTool');
|
|
257
257
|
await expect(handler({
|
|
@@ -260,7 +260,7 @@ describe('Argument Validation Tests', () => {
|
|
|
260
260
|
arguments: {
|
|
261
261
|
prompt: 'test',
|
|
262
262
|
workFolder: '/tmp',
|
|
263
|
-
model: '
|
|
263
|
+
model: 'gemini-2.5-pro',
|
|
264
264
|
reasoning_effort: 'low'
|
|
265
265
|
}
|
|
266
266
|
}
|
|
@@ -78,6 +78,7 @@ describe('Wait Tool Tests', () => {
|
|
|
78
78
|
});
|
|
79
79
|
afterEach(() => {
|
|
80
80
|
vi.clearAllMocks();
|
|
81
|
+
vi.useRealTimers();
|
|
81
82
|
});
|
|
82
83
|
const createMockProcess = (pid) => {
|
|
83
84
|
const mockProcess = new EventEmitter();
|
|
@@ -185,6 +186,36 @@ describe('Wait Tool Tests', () => {
|
|
|
185
186
|
expect(response.find((r) => r.pid === 101).status).toBe('completed');
|
|
186
187
|
expect(response.find((r) => r.pid === 102).status).toBe('completed');
|
|
187
188
|
});
|
|
189
|
+
it('should clear timeout timers after wait resolves', async () => {
|
|
190
|
+
vi.useFakeTimers();
|
|
191
|
+
const callToolHandler = handlers.get('callTool');
|
|
192
|
+
const mockProcess = createMockProcess(12348);
|
|
193
|
+
mockSpawn.mockReturnValue(mockProcess);
|
|
194
|
+
await callToolHandler({
|
|
195
|
+
params: {
|
|
196
|
+
name: 'run',
|
|
197
|
+
arguments: {
|
|
198
|
+
prompt: 'test prompt',
|
|
199
|
+
workFolder: '/tmp'
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
const waitPromise = callToolHandler({
|
|
204
|
+
params: {
|
|
205
|
+
name: 'wait',
|
|
206
|
+
arguments: {
|
|
207
|
+
pids: [12348],
|
|
208
|
+
timeout: 180
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
mockProcess.emit('close', 0);
|
|
213
|
+
await vi.runAllTicks();
|
|
214
|
+
const result = await waitPromise;
|
|
215
|
+
const response = JSON.parse(result.content[0].text);
|
|
216
|
+
expect(response[0].status).toBe('completed');
|
|
217
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
218
|
+
});
|
|
188
219
|
it('should throw error for non-existent PID', async () => {
|
|
189
220
|
const callToolHandler = handlers.get('callTool');
|
|
190
221
|
try {
|