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.
Files changed (51) hide show
  1. package/.github/workflows/watch-session-prs.yml +276 -0
  2. package/CHANGELOG.md +17 -0
  3. package/README.ja.md +104 -5
  4. package/README.md +104 -5
  5. package/dist/__tests__/app-cli.test.js +285 -0
  6. package/dist/__tests__/cli-bin-smoke.test.js +54 -0
  7. package/dist/__tests__/cli-builder.test.js +49 -2
  8. package/dist/__tests__/cli-process-service.test.js +233 -0
  9. package/dist/__tests__/cli-utils.test.js +109 -0
  10. package/dist/__tests__/error-cases.test.js +2 -1
  11. package/dist/__tests__/mcp-contract.test.js +195 -0
  12. package/dist/__tests__/process-management.test.js +15 -8
  13. package/dist/__tests__/server.test.js +29 -3
  14. package/dist/__tests__/validation.test.js +2 -2
  15. package/dist/__tests__/wait.test.js +31 -0
  16. package/dist/app/cli.js +304 -0
  17. package/dist/app/mcp.js +362 -0
  18. package/dist/bin/ai-cli-mcp.js +6 -0
  19. package/dist/bin/ai-cli.js +10 -0
  20. package/dist/cli-builder.js +29 -22
  21. package/dist/cli-process-service.js +328 -0
  22. package/dist/cli-utils.js +142 -88
  23. package/dist/cli.js +1 -1
  24. package/dist/model-catalog.js +50 -0
  25. package/dist/process-service.js +198 -0
  26. package/dist/server.js +3 -577
  27. package/docs/cli-architecture.md +275 -0
  28. package/package.json +3 -2
  29. package/src/__tests__/app-cli.test.ts +362 -0
  30. package/src/__tests__/cli-bin-smoke.test.ts +71 -0
  31. package/src/__tests__/cli-builder.test.ts +62 -3
  32. package/src/__tests__/cli-process-service.test.ts +278 -0
  33. package/src/__tests__/cli-utils.test.ts +132 -0
  34. package/src/__tests__/error-cases.test.ts +3 -4
  35. package/src/__tests__/mcp-contract.test.ts +250 -0
  36. package/src/__tests__/process-management.test.ts +15 -9
  37. package/src/__tests__/server.test.ts +27 -6
  38. package/src/__tests__/validation.test.ts +2 -2
  39. package/src/__tests__/wait.test.ts +38 -0
  40. package/src/app/cli.ts +373 -0
  41. package/src/app/mcp.ts +398 -0
  42. package/src/bin/ai-cli-mcp.ts +7 -0
  43. package/src/bin/ai-cli.ts +11 -0
  44. package/src/cli-builder.ts +32 -22
  45. package/src/cli-process-service.ts +415 -0
  46. package/src/cli-utils.ts +185 -99
  47. package/src/cli.ts +1 -1
  48. package/src/model-catalog.ts +60 -0
  49. package/src/process-service.ts +261 -0
  50. package/src/server.ts +3 -667
  51. package/.github/workflows/watch-codex-fork-pr.yml +0 -98
