ai-cli-mcp 2.11.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.ja.md +102 -3
  3. package/README.md +102 -3
  4. package/dist/__tests__/app-cli.test.js +285 -0
  5. package/dist/__tests__/cli-bin-smoke.test.js +54 -0
  6. package/dist/__tests__/cli-process-service.test.js +233 -0
  7. package/dist/__tests__/cli-utils.test.js +109 -0
  8. package/dist/__tests__/error-cases.test.js +2 -1
  9. package/dist/__tests__/mcp-contract.test.js +195 -0
  10. package/dist/__tests__/process-management.test.js +15 -8
  11. package/dist/__tests__/server.test.js +29 -3
  12. package/dist/__tests__/wait.test.js +31 -0
  13. package/dist/app/cli.js +304 -0
  14. package/dist/app/mcp.js +362 -0
  15. package/dist/bin/ai-cli-mcp.js +6 -0
  16. package/dist/bin/ai-cli.js +10 -0
  17. package/dist/cli-builder.js +1 -6
  18. package/dist/cli-process-service.js +328 -0
  19. package/dist/cli-utils.js +142 -88
  20. package/dist/model-catalog.js +50 -0
  21. package/dist/process-service.js +198 -0
  22. package/dist/server.js +3 -577
  23. package/docs/cli-architecture.md +275 -0
  24. package/package.json +3 -2
  25. package/src/__tests__/app-cli.test.ts +362 -0
  26. package/src/__tests__/cli-bin-smoke.test.ts +71 -0
  27. package/src/__tests__/cli-process-service.test.ts +278 -0
  28. package/src/__tests__/cli-utils.test.ts +132 -0
  29. package/src/__tests__/error-cases.test.ts +3 -4
  30. package/src/__tests__/mcp-contract.test.ts +250 -0
  31. package/src/__tests__/process-management.test.ts +15 -9
  32. package/src/__tests__/server.test.ts +27 -6
  33. package/src/__tests__/wait.test.ts +38 -0
  34. package/src/app/cli.ts +373 -0
  35. package/src/app/mcp.ts +398 -0
  36. package/src/bin/ai-cli-mcp.ts +7 -0
  37. package/src/bin/ai-cli.ts +11 -0
  38. package/src/cli-builder.ts +1 -7
  39. package/src/cli-process-service.ts +415 -0
  40. package/src/cli-utils.ts +185 -99
  41. package/src/model-catalog.ts +60 -0
  42. package/src/process-service.ts +261 -0
  43. package/src/server.ts +3 -667
