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,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(consoleWarnSpy).toHaveBeenCalledWith(
348
- expect.stringContaining('Claude CLI not found')
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
+ });