ai-cli-mcp 2.11.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +25 -0
- package/CHANGELOG.md +23 -0
- package/README.ja.md +112 -8
- package/README.md +112 -9
- package/dist/__tests__/app-cli.test.js +293 -0
- package/dist/__tests__/cli-bin-smoke.test.js +58 -0
- package/dist/__tests__/cli-builder.test.js +37 -0
- package/dist/__tests__/cli-process-service.test.js +279 -0
- package/dist/__tests__/cli-utils.test.js +140 -0
- package/dist/__tests__/error-cases.test.js +2 -1
- package/dist/__tests__/mcp-contract.test.js +343 -0
- package/dist/__tests__/parsers.test.js +37 -1
- package/dist/__tests__/process-management.test.js +15 -8
- package/dist/__tests__/server.test.js +29 -3
- package/dist/__tests__/wait.test.js +31 -0
- package/dist/app/cli.js +304 -0
- package/dist/app/mcp.js +366 -0
- package/dist/bin/ai-cli-mcp.js +6 -0
- package/dist/bin/ai-cli.js +10 -0
- package/dist/cli-builder.js +15 -6
- package/dist/cli-parse.js +8 -5
- package/dist/cli-process-service.js +332 -0
- package/dist/cli-utils.js +159 -88
- package/dist/cli.js +4 -3
- package/dist/model-catalog.js +53 -0
- package/dist/parsers.js +55 -0
- package/dist/process-service.js +201 -0
- package/dist/server.js +4 -578
- package/docs/cli-architecture.md +275 -0
- package/package.json +4 -3
- package/server.json +1 -1
- package/src/__tests__/app-cli.test.ts +370 -0
- package/src/__tests__/cli-bin-smoke.test.ts +75 -0
- package/src/__tests__/cli-builder.test.ts +47 -0
- package/src/__tests__/cli-process-service.test.ts +334 -0
- package/src/__tests__/cli-utils.test.ts +166 -0
- package/src/__tests__/error-cases.test.ts +3 -4
- package/src/__tests__/mcp-contract.test.ts +422 -0
- package/src/__tests__/parsers.test.ts +44 -1
- package/src/__tests__/process-management.test.ts +15 -9
- package/src/__tests__/server.test.ts +27 -6
- package/src/__tests__/wait.test.ts +38 -0
- package/src/app/cli.ts +373 -0
- package/src/app/mcp.ts +402 -0
- package/src/bin/ai-cli-mcp.ts +7 -0
- package/src/bin/ai-cli.ts +11 -0
- package/src/cli-builder.ts +19 -10
- package/src/cli-parse.ts +8 -5
- package/src/cli-process-service.ts +418 -0
- package/src/cli-utils.ts +205 -99
- package/src/cli.ts +4 -3
- package/src/model-catalog.ts +64 -0
- package/src/parsers.ts +61 -0
- package/src/process-service.ts +263 -0
- package/src/server.ts +4 -668
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { delimiter, join } from 'node:path';
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
const tempDirs: string[] = [];
|
|
8
|
+
|
|
9
|
+
function makeTempDir(prefix: string): string {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
11
|
+
tempDirs.push(dir);
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function writeExecutable(dir: string, name: string): void {
|
|
16
|
+
const filePath = join(dir, name);
|
|
17
|
+
writeFileSync(filePath, '#!/bin/sh\nexit 0\n', 'utf8');
|
|
18
|
+
chmodSync(filePath, 0o755);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const dir of tempDirs.splice(0)) {
|
|
23
|
+
rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('ai-cli entrypoint smoke', () => {
|
|
28
|
+
it('prints doctor output for the ai-cli entrypoint', () => {
|
|
29
|
+
const fakeBinDir = makeTempDir('ai-cli-bin-');
|
|
30
|
+
writeExecutable(fakeBinDir, 'claude');
|
|
31
|
+
writeExecutable(fakeBinDir, 'codex');
|
|
32
|
+
writeExecutable(fakeBinDir, 'gemini');
|
|
33
|
+
writeExecutable(fakeBinDir, 'forge');
|
|
34
|
+
|
|
35
|
+
const output = execFileSync(
|
|
36
|
+
'node',
|
|
37
|
+
['--import', 'tsx', 'src/bin/ai-cli.ts', 'doctor'],
|
|
38
|
+
{
|
|
39
|
+
cwd: process.cwd(),
|
|
40
|
+
encoding: 'utf8',
|
|
41
|
+
env: {
|
|
42
|
+
...process.env,
|
|
43
|
+
PATH: `${fakeBinDir}${delimiter}${process.env.PATH || ''}`,
|
|
44
|
+
CLAUDE_CLI_NAME: 'claude',
|
|
45
|
+
CODEX_CLI_NAME: 'codex',
|
|
46
|
+
GEMINI_CLI_NAME: 'gemini',
|
|
47
|
+
FORGE_CLI_NAME: 'forge',
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(output).toContain('"claude"');
|
|
53
|
+
expect(output).toContain('"codex"');
|
|
54
|
+
expect(output).toContain('"gemini"');
|
|
55
|
+
expect(output).toContain('"forge"');
|
|
56
|
+
expect(output).toContain('"available": true');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('prints run help for the ai-cli entrypoint', () => {
|
|
60
|
+
const output = execFileSync(
|
|
61
|
+
'node',
|
|
62
|
+
['--import', 'tsx', 'src/bin/ai-cli.ts', 'run', '--help'],
|
|
63
|
+
{
|
|
64
|
+
cwd: process.cwd(),
|
|
65
|
+
encoding: 'utf8',
|
|
66
|
+
env: process.env,
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(output).toContain('Usage: ai-cli run --cwd <path> [options]');
|
|
71
|
+
expect(output).toContain('--model <model>');
|
|
72
|
+
expect(output).toContain('claude-ultra');
|
|
73
|
+
expect(output).toContain('forge');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -22,6 +22,7 @@ const DEFAULT_CLI_PATHS = {
|
|
|
22
22
|
claude: '/usr/bin/claude',
|
|
23
23
|
codex: '/usr/bin/codex',
|
|
24
24
|
gemini: '/usr/bin/gemini',
|
|
25
|
+
forge: '/usr/bin/forge',
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
describe('cli-builder', () => {
|
|
@@ -97,6 +98,12 @@ describe('cli-builder', () => {
|
|
|
97
98
|
'reasoning_effort is only supported for Claude and Codex models.'
|
|
98
99
|
);
|
|
99
100
|
});
|
|
101
|
+
|
|
102
|
+
it('should reject reasoning_effort for forge explicitly', () => {
|
|
103
|
+
expect(() => getReasoningEffort('forge', 'high')).toThrow(
|
|
104
|
+
'reasoning_effort is not supported for forge.'
|
|
105
|
+
);
|
|
106
|
+
});
|
|
100
107
|
});
|
|
101
108
|
|
|
102
109
|
describe('buildCliCommand', () => {
|
|
@@ -401,5 +408,45 @@ describe('cli-builder', () => {
|
|
|
401
408
|
expect(cmd.resolvedModel).toBe('gemini-3.1-pro-preview');
|
|
402
409
|
});
|
|
403
410
|
});
|
|
411
|
+
|
|
412
|
+
describe('forge agent', () => {
|
|
413
|
+
it('should build forge command without model flags', () => {
|
|
414
|
+
const cmd = buildCliCommand({
|
|
415
|
+
prompt: 'test',
|
|
416
|
+
workFolder: '/tmp',
|
|
417
|
+
model: 'forge',
|
|
418
|
+
cliPaths: DEFAULT_CLI_PATHS,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
expect(cmd.agent).toBe('forge');
|
|
422
|
+
expect(cmd.cliPath).toBe('/usr/bin/forge');
|
|
423
|
+
expect(cmd.resolvedModel).toBe('forge');
|
|
424
|
+
expect(cmd.args).toEqual(['-C', '/tmp', '-p', 'test']);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('should map session_id to --conversation-id for forge', () => {
|
|
428
|
+
const cmd = buildCliCommand({
|
|
429
|
+
prompt: 'test',
|
|
430
|
+
workFolder: '/tmp',
|
|
431
|
+
model: 'forge',
|
|
432
|
+
session_id: 'forge-conv-123',
|
|
433
|
+
cliPaths: DEFAULT_CLI_PATHS,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
expect(cmd.args).toEqual(['-C', '/tmp', '--conversation-id', 'forge-conv-123', '-p', 'test']);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('should reject reasoning_effort for forge in command building', () => {
|
|
440
|
+
expect(() =>
|
|
441
|
+
buildCliCommand({
|
|
442
|
+
prompt: 'test',
|
|
443
|
+
workFolder: '/tmp',
|
|
444
|
+
model: 'forge',
|
|
445
|
+
reasoning_effort: 'high',
|
|
446
|
+
cliPaths: DEFAULT_CLI_PATHS,
|
|
447
|
+
})
|
|
448
|
+
).toThrow('reasoning_effort is not supported for forge.');
|
|
449
|
+
});
|
|
450
|
+
});
|
|
404
451
|
});
|
|
405
452
|
});
|
|
@@ -0,0 +1,334 @@
|
|
|
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
|
+
|
|
7
|
+
function createMockCliScript(dir: string, name: string, options: { ignoreSigterm?: boolean } = {}): string {
|
|
8
|
+
const scriptPath = join(dir, name);
|
|
9
|
+
writeFileSync(
|
|
10
|
+
scriptPath,
|
|
11
|
+
`#!/bin/bash
|
|
12
|
+
prompt=""
|
|
13
|
+
while [[ $# -gt 0 ]]; do
|
|
14
|
+
case "$1" in
|
|
15
|
+
-p|--prompt)
|
|
16
|
+
prompt="$2"
|
|
17
|
+
shift 2
|
|
18
|
+
;;
|
|
19
|
+
*)
|
|
20
|
+
shift
|
|
21
|
+
;;
|
|
22
|
+
esac
|
|
23
|
+
done
|
|
24
|
+
|
|
25
|
+
${options.ignoreSigterm ? "trap '' TERM\n" : ''}
|
|
26
|
+
|
|
27
|
+
if [[ "$prompt" == *"sleep"* ]]; then
|
|
28
|
+
${options.ignoreSigterm ? ' while true; do sleep 1; done\n' : ' sleep 5\n'}
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
echo "Command executed successfully"
|
|
32
|
+
`
|
|
33
|
+
);
|
|
34
|
+
chmodSync(scriptPath, 0o755);
|
|
35
|
+
return scriptPath;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function encodeCwd(cwd: string): string {
|
|
39
|
+
return cwd
|
|
40
|
+
.split('')
|
|
41
|
+
.map((char) => (/^[A-Za-z0-9.-]$/.test(char) ? char : `_${char.charCodeAt(0).toString(16).padStart(2, '0')}`))
|
|
42
|
+
.join('');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('CliProcessService', () => {
|
|
46
|
+
const tempDirs: string[] = [];
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
for (const dir of tempDirs.splice(0)) {
|
|
50
|
+
rmSync(dir, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('starts a detached process and persists state under a normalized cwd directory', async () => {
|
|
55
|
+
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
56
|
+
tempDirs.push(root);
|
|
57
|
+
const scriptPath = createMockCliScript(root, 'mock-claude');
|
|
58
|
+
const stateDir = join(root, 'state');
|
|
59
|
+
const workFolder = join(root, 'work');
|
|
60
|
+
mkdirSync(workFolder, { recursive: true });
|
|
61
|
+
|
|
62
|
+
const service = new CliProcessService({
|
|
63
|
+
stateDir,
|
|
64
|
+
cliPaths: {
|
|
65
|
+
claude: scriptPath,
|
|
66
|
+
codex: scriptPath,
|
|
67
|
+
gemini: scriptPath,
|
|
68
|
+
forge: scriptPath,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const runResult = await service.startProcess({
|
|
73
|
+
prompt: 'hello',
|
|
74
|
+
cwd: workFolder,
|
|
75
|
+
model: 'sonnet',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(runResult.pid));
|
|
79
|
+
expect(runResult.pid).toBeGreaterThan(0);
|
|
80
|
+
expect(runResult.status).toBe('started');
|
|
81
|
+
expect(existsSync(join(processDir, 'meta.json'))).toBe(true);
|
|
82
|
+
expect(existsSync(join(processDir, 'stdout.log'))).toBe(true);
|
|
83
|
+
expect(existsSync(join(processDir, 'stderr.log'))).toBe(true);
|
|
84
|
+
|
|
85
|
+
const waitResult = await service.waitForProcesses([runResult.pid], 5);
|
|
86
|
+
expect(waitResult).toHaveLength(1);
|
|
87
|
+
expect(waitResult[0].pid).toBe(runResult.pid);
|
|
88
|
+
expect(waitResult[0].status).toBe('completed');
|
|
89
|
+
|
|
90
|
+
const listed = await service.listProcesses();
|
|
91
|
+
expect(listed).toContainEqual({
|
|
92
|
+
pid: runResult.pid,
|
|
93
|
+
agent: 'claude',
|
|
94
|
+
status: 'completed',
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const result = await service.getProcessResult(runResult.pid, false);
|
|
98
|
+
expect(result.pid).toBe(runResult.pid);
|
|
99
|
+
expect(result.status).toBe('completed');
|
|
100
|
+
expect(result.stdout).toContain('Command executed successfully');
|
|
101
|
+
expect(readFileSync(join(processDir, 'meta.json'), 'utf-8')).toContain('"status": "completed"');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('can terminate a tracked process', async () => {
|
|
105
|
+
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
106
|
+
tempDirs.push(root);
|
|
107
|
+
const scriptPath = createMockCliScript(root, 'mock-claude');
|
|
108
|
+
const stateDir = join(root, 'state');
|
|
109
|
+
const workFolder = join(root, 'work');
|
|
110
|
+
mkdirSync(workFolder, { recursive: true });
|
|
111
|
+
|
|
112
|
+
const service = new CliProcessService({
|
|
113
|
+
stateDir,
|
|
114
|
+
cliPaths: {
|
|
115
|
+
claude: scriptPath,
|
|
116
|
+
codex: scriptPath,
|
|
117
|
+
gemini: scriptPath,
|
|
118
|
+
forge: scriptPath,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const runResult = await service.startProcess({
|
|
123
|
+
prompt: 'sleep please',
|
|
124
|
+
cwd: workFolder,
|
|
125
|
+
model: 'sonnet',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
129
|
+
|
|
130
|
+
const killResult = await service.killProcess(runResult.pid);
|
|
131
|
+
expect(killResult).toEqual({
|
|
132
|
+
pid: runResult.pid,
|
|
133
|
+
status: 'terminated',
|
|
134
|
+
message: 'Process terminated successfully',
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const result = await service.getProcessResult(runResult.pid, false);
|
|
138
|
+
expect(result.status).toBe('failed');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('does not report termination until the process actually exits', async () => {
|
|
142
|
+
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
143
|
+
tempDirs.push(root);
|
|
144
|
+
const stateDir = join(root, 'state');
|
|
145
|
+
const workFolder = join(root, 'project');
|
|
146
|
+
mkdirSync(workFolder, { recursive: true });
|
|
147
|
+
const pid = 12345;
|
|
148
|
+
const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(pid));
|
|
149
|
+
mkdirSync(processDir, { recursive: true });
|
|
150
|
+
|
|
151
|
+
const service = new CliProcessService({
|
|
152
|
+
stateDir,
|
|
153
|
+
cliPaths: {
|
|
154
|
+
claude: '/bin/sh',
|
|
155
|
+
codex: '/bin/sh',
|
|
156
|
+
gemini: '/bin/sh',
|
|
157
|
+
forge: '/bin/sh',
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
writeFileSync(
|
|
162
|
+
join(processDir, 'meta.json'),
|
|
163
|
+
JSON.stringify({
|
|
164
|
+
pid,
|
|
165
|
+
prompt: 'sleep please',
|
|
166
|
+
workFolder,
|
|
167
|
+
model: 'sonnet',
|
|
168
|
+
toolType: 'claude',
|
|
169
|
+
startTime: new Date().toISOString(),
|
|
170
|
+
stdoutPath: join(processDir, 'stdout.log'),
|
|
171
|
+
stderrPath: join(processDir, 'stderr.log'),
|
|
172
|
+
status: 'running',
|
|
173
|
+
})
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target: number, signal?: string | number) => {
|
|
177
|
+
if (signal === 0) {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
if (target === -pid && signal === 'SIGTERM') {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const killResult = await service.killProcess(pid);
|
|
187
|
+
expect(killResult).toEqual({
|
|
188
|
+
pid,
|
|
189
|
+
status: 'running',
|
|
190
|
+
message: 'Signal sent but process is still running',
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const stored = JSON.parse(readFileSync(join(processDir, 'meta.json'), 'utf-8'));
|
|
194
|
+
expect(stored.status).toBe('running');
|
|
195
|
+
killSpy.mockRestore();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('cleans up completed and failed process directories but preserves running ones', async () => {
|
|
199
|
+
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
200
|
+
tempDirs.push(root);
|
|
201
|
+
const stateDir = join(root, 'state');
|
|
202
|
+
const runningCwd = join(root, 'running-project');
|
|
203
|
+
const finishedCwd = join(root, 'finished-project');
|
|
204
|
+
mkdirSync(runningCwd, { recursive: true });
|
|
205
|
+
mkdirSync(finishedCwd, { recursive: true });
|
|
206
|
+
|
|
207
|
+
const runningDir = join(stateDir, 'cwds', encodeCwd(realpathSync(runningCwd)), '111');
|
|
208
|
+
const completedDir = join(stateDir, 'cwds', encodeCwd(realpathSync(finishedCwd)), '222');
|
|
209
|
+
const failedDir = join(stateDir, 'cwds', encodeCwd(realpathSync(finishedCwd)), '333');
|
|
210
|
+
mkdirSync(runningDir, { recursive: true });
|
|
211
|
+
mkdirSync(completedDir, { recursive: true });
|
|
212
|
+
mkdirSync(failedDir, { recursive: true });
|
|
213
|
+
|
|
214
|
+
writeFileSync(
|
|
215
|
+
join(runningDir, 'meta.json'),
|
|
216
|
+
JSON.stringify({
|
|
217
|
+
pid: 111,
|
|
218
|
+
prompt: 'keep',
|
|
219
|
+
workFolder: runningCwd,
|
|
220
|
+
toolType: 'claude',
|
|
221
|
+
startTime: new Date().toISOString(),
|
|
222
|
+
stdoutPath: join(runningDir, 'stdout.log'),
|
|
223
|
+
stderrPath: join(runningDir, 'stderr.log'),
|
|
224
|
+
status: 'running',
|
|
225
|
+
})
|
|
226
|
+
);
|
|
227
|
+
writeFileSync(
|
|
228
|
+
join(completedDir, 'meta.json'),
|
|
229
|
+
JSON.stringify({
|
|
230
|
+
pid: 222,
|
|
231
|
+
prompt: 'done',
|
|
232
|
+
workFolder: finishedCwd,
|
|
233
|
+
toolType: 'claude',
|
|
234
|
+
startTime: new Date().toISOString(),
|
|
235
|
+
stdoutPath: join(completedDir, 'stdout.log'),
|
|
236
|
+
stderrPath: join(completedDir, 'stderr.log'),
|
|
237
|
+
status: 'completed',
|
|
238
|
+
})
|
|
239
|
+
);
|
|
240
|
+
writeFileSync(
|
|
241
|
+
join(failedDir, 'meta.json'),
|
|
242
|
+
JSON.stringify({
|
|
243
|
+
pid: 333,
|
|
244
|
+
prompt: 'failed',
|
|
245
|
+
workFolder: finishedCwd,
|
|
246
|
+
toolType: 'claude',
|
|
247
|
+
startTime: new Date().toISOString(),
|
|
248
|
+
stdoutPath: join(failedDir, 'stdout.log'),
|
|
249
|
+
stderrPath: join(failedDir, 'stderr.log'),
|
|
250
|
+
status: 'failed',
|
|
251
|
+
})
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const service = new CliProcessService({
|
|
255
|
+
stateDir,
|
|
256
|
+
cliPaths: {
|
|
257
|
+
claude: '/bin/sh',
|
|
258
|
+
codex: '/bin/sh',
|
|
259
|
+
gemini: '/bin/sh',
|
|
260
|
+
forge: '/bin/sh',
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target: number, signal?: string | number) => {
|
|
265
|
+
if (signal === 0 && target === 111) {
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
throw Object.assign(new Error('not running'), { code: 'ESRCH' });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const result = await service.cleanupProcesses();
|
|
272
|
+
|
|
273
|
+
expect(result).toEqual({
|
|
274
|
+
removed: 2,
|
|
275
|
+
message: 'Removed 2 processes',
|
|
276
|
+
});
|
|
277
|
+
expect(existsSync(runningDir)).toBe(true);
|
|
278
|
+
expect(existsSync(completedDir)).toBe(false);
|
|
279
|
+
expect(existsSync(failedDir)).toBe(false);
|
|
280
|
+
killSpy.mockRestore();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('parses forge output from detached process logs', async () => {
|
|
284
|
+
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
285
|
+
tempDirs.push(root);
|
|
286
|
+
const stateDir = join(root, 'state');
|
|
287
|
+
const workFolder = join(root, 'forge-project');
|
|
288
|
+
mkdirSync(workFolder, { recursive: true });
|
|
289
|
+
const pid = 54321;
|
|
290
|
+
const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(pid));
|
|
291
|
+
mkdirSync(processDir, { recursive: true });
|
|
292
|
+
|
|
293
|
+
writeFileSync(
|
|
294
|
+
join(processDir, 'stdout.log'),
|
|
295
|
+
`● [21:09:01] Initialize forge-conv-1
|
|
296
|
+
Forge assistant reply
|
|
297
|
+
● [21:09:08] Finished forge-conv-1
|
|
298
|
+
`
|
|
299
|
+
);
|
|
300
|
+
writeFileSync(join(processDir, 'stderr.log'), '');
|
|
301
|
+
writeFileSync(
|
|
302
|
+
join(processDir, 'meta.json'),
|
|
303
|
+
JSON.stringify({
|
|
304
|
+
pid,
|
|
305
|
+
prompt: 'hello forge',
|
|
306
|
+
workFolder,
|
|
307
|
+
model: 'forge',
|
|
308
|
+
toolType: 'forge',
|
|
309
|
+
startTime: new Date().toISOString(),
|
|
310
|
+
stdoutPath: join(processDir, 'stdout.log'),
|
|
311
|
+
stderrPath: join(processDir, 'stderr.log'),
|
|
312
|
+
status: 'completed',
|
|
313
|
+
})
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const service = new CliProcessService({
|
|
317
|
+
stateDir,
|
|
318
|
+
cliPaths: {
|
|
319
|
+
claude: '/bin/sh',
|
|
320
|
+
codex: '/bin/sh',
|
|
321
|
+
gemini: '/bin/sh',
|
|
322
|
+
forge: '/bin/sh',
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const result = await service.getProcessResult(pid, false);
|
|
327
|
+
expect(result.agent).toBe('forge');
|
|
328
|
+
expect(result.session_id).toBe('forge-conv-1');
|
|
329
|
+
expect(result.agentOutput).toEqual({
|
|
330
|
+
message: 'Forge assistant reply',
|
|
331
|
+
session_id: 'forge-conv-1',
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { accessSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
vi.mock('node:fs', () => ({
|
|
5
|
+
accessSync: vi.fn(),
|
|
6
|
+
constants: { X_OK: 1 },
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
const mockAccessSync = vi.mocked(accessSync);
|
|
10
|
+
|
|
11
|
+
describe('cli-utils doctor status', () => {
|
|
12
|
+
const originalEnv = process.env;
|
|
13
|
+
const originalPlatform = process.platform;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.resetModules();
|
|
17
|
+
mockAccessSync.mockReset();
|
|
18
|
+
process.env = { ...originalEnv };
|
|
19
|
+
delete process.env.CLAUDE_CLI_NAME;
|
|
20
|
+
delete process.env.CODEX_CLI_NAME;
|
|
21
|
+
delete process.env.GEMINI_CLI_NAME;
|
|
22
|
+
delete process.env.FORGE_CLI_NAME;
|
|
23
|
+
process.env.PATH = '/mock/bin:/usr/bin';
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
process.env = originalEnv;
|
|
28
|
+
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('marks PATH binaries available when they are executable', async () => {
|
|
32
|
+
mockAccessSync.mockImplementation((filePath) => {
|
|
33
|
+
if (filePath === '/mock/bin/claude') {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
throw new Error('not executable');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const { getCliDoctorStatus } = await import('../cli-utils.js');
|
|
40
|
+
const status = getCliDoctorStatus();
|
|
41
|
+
|
|
42
|
+
expect(status.claude).toEqual({
|
|
43
|
+
configuredCommand: 'claude',
|
|
44
|
+
resolvedPath: '/mock/bin/claude',
|
|
45
|
+
available: true,
|
|
46
|
+
lookup: 'path',
|
|
47
|
+
});
|
|
48
|
+
expect(status.forge).toEqual({
|
|
49
|
+
configuredCommand: 'forge',
|
|
50
|
+
resolvedPath: null,
|
|
51
|
+
available: false,
|
|
52
|
+
lookup: 'path',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('does not mark non-executable PATH entries as available', async () => {
|
|
57
|
+
mockAccessSync.mockImplementation(() => {
|
|
58
|
+
throw new Error('not executable');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const { getCliDoctorStatus } = await import('../cli-utils.js');
|
|
62
|
+
const status = getCliDoctorStatus();
|
|
63
|
+
|
|
64
|
+
expect(status.claude).toEqual({
|
|
65
|
+
configuredCommand: 'claude',
|
|
66
|
+
resolvedPath: null,
|
|
67
|
+
available: false,
|
|
68
|
+
lookup: 'path',
|
|
69
|
+
});
|
|
70
|
+
expect(status.forge).toEqual({
|
|
71
|
+
configuredCommand: 'forge',
|
|
72
|
+
resolvedPath: null,
|
|
73
|
+
available: false,
|
|
74
|
+
lookup: 'path',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('reports invalid relative env paths as doctor errors', async () => {
|
|
79
|
+
process.env.CLAUDE_CLI_NAME = './relative/claude';
|
|
80
|
+
|
|
81
|
+
const { getCliDoctorStatus } = await import('../cli-utils.js');
|
|
82
|
+
const status = getCliDoctorStatus();
|
|
83
|
+
|
|
84
|
+
expect(status.claude.available).toBe(false);
|
|
85
|
+
expect(status.claude.lookup).toBe('env');
|
|
86
|
+
expect(status.claude.error).toContain('Invalid CLAUDE_CLI_NAME');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('reports missing absolute env paths as unavailable', async () => {
|
|
90
|
+
process.env.CLAUDE_CLI_NAME = '/missing/claude';
|
|
91
|
+
mockAccessSync.mockImplementation(() => {
|
|
92
|
+
throw new Error('missing');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const { getCliDoctorStatus } = await import('../cli-utils.js');
|
|
96
|
+
const status = getCliDoctorStatus();
|
|
97
|
+
|
|
98
|
+
expect(status.claude).toEqual({
|
|
99
|
+
configuredCommand: '/missing/claude',
|
|
100
|
+
resolvedPath: '/missing/claude',
|
|
101
|
+
available: false,
|
|
102
|
+
lookup: 'env',
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('falls back cleanly when PATH is empty', async () => {
|
|
107
|
+
process.env.PATH = '';
|
|
108
|
+
mockAccessSync.mockImplementation(() => {
|
|
109
|
+
throw new Error('missing');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const { getCliDoctorStatus } = await import('../cli-utils.js');
|
|
113
|
+
const status = getCliDoctorStatus();
|
|
114
|
+
|
|
115
|
+
expect(status.codex).toEqual({
|
|
116
|
+
configuredCommand: 'codex',
|
|
117
|
+
resolvedPath: null,
|
|
118
|
+
available: false,
|
|
119
|
+
lookup: 'path',
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('supports Windows commands that already include an executable suffix', async () => {
|
|
124
|
+
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
125
|
+
process.env.PATHEXT = '.EXE;.CMD';
|
|
126
|
+
process.env.CLAUDE_CLI_NAME = 'claude.cmd';
|
|
127
|
+
process.env.PATH = '/mock/bin';
|
|
128
|
+
mockAccessSync.mockImplementation((filePath) => {
|
|
129
|
+
if (filePath === '/mock/bin/claude.cmd') {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
throw new Error('not executable');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const { getCliDoctorStatus } = await import('../cli-utils.js');
|
|
136
|
+
const status = getCliDoctorStatus();
|
|
137
|
+
|
|
138
|
+
expect(status.claude).toEqual({
|
|
139
|
+
configuredCommand: 'claude.cmd',
|
|
140
|
+
resolvedPath: '/mock/bin/claude.cmd',
|
|
141
|
+
available: true,
|
|
142
|
+
lookup: 'env',
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('supports forge lookup via FORGE_CLI_NAME', async () => {
|
|
147
|
+
process.env.FORGE_CLI_NAME = 'forge-custom';
|
|
148
|
+
mockAccessSync.mockImplementation((filePath) => {
|
|
149
|
+
if (filePath === '/mock/bin/forge-custom') {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
throw new Error('not executable');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const { getCliDoctorStatus, findForgeCli } = await import('../cli-utils.js');
|
|
156
|
+
const status = getCliDoctorStatus();
|
|
157
|
+
|
|
158
|
+
expect(status.forge).toEqual({
|
|
159
|
+
configuredCommand: 'forge-custom',
|
|
160
|
+
resolvedPath: '/mock/bin/forge-custom',
|
|
161
|
+
available: true,
|
|
162
|
+
lookup: 'env',
|
|
163
|
+
});
|
|
164
|
+
expect(findForgeCli()).toBe('forge-custom');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -344,9 +344,8 @@ describe('Error Handling Tests', () => {
|
|
|
344
344
|
|
|
345
345
|
const server = new ClaudeCodeServer();
|
|
346
346
|
|
|
347
|
-
expect(
|
|
348
|
-
|
|
349
|
-
);
|
|
347
|
+
expect(server).toBeDefined();
|
|
348
|
+
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
|
350
349
|
|
|
351
350
|
consoleWarnSpy.mockRestore();
|
|
352
351
|
});
|
|
@@ -368,4 +367,4 @@ describe('Error Handling Tests', () => {
|
|
|
368
367
|
await expect(server.run()).rejects.toThrow('Connection failed');
|
|
369
368
|
});
|
|
370
369
|
});
|
|
371
|
-
});
|
|
370
|
+
});
|