@@ -0,0 +1,285 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { CLI_HELP_TEXT, DOCTOR_HELP_TEXT, MODELS_HELP_TEXT, RUN_HELP_TEXT, WAIT_HELP_TEXT, runCli, } from '../app/cli.js';
3
+ describe('ai-cli app', () => {
4
+ it('prints help and exits successfully when no subcommand is provided', async () => {
5
+ const stdout = vi.fn();
6
+ const stderr = vi.fn();
7
+ const startMcpServer = vi.fn();
8
+ const exitCode = await runCli([], {
9
+ stdout,
10
+ stderr,
11
+ startMcpServer,
12
+ });
13
+ expect(exitCode).toBe(0);
14
+ expect(stdout).toHaveBeenCalledWith(CLI_HELP_TEXT);
15
+ expect(stderr).not.toHaveBeenCalled();
16
+ expect(startMcpServer).not.toHaveBeenCalled();
17
+ });
18
+ it('starts MCP mode when the mcp subcommand is provided', async () => {
19
+ const stdout = vi.fn();
20
+ const stderr = vi.fn();
21
+ const startMcpServer = vi.fn().mockResolvedValue(undefined);
22
+ const exitCode = await runCli(['mcp'], {
23
+ stdout,
24
+ stderr,
25
+ startMcpServer,
26
+ });
27
+ expect(exitCode).toBe(0);
28
+ expect(startMcpServer).toHaveBeenCalledTimes(1);
29
+ expect(stdout).not.toHaveBeenCalled();
30
+ expect(stderr).not.toHaveBeenCalled();
31
+ });
32
+ it('dispatches run with parsed CLI options', async () => {
33
+ const stdout = vi.fn();
34
+ const stderr = vi.fn();
35
+ const startMcpServer = vi.fn();
36
+ const runProcess = vi.fn().mockResolvedValue({
37
+ pid: 123,
38
+ status: 'started',
39
+ agent: 'claude',
40
+ message: 'claude process started successfully',
41
+ });
42
+ const exitCode = await runCli(['run', '--cwd', '/tmp/project', '--prompt', 'hello', '--model', 'sonnet'], {
43
+ stdout,
44
+ stderr,
45
+ startMcpServer,
46
+ runProcess,
47
+ });
48
+ expect(exitCode).toBe(0);
49
+ expect(runProcess).toHaveBeenCalledWith({
50
+ cwd: '/tmp/project',
51
+ prompt: 'hello',
52
+ model: 'sonnet',
53
+ });
54
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"pid": 123'));
55
+ expect(stderr).not.toHaveBeenCalled();
56
+ });
57
+ it('accepts legacy run option aliases', async () => {
58
+ const stdout = vi.fn();
59
+ const stderr = vi.fn();
60
+ const runProcess = vi.fn().mockResolvedValue({
61
+ pid: 123,
62
+ status: 'started',
63
+ agent: 'claude',
64
+ message: 'claude process started successfully',
65
+ });
66
+ const exitCode = await runCli([
67
+ 'run',
68
+ '--workFolder',
69
+ '/tmp/project',
70
+ '--prompt_file',
71
+ '/tmp/prompt.txt',
72
+ '--session_id',
73
+ 'session-123',
74
+ '--reasoning_effort',
75
+ 'high',
76
+ ], {
77
+ stdout,
78
+ stderr,
79
+ runProcess,
80
+ });
81
+ expect(exitCode).toBe(0);
82
+ expect(runProcess).toHaveBeenCalledWith({
83
+ cwd: '/tmp/project',
84
+ prompt_file: '/tmp/prompt.txt',
85
+ session_id: 'session-123',
86
+ reasoning_effort: 'high',
87
+ });
88
+ expect(stderr).not.toHaveBeenCalled();
89
+ });
90
+ it('requires a prompt or prompt file for run', async () => {
91
+ const stdout = vi.fn();
92
+ const stderr = vi.fn();
93
+ const exitCode = await runCli(['run', '--cwd', '/tmp/project'], {
94
+ stdout,
95
+ stderr,
96
+ });
97
+ expect(exitCode).toBe(1);
98
+ expect(stderr).toHaveBeenCalledWith('Missing required option: --prompt or --prompt-file\n');
99
+ expect(stdout).toHaveBeenCalledWith(CLI_HELP_TEXT);
100
+ });
101
+ it('dispatches wait with pid arguments and timeout', async () => {
102
+ const stdout = vi.fn();
103
+ const stderr = vi.fn();
104
+ const waitForProcesses = vi.fn().mockResolvedValue([{ pid: 123, status: 'completed' }]);
105
+ const exitCode = await runCli(['wait', '123', '456', '--timeout', '5'], {
106
+ stdout,
107
+ stderr,
108
+ waitForProcesses,
109
+ });
110
+ expect(exitCode).toBe(0);
111
+ expect(waitForProcesses).toHaveBeenCalledWith([123, 456], 5);
112
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"status": "completed"'));
113
+ });
114
+ it('rejects invalid wait timeout values', async () => {
115
+ const stdout = vi.fn();
116
+ const stderr = vi.fn();
117
+ const waitForProcesses = vi.fn();
118
+ const exitCode = await runCli(['wait', '123', '--timeout', 'abc'], {
119
+ stdout,
120
+ stderr,
121
+ waitForProcesses,
122
+ });
123
+ expect(exitCode).toBe(1);
124
+ expect(stderr).toHaveBeenCalledWith('Invalid --timeout value\n');
125
+ expect(stdout).toHaveBeenCalledWith(CLI_HELP_TEXT);
126
+ expect(waitForProcesses).not.toHaveBeenCalled();
127
+ });
128
+ it('rejects non-integer pid arguments for wait', async () => {
129
+ const stdout = vi.fn();
130
+ const stderr = vi.fn();
131
+ const waitForProcesses = vi.fn();
132
+ const exitCode = await runCli(['wait', '123', 'abc'], {
133
+ stdout,
134
+ stderr,
135
+ waitForProcesses,
136
+ });
137
+ expect(exitCode).toBe(1);
138
+ expect(stderr).toHaveBeenCalledWith('All pid arguments must be positive integers\n');
139
+ expect(stdout).toHaveBeenCalledWith(CLI_HELP_TEXT);
140
+ expect(waitForProcesses).not.toHaveBeenCalled();
141
+ });
142
+ it('dispatches ps, result, and kill', async () => {
143
+ const stdout = vi.fn();
144
+ const stderr = vi.fn();
145
+ const listProcesses = vi.fn().mockResolvedValue([{ pid: 123, agent: 'claude', status: 'running' }]);
146
+ const getProcessResult = vi.fn().mockResolvedValue({ pid: 123, status: 'completed' });
147
+ const killProcess = vi.fn().mockResolvedValue({ pid: 123, status: 'terminated' });
148
+ const psExitCode = await runCli(['ps'], { stdout, stderr, listProcesses });
149
+ expect(psExitCode).toBe(0);
150
+ expect(listProcesses).toHaveBeenCalledTimes(1);
151
+ const resultExitCode = await runCli(['result', '123'], { stdout, stderr, getProcessResult });
152
+ expect(resultExitCode).toBe(0);
153
+ expect(getProcessResult).toHaveBeenCalledWith(123, false);
154
+ const killExitCode = await runCli(['kill', '123'], { stdout, stderr, killProcess });
155
+ expect(killExitCode).toBe(0);
156
+ expect(killProcess).toHaveBeenCalledWith(123);
157
+ });
158
+ it('dispatches cleanup', async () => {
159
+ const stdout = vi.fn();
160
+ const stderr = vi.fn();
161
+ const cleanupProcesses = vi.fn().mockResolvedValue({ removed: 2, message: 'Removed 2 processes' });
162
+ const exitCode = await runCli(['cleanup'], { stdout, stderr, cleanupProcesses });
163
+ expect(exitCode).toBe(0);
164
+ expect(cleanupProcesses).toHaveBeenCalledTimes(1);
165
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"removed": 2'));
166
+ });
167
+ it('prints models as structured json', async () => {
168
+ const stdout = vi.fn();
169
+ const stderr = vi.fn();
170
+ const exitCode = await runCli(['models'], { stdout, stderr });
171
+ expect(exitCode).toBe(0);
172
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"aliases"'));
173
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"claude-ultra"'));
174
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"gpt-5.4"'));
175
+ expect(stderr).not.toHaveBeenCalled();
176
+ });
177
+ it('prints doctor status as structured json', async () => {
178
+ const stdout = vi.fn();
179
+ const stderr = vi.fn();
180
+ const getDoctorStatus = vi.fn().mockReturnValue({
181
+ claude: {
182
+ configuredCommand: 'claude',
183
+ resolvedPath: '/tmp/bin/claude',
184
+ available: true,
185
+ lookup: 'path',
186
+ },
187
+ codex: {
188
+ configuredCommand: 'codex',
189
+ resolvedPath: null,
190
+ available: false,
191
+ lookup: 'path',
192
+ },
193
+ gemini: {
194
+ configuredCommand: 'gemini',
195
+ resolvedPath: '/tmp/bin/gemini',
196
+ available: true,
197
+ lookup: 'path',
198
+ },
199
+ });
200
+ const exitCode = await runCli(['doctor'], { stdout, stderr, getDoctorStatus });
201
+ expect(exitCode).toBe(0);
202
+ expect(getDoctorStatus).toHaveBeenCalledTimes(1);
203
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"configuredCommand": "claude"'));
204
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"available": false'));
205
+ expect(stderr).not.toHaveBeenCalled();
206
+ });
207
+ it('passes verbose through to result', async () => {
208
+ const stdout = vi.fn();
209
+ const stderr = vi.fn();
210
+ const getProcessResult = vi.fn().mockResolvedValue({ pid: 123, status: 'completed' });
211
+ const exitCode = await runCli(['result', '123', '--verbose'], { stdout, stderr, getProcessResult });
212
+ expect(exitCode).toBe(0);
213
+ expect(getProcessResult).toHaveBeenCalledWith(123, true);
214
+ });
215
+ it('prints detailed help for run --help', async () => {
216
+ const stdout = vi.fn();
217
+ const stderr = vi.fn();
218
+ const exitCode = await runCli(['run', '--help'], { stdout, stderr });
219
+ expect(exitCode).toBe(0);
220
+ expect(stdout).toHaveBeenCalledWith(RUN_HELP_TEXT);
221
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('claude-ultra'));
222
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gpt-5.2-codex'));
223
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gemini-2.5-pro'));
224
+ expect(stderr).not.toHaveBeenCalled();
225
+ });
226
+ it('prints detailed help for wait --help', async () => {
227
+ const stdout = vi.fn();
228
+ const stderr = vi.fn();
229
+ const exitCode = await runCli(['wait', '--help'], { stdout, stderr });
230
+ expect(exitCode).toBe(0);
231
+ expect(stdout).toHaveBeenCalledWith(WAIT_HELP_TEXT);
232
+ expect(stderr).not.toHaveBeenCalled();
233
+ });
234
+ it('prints detailed help for models --help', async () => {
235
+ const stdout = vi.fn();
236
+ const stderr = vi.fn();
237
+ const exitCode = await runCli(['models', '--help'], { stdout, stderr });
238
+ expect(exitCode).toBe(0);
239
+ expect(stdout).toHaveBeenCalledWith(MODELS_HELP_TEXT);
240
+ expect(stderr).not.toHaveBeenCalled();
241
+ });
242
+ it('prints detailed help for doctor --help', async () => {
243
+ const stdout = vi.fn();
244
+ const stderr = vi.fn();
245
+ const exitCode = await runCli(['doctor', '--help'], { stdout, stderr });
246
+ expect(exitCode).toBe(0);
247
+ expect(stdout).toHaveBeenCalledWith(DOCTOR_HELP_TEXT);
248
+ expect(stderr).not.toHaveBeenCalled();
249
+ });
250
+ it('prints detailed help for doctor -h', async () => {
251
+ const stdout = vi.fn();
252
+ const stderr = vi.fn();
253
+ const exitCode = await runCli(['doctor', '-h'], { stdout, stderr });
254
+ expect(exitCode).toBe(0);
255
+ expect(stdout).toHaveBeenCalledWith(DOCTOR_HELP_TEXT);
256
+ expect(stderr).not.toHaveBeenCalled();
257
+ });
258
+ it('prints help for --help', async () => {
259
+ const stdout = vi.fn();
260
+ const stderr = vi.fn();
261
+ const startMcpServer = vi.fn();
262
+ const exitCode = await runCli(['--help'], {
263
+ stdout,
264
+ stderr,
265
+ startMcpServer,
266
+ });
267
+ expect(exitCode).toBe(0);
268
+ expect(stdout).toHaveBeenCalledWith(CLI_HELP_TEXT);
269
+ expect(stderr).not.toHaveBeenCalled();
270
+ });
271
+ it('returns a non-zero exit code for unknown subcommands', async () => {
272
+ const stdout = vi.fn();
273
+ const stderr = vi.fn();
274
+ const startMcpServer = vi.fn();
275
+ const exitCode = await runCli(['unknown'], {
276
+ stdout,
277
+ stderr,
278
+ startMcpServer,
279
+ });
280
+ expect(exitCode).toBe(1);
281
+ expect(stderr).toHaveBeenCalledWith(expect.stringContaining('Unknown subcommand: unknown'));
282
+ expect(stdout).toHaveBeenCalledWith(CLI_HELP_TEXT);
283
+ expect(startMcpServer).not.toHaveBeenCalled();
284
+ });
285
+ });
@@ -0,0 +1,54 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync, 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
+ const tempDirs = [];
7
+ function makeTempDir(prefix) {
8
+ const dir = mkdtempSync(join(tmpdir(), prefix));
9
+ tempDirs.push(dir);
10
+ return dir;
11
+ }
12
+ function writeExecutable(dir, name) {
13
+ const filePath = join(dir, name);
14
+ writeFileSync(filePath, '#!/bin/sh\nexit 0\n', 'utf8');
15
+ chmodSync(filePath, 0o755);
16
+ }
17
+ afterEach(() => {
18
+ for (const dir of tempDirs.splice(0)) {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ }
21
+ });
22
+ describe('ai-cli entrypoint smoke', () => {
23
+ it('prints doctor output for the ai-cli entrypoint', () => {
24
+ const fakeBinDir = makeTempDir('ai-cli-bin-');
25
+ writeExecutable(fakeBinDir, 'claude');
26
+ writeExecutable(fakeBinDir, 'codex');
27
+ writeExecutable(fakeBinDir, 'gemini');
28
+ const output = execFileSync('node', ['--import', 'tsx', 'src/bin/ai-cli.ts', 'doctor'], {
29
+ cwd: process.cwd(),
30
+ encoding: 'utf8',
31
+ env: {
32
+ ...process.env,
33
+ PATH: `${fakeBinDir}${delimiter}${process.env.PATH || ''}`,
34
+ CLAUDE_CLI_NAME: 'claude',
35
+ CODEX_CLI_NAME: 'codex',
36
+ GEMINI_CLI_NAME: 'gemini',
37
+ },
38
+ });
39
+ expect(output).toContain('"claude"');
40
+ expect(output).toContain('"codex"');
41
+ expect(output).toContain('"gemini"');
42
+ expect(output).toContain('"available": true');
43
+ });
44
+ it('prints run help for the ai-cli entrypoint', () => {
45
+ const output = execFileSync('node', ['--import', 'tsx', 'src/bin/ai-cli.ts', 'run', '--help'], {
46
+ cwd: process.cwd(),
47
+ encoding: 'utf8',
48
+ env: process.env,
49
+ });
50
+ expect(output).toContain('Usage: ai-cli run --cwd <path> [options]');
51
+ expect(output).toContain('--model <model>');
52
+ expect(output).toContain('claude-ultra');
53
+ });
54
+ });
@@ -58,12 +58,17 @@ describe('cli-builder', () => {
58
58
  expect(getReasoningEffort('gpt-5.2', 'medium')).toBe('medium');
59
59
  expect(getReasoningEffort('gpt-5.2', 'high')).toBe('high');
60
60
  expect(getReasoningEffort('gpt-5.2', 'xhigh')).toBe('xhigh');
61
+ expect(getReasoningEffort('sonnet', 'high')).toBe('high');
62
+ expect(getReasoningEffort('', 'low')).toBe('low');
61
63
  });
