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.
Files changed (55) hide show
  1. package/.github/workflows/publish.yml +25 -0
  2. package/CHANGELOG.md +23 -0
  3. package/README.ja.md +112 -8
  4. package/README.md +112 -9
  5. package/dist/__tests__/app-cli.test.js +293 -0
  6. package/dist/__tests__/cli-bin-smoke.test.js +58 -0
  7. package/dist/__tests__/cli-builder.test.js +37 -0
  8. package/dist/__tests__/cli-process-service.test.js +279 -0
  9. package/dist/__tests__/cli-utils.test.js +140 -0
  10. package/dist/__tests__/error-cases.test.js +2 -1
  11. package/dist/__tests__/mcp-contract.test.js +343 -0
  12. package/dist/__tests__/parsers.test.js +37 -1
  13. package/dist/__tests__/process-management.test.js +15 -8
  14. package/dist/__tests__/server.test.js +29 -3
  15. package/dist/__tests__/wait.test.js +31 -0
  16. package/dist/app/cli.js +304 -0
  17. package/dist/app/mcp.js +366 -0
  18. package/dist/bin/ai-cli-mcp.js +6 -0
  19. package/dist/bin/ai-cli.js +10 -0
  20. package/dist/cli-builder.js +15 -6
  21. package/dist/cli-parse.js +8 -5
  22. package/dist/cli-process-service.js +332 -0
  23. package/dist/cli-utils.js +159 -88
  24. package/dist/cli.js +4 -3
  25. package/dist/model-catalog.js +53 -0
  26. package/dist/parsers.js +55 -0
  27. package/dist/process-service.js +201 -0
  28. package/dist/server.js +4 -578
  29. package/docs/cli-architecture.md +275 -0
  30. package/package.json +4 -3
  31. package/server.json +1 -1
  32. package/src/__tests__/app-cli.test.ts +370 -0
  33. package/src/__tests__/cli-bin-smoke.test.ts +75 -0
  34. package/src/__tests__/cli-builder.test.ts +47 -0
  35. package/src/__tests__/cli-process-service.test.ts +334 -0
  36. package/src/__tests__/cli-utils.test.ts +166 -0
  37. package/src/__tests__/error-cases.test.ts +3 -4
  38. package/src/__tests__/mcp-contract.test.ts +422 -0
  39. package/src/__tests__/parsers.test.ts +44 -1
  40. package/src/__tests__/process-management.test.ts +15 -9
  41. package/src/__tests__/server.test.ts +27 -6
  42. package/src/__tests__/wait.test.ts +38 -0
  43. package/src/app/cli.ts +373 -0
  44. package/src/app/mcp.ts +402 -0
  45. package/src/bin/ai-cli-mcp.ts +7 -0
  46. package/src/bin/ai-cli.ts +11 -0
  47. package/src/cli-builder.ts +19 -10
  48. package/src/cli-parse.ts +8 -5
  49. package/src/cli-process-service.ts +418 -0
  50. package/src/cli-utils.ts +205 -99
  51. package/src/cli.ts +4 -3
  52. package/src/model-catalog.ts +64 -0
  53. package/src/parsers.ts +61 -0
  54. package/src/process-service.ts +263 -0
  55. package/src/server.ts +4 -668
