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
@@ -43,22 +43,47 @@ jobs:
43
43
  - name: Verify provenance attestations
44
44
  run: npm audit signatures
45
45
 
46
+ - name: Capture version before release
47
+ id: release_before
48
+ run: |
49
+ VERSION=$(node -p 'require("./package.json").version')
50
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
51
+
46
52
  - name: Release
47
53
  env:
48
54
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49
55
  run: npx semantic-release
50
56
 
57
+ - name: Capture version after release
58
+ id: release_after
59
+ run: |
60
+ VERSION=$(node -p 'require("./package.json").version')
61
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
62
+
63
+ - name: Detect whether a release was published
64
+ id: release_status
65
+ run: |
66
+ if [ "${{ steps.release_before.outputs.version }}" != "${{ steps.release_after.outputs.version }}" ]; then
67
+ echo "published=true" >> "$GITHUB_OUTPUT"
68
+ else
69
+ echo "published=false" >> "$GITHUB_OUTPUT"
70
+ fi
71
+
51
72
  - name: Install mcp-publisher
73
+ if: steps.release_status.outputs.published == 'true'
52
74
  run: |
53
75
  curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
54
76
 
55
77
  - name: Authenticate to MCP Registry
78
+ if: steps.release_status.outputs.published == 'true'
56
79
  run: ./mcp-publisher login github-oidc
57
80
 
58
81
  - name: Set version in server.json
82
+ if: steps.release_status.outputs.published == 'true'
59
83
  run: |
60
84
  VERSION=$(node -p "require('./package.json').version")
61
85
  jq --arg v "$VERSION" '.version = $v | .packages[0].version = $v' server.json > server.tmp && mv server.tmp server.json
62
86
 
63
87
  - name: Publish to MCP Registry
88
+ if: steps.release_status.outputs.published == 'true'
64
89
  run: ./mcp-publisher publish
