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
@@ -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
  describe('parseCodexOutput', () => {
4
4
  it('should parse basic Codex output with message and session_id', () => {
5
5
  const output = `
@@ -96,3 +96,39 @@ INVALID_LINE
96
96
  expect(result.message).toBe("Success");
97
97
  });
98
98
  });
99
+ describe('parseForgeOutput', () => {
100
+ it('should parse initialized forge output with a conversation id', () => {
101
+ const output = `● [21:09:01] Initialize 123e4567-e89b-12d3-a456-426614174000
102
+ Hello from Forge
103
+ ● [21:09:08] Finished 123e4567-e89b-12d3-a456-426614174000
104
+ `;
105
+ expect(parseForgeOutput(output)).toEqual({
106
+ message: 'Hello from Forge',
107
+ session_id: '123e4567-e89b-12d3-a456-426614174000',
108
+ });
109
+ });
110
+ it('should parse resumed forge output with multiline assistant content', () => {
111
+ const output = `● [21:09:33] Continue conv-123
112
+ Line one
113
+
114
+ Line three
115
+ ● [21:09:37] Finished conv-123
116
+ `;
117
+ expect(parseForgeOutput(output)).toEqual({
118
+ message: 'Line one\n\nLine three',
119
+ session_id: 'conv-123',
120
+ });
121
+ });
122
+ it('should return the current message while forge output is still in progress', () => {
123
+ const output = `● [21:09:33] Continue conv-456
124
+ Partial answer
125
+ still streaming`;
126
+ expect(parseForgeOutput(output)).toEqual({
127
+ message: 'Partial answer\nstill streaming',
128
+ session_id: 'conv-456',
129
+ });
130
+ });
131
+ it('should return null for unrelated forge output', () => {
132
+ expect(parseForgeOutput('plain text')).toBeNull();
133
+ });
134
+ });
package/dist/app/cli.js CHANGED
@@ -24,9 +24,9 @@ Options:
24
24
  --cwd <path> Working directory
25
25
  --prompt <text> Prompt text
26
26
  --prompt-file <path> Path to a prompt file
27
- --model <model> Model name or alias (e.g. sonnet, claude-ultra, gpt-5.2-codex, codex-ultra, gemini-2.5-pro, gemini-ultra)
27
+ --model <model> Model name or alias (e.g. sonnet, claude-ultra, gpt-5.2-codex, codex-ultra, gemini-2.5-pro, gemini-ultra, forge)
28
28
  --session-id <id> Resume a previous session
29
- --reasoning-effort <level> Reasoning level for Claude/Codex
29
+ --reasoning-effort <level> Reasoning level for Claude/Codex only
30
30
  --help, -h Show this help message
31
31
 
32
32
  Compatibility aliases:
package/dist/app/mcp.js CHANGED
@@ -2,7 +2,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { spawn } from 'node:child_process';
5
- import { debugLog, findClaudeCli, findCodexCli, findGeminiCli } from '../cli-utils.js';
5
+ import { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from '../cli-utils.js';
6
6
  import { getModelParameterDescription, getSupportedModelsDescription } from '../model-catalog.js';
7
7
  import { ProcessService } from '../process-service.js';
8
8
  // Server version - update this when releasing new versions
@@ -58,6 +58,7 @@ export class ClaudeCodeServer {
58
58
  claudeCliPath;
59
59
  codexCliPath;
60
60
  geminiCliPath;
61
+ forgeCliPath;
61
62
  processService;
62
63
  sigintHandler;
63
64
  packageVersion;
@@ -65,15 +66,18 @@ export class ClaudeCodeServer {
65
66
  this.claudeCliPath = findClaudeCli();
66
67
  this.codexCliPath = findCodexCli();
67
68
  this.geminiCliPath = findGeminiCli();
69
+ this.forgeCliPath = findForgeCli();
68
70
  console.error(`[Setup] Using Claude CLI command/path: ${this.claudeCliPath}`);
69
71
  console.error(`[Setup] Using Codex CLI command/path: ${this.codexCliPath}`);
70
72
  console.error(`[Setup] Using Gemini CLI command/path: ${this.geminiCliPath}`);
73
+ console.error(`[Setup] Using Forge CLI command/path: ${this.forgeCliPath}`);
71
74
  this.packageVersion = SERVER_VERSION;
72
75
  this.processService = new ProcessService({
73
76
  cliPaths: {
74
77
  claude: this.claudeCliPath,
75
78
  codex: this.codexCliPath,
76
79
  gemini: this.geminiCliPath,
80
+ forge: this.forgeCliPath,
77
81
  },
78
82
  });
79
83
  this.server = new Server({
@@ -97,7 +101,7 @@ export class ClaudeCodeServer {
97
101
  tools: [
98
102
  {
99
103
  name: 'run',
100
- 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.
104
+ 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.
101
105
 
102
106
  • File ops: Create, read, (fuzzy) edit, move, copy, delete, list files, analyze/ocr images, file content analysis
103
107
  • Code: Generate / analyse / refactor / fix
@@ -141,11 +145,11 @@ ${getSupportedModelsDescription()}
141
145
  },
142
146
  reasoning_effort: {
143
147
  type: 'string',
144
- description: 'Reasoning control for Claude and Codex. Claude uses --effort with "low", "medium", "high". Codex uses model_reasoning_effort with "low", "medium", "high", "xhigh".',
148
+ 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.',
145
149
  },
146
150
  session_id: {
147
151
  type: 'string',
148
- 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.',
152
+ 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.',
149
153
  },
150
154
  },
151
155
  required: ['workFolder'],
@@ -4,6 +4,9 @@ import { MODEL_ALIASES } from './model-catalog.js';
4
4
  export const ALLOWED_REASONING_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh']);
5
5
  const CLAUDE_REASONING_EFFORTS = new Set(['low', 'medium', 'high']);
6
6
  function getAgentForModel(model) {
7
+ if (model === 'forge') {
8
+ return 'forge';
9
+ }
7
10
  if (model.startsWith('gpt-')) {
8
11
  return 'codex';
9
12
  }
@@ -38,6 +41,9 @@ export function getReasoningEffort(model, rawValue) {
38
41
  throw new Error(`Invalid reasoning_effort: ${rawValue}. Allowed values: low, medium, high, xhigh.`);
39
42
  }
40
43
  const agent = getAgentForModel(model);
44
+ if (agent === 'forge') {
45
+ throw new Error('reasoning_effort is not supported for forge.');
46
+ }
41
47
  if (agent === 'gemini') {
42
48
  throw new Error('reasoning_effort is only supported for Claude and Codex models.');
43
49
  }
@@ -134,6 +140,14 @@ export function buildCliCommand(options) {
134
140
  }
135
141
  args.push(prompt);
136
142
  }
143
+ else if (agent === 'forge') {
144
+ cliPath = options.cliPaths.forge;
145
+ args = ['-C', cwd];
146
+ if (options.session_id && typeof options.session_id === 'string') {
147
+ args.push('--conversation-id', options.session_id);
148
+ }
149
+ args.push('-p', prompt);
150
+ }
137
151
  else {
138
152
  cliPath = options.cliPaths.claude;
139
153
  args = ['--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose'];
package/dist/cli-parse.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { parseClaudeOutput, parseCodexOutput, parseGeminiOutput } from './parsers.js';
3
- const AGENTS = ['claude', 'codex', 'gemini'];
4
- const USAGE = `Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini>
2
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
3
+ const AGENTS = ['claude', 'codex', 'gemini', 'forge'];
4
+ const USAGE = `Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini|forge>
5
5
 
6
6
  Reads raw CLI output from stdin and outputs parsed JSON to stdout.
7
7
 
8
8
  Options:
9
- --agent Agent type: claude, codex, or gemini (required)
9
+ --agent Agent type: claude, codex, gemini, or forge (required)
10
10
  --help Show this help message
11
11
 
12
12
  Examples:
@@ -56,7 +56,7 @@ async function main() {
56
56
  }
57
57
  const agent = args.agent;
58
58
  if (!agent || !AGENTS.includes(agent)) {
59
- process.stderr.write(`Error: --agent is required (claude, codex, or gemini)\n\n`);
59
+ process.stderr.write(`Error: --agent is required (claude, codex, gemini, or forge)\n\n`);
60
60
  process.stderr.write(USAGE);
61
61
  process.exit(1);
62
62
  }
@@ -76,6 +76,9 @@ async function main() {
76
76
  case 'gemini':
77
77
  parsed = parseGeminiOutput(input);
78
78
  break;
79
+ case 'forge':
80
+ parsed = parseForgeOutput(input);
81
+ break;
79
82
  }
80
83
  process.stdout.write(JSON.stringify(parsed, null, 2) + '\n');
81
84
  }
@@ -3,8 +3,8 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync,
3
3
  import { join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { buildCliCommand } from './cli-builder.js';
6
- import { findClaudeCli, findCodexCli, findGeminiCli } from './cli-utils.js';
7
- import { parseClaudeOutput, parseCodexOutput, parseGeminiOutput } from './parsers.js';
6
+ import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
7
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
8
8
  function resolveDefaultStateDir() {
9
9
  return process.env.AI_CLI_STATE_DIR || join(homedir(), '.local', 'state', 'ai-cli');
10
10
  }
@@ -35,6 +35,7 @@ export class CliProcessService {
35
35
  claude: findClaudeCli(),
36
36
  codex: findCodexCli(),
37
37
  gemini: findGeminiCli(),
38
+ forge: findForgeCli(),
38
39
  };
39
40
  mkdirSync(this.stateDir, { recursive: true });
40
41
  }
@@ -127,6 +128,9 @@ export class CliProcessService {
127
128
  else if (refreshed.toolType === 'gemini') {
128
129
  agentOutput = parseGeminiOutput(stdout);
129
130
  }
131
+ else if (refreshed.toolType === 'forge') {
132
+ agentOutput = parseForgeOutput(stdout);
133
+ }
130
134
  }
131
135
  const response = {
132
136
  pid,
package/dist/cli-utils.js CHANGED
@@ -128,6 +128,14 @@ function getCliBinaryConfig(name) {
128
128
  localInstallPath: join(homedir(), '.codex', 'local', 'codex'),
129
129
  };
130
130
  }
131
+ if (name === 'forge') {
132
+ return {
133
+ envVarName: 'FORGE_CLI_NAME',
134
+ customCliName: process.env.FORGE_CLI_NAME,
135
+ defaultCliName: 'forge',
136
+ localInstallPath: join(homedir(), '.forge', 'local', 'forge'),
137
+ };
138
+ }
131
139
  return {
132
140
  envVarName: 'GEMINI_CLI_NAME',
133
141
  customCliName: process.env.GEMINI_CLI_NAME,
@@ -143,6 +151,7 @@ export function getCliDoctorStatus() {
143
151
  claude: getCliBinaryStatus('claude'),
144
152
  codex: getCliBinaryStatus('codex'),
145
153
  gemini: getCliBinaryStatus('gemini'),
154
+ forge: getCliBinaryStatus('forge'),
146
155
  };
147
156
  }
148
157
  /**
@@ -163,6 +172,14 @@ export function findCodexCli() {
163
172
  const status = getCliBinaryStatus('codex');
164
173
  return getCliCommandOrThrow(status);
165
174
  }
175
+ /**
176
+ * Determine the Forge CLI command/path.
177
+ */
178
+ export function findForgeCli() {
179
+ debugLog('[Debug] Attempting to find Forge CLI...');
180
+ const status = getCliBinaryStatus('forge');
181
+ return getCliCommandOrThrow(status);
182
+ }
166
183
  /**
167
184
  * Determine the Claude CLI command/path.
168
185
  * 1. Checks for CLAUDE_CLI_NAME environment variable:
package/dist/cli.js 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
  * Minimal argv parser. No external dependencies.
7
7
  * Supports: --key value, --key=value
@@ -35,12 +35,12 @@ function parseArgs(argv) {
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:
@@ -68,6 +68,7 @@ async function main() {
68
68
  claude: findClaudeCli(),
69
69
  codex: findCodexCli(),
70
70
  gemini: findGeminiCli(),
71
+ forge: findForgeCli(),
71
72
  };
72
73
  // Build command
73
74
  let cmd;
@@ -19,6 +19,7 @@ export const GEMINI_MODELS = [
19
19
  'gemini-3-pro-preview',
20
20
  'gemini-3-flash-preview',
21
21
  ];
22
+ export const FORGE_MODELS = ['forge'];
22
23
  export const MODEL_ALIASES = {
23
24
  'claude-ultra': 'opus',
24
25
  'codex-ultra': 'gpt-5.4',
@@ -35,10 +36,11 @@ export function getSupportedModelsDescription() {
35
36
  ...CLAUDE_MODELS.map((model) => `"${model}"`),
36
37
  ...CODEX_MODELS.map((model) => `"${model}"`),
37
38
  ...GEMINI_MODELS.map((model) => `"${model}"`),
39
+ ...FORGE_MODELS.map((model) => `"${model}"`),
38
40
  ].join(', ');
39
41
  }
40
42
  export function getModelParameterDescription() {
41
- 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(', ')}.`;
43
+ 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.`;
42
44
  }
43
45
  export function getModelsPayload() {
44
46
  return {
@@ -46,5 +48,6 @@ export function getModelsPayload() {
46
48
  claude: CLAUDE_MODELS,
47
49
  codex: CODEX_MODELS,
48
50
  gemini: GEMINI_MODELS,
51
+ forge: FORGE_MODELS,
49
52
  };
50
53
  }
package/dist/parsers.js CHANGED
@@ -163,3 +163,58 @@ export function parseGeminiOutput(stdout) {
163
163
  return null;
164
164
  }
165
165
  }
166
+ /**
167
+ * Parse Forge output framed by Initialize/Continue/Finished markers.
168
+ */
169
+ export function parseForgeOutput(stdout) {
170
+ if (!stdout)
171
+ return null;
172
+ const lines = stdout.split('\n');
173
+ const markerPattern = /^● \[[^\]]+\] (Initialize|Continue|Finished) (\S+)\s*$/;
174
+ let collecting = false;
175
+ let currentConversationId = null;
176
+ let currentBody = [];
177
+ let lastConversationId = null;
178
+ let lastMessage = null;
179
+ for (const line of lines) {
180
+ const match = line.match(markerPattern);
181
+ if (match) {
182
+ const [, action, conversationId] = match;
183
+ lastConversationId = conversationId;
184
+ if (action === 'Initialize' || action === 'Continue') {
185
+ collecting = true;
186
+ currentConversationId = conversationId;
187
+ currentBody = [];
188
+ }
189
+ else if (collecting && currentConversationId === conversationId) {
190
+ const message = currentBody.join('\n').trim();
191
+ if (message) {
192
+ lastMessage = message;
193
+ }
194
+ collecting = false;
195
+ currentConversationId = null;
196
+ currentBody = [];
197
+ }
198
+ continue;
199
+ }
200
+ if (collecting) {
201
+ currentBody.push(line);
202
+ }
203
+ }
204
+ if (collecting) {
205
+ const message = currentBody.join('\n').trim();
206
+ if (message) {
207
+ lastMessage = message;
208
+ }
209
+ if (currentConversationId) {
210
+ lastConversationId = currentConversationId;
211
+ }
212
+ }
213
+ if (!lastMessage && !lastConversationId) {
214
+ return null;
215
+ }
216
+ return {
217
+ message: lastMessage,
218
+ session_id: lastConversationId,
219
+ };
220
+ }
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { buildCliCommand } from './cli-builder.js';
3
- import { parseClaudeOutput, parseCodexOutput, parseGeminiOutput } from './parsers.js';
3
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
4
4
  export class ProcessService {
5
5
  processManager = new Map();
6
6
  cliPaths;
@@ -96,6 +96,9 @@ export class ProcessService {
96
96
  else if (process.toolType === 'gemini') {
97
97
  agentOutput = parseGeminiOutput(process.stdout);
98
98
  }
99
+ else if (process.toolType === 'forge') {
100
+ agentOutput = parseForgeOutput(process.stdout);
101
+ }
99
102
  }
100
103
  const response = {
101
104
  pid,
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- export { debugLog, findClaudeCli, findCodexCli, findGeminiCli } from './cli-utils.js';
2
+ export { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
3
3
  export { resolveModelAlias } from './cli-builder.js';
4
4
  export { ClaudeCodeServer, runMcpServer, spawnAsync } from './app/mcp.js';
5
5
  import { runMcpServer } from './app/mcp.js';
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "ai-cli-mcp",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "mcpName": "io.github.mkXultra/ai-cli-mcp",
5
- "description": "MCP server for AI CLI tools (Claude, Codex, and Gemini) with background process management",
5
+ "description": "MCP server for AI CLI tools (Claude, Codex, Gemini, and Forge) with background process management",
6
6
  "author": "mkXultra",
7
7
  "license": "MIT",
8
8
  "main": "dist/server.js",
package/server.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.mkXultra/ai-cli-mcp",
4
- "description": "MCP server for AI CLI tools (Claude, Codex, and Gemini) with background process management",
4
+ "description": "MCP server for AI CLI tools (Claude, Codex, Gemini, and Forge) with background process management",
5
5
  "repository": {
6
6
  "url": "https://github.com/mkXultra/ai-cli-mcp",
7
7
  "source": "github"
@@ -222,6 +222,7 @@ describe('ai-cli app', () => {
222
222
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"aliases"'));
223
223
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"claude-ultra"'));
224
224
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"gpt-5.4"'));
225
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"forge"'));
225
226
  expect(stderr).not.toHaveBeenCalled();
226
227
  });
227
228
 
@@ -247,6 +248,12 @@ describe('ai-cli app', () => {
247
248
  available: true,
248
249
  lookup: 'path',
249
250
  },
251
+ forge: {
252
+ configuredCommand: 'forge',
253
+ resolvedPath: '/tmp/bin/forge',
254
+ available: true,
255
+ lookup: 'path',
256
+ },
250
257
  });
251
258
 
252
259
  const exitCode = await runCli(['doctor'], { stdout, stderr, getDoctorStatus });
@@ -280,6 +287,7 @@ describe('ai-cli app', () => {
280
287
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('claude-ultra'));
281
288
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gpt-5.2-codex'));
282
289
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gemini-2.5-pro'));
290
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('forge'));
283
291
  expect(stderr).not.toHaveBeenCalled();
284
292
  });
285
293
 
@@ -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
 
@@ -114,6 +115,7 @@ describe('CliProcessService', () => {
114
115
  claude: scriptPath,
115
116
  codex: scriptPath,
116
117
  gemini: scriptPath,
118
+ forge: scriptPath,
117
119
  },
118
120
  });
119
121
 
@@ -152,6 +154,7 @@ describe('CliProcessService', () => {
152
154
  claude: '/bin/sh',
153
155
  codex: '/bin/sh',
154
156
  gemini: '/bin/sh',
157
+ forge: '/bin/sh',
155
158
  },
156
159
  });
157
160
 
@@ -254,6 +257,7 @@ describe('CliProcessService', () => {
254
257
  claude: '/bin/sh',
255
258
  codex: '/bin/sh',
256
259
  gemini: '/bin/sh',
260
+ forge: '/bin/sh',
257
261
  },
258
262
  });
259
263
 
@@ -275,4 +279,56 @@ describe('CliProcessService', () => {
275
279
  expect(existsSync(failedDir)).toBe(false);
276
280
  killSpy.mockRestore();
277
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
+ });
278
334
  });