ai-cli-mcp 2.14.1 → 2.15.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 (56) 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 +7 -0
  5. package/README.ja.md +25 -6
  6. package/README.md +25 -7
  7. package/dist/__tests__/app-cli.test.js +24 -4
  8. package/dist/__tests__/cli-bin-smoke.test.js +43 -0
  9. package/dist/__tests__/cli-builder.test.js +92 -14
  10. package/dist/__tests__/cli-process-service.test.js +103 -0
  11. package/dist/__tests__/cli-utils.test.js +31 -0
  12. package/dist/__tests__/e2e.test.js +77 -51
  13. package/dist/__tests__/mcp-contract.test.js +154 -0
  14. package/dist/__tests__/parsers.test.js +62 -1
  15. package/dist/__tests__/process-management.test.js +1 -1
  16. package/dist/__tests__/server.test.js +35 -6
  17. package/dist/__tests__/utils/opencode-mock.js +91 -0
  18. package/dist/__tests__/validation.test.js +40 -2
  19. package/dist/app/cli.js +4 -4
  20. package/dist/app/mcp.js +8 -4
  21. package/dist/cli-builder.js +66 -27
  22. package/dist/cli-parse.js +11 -5
  23. package/dist/cli-process-service.js +139 -20
  24. package/dist/cli-utils.js +14 -23
  25. package/dist/cli.js +6 -4
  26. package/dist/model-catalog.js +13 -1
  27. package/dist/parsers.js +57 -26
  28. package/dist/process-result.js +9 -2
  29. package/dist/process-service.js +23 -17
  30. package/dist/server.js +1 -2
  31. package/package.json +9 -6
  32. package/src/__tests__/app-cli.test.ts +24 -4
  33. package/src/__tests__/cli-bin-smoke.test.ts +62 -1
  34. package/src/__tests__/cli-builder.test.ts +110 -14
  35. package/src/__tests__/cli-process-service.test.ts +112 -0
  36. package/src/__tests__/cli-utils.test.ts +34 -0
  37. package/src/__tests__/e2e.test.ts +85 -54
  38. package/src/__tests__/mcp-contract.test.ts +179 -0
  39. package/src/__tests__/parsers.test.ts +73 -1
  40. package/src/__tests__/process-management.test.ts +1 -1
  41. package/src/__tests__/server.test.ts +45 -10
  42. package/src/__tests__/utils/opencode-mock.ts +108 -0
  43. package/src/__tests__/validation.test.ts +48 -2
  44. package/src/app/cli.ts +4 -4
  45. package/src/app/mcp.ts +8 -4
  46. package/src/cli-builder.ts +90 -31
  47. package/src/cli-parse.ts +11 -5
  48. package/src/cli-process-service.ts +171 -17
  49. package/src/cli-utils.ts +37 -33
  50. package/src/cli.ts +6 -4
  51. package/src/model-catalog.ts +24 -1
  52. package/src/parsers.ts +77 -31
  53. package/src/process-result.ts +11 -2
  54. package/src/process-service.ts +28 -15
  55. package/src/server.ts +2 -2
  56. package/vitest.config.unit.ts +2 -3
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "ai-cli-mcp",
3
- "version": "2.14.1",
3
+ "version": "2.15.0",
4
4
  "mcpName": "io.github.mkXultra/ai-cli-mcp",
5
- "description": "MCP server for AI CLI tools (Claude, Codex, Gemini, and Forge) with background process management",
5
+ "description": "MCP server for AI CLI tools (Claude, Codex, Gemini, Forge, and OpenCode) with background process management",
6
6
  "author": "mkXultra",
7
7
  "license": "MIT",
8
8
  "main": "dist/server.js",
@@ -25,21 +25,24 @@
25
25
  "prepare": "husky"
26
26
  },
27
27
  "dependencies": {
28
- "@modelcontextprotocol/sdk": "^1.11.2",
28
+ "@modelcontextprotocol/sdk": "^1.29.0",
29
29
  "zod": "^3.24.4"
30
30
  },