@@ -0,0 +1,233 @@
1
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { CliProcessService } from '../cli-process-service.js';
6
+ function createMockCliScript(dir, name, options = {}) {
7
+ const scriptPath = join(dir, name);
8
+ writeFileSync(scriptPath, `#!/bin/bash
9
+ prompt=""
10
+ while [[ $# -gt 0 ]]; do
11
+ case "$1" in
12
+ -p|--prompt)
13
+ prompt="$2"
14
+ shift 2
15
+ ;;
16
+ *)
17
+ shift
18
+ ;;
19
+ esac
20
+ done
21
+
22
+ ${options.ignoreSigterm ? "trap '' TERM\n" : ''}
23
+
24
+ if [[ "$prompt" == *"sleep"* ]]; then
25
+ ${options.ignoreSigterm ? ' while true; do sleep 1; done\n' : ' sleep 5\n'}
26
+ fi
27
+
28
+ echo "Command executed successfully"
29
+ `);
30
+ chmodSync(scriptPath, 0o755);
31
+ return scriptPath;
32
+ }
33
+ function encodeCwd(cwd) {
34
+ return cwd
35
+ .split('')
36
+ .map((char) => (/^[A-Za-z0-9.-]$/.test(char) ? char : `_${char.charCodeAt(0).toString(16).padStart(2, '0')}`))
37
+ .join('');
38
+ }
39
+ describe('CliProcessService', () => {
40
+ const tempDirs = [];
41
+ afterEach(() => {
42
+ for (const dir of tempDirs.splice(0)) {
43
+ rmSync(dir, { recursive: true, force: true });
44
+ }
45
+ });
46
+ it('starts a detached process and persists state under a normalized cwd directory', async () => {
47
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
48
+ tempDirs.push(root);
49
+ const scriptPath = createMockCliScript(root, 'mock-claude');
50
+ const stateDir = join(root, 'state');
51
+ const workFolder = join(root, 'work');
52
+ mkdirSync(workFolder, { recursive: true });
53
+ const service = new CliProcessService({
54
+ stateDir,
55
+ cliPaths: {
56
+ claude: scriptPath,
57
+ codex: scriptPath,
58
+ gemini: scriptPath,
59
+ },
60
+ });
61
+ const runResult = await service.startProcess({
62
+ prompt: 'hello',
63
+ cwd: workFolder,
64
+ model: 'sonnet',
65
+ });
66
+ const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(runResult.pid));
67
+ expect(runResult.pid).toBeGreaterThan(0);
68
+ expect(runResult.status).toBe('started');
69
+ expect(existsSync(join(processDir, 'meta.json'))).toBe(true);
70
+ expect(existsSync(join(processDir, 'stdout.log'))).toBe(true);
71
+ expect(existsSync(join(processDir, 'stderr.log'))).toBe(true);
72
+ const waitResult = await service.waitForProcesses([runResult.pid], 5);
73
+ expect(waitResult).toHaveLength(1);
74
+ expect(waitResult[0].pid).toBe(runResult.pid);
75
+ expect(waitResult[0].status).toBe('completed');
76
+ const listed = await service.listProcesses();
77
+ expect(listed).toContainEqual({
78
+ pid: runResult.pid,
79
+ agent: 'claude',
80
+ status: 'completed',
81
+ });
82
+ const result = await service.getProcessResult(runResult.pid, false);
83
+ expect(result.pid).toBe(runResult.pid);
84
+ expect(result.status).toBe('completed');
85
+ expect(result.stdout).toContain('Command executed successfully');
86
+ expect(readFileSync(join(processDir, 'meta.json'), 'utf-8')).toContain('"status": "completed"');
87
+ });
88
+ it('can terminate a tracked process', async () => {
89
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
90
+ tempDirs.push(root);
91
+ const scriptPath = createMockCliScript(root, 'mock-claude');
92
+ const stateDir = join(root, 'state');
93
+ const workFolder = join(root, 'work');
94
+ mkdirSync(workFolder, { recursive: true });
95
+ const service = new CliProcessService({
96
+ stateDir,
97
+ cliPaths: {
98
+ claude: scriptPath,
99
+ codex: scriptPath,
100
+ gemini: scriptPath,
101
+ },
102
+ });
103
+ const runResult = await service.startProcess({
104
+ prompt: 'sleep please',
105
+ cwd: workFolder,
106
+ model: 'sonnet',
107
+ });
108
+ await new Promise((resolve) => setTimeout(resolve, 150));
109
+ const killResult = await service.killProcess(runResult.pid);
110
+ expect(killResult).toEqual({
111
+ pid: runResult.pid,
112
+ status: 'terminated',
113
+ message: 'Process terminated successfully',
114
+ });
115
+ const result = await service.getProcessResult(runResult.pid, false);
116
+ expect(result.status).toBe('failed');
117
+ });
118
+ it('does not report termination until the process actually exits', async () => {
119
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
120
+ tempDirs.push(root);
121
+ const stateDir = join(root, 'state');
122
+ const workFolder = join(root, 'project');
123
+ mkdirSync(workFolder, { recursive: true });
124
+ const pid = 12345;
125
+ const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(pid));
126
+ mkdirSync(processDir, { recursive: true });
127
+ const service = new CliProcessService({
128
+ stateDir,
129
+ cliPaths: {
130
+ claude: '/bin/sh',
131
+ codex: '/bin/sh',
132
+ gemini: '/bin/sh',
133
+ },
134
+ });
135
+ writeFileSync(join(processDir, 'meta.json'), JSON.stringify({
136
+ pid,
137
+ prompt: 'sleep please',
138
+ workFolder,
139
+ model: 'sonnet',
140
+ toolType: 'claude',
141
+ startTime: new Date().toISOString(),
142
+ stdoutPath: join(processDir, 'stdout.log'),
143
+ stderrPath: join(processDir, 'stderr.log'),
144
+ status: 'running',
145
+ }));
146
+ const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target, signal) => {
147
+ if (signal === 0) {
148
+ return true;
149
+ }
150
+ if (target === -pid && signal === 'SIGTERM') {
151
+ return true;
152
+ }
153
+ return true;
154
+ });
155
+ const killResult = await service.killProcess(pid);
156
+ expect(killResult).toEqual({
157
+ pid,
158
+ status: 'running',
159
+ message: 'Signal sent but process is still running',
160
+ });
161
+ const stored = JSON.parse(readFileSync(join(processDir, 'meta.json'), 'utf-8'));
162
+ expect(stored.status).toBe('running');
163
+ killSpy.mockRestore();
164
+ });
165
+ it('cleans up completed and failed process directories but preserves running ones', async () => {
166
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
167
+ tempDirs.push(root);
168
+ const stateDir = join(root, 'state');
169
+ const runningCwd = join(root, 'running-project');
170
+ const finishedCwd = join(root, 'finished-project');
171
+ mkdirSync(runningCwd, { recursive: true });
172
+ mkdirSync(finishedCwd, { recursive: true });
173
+ const runningDir = join(stateDir, 'cwds', encodeCwd(realpathSync(runningCwd)), '111');
174
+ const completedDir = join(stateDir, 'cwds', encodeCwd(realpathSync(finishedCwd)), '222');
175
+ const failedDir = join(stateDir, 'cwds', encodeCwd(realpathSync(finishedCwd)), '333');
176
+ mkdirSync(runningDir, { recursive: true });
177
+ mkdirSync(completedDir, { recursive: true });
178
+ mkdirSync(failedDir, { recursive: true });
179
+ writeFileSync(join(runningDir, 'meta.json'), JSON.stringify({
180
+ pid: 111,
181
+ prompt: 'keep',
182
+ workFolder: runningCwd,
183
+ toolType: 'claude',
184
+ startTime: new Date().toISOString(),
185
+ stdoutPath: join(runningDir, 'stdout.log'),
186
+ stderrPath: join(runningDir, 'stderr.log'),
187
+ status: 'running',
188
+ }));
189
+ writeFileSync(join(completedDir, 'meta.json'), JSON.stringify({
190
+ pid: 222,
191
+ prompt: 'done',
192
+ workFolder: finishedCwd,
193
+ toolType: 'claude',
194
+ startTime: new Date().toISOString(),
195
+ stdoutPath: join(completedDir, 'stdout.log'),
196
+ stderrPath: join(completedDir, 'stderr.log'),
197
+ status: 'completed',
198
+ }));
199
+ writeFileSync(join(failedDir, 'meta.json'), JSON.stringify({
200
+ pid: 333,
201
+ prompt: 'failed',
202
+ workFolder: finishedCwd,
203
+ toolType: 'claude',
204
+ startTime: new Date().toISOString(),
205
+ stdoutPath: join(failedDir, 'stdout.log'),
206
+ stderrPath: join(failedDir, 'stderr.log'),
207
+ status: 'failed',
208
+ }));
209
+ const service = new CliProcessService({
210
+ stateDir,
211
+ cliPaths: {
212
+ claude: '/bin/sh',
213
+ codex: '/bin/sh',
214
+ gemini: '/bin/sh',
215
+ },
216
+ });
217
+ const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target, signal) => {
218
+ if (signal === 0 && target === 111) {
219
+ return true;
220
+ }
221
+ throw Object.assign(new Error('not running'), { code: 'ESRCH' });
222
+ });
223
+ const result = await service.cleanupProcesses();
224
+ expect(result).toEqual({
225
+ removed: 2,
226
+ message: 'Removed 2 processes',
227
+ });
228
+ expect(existsSync(runningDir)).toBe(true);
229
+ expect(existsSync(completedDir)).toBe(false);
230
+ expect(existsSync(failedDir)).toBe(false);
231
+ killSpy.mockRestore();
232
+ });
233
+ });
@@ -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(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Claude CLI not found'));
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
- await expect(callToolHandler({
267
- params: {
268
- name: 'run',
269
- arguments: {
270
- prompt: 'test prompt',
271
- workFolder: '/tmp/test'
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
- })).rejects.toThrow('Failed to start claude CLI process');
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).toHaveBeenCalledWith(expect.stringContaining('Claude CLI not found at ~/.claude/local/claude'));
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.mockReturnValue(false);
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;