ai-cli-mcp 2.12.0 → 2.14.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 (46) hide show
  1. package/.github/workflows/publish.yml +25 -0
  2. package/CHANGELOG.md +20 -0
  3. package/README.ja.md +20 -5
  4. package/README.md +20 -6
  5. package/dist/__tests__/app-cli.test.js +34 -2
  6. package/dist/__tests__/cli-bin-smoke.test.js +4 -0
  7. package/dist/__tests__/cli-builder.test.js +37 -0
  8. package/dist/__tests__/cli-process-service.test.js +180 -5
  9. package/dist/__tests__/cli-utils.test.js +31 -0
  10. package/dist/__tests__/mcp-contract.test.js +287 -9
  11. package/dist/__tests__/parsers.test.js +37 -1
  12. package/dist/__tests__/process-management.test.js +2 -1
  13. package/dist/app/cli.js +8 -6
  14. package/dist/app/mcp.js +16 -8
  15. package/dist/cli-builder.js +14 -0
  16. package/dist/cli-parse.js +8 -5
  17. package/dist/cli-process-service.js +13 -23
  18. package/dist/cli-utils.js +17 -0
  19. package/dist/cli.js +4 -3
  20. package/dist/model-catalog.js +4 -1
  21. package/dist/parsers.js +55 -0
  22. package/dist/process-result.js +51 -0
  23. package/dist/process-service.js +11 -22
  24. package/dist/server.js +1 -1
  25. package/package.json +2 -2
  26. package/server.json +1 -1
  27. package/src/__tests__/app-cli.test.ts +43 -1
  28. package/src/__tests__/cli-bin-smoke.test.ts +4 -0
  29. package/src/__tests__/cli-builder.test.ts +47 -0
  30. package/src/__tests__/cli-process-service.test.ts +200 -5
  31. package/src/__tests__/cli-utils.test.ts +34 -0
  32. package/src/__tests__/mcp-contract.test.ts +325 -9
  33. package/src/__tests__/parsers.test.ts +44 -1
  34. package/src/__tests__/process-management.test.ts +2 -1
  35. package/src/app/cli.ts +9 -7
  36. package/src/app/mcp.ts +17 -8
  37. package/src/cli-builder.ts +18 -3
  38. package/src/cli-parse.ts +8 -5
  39. package/src/cli-process-service.ts +12 -23
  40. package/src/cli-utils.ts +21 -1
  41. package/src/cli.ts +4 -3
  42. package/src/model-catalog.ts +5 -1
  43. package/src/parsers.ts +61 -0
  44. package/src/process-result.ts +79 -0
  45. package/src/process-service.ts +11 -24
  46. package/src/server.ts +1 -1
@@ -3,6 +3,7 @@ import {
3
3
  CLI_HELP_TEXT,
4
4
  DOCTOR_HELP_TEXT,
5
5
  MODELS_HELP_TEXT,
6
+ RESULT_HELP_TEXT,
6
7
  RUN_HELP_TEXT,
7
8
  WAIT_HELP_TEXT,
8
9
  runCli,
@@ -142,10 +143,28 @@ describe('ai-cli app', () => {
142
143
  );
143
144
 
144
145
  expect(exitCode).toBe(0);
145
- expect(waitForProcesses).toHaveBeenCalledWith([123, 456], 5);
146
+ expect(waitForProcesses).toHaveBeenCalledWith([123, 456], 5, false);
146
147
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"status": "completed"'));
147
148
  });
148
149
 