31
+ "engines": {
32
+ "node": "^20.19.0 || >=22.12.0"
33
+ },
31
34
  "type": "module",
32
35
  "devDependencies": {
33
36
  "@eslint/js": "^9.26.0",
34
37
  "@semantic-release/changelog": "^6.0.3",
35
38
  "@semantic-release/git": "^10.0.1",
36
39
  "@types/node": "^22.15.17",
37
- "@vitest/coverage-v8": "^2.1.8",
40
+ "@vitest/coverage-v8": "^4.1.3",
38
41
  "husky": "^9.1.7",
39
- "semantic-release": "^25.0.2",
42
+ "semantic-release": "^25.0.3",
40
43
  "tsx": "^4.19.4",
41
44
  "typescript": "^5.8.3",
42
- "vitest": "^2.1.8"
45
+ "vitest": "^4.1.3"
43
46
  },
44
47
  "repository": {
45
48
  "type": "git",
@@ -236,12 +236,22 @@ describe('ai-cli app', () => {
236
236
  const stderr = vi.fn();
237
237
 
238
238
  const exitCode = await runCli(['models'], { stdout, stderr });
239
+ const payload = JSON.parse(stdout.mock.calls[0][0]);
239
240
 
240
241
  expect(exitCode).toBe(0);
241
- expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"aliases"'));
242
- expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"claude-ultra"'));
243
- expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"gpt-5.4"'));
244
- expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"forge"'));
242
+ expect(payload.aliases).toEqual(expect.any(Array));
243
+ expect(payload.claude).toContain('sonnet');
244
+ expect(payload.codex).toContain('gpt-5.4');
245
+ expect(payload.forge).toEqual(['forge']);
246
+ expect(payload.opencode).toEqual(['opencode']);
247
+ expect(payload.dynamicModelBackends).toEqual({
248
+ opencode: {
249
+ explicitPrefix: 'oc-',
250
+ explicitPattern: 'oc-<provider/model>',
251
+ discoveryCommand: 'opencode models',
252
+ modelsAreDynamic: true,
253
+ },
254
+ });
245
255
  expect(stderr).not.toHaveBeenCalled();
246
256
  });
247
257
 
@@ -273,6 +283,12 @@ describe('ai-cli app', () => {
273
283
  available: true,
274
284
  lookup: 'path',
275
285
  },
286
+ opencode: {
287
+ configuredCommand: 'opencode',
288
+ resolvedPath: '/tmp/bin/opencode',
289
+ available: true,
290
+ lookup: 'path',
291
+ },
276
292
  });
277
293
 
278
294
  const exitCode = await runCli(['doctor'], { stdout, stderr, getDoctorStatus });
@@ -307,6 +323,8 @@ describe('ai-cli app', () => {
307
323
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gpt-5.2-codex'));
308
324
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gemini-2.5-pro'));
309
325
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('forge'));
326
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('opencode'));
327
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('oc-openai/gpt-5.4'));
310
328
  expect(stderr).not.toHaveBeenCalled();
311
329
  });
312
330
 
@@ -355,6 +373,7 @@ describe('ai-cli app', () => {
355
373
 
356
374
  expect(exitCode).toBe(0);
357
375
  expect(stdout).toHaveBeenCalledWith(DOCTOR_HELP_TEXT);
376
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('OpenCode'));
358
377
  expect(stderr).not.toHaveBeenCalled();
359
378
  });
360
379
 
@@ -366,6 +385,7 @@ describe('ai-cli app', () => {
366
385
 
367
386
  expect(exitCode).toBe(0);
368
387
  expect(stdout).toHaveBeenCalledWith(DOCTOR_HELP_TEXT);
388
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('OpenCode'));
369
389
  expect(stderr).not.toHaveBeenCalled();
370
390
  });
371
391
 
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { delimiter, join } from 'node:path';
5
5
  import { afterEach, describe, expect, it } from 'vitest';
