ai-cli-mcp 2.12.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 (42) hide show
  1. package/.github/workflows/publish.yml +25 -0
  2. package/CHANGELOG.md +13 -0
  3. package/README.ja.md +10 -5
  4. package/README.md +10 -6
  5. package/dist/__tests__/app-cli.test.js +8 -0
  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 +46 -0
  9. package/dist/__tests__/cli-utils.test.js +31 -0
  10. package/dist/__tests__/mcp-contract.test.js +149 -1
  11. package/dist/__tests__/parsers.test.js +37 -1
  12. package/dist/app/cli.js +2 -2
  13. package/dist/app/mcp.js +8 -4
  14. package/dist/cli-builder.js +14 -0
  15. package/dist/cli-parse.js +8 -5
  16. package/dist/cli-process-service.js +6 -2
  17. package/dist/cli-utils.js +17 -0
  18. package/dist/cli.js +4 -3
  19. package/dist/model-catalog.js +4 -1
  20. package/dist/parsers.js +55 -0
  21. package/dist/process-service.js +4 -1
  22. package/dist/server.js +1 -1
  23. package/package.json +2 -2
  24. package/server.json +1 -1
  25. package/src/__tests__/app-cli.test.ts +8 -0
  26. package/src/__tests__/cli-bin-smoke.test.ts +4 -0
  27. package/src/__tests__/cli-builder.test.ts +47 -0
  28. package/src/__tests__/cli-process-service.test.ts +56 -0
  29. package/src/__tests__/cli-utils.test.ts +34 -0
  30. package/src/__tests__/mcp-contract.test.ts +173 -1
  31. package/src/__tests__/parsers.test.ts +44 -1
  32. package/src/app/cli.ts +2 -2
  33. package/src/app/mcp.ts +8 -4
  34. package/src/cli-builder.ts +18 -3
  35. package/src/cli-parse.ts +8 -5
  36. package/src/cli-process-service.ts +5 -2
  37. package/src/cli-utils.ts +21 -1
  38. package/src/cli.ts +4 -3
  39. package/src/model-catalog.ts +5 -1
  40. package/src/parsers.ts +61 -0
  41. package/src/process-service.ts +4 -2
  42. package/src/server.ts +1 -1
@@ -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
  });
@@ -1,5 +1,5 @@
1
1
  import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
2
- import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { cleanupSharedMock, getSharedMock } from './utils/persistent-mock.js';
@@ -19,6 +19,53 @@ function expectProcessSummaryShape(processInfo: any): void {
19
19
  });
20
20
  }
21
21
 
22
+ function createForgeMockScript(dir: string, argsLogPath: string): string {
23
+ const scriptPath = join(dir, 'mock-forge');
24
+ writeFileSync(
25
+ scriptPath,
26
+ `#!/bin/bash
27
+ set -euo pipefail
28
+
29
+ log_file="${argsLogPath}"
30
+ prompt=""
31
+ conversation_id=""
32
+
33
+ printf '%s\\n' "$*" >> "$log_file"
34
+
35
+ while [[ $# -gt 0 ]]; do
36
+ case "$1" in
37
+ -C)
38
+ shift 2
39
+ ;;
40
+ -p)
41
+ prompt="$2"
42
+ shift 2
43
+ ;;
44
+ --conversation-id)
45
+ conversation_id="$2"
46
+ shift 2
47
+ ;;
48
+ *)
49
+ shift
50
+ ;;
51
+ esac
52
+ done
53
+
54
+ if [[ -n "$conversation_id" ]]; then
55
+ printf '● [21:09:33] Continue %s\\n' "$conversation_id"
56
+ printf 'Resumed: %s\\n' "$prompt"
57
+ printf '● [21:09:37] Finished %s\\n' "$conversation_id"
58
+ else
59
+ printf '● [21:09:01] Initialize forge-session-1\\n'
60
+ printf 'Initial: %s\\n' "$prompt"
61
+ printf '● [21:09:08] Finished forge-session-1\\n'
62
+ fi
63
+ `
64
+ );
65
+ chmodSync(scriptPath, 0o755);
66
+ return scriptPath;
67
+ }
68
+
22
69
  describe('MCP Contract Tests', () => {
23
70
  let client: MCPTestClient;
24
71
  let testDir: string;
@@ -154,6 +201,131 @@ describe('MCP Contract Tests', () => {
154
201
  });
155
202
  });