150
+ it('passes verbose through to wait', async () => {
151
+ const stdout = vi.fn();
152
+ const stderr = vi.fn();
153
+ const waitForProcesses = vi.fn().mockResolvedValue([{ pid: 123, status: 'completed' }]);
154
+
155
+ const exitCode = await runCli(
156
+ ['wait', '123', '--verbose'],
157
+ {
158
+ stdout,
159
+ stderr,
160
+ waitForProcesses,
161
+ }
162
+ );
163
+
164
+ expect(exitCode).toBe(0);
165
+ expect(waitForProcesses).toHaveBeenCalledWith([123], undefined, true);
166
+ });
167
+
149
168
  it('rejects invalid wait timeout values', async () => {
150
169
  const stdout = vi.fn();
151
170
  const stderr = vi.fn();
@@ -222,6 +241,7 @@ describe('ai-cli app', () => {
222
241
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"aliases"'));
223
242
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"claude-ultra"'));
224
243
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"gpt-5.4"'));
244
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"forge"'));
225
245
  expect(stderr).not.toHaveBeenCalled();
226
246
  });
227
247
 
@@ -247,6 +267,12 @@ describe('ai-cli app', () => {
247
267
  available: true,
248
268
  lookup: 'path',
249
269
  },
270
+ forge: {
271
+ configuredCommand: 'forge',
272
+ resolvedPath: '/tmp/bin/forge',
273
+ available: true,
274
+ lookup: 'path',
275
+ },
250
276
  });
251
277
 
252
278
  const exitCode = await runCli(['doctor'], { stdout, stderr, getDoctorStatus });
@@ -280,6 +306,20 @@ describe('ai-cli app', () => {
280
306
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('claude-ultra'));
281
307
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gpt-5.2-codex'));
282
308
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gemini-2.5-pro'));
309
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('forge'));
310
+ expect(stderr).not.toHaveBeenCalled();
311
+ });
312
+
313
+ it('prints detailed help for result --help', async () => {
314
+ const stdout = vi.fn();
315
+ const stderr = vi.fn();
316
+
317
+ const exitCode = await runCli(['result', '--help'], { stdout, stderr });
318
+
319
+ expect(exitCode).toBe(0);
320
+ expect(stdout).toHaveBeenCalledWith(RESULT_HELP_TEXT);
321
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('compact result shape'));
322
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('--verbose'));
283
323
  expect(stderr).not.toHaveBeenCalled();
284
324
  });
285
325
 
@@ -291,6 +331,8 @@ describe('ai-cli app', () => {
291
331
 
292
332
  expect(exitCode).toBe(0);
293
333
  expect(stdout).toHaveBeenCalledWith(WAIT_HELP_TEXT);
334
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('compact shape'));
335
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('--verbose'));
294
336
  expect(stderr).not.toHaveBeenCalled();
295
337
  });
296
338
 
@@ -30,6 +30,7 @@ describe('ai-cli entrypoint smoke', () => {
30
30
  writeExecutable(fakeBinDir, 'claude');
31
31
  writeExecutable(fakeBinDir, 'codex');
32
32
  writeExecutable(fakeBinDir, 'gemini');
33
+ writeExecutable(fakeBinDir, 'forge');
33
34
 
34
35
  const output = execFileSync(
35
36
  'node',
@@ -43,6 +44,7 @@ describe('ai-cli entrypoint smoke', () => {
43
44
  CLAUDE_CLI_NAME: 'claude',
44
45
  CODEX_CLI_NAME: 'codex',
45
46
  GEMINI_CLI_NAME: 'gemini',
47
+ FORGE_CLI_NAME: 'forge',
46
48
  },
47
49
  }
48
50
  );
@@ -50,6 +52,7 @@ describe('ai-cli entrypoint smoke', () => {
50
52
  expect(output).toContain('"claude"');
51
53
  expect(output).toContain('"codex"');
52
54
  expect(output).toContain('"gemini"');
55
+ expect(output).toContain('"forge"');
53
56
  expect(output).toContain('"available": true');
54
57
  });
55
58
 
@@ -67,5 +70,6 @@ describe('ai-cli entrypoint smoke', () => {
67
70
  expect(output).toContain('Usage: ai-cli run --cwd <path> [options]');
68
71
  expect(output).toContain('--model <model>');
69
72
  expect(output).toContain('claude-ultra');
73
+ expect(output).toContain('forge');
70
74
  });
71
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
  });