package/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ # [2.13.0](https://github.com/mkXultra/ai-cli-mcp/compare/v2.12.0...v2.13.0) (2026-04-07)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **ci:** correct release version capture shell quoting ([2986b0b](https://github.com/mkXultra/ai-cli-mcp/commit/2986b0bb0452fcf4b34491c3d832cff3b271616c))
7
+ * **ci:** skip MCP Registry publish when no release is created ([9de4c83](https://github.com/mkXultra/ai-cli-mcp/commit/9de4c836a946b4a0028a46525b4bcfa2094032cc))
8
+
9
+
10
+ ### Features
11
+
12
+ * Forge CLIを新しいAIバックエンドとして追加 ([35ae860](https://github.com/mkXultra/ai-cli-mcp/commit/35ae8604ff0a663e960fc758eee2872680aec503))
13
+
1
14
  # [2.12.0](https://github.com/mkXultra/ai-cli-mcp/compare/v2.11.0...v2.12.0) (2026-03-12)
2
15
 
3
16
 
package/README.ja.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  > **📦 パッケージ移行のお知らせ**: 本パッケージは旧名 `@mkxultra/claude-code-mcp` から `ai-cli-mcp` に名称変更されました。これは、複数のAI CLIツールのサポート拡大を反映したものです。
7
7
 
8
- AI CLIツール(Claude, Codex, Gemini)をバックグラウンドプロセスとして実行し、権限処理を自動化するMCP(Model Context Protocol)サーバーです。
8
+ AI CLIツール(Claude, Codex, Gemini, Forge)をバックグラウンドプロセスとして実行し、権限処理を自動化するMCP(Model Context Protocol)サーバーです。
9
9
 
10
10
  Cursorなどのエディタが、複雑な手順を伴う編集や操作に苦戦していることに気づいたことはありませんか?このサーバーは、強力な統合 `run` ツールを提供し、複数のAIエージェントを活用してコーディングタスクをより効果的に処理できるようにします。
11
11
 
@@ -20,10 +20,12 @@ Cursorなどのエディタが、複雑な手順を伴う編集や操作に苦
20
20
  - すべての権限確認をスキップしてClaude CLIを実行(`--dangerously-skip-permissions` を使用)
21
21
  - 自動承認モードでCodex CLIを実行(`--full-auto` を使用)
22
22
  - 自動承認モードでGemini CLIを実行(`-y` を使用)
23
+ - Forge CLI を非対話モードで実行(`forge -C <workFolder> -p <prompt>` を使用)
23
24
  - 複数のAIモデルのサポート:
24
25
  - Claude (sonnet, sonnet[1m], opus, opusplan, haiku)
25
26
  - Codex (gpt-5.4, gpt-5.3-codex, gpt-5.2-codex, gpt-5.1-codex-mini, gpt-5.1-codex-max, など)
26
27
  - Gemini (gemini-2.5-pro, gemini-2.5-flash, gemini-3.1-pro-preview, gemini-3-pro-preview, gemini-3-flash-preview)
28
+ - Forge (`forge`)
27
29
  - PID追跡によるバックグラウンドプロセスの管理
28
30
  - ツールからの構造化された出力の解析と返却
29
31
 
@@ -55,7 +57,7 @@ Cursorなどのエディタが、複雑な手順を伴う編集や操作に苦
55
57
 
56
58
  - **真の非同期マルチタスク**: エージェントの実行はバックグラウンドで行われ、即座に制御が戻ります。呼び出し元のAIは実行完了を待つことなく、並行して次のタスクの実行や別のエージェントの呼び出しを行うことができます。
57
59
  - **CLI in CLI (Agent in Agent) の実現**: MCPをサポートするあらゆるIDEやCLIから、Claude CodeやCodexといった強力なCLIツールを直接呼び出せます。ホスト環境の制限を超えた、より広範で複雑なシステム操作や自動化が可能になります。
58
- - **モデル・プロバイダの制約からの解放**: 特定のエコシステムに縛られることなく、Claude、Codex (GPT)、Geminiの中から、タスクに最適な「最強のモデル」や「コスト効率の良いモデル」を自由に選択・組み合わせて利用できます。
60
+ - **モデル・プロバイダの制約からの解放**: 特定のエコシステムに縛られることなく、Claude、Codex (GPT)、Gemini、Forgeの中から、タスクに最適な「最強のモデル」や「コスト効率の良いモデル」を自由に選択・組み合わせて利用できます。
59
61
 
60
62
  ## 前提条件
61
63
 
@@ -64,6 +66,7 @@ Cursorなどのエディタが、複雑な手順を伴う編集や操作に苦
64
66
  - **Claude Code**: `claude doctor` が通り、`--dangerously-skip-permissions` での実行が承認済み(一度手動で実行してログイン・承認済み)であること。
65
67
  - **Codex CLI**(オプション): インストール済みで、ログインなどの初期設定が完了していること。
66
68
  - **Gemini CLI**(オプション): インストール済みで、ログインなどの初期設定が完了していること。
69
+ - **Forge CLI**(オプション): インストール済みで、初期設定が完了していること。
67
70
 
68
71
  ## インストールと使い方
69
72
 
@@ -222,7 +225,7 @@ detached 実行された `ai-cli` の自然終了 exit code は、まだ永続
222
225
 
223
226
  ### `run`
224
227
 
225
- Claude CLI、Codex CLI、またはGemini CLIを使用してプロンプトを実行します。モデル名に基づいて適切なCLIが自動的に選択されます。
228
+ Claude CLI、Codex CLIGemini CLI、または Forge CLI を使用してプロンプトを実行します。モデル名に基づいて適切なCLIが自動的に選択されます。
226
229
 
227
230
  **引数:**
228
231
  - `prompt` (string, 任意): AIエージェントに送信するプロンプト。`prompt` または `prompt_file` のいずれかが必須です。
@@ -233,8 +236,9 @@ Claude CLI、Codex CLI、またはGemini CLIを使用してプロンプトを実
233
236
  - Claude: `sonnet`, `sonnet[1m]`, `opus`, `opusplan`, `haiku`
234
237
  - Codex: `gpt-5.4`, `gpt-5.3-codex`, `gpt-5.2-codex`, `gpt-5.1-codex-mini`, `gpt-5.1-codex-max`, `gpt-5.2`, `gpt-5.1`, `gpt-5`
235
238
  - Gemini: `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-3.1-pro-preview`, `gemini-3-pro-preview`, `gemini-3-flash-preview`
236
- - `reasoning_effort` (string, 任意): Claude と Codex の推論制御。Claude では `--effort` を使います(許容値: "low", "medium", "high")。Codex では `model_reasoning_effort` を使います(許容値: "low", "medium", "high", "xhigh")。
237
- - `session_id` (string, 任意): 以前のセッションを再開するためのセッションID。対応モデル: haiku, sonnet, opus, gemini-2.5-pro, gemini-2.5-flash, gemini-3.1-pro-preview, gemini-3-pro-preview, gemini-3-flash-preview。
239
+ - Forge: `forge`
240
+ - `reasoning_effort` (string, 任意): Claude Codex の推論制御。Claude では `--effort` を使います(許容値: "low", "medium", "high")。Codex では `model_reasoning_effort` を使います(許容値: "low", "medium", "high", "xhigh")。Forge では `reasoning_effort` はサポートしません。
241
+ - `session_id` (string, 任意): 以前のセッションを再開するためのセッションID。対応モデル: 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。
238
242
 
239
243
  ### `wait`
240
244
 
@@ -296,6 +300,7 @@ npm run test:e2e
296
300
  - `CLAUDE_CLI_NAME`: Claude CLIのバイナリ名または絶対パスを上書き(デフォルト: `claude`)
297
301
  - `CODEX_CLI_NAME`: Codex CLIのバイナリ名または絶対パスを上書き(デフォルト: `codex`)
298
302
  - `GEMINI_CLI_NAME`: Gemini CLIのバイナリ名または絶対パスを上書き(デフォルト: `gemini`)
303
+ - `FORGE_CLI_NAME`: Forge CLIのバイナリ名または絶対パスを上書き(デフォルト: `forge`)
299
304
  - `MCP_CLAUDE_DEBUG`: デバッグログを有効化(`true` に設定すると詳細な出力が表示されます)
300
305
 
301
306
  **CLI名の指定方法:**
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  > **📦 Package Migration Notice**: This package was formerly `@mkxultra/claude-code-mcp` and has been renamed to `ai-cli-mcp` to reflect its expanded support for multiple AI CLI tools.
9
9
 
10
- An MCP (Model Context Protocol) server that allows running AI CLI tools (Claude, Codex, and Gemini) in background processes with automatic permission handling.
10
+ An MCP (Model Context Protocol) server that allows running AI CLI tools (Claude, Codex, Gemini, and Forge) in background processes with automatic permission handling.
11
11
 
12
12
  Did you notice that Cursor sometimes struggles with complex, multi-step edits or operations? This server, with its powerful unified `run` tool, enables multiple AI agents to handle your coding tasks more effectively.
13
13
 
@@ -22,7 +22,8 @@ This MCP server provides tools that can be used by LLMs to interact with AI CLI
22
22
  - Run Claude CLI with all permissions bypassed (using `--dangerously-skip-permissions`)
23
23
  - Execute Codex CLI with automatic approval mode (using `--full-auto`)
24
24
  - Execute Gemini CLI with automatic approval mode (using `-y`)
25
- - Support multiple AI models: Claude (sonnet, sonnet[1m], opus, opusplan, haiku), Codex (gpt-5.4, gpt-5.3-codex, gpt-5.2-codex, gpt-5.1-codex-mini, gpt-5.1-codex-max, gpt-5.2, gpt-5.1, gpt-5.1-codex, gpt-5-codex, gpt-5-codex-mini, gpt-5), and Gemini (gemini-2.5-pro, gemini-2.5-flash, gemini-3.1-pro-preview, gemini-3-pro-preview, gemini-3-flash-preview)
25
+ - Execute Forge CLI in non-interactive mode (using `forge -C <workFolder> -p <prompt>`)
26
+ - Support multiple AI models: Claude (sonnet, sonnet[1m], opus, opusplan, haiku), Codex (gpt-5.4, gpt-5.3-codex, gpt-5.2-codex, gpt-5.1-codex-mini, gpt-5.1-codex-max, gpt-5.2, gpt-5.1, gpt-5.1-codex, gpt-5-codex, gpt-5-codex-mini, gpt-5), Gemini (gemini-2.5-pro, gemini-2.5-flash, gemini-3.1-pro-preview, gemini-3-pro-preview, gemini-3-flash-preview), and Forge (`forge`)
26
27
  - Manage background processes with PID tracking
27
28
  - Parse and return structured outputs from both tools
28
29
 
@@ -54,7 +55,7 @@ You can reuse heavy context (like large codebases) using session IDs to save cos
54
55
 
55
56
  - **True Async Multitasking**: Agent execution happens in the background, returning control immediately. The calling AI can proceed with the next task or invoke another agent without waiting for completion.
56
57
  - **CLI in CLI (Agent in Agent)**: Directly invoke powerful CLI tools like Claude Code or Codex from any MCP-supported IDE or CLI. This enables broader, more complex system operations and automation beyond host environment limitations.
57
- - **Freedom from Model/Provider Constraints**: Freely select and combine the "strongest" or "most cost-effective" models from Claude, Codex (GPT), and Gemini without being tied to a specific ecosystem.
58
+ - **Freedom from Model/Provider Constraints**: Freely select and combine the "strongest" or "most cost-effective" models from Claude, Codex (GPT), Gemini, and Forge without being tied to a specific ecosystem.
58
59
 
59
60
  ## Prerequisites
60
61
 
@@ -63,6 +64,7 @@ The only prerequisite is that the AI CLI tools you want to use are locally insta
63
64
  - **Claude Code**: `claude doctor` passes, and execution with `--dangerously-skip-permissions` is approved (you must run it manually once to login and accept terms).
64
65
  - **Codex CLI** (Optional): Installed and initial setup (login etc.) completed.
65
66
  - **Gemini CLI** (Optional): Installed and initial setup (login etc.) completed.
67
+ - **Forge CLI** (Optional): Installed and initial setup completed.
66
68
 
67
69
  ## Installation & Usage
68
70
 
@@ -221,7 +223,7 @@ This server exposes the following tools:
221
223
 
222
224
  ### `run`
223
225
 
224
- Executes a prompt using Claude CLI, Codex CLI, or Gemini CLI. The appropriate CLI is automatically selected based on the model name.
226
+ Executes a prompt using Claude CLI, Codex CLI, Gemini CLI, or Forge CLI. The appropriate CLI is automatically selected based on the model name.
225
227
 
226
228
  **Arguments:**
227
229
  - `prompt` (string, optional): The prompt to send to the AI agent. Either `prompt` or `prompt_file` is required.
@@ -232,8 +234,9 @@ Executes a prompt using Claude CLI, Codex CLI, or Gemini CLI. The appropriate CL
232
234
  - Claude: `sonnet`, `sonnet[1m]`, `opus`, `opusplan`, `haiku`
233
235
  - Codex: `gpt-5.4`, `gpt-5.3-codex`, `gpt-5.2-codex`, `gpt-5.1-codex-mini`, `gpt-5.1-codex-max`, `gpt-5.2`, `gpt-5.1`, `gpt-5`
234
236
  - Gemini: `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-3.1-pro-preview`, `gemini-3-pro-preview`, `gemini-3-flash-preview`
235
- - `reasoning_effort` (string, optional): Reasoning control for Claude and Codex. Claude uses `--effort` (allowed: "low", "medium", "high"). Codex uses `model_reasoning_effort` (allowed: "low", "medium", "high", "xhigh").
236
- - `session_id` (string, optional): 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.
237
+ - Forge: `forge`
238
+ - `reasoning_effort` (string, optional): Reasoning control for Claude and Codex. Claude uses `--effort` (allowed: "low", "medium", "high"). Codex uses `model_reasoning_effort` (allowed: "low", "medium", "high", "xhigh"). Forge does not support `reasoning_effort`.
239
+ - `session_id` (string, optional): 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.
237
240
 
238
241
  ### `wait`
239
242
 
@@ -281,6 +284,7 @@ Normally not required, but useful for customizing CLI paths or debugging.
281
284
  - `CLAUDE_CLI_NAME`: Override the Claude CLI binary name or provide an absolute path (default: `claude`)
282
285
  - `CODEX_CLI_NAME`: Override the Codex CLI binary name or provide an absolute path (default: `codex`)
283
286
  - `GEMINI_CLI_NAME`: Override the Gemini CLI binary name or provide an absolute path (default: `gemini`)
287
+ - `FORGE_CLI_NAME`: Override the Forge CLI binary name or provide an absolute path (default: `forge`)
284
288
  - `MCP_CLAUDE_DEBUG`: Enable debug logging (set to `true` for verbose output)
285
289
 
286
290
  **CLI Name Specification:**
@@ -172,6 +172,7 @@ describe('ai-cli app', () => {
172
172
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"aliases"'));
173
173
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"claude-ultra"'));
174
174
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"gpt-5.4"'));
175
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"forge"'));
175
176
  expect(stderr).not.toHaveBeenCalled();
176
177
  });
177
178
  it('prints doctor status as structured json', async () => {
@@ -196,6 +197,12 @@ describe('ai-cli app', () => {
196
197
  available: true,
197
198
  lookup: 'path',
198
199
  },
200
+ forge: {
201
+ configuredCommand: 'forge',
202
+ resolvedPath: '/tmp/bin/forge',
203
+ available: true,
204
+ lookup: 'path',
205
+ },
199
206
  });
200
207
  const exitCode = await runCli(['doctor'], { stdout, stderr, getDoctorStatus });
201
208
  expect(exitCode).toBe(0);
@@ -221,6 +228,7 @@ describe('ai-cli app', () => {
221
228
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('claude-ultra'));
222
229
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gpt-5.2-codex'));
223
230
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gemini-2.5-pro'));
231
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('forge'));
224
232
  expect(stderr).not.toHaveBeenCalled();
225
233
  });
226
234
  it('prints detailed help for wait --help', async () => {
@@ -25,6 +25,7 @@ describe('ai-cli entrypoint smoke', () => {
25
25
  writeExecutable(fakeBinDir, 'claude');
26
26
  writeExecutable(fakeBinDir, 'codex');
27
27
  writeExecutable(fakeBinDir, 'gemini');
28
+ writeExecutable(fakeBinDir, 'forge');
28
29
  const output = execFileSync('node', ['--import', 'tsx', 'src/bin/ai-cli.ts', 'doctor'], {
29
30
  cwd: process.cwd(),
30
31
  encoding: 'utf8',
@@ -34,11 +35,13 @@ describe('ai-cli entrypoint smoke', () => {
34
35
  CLAUDE_CLI_NAME: 'claude',
35
36
  CODEX_CLI_NAME: 'codex',
36
37
  GEMINI_CLI_NAME: 'gemini',
38
+ FORGE_CLI_NAME: 'forge',
37
39
  },
38
40
  });
39
41
  expect(output).toContain('"claude"');
40
42
  expect(output).toContain('"codex"');
41
43
  expect(output).toContain('"gemini"');
44
+ expect(output).toContain('"forge"');
42
45
  expect(output).toContain('"available": true');
43
46
  });
44
47
  it('prints run help for the ai-cli entrypoint', () => {
@@ -50,5 +53,6 @@ describe('ai-cli entrypoint smoke', () => {
50
53
  expect(output).toContain('Usage: ai-cli run --cwd <path> [options]');
51
54
  expect(output).toContain('--model <model>');
52
55
  expect(output).toContain('claude-ultra');
56
+ expect(output).toContain('forge');
53
57
  });
54
58
  });
@@ -14,6 +14,7 @@ const DEFAULT_CLI_PATHS = {
14
14
  claude: '/usr/bin/claude',
15
15
  codex: '/usr/bin/codex',
16
16
  gemini: '/usr/bin/gemini',
17
+ forge: '/usr/bin/forge',
17
18
  };
18
19
  describe('cli-builder', () => {
19
20
  beforeEach(() => {
@@ -70,6 +71,9 @@ describe('cli-builder', () => {
70
71
  it('should throw for unsupported model families', () => {
71
72
  expect(() => getReasoningEffort('gemini-2.5-pro', 'high')).toThrow('reasoning_effort is only supported for Claude and Codex models.');
72
73
  });
74
+ it('should reject reasoning_effort for forge explicitly', () => {
75
+ expect(() => getReasoningEffort('forge', 'high')).toThrow('reasoning_effort is not supported for forge.');
76
+ });
73
77
  });
74
78
  describe('buildCliCommand', () => {
75
79
  describe('validation', () => {
@@ -322,5 +326,38 @@ describe('cli-builder', () => {
322
326
  expect(cmd.resolvedModel).toBe('gemini-3.1-pro-preview');
323
327
  });
324
328
  });
329
+ describe('forge agent', () => {
330
+ it('should build forge command without model flags', () => {
331
+ const cmd = buildCliCommand({
332
+ prompt: 'test',
333
+ workFolder: '/tmp',
334
+ model: 'forge',
335
+ cliPaths: DEFAULT_CLI_PATHS,
336
+ });
337
+ expect(cmd.agent).toBe('forge');
338
+ expect(cmd.cliPath).toBe('/usr/bin/forge');
339
+ expect(cmd.resolvedModel).toBe('forge');
340
+ expect(cmd.args).toEqual(['-C', '/tmp', '-p', 'test']);
341
+ });
342
+ it('should map session_id to --conversation-id for forge', () => {
343
+ const cmd = buildCliCommand({
344
+ prompt: 'test',
345
+ workFolder: '/tmp',
346
+ model: 'forge',
347
+ session_id: 'forge-conv-123',
348
+ cliPaths: DEFAULT_CLI_PATHS,
349
+ });
350
+ expect(cmd.args).toEqual(['-C', '/tmp', '--conversation-id', 'forge-conv-123', '-p', 'test']);
351
+ });
352
+ it('should reject reasoning_effort for forge in command building', () => {
353
+ expect(() => buildCliCommand({
354
+ prompt: 'test',
355
+ workFolder: '/tmp',
356
+ model: 'forge',
357
+ reasoning_effort: 'high',
358
+ cliPaths: DEFAULT_CLI_PATHS,
359
+ })).toThrow('reasoning_effort is not supported for forge.');
360
+ });
361
+ });
325
362
  });
326
363
  });
@@ -56,6 +56,7 @@ describe('CliProcessService', () => {
56
56
  claude: scriptPath,
57
57
  codex: scriptPath,
58
58
  gemini: scriptPath,
59
+ forge: scriptPath,
59
60
  },
60
61
  });
61
62
  const runResult = await service.startProcess({
@@ -98,6 +99,7 @@ describe('CliProcessService', () => {
98
99
  claude: scriptPath,
99
100
  codex: scriptPath,
100
101
  gemini: scriptPath,
102
+ forge: scriptPath,
101
103
  },
102
104
  });
103
105
  const runResult = await service.startProcess({
@@ -130,6 +132,7 @@ describe('CliProcessService', () => {
130
132
  claude: '/bin/sh',
131
133
  codex: '/bin/sh',
132
134
  gemini: '/bin/sh',
135
+ forge: '/bin/sh',
133
136
  },
134
137
  });
135
138
  writeFileSync(join(processDir, 'meta.json'), JSON.stringify({
@@ -212,6 +215,7 @@ describe('CliProcessService', () => {
212
215
  claude: '/bin/sh',
213
216
  codex: '/bin/sh',
214
217
  gemini: '/bin/sh',
218
+ forge: '/bin/sh',
215
219
  },
216
220
  });
217
221
  const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target, signal) => {
@@ -230,4 +234,46 @@ describe('CliProcessService', () => {
230
234
  expect(existsSync(failedDir)).toBe(false);
231
235
  killSpy.mockRestore();
232
236
  });
237
+ it('parses forge output from detached process logs', async () => {
238
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
239
+ tempDirs.push(root);
240
+ const stateDir = join(root, 'state');
241
+ const workFolder = join(root, 'forge-project');
242
+ mkdirSync(workFolder, { recursive: true });
243
+ const pid = 54321;
244
+ const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(pid));
245
+ mkdirSync(processDir, { recursive: true });
246
+ writeFileSync(join(processDir, 'stdout.log'), `● [21:09:01] Initialize forge-conv-1
247
+ Forge assistant reply
248
+ ● [21:09:08] Finished forge-conv-1
249
+ `);
250
+ writeFileSync(join(processDir, 'stderr.log'), '');
251
+ writeFileSync(join(processDir, 'meta.json'), JSON.stringify({
252
+ pid,
253
+ prompt: 'hello forge',
254
+ workFolder,
255
+ model: 'forge',
256
+ toolType: 'forge',
257
+ startTime: new Date().toISOString(),
258
+ stdoutPath: join(processDir, 'stdout.log'),
259
+ stderrPath: join(processDir, 'stderr.log'),
260
+ status: 'completed',
261
+ }));
262
+ const service = new CliProcessService({
263
+ stateDir,
264
+ cliPaths: {
265
+ claude: '/bin/sh',
266
+ codex: '/bin/sh',
267
+ gemini: '/bin/sh',
268
+ forge: '/bin/sh',
269
+ },
270
+ });
271
+ const result = await service.getProcessResult(pid, false);
272
+ expect(result.agent).toBe('forge');
273
+ expect(result.session_id).toBe('forge-conv-1');
274
+ expect(result.agentOutput).toEqual({
275
+ message: 'Forge assistant reply',
276
+ session_id: 'forge-conv-1',
277
+ });
278
+ });
233
279
  });
@@ -15,6 +15,7 @@ describe('cli-utils doctor status', () => {
15
15
  delete process.env.CLAUDE_CLI_NAME;
16
16
  delete process.env.CODEX_CLI_NAME;
17
17
  delete process.env.GEMINI_CLI_NAME;
18
+ delete process.env.FORGE_CLI_NAME;
18
19
  process.env.PATH = '/mock/bin:/usr/bin';
19
20
  });
20
21
  afterEach(() => {
@@ -36,6 +37,12 @@ describe('cli-utils doctor status', () => {
36
37
  available: true,
37
38
  lookup: 'path',
38
39
  });
40
+ expect(status.forge).toEqual({
41
+ configuredCommand: 'forge',
42
+ resolvedPath: null,
43
+ available: false,
44
+ lookup: 'path',
45
+ });
39
46
  });
40
47
  it('does not mark non-executable PATH entries as available', async () => {
41
48
  mockAccessSync.mockImplementation(() => {
@@ -49,6 +56,12 @@ describe('cli-utils doctor status', () => {
49
56
  available: false,
50
57
  lookup: 'path',
51
58
  });
59
+ expect(status.forge).toEqual({
60
+ configuredCommand: 'forge',
61
+ resolvedPath: null,
62
+ available: false,
63
+ lookup: 'path',
64
+ });
52
65
  });
53
66
  it('reports invalid relative env paths as doctor errors', async () => {
54
67
  process.env.CLAUDE_CLI_NAME = './relative/claude';
@@ -106,4 +119,22 @@ describe('cli-utils doctor status', () => {
106
119
  lookup: 'env',
107
120
  });
108
121
  });
122
+ it('supports forge lookup via FORGE_CLI_NAME', async () => {
123
+ process.env.FORGE_CLI_NAME = 'forge-custom';
124
+ mockAccessSync.mockImplementation((filePath) => {
125
+ if (filePath === '/mock/bin/forge-custom') {
126
+ return undefined;
127
+ }
128
+ throw new Error('not executable');
129
+ });
130
+ const { getCliDoctorStatus, findForgeCli } = await import('../cli-utils.js');
131
+ const status = getCliDoctorStatus();
132
+ expect(status.forge).toEqual({
133
+ configuredCommand: 'forge-custom',
134
+ resolvedPath: '/mock/bin/forge-custom',
135
+ available: true,
136
+ lookup: 'env',
137
+ });
138
+ expect(findForgeCli()).toBe('forge-custom');
139
+ });
109
140
  });
@@ -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';
@@ -16,6 +16,49 @@ function expectProcessSummaryShape(processInfo) {
16
16
  status: expect.any(String),
17
17
  });
18
18
  }
19
+ function createForgeMockScript(dir, argsLogPath) {
20
+ const scriptPath = join(dir, 'mock-forge');
21
+ writeFileSync(scriptPath, `#!/bin/bash
22
+ set -euo pipefail
23
+
24
+ log_file="${argsLogPath}"
25
+ prompt=""
26
+ conversation_id=""
27
+
28
+ printf '%s\\n' "$*" >> "$log_file"
29
+
30
+ while [[ $# -gt 0 ]]; do
31
+ case "$1" in
32
+ -C)
33
+ shift 2
34
+ ;;
35
+ -p)
36
+ prompt="$2"
37
+ shift 2
38
+ ;;
39
+ --conversation-id)
40
+ conversation_id="$2"
41
+ shift 2
42
+ ;;
43
+ *)
44
+ shift
45
+ ;;
46
+ esac
47
+ done
48
+
49
+ if [[ -n "$conversation_id" ]]; then
50
+ printf '● [21:09:33] Continue %s\\n' "$conversation_id"
51
+ printf 'Resumed: %s\\n' "$prompt"
52
+ printf '● [21:09:37] Finished %s\\n' "$conversation_id"
53
+ else
54
+ printf '● [21:09:01] Initialize forge-session-1\\n'
55
+ printf 'Initial: %s\\n' "$prompt"
56
+ printf '● [21:09:08] Finished forge-session-1\\n'
57
+ fi
58
+ `);
59
+ chmodSync(scriptPath, 0o755);
60
+ return scriptPath;
61
+ }
19
62
  describe('MCP Contract Tests', () => {
20
63
  let client;
21
64
  let testDir;
@@ -129,6 +172,111 @@ describe('MCP Contract Tests', () => {
129
172
  message: expect.any(String),
130
173
  });
131
174
  });
175
+ it('covers forge end-to-end through the MCP process path', async () => {
176
+ await client.disconnect();
177
+ const forgeArgsLogPath = join(testDir, 'forge-args.log');
178
+ const forgeMockPath = createForgeMockScript(testDir, forgeArgsLogPath);
179
+ client = createTestClient({
180
+ debug: false,
181
+ env: {
182
+ FORGE_CLI_NAME: forgeMockPath,
183
+ },
184
+ });
185
+ await client.connect();
186
+ const initialRunResponse = await client.callTool('run', {
187
+ prompt: 'forge-initial-prompt',
188
+ workFolder: testDir,
189
+ model: 'forge',
190
+ });
191
+ const initialRunData = parseToolJson(initialRunResponse);
192
+ expect(initialRunData).toEqual({
193
+ pid: expect.any(Number),
194
+ status: 'started',
195
+ agent: 'forge',
196
+ message: expect.any(String),
197
+ });
198
+ const initialWaitResponse = await client.callTool('wait', { pids: [initialRunData.pid], timeout: 5 });
199
+ const initialWaitData = parseToolJson(initialWaitResponse);
200
+ expect(initialWaitData).toHaveLength(1);
201
+ expect(initialWaitData[0]).toMatchObject({
202
+ pid: initialRunData.pid,
203
+ agent: 'forge',
204
+ status: 'completed',
205
+ session_id: 'forge-session-1',
206
+ agentOutput: {
207
+ message: 'Initial: forge-initial-prompt',
208
+ session_id: 'forge-session-1',
209
+ },
210
+ });
211
+ const initialResultResponse = await client.callTool('get_result', { pid: initialRunData.pid });
212
+ const initialResultData = parseToolJson(initialResultResponse);
213
+ expect(initialResultData).toMatchObject({
214
+ pid: initialRunData.pid,
215
+ agent: 'forge',
216
+ status: 'completed',
217
+ session_id: 'forge-session-1',
218
+ agentOutput: {
219
+ message: 'Initial: forge-initial-prompt',
220
+ session_id: 'forge-session-1',
221
+ },
222
+ });
223
+ const resumedRunResponse = await client.callTool('run', {
224
+ prompt: 'forge-resume-prompt',
225
+ workFolder: testDir,
226
+ model: 'forge',
227
+ session_id: 'forge-session-1',
228
+ });
229
+ const resumedRunData = parseToolJson(resumedRunResponse);
230
+ expect(resumedRunData).toEqual({
231
+ pid: expect.any(Number),
232
+ status: 'started',
233
+ agent: 'forge',
234
+ message: expect.any(String),
235
+ });
236
+ const resumedWaitResponse = await client.callTool('wait', { pids: [resumedRunData.pid], timeout: 5 });
237
+ const resumedWaitData = parseToolJson(resumedWaitResponse);
238
+ expect(resumedWaitData).toHaveLength(1);
239
+ expect(resumedWaitData[0]).toMatchObject({
240
+ pid: resumedRunData.pid,
241
+ agent: 'forge',
242
+ status: 'completed',
243
+ session_id: 'forge-session-1',
244
+ agentOutput: {
245
+ message: 'Resumed: forge-resume-prompt',
246
+ session_id: 'forge-session-1',
247
+ },
248
+ });
249
+ const resumedResultResponse = await client.callTool('get_result', { pid: resumedRunData.pid });
250
+ const resumedResultData = parseToolJson(resumedResultResponse);
251
+ expect(resumedResultData).toMatchObject({
252
+ pid: resumedRunData.pid,
253
+ agent: 'forge',
254
+ status: 'completed',
255
+ session_id: 'forge-session-1',
256
+ agentOutput: {
257
+ message: 'Resumed: forge-resume-prompt',
258
+ session_id: 'forge-session-1',
259
+ },
260
+ });
261
+ const forgeInvocations = readFileSync(forgeArgsLogPath, 'utf-8').trim().split('\n');
262
+ expect(forgeInvocations).toHaveLength(2);
263
+ expect(forgeInvocations[0]).toContain(`-C ${testDir}`);
264
+ expect(forgeInvocations[0]).toContain('-p forge-initial-prompt');
265
+ expect(forgeInvocations[0]).not.toContain('--model');
266
+ expect(forgeInvocations[0]).not.toContain('--agent');
267
+ expect(forgeInvocations[0]).not.toContain('--conversation-id');
268
+ expect(forgeInvocations[1]).toContain(`-C ${testDir}`);
269
+ expect(forgeInvocations[1]).toContain('--conversation-id forge-session-1');
270
+ expect(forgeInvocations[1]).toContain('-p forge-resume-prompt');
271
+ expect(forgeInvocations[1]).not.toContain('--model');
272
+ expect(forgeInvocations[1]).not.toContain('--agent');
273
+ await expect(client.callTool('run', {
274
+ prompt: 'forge-invalid-reasoning',
275
+ workFolder: testDir,
276
+ model: 'forge',
277
+ reasoning_effort: 'high',
278
+ })).rejects.toThrow(/reasoning_effort is not supported for forge/i);
279
+ });
132
280
  it('keeps key invalid-input errors stable', async () => {
133
281
  await expect(client.callTool('run', {
134
282
  prompt: 'missing workFolder',