@@ -0,0 +1,418 @@
1
+ import { spawn } from 'node:child_process';
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ realpathSync,
10
+ renameSync,
11
+ rmSync,
12
+ unlinkSync,
13
+ writeFileSync,
14
+ } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+ import { buildCliCommand, type BuildCliCommandOptions } from './cli-builder.js';
18
+ import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
19
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
20
+ import type { AgentType, ProcessListItem } from './process-service.js';
21
+
22
+ interface StoredProcess {
23
+ pid: number;
24
+ prompt: string;
25
+ workFolder: string;
26
+ model?: string;
27
+ toolType: AgentType;
28
+ startTime: string;
29
+ stdoutPath: string;
30
+ stderrPath: string;
31
+ status: 'running' | 'completed' | 'failed';
32
+ }
33
+
34
+ interface CliProcessServiceOptions {
35
+ stateDir?: string;
36
+ cliPaths?: BuildCliCommandOptions['cliPaths'];
37
+ }
38
+
39
+ export interface CliRunOptions {
40
+ cwd: string;
41
+ prompt?: string;
42
+ prompt_file?: string;
43
+ model?: string;
44
+ session_id?: string;
45
+ reasoning_effort?: string;
46
+ }
47
+
48
+ function resolveDefaultStateDir(): string {
49
+ return process.env.AI_CLI_STATE_DIR || join(homedir(), '.local', 'state', 'ai-cli');
50
+ }
51
+
52
+ function isProcessRunning(pid: number): boolean {
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ } catch (error: any) {
57
+ if (error.code === 'EPERM') {
58
+ return true;
59
+ }
60
+ return false;
61
+ }
62
+ }
63
+
64
+ function normalizeCwdForStorage(cwd: string): string {
65
+ return cwd
66
+ .split('')
67
+ .map((char) => (/^[A-Za-z0-9.-]$/.test(char) ? char : `_${char.charCodeAt(0).toString(16).padStart(2, '0')}`))
68
+ .join('');
69
+ }
70
+
71
+ export class CliProcessService {
72
+ private readonly stateDir: string;
73
+ private readonly cliPaths: BuildCliCommandOptions['cliPaths'];
74
+
75
+ constructor(options: CliProcessServiceOptions = {}) {
76
+ this.stateDir = options.stateDir || resolveDefaultStateDir();
77
+ this.cliPaths = options.cliPaths || {
78
+ claude: findClaudeCli(),
79
+ codex: findCodexCli(),
80
+ gemini: findGeminiCli(),
81
+ forge: findForgeCli(),
82
+ };
83
+ mkdirSync(this.stateDir, { recursive: true });
84
+ }
85
+
86
+ async startProcess(options: CliRunOptions): Promise<{ pid: number; status: 'started'; agent: AgentType; message: string }> {
87
+ const cmd = buildCliCommand({
88
+ prompt: options.prompt,
89
+ prompt_file: options.prompt_file,
90
+ workFolder: options.cwd,
91
+ model: options.model,
92
+ session_id: options.session_id,
93
+ reasoning_effort: options.reasoning_effort,
94
+ cliPaths: this.cliPaths,
95
+ });
96
+
97
+ const stdoutPath = this.resolveStdoutPathForPidPlaceholder();
98
+ const stderrPath = this.resolveStderrPathForPidPlaceholder();
99
+ let stdoutFd: number | undefined;
100
+ let stderrFd: number | undefined;
101
+
102
+ try {
103
+ stdoutFd = openSync(stdoutPath, 'w');
104
+ stderrFd = openSync(stderrPath, 'w');
105
+
106
+ const childProcess = spawn(cmd.cliPath, cmd.args, {
107
+ cwd: cmd.cwd,
108
+ detached: true,
109
+ stdio: ['ignore', stdoutFd, stderrFd],
110
+ });
111
+
112
+ const pid = childProcess.pid;
113
+ childProcess.unref();
114
+
115
+ if (!pid) {
116
+ throw new Error(`Failed to start ${cmd.agent} CLI process`);
117
+ }
118
+
119
+ const processDir = this.resolveProcessDir(cmd.cwd, pid);
120
+ mkdirSync(processDir, { recursive: true });
121
+ const finalStdoutPath = this.resolveStdoutPath(processDir);
122
+ const finalStderrPath = this.resolveStderrPath(processDir);
123
+ this.renamePlaceholderFile(stdoutPath, finalStdoutPath);
124
+ this.renamePlaceholderFile(stderrPath, finalStderrPath);
125
+
126
+ const storedProcess: StoredProcess = {
127
+ pid,
128
+ prompt: cmd.prompt,
129
+ workFolder: cmd.cwd,
130
+ model: options.model,
131
+ toolType: cmd.agent,
132
+ startTime: new Date().toISOString(),
133
+ stdoutPath: finalStdoutPath,
134
+ stderrPath: finalStderrPath,
135
+ status: 'running',
136
+ };
137
+ this.writeProcess(storedProcess);
138
+
139
+ return {
140
+ pid,
141
+ status: 'started',
142
+ agent: cmd.agent,
143
+ message: `${cmd.agent} process started successfully`,
144
+ };
145
+ } catch (error) {
146
+ this.removeFileIfExists(stdoutPath);
147
+ this.removeFileIfExists(stderrPath);
148
+ throw error;
149
+ } finally {
150
+ if (stdoutFd !== undefined) {
151
+ closeSync(stdoutFd);
152
+ }
153
+ if (stderrFd !== undefined) {
154
+ closeSync(stderrFd);
155
+ }
156
+ }
157
+ }
158
+
159
+ async listProcesses(): Promise<ProcessListItem[]> {
160
+ return this.readAllProcesses().map((process) => ({
161
+ pid: process.pid,
162
+ agent: process.toolType,
163
+ status: this.refreshStatus(process).status,
164
+ }));
165
+ }
166
+
167
+ async getProcessResult(pid: number, verbose = false): Promise<any> {
168
+ const storedProcess = this.readProcess(pid);
169
+ const refreshed = this.refreshStatus(storedProcess);
170
+ const stdout = this.readTextFileSafe(refreshed.stdoutPath);
171
+ const stderr = this.readTextFileSafe(refreshed.stderrPath);
172
+
173
+ let agentOutput: any = null;
174
+ if (refreshed.toolType === 'codex') {
175
+ agentOutput = parseCodexOutput(`${stdout}\n${stderr}`);
176
+ } else if (stdout) {
177
+ if (refreshed.toolType === 'claude') {
178
+ agentOutput = parseClaudeOutput(stdout);
179
+ } else if (refreshed.toolType === 'gemini') {
180
+ agentOutput = parseGeminiOutput(stdout);
181
+ } else if (refreshed.toolType === 'forge') {
182
+ agentOutput = parseForgeOutput(stdout);
183
+ }
184
+ }
185
+
186
+ const response: any = {
187
+ pid,
188
+ agent: refreshed.toolType,
189
+ status: refreshed.status,
190
+ exitCode: undefined,
191
+ startTime: refreshed.startTime,
192
+ workFolder: refreshed.workFolder,
193
+ prompt: refreshed.prompt,
194
+ model: refreshed.model,
195
+ };
196
+
197
+ if (agentOutput) {
198
+ if (!verbose && agentOutput.tools) {
199
+ const { tools, ...rest } = agentOutput;
200
+ response.agentOutput = rest;
201
+ } else {
202
+ response.agentOutput = agentOutput;
203
+ }
204
+ if (agentOutput.session_id) {
205
+ response.session_id = agentOutput.session_id;
206
+ }
207
+ } else {
208
+ response.stdout = stdout;
209
+ response.stderr = stderr;
210
+ }
211
+
212
+ return response;
213
+ }
214
+
215
+ async waitForProcesses(pids: number[], timeoutSeconds = 180): Promise<any[]> {
216
+ const start = Date.now();
217
+ for (const pid of pids) {
218
+ this.readProcess(pid);
219
+ }
220
+
221
+ while (true) {
222
+ const statuses = pids.map((pid) => this.refreshStatus(this.readProcess(pid)).status);
223
+ if (statuses.every((status) => status !== 'running')) {
224
+ return Promise.all(pids.map((pid) => this.getProcessResult(pid, false)));
225
+ }
226
+
227
+ if (Date.now() - start >= timeoutSeconds * 1000) {
228
+ throw new Error(`Timed out after ${timeoutSeconds} seconds waiting for processes`);
229
+ }
230
+
231
+ await new Promise((resolve) => setTimeout(resolve, 50));
232
+ }
233
+ }
234
+
235
+ async killProcess(pid: number): Promise<{ pid: number; status: string; message: string }> {
236
+ const process = this.readProcess(pid);
237
+ const refreshed = this.refreshStatus(process);
238
+
239
+ if (refreshed.status !== 'running') {
240
+ return {
241
+ pid,
242
+ status: refreshed.status,
243
+ message: 'Process already terminated',
244
+ };
245
+ }
246
+
247
+ this.killPidOrGroup(pid, 'SIGTERM');
248
+ await this.waitForProcessExit(pid, 250);
249
+
250
+ if (isProcessRunning(pid)) {
251
+ return {
252
+ pid,
253
+ status: 'running',
254
+ message: 'Signal sent but process is still running',
255
+ };
256
+ }
257
+
258
+ refreshed.status = 'failed';
259
+ this.writeProcess(refreshed);
260
+
261
+ return {
262
+ pid,
263
+ status: 'terminated',
264
+ message: 'Process terminated successfully',
265
+ };
266
+ }
267
+
268
+ async cleanupProcesses(): Promise<{ removed: number; message: string }> {
269
+ let removed = 0;
270
+
271
+ for (const process of this.readAllProcesses()) {
272
+ const refreshed = this.refreshStatus(process);
273
+ if (refreshed.status === 'running') {
274
+ continue;
275
+ }
276
+
277
+ const processDir = this.resolveProcessDir(refreshed.workFolder, refreshed.pid);
278
+ if (existsSync(processDir)) {
279
+ rmSync(processDir, { recursive: true, force: true });
280
+ removed++;
281
+ }
282
+ }
283
+
284
+ this.removeEmptyCwdDirs();
285
+
286
+ return {
287
+ removed,
288
+ message: `Removed ${removed} processes`,
289
+ };
290
+ }
291
+
292
+ private readAllProcesses(): StoredProcess[] {
293
+ const cwdsDir = this.resolveCwdsDir();
294
+ if (!existsSync(cwdsDir)) {
295
+ return [];
296
+ }
297
+
298
+ const processes: StoredProcess[] = [];
299
+ for (const cwdEntry of readdirSync(cwdsDir)) {
300
+ const cwdDir = join(cwdsDir, cwdEntry);
301
+ for (const pidEntry of readdirSync(cwdDir)) {
302
+ const metaPath = join(cwdDir, pidEntry, 'meta.json');
303
+ if (existsSync(metaPath)) {
304
+ processes.push(this.parseProcessFile(metaPath));
305
+ }
306
+ }
307
+ }
308
+
309
+ return processes;
310
+ }
311
+
312
+ private readProcess(pid: number): StoredProcess {
313
+ const process = this.readAllProcesses().find((entry) => entry.pid === pid);
314
+ if (!process) {
315
+ throw new Error(`Process with PID ${pid} not found`);
316
+ }
317
+ return process;
318
+ }
319
+
320
+ private parseProcessFile(metaPath: string): StoredProcess {
321
+ return JSON.parse(readFileSync(metaPath, 'utf-8')) as StoredProcess;
322
+ }
323
+
324
+ private writeProcess(process: StoredProcess): void {
325
+ const processDir = this.resolveProcessDir(process.workFolder, process.pid);
326
+ mkdirSync(processDir, { recursive: true });
327
+ writeFileSync(this.resolveMetaPath(processDir), JSON.stringify(process, null, 2));
328
+ }
329
+
330
+ private refreshStatus(process: StoredProcess): StoredProcess {
331
+ if (process.status === 'running' && !isProcessRunning(process.pid)) {
332
+ process.status = 'completed';
333
+ this.writeProcess(process);
334
+ }
335
+ return process;
336
+ }
337
+
338
+ private readTextFileSafe(filePath: string): string {
339
+ if (!existsSync(filePath)) {
340
+ return '';
341
+ }
342
+ return readFileSync(filePath, 'utf-8');
343
+ }
344
+
345
+ private resolveCwdsDir(): string {
346
+ return join(this.stateDir, 'cwds');
347
+ }
348
+
349
+ private resolveProcessDir(cwd: string, pid: number): string {
350
+ return join(this.resolveCwdsDir(), normalizeCwdForStorage(realpathSync(cwd)), String(pid));
351
+ }
352
+
353
+ private resolveMetaPath(processDir: string): string {
354
+ return join(processDir, 'meta.json');
355
+ }
356
+
357
+ private resolveStdoutPath(processDir: string): string {
358
+ return join(processDir, 'stdout.log');
359
+ }
360
+
361
+ private resolveStderrPath(processDir: string): string {
362
+ return join(processDir, 'stderr.log');
363
+ }
364
+
365
+ private resolveStdoutPathForPidPlaceholder(): string {
366
+ return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stdout.log`);
367
+ }
368
+
369
+ private resolveStderrPathForPidPlaceholder(): string {
370
+ return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stderr.log`);
371
+ }
372
+
373
+ private renamePlaceholderFile(fromPath: string, toPath: string): void {
374
+ renameSync(fromPath, toPath);
375
+ }
376
+
377
+ private removeFileIfExists(filePath: string): void {
378
+ if (existsSync(filePath)) {
379
+ unlinkSync(filePath);
380
+ }
381
+ }
382
+
383
+ private killPidOrGroup(pid: number, signal: NodeJS.Signals): void {
384
+ try {
385
+ globalThis.process.kill(-pid, signal);
386
+ } catch (error: any) {
387
+ if (error.code === 'ESRCH' || error.code === 'EINVAL') {
388
+ globalThis.process.kill(pid, signal);
389
+ return;
390
+ }
391
+ if (error.code === 'EPERM') {
392
+ throw error;
393
+ }
394
+ globalThis.process.kill(pid, signal);
395
+ }
396
+ }
397
+
398
+ private async waitForProcessExit(pid: number, timeoutMs: number): Promise<void> {
399
+ const startedAt = Date.now();
400
+ while (isProcessRunning(pid) && Date.now() - startedAt < timeoutMs) {
401
+ await new Promise((resolve) => setTimeout(resolve, 25));
402
+ }
403
+ }
404
+
405
+ private removeEmptyCwdDirs(): void {
406
+ const cwdsDir = this.resolveCwdsDir();
407
+ if (!existsSync(cwdsDir)) {
408
+ return;
409
+ }
410
+
411
+ for (const cwdEntry of readdirSync(cwdsDir)) {
412
+ const cwdDir = join(cwdsDir, cwdEntry);
413
+ if (readdirSync(cwdDir).length === 0) {
414
+ rmSync(cwdDir, { recursive: true, force: true });
415
+ }
416
+ }
417
+ }
418
+ }