@@ -65,6 +65,7 @@ describe('CliProcessService', () => {
65
65
  claude: scriptPath,
66
66
  codex: scriptPath,
67
67
  gemini: scriptPath,
68
+ forge: scriptPath,
68
69
  },
69
70
  });
70
71
 
@@ -83,8 +84,18 @@ describe('CliProcessService', () => {
83
84
 
84
85
  const waitResult = await service.waitForProcesses([runResult.pid], 5);
85
86
  expect(waitResult).toHaveLength(1);
86
- expect(waitResult[0].pid).toBe(runResult.pid);
87
- expect(waitResult[0].status).toBe('completed');
87
+ expect(waitResult[0]).toMatchObject({
88
+ pid: runResult.pid,
89
+ agent: 'claude',
90
+ status: 'completed',
91
+ exitCode: null,
92
+ model: 'sonnet',
93
+ stdout: expect.any(String),
94
+ stderr: expect.any(String),
95
+ });
96
+ expect(waitResult[0]).not.toHaveProperty('startTime');
97
+ expect(waitResult[0]).not.toHaveProperty('workFolder');
98
+ expect(waitResult[0]).not.toHaveProperty('prompt');
88
99
 
89
100
  const listed = await service.listProcesses();
90
101
  expect(listed).toContainEqual({
@@ -94,12 +105,141 @@ describe('CliProcessService', () => {
94
105
  });
95
106
 
96
107
  const result = await service.getProcessResult(runResult.pid, false);
97
- expect(result.pid).toBe(runResult.pid);
98
- expect(result.status).toBe('completed');
99
- expect(result.stdout).toContain('Command executed successfully');
108
+ expect(result).toMatchObject({
109
+ pid: runResult.pid,
110
+ agent: 'claude',
111
+ status: 'completed',
112
+ exitCode: null,
113
+ model: 'sonnet',
114
+ stdout: expect.stringContaining('Command executed successfully'),
115
+ stderr: expect.any(String),
116
+ });
117
+ expect(result).not.toHaveProperty('startTime');
118
+ expect(result).not.toHaveProperty('workFolder');
119
+ expect(result).not.toHaveProperty('prompt');
100
120
  expect(readFileSync(join(processDir, 'meta.json'), 'utf-8')).toContain('"status": "completed"');
101
121
  });
102
122
 
123
+ it('returns compact results by default and full results when verbose is true', async () => {
124
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
125
+ tempDirs.push(root);
126
+ const scriptPath = join(root, 'mock-claude-json');
127
+ writeFileSync(
128
+ scriptPath,
129
+ `#!/bin/bash
130
+ printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/demo.txt"}}]}}'
131
+ printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":[{"type":"text","text":"demo output"}]}]}}'
132
+ printf '%s\n' '{"type":"result","result":"Completed cli-process-service test"}'
133
+ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
134
+ `
135
+ );
136
+ chmodSync(scriptPath, 0o755);
137
+ const stateDir = join(root, 'state');
138
+ const workFolder = join(root, 'work');
139
+ mkdirSync(workFolder, { recursive: true });
140
+
141
+ const service = new CliProcessService({
142
+ stateDir,
143
+ cliPaths: {
144
+ claude: scriptPath,
145
+ codex: scriptPath,
146
+ gemini: scriptPath,
147
+ forge: scriptPath,
148
+ },
149
+ });
150
+
151
+ const runResult = await service.startProcess({
152
+ prompt: 'hello structured output',
153
+ cwd: workFolder,
154
+ });
155
+
156
+ const compactWait = await service.waitForProcesses([runResult.pid], 5);
157
+ expect(compactWait).toHaveLength(1);
158
+ expect(compactWait[0]).toMatchObject({
159
+ pid: runResult.pid,
160
+ agent: 'claude',
161
+ status: 'completed',
162
+ exitCode: null,
163
+ model: null,
164
+ session_id: 'session-cli-1',
165
+ agentOutput: {
166
+ message: 'Completed cli-process-service test',
167
+ session_id: 'session-cli-1',
168
+ },
169
+ });
170
+ expect(compactWait[0]).not.toHaveProperty('startTime');
171
+ expect(compactWait[0]).not.toHaveProperty('workFolder');
172
+ expect(compactWait[0]).not.toHaveProperty('prompt');
173
+ expect(compactWait[0].agentOutput).not.toHaveProperty('tools');
174
+
175
+ const compactResult = await service.getProcessResult(runResult.pid, false);
176
+ expect(compactResult).toMatchObject({
177
+ pid: runResult.pid,
178
+ agent: 'claude',
179
+ status: 'completed',
180
+ exitCode: null,
181
+ model: null,
182
+ session_id: 'session-cli-1',
183
+ agentOutput: {
184
+ message: 'Completed cli-process-service test',
185
+ session_id: 'session-cli-1',
186
+ },
187
+ });
188
+ expect(compactResult).not.toHaveProperty('startTime');
189
+ expect(compactResult).not.toHaveProperty('workFolder');
190
+ expect(compactResult).not.toHaveProperty('prompt');
191
+ expect(compactResult.agentOutput).not.toHaveProperty('tools');
192
+
193
+ const verboseWait = await service.waitForProcesses([runResult.pid], 5, true);
194
+ expect(verboseWait).toHaveLength(1);
195
+ expect(verboseWait[0]).toMatchObject({
196
+ pid: runResult.pid,
197
+ agent: 'claude',
198
+ status: 'completed',
199
+ exitCode: null,
200
+ model: null,
201
+ startTime: expect.any(String),
202
+ workFolder,
203
+ prompt: 'hello structured output',
204
+ session_id: 'session-cli-1',
205
+ agentOutput: {
206
+ message: 'Completed cli-process-service test',
207
+ session_id: 'session-cli-1',
208
+ tools: [
209
+ {
210
+ tool: 'Read',
211
+ input: { file_path: '/tmp/demo.txt' },
212
+ output: 'demo output',
213
+ },
214
+ ],
215
+ },
216
+ });
217
+
218
+ const verboseResult = await service.getProcessResult(runResult.pid, true);
219
+ expect(verboseResult).toMatchObject({
220
+ pid: runResult.pid,
221
+ agent: 'claude',
222
+ status: 'completed',
223
+ exitCode: null,
224
+ model: null,
225
+ startTime: expect.any(String),
226
+ workFolder,
227
+ prompt: 'hello structured output',
228
+ session_id: 'session-cli-1',
229
+ agentOutput: {
230
+ message: 'Completed cli-process-service test',
231
+ session_id: 'session-cli-1',
232
+ tools: [
233
+ {
234
+ tool: 'Read',
235
+ input: { file_path: '/tmp/demo.txt' },
236
+ output: 'demo output',
237
+ },
238
+ ],
239
+ },
240
+ });
241
+ });
242
+
103
243
  it('can terminate a tracked process', async () => {
104
244
  const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
105
245
  tempDirs.push(root);
@@ -114,6 +254,7 @@ describe('CliProcessService', () => {
114
254
  claude: scriptPath,
115
255
  codex: scriptPath,
116
256
  gemini: scriptPath,
257
+ forge: scriptPath,
117
258
  },
118
259
  });
119
260
 
@@ -152,6 +293,7 @@ describe('CliProcessService', () => {
152
293
  claude: '/bin/sh',
153
294
  codex: '/bin/sh',
154
295
  gemini: '/bin/sh',
296
+ forge: '/bin/sh',
155
297
  },
156
298
  });
157
299
 
@@ -254,6 +396,7 @@ describe('CliProcessService', () => {
254
396
  claude: '/bin/sh',
255
397
  codex: '/bin/sh',
256
398
  gemini: '/bin/sh',
399
+ forge: '/bin/sh',
257
400
  },
258
401
  });
259
402
 
@@ -275,4 +418,56 @@ describe('CliProcessService', () => {
275
418
  expect(existsSync(failedDir)).toBe(false);
276
419
  killSpy.mockRestore();
277
420
  });
421
+
422
+ it('parses forge output from detached process logs', async () => {
423
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
424
+ tempDirs.push(root);
425
+ const stateDir = join(root, 'state');
426
+ const workFolder = join(root, 'forge-project');
427
+ mkdirSync(workFolder, { recursive: true });
428
+ const pid = 54321;
429
+ const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(pid));
430
+ mkdirSync(processDir, { recursive: true });
431
+
432
+ writeFileSync(
433
+ join(processDir, 'stdout.log'),
434
+ `● [21:09:01] Initialize forge-conv-1
435
+ Forge assistant reply
436
+ ● [21:09:08] Finished forge-conv-1
437
+ `
438
+ );
439
+ writeFileSync(join(processDir, 'stderr.log'), '');
440
+ writeFileSync(
441
+ join(processDir, 'meta.json'),
442
+ JSON.stringify({
443
+ pid,
444
+ prompt: 'hello forge',
445
+ workFolder,
446
+ model: 'forge',
447
+ toolType: 'forge',
448
+ startTime: new Date().toISOString(),
449
+ stdoutPath: join(processDir, 'stdout.log'),
450
+ stderrPath: join(processDir, 'stderr.log'),
451
+ status: 'completed',
452
+ })
453
+ );
454
+
455
+ const service = new CliProcessService({
456
+ stateDir,
457
+ cliPaths: {
458
+ claude: '/bin/sh',
459
+ codex: '/bin/sh',
460
+ gemini: '/bin/sh',
461
+ forge: '/bin/sh',
462
+ },
463
+ });
464
+
465
+ const result = await service.getProcessResult(pid, false);
466
+ expect(result.agent).toBe('forge');
467
+ expect(result.session_id).toBe('forge-conv-1');
468
+ expect(result.agentOutput).toEqual({
469
+ message: 'Forge assistant reply',
470
+ session_id: 'forge-conv-1',
471
+ });
472
+ });
278
473
  });