@@ -24,6 +24,62 @@ afterEach(() => {
24
24
  }
25
25
  });
26
26
 
27
+ describe('cli helper entrypoint smoke', () => {
28
+ it('prints help for cli.run with OpenCode examples', () => {
29
+ const output = execFileSync(
30
+ 'node',
31
+ ['--import', 'tsx', 'src/cli.ts', '--help'],
32
+ {
33
+ cwd: process.cwd(),
34
+ encoding: 'utf8',
35
+ env: process.env,
36
+ }
37
+ );
38
+
39
+ expect(output).toContain('Usage: npm run -s cli.run -- --model <model> --workFolder <path> --prompt "..." [options]');
40
+ expect(output).toContain('opencode');
41
+ expect(output).toContain('oc-openai/gpt-5.4');
42
+ expect(output).toContain('OpenCode');
43
+ expect(output).toContain('npm run -s cli.run.parse -- --agent opencode < raw.txt');
44
+ });
45
+
46
+ it('prints help for cli.run.parse with OpenCode agent support', () => {
47
+ const output = execFileSync(
48
+ 'node',
49
+ ['--import', 'tsx', 'src/cli-parse.ts', '--help'],
50
+ {
51
+ cwd: process.cwd(),
52
+ encoding: 'utf8',
53
+ env: process.env,
54
+ }
55
+ );
56
+
57
+ expect(output).toContain('Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini|forge|opencode>');
58
+ expect(output).toContain('Agent type: claude, codex, gemini, forge, or opencode');
59
+ expect(output).toContain('npm run -s cli.run.parse -- --agent opencode < raw.txt');
60
+ });
61
+
62
+ it('parses OpenCode NDJSON through cli.run.parse', () => {
63
+ const output = execFileSync(
64
+ 'node',
65
+ ['--import', 'tsx', 'src/cli-parse.ts', '--agent', 'opencode'],
66
+ {
67
+ cwd: process.cwd(),
68
+ encoding: 'utf8',
69
+ env: process.env,
70
+ 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',
71
+ }
72
+ );
73
+
74
+ expect(JSON.parse(output)).toEqual({
75
+ message: 'Hello from cli.parse',
76
+ session_id: 'ses_cli_parse',
77
+ tokens: { total: 9 },
78
+ cost: 1,
79
+ });
80
+ });
81
+ });
82
+
27
83
  describe('ai-cli entrypoint smoke', () => {
28
84
  it('prints doctor output for the ai-cli entrypoint', () => {
29
85
  const fakeBinDir = makeTempDir('ai-cli-bin-');
@@ -31,6 +87,7 @@ describe('ai-cli entrypoint smoke', () => {
31
87
  writeExecutable(fakeBinDir, 'codex');
32
88
  writeExecutable(fakeBinDir, 'gemini');
33
89
  writeExecutable(fakeBinDir, 'forge');
90
+ writeExecutable(fakeBinDir, 'opencode');
34
91
 
35
92
  const output = execFileSync(
36
93
  'node',
@@ -45,6 +102,7 @@ describe('ai-cli entrypoint smoke', () => {
45
102
  CODEX_CLI_NAME: 'codex',
46
103
  GEMINI_CLI_NAME: 'gemini',
47
104
  FORGE_CLI_NAME: 'forge',
105
+ OPENCODE_CLI_NAME: 'opencode',
48
106
  },
49
107
  }
50
108
  );
@@ -53,6 +111,7 @@ describe('ai-cli entrypoint smoke', () => {
53
111
  expect(output).toContain('"codex"');
54
112
  expect(output).toContain('"gemini"');
55
113
  expect(output).toContain('"forge"');
114
+ expect(output).toContain('"opencode"');
56
115
  expect(output).toContain('"available": true');
57
116
  });
58
117
 
@@ -71,5 +130,7 @@ describe('ai-cli entrypoint smoke', () => {
71
130
  expect(output).toContain('--model <model>');
72
131
  expect(output).toContain('claude-ultra');
73
132
  expect(output).toContain('forge');
133
+ expect(output).toContain('opencode');
134
+ expect(output).toContain('oc-openai/gpt-5.4');
74
135
  });
75
136
  });
@@ -23,6 +23,7 @@ const DEFAULT_CLI_PATHS = {
23
23
  codex: '/usr/bin/codex',
24
24
  gemini: '/usr/bin/gemini',
25
25
  forge: '/usr/bin/forge',
26
+ opencode: '/usr/bin/opencode',
26
27
  };
27
28
 
28
29
  describe('cli-builder', () => {
@@ -104,6 +105,15 @@ describe('cli-builder', () => {
104
105
  'reasoning_effort is not supported for forge.'
105
106
  );
106
107
  });
108
+
109
+ it('should reject reasoning_effort for opencode explicitly', () => {
110
+ expect(() => getReasoningEffort('opencode', 'high')).toThrow(
111
+ 'reasoning_effort is not supported for opencode.'
112
+ );
113
+ expect(() => getReasoningEffort('oc-openai/gpt-5.4', 'high')).toThrow(
114
+ 'reasoning_effort is not supported for opencode.'
115
+ );
116
+ });
107
117
  });
108
118
 
109
119
  describe('buildCliCommand', () => {
@@ -409,43 +419,129 @@ describe('cli-builder', () => {
409
419
  });
410
420
  });
411
421
 
412
- describe('forge agent', () => {
413
- it('should build forge command without model flags', () => {
422
+ describe('opencode agent', () => {
423
+ it('should build default opencode command without --model', () => {
414
424
  const cmd = buildCliCommand({
415
425
  prompt: 'test',
416
426
  workFolder: '/tmp',
417
- model: 'forge',
427
+ model: 'opencode',
418
428
  cliPaths: DEFAULT_CLI_PATHS,
419
429
  });
420
430
 
421
- expect(cmd.agent).toBe('forge');
422
- expect(cmd.cliPath).toBe('/usr/bin/forge');
423
- expect(cmd.resolvedModel).toBe('forge');
424
- expect(cmd.args).toEqual(['-C', '/tmp', '-p', 'test']);
431
+ expect(cmd.agent).toBe('opencode');
432
+ expect(cmd.cliPath).toBe('/usr/bin/opencode');
433
+ expect(cmd.cwd).toBe('/tmp');
434
+ expect(cmd.args).toEqual(['run', '--format', 'json', '--dir', '/tmp', 'test']);
435
+ expect(cmd.args).not.toContain('--model');
425
436
  });
426
437
 
427
- it('should map session_id to --conversation-id for forge', () => {
438
+ it('should route valid explicit OpenCode model syntax', () => {
428
439
  const cmd = buildCliCommand({
429
440
  prompt: 'test',
430
441
  workFolder: '/tmp',
431
- model: 'forge',
432
- session_id: 'forge-conv-123',
442
+ model: 'oc-openai/gpt-5.4',
433
443
  cliPaths: DEFAULT_CLI_PATHS,
434
444
  });
435
445
 
436
- expect(cmd.args).toEqual(['-C', '/tmp', '--conversation-id', 'forge-conv-123', '-p', 'test']);
446
+ expect(cmd.agent).toBe('opencode');
447
+ expect(cmd.resolvedModel).toBe('oc-openai/gpt-5.4');
448
+ expect(cmd.args).toEqual([
449
+ 'run',
450
+ '--format',
451
+ 'json',
452
+ '--dir',
453
+ '/tmp',
454
+ '--model',
455
+ 'openai/gpt-5.4',
456
+ 'test',
457
+ ]);
458
+ });
459
+
460
+ it.each([
461
+ 'oc-',
462
+ 'oc-openai',
463
+ 'oc-/gpt-5.4',
464
+ 'oc-openai/',
465
+ ])('should reject invalid explicit OpenCode syntax: %s', (model) => {
466
+ expect(() =>
467
+ buildCliCommand({
468
+ prompt: 'test',
469
+ workFolder: '/tmp',
470
+ model,
471
+ cliPaths: DEFAULT_CLI_PATHS,
472
+ })
473
+ ).toThrow('Invalid OpenCode model. Expected exact syntax oc-<provider/model>.');
437
474
  });
438
475
 
439
- it('should reject reasoning_effort for forge in command building', () => {
476
+ it.each([' oc-openai/gpt-5.4', 'oc-openai/gpt-5.4 '])(
477
+ 'should reject explicit OpenCode models with surrounding whitespace: %s',
478
+ (model) => {
479
+ expect(() =>
480
+ buildCliCommand({
481
+ prompt: 'test',
482
+ workFolder: '/tmp',
483
+ model,
484
+ cliPaths: DEFAULT_CLI_PATHS,
485
+ })
486
+ ).toThrow('Invalid OpenCode model. Expected exact syntax oc-<provider/model>.');
487
+ }
488
+ );
489
+
490
+ it('should reject reasoning_effort for OpenCode in command building', () => {
440
491
  expect(() =>
441
492
  buildCliCommand({
442
493
  prompt: 'test',
443
494
  workFolder: '/tmp',
444
- model: 'forge',
495
+ model: 'opencode',
445
496
  reasoning_effort: 'high',
446
497
  cliPaths: DEFAULT_CLI_PATHS,
447
498
  })
448
- ).toThrow('reasoning_effort is not supported for forge.');
499
+ ).toThrow('reasoning_effort is not supported for opencode.');
500
+ });
501
+
502
+ it('should build resumed default OpenCode command', () => {
503
+ const cmd = buildCliCommand({
504
+ prompt: 'resume prompt',
505
+ workFolder: '/tmp',
506
+ model: 'opencode',
507
+ session_id: 'ses-123',
508
+ cliPaths: DEFAULT_CLI_PATHS,
509
+ });
510
+
511
+ expect(cmd.args).toEqual([
512
+ 'run',
513
+ '--format',
514
+ 'json',
515
+ '--dir',
516
+ '/tmp',
517
+ '--session',
518
+ 'ses-123',
519
+ 'resume prompt',
520
+ ]);
521
+ expect(cmd.args).not.toContain('--model');
522
+ });
523
+
524
+ it('should build resumed explicit OpenCode command', () => {
525
+ const cmd = buildCliCommand({
526
+ prompt: 'resume prompt',
527
+ workFolder: '/tmp',
528
+ model: 'oc-openai/gpt-5.4',
529
+ session_id: 'ses-456',
530
+ cliPaths: DEFAULT_CLI_PATHS,
531
+ });
532
+
533
+ expect(cmd.args).toEqual([
534
+ 'run',
535
+ '--format',
536
+ 'json',
537
+ '--dir',
538
+ '/tmp',
539
+ '--session',
540
+ 'ses-456',
541
+ '--model',
542
+ 'openai/gpt-5.4',
543
+ 'resume prompt',
544
+ ]);
449
545
  });
450
546
  });
451
547
  });
@@ -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
 
7
8
  function createMockCliScript(dir: string, name: string, options: { ignoreSigterm?: boolean } = {}): string {
8
9
  const scriptPath = join(dir, name);
@@ -66,6 +67,7 @@ describe('CliProcessService', () => {
66
67
  codex: scriptPath,
67
68
  gemini: scriptPath,
68
69
  forge: scriptPath,
70
+ opencode: scriptPath,
69
71
  },
70
72
  });
71
73
 
@@ -145,6 +147,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
145
147
  codex: scriptPath,
146
148
  gemini: scriptPath,
147
149
  forge: scriptPath,
150
+ opencode: scriptPath,
148
151
  },
149
152
  });
150
153
 
@@ -255,6 +258,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
255
258
  codex: scriptPath,
256
259
  gemini: scriptPath,
257
260
  forge: scriptPath,
261
+ opencode: scriptPath,
258
262
  },
259
263
  });
260
264
 
@@ -294,6 +298,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
294
298
  codex: '/bin/sh',
295
299
  gemini: '/bin/sh',
296
300
  forge: '/bin/sh',
301
+ opencode: '/bin/sh',
297
302
  },
298
303
  });
