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,332 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
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';
|
|
8
|
+
function resolveDefaultStateDir() {
|
|
9
|
+
return process.env.AI_CLI_STATE_DIR || join(homedir(), '.local', 'state', 'ai-cli');
|
|
10
|
+
}
|
|
11
|
+
function isProcessRunning(pid) {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error.code === 'EPERM') {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function normalizeCwdForStorage(cwd) {
|
|
24
|
+
return cwd
|
|
25
|
+
.split('')
|
|
26
|
+
.map((char) => (/^[A-Za-z0-9.-]$/.test(char) ? char : `_${char.charCodeAt(0).toString(16).padStart(2, '0')}`))
|
|
27
|
+
.join('');
|
|
28
|
+
}
|
|
29
|
+
export class CliProcessService {
|
|
30
|
+
stateDir;
|
|
31
|
+
cliPaths;
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
this.stateDir = options.stateDir || resolveDefaultStateDir();
|
|
34
|
+
this.cliPaths = options.cliPaths || {
|
|
35
|
+
claude: findClaudeCli(),
|
|
36
|
+
codex: findCodexCli(),
|
|
37
|
+
gemini: findGeminiCli(),
|
|
38
|
+
forge: findForgeCli(),
|
|
39
|
+
};
|
|
40
|
+
mkdirSync(this.stateDir, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
async startProcess(options) {
|
|
43
|
+
const cmd = buildCliCommand({
|
|
44
|
+
prompt: options.prompt,
|
|
45
|
+
prompt_file: options.prompt_file,
|
|
46
|
+
workFolder: options.cwd,
|
|
47
|
+
model: options.model,
|
|
48
|
+
session_id: options.session_id,
|
|
49
|
+
reasoning_effort: options.reasoning_effort,
|
|
50
|
+
cliPaths: this.cliPaths,
|
|
51
|
+
});
|
|
52
|
+
const stdoutPath = this.resolveStdoutPathForPidPlaceholder();
|
|
53
|
+
const stderrPath = this.resolveStderrPathForPidPlaceholder();
|
|
54
|
+
let stdoutFd;
|
|
55
|
+
let stderrFd;
|
|
56
|
+
try {
|
|
57
|
+
stdoutFd = openSync(stdoutPath, 'w');
|
|
58
|
+
stderrFd = openSync(stderrPath, 'w');
|
|
59
|
+
const childProcess = spawn(cmd.cliPath, cmd.args, {
|
|
60
|
+
cwd: cmd.cwd,
|
|
61
|
+
detached: true,
|
|
62
|
+
stdio: ['ignore', stdoutFd, stderrFd],
|
|
63
|
+
});
|
|
64
|
+
const pid = childProcess.pid;
|
|
65
|
+
childProcess.unref();
|
|
66
|
+
if (!pid) {
|
|
67
|
+
throw new Error(`Failed to start ${cmd.agent} CLI process`);
|
|
68
|
+
}
|
|
69
|
+
const processDir = this.resolveProcessDir(cmd.cwd, pid);
|
|
70
|
+
mkdirSync(processDir, { recursive: true });
|
|
71
|
+
const finalStdoutPath = this.resolveStdoutPath(processDir);
|
|
72
|
+
const finalStderrPath = this.resolveStderrPath(processDir);
|
|
73
|
+
this.renamePlaceholderFile(stdoutPath, finalStdoutPath);
|
|
74
|
+
this.renamePlaceholderFile(stderrPath, finalStderrPath);
|
|
75
|
+
const storedProcess = {
|
|
76
|
+
pid,
|
|
77
|
+
prompt: cmd.prompt,
|
|
78
|
+
workFolder: cmd.cwd,
|
|
79
|
+
model: options.model,
|
|
80
|
+
toolType: cmd.agent,
|
|
81
|
+
startTime: new Date().toISOString(),
|
|
82
|
+
stdoutPath: finalStdoutPath,
|
|
83
|
+
stderrPath: finalStderrPath,
|
|
84
|
+
status: 'running',
|
|
85
|
+
};
|
|
86
|
+
this.writeProcess(storedProcess);
|
|
87
|
+
return {
|
|
88
|
+
pid,
|
|
89
|
+
status: 'started',
|
|
90
|
+
agent: cmd.agent,
|
|
91
|
+
message: `${cmd.agent} process started successfully`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
this.removeFileIfExists(stdoutPath);
|
|
96
|
+
this.removeFileIfExists(stderrPath);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
if (stdoutFd !== undefined) {
|
|
101
|
+
closeSync(stdoutFd);
|
|
102
|
+
}
|
|
103
|
+
if (stderrFd !== undefined) {
|
|
104
|
+
closeSync(stderrFd);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async listProcesses() {
|
|
109
|
+
return this.readAllProcesses().map((process) => ({
|
|
110
|
+
pid: process.pid,
|
|
111
|
+
agent: process.toolType,
|
|
112
|
+
status: this.refreshStatus(process).status,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
async getProcessResult(pid, verbose = false) {
|
|
116
|
+
const storedProcess = this.readProcess(pid);
|
|
117
|
+
const refreshed = this.refreshStatus(storedProcess);
|
|
118
|
+
const stdout = this.readTextFileSafe(refreshed.stdoutPath);
|
|
119
|
+
const stderr = this.readTextFileSafe(refreshed.stderrPath);
|
|
120
|
+
let agentOutput = null;
|
|
121
|
+
if (refreshed.toolType === 'codex') {
|
|
122
|
+
agentOutput = parseCodexOutput(`${stdout}\n${stderr}`);
|
|
123
|
+
}
|
|
124
|
+
else if (stdout) {
|
|
125
|
+
if (refreshed.toolType === 'claude') {
|
|
126
|
+
agentOutput = parseClaudeOutput(stdout);
|
|
127
|
+
}
|
|
128
|
+
else if (refreshed.toolType === 'gemini') {
|
|
129
|
+
agentOutput = parseGeminiOutput(stdout);
|
|
130
|
+
}
|
|
131
|
+
else if (refreshed.toolType === 'forge') {
|
|
132
|
+
agentOutput = parseForgeOutput(stdout);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const response = {
|
|
136
|
+
pid,
|
|
137
|
+
agent: refreshed.toolType,
|
|
138
|
+
status: refreshed.status,
|
|
139
|
+
exitCode: undefined,
|
|
140
|
+
startTime: refreshed.startTime,
|
|
141
|
+
workFolder: refreshed.workFolder,
|
|
142
|
+
prompt: refreshed.prompt,
|
|
143
|
+
model: refreshed.model,
|
|
144
|
+
};
|
|
145
|
+
if (agentOutput) {
|
|
146
|
+
if (!verbose && agentOutput.tools) {
|
|
147
|
+
const { tools, ...rest } = agentOutput;
|
|
148
|
+
response.agentOutput = rest;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
response.agentOutput = agentOutput;
|
|
152
|
+
}
|
|
153
|
+
if (agentOutput.session_id) {
|
|
154
|
+
response.session_id = agentOutput.session_id;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
response.stdout = stdout;
|
|
159
|
+
response.stderr = stderr;
|
|
160
|
+
}
|
|
161
|
+
return response;
|
|
162
|
+
}
|
|
163
|
+
async waitForProcesses(pids, timeoutSeconds = 180) {
|
|
164
|
+
const start = Date.now();
|
|
165
|
+
for (const pid of pids) {
|
|
166
|
+
this.readProcess(pid);
|
|
167
|
+
}
|
|
168
|
+
while (true) {
|
|
169
|
+
const statuses = pids.map((pid) => this.refreshStatus(this.readProcess(pid)).status);
|
|
170
|
+
if (statuses.every((status) => status !== 'running')) {
|
|
171
|
+
return Promise.all(pids.map((pid) => this.getProcessResult(pid, false)));
|
|
172
|
+
}
|
|
173
|
+
if (Date.now() - start >= timeoutSeconds * 1000) {
|
|
174
|
+
throw new Error(`Timed out after ${timeoutSeconds} seconds waiting for processes`);
|
|
175
|
+
}
|
|
176
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async killProcess(pid) {
|
|
180
|
+
const process = this.readProcess(pid);
|
|
181
|
+
const refreshed = this.refreshStatus(process);
|
|
182
|
+
if (refreshed.status !== 'running') {
|
|
183
|
+
return {
|
|
184
|
+
pid,
|
|
185
|
+
status: refreshed.status,
|
|
186
|
+
message: 'Process already terminated',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
this.killPidOrGroup(pid, 'SIGTERM');
|
|
190
|
+
await this.waitForProcessExit(pid, 250);
|
|
191
|
+
if (isProcessRunning(pid)) {
|
|
192
|
+
return {
|
|
193
|
+
pid,
|
|
194
|
+
status: 'running',
|
|
195
|
+
message: 'Signal sent but process is still running',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
refreshed.status = 'failed';
|
|
199
|
+
this.writeProcess(refreshed);
|
|
200
|
+
return {
|
|
201
|
+
pid,
|
|
202
|
+
status: 'terminated',
|
|
203
|
+
message: 'Process terminated successfully',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async cleanupProcesses() {
|
|
207
|
+
let removed = 0;
|
|
208
|
+
for (const process of this.readAllProcesses()) {
|
|
209
|
+
const refreshed = this.refreshStatus(process);
|
|
210
|
+
if (refreshed.status === 'running') {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const processDir = this.resolveProcessDir(refreshed.workFolder, refreshed.pid);
|
|
214
|
+
if (existsSync(processDir)) {
|
|
215
|
+
rmSync(processDir, { recursive: true, force: true });
|
|
216
|
+
removed++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
this.removeEmptyCwdDirs();
|
|
220
|
+
return {
|
|
221
|
+
removed,
|
|
222
|
+
message: `Removed ${removed} processes`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
readAllProcesses() {
|
|
226
|
+
const cwdsDir = this.resolveCwdsDir();
|
|
227
|
+
if (!existsSync(cwdsDir)) {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
const processes = [];
|
|
231
|
+
for (const cwdEntry of readdirSync(cwdsDir)) {
|
|
232
|
+
const cwdDir = join(cwdsDir, cwdEntry);
|
|
233
|
+
for (const pidEntry of readdirSync(cwdDir)) {
|
|
234
|
+
const metaPath = join(cwdDir, pidEntry, 'meta.json');
|
|
235
|
+
if (existsSync(metaPath)) {
|
|
236
|
+
processes.push(this.parseProcessFile(metaPath));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return processes;
|
|
241
|
+
}
|
|
242
|
+
readProcess(pid) {
|
|
243
|
+
const process = this.readAllProcesses().find((entry) => entry.pid === pid);
|
|
244
|
+
if (!process) {
|
|
245
|
+
throw new Error(`Process with PID ${pid} not found`);
|
|
246
|
+
}
|
|
247
|
+
return process;
|
|
248
|
+
}
|
|
249
|
+
parseProcessFile(metaPath) {
|
|
250
|
+
return JSON.parse(readFileSync(metaPath, 'utf-8'));
|
|
251
|
+
}
|
|
252
|
+
writeProcess(process) {
|
|
253
|
+
const processDir = this.resolveProcessDir(process.workFolder, process.pid);
|
|
254
|
+
mkdirSync(processDir, { recursive: true });
|
|
255
|
+
writeFileSync(this.resolveMetaPath(processDir), JSON.stringify(process, null, 2));
|
|
256
|
+
}
|
|
257
|
+
refreshStatus(process) {
|
|
258
|
+
if (process.status === 'running' && !isProcessRunning(process.pid)) {
|
|
259
|
+
process.status = 'completed';
|
|
260
|
+
this.writeProcess(process);
|
|
261
|
+
}
|
|
262
|
+
return process;
|
|
263
|
+
}
|
|
264
|
+
readTextFileSafe(filePath) {
|
|
265
|
+
if (!existsSync(filePath)) {
|
|
266
|
+
return '';
|
|
267
|
+
}
|
|
268
|
+
return readFileSync(filePath, 'utf-8');
|
|
269
|
+
}
|
|
270
|
+
resolveCwdsDir() {
|
|
271
|
+
return join(this.stateDir, 'cwds');
|
|
272
|
+
}
|
|
273
|
+
resolveProcessDir(cwd, pid) {
|
|
274
|
+
return join(this.resolveCwdsDir(), normalizeCwdForStorage(realpathSync(cwd)), String(pid));
|
|
275
|
+
}
|
|
276
|
+
resolveMetaPath(processDir) {
|
|
277
|
+
return join(processDir, 'meta.json');
|
|
278
|
+
}
|
|
279
|
+
resolveStdoutPath(processDir) {
|
|
280
|
+
return join(processDir, 'stdout.log');
|
|
281
|
+
}
|
|
282
|
+
resolveStderrPath(processDir) {
|
|
283
|
+
return join(processDir, 'stderr.log');
|
|
284
|
+
}
|
|
285
|
+
resolveStdoutPathForPidPlaceholder() {
|
|
286
|
+
return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stdout.log`);
|
|
287
|
+
}
|
|
288
|
+
resolveStderrPathForPidPlaceholder() {
|
|
289
|
+
return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stderr.log`);
|
|
290
|
+
}
|
|
291
|
+
renamePlaceholderFile(fromPath, toPath) {
|
|
292
|
+
renameSync(fromPath, toPath);
|
|
293
|
+
}
|
|
294
|
+
removeFileIfExists(filePath) {
|
|
295
|
+
if (existsSync(filePath)) {
|
|
296
|
+
unlinkSync(filePath);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
killPidOrGroup(pid, signal) {
|
|
300
|
+
try {
|
|
301
|
+
globalThis.process.kill(-pid, signal);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (error.code === 'ESRCH' || error.code === 'EINVAL') {
|
|
305
|
+
globalThis.process.kill(pid, signal);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (error.code === 'EPERM') {
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
globalThis.process.kill(pid, signal);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
async waitForProcessExit(pid, timeoutMs) {
|
|
315
|
+
const startedAt = Date.now();
|
|
316
|
+
while (isProcessRunning(pid) && Date.now() - startedAt < timeoutMs) {
|
|
317
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
removeEmptyCwdDirs() {
|
|
321
|
+
const cwdsDir = this.resolveCwdsDir();
|
|
322
|
+
if (!existsSync(cwdsDir)) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
for (const cwdEntry of readdirSync(cwdsDir)) {
|
|
326
|
+
const cwdDir = join(cwdsDir, cwdEntry);
|
|
327
|
+
if (readdirSync(cwdDir).length === 0) {
|
|
328
|
+
rmSync(cwdDir, { recursive: true, force: true });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
package/dist/cli-utils.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
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';
|
|
@@ -10,41 +10,158 @@ export function debugLog(message, ...optionalParams) {
|
|
|
10
10
|
console.error(message, ...optionalParams);
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
function getPathDelimiter() {
|
|
14
|
+
return process.platform === 'win32' ? ';' : ':';
|
|
15
|
+
}
|
|
16
|
+
function getPathExtensions() {
|
|
17
|
+
if (process.platform !== 'win32') {
|
|
18
|
+
return [''];
|
|
19
|
+
}
|
|
20
|
+
const rawPathext = process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM';
|
|
21
|
+
return ['', ...rawPathext.split(';').filter(Boolean)];
|
|
22
|
+
}
|
|
23
|
+
function findExecutableOnPath(commandName) {
|
|
24
|
+
const rawPath = process.env.PATH || '';
|
|
25
|
+
if (!rawPath) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const pathEntries = rawPath.split(getPathDelimiter()).filter(Boolean);
|
|
29
|
+
const extensions = getPathExtensions();
|
|
30
|
+
for (const entry of pathEntries) {
|
|
31
|
+
for (const extension of extensions) {
|
|
32
|
+
const candidate = join(entry, `${commandName}${extension}`);
|
|
33
|
+
if (isExecutableFile(candidate)) {
|
|
34
|
+
return candidate;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
function validateCustomCliName(envVarName, customCliName) {
|
|
41
|
+
if (path.isAbsolute(customCliName)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (customCliName.startsWith('./') ||
|
|
45
|
+
customCliName.startsWith('../') ||
|
|
46
|
+
customCliName.includes('/')) {
|
|
47
|
+
return `Invalid ${envVarName}: Relative paths are not allowed. Use either a simple name (e.g., '${customCliName.split('/').pop() || 'cli'}') or an absolute path (e.g., '/tmp/${customCliName.split('/').pop() || 'cli'}-test')`;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
function inspectCliBinary(options) {
|
|
52
|
+
const configuredCommand = options.customCliName || options.defaultCliName;
|
|
53
|
+
if (options.customCliName) {
|
|
54
|
+
const validationError = validateCustomCliName(options.envVarName, options.customCliName);
|
|
55
|
+
if (validationError) {
|
|
56
|
+
return {
|
|
57
|
+
configuredCommand,
|
|
58
|
+
resolvedPath: null,
|
|
59
|
+
available: false,
|
|
60
|
+
lookup: 'env',
|
|
61
|
+
error: validationError,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (path.isAbsolute(options.customCliName)) {
|
|
65
|
+
return {
|
|
66
|
+
configuredCommand,
|
|
67
|
+
resolvedPath: options.customCliName,
|
|
68
|
+
available: isExecutableFile(options.customCliName),
|
|
69
|
+
lookup: 'env',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const resolvedPath = findExecutableOnPath(configuredCommand);
|
|
73
|
+
return {
|
|
74
|
+
configuredCommand,
|
|
75
|
+
resolvedPath,
|
|
76
|
+
available: resolvedPath !== null,
|
|
77
|
+
lookup: 'env',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (isExecutableFile(options.localInstallPath)) {
|
|
81
|
+
return {
|
|
82
|
+
configuredCommand,
|
|
83
|
+
resolvedPath: options.localInstallPath,
|
|
84
|
+
available: true,
|
|
85
|
+
lookup: 'local',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const resolvedPath = findExecutableOnPath(configuredCommand);
|
|
89
|
+
return {
|
|
90
|
+
configuredCommand,
|
|
91
|
+
resolvedPath,
|
|
92
|
+
available: resolvedPath !== null,
|
|
93
|
+
lookup: 'path',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function getCliCommandOrThrow(status) {
|
|
97
|
+
if (status.error) {
|
|
98
|
+
throw new Error(status.error);
|
|
99
|
+
}
|
|
100
|
+
if (status.lookup === 'env' && !path.isAbsolute(status.configuredCommand)) {
|
|
101
|
+
return status.configuredCommand;
|
|
102
|
+
}
|
|
103
|
+
return status.resolvedPath || status.configuredCommand;
|
|
104
|
+
}
|
|
105
|
+
function isExecutableFile(filePath) {
|
|
106
|
+
try {
|
|
107
|
+
accessSync(filePath, constants.X_OK);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function getCliBinaryConfig(name) {
|
|
115
|
+
if (name === 'claude') {
|
|
116
|
+
return {
|
|
117
|
+
envVarName: 'CLAUDE_CLI_NAME',
|
|
118
|
+
customCliName: process.env.CLAUDE_CLI_NAME,
|
|
119
|
+
defaultCliName: 'claude',
|
|
120
|
+
localInstallPath: join(homedir(), '.claude', 'local', 'claude'),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (name === 'codex') {
|
|
124
|
+
return {
|
|
125
|
+
envVarName: 'CODEX_CLI_NAME',
|
|
126
|
+
customCliName: process.env.CODEX_CLI_NAME,
|
|
127
|
+
defaultCliName: 'codex',
|
|
128
|
+
localInstallPath: join(homedir(), '.codex', 'local', 'codex'),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (name === 'forge') {
|
|
132
|
+
return {
|
|
133
|
+
envVarName: 'FORGE_CLI_NAME',
|
|
134
|
+
customCliName: process.env.FORGE_CLI_NAME,
|
|
135
|
+
defaultCliName: 'forge',
|
|
136
|
+
localInstallPath: join(homedir(), '.forge', 'local', 'forge'),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
envVarName: 'GEMINI_CLI_NAME',
|
|
141
|
+
customCliName: process.env.GEMINI_CLI_NAME,
|
|
142
|
+
defaultCliName: 'gemini',
|
|
143
|
+
localInstallPath: join(homedir(), '.gemini', 'local', 'gemini'),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function getCliBinaryStatus(name) {
|
|
147
|
+
return inspectCliBinary(getCliBinaryConfig(name));
|
|
148
|
+
}
|
|
149
|
+
export function getCliDoctorStatus() {
|
|
150
|
+
return {
|
|
151
|
+
claude: getCliBinaryStatus('claude'),
|
|
152
|
+
codex: getCliBinaryStatus('codex'),
|
|
153
|
+
gemini: getCliBinaryStatus('gemini'),
|
|
154
|
+
forge: getCliBinaryStatus('forge'),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
13
157
|
/**
|
|
14
158
|
* Determine the Gemini CLI command/path.
|
|
15
159
|
* Similar to findClaudeCli but for Gemini
|
|
16
160
|
*/
|
|
17
161
|
export function findGeminiCli() {
|
|
18
162
|
debugLog('[Debug] Attempting to find Gemini CLI...');
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (customCliName) {
|
|
22
|
-
debugLog(`[Debug] Using custom Gemini CLI name from GEMINI_CLI_NAME: ${customCliName}`);
|
|
23
|
-
// If it's an absolute path, use it directly
|
|
24
|
-
if (path.isAbsolute(customCliName)) {
|
|
25
|
-
debugLog(`[Debug] GEMINI_CLI_NAME is an absolute path: ${customCliName}`);
|
|
26
|
-
return customCliName;
|
|
27
|
-
}
|
|
28
|
-
// If it starts with ~ or ./, reject as relative paths are not allowed
|
|
29
|
-
if (customCliName.startsWith('./') || customCliName.startsWith('../') || customCliName.includes('/')) {
|
|
30
|
-
throw new Error(`Invalid GEMINI_CLI_NAME: Relative paths are not allowed. Use either a simple name (e.g., 'gemini') or an absolute path (e.g., '/tmp/gemini-test')`);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
const cliName = customCliName || 'gemini';
|
|
34
|
-
// Try local install path: ~/.gemini/local/gemini
|
|
35
|
-
const userPath = join(homedir(), '.gemini', 'local', 'gemini');
|
|
36
|
-
debugLog(`[Debug] Checking for Gemini CLI at local user path: ${userPath}`);
|
|
37
|
-
if (existsSync(userPath)) {
|
|
38
|
-
debugLog(`[Debug] Found Gemini CLI at local user path: ${userPath}. Using this path.`);
|
|
39
|
-
return userPath;
|
|
40
|
-
}
|
|
41
|
-
else {
|
|
42
|
-
debugLog(`[Debug] Gemini CLI not found at local user path: ${userPath}.`);
|
|
43
|
-
}
|
|
44
|
-
// Fallback to CLI name (PATH lookup)
|
|
45
|
-
debugLog(`[Debug] Falling back to "${cliName}" command name, relying on spawn/PATH lookup.`);
|
|
46
|
-
console.warn(`[Warning] Gemini CLI not found at ~/.gemini/local/gemini. Falling back to "${cliName}" in PATH. Ensure it is installed and accessible.`);
|
|
47
|
-
return cliName;
|
|
163
|
+
const status = getCliBinaryStatus('gemini');
|
|
164
|
+
return getCliCommandOrThrow(status);
|
|
48
165
|
}
|
|
49
166
|
/**
|
|
50
167
|
* Determine the Codex CLI command/path.
|
|
@@ -52,35 +169,16 @@ export function findGeminiCli() {
|
|
|
52
169
|
*/
|
|
53
170
|
export function findCodexCli() {
|
|
54
171
|
debugLog('[Debug] Attempting to find Codex CLI...');
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (customCliName.startsWith('./') || customCliName.startsWith('../') || customCliName.includes('/')) {
|
|
66
|
-
throw new Error(`Invalid CODEX_CLI_NAME: Relative paths are not allowed. Use either a simple name (e.g., 'codex') or an absolute path (e.g., '/tmp/codex-test')`);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
const cliName = customCliName || 'codex';
|
|
70
|
-
// Try local install path: ~/.codex/local/codex
|
|
71
|
-
const userPath = join(homedir(), '.codex', 'local', 'codex');
|
|
72
|
-
debugLog(`[Debug] Checking for Codex CLI at local user path: ${userPath}`);
|
|
73
|
-
if (existsSync(userPath)) {
|
|
74
|
-
debugLog(`[Debug] Found Codex CLI at local user path: ${userPath}. Using this path.`);
|
|
75
|
-
return userPath;
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
debugLog(`[Debug] Codex CLI not found at local user path: ${userPath}.`);
|
|
79
|
-
}
|
|
80
|
-
// Fallback to CLI name (PATH lookup)
|
|
81
|
-
debugLog(`[Debug] Falling back to "${cliName}" command name, relying on spawn/PATH lookup.`);
|
|
82
|
-
console.warn(`[Warning] Codex CLI not found at ~/.codex/local/codex. Falling back to "${cliName}" in PATH. Ensure it is installed and accessible.`);
|
|
83
|
-
return cliName;
|
|
172
|
+
const status = getCliBinaryStatus('codex');
|
|
173
|
+
return getCliCommandOrThrow(status);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Determine the Forge CLI command/path.
|
|
177
|
+
*/
|
|
178
|
+
export function findForgeCli() {
|
|
179
|
+
debugLog('[Debug] Attempting to find Forge CLI...');
|
|
180
|
+
const status = getCliBinaryStatus('forge');
|
|
181
|
+
return getCliCommandOrThrow(status);
|
|
84
182
|
}
|
|
85
183
|
/**
|
|
86
184
|
* Determine the Claude CLI command/path.
|
|
@@ -93,33 +191,6 @@ export function findCodexCli() {
|
|
|
93
191
|
*/
|
|
94
192
|
export function findClaudeCli() {
|
|
95
193
|
debugLog('[Debug] Attempting to find Claude CLI...');
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (customCliName) {
|
|
99
|
-
debugLog(`[Debug] Using custom Claude CLI name from CLAUDE_CLI_NAME: ${customCliName}`);
|
|
100
|
-
// If it's an absolute path, use it directly
|
|
101
|
-
if (path.isAbsolute(customCliName)) {
|
|
102
|
-
debugLog(`[Debug] CLAUDE_CLI_NAME is an absolute path: ${customCliName}`);
|
|
103
|
-
return customCliName;
|
|
104
|
-
}
|
|
105
|
-
// If it starts with ~ or ./, reject as relative paths are not allowed
|
|
106
|
-
if (customCliName.startsWith('./') || customCliName.startsWith('../') || customCliName.includes('/')) {
|
|
107
|
-
throw new Error(`Invalid CLAUDE_CLI_NAME: Relative paths are not allowed. Use either a simple name (e.g., 'claude') or an absolute path (e.g., '/tmp/claude-test')`);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
const cliName = customCliName || 'claude';
|
|
111
|
-
// Try local install path: ~/.claude/local/claude (using the original name for local installs)
|
|
112
|
-
const userPath = join(homedir(), '.claude', 'local', 'claude');
|
|
113
|
-
debugLog(`[Debug] Checking for Claude CLI at local user path: ${userPath}`);
|
|
114
|
-
if (existsSync(userPath)) {
|
|
115
|
-
debugLog(`[Debug] Found Claude CLI at local user path: ${userPath}. Using this path.`);
|
|
116
|
-
return userPath;
|
|
117
|
-
}
|
|
118
|
-
else {
|
|
119
|
-
debugLog(`[Debug] Claude CLI not found at local user path: ${userPath}.`);
|
|
120
|
-
}
|
|
121
|
-
// 3. Fallback to CLI name (PATH lookup)
|
|
122
|
-
debugLog(`[Debug] Falling back to "${cliName}" command name, relying on spawn/PATH lookup.`);
|
|
123
|
-
console.warn(`[Warning] Claude CLI not found at ~/.claude/local/claude. Falling back to "${cliName}" in PATH. Ensure it is installed and accessible.`);
|
|
124
|
-
return cliName;
|
|
194
|
+
const status = getCliBinaryStatus('claude');
|
|
195
|
+
return getCliCommandOrThrow(status);
|
|
125
196
|
}
|
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, findGeminiCli } from './cli-utils.js';
|
|
4
|
+
import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
|
|
5
5
|
/**
|
|
6
6
|
* Minimal argv parser. No external dependencies.
|
|
7
7
|
* Supports: --key value, --key=value
|
|
@@ -35,12 +35,12 @@ 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)
|
|
38
|
+
--model Model name or alias (e.g. sonnet, opus, gpt-5.2-codex, gemini-2.5-pro, forge)
|
|
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
42
|
--session_id Session ID to resume
|
|
43
|
-
--reasoning_effort Claude/Codex: Claude=low|medium|high, Codex=low|medium|high|xhigh
|
|
43
|
+
--reasoning_effort Claude/Codex only: Claude=low|medium|high, Codex=low|medium|high|xhigh
|
|
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:
|
|
@@ -68,6 +68,7 @@ async function main() {
|
|
|
68
68
|
claude: findClaudeCli(),
|
|
69
69
|
codex: findCodexCli(),
|
|
70
70
|
gemini: findGeminiCli(),
|
|
71
|
+
forge: findForgeCli(),
|
|
71
72
|
};
|
|
72
73
|
// Build command
|
|
73
74
|
let cmd;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const CLAUDE_MODELS = ['sonnet', 'sonnet[1m]', 'opus', 'opusplan', 'haiku'];
|
|
2
|
+
export const CODEX_MODELS = [
|
|
3
|
+
'gpt-5.4',
|
|
4
|
+
'gpt-5.3-codex',
|
|
5
|
+
'gpt-5.2-codex',
|
|
6
|
+
'gpt-5.1-codex-mini',
|
|
7
|
+
'gpt-5.1-codex-max',
|
|
8
|
+
'gpt-5.2',
|
|
9
|
+
'gpt-5.1',
|
|
10
|
+
'gpt-5.1-codex',
|
|
11
|
+
'gpt-5-codex',
|
|
12
|
+
'gpt-5-codex-mini',
|
|
13
|
+
'gpt-5',
|
|
14
|
+
];
|
|
15
|
+
export const GEMINI_MODELS = [
|
|
16
|
+
'gemini-2.5-pro',
|
|
17
|
+
'gemini-2.5-flash',
|
|
18
|
+
'gemini-3.1-pro-preview',
|
|
19
|
+
'gemini-3-pro-preview',
|
|
20
|
+
'gemini-3-flash-preview',
|
|
21
|
+
];
|
|
22
|
+
export const FORGE_MODELS = ['forge'];
|
|
23
|
+
export const MODEL_ALIASES = {
|
|
24
|
+
'claude-ultra': 'opus',
|
|
25
|
+
'codex-ultra': 'gpt-5.4',
|
|
26
|
+
'gemini-ultra': 'gemini-3.1-pro-preview',
|
|
27
|
+
};
|
|
28
|
+
export const MODEL_ALIAS_DETAILS = [
|
|
29
|
+
{ name: 'claude-ultra', resolvesTo: 'opus', agent: 'claude', defaultReasoningEffort: 'high' },
|
|
30
|
+
{ name: 'codex-ultra', resolvesTo: 'gpt-5.4', agent: 'codex', defaultReasoningEffort: 'xhigh' },
|
|
31
|
+
{ name: 'gemini-ultra', resolvesTo: 'gemini-3.1-pro-preview', agent: 'gemini' },
|
|
32
|
+
];
|
|
33
|
+
export function getSupportedModelsDescription() {
|
|
34
|
+
return [
|
|
35
|
+
'"claude-ultra", "codex-ultra", "gemini-ultra"',
|
|
36
|
+
...CLAUDE_MODELS.map((model) => `"${model}"`),
|
|
37
|
+
...CODEX_MODELS.map((model) => `"${model}"`),
|
|
38
|
+
...GEMINI_MODELS.map((model) => `"${model}"`),
|
|
39
|
+
...FORGE_MODELS.map((model) => `"${model}"`),
|
|
40
|
+
].join(', ');
|
|
41
|
+
}
|
|
42
|
+
export function getModelParameterDescription() {
|
|
43
|
+
return `The model to use. Aliases: "claude-ultra" (auto high effort), "codex-ultra" (auto xhigh reasoning), "gemini-ultra". Standard: ${[...CLAUDE_MODELS, ...CODEX_MODELS, ...GEMINI_MODELS, ...FORGE_MODELS].map((model) => `"${model}"`).join(', ')}. "forge" is a provider key, not a Forge model family selector.`;
|
|
44
|
+
}
|
|
45
|
+
export function getModelsPayload() {
|
|
46
|
+
return {
|
|
47
|
+
aliases: MODEL_ALIAS_DETAILS,
|
|
48
|
+
claude: CLAUDE_MODELS,
|
|
49
|
+
codex: CODEX_MODELS,
|
|
50
|
+
gemini: GEMINI_MODELS,
|
|
51
|
+
forge: FORGE_MODELS,
|
|
52
|
+
};
|
|
53
|
+
}
|