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.
- package/.github/workflows/publish.yml +25 -0
- package/CHANGELOG.md +13 -0
- package/README.ja.md +10 -5
- package/README.md +10 -6
- package/dist/__tests__/app-cli.test.js +8 -0
- package/dist/__tests__/cli-bin-smoke.test.js +4 -0
- package/dist/__tests__/cli-builder.test.js +37 -0
- package/dist/__tests__/cli-process-service.test.js +46 -0
- package/dist/__tests__/cli-utils.test.js +31 -0
- package/dist/__tests__/mcp-contract.test.js +149 -1
- package/dist/__tests__/parsers.test.js +37 -1
- package/dist/app/cli.js +2 -2
- package/dist/app/mcp.js +8 -4
- package/dist/cli-builder.js +14 -0
- package/dist/cli-parse.js +8 -5
- package/dist/cli-process-service.js +6 -2
- package/dist/cli-utils.js +17 -0
- package/dist/cli.js +4 -3
- package/dist/model-catalog.js +4 -1
- package/dist/parsers.js +55 -0
- package/dist/process-service.js +4 -1
- package/dist/server.js +1 -1
- package/package.json +2 -2
- package/server.json +1 -1
- package/src/__tests__/app-cli.test.ts +8 -0
- package/src/__tests__/cli-bin-smoke.test.ts +4 -0
- package/src/__tests__/cli-builder.test.ts +47 -0
- package/src/__tests__/cli-process-service.test.ts +56 -0
- package/src/__tests__/cli-utils.test.ts +34 -0
- package/src/__tests__/mcp-contract.test.ts +173 -1
- package/src/__tests__/parsers.test.ts +44 -1
- package/src/app/cli.ts +2 -2
- package/src/app/mcp.ts +8 -4
- package/src/cli-builder.ts +18 -3
- package/src/cli-parse.ts +8 -5
- package/src/cli-process-service.ts +5 -2
- package/src/cli-utils.ts +21 -1
- package/src/cli.ts +4 -3
- package/src/model-catalog.ts +5 -1
- package/src/parsers.ts +61 -0
- package/src/process-service.ts +4 -2
- 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
|
|
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'],
|
package/src/cli-builder.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/src/model-catalog.ts
CHANGED
|
@@ -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
|
+
}
|