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.
- package/.github/dependabot.yml +28 -0
- package/.github/workflows/ci.yml +4 -1
- package/.github/workflows/dependency-review.yml +22 -0
- package/CHANGELOG.md +14 -0
- package/README.ja.md +83 -6
- package/README.md +83 -7
- package/dist/__tests__/app-cli.test.js +80 -5
- package/dist/__tests__/cli-bin-smoke.test.js +43 -0
- package/dist/__tests__/cli-builder.test.js +93 -15
- package/dist/__tests__/cli-process-service.test.js +162 -0
- package/dist/__tests__/cli-utils.test.js +31 -0
- package/dist/__tests__/e2e.test.js +79 -52
- package/dist/__tests__/mcp-contract.test.js +162 -0
- package/dist/__tests__/parsers.test.js +224 -1
- package/dist/__tests__/peek.test.js +35 -0
- package/dist/__tests__/process-management.test.js +160 -1
- package/dist/__tests__/server.test.js +39 -9
- package/dist/__tests__/utils/opencode-mock.js +91 -0
- package/dist/__tests__/validation.test.js +40 -2
- package/dist/app/cli.js +47 -5
- package/dist/app/mcp.js +53 -4
- package/dist/cli-builder.js +67 -28
- package/dist/cli-parse.js +11 -5
- package/dist/cli-process-service.js +241 -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 +242 -28
- package/dist/peek.js +56 -0
- package/dist/process-result.js +9 -2
- package/dist/process-service.js +103 -17
- package/dist/server.js +1 -2
- package/package.json +9 -6
- package/src/__tests__/app-cli.test.ts +95 -4
- package/src/__tests__/cli-bin-smoke.test.ts +62 -1
- package/src/__tests__/cli-builder.test.ts +111 -15
- package/src/__tests__/cli-process-service.test.ts +180 -0
- package/src/__tests__/cli-utils.test.ts +34 -0
- package/src/__tests__/e2e.test.ts +87 -55
- package/src/__tests__/mcp-contract.test.ts +188 -0
- package/src/__tests__/parsers.test.ts +260 -1
- package/src/__tests__/peek.test.ts +43 -0
- package/src/__tests__/process-management.test.ts +185 -1
- package/src/__tests__/server.test.ts +49 -13
- package/src/__tests__/utils/opencode-mock.ts +108 -0
- package/src/__tests__/validation.test.ts +48 -2
- package/src/app/cli.ts +52 -4
- package/src/app/mcp.ts +54 -4
- package/src/cli-builder.ts +91 -32
- package/src/cli-parse.ts +11 -5
- package/src/cli-process-service.ts +304 -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 +299 -33
- package/src/peek.ts +88 -0
- package/src/process-result.ts +11 -2
- package/src/process-service.ts +134 -15
- package/src/server.ts +2 -2
- 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('
|
|
330
|
-
it('should build
|
|
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: '
|
|
339
|
+
model: 'opencode',
|
|
335
340
|
cliPaths: DEFAULT_CLI_PATHS,
|
|
336
341
|
});
|
|
337
|
-
expect(cmd.agent).toBe('
|
|
338
|
-
expect(cmd.cliPath).toBe('/usr/bin/
|
|
339
|
-
expect(cmd.
|
|
340
|
-
expect(cmd.args).toEqual(['
|
|
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
|
|
348
|
+
it('should route valid explicit OpenCode model syntax', () => {
|
|
343
349
|
const cmd = buildCliCommand({
|
|
344
350
|
prompt: 'test',
|
|
345
351
|
workFolder: '/tmp',
|
|
346
|
-
model: '
|
|
347
|
-
session_id: 'forge-conv-123',
|
|
352
|
+
model: 'oc-openai/gpt-5.4',
|
|
348
353
|
cliPaths: DEFAULT_CLI_PATHS,
|
|
349
354
|
});
|
|
350
|
-
expect(cmd.
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|