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..b54592c3 --- /dev/null +++ b/apps/server/src/providers/opencode-provider.ts @@ -0,0 +1,666 @@ +/** + * 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, getOpenCodeAuthIndicators } from '@automaker/platform'; + +// ============================================================================= +// OpenCode Auth Types +// ============================================================================= + +export interface OpenCodeAuthStatus { + authenticated: boolean; + method: 'api_key' | 'oauth' | 'none'; + hasOAuthToken?: boolean; + hasApiKey?: boolean; +} + +// ============================================================================= +// 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(), '.opencode/bin/opencode'), + 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(), '.opencode/bin/opencode'), + 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(), '.opencode', 'bin', 'opencode.exe'), + 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); + } + + // ========================================================================== + // Authentication + // ========================================================================== + + /** + * Check authentication status for OpenCode CLI + * + * Checks for authentication via: + * - OAuth token in auth file + * - API key in auth file + */ + async checkAuth(): Promise { + const authIndicators = await getOpenCodeAuthIndicators(); + + // Check for OAuth token + if (authIndicators.hasOAuthToken) { + return { + authenticated: true, + method: 'oauth', + hasOAuthToken: true, + hasApiKey: authIndicators.hasApiKey, + }; + } + + // Check for API key + if (authIndicators.hasApiKey) { + return { + authenticated: true, + method: 'api_key', + hasOAuthToken: false, + hasApiKey: true, + }; + } + + return { + authenticated: false, + method: 'none', + hasOAuthToken: false, + hasApiKey: false, + }; + } + + // ========================================================================== + // Installation Detection + // ========================================================================== + + /** + * Detect OpenCode installation status + * + * Checks if the opencode CLI is available either through: + * - Direct installation (npm global) + * - NPX (fallback on Windows) + * Also checks authentication status. + */ + async detectInstallation(): Promise { + this.ensureCliDetected(); + + const installed = await this.isInstalled(); + const auth = await this.checkAuth(); + + return { + installed, + path: this.cliPath || undefined, + method: this.detectedStrategy === 'npx' ? 'npm' : 'cli', + authenticated: auth.authenticated, + hasApiKey: auth.hasApiKey, + hasOAuthToken: auth.hasOAuthToken, + }; + } +} 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/src/routes/setup/index.ts b/apps/server/src/routes/setup/index.ts index 3fac6a20..30c2dbc9 100644 --- a/apps/server/src/routes/setup/index.ts +++ b/apps/server/src/routes/setup/index.ts @@ -17,6 +17,7 @@ import { createCursorStatusHandler } from './routes/cursor-status.js'; import { createCodexStatusHandler } from './routes/codex-status.js'; import { createInstallCodexHandler } from './routes/install-codex.js'; import { createAuthCodexHandler } from './routes/auth-codex.js'; +import { createOpencodeStatusHandler } from './routes/opencode-status.js'; import { createGetCursorConfigHandler, createSetCursorDefaultModelHandler, @@ -49,6 +50,9 @@ export function createSetupRoutes(): Router { router.get('/codex-status', createCodexStatusHandler()); router.post('/install-codex', createInstallCodexHandler()); router.post('/auth-codex', createAuthCodexHandler()); + + // OpenCode CLI routes + router.get('/opencode-status', createOpencodeStatusHandler()); router.get('/cursor-config', createGetCursorConfigHandler()); router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler()); router.post('/cursor-config/models', createSetCursorModelsHandler()); diff --git a/apps/server/src/routes/setup/routes/opencode-status.ts b/apps/server/src/routes/setup/routes/opencode-status.ts new file mode 100644 index 00000000..f474cfb1 --- /dev/null +++ b/apps/server/src/routes/setup/routes/opencode-status.ts @@ -0,0 +1,59 @@ +/** + * GET /opencode-status endpoint - Get OpenCode CLI installation and auth status + */ + +import type { Request, Response } from 'express'; +import { OpencodeProvider } from '../../../providers/opencode-provider.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Creates handler for GET /api/setup/opencode-status + * Returns OpenCode CLI installation and authentication status + */ +export function createOpencodeStatusHandler() { + const installCommand = 'curl -fsSL https://opencode.ai/install | bash'; + const loginCommand = 'opencode auth login'; + + return async (_req: Request, res: Response): Promise => { + try { + const provider = new OpencodeProvider(); + const status = await provider.detectInstallation(); + + // Derive auth method from authenticated status and API key presence + let authMethod = 'none'; + if (status.authenticated) { + authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated'; + } + + res.json({ + success: true, + installed: status.installed, + version: status.version || null, + path: status.path || null, + auth: { + authenticated: status.authenticated || false, + method: authMethod, + hasApiKey: status.hasApiKey || false, + hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY || !!process.env.OPENAI_API_KEY, + hasOAuthToken: status.hasOAuthToken || false, + }, + recommendation: status.installed + ? undefined + : 'Install OpenCode CLI to use multi-provider AI models.', + installCommand, + loginCommand, + installCommands: { + macos: installCommand, + linux: installCommand, + npm: 'npm install -g opencode-ai', + }, + }); + } catch (error) { + logError(error, 'Get OpenCode status failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} 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..b33217a8 --- /dev/null +++ b/apps/server/tests/unit/providers/opencode-provider.test.ts @@ -0,0 +1,1277 @@ +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, getOpenCodeAuthIndicators } 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(), + getOpenCodeAuthIndicators: vi.fn().mockResolvedValue({ + hasAuthFile: false, + hasOAuthToken: false, + hasApiKey: false, + }), +})); + +describe('opencode-provider.ts', () => { + let provider: OpencodeProvider; + + beforeEach(() => { + vi.clearAllMocks(); + resetToolUseIdCounter(); + provider = new OpencodeProvider(); + }); + + afterEach(() => { + // Note: Don't use vi.restoreAllMocks() here as it would undo the module-level + // mock implementations (like getOpenCodeAuthIndicators) set up with vi.mock() + }); + + // ========================================================================== + // 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', () => { + beforeEach(() => { + // Ensure the mock implementation is set up for each test + vi.mocked(getOpenCodeAuthIndicators).mockResolvedValue({ + hasAuthFile: false, + hasOAuthToken: false, + hasApiKey: false, + }); + }); + + 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 8a344777..5b717364 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', () => { @@ -160,9 +166,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', () => { @@ -199,7 +205,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 f70de966..e21e9ffc 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'; @@ -96,6 +97,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 }> @@ -103,6 +108,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/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index 5c93f4e2..92934722 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -9,11 +9,11 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; import { HotkeyButton } from '@/components/ui/hotkey-button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; import { CategoryAutocomplete } from '@/components/ui/category-autocomplete'; import { DescriptionImageDropZone, @@ -21,15 +21,10 @@ import { FeatureTextFilePath as DescriptionTextFilePath, ImagePreviewMap, } from '@/components/ui/description-image-dropzone'; -import { - MessageSquare, - Settings2, - SlidersHorizontal, - Sparkles, - ChevronDown, - Play, -} from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Sparkles, ChevronDown, ChevronRight, Play, Cpu, FolderKanban } from 'lucide-react'; import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; import { getElectronAPI } from '@/lib/electron'; import { modelSupportsThinking } from '@/lib/utils'; import { @@ -41,19 +36,22 @@ import { PlanningMode, Feature, } from '@/store/app-store'; -import type { ReasoningEffort } from '@automaker/types'; -import { codexModelHasThinking, supportsReasoningEffort } from '@automaker/types'; +import type { ReasoningEffort, PhaseModelEntry } from '@automaker/types'; +import { + supportsReasoningEffort, + PROVIDER_PREFIXES, + isCursorModel, + isClaudeModel, +} from '@automaker/types'; import { - ModelSelector, - ThinkingLevelSelector, - ReasoningEffortSelector, - ProfileQuickSelect, TestingTabContent, PrioritySelector, BranchSelector, - PlanningModeSelector, + PlanningModeSelect, AncestorContextSection, + ProfileTypeahead, } from '../shared'; +import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; import { ModelOverrideTrigger, useModelOverride } from '@/components/shared'; import { DropdownMenu, @@ -67,7 +65,6 @@ import { formatAncestorContextForPrompt, type AncestorContext, } from '@automaker/dependency-resolver'; -import { isCursorModel, PROVIDER_PREFIXES } from '@automaker/types'; const logger = createLogger('AddFeatureDialog'); @@ -82,7 +79,7 @@ type FeatureData = { model: AgentModel; thinkingLevel: ThinkingLevel; reasoningEffort: ReasoningEffort; - branchName: string; // Can be empty string to use current branch + branchName: string; priority: number; planningMode: PlanningMode; requirePlanApproval: boolean; @@ -96,14 +93,13 @@ interface AddFeatureDialogProps { onAddAndStart?: (feature: FeatureData) => void; categorySuggestions: string[]; branchSuggestions: string[]; - branchCardCounts?: Record; // Map of branch name to unarchived card count + branchCardCounts?: Record; defaultSkipTests: boolean; defaultBranch?: string; currentBranch?: string; isMaximized: boolean; showProfilesOnly: boolean; aiProfiles: AIProfile[]; - // Spawn task mode props parentFeature?: Feature | null; allFeatures?: Feature[]; } @@ -128,37 +124,43 @@ export function AddFeatureDialog({ const isSpawnMode = !!parentFeature; const navigate = useNavigate(); const [useCurrentBranch, setUseCurrentBranch] = useState(true); - const [newFeature, setNewFeature] = useState({ - title: '', - category: '', - description: '', - images: [] as FeatureImage[], - imagePaths: [] as DescriptionImagePath[], - textFilePaths: [] as DescriptionTextFilePath[], - skipTests: false, - model: 'opus' as ModelAlias, - thinkingLevel: 'none' as ThinkingLevel, - reasoningEffort: 'none' as ReasoningEffort, - branchName: '', - priority: 2 as number, // Default to medium priority - }); - const [newFeaturePreviewMap, setNewFeaturePreviewMap] = useState( - () => new Map() - ); - const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); + + // Form state + const [title, setTitle] = useState(''); + const [category, setCategory] = useState(''); + const [description, setDescription] = useState(''); + const [images, setImages] = useState([]); + const [imagePaths, setImagePaths] = useState([]); + const [textFilePaths, setTextFilePaths] = useState([]); + const [skipTests, setSkipTests] = useState(false); + const [branchName, setBranchName] = useState(''); + const [priority, setPriority] = useState(2); + + // Model selection state + const [selectedProfileId, setSelectedProfileId] = useState(); + const [modelEntry, setModelEntry] = useState({ model: 'opus' }); + + // Check if current model supports planning mode (Claude/Anthropic only) + const modelSupportsPlanningMode = isClaudeModel(modelEntry.model); + + // Planning mode state + const [planningMode, setPlanningMode] = useState('skip'); + const [requirePlanApproval, setRequirePlanApproval] = useState(false); + + // UI state + const [previewMap, setPreviewMap] = useState(() => new Map()); const [descriptionError, setDescriptionError] = useState(false); const [isEnhancing, setIsEnhancing] = useState(false); const [enhancementMode, setEnhancementMode] = useState< 'improve' | 'technical' | 'simplify' | 'acceptance' >('improve'); - const [planningMode, setPlanningMode] = useState('skip'); - const [requirePlanApproval, setRequirePlanApproval] = useState(false); + const [enhanceOpen, setEnhanceOpen] = useState(false); // Spawn mode state const [ancestors, setAncestors] = useState([]); const [selectedAncestorIds, setSelectedAncestorIds] = useState>(new Set()); - // Get planning mode defaults and worktrees setting from store + // Get defaults from store const { defaultPlanningMode, defaultRequirePlanApproval, defaultAIProfileId, useWorktrees } = useAppStore(); @@ -168,28 +170,29 @@ export function AddFeatureDialog({ // Sync defaults when dialog opens useEffect(() => { if (open) { - // Find the default profile if one is set const defaultProfile = defaultAIProfileId ? aiProfiles.find((p) => p.id === defaultAIProfileId) : null; - setNewFeature((prev) => ({ - ...prev, - skipTests: defaultSkipTests, - branchName: defaultBranch || '', - // Use default profile's model/thinkingLevel if set, else fallback to defaults - model: defaultProfile?.model ?? 'opus', - thinkingLevel: defaultProfile?.thinkingLevel ?? 'none', - })); + setSkipTests(defaultSkipTests); + setBranchName(defaultBranch || ''); setUseCurrentBranch(true); setPlanningMode(defaultPlanningMode); setRequirePlanApproval(defaultRequirePlanApproval); + // Set model from default profile or fallback + if (defaultProfile) { + setSelectedProfileId(defaultProfile.id); + applyProfileToModel(defaultProfile); + } else { + setSelectedProfileId(undefined); + setModelEntry({ model: 'opus' }); + } + // Initialize ancestors for spawn mode if (parentFeature) { const ancestorList = getAncestors(parentFeature, allFeatures); setAncestors(ancestorList); - // Only select parent by default - ancestors are optional context setSelectedAncestorIds(new Set([parentFeature.id])); } else { setAncestors([]); @@ -208,36 +211,62 @@ export function AddFeatureDialog({ allFeatures, ]); + const applyProfileToModel = (profile: AIProfile) => { + if (profile.provider === 'cursor') { + const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`; + setModelEntry({ model: cursorModel as ModelAlias }); + } else if (profile.provider === 'codex') { + setModelEntry({ + model: profile.codexModel || 'codex-gpt-5.2-codex', + reasoningEffort: 'none', + }); + } else if (profile.provider === 'opencode') { + setModelEntry({ model: profile.opencodeModel || 'opencode/big-pickle' }); + } else { + // Claude + setModelEntry({ + model: profile.model || 'sonnet', + thinkingLevel: profile.thinkingLevel || 'none', + }); + } + }; + + const handleProfileSelect = (profile: AIProfile) => { + setSelectedProfileId(profile.id); + applyProfileToModel(profile); + }; + + const handleModelChange = (entry: PhaseModelEntry) => { + setModelEntry(entry); + // Clear profile selection when manually changing model + setSelectedProfileId(undefined); + }; + const buildFeatureData = (): FeatureData | null => { - if (!newFeature.description.trim()) { + if (!description.trim()) { setDescriptionError(true); return null; } - // Validate branch selection when "other branch" is selected - if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) { + if (useWorktrees && !useCurrentBranch && !branchName.trim()) { toast.error('Please select a branch name'); return null; } - const category = newFeature.category || 'Uncategorized'; - const selectedModel = newFeature.model; + const finalCategory = category || 'Uncategorized'; + const selectedModel = modelEntry.model; const normalizedThinking = modelSupportsThinking(selectedModel) - ? newFeature.thinkingLevel + ? modelEntry.thinkingLevel || 'none' : 'none'; const normalizedReasoning = supportsReasoningEffort(selectedModel) - ? newFeature.reasoningEffort + ? modelEntry.reasoningEffort || 'none' : 'none'; - // Use current branch if toggle is on - // If currentBranch is provided (non-primary worktree), use it - // Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary) - const finalBranchName = useCurrentBranch ? currentBranch || '' : newFeature.branchName || ''; + const finalBranchName = useCurrentBranch ? currentBranch || '' : branchName || ''; - // Build final description - prepend ancestor context in spawn mode - let finalDescription = newFeature.description; + // Build final description with ancestor context in spawn mode + let finalDescription = description; if (isSpawnMode && parentFeature && selectedAncestorIds.size > 0) { - // Create parent context as an AncestorContext const parentContext: AncestorContext = { id: parentFeature.id, title: parentFeature.title, @@ -254,93 +283,84 @@ export function AddFeatureDialog({ ); if (contextText) { - finalDescription = `${contextText}\n\n---\n\n## Task Description\n\n${newFeature.description}`; + finalDescription = `${contextText}\n\n---\n\n## Task Description\n\n${description}`; } } return { - title: newFeature.title, - category, + title, + category: finalCategory, description: finalDescription, - images: newFeature.images, - imagePaths: newFeature.imagePaths, - textFilePaths: newFeature.textFilePaths, - skipTests: newFeature.skipTests, + images, + imagePaths, + textFilePaths, + skipTests, model: selectedModel, thinkingLevel: normalizedThinking, reasoningEffort: normalizedReasoning, branchName: finalBranchName, - priority: newFeature.priority, + priority, planningMode, requirePlanApproval, - // In spawn mode, automatically add parent as dependency dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined, }; }; const resetForm = () => { - setNewFeature({ - title: '', - category: '', - description: '', - images: [], - imagePaths: [], - textFilePaths: [], - skipTests: defaultSkipTests, - model: 'opus', - priority: 2, - thinkingLevel: 'none', - reasoningEffort: 'none', - branchName: '', - }); + setTitle(''); + setCategory(''); + setDescription(''); + setImages([]); + setImagePaths([]); + setTextFilePaths([]); + setSkipTests(defaultSkipTests); + setBranchName(''); + setPriority(2); + setSelectedProfileId(undefined); + setModelEntry({ model: 'opus' }); setUseCurrentBranch(true); setPlanningMode(defaultPlanningMode); setRequirePlanApproval(defaultRequirePlanApproval); - setNewFeaturePreviewMap(new Map()); - setShowAdvancedOptions(false); + setPreviewMap(new Map()); setDescriptionError(false); + setEnhanceOpen(false); onOpenChange(false); }; const handleAction = (actionFn?: (data: FeatureData) => void) => { if (!actionFn) return; - const featureData = buildFeatureData(); if (!featureData) return; - actionFn(featureData); resetForm(); }; const handleAdd = () => handleAction(onAdd); - const handleAddAndStart = () => handleAction(onAddAndStart); const handleDialogClose = (open: boolean) => { onOpenChange(open); if (!open) { - setNewFeaturePreviewMap(new Map()); - setShowAdvancedOptions(false); + setPreviewMap(new Map()); setDescriptionError(false); } }; const handleEnhanceDescription = async () => { - if (!newFeature.description.trim() || isEnhancing) return; + if (!description.trim() || isEnhancing) return; setIsEnhancing(true); try { const api = getElectronAPI(); const result = await api.enhancePrompt?.enhance( - newFeature.description, + description, enhancementMode, - enhancementOverride.effectiveModel, // API accepts string, extract from PhaseModelEntry - enhancementOverride.effectiveModelEntry.thinkingLevel // Pass thinking level + enhancementOverride.effectiveModel, + enhancementOverride.effectiveModelEntry.thinkingLevel ); if (result?.success && result.enhancedText) { - const enhancedText = result.enhancedText; - setNewFeature((prev) => ({ ...prev, description: enhancedText })); + setDescription(result.enhancedText); toast.success('Description enhanced!'); } else { toast.error(result?.error || 'Failed to enhance description'); @@ -353,59 +373,9 @@ export function AddFeatureDialog({ } }; - const handleModelSelect = (model: string) => { - // For Cursor models, thinking is handled by the model itself - // For Claude models, check if it supports extended thinking - const isCursor = isCursorModel(model); - setNewFeature({ - ...newFeature, - model: model as ModelAlias, - thinkingLevel: isCursor - ? 'none' - : modelSupportsThinking(model) - ? newFeature.thinkingLevel - : 'none', - }); - }; - - const handleProfileSelect = (profile: AIProfile) => { - if (profile.provider === 'cursor') { - // Cursor profile - set cursor model - const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`; - setNewFeature({ - ...newFeature, - model: cursorModel as ModelAlias, - thinkingLevel: 'none', // Cursor handles thinking internally - }); - } else { - // Claude profile - ensure model is always set from profile - const profileModel = profile.model; - if (!profileModel || !['haiku', 'sonnet', 'opus'].includes(profileModel)) { - console.warn( - `[ProfileSelect] Invalid or missing model "${profileModel}" for profile "${profile.name}", defaulting to sonnet` - ); - } - setNewFeature({ - ...newFeature, - model: - profileModel && ['haiku', 'sonnet', 'opus'].includes(profileModel) - ? profileModel - : 'sonnet', - thinkingLevel: - profile.thinkingLevel && profile.thinkingLevel !== 'none' - ? profile.thinkingLevel - : 'none', - }); - } - }; - - // Cursor models handle thinking internally, so only show thinking selector for Claude models - const isCurrentModelCursor = isCursorModel(newFeature.model); - const newModelAllowsThinking = - !isCurrentModelCursor && modelSupportsThinking(newFeature.model || 'sonnet'); - - // Codex models that support reasoning effort - show reasoning selector - const newModelAllowsReasoning = supportsReasoningEffort(newFeature.model || ''); + // Shared card styling + const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3'; + const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground'; return ( @@ -433,239 +403,264 @@ export function AddFeatureDialog({ : 'Create a new feature card for the Kanban board.'} - - - - - Prompt - - - - Model - - - - Options - - - {/* Prompt Tab */} - - {/* Ancestor Context Section - only in spawn mode */} - {isSpawnMode && parentFeature && ( - - )} +
+ {/* Ancestor Context Section - only in spawn mode */} + {isSpawnMode && parentFeature && ( + + )} + {/* Task Details Section */} +
{ - setNewFeature({ ...newFeature, description: value }); - if (value.trim()) { - setDescriptionError(false); - } + setDescription(value); + if (value.trim()) setDescriptionError(false); }} - images={newFeature.imagePaths} - onImagesChange={(images) => setNewFeature({ ...newFeature, imagePaths: images })} - textFiles={newFeature.textFilePaths} - onTextFilesChange={(textFiles) => - setNewFeature({ ...newFeature, textFilePaths: textFiles }) - } + images={imagePaths} + onImagesChange={setImagePaths} + textFiles={textFilePaths} + onTextFilesChange={setTextFilePaths} placeholder="Describe the feature..." - previewMap={newFeaturePreviewMap} - onPreviewMapChange={setNewFeaturePreviewMap} + previewMap={previewMap} + onPreviewMapChange={setPreviewMap} autoFocus error={descriptionError} />
+
setNewFeature({ ...newFeature, title: e.target.value })} + value={title} + onChange={(e) => setTitle(e.target.value)} placeholder="Leave blank to auto-generate" />
-
- - - + + +
+ + + + + + setEnhancementMode('improve')}> + Improve Clarity + + setEnhancementMode('technical')}> + Add Technical Details + + setEnhancementMode('simplify')}> + Simplify + + setEnhancementMode('acceptance')}> + Add Acceptance Criteria + + + + + - - - setEnhancementMode('improve')}> - Improve Clarity - - setEnhancementMode('technical')}> - Add Technical Details - - setEnhancementMode('simplify')}> - Simplify - - setEnhancementMode('acceptance')}> - Add Acceptance Criteria - - - - - - -
-
- - setNewFeature({ ...newFeature, category: value })} - suggestions={categorySuggestions} - placeholder="e.g., Core, UI, API" - data-testid="feature-category-input" - /> -
- {useWorktrees && ( - setNewFeature({ ...newFeature, branchName: value })} - branchSuggestions={branchSuggestions} - branchCardCounts={branchCardCounts} - currentBranch={currentBranch} - testIdPrefix="feature" - /> - )} - - {/* Priority Selector */} - setNewFeature({ ...newFeature, priority })} - testIdPrefix="priority" - /> - - - {/* Model Tab */} - - {/* Show Advanced Options Toggle */} - {showProfilesOnly && ( -
-
-

Simple Mode Active

-

- Only showing AI profiles. Advanced model tweaking is hidden. -

+
- + + +
+ + {/* AI & Execution Section */} +
+
+ + AI & Execution +
+ +
+
+ + { + onOpenChange(false); + navigate({ to: '/profiles' }); + }} + testIdPrefix="add-feature-profile" + /> +
+
+ + +
+
+ +
+ {modelSupportsPlanningMode && ( +
+ + +
+ )} +
+ +
+
+ setSkipTests(!checked)} + data-testid="add-feature-skip-tests-checkbox" + /> + +
+ {modelSupportsPlanningMode && ( +
+ setRequirePlanApproval(!!checked)} + disabled={planningMode === 'skip' || planningMode === 'lite'} + data-testid="add-feature-require-approval-checkbox" + /> + +
+ )} +
+
+
+
+ + {/* Organization Section */} +
+
+ + Organization +
+ +
+
+ + +
+
+ + +
+
+ + {/* Branch Selector */} + {useWorktrees && ( +
+
)} +
+
- {/* Quick Select Profile Section */} - { - onOpenChange(false); - navigate({ to: '/profiles' }); - }} - /> - - {/* Separator */} - {aiProfiles.length > 0 && (!showProfilesOnly || showAdvancedOptions) && ( -
- )} - - {/* Claude Models Section */} - {(!showProfilesOnly || showAdvancedOptions) && ( - <> - - {newModelAllowsThinking && ( - - setNewFeature({ ...newFeature, thinkingLevel: level }) - } - /> - )} - {newModelAllowsReasoning && ( - - setNewFeature({ ...newFeature, reasoningEffort: effort }) - } - /> - )} - - )} - - - {/* Options Tab */} - - {/* Planning Mode Section */} - - -
- - {/* Testing Section */} - setNewFeature({ ...newFeature, skipTests })} - /> - - + + +
+

