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/dist/cli-builder.js
CHANGED
|
@@ -3,7 +3,8 @@ import { resolve as pathResolve, isAbsolute } from 'node:path';
|
|
|
3
3
|
import { MODEL_ALIASES } from './model-catalog.js';
|
|
4
4
|
export const ALLOWED_REASONING_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh']);
|
|
5
5
|
const CLAUDE_REASONING_EFFORTS = new Set(['low', 'medium', 'high']);
|
|
6
|
-
|
|
6
|
+
const OPENCODE_MODEL_ERROR = 'Invalid OpenCode model. Expected exact syntax oc-<provider/model>.';
|
|
7
|
+
function getStandardAgentForModel(model) {
|
|
7
8
|
if (model === 'forge') {
|
|
8
9
|
return 'forge';
|
|
9
10
|
}
|
|
@@ -15,19 +16,53 @@ function getAgentForModel(model) {
|
|
|
15
16
|
}
|
|
16
17
|
return 'claude';
|
|
17
18
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
function isPotentialOpenCodeExplicitModel(rawModel) {
|
|
20
|
+
return rawModel.startsWith('oc-') || rawModel.trim().startsWith('oc-');
|
|
21
|
+
}
|
|
22
|
+
function extractOpenCodeModel(rawModel) {
|
|
23
|
+
if (rawModel !== rawModel.trim()) {
|
|
24
|
+
throw new Error(OPENCODE_MODEL_ERROR);
|
|
25
|
+
}
|
|
26
|
+
if (!rawModel.startsWith('oc-')) {
|
|
27
|
+
throw new Error(OPENCODE_MODEL_ERROR);
|
|
28
|
+
}
|
|
29
|
+
const remainder = rawModel.slice(3);
|
|
30
|
+
const slashIndex = remainder.indexOf('/');
|
|
31
|
+
if (slashIndex === -1) {
|
|
32
|
+
throw new Error(OPENCODE_MODEL_ERROR);
|
|
33
|
+
}
|
|
34
|
+
const provider = remainder.slice(0, slashIndex);
|
|
35
|
+
const model = remainder.slice(slashIndex + 1);
|
|
36
|
+
if (!provider || !model) {
|
|
37
|
+
throw new Error(OPENCODE_MODEL_ERROR);
|
|
38
|
+
}
|
|
39
|
+
return remainder;
|
|
40
|
+
}
|
|
41
|
+
function resolveModelSelection(rawModel) {
|
|
42
|
+
if (rawModel === 'opencode') {
|
|
43
|
+
return {
|
|
44
|
+
agent: 'opencode',
|
|
45
|
+
resolvedModel: rawModel,
|
|
46
|
+
openCodeModel: null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (isPotentialOpenCodeExplicitModel(rawModel)) {
|
|
50
|
+
return {
|
|
51
|
+
agent: 'opencode',
|
|
52
|
+
resolvedModel: rawModel,
|
|
53
|
+
openCodeModel: extractOpenCodeModel(rawModel),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const resolvedModel = resolveModelAlias(rawModel);
|
|
57
|
+
return {
|
|
58
|
+
agent: getStandardAgentForModel(resolvedModel),
|
|
59
|
+
resolvedModel,
|
|
60
|
+
openCodeModel: null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
23
63
|
export function resolveModelAlias(model) {
|
|
24
64
|
return MODEL_ALIASES[model] || model;
|
|
25
65
|
}
|
|
26
|
-
/**
|
|
27
|
-
* Validates and normalizes reasoning effort parameter.
|
|
28
|
-
* @returns normalized reasoning effort string, or '' if not applicable
|
|
29
|
-
* @throws Error for invalid values (plain Error, not MCP-specific)
|
|
30
|
-
*/
|
|
31
66
|
export function getReasoningEffort(model, rawValue) {
|
|
32
67
|
if (typeof rawValue !== 'string') {
|
|
33
68
|
return '';
|
|
@@ -36,11 +71,14 @@ export function getReasoningEffort(model, rawValue) {
|
|
|
36
71
|
if (!trimmed) {
|
|
37
72
|
return '';
|
|
38
73
|
}
|
|
74
|
+
if (model === 'opencode' || model.startsWith('oc-')) {
|
|
75
|
+
throw new Error('reasoning_effort is not supported for opencode.');
|
|
76
|
+
}
|
|
39
77
|
const normalized = trimmed.toLowerCase();
|
|
40
78
|
if (!ALLOWED_REASONING_EFFORTS.has(normalized)) {
|
|
41
79
|
throw new Error(`Invalid reasoning_effort: ${rawValue}. Allowed values: low, medium, high, xhigh.`);
|
|
42
80
|
}
|
|
43
|
-
const agent =
|
|
81
|
+
const agent = getStandardAgentForModel(model);
|
|
44
82
|
if (agent === 'forge') {
|
|
45
83
|
throw new Error('reasoning_effort is not supported for forge.');
|
|
46
84
|
}
|
|
@@ -52,17 +90,10 @@ export function getReasoningEffort(model, rawValue) {
|
|
|
52
90
|
}
|
|
53
91
|
return normalized;
|
|
54
92
|
}
|
|
55
|
-
/**
|
|
56
|
-
* Build a CLI command from the given options.
|
|
57
|
-
* This is a pure function (aside from filesystem reads for prompt_file / workFolder validation).
|
|
58
|
-
* @throws Error on validation failures
|
|
59
|
-
*/
|
|
60
93
|
export function buildCliCommand(options) {
|
|
61
|
-
// Validate workFolder
|
|
62
94
|
if (!options.workFolder || typeof options.workFolder !== 'string') {
|
|
63
95
|
throw new Error('Missing or invalid required parameter: workFolder');
|
|
64
96
|
}
|
|
65
|
-
// Validate prompt / prompt_file
|
|
66
97
|
const hasPrompt = !!options.prompt && typeof options.prompt === 'string' && options.prompt.trim() !== '';
|
|
67
98
|
const hasPromptFile = !!options.prompt_file && typeof options.prompt_file === 'string' && options.prompt_file.trim() !== '';
|
|
68
99
|
if (!hasPrompt && !hasPromptFile) {
|
|
@@ -71,7 +102,6 @@ export function buildCliCommand(options) {
|
|
|
71
102
|
if (hasPrompt && hasPromptFile) {
|
|
72
103
|
throw new Error('Cannot specify both prompt and prompt_file. Please use only one.');
|
|
73
104
|
}
|
|
74
|
-
// Determine prompt
|
|
75
105
|
let prompt;
|
|
76
106
|
if (hasPrompt) {
|
|
77
107
|
prompt = options.prompt;
|
|
@@ -90,16 +120,12 @@ export function buildCliCommand(options) {
|
|
|
90
120
|
throw new Error(`Failed to read prompt file: ${error.message}`);
|
|
91
121
|
}
|
|
92
122
|
}
|
|
93
|
-
// Resolve workFolder
|
|
94
123
|
const cwd = pathResolve(options.workFolder);
|
|
95
124
|
if (!existsSync(cwd)) {
|
|
96
125
|
throw new Error(`Working folder does not exist: ${options.workFolder}`);
|
|
97
126
|
}
|
|
98
|
-
// Resolve model
|
|
99
127
|
const rawModel = options.model || '';
|
|
100
|
-
const resolvedModel =
|
|
101
|
-
const agent = getAgentForModel(resolvedModel);
|
|
102
|
-
// Special handling for ultra aliases: default to higher reasoning if not specified
|
|
128
|
+
const { agent, resolvedModel, openCodeModel } = resolveModelSelection(rawModel);
|
|
103
129
|
let reasoningEffortArg = options.reasoning_effort;
|
|
104
130
|
if (!reasoningEffortArg) {
|
|
105
131
|
if (rawModel === 'codex-ultra') {
|
|
@@ -109,8 +135,10 @@ export function buildCliCommand(options) {
|
|
|
109
135
|
reasoningEffortArg = 'high';
|
|
110
136
|
}
|
|
111
137
|
}
|
|
112
|
-
const
|
|
113
|
-
|
|
138
|
+
const reasoningTargetModel = rawModel === 'opencode' || rawModel.startsWith('oc-')
|
|
139
|
+
? rawModel
|
|
140
|
+
: (resolvedModel || rawModel);
|
|
141
|
+
const reasoningEffort = getReasoningEffort(reasoningTargetModel, reasoningEffortArg);
|
|
114
142
|
let cliPath;
|
|
115
143
|
let args;
|
|
116
144
|
if (agent === 'codex') {
|
|
@@ -131,7 +159,7 @@ export function buildCliCommand(options) {
|
|
|
131
159
|
}
|
|
132
160
|
else if (agent === 'gemini') {
|
|
133
161
|
cliPath = options.cliPaths.gemini;
|
|
134
|
-
args = ['-y', '--output-format', 'json'];
|
|
162
|
+
args = ['-y', '--output-format', 'stream-json'];
|
|
135
163
|
if (options.session_id && typeof options.session_id === 'string') {
|
|
136
164
|
args.push('-r', options.session_id);
|
|
137
165
|
}
|
|
@@ -148,6 +176,17 @@ export function buildCliCommand(options) {
|
|
|
148
176
|
}
|
|
149
177
|
args.push('-p', prompt);
|
|
150
178
|
}
|
|
179
|
+
else if (agent === 'opencode') {
|
|
180
|
+
cliPath = options.cliPaths.opencode;
|
|
181
|
+
args = ['run', '--format', 'json', '--dir', cwd];
|
|
182
|
+
if (options.session_id && typeof options.session_id === 'string') {
|
|
183
|
+
args.push('--session', options.session_id);
|
|
184
|
+
}
|
|
185
|
+
if (openCodeModel) {
|
|
186
|
+
args.push('--model', openCodeModel);
|
|
187
|
+
}
|
|
188
|
+
args.push(prompt);
|
|
189
|
+
}
|
|
151
190
|
else {
|
|
152
191
|
cliPath = options.cliPaths.claude;
|
|
153
192
|
args = ['--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose'];
|
package/dist/cli-parse.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
|
|
3
|
-
const AGENTS = ['claude', 'codex', 'gemini', 'forge'];
|
|
4
|
-
const USAGE = `Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini|forge>
|
|
2
|
+
import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput } from './parsers.js';
|
|
3
|
+
const AGENTS = ['claude', 'codex', 'gemini', 'forge', 'opencode'];
|
|
4
|
+
const USAGE = `Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini|forge|opencode>
|
|
5
5
|
|
|
6
6
|
Reads raw CLI output from stdin and outputs parsed JSON to stdout.
|
|
7
7
|
|
|
8
8
|
Options:
|
|
9
|
-
--agent Agent type: claude, codex, gemini, or
|
|
9
|
+
--agent Agent type: claude, codex, gemini, forge, or opencode (required)
|
|
10
10
|
--help Show this help message
|
|
11
11
|
|
|
12
12
|
Examples:
|
|
13
13
|
npm run -s cli.run -- --model sonnet --workFolder /tmp --prompt "hi" > raw.txt
|
|
14
14
|
npm run -s cli.run.parse -- --agent claude < raw.txt
|
|
15
15
|
|
|
16
|
+
npm run -s cli.run -- --model opencode --workFolder /tmp --prompt "hi" > raw.txt
|
|
17
|
+
npm run -s cli.run.parse -- --agent opencode < raw.txt
|
|
18
|
+
|
|
16
19
|
# Or pipe directly
|
|
17
20
|
npm run -s cli.run -- --model sonnet --workFolder /tmp --prompt "hi" | npm run -s cli.run.parse -- --agent claude
|
|
18
21
|
`;
|
|
@@ -56,7 +59,7 @@ async function main() {
|
|
|
56
59
|
}
|
|
57
60
|
const agent = args.agent;
|
|
58
61
|
if (!agent || !AGENTS.includes(agent)) {
|
|
59
|
-
process.stderr.write(`Error: --agent is required (claude, codex, gemini, or
|
|
62
|
+
process.stderr.write(`Error: --agent is required (claude, codex, gemini, forge, or opencode)\n\n`);
|
|
60
63
|
process.stderr.write(USAGE);
|
|
61
64
|
process.exit(1);
|
|
62
65
|
}
|
|
@@ -79,6 +82,9 @@ async function main() {
|
|
|
79
82
|
case 'forge':
|
|
80
83
|
parsed = parseForgeOutput(input);
|
|
81
84
|
break;
|
|
85
|
+
case 'opencode':
|
|
86
|
+
parsed = parseOpenCodeOutput(input);
|
|
87
|
+
break;
|
|
82
88
|
}
|
|
83
89
|
process.stdout.write(JSON.stringify(parsed, null, 2) + '\n');
|
|
84
90
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import { join, basename, dirname } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { buildCliCommand } from './cli-builder.js';
|
|
6
|
-
import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
|
|
7
|
-
import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
|
|
6
|
+
import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from './cli-utils.js';
|
|
7
|
+
import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput, PeekMessageExtractor } from './parsers.js';
|
|
8
8
|
import { buildProcessResult } from './process-result.js';
|
|
9
|
+
import { appendPeekMessages, buildNotFoundPeekProcess, observedDurationSec, validatePeekPids, validatePeekTimeSec, } from './peek.js';
|
|
9
10
|
function resolveDefaultStateDir() {
|
|
10
11
|
return process.env.AI_CLI_STATE_DIR || join(homedir(), '.local', 'state', 'ai-cli');
|
|
11
12
|
}
|
|
@@ -27,6 +28,27 @@ function normalizeCwdForStorage(cwd) {
|
|
|
27
28
|
.map((char) => (/^[A-Za-z0-9.-]$/.test(char) ? char : `_${char.charCodeAt(0).toString(16).padStart(2, '0')}`))
|
|
28
29
|
.join('');
|
|
29
30
|
}
|
|
31
|
+
function parseAgentOutput(agent, stdout, stderr) {
|
|
32
|
+
if (agent === 'codex') {
|
|
33
|
+
return parseCodexOutput(`${stdout}\n${stderr}`);
|
|
34
|
+
}
|
|
35
|
+
if (!stdout) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (agent === 'claude') {
|
|
39
|
+
return parseClaudeOutput(stdout);
|
|
40
|
+
}
|
|
41
|
+
if (agent === 'gemini') {
|
|
42
|
+
return parseGeminiOutput(stdout);
|
|
43
|
+
}
|
|
44
|
+
if (agent === 'forge') {
|
|
45
|
+
return parseForgeOutput(stdout);
|
|
46
|
+
}
|
|
47
|
+
if (agent === 'opencode') {
|
|
48
|
+
return parseOpenCodeOutput(stdout);
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
30
52
|
export class CliProcessService {
|
|
31
53
|
stateDir;
|
|
32
54
|
cliPaths;
|
|
@@ -37,6 +59,7 @@ export class CliProcessService {
|
|
|
37
59
|
codex: findCodexCli(),
|
|
38
60
|
gemini: findGeminiCli(),
|
|
39
61
|
forge: findForgeCli(),
|
|
62
|
+
opencode: findOpencodeCli(),
|
|
40
63
|
};
|
|
41
64
|
mkdirSync(this.stateDir, { recursive: true });
|
|
42
65
|
}
|
|
@@ -50,6 +73,9 @@ export class CliProcessService {
|
|
|
50
73
|
reasoning_effort: options.reasoning_effort,
|
|
51
74
|
cliPaths: this.cliPaths,
|
|
52
75
|
});
|
|
76
|
+
if (cmd.agent === 'opencode') {
|
|
77
|
+
return this.startDetachedOpenCodeProcess(cmd, options.model);
|
|
78
|
+
}
|
|
53
79
|
const stdoutPath = this.resolveStdoutPathForPidPlaceholder();
|
|
54
80
|
const stderrPath = this.resolveStderrPathForPidPlaceholder();
|
|
55
81
|
let stdoutFd;
|
|
@@ -119,26 +145,12 @@ export class CliProcessService {
|
|
|
119
145
|
const refreshed = this.refreshStatus(storedProcess);
|
|
120
146
|
const stdout = this.readTextFileSafe(refreshed.stdoutPath);
|
|
121
147
|
const stderr = this.readTextFileSafe(refreshed.stderrPath);
|
|
122
|
-
|
|
123
|
-
if (refreshed.toolType === 'codex') {
|
|
124
|
-
agentOutput = parseCodexOutput(`${stdout}\n${stderr}`);
|
|
125
|
-
}
|
|
126
|
-
else if (stdout) {
|
|
127
|
-
if (refreshed.toolType === 'claude') {
|
|
128
|
-
agentOutput = parseClaudeOutput(stdout);
|
|
129
|
-
}
|
|
130
|
-
else if (refreshed.toolType === 'gemini') {
|
|
131
|
-
agentOutput = parseGeminiOutput(stdout);
|
|
132
|
-
}
|
|
133
|
-
else if (refreshed.toolType === 'forge') {
|
|
134
|
-
agentOutput = parseForgeOutput(stdout);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
148
|
+
const agentOutput = parseAgentOutput(refreshed.toolType, stdout, stderr);
|
|
137
149
|
return buildProcessResult({
|
|
138
150
|
pid,
|
|
139
151
|
agent: refreshed.toolType,
|
|
140
152
|
status: refreshed.status,
|
|
141
|
-
exitCode:
|
|
153
|
+
exitCode: refreshed.exitCode,
|
|
142
154
|
startTime: refreshed.startTime,
|
|
143
155
|
workFolder: refreshed.workFolder,
|
|
144
156
|
prompt: refreshed.prompt,
|
|
@@ -163,6 +175,79 @@ export class CliProcessService {
|
|
|
163
175
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
164
176
|
}
|
|
165
177
|
}
|
|
178
|
+
async peekProcesses(pids, peekTimeSec = 10) {
|
|
179
|
+
const targetPids = validatePeekPids(pids);
|
|
180
|
+
const targetPeekTimeSec = validatePeekTimeSec(peekTimeSec);
|
|
181
|
+
const processes = [];
|
|
182
|
+
const observers = [];
|
|
183
|
+
for (const pid of targetPids) {
|
|
184
|
+
let process;
|
|
185
|
+
try {
|
|
186
|
+
process = this.refreshStatus(this.readProcess(pid));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
processes.push(buildNotFoundPeekProcess(pid));
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const result = {
|
|
193
|
+
pid,
|
|
194
|
+
agent: process.toolType,
|
|
195
|
+
status: process.status,
|
|
196
|
+
messages: [],
|
|
197
|
+
truncated: false,
|
|
198
|
+
error: null,
|
|
199
|
+
};
|
|
200
|
+
processes.push(result);
|
|
201
|
+
observers.push({
|
|
202
|
+
process,
|
|
203
|
+
result,
|
|
204
|
+
stdoutExtractor: new PeekMessageExtractor(process.toolType),
|
|
205
|
+
stderrExtractor: new PeekMessageExtractor(process.toolType),
|
|
206
|
+
stdoutOffset: this.fileSizeSafe(process.stdoutPath),
|
|
207
|
+
stderrOffset: this.fileSizeSafe(process.stderrPath),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const startedAt = new Date();
|
|
211
|
+
const startedAtMs = Date.now();
|
|
212
|
+
const deadlineMs = startedAtMs + targetPeekTimeSec * 1000;
|
|
213
|
+
while (Date.now() <= deadlineMs) {
|
|
214
|
+
const observedAt = new Date().toISOString();
|
|
215
|
+
let allTerminal = true;
|
|
216
|
+
for (const observer of observers) {
|
|
217
|
+
const stdoutRead = this.readTextFromOffset(observer.process.stdoutPath, observer.stdoutOffset);
|
|
218
|
+
observer.stdoutOffset = stdoutRead.offset;
|
|
219
|
+
appendPeekMessages(observer.result, observer.stdoutExtractor.push(stdoutRead.text, observedAt));
|
|
220
|
+
const stderrRead = this.readTextFromOffset(observer.process.stderrPath, observer.stderrOffset);
|
|
221
|
+
observer.stderrOffset = stderrRead.offset;
|
|
222
|
+
appendPeekMessages(observer.result, observer.stderrExtractor.push(stderrRead.text, observedAt));
|
|
223
|
+
observer.process = this.refreshStatus(this.readProcess(observer.process.pid));
|
|
224
|
+
observer.result.status = observer.process.status;
|
|
225
|
+
if (observer.process.status === 'running') {
|
|
226
|
+
allTerminal = false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (allTerminal) {
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
const remainingMs = deadlineMs - Date.now();
|
|
233
|
+
if (remainingMs <= 0) {
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(50, remainingMs)));
|
|
237
|
+
}
|
|
238
|
+
const flushTs = new Date().toISOString();
|
|
239
|
+
for (const observer of observers) {
|
|
240
|
+
observer.process = this.refreshStatus(this.readProcess(observer.process.pid));
|
|
241
|
+
observer.result.status = observer.process.status;
|
|
242
|
+
appendPeekMessages(observer.result, observer.stdoutExtractor.flush(flushTs));
|
|
243
|
+
appendPeekMessages(observer.result, observer.stderrExtractor.flush(flushTs));
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
peek_started_at: startedAt.toISOString(),
|
|
247
|
+
observed_duration_sec: observedDurationSec(startedAtMs),
|
|
248
|
+
processes,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
166
251
|
async killProcess(pid) {
|
|
167
252
|
const process = this.readProcess(pid);
|
|
168
253
|
const refreshed = this.refreshStatus(process);
|
|
@@ -209,6 +294,49 @@ export class CliProcessService {
|
|
|
209
294
|
message: `Removed ${removed} processes`,
|
|
210
295
|
};
|
|
211
296
|
}
|
|
297
|
+
async startDetachedOpenCodeProcess(cmd, model) {
|
|
298
|
+
const cwdKey = this.resolveCwdKey(cmd.cwd);
|
|
299
|
+
const wrapperPath = this.ensureOpenCodeWrapperScript();
|
|
300
|
+
const childProcess = spawn(wrapperPath, [this.stateDir, cwdKey, cmd.cliPath, ...cmd.args], {
|
|
301
|
+
cwd: cmd.cwd,
|
|
302
|
+
detached: true,
|
|
303
|
+
stdio: 'ignore',
|
|
304
|
+
});
|
|
305
|
+
const pid = childProcess.pid;
|
|
306
|
+
childProcess.unref();
|
|
307
|
+
if (!pid) {
|
|
308
|
+
throw new Error(`Failed to start ${cmd.agent} CLI process`);
|
|
309
|
+
}
|
|
310
|
+
const processDir = this.resolveProcessDir(cmd.cwd, pid);
|
|
311
|
+
mkdirSync(processDir, { recursive: true });
|
|
312
|
+
const stdoutPath = this.resolveStdoutPath(processDir);
|
|
313
|
+
const stderrPath = this.resolveStderrPath(processDir);
|
|
314
|
+
if (!existsSync(stdoutPath)) {
|
|
315
|
+
writeFileSync(stdoutPath, '');
|
|
316
|
+
}
|
|
317
|
+
if (!existsSync(stderrPath)) {
|
|
318
|
+
writeFileSync(stderrPath, '');
|
|
319
|
+
}
|
|
320
|
+
const storedProcess = {
|
|
321
|
+
pid,
|
|
322
|
+
prompt: cmd.prompt,
|
|
323
|
+
workFolder: cmd.cwd,
|
|
324
|
+
cwdKey,
|
|
325
|
+
model,
|
|
326
|
+
toolType: cmd.agent,
|
|
327
|
+
startTime: new Date().toISOString(),
|
|
328
|
+
stdoutPath,
|
|
329
|
+
stderrPath,
|
|
330
|
+
status: 'running',
|
|
331
|
+
};
|
|
332
|
+
this.writeProcess(storedProcess);
|
|
333
|
+
return {
|
|
334
|
+
pid,
|
|
335
|
+
status: 'started',
|
|
336
|
+
agent: cmd.agent,
|
|
337
|
+
message: `${cmd.agent} process started successfully`,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
212
340
|
readAllProcesses() {
|
|
213
341
|
const cwdsDir = this.resolveCwdsDir();
|
|
214
342
|
if (!existsSync(cwdsDir)) {
|
|
@@ -246,18 +374,75 @@ export class CliProcessService {
|
|
|
246
374
|
writeFileSync(this.resolveMetaPath(processDir), JSON.stringify(process, null, 2));
|
|
247
375
|
}
|
|
248
376
|
refreshStatus(process) {
|
|
249
|
-
if (process.status
|
|
377
|
+
if (process.status !== 'running') {
|
|
378
|
+
return process;
|
|
379
|
+
}
|
|
380
|
+
const persistedExitStatus = this.readExitStatus(process);
|
|
381
|
+
if (persistedExitStatus) {
|
|
382
|
+
process.status = persistedExitStatus.status;
|
|
383
|
+
process.exitCode = persistedExitStatus.exitCode;
|
|
384
|
+
this.writeProcess(process);
|
|
385
|
+
return process;
|
|
386
|
+
}
|
|
387
|
+
if (!isProcessRunning(process.pid)) {
|
|
250
388
|
process.status = 'completed';
|
|
251
389
|
this.writeProcess(process);
|
|
252
390
|
}
|
|
253
391
|
return process;
|
|
254
392
|
}
|
|
393
|
+
readExitStatus(process) {
|
|
394
|
+
if (process.toolType !== 'opencode') {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
const exitMetaPath = this.resolveExitStatusPath(this.resolveStoredProcessDir(process));
|
|
398
|
+
if (!existsSync(exitMetaPath)) {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
const parsed = JSON.parse(readFileSync(exitMetaPath, 'utf-8'));
|
|
403
|
+
if (parsed.status === 'completed' || parsed.status === 'failed') {
|
|
404
|
+
return parsed;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
255
412
|
readTextFileSafe(filePath) {
|
|
256
413
|
if (!existsSync(filePath)) {
|
|
257
414
|
return '';
|
|
258
415
|
}
|
|
259
416
|
return readFileSync(filePath, 'utf-8');
|
|
260
417
|
}
|
|
418
|
+
fileSizeSafe(filePath) {
|
|
419
|
+
if (!existsSync(filePath)) {
|
|
420
|
+
return 0;
|
|
421
|
+
}
|
|
422
|
+
return statSync(filePath).size;
|
|
423
|
+
}
|
|
424
|
+
readTextFromOffset(filePath, offset) {
|
|
425
|
+
if (!existsSync(filePath)) {
|
|
426
|
+
return { text: '', offset };
|
|
427
|
+
}
|
|
428
|
+
const size = statSync(filePath).size;
|
|
429
|
+
if (size <= offset) {
|
|
430
|
+
return { text: '', offset: size };
|
|
431
|
+
}
|
|
432
|
+
const fd = openSync(filePath, 'r');
|
|
433
|
+
try {
|
|
434
|
+
const length = size - offset;
|
|
435
|
+
const buffer = Buffer.alloc(length);
|
|
436
|
+
const bytesRead = readSync(fd, buffer, 0, length, offset);
|
|
437
|
+
return {
|
|
438
|
+
text: buffer.subarray(0, bytesRead).toString('utf-8'),
|
|
439
|
+
offset: size,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
finally {
|
|
443
|
+
closeSync(fd);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
261
446
|
resolveCwdsDir() {
|
|
262
447
|
return join(this.stateDir, 'cwds');
|
|
263
448
|
}
|
|
@@ -282,12 +467,48 @@ export class CliProcessService {
|
|
|
282
467
|
resolveStderrPath(processDir) {
|
|
283
468
|
return join(processDir, 'stderr.log');
|
|
284
469
|
}
|
|
470
|
+
resolveExitStatusPath(processDir) {
|
|
471
|
+
return join(processDir, 'exit-status.json');
|
|
472
|
+
}
|
|
473
|
+
resolveOpenCodeWrapperPath() {
|
|
474
|
+
return join(this.stateDir, 'opencode-detached-wrapper.sh');
|
|
475
|
+
}
|
|
285
476
|
resolveStdoutPathForPidPlaceholder() {
|
|
286
477
|
return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stdout.log`);
|
|
287
478
|
}
|
|
288
479
|
resolveStderrPathForPidPlaceholder() {
|
|
289
480
|
return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stderr.log`);
|
|
290
481
|
}
|
|
482
|
+
ensureOpenCodeWrapperScript() {
|
|
483
|
+
const wrapperPath = this.resolveOpenCodeWrapperPath();
|
|
484
|
+
if (existsSync(wrapperPath)) {
|
|
485
|
+
return wrapperPath;
|
|
486
|
+
}
|
|
487
|
+
writeFileSync(wrapperPath, `#!/bin/sh
|
|
488
|
+
set +e
|
|
489
|
+
state_dir="$1"
|
|
490
|
+
cwd_key="$2"
|
|
491
|
+
shift 2
|
|
492
|
+
pid="$$"
|
|
493
|
+
process_dir="$state_dir/cwds/$cwd_key/$pid"
|
|
494
|
+
stdout_path="$process_dir/stdout.log"
|
|
495
|
+
stderr_path="$process_dir/stderr.log"
|
|
496
|
+
exit_meta_path="$process_dir/exit-status.json"
|
|
497
|
+
mkdir -p "$process_dir"
|
|
498
|
+
: > "$stdout_path"
|
|
499
|
+
: > "$stderr_path"
|
|
500
|
+
"$@" >> "$stdout_path" 2>> "$stderr_path"
|
|
501
|
+
exit_code="$?"
|
|
502
|
+
status="completed"
|
|
503
|
+
if [ "$exit_code" -ne 0 ]; then
|
|
504
|
+
status="failed"
|
|
505
|
+
fi
|
|
506
|
+
printf '{\n "status": "%s",\n "exitCode": %s\n}\n' "$status" "$exit_code" > "$exit_meta_path"
|
|
507
|
+
exit "$exit_code"
|
|
508
|
+
`);
|
|
509
|
+
chmodSync(wrapperPath, 0o755);
|
|
510
|
+
return wrapperPath;
|
|
511
|
+
}
|
|
291
512
|
renamePlaceholderFile(fromPath, toPath) {
|
|
292
513
|
renameSync(fromPath, toPath);
|
|
293
514
|
}
|
package/dist/cli-utils.js
CHANGED
|
@@ -2,9 +2,7 @@ import { accessSync, constants } from 'node:fs';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import * as path from 'path';
|
|
5
|
-
// Define debugMode globally using const
|
|
6
5
|
const debugMode = process.env.MCP_CLAUDE_DEBUG === 'true';
|
|
7
|
-
// Dedicated debug logging function
|
|
8
6
|
export function debugLog(message, ...optionalParams) {
|
|
9
7
|
if (debugMode) {
|
|
10
8
|
console.error(message, ...optionalParams);
|
|
@@ -77,7 +75,7 @@ function inspectCliBinary(options) {
|
|
|
77
75
|
lookup: 'env',
|
|
78
76
|
};
|
|
79
77
|
}
|
|
80
|
-
if (isExecutableFile(options.localInstallPath)) {
|
|
78
|
+
if (options.localInstallPath && isExecutableFile(options.localInstallPath)) {
|
|
81
79
|
return {
|
|
82
80
|
configuredCommand,
|
|
83
81
|
resolvedPath: options.localInstallPath,
|
|
@@ -136,6 +134,13 @@ function getCliBinaryConfig(name) {
|
|
|
136
134
|
localInstallPath: join(homedir(), '.forge', 'local', 'forge'),
|
|
137
135
|
};
|
|
138
136
|
}
|
|
137
|
+
if (name === 'opencode') {
|
|
138
|
+
return {
|
|
139
|
+
envVarName: 'OPENCODE_CLI_NAME',
|
|
140
|
+
customCliName: process.env.OPENCODE_CLI_NAME,
|
|
141
|
+
defaultCliName: 'opencode',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
139
144
|
return {
|
|
140
145
|
envVarName: 'GEMINI_CLI_NAME',
|
|
141
146
|
customCliName: process.env.GEMINI_CLI_NAME,
|
|
@@ -152,43 +157,29 @@ export function getCliDoctorStatus() {
|
|
|
152
157
|
codex: getCliBinaryStatus('codex'),
|
|
153
158
|
gemini: getCliBinaryStatus('gemini'),
|
|
154
159
|
forge: getCliBinaryStatus('forge'),
|
|
160
|
+
opencode: getCliBinaryStatus('opencode'),
|
|
155
161
|
};
|
|
156
162
|
}
|
|
157
|
-
/**
|
|
158
|
-
* Determine the Gemini CLI command/path.
|
|
159
|
-
* Similar to findClaudeCli but for Gemini
|
|
160
|
-
*/
|
|
161
163
|
export function findGeminiCli() {
|
|
162
164
|
debugLog('[Debug] Attempting to find Gemini CLI...');
|
|
163
165
|
const status = getCliBinaryStatus('gemini');
|
|
164
166
|
return getCliCommandOrThrow(status);
|
|
165
167
|
}
|
|
166
|
-
/**
|
|
167
|
-
* Determine the Codex CLI command/path.
|
|
168
|
-
* Similar to findClaudeCli but for Codex
|
|
169
|
-
*/
|
|
170
168
|
export function findCodexCli() {
|
|
171
169
|
debugLog('[Debug] Attempting to find Codex CLI...');
|
|
172
170
|
const status = getCliBinaryStatus('codex');
|
|
173
171
|
return getCliCommandOrThrow(status);
|
|
174
172
|
}
|
|
175
|
-
/**
|
|
176
|
-
* Determine the Forge CLI command/path.
|
|
177
|
-
*/
|
|
178
173
|
export function findForgeCli() {
|
|
179
174
|
debugLog('[Debug] Attempting to find Forge CLI...');
|
|
180
175
|
const status = getCliBinaryStatus('forge');
|
|
181
176
|
return getCliCommandOrThrow(status);
|
|
182
177
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
* - If simple name, continues with path resolution
|
|
189
|
-
* 2. Checks for Claude CLI at the local user path: ~/.claude/local/claude.
|
|
190
|
-
* 3. If not found, defaults to the CLI name (or 'claude'), relying on the system's PATH for lookup.
|
|
191
|
-
*/
|
|
178
|
+
export function findOpencodeCli() {
|
|
179
|
+
debugLog('[Debug] Attempting to find OpenCode CLI...');
|
|
180
|
+
const status = getCliBinaryStatus('opencode');
|
|
181
|
+
return getCliCommandOrThrow(status);
|
|
182
|
+
}
|
|
192
183
|
export function findClaudeCli() {
|
|
193
184
|
debugLog('[Debug] Attempting to find Claude CLI...');
|
|
194
185
|
const status = getCliBinaryStatus('claude');
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { buildCliCommand } from './cli-builder.js';
|
|
4
|
-
import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
|
|
4
|
+
import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from './cli-utils.js';
|
|
5
5
|
/**
|
|
6
6
|
* Minimal argv parser. No external dependencies.
|
|
7
7
|
* Supports: --key value, --key=value
|
|
@@ -35,17 +35,18 @@ function parseArgs(argv) {
|
|
|
35
35
|
const USAGE = `Usage: npm run -s cli.run -- --model <model> --workFolder <path> --prompt "..." [options]
|
|
36
36
|
|
|
37
37
|
Options:
|
|
38
|
-
--model Model name or alias (e.g. sonnet, opus, gpt-5.2-codex, gemini-2.5-pro, 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
|
async function main() {
|
|
51
52
|
const args = parseArgs(process.argv.slice(2));
|
|
@@ -69,6 +70,7 @@ async function main() {
|
|
|
69
70
|
codex: findCodexCli(),
|
|
70
71
|
gemini: findGeminiCli(),
|
|
71
72
|
forge: findForgeCli(),
|
|
73
|
+
opencode: findOpencodeCli(),
|
|
72
74
|
};
|
|
73
75
|
// Build command
|
|
74
76
|
let cmd;
|