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.
Files changed (55) hide show
  1. package/.github/workflows/publish.yml +25 -0
  2. package/CHANGELOG.md +23 -0
  3. package/README.ja.md +112 -8
  4. package/README.md +112 -9
  5. package/dist/__tests__/app-cli.test.js +293 -0
  6. package/dist/__tests__/cli-bin-smoke.test.js +58 -0
  7. package/dist/__tests__/cli-builder.test.js +37 -0
  8. package/dist/__tests__/cli-process-service.test.js +279 -0
  9. package/dist/__tests__/cli-utils.test.js +140 -0
  10. package/dist/__tests__/error-cases.test.js +2 -1
  11. package/dist/__tests__/mcp-contract.test.js +343 -0
  12. package/dist/__tests__/parsers.test.js +37 -1
  13. package/dist/__tests__/process-management.test.js +15 -8
  14. package/dist/__tests__/server.test.js +29 -3
  15. package/dist/__tests__/wait.test.js +31 -0
  16. package/dist/app/cli.js +304 -0
  17. package/dist/app/mcp.js +366 -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 +15 -6
  21. package/dist/cli-parse.js +8 -5
  22. package/dist/cli-process-service.js +332 -0
  23. package/dist/cli-utils.js +159 -88
  24. package/dist/cli.js +4 -3
  25. package/dist/model-catalog.js +53 -0
  26. package/dist/parsers.js +55 -0
  27. package/dist/process-service.js +201 -0
  28. package/dist/server.js +4 -578
  29. package/docs/cli-architecture.md +275 -0
  30. package/package.json +4 -3
  31. package/server.json +1 -1
  32. package/src/__tests__/app-cli.test.ts +370 -0
  33. package/src/__tests__/cli-bin-smoke.test.ts +75 -0
  34. package/src/__tests__/cli-builder.test.ts +47 -0
  35. package/src/__tests__/cli-process-service.test.ts +334 -0
  36. package/src/__tests__/cli-utils.test.ts +166 -0
  37. package/src/__tests__/error-cases.test.ts +3 -4
  38. package/src/__tests__/mcp-contract.test.ts +422 -0
  39. package/src/__tests__/parsers.test.ts +44 -1
  40. package/src/__tests__/process-management.test.ts +15 -9
  41. package/src/__tests__/server.test.ts +27 -6
  42. package/src/__tests__/wait.test.ts +38 -0
  43. package/src/app/cli.ts +373 -0
  44. package/src/app/mcp.ts +402 -0
  45. package/src/bin/ai-cli-mcp.ts +7 -0
  46. package/src/bin/ai-cli.ts +11 -0
  47. package/src/cli-builder.ts +19 -10
  48. package/src/cli-parse.ts +8 -5
  49. package/src/cli-process-service.ts +418 -0
  50. package/src/cli-utils.ts +205 -99
  51. package/src/cli.ts +4 -3
  52. package/src/model-catalog.ts +64 -0
  53. package/src/parsers.ts +61 -0
  54. package/src/process-service.ts +263 -0
  55. package/src/server.ts +4 -668