@@ -19,6 +19,7 @@ describe('cli-utils doctor status', () => {
19
19
  delete process.env.CLAUDE_CLI_NAME;
20
20
  delete process.env.CODEX_CLI_NAME;
21
21
  delete process.env.GEMINI_CLI_NAME;
22
+ delete process.env.FORGE_CLI_NAME;
22
23
  process.env.PATH = '/mock/bin:/usr/bin';
23
24
  });
24
25
 
@@ -44,6 +45,12 @@ describe('cli-utils doctor status', () => {
44
45
  available: true,
45
46
  lookup: 'path',
46
47
  });
48
+ expect(status.forge).toEqual({
49
+ configuredCommand: 'forge',
50
+ resolvedPath: null,
51
+ available: false,
52
+ lookup: 'path',
53
+ });
47
54
  });
48
55
 
49
56
  it('does not mark non-executable PATH entries as available', async () => {
@@ -60,6 +67,12 @@ describe('cli-utils doctor status', () => {
60
67
  available: false,
61
68
  lookup: 'path',
62
69
  });
70
+ expect(status.forge).toEqual({
71
+ configuredCommand: 'forge',
72
+ resolvedPath: null,
73
+ available: false,
74
+ lookup: 'path',
75
+ });
63
76
  });
64
77
 
65
78
  it('reports invalid relative env paths as doctor errors', async () => {
@@ -129,4 +142,25 @@ describe('cli-utils doctor status', () => {
129
142
  lookup: 'env',
130
143
  });
131
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
+ });
132
166
  });