299
304
 
@@ -368,6 +373,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
368
373
  codex: '/bin/sh',
369
374
  gemini: '/bin/sh',
370
375
  forge: '/bin/sh',
376
+ opencode: '/bin/sh',
371
377
  },
372
378
  });
373
379
 
@@ -426,6 +432,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
426
432
  codex: '/bin/sh',
427
433
  gemini: '/bin/sh',
428
434
  forge: '/bin/sh',
435
+ opencode: '/bin/sh',
429
436
  },
430
437
  });
431
438
 
@@ -502,6 +509,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
502
509
  codex: '/bin/sh',
503
510
  gemini: '/bin/sh',
504
511
  forge: '/bin/sh',
512
+ opencode: '/bin/sh',
505
513
  },
506
514
  });
507
515
 
@@ -564,6 +572,7 @@ Forge assistant reply
564
572
  codex: '/bin/sh',
565
573
  gemini: '/bin/sh',
566
574
  forge: '/bin/sh',
575
+ opencode: '/bin/sh',
567
576
  },
568
577
  });
569
578
 
@@ -575,4 +584,107 @@ Forge assistant reply
575
584
  session_id: 'forge-conv-1',
576
585
  });
577
586
  });
587
+
588
+ it('parses successful OpenCode detached runs from stdout only', async () => {
589
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
590
+ tempDirs.push(root);
591
+ const stateDir = join(root, 'state');
592
+ const workFolder = join(root, 'opencode-project');
593
+ mkdirSync(workFolder, { recursive: true });
594
+ const argsLogPath = join(root, 'opencode-args.log');
595
+ const { scriptPath } = createOpenCodeMock(root, { argsLogPath });
596
+
597
+ const service = new CliProcessService({
598
+ stateDir,
599
+ cliPaths: {
600
+ claude: '/bin/sh',
601
+ codex: '/bin/sh',
602
+ gemini: '/bin/sh',
603
+ forge: '/bin/sh',
604
+ opencode: scriptPath,
605
+ },
606
+ });
607
+
608
+ const runResult = await service.startProcess({
609
+ prompt: 'hello opencode',
610
+ cwd: workFolder,
611
+ model: 'opencode',
612
+ });
613
+
614
+ const waited = await service.waitForProcesses([runResult.pid], 5);
615
+ expect(waited).toHaveLength(1);
616
+ expect(waited[0]).toMatchObject({
617
+ pid: runResult.pid,
618
+ agent: 'opencode',
619
+ status: 'completed',
620
+ exitCode: 0,
621
+ model: 'opencode',
622
+ session_id: 'ses-opencode-default',
623
+ agentOutput: {
624
+ message: 'Initial: hello opencode',
625
+ session_id: 'ses-opencode-default',
626
+ tokens: { total: 11833 },
627
+ cost: 0,
628
+ },
629
+ });
630
+ expect(waited[0]).not.toHaveProperty('stdout');
631
+ expect(waited[0]).not.toHaveProperty('stderr');
632
+ expect(readFileSync(argsLogPath, 'utf8')).toContain(`--dir ${workFolder}`);
633
+ });
634
+
635
+ it('preserves raw stdout and stderr for failed detached OpenCode runs', async () => {
636
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
637
+ tempDirs.push(root);
638
+ const stateDir = join(root, 'state');
639
+ const workFolder = join(root, 'opencode-fail-project');
640
+ mkdirSync(workFolder, { recursive: true });
641
+ const { scriptPath } = createOpenCodeMock(root);
642
+
643
+ const service = new CliProcessService({
644
+ stateDir,
645
+ cliPaths: {
646
+ claude: '/bin/sh',
647
+ codex: '/bin/sh',
648
+ gemini: '/bin/sh',
649
+ forge: '/bin/sh',
650
+ opencode: scriptPath,
651
+ },
652
+ });
653
+
654
+ const runResult = await service.startProcess({
655
+ prompt: 'please fail',
656
+ cwd: workFolder,
657
+ model: 'oc-openai/gpt-5.4',
658
+ });
659
+
660
+ const [compactResult] = await service.waitForProcesses([runResult.pid], 5);
661
+ expect(compactResult).toMatchObject({
662
+ pid: runResult.pid,
663
+ agent: 'opencode',
664
+ status: 'failed',
665
+ exitCode: 7,
666
+ model: 'oc-openai/gpt-5.4',
667
+ session_id: 'ses-opencode-default',
668
+ stdout: expect.stringContaining('Partial failure output'),
669
+ stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
670
+ });
671
+ expect(compactResult).not.toHaveProperty('agentOutput');
672
+
673
+ const verboseResult = await service.getProcessResult(runResult.pid, true);
674
+ expect(verboseResult).toMatchObject({
675
+ pid: runResult.pid,
676
+ agent: 'opencode',
677
+ status: 'failed',
678
+ exitCode: 7,
679
+ session_id: 'ses-opencode-default',
680
+ stdout: expect.stringContaining('Partial failure output'),
681
+ stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
682
+ agentOutput: {
683
+ message: 'Partial failure output',
684
+ session_id: 'ses-opencode-default',
685
+ tokens: { total: 42 },
686
+ cost: 0,
687
+ },
688
+ });
689
+ });
578
690
  });
