ai-cli-mcp 2.14.0 → 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 +14 -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 +187 -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 +158 -25
  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 +217 -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 +193 -22
  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
@@ -1,7 +1,28 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { buildCliCommand } from './cli-builder.js';
3
- import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
3
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput } from './parsers.js';
4
4
  import { buildProcessResult } from './process-result.js';
5
+ function parseAgentOutput(agent, stdout, stderr) {
6
+ if (agent === 'codex') {
7
+ return parseCodexOutput(`${stdout || ''}\n${stderr || ''}`);
8
+ }
9
+ if (!stdout) {
10
+ return null;
11
+ }
12
+ if (agent === 'claude') {
13
+ return parseClaudeOutput(stdout);
14
+ }
15
+ if (agent === 'gemini') {
16
+ return parseGeminiOutput(stdout);
17
+ }
18
+ if (agent === 'forge') {
19
+ return parseForgeOutput(stdout);
20
+ }
21
+ if (agent === 'opencode') {
22
+ return parseOpenCodeOutput(stdout);
23
+ }
24
+ return null;
25
+ }
5
26
  export class ProcessService {
6
27
  processManager = new Map();
7
28
  cliPaths;
@@ -85,22 +106,7 @@ export class ProcessService {
85
106
  if (!process) {
86
107
  throw new Error(`Process with PID ${pid} not found`);
87
108
  }
88
- let agentOutput = null;
89
- if (process.toolType === 'codex') {
90
- const combinedOutput = (process.stdout || '') + '\n' + (process.stderr || '');
91
- agentOutput = parseCodexOutput(combinedOutput);
92
- }
93
- else if (process.stdout) {
94
- if (process.toolType === 'claude') {
95
- agentOutput = parseClaudeOutput(process.stdout);
96
- }
97
- else if (process.toolType === 'gemini') {
98
- agentOutput = parseGeminiOutput(process.stdout);
99
- }
100
- else if (process.toolType === 'forge') {
101
- agentOutput = parseForgeOutput(process.stdout);
102
- }
103
- }
109
+ const agentOutput = parseAgentOutput(process.toolType, process.stdout, process.stderr);
104
110
  return buildProcessResult({
105
111
  pid,
106
112
  agent: process.toolType,
package/dist/server.js CHANGED
@@ -1,5 +1,4 @@
1
- #!/usr/bin/env node
2
- export { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
1
+ export { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from './cli-utils.js';
3
2
  export { resolveModelAlias } from './cli-builder.js';
4
3
  export { ClaudeCodeServer, runMcpServer, spawnAsync } from './app/mcp.js';
5
4
  import { runMcpServer } from './app/mcp.js';
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "ai-cli-mcp",
3
- "version": "2.14.0",
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
  });