Version History

+

+ Click a version to restore it +

+
+
+ {[...(feature.descriptionHistory || [])] + .reverse() + .map((entry: DescriptionHistoryEntry, index: number) => { + const isCurrentVersion = + entry.description === editingFeature.description; + const date = new Date(entry.timestamp); + const formattedDate = date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + const sourceLabel = + entry.source === 'initial' + ? 'Original' + : entry.source === 'enhance' + ? `Enhanced (${entry.enhancementMode || 'improve'})` + : 'Edited'; + + return ( + + ); + })} +
+
+ + )} +
{ @@ -402,6 +469,7 @@ export function EditFeatureDialog({ data-testid="edit-feature-description" />
+
-
- - - + + +
+ + + + + + setEnhancementMode('improve')}> + Improve Clarity + + setEnhancementMode('technical')}> + Add Technical Details + + setEnhancementMode('simplify')}> + Simplify + + setEnhancementMode('acceptance')}> + Add Acceptance Criteria + + + + + - - - setEnhancementMode('improve')}> - Improve Clarity - - setEnhancementMode('technical')}> - Add Technical Details - - setEnhancementMode('simplify')}> - Simplify - - setEnhancementMode('acceptance')}> - Add Acceptance Criteria - - - - - - - - {/* Version History Button */} - {feature?.descriptionHistory && feature.descriptionHistory.length > 0 && ( - - - - - -
-

Version History

-

- Click a version to restore it -

-
-
- {[...(feature.descriptionHistory || [])] - .reverse() - .map((entry: DescriptionHistoryEntry, index: number) => { - const isCurrentVersion = entry.description === editingFeature.description; - const date = new Date(entry.timestamp); - const formattedDate = date.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - const sourceLabel = - entry.source === 'initial' - ? 'Original' - : entry.source === 'enhance' - ? `Enhanced (${entry.enhancementMode || 'improve'})` - : 'Edited'; - - return ( - - ); - })} -
-
-
- )} -
-
- - - setEditingFeature({ - ...editingFeature, - category: value, - }) - } - suggestions={categorySuggestions} - placeholder="e.g., Core, UI, API" - data-testid="edit-feature-category" - /> -
- {useWorktrees && ( - - setEditingFeature({ - ...editingFeature, - branchName: value, - }) - } - branchSuggestions={branchSuggestions} - branchCardCounts={branchCardCounts} - currentBranch={currentBranch} - disabled={editingFeature.status !== 'backlog'} - testIdPrefix="edit-feature" - /> - )} - - {/* Priority Selector */} - - setEditingFeature({ - ...editingFeature, - priority, - }) - } - testIdPrefix="edit-priority" - /> - - - {/* Model Tab */} - - {/* Show Advanced Options Toggle */} - {showProfilesOnly && ( -
-
-

Simple Mode Active

-

- Only showing AI profiles. Advanced model tweaking is hidden. -

+
- + + +
+ + {/* AI & Execution Section */} +
+
+ + AI & Execution +
+ +
+
+ + { + onClose(); + navigate({ to: '/profiles' }); + }} + testIdPrefix="edit-feature-profile" + /> +
+
+ + +
+
+ +
+ {modelSupportsPlanningMode && ( +
+ + +
+ )} +
+ +
+
+ + setEditingFeature({ ...editingFeature, skipTests: !checked }) + } + data-testid="edit-feature-skip-tests-checkbox" + /> + +
+ {modelSupportsPlanningMode && ( +
+ setRequirePlanApproval(!!checked)} + disabled={planningMode === 'skip' || planningMode === 'lite'} + data-testid="edit-feature-require-approval-checkbox" + /> + +
+ )} +
+
+
+
+ + {/* Organization Section */} +
+
+ + Organization +
+ +
+
+ + + setEditingFeature({ + ...editingFeature, + category: value, + }) + } + suggestions={categorySuggestions} + placeholder="e.g., Core, UI, API" + data-testid="edit-feature-category" + /> +
+
+ + + setEditingFeature({ + ...editingFeature, + priority, + }) + } + testIdPrefix="edit-priority" + /> +
+
+ + {/* Branch Selector */} + {useWorktrees && ( +
+ + setEditingFeature({ + ...editingFeature, + branchName: value, + }) + } + branchSuggestions={branchSuggestions} + branchCardCounts={branchCardCounts} + currentBranch={currentBranch} + disabled={editingFeature.status !== 'backlog'} + testIdPrefix="edit-feature" + />
)} +
+
- {/* Quick Select Profile Section */} - - - {/* Separator */} - {aiProfiles.length > 0 && (!showProfilesOnly || showEditAdvancedOptions) && ( -
- )} - - {/* Claude Models Section */} - {(!showProfilesOnly || showEditAdvancedOptions) && ( - <> - - {editModelAllowsThinking && ( - - setEditingFeature({ - ...editingFeature, - thinkingLevel: level, - }) - } - testIdPrefix="edit-thinking-level" - /> - )} - {editModelAllowsReasoning && ( - - setEditingFeature({ - ...editingFeature, - reasoningEffort: effort, - }) - } - testIdPrefix="edit-reasoning-effort" - /> - )} - - )} - - - {/* Options Tab */} - - {/* Planning Mode Section */} - - -
- - {/* Testing Section */} - setEditingFeature({ ...editingFeature, skipTests })} - testIdPrefix="edit" - /> - - - - -
+
+ + +
); } diff --git a/apps/ui/src/components/views/board-view/shared/profile-typeahead.tsx b/apps/ui/src/components/views/board-view/shared/profile-typeahead.tsx new file mode 100644 index 00000000..4080676c --- /dev/null +++ b/apps/ui/src/components/views/board-view/shared/profile-typeahead.tsx @@ -0,0 +1,237 @@ +import * as React from 'react'; +import { Check, ChevronsUpDown, UserCircle, Settings2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Badge } from '@/components/ui/badge'; +import type { AIProfile } from '@automaker/types'; +import { CURSOR_MODEL_MAP, profileHasThinking, getCodexModelLabel } from '@automaker/types'; +import { PROVIDER_ICON_COMPONENTS } from '@/components/ui/provider-icon'; + +/** + * Get display string for a profile's model configuration + */ +function getProfileModelDisplay(profile: AIProfile): string { + if (profile.provider === 'cursor') { + const cursorModel = profile.cursorModel || 'auto'; + const modelConfig = CURSOR_MODEL_MAP[cursorModel]; + return modelConfig?.label || cursorModel; + } + if (profile.provider === 'codex') { + return getCodexModelLabel(profile.codexModel || 'codex-gpt-5.2-codex'); + } + if (profile.provider === 'opencode') { + // Extract a short label from the opencode model + const modelId = profile.opencodeModel || ''; + if (modelId.includes('/')) { + const parts = modelId.split('/'); + return parts[parts.length - 1].split('.')[0] || modelId; + } + return modelId; + } + // Claude + return profile.model || 'sonnet'; +} + +/** + * Get display string for a profile's thinking configuration + */ +function getProfileThinkingDisplay(profile: AIProfile): string | null { + if (profile.provider === 'cursor' || profile.provider === 'codex') { + return profileHasThinking(profile) ? 'thinking' : null; + } + // Claude + return profile.thinkingLevel && profile.thinkingLevel !== 'none' ? profile.thinkingLevel : null; +} + +interface ProfileTypeaheadProps { + profiles: AIProfile[]; + selectedProfileId?: string; + onSelect: (profile: AIProfile) => void; + placeholder?: string; + className?: string; + disabled?: boolean; + showManageLink?: boolean; + onManageLinkClick?: () => void; + testIdPrefix?: string; +} + +export function ProfileTypeahead({ + profiles, + selectedProfileId, + onSelect, + placeholder = 'Select profile...', + className, + disabled = false, + showManageLink = false, + onManageLinkClick, + testIdPrefix = 'profile-typeahead', +}: ProfileTypeaheadProps) { + const [open, setOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(''); + const [triggerWidth, setTriggerWidth] = React.useState(0); + const triggerRef = React.useRef(null); + + const selectedProfile = React.useMemo( + () => profiles.find((p) => p.id === selectedProfileId), + [profiles, selectedProfileId] + ); + + // Update trigger width when component mounts or value changes + React.useEffect(() => { + if (triggerRef.current) { + const updateWidth = () => { + setTriggerWidth(triggerRef.current?.offsetWidth || 0); + }; + updateWidth(); + const resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(triggerRef.current); + return () => { + resizeObserver.disconnect(); + }; + } + }, [selectedProfileId]); + + // Filter profiles based on input + const filteredProfiles = React.useMemo(() => { + if (!inputValue) return profiles; + const lower = inputValue.toLowerCase(); + return profiles.filter( + (p) => + p.name.toLowerCase().includes(lower) || + p.description?.toLowerCase().includes(lower) || + p.provider.toLowerCase().includes(lower) + ); + }, [profiles, inputValue]); + + const handleSelect = (profile: AIProfile) => { + onSelect(profile); + setInputValue(''); + setOpen(false); + }; + + return ( + + + + + + + + + No profile found. + + {filteredProfiles.map((profile) => { + const ProviderIcon = PROVIDER_ICON_COMPONENTS[profile.provider]; + const isSelected = profile.id === selectedProfileId; + const modelDisplay = getProfileModelDisplay(profile); + const thinkingDisplay = getProfileThinkingDisplay(profile); + + return ( + handleSelect(profile)} + className="flex items-center gap-2 py-2" + data-testid={`${testIdPrefix}-option-${profile.id}`} + > +
+ {ProviderIcon ? ( + + ) : ( + + )} +
+ {profile.name} + + {modelDisplay} + {thinkingDisplay && ( + + {thinkingDisplay} + )} + +
+
+
+ {profile.isBuiltIn && ( + + Built-in + + )} + +
+
+ ); + })} +
+ {showManageLink && onManageLinkClick && ( + <> + + + { + setOpen(false); + onManageLinkClick(); + }} + className="text-muted-foreground" + data-testid={`${testIdPrefix}-manage-link`} + > + + Manage AI Profiles + + + + )} +
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/profiles-view/components/profile-form.tsx b/apps/ui/src/components/views/profiles-view/components/profile-form.tsx index 5983a43f..1e7090d8 100644 --- a/apps/ui/src/components/views/profiles-view/components/profile-form.tsx +++ b/apps/ui/src/components/views/profiles-view/components/profile-form.tsx @@ -8,7 +8,7 @@ import { Badge } from '@/components/ui/badge'; import { cn, modelSupportsThinking } from '@/lib/utils'; import { DialogFooter } from '@/components/ui/dialog'; import { Brain } from 'lucide-react'; -import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon'; +import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon'; import { toast } from 'sonner'; import type { AIProfile, @@ -17,8 +17,15 @@ import type { ModelProvider, CursorModelId, CodexModelId, + OpencodeModelId, +} from '@automaker/types'; +import { + CURSOR_MODEL_MAP, + cursorModelHasThinking, + CODEX_MODEL_MAP, + OPENCODE_MODELS, + DEFAULT_OPENCODE_MODEL, } from '@automaker/types'; -import { CURSOR_MODEL_MAP, cursorModelHasThinking, CODEX_MODEL_MAP } from '@automaker/types'; import { useAppStore } from '@/store/app-store'; import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from '../constants'; @@ -50,6 +57,8 @@ export function ProfileForm({ cursorModel: profile.cursorModel || ('auto' as CursorModelId), // Codex-specific - use a valid CodexModelId from CODEX_MODEL_MAP codexModel: profile.codexModel || (CODEX_MODEL_MAP.gpt52Codex as CodexModelId), + // OpenCode-specific + opencodeModel: profile.opencodeModel || (DEFAULT_OPENCODE_MODEL as OpencodeModelId), icon: profile.icon || 'Brain', }); @@ -66,6 +75,8 @@ export function ProfileForm({ cursorModel: profile.cursorModel || ('auto' as CursorModelId), // Codex-specific - use a valid CodexModelId from CODEX_MODEL_MAP codexModel: profile.codexModel || (CODEX_MODEL_MAP.gpt52Codex as CodexModelId), + // OpenCode-specific + opencodeModel: profile.opencodeModel || (DEFAULT_OPENCODE_MODEL as OpencodeModelId), icon: profile.icon || 'Brain', }); }, [profile]); @@ -79,10 +90,14 @@ export function ProfileForm({ // Only reset Claude fields when switching TO Claude; preserve otherwise model: provider === 'claude' ? 'sonnet' : formData.model, thinkingLevel: provider === 'claude' ? 'none' : formData.thinkingLevel, - // Reset cursor/codex models when switching to that provider + // Reset cursor/codex/opencode models when switching to that provider cursorModel: provider === 'cursor' ? 'auto' : formData.cursorModel, codexModel: provider === 'codex' ? (CODEX_MODEL_MAP.gpt52Codex as CodexModelId) : formData.codexModel, + opencodeModel: + provider === 'opencode' + ? (DEFAULT_OPENCODE_MODEL as OpencodeModelId) + : formData.opencodeModel, }); }; @@ -107,6 +122,13 @@ export function ProfileForm({ }); }; + const handleOpencodeModelChange = (opencodeModel: OpencodeModelId) => { + setFormData({ + ...formData, + opencodeModel, + }); + }; + const handleSubmit = () => { if (!formData.name.trim()) { toast.error('Please enter a profile name'); @@ -140,6 +162,11 @@ export function ProfileForm({ ...baseProfile, codexModel: formData.codexModel, }); + } else if (formData.provider === 'opencode') { + onSave({ + ...baseProfile, + opencodeModel: formData.opencodeModel, + }); } else { onSave({ ...baseProfile, @@ -203,7 +230,7 @@ export function ProfileForm({ {/* Provider Selection */}
-
+
+
@@ -404,6 +445,61 @@ export function ProfileForm({
)} + {/* OpenCode Model Selection */} + {formData.provider === 'opencode' && ( +
+ +
+ {OPENCODE_MODELS.map((model) => ( + + ))} +
+
+ )} + {/* Claude Thinking Level */} {formData.provider === 'claude' && supportsThinking && (
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/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index e6b9c9ce..89387530 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -5,6 +5,7 @@ import type { ModelAlias, CursorModelId, CodexModelId, + OpencodeModelId, GroupedModel, PhaseModelEntry, ThinkingLevel, @@ -23,13 +24,14 @@ import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, + OPENCODE_MODELS, THINKING_LEVELS, THINKING_LEVEL_LABELS, REASONING_EFFORT_LEVELS, REASONING_EFFORT_LABELS, } from '@/components/views/board-view/shared/model-constants'; import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react'; -import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon'; +import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon'; import { Button } from '@/components/ui/button'; import { Command, @@ -199,6 +201,10 @@ export function PhaseModelSelector({ const codexModel = CODEX_MODELS.find((m) => m.id === selectedModel); if (codexModel) return { ...codexModel, icon: OpenAIIcon }; + // Check OpenCode models + const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel); + if (opencodeModel) return { ...opencodeModel, icon: OpenCodeIcon }; + return null; }, [selectedModel, selectedThinkingLevel, availableCursorModels]); @@ -236,11 +242,12 @@ export function PhaseModelSelector({ }, [availableCursorModels, enabledCursorModels]); // Group models - const { favorites, claude, cursor, codex } = React.useMemo(() => { + const { favorites, claude, cursor, codex, opencode } = React.useMemo(() => { const favs: typeof CLAUDE_MODELS = []; const cModels: typeof CLAUDE_MODELS = []; const curModels: typeof CURSOR_MODELS = []; const codModels: typeof CODEX_MODELS = []; + const ocModels: typeof OPENCODE_MODELS = []; // Process Claude Models CLAUDE_MODELS.forEach((model) => { @@ -269,7 +276,22 @@ export function PhaseModelSelector({ } }); - return { favorites: favs, claude: cModels, cursor: curModels, codex: codModels }; + // Process OpenCode Models + OPENCODE_MODELS.forEach((model) => { + if (favoriteModels.includes(model.id)) { + favs.push(model); + } else { + ocModels.push(model); + } + }); + + return { + favorites: favs, + claude: cModels, + cursor: curModels, + codex: codModels, + opencode: ocModels, + }; }, [favoriteModels, availableCursorModels]); // Render Codex model item with secondary popover for reasoning effort (only for models that support it) @@ -453,6 +475,64 @@ export function PhaseModelSelector({ ); }; + // Render OpenCode model item (simple selector, no thinking/reasoning options) + const renderOpencodeModelItem = (model: (typeof OPENCODE_MODELS)[0]) => { + const isSelected = selectedModel === model.id; + const isFavorite = favoriteModels.includes(model.id); + + return ( + { + onChange({ model: model.id as OpencodeModelId }); + setOpen(false); + }} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + {model.description} +
+
+ +
+ {model.badge && ( + + {model.badge} + + )} + + {isSelected && } +
+
+ ); + }; + // Render Cursor model item (no thinking level needed) const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => { const modelValue = stripProviderPrefix(model.id); @@ -835,6 +915,10 @@ export function PhaseModelSelector({ if (model.provider === 'codex') { return renderCodexModelItem(model); } + // OpenCode model + if (model.provider === 'opencode') { + return renderOpencodeModelItem(model); + } // Claude model return renderClaudeModelItem(model); }); @@ -864,6 +948,12 @@ export function PhaseModelSelector({ {codex.map((model) => renderCodexModelItem(model))} )} + + {opencode.length > 0 && ( + + {opencode.map((model) => renderOpencodeModelItem(model))} + + )} 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 ba640c94..a9d8c06e 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 { @@ -160,17 +158,6 @@ export function CodexModelConfiguration({ ); } -function getModelDisplayName(modelId: string): string { - const displayNames: Record = { - 'codex-gpt-5.2-codex': 'GPT-5.2-Codex', - 'codex-gpt-5.1-codex-max': 'GPT-5.1-Codex-Max', - 'codex-gpt-5.1-codex-mini': 'GPT-5.1-Codex-Mini', - 'codex-gpt-5.2': 'GPT-5.2', - 'codex-gpt-5.1': 'GPT-5.1', - }; - return displayNames[modelId] || modelId; -} - function supportsReasoningEffort(modelId: string): boolean { const reasoningModels = [ 'codex-gpt-5.2-codex', 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..f3e9d1dd 100644 --- a/apps/ui/src/components/views/setup-view.tsx +++ b/apps/ui/src/components/views/setup-view.tsx @@ -5,9 +5,7 @@ import { WelcomeStep, ThemeStep, CompleteStep, - ClaudeSetupStep, - CursorSetupStep, - CodexSetupStep, + ProvidersSetupStep, GitHubSetupStep, } from './setup-view/steps'; import { useNavigate } from '@tanstack/react-router'; @@ -16,20 +14,31 @@ const logger = createLogger('SetupView'); // Main Setup View export function SetupView() { - const { currentStep, setCurrentStep, completeSetup, setSkipClaudeSetup } = useSetupStore(); + const { currentStep, setCurrentStep, completeSetup } = useSetupStore(); const navigate = useNavigate(); - const steps = ['welcome', 'theme', 'claude', 'cursor', 'codex', 'github', 'complete'] as const; + // Simplified steps: welcome, theme, providers (combined), github, complete + const steps = ['welcome', 'theme', 'providers', 'github', 'complete'] as const; type StepName = (typeof steps)[number]; + const getStepName = (): StepName => { - if (currentStep === 'claude_detect' || currentStep === 'claude_auth') return 'claude'; + // Map old step names to new consolidated steps if (currentStep === 'welcome') return 'welcome'; if (currentStep === 'theme') return 'theme'; - if (currentStep === 'cursor') return 'cursor'; - if (currentStep === 'codex') return 'codex'; + if ( + currentStep === 'claude_detect' || + currentStep === 'claude_auth' || + currentStep === 'cursor' || + currentStep === 'codex' || + currentStep === 'opencode' || + currentStep === 'providers' + ) { + return 'providers'; + } if (currentStep === 'github') return 'github'; return 'complete'; }; + const currentIndex = steps.indexOf(getStepName()); const handleNext = (from: string) => { @@ -40,18 +49,10 @@ export function SetupView() { setCurrentStep('theme'); break; case 'theme': - logger.debug('[Setup Flow] Moving to claude_detect step'); - setCurrentStep('claude_detect'); + logger.debug('[Setup Flow] Moving to providers step'); + setCurrentStep('providers'); break; - case 'claude': - logger.debug('[Setup Flow] Moving to cursor step'); - setCurrentStep('cursor'); - break; - case 'cursor': - logger.debug('[Setup Flow] Moving to codex step'); - setCurrentStep('codex'); - break; - case 'codex': + case 'providers': logger.debug('[Setup Flow] Moving to github step'); setCurrentStep('github'); break; @@ -68,37 +69,15 @@ export function SetupView() { case 'theme': setCurrentStep('welcome'); break; - case 'claude': + case 'providers': setCurrentStep('theme'); break; - case 'cursor': - setCurrentStep('claude_detect'); - break; - case 'codex': - setCurrentStep('cursor'); - break; case 'github': - setCurrentStep('codex'); + setCurrentStep('providers'); break; } }; - const handleSkipClaude = () => { - logger.debug('[Setup Flow] Skipping Claude setup'); - setSkipClaudeSetup(true); - setCurrentStep('cursor'); - }; - - const handleSkipCursor = () => { - logger.debug('[Setup Flow] Skipping Cursor setup'); - setCurrentStep('codex'); - }; - - const handleSkipCodex = () => { - logger.debug('[Setup Flow] Skipping Codex setup'); - setCurrentStep('github'); - }; - const handleSkipGithub = () => { logger.debug('[Setup Flow] Skipping GitHub setup'); setCurrentStep('complete'); @@ -137,27 +116,15 @@ export function SetupView() { handleNext('theme')} onBack={() => handleBack('theme')} /> )} - {(currentStep === 'claude_detect' || currentStep === 'claude_auth') && ( - handleNext('claude')} - onBack={() => handleBack('claude')} - onSkip={handleSkipClaude} - /> - )} - - {currentStep === 'cursor' && ( - handleNext('cursor')} - onBack={() => handleBack('cursor')} - onSkip={handleSkipCursor} - /> - )} - - {currentStep === 'codex' && ( - handleNext('codex')} - onBack={() => handleBack('codex')} - onSkip={handleSkipCodex} + {(currentStep === 'providers' || + currentStep === 'claude_detect' || + currentStep === 'claude_auth' || + currentStep === 'cursor' || + currentStep === 'codex' || + currentStep === 'opencode') && ( + handleNext('providers')} + onBack={() => handleBack('providers')} /> )} diff --git a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx index 9637a081..8b56f49c 100644 --- a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx @@ -38,6 +38,11 @@ interface ClaudeSetupStepProps { onSkip: () => void; } +interface ClaudeSetupContentProps { + /** Hide header and navigation for embedded use */ + embedded?: boolean; +} + type VerificationStatus = 'idle' | 'verifying' | 'verified' | 'error'; // Claude Setup Step 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..f6497647 100644 --- a/apps/ui/src/components/views/setup-view/steps/index.ts +++ b/apps/ui/src/components/views/setup-view/steps/index.ts @@ -2,7 +2,11 @@ export { WelcomeStep } from './welcome-step'; export { ThemeStep } from './theme-step'; export { CompleteStep } from './complete-step'; +export { ProvidersSetupStep } from './providers-setup-step'; +export { GitHubSetupStep } from './github-setup-step'; + +// Legacy individual step exports (kept for backwards compatibility) export { ClaudeSetupStep } from './claude-setup-step'; export { CursorSetupStep } from './cursor-setup-step'; export { CodexSetupStep } from './codex-setup-step'; -export { GitHubSetupStep } from './github-setup-step'; +export { OpencodeSetupStep } from './opencode-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..afb40b6d --- /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 auth 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 auth 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/components/views/setup-view/steps/providers-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx new file mode 100644 index 00000000..ca2b1759 --- /dev/null +++ b/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx @@ -0,0 +1,1519 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; +import { useSetupStore } from '@/store/setup-store'; +import { useAppStore } from '@/store/app-store'; +import { getElectronAPI } from '@/lib/electron'; +import { + ArrowRight, + ArrowLeft, + CheckCircle2, + Loader2, + Key, + ExternalLink, + Copy, + RefreshCw, + Download, + XCircle, + Trash2, + AlertTriangle, + Terminal, + AlertCircle, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; +import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon'; +import { TerminalOutput } from '../components'; +import { useCliInstallation, useTokenSave } from '../hooks'; + +interface ProvidersSetupStepProps { + onNext: () => void; + onBack: () => void; +} + +type ProviderTab = 'claude' | 'cursor' | 'codex' | 'opencode'; + +// ============================================================================ +// Claude Content +// ============================================================================ +function ClaudeContent() { + const { + claudeCliStatus, + claudeAuthStatus, + setClaudeCliStatus, + setClaudeAuthStatus, + setClaudeInstallProgress, + setClaudeIsVerifying, + } = useSetupStore(); + const { setApiKeys, apiKeys } = useAppStore(); + + const [apiKey, setApiKey] = useState(''); + const [isChecking, setIsChecking] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + const [verificationError, setVerificationError] = useState(null); + const [isDeletingApiKey, setIsDeletingApiKey] = useState(false); + const hasVerifiedRef = useRef(false); + + const installApi = useCallback( + () => getElectronAPI().setup?.installClaude() || Promise.reject(), + [] + ); + const getStoreState = useCallback(() => useSetupStore.getState().claudeCliStatus, []); + + // Auto-verify CLI authentication + const verifyAuth = useCallback(async () => { + // Guard against duplicate verification + if (hasVerifiedRef.current) { + return; + } + + setIsVerifying(true); + setClaudeIsVerifying(true); // Update store for parent to see + setVerificationError(null); + try { + const api = getElectronAPI(); + if (!api.setup?.verifyClaudeAuth) { + return; + } + const result = await api.setup.verifyClaudeAuth('cli'); + const hasLimitReachedError = + result.error?.toLowerCase().includes('limit reached') || + result.error?.toLowerCase().includes('rate limit'); + + if (result.authenticated && !hasLimitReachedError) { + hasVerifiedRef.current = true; + // Use getState() to avoid dependency on claudeAuthStatus + const currentAuthStatus = useSetupStore.getState().claudeAuthStatus; + setClaudeAuthStatus({ + authenticated: true, + method: 'cli_authenticated', + hasCredentialsFile: currentAuthStatus?.hasCredentialsFile || false, + }); + toast.success('Claude CLI authenticated!'); + } else if (hasLimitReachedError) { + setVerificationError('Rate limit reached. Please try again later.'); + } else if (result.error) { + setVerificationError(result.error); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Verification failed'; + setVerificationError(errorMessage); + } finally { + setIsVerifying(false); + setClaudeIsVerifying(false); // Update store when done + } + }, [setClaudeAuthStatus, setClaudeIsVerifying]); + + // Check status and auto-verify + const checkStatus = useCallback(async () => { + setIsChecking(true); + setVerificationError(null); + // Reset verification guard to allow fresh verification (for manual refresh) + hasVerifiedRef.current = false; + try { + const api = getElectronAPI(); + if (!api.setup?.getClaudeStatus) return; + const result = await api.setup.getClaudeStatus(); + if (result.success) { + setClaudeCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.path, + method: 'none', + }); + + if (result.installed) { + toast.success('Claude CLI installed!'); + // Auto-verify if CLI is installed + setIsChecking(false); + await verifyAuth(); + return; + } + } + } catch { + // Ignore errors + } finally { + setIsChecking(false); + } + }, [setClaudeCliStatus, verifyAuth]); + + const onInstallSuccess = useCallback(() => { + hasVerifiedRef.current = false; + checkStatus(); + }, [checkStatus]); + + const { isInstalling, installProgress, install } = useCliInstallation({ + cliType: 'claude', + installApi, + onProgressEvent: getElectronAPI().setup?.onInstallProgress, + onSuccess: onInstallSuccess, + getStoreState, + }); + + const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave({ + provider: 'anthropic', + onSuccess: () => { + setClaudeAuthStatus({ + authenticated: true, + method: 'api_key', + hasCredentialsFile: false, + apiKeyValid: true, + }); + setApiKeys({ ...apiKeys, anthropic: apiKey }); + toast.success('API key saved successfully!'); + }, + }); + + const deleteApiKey = useCallback(async () => { + setIsDeletingApiKey(true); + try { + const api = getElectronAPI(); + if (!api.setup?.deleteApiKey) { + toast.error('Delete API not available'); + return; + } + const result = await api.setup.deleteApiKey('anthropic'); + if (result.success) { + setApiKey(''); + setApiKeys({ ...apiKeys, anthropic: '' }); + // Use getState() to avoid dependency on claudeAuthStatus + const currentAuthStatus = useSetupStore.getState().claudeAuthStatus; + setClaudeAuthStatus({ + authenticated: false, + method: 'none', + hasCredentialsFile: currentAuthStatus?.hasCredentialsFile || false, + }); + // Reset verification guard so next check can verify again + hasVerifiedRef.current = false; + toast.success('API key deleted successfully'); + } + } catch { + toast.error('Failed to delete API key'); + } finally { + setIsDeletingApiKey(false); + } + }, [apiKeys, setApiKeys, setClaudeAuthStatus]); + + useEffect(() => { + setClaudeInstallProgress({ isInstalling, output: installProgress.output }); + }, [isInstalling, installProgress, setClaudeInstallProgress]); + + useEffect(() => { + checkStatus(); + }, [checkStatus]); + + const copyCommand = (command: string) => { + navigator.clipboard.writeText(command); + toast.success('Command copied to clipboard'); + }; + + const hasApiKey = + !!apiKeys.anthropic || + claudeAuthStatus?.method === 'api_key' || + claudeAuthStatus?.method === 'api_key_env'; + + const isCliAuthenticated = claudeAuthStatus?.method === 'cli_authenticated'; + const isApiKeyAuthenticated = + claudeAuthStatus?.method === 'api_key' || claudeAuthStatus?.method === 'api_key_env'; + const isReady = claudeCliStatus?.installed && claudeAuthStatus?.authenticated; + + return ( + + +
+ + + Claude CLI Status + + +
+ + {claudeCliStatus?.installed + ? claudeAuthStatus?.authenticated + ? `Authenticated${claudeCliStatus.version ? ` (v${claudeCliStatus.version})` : ''}` + : isVerifying + ? 'Verifying authentication...' + : 'Installed but not authenticated' + : 'Not installed on your system'} + +
+ + {/* Success State - CLI Ready */} + {isReady && ( +
+
+ +
+

CLI Installed

+

+ {claudeCliStatus?.version && `Version: ${claudeCliStatus.version}`} +

+
+
+
+ +

+ {isCliAuthenticated ? 'CLI Authenticated' : 'API Key Configured'} +

+
+
+ )} + + {/* Checking/Verifying State */} + {(isChecking || isVerifying) && ( +
+ +

+ {isChecking ? 'Checking Claude CLI status...' : 'Verifying authentication...'} +

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

Claude CLI not found

+

+ Install Claude CLI to use Claude Code subscription. +

+
+
+
+

Install Claude CLI:

+
+ +
+ + curl -fsSL https://claude.ai/install.sh | bash + + +
+
+ {isInstalling && } + +
+
+ )} + + {/* Installed but not authenticated */} + {claudeCliStatus?.installed && + !claudeAuthStatus?.authenticated && + !isChecking && + !isVerifying && ( +
+ {/* Show CLI installed toast */} +
+ +
+

CLI Installed

+

+ {claudeCliStatus?.version && `Version: ${claudeCliStatus.version}`} +

+
+
+ + {/* Error state */} + {verificationError && ( +
+ +
+

Authentication failed

+

{verificationError}

+
+
+ )} + + {/* Not authenticated warning */} +
+ +
+

Claude CLI not authenticated

+

+ Run claude login in your terminal + or provide an API key below. +

+
+
+ + {/* API Key alternative */} + + + +
+ + Use Anthropic API Key instead +
+
+ +
+ + setApiKey(e.target.value)} + className="bg-input border-border text-foreground" + /> +

+ Don't have an API key?{' '} + + Get one from Anthropic Console + + +

+
+
+ + {hasApiKey && ( + + )} +
+
+
+
+
+ )} +
+
+ ); +} + +// ============================================================================ +// Cursor Content +// ============================================================================ +function CursorContent() { + const { cursorCliStatus, setCursorCliStatus } = 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?.getCursorStatus) return; + const result = await api.setup.getCursorStatus(); + if (result.success) { + setCursorCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.path, + auth: result.auth, + installCommand: result.installCommand, + loginCommand: result.loginCommand, + }); + if (result.auth?.authenticated) { + toast.success('Cursor CLI is ready!'); + } + } + } catch { + // Ignore errors + } finally { + setIsChecking(false); + } + }, [setCursorCliStatus]); + + useEffect(() => { + checkStatus(); + 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 { + const loginCommand = cursorCliStatus?.loginCommand || 'cursor-agent login'; + await navigator.clipboard.writeText(loginCommand); + toast.info('Login command copied! Paste in terminal to authenticate.'); + + let attempts = 0; + pollIntervalRef.current = setInterval(async () => { + attempts++; + try { + const api = getElectronAPI(); + if (!api.setup?.getCursorStatus) return; + const result = await api.setup.getCursorStatus(); + if (result.auth?.authenticated) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setCursorCliStatus({ + ...cursorCliStatus, + installed: result.installed ?? true, + version: result.version, + path: result.path, + auth: result.auth, + }); + setIsLoggingIn(false); + toast.success('Successfully logged in to Cursor!'); + } + } catch { + // Ignore + } + if (attempts >= 60) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setIsLoggingIn(false); + toast.error('Login timed out. Please try again.'); + } + }, 2000); + } catch { + toast.error('Failed to start login process'); + setIsLoggingIn(false); + } + }; + + const isReady = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated; + + return ( + + +
+ + + Cursor CLI Status + + +
+ + {cursorCliStatus?.installed + ? cursorCliStatus.auth?.authenticated + ? `Authenticated${cursorCliStatus.version ? ` (v${cursorCliStatus.version})` : ''}` + : 'Installed but not authenticated' + : 'Not installed on your system'} + +
+ + {isReady && ( +
+
+ +
+

CLI Installed

+

+ {cursorCliStatus?.version && `Version: ${cursorCliStatus.version}`} +

+
+
+
+ +

Authenticated

+
+
+ )} + + {!cursorCliStatus?.installed && !isChecking && ( +
+
+ +
+

Cursor CLI not found

+

+ Install Cursor IDE to use Cursor AI agent. +

+
+
+
+

Install Cursor:

+
+ + {cursorCliStatus?.installCommand || 'npm install -g @anthropic/cursor-agent'} + + +
+
+
+ )} + + {cursorCliStatus?.installed && !cursorCliStatus?.auth?.authenticated && !isChecking && ( +
+ {/* Show CLI installed toast */} +
+ +
+

CLI Installed

+

+ {cursorCliStatus?.version && `Version: ${cursorCliStatus.version}`} +

+
+
+ +
+ +
+

Cursor CLI not authenticated

+

+ Run the login command to authenticate. +

+
+
+
+
+ + {cursorCliStatus?.loginCommand || 'cursor-agent login'} + + +
+ +
+
+ )} + + {isChecking && ( +
+ +

Checking Cursor CLI status...

+
+ )} +
+
+ ); +} + +// ============================================================================ +// Codex Content +// ============================================================================ +function CodexContent() { + const { codexCliStatus, codexAuthStatus, setCodexCliStatus, setCodexAuthStatus } = + useSetupStore(); + const { setApiKeys, apiKeys } = useAppStore(); + const [isChecking, setIsChecking] = useState(false); + const [apiKey, setApiKey] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const pollIntervalRef = useRef(null); + + const checkStatus = useCallback(async () => { + setIsChecking(true); + try { + const api = getElectronAPI(); + if (!api.setup?.getCodexStatus) return; + const result = await api.setup.getCodexStatus(); + if (result.success) { + setCodexCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.path, + method: 'none', + }); + if (result.auth?.authenticated) { + setCodexAuthStatus({ + authenticated: true, + method: result.auth.method || 'cli_authenticated', + }); + toast.success('Codex CLI is ready!'); + } + } + } catch { + // Ignore + } finally { + setIsChecking(false); + } + }, [setCodexCliStatus, setCodexAuthStatus]); + + useEffect(() => { + checkStatus(); + return () => { + if (pollIntervalRef.current) clearInterval(pollIntervalRef.current); + }; + }, [checkStatus]); + + const copyCommand = (command: string) => { + navigator.clipboard.writeText(command); + toast.success('Command copied to clipboard'); + }; + + const handleSaveApiKey = async () => { + if (!apiKey.trim()) return; + setIsSaving(true); + try { + const api = getElectronAPI(); + if (!api.setup?.saveApiKey) { + toast.error('Save API not available'); + return; + } + const result = await api.setup.saveApiKey('openai', apiKey); + if (result.success) { + setApiKeys({ ...apiKeys, openai: apiKey }); + setCodexAuthStatus({ authenticated: true, method: 'api_key' }); + toast.success('API key saved successfully!'); + } + } catch { + toast.error('Failed to save API key'); + } finally { + setIsSaving(false); + } + }; + + const handleLogin = async () => { + setIsLoggingIn(true); + try { + await navigator.clipboard.writeText('codex login'); + toast.info('Login command copied! Paste in terminal to authenticate.'); + + let attempts = 0; + pollIntervalRef.current = setInterval(async () => { + attempts++; + try { + const api = getElectronAPI(); + if (!api.setup?.getCodexStatus) return; + const result = await api.setup.getCodexStatus(); + if (result.auth?.authenticated) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setCodexAuthStatus({ authenticated: true, method: 'cli_authenticated' }); + setIsLoggingIn(false); + toast.success('Successfully logged in to Codex!'); + } + } catch { + // Ignore + } + if (attempts >= 60) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setIsLoggingIn(false); + toast.error('Login timed out. Please try again.'); + } + }, 2000); + } catch { + toast.error('Failed to start login process'); + setIsLoggingIn(false); + } + }; + + const isReady = codexCliStatus?.installed && codexAuthStatus?.authenticated; + const hasApiKey = !!apiKeys.openai || codexAuthStatus?.method === 'api_key'; + + return ( + + +
+ + + Codex CLI Status + + +
+ + {codexCliStatus?.installed + ? codexAuthStatus?.authenticated + ? `Authenticated${codexCliStatus.version ? ` (v${codexCliStatus.version})` : ''}` + : 'Installed but not authenticated' + : 'Not installed on your system'} + +
+ + {isReady && ( +
+
+ +
+

CLI Installed

+

+ {codexCliStatus?.version && `Version: ${codexCliStatus.version}`} +

+
+
+
+ +

+ {codexAuthStatus?.method === 'api_key' ? 'API Key Configured' : 'Authenticated'} +

+
+
+ )} + + {!codexCliStatus?.installed && !isChecking && ( +
+
+ +
+

Codex CLI not found

+

+ Install the Codex CLI to use OpenAI models. +

+
+
+
+

Install Codex CLI:

+
+ + npm install -g @openai/codex + + +
+
+
+ )} + + {codexCliStatus?.installed && !codexAuthStatus?.authenticated && !isChecking && ( +
+ {/* Show CLI installed toast */} +
+ +
+

CLI Installed

+

+ {codexCliStatus?.version && `Version: ${codexCliStatus.version}`} +

+
+
+ +
+ +
+

Codex CLI not authenticated

+

+ Run the login command or provide an API key below. +

+
+
+ + + + +
+ + Codex CLI Login +
+
+ +
+ + codex login + + +
+ +
+
+ + + +
+ + OpenAI API Key +
+
+ +
+ setApiKey(e.target.value)} + className="bg-input border-border text-foreground" + /> +

+ + Get an API key from OpenAI + + +

+
+ +
+
+
+
+ )} + + {isChecking && ( +
+ +

Checking Codex CLI status...

+
+ )} +
+
+ ); +} + +// ============================================================================ +// OpenCode Content +// ============================================================================ +function OpencodeContent() { + 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) { + setOpencodeCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.path, + auth: result.auth, + installCommand: result.installCommand, + loginCommand: result.loginCommand, + }); + if (result.auth?.authenticated) { + toast.success('OpenCode CLI is ready!'); + } + } + } catch { + // Ignore + } finally { + setIsChecking(false); + } + }, [setOpencodeCliStatus]); + + useEffect(() => { + checkStatus(); + 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 { + const loginCommand = opencodeCliStatus?.loginCommand || 'opencode auth login'; + await navigator.clipboard.writeText(loginCommand); + toast.info('Login command copied! Paste in terminal to authenticate.'); + + let attempts = 0; + 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, + }); + setIsLoggingIn(false); + toast.success('Successfully logged in to OpenCode!'); + } + } catch { + // Ignore + } + if (attempts >= 60) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setIsLoggingIn(false); + toast.error('Login timed out. Please try again.'); + } + }, 2000); + } catch { + toast.error('Failed to start login process'); + setIsLoggingIn(false); + } + }; + + const isReady = opencodeCliStatus?.installed && opencodeCliStatus?.auth?.authenticated; + + return ( + + +
+ + + OpenCode CLI Status + + +
+ + {opencodeCliStatus?.installed + ? opencodeCliStatus.auth?.authenticated + ? `Authenticated${opencodeCliStatus.version ? ` (v${opencodeCliStatus.version})` : ''}` + : 'Installed but not authenticated' + : 'Not installed on your system'} + +
+ + {isReady && ( +
+
+ +
+

CLI Installed

+

+ {opencodeCliStatus?.version && `Version: ${opencodeCliStatus.version}`} +

+
+
+
+ +

Authenticated

+
+
+ )} + + {!opencodeCliStatus?.installed && !isChecking && ( +
+
+ +
+

OpenCode CLI not found

+

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

+
+
+
+

Install OpenCode CLI:

+
+ + {opencodeCliStatus?.installCommand || + 'curl -fsSL https://opencode.ai/install | bash'} + + +
+
+
+ )} + + {opencodeCliStatus?.installed && !opencodeCliStatus?.auth?.authenticated && !isChecking && ( +
+ {/* Show CLI installed toast */} +
+ +
+

CLI Installed

+

+ {opencodeCliStatus?.version && `Version: ${opencodeCliStatus.version}`} +

+
+
+ +
+ +
+

OpenCode CLI not authenticated

+

+ Run the login command to authenticate. +

+
+
+
+
+ + {opencodeCliStatus?.loginCommand || 'opencode auth login'} + + +
+ +
+
+ )} + + {isChecking && ( +
+ +

Checking OpenCode CLI status...

+
+ )} +
+
+ ); +} + +// ============================================================================ +// Main Component +// ============================================================================ +export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) { + const [activeTab, setActiveTab] = useState('claude'); + const [isInitialChecking, setIsInitialChecking] = useState(true); + const hasCheckedRef = useRef(false); + + const { + claudeCliStatus, + claudeAuthStatus, + claudeIsVerifying, + cursorCliStatus, + codexCliStatus, + codexAuthStatus, + opencodeCliStatus, + setClaudeCliStatus, + setCursorCliStatus, + setCodexCliStatus, + setCodexAuthStatus, + setOpencodeCliStatus, + } = useSetupStore(); + + // Check all providers on mount + const checkAllProviders = useCallback(async () => { + const api = getElectronAPI(); + + // Check Claude - only check CLI status, let ClaudeContent handle auth verification + const checkClaude = async () => { + try { + if (!api.setup?.getClaudeStatus) return; + const result = await api.setup.getClaudeStatus(); + if (result.success) { + setClaudeCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.path, + method: 'none', + }); + // Note: Auth verification is handled by ClaudeContent component to avoid duplicate calls + } + } catch { + // Ignore errors + } + }; + + // Check Cursor + const checkCursor = async () => { + try { + if (!api.setup?.getCursorStatus) return; + const result = await api.setup.getCursorStatus(); + if (result.success) { + setCursorCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.path, + auth: result.auth, + installCommand: result.installCommand, + loginCommand: result.loginCommand, + }); + } + } catch { + // Ignore errors + } + }; + + // Check Codex + const checkCodex = async () => { + try { + if (!api.setup?.getCodexStatus) return; + const result = await api.setup.getCodexStatus(); + if (result.success) { + setCodexCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.path, + method: 'none', + }); + if (result.auth?.authenticated) { + setCodexAuthStatus({ + authenticated: true, + method: result.auth.method || 'cli_authenticated', + }); + } + } + } catch { + // Ignore errors + } + }; + + // Check OpenCode + const checkOpencode = async () => { + try { + if (!api.setup?.getOpencodeStatus) return; + const result = await api.setup.getOpencodeStatus(); + if (result.success) { + setOpencodeCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.path, + auth: result.auth, + installCommand: result.installCommand, + loginCommand: result.loginCommand, + }); + } + } catch { + // Ignore errors + } + }; + + // Run all checks in parallel + await Promise.all([checkClaude(), checkCursor(), checkCodex(), checkOpencode()]); + setIsInitialChecking(false); + }, [ + setClaudeCliStatus, + setCursorCliStatus, + setCodexCliStatus, + setCodexAuthStatus, + setOpencodeCliStatus, + ]); + + useEffect(() => { + if (!hasCheckedRef.current) { + hasCheckedRef.current = true; + checkAllProviders(); + } + }, [checkAllProviders]); + + // Determine status for each provider + const isClaudeInstalled = claudeCliStatus?.installed === true; + const isClaudeAuthenticated = + claudeAuthStatus?.authenticated === true && + (claudeAuthStatus?.method === 'cli_authenticated' || + claudeAuthStatus?.method === 'api_key' || + claudeAuthStatus?.method === 'api_key_env'); + + const isCursorInstalled = cursorCliStatus?.installed === true; + const isCursorAuthenticated = cursorCliStatus?.auth?.authenticated === true; + + const isCodexInstalled = codexCliStatus?.installed === true; + const isCodexAuthenticated = codexAuthStatus?.authenticated === true; + + const isOpencodeInstalled = opencodeCliStatus?.installed === true; + const isOpencodeAuthenticated = opencodeCliStatus?.auth?.authenticated === true; + + const hasAtLeastOneProvider = + isClaudeAuthenticated || + isCursorAuthenticated || + isCodexAuthenticated || + isOpencodeAuthenticated; + + type ProviderStatus = 'not_installed' | 'installed_not_auth' | 'authenticated' | 'verifying'; + + const getProviderStatus = ( + installed: boolean, + authenticated: boolean, + isVerifying?: boolean + ): ProviderStatus => { + if (!installed) return 'not_installed'; + if (isVerifying) return 'verifying'; + if (!authenticated) return 'installed_not_auth'; + return 'authenticated'; + }; + + const providers = [ + { + id: 'claude' as const, + label: 'Claude', + icon: AnthropicIcon, + status: getProviderStatus(isClaudeInstalled, isClaudeAuthenticated, claudeIsVerifying), + color: 'text-brand-500', + }, + { + id: 'cursor' as const, + label: 'Cursor', + icon: CursorIcon, + status: getProviderStatus(isCursorInstalled, isCursorAuthenticated), + color: 'text-blue-500', + }, + { + id: 'codex' as const, + label: 'Codex', + icon: OpenAIIcon, + status: getProviderStatus(isCodexInstalled, isCodexAuthenticated), + color: 'text-emerald-500', + }, + { + id: 'opencode' as const, + label: 'OpenCode', + icon: OpenCodeIcon, + status: getProviderStatus(isOpencodeInstalled, isOpencodeAuthenticated), + color: 'text-green-500', + }, + ]; + + const renderStatusIcon = (status: ProviderStatus) => { + switch (status) { + case 'authenticated': + return ( + + ); + case 'verifying': + return ( + + ); + case 'installed_not_auth': + return ( + + ); + default: + return null; + } + }; + + return ( +
+
+

