ai-cli-mcp 2.11.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 +23 -0
- package/README.ja.md +112 -8
- package/README.md +112 -9
- package/dist/__tests__/app-cli.test.js +293 -0
- package/dist/__tests__/cli-bin-smoke.test.js +58 -0
- package/dist/__tests__/cli-builder.test.js +37 -0
- package/dist/__tests__/cli-process-service.test.js +279 -0
- package/dist/__tests__/cli-utils.test.js +140 -0
- package/dist/__tests__/error-cases.test.js +2 -1
- package/dist/__tests__/mcp-contract.test.js +343 -0
- package/dist/__tests__/parsers.test.js +37 -1
- package/dist/__tests__/process-management.test.js +15 -8
- package/dist/__tests__/server.test.js +29 -3
- package/dist/__tests__/wait.test.js +31 -0
- package/dist/app/cli.js +304 -0
- package/dist/app/mcp.js +366 -0
- package/dist/bin/ai-cli-mcp.js +6 -0
- package/dist/bin/ai-cli.js +10 -0
- package/dist/cli-builder.js +15 -6
- package/dist/cli-parse.js +8 -5
- package/dist/cli-process-service.js +332 -0
- package/dist/cli-utils.js +159 -88
- package/dist/cli.js +4 -3
- package/dist/model-catalog.js +53 -0
- package/dist/parsers.js +55 -0
- package/dist/process-service.js +201 -0
- package/dist/server.js +4 -578
- package/docs/cli-architecture.md +275 -0
- package/package.json +4 -3
- package/server.json +1 -1
- package/src/__tests__/app-cli.test.ts +370 -0
- package/src/__tests__/cli-bin-smoke.test.ts +75 -0
- package/src/__tests__/cli-builder.test.ts +47 -0
- package/src/__tests__/cli-process-service.test.ts +334 -0
- package/src/__tests__/cli-utils.test.ts +166 -0
- package/src/__tests__/error-cases.test.ts +3 -4
- package/src/__tests__/mcp-contract.test.ts +422 -0
- package/src/__tests__/parsers.test.ts +44 -1
- package/src/__tests__/process-management.test.ts +15 -9
- package/src/__tests__/server.test.ts +27 -6
- package/src/__tests__/wait.test.ts +38 -0
- package/src/app/cli.ts +373 -0
- package/src/app/mcp.ts +402 -0
- package/src/bin/ai-cli-mcp.ts +7 -0
- package/src/bin/ai-cli.ts +11 -0
- package/src/cli-builder.ts +19 -10
- package/src/cli-parse.ts +8 -5
- package/src/cli-process-service.ts +418 -0
- package/src/cli-utils.ts +205 -99
- package/src/cli.ts +4 -3
- package/src/model-catalog.ts +64 -0
- package/src/parsers.ts +61 -0
- package/src/process-service.ts +263 -0
- package/src/server.ts +4 -668
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# AI CLI Architecture Plan
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
`ai-cli-mcp` package will expose two global commands:
|
|
6
|
+
|
|
7
|
+
- `ai-cli`: human-facing production CLI
|
|
8
|
+
- `ai-cli-mcp`: MCP server entrypoint for backward compatibility
|
|
9
|
+
|
|
10
|
+
The package name stays `ai-cli-mcp` for now. We do not introduce a daemon. We keep the product as a thin wrapper over Claude Code, Codex CLI, and Gemini CLI.
|
|
11
|
+
|
|
12
|
+
## Non-Goals
|
|
13
|
+
|
|
14
|
+
- Renaming the npm package in this phase
|
|
15
|
+
- Introducing a long-running background daemon
|
|
16
|
+
- Introducing a new public job identifier such as `run_id`
|
|
17
|
+
- Making the CLI responsible for deep process orchestration beyond launching and observing AI CLI processes
|
|
18
|
+
- Capturing exit codes in the first production CLI iteration
|
|
19
|
+
|
|
20
|
+
## Product Shape
|
|
21
|
+
|
|
22
|
+
### `ai-cli`
|
|
23
|
+
|
|
24
|
+
`ai-cli` is the primary CLI for humans.
|
|
25
|
+
|
|
26
|
+
Supported commands:
|
|
27
|
+
|
|
28
|
+
- `ai-cli run`
|
|
29
|
+
- `ai-cli wait`
|
|
30
|
+
- `ai-cli ps`
|
|
31
|
+
- `ai-cli result`
|
|
32
|
+
- `ai-cli kill`
|
|
33
|
+
- `ai-cli cleanup`
|
|
34
|
+
- `ai-cli models`
|
|
35
|
+
- `ai-cli doctor`
|
|
36
|
+
- `ai-cli mcp`
|
|
37
|
+
|
|
38
|
+
Behavior:
|
|
39
|
+
|
|
40
|
+
- Running `ai-cli` with no subcommand prints help
|
|
41
|
+
- Public process identity is `pid`
|
|
42
|
+
- `--cwd` is the working directory flag
|
|
43
|
+
- Output format should stay close to MCP responses
|
|
44
|
+
|
|
45
|
+
### `ai-cli-mcp`
|
|
46
|
+
|
|
47
|
+
`ai-cli-mcp` remains the MCP-focused command.
|
|
48
|
+
|
|
49
|
+
Behavior:
|
|
50
|
+
|
|
51
|
+
- Running `ai-cli-mcp` with no arguments starts the MCP server
|
|
52
|
+
- This command exists for compatibility with existing users and MCP configurations
|
|
53
|
+
|
|
54
|
+
## Public Command Semantics
|
|
55
|
+
|
|
56
|
+
### `ai-cli run`
|
|
57
|
+
|
|
58
|
+
Starts the target AI CLI in the background and returns immediately.
|
|
59
|
+
|
|
60
|
+
Properties:
|
|
61
|
+
|
|
62
|
+
- Returns MCP-like JSON including `pid`, `status`, `agent`, and `message`
|
|
63
|
+
- Uses `pid` as the public identifier
|
|
64
|
+
- Spawns the actual Claude/Codex/Gemini process directly
|
|
65
|
+
- Redirects `stdout` and `stderr` to files
|
|
66
|
+
- Does not guarantee `exitCode` in the initial design
|
|
67
|
+
|
|
68
|
+
### `ai-cli wait`
|
|
69
|
+
|
|
70
|
+
Waits until all given PIDs are no longer running.
|
|
71
|
+
|
|
72
|
+
Properties:
|
|
73
|
+
|
|
74
|
+
- Input is one or more `pid` values
|
|
75
|
+
- Timeout is supported
|
|
76
|
+
- Response format follows MCP `wait` as closely as possible
|
|
77
|
+
- Returns a result array, same direction as MCP
|
|
78
|
+
|
|
79
|
+
### `ai-cli ps`
|
|
80
|
+
|
|
81
|
+
Lists tracked runs with minimal information.
|
|
82
|
+
|
|
83
|
+
Properties:
|
|
84
|
+
|
|
85
|
+
- Output includes `pid`, `agent`, and `status`
|
|
86
|
+
- Initial scope is intentionally minimal
|
|
87
|
+
|
|
88
|
+
### `ai-cli result`
|
|
89
|
+
|
|
90
|
+
Reads saved output and returns parsed results.
|
|
91
|
+
|
|
92
|
+
Properties:
|
|
93
|
+
|
|
94
|
+
- Behavior should stay close to MCP `get_result`
|
|
95
|
+
- Parsed output is preferred
|
|
96
|
+
- Falls back to raw output when parsing fails or output is incomplete
|
|
97
|
+
|
|
98
|
+
### `ai-cli kill`
|
|
99
|
+
|
|
100
|
+
Sends `SIGTERM` to the given PID.
|
|
101
|
+
|
|
102
|
+
Properties:
|
|
103
|
+
|
|
104
|
+
- Public API is intentionally PID-based
|
|
105
|
+
- Users may also kill processes manually outside the tool
|
|
106
|
+
|
|
107
|
+
### `ai-cli cleanup`
|
|
108
|
+
|
|
109
|
+
Removes tracked process state for runs that are no longer running.
|
|
110
|
+
|
|
111
|
+
Properties:
|
|
112
|
+
|
|
113
|
+
- Removes completed and failed PID directories
|
|
114
|
+
- Keeps running processes intact
|
|
115
|
+
- Removes empty per-cwd directories after cleanup
|
|
116
|
+
|
|
117
|
+
### `ai-cli doctor`
|
|
118
|
+
|
|
119
|
+
Checks whether supported AI CLI binaries are available.
|
|
120
|
+
|
|
121
|
+
Properties:
|
|
122
|
+
|
|
123
|
+
- Scope is binary existence/path resolution only
|
|
124
|
+
- It does not verify login or acceptance state
|
|
125
|
+
|
|
126
|
+
### `ai-cli models`
|
|
127
|
+
|
|
128
|
+
Returns the supported model list and aliases.
|
|
129
|
+
|
|
130
|
+
Properties:
|
|
131
|
+
|
|
132
|
+
- Behavior should stay close to MCP-supported model documentation
|
|
133
|
+
- Static model definitions are acceptable in this phase
|
|
134
|
+
|
|
135
|
+
### `ai-cli mcp`
|
|
136
|
+
|
|
137
|
+
Starts the MCP server from the `ai-cli` command.
|
|
138
|
+
|
|
139
|
+
Properties:
|
|
140
|
+
|
|
141
|
+
- Allows one package to support both direct CLI usage and MCP usage
|
|
142
|
+
|
|
143
|
+
## Entrypoints
|
|
144
|
+
|
|
145
|
+
Planned package bin layout:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"bin": {
|
|
150
|
+
"ai-cli": "dist/bin/ai-cli.js",
|
|
151
|
+
"ai-cli-mcp": "dist/bin/ai-cli-mcp.js"
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Planned source layout:
|
|
157
|
+
|
|
158
|
+
```text
|
|
159
|
+
src/
|
|
160
|
+
bin/
|
|
161
|
+
ai-cli.ts
|
|
162
|
+
ai-cli-mcp.ts
|
|
163
|
+
app/
|
|
164
|
+
cli.ts
|
|
165
|
+
mcp.ts
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Responsibilities:
|
|
169
|
+
|
|
170
|
+
- `src/bin/ai-cli.ts`: thin CLI entrypoint
|
|
171
|
+
- `src/bin/ai-cli-mcp.ts`: thin MCP entrypoint
|
|
172
|
+
- `src/app/cli.ts`: subcommand parsing and dispatch for `ai-cli`
|
|
173
|
+
- `src/app/mcp.ts`: MCP server bootstrap
|
|
174
|
+
|
|
175
|
+
## Backend Architecture
|
|
176
|
+
|
|
177
|
+
The core implementation should be shared between CLI and MCP.
|
|
178
|
+
|
|
179
|
+
Suggested internal boundaries:
|
|
180
|
+
|
|
181
|
+
- `cli-builder`
|
|
182
|
+
- resolves model aliases
|
|
183
|
+
- validates input
|
|
184
|
+
- builds the real Claude/Codex/Gemini command
|
|
185
|
+
- `runner`
|
|
186
|
+
- spawns the actual AI CLI process
|
|
187
|
+
- redirects `stdout` and `stderr` to files
|
|
188
|
+
- `process-store`
|
|
189
|
+
- stores tracked process metadata
|
|
190
|
+
- exact path and file format are intentionally deferred
|
|
191
|
+
- `process-service`
|
|
192
|
+
- shared use cases for `run`, `wait`, `ps`, `result`, and `kill`
|
|
193
|
+
- `parsers`
|
|
194
|
+
- parses saved output into structured results
|
|
195
|
+
|
|
196
|
+
## PID-Based Design Decision
|
|
197
|
+
|
|
198
|
+
The public interface stays PID-based on purpose.
|
|
199
|
+
|
|
200
|
+
Rationale:
|
|
201
|
+
|
|
202
|
+
- This CLI is a thin wrapper over existing AI CLI tools
|
|
203
|
+
- PID is already the native OS process identifier
|
|
204
|
+
- Users can inspect or terminate processes with normal Unix tooling
|
|
205
|
+
- We do not want to introduce a synthetic public job ID in this phase
|
|
206
|
+
|
|
207
|
+
Implication:
|
|
208
|
+
|
|
209
|
+
- Public commands use `pid`
|
|
210
|
+
- Internal storage may store additional metadata if needed
|
|
211
|
+
- PID remains the only required identifier at the product surface
|
|
212
|
+
|
|
213
|
+
## Background Execution Strategy
|
|
214
|
+
|
|
215
|
+
The first production CLI implementation uses direct process spawning with file redirection.
|
|
216
|
+
|
|
217
|
+
Approach:
|
|
218
|
+
|
|
219
|
+
- Spawn the actual AI CLI process directly
|
|
220
|
+
- Redirect `stdout` to a file
|
|
221
|
+
- Redirect `stderr` to a file
|
|
222
|
+
- Persist enough metadata to support `wait`, `ps`, `result`, and `kill`
|
|
223
|
+
|
|
224
|
+
Why this approach:
|
|
225
|
+
|
|
226
|
+
- Lighter than introducing a worker process
|
|
227
|
+
- Keeps the CLI close to Unix process semantics
|
|
228
|
+
- Avoids worker-child termination complexity
|
|
229
|
+
- Keeps migration from the current MCP server relatively simple
|
|
230
|
+
|
|
231
|
+
Tradeoff accepted in phase one:
|
|
232
|
+
|
|
233
|
+
- `exitCode` is not guaranteed to be captured
|
|
234
|
+
|
|
235
|
+
If this becomes a practical problem, a thin per-run wrapper/worker can be introduced later without changing the public CLI model.
|
|
236
|
+
|
|
237
|
+
## MCP Compatibility Plan
|
|
238
|
+
|
|
239
|
+
MCP functionality stays in the project and should be preserved.
|
|
240
|
+
|
|
241
|
+
Compatibility goals:
|
|
242
|
+
|
|
243
|
+
- Keep current MCP tool names
|
|
244
|
+
- Keep current response shape as much as practical
|
|
245
|
+
- Reuse the same backend logic as the new CLI where possible
|
|
246
|
+
|
|
247
|
+
Target mapping:
|
|
248
|
+
|
|
249
|
+
- MCP `run` -> shared process service `run`
|
|
250
|
+
- MCP `wait` -> shared process service `wait`
|
|
251
|
+
- MCP `list_processes` -> shared process service `ps`
|
|
252
|
+
- MCP `get_result` -> shared process service `result`
|
|
253
|
+
- MCP `kill_process` -> shared process service `kill`
|
|
254
|
+
|
|
255
|
+
## Implementation Order
|
|
256
|
+
|
|
257
|
+
1. Split the current `src/server.ts` responsibilities into MCP surface and shared process logic
|
|
258
|
+
2. Introduce new bin entrypoints for `ai-cli` and `ai-cli-mcp`
|
|
259
|
+
3. Add `ai-cli` subcommand parsing and help output
|
|
260
|
+
4. Implement direct background spawning with stdout/stderr file redirection
|
|
261
|
+
5. Implement CLI commands: `run`, `wait`, `ps`, `result`, `kill`
|
|
262
|
+
6. Add `models`, `doctor`, and `mcp`
|
|
263
|
+
7. Rewire MCP handlers to the same shared backend
|
|
264
|
+
|
|
265
|
+
## Open Items Deferred
|
|
266
|
+
|
|
267
|
+
The following items are intentionally deferred:
|
|
268
|
+
|
|
269
|
+
- state directory path
|
|
270
|
+
- file naming scheme
|
|
271
|
+
- metadata file schema
|
|
272
|
+
- exit code capture for detached CLI runs
|
|
273
|
+
- retention and cleanup policy
|
|
274
|
+
- exact raw output access patterns
|
|
275
|
+
- Windows-specific process handling details
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-cli-mcp",
|
|
3
|
-
"version": "2.
|
|
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
|
|
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",
|
|
9
9
|
"bin": {
|
|
10
|
-
"ai-cli
|
|
10
|
+
"ai-cli": "dist/bin/ai-cli.js",
|
|
11
|
+
"ai-cli-mcp": "dist/bin/ai-cli-mcp.js"
|
|
11
12
|
},
|
|
12
13
|
"scripts": {
|
|
13
14
|
"build": "tsc",
|
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
|
|
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"
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
CLI_HELP_TEXT,
|
|
4
|
+
DOCTOR_HELP_TEXT,
|
|
5
|
+
MODELS_HELP_TEXT,
|
|
6
|
+
RUN_HELP_TEXT,
|
|
7
|
+
WAIT_HELP_TEXT,
|
|
8
|
+
runCli,
|
|
9
|
+
} from '../app/cli.js';
|
|
10
|
+
|
|
11
|
+
describe('ai-cli app', () => {
|
|
12
|
+
it('prints help and exits successfully when no subcommand is provided', async () => {
|
|
13
|
+
const stdout = vi.fn();
|
|
14
|
+
const stderr = vi.fn();
|
|
15
|
+
const startMcpServer = vi.fn();
|
|
16
|
+
|
|
17
|
+
const exitCode = await runCli([], {
|
|
18
|
+
stdout,
|
|
19
|
+
stderr,
|
|
20
|
+
startMcpServer,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(exitCode).toBe(0);
|
|
24
|
+
expect(stdout).toHaveBeenCalledWith(CLI_HELP_TEXT);
|
|
25
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
26
|
+
expect(startMcpServer).not.toHaveBeenCalled();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('starts MCP mode when the mcp subcommand is provided', async () => {
|
|
30
|
+
const stdout = vi.fn();
|
|
31
|
+
const stderr = vi.fn();
|
|
32
|
+
const startMcpServer = vi.fn().mockResolvedValue(undefined);
|
|
33
|
+
|
|
34
|
+
const exitCode = await runCli(['mcp'], {
|
|
35
|
+
stdout,
|
|
36
|
+
stderr,
|
|
37
|
+
startMcpServer,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(exitCode).toBe(0);
|
|
41
|
+
expect(startMcpServer).toHaveBeenCalledTimes(1);
|
|
42
|
+
expect(stdout).not.toHaveBeenCalled();
|
|
43
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('dispatches run with parsed CLI options', async () => {
|
|
47
|
+
const stdout = vi.fn();
|
|
48
|
+
const stderr = vi.fn();
|
|
49
|
+
const startMcpServer = vi.fn();
|
|
50
|
+
const runProcess = vi.fn().mockResolvedValue({
|
|
51
|
+
pid: 123,
|
|
52
|
+
status: 'started',
|
|
53
|
+
agent: 'claude',
|
|
54
|
+
message: 'claude process started successfully',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const exitCode = await runCli(
|
|
58
|
+
['run', '--cwd', '/tmp/project', '--prompt', 'hello', '--model', 'sonnet'],
|
|
59
|
+
{
|
|
60
|
+
stdout,
|
|
61
|
+
stderr,
|
|
62
|
+
startMcpServer,
|
|
63
|
+
runProcess,
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(exitCode).toBe(0);
|
|
68
|
+
expect(runProcess).toHaveBeenCalledWith({
|
|
69
|
+
cwd: '/tmp/project',
|
|
70
|
+
prompt: 'hello',
|
|
71
|
+
model: 'sonnet',
|
|
72
|
+
});
|
|
73
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"pid": 123'));
|
|
74
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('accepts legacy run option aliases', async () => {
|
|
78
|
+
const stdout = vi.fn();
|
|
79
|
+
const stderr = vi.fn();
|
|
80
|
+
const runProcess = vi.fn().mockResolvedValue({
|
|
81
|
+
pid: 123,
|
|
82
|
+
status: 'started',
|
|
83
|
+
agent: 'claude',
|
|
84
|
+
message: 'claude process started successfully',
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const exitCode = await runCli(
|
|
88
|
+
[
|
|
89
|
+
'run',
|
|
90
|
+
'--workFolder',
|
|
91
|
+
'/tmp/project',
|
|
92
|
+
'--prompt_file',
|
|
93
|
+
'/tmp/prompt.txt',
|
|
94
|
+
'--session_id',
|
|
95
|
+
'session-123',
|
|
96
|
+
'--reasoning_effort',
|
|
97
|
+
'high',
|
|
98
|
+
],
|
|
99
|
+
{
|
|
100
|
+
stdout,
|
|
101
|
+
stderr,
|
|
102
|
+
runProcess,
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(exitCode).toBe(0);
|
|
107
|
+
expect(runProcess).toHaveBeenCalledWith({
|
|
108
|
+
cwd: '/tmp/project',
|
|
109
|
+
prompt_file: '/tmp/prompt.txt',
|
|
110
|
+
session_id: 'session-123',
|
|
111
|
+
reasoning_effort: 'high',
|
|
112
|
+
});
|
|
113
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('requires a prompt or prompt file for run', async () => {
|
|
117
|
+
const stdout = vi.fn();
|
|
118
|
+
const stderr = vi.fn();
|
|
119
|
+
|
|
120
|
+
const exitCode = await runCli(['run', '--cwd', '/tmp/project'], {
|
|
121
|
+
stdout,
|
|
122
|
+
stderr,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(exitCode).toBe(1);
|
|
126
|
+
expect(stderr).toHaveBeenCalledWith('Missing required option: --prompt or --prompt-file\n');
|
|
127
|
+
expect(stdout).toHaveBeenCalledWith(CLI_HELP_TEXT);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('dispatches wait with pid arguments and timeout', async () => {
|
|
131
|
+
const stdout = vi.fn();
|
|
132
|
+
const stderr = vi.fn();
|
|
133
|
+
const waitForProcesses = vi.fn().mockResolvedValue([{ pid: 123, status: 'completed' }]);
|
|
134
|
+
|
|
135
|
+
const exitCode = await runCli(
|
|
136
|
+
['wait', '123', '456', '--timeout', '5'],
|
|
137
|
+
{
|
|
138
|
+
stdout,
|
|
139
|
+
stderr,
|
|
140
|
+
waitForProcesses,
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(exitCode).toBe(0);
|
|
145
|
+
expect(waitForProcesses).toHaveBeenCalledWith([123, 456], 5);
|
|
146
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"status": "completed"'));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('rejects invalid wait timeout values', async () => {
|
|
150
|
+
const stdout = vi.fn();
|
|
151
|
+
const stderr = vi.fn();
|
|
152
|
+
const waitForProcesses = vi.fn();
|
|
153
|
+
|
|
154
|
+
const exitCode = await runCli(['wait', '123', '--timeout', 'abc'], {
|
|
155
|
+
stdout,
|
|
156
|
+
stderr,
|
|
157
|
+
waitForProcesses,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(exitCode).toBe(1);
|
|
161
|
+
expect(stderr).toHaveBeenCalledWith('Invalid --timeout value\n');
|
|
162
|
+
expect(stdout).toHaveBeenCalledWith(CLI_HELP_TEXT);
|
|
163
|
+
expect(waitForProcesses).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('rejects non-integer pid arguments for wait', async () => {
|
|
167
|
+
const stdout = vi.fn();
|
|
168
|
+
const stderr = vi.fn();
|
|
169
|
+
const waitForProcesses = vi.fn();
|
|
170
|
+
|
|
171
|
+
const exitCode = await runCli(['wait', '123', 'abc'], {
|
|
172
|
+
stdout,
|
|
173
|
+
stderr,
|
|
174
|
+
waitForProcesses,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(exitCode).toBe(1);
|
|
178
|
+
expect(stderr).toHaveBeenCalledWith('All pid arguments must be positive integers\n');
|
|
179
|
+
expect(stdout).toHaveBeenCalledWith(CLI_HELP_TEXT);
|
|
180
|
+
expect(waitForProcesses).not.toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('dispatches ps, result, and kill', async () => {
|
|
184
|
+
const stdout = vi.fn();
|
|
185
|
+
const stderr = vi.fn();
|
|
186
|
+
const listProcesses = vi.fn().mockResolvedValue([{ pid: 123, agent: 'claude', status: 'running' }]);
|
|
187
|
+
const getProcessResult = vi.fn().mockResolvedValue({ pid: 123, status: 'completed' });
|
|
188
|
+
const killProcess = vi.fn().mockResolvedValue({ pid: 123, status: 'terminated' });
|
|
189
|
+
|
|
190
|
+
const psExitCode = await runCli(['ps'], { stdout, stderr, listProcesses });
|
|
191
|
+
expect(psExitCode).toBe(0);
|
|
192
|
+
expect(listProcesses).toHaveBeenCalledTimes(1);
|
|
193
|
+
|
|
194
|
+
const resultExitCode = await runCli(['result', '123'], { stdout, stderr, getProcessResult });
|
|
195
|
+
expect(resultExitCode).toBe(0);
|
|
196
|
+
expect(getProcessResult).toHaveBeenCalledWith(123, false);
|
|
197
|
+
|
|
198
|
+
const killExitCode = await runCli(['kill', '123'], { stdout, stderr, killProcess });
|
|
199
|
+
expect(killExitCode).toBe(0);
|
|
200
|
+
expect(killProcess).toHaveBeenCalledWith(123);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('dispatches cleanup', async () => {
|
|
204
|
+
const stdout = vi.fn();
|
|
205
|
+
const stderr = vi.fn();
|
|
206
|
+
const cleanupProcesses = vi.fn().mockResolvedValue({ removed: 2, message: 'Removed 2 processes' });
|
|
207
|
+
|
|
208
|
+
const exitCode = await runCli(['cleanup'], { stdout, stderr, cleanupProcesses });
|
|
209
|
+
|
|
210
|
+
expect(exitCode).toBe(0);
|
|
211
|
+
expect(cleanupProcesses).toHaveBeenCalledTimes(1);
|
|
212
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"removed": 2'));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('prints models as structured json', async () => {
|
|
216
|
+
const stdout = vi.fn();
|
|
217
|
+
const stderr = vi.fn();
|
|
218
|
+
|
|
219
|
+
const exitCode = await runCli(['models'], { stdout, stderr });
|
|
220
|
+
|
|
221
|
+
expect(exitCode).toBe(0);
|
|
222
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"aliases"'));
|
|
223
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"claude-ultra"'));
|
|
224
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"gpt-5.4"'));
|
|
225
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"forge"'));
|
|
226
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('prints doctor status as structured json', async () => {
|
|
230
|
+
const stdout = vi.fn();
|
|
231
|
+
const stderr = vi.fn();
|
|
232
|
+
const getDoctorStatus = vi.fn().mockReturnValue({
|
|
233
|
+
claude: {
|
|
234
|
+
configuredCommand: 'claude',
|
|
235
|
+
resolvedPath: '/tmp/bin/claude',
|
|
236
|
+
available: true,
|
|
237
|
+
lookup: 'path',
|
|
238
|
+
},
|
|
239
|
+
codex: {
|
|
240
|
+
configuredCommand: 'codex',
|
|
241
|
+
resolvedPath: null,
|
|
242
|
+
available: false,
|
|
243
|
+
lookup: 'path',
|
|
244
|
+
},
|
|
245
|
+
gemini: {
|
|
246
|
+
configuredCommand: 'gemini',
|
|
247
|
+
resolvedPath: '/tmp/bin/gemini',
|
|
248
|
+
available: true,
|
|
249
|
+
lookup: 'path',
|
|
250
|
+
},
|
|
251
|
+
forge: {
|
|
252
|
+
configuredCommand: 'forge',
|
|
253
|
+
resolvedPath: '/tmp/bin/forge',
|
|
254
|
+
available: true,
|
|
255
|
+
lookup: 'path',
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const exitCode = await runCli(['doctor'], { stdout, stderr, getDoctorStatus });
|
|
260
|
+
|
|
261
|
+
expect(exitCode).toBe(0);
|
|
262
|
+
expect(getDoctorStatus).toHaveBeenCalledTimes(1);
|
|
263
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"configuredCommand": "claude"'));
|
|
264
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"available": false'));
|
|
265
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('passes verbose through to result', async () => {
|
|
269
|
+
const stdout = vi.fn();
|
|
270
|
+
const stderr = vi.fn();
|
|
271
|
+
const getProcessResult = vi.fn().mockResolvedValue({ pid: 123, status: 'completed' });
|
|
272
|
+
|
|
273
|
+
const exitCode = await runCli(['result', '123', '--verbose'], { stdout, stderr, getProcessResult });
|
|
274
|
+
|
|
275
|
+
expect(exitCode).toBe(0);
|
|
276
|
+
expect(getProcessResult).toHaveBeenCalledWith(123, true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('prints detailed help for run --help', async () => {
|
|
280
|
+
const stdout = vi.fn();
|
|
281
|
+
const stderr = vi.fn();
|
|
282
|
+
|
|
283
|
+
const exitCode = await runCli(['run', '--help'], { stdout, stderr });
|
|
284
|
+
|
|
285
|
+
expect(exitCode).toBe(0);
|
|
286
|
+
expect(stdout).toHaveBeenCalledWith(RUN_HELP_TEXT);
|
|
287
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('claude-ultra'));
|
|
288
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gpt-5.2-codex'));
|
|
289
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gemini-2.5-pro'));
|
|
290
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('forge'));
|
|
291
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('prints detailed help for wait --help', async () => {
|
|
295
|
+
const stdout = vi.fn();
|
|
296
|
+
const stderr = vi.fn();
|
|
297
|
+
|
|
298
|
+
const exitCode = await runCli(['wait', '--help'], { stdout, stderr });
|
|
299
|
+
|
|
300
|
+
expect(exitCode).toBe(0);
|
|
301
|
+
expect(stdout).toHaveBeenCalledWith(WAIT_HELP_TEXT);
|
|
302
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('prints detailed help for models --help', async () => {
|
|
306
|
+
const stdout = vi.fn();
|
|
307
|
+
const stderr = vi.fn();
|
|
308
|
+
|
|
309
|
+
const exitCode = await runCli(['models', '--help'], { stdout, stderr });
|
|
310
|
+
|
|
311
|
+
expect(exitCode).toBe(0);
|
|
312
|
+
expect(stdout).toHaveBeenCalledWith(MODELS_HELP_TEXT);
|
|
313
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('prints detailed help for doctor --help', async () => {
|
|
317
|
+
const stdout = vi.fn();
|
|
318
|
+
const stderr = vi.fn();
|
|
319
|
+
|
|
320
|
+
const exitCode = await runCli(['doctor', '--help'], { stdout, stderr });
|
|
321
|
+
|
|
322
|
+
expect(exitCode).toBe(0);
|
|
323
|
+
expect(stdout).toHaveBeenCalledWith(DOCTOR_HELP_TEXT);
|
|
324
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('prints detailed help for doctor -h', async () => {
|
|
328
|
+
const stdout = vi.fn();
|
|
329
|
+
const stderr = vi.fn();
|
|
330
|
+
|
|
331
|
+
const exitCode = await runCli(['doctor', '-h'], { stdout, stderr });
|
|
332
|
+
|
|
333
|
+
expect(exitCode).toBe(0);
|
|
334
|
+
expect(stdout).toHaveBeenCalledWith(DOCTOR_HELP_TEXT);
|
|
335
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('prints help for --help', async () => {
|
|
339
|
+
const stdout = vi.fn();
|
|
340
|
+
const stderr = vi.fn();
|
|
341
|
+
const startMcpServer = vi.fn();
|
|
342
|
+
|
|
343
|
+
const exitCode = await runCli(['--help'], {
|
|
344
|
+
stdout,
|
|
345
|
+
stderr,
|
|
346
|
+
startMcpServer,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
expect(exitCode).toBe(0);
|
|
350
|
+
expect(stdout).toHaveBeenCalledWith(CLI_HELP_TEXT);
|
|
351
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('returns a non-zero exit code for unknown subcommands', async () => {
|
|
355
|
+
const stdout = vi.fn();
|
|
356
|
+
const stderr = vi.fn();
|
|
357
|
+
const startMcpServer = vi.fn();
|
|
358
|
+
|
|
359
|
+
const exitCode = await runCli(['unknown'], {
|
|
360
|
+
stdout,
|
|
361
|
+
stderr,
|
|
362
|
+
startMcpServer,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
expect(exitCode).toBe(1);
|
|
366
|
+
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('Unknown subcommand: unknown'));
|
|
367
|
+
expect(stdout).toHaveBeenCalledWith(CLI_HELP_TEXT);
|
|
368
|
+
expect(startMcpServer).not.toHaveBeenCalled();
|
|
369
|
+
});
|
|
370
|
+
});
|