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.
- package/CHANGELOG.md +10 -0
- package/README.ja.md +102 -3
- package/README.md +102 -3
- package/dist/__tests__/app-cli.test.js +285 -0
- package/dist/__tests__/cli-bin-smoke.test.js +54 -0
- 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__/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 +1 -6
- package/dist/cli-process-service.js +328 -0
- package/dist/cli-utils.js +142 -88
- 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-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__/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 +1 -7
- package/src/cli-process-service.ts +415 -0
- package/src/cli-utils.ts +185 -99
- package/src/model-catalog.ts +60 -0
- package/src/process-service.ts +261 -0
- 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(
|
|
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;
|