@@ -20,6 +20,7 @@ describe('cli-utils doctor status', () => {
20
20
  delete process.env.CODEX_CLI_NAME;
21
21
  delete process.env.GEMINI_CLI_NAME;
22
22
  delete process.env.FORGE_CLI_NAME;
23
+ delete process.env.OPENCODE_CLI_NAME;
23
24
  process.env.PATH = '/mock/bin:/usr/bin';
24
25
  });
25
26
 
@@ -51,6 +52,12 @@ describe('cli-utils doctor status', () => {
51
52
  available: false,
52
53
  lookup: 'path',
53
54
  });
55
+ expect(status.opencode).toEqual({
56
+ configuredCommand: 'opencode',
57
+ resolvedPath: null,
58
+ available: false,
59
+ lookup: 'path',
60
+ });
54
61
  });
55
62
 
56
63
  it('does not mark non-executable PATH entries as available', async () => {
@@ -73,6 +80,12 @@ describe('cli-utils doctor status', () => {
73
80
  available: false,
74
81
  lookup: 'path',
75
82
  });
83
+ expect(status.opencode).toEqual({
84
+ configuredCommand: 'opencode',
85
+ resolvedPath: null,
86
+ available: false,
87
+ lookup: 'path',
88
+ });
76
89
  });
77
90
 
78
91
  it('reports invalid relative env paths as doctor errors', async () => {
@@ -163,4 +176,25 @@ describe('cli-utils doctor status', () => {
163
176
  });
164
177
  expect(findForgeCli()).toBe('forge-custom');
165
178
  });
179
+
180
+ it('supports OpenCode lookup via OPENCODE_CLI_NAME', async () => {
181
+ process.env.OPENCODE_CLI_NAME = 'opencode-custom';
182
+ mockAccessSync.mockImplementation((filePath) => {
183
+ if (filePath === '/mock/bin/opencode-custom') {
184
+ return undefined;
185
+ }
186
+ throw new Error('not executable');
187
+ });
188
+
189
+ const { getCliDoctorStatus, findOpencodeCli } = await import('../cli-utils.js');
190
+ const status = getCliDoctorStatus();
191
+
192
+ expect(status.opencode).toEqual({
193
+ configuredCommand: 'opencode-custom',
194
+ resolvedPath: '/mock/bin/opencode-custom',
195
+ available: true,
196
+ lookup: 'env',
197
+ });
198
+ expect(findOpencodeCli()).toBe('opencode-custom');
199
+ });
166
200
  });