62
64
  it('should throw for invalid reasoning effort value', () => {
63
65
  expect(() => getReasoningEffort('gpt-5.2', 'ultra')).toThrow('Invalid reasoning_effort: ultra. Allowed values: low, medium, high, xhigh.');
64
66
  });
65
- it('should throw for non-codex models', () => {
66
- expect(() => getReasoningEffort('sonnet', 'high')).toThrow('reasoning_effort is only supported for Codex models (gpt-*).');
67
+ it('should reject xhigh for claude models', () => {
68
+ expect(() => getReasoningEffort('sonnet', 'xhigh')).toThrow('Claude reasoning_effort supports only low, medium, high.');
69
+ });
70
+ it('should throw for unsupported model families', () => {
71
+ expect(() => getReasoningEffort('gemini-2.5-pro', 'high')).toThrow('reasoning_effort is only supported for Claude and Codex models.');
67
72
  });
68
73
  });
69
74
  describe('buildCliCommand', () => {
@@ -173,6 +178,48 @@ describe('cli-builder', () => {
173
178
  expect(cmd.resolvedModel).toBe('opus');
174
179
  expect(cmd.args).toContain('opus');
175
180
  });
181
+ it('should resolve claude-ultra and default to high effort', () => {
182
+ const cmd = buildCliCommand({
183
+ prompt: 'test',
184
+ workFolder: '/tmp',
185
+ model: 'claude-ultra',
186
+ cliPaths: DEFAULT_CLI_PATHS,
187
+ });
188
+ expect(cmd.args).toContain('--effort');
189
+ expect(cmd.args).toContain('high');
190
+ });
191
+ it('should build claude command with reasoning_effort using --effort', () => {
192
+ const cmd = buildCliCommand({
193
+ prompt: 'test',
194
+ workFolder: '/tmp',
195
+ model: 'sonnet',
196
+ reasoning_effort: 'medium',
197
+ cliPaths: DEFAULT_CLI_PATHS,
198
+ });
199
+ expect(cmd.args).toContain('--effort');
200
+ expect(cmd.args).toContain('medium');
201
+ });
202
+ it('should reject xhigh reasoning_effort for claude', () => {
203
+ expect(() => buildCliCommand({
204
+ prompt: 'test',
205
+ workFolder: '/tmp',
206
+ model: 'sonnet',
207
+ reasoning_effort: 'xhigh',
208
+ cliPaths: DEFAULT_CLI_PATHS,
209
+ })).toThrow('Claude reasoning_effort supports only low, medium, high.');
210
+ });
211
+ it('should allow overriding reasoning_effort for claude-ultra', () => {
212
+ const cmd = buildCliCommand({
213
+ prompt: 'test',
214
+ workFolder: '/tmp',
215
+ model: 'claude-ultra',
216
+ reasoning_effort: 'low',
217
+ cliPaths: DEFAULT_CLI_PATHS,
218
+ });
219
+ expect(cmd.args).toContain('--effort');
220
+ expect(cmd.args).toContain('low');
221
+ expect(cmd.args).not.toContain('high');
222
+ });
176
223
  });
177
224
  describe('codex agent', () => {
178
225
  it('should build codex command', () => {
@@ -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
+ });