ai-cli-mcp 2.14.1 → 2.16.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/dependabot.yml +28 -0
- package/.github/workflows/ci.yml +4 -1
- package/.github/workflows/dependency-review.yml +22 -0
- package/CHANGELOG.md +14 -0
- package/README.ja.md +83 -6
- package/README.md +83 -7
- package/dist/__tests__/app-cli.test.js +80 -5
- package/dist/__tests__/cli-bin-smoke.test.js +43 -0
- package/dist/__tests__/cli-builder.test.js +93 -15
- package/dist/__tests__/cli-process-service.test.js +162 -0
- package/dist/__tests__/cli-utils.test.js +31 -0
- package/dist/__tests__/e2e.test.js +79 -52
- package/dist/__tests__/mcp-contract.test.js +162 -0
- package/dist/__tests__/parsers.test.js +224 -1
- package/dist/__tests__/peek.test.js +35 -0
- package/dist/__tests__/process-management.test.js +160 -1
- package/dist/__tests__/server.test.js +39 -9
- package/dist/__tests__/utils/opencode-mock.js +91 -0
- package/dist/__tests__/validation.test.js +40 -2
- package/dist/app/cli.js +47 -5
- package/dist/app/mcp.js +53 -4
- package/dist/cli-builder.js +67 -28
- package/dist/cli-parse.js +11 -5
- package/dist/cli-process-service.js +241 -20
- package/dist/cli-utils.js +14 -23
- package/dist/cli.js +6 -4
- package/dist/model-catalog.js +13 -1
- package/dist/parsers.js +242 -28
- package/dist/peek.js +56 -0
- package/dist/process-result.js +9 -2
- package/dist/process-service.js +103 -17
- package/dist/server.js +1 -2
- package/package.json +9 -6
- package/src/__tests__/app-cli.test.ts +95 -4
- package/src/__tests__/cli-bin-smoke.test.ts +62 -1
- package/src/__tests__/cli-builder.test.ts +111 -15
- package/src/__tests__/cli-process-service.test.ts +180 -0
- package/src/__tests__/cli-utils.test.ts +34 -0
- package/src/__tests__/e2e.test.ts +87 -55
- package/src/__tests__/mcp-contract.test.ts +188 -0
- package/src/__tests__/parsers.test.ts +260 -1
- package/src/__tests__/peek.test.ts +43 -0
- package/src/__tests__/process-management.test.ts +185 -1
- package/src/__tests__/server.test.ts +49 -13
- package/src/__tests__/utils/opencode-mock.ts +108 -0
- package/src/__tests__/validation.test.ts +48 -2
- package/src/app/cli.ts +52 -4
- package/src/app/mcp.ts +54 -4
- package/src/cli-builder.ts +91 -32
- package/src/cli-parse.ts +11 -5
- package/src/cli-process-service.ts +304 -17
- package/src/cli-utils.ts +37 -33
- package/src/cli.ts +6 -4
- package/src/model-catalog.ts +24 -1
- package/src/parsers.ts +299 -33
- package/src/peek.ts +88 -0
- package/src/process-result.ts +11 -2
- package/src/process-service.ts +134 -15
- package/src/server.ts +2 -2
- package/vitest.config.unit.ts +2 -3
package/src/cli-parse.ts
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
|
|
2
|
+
import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput } from './parsers.js';
|
|
3
3
|
|
|
4
|
-
const AGENTS = ['claude', 'codex', 'gemini', 'forge'] as const;
|
|
4
|
+
const AGENTS = ['claude', 'codex', 'gemini', 'forge', 'opencode'] as const;
|
|
5
5
|
type Agent = typeof AGENTS[number];
|
|
6
6
|
|
|
7
|
-
const USAGE = `Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini|forge>
|
|
7
|
+
const USAGE = `Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini|forge|opencode>
|
|
8
8
|
|
|
9
9
|
Reads raw CLI output from stdin and outputs parsed JSON to stdout.
|
|
10
10
|
|
|
11
11
|
Options:
|
|
12
|
-
--agent Agent type: claude, codex, gemini, or
|
|
12
|
+
--agent Agent type: claude, codex, gemini, forge, or opencode (required)
|
|
13
13
|
--help Show this help message
|
|
14
14
|
|
|
15
15
|
Examples:
|
|
16
16
|
npm run -s cli.run -- --model sonnet --workFolder /tmp --prompt "hi" > raw.txt
|
|
17
17
|
npm run -s cli.run.parse -- --agent claude < raw.txt
|
|
18
18
|
|
|
19
|
+
npm run -s cli.run -- --model opencode --workFolder /tmp --prompt "hi" > raw.txt
|
|
20
|
+
npm run -s cli.run.parse -- --agent opencode < raw.txt
|
|
21
|
+
|
|
19
22
|
# Or pipe directly
|
|
20
23
|
npm run -s cli.run -- --model sonnet --workFolder /tmp --prompt "hi" | npm run -s cli.run.parse -- --agent claude
|
|
21
24
|
`;
|
|
@@ -62,7 +65,7 @@ async function main(): Promise<void> {
|
|
|
62
65
|
|
|
63
66
|
const agent = args.agent as Agent;
|
|
64
67
|
if (!agent || !AGENTS.includes(agent)) {
|
|
65
|
-
process.stderr.write(`Error: --agent is required (claude, codex, gemini, or
|
|
68
|
+
process.stderr.write(`Error: --agent is required (claude, codex, gemini, forge, or opencode)\n\n`);
|
|
66
69
|
process.stderr.write(USAGE);
|
|
67
70
|
process.exit(1);
|
|
68
71
|
}
|
|
@@ -88,6 +91,9 @@ async function main(): Promise<void> {
|
|
|
88
91
|
case 'forge':
|
|
89
92
|
parsed = parseForgeOutput(input);
|
|
90
93
|
break;
|
|
94
|
+
case 'opencode':
|
|
95
|
+
parsed = parseOpenCodeOutput(input);
|
|
96
|
+
break;
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
process.stdout.write(JSON.stringify(parsed, null, 2) + '\n');
|
|
@@ -1,23 +1,35 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import {
|
|
3
|
+
chmodSync,
|
|
3
4
|
closeSync,
|
|
4
5
|
existsSync,
|
|
5
6
|
mkdirSync,
|
|
6
7
|
openSync,
|
|
8
|
+
readSync,
|
|
7
9
|
readFileSync,
|
|
8
10
|
readdirSync,
|
|
9
11
|
realpathSync,
|
|
10
12
|
renameSync,
|
|
11
13
|
rmSync,
|
|
14
|
+
statSync,
|
|
12
15
|
unlinkSync,
|
|
13
16
|
writeFileSync,
|
|
14
17
|
} from 'node:fs';
|
|
15
18
|
import { join, basename, dirname } from 'node:path';
|
|
16
19
|
import { homedir } from 'node:os';
|
|
17
20
|
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';
|
|
21
|
+
import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from './cli-utils.js';
|
|
22
|
+
import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput, PeekMessageExtractor } from './parsers.js';
|
|
20
23
|
import { buildProcessResult } from './process-result.js';
|
|
24
|
+
import {
|
|
25
|
+
appendPeekMessages,
|
|
26
|
+
buildNotFoundPeekProcess,
|
|
27
|
+
observedDurationSec,
|
|
28
|
+
validatePeekPids,
|
|
29
|
+
validatePeekTimeSec,
|
|
30
|
+
type PeekProcessResult,
|
|
31
|
+
type PeekResponse,
|
|
32
|
+
} from './peek.js';
|
|
21
33
|
import type { AgentType, ProcessListItem } from './process-service.js';
|
|
22
34
|
|
|
23
35
|
interface StoredProcess {
|
|
@@ -31,6 +43,12 @@ interface StoredProcess {
|
|
|
31
43
|
stdoutPath: string;
|
|
32
44
|
stderrPath: string;
|
|
33
45
|
status: 'running' | 'completed' | 'failed';
|
|
46
|
+
exitCode?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface StoredExitStatus {
|
|
50
|
+
status: 'completed' | 'failed';
|
|
51
|
+
exitCode?: number;
|
|
34
52
|
}
|
|
35
53
|
|
|
36
54
|
interface CliProcessServiceOptions {
|
|
@@ -70,6 +88,30 @@ function normalizeCwdForStorage(cwd: string): string {
|
|
|
70
88
|
.join('');
|
|
71
89
|
}
|
|
72
90
|
|
|
91
|
+
function parseAgentOutput(agent: AgentType, stdout: string, stderr: string): any {
|
|
92
|
+
if (agent === 'codex') {
|
|
93
|
+
return parseCodexOutput(`${stdout}\n${stderr}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!stdout) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (agent === 'claude') {
|
|
101
|
+
return parseClaudeOutput(stdout);
|
|
102
|
+
}
|
|
103
|
+
if (agent === 'gemini') {
|
|
104
|
+
return parseGeminiOutput(stdout);
|
|
105
|
+
}
|
|
106
|
+
if (agent === 'forge') {
|
|
107
|
+
return parseForgeOutput(stdout);
|
|
108
|
+
}
|
|
109
|
+
if (agent === 'opencode') {
|
|
110
|
+
return parseOpenCodeOutput(stdout);
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
73
115
|
export class CliProcessService {
|
|
74
116
|
private readonly stateDir: string;
|
|
75
117
|
private readonly cliPaths: BuildCliCommandOptions['cliPaths'];
|
|
@@ -81,6 +123,7 @@ export class CliProcessService {
|
|
|
81
123
|
codex: findCodexCli(),
|
|
82
124
|
gemini: findGeminiCli(),
|
|
83
125
|
forge: findForgeCli(),
|
|
126
|
+
opencode: findOpencodeCli(),
|
|
84
127
|
};
|
|
85
128
|
mkdirSync(this.stateDir, { recursive: true });
|
|
86
129
|
}
|
|
@@ -96,6 +139,10 @@ export class CliProcessService {
|
|
|
96
139
|
cliPaths: this.cliPaths,
|
|
97
140
|
});
|
|
98
141
|
|
|
142
|
+
if (cmd.agent === 'opencode') {
|
|
143
|
+
return this.startDetachedOpenCodeProcess(cmd, options.model);
|
|
144
|
+
}
|
|
145
|
+
|
|
99
146
|
const stdoutPath = this.resolveStdoutPathForPidPlaceholder();
|
|
100
147
|
const stderrPath = this.resolveStderrPathForPidPlaceholder();
|
|
101
148
|
let stdoutFd: number | undefined;
|
|
@@ -172,25 +219,13 @@ export class CliProcessService {
|
|
|
172
219
|
const refreshed = this.refreshStatus(storedProcess);
|
|
173
220
|
const stdout = this.readTextFileSafe(refreshed.stdoutPath);
|
|
174
221
|
const stderr = this.readTextFileSafe(refreshed.stderrPath);
|
|
175
|
-
|
|
176
|
-
let agentOutput: any = null;
|
|
177
|
-
if (refreshed.toolType === 'codex') {
|
|
178
|
-
agentOutput = parseCodexOutput(`${stdout}\n${stderr}`);
|
|
179
|
-
} else if (stdout) {
|
|
180
|
-
if (refreshed.toolType === 'claude') {
|
|
181
|
-
agentOutput = parseClaudeOutput(stdout);
|
|
182
|
-
} else if (refreshed.toolType === 'gemini') {
|
|
183
|
-
agentOutput = parseGeminiOutput(stdout);
|
|
184
|
-
} else if (refreshed.toolType === 'forge') {
|
|
185
|
-
agentOutput = parseForgeOutput(stdout);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
222
|
+
const agentOutput = parseAgentOutput(refreshed.toolType, stdout, stderr);
|
|
188
223
|
|
|
189
224
|
return buildProcessResult({
|
|
190
225
|
pid,
|
|
191
226
|
agent: refreshed.toolType,
|
|
192
227
|
status: refreshed.status,
|
|
193
|
-
exitCode:
|
|
228
|
+
exitCode: refreshed.exitCode,
|
|
194
229
|
startTime: refreshed.startTime,
|
|
195
230
|
workFolder: refreshed.workFolder,
|
|
196
231
|
prompt: refreshed.prompt,
|
|
@@ -220,6 +255,97 @@ export class CliProcessService {
|
|
|
220
255
|
}
|
|
221
256
|
}
|
|
222
257
|
|
|
258
|
+
async peekProcesses(pids: number[], peekTimeSec = 10): Promise<PeekResponse> {
|
|
259
|
+
const targetPids = validatePeekPids(pids);
|
|
260
|
+
const targetPeekTimeSec = validatePeekTimeSec(peekTimeSec);
|
|
261
|
+
const processes: PeekProcessResult[] = [];
|
|
262
|
+
const observers: Array<{
|
|
263
|
+
process: StoredProcess;
|
|
264
|
+
result: PeekProcessResult;
|
|
265
|
+
stdoutExtractor: PeekMessageExtractor;
|
|
266
|
+
stderrExtractor: PeekMessageExtractor;
|
|
267
|
+
stdoutOffset: number;
|
|
268
|
+
stderrOffset: number;
|
|
269
|
+
}> = [];
|
|
270
|
+
|
|
271
|
+
for (const pid of targetPids) {
|
|
272
|
+
let process: StoredProcess;
|
|
273
|
+
try {
|
|
274
|
+
process = this.refreshStatus(this.readProcess(pid));
|
|
275
|
+
} catch {
|
|
276
|
+
processes.push(buildNotFoundPeekProcess(pid));
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const result: PeekProcessResult = {
|
|
281
|
+
pid,
|
|
282
|
+
agent: process.toolType,
|
|
283
|
+
status: process.status,
|
|
284
|
+
messages: [],
|
|
285
|
+
truncated: false,
|
|
286
|
+
error: null,
|
|
287
|
+
};
|
|
288
|
+
processes.push(result);
|
|
289
|
+
observers.push({
|
|
290
|
+
process,
|
|
291
|
+
result,
|
|
292
|
+
stdoutExtractor: new PeekMessageExtractor(process.toolType),
|
|
293
|
+
stderrExtractor: new PeekMessageExtractor(process.toolType),
|
|
294
|
+
stdoutOffset: this.fileSizeSafe(process.stdoutPath),
|
|
295
|
+
stderrOffset: this.fileSizeSafe(process.stderrPath),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const startedAt = new Date();
|
|
300
|
+
const startedAtMs = Date.now();
|
|
301
|
+
const deadlineMs = startedAtMs + targetPeekTimeSec * 1000;
|
|
302
|
+
|
|
303
|
+
while (Date.now() <= deadlineMs) {
|
|
304
|
+
const observedAt = new Date().toISOString();
|
|
305
|
+
let allTerminal = true;
|
|
306
|
+
|
|
307
|
+
for (const observer of observers) {
|
|
308
|
+
const stdoutRead = this.readTextFromOffset(observer.process.stdoutPath, observer.stdoutOffset);
|
|
309
|
+
observer.stdoutOffset = stdoutRead.offset;
|
|
310
|
+
appendPeekMessages(observer.result, observer.stdoutExtractor.push(stdoutRead.text, observedAt));
|
|
311
|
+
|
|
312
|
+
const stderrRead = this.readTextFromOffset(observer.process.stderrPath, observer.stderrOffset);
|
|
313
|
+
observer.stderrOffset = stderrRead.offset;
|
|
314
|
+
appendPeekMessages(observer.result, observer.stderrExtractor.push(stderrRead.text, observedAt));
|
|
315
|
+
|
|
316
|
+
observer.process = this.refreshStatus(this.readProcess(observer.process.pid));
|
|
317
|
+
observer.result.status = observer.process.status;
|
|
318
|
+
if (observer.process.status === 'running') {
|
|
319
|
+
allTerminal = false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (allTerminal) {
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const remainingMs = deadlineMs - Date.now();
|
|
328
|
+
if (remainingMs <= 0) {
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(50, remainingMs)));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const flushTs = new Date().toISOString();
|
|
335
|
+
for (const observer of observers) {
|
|
336
|
+
observer.process = this.refreshStatus(this.readProcess(observer.process.pid));
|
|
337
|
+
observer.result.status = observer.process.status;
|
|
338
|
+
appendPeekMessages(observer.result, observer.stdoutExtractor.flush(flushTs));
|
|
339
|
+
appendPeekMessages(observer.result, observer.stderrExtractor.flush(flushTs));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
peek_started_at: startedAt.toISOString(),
|
|
344
|
+
observed_duration_sec: observedDurationSec(startedAtMs),
|
|
345
|
+
processes,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
223
349
|
async killProcess(pid: number): Promise<{ pid: number; status: string; message: string }> {
|
|
224
350
|
const process = this.readProcess(pid);
|
|
225
351
|
const refreshed = this.refreshStatus(process);
|
|
@@ -277,6 +403,59 @@ export class CliProcessService {
|
|
|
277
403
|
};
|
|
278
404
|
}
|
|
279
405
|
|
|
406
|
+
private async startDetachedOpenCodeProcess(
|
|
407
|
+
cmd: Awaited<ReturnType<typeof buildCliCommand>>,
|
|
408
|
+
model: string | undefined,
|
|
409
|
+
): Promise<{ pid: number; status: 'started'; agent: AgentType; message: string }> {
|
|
410
|
+
const cwdKey = this.resolveCwdKey(cmd.cwd);
|
|
411
|
+
const wrapperPath = this.ensureOpenCodeWrapperScript();
|
|
412
|
+
|
|
413
|
+
const childProcess = spawn(wrapperPath, [this.stateDir, cwdKey, cmd.cliPath, ...cmd.args], {
|
|
414
|
+
cwd: cmd.cwd,
|
|
415
|
+
detached: true,
|
|
416
|
+
stdio: 'ignore',
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const pid = childProcess.pid;
|
|
420
|
+
childProcess.unref();
|
|
421
|
+
|
|
422
|
+
if (!pid) {
|
|
423
|
+
throw new Error(`Failed to start ${cmd.agent} CLI process`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const processDir = this.resolveProcessDir(cmd.cwd, pid);
|
|
427
|
+
mkdirSync(processDir, { recursive: true });
|
|
428
|
+
const stdoutPath = this.resolveStdoutPath(processDir);
|
|
429
|
+
const stderrPath = this.resolveStderrPath(processDir);
|
|
430
|
+
if (!existsSync(stdoutPath)) {
|
|
431
|
+
writeFileSync(stdoutPath, '');
|
|
432
|
+
}
|
|
433
|
+
if (!existsSync(stderrPath)) {
|
|
434
|
+
writeFileSync(stderrPath, '');
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const storedProcess: StoredProcess = {
|
|
438
|
+
pid,
|
|
439
|
+
prompt: cmd.prompt,
|
|
440
|
+
workFolder: cmd.cwd,
|
|
441
|
+
cwdKey,
|
|
442
|
+
model,
|
|
443
|
+
toolType: cmd.agent,
|
|
444
|
+
startTime: new Date().toISOString(),
|
|
445
|
+
stdoutPath,
|
|
446
|
+
stderrPath,
|
|
447
|
+
status: 'running',
|
|
448
|
+
};
|
|
449
|
+
this.writeProcess(storedProcess);
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
pid,
|
|
453
|
+
status: 'started',
|
|
454
|
+
agent: cmd.agent,
|
|
455
|
+
message: `${cmd.agent} process started successfully`,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
280
459
|
private readAllProcesses(): StoredProcess[] {
|
|
281
460
|
const cwdsDir = this.resolveCwdsDir();
|
|
282
461
|
if (!existsSync(cwdsDir)) {
|
|
@@ -320,13 +499,47 @@ export class CliProcessService {
|
|
|
320
499
|
}
|
|
321
500
|
|
|
322
501
|
private refreshStatus(process: StoredProcess): StoredProcess {
|
|
323
|
-
if (process.status
|
|
502
|
+
if (process.status !== 'running') {
|
|
503
|
+
return process;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const persistedExitStatus = this.readExitStatus(process);
|
|
507
|
+
if (persistedExitStatus) {
|
|
508
|
+
process.status = persistedExitStatus.status;
|
|
509
|
+
process.exitCode = persistedExitStatus.exitCode;
|
|
510
|
+
this.writeProcess(process);
|
|
511
|
+
return process;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (!isProcessRunning(process.pid)) {
|
|
324
515
|
process.status = 'completed';
|
|
325
516
|
this.writeProcess(process);
|
|
326
517
|
}
|
|
327
518
|
return process;
|
|
328
519
|
}
|
|
329
520
|
|
|
521
|
+
private readExitStatus(process: StoredProcess): StoredExitStatus | null {
|
|
522
|
+
if (process.toolType !== 'opencode') {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const exitMetaPath = this.resolveExitStatusPath(this.resolveStoredProcessDir(process));
|
|
527
|
+
if (!existsSync(exitMetaPath)) {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
const parsed = JSON.parse(readFileSync(exitMetaPath, 'utf-8')) as StoredExitStatus;
|
|
533
|
+
if (parsed.status === 'completed' || parsed.status === 'failed') {
|
|
534
|
+
return parsed;
|
|
535
|
+
}
|
|
536
|
+
} catch {
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
|
|
330
543
|
private readTextFileSafe(filePath: string): string {
|
|
331
544
|
if (!existsSync(filePath)) {
|
|
332
545
|
return '';
|
|
@@ -334,6 +547,37 @@ export class CliProcessService {
|
|
|
334
547
|
return readFileSync(filePath, 'utf-8');
|
|
335
548
|
}
|
|
336
549
|
|
|
550
|
+
private fileSizeSafe(filePath: string): number {
|
|
551
|
+
if (!existsSync(filePath)) {
|
|
552
|
+
return 0;
|
|
553
|
+
}
|
|
554
|
+
return statSync(filePath).size;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private readTextFromOffset(filePath: string, offset: number): { text: string; offset: number } {
|
|
558
|
+
if (!existsSync(filePath)) {
|
|
559
|
+
return { text: '', offset };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const size = statSync(filePath).size;
|
|
563
|
+
if (size <= offset) {
|
|
564
|
+
return { text: '', offset: size };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const fd = openSync(filePath, 'r');
|
|
568
|
+
try {
|
|
569
|
+
const length = size - offset;
|
|
570
|
+
const buffer = Buffer.alloc(length);
|
|
571
|
+
const bytesRead = readSync(fd, buffer, 0, length, offset);
|
|
572
|
+
return {
|
|
573
|
+
text: buffer.subarray(0, bytesRead).toString('utf-8'),
|
|
574
|
+
offset: size,
|
|
575
|
+
};
|
|
576
|
+
} finally {
|
|
577
|
+
closeSync(fd);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
337
581
|
private resolveCwdsDir(): string {
|
|
338
582
|
return join(this.stateDir, 'cwds');
|
|
339
583
|
}
|
|
@@ -365,6 +609,14 @@ export class CliProcessService {
|
|
|
365
609
|
return join(processDir, 'stderr.log');
|
|
366
610
|
}
|
|
367
611
|
|
|
612
|
+
private resolveExitStatusPath(processDir: string): string {
|
|
613
|
+
return join(processDir, 'exit-status.json');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private resolveOpenCodeWrapperPath(): string {
|
|
617
|
+
return join(this.stateDir, 'opencode-detached-wrapper.sh');
|
|
618
|
+
}
|
|
619
|
+
|
|
368
620
|
private resolveStdoutPathForPidPlaceholder(): string {
|
|
369
621
|
return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stdout.log`);
|
|
370
622
|
}
|
|
@@ -373,6 +625,41 @@ export class CliProcessService {
|
|
|
373
625
|
return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stderr.log`);
|
|
374
626
|
}
|
|
375
627
|
|
|
628
|
+
private ensureOpenCodeWrapperScript(): string {
|
|
629
|
+
const wrapperPath = this.resolveOpenCodeWrapperPath();
|
|
630
|
+
if (existsSync(wrapperPath)) {
|
|
631
|
+
return wrapperPath;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
writeFileSync(
|
|
635
|
+
wrapperPath,
|
|
636
|
+
`#!/bin/sh
|
|
637
|
+
set +e
|
|
638
|
+
state_dir="$1"
|
|
639
|
+
cwd_key="$2"
|
|
640
|
+
shift 2
|
|
641
|
+
pid="$$"
|
|
642
|
+
process_dir="$state_dir/cwds/$cwd_key/$pid"
|
|
643
|
+
stdout_path="$process_dir/stdout.log"
|
|
644
|
+
stderr_path="$process_dir/stderr.log"
|
|
645
|
+
exit_meta_path="$process_dir/exit-status.json"
|
|
646
|
+
mkdir -p "$process_dir"
|
|
647
|
+
: > "$stdout_path"
|
|
648
|
+
: > "$stderr_path"
|
|
649
|
+
"$@" >> "$stdout_path" 2>> "$stderr_path"
|
|
650
|
+
exit_code="$?"
|
|
651
|
+
status="completed"
|
|
652
|
+
if [ "$exit_code" -ne 0 ]; then
|
|
653
|
+
status="failed"
|
|
654
|
+
fi
|
|
655
|
+
printf '{\n "status": "%s",\n "exitCode": %s\n}\n' "$status" "$exit_code" > "$exit_meta_path"
|
|
656
|
+
exit "$exit_code"
|
|
657
|
+
`,
|
|
658
|
+
);
|
|
659
|
+
chmodSync(wrapperPath, 0o755);
|
|
660
|
+
return wrapperPath;
|
|
661
|
+
}
|
|
662
|
+
|
|
376
663
|
private renamePlaceholderFile(fromPath: string, toPath: string): void {
|
|
377
664
|
renameSync(fromPath, toPath);
|
|
378
665
|
}
|
package/src/cli-utils.ts
CHANGED
|
@@ -3,10 +3,8 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import * as path from 'path';
|
|
5
5
|
|
|
6
|
-
// Define debugMode globally using const
|
|
7
6
|
const debugMode = process.env.MCP_CLAUDE_DEBUG === 'true';
|
|
8
7
|
|
|
9
|
-
// Dedicated debug logging function
|
|
10
8
|
export function debugLog(message?: any, ...optionalParams: any[]): void {
|
|
11
9
|
if (debugMode) {
|
|
12
10
|
console.error(message, ...optionalParams);
|
|
@@ -21,6 +19,24 @@ export interface CliBinaryStatus {
|
|
|
21
19
|
error?: string;
|
|
22
20
|
}
|
|
23
21
|
|
|
22
|
+
export type CliBinaryName = 'claude' | 'codex' | 'gemini' | 'forge' | 'opencode';
|
|
23
|
+
|
|
24
|
+
export interface CliPaths {
|
|
25
|
+
claude: string;
|
|
26
|
+
codex: string;
|
|
27
|
+
gemini: string;
|
|
28
|
+
forge: string;
|
|
29
|
+
opencode: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CliDoctorStatus {
|
|
33
|
+
claude: CliBinaryStatus;
|
|
34
|
+
codex: CliBinaryStatus;
|
|
35
|
+
gemini: CliBinaryStatus;
|
|
36
|
+
forge: CliBinaryStatus;
|
|
37
|
+
opencode: CliBinaryStatus;
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
function getPathDelimiter(): string {
|
|
25
41
|
return process.platform === 'win32' ? ';' : ':';
|
|
26
42
|
}
|
|
@@ -75,7 +91,7 @@ function inspectCliBinary(options: {
|
|
|
75
91
|
envVarName: string;
|
|
76
92
|
customCliName: string | undefined;
|
|
77
93
|
defaultCliName: string;
|
|
78
|
-
localInstallPath
|
|
94
|
+
localInstallPath?: string;
|
|
79
95
|
}): CliBinaryStatus {
|
|
80
96
|
const configuredCommand = options.customCliName || options.defaultCliName;
|
|
81
97
|
|
|
@@ -109,7 +125,7 @@ function inspectCliBinary(options: {
|
|
|
109
125
|
};
|
|
110
126
|
}
|
|
111
127
|
|
|
112
|
-
if (isExecutableFile(options.localInstallPath)) {
|
|
128
|
+
if (options.localInstallPath && isExecutableFile(options.localInstallPath)) {
|
|
113
129
|
return {
|
|
114
130
|
configuredCommand,
|
|
115
131
|
resolvedPath: options.localInstallPath,
|
|
@@ -148,13 +164,11 @@ function isExecutableFile(filePath: string): boolean {
|
|
|
148
164
|
}
|
|
149
165
|
}
|
|
150
166
|
|
|
151
|
-
type CliBinaryName = 'claude' | 'codex' | 'gemini' | 'forge';
|
|
152
|
-
|
|
153
167
|
function getCliBinaryConfig(name: CliBinaryName): {
|
|
154
168
|
envVarName: string;
|
|
155
169
|
customCliName: string | undefined;
|
|
156
170
|
defaultCliName: string;
|
|
157
|
-
localInstallPath
|
|
171
|
+
localInstallPath?: string;
|
|
158
172
|
} {
|
|
159
173
|
if (name === 'claude') {
|
|
160
174
|
return {
|
|
@@ -183,6 +197,14 @@ function getCliBinaryConfig(name: CliBinaryName): {
|
|
|
183
197
|
};
|
|
184
198
|
}
|
|
185
199
|
|
|
200
|
+
if (name === 'opencode') {
|
|
201
|
+
return {
|
|
202
|
+
envVarName: 'OPENCODE_CLI_NAME',
|
|
203
|
+
customCliName: process.env.OPENCODE_CLI_NAME,
|
|
204
|
+
defaultCliName: 'opencode',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
186
208
|
return {
|
|
187
209
|
envVarName: 'GEMINI_CLI_NAME',
|
|
188
210
|
customCliName: process.env.GEMINI_CLI_NAME,
|
|
@@ -195,58 +217,40 @@ function getCliBinaryStatus(name: CliBinaryName): CliBinaryStatus {
|
|
|
195
217
|
return inspectCliBinary(getCliBinaryConfig(name));
|
|
196
218
|
}
|
|
197
219
|
|
|
198
|
-
export function getCliDoctorStatus(): {
|
|
199
|
-
claude: CliBinaryStatus;
|
|
200
|
-
codex: CliBinaryStatus;
|
|
201
|
-
gemini: CliBinaryStatus;
|
|
202
|
-
forge: CliBinaryStatus;
|
|
203
|
-
} {
|
|
220
|
+
export function getCliDoctorStatus(): CliDoctorStatus {
|
|
204
221
|
return {
|
|
205
222
|
claude: getCliBinaryStatus('claude'),
|
|
206
223
|
codex: getCliBinaryStatus('codex'),
|
|
207
224
|
gemini: getCliBinaryStatus('gemini'),
|
|
208
225
|
forge: getCliBinaryStatus('forge'),
|
|
226
|
+
opencode: getCliBinaryStatus('opencode'),
|
|
209
227
|
};
|
|
210
228
|
}
|
|
211
229
|
|
|
212
|
-
/**
|
|
213
|
-
* Determine the Gemini CLI command/path.
|
|
214
|
-
* Similar to findClaudeCli but for Gemini
|
|
215
|
-
*/
|
|
216
230
|
export function findGeminiCli(): string {
|
|
217
231
|
debugLog('[Debug] Attempting to find Gemini CLI...');
|
|
218
232
|
const status = getCliBinaryStatus('gemini');
|
|
219
233
|
return getCliCommandOrThrow(status);
|
|
220
234
|
}
|
|
221
235
|
|
|
222
|
-
/**
|
|
223
|
-
* Determine the Codex CLI command/path.
|
|
224
|
-
* Similar to findClaudeCli but for Codex
|
|
225
|
-
*/
|
|
226
236
|
export function findCodexCli(): string {
|
|
227
237
|
debugLog('[Debug] Attempting to find Codex CLI...');
|
|
228
238
|
const status = getCliBinaryStatus('codex');
|
|
229
239
|
return getCliCommandOrThrow(status);
|
|
230
240
|
}
|
|
231
241
|
|
|
232
|
-
/**
|
|
233
|
-
* Determine the Forge CLI command/path.
|
|
234
|
-
*/
|
|
235
242
|
export function findForgeCli(): string {
|
|
236
243
|
debugLog('[Debug] Attempting to find Forge CLI...');
|
|
237
244
|
const status = getCliBinaryStatus('forge');
|
|
238
245
|
return getCliCommandOrThrow(status);
|
|
239
246
|
}
|
|
240
247
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
* 2. Checks for Claude CLI at the local user path: ~/.claude/local/claude.
|
|
248
|
-
* 3. If not found, defaults to the CLI name (or 'claude'), relying on the system's PATH for lookup.
|
|
249
|
-
*/
|
|
248
|
+
export function findOpencodeCli(): string {
|
|
249
|
+
debugLog('[Debug] Attempting to find OpenCode CLI...');
|
|
250
|
+
const status = getCliBinaryStatus('opencode');
|
|
251
|
+
return getCliCommandOrThrow(status);
|
|
252
|
+
}
|
|
253
|
+
|
|
250
254
|
export function findClaudeCli(): string {
|
|
251
255
|
debugLog('[Debug] Attempting to find Claude CLI...');
|
|
252
256
|
const status = getCliBinaryStatus('claude');
|
package/src/cli.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { buildCliCommand } from './cli-builder.js';
|
|
4
|
-
import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
|
|
4
|
+
import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from './cli-utils.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Minimal argv parser. No external dependencies.
|
|
@@ -35,17 +35,18 @@ function parseArgs(argv: string[]): Record<string, string> {
|
|
|
35
35
|
const USAGE = `Usage: npm run -s cli.run -- --model <model> --workFolder <path> --prompt "..." [options]
|
|
36
36
|
|
|
37
37
|
Options:
|
|
38
|
-
--model Model name or alias (e.g. sonnet, opus, gpt-5.2-codex, gemini-2.5-pro, forge)
|
|
38
|
+
--model Model name or alias (e.g. sonnet, opus, gpt-5.2-codex, gemini-2.5-pro, forge, opencode, oc-openai/gpt-5.4)
|
|
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
|
-
--session_id Session ID to resume
|
|
43
|
-
--reasoning_effort Claude/Codex only: Claude=low|medium|high, Codex=low|medium|high|xhigh
|
|
42
|
+
--session_id Session ID to resume, including OpenCode in-place resumes
|
|
43
|
+
--reasoning_effort Claude/Codex only: Claude=low|medium|high, Codex=low|medium|high|xhigh; unsupported for Gemini, Forge, and OpenCode
|
|
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:
|
|
47
47
|
npm run -s cli.run -- ... > raw.txt
|
|
48
48
|
npm run -s cli.run.parse -- --agent claude < raw.txt
|
|
49
|
+
npm run -s cli.run.parse -- --agent opencode < raw.txt
|
|
49
50
|
`;
|
|
50
51
|
|
|
51
52
|
async function main(): Promise<void> {
|
|
@@ -74,6 +75,7 @@ async function main(): Promise<void> {
|
|
|
74
75
|
codex: findCodexCli(),
|
|
75
76
|
gemini: findGeminiCli(),
|
|
76
77
|
forge: findForgeCli(),
|
|
78
|
+
opencode: findOpencodeCli(),
|
|
77
79
|
};
|
|
78
80
|
|
|
79
81
|
// Build command
|