diff --git a/apps/server/src/providers/index.ts b/apps/server/src/providers/index.ts index ce0bf8d0..a1da283b 100644 --- a/apps/server/src/providers/index.ts +++ b/apps/server/src/providers/index.ts @@ -25,5 +25,8 @@ export { ClaudeProvider } from './claude-provider.js'; export { CursorProvider, CursorErrorCode, CursorError } from './cursor-provider.js'; export { CursorConfigManager } from './cursor-config-manager.js'; +// OpenCode provider +export { OpencodeProvider } from './opencode-provider.js'; + // Provider factory export { ProviderFactory } from './provider-factory.js'; diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts new file mode 100644 index 00000000..6dbef31a --- /dev/null +++ b/apps/server/src/providers/opencode-provider.ts @@ -0,0 +1,605 @@ +/** + * OpenCode Provider - Executes queries using opencode CLI + * + * Extends CliProvider with OpenCode-specific configuration: + * - Event normalization for OpenCode's stream-json format + * - Model definitions for anthropic, openai, and google models + * - NPX-based Windows execution strategy + * - Platform-specific npm global installation paths + * + * Spawns the opencode CLI with --output-format stream-json for streaming responses. + */ + +import * as path from 'path'; +import * as os from 'os'; +import { CliProvider, type CliSpawnConfig } from './cli-provider.js'; +import type { + ProviderConfig, + ExecuteOptions, + ProviderMessage, + ModelDefinition, + InstallationStatus, + ContentBlock, +} from '@automaker/types'; +import { stripProviderPrefix } from '@automaker/types'; +import { type SubprocessOptions } from '@automaker/platform'; + +// ============================================================================= +// OpenCode Stream Event Types +// ============================================================================= + +/** + * Base interface for all OpenCode stream events + */ +interface OpenCodeBaseEvent { + /** Event type identifier */ + type: string; + /** Optional session identifier */ + session_id?: string; +} + +/** + * Text delta event - Incremental text output from the model + */ +export interface OpenCodeTextDeltaEvent extends OpenCodeBaseEvent { + type: 'text-delta'; + /** The incremental text content */ + text: string; +} + +/** + * Text end event - Signals completion of text generation + */ +export interface OpenCodeTextEndEvent extends OpenCodeBaseEvent { + type: 'text-end'; +} + +/** + * Tool call event - Request to execute a tool + */ +export interface OpenCodeToolCallEvent extends OpenCodeBaseEvent { + type: 'tool-call'; + /** Unique identifier for this tool call */ + call_id?: string; + /** Tool name to invoke */ + name: string; + /** Arguments to pass to the tool */ + args: unknown; +} + +/** + * Tool result event - Output from a tool execution + */ +export interface OpenCodeToolResultEvent extends OpenCodeBaseEvent { + type: 'tool-result'; + /** The tool call ID this result corresponds to */ + call_id?: string; + /** Output from the tool execution */ + output: string; +} + +/** + * Tool error event - Tool execution failed + */ +export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent { + type: 'tool-error'; + /** The tool call ID that failed */ + call_id?: string; + /** Error message describing the failure */ + error: string; +} + +/** + * Start step event - Begins an agentic loop iteration + */ +export interface OpenCodeStartStepEvent extends OpenCodeBaseEvent { + type: 'start-step'; + /** Step number in the agentic loop */ + step?: number; +} + +/** + * Finish step event - Completes an agentic loop iteration + */ +export interface OpenCodeFinishStepEvent extends OpenCodeBaseEvent { + type: 'finish-step'; + /** Step number that completed */ + step?: number; + /** Whether the step completed successfully */ + success?: boolean; + /** Optional result data */ + result?: string; + /** Optional error if step failed */ + error?: string; +} + +/** + * Union type of all OpenCode stream events + */ +export type OpenCodeStreamEvent = + | OpenCodeTextDeltaEvent + | OpenCodeTextEndEvent + | OpenCodeToolCallEvent + | OpenCodeToolResultEvent + | OpenCodeToolErrorEvent + | OpenCodeStartStepEvent + | OpenCodeFinishStepEvent; + +// ============================================================================= +// Tool Use ID Generation +// ============================================================================= + +/** Counter for generating unique tool use IDs when call_id is not provided */ +let toolUseIdCounter = 0; + +/** + * Generate a unique tool use ID for tool calls without explicit IDs + */ +function generateToolUseId(): string { + toolUseIdCounter += 1; + return `opencode-tool-${toolUseIdCounter}`; +} + +/** + * Reset the tool use ID counter (useful for testing) + */ +export function resetToolUseIdCounter(): void { + toolUseIdCounter = 0; +} + +// ============================================================================= +// Provider Implementation +// ============================================================================= + +/** + * OpencodeProvider - Integrates opencode CLI as an AI provider + * + * OpenCode is an npm-distributed CLI tool that provides access to + * multiple AI model providers through a unified interface. + */ +export class OpencodeProvider extends CliProvider { + constructor(config: ProviderConfig = {}) { + super(config); + } + + // ========================================================================== + // CliProvider Abstract Method Implementations + // ========================================================================== + + getName(): string { + return 'opencode'; + } + + getCliName(): string { + return 'opencode'; + } + + getSpawnConfig(): CliSpawnConfig { + return { + windowsStrategy: 'npx', + npxPackage: 'opencode-ai@latest', + commonPaths: { + linux: [ + path.join(os.homedir(), '.npm-global/bin/opencode'), + '/usr/local/bin/opencode', + '/usr/bin/opencode', + path.join(os.homedir(), '.local/bin/opencode'), + ], + darwin: [ + path.join(os.homedir(), '.npm-global/bin/opencode'), + '/usr/local/bin/opencode', + '/opt/homebrew/bin/opencode', + path.join(os.homedir(), '.local/bin/opencode'), + ], + win32: [ + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'opencode.cmd'), + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'opencode'), + path.join(process.env.APPDATA || '', 'npm', 'opencode.cmd'), + ], + }, + }; + } + + /** + * Build CLI arguments for the `opencode run` command + * + * Arguments built: + * - 'run' subcommand for executing queries + * - '--format', 'stream-json' for JSONL streaming output + * - '-q' / '--quiet' to suppress spinner and interactive elements + * - '-c', '' for working directory + * - '--model', '' for model selection (if specified) + * - '-' as final arg to read prompt from stdin + * + * The prompt is NOT included in CLI args - it's passed via stdin to avoid + * shell escaping issues with special characters in content. + * + * @param options - Execution options containing model, cwd, etc. + * @returns Array of CLI arguments for opencode run + */ + buildCliArgs(options: ExecuteOptions): string[] { + const args: string[] = ['run']; + + // Add streaming JSON output format for JSONL parsing + args.push('--format', 'stream-json'); + + // Suppress spinner and interactive elements for non-TTY usage + args.push('-q'); + + // Set working directory + if (options.cwd) { + args.push('-c', options.cwd); + } + + // Handle model selection + // Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5' + if (options.model) { + const model = stripProviderPrefix(options.model); + args.push('--model', model); + } + + // Use '-' to indicate reading prompt from stdin + // This avoids shell escaping issues with special characters + args.push('-'); + + return args; + } + + // ========================================================================== + // Prompt Handling + // ========================================================================== + + /** + * Extract prompt text from ExecuteOptions for passing via stdin + * + * Handles both string prompts and array-based prompts with content blocks. + * For array prompts with images, extracts only text content (images would + * need separate handling via file paths if OpenCode supports them). + * + * @param options - Execution options containing the prompt + * @returns Plain text prompt string + */ + private extractPromptText(options: ExecuteOptions): string { + if (typeof options.prompt === 'string') { + return options.prompt; + } + + // Array-based prompt - extract text content + if (Array.isArray(options.prompt)) { + return options.prompt + .filter((block) => block.type === 'text' && block.text) + .map((block) => block.text) + .join('\n'); + } + + throw new Error('Invalid prompt format: expected string or content block array'); + } + + /** + * Build subprocess options with stdin data for prompt + * + * Extends the base class method to add stdinData containing the prompt. + * This allows passing prompts via stdin instead of CLI arguments, + * avoiding shell escaping issues with special characters. + * + * @param options - Execution options + * @param cliArgs - CLI arguments from buildCliArgs + * @returns SubprocessOptions with stdinData set + */ + protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions { + const subprocessOptions = super.buildSubprocessOptions(options, cliArgs); + + // Pass prompt via stdin to avoid shell interpretation of special characters + // like $(), backticks, quotes, etc. that may appear in prompts or file content + subprocessOptions.stdinData = this.extractPromptText(options); + + return subprocessOptions; + } + + /** + * Normalize a raw CLI event to ProviderMessage format + * + * Maps OpenCode event types to the standard ProviderMessage structure: + * - text-delta -> type: 'assistant', content with type: 'text' + * - text-end -> null (informational, no message needed) + * - tool-call -> type: 'assistant', content with type: 'tool_use' + * - tool-result -> type: 'assistant', content with type: 'tool_result' + * - tool-error -> type: 'error' + * - start-step -> null (informational, no message needed) + * - finish-step with success -> type: 'result', subtype: 'success' + * - finish-step with error -> type: 'error' + * + * @param event - Raw event from OpenCode CLI JSONL output + * @returns Normalized ProviderMessage or null to skip the event + */ + normalizeEvent(event: unknown): ProviderMessage | null { + if (!event || typeof event !== 'object') { + return null; + } + + const openCodeEvent = event as OpenCodeStreamEvent; + + switch (openCodeEvent.type) { + case 'text-delta': { + const textEvent = openCodeEvent as OpenCodeTextDeltaEvent; + + // Skip empty text deltas + if (!textEvent.text) { + return null; + } + + const content: ContentBlock[] = [ + { + type: 'text', + text: textEvent.text, + }, + ]; + + return { + type: 'assistant', + session_id: textEvent.session_id, + message: { + role: 'assistant', + content, + }, + }; + } + + case 'text-end': { + // Text end is informational - no message needed + return null; + } + + case 'tool-call': { + const toolEvent = openCodeEvent as OpenCodeToolCallEvent; + + // Generate a tool use ID if not provided + const toolUseId = toolEvent.call_id || generateToolUseId(); + + const content: ContentBlock[] = [ + { + type: 'tool_use', + name: toolEvent.name, + tool_use_id: toolUseId, + input: toolEvent.args, + }, + ]; + + return { + type: 'assistant', + session_id: toolEvent.session_id, + message: { + role: 'assistant', + content, + }, + }; + } + + case 'tool-result': { + const resultEvent = openCodeEvent as OpenCodeToolResultEvent; + + const content: ContentBlock[] = [ + { + type: 'tool_result', + tool_use_id: resultEvent.call_id, + content: resultEvent.output, + }, + ]; + + return { + type: 'assistant', + session_id: resultEvent.session_id, + message: { + role: 'assistant', + content, + }, + }; + } + + case 'tool-error': { + const errorEvent = openCodeEvent as OpenCodeToolErrorEvent; + + return { + type: 'error', + session_id: errorEvent.session_id, + error: errorEvent.error || 'Tool execution failed', + }; + } + + case 'start-step': { + // Start step is informational - no message needed + return null; + } + + case 'finish-step': { + const finishEvent = openCodeEvent as OpenCodeFinishStepEvent; + + // Check if the step failed + if (finishEvent.success === false || finishEvent.error) { + return { + type: 'error', + session_id: finishEvent.session_id, + error: finishEvent.error || 'Step execution failed', + }; + } + + // Successful completion + return { + type: 'result', + subtype: 'success', + session_id: finishEvent.session_id, + result: finishEvent.result, + }; + } + + default: { + // Unknown event type - skip it + return null; + } + } + } + + // ========================================================================== + // Model Configuration + // ========================================================================== + + /** + * Get available models for OpenCode + * + * Returns model definitions for supported AI providers: + * - Anthropic Claude models (Sonnet, Opus, Haiku) + * - OpenAI GPT-4o + * - Google Gemini 2.5 Pro + */ + getAvailableModels(): ModelDefinition[] { + return [ + // OpenCode Free Tier Models + { + id: 'opencode/big-pickle', + name: 'Big Pickle (Free)', + modelString: 'opencode/big-pickle', + provider: 'opencode', + description: 'OpenCode free tier model - great for general coding', + supportsTools: true, + supportsVision: false, + tier: 'basic', + }, + { + id: 'opencode/gpt-5-nano', + name: 'GPT-5 Nano (Free)', + modelString: 'opencode/gpt-5-nano', + provider: 'opencode', + description: 'Fast and lightweight free tier model', + supportsTools: true, + supportsVision: false, + tier: 'basic', + }, + { + id: 'opencode/grok-code', + name: 'Grok Code (Free)', + modelString: 'opencode/grok-code', + provider: 'opencode', + description: 'OpenCode free tier Grok model for coding', + supportsTools: true, + supportsVision: false, + tier: 'basic', + }, + // Amazon Bedrock - Claude Models + { + id: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + name: 'Claude Sonnet 4.5 (Bedrock)', + modelString: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + provider: 'opencode', + description: 'Latest Claude Sonnet via AWS Bedrock - fast and intelligent', + supportsTools: true, + supportsVision: true, + tier: 'premium', + default: true, + }, + { + id: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', + name: 'Claude Opus 4.5 (Bedrock)', + modelString: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', + provider: 'opencode', + description: 'Most capable Claude model via AWS Bedrock', + supportsTools: true, + supportsVision: true, + tier: 'premium', + }, + { + id: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', + name: 'Claude Haiku 4.5 (Bedrock)', + modelString: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', + provider: 'opencode', + description: 'Fastest Claude model via AWS Bedrock', + supportsTools: true, + supportsVision: true, + tier: 'standard', + }, + // Amazon Bedrock - DeepSeek Models + { + id: 'amazon-bedrock/deepseek.r1-v1:0', + name: 'DeepSeek R1 (Bedrock)', + modelString: 'amazon-bedrock/deepseek.r1-v1:0', + provider: 'opencode', + description: 'DeepSeek R1 reasoning model - excellent for coding', + supportsTools: true, + supportsVision: false, + tier: 'premium', + }, + // Amazon Bedrock - Amazon Nova Models + { + id: 'amazon-bedrock/amazon.nova-pro-v1:0', + name: 'Amazon Nova Pro (Bedrock)', + modelString: 'amazon-bedrock/amazon.nova-pro-v1:0', + provider: 'opencode', + description: 'Amazon Nova Pro - balanced performance', + supportsTools: true, + supportsVision: true, + tier: 'standard', + }, + // Amazon Bedrock - Meta Llama Models + { + id: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0', + name: 'Llama 4 Maverick 17B (Bedrock)', + modelString: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0', + provider: 'opencode', + description: 'Meta Llama 4 Maverick via AWS Bedrock', + supportsTools: true, + supportsVision: false, + tier: 'standard', + }, + // Amazon Bedrock - Qwen Models + { + id: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0', + name: 'Qwen3 Coder 480B (Bedrock)', + modelString: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0', + provider: 'opencode', + description: 'Qwen3 Coder 480B - excellent for coding', + supportsTools: true, + supportsVision: false, + tier: 'premium', + }, + ]; + } + + // ========================================================================== + // Feature Support + // ========================================================================== + + /** + * Check if a feature is supported by OpenCode + * + * Supported features: + * - tools: Function calling / tool use + * - text: Text generation + * - vision: Image understanding + */ + supportsFeature(feature: string): boolean { + const supportedFeatures = ['tools', 'text', 'vision']; + return supportedFeatures.includes(feature); + } + + // ========================================================================== + // Installation Detection + // ========================================================================== + + /** + * Detect OpenCode installation status + * + * Checks if the opencode CLI is available either through: + * - Direct installation (npm global) + * - NPX (fallback on Windows) + */ + async detectInstallation(): Promise { + this.ensureCliDetected(); + + const installed = await this.isInstalled(); + + return { + installed, + path: this.cliPath || undefined, + method: this.detectedStrategy === 'npx' ? 'npm' : 'cli', + }; + } +} diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts index 0dde03ad..428f009c 100644 --- a/apps/server/src/providers/provider-factory.ts +++ b/apps/server/src/providers/provider-factory.ts @@ -7,7 +7,7 @@ import { BaseProvider } from './base-provider.js'; import type { InstallationStatus, ModelDefinition } from './types.js'; -import { isCursorModel, isCodexModel, type ModelProvider } from '@automaker/types'; +import { isCursorModel, isCodexModel, isOpencodeModel, type ModelProvider } from '@automaker/types'; /** * Provider registration entry @@ -201,6 +201,7 @@ export class ProviderFactory { import { ClaudeProvider } from './claude-provider.js'; import { CursorProvider } from './cursor-provider.js'; import { CodexProvider } from './codex-provider.js'; +import { OpencodeProvider } from './opencode-provider.js'; // Register Claude provider registerProvider('claude', { @@ -228,3 +229,10 @@ registerProvider('codex', { canHandleModel: (model: string) => isCodexModel(model), priority: 5, // Medium priority - check after Cursor but before Claude }); + +// Register OpenCode provider +registerProvider('opencode', { + factory: () => new OpencodeProvider(), + canHandleModel: (model: string) => isOpencodeModel(model), + priority: 3, // Between codex (5) and claude (0) +}); diff --git a/apps/server/tests/unit/providers/opencode-provider.test.ts b/apps/server/tests/unit/providers/opencode-provider.test.ts new file mode 100644 index 00000000..e20e5e67 --- /dev/null +++ b/apps/server/tests/unit/providers/opencode-provider.test.ts @@ -0,0 +1,1262 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + OpencodeProvider, + resetToolUseIdCounter, +} from '../../../src/providers/opencode-provider.js'; +import type { ProviderMessage } from '@automaker/types'; +import { collectAsyncGenerator } from '../../utils/helpers.js'; +import { spawnJSONLProcess } from '@automaker/platform'; + +vi.mock('@automaker/platform', () => ({ + spawnJSONLProcess: vi.fn(), + isWslAvailable: vi.fn().mockReturnValue(false), + findCliInWsl: vi.fn().mockReturnValue(null), + createWslCommand: vi.fn(), + windowsToWslPath: vi.fn(), +})); + +describe('opencode-provider.ts', () => { + let provider: OpencodeProvider; + + beforeEach(() => { + vi.clearAllMocks(); + resetToolUseIdCounter(); + provider = new OpencodeProvider(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ========================================================================== + // Basic Provider Tests + // ========================================================================== + + describe('getName', () => { + it("should return 'opencode' as provider name", () => { + expect(provider.getName()).toBe('opencode'); + }); + }); + + describe('getCliName', () => { + it("should return 'opencode' as CLI name", () => { + expect(provider.getCliName()).toBe('opencode'); + }); + }); + + describe('getAvailableModels', () => { + it('should return 10 models', () => { + const models = provider.getAvailableModels(); + expect(models).toHaveLength(10); + }); + + it('should include Claude Sonnet 4.5 (Bedrock) as default', () => { + const models = provider.getAvailableModels(); + const sonnet = models.find( + (m) => m.id === 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0' + ); + + expect(sonnet).toBeDefined(); + expect(sonnet?.name).toBe('Claude Sonnet 4.5 (Bedrock)'); + expect(sonnet?.provider).toBe('opencode'); + expect(sonnet?.default).toBe(true); + expect(sonnet?.modelString).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0'); + }); + + it('should include Claude Opus 4.5 (Bedrock)', () => { + const models = provider.getAvailableModels(); + const opus = models.find( + (m) => m.id === 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0' + ); + + expect(opus).toBeDefined(); + expect(opus?.name).toBe('Claude Opus 4.5 (Bedrock)'); + expect(opus?.modelString).toBe('amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0'); + }); + + it('should include Claude Haiku 4.5 (Bedrock)', () => { + const models = provider.getAvailableModels(); + const haiku = models.find( + (m) => m.id === 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0' + ); + + expect(haiku).toBeDefined(); + expect(haiku?.name).toBe('Claude Haiku 4.5 (Bedrock)'); + expect(haiku?.tier).toBe('standard'); + }); + + it('should include free tier Big Pickle model', () => { + const models = provider.getAvailableModels(); + const bigPickle = models.find((m) => m.id === 'opencode/big-pickle'); + + expect(bigPickle).toBeDefined(); + expect(bigPickle?.name).toBe('Big Pickle (Free)'); + expect(bigPickle?.modelString).toBe('opencode/big-pickle'); + expect(bigPickle?.tier).toBe('basic'); + }); + + it('should include DeepSeek R1 (Bedrock)', () => { + const models = provider.getAvailableModels(); + const deepseek = models.find((m) => m.id === 'amazon-bedrock/deepseek.r1-v1:0'); + + expect(deepseek).toBeDefined(); + expect(deepseek?.name).toBe('DeepSeek R1 (Bedrock)'); + expect(deepseek?.tier).toBe('premium'); + }); + + it('should have all models support tools', () => { + const models = provider.getAvailableModels(); + + models.forEach((model) => { + expect(model.supportsTools).toBe(true); + }); + }); + + it('should have models with modelString property', () => { + const models = provider.getAvailableModels(); + + for (const model of models) { + expect(model).toHaveProperty('modelString'); + expect(typeof model.modelString).toBe('string'); + } + }); + }); + + describe('supportsFeature', () => { + it("should support 'tools' feature", () => { + expect(provider.supportsFeature('tools')).toBe(true); + }); + + it("should support 'text' feature", () => { + expect(provider.supportsFeature('text')).toBe(true); + }); + + it("should support 'vision' feature", () => { + expect(provider.supportsFeature('vision')).toBe(true); + }); + + it("should not support 'thinking' feature", () => { + expect(provider.supportsFeature('thinking')).toBe(false); + }); + + it("should not support 'mcp' feature", () => { + expect(provider.supportsFeature('mcp')).toBe(false); + }); + + it("should not support 'cli' feature", () => { + expect(provider.supportsFeature('cli')).toBe(false); + }); + + it('should return false for unknown features', () => { + expect(provider.supportsFeature('unknown-feature')).toBe(false); + expect(provider.supportsFeature('nonexistent')).toBe(false); + expect(provider.supportsFeature('')).toBe(false); + }); + }); + + // ========================================================================== + // buildCliArgs Tests + // ========================================================================== + + describe('buildCliArgs', () => { + it('should build correct args with run subcommand', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + cwd: '/tmp/project', + }); + + expect(args[0]).toBe('run'); + }); + + it('should include --format stream-json for streaming output', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + cwd: '/tmp/project', + }); + + const formatIndex = args.indexOf('--format'); + expect(formatIndex).toBeGreaterThan(-1); + expect(args[formatIndex + 1]).toBe('stream-json'); + }); + + it('should include -q flag for quiet mode', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + cwd: '/tmp/project', + }); + + expect(args).toContain('-q'); + }); + + it('should include working directory with -c flag', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + cwd: '/tmp/my-project', + }); + + const cwdIndex = args.indexOf('-c'); + expect(cwdIndex).toBeGreaterThan(-1); + expect(args[cwdIndex + 1]).toBe('/tmp/my-project'); + }); + + it('should include model with --model flag', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: 'anthropic/claude-sonnet-4-5', + cwd: '/tmp/project', + }); + + const modelIndex = args.indexOf('--model'); + expect(modelIndex).toBeGreaterThan(-1); + expect(args[modelIndex + 1]).toBe('anthropic/claude-sonnet-4-5'); + }); + + it('should strip opencode- prefix from model', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: 'opencode-anthropic/claude-sonnet-4-5', + cwd: '/tmp/project', + }); + + const modelIndex = args.indexOf('--model'); + expect(args[modelIndex + 1]).toBe('anthropic/claude-sonnet-4-5'); + }); + + it('should include dash as final arg for stdin prompt', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + cwd: '/tmp/project', + }); + + expect(args[args.length - 1]).toBe('-'); + }); + + it('should handle missing cwd', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + }); + + expect(args).not.toContain('-c'); + }); + + it('should handle missing model', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + cwd: '/tmp/project', + }); + + expect(args).not.toContain('--model'); + }); + }); + + // ========================================================================== + // normalizeEvent Tests + // ========================================================================== + + describe('normalizeEvent', () => { + describe('text-delta events', () => { + it('should convert text-delta to assistant message with text content', () => { + const event = { + type: 'text-delta', + text: 'Hello, world!', + session_id: 'test-session', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toEqual({ + type: 'assistant', + session_id: 'test-session', + message: { + role: 'assistant', + content: [ + { + type: 'text', + text: 'Hello, world!', + }, + ], + }, + }); + }); + + it('should return null for empty text-delta', () => { + const event = { + type: 'text-delta', + text: '', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toBeNull(); + }); + + it('should return null for text-delta with undefined text', () => { + const event = { + type: 'text-delta', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toBeNull(); + }); + }); + + describe('text-end events', () => { + it('should return null for text-end events (informational)', () => { + const event = { + type: 'text-end', + session_id: 'test-session', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toBeNull(); + }); + }); + + describe('tool-call events', () => { + it('should convert tool-call to assistant message with tool_use content', () => { + const event = { + type: 'tool-call', + call_id: 'call-123', + name: 'Read', + args: { file_path: '/tmp/test.txt' }, + session_id: 'test-session', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toEqual({ + type: 'assistant', + session_id: 'test-session', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'Read', + tool_use_id: 'call-123', + input: { file_path: '/tmp/test.txt' }, + }, + ], + }, + }); + }); + + it('should generate tool_use_id when call_id is missing', () => { + const event = { + type: 'tool-call', + name: 'Write', + args: { content: 'test' }, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.message?.content[0].type).toBe('tool_use'); + expect(result?.message?.content[0].tool_use_id).toBe('opencode-tool-1'); + + // Second call should increment + const result2 = provider.normalizeEvent({ + type: 'tool-call', + name: 'Edit', + args: {}, + }); + expect(result2?.message?.content[0].tool_use_id).toBe('opencode-tool-2'); + }); + }); + + describe('tool-result events', () => { + it('should convert tool-result to assistant message with tool_result content', () => { + const event = { + type: 'tool-result', + call_id: 'call-123', + output: 'File contents here', + session_id: 'test-session', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toEqual({ + type: 'assistant', + session_id: 'test-session', + message: { + role: 'assistant', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-123', + content: 'File contents here', + }, + ], + }, + }); + }); + + it('should handle tool-result without call_id', () => { + const event = { + type: 'tool-result', + output: 'Result without ID', + }; + + const result = provider.normalizeEvent(event); + + expect(result?.message?.content[0].type).toBe('tool_result'); + expect(result?.message?.content[0].tool_use_id).toBeUndefined(); + }); + }); + + describe('tool-error events', () => { + it('should convert tool-error to error message', () => { + const event = { + type: 'tool-error', + call_id: 'call-123', + error: 'File not found', + session_id: 'test-session', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toEqual({ + type: 'error', + session_id: 'test-session', + error: 'File not found', + }); + }); + + it('should provide default error message when error is missing', () => { + const event = { + type: 'tool-error', + call_id: 'call-123', + }; + + const result = provider.normalizeEvent(event); + + expect(result?.type).toBe('error'); + expect(result?.error).toBe('Tool execution failed'); + }); + }); + + describe('start-step events', () => { + it('should return null for start-step events (informational)', () => { + const event = { + type: 'start-step', + step: 1, + session_id: 'test-session', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toBeNull(); + }); + }); + + describe('finish-step events', () => { + it('should convert successful finish-step to result message', () => { + const event = { + type: 'finish-step', + step: 1, + success: true, + result: 'Task completed successfully', + session_id: 'test-session', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toEqual({ + type: 'result', + subtype: 'success', + session_id: 'test-session', + result: 'Task completed successfully', + }); + }); + + it('should convert finish-step with success=false to error message', () => { + const event = { + type: 'finish-step', + step: 1, + success: false, + error: 'Something went wrong', + session_id: 'test-session', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toEqual({ + type: 'error', + session_id: 'test-session', + error: 'Something went wrong', + }); + }); + + it('should convert finish-step with error property to error message', () => { + const event = { + type: 'finish-step', + step: 1, + error: 'Process failed', + }; + + const result = provider.normalizeEvent(event); + + expect(result?.type).toBe('error'); + expect(result?.error).toBe('Process failed'); + }); + + it('should provide default error message for failed step without error text', () => { + const event = { + type: 'finish-step', + step: 1, + success: false, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.type).toBe('error'); + expect(result?.error).toBe('Step execution failed'); + }); + + it('should treat finish-step without success flag as success', () => { + const event = { + type: 'finish-step', + step: 1, + result: 'Done', + }; + + const result = provider.normalizeEvent(event); + + expect(result?.type).toBe('result'); + expect(result?.subtype).toBe('success'); + }); + }); + + describe('unknown events', () => { + it('should return null for unknown event types', () => { + const event = { + type: 'unknown-event', + data: 'some data', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toBeNull(); + }); + + it('should return null for null input', () => { + const result = provider.normalizeEvent(null); + expect(result).toBeNull(); + }); + + it('should return null for undefined input', () => { + const result = provider.normalizeEvent(undefined); + expect(result).toBeNull(); + }); + + it('should return null for non-object input', () => { + expect(provider.normalizeEvent('string')).toBeNull(); + expect(provider.normalizeEvent(123)).toBeNull(); + expect(provider.normalizeEvent(true)).toBeNull(); + }); + + it('should return null for events without type', () => { + expect(provider.normalizeEvent({})).toBeNull(); + expect(provider.normalizeEvent({ data: 'no type' })).toBeNull(); + }); + }); + }); + + // ========================================================================== + // executeQuery Tests + // ========================================================================== + + describe('executeQuery', () => { + /** + * Helper to set up the provider with a mocked CLI path + * This bypasses CLI detection for testing + */ + function setupMockedProvider(): OpencodeProvider { + const mockedProvider = new OpencodeProvider(); + // Access protected property to simulate CLI detection + (mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode'; + (mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; + return mockedProvider; + } + + it('should stream text-delta events as assistant messages', async () => { + const mockedProvider = setupMockedProvider(); + + const mockEvents = [ + { type: 'text-delta', text: 'Hello ' }, + { type: 'text-delta', text: 'World!' }, + { type: 'text-end' }, + ]; + + vi.mocked(spawnJSONLProcess).mockReturnValue( + (async function* () { + for (const event of mockEvents) { + yield event; + } + })() + ); + + const results = await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: 'Say hello', + model: 'anthropic/claude-sonnet-4-5', + cwd: '/tmp', + }) + ); + + // text-end should be filtered out (returns null) + expect(results).toHaveLength(2); + expect(results[0].type).toBe('assistant'); + expect(results[0].message?.content[0].text).toBe('Hello '); + expect(results[1].message?.content[0].text).toBe('World!'); + }); + + it('should emit tool_use and tool_result with matching IDs', async () => { + const mockedProvider = setupMockedProvider(); + + const mockEvents = [ + { + type: 'tool-call', + call_id: 'tool-1', + name: 'Read', + args: { file_path: '/tmp/test.txt' }, + }, + { + type: 'tool-result', + call_id: 'tool-1', + output: 'File contents', + }, + ]; + + vi.mocked(spawnJSONLProcess).mockReturnValue( + (async function* () { + for (const event of mockEvents) { + yield event; + } + })() + ); + + const results = await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: 'Read a file', + cwd: '/tmp', + }) + ); + + expect(results).toHaveLength(2); + + const toolUse = results[0]; + const toolResult = results[1]; + + expect(toolUse.type).toBe('assistant'); + expect(toolUse.message?.content[0].type).toBe('tool_use'); + expect(toolUse.message?.content[0].tool_use_id).toBe('tool-1'); + + expect(toolResult.type).toBe('assistant'); + expect(toolResult.message?.content[0].type).toBe('tool_result'); + expect(toolResult.message?.content[0].tool_use_id).toBe('tool-1'); + }); + + it('should pass stdinData containing the prompt', async () => { + const mockedProvider = setupMockedProvider(); + + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: 'My test prompt', + cwd: '/tmp', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.stdinData).toBe('My test prompt'); + }); + + it('should extract text from array prompt', async () => { + const mockedProvider = setupMockedProvider(); + + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + const arrayPrompt = [ + { type: 'text', text: 'First part' }, + { type: 'image', source: { type: 'base64', data: '...' } }, + { type: 'text', text: 'Second part' }, + ]; + + await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: arrayPrompt as unknown as string, + cwd: '/tmp', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.stdinData).toBe('First part\nSecond part'); + }); + + it('should include correct CLI args in subprocess options', async () => { + const mockedProvider = setupMockedProvider(); + + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: 'Test', + model: 'opencode-anthropic/claude-opus-4-5', + cwd: '/tmp/workspace', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.args).toContain('run'); + expect(call.args).toContain('--format'); + expect(call.args).toContain('stream-json'); + expect(call.args).toContain('-q'); + expect(call.args).toContain('-c'); + expect(call.args).toContain('/tmp/workspace'); + expect(call.args).toContain('--model'); + expect(call.args).toContain('anthropic/claude-opus-4-5'); + }); + + it('should skip null-normalized events', async () => { + const mockedProvider = setupMockedProvider(); + + const mockEvents = [ + { type: 'unknown-internal-event', data: 'ignored' }, + { type: 'text-delta', text: 'Valid text' }, + { type: 'another-unknown', foo: 'bar' }, + { type: 'finish-step', result: 'Done' }, + ]; + + vi.mocked(spawnJSONLProcess).mockReturnValue( + (async function* () { + for (const event of mockEvents) { + yield event; + } + })() + ); + + const results = await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: 'Test', + cwd: '/test', + }) + ); + + // Should only have valid events (text and result), not the unknown ones + expect(results.length).toBe(2); + }); + + it('should throw error when CLI is not installed', async () => { + // Create provider and explicitly set cliPath to null to simulate not installed + // Set detectedStrategy to 'npx' to prevent ensureCliDetected from re-running detection + const unmockedProvider = new OpencodeProvider(); + (unmockedProvider as unknown as { cliPath: string | null }).cliPath = null; + (unmockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx'; + + await expect( + collectAsyncGenerator( + unmockedProvider.executeQuery({ + prompt: 'Test', + cwd: '/test', + }) + ) + ).rejects.toThrow(/CLI not found/); + }); + }); + + // ========================================================================== + // getSpawnConfig Tests + // ========================================================================== + + describe('getSpawnConfig', () => { + it('should return npx as Windows strategy', () => { + const config = provider.getSpawnConfig(); + expect(config.windowsStrategy).toBe('npx'); + }); + + it('should specify opencode-ai@latest as npx package', () => { + const config = provider.getSpawnConfig(); + expect(config.npxPackage).toBe('opencode-ai@latest'); + }); + + it('should include common paths for Linux', () => { + const config = provider.getSpawnConfig(); + const linuxPaths = config.commonPaths['linux']; + + expect(linuxPaths).toBeDefined(); + expect(linuxPaths.length).toBeGreaterThan(0); + expect(linuxPaths.some((p) => p.includes('opencode'))).toBe(true); + }); + + it('should include common paths for macOS', () => { + const config = provider.getSpawnConfig(); + const darwinPaths = config.commonPaths['darwin']; + + expect(darwinPaths).toBeDefined(); + expect(darwinPaths.length).toBeGreaterThan(0); + expect(darwinPaths.some((p) => p.includes('homebrew'))).toBe(true); + }); + + it('should include common paths for Windows', () => { + const config = provider.getSpawnConfig(); + const win32Paths = config.commonPaths['win32']; + + expect(win32Paths).toBeDefined(); + expect(win32Paths.length).toBeGreaterThan(0); + expect(win32Paths.some((p) => p.includes('npm'))).toBe(true); + }); + }); + + // ========================================================================== + // detectInstallation Tests + // ========================================================================== + + describe('detectInstallation', () => { + it('should return installed true when CLI is found', async () => { + (provider as unknown as { cliPath: string }).cliPath = '/usr/local/bin/opencode'; + (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; + + const result = await provider.detectInstallation(); + + expect(result.installed).toBe(true); + expect(result.path).toBe('/usr/local/bin/opencode'); + }); + + it('should return installed false when CLI is not found', async () => { + // Set both cliPath to null and detectedStrategy to something other than 'native' + // to prevent ensureCliDetected from re-detecting + (provider as unknown as { cliPath: string | null }).cliPath = null; + (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx'; + + const result = await provider.detectInstallation(); + + expect(result.installed).toBe(false); + }); + + it('should return method as npm when using npx strategy', async () => { + (provider as unknown as { cliPath: string }).cliPath = 'npx'; + (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx'; + + const result = await provider.detectInstallation(); + + expect(result.method).toBe('npm'); + }); + + it('should return method as cli when using native strategy', async () => { + (provider as unknown as { cliPath: string }).cliPath = '/usr/local/bin/opencode'; + (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; + + const result = await provider.detectInstallation(); + + expect(result.method).toBe('cli'); + }); + }); + + // ========================================================================== + // Config Management Tests (inherited from BaseProvider) + // ========================================================================== + + describe('config management', () => { + it('should get and set config', () => { + provider.setConfig({ apiKey: 'test-api-key' }); + + const config = provider.getConfig(); + expect(config.apiKey).toBe('test-api-key'); + }); + + it('should merge config updates', () => { + provider.setConfig({ apiKey: 'key1' }); + provider.setConfig({ model: 'model1' }); + + const config = provider.getConfig(); + expect(config.apiKey).toBe('key1'); + expect(config.model).toBe('model1'); + }); + }); + + describe('validateConfig', () => { + it('should validate config from base class', () => { + const result = provider.validateConfig(); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + // ========================================================================== + // Additional Edge Case Tests + // ========================================================================== + + describe('extractPromptText edge cases', () => { + function setupMockedProvider(): OpencodeProvider { + const mockedProvider = new OpencodeProvider(); + (mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode'; + (mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; + return mockedProvider; + } + + it('should handle empty array prompt', async () => { + const mockedProvider = setupMockedProvider(); + + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: [] as unknown as string, + cwd: '/tmp', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.stdinData).toBe(''); + }); + + it('should handle array prompt with only image blocks (no text)', async () => { + const mockedProvider = setupMockedProvider(); + + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + const imageOnlyPrompt = [ + { type: 'image', source: { type: 'base64', data: 'abc123' } }, + { type: 'image', source: { type: 'base64', data: 'def456' } }, + ]; + + await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: imageOnlyPrompt as unknown as string, + cwd: '/tmp', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.stdinData).toBe(''); + }); + + it('should handle array prompt with mixed content types', async () => { + const mockedProvider = setupMockedProvider(); + + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + const mixedPrompt = [ + { type: 'text', text: 'Analyze this image' }, + { type: 'image', source: { type: 'base64', data: 'abc123' } }, + { type: 'text', text: 'And this one' }, + { type: 'image', source: { type: 'base64', data: 'def456' } }, + { type: 'text', text: 'What differences do you see?' }, + ]; + + await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: mixedPrompt as unknown as string, + cwd: '/tmp', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.stdinData).toBe('Analyze this image\nAnd this one\nWhat differences do you see?'); + }); + + it('should handle text blocks with empty text property', async () => { + const mockedProvider = setupMockedProvider(); + + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + const promptWithEmptyText = [ + { type: 'text', text: 'Hello' }, + { type: 'text', text: '' }, + { type: 'text', text: 'World' }, + ]; + + await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: promptWithEmptyText as unknown as string, + cwd: '/tmp', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + // Empty text blocks should be filtered out + expect(call.stdinData).toBe('Hello\nWorld'); + }); + }); + + describe('abort handling', () => { + function setupMockedProvider(): OpencodeProvider { + const mockedProvider = new OpencodeProvider(); + (mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode'; + (mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; + return mockedProvider; + } + + it('should pass abortController to subprocess options', async () => { + const mockedProvider = setupMockedProvider(); + + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + const abortController = new AbortController(); + + await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: 'Test', + cwd: '/tmp', + abortController, + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.abortController).toBe(abortController); + }); + }); + + describe('session_id preservation', () => { + function setupMockedProvider(): OpencodeProvider { + const mockedProvider = new OpencodeProvider(); + (mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode'; + (mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; + return mockedProvider; + } + + it('should preserve session_id through the full executeQuery flow', async () => { + const mockedProvider = setupMockedProvider(); + const sessionId = 'test-session-123'; + + const mockEvents = [ + { type: 'text-delta', text: 'Hello ', session_id: sessionId }, + { type: 'tool-call', name: 'Read', args: {}, call_id: 'c1', session_id: sessionId }, + { type: 'tool-result', call_id: 'c1', output: 'file content', session_id: sessionId }, + { type: 'finish-step', result: 'Done', session_id: sessionId }, + ]; + + vi.mocked(spawnJSONLProcess).mockReturnValue( + (async function* () { + for (const event of mockEvents) { + yield event; + } + })() + ); + + const results = await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: 'Test', + cwd: '/tmp', + }) + ); + + // All emitted messages should have the session_id + expect(results).toHaveLength(4); + results.forEach((result) => { + expect(result.session_id).toBe(sessionId); + }); + }); + }); + + describe('normalizeEvent additional edge cases', () => { + it('should handle tool-call with empty args object', () => { + const event = { + type: 'tool-call', + call_id: 'call-123', + name: 'Glob', + args: {}, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.message?.content[0].type).toBe('tool_use'); + expect(result?.message?.content[0].input).toEqual({}); + }); + + it('should handle tool-call with null args', () => { + const event = { + type: 'tool-call', + call_id: 'call-123', + name: 'Glob', + args: null, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.message?.content[0].type).toBe('tool_use'); + expect(result?.message?.content[0].input).toBeNull(); + }); + + it('should handle tool-call with complex nested args', () => { + const event = { + type: 'tool-call', + call_id: 'call-123', + name: 'Edit', + args: { + file_path: '/tmp/test.ts', + changes: [ + { line: 10, old: 'foo', new: 'bar' }, + { line: 20, old: 'baz', new: 'qux' }, + ], + options: { replace_all: true }, + }, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.message?.content[0].type).toBe('tool_use'); + expect(result?.message?.content[0].input).toEqual({ + file_path: '/tmp/test.ts', + changes: [ + { line: 10, old: 'foo', new: 'bar' }, + { line: 20, old: 'baz', new: 'qux' }, + ], + options: { replace_all: true }, + }); + }); + + it('should handle tool-result with empty output', () => { + const event = { + type: 'tool-result', + call_id: 'call-123', + output: '', + }; + + const result = provider.normalizeEvent(event); + + expect(result?.message?.content[0].type).toBe('tool_result'); + expect(result?.message?.content[0].content).toBe(''); + }); + + it('should handle text-delta with whitespace-only text', () => { + const event = { + type: 'text-delta', + text: ' ', + }; + + const result = provider.normalizeEvent(event); + + // Whitespace should be preserved (not filtered like empty string) + expect(result).not.toBeNull(); + expect(result?.message?.content[0].text).toBe(' '); + }); + + it('should handle text-delta with newlines', () => { + const event = { + type: 'text-delta', + text: 'Line 1\nLine 2\nLine 3', + }; + + const result = provider.normalizeEvent(event); + + expect(result?.message?.content[0].text).toBe('Line 1\nLine 2\nLine 3'); + }); + + it('should handle finish-step with both result and error (error takes precedence)', () => { + const event = { + type: 'finish-step', + result: 'Some result', + error: 'But also an error', + success: false, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.type).toBe('error'); + expect(result?.error).toBe('But also an error'); + }); + }); + + describe('isInstalled', () => { + it('should return true when CLI path is set', async () => { + (provider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode'; + (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; + + const result = await provider.isInstalled(); + + expect(result).toBe(true); + }); + + it('should return false when CLI path is null', async () => { + (provider as unknown as { cliPath: string | null }).cliPath = null; + (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx'; + + const result = await provider.isInstalled(); + + expect(result).toBe(false); + }); + }); + + describe('model tier validation', () => { + it('should have exactly one default model', () => { + const models = provider.getAvailableModels(); + const defaultModels = models.filter((m) => m.default === true); + + expect(defaultModels).toHaveLength(1); + expect(defaultModels[0].id).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0'); + }); + + it('should have valid tier values for all models', () => { + const models = provider.getAvailableModels(); + const validTiers = ['basic', 'standard', 'premium']; + + models.forEach((model) => { + expect(validTiers).toContain(model.tier); + }); + }); + + it('should have descriptions for all models', () => { + const models = provider.getAvailableModels(); + + models.forEach((model) => { + expect(model.description).toBeDefined(); + expect(typeof model.description).toBe('string'); + expect(model.description!.length).toBeGreaterThan(0); + }); + }); + }); + + describe('buildCliArgs edge cases', () => { + it('should handle very long prompts', () => { + const longPrompt = 'a'.repeat(10000); + const args = provider.buildCliArgs({ + prompt: longPrompt, + cwd: '/tmp', + }); + + // The prompt is NOT in args (it's passed via stdin) + // Just verify the args structure is correct + expect(args).toContain('run'); + expect(args).toContain('-'); + expect(args.join(' ')).not.toContain(longPrompt); + }); + + it('should handle prompts with special characters', () => { + const specialPrompt = 'Test $HOME $(rm -rf /) `command` "quotes" \'single\''; + const args = provider.buildCliArgs({ + prompt: specialPrompt, + cwd: '/tmp', + }); + + // Special chars in prompt should not affect args (prompt is via stdin) + expect(args).toContain('run'); + expect(args).toContain('-'); + }); + + it('should handle cwd with spaces', () => { + const args = provider.buildCliArgs({ + prompt: 'Test', + cwd: '/tmp/path with spaces/project', + }); + + const cwdIndex = args.indexOf('-c'); + expect(args[cwdIndex + 1]).toBe('/tmp/path with spaces/project'); + }); + + it('should handle model with unusual characters', () => { + const args = provider.buildCliArgs({ + prompt: 'Test', + model: 'opencode-provider/model-v1.2.3-beta', + cwd: '/tmp', + }); + + const modelIndex = args.indexOf('--model'); + expect(args[modelIndex + 1]).toBe('provider/model-v1.2.3-beta'); + }); + }); +}); diff --git a/apps/server/tests/unit/providers/provider-factory.test.ts b/apps/server/tests/unit/providers/provider-factory.test.ts index 550a0ffd..94fedb58 100644 --- a/apps/server/tests/unit/providers/provider-factory.test.ts +++ b/apps/server/tests/unit/providers/provider-factory.test.ts @@ -3,12 +3,14 @@ import { ProviderFactory } from '@/providers/provider-factory.js'; import { ClaudeProvider } from '@/providers/claude-provider.js'; import { CursorProvider } from '@/providers/cursor-provider.js'; import { CodexProvider } from '@/providers/codex-provider.js'; +import { OpencodeProvider } from '@/providers/opencode-provider.js'; describe('provider-factory.ts', () => { let consoleSpy: any; let detectClaudeSpy: any; let detectCursorSpy: any; let detectCodexSpy: any; + let detectOpencodeSpy: any; beforeEach(() => { consoleSpy = { @@ -25,6 +27,9 @@ describe('provider-factory.ts', () => { detectCodexSpy = vi .spyOn(CodexProvider.prototype, 'detectInstallation') .mockResolvedValue({ installed: true }); + detectOpencodeSpy = vi + .spyOn(OpencodeProvider.prototype, 'detectInstallation') + .mockResolvedValue({ installed: true }); }); afterEach(() => { @@ -32,6 +37,7 @@ describe('provider-factory.ts', () => { detectClaudeSpy.mockRestore(); detectCursorSpy.mockRestore(); detectCodexSpy.mockRestore(); + detectOpencodeSpy.mockRestore(); }); describe('getProviderForModel', () => { @@ -159,9 +165,9 @@ describe('provider-factory.ts', () => { expect(hasClaudeProvider).toBe(true); }); - it('should return exactly 3 providers', () => { + it('should return exactly 4 providers', () => { const providers = ProviderFactory.getAllProviders(); - expect(providers).toHaveLength(3); + expect(providers).toHaveLength(4); }); it('should include CursorProvider', () => { @@ -198,7 +204,8 @@ describe('provider-factory.ts', () => { expect(keys).toContain('claude'); expect(keys).toContain('cursor'); expect(keys).toContain('codex'); - expect(keys).toHaveLength(3); + expect(keys).toContain('opencode'); + expect(keys).toHaveLength(4); }); it('should include cursor status', async () => { diff --git a/apps/ui/src/components/ui/provider-icon.tsx b/apps/ui/src/components/ui/provider-icon.tsx index e0996a68..90096d96 100644 --- a/apps/ui/src/components/ui/provider-icon.tsx +++ b/apps/ui/src/components/ui/provider-icon.tsx @@ -1,4 +1,5 @@ import type { ComponentType, SVGProps } from 'react'; +import { Cpu } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { AgentModel, ModelProvider } from '@automaker/types'; import { getProviderFromModel } from '@/lib/utils'; @@ -95,6 +96,10 @@ export function GrokIcon(props: Omit) { return ; } +export function OpenCodeIcon({ className, ...props }: { className?: string }) { + return ; +} + export const PROVIDER_ICON_COMPONENTS: Record< ModelProvider, ComponentType<{ className?: string }> @@ -102,6 +107,7 @@ export const PROVIDER_ICON_COMPONENTS: Record< claude: AnthropicIcon, cursor: CursorIcon, // Default for Cursor provider (will be overridden by getProviderIconForModel) codex: OpenAIIcon, + opencode: OpenCodeIcon, }; /** diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index c57ca13d..15ade5cc 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -18,7 +18,12 @@ import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section'; import { AccountSection } from './settings-view/account'; import { SecuritySection } from './settings-view/security'; -import { ClaudeSettingsTab, CursorSettingsTab, CodexSettingsTab } from './settings-view/providers'; +import { + ClaudeSettingsTab, + CursorSettingsTab, + CodexSettingsTab, + OpencodeSettingsTab, +} from './settings-view/providers'; import { MCPServersSection } from './settings-view/mcp-servers'; import { PromptCustomizationSection } from './settings-view/prompts'; import type { Project as SettingsProject, Theme } from './settings-view/shared/types'; @@ -109,6 +114,8 @@ export function SettingsView() { return ; case 'codex-provider': return ; + case 'opencode-provider': + return ; case 'providers': case 'claude': // Backwards compatibility - redirect to claude-provider return ; diff --git a/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx new file mode 100644 index 00000000..a68dbcb7 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx @@ -0,0 +1,306 @@ +import { Button } from '@/components/ui/button'; +import { CheckCircle2, AlertCircle, RefreshCw, XCircle, Bot } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { CliStatus } from '../shared/types'; + +export type OpencodeAuthMethod = + | 'api_key_env' // ANTHROPIC_API_KEY or other provider env vars + | 'api_key' // Manually stored API key + | 'oauth' // OAuth authentication + | 'config_file' // Config file with credentials + | 'none'; + +export interface OpencodeAuthStatus { + authenticated: boolean; + method: OpencodeAuthMethod; + hasApiKey?: boolean; + hasEnvApiKey?: boolean; + hasOAuthToken?: boolean; + error?: string; +} + +function getAuthMethodLabel(method: OpencodeAuthMethod): string { + switch (method) { + case 'api_key': + return 'API Key'; + case 'api_key_env': + return 'API Key (Environment)'; + case 'oauth': + return 'OAuth Authentication'; + case 'config_file': + return 'Configuration File'; + default: + return method || 'Unknown'; + } +} + +interface OpencodeCliStatusProps { + status: CliStatus | null; + authStatus?: OpencodeAuthStatus | null; + isChecking: boolean; + onRefresh: () => void; +} + +function SkeletonPulse({ className }: { className?: string }) { + return
; +} + +export function OpencodeCliStatusSkeleton() { + return ( +
+
+
+
+ + +
+ +
+
+ +
+
+
+ {/* Installation status skeleton */} +
+ +
+ + + +
+
+ {/* Auth status skeleton */} +
+ +
+ + +
+
+
+
+ ); +} + +export function OpencodeModelConfigSkeleton() { + return ( +
+
+
+ + +
+
+ +
+
+
+ {/* Default Model skeleton */} +
+ + +
+ {/* Available Models skeleton */} +
+ + {/* Provider group skeleton */} +
+
+ + +
+
+ {[1, 2, 3].map((i) => ( +
+
+ +
+ + +
+
+ +
+ ))} +
+
+
+
+
+ ); +} + +export function OpencodeCliStatus({ + status, + authStatus, + isChecking, + onRefresh, +}: OpencodeCliStatusProps) { + if (!status) return ; + + return ( +
+
+
+
+
+ +
+

OpenCode CLI

+
+ +
+

+ OpenCode CLI provides multi-provider AI support with Claude, GPT, and Gemini models. +

+
+
+ {status.success && status.status === 'installed' ? ( +
+
+
+ +
+
+

OpenCode CLI Installed

+
+ {status.method && ( +

+ Method: {status.method} +

+ )} + {status.version && ( +

+ Version: {status.version} +

+ )} + {status.path && ( +

+ Path: {status.path} +

+ )} +
+
+
+ + {/* Authentication Status */} + {authStatus?.authenticated ? ( +
+
+ +
+
+

Authenticated

+
+

+ Method:{' '} + {getAuthMethodLabel(authStatus.method)} +

+
+
+
+ ) : ( +
+
+ +
+
+

Not Authenticated

+

+ Run{' '} + opencode auth or + set an API key to authenticate. +

+
+
+ )} + + {status.recommendation && ( +

{status.recommendation}

+ )} +
+ ) : ( +
+
+
+ +
+
+

OpenCode CLI Not Detected

+

+ {status.recommendation || 'Install OpenCode CLI to use multi-provider AI models.'} +

+
+
+ {status.installCommands && ( +
+

Installation Commands:

+
+ {status.installCommands.npm && ( +
+

+ npm +

+ + {status.installCommands.npm} + +
+ )} + {status.installCommands.macos && ( +
+

+ macOS/Linux +

+ + {status.installCommands.macos} + +
+ )} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/config/navigation.ts b/apps/ui/src/components/views/settings-view/config/navigation.ts index 391e5f34..463f6a52 100644 --- a/apps/ui/src/components/views/settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/settings-view/config/navigation.ts @@ -14,6 +14,7 @@ import { MessageSquareText, User, Shield, + Cpu, } from 'lucide-react'; import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon'; import type { SettingsViewId } from '../hooks/use-settings-view'; @@ -41,6 +42,7 @@ export const GLOBAL_NAV_ITEMS: NavigationItem[] = [ { id: 'claude-provider', label: 'Claude', icon: AnthropicIcon }, { id: 'cursor-provider', label: 'Cursor', icon: CursorIcon }, { id: 'codex-provider', label: 'Codex', icon: OpenAIIcon }, + { id: 'opencode-provider', label: 'OpenCode', icon: Cpu }, ], }, { id: 'mcp-servers', label: 'MCP Servers', icon: Plug }, diff --git a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts index f18ce832..b1109d7b 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts @@ -7,6 +7,7 @@ export type SettingsViewId = | 'claude-provider' | 'cursor-provider' | 'codex-provider' + | 'opencode-provider' | 'mcp-servers' | 'prompts' | 'model-defaults' diff --git a/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx index e3849f26..f57b9d95 100644 --- a/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx +++ b/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx @@ -8,10 +8,8 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Cpu } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { CodexModelId } from '@automaker/types'; -import { CODEX_MODEL_MAP } from '@automaker/types'; import { OpenAIIcon } from '@/components/ui/provider-icon'; interface CodexModelConfigurationProps { @@ -165,18 +163,6 @@ export function CodexModelConfiguration({ ); } -function getModelDisplayName(modelId: string): string { - const displayNames: Record = { - 'gpt-5.2-codex': 'GPT-5.2-Codex', - 'gpt-5-codex': 'GPT-5-Codex', - 'gpt-5-codex-mini': 'GPT-5-Codex-Mini', - 'codex-1': 'Codex-1', - 'codex-mini-latest': 'Codex-Mini-Latest', - 'gpt-5': 'GPT-5', - }; - return displayNames[modelId] || modelId; -} - function supportsReasoningEffort(modelId: string): boolean { const reasoningModels = ['gpt-5.2-codex', 'gpt-5-codex', 'gpt-5', 'codex-1']; return reasoningModels.includes(modelId); diff --git a/apps/ui/src/components/views/settings-view/providers/index.ts b/apps/ui/src/components/views/settings-view/providers/index.ts index 6711dedd..19d3226e 100644 --- a/apps/ui/src/components/views/settings-view/providers/index.ts +++ b/apps/ui/src/components/views/settings-view/providers/index.ts @@ -2,3 +2,4 @@ export { ProviderTabs } from './provider-tabs'; export { ClaudeSettingsTab } from './claude-settings-tab'; export { CursorSettingsTab } from './cursor-settings-tab'; export { CodexSettingsTab } from './codex-settings-tab'; +export { OpencodeSettingsTab } from './opencode-settings-tab'; diff --git a/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx new file mode 100644 index 00000000..1c762018 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx @@ -0,0 +1,231 @@ +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Terminal, Cloud, Cpu, Brain, Sparkles, Zap } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { OpencodeModelId, OpencodeProvider, OpencodeModelConfig } from '@automaker/types'; +import { OPENCODE_MODELS, OPENCODE_MODEL_CONFIG_MAP } from '@automaker/types'; +import { AnthropicIcon } from '@/components/ui/provider-icon'; +import type { ComponentType } from 'react'; + +interface OpencodeModelConfigurationProps { + enabledOpencodeModels: OpencodeModelId[]; + opencodeDefaultModel: OpencodeModelId; + isSaving: boolean; + onDefaultModelChange: (model: OpencodeModelId) => void; + onModelToggle: (model: OpencodeModelId, enabled: boolean) => void; +} + +/** + * Returns the appropriate icon component for a given OpenCode provider + */ +function getProviderIcon(provider: OpencodeProvider): ComponentType<{ className?: string }> { + switch (provider) { + case 'opencode': + return Terminal; + case 'amazon-bedrock-anthropic': + return AnthropicIcon; + case 'amazon-bedrock-deepseek': + return Brain; + case 'amazon-bedrock-amazon': + return Cloud; + case 'amazon-bedrock-meta': + return Cpu; + case 'amazon-bedrock-mistral': + return Sparkles; + case 'amazon-bedrock-qwen': + return Zap; + default: + return Terminal; + } +} + +/** + * Returns a formatted provider label for display + */ +function getProviderLabel(provider: OpencodeProvider): string { + switch (provider) { + case 'opencode': + return 'OpenCode (Free)'; + case 'amazon-bedrock-anthropic': + return 'Claude (Bedrock)'; + case 'amazon-bedrock-deepseek': + return 'DeepSeek (Bedrock)'; + case 'amazon-bedrock-amazon': + return 'Amazon Nova (Bedrock)'; + case 'amazon-bedrock-meta': + return 'Meta Llama (Bedrock)'; + case 'amazon-bedrock-mistral': + return 'Mistral (Bedrock)'; + case 'amazon-bedrock-qwen': + return 'Qwen (Bedrock)'; + default: + return provider; + } +} + +export function OpencodeModelConfiguration({ + enabledOpencodeModels, + opencodeDefaultModel, + isSaving, + onDefaultModelChange, + onModelToggle, +}: OpencodeModelConfigurationProps) { + // Group models by provider for organized display + const modelsByProvider = OPENCODE_MODELS.reduce( + (acc, model) => { + if (!acc[model.provider]) { + acc[model.provider] = []; + } + acc[model.provider].push(model); + return acc; + }, + {} as Record + ); + + // Order: Free tier first, then Claude, then others + const providerOrder: OpencodeProvider[] = [ + 'opencode', + 'amazon-bedrock-anthropic', + 'amazon-bedrock-deepseek', + 'amazon-bedrock-amazon', + 'amazon-bedrock-meta', + 'amazon-bedrock-mistral', + 'amazon-bedrock-qwen', + ]; + + return ( +
+
+
+
+ +
+

+ Model Configuration +

+
+

+ Configure which OpenCode models are available in the feature modal +

+
+
+ {/* Default Model Selection */} +
+ + +
+ + {/* Available Models grouped by provider */} +
+ + {providerOrder.map((provider) => { + const models = modelsByProvider[provider]; + if (!models || models.length === 0) return null; + + const ProviderIconComponent = getProviderIcon(provider); + + return ( +
+
+ + {getProviderLabel(provider)} + {provider === 'opencode' && ( + + Free + + )} +
+
+ {models.map((model) => { + const isEnabled = enabledOpencodeModels.includes(model.id); + const isDefault = model.id === opencodeDefaultModel; + + return ( +
+
+ onModelToggle(model.id, !!checked)} + disabled={isSaving || isDefault} + /> +
+
+ {model.label} + {model.supportsVision && ( + + Vision + + )} + {model.tier === 'free' && ( + + Free + + )} + {isDefault && ( + + Default + + )} +
+

{model.description}

+
+
+
+ ); + })} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx new file mode 100644 index 00000000..dbcd762c --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx @@ -0,0 +1,180 @@ +import { useState, useCallback, useEffect } from 'react'; +import { toast } from 'sonner'; +import { useAppStore } from '@/store/app-store'; +import { + OpencodeCliStatus, + OpencodeCliStatusSkeleton, + OpencodeModelConfigSkeleton, +} from '../cli-status/opencode-cli-status'; +import { OpencodeModelConfiguration } from './opencode-model-configuration'; +import { getElectronAPI } from '@/lib/electron'; +import { createLogger } from '@automaker/utils/logger'; +import type { CliStatus as SharedCliStatus } from '../shared/types'; +import type { OpencodeModelId } from '@automaker/types'; +import type { OpencodeAuthStatus } from '../cli-status/opencode-cli-status'; + +const logger = createLogger('OpencodeSettings'); + +export function OpencodeSettingsTab() { + const { + enabledOpencodeModels, + opencodeDefaultModel, + setOpencodeDefaultModel, + toggleOpencodeModel, + } = useAppStore(); + + const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false); + const [isInitialLoading, setIsInitialLoading] = useState(true); + const [cliStatus, setCliStatus] = useState(null); + const [authStatus, setAuthStatus] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + // Load OpenCode CLI status on mount + useEffect(() => { + const checkOpencodeStatus = async () => { + setIsCheckingOpencodeCli(true); + try { + const api = getElectronAPI(); + if (api?.setup?.getOpencodeStatus) { + const result = await api.setup.getOpencodeStatus(); + setCliStatus({ + success: result.success, + status: result.installed ? 'installed' : 'not_installed', + method: result.auth?.method, + version: result.version, + path: result.path, + recommendation: result.recommendation, + installCommands: result.installCommands, + }); + // Set auth status if available + if (result.auth) { + setAuthStatus({ + authenticated: result.auth.authenticated, + method: (result.auth.method as OpencodeAuthStatus['method']) || 'none', + hasApiKey: result.auth.hasApiKey, + hasEnvApiKey: result.auth.hasEnvApiKey, + hasOAuthToken: result.auth.hasOAuthToken, + }); + } + } else { + // Fallback for web mode or when API is not available + setCliStatus({ + success: false, + status: 'not_installed', + recommendation: 'OpenCode CLI detection is only available in desktop mode.', + }); + } + } catch (error) { + logger.error('Failed to check OpenCode CLI status:', error); + setCliStatus({ + success: false, + status: 'not_installed', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCheckingOpencodeCli(false); + setIsInitialLoading(false); + } + }; + checkOpencodeStatus(); + }, []); + + const handleRefreshOpencodeCli = useCallback(async () => { + setIsCheckingOpencodeCli(true); + try { + const api = getElectronAPI(); + if (api?.setup?.getOpencodeStatus) { + const result = await api.setup.getOpencodeStatus(); + setCliStatus({ + success: result.success, + status: result.installed ? 'installed' : 'not_installed', + method: result.auth?.method, + version: result.version, + path: result.path, + recommendation: result.recommendation, + installCommands: result.installCommands, + }); + // Update auth status if available + if (result.auth) { + setAuthStatus({ + authenticated: result.auth.authenticated, + method: (result.auth.method as OpencodeAuthStatus['method']) || 'none', + hasApiKey: result.auth.hasApiKey, + hasEnvApiKey: result.auth.hasEnvApiKey, + hasOAuthToken: result.auth.hasOAuthToken, + }); + } + } + } catch (error) { + logger.error('Failed to refresh OpenCode CLI status:', error); + toast.error('Failed to refresh OpenCode CLI status'); + } finally { + setIsCheckingOpencodeCli(false); + } + }, []); + + const handleDefaultModelChange = useCallback( + (model: OpencodeModelId) => { + setIsSaving(true); + try { + setOpencodeDefaultModel(model); + toast.success('Default model updated'); + } catch (error) { + toast.error('Failed to update default model'); + } finally { + setIsSaving(false); + } + }, + [setOpencodeDefaultModel] + ); + + const handleModelToggle = useCallback( + (model: OpencodeModelId, enabled: boolean) => { + setIsSaving(true); + try { + toggleOpencodeModel(model, enabled); + } catch (error) { + toast.error('Failed to update models'); + } finally { + setIsSaving(false); + } + }, + [toggleOpencodeModel] + ); + + // Show loading skeleton during initial load + if (isInitialLoading) { + return ( +
+ + +
+ ); + } + + const isCliInstalled = cliStatus?.success && cliStatus?.status === 'installed'; + + return ( +
+ + + {/* Model Configuration - Only show when CLI is installed */} + {isCliInstalled && ( + + )} +
+ ); +} + +export default OpencodeSettingsTab; diff --git a/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx b/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx index 56305aad..6df2a4c5 100644 --- a/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx +++ b/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx @@ -1,18 +1,20 @@ import React from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon'; +import { Cpu } from 'lucide-react'; import { CursorSettingsTab } from './cursor-settings-tab'; import { ClaudeSettingsTab } from './claude-settings-tab'; import { CodexSettingsTab } from './codex-settings-tab'; +import { OpencodeSettingsTab } from './opencode-settings-tab'; interface ProviderTabsProps { - defaultTab?: 'claude' | 'cursor' | 'codex'; + defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode'; } export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) { return ( - + Claude @@ -25,6 +27,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) { Codex + + + OpenCode + @@ -38,6 +44,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) { + + + + ); } diff --git a/apps/ui/src/components/views/setup-view.tsx b/apps/ui/src/components/views/setup-view.tsx index a15944b2..82e399ea 100644 --- a/apps/ui/src/components/views/setup-view.tsx +++ b/apps/ui/src/components/views/setup-view.tsx @@ -8,6 +8,7 @@ import { ClaudeSetupStep, CursorSetupStep, CodexSetupStep, + OpencodeSetupStep, GitHubSetupStep, } from './setup-view/steps'; import { useNavigate } from '@tanstack/react-router'; @@ -19,7 +20,16 @@ export function SetupView() { const { currentStep, setCurrentStep, completeSetup, setSkipClaudeSetup } = useSetupStore(); const navigate = useNavigate(); - const steps = ['welcome', 'theme', 'claude', 'cursor', 'codex', 'github', 'complete'] as const; + const steps = [ + 'welcome', + 'theme', + 'claude', + 'cursor', + 'codex', + 'opencode', + 'github', + 'complete', + ] as const; type StepName = (typeof steps)[number]; const getStepName = (): StepName => { if (currentStep === 'claude_detect' || currentStep === 'claude_auth') return 'claude'; @@ -27,6 +37,7 @@ export function SetupView() { if (currentStep === 'theme') return 'theme'; if (currentStep === 'cursor') return 'cursor'; if (currentStep === 'codex') return 'codex'; + if (currentStep === 'opencode') return 'opencode'; if (currentStep === 'github') return 'github'; return 'complete'; }; @@ -52,6 +63,10 @@ export function SetupView() { setCurrentStep('codex'); break; case 'codex': + logger.debug('[Setup Flow] Moving to opencode step'); + setCurrentStep('opencode'); + break; + case 'opencode': logger.debug('[Setup Flow] Moving to github step'); setCurrentStep('github'); break; @@ -77,9 +92,12 @@ export function SetupView() { case 'codex': setCurrentStep('cursor'); break; - case 'github': + case 'opencode': setCurrentStep('codex'); break; + case 'github': + setCurrentStep('opencode'); + break; } }; @@ -96,6 +114,11 @@ export function SetupView() { const handleSkipCodex = () => { logger.debug('[Setup Flow] Skipping Codex setup'); + setCurrentStep('opencode'); + }; + + const handleSkipOpencode = () => { + logger.debug('[Setup Flow] Skipping OpenCode setup'); setCurrentStep('github'); }; @@ -161,6 +184,14 @@ export function SetupView() { /> )} + {currentStep === 'opencode' && ( + handleNext('opencode')} + onBack={() => handleBack('opencode')} + onSkip={handleSkipOpencode} + /> + )} + {currentStep === 'github' && ( handleNext('github')} diff --git a/apps/ui/src/components/views/setup-view/steps/index.ts b/apps/ui/src/components/views/setup-view/steps/index.ts index 73e2de56..0c25aaed 100644 --- a/apps/ui/src/components/views/setup-view/steps/index.ts +++ b/apps/ui/src/components/views/setup-view/steps/index.ts @@ -5,4 +5,5 @@ export { CompleteStep } from './complete-step'; export { ClaudeSetupStep } from './claude-setup-step'; export { CursorSetupStep } from './cursor-setup-step'; export { CodexSetupStep } from './codex-setup-step'; +export { OpencodeSetupStep } from './opencode-setup-step'; export { GitHubSetupStep } from './github-setup-step'; diff --git a/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx new file mode 100644 index 00000000..a185d888 --- /dev/null +++ b/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx @@ -0,0 +1,369 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { createLogger } from '@automaker/utils/logger'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { useSetupStore } from '@/store/setup-store'; +import { getElectronAPI } from '@/lib/electron'; +import { + CheckCircle2, + Loader2, + ArrowRight, + ArrowLeft, + ExternalLink, + Copy, + RefreshCw, + AlertTriangle, + XCircle, + Terminal, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { StatusBadge } from '../components'; + +const logger = createLogger('OpencodeSetupStep'); + +interface OpencodeSetupStepProps { + onNext: () => void; + onBack: () => void; + onSkip: () => void; +} + +interface OpencodeCliStatus { + installed: boolean; + version?: string | null; + path?: string | null; + auth?: { + authenticated: boolean; + method: string; + }; + installCommand?: string; + loginCommand?: string; +} + +export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepProps) { + const { opencodeCliStatus, setOpencodeCliStatus } = useSetupStore(); + const [isChecking, setIsChecking] = useState(false); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const pollIntervalRef = useRef(null); + + const checkStatus = useCallback(async () => { + setIsChecking(true); + try { + const api = getElectronAPI(); + if (!api.setup?.getOpencodeStatus) { + return; + } + const result = await api.setup.getOpencodeStatus(); + if (result.success) { + const status: OpencodeCliStatus = { + installed: result.installed ?? false, + version: result.version, + path: result.path, + auth: result.auth, + installCommand: result.installCommand, + loginCommand: result.loginCommand, + }; + setOpencodeCliStatus(status); + + if (result.auth?.authenticated) { + toast.success('OpenCode CLI is ready!'); + } + } + } catch (error) { + logger.error('Failed to check OpenCode status:', error); + } finally { + setIsChecking(false); + } + }, [setOpencodeCliStatus]); + + useEffect(() => { + checkStatus(); + // Cleanup polling on unmount + return () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + }; + }, [checkStatus]); + + const copyCommand = (command: string) => { + navigator.clipboard.writeText(command); + toast.success('Command copied to clipboard'); + }; + + const handleLogin = async () => { + setIsLoggingIn(true); + + try { + // Copy login command to clipboard and show instructions + const loginCommand = opencodeCliStatus?.loginCommand || 'opencode login'; + await navigator.clipboard.writeText(loginCommand); + toast.info('Login command copied! Paste in terminal to authenticate.'); + + // Poll for auth status + let attempts = 0; + const maxAttempts = 60; // 2 minutes with 2s interval + + pollIntervalRef.current = setInterval(async () => { + attempts++; + + try { + const api = getElectronAPI(); + if (!api.setup?.getOpencodeStatus) { + return; + } + const result = await api.setup.getOpencodeStatus(); + + if (result.auth?.authenticated) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setOpencodeCliStatus({ + ...opencodeCliStatus, + installed: result.installed ?? true, + version: result.version, + path: result.path, + auth: result.auth, + } as OpencodeCliStatus); + setIsLoggingIn(false); + toast.success('Successfully logged in to OpenCode!'); + } + } catch { + // Ignore polling errors + } + + if (attempts >= maxAttempts) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setIsLoggingIn(false); + toast.error('Login timed out. Please try again.'); + } + }, 2000); + } catch (error) { + logger.error('Login failed:', error); + toast.error('Failed to start login process'); + setIsLoggingIn(false); + } + }; + + const isReady = opencodeCliStatus?.installed && opencodeCliStatus?.auth?.authenticated; + + const getStatusBadge = () => { + if (isChecking) { + return ; + } + if (opencodeCliStatus?.auth?.authenticated) { + return ; + } + if (opencodeCliStatus?.installed) { + return ; + } + return ; + }; + + return ( +
+
+
+ +
+

OpenCode CLI Setup

+

Optional - Use OpenCode as an AI provider

+
+ + {/* Info Banner */} + + +
+ +
+

This step is optional

+

+ Configure OpenCode CLI for access to free tier models and AWS Bedrock models. You + can skip this and use other providers, or configure it later in Settings. +

+
+
+
+
+ + {/* Status Card */} + + +
+ + + OpenCode CLI Status + + Optional + + +
+ {getStatusBadge()} + +
+
+ + {opencodeCliStatus?.installed + ? opencodeCliStatus.auth?.authenticated + ? `Authenticated via ${opencodeCliStatus.auth.method === 'api_key' ? 'API Key' : 'Browser Login'}${opencodeCliStatus.version ? ` (v${opencodeCliStatus.version})` : ''}` + : 'Installed but not authenticated' + : 'Not installed on your system'} + +
+ + {/* Success State */} + {isReady && ( +
+ +
+

OpenCode CLI is ready!

+

+ You can use OpenCode models for AI tasks. + {opencodeCliStatus?.version && ( + Version: {opencodeCliStatus.version} + )} +

+
+
+ )} + + {/* Not Installed */} + {!opencodeCliStatus?.installed && !isChecking && ( +
+
+ +
+

OpenCode CLI not found

+

+ Install the OpenCode CLI to use free tier and AWS Bedrock models. +

+
+
+ +
+

Install OpenCode CLI:

+
+ + {opencodeCliStatus?.installCommand || 'npm install -g opencode'} + + +
+ + View installation docs + + +
+
+ )} + + {/* Installed but not authenticated */} + {opencodeCliStatus?.installed && + !opencodeCliStatus?.auth?.authenticated && + !isChecking && ( +
+
+ +
+

OpenCode CLI not authenticated

+

+ Run the login command to authenticate with OpenCode. +

+
+
+ +
+

+ Run the login command in your terminal, then complete authentication in your + browser: +

+
+ + {opencodeCliStatus?.loginCommand || 'opencode login'} + + +
+ +
+
+ )} + + {/* Loading State */} + {isChecking && ( +
+ +
+

Checking OpenCode CLI status...

+
+
+ )} +
+
+ + {/* Navigation */} +
+ +
+ + +
+
+ + {/* Info note */} +

+ You can always configure OpenCode later in Settings +

+
+ ); +} diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 250451e9..c4fb17a4 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -14,6 +14,7 @@ import type { AIProfile, CursorModelId, CodexModelId, + OpencodeModelId, PhaseModelConfig, PhaseModelKey, PhaseModelEntry, @@ -23,7 +24,13 @@ import type { PipelineStep, PromptCustomization, } from '@automaker/types'; -import { getAllCursorModelIds, getAllCodexModelIds, DEFAULT_PHASE_MODELS } from '@automaker/types'; +import { + getAllCursorModelIds, + getAllCodexModelIds, + getAllOpencodeModelIds, + DEFAULT_PHASE_MODELS, + DEFAULT_OPENCODE_MODEL, +} from '@automaker/types'; const logger = createLogger('AppStore'); @@ -567,6 +574,10 @@ export interface AppState { codexEnableWebSearch: boolean; // Enable web search capability codexEnableImages: boolean; // Enable image processing + // OpenCode CLI Settings (global) + enabledOpencodeModels: OpencodeModelId[]; // Which OpenCode models are available in feature modal + opencodeDefaultModel: OpencodeModelId; // Default OpenCode model selection + // Claude Agent SDK Settings autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup @@ -930,6 +941,11 @@ export interface AppActions { setCodexEnableWebSearch: (enabled: boolean) => Promise; setCodexEnableImages: (enabled: boolean) => Promise; + // OpenCode CLI Settings actions + setEnabledOpencodeModels: (models: OpencodeModelId[]) => void; + setOpencodeDefaultModel: (model: OpencodeModelId) => void; + toggleOpencodeModel: (model: OpencodeModelId, enabled: boolean) => void; + // Claude Agent SDK Settings actions setAutoLoadClaudeMd: (enabled: boolean) => Promise; setSkipSandboxWarning: (skip: boolean) => Promise; @@ -1167,6 +1183,8 @@ const initialState: AppState = { codexApprovalPolicy: 'on-request', // Default to on-request for balanced safety codexEnableWebSearch: false, // Default to disabled codexEnableImages: false, // Default to disabled + enabledOpencodeModels: getAllOpencodeModelIds(), // All OpenCode models enabled by default + opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Default to Claude Sonnet 4.5 autoLoadClaudeMd: false, // Default to disabled (user must opt-in) skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) mcpServers: [], // No MCP servers configured by default @@ -1896,6 +1914,16 @@ export const useAppStore = create()((set, get) => ({ await syncSettingsToServer(); }, + // OpenCode CLI Settings actions + setEnabledOpencodeModels: (models) => set({ enabledOpencodeModels: models }), + setOpencodeDefaultModel: (model) => set({ opencodeDefaultModel: model }), + toggleOpencodeModel: (model, enabled) => + set((state) => ({ + enabledOpencodeModels: enabled + ? [...state.enabledOpencodeModels, model] + : state.enabledOpencodeModels.filter((m) => m !== model), + })), + // Claude Agent SDK Settings actions setAutoLoadClaudeMd: async (enabled) => { const previous = get().autoLoadClaudeMd; diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index 68970ea0..b8e7f717 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -48,6 +48,20 @@ export interface CodexCliStatus { error?: string; } +// OpenCode CLI Status +export interface OpencodeCliStatus { + installed: boolean; + version?: string | null; + path?: string | null; + auth?: { + authenticated: boolean; + method: string; + }; + installCommand?: string; + loginCommand?: string; + error?: string; +} + // Codex Auth Method export type CodexAuthMethod = | 'api_key_env' // OPENAI_API_KEY environment variable @@ -103,6 +117,7 @@ export type SetupStep = | 'claude_auth' | 'cursor' | 'codex' + | 'opencode' | 'github' | 'complete'; @@ -128,6 +143,9 @@ export interface SetupState { codexAuthStatus: CodexAuthStatus | null; codexInstallProgress: InstallProgress; + // OpenCode CLI state + opencodeCliStatus: OpencodeCliStatus | null; + // Setup preferences skipClaudeSetup: boolean; } @@ -158,6 +176,9 @@ export interface SetupActions { setCodexInstallProgress: (progress: Partial) => void; resetCodexInstallProgress: () => void; + // OpenCode CLI + setOpencodeCliStatus: (status: OpencodeCliStatus | null) => void; + // Preferences setSkipClaudeSetup: (skip: boolean) => void; } @@ -188,6 +209,8 @@ const initialState: SetupState = { codexAuthStatus: null, codexInstallProgress: { ...initialInstallProgress }, + opencodeCliStatus: null, + skipClaudeSetup: shouldSkipSetup, }; @@ -255,6 +278,9 @@ export const useSetupStore = create()((set, get) => ( codexInstallProgress: { ...initialInstallProgress }, }), + // OpenCode CLI + setOpencodeCliStatus: (status) => set({ opencodeCliStatus: status }), + // Preferences setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }), })); diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index 9d24ed23..7e4f2474 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -96,6 +96,9 @@ export { getCodexCliPaths, getCodexConfigDir, getCodexAuthPath, + getOpenCodeCliPaths, + getOpenCodeConfigDir, + getOpenCodeAuthPath, getShellPaths, getExtendedPath, // Node.js paths @@ -126,6 +129,9 @@ export { findCodexCliPath, getCodexAuthIndicators, type CodexAuthIndicators, + findOpenCodeCliPath, + getOpenCodeAuthIndicators, + type OpenCodeAuthIndicators, // Electron userData operations setElectronUserDataPath, getElectronUserDataPath, diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index 5575f659..f9f98ae6 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -543,6 +543,11 @@ function getAllAllowedSystemPaths(): string[] { // Codex config directory and files getCodexConfigDir(), getCodexAuthPath(), + // OpenCode CLI paths + ...getOpenCodeCliPaths(), + // OpenCode config directory and files + getOpenCodeConfigDir(), + getOpenCodeAuthPath(), // Shell paths ...getShellPaths(), // Node.js system paths @@ -564,6 +569,8 @@ function getAllAllowedSystemDirs(): string[] { getClaudeProjectsDir(), // Codex config getCodexConfigDir(), + // OpenCode config + getOpenCodeConfigDir(), // Version managers (need recursive access for version directories) ...getNvmPaths(), ...getFnmPaths(), @@ -1007,3 +1014,148 @@ export async function getCodexAuthIndicators(): Promise { return result; } + +// ============================================================================= +// OpenCode CLI Detection +// ============================================================================= + +const OPENCODE_CONFIG_DIR_NAME = '.opencode'; +const OPENCODE_AUTH_FILENAME = 'auth.json'; +const OPENCODE_TOKENS_KEY = 'tokens'; + +/** + * Get common paths where OpenCode CLI might be installed + */ +export function getOpenCodeCliPaths(): string[] { + const isWindows = process.platform === 'win32'; + const homeDir = os.homedir(); + + if (isWindows) { + const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'); + const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'); + return [ + path.join(homeDir, '.local', 'bin', 'opencode.exe'), + path.join(appData, 'npm', 'opencode.cmd'), + path.join(appData, 'npm', 'opencode'), + path.join(appData, '.npm-global', 'bin', 'opencode.cmd'), + path.join(appData, '.npm-global', 'bin', 'opencode'), + // Volta on Windows + path.join(homeDir, '.volta', 'bin', 'opencode.exe'), + // pnpm on Windows + path.join(localAppData, 'pnpm', 'opencode.cmd'), + path.join(localAppData, 'pnpm', 'opencode'), + // Go installation (if OpenCode is a Go binary) + path.join(homeDir, 'go', 'bin', 'opencode.exe'), + path.join(process.env.GOPATH || path.join(homeDir, 'go'), 'bin', 'opencode.exe'), + ]; + } + + // Include NVM bin paths for opencode installed via npm global under NVM + const nvmBinPaths = getNvmBinPaths().map((binPath) => path.join(binPath, 'opencode')); + + // Include fnm bin paths + const fnmBinPaths = getFnmBinPaths().map((binPath) => path.join(binPath, 'opencode')); + + // pnpm global bin path + const pnpmHome = process.env.PNPM_HOME || path.join(homeDir, '.local', 'share', 'pnpm'); + + return [ + // Standard locations + path.join(homeDir, '.local', 'bin', 'opencode'), + '/opt/homebrew/bin/opencode', + '/usr/local/bin/opencode', + '/usr/bin/opencode', + path.join(homeDir, '.npm-global', 'bin', 'opencode'), + // Linuxbrew + '/home/linuxbrew/.linuxbrew/bin/opencode', + // Volta + path.join(homeDir, '.volta', 'bin', 'opencode'), + // pnpm global + path.join(pnpmHome, 'opencode'), + // Yarn global + path.join(homeDir, '.yarn', 'bin', 'opencode'), + path.join(homeDir, '.config', 'yarn', 'global', 'node_modules', '.bin', 'opencode'), + // Go installation (if OpenCode is a Go binary) + path.join(homeDir, 'go', 'bin', 'opencode'), + path.join(process.env.GOPATH || path.join(homeDir, 'go'), 'bin', 'opencode'), + // Snap packages + '/snap/bin/opencode', + // NVM paths + ...nvmBinPaths, + // fnm paths + ...fnmBinPaths, + ]; +} + +/** + * Get the OpenCode configuration directory path + */ +export function getOpenCodeConfigDir(): string { + return path.join(os.homedir(), OPENCODE_CONFIG_DIR_NAME); +} + +/** + * Get path to OpenCode auth file + */ +export function getOpenCodeAuthPath(): string { + return path.join(getOpenCodeConfigDir(), OPENCODE_AUTH_FILENAME); +} + +/** + * Check if OpenCode CLI is installed and return its path + */ +export async function findOpenCodeCliPath(): Promise { + return findFirstExistingPath(getOpenCodeCliPaths()); +} + +export interface OpenCodeAuthIndicators { + hasAuthFile: boolean; + hasOAuthToken: boolean; + hasApiKey: boolean; +} + +const OPENCODE_OAUTH_KEYS = ['access_token', 'oauth_token'] as const; +const OPENCODE_API_KEY_KEYS = ['api_key', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY'] as const; + +function getOpenCodeNestedTokens(record: Record): Record | null { + const tokens = record[OPENCODE_TOKENS_KEY]; + if (tokens && typeof tokens === 'object' && !Array.isArray(tokens)) { + return tokens as Record; + } + return null; +} + +/** + * Get OpenCode authentication status by checking auth file indicators + */ +export async function getOpenCodeAuthIndicators(): Promise { + const result: OpenCodeAuthIndicators = { + hasAuthFile: false, + hasOAuthToken: false, + hasApiKey: false, + }; + + try { + const authContent = await systemPathReadFile(getOpenCodeAuthPath()); + result.hasAuthFile = true; + + try { + const authJson = JSON.parse(authContent) as Record; + result.hasOAuthToken = hasNonEmptyStringField(authJson, OPENCODE_OAUTH_KEYS); + result.hasApiKey = hasNonEmptyStringField(authJson, OPENCODE_API_KEY_KEYS); + const nestedTokens = getOpenCodeNestedTokens(authJson); + if (nestedTokens) { + result.hasOAuthToken = + result.hasOAuthToken || hasNonEmptyStringField(nestedTokens, OPENCODE_OAUTH_KEYS); + result.hasApiKey = + result.hasApiKey || hasNonEmptyStringField(nestedTokens, OPENCODE_API_KEY_KEYS); + } + } catch { + // Ignore parse errors; file exists but contents are unreadable + } + } catch { + // Auth file not found or inaccessible + } + + return result; +} diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index edc7dd0b..34c7f7a3 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -174,12 +174,16 @@ export type { export * from './cursor-models.js'; export * from './cursor-cli.js'; +// OpenCode types +export * from './opencode-models.js'; + // Provider utilities export { PROVIDER_PREFIXES, isCursorModel, isClaudeModel, isCodexModel, + isOpencodeModel, getModelProvider, stripProviderPrefix, addProviderPrefix, diff --git a/libs/types/src/opencode-models.ts b/libs/types/src/opencode-models.ts new file mode 100644 index 00000000..246f8770 --- /dev/null +++ b/libs/types/src/opencode-models.ts @@ -0,0 +1,397 @@ +/** + * OpenCode Model IDs + * Models available via OpenCode CLI (opencode models command) + */ +export type OpencodeModelId = + // OpenCode Free Tier Models + | 'opencode/big-pickle' + | 'opencode/glm-4.7-free' + | 'opencode/gpt-5-nano' + | 'opencode/grok-code' + | 'opencode/minimax-m2.1-free' + // Amazon Bedrock - Claude Models + | 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0' + | 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0' + | 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0' + | 'amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0' + | 'amazon-bedrock/anthropic.claude-opus-4-20250514-v1:0' + | 'amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0' + | 'amazon-bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0' + | 'amazon-bedrock/anthropic.claude-3-opus-20240229-v1:0' + // Amazon Bedrock - DeepSeek Models + | 'amazon-bedrock/deepseek.r1-v1:0' + | 'amazon-bedrock/deepseek.v3-v1:0' + // Amazon Bedrock - Amazon Nova Models + | 'amazon-bedrock/amazon.nova-premier-v1:0' + | 'amazon-bedrock/amazon.nova-pro-v1:0' + | 'amazon-bedrock/amazon.nova-lite-v1:0' + // Amazon Bedrock - Meta Llama Models + | 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0' + | 'amazon-bedrock/meta.llama3-3-70b-instruct-v1:0' + // Amazon Bedrock - Mistral Models + | 'amazon-bedrock/mistral.mistral-large-2402-v1:0' + // Amazon Bedrock - Qwen Models + | 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0' + | 'amazon-bedrock/qwen.qwen3-235b-a22b-2507-v1:0'; + +/** + * Provider type for OpenCode models + */ +export type OpencodeProvider = + | 'opencode' + | 'amazon-bedrock-anthropic' + | 'amazon-bedrock-deepseek' + | 'amazon-bedrock-amazon' + | 'amazon-bedrock-meta' + | 'amazon-bedrock-mistral' + | 'amazon-bedrock-qwen'; + +/** + * Friendly aliases mapped to full model IDs + */ +export const OPENCODE_MODEL_MAP: Record = { + // OpenCode free tier aliases + 'big-pickle': 'opencode/big-pickle', + pickle: 'opencode/big-pickle', + 'glm-free': 'opencode/glm-4.7-free', + 'gpt-nano': 'opencode/gpt-5-nano', + nano: 'opencode/gpt-5-nano', + 'grok-code': 'opencode/grok-code', + grok: 'opencode/grok-code', + minimax: 'opencode/minimax-m2.1-free', + + // Claude aliases (via Bedrock) + 'claude-sonnet-4.5': 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + 'sonnet-4.5': 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + sonnet: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + 'claude-opus-4.5': 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', + 'opus-4.5': 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', + opus: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', + 'claude-haiku-4.5': 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', + 'haiku-4.5': 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', + haiku: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', + + // DeepSeek aliases + 'deepseek-r1': 'amazon-bedrock/deepseek.r1-v1:0', + r1: 'amazon-bedrock/deepseek.r1-v1:0', + 'deepseek-v3': 'amazon-bedrock/deepseek.v3-v1:0', + + // Nova aliases + 'nova-premier': 'amazon-bedrock/amazon.nova-premier-v1:0', + 'nova-pro': 'amazon-bedrock/amazon.nova-pro-v1:0', + nova: 'amazon-bedrock/amazon.nova-pro-v1:0', + + // Llama aliases + llama4: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0', + 'llama-4': 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0', + llama3: 'amazon-bedrock/meta.llama3-3-70b-instruct-v1:0', + + // Qwen aliases + qwen: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0', + 'qwen-coder': 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0', +} as const; + +/** + * OpenCode model metadata + */ +export interface OpencodeModelConfig { + id: OpencodeModelId; + label: string; + description: string; + supportsVision: boolean; + provider: OpencodeProvider; + tier: 'free' | 'standard' | 'premium'; +} + +/** + * Complete list of OpenCode model configurations + */ +export const OPENCODE_MODELS: OpencodeModelConfig[] = [ + // OpenCode Free Tier Models + { + id: 'opencode/big-pickle', + label: 'Big Pickle', + description: 'OpenCode free tier model - great for general coding', + supportsVision: false, + provider: 'opencode', + tier: 'free', + }, + { + id: 'opencode/glm-4.7-free', + label: 'GLM 4.7 Free', + description: 'OpenCode free tier GLM model', + supportsVision: false, + provider: 'opencode', + tier: 'free', + }, + { + id: 'opencode/gpt-5-nano', + label: 'GPT-5 Nano', + description: 'OpenCode free tier nano model - fast and lightweight', + supportsVision: false, + provider: 'opencode', + tier: 'free', + }, + { + id: 'opencode/grok-code', + label: 'Grok Code', + description: 'OpenCode free tier Grok model for coding', + supportsVision: false, + provider: 'opencode', + tier: 'free', + }, + { + id: 'opencode/minimax-m2.1-free', + label: 'MiniMax M2.1 Free', + description: 'OpenCode free tier MiniMax model', + supportsVision: false, + provider: 'opencode', + tier: 'free', + }, + + // Amazon Bedrock - Claude Models + { + id: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + label: 'Claude Sonnet 4.5 (Bedrock)', + description: 'Latest Claude Sonnet via AWS Bedrock - fast and intelligent (default)', + supportsVision: true, + provider: 'amazon-bedrock-anthropic', + tier: 'premium', + }, + { + id: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', + label: 'Claude Opus 4.5 (Bedrock)', + description: 'Most capable Claude model via AWS Bedrock', + supportsVision: true, + provider: 'amazon-bedrock-anthropic', + tier: 'premium', + }, + { + id: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', + label: 'Claude Haiku 4.5 (Bedrock)', + description: 'Fastest Claude model via AWS Bedrock', + supportsVision: true, + provider: 'amazon-bedrock-anthropic', + tier: 'standard', + }, + { + id: 'amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0', + label: 'Claude Sonnet 4 (Bedrock)', + description: 'Claude Sonnet 4 via AWS Bedrock', + supportsVision: true, + provider: 'amazon-bedrock-anthropic', + tier: 'premium', + }, + { + id: 'amazon-bedrock/anthropic.claude-opus-4-20250514-v1:0', + label: 'Claude Opus 4 (Bedrock)', + description: 'Claude Opus 4 via AWS Bedrock', + supportsVision: true, + provider: 'amazon-bedrock-anthropic', + tier: 'premium', + }, + { + id: 'amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0', + label: 'Claude 3.7 Sonnet (Bedrock)', + description: 'Claude 3.7 Sonnet via AWS Bedrock', + supportsVision: true, + provider: 'amazon-bedrock-anthropic', + tier: 'standard', + }, + { + id: 'amazon-bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0', + label: 'Claude 3.5 Sonnet (Bedrock)', + description: 'Claude 3.5 Sonnet v2 via AWS Bedrock', + supportsVision: true, + provider: 'amazon-bedrock-anthropic', + tier: 'standard', + }, + { + id: 'amazon-bedrock/anthropic.claude-3-opus-20240229-v1:0', + label: 'Claude 3 Opus (Bedrock)', + description: 'Claude 3 Opus via AWS Bedrock', + supportsVision: true, + provider: 'amazon-bedrock-anthropic', + tier: 'premium', + }, + + // Amazon Bedrock - DeepSeek Models + { + id: 'amazon-bedrock/deepseek.r1-v1:0', + label: 'DeepSeek R1 (Bedrock)', + description: 'DeepSeek R1 reasoning model via AWS Bedrock - excellent for coding', + supportsVision: false, + provider: 'amazon-bedrock-deepseek', + tier: 'premium', + }, + { + id: 'amazon-bedrock/deepseek.v3-v1:0', + label: 'DeepSeek V3 (Bedrock)', + description: 'DeepSeek V3 via AWS Bedrock', + supportsVision: false, + provider: 'amazon-bedrock-deepseek', + tier: 'standard', + }, + + // Amazon Bedrock - Amazon Nova Models + { + id: 'amazon-bedrock/amazon.nova-premier-v1:0', + label: 'Amazon Nova Premier (Bedrock)', + description: 'Amazon Nova Premier - most capable Nova model', + supportsVision: true, + provider: 'amazon-bedrock-amazon', + tier: 'premium', + }, + { + id: 'amazon-bedrock/amazon.nova-pro-v1:0', + label: 'Amazon Nova Pro (Bedrock)', + description: 'Amazon Nova Pro - balanced performance', + supportsVision: true, + provider: 'amazon-bedrock-amazon', + tier: 'standard', + }, + { + id: 'amazon-bedrock/amazon.nova-lite-v1:0', + label: 'Amazon Nova Lite (Bedrock)', + description: 'Amazon Nova Lite - fast and efficient', + supportsVision: true, + provider: 'amazon-bedrock-amazon', + tier: 'standard', + }, + + // Amazon Bedrock - Meta Llama Models + { + id: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0', + label: 'Llama 4 Maverick 17B (Bedrock)', + description: 'Meta Llama 4 Maverick via AWS Bedrock', + supportsVision: false, + provider: 'amazon-bedrock-meta', + tier: 'standard', + }, + { + id: 'amazon-bedrock/meta.llama3-3-70b-instruct-v1:0', + label: 'Llama 3.3 70B (Bedrock)', + description: 'Meta Llama 3.3 70B via AWS Bedrock', + supportsVision: false, + provider: 'amazon-bedrock-meta', + tier: 'standard', + }, + + // Amazon Bedrock - Mistral Models + { + id: 'amazon-bedrock/mistral.mistral-large-2402-v1:0', + label: 'Mistral Large (Bedrock)', + description: 'Mistral Large via AWS Bedrock', + supportsVision: false, + provider: 'amazon-bedrock-mistral', + tier: 'standard', + }, + + // Amazon Bedrock - Qwen Models + { + id: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0', + label: 'Qwen3 Coder 480B (Bedrock)', + description: 'Qwen3 Coder 480B via AWS Bedrock - excellent for coding', + supportsVision: false, + provider: 'amazon-bedrock-qwen', + tier: 'premium', + }, + { + id: 'amazon-bedrock/qwen.qwen3-235b-a22b-2507-v1:0', + label: 'Qwen3 235B (Bedrock)', + description: 'Qwen3 235B via AWS Bedrock', + supportsVision: false, + provider: 'amazon-bedrock-qwen', + tier: 'premium', + }, +]; + +/** + * Complete model configuration map indexed by model ID + */ +export const OPENCODE_MODEL_CONFIG_MAP: Record = + OPENCODE_MODELS.reduce( + (acc, config) => { + acc[config.id] = config; + return acc; + }, + {} as Record + ); + +/** + * Default OpenCode model - Claude Sonnet 4.5 via Bedrock + */ +export const DEFAULT_OPENCODE_MODEL: OpencodeModelId = + 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0'; + +/** + * Helper: Get display name for model + */ +export function getOpencodeModelLabel(modelId: OpencodeModelId): string { + return OPENCODE_MODEL_CONFIG_MAP[modelId]?.label ?? modelId; +} + +/** + * Helper: Get all OpenCode model IDs + */ +export function getAllOpencodeModelIds(): OpencodeModelId[] { + return OPENCODE_MODELS.map((config) => config.id); +} + +/** + * Helper: Check if OpenCode model supports vision + */ +export function opencodeModelSupportsVision(modelId: OpencodeModelId): boolean { + return OPENCODE_MODEL_CONFIG_MAP[modelId]?.supportsVision ?? false; +} + +/** + * Helper: Get the provider for a model + */ +export function getOpencodeModelProvider(modelId: OpencodeModelId): OpencodeProvider { + return OPENCODE_MODEL_CONFIG_MAP[modelId]?.provider ?? 'opencode'; +} + +/** + * Helper: Resolve an alias or partial model ID to a full model ID + */ +export function resolveOpencodeModelId(input: string): OpencodeModelId | undefined { + // Check if it's already a valid model ID + if (OPENCODE_MODEL_CONFIG_MAP[input as OpencodeModelId]) { + return input as OpencodeModelId; + } + + // Check alias map + const normalized = input.toLowerCase(); + return OPENCODE_MODEL_MAP[normalized]; +} + +/** + * Helper: Check if a string is a valid OpenCode model ID + */ +export function isOpencodeModelId(value: string): value is OpencodeModelId { + return value in OPENCODE_MODEL_CONFIG_MAP; +} + +/** + * Helper: Get models filtered by provider + */ +export function getOpencodeModelsByProvider(provider: OpencodeProvider): OpencodeModelConfig[] { + return OPENCODE_MODELS.filter((config) => config.provider === provider); +} + +/** + * Helper: Get models filtered by tier + */ +export function getOpencodeModelsByTier( + tier: 'free' | 'standard' | 'premium' +): OpencodeModelConfig[] { + return OPENCODE_MODELS.filter((config) => config.tier === tier); +} + +/** + * Helper: Get free tier models + */ +export function getOpencodeFreeModels(): OpencodeModelConfig[] { + return getOpencodeModelsByTier('free'); +} diff --git a/libs/types/src/provider-utils.ts b/libs/types/src/provider-utils.ts index 51ebb85d..a92754de 100644 --- a/libs/types/src/provider-utils.ts +++ b/libs/types/src/provider-utils.ts @@ -9,11 +9,13 @@ import type { ModelProvider } from './settings.js'; import { CURSOR_MODEL_MAP, type CursorModelId } from './cursor-models.js'; import { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP, type CodexModelId } from './model.js'; +import { OPENCODE_MODEL_CONFIG_MAP } from './opencode-models.js'; /** Provider prefix constants */ export const PROVIDER_PREFIXES = { cursor: 'cursor-', codex: 'codex-', + opencode: 'opencode-', // Add new provider prefixes here } as const; @@ -82,6 +84,41 @@ export function isCodexModel(model: string | undefined | null): boolean { return modelValues.includes(model as CodexModelId); } +/** + * Check if a model string represents an OpenCode model + * + * OpenCode models can be identified by: + * - Explicit 'opencode-' prefix (for routing in Automaker) + * - 'opencode/' prefix (OpenCode free tier models) + * - 'amazon-bedrock/' prefix (AWS Bedrock models via OpenCode) + * - Full model ID from OPENCODE_MODEL_CONFIG_MAP + * + * @param model - Model string to check (e.g., "opencode-sonnet", "opencode/big-pickle", "amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0") + * @returns true if the model is an OpenCode model + */ +export function isOpencodeModel(model: string | undefined | null): boolean { + if (!model || typeof model !== 'string') return false; + + // Check for explicit opencode- prefix (Automaker routing prefix) + if (model.startsWith(PROVIDER_PREFIXES.opencode)) { + return true; + } + + // Check if it's a known OpenCode model ID + if (model in OPENCODE_MODEL_CONFIG_MAP) { + return true; + } + + // Check for OpenCode native model prefixes + // - opencode/ = OpenCode free tier models (e.g., opencode/big-pickle) + // - amazon-bedrock/ = AWS Bedrock models (e.g., amazon-bedrock/anthropic.claude-*) + if (model.startsWith('opencode/') || model.startsWith('amazon-bedrock/')) { + return true; + } + + return false; +} + /** * Get the provider for a model string * @@ -89,7 +126,11 @@ export function isCodexModel(model: string | undefined | null): boolean { * @returns The provider type, defaults to 'claude' for unknown models */ export function getModelProvider(model: string | undefined | null): ModelProvider { - // Check Codex first before Cursor, since Cursor also supports gpt models + // Check OpenCode first since it uses provider-prefixed formats that could conflict + if (isOpencodeModel(model)) { + return 'opencode'; + } + // Check Codex before Cursor, since Cursor also supports gpt models // but bare gpt-* should route to Codex if (isCodexModel(model)) { return 'codex'; @@ -145,6 +186,10 @@ export function addProviderPrefix(model: string, provider: ModelProvider): strin if (!model.startsWith(PROVIDER_PREFIXES.codex)) { return `${PROVIDER_PREFIXES.codex}${model}`; } + } else if (provider === 'opencode') { + if (!model.startsWith(PROVIDER_PREFIXES.opencode)) { + return `${PROVIDER_PREFIXES.opencode}${model}`; + } } // Claude models don't use prefixes return model; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 29fac9a5..ffd0b7eb 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -9,6 +9,8 @@ import type { ModelAlias, AgentModel, CodexModelId } from './model.js'; import type { CursorModelId } from './cursor-models.js'; import { CURSOR_MODEL_MAP, getAllCursorModelIds } from './cursor-models.js'; +import type { OpencodeModelId } from './opencode-models.js'; +import { getAllOpencodeModelIds, DEFAULT_OPENCODE_MODEL } from './opencode-models.js'; import type { PromptCustomization } from './prompts.js'; import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js'; @@ -96,7 +98,7 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number } /** ModelProvider - AI model provider for credentials and API key management */ -export type ModelProvider = 'claude' | 'cursor' | 'codex'; +export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode'; const DEFAULT_CODEX_AUTO_LOAD_AGENTS = false; const DEFAULT_CODEX_SANDBOX_MODE: CodexSandboxMode = 'workspace-write'; @@ -257,6 +259,10 @@ export interface AIProfile { // Codex-specific settings /** Which Codex/GPT model to use - only for Codex provider */ codexModel?: CodexModelId; + + // OpenCode-specific settings + /** Which OpenCode model to use - only for OpenCode provider */ + opencodeModel?: OpencodeModelId; } /** @@ -280,6 +286,11 @@ export function profileHasThinking(profile: AIProfile): boolean { return model.startsWith('o'); } + if (profile.provider === 'opencode') { + // OpenCode models don't expose thinking configuration + return false; + } + return false; } @@ -295,6 +306,10 @@ export function getProfileModelString(profile: AIProfile): string { return `codex:${profile.codexModel || 'gpt-5.2'}`; } + if (profile.provider === 'opencode') { + return `opencode:${profile.opencodeModel || DEFAULT_OPENCODE_MODEL}`; + } + // Claude return profile.model || 'sonnet'; } @@ -473,6 +488,12 @@ export interface GlobalSettings { /** Default Cursor model selection when switching to Cursor CLI */ cursorDefaultModel: CursorModelId; + // OpenCode CLI Settings (global) + /** Which OpenCode models are available in feature modal (empty = all) */ + enabledOpencodeModels?: OpencodeModelId[]; + /** Default OpenCode model selection when switching to OpenCode CLI */ + opencodeDefaultModel?: OpencodeModelId; + // Input Configuration /** User's keyboard shortcut bindings */ keyboardShortcuts: KeyboardShortcuts; @@ -717,6 +738,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { validationModel: 'opus', enabledCursorModels: getAllCursorModelIds(), cursorDefaultModel: 'auto', + enabledOpencodeModels: getAllOpencodeModelIds(), + opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, aiProfiles: [], projects: [],