@@ -0,0 +1,293 @@
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(stdout).toHaveBeenCalledWith(expect.stringContaining('"forge"'));
176
+ expect(stderr).not.toHaveBeenCalled();
177
+ });
178
+ it('prints doctor status as structured json', async () => {
179
+ const stdout = vi.fn();
180
+ const stderr = vi.fn();
181
+ const getDoctorStatus = vi.fn().mockReturnValue({
182
+ claude: {
183
+ configuredCommand: 'claude',
184
+ resolvedPath: '/tmp/bin/claude',
185
+ available: true,
186
+ lookup: 'path',
187
+ },
188
+ codex: {
189
+ configuredCommand: 'codex',
190
+ resolvedPath: null,
191
+ available: false,
192
+ lookup: 'path',
193
+ },
194
+ gemini: {
195
+ configuredCommand: 'gemini',
196
+ resolvedPath: '/tmp/bin/gemini',
197
+ available: true,
198
+ lookup: 'path',
199
+ },
200
+ forge: {
201
+ configuredCommand: 'forge',
202
+ resolvedPath: '/tmp/bin/forge',
203
+ available: true,
204
+ lookup: 'path',
205
+ },
206
+ });
207
+ const exitCode = await runCli(['doctor'], { stdout, stderr, getDoctorStatus });
208
+ expect(exitCode).toBe(0);
209
+ expect(getDoctorStatus).toHaveBeenCalledTimes(1);
210
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"configuredCommand": "claude"'));
211
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"available": false'));
212
+ expect(stderr).not.toHaveBeenCalled();
213
+ });
214
+ it('passes verbose through to result', async () => {
215
+ const stdout = vi.fn();
216
+ const stderr = vi.fn();
217
+ const getProcessResult = vi.fn().mockResolvedValue({ pid: 123, status: 'completed' });
218
+ const exitCode = await runCli(['result', '123', '--verbose'], { stdout, stderr, getProcessResult });
219
+ expect(exitCode).toBe(0);
220
+ expect(getProcessResult).toHaveBeenCalledWith(123, true);
221
+ });
222
+ it('prints detailed help for run --help', async () => {
223
+ const stdout = vi.fn();
224
+ const stderr = vi.fn();
225
+ const exitCode = await runCli(['run', '--help'], { stdout, stderr });
226
+ expect(exitCode).toBe(0);
227
+ expect(stdout).toHaveBeenCalledWith(RUN_HELP_TEXT);
228
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('claude-ultra'));
229
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gpt-5.2-codex'));
230
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gemini-2.5-pro'));
231
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('forge'));
232
+ expect(stderr).not.toHaveBeenCalled();
233
+ });
234
+ it('prints detailed help for wait --help', async () => {
235
+ const stdout = vi.fn();
236
+ const stderr = vi.fn();
237
+ const exitCode = await runCli(['wait', '--help'], { stdout, stderr });
238
+ expect(exitCode).toBe(0);
239
+ expect(stdout).toHaveBeenCalledWith(WAIT_HELP_TEXT);
240
+ expect(stderr).not.toHaveBeenCalled();
241
+ });
242
+ it('prints detailed help for models --help', async () => {
243
+ const stdout = vi.fn();
244
+ const stderr = vi.fn();
245
+ const exitCode = await runCli(['models', '--help'], { stdout, stderr });
246
+ expect(exitCode).toBe(0);
247
+ expect(stdout).toHaveBeenCalledWith(MODELS_HELP_TEXT);
248
+ expect(stderr).not.toHaveBeenCalled();
249
+ });
250
+ it('prints detailed help for doctor --help', async () => {
251
+ const stdout = vi.fn();
252
+ const stderr = vi.fn();
253
+ const exitCode = await runCli(['doctor', '--help'], { stdout, stderr });
254
+ expect(exitCode).toBe(0);
255
+ expect(stdout).toHaveBeenCalledWith(DOCTOR_HELP_TEXT);
256
+ expect(stderr).not.toHaveBeenCalled();
257
+ });
258
+ it('prints detailed help for doctor -h', async () => {
259
+ const stdout = vi.fn();
260
+ const stderr = vi.fn();
261
+ const exitCode = await runCli(['doctor', '-h'], { stdout, stderr });
262
+ expect(exitCode).toBe(0);
263
+ expect(stdout).toHaveBeenCalledWith(DOCTOR_HELP_TEXT);
264
+ expect(stderr).not.toHaveBeenCalled();
265
+ });
266
+ it('prints help for --help', async () => {
267
+ const stdout = vi.fn();
268
+ const stderr = vi.fn();
269
+ const startMcpServer = vi.fn();
270
+ const exitCode = await runCli(['--help'], {
271
+ stdout,
272
+ stderr,
273
+ startMcpServer,
274
+ });
275
+ expect(exitCode).toBe(0);
276
+ expect(stdout).toHaveBeenCalledWith(CLI_HELP_TEXT);
277
+ expect(stderr).not.toHaveBeenCalled();
278
+ });
279
+ it('returns a non-zero exit code for unknown subcommands', async () => {
280
+ const stdout = vi.fn();
281
+ const stderr = vi.fn();
282
+ const startMcpServer = vi.fn();
283
+ const exitCode = await runCli(['unknown'], {
284
+ stdout,
285
+ stderr,
286
+ startMcpServer,
287
+ });
288
+ expect(exitCode).toBe(1);
289
+ expect(stderr).toHaveBeenCalledWith(expect.stringContaining('Unknown subcommand: unknown'));
290
+ expect(stdout).toHaveBeenCalledWith(CLI_HELP_TEXT);
291
+ expect(startMcpServer).not.toHaveBeenCalled();
292
+ });
293
+ });
@@ -0,0 +1,58 @@
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
+ writeExecutable(fakeBinDir, 'forge');
29
+ const output = execFileSync('node', ['--import', 'tsx', 'src/bin/ai-cli.ts', 'doctor'], {
30
+ cwd: process.cwd(),
31
+ encoding: 'utf8',
32
+ env: {
33
+ ...process.env,
34
+ PATH: `${fakeBinDir}${delimiter}${process.env.PATH || ''}`,
35
+ CLAUDE_CLI_NAME: 'claude',
36
+ CODEX_CLI_NAME: 'codex',
37
+ GEMINI_CLI_NAME: 'gemini',
38
+ FORGE_CLI_NAME: 'forge',
39
+ },
40
+ });
41
+ expect(output).toContain('"claude"');
42
+ expect(output).toContain('"codex"');
43
+ expect(output).toContain('"gemini"');
44
+ expect(output).toContain('"forge"');
45
+ expect(output).toContain('"available": true');
46
+ });
47
+ it('prints run help for the ai-cli entrypoint', () => {
48
+ const output = execFileSync('node', ['--import', 'tsx', 'src/bin/ai-cli.ts', 'run', '--help'], {
49
+ cwd: process.cwd(),
50
+ encoding: 'utf8',
51
+ env: process.env,
52
+ });
53
+ expect(output).toContain('Usage: ai-cli run --cwd <path> [options]');
54
+ expect(output).toContain('--model <model>');
55
+ expect(output).toContain('claude-ultra');
56
+ expect(output).toContain('forge');
57
+ });
58
+ });
@@ -14,6 +14,7 @@ const DEFAULT_CLI_PATHS = {
14
14
  claude: '/usr/bin/claude',
15
15
  codex: '/usr/bin/codex',
16
16
  gemini: '/usr/bin/gemini',
17
+ forge: '/usr/bin/forge',
17
18
  };
