diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index e4778fb58..6b57c2aa6 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -189,7 +189,7 @@ export const en = { 'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars', 'help.option.wiki.force': 'Force full regeneration even if up to date', 'help.option.wiki.provider': - 'LLM provider: openai, openrouter, azure, custom, cursor, claude, or codex (default: openai)', + 'LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)', 'help.option.wiki.model': 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)', 'help.option.wiki.baseUrl': 'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index cd488de0a..0eec71c44 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -178,7 +178,7 @@ export const zhCN = { 'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar', 'help.option.wiki.force': '即使已是最新也强制完整重新生成', 'help.option.wiki.provider': - 'LLM 提供商:openai、openrouter、azure、custom、cursor、claude 或 codex(默认:openai)', + 'LLM 提供商:openai、openrouter、azure、custom、cursor、claude、codex 或 opencode(默认:openai)', 'help.option.wiki.model': 'LLM 模型或 Azure deployment 名称(默认:minimax/minimax-m2.5)', 'help.option.wiki.baseUrl': 'LLM API base URL。Azure v1:https://{resource}.openai.azure.com/openai/v1', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index f64b3a24d..8ae99455d 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -155,7 +155,7 @@ program .option('-f, --force', 'Force full regeneration even if up to date') .option( '--provider ', - 'LLM provider: openai, openrouter, azure, custom, cursor, claude, or codex (default: openai)', + 'LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)', ) .option('--model ', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)') .option( diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index d10d10875..446b83d30 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -58,14 +58,21 @@ function parsePositiveIntegerOption( function isLocalProvider( provider: LLMProvider | undefined, -): provider is 'cursor' | 'claude' | 'codex' { - return provider === 'cursor' || provider === 'claude' || provider === 'codex'; +): provider is 'cursor' | 'claude' | 'codex' | 'opencode' { + return ( + provider === 'cursor' || + provider === 'claude' || + provider === 'codex' || + provider === 'opencode' + ); } -function localModelConfigKey(provider: 'cursor' | 'claude' | 'codex') { +function localModelConfigKey(provider: 'cursor' | 'claude' | 'codex' | 'opencode') { if (provider === 'cursor') return 'cursorModel'; if (provider === 'claude') return 'claudeModel'; - return 'codexModel'; + if (provider === 'codex') return 'codexModel'; + if (provider === 'opencode') return 'opencodeModel'; + throw new Error(`Unsupported local provider: ${provider satisfies never}`); } /** @@ -248,7 +255,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) if (!llmConfig.apiKey && !isLocalProvider(llmConfig.provider)) { console.log(' Error: No LLM API key found.'); console.log(' Set OPENAI_API_KEY or GITNEXUS_API_KEY environment variable,'); - console.log(' or pass --api-key , or use --provider cursor|claude|codex.\n'); + console.log(' or pass --api-key , or use --provider cursor|claude|codex|opencode.\n'); process.exitCode = 1; return; } @@ -256,16 +263,17 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) } else { console.log(" No LLM configured. Let's set it up.\n"); console.log( - ' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, Cursor CLI, Claude CLI, or Codex CLI.\n', + ' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, Cursor CLI, Claude CLI, Codex CLI, or OpenCode CLI.\n', ); // Check if local agent CLIs are available. const hasCursor = detectCursorCLI(); const hasClaude = detectLocalCLI('claude'); const hasCodex = detectLocalCLI('codex'); + const hasOpenCode = detectLocalCLI('opencode'); const localChoices: Array<{ choice: string; - provider: 'cursor' | 'claude' | 'codex'; + provider: 'cursor' | 'claude' | 'codex' | 'opencode'; }> = []; // Provider selection @@ -298,6 +306,14 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) }); console.log(` [${choice}] Codex CLI (local, uses your Codex login)`); } + if (hasOpenCode) { + const choice = String(nextChoice++); + localChoices.push({ + choice, + provider: 'opencode', + }); + console.log(` [${choice}] OpenCode CLI (local, uses your OpenCode login/config)`); + } console.log(''); const maxChoice = String(nextChoice - 1); diff --git a/gitnexus/src/core/wiki/generator.ts b/gitnexus/src/core/wiki/generator.ts index a86d60d74..11df0b6c2 100644 --- a/gitnexus/src/core/wiki/generator.ts +++ b/gitnexus/src/core/wiki/generator.ts @@ -39,7 +39,12 @@ import { } from './llm-client.js'; import { callCursorLLM, resolveCursorConfig } from './cursor-client.js'; -import { callClaudeLLM, callCodexLLM, resolveLocalCLIConfig } from './local-cli-client.js'; +import { + callClaudeLLM, + callCodexLLM, + callOpenCodeLLM, + resolveLocalCLIConfig, +} from './local-cli-client.js'; import { GROUPING_SYSTEM_PROMPT, @@ -219,15 +224,25 @@ export class WikiGenerator { }); return callCursorLLM(prompt, cursorConfig, systemPrompt, options); } - if (this.llmConfig.provider === 'claude' || this.llmConfig.provider === 'codex') { + if ( + this.llmConfig.provider === 'claude' || + this.llmConfig.provider === 'codex' || + this.llmConfig.provider === 'opencode' + ) { const localConfig = resolveLocalCLIConfig({ model: this.llmConfig.model, workingDirectory: this.repoPath, requestTimeoutMs: this.llmConfig.requestTimeoutMs, }); - return this.llmConfig.provider === 'claude' - ? callClaudeLLM(prompt, localConfig, systemPrompt, options) - : callCodexLLM(prompt, localConfig, systemPrompt, options); + if (this.llmConfig.provider === 'claude') { + return callClaudeLLM(prompt, localConfig, systemPrompt, options); + } + if (this.llmConfig.provider === 'codex') { + return callCodexLLM(prompt, localConfig, systemPrompt, options); + } + if (this.llmConfig.provider === 'opencode') { + return callOpenCodeLLM(prompt, localConfig, systemPrompt, options); + } } return callLLM(prompt, this.llmConfig, systemPrompt, options); } diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index 6e88f8e74..786fcbea5 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -16,7 +16,8 @@ export type LLMProvider = | 'custom' | 'cursor' | 'claude' - | 'codex'; + | 'codex' + | 'opencode'; export interface LLMConfig { apiKey: string; @@ -59,9 +60,14 @@ export async function resolveLLMConfig(overrides?: Partial): Promise< ? savedConfig.claudeModel : savedProvider === 'codex' ? savedConfig.codexModel - : undefined; + : savedProvider === 'opencode' + ? savedConfig.opencodeModel + : undefined; const localProvider = - savedProvider === 'cursor' || savedProvider === 'claude' || savedProvider === 'codex'; + savedProvider === 'cursor' || + savedProvider === 'claude' || + savedProvider === 'codex' || + savedProvider === 'opencode'; const apiKey = overrides?.apiKey || diff --git a/gitnexus/src/core/wiki/local-cli-client.ts b/gitnexus/src/core/wiki/local-cli-client.ts index cd3e36237..4edfe57ec 100644 --- a/gitnexus/src/core/wiki/local-cli-client.ts +++ b/gitnexus/src/core/wiki/local-cli-client.ts @@ -15,7 +15,7 @@ import type { LLMResponse, CallLLMOptions } from './llm-client.js'; import { logger } from '../logger.js'; -export type LocalAgentProvider = 'claude' | 'codex'; +export type LocalAgentProvider = 'claude' | 'codex' | 'opencode'; export interface LocalCLIConfig { model?: string; @@ -26,6 +26,7 @@ export interface LocalCLIConfig { const COMMANDS: Record = { claude: 'claude', codex: 'codex', + opencode: 'opencode', }; interface LocalCommand { @@ -162,6 +163,102 @@ export async function callCodexLLM( } } +interface OpenCodeEvent { + type?: string; + message?: string; + error?: { + message?: string; + name?: string; + data?: { + message?: string; + }; + }; + part?: { + type?: string; + text?: string; + }; +} + +function parseOpenCodeEventStream(output: string): string { + const lines = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + const textParts: string[] = []; + + for (const line of lines) { + let event: OpenCodeEvent; + try { + event = JSON.parse(line) as OpenCodeEvent; + } catch { + continue; + } + + if (event.type === 'error') { + const message = + event.error?.data?.message || + event.error?.name || + event.message || + event.part?.text || + line; + throw new Error(`OpenCode CLI returned error event: ${message}`); + } + + if (event.type === 'text' && typeof event.part?.text === 'string') { + textParts.push(event.part.text); + } + } + + const content = textParts.join('').trim(); + if (!content) { + throw new Error('OpenCode CLI returned no text output'); + } + return content; +} + +function buildChildEnv(provider: LocalAgentProvider): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...process.env, + CI: '1', + }; + + if (provider === 'opencode') { + delete env.OPENCODE_SERVER_PASSWORD; + delete env.OPENCODE_SERVER_USERNAME; + } + + return env; +} + +export async function callOpenCodeLLM( + prompt: string, + config: LocalCLIConfig, + systemPrompt?: string, + options?: CallLLMOptions, +): Promise { + const commandInfo = getDetectedCommand('opencode'); + if (!commandInfo) { + throw new Error( + 'OpenCode CLI not found. Install OpenCode CLI and ensure `opencode` is on PATH.', + ); + } + + const workingDirectory = config.workingDirectory || process.cwd(); + const fullPrompt = systemPrompt ? `${systemPrompt}\n\n---\n\n${prompt}` : prompt; + // OpenCode does not expose a Codex-style read-only sandbox / no-tools flag, + // so we rely on its non-interactive permission model and tolerate any + // non-JSON stdout warnings in the parser. + const args = ['run', '--format', 'json', '--dir', workingDirectory]; + + if (config.model) { + args.push('--model', config.model); + } + + const response = await runLocalCLI('opencode', commandInfo, args, config, fullPrompt, options); + return { content: parseOpenCodeEventStream(response.content) }; +} + function runLocalCLI( provider: LocalAgentProvider, commandInfo: LocalCommand, @@ -191,10 +288,7 @@ function runLocalCLI( cwd: config.workingDirectory || process.cwd(), stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, - env: { - ...process.env, - CI: '1', - }, + env: buildChildEnv(provider), }); verboseLog(provider, 'Process spawned with PID:', child.pid); diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 72be42733..581bb9250 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -933,10 +933,19 @@ export interface CLIConfig { apiKey?: string; model?: string; baseUrl?: string; - provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor' | 'claude' | 'codex'; + provider?: + | 'openai' + | 'openrouter' + | 'azure' + | 'custom' + | 'cursor' + | 'claude' + | 'codex' + | 'opencode'; cursorModel?: string; claudeModel?: string; codexModel?: string; + opencodeModel?: string; /** Azure api-version query param (e.g. '2024-10-21'). Only used when provider is 'azure'. */ apiVersion?: string; /** Set true when the deployment is a reasoning model (o1, o3, o4-mini). Auto-detected for OpenAI; must be set for Azure deployments. */ diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index 47d929bc4..0d6c71413 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for wiki CLI flags: --provider cursor/claude/codex, --review, --verbose + * Unit tests for wiki CLI flags: --provider cursor/claude/codex/opencode, --review, --verbose * * Tests the new wiki provider infrastructure without requiring an actual * local agent CLI binary or LLM API key. All external dependencies are mocked. @@ -192,6 +192,36 @@ describe('resolveLLMConfig', () => { expect(config.model).toBe('gpt-5.4'); }); + it('uses opencodeModel when provider is opencode', async () => { + vi.doMock('../../src/storage/repo-manager.js', () => ({ + loadCLIConfig: vi.fn().mockResolvedValue({ + provider: 'opencode', + opencodeModel: 'openai/gpt-5.4-mini', + }), + })); + + const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js'); + const config = await resolveLLMConfig({ provider: 'opencode' }); + + expect(config.provider).toBe('opencode'); + expect(config.model).toBe('openai/gpt-5.4-mini'); + }); + + it('does not inherit HTTP model defaults for OpenCode local provider', async () => { + vi.doMock('../../src/storage/repo-manager.js', () => ({ + loadCLIConfig: vi.fn().mockResolvedValue({ + provider: 'openai', + model: 'minimax/minimax-m2.5', + }), + })); + + const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js'); + const config = await resolveLLMConfig({ provider: 'opencode' }); + + expect(config.provider).toBe('opencode'); + expect(config.model).toBe(''); + }); + it('does not inherit HTTP model defaults for local CLI providers', async () => { vi.doMock('../../src/storage/repo-manager.js', () => ({ loadCLIConfig: vi.fn().mockResolvedValue({ @@ -760,6 +790,16 @@ describe('CLI config round-trip with cursor provider', () => { expect(loaded.apiKey).toBeUndefined(); }); + it('saves and loads opencode provider config correctly', async () => { + const config = { provider: 'opencode', opencodeModel: 'openai/gpt-5.4-mini' }; + await fs.writeFile(configPath, JSON.stringify(config, null, 2)); + + const loaded = JSON.parse(await fs.readFile(configPath, 'utf-8')); + expect(loaded.provider).toBe('opencode'); + expect(loaded.opencodeModel).toBe('openai/gpt-5.4-mini'); + expect(loaded.apiKey).toBeUndefined(); + }); + it('saves openai provider config with model and apiKey', async () => { const config = { provider: 'openai', @@ -941,6 +981,55 @@ describe('WikiGenerator invokeLLM routing', () => { expect(result.content).toBe('codex response'); }); + it('routes to callOpenCodeLLM when provider is opencode', async () => { + const cursorClient = await import('../../src/core/wiki/cursor-client.js'); + const localClient = await import('../../src/core/wiki/local-cli-client.js'); + const llmClient = await import('../../src/core/wiki/llm-client.js'); + + const cursorSpy = vi + .spyOn(cursorClient, 'callCursorLLM') + .mockResolvedValue({ content: 'cursor response' }); + const claudeSpy = vi + .spyOn(localClient, 'callClaudeLLM') + .mockResolvedValue({ content: 'claude response' }); + const codexSpy = vi + .spyOn(localClient, 'callCodexLLM') + .mockResolvedValue({ content: 'codex response' }); + const opencodeSpy = vi + .spyOn(localClient, 'callOpenCodeLLM') + .mockResolvedValue({ content: 'opencode response' }); + const openaiSpy = vi + .spyOn(llmClient, 'callLLM') + .mockResolvedValue({ content: 'openai response' }); + + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const storagePath = path.join(tmpDir, 'storage'); + const wikiDir = path.join(storagePath, 'wiki'); + await fs.mkdir(wikiDir, { recursive: true }); + + const repoPath = path.join(tmpDir, 'repo'); + await fs.mkdir(repoPath, { recursive: true }); + + const generator = new WikiGenerator(repoPath, storagePath, path.join(storagePath, 'lbug'), { + apiKey: '', + baseUrl: '', + model: 'openai/gpt-5.4-mini', + maxTokens: 1000, + temperature: 0, + provider: 'opencode', + }); + + const result = await (generator as any).invokeLLM('test prompt', 'system prompt'); + + expect(opencodeSpy).toHaveBeenCalledTimes(1); + expect(codexSpy).not.toHaveBeenCalled(); + expect(claudeSpy).not.toHaveBeenCalled(); + expect(cursorSpy).not.toHaveBeenCalled(); + expect(openaiSpy).not.toHaveBeenCalled(); + expect(result.content).toBe('opencode response'); + }); + it('routes to callLLM when provider is openai', async () => { const cursorClient = await import('../../src/core/wiki/cursor-client.js'); const localClient = await import('../../src/core/wiki/local-cli-client.js'); @@ -1017,6 +1106,13 @@ describe('local agent CLI calls', () => { afterEach(() => { vi.restoreAllMocks(); + delete process.env.OPENCODE; + delete process.env.OPENCODE_PID; + delete process.env.OPENCODE_PROCESS_ROLE; + delete process.env.OPENCODE_RUN_ID; + delete process.env.OPENCODE_EXPERIMENTAL_WEBSOCKETS; + delete process.env.OPENCODE_SERVER_PASSWORD; + delete process.env.OPENCODE_SERVER_USERNAME; }); it('throws when Claude CLI is not in PATH', async () => { @@ -1045,6 +1141,283 @@ describe('local agent CLI calls', () => { await expect(callCodexLLM('hello', {})).rejects.toThrow('Codex CLI not found'); }); + it('throws when OpenCode CLI is not in PATH', async () => { + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockImplementation(() => { + throw new Error('not found'); + }), + spawn: vi.fn(), + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await expect(callOpenCodeLLM('hello', {})).rejects.toThrow('OpenCode CLI not found'); + }); + + it('spawns OpenCode run, strips only credential env vars, and parses JSON text events', async () => { + process.env.OPENCODE = '1'; + process.env.OPENCODE_PID = '4242'; + process.env.OPENCODE_PROCESS_ROLE = 'worker'; + process.env.OPENCODE_RUN_ID = 'run-123'; + process.env.OPENCODE_EXPERIMENTAL_WEBSOCKETS = 'true'; + process.env.OPENCODE_SERVER_PASSWORD = 'secret'; + process.env.OPENCODE_SERVER_USERNAME = 'opencode'; + + const jsonOutput = [ + JSON.stringify({ type: 'step_start', part: { type: 'step-start' } }), + JSON.stringify({ type: 'text', part: { type: 'text', text: 'Hello' } }), + JSON.stringify({ type: 'text', part: { type: 'text', text: ' world' } }), + JSON.stringify({ type: 'step_finish', part: { type: 'step-finish', reason: 'stop' } }), + ].join('\n'); + + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn((stdinText?: string) => { + queueMicrotask(() => { + child.stdout.emit('data', Buffer.from(jsonOutput)); + child.emit('close', 0); + }); + return stdinText; + }); + + const spawnSpy = vi.fn(() => child); + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), + spawn: spawnSpy, + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + const response = await callOpenCodeLLM( + 'hello', + { model: 'openai/gpt-5.4-mini', workingDirectory: process.cwd() }, + 'system prompt', + ); + + expect(response.content).toBe('Hello world'); + expect(child.stdin.end).toHaveBeenCalledWith('system prompt\n\n---\n\nhello'); + + const args = spawnSpy.mock.calls[0][1] as string[]; + expect(args).toContain('run'); + expect(args).toContain('--format'); + expect(args).toContain('json'); + expect(args).toContain('--dir'); + expect(args).toContain(process.cwd()); + expect(args).toContain('--model'); + expect(args).toContain('openai/gpt-5.4-mini'); + + const spawnOptions = spawnSpy.mock.calls[0][2] as { env: Record }; + expect(spawnOptions.env.OPENCODE).toBe('1'); + expect(spawnOptions.env.OPENCODE_PID).toBe('4242'); + expect(spawnOptions.env.OPENCODE_PROCESS_ROLE).toBe('worker'); + expect(spawnOptions.env.OPENCODE_RUN_ID).toBe('run-123'); + expect(spawnOptions.env.OPENCODE_EXPERIMENTAL_WEBSOCKETS).toBe('true'); + expect(spawnOptions.env.OPENCODE_SERVER_PASSWORD).toBeUndefined(); + expect(spawnOptions.env.OPENCODE_SERVER_USERNAME).toBeUndefined(); + }); + + it('omits --model when OpenCode config does not specify one', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit( + 'data', + Buffer.from(JSON.stringify({ type: 'text', part: { type: 'text', text: 'OK' } })), + ); + child.emit('close', 0); + }); + }); + + const spawnSpy = vi.fn(() => child); + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), + spawn: spawnSpy, + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + const response = await callOpenCodeLLM('hello', { workingDirectory: process.cwd() }); + + expect(response.content).toBe('OK'); + const args = spawnSpy.mock.calls[0][1] as string[]; + expect(args).not.toContain('--model'); + }); + + it('parses OpenCode text events even when part.type is omitted', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit( + 'data', + Buffer.from(JSON.stringify({ type: 'text', part: { text: 'fallback text' } })), + ); + child.emit('close', 0); + }); + }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), + spawn: vi.fn(() => child), + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + const response = await callOpenCodeLLM('hello', { workingDirectory: process.cwd() }); + + expect(response.content).toBe('fallback text'); + }); + + it('ignores non-JSON stdout lines when OpenCode text events are present', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit( + 'data', + Buffer.from( + [ + 'permission requested: write access denied', + JSON.stringify({ type: 'text', part: { text: 'Hello from opencode' } }), + '~ https://opencode.ai/share/abc123', + ].join('\n'), + ), + ); + child.emit('close', 0); + }); + }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), + spawn: vi.fn(() => child), + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + const response = await callOpenCodeLLM('hello', { workingDirectory: process.cwd() }); + + expect(response.content).toBe('Hello from opencode'); + }); + + it('fails with no text output when OpenCode only writes non-JSON stdout lines', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit('data', Buffer.from('not-json')); + child.emit('close', 0); + }); + }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), + spawn: vi.fn(() => child), + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await expect(callOpenCodeLLM('hello', { workingDirectory: process.cwd() })).rejects.toThrow( + 'OpenCode CLI returned no text output', + ); + }); + + it('surfaces OpenCode error events', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit( + 'data', + Buffer.from( + JSON.stringify({ + type: 'error', + error: { name: 'PermissionDenied', data: { message: 'permission denied' } }, + }), + ), + ); + child.emit('close', 0); + }); + }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), + spawn: vi.fn(() => child), + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await expect(callOpenCodeLLM('hello', { workingDirectory: process.cwd() })).rejects.toThrow( + 'OpenCode CLI returned error event: permission denied', + ); + }); + + it('fails when OpenCode returns no text events', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit( + 'data', + Buffer.from(JSON.stringify({ type: 'step_finish', part: { type: 'step-finish' } })), + ); + child.emit('close', 0); + }); + }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), + spawn: vi.fn(() => child), + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await expect(callOpenCodeLLM('hello', { workingDirectory: process.cwd() })).rejects.toThrow( + 'OpenCode CLI returned no text output', + ); + }); + + it('falls back to the OpenCode error name when the nested message is missing', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter() as any; + child.stdin.end = vi.fn(() => { + queueMicrotask(() => { + child.stdout.emit( + 'data', + Buffer.from(JSON.stringify({ type: 'error', error: { name: 'PermissionDenied' } })), + ); + child.emit('close', 0); + }); + }); + + vi.doMock('child_process', () => ({ + execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), + spawn: vi.fn(() => child), + })); + + const { callOpenCodeLLM } = await import('../../src/core/wiki/local-cli-client.js'); + + await expect(callOpenCodeLLM('hello', { workingDirectory: process.cwd() })).rejects.toThrow( + 'OpenCode CLI returned error event: PermissionDenied', + ); + }); + it('uses Codex config overrides instead of removed approval flags', async () => { const child = new EventEmitter() as any; child.stdout = new EventEmitter();