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.
Files changed (60) hide show
  1. package/.github/dependabot.yml +28 -0
  2. package/.github/workflows/ci.yml +4 -1
  3. package/.github/workflows/dependency-review.yml +22 -0
  4. package/CHANGELOG.md +14 -0
  5. package/README.ja.md +83 -6
  6. package/README.md +83 -7
  7. package/dist/__tests__/app-cli.test.js +80 -5
  8. package/dist/__tests__/cli-bin-smoke.test.js +43 -0
  9. package/dist/__tests__/cli-builder.test.js +93 -15
  10. package/dist/__tests__/cli-process-service.test.js +162 -0
  11. package/dist/__tests__/cli-utils.test.js +31 -0
  12. package/dist/__tests__/e2e.test.js +79 -52
  13. package/dist/__tests__/mcp-contract.test.js +162 -0
  14. package/dist/__tests__/parsers.test.js +224 -1
  15. package/dist/__tests__/peek.test.js +35 -0
  16. package/dist/__tests__/process-management.test.js +160 -1
  17. package/dist/__tests__/server.test.js +39 -9
  18. package/dist/__tests__/utils/opencode-mock.js +91 -0
  19. package/dist/__tests__/validation.test.js +40 -2
  20. package/dist/app/cli.js +47 -5
  21. package/dist/app/mcp.js +53 -4
  22. package/dist/cli-builder.js +67 -28
  23. package/dist/cli-parse.js +11 -5
  24. package/dist/cli-process-service.js +241 -20
  25. package/dist/cli-utils.js +14 -23
  26. package/dist/cli.js +6 -4
  27. package/dist/model-catalog.js +13 -1
  28. package/dist/parsers.js +242 -28
  29. package/dist/peek.js +56 -0
  30. package/dist/process-result.js +9 -2
  31. package/dist/process-service.js +103 -17
  32. package/dist/server.js +1 -2
  33. package/package.json +9 -6
  34. package/src/__tests__/app-cli.test.ts +95 -4
  35. package/src/__tests__/cli-bin-smoke.test.ts +62 -1
  36. package/src/__tests__/cli-builder.test.ts +111 -15
  37. package/src/__tests__/cli-process-service.test.ts +180 -0
  38. package/src/__tests__/cli-utils.test.ts +34 -0
  39. package/src/__tests__/e2e.test.ts +87 -55
  40. package/src/__tests__/mcp-contract.test.ts +188 -0
  41. package/src/__tests__/parsers.test.ts +260 -1
  42. package/src/__tests__/peek.test.ts +43 -0
  43. package/src/__tests__/process-management.test.ts +185 -1
  44. package/src/__tests__/server.test.ts +49 -13
  45. package/src/__tests__/utils/opencode-mock.ts +108 -0
  46. package/src/__tests__/validation.test.ts +48 -2
  47. package/src/app/cli.ts +52 -4
  48. package/src/app/mcp.ts +54 -4
  49. package/src/cli-builder.ts +91 -32
  50. package/src/cli-parse.ts +11 -5
  51. package/src/cli-process-service.ts +304 -17
  52. package/src/cli-utils.ts +37 -33
  53. package/src/cli.ts +6 -4
  54. package/src/model-catalog.ts +24 -1
  55. package/src/parsers.ts +299 -33
  56. package/src/peek.ts +88 -0
  57. package/src/process-result.ts +11 -2
  58. package/src/process-service.ts +134 -15
  59. package/src/server.ts +2 -2
  60. 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 forge (required)
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 forge)\n\n`);
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: undefined,
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 === 'running' && !isProcessRunning(process.pid)) {
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: string;
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: string;
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
- * Determine the Claude CLI command/path.
243
- * 1. Checks for CLAUDE_CLI_NAME environment variable:
244
- * - If absolute path, uses it directly
245
- * - If relative path, throws error
246
- * - If simple name, continues with path resolution
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