156
203
 
204
+ it('covers forge end-to-end through the MCP process path', async () => {
205
+ await client.disconnect();
206
+
207
+ const forgeArgsLogPath = join(testDir, 'forge-args.log');
208
+ const forgeMockPath = createForgeMockScript(testDir, forgeArgsLogPath);
209
+
210
+ client = createTestClient({
211
+ debug: false,
212
+ env: {
213
+ FORGE_CLI_NAME: forgeMockPath,
214
+ },
215
+ });
216
+ await client.connect();
217
+
218
+ const initialRunResponse = await client.callTool('run', {
219
+ prompt: 'forge-initial-prompt',
220
+ workFolder: testDir,
221
+ model: 'forge',
222
+ });
223
+ const initialRunData = parseToolJson(initialRunResponse);
224
+
225
+ expect(initialRunData).toEqual({
226
+ pid: expect.any(Number),
227
+ status: 'started',
228
+ agent: 'forge',
229
+ message: expect.any(String),
230
+ });
231
+
232
+ const initialWaitResponse = await client.callTool('wait', { pids: [initialRunData.pid], timeout: 5 });
233
+ const initialWaitData = parseToolJson(initialWaitResponse);
234
+
235
+ expect(initialWaitData).toHaveLength(1);
236
+ expect(initialWaitData[0]).toMatchObject({
237
+ pid: initialRunData.pid,
238
+ agent: 'forge',
239
+ status: 'completed',
240
+ session_id: 'forge-session-1',
241
+ agentOutput: {
242
+ message: 'Initial: forge-initial-prompt',
243
+ session_id: 'forge-session-1',
244
+ },
245
+ });
246
+
247
+ const initialResultResponse = await client.callTool('get_result', { pid: initialRunData.pid });
248
+ const initialResultData = parseToolJson(initialResultResponse);
249
+
250
+ expect(initialResultData).toMatchObject({
251
+ pid: initialRunData.pid,
252
+ agent: 'forge',
253
+ status: 'completed',
254
+ session_id: 'forge-session-1',
255
+ agentOutput: {
256
+ message: 'Initial: forge-initial-prompt',
257
+ session_id: 'forge-session-1',
258
+ },
259
+ });
260
+
261
+ const resumedRunResponse = await client.callTool('run', {
262
+ prompt: 'forge-resume-prompt',
263
+ workFolder: testDir,
264
+ model: 'forge',
265
+ session_id: 'forge-session-1',
266
+ });
267
+ const resumedRunData = parseToolJson(resumedRunResponse);
268
+
269
+ expect(resumedRunData).toEqual({
270
+ pid: expect.any(Number),
271
+ status: 'started',
272
+ agent: 'forge',
273
+ message: expect.any(String),
274
+ });
275
+
276
+ const resumedWaitResponse = await client.callTool('wait', { pids: [resumedRunData.pid], timeout: 5 });
277
+ const resumedWaitData = parseToolJson(resumedWaitResponse);
278
+
279
+ expect(resumedWaitData).toHaveLength(1);
280
+ expect(resumedWaitData[0]).toMatchObject({
281
+ pid: resumedRunData.pid,
282
+ agent: 'forge',
283
+ status: 'completed',
284
+ session_id: 'forge-session-1',
285
+ agentOutput: {
286
+ message: 'Resumed: forge-resume-prompt',
287
+ session_id: 'forge-session-1',
288
+ },
289
+ });
290
+
291
+ const resumedResultResponse = await client.callTool('get_result', { pid: resumedRunData.pid });
292
+ const resumedResultData = parseToolJson(resumedResultResponse);
293
+
294
+ expect(resumedResultData).toMatchObject({
295
+ pid: resumedRunData.pid,
296
+ agent: 'forge',
297
+ status: 'completed',
298
+ session_id: 'forge-session-1',
299
+ agentOutput: {
300
+ message: 'Resumed: forge-resume-prompt',
301
+ session_id: 'forge-session-1',
302
+ },
303
+ });
304
+
305
+ const forgeInvocations = readFileSync(forgeArgsLogPath, 'utf-8').trim().split('\n');
306
+ expect(forgeInvocations).toHaveLength(2);
307
+ expect(forgeInvocations[0]).toContain(`-C ${testDir}`);
308
+ expect(forgeInvocations[0]).toContain('-p forge-initial-prompt');
309
+ expect(forgeInvocations[0]).not.toContain('--model');
310
+ expect(forgeInvocations[0]).not.toContain('--agent');
311
+ expect(forgeInvocations[0]).not.toContain('--conversation-id');
312
+
313
+ expect(forgeInvocations[1]).toContain(`-C ${testDir}`);
314
+ expect(forgeInvocations[1]).toContain('--conversation-id forge-session-1');
315
+ expect(forgeInvocations[1]).toContain('-p forge-resume-prompt');
316
+ expect(forgeInvocations[1]).not.toContain('--model');
317
+ expect(forgeInvocations[1]).not.toContain('--agent');
318
+
319
+ await expect(
320
+ client.callTool('run', {
321
+ prompt: 'forge-invalid-reasoning',
322
+ workFolder: testDir,
323
+ model: 'forge',
324
+ reasoning_effort: 'high',
325
+ })
326
+ ).rejects.toThrow(/reasoning_effort is not supported for forge/i);
327
+ });
328
+
157
329
  it('keeps key invalid-input errors stable', async () => {
158
330
  await expect(
159
331
  client.callTool('run', {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { parseCodexOutput, parseClaudeOutput } from '../parsers.js';
2
+ import { parseCodexOutput, parseClaudeOutput, parseForgeOutput } from '../parsers.js';
3
3
 
4
4
  describe('parseCodexOutput', () => {
5
5
  it('should parse basic Codex output with message and session_id', () => {
@@ -106,3 +106,46 @@ INVALID_LINE
106
106
  expect(result.message).toBe("Success");
107
107
  });
108
108
  });
109
+
110
+ describe('parseForgeOutput', () => {
111
+ it('should parse initialized forge output with a conversation id', () => {
112
+ const output = `● [21:09:01] Initialize 123e4567-e89b-12d3-a456-426614174000
113
+ Hello from Forge
114
+ ● [21:09:08] Finished 123e4567-e89b-12d3-a456-426614174000
115
+ `;
116
+
117
+ expect(parseForgeOutput(output)).toEqual({
118
+ message: 'Hello from Forge',
119
+ session_id: '123e4567-e89b-12d3-a456-426614174000',
120
+ });
121
+ });
122
+
123
+ it('should parse resumed forge output with multiline assistant content', () => {
124
+ const output = `● [21:09:33] Continue conv-123
125
+ Line one
126
+
127
+ Line three
128
+ ● [21:09:37] Finished conv-123
129
+ `;
130
+
131
+ expect(parseForgeOutput(output)).toEqual({
132
+ message: 'Line one\n\nLine three',
133
+ session_id: 'conv-123',
134
+ });
135
+ });
136
+
137
+ it('should return the current message while forge output is still in progress', () => {
138
+ const output = `● [21:09:33] Continue conv-456
139
+ Partial answer
140
+ still streaming`;
141
+
142
+ expect(parseForgeOutput(output)).toEqual({
143
+ message: 'Partial answer\nstill streaming',
144
+ session_id: 'conv-456',
145
+ });
146
+ });
147
+
148
+ it('should return null for unrelated forge output', () => {
149
+ expect(parseForgeOutput('plain text')).toBeNull();
150
+ });
151
+ });
package/src/app/cli.ts CHANGED
@@ -26,9 +26,9 @@ Options:
26
26
  --cwd <path> Working directory
27
27
  --prompt <text> Prompt text
28
28
  --prompt-file <path> Path to a prompt file
29
- --model <model> Model name or alias (e.g. sonnet, claude-ultra, gpt-5.2-codex, codex-ultra, gemini-2.5-pro, gemini-ultra)
29
+ --model <model> Model name or alias (e.g. sonnet, claude-ultra, gpt-5.2-codex, codex-ultra, gemini-2.5-pro, gemini-ultra, forge)
30
30
  --session-id <id> Resume a previous session
31
- --reasoning-effort <level> Reasoning level for Claude/Codex
31
+ --reasoning-effort <level> Reasoning level for Claude/Codex only
32
32
  --help, -h Show this help message
33
33
 
34
34
  Compatibility aliases:
package/src/app/mcp.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  type ServerResult,
9
9
  } from '@modelcontextprotocol/sdk/types.js';
10
10
  import { spawn } from 'node:child_process';
11
- import { debugLog, findClaudeCli, findCodexCli, findGeminiCli } from '../cli-utils.js';
11
+ import { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from '../cli-utils.js';
12
12
  import { getModelParameterDescription, getSupportedModelsDescription } from '../model-catalog.js';
13
13
  import { ProcessService } from '../process-service.js';
14
14
 
@@ -72,6 +72,7 @@ export class ClaudeCodeServer {
72
72
  private claudeCliPath: string;
73
73
  private codexCliPath: string;
74
74
  private geminiCliPath: string;
75
+ private forgeCliPath: string;
75
76
  private processService: ProcessService;
76
77
  private sigintHandler?: () => Promise<void>;
77
78
  private packageVersion: string;
@@ -80,15 +81,18 @@ export class ClaudeCodeServer {
80
81
  this.claudeCliPath = findClaudeCli();
81
82
  this.codexCliPath = findCodexCli();
82
83
  this.geminiCliPath = findGeminiCli();
84
+ this.forgeCliPath = findForgeCli();
83
85
  console.error(`[Setup] Using Claude CLI command/path: ${this.claudeCliPath}`);
84
86
  console.error(`[Setup] Using Codex CLI command/path: ${this.codexCliPath}`);
85
87
  console.error(`[Setup] Using Gemini CLI command/path: ${this.geminiCliPath}`);
88
+ console.error(`[Setup] Using Forge CLI command/path: ${this.forgeCliPath}`);
86
89
  this.packageVersion = SERVER_VERSION;
87
90
  this.processService = new ProcessService({
88
91
  cliPaths: {
89
92
  claude: this.claudeCliPath,
90
93
  codex: this.codexCliPath,
91
94
  gemini: this.geminiCliPath,
95
+ forge: this.forgeCliPath,
92
96
  },
93
97
  });
94
98
 
@@ -119,7 +123,7 @@ export class ClaudeCodeServer {
119
123
  tools: [
120
124
  {
121
125
  name: 'run',
122
- description: `AI Agent Runner: Starts a Claude, Codex, or Gemini CLI process in the background and returns a PID immediately. Use list_processes and get_result to monitor progress.
126
+ description: `AI Agent Runner: Starts a Claude, Codex, Gemini, or Forge CLI process in the background and returns a PID immediately. Use list_processes and get_result to monitor progress.
123
127
 
124
128
  • File ops: Create, read, (fuzzy) edit, move, copy, delete, list files, analyze/ocr images, file content analysis
125
129
  • Code: Generate / analyse / refactor / fix
@@ -163,11 +167,11 @@ ${getSupportedModelsDescription()}
163
167
  },
164
168
  reasoning_effort: {
165
169
  type: 'string',
166
- description: 'Reasoning control for Claude and Codex. Claude uses --effort with "low", "medium", "high". Codex uses model_reasoning_effort with "low", "medium", "high", "xhigh".',
170
+ description: 'Reasoning control for Claude and Codex. Claude uses --effort with "low", "medium", "high". Codex uses model_reasoning_effort with "low", "medium", "high", "xhigh". Forge does not support reasoning_effort in this integration.',
167
171
  },
168
172
  session_id: {
169
173
  type: 'string',
170
- description: 'Optional session ID to resume a previous session. Supported for: haiku, sonnet, opus, gemini-2.5-pro, gemini-2.5-flash, gemini-3.1-pro-preview, gemini-3-pro-preview, gemini-3-flash-preview.',
174
+ description: 'Optional session ID to resume a previous session. Supported for: haiku, sonnet, opus, gemini-2.5-pro, gemini-2.5-flash, gemini-3.1-pro-preview, gemini-3-pro-preview, gemini-3-flash-preview, forge.',
171
175
  },
172
176
  },
173
177
  required: ['workFolder'],
@@ -5,7 +5,10 @@ import { MODEL_ALIASES } from './model-catalog.js';
5
5
  export const ALLOWED_REASONING_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh']);
6
6
  const CLAUDE_REASONING_EFFORTS = new Set(['low', 'medium', 'high']);
7
7
 
8
- function getAgentForModel(model: string): 'codex' | 'claude' | 'gemini' {
8
+ function getAgentForModel(model: string): 'codex' | 'claude' | 'gemini' | 'forge' {
9
+ if (model === 'forge') {
10
+ return 'forge';
11
+ }
9
12
  if (model.startsWith('gpt-')) {
10
13
  return 'codex';
11
14
  }
@@ -44,6 +47,9 @@ export function getReasoningEffort(model: string, rawValue: unknown): string {
44
47
  );
45
48
  }
46
49
  const agent = getAgentForModel(model);
50
+ if (agent === 'forge') {
51
+ throw new Error('reasoning_effort is not supported for forge.');
52
+ }
47
53
  if (agent === 'gemini') {
48
54
  throw new Error(
49
55
  'reasoning_effort is only supported for Claude and Codex models.'
@@ -61,7 +67,7 @@ export interface CliCommand {
61
67
  cliPath: string;
62
68
  args: string[];
63
69
  cwd: string;
64
- agent: 'claude' | 'codex' | 'gemini';
70
+ agent: 'claude' | 'codex' | 'gemini' | 'forge';
65
71
  prompt: string;
66
72
  resolvedModel: string;
67
73
  }
@@ -73,7 +79,7 @@ export interface BuildCliCommandOptions {
73
79
  model?: string;
74
80
  session_id?: string;
75
81
  reasoning_effort?: string;
76
- cliPaths: { claude: string; codex: string; gemini: string };
82
+ cliPaths: { claude: string; codex: string; gemini: string; forge: string };
77
83
  }
78
84
 
79
85
  /**
@@ -178,6 +184,15 @@ export function buildCliCommand(options: BuildCliCommandOptions): CliCommand {
178
184
 
179
185
  args.push(prompt);
180
186
 
187
+ } else if (agent === 'forge') {
188
+ cliPath = options.cliPaths.forge;
189
+ args = ['-C', cwd];
190
+
191
+ if (options.session_id && typeof options.session_id === 'string') {
192
+ args.push('--conversation-id', options.session_id);
193
+ }
194
+
195
+ args.push('-p', prompt);
181
196
  } else {
182
197
  cliPath = options.cliPaths.claude;
183
198
  args = ['--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose'];
package/src/cli-parse.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { parseClaudeOutput, parseCodexOutput, parseGeminiOutput } from './parsers.js';
2
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
3
3
 
4
- const AGENTS = ['claude', 'codex', 'gemini'] as const;
4
+ const AGENTS = ['claude', 'codex', 'gemini', 'forge'] as const;
5
5
  type Agent = typeof AGENTS[number];
6
6
 
7
- const USAGE = `Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini>
7
+ const USAGE = `Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini|forge>
8
8
 
9
9
  Reads raw CLI output from stdin and outputs parsed JSON to stdout.
10
10
 
11
11
  Options:
12
- --agent Agent type: claude, codex, or gemini (required)
12
+ --agent Agent type: claude, codex, gemini, or forge (required)
13
13
  --help Show this help message
14
14
 
15
15
  Examples:
@@ -62,7 +62,7 @@ async function main(): Promise<void> {
62
62
 
63
63
  const agent = args.agent as Agent;
64
64
  if (!agent || !AGENTS.includes(agent)) {
65
- process.stderr.write(`Error: --agent is required (claude, codex, or gemini)\n\n`);
65
+ process.stderr.write(`Error: --agent is required (claude, codex, gemini, or forge)\n\n`);
66
66
  process.stderr.write(USAGE);
67
67
  process.exit(1);
68
68
  }
@@ -85,6 +85,9 @@ async function main(): Promise<void> {
85
85
  case 'gemini':
86
86
  parsed = parseGeminiOutput(input);
87
87
  break;
88
+ case 'forge':
89
+ parsed = parseForgeOutput(input);
90
+ break;
88
91
  }
89
92
 
90
93
  process.stdout.write(JSON.stringify(parsed, null, 2) + '\n');
@@ -15,8 +15,8 @@ import {
15
15
  import { join } from 'node:path';
16
16
  import { homedir } from 'node:os';
17
17
  import { buildCliCommand, type BuildCliCommandOptions } from './cli-builder.js';
18
- import { findClaudeCli, findCodexCli, findGeminiCli } from './cli-utils.js';
19
- import { parseClaudeOutput, parseCodexOutput, parseGeminiOutput } from './parsers.js';
18
+ import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
19
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
20
20
  import type { AgentType, ProcessListItem } from './process-service.js';
21
21
 
22
22
  interface StoredProcess {
@@ -78,6 +78,7 @@ export class CliProcessService {
78
78
  claude: findClaudeCli(),
79
79
  codex: findCodexCli(),
80
80
  gemini: findGeminiCli(),
81
+ forge: findForgeCli(),
81
82
  };
82
83
  mkdirSync(this.stateDir, { recursive: true });
83
84
  }
@@ -177,6 +178,8 @@ export class CliProcessService {
177
178
  agentOutput = parseClaudeOutput(stdout);
178
179
  } else if (refreshed.toolType === 'gemini') {
179
180
  agentOutput = parseGeminiOutput(stdout);
181
+ } else if (refreshed.toolType === 'forge') {
182
+ agentOutput = parseForgeOutput(stdout);
180
183
  }
181
184
  }
182
185
 
package/src/cli-utils.ts CHANGED
@@ -148,7 +148,7 @@ function isExecutableFile(filePath: string): boolean {
148
148
  }
149
149
  }
150
150
 
151
- type CliBinaryName = 'claude' | 'codex' | 'gemini';
151
+ type CliBinaryName = 'claude' | 'codex' | 'gemini' | 'forge';
152
152
 
153
153
  function getCliBinaryConfig(name: CliBinaryName): {
154
154
  envVarName: string;
@@ -174,6 +174,15 @@ function getCliBinaryConfig(name: CliBinaryName): {
174
174
  };
175
175
  }
176
176
 
177
+ if (name === 'forge') {
178
+ return {
179
+ envVarName: 'FORGE_CLI_NAME',
180
+ customCliName: process.env.FORGE_CLI_NAME,
181
+ defaultCliName: 'forge',
182
+ localInstallPath: join(homedir(), '.forge', 'local', 'forge'),
183
+ };
184
+ }
185
+
177
186
  return {
178
187
  envVarName: 'GEMINI_CLI_NAME',
179
188
  customCliName: process.env.GEMINI_CLI_NAME,
@@ -190,11 +199,13 @@ export function getCliDoctorStatus(): {
190
199
  claude: CliBinaryStatus;
191
200
  codex: CliBinaryStatus;
192
201
  gemini: CliBinaryStatus;
202
+ forge: CliBinaryStatus;
193
203
  } {
194
204
  return {
195
205
  claude: getCliBinaryStatus('claude'),
196
206
  codex: getCliBinaryStatus('codex'),
197
207
  gemini: getCliBinaryStatus('gemini'),
208
+ forge: getCliBinaryStatus('forge'),
198
209
  };
199
210
  }
200
211
 
@@ -218,6 +229,15 @@ export function findCodexCli(): string {
218
229
  return getCliCommandOrThrow(status);
219
230
  }
220
231
 
232
+ /**
233
+ * Determine the Forge CLI command/path.
234
+ */
235
+ export function findForgeCli(): string {
236
+ debugLog('[Debug] Attempting to find Forge CLI...');
237
+ const status = getCliBinaryStatus('forge');
238
+ return getCliCommandOrThrow(status);
239
+ }
240
+
221
241
  /**
222
242
  * Determine the Claude CLI command/path.
223
243
  * 1. Checks for CLAUDE_CLI_NAME environment variable:
package/src/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
3
  import { buildCliCommand } from './cli-builder.js';
4
- import { findClaudeCli, findCodexCli, findGeminiCli } from './cli-utils.js';
4
+ import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
5
5
 
6
6
  /**
7
7
  * Minimal argv parser. No external dependencies.
@@ -35,12 +35,12 @@ function parseArgs(argv: string[]): Record<string, string> {
35
35
  const USAGE = `Usage: npm run -s cli.run -- --model <model> --workFolder <path> --prompt "..." [options]
36
36
 
37
37
  Options:
38
- --model Model name or alias (e.g. sonnet, opus, gpt-5.2-codex, gemini-2.5-pro)
38
+ --model Model name or alias (e.g. sonnet, opus, gpt-5.2-codex, gemini-2.5-pro, forge)
39
39
  --workFolder Working directory (absolute path)
40
40
  --prompt Prompt string (mutually exclusive with --prompt_file)
41
41
  --prompt_file Path to a file containing the prompt
42
42
  --session_id Session ID to resume
43
- --reasoning_effort Claude/Codex: Claude=low|medium|high, Codex=low|medium|high|xhigh
43
+ --reasoning_effort Claude/Codex only: Claude=low|medium|high, Codex=low|medium|high|xhigh
44
44
  --help Show this help message
45
45
 
46
46
  Raw CLI output goes to stdout. Use cli.run.parse to parse the output:
@@ -73,6 +73,7 @@ async function main(): Promise<void> {
73
73
  claude: findClaudeCli(),
74
74
  codex: findCodexCli(),
75
75
  gemini: findGeminiCli(),
76
+ forge: findForgeCli(),
76
77
  };
77
78
 
78
79
  // Build command
@@ -19,6 +19,7 @@ export const GEMINI_MODELS = [
19
19
  'gemini-3-pro-preview',
20
20
  'gemini-3-flash-preview',
21
21
  ] as const;
22
+ export const FORGE_MODELS = ['forge'] as const;
22
23
 
23
24
  export const MODEL_ALIASES: Record<string, string> = {
24
25
  'claude-ultra': 'opus',
@@ -38,11 +39,12 @@ export function getSupportedModelsDescription(): string {
38
39
  ...CLAUDE_MODELS.map((model) => `"${model}"`),
39
40
  ...CODEX_MODELS.map((model) => `"${model}"`),
40
41
  ...GEMINI_MODELS.map((model) => `"${model}"`),
42
+ ...FORGE_MODELS.map((model) => `"${model}"`),
41
43
  ].join(', ');
42
44
  }
43
45
 
44
46
  export function getModelParameterDescription(): string {
45
- return `The model to use. Aliases: "claude-ultra" (auto high effort), "codex-ultra" (auto xhigh reasoning), "gemini-ultra". Standard: ${[...CLAUDE_MODELS, ...CODEX_MODELS, ...GEMINI_MODELS].map((model) => `"${model}"`).join(', ')}.`;
47
+ return `The model to use. Aliases: "claude-ultra" (auto high effort), "codex-ultra" (auto xhigh reasoning), "gemini-ultra". Standard: ${[...CLAUDE_MODELS, ...CODEX_MODELS, ...GEMINI_MODELS, ...FORGE_MODELS].map((model) => `"${model}"`).join(', ')}. "forge" is a provider key, not a Forge model family selector.`;
46
48
  }
47
49
 
48
50
  export function getModelsPayload(): {
@@ -50,11 +52,13 @@ export function getModelsPayload(): {
50
52
  claude: ReadonlyArray<string>;
51
53
  codex: ReadonlyArray<string>;
52
54
  gemini: ReadonlyArray<string>;
55
+ forge: ReadonlyArray<string>;
53
56
  } {
54
57
  return {
55
58
  aliases: MODEL_ALIAS_DETAILS,
56
59
  claude: CLAUDE_MODELS,
57
60
  codex: CODEX_MODELS,
58
61
  gemini: GEMINI_MODELS,
62
+ forge: FORGE_MODELS,
59
63
  };
60
64
  }
package/src/parsers.ts CHANGED
@@ -167,3 +167,64 @@ export function parseGeminiOutput(stdout: string): any {
167
167
  return null;
168
168
  }
169
169
  }
170
+
171
+ /**
172
+ * Parse Forge output framed by Initialize/Continue/Finished markers.
173
+ */
174
+ export function parseForgeOutput(stdout: string): any {
175
+ if (!stdout) return null;
176
+
177
+ const lines = stdout.split('\n');
178
+ const markerPattern = /^● \[[^\]]+\] (Initialize|Continue|Finished) (\S+)\s*$/;
179
+ let collecting = false;
180
+ let currentConversationId: string | null = null;
181
+ let currentBody: string[] = [];
182
+ let lastConversationId: string | null = null;
183
+ let lastMessage: string | null = null;
184
+
185
+ for (const line of lines) {
186
+ const match = line.match(markerPattern);
187
+ if (match) {
188
+ const [, action, conversationId] = match;
189
+ lastConversationId = conversationId;
190
+
191
+ if (action === 'Initialize' || action === 'Continue') {
192
+ collecting = true;
193
+ currentConversationId = conversationId;
194
+ currentBody = [];
195
+ } else if (collecting && currentConversationId === conversationId) {
196
+ const message = currentBody.join('\n').trim();
197
+ if (message) {
198
+ lastMessage = message;
199
+ }
200
+ collecting = false;
201
+ currentConversationId = null;
202
+ currentBody = [];
203
+ }
204
+ continue;
205
+ }
206
+
207
+ if (collecting) {
208
+ currentBody.push(line);
209
+ }
210
+ }
211
+
212
+ if (collecting) {
213
+ const message = currentBody.join('\n').trim();
214
+ if (message) {
215
+ lastMessage = message;
216
+ }
217
+ if (currentConversationId) {
218
+ lastConversationId = currentConversationId;
219
+ }
220
+ }
221
+
222
+ if (!lastMessage && !lastConversationId) {
223
+ return null;
224
+ }
225
+
226
+ return {
227
+ message: lastMessage,
228
+ session_id: lastConversationId,
229
+ };
230
+ }