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.
- package/.github/dependabot.yml +28 -0
- package/.github/workflows/ci.yml +4 -1
- package/.github/workflows/dependency-review.yml +22 -0
- package/CHANGELOG.md +7 -0
- package/README.ja.md +25 -6
- package/README.md +25 -7
- package/dist/__tests__/app-cli.test.js +24 -4
- package/dist/__tests__/cli-bin-smoke.test.js +43 -0
- package/dist/__tests__/cli-builder.test.js +92 -14
- package/dist/__tests__/cli-process-service.test.js +103 -0
- package/dist/__tests__/cli-utils.test.js +31 -0
- package/dist/__tests__/e2e.test.js +77 -51
- package/dist/__tests__/mcp-contract.test.js +154 -0
- package/dist/__tests__/parsers.test.js +62 -1
- package/dist/__tests__/process-management.test.js +1 -1
- package/dist/__tests__/server.test.js +35 -6
- package/dist/__tests__/utils/opencode-mock.js +91 -0
- package/dist/__tests__/validation.test.js +40 -2
- package/dist/app/cli.js +4 -4
- package/dist/app/mcp.js +8 -4
- package/dist/cli-builder.js +66 -27
- package/dist/cli-parse.js +11 -5
- package/dist/cli-process-service.js +139 -20
- package/dist/cli-utils.js +14 -23
- package/dist/cli.js +6 -4
- package/dist/model-catalog.js +13 -1
- package/dist/parsers.js +57 -26
- package/dist/process-result.js +9 -2
- package/dist/process-service.js +23 -17
- package/dist/server.js +1 -2
- package/package.json +9 -6
- package/src/__tests__/app-cli.test.ts +24 -4
- package/src/__tests__/cli-bin-smoke.test.ts +62 -1
- package/src/__tests__/cli-builder.test.ts +110 -14
- package/src/__tests__/cli-process-service.test.ts +112 -0
- package/src/__tests__/cli-utils.test.ts +34 -0
- package/src/__tests__/e2e.test.ts +85 -54
- package/src/__tests__/mcp-contract.test.ts +179 -0
- package/src/__tests__/parsers.test.ts +73 -1
- package/src/__tests__/process-management.test.ts +1 -1
- package/src/__tests__/server.test.ts +45 -10
- package/src/__tests__/utils/opencode-mock.ts +108 -0
- package/src/__tests__/validation.test.ts +48 -2
- package/src/app/cli.ts +4 -4
- package/src/app/mcp.ts +8 -4
- package/src/cli-builder.ts +90 -31
- package/src/cli-parse.ts +11 -5
- package/src/cli-process-service.ts +171 -17
- package/src/cli-utils.ts +37 -33
- package/src/cli.ts +6 -4
- package/src/model-catalog.ts +24 -1
- package/src/parsers.ts +77 -31
- package/src/process-result.ts +11 -2
- package/src/process-service.ts +28 -15
- package/src/server.ts +2 -2
- 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.
|
|
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
|
|
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.
|
|
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": "^
|
|
40
|
+
"@vitest/coverage-v8": "^4.1.3",
|
|
38
41
|
"husky": "^9.1.7",
|
|
39
|
-
"semantic-release": "^25.0.
|
|
42
|
+
"semantic-release": "^25.0.3",
|
|
40
43
|
"tsx": "^4.19.4",
|
|
41
44
|
"typescript": "^5.8.3",
|
|
42
|
-
"vitest": "^
|
|
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(
|
|
242
|
-
expect(
|
|
243
|
-
expect(
|
|
244
|
-
expect(
|
|
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,
|
|
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('
|
|
413
|
-
it('should build
|
|
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: '
|
|
427
|
+
model: 'opencode',
|
|
418
428
|
cliPaths: DEFAULT_CLI_PATHS,
|
|
419
429
|
});
|
|
420
430
|
|
|
421
|
-
expect(cmd.agent).toBe('
|
|
422
|
-
expect(cmd.cliPath).toBe('/usr/bin/
|
|
423
|
-
expect(cmd.
|
|
424
|
-
expect(cmd.args).toEqual(['
|
|
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
|
|
438
|
+
it('should route valid explicit OpenCode model syntax', () => {
|
|
428
439
|
const cmd = buildCliCommand({
|
|
429
440
|
prompt: 'test',
|
|
430
441
|
workFolder: '/tmp',
|
|
431
|
-
model: '
|
|
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.
|
|
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('
|
|
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: '
|
|
495
|
+
model: 'opencode',
|
|
445
496
|
reasoning_effort: 'high',
|
|
446
497
|
cliPaths: DEFAULT_CLI_PATHS,
|
|
447
498
|
})
|
|
448
|
-
).toThrow('reasoning_effort is not supported for
|
|
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
|
});
|