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
@@ -19,6 +19,44 @@ afterEach(() => {
19
19
  rmSync(dir, { recursive: true, force: true });
20
20
  }
21
21
  });
22
+ describe('cli helper entrypoint smoke', () => {
23
+ it('prints help for cli.run with OpenCode examples', () => {
24
+ const output = execFileSync('node', ['--import', 'tsx', 'src/cli.ts', '--help'], {
25
+ cwd: process.cwd(),
26
+ encoding: 'utf8',
27
+ env: process.env,
28
+ });
29
+ expect(output).toContain('Usage: npm run -s cli.run -- --model <model> --workFolder <path> --prompt "..." [options]');
30
+ expect(output).toContain('opencode');
31
+ expect(output).toContain('oc-openai/gpt-5.4');
32
+ expect(output).toContain('OpenCode');
33
+ expect(output).toContain('npm run -s cli.run.parse -- --agent opencode < raw.txt');
34
+ });
35
+ it('prints help for cli.run.parse with OpenCode agent support', () => {
36
+ const output = execFileSync('node', ['--import', 'tsx', 'src/cli-parse.ts', '--help'], {
37
+ cwd: process.cwd(),
38
+ encoding: 'utf8',
39
+ env: process.env,
40
+ });
41
+ expect(output).toContain('Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini|forge|opencode>');
42
+ expect(output).toContain('Agent type: claude, codex, gemini, forge, or opencode');
43
+ expect(output).toContain('npm run -s cli.run.parse -- --agent opencode < raw.txt');
44
+ });
45
+ it('parses OpenCode NDJSON through cli.run.parse', () => {
46
+ const output = execFileSync('node', ['--import', 'tsx', 'src/cli-parse.ts', '--agent', 'opencode'], {
47
+ cwd: process.cwd(),
48
+ encoding: 'utf8',
49
+ env: process.env,
50
+ input: '{"type":"step_start","sessionID":"ses_cli_parse"}\n{"type":"text","sessionID":"ses_cli_parse","part":{"type":"text","text":"Hello from cli.parse"}}\n{"type":"step_finish","sessionID":"ses_cli_parse","part":{"type":"step-finish","tokens":{"total":9},"cost":1}}\n',
51
+ });
52
+ expect(JSON.parse(output)).toEqual({
53
+ message: 'Hello from cli.parse',
54
+ session_id: 'ses_cli_parse',
55
+ tokens: { total: 9 },
56
+ cost: 1,
57
+ });
58
+ });
59
+ });
22
60
  describe('ai-cli entrypoint smoke', () => {
23
61
  it('prints doctor output for the ai-cli entrypoint', () => {
24
62
  const fakeBinDir = makeTempDir('ai-cli-bin-');
@@ -26,6 +64,7 @@ describe('ai-cli entrypoint smoke', () => {
26
64
  writeExecutable(fakeBinDir, 'codex');
27
65
  writeExecutable(fakeBinDir, 'gemini');
28
66
  writeExecutable(fakeBinDir, 'forge');
67
+ writeExecutable(fakeBinDir, 'opencode');
29
68
  const output = execFileSync('node', ['--import', 'tsx', 'src/bin/ai-cli.ts', 'doctor'], {
30
69
  cwd: process.cwd(),
31
70
  encoding: 'utf8',
@@ -36,12 +75,14 @@ describe('ai-cli entrypoint smoke', () => {
36
75
  CODEX_CLI_NAME: 'codex',
37
76
  GEMINI_CLI_NAME: 'gemini',
38
77
  FORGE_CLI_NAME: 'forge',
78
+ OPENCODE_CLI_NAME: 'opencode',
39
79
  },
40
80
  });
41
81
  expect(output).toContain('"claude"');
42
82
  expect(output).toContain('"codex"');
43
83
  expect(output).toContain('"gemini"');
44
84
  expect(output).toContain('"forge"');
85
+ expect(output).toContain('"opencode"');
45
86
  expect(output).toContain('"available": true');
46
87
  });
47
88
  it('prints run help for the ai-cli entrypoint', () => {
@@ -54,5 +95,7 @@ describe('ai-cli entrypoint smoke', () => {
54
95
  expect(output).toContain('--model <model>');
55
96
  expect(output).toContain('claude-ultra');
56
97
  expect(output).toContain('forge');
98
+ expect(output).toContain('opencode');
99
+ expect(output).toContain('oc-openai/gpt-5.4');
57
100
  });
58
101
  });
@@ -15,6 +15,7 @@ const DEFAULT_CLI_PATHS = {
15
15
  codex: '/usr/bin/codex',
16
16
  gemini: '/usr/bin/gemini',
17
17
  forge: '/usr/bin/forge',
18
+ opencode: '/usr/bin/opencode',
18
19
  };
19
20
  describe('cli-builder', () => {
20
21
  beforeEach(() => {
@@ -74,6 +75,10 @@ describe('cli-builder', () => {
74
75
  it('should reject reasoning_effort for forge explicitly', () => {
75
76
  expect(() => getReasoningEffort('forge', 'high')).toThrow('reasoning_effort is not supported for forge.');
76
77
  });
78
+ it('should reject reasoning_effort for opencode explicitly', () => {
79
+ expect(() => getReasoningEffort('opencode', 'high')).toThrow('reasoning_effort is not supported for opencode.');
80
+ expect(() => getReasoningEffort('oc-openai/gpt-5.4', 'high')).toThrow('reasoning_effort is not supported for opencode.');
81
+ });
77
82
  });
78
83
  describe('buildCliCommand', () => {
79
84
  describe('validation', () => {
@@ -300,7 +305,7 @@ describe('cli-builder', () => {
300
305
  expect(cmd.cliPath).toBe('/usr/bin/gemini');
301
306
  expect(cmd.args).toContain('-y');
302
307
  expect(cmd.args).toContain('--output-format');
303
- expect(cmd.args).toContain('json');
308
+ expect(cmd.args).toContain('stream-json');
304
309
  expect(cmd.args).toContain('--model');
305
310
  expect(cmd.args).toContain('gemini-2.5-pro');
306
311
  });
@@ -326,37 +331,110 @@ describe('cli-builder', () => {
326
331
  expect(cmd.resolvedModel).toBe('gemini-3.1-pro-preview');
327
332
  });
328
333
  });
329
- describe('forge agent', () => {
330
- it('should build forge command without model flags', () => {
334
+ describe('opencode agent', () => {
335
+ it('should build default opencode command without --model', () => {
331
336
  const cmd = buildCliCommand({
332
337
  prompt: 'test',
333
338
  workFolder: '/tmp',
334
- model: 'forge',
339
+ model: 'opencode',
335
340
  cliPaths: DEFAULT_CLI_PATHS,
336
341
  });
337
- expect(cmd.agent).toBe('forge');
338
- expect(cmd.cliPath).toBe('/usr/bin/forge');
339
- expect(cmd.resolvedModel).toBe('forge');
340
- expect(cmd.args).toEqual(['-C', '/tmp', '-p', 'test']);
342
+ expect(cmd.agent).toBe('opencode');
343
+ expect(cmd.cliPath).toBe('/usr/bin/opencode');
344
+ expect(cmd.cwd).toBe('/tmp');
345
+ expect(cmd.args).toEqual(['run', '--format', 'json', '--dir', '/tmp', 'test']);
346
+ expect(cmd.args).not.toContain('--model');
341
347
  });
342
- it('should map session_id to --conversation-id for forge', () => {
348
+ it('should route valid explicit OpenCode model syntax', () => {
343
349
  const cmd = buildCliCommand({
344
350
  prompt: 'test',
345
351
  workFolder: '/tmp',
346
- model: 'forge',
347
- session_id: 'forge-conv-123',
352
+ model: 'oc-openai/gpt-5.4',
348
353
  cliPaths: DEFAULT_CLI_PATHS,
349
354
  });
350
- expect(cmd.args).toEqual(['-C', '/tmp', '--conversation-id', 'forge-conv-123', '-p', 'test']);
355
+ expect(cmd.agent).toBe('opencode');
356
+ expect(cmd.resolvedModel).toBe('oc-openai/gpt-5.4');
357
+ expect(cmd.args).toEqual([
358
+ 'run',
359
+ '--format',
360
+ 'json',
361
+ '--dir',
362
+ '/tmp',
363
+ '--model',
364
+ 'openai/gpt-5.4',
365
+ 'test',
366
+ ]);
367
+ });
368
+ it.each([
369
+ 'oc-',
370
+ 'oc-openai',
371
+ 'oc-/gpt-5.4',
372
+ 'oc-openai/',
373
+ ])('should reject invalid explicit OpenCode syntax: %s', (model) => {
374
+ expect(() => buildCliCommand({
375
+ prompt: 'test',
376
+ workFolder: '/tmp',
377
+ model,
378
+ cliPaths: DEFAULT_CLI_PATHS,
379
+ })).toThrow('Invalid OpenCode model. Expected exact syntax oc-<provider/model>.');
351
380
  });
352
- it('should reject reasoning_effort for forge in command building', () => {
381
+ it.each([' oc-openai/gpt-5.4', 'oc-openai/gpt-5.4 '])('should reject explicit OpenCode models with surrounding whitespace: %s', (model) => {
353
382
  expect(() => buildCliCommand({
354
383
  prompt: 'test',
355
384
  workFolder: '/tmp',
356
- model: 'forge',
385
+ model,
386
+ cliPaths: DEFAULT_CLI_PATHS,
387
+ })).toThrow('Invalid OpenCode model. Expected exact syntax oc-<provider/model>.');
388
+ });
389
+ it('should reject reasoning_effort for OpenCode in command building', () => {
390
+ expect(() => buildCliCommand({
391
+ prompt: 'test',
392
+ workFolder: '/tmp',
393
+ model: 'opencode',
357
394
  reasoning_effort: 'high',
358
395
  cliPaths: DEFAULT_CLI_PATHS,
359
- })).toThrow('reasoning_effort is not supported for forge.');
396
+ })).toThrow('reasoning_effort is not supported for opencode.');
397
+ });
398
+ it('should build resumed default OpenCode command', () => {
399
+ const cmd = buildCliCommand({
400
+ prompt: 'resume prompt',
401
+ workFolder: '/tmp',
402
+ model: 'opencode',
403
+ session_id: 'ses-123',
404
+ cliPaths: DEFAULT_CLI_PATHS,
405
+ });
406
+ expect(cmd.args).toEqual([
407
+ 'run',
408
+ '--format',
409
+ 'json',
410
+ '--dir',
411
+ '/tmp',
412
+ '--session',
413
+ 'ses-123',
414
+ 'resume prompt',
415
+ ]);
416
+ expect(cmd.args).not.toContain('--model');
417
+ });
418
+ it('should build resumed explicit OpenCode command', () => {
419
+ const cmd = buildCliCommand({
420
+ prompt: 'resume prompt',
421
+ workFolder: '/tmp',
422
+ model: 'oc-openai/gpt-5.4',
423
+ session_id: 'ses-456',
424
+ cliPaths: DEFAULT_CLI_PATHS,
425
+ });
426
+ expect(cmd.args).toEqual([
427
+ 'run',
428
+ '--format',
429
+ 'json',
430
+ '--dir',
431
+ '/tmp',
432
+ '--session',
433
+ 'ses-456',
434
+ '--model',
435
+ 'openai/gpt-5.4',
436
+ 'resume prompt',
437
+ ]);
360
438
  });
361
439
  });
362
440
  });
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest';
5
5
  import { CliProcessService } from '../cli-process-service.js';
6
+ import { createOpenCodeMock } from './utils/opencode-mock.js';
6
7
  function createMockCliScript(dir, name, options = {}) {
7
8
  const scriptPath = join(dir, name);
8
9
  writeFileSync(scriptPath, `#!/bin/bash
@@ -57,6 +58,7 @@ describe('CliProcessService', () => {
57
58
  codex: scriptPath,
58
59
  gemini: scriptPath,
59
60
  forge: scriptPath,
61
+ opencode: scriptPath,
60
62
  },
61
63
  });
62
64
  const runResult = await service.startProcess({
@@ -105,6 +107,65 @@ describe('CliProcessService', () => {
105
107
  expect(result).not.toHaveProperty('prompt');
106
108
  expect(readFileSync(join(processDir, 'meta.json'), 'utf-8')).toContain('"status": "completed"');
107
109
  });
110
+ it('peeks only appended natural-language messages from detached logs', async () => {
111
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
112
+ tempDirs.push(root);
113
+ const scriptPath = join(root, 'mock-claude-peek');
114
+ writeFileSync(scriptPath, `#!/bin/bash
115
+ printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"old cli message"}]}}'
116
+ sleep 2
117
+ printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"new cli message"},{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/a"}}]}}'
118
+ printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"secret"}]}}'
119
+ `);
120
+ chmodSync(scriptPath, 0o755);
121
+ const stateDir = join(root, 'state');
122
+ const workFolder = join(root, 'work');
123
+ mkdirSync(workFolder, { recursive: true });
124
+ const service = new CliProcessService({
125
+ stateDir,
126
+ cliPaths: {
127
+ claude: scriptPath,
128
+ codex: scriptPath,
129
+ gemini: scriptPath,
130
+ forge: scriptPath,
131
+ opencode: scriptPath,
132
+ },
133
+ });
134
+ const runResult = await service.startProcess({
135
+ prompt: 'hello peek',
136
+ cwd: workFolder,
137
+ });
138
+ const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(runResult.pid));
139
+ const stdoutPath = join(processDir, 'stdout.log');
140
+ const startedAt = Date.now();
141
+ while (Date.now() - startedAt < 5000 && !readFileSync(stdoutPath, 'utf-8').includes('old cli message')) {
142
+ await new Promise((resolve) => setTimeout(resolve, 25));
143
+ }
144
+ expect(readFileSync(stdoutPath, 'utf-8')).toContain('old cli message');
145
+ const peekResult = await service.peekProcesses([runResult.pid, runResult.pid, 999999], 3);
146
+ expect(peekResult.processes).toHaveLength(2);
147
+ expect(peekResult.processes[0]).toMatchObject({
148
+ pid: runResult.pid,
149
+ agent: 'claude',
150
+ status: 'completed',
151
+ messages: [
152
+ {
153
+ ts: expect.any(String),
154
+ text: 'new cli message',
155
+ },
156
+ ],
157
+ truncated: false,
158
+ error: null,
159
+ });
160
+ expect(peekResult.processes[1]).toEqual({
161
+ pid: 999999,
162
+ agent: null,
163
+ status: 'not_found',
164
+ messages: [],
165
+ truncated: false,
166
+ error: 'process not found',
167
+ });
168
+ });
108
169
  it('returns compact results by default and full results when verbose is true', async () => {
109
170
  const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
110
171
  tempDirs.push(root);
@@ -126,6 +187,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
126
187
  codex: scriptPath,
127
188
  gemini: scriptPath,
128
189
  forge: scriptPath,
190
+ opencode: scriptPath,
129
191
  },
130
192
  });
131
193
  const runResult = await service.startProcess({
@@ -229,6 +291,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
229
291
  codex: scriptPath,
230
292
  gemini: scriptPath,
231
293
  forge: scriptPath,
294
+ opencode: scriptPath,
232
295
  },
233
296
  });
234
297
  const runResult = await service.startProcess({
@@ -262,6 +325,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
262
325
  codex: '/bin/sh',
263
326
  gemini: '/bin/sh',
264
327
  forge: '/bin/sh',
328
+ opencode: '/bin/sh',
265
329
  },
266
330
  });
267
331
  writeFileSync(join(processDir, 'meta.json'), JSON.stringify({
@@ -321,6 +385,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
321
385
  codex: '/bin/sh',
322
386
  gemini: '/bin/sh',
323
387
  forge: '/bin/sh',
388
+ opencode: '/bin/sh',
324
389
  },
325
390
  });
326
391
  const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target, signal) => {
@@ -368,6 +433,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
368
433
  codex: '/bin/sh',
369
434
  gemini: '/bin/sh',
370
435
  forge: '/bin/sh',
436
+ opencode: '/bin/sh',
371
437
  },
372
438
  });
373
439
  const result = await service.cleanupProcesses();
@@ -429,6 +495,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
429
495
  codex: '/bin/sh',
430
496
  gemini: '/bin/sh',
431
497
  forge: '/bin/sh',
498
+ opencode: '/bin/sh',
432
499
  },
433
500
  });
434
501
  const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target, signal) => {
@@ -479,6 +546,7 @@ Forge assistant reply
479
546
  codex: '/bin/sh',
480
547
  gemini: '/bin/sh',
481
548
  forge: '/bin/sh',
549
+ opencode: '/bin/sh',
482
550
  },
483
551
  });
484
552
  const result = await service.getProcessResult(pid, false);
@@ -489,4 +557,98 @@ Forge assistant reply
489
557
  session_id: 'forge-conv-1',
490
558
  });
491
559
  });
560
+ it('parses successful OpenCode detached runs from stdout only', async () => {
561
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
562
+ tempDirs.push(root);
563
+ const stateDir = join(root, 'state');
564
+ const workFolder = join(root, 'opencode-project');
565
+ mkdirSync(workFolder, { recursive: true });
566
+ const argsLogPath = join(root, 'opencode-args.log');
567
+ const { scriptPath } = createOpenCodeMock(root, { argsLogPath });
568
+ const service = new CliProcessService({
569
+ stateDir,
570
+ cliPaths: {
571
+ claude: '/bin/sh',
572
+ codex: '/bin/sh',
573
+ gemini: '/bin/sh',
574
+ forge: '/bin/sh',
575
+ opencode: scriptPath,
576
+ },
577
+ });
578
+ const runResult = await service.startProcess({
579
+ prompt: 'hello opencode',
580
+ cwd: workFolder,
581
+ model: 'opencode',
582
+ });
583
+ const waited = await service.waitForProcesses([runResult.pid], 5);
584
+ expect(waited).toHaveLength(1);
585
+ expect(waited[0]).toMatchObject({
586
+ pid: runResult.pid,
587
+ agent: 'opencode',
588
+ status: 'completed',
589
+ exitCode: 0,
590
+ model: 'opencode',
591
+ session_id: 'ses-opencode-default',
592
+ agentOutput: {
593
+ message: 'Initial: hello opencode',
594
+ session_id: 'ses-opencode-default',
595
+ tokens: { total: 11833 },
596
+ cost: 0,
597
+ },
598
+ });
599
+ expect(waited[0]).not.toHaveProperty('stdout');
600
+ expect(waited[0]).not.toHaveProperty('stderr');
601
+ expect(readFileSync(argsLogPath, 'utf8')).toContain(`--dir ${workFolder}`);
602
+ });
603
+ it('preserves raw stdout and stderr for failed detached OpenCode runs', async () => {
604
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
605
+ tempDirs.push(root);
606
+ const stateDir = join(root, 'state');
607
+ const workFolder = join(root, 'opencode-fail-project');
608
+ mkdirSync(workFolder, { recursive: true });
609
+ const { scriptPath } = createOpenCodeMock(root);
610
+ const service = new CliProcessService({
611
+ stateDir,
612
+ cliPaths: {
613
+ claude: '/bin/sh',
614
+ codex: '/bin/sh',
615
+ gemini: '/bin/sh',
616
+ forge: '/bin/sh',
617
+ opencode: scriptPath,
618
+ },
619
+ });
620
+ const runResult = await service.startProcess({
621
+ prompt: 'please fail',
622
+ cwd: workFolder,
623
+ model: 'oc-openai/gpt-5.4',
624
+ });
625
+ const [compactResult] = await service.waitForProcesses([runResult.pid], 5);
626
+ expect(compactResult).toMatchObject({
627
+ pid: runResult.pid,
628
+ agent: 'opencode',
629
+ status: 'failed',
630
+ exitCode: 7,
631
+ model: 'oc-openai/gpt-5.4',
632
+ session_id: 'ses-opencode-default',
633
+ stdout: expect.stringContaining('Partial failure output'),
634
+ stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
635
+ });
636
+ expect(compactResult).not.toHaveProperty('agentOutput');
637
+ const verboseResult = await service.getProcessResult(runResult.pid, true);
638
+ expect(verboseResult).toMatchObject({
639
+ pid: runResult.pid,
640
+ agent: 'opencode',
641
+ status: 'failed',
642
+ exitCode: 7,
643
+ session_id: 'ses-opencode-default',
644
+ stdout: expect.stringContaining('Partial failure output'),
645
+ stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
646
+ agentOutput: {
647
+ message: 'Partial failure output',
648
+ session_id: 'ses-opencode-default',
649
+ tokens: { total: 42 },
650
+ cost: 0,
651
+ },
652
+ });
653
+ });
492
654
  });
@@ -16,6 +16,7 @@ describe('cli-utils doctor status', () => {
16
16
  delete process.env.CODEX_CLI_NAME;
17
17
  delete process.env.GEMINI_CLI_NAME;
18
18
  delete process.env.FORGE_CLI_NAME;
19
+ delete process.env.OPENCODE_CLI_NAME;
19
20
  process.env.PATH = '/mock/bin:/usr/bin';
20
21
  });
21
22
  afterEach(() => {
@@ -43,6 +44,12 @@ describe('cli-utils doctor status', () => {
43
44
  available: false,
44
45
  lookup: 'path',
45
46
  });
47
+ expect(status.opencode).toEqual({
48
+ configuredCommand: 'opencode',
49
+ resolvedPath: null,
50
+ available: false,
51
+ lookup: 'path',
52
+ });
46
53
  });
47
54
  it('does not mark non-executable PATH entries as available', async () => {
48
55
  mockAccessSync.mockImplementation(() => {
@@ -62,6 +69,12 @@ describe('cli-utils doctor status', () => {
62
69
  available: false,
63
70
  lookup: 'path',
64
71
  });
72
+ expect(status.opencode).toEqual({
73
+ configuredCommand: 'opencode',
74
+ resolvedPath: null,
75
+ available: false,
76
+ lookup: 'path',
77
+ });
65
78
  });
66
79
  it('reports invalid relative env paths as doctor errors', async () => {
67
80
  process.env.CLAUDE_CLI_NAME = './relative/claude';
@@ -137,4 +150,22 @@ describe('cli-utils doctor status', () => {
137
150
  });
138
151
  expect(findForgeCli()).toBe('forge-custom');
139
152
  });
153
+ it('supports OpenCode lookup via OPENCODE_CLI_NAME', async () => {
154
+ process.env.OPENCODE_CLI_NAME = 'opencode-custom';
155
+ mockAccessSync.mockImplementation((filePath) => {
156
+ if (filePath === '/mock/bin/opencode-custom') {
157
+ return undefined;
158
+ }
159
+ throw new Error('not executable');
160
+ });
161
+ const { getCliDoctorStatus, findOpencodeCli } = await import('../cli-utils.js');
162
+ const status = getCliDoctorStatus();
163
+ expect(status.opencode).toEqual({
164
+ configuredCommand: 'opencode-custom',
165
+ resolvedPath: '/mock/bin/opencode-custom',
166
+ available: true,
167
+ lookup: 'env',
168
+ });
169
+ expect(findOpencodeCli()).toBe('opencode-custom');
170
+ });
140
171
  });