AI Provider Setup

+

Configure at least one AI provider to continue

+
+ + {isInitialChecking && ( +
+ +

Checking provider status...

+
+ )} + + setActiveTab(v as ProviderTab)}> + + {providers.map((provider) => { + const Icon = provider.icon; + return ( + +
+ + {!isInitialChecking && renderStatusIcon(provider.status)} +
+ {provider.label} +
+ ); + })} +
+ +
+ + + + + + + + + + + + +
+
+ +
+ + +
+ + {!hasAtLeastOneProvider && ( +

+ You can configure providers later in Settings +

+ )} +
+ ); +} diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 9e2aafbe..ca7414df 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1288,6 +1288,32 @@ export class HttpApiClient implements ElectronAPI { error?: string; }> => this.post('/api/setup/verify-codex-auth', { authMethod, apiKey }), + // OpenCode CLI methods + getOpencodeStatus: (): Promise<{ + success: boolean; + status?: string; + installed?: boolean; + method?: string; + version?: string; + path?: string; + recommendation?: string; + installCommands?: { + macos?: string; + linux?: string; + npm?: string; + }; + auth?: { + authenticated: boolean; + method: string; + hasAuthFile?: boolean; + hasOAuthToken?: boolean; + hasApiKey?: boolean; + hasStoredApiKey?: boolean; + hasEnvApiKey?: boolean; + }; + error?: string; + }> => this.get('/api/setup/opencode-status'), + onInstallProgress: (callback: (progress: unknown) => void) => { return this.subscribeToEvent('agent:stream', callback); }, diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 317af392..2f55ab96 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 @@ -938,6 +949,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; @@ -1175,6 +1191,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 @@ -1908,6 +1926,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..6b872819 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 @@ -99,10 +113,12 @@ export interface InstallProgress { export type SetupStep = | 'welcome' | 'theme' + | 'providers' | 'claude_detect' | 'claude_auth' | 'cursor' | 'codex' + | 'opencode' | 'github' | 'complete'; @@ -116,6 +132,7 @@ export interface SetupState { claudeCliStatus: CliStatus | null; claudeAuthStatus: ClaudeAuthStatus | null; claudeInstallProgress: InstallProgress; + claudeIsVerifying: boolean; // GitHub CLI state ghCliStatus: GhCliStatus | null; @@ -128,6 +145,9 @@ export interface SetupState { codexAuthStatus: CodexAuthStatus | null; codexInstallProgress: InstallProgress; + // OpenCode CLI state + opencodeCliStatus: OpencodeCliStatus | null; + // Setup preferences skipClaudeSetup: boolean; } @@ -145,6 +165,7 @@ export interface SetupActions { setClaudeAuthStatus: (status: ClaudeAuthStatus | null) => void; setClaudeInstallProgress: (progress: Partial) => void; resetClaudeInstallProgress: () => void; + setClaudeIsVerifying: (isVerifying: boolean) => void; // GitHub CLI setGhCliStatus: (status: GhCliStatus | null) => void; @@ -158,6 +179,9 @@ export interface SetupActions { setCodexInstallProgress: (progress: Partial) => void; resetCodexInstallProgress: () => void; + // OpenCode CLI + setOpencodeCliStatus: (status: OpencodeCliStatus | null) => void; + // Preferences setSkipClaudeSetup: (skip: boolean) => void; } @@ -180,6 +204,7 @@ const initialState: SetupState = { claudeCliStatus: null, claudeAuthStatus: null, claudeInstallProgress: { ...initialInstallProgress }, + claudeIsVerifying: false, ghCliStatus: null, cursorCliStatus: null, @@ -188,6 +213,8 @@ const initialState: SetupState = { codexAuthStatus: null, codexInstallProgress: { ...initialInstallProgress }, + opencodeCliStatus: null, + skipClaudeSetup: shouldSkipSetup, }; @@ -231,6 +258,8 @@ export const useSetupStore = create()((set, get) => ( claudeInstallProgress: { ...initialInstallProgress }, }), + setClaudeIsVerifying: (isVerifying) => set({ claudeIsVerifying: isVerifying }), + // GitHub CLI setGhCliStatus: (status) => set({ ghCliStatus: status }), @@ -255,6 +284,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/apps/ui/tests/projects/board-background-persistence.spec.ts b/apps/ui/tests/projects/board-background-persistence.spec.ts index 91382ebf..f7d35dec 100644 --- a/apps/ui/tests/projects/board-background-persistence.spec.ts +++ b/apps/ui/tests/projects/board-background-persistence.spec.ts @@ -20,6 +20,7 @@ import { cleanupTempDir, authenticateForTests, handleLoginScreenIfPresent, + setupWelcomeView, } from '../utils'; // Create unique temp dirs for this test run @@ -102,53 +103,53 @@ test.describe('Board Background Persistence', () => { JSON.stringify({ version: 1 }, null, 2) ); - // Set up app state with both projects in the list (not recent, but in projects list) - await page.addInitScript( - ({ projects }: { projects: string[] }) => { - const appState = { - state: { - projects: [ - { - id: projects[0], - name: projects[1], - path: projects[2], - lastOpened: new Date(Date.now() - 86400000).toISOString(), - theme: 'red', - }, - { - id: projects[3], - name: projects[4], - path: projects[5], - lastOpened: new Date(Date.now() - 172800000).toISOString(), - theme: 'red', - }, - ], - currentProject: null, - currentView: 'welcome', - theme: 'red', - sidebarOpen: true, - apiKeys: { anthropic: '', google: '' }, - chatSessions: [], - chatHistoryOpen: false, - maxConcurrency: 3, - boardBackgroundByProject: {}, - }, - version: 2, - }; - localStorage.setItem('automaker-storage', JSON.stringify(appState)); + // Set up welcome view with both projects in the list + await setupWelcomeView(page, { + workspaceDir: TEST_TEMP_DIR, + recentProjects: [ + { + id: projectAId, + name: projectAName, + path: projectAPath, + lastOpened: new Date(Date.now() - 86400000).toISOString(), + }, + { + id: projectBId, + name: projectBName, + path: projectBPath, + lastOpened: new Date(Date.now() - 172800000).toISOString(), + }, + ], + }); - // Setup complete - const setupState = { - state: { - setupComplete: true, - workspaceDir: '/tmp', + await authenticateForTests(page); + + // Intercept settings API to use our test projects and clear currentProjectId + // This ensures the app shows the welcome view with our test projects + await page.route('**/api/settings/global', async (route) => { + const response = await route.fetch(); + const json = await response.json(); + if (json.settings) { + // Clear currentProjectId to show welcome view + json.settings.currentProjectId = null; + // Include our test projects so they appear in the recent projects list + json.settings.projects = [ + { + id: projectAId, + name: projectAName, + path: projectAPath, + lastOpened: new Date(Date.now() - 86400000).toISOString(), }, - version: 0, - }; - localStorage.setItem('setup-storage', JSON.stringify(setupState)); - }, - { projects: [projectAId, projectAName, projectAPath, projectBId, projectBName, projectBPath] } - ); + { + id: projectBId, + name: projectBName, + path: projectBPath, + lastOpened: new Date(Date.now() - 172800000).toISOString(), + }, + ]; + } + await route.fulfill({ response, json }); + }); // Track API calls to /api/settings/project to verify settings are being loaded const settingsApiCalls: Array<{ url: string; method: string; body: string }> = []; @@ -163,7 +164,6 @@ test.describe('Board Background Persistence', () => { }); // Navigate to the app - await authenticateForTests(page); await page.goto('/'); await page.waitForLoadState('load'); await handleLoginScreenIfPresent(page); @@ -179,10 +179,10 @@ test.describe('Board Background Persistence', () => { // Wait for board view await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 }); - // Verify project A is current - await expect( - page.locator('[data-testid="project-selector"]').getByText(projectAName) - ).toBeVisible({ timeout: 5000 }); + // Verify project A is current (check header paragraph which is always visible) + await expect(page.locator('[data-testid="board-view"]').getByText(projectAName)).toBeVisible({ + timeout: 5000, + }); // CRITICAL: Wait for settings to be loaded (useProjectSettingsLoader hook) // This ensures the background settings are fetched from the server @@ -196,8 +196,16 @@ test.describe('Board Background Persistence', () => { // Wait for initial project load to stabilize await page.waitForTimeout(500); + // Ensure sidebar is expanded before interacting with project selector + const expandSidebarButton = page.locator('button:has-text("Expand sidebar")'); + if (await expandSidebarButton.isVisible()) { + await expandSidebarButton.click(); + await page.waitForTimeout(300); + } + // Switch to project B (no background) const projectSelector = page.locator('[data-testid="project-selector"]'); + await expect(projectSelector).toBeVisible({ timeout: 5000 }); await projectSelector.click(); // Wait for dropdown to be visible @@ -315,7 +323,6 @@ test.describe('Board Background Persistence', () => { name: project[1], path: project[2], lastOpened: new Date().toISOString(), - theme: 'red', }; const appState = { @@ -323,8 +330,9 @@ test.describe('Board Background Persistence', () => { projects: [projectObj], currentProject: projectObj, currentView: 'board', - theme: 'red', + theme: 'dark', sidebarOpen: true, + skipSandboxWarning: true, apiKeys: { anthropic: '', google: '' }, chatSessions: [], chatHistoryOpen: false, @@ -335,19 +343,44 @@ test.describe('Board Background Persistence', () => { }; localStorage.setItem('automaker-storage', JSON.stringify(appState)); - // Setup complete + // Setup complete - use correct key name const setupState = { state: { + isFirstRun: false, setupComplete: true, - workspaceDir: '/tmp', + skipClaudeSetup: false, }, - version: 0, + version: 1, }; - localStorage.setItem('setup-storage', JSON.stringify(setupState)); + localStorage.setItem('automaker-setup', JSON.stringify(setupState)); + + // Disable splash screen in tests + sessionStorage.setItem('automaker-splash-shown', 'true'); }, { project: [projectId, projectName, projectPath] } ); + await authenticateForTests(page); + + // Intercept settings API to use our test project instead of the E2E fixture + await page.route('**/api/settings/global', async (route) => { + const response = await route.fetch(); + const json = await response.json(); + // Override to use our test project + if (json.settings) { + json.settings.currentProjectId = projectId; + json.settings.projects = [ + { + id: projectId, + name: projectName, + path: projectPath, + lastOpened: new Date().toISOString(), + }, + ]; + } + await route.fulfill({ response, json }); + }); + // Track API calls to /api/settings/project to verify settings are being loaded const settingsApiCalls: Array<{ url: string; method: string; body: string }> = []; page.on('request', (request) => { @@ -360,8 +393,7 @@ test.describe('Board Background Persistence', () => { } }); - // Navigate and authenticate - await authenticateForTests(page); + // Navigate to the app await page.goto('/'); await page.waitForLoadState('load'); await handleLoginScreenIfPresent(page); diff --git a/apps/ui/tests/settings/settings-startup-sync-race.spec.ts b/apps/ui/tests/settings/settings-startup-sync-race.spec.ts index 1a5093f5..4676dba4 100644 --- a/apps/ui/tests/settings/settings-startup-sync-race.spec.ts +++ b/apps/ui/tests/settings/settings-startup-sync-race.spec.ts @@ -14,7 +14,7 @@ import { test, expect } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; -import { authenticateForTests } from '../utils'; +import { authenticateForTests, handleLoginScreenIfPresent } from '../utils'; const SETTINGS_PATH = path.resolve(process.cwd(), '../server/data/settings.json'); const WORKSPACE_ROOT = path.resolve(process.cwd(), '../..'); @@ -109,6 +109,8 @@ test.describe('Settings startup sync race', () => { // Ensure authenticated and app is loaded at least to welcome/board. await authenticateForTests(page); await page.goto('/'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); await page .locator('[data-testid="welcome-view"], [data-testid="board-view"]') .first() 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..c1faee26 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,209 @@ export async function getCodexAuthIndicators(): Promise { return result; } + +// ============================================================================= +// OpenCode CLI Detection +// ============================================================================= + +const OPENCODE_DATA_DIR = '.local/share/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 [ + // OpenCode's default installation directory + path.join(homeDir, '.opencode', 'bin', 'opencode.exe'), + 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 [ + // OpenCode's default installation directory + path.join(homeDir, '.opencode', 'bin', 'opencode'), + // 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 data directory path + * macOS/Linux: ~/.local/share/opencode + * Windows: %USERPROFILE%\.local\share\opencode + */ +export function getOpenCodeConfigDir(): string { + return path.join(os.homedir(), OPENCODE_DATA_DIR); +} + +/** + * 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; + +// Provider names that OpenCode uses for provider-specific auth entries +const OPENCODE_PROVIDERS = ['anthropic', 'openai', 'google', 'bedrock', 'amazon-bedrock'] 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; +} + +/** + * Check if the auth JSON has provider-specific OAuth credentials + * OpenCode stores auth in format: { "anthropic": { "type": "oauth", "access": "...", "refresh": "..." } } + */ +function hasProviderOAuth(authJson: Record): boolean { + for (const provider of OPENCODE_PROVIDERS) { + const providerAuth = authJson[provider]; + if (providerAuth && typeof providerAuth === 'object' && !Array.isArray(providerAuth)) { + const auth = providerAuth as Record; + // Check for OAuth type with access token + if (auth.type === 'oauth' && typeof auth.access === 'string' && auth.access) { + return true; + } + // Also check for access_token field directly + if (typeof auth.access_token === 'string' && auth.access_token) { + return true; + } + } + } + return false; +} + +/** + * Check if the auth JSON has provider-specific API key credentials + */ +function hasProviderApiKey(authJson: Record): boolean { + for (const provider of OPENCODE_PROVIDERS) { + const providerAuth = authJson[provider]; + if (providerAuth && typeof providerAuth === 'object' && !Array.isArray(providerAuth)) { + const auth = providerAuth as Record; + // Check for API key type + if (auth.type === 'api_key' && typeof auth.key === 'string' && auth.key) { + return true; + } + // Also check for api_key field directly + if (typeof auth.api_key === 'string' && auth.api_key) { + return true; + } + } + } + return false; +} + +/** + * 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; + + // Check for legacy top-level keys + result.hasOAuthToken = hasNonEmptyStringField(authJson, OPENCODE_OAUTH_KEYS); + result.hasApiKey = hasNonEmptyStringField(authJson, OPENCODE_API_KEY_KEYS); + + // Check for nested tokens object (legacy format) + 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); + } + + // Check for provider-specific auth entries (current OpenCode format) + // Format: { "anthropic": { "type": "oauth", "access": "...", "refresh": "..." } } + result.hasOAuthToken = result.hasOAuthToken || hasProviderOAuth(authJson); + result.hasApiKey = result.hasApiKey || hasProviderApiKey(authJson); + } 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 744a14cc..259e251d 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -175,12 +175,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 1842d12c..af1a2267 100644 --- a/libs/types/src/provider-utils.ts +++ b/libs/types/src/provider-utils.ts @@ -7,14 +7,15 @@ */ import type { ModelProvider } from './settings.js'; -import { CURSOR_MODEL_MAP, type CursorModelId } from './cursor-models.js'; +import { CURSOR_MODEL_MAP } from './cursor-models.js'; import { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP } from './model.js'; -import { CODEX_MODEL_CONFIG_MAP, type CodexModelId } from './codex-models.js'; +import { OPENCODE_MODEL_CONFIG_MAP } from './opencode-models.js'; /** Provider prefix constants */ export const PROVIDER_PREFIXES = { cursor: 'cursor-', codex: 'codex-', + opencode: 'opencode-', } as const; /** @@ -86,6 +87,41 @@ export function isCodexModel(model: string | undefined | null): boolean { return model in CODEX_MODEL_MAP; } +/** + * 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 * @@ -93,7 +129,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'; @@ -149,6 +189,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/provider.ts b/libs/types/src/provider.ts index 45481ed2..06428d0b 100644 --- a/libs/types/src/provider.ts +++ b/libs/types/src/provider.ts @@ -202,6 +202,7 @@ export interface InstallationStatus { */ method?: 'cli' | 'wsl' | 'npm' | 'brew' | 'sdk'; hasApiKey?: boolean; + hasOAuthToken?: boolean; authenticated?: boolean; error?: string; } diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 3e4006ba..a90e1fcb 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'; import type { ReasoningEffort } from './provider.js'; @@ -97,7 +99,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'; @@ -262,6 +264,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; } /** @@ -285,6 +291,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; } @@ -300,6 +311,10 @@ export function getProfileModelString(profile: AIProfile): string { return `codex:${profile.codexModel || 'codex-gpt-5.2'}`; } + if (profile.provider === 'opencode') { + return `opencode:${profile.opencodeModel || DEFAULT_OPENCODE_MODEL}`; + } + // Claude return profile.model || 'sonnet'; } @@ -478,6 +493,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; @@ -768,6 +789,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: [],