18
19
  describe('cli-builder', () => {
19
20
  beforeEach(() => {
@@ -70,6 +71,9 @@ describe('cli-builder', () => {
70
71
  it('should throw for unsupported model families', () => {
71
72
  expect(() => getReasoningEffort('gemini-2.5-pro', 'high')).toThrow('reasoning_effort is only supported for Claude and Codex models.');
72
73
  });
74
+ it('should reject reasoning_effort for forge explicitly', () => {
75
+ expect(() => getReasoningEffort('forge', 'high')).toThrow('reasoning_effort is not supported for forge.');
76
+ });
73
77
  });
74
78
  describe('buildCliCommand', () => {
75
79
  describe('validation', () => {
@@ -322,5 +326,38 @@ describe('cli-builder', () => {
322
326
  expect(cmd.resolvedModel).toBe('gemini-3.1-pro-preview');
323
327
  });
324
328
  });
329
+ describe('forge agent', () => {
330
+ it('should build forge command without model flags', () => {
331
+ const cmd = buildCliCommand({
332
+ prompt: 'test',
333
+ workFolder: '/tmp',
334
+ model: 'forge',
335
+ cliPaths: DEFAULT_CLI_PATHS,
336
+ });
337
+ expect(cmd.agent).toBe('forge');
338
+ expect(cmd.cliPath).toBe('/usr/bin/forge');
339
+ expect(cmd.resolvedModel).toBe('forge');
340
+ expect(cmd.args).toEqual(['-C', '/tmp', '-p', 'test']);
341
+ });
342
+ it('should map session_id to --conversation-id for forge', () => {
343
+ const cmd = buildCliCommand({
344
+ prompt: 'test',
345
+ workFolder: '/tmp',
346
+ model: 'forge',
347
+ session_id: 'forge-conv-123',
348
+ cliPaths: DEFAULT_CLI_PATHS,
349
+ });
350
+ expect(cmd.args).toEqual(['-C', '/tmp', '--conversation-id', 'forge-conv-123', '-p', 'test']);
351
+ });
352
+ it('should reject reasoning_effort for forge in command building', () => {
353
+ expect(() => buildCliCommand({
354
+ prompt: 'test',
355
+ workFolder: '/tmp',
356
+ model: 'forge',
357
+ reasoning_effort: 'high',
358
+ cliPaths: DEFAULT_CLI_PATHS,
359
+ })).toThrow('reasoning_effort is not supported for forge.');
360
+ });
361
+ });
325
362
  });
326
363
  });
@@ -0,0 +1,279 @@
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
+ forge: scriptPath,
60
+ },
61
+ });
62
+ const runResult = await service.startProcess({
63
+ prompt: 'hello',
64
+ cwd: workFolder,
65
+ model: 'sonnet',
66
+ });
67
+ const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(runResult.pid));
68
+ expect(runResult.pid).toBeGreaterThan(0);
69
+ expect(runResult.status).toBe('started');
70
+ expect(existsSync(join(processDir, 'meta.json'))).toBe(true);
71
+ expect(existsSync(join(processDir, 'stdout.log'))).toBe(true);
72
+ expect(existsSync(join(processDir, 'stderr.log'))).toBe(true);
73
+ const waitResult = await service.waitForProcesses([runResult.pid], 5);
74
+ expect(waitResult).toHaveLength(1);
75
+ expect(waitResult[0].pid).toBe(runResult.pid);
76
+ expect(waitResult[0].status).toBe('completed');
77
+ const listed = await service.listProcesses();
78
+ expect(listed).toContainEqual({
79
+ pid: runResult.pid,
80
+ agent: 'claude',
81
+ status: 'completed',
82
+ });
83
+ const result = await service.getProcessResult(runResult.pid, false);
84
+ expect(result.pid).toBe(runResult.pid);
85
+ expect(result.status).toBe('completed');
86
+ expect(result.stdout).toContain('Command executed successfully');
87
+ expect(readFileSync(join(processDir, 'meta.json'), 'utf-8')).toContain('"status": "completed"');
88
+ });
89
+ it('can terminate a tracked process', async () => {
90
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
91
+ tempDirs.push(root);
92
+ const scriptPath = createMockCliScript(root, 'mock-claude');
93
+ const stateDir = join(root, 'state');
94
+ const workFolder = join(root, 'work');
95
+ mkdirSync(workFolder, { recursive: true });
96
+ const service = new CliProcessService({
97
+ stateDir,
98
+ cliPaths: {
99
+ claude: scriptPath,
100
+ codex: scriptPath,
101
+ gemini: scriptPath,
102
+ forge: scriptPath,
103
+ },
104
+ });
105
+ const runResult = await service.startProcess({
106
+ prompt: 'sleep please',
107
+ cwd: workFolder,
108
+ model: 'sonnet',
109
+ });
110
+ await new Promise((resolve) => setTimeout(resolve, 150));
111
+ const killResult = await service.killProcess(runResult.pid);
112
+ expect(killResult).toEqual({
113
+ pid: runResult.pid,
114
+ status: 'terminated',
115
+ message: 'Process terminated successfully',
116
+ });
117
+ const result = await service.getProcessResult(runResult.pid, false);
118
+ expect(result.status).toBe('failed');
119
+ });
120
+ it('does not report termination until the process actually exits', async () => {
121
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
122
+ tempDirs.push(root);
123
+ const stateDir = join(root, 'state');
124
+ const workFolder = join(root, 'project');
125
+ mkdirSync(workFolder, { recursive: true });
126
+ const pid = 12345;
127
+ const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(pid));
128
+ mkdirSync(processDir, { recursive: true });
129
+ const service = new CliProcessService({
130
+ stateDir,
131
+ cliPaths: {
132
+ claude: '/bin/sh',
133
+ codex: '/bin/sh',
134
+ gemini: '/bin/sh',
135
+ forge: '/bin/sh',
136
+ },
137
+ });
138
+ writeFileSync(join(processDir, 'meta.json'), JSON.stringify({
139
+ pid,
140
+ prompt: 'sleep please',
141
+ workFolder,
142
+ model: 'sonnet',
143
+ toolType: 'claude',
144
+ startTime: new Date().toISOString(),
145
+ stdoutPath: join(processDir, 'stdout.log'),
146
+ stderrPath: join(processDir, 'stderr.log'),
147
+ status: 'running',
148
+ }));
149
+ const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target, signal) => {
150
+ if (signal === 0) {
151
+ return true;
152
+ }
153
+ if (target === -pid && signal === 'SIGTERM') {
154
+ return true;
155
+ }
156
+ return true;
157
+ });
158
+ const killResult = await service.killProcess(pid);
159
+ expect(killResult).toEqual({
160
+ pid,
161
+ status: 'running',
162
+ message: 'Signal sent but process is still running',
163
+ });
164
+ const stored = JSON.parse(readFileSync(join(processDir, 'meta.json'), 'utf-8'));
165
+ expect(stored.status).toBe('running');
166
+ killSpy.mockRestore();
167
+ });
168
+ it('cleans up completed and failed process directories but preserves running ones', async () => {
169
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
170
+ tempDirs.push(root);
171
+ const stateDir = join(root, 'state');
172
+ const runningCwd = join(root, 'running-project');
173
+ const finishedCwd = join(root, 'finished-project');
174
+ mkdirSync(runningCwd, { recursive: true });
175
+ mkdirSync(finishedCwd, { recursive: true });
176
+ const runningDir = join(stateDir, 'cwds', encodeCwd(realpathSync(runningCwd)), '111');
177
+ const completedDir = join(stateDir, 'cwds', encodeCwd(realpathSync(finishedCwd)), '222');
178
+ const failedDir = join(stateDir, 'cwds', encodeCwd(realpathSync(finishedCwd)), '333');
179
+ mkdirSync(runningDir, { recursive: true });
180
+ mkdirSync(completedDir, { recursive: true });
181
+ mkdirSync(failedDir, { recursive: true });
182
+ writeFileSync(join(runningDir, 'meta.json'), JSON.stringify({
183
+ pid: 111,
184
+ prompt: 'keep',
185
+ workFolder: runningCwd,
186
+ toolType: 'claude',
187
+ startTime: new Date().toISOString(),
188
+ stdoutPath: join(runningDir, 'stdout.log'),
189
+ stderrPath: join(runningDir, 'stderr.log'),
190
+ status: 'running',
191
+ }));
192
+ writeFileSync(join(completedDir, 'meta.json'), JSON.stringify({
193
+ pid: 222,
194
+ prompt: 'done',
195
+ workFolder: finishedCwd,
196
+ toolType: 'claude',
197
+ startTime: new Date().toISOString(),
198
+ stdoutPath: join(completedDir, 'stdout.log'),
199
+ stderrPath: join(completedDir, 'stderr.log'),
200
+ status: 'completed',
201
+ }));
202
+ writeFileSync(join(failedDir, 'meta.json'), JSON.stringify({
203
+ pid: 333,
204
+ prompt: 'failed',
205
+ workFolder: finishedCwd,
206
+ toolType: 'claude',
207
+ startTime: new Date().toISOString(),
208
+ stdoutPath: join(failedDir, 'stdout.log'),
209
+ stderrPath: join(failedDir, 'stderr.log'),
210
+ status: 'failed',
211
+ }));
212
+ const service = new CliProcessService({
213
+ stateDir,
214
+ cliPaths: {
215
+ claude: '/bin/sh',
216
+ codex: '/bin/sh',
217
+ gemini: '/bin/sh',
218
+ forge: '/bin/sh',
219
+ },
220
+ });
221
+ const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target, signal) => {
222
+ if (signal === 0 && target === 111) {
223
+ return true;
224
+ }
225
+ throw Object.assign(new Error('not running'), { code: 'ESRCH' });
226
+ });
227
+ const result = await service.cleanupProcesses();
228
+ expect(result).toEqual({
229
+ removed: 2,
230
+ message: 'Removed 2 processes',
231
+ });
232
+ expect(existsSync(runningDir)).toBe(true);
233
+ expect(existsSync(completedDir)).toBe(false);
234
+ expect(existsSync(failedDir)).toBe(false);
235
+ killSpy.mockRestore();
236
+ });
237
+ it('parses forge output from detached process logs', async () => {
238
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
239
+ tempDirs.push(root);
240
+ const stateDir = join(root, 'state');
241
+ const workFolder = join(root, 'forge-project');
242
+ mkdirSync(workFolder, { recursive: true });
243
+ const pid = 54321;
244
+ const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(pid));
245
+ mkdirSync(processDir, { recursive: true });
246
+ writeFileSync(join(processDir, 'stdout.log'), `● [21:09:01] Initialize forge-conv-1
247
+ Forge assistant reply
248
+ ● [21:09:08] Finished forge-conv-1
249
+ `);
250
+ writeFileSync(join(processDir, 'stderr.log'), '');
251
+ writeFileSync(join(processDir, 'meta.json'), JSON.stringify({
252
+ pid,
253
+ prompt: 'hello forge',
254
+ workFolder,
255
+ model: 'forge',
256
+ toolType: 'forge',
257
+ startTime: new Date().toISOString(),
258
+ stdoutPath: join(processDir, 'stdout.log'),
259
+ stderrPath: join(processDir, 'stderr.log'),
260
+ status: 'completed',
261
+ }));
262
+ const service = new CliProcessService({
263
+ stateDir,
264
+ cliPaths: {
265
+ claude: '/bin/sh',
266
+ codex: '/bin/sh',
267
+ gemini: '/bin/sh',
268
+ forge: '/bin/sh',
269
+ },
270
+ });
271
+ const result = await service.getProcessResult(pid, false);
272
+ expect(result.agent).toBe('forge');
273
+ expect(result.session_id).toBe('forge-conv-1');
274
+ expect(result.agentOutput).toEqual({
275
+ message: 'Forge assistant reply',
276
+ session_id: 'forge-conv-1',
277
+ });
278
+ });
279
+ });