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

OpenCode CLI

+
+ +
+

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

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

OpenCode CLI Installed

+
+ {status.method && ( +

+ Method: {status.method} +

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

+ Version: {status.version} +

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

+ Path: {status.path} +

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

Authenticated

+
+

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

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

Not Authenticated

+

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

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

{status.recommendation}

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

OpenCode CLI Not Detected

+

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

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

Installation Commands:

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

+ npm +

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

+ macOS/Linux +

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

+ Model Configuration +

+
+

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

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

{model.description}

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

OpenCode CLI Setup

+

Optional - Use OpenCode as an AI provider

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

This step is optional

+

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

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

OpenCode CLI is ready!

+

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

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

OpenCode CLI not found

+

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

+
+
+ +
+

Install OpenCode CLI:

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

OpenCode CLI not authenticated

+

+ Run the login command to authenticate with OpenCode. +

+
+
+ +
+

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

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

Checking OpenCode CLI status...

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

+ You can always configure OpenCode later in Settings +

+
+ ); +} diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 250451e9..c4fb17a4 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -14,6 +14,7 @@ import type { AIProfile, CursorModelId, CodexModelId, + OpencodeModelId, PhaseModelConfig, PhaseModelKey, PhaseModelEntry, @@ -23,7 +24,13 @@ import type { PipelineStep, PromptCustomization, } from '@automaker/types'; -import { getAllCursorModelIds, getAllCodexModelIds, DEFAULT_PHASE_MODELS } from '@automaker/types'; +import { + getAllCursorModelIds, + getAllCodexModelIds, + getAllOpencodeModelIds, + DEFAULT_PHASE_MODELS, + DEFAULT_OPENCODE_MODEL, +} from '@automaker/types'; const logger = createLogger('AppStore'); @@ -567,6 +574,10 @@ export interface AppState { codexEnableWebSearch: boolean; // Enable web search capability codexEnableImages: boolean; // Enable image processing + // OpenCode CLI Settings (global) + enabledOpencodeModels: OpencodeModelId[]; // Which OpenCode models are available in feature modal + opencodeDefaultModel: OpencodeModelId; // Default OpenCode model selection + // Claude Agent SDK Settings autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup @@ -930,6 +941,11 @@ export interface AppActions { setCodexEnableWebSearch: (enabled: boolean) => Promise; setCodexEnableImages: (enabled: boolean) => Promise; + // OpenCode CLI Settings actions + setEnabledOpencodeModels: (models: OpencodeModelId[]) => void; + setOpencodeDefaultModel: (model: OpencodeModelId) => void; + toggleOpencodeModel: (model: OpencodeModelId, enabled: boolean) => void; + // Claude Agent SDK Settings actions setAutoLoadClaudeMd: (enabled: boolean) => Promise; setSkipSandboxWarning: (skip: boolean) => Promise; @@ -1167,6 +1183,8 @@ const initialState: AppState = { codexApprovalPolicy: 'on-request', // Default to on-request for balanced safety codexEnableWebSearch: false, // Default to disabled codexEnableImages: false, // Default to disabled + enabledOpencodeModels: getAllOpencodeModelIds(), // All OpenCode models enabled by default + opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Default to Claude Sonnet 4.5 autoLoadClaudeMd: false, // Default to disabled (user must opt-in) skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) mcpServers: [], // No MCP servers configured by default @@ -1896,6 +1914,16 @@ export const useAppStore = create()((set, get) => ({ await syncSettingsToServer(); }, + // OpenCode CLI Settings actions + setEnabledOpencodeModels: (models) => set({ enabledOpencodeModels: models }), + setOpencodeDefaultModel: (model) => set({ opencodeDefaultModel: model }), + toggleOpencodeModel: (model, enabled) => + set((state) => ({ + enabledOpencodeModels: enabled + ? [...state.enabledOpencodeModels, model] + : state.enabledOpencodeModels.filter((m) => m !== model), + })), + // Claude Agent SDK Settings actions setAutoLoadClaudeMd: async (enabled) => { const previous = get().autoLoadClaudeMd; diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index 68970ea0..b8e7f717 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -48,6 +48,20 @@ export interface CodexCliStatus { error?: string; } +// OpenCode CLI Status +export interface OpencodeCliStatus { + installed: boolean; + version?: string | null; + path?: string | null; + auth?: { + authenticated: boolean; + method: string; + }; + installCommand?: string; + loginCommand?: string; + error?: string; +} + // Codex Auth Method export type CodexAuthMethod = | 'api_key_env' // OPENAI_API_KEY environment variable @@ -103,6 +117,7 @@ export type SetupStep = | 'claude_auth' | 'cursor' | 'codex' + | 'opencode' | 'github' | 'complete'; @@ -128,6 +143,9 @@ export interface SetupState { codexAuthStatus: CodexAuthStatus | null; codexInstallProgress: InstallProgress; + // OpenCode CLI state + opencodeCliStatus: OpencodeCliStatus | null; + // Setup preferences skipClaudeSetup: boolean; } @@ -158,6 +176,9 @@ export interface SetupActions { setCodexInstallProgress: (progress: Partial) => void; resetCodexInstallProgress: () => void; + // OpenCode CLI + setOpencodeCliStatus: (status: OpencodeCliStatus | null) => void; + // Preferences setSkipClaudeSetup: (skip: boolean) => void; } @@ -188,6 +209,8 @@ const initialState: SetupState = { codexAuthStatus: null, codexInstallProgress: { ...initialInstallProgress }, + opencodeCliStatus: null, + skipClaudeSetup: shouldSkipSetup, }; @@ -255,6 +278,9 @@ export const useSetupStore = create()((set, get) => ( codexInstallProgress: { ...initialInstallProgress }, }), + // OpenCode CLI + setOpencodeCliStatus: (status) => set({ opencodeCliStatus: status }), + // Preferences setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }), })); diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index 9d24ed23..7e4f2474 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -96,6 +96,9 @@ export { getCodexCliPaths, getCodexConfigDir, getCodexAuthPath, + getOpenCodeCliPaths, + getOpenCodeConfigDir, + getOpenCodeAuthPath, getShellPaths, getExtendedPath, // Node.js paths @@ -126,6 +129,9 @@ export { findCodexCliPath, getCodexAuthIndicators, type CodexAuthIndicators, + findOpenCodeCliPath, + getOpenCodeAuthIndicators, + type OpenCodeAuthIndicators, // Electron userData operations setElectronUserDataPath, getElectronUserDataPath, diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index 5575f659..f9f98ae6 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -543,6 +543,11 @@ function getAllAllowedSystemPaths(): string[] { // Codex config directory and files getCodexConfigDir(), getCodexAuthPath(), + // OpenCode CLI paths + ...getOpenCodeCliPaths(), + // OpenCode config directory and files + getOpenCodeConfigDir(), + getOpenCodeAuthPath(), // Shell paths ...getShellPaths(), // Node.js system paths @@ -564,6 +569,8 @@ function getAllAllowedSystemDirs(): string[] { getClaudeProjectsDir(), // Codex config getCodexConfigDir(), + // OpenCode config + getOpenCodeConfigDir(), // Version managers (need recursive access for version directories) ...getNvmPaths(), ...getFnmPaths(), @@ -1007,3 +1014,148 @@ export async function getCodexAuthIndicators(): Promise { return result; } + +// ============================================================================= +// OpenCode CLI Detection +// ============================================================================= + +const OPENCODE_CONFIG_DIR_NAME = '.opencode'; +const OPENCODE_AUTH_FILENAME = 'auth.json'; +const OPENCODE_TOKENS_KEY = 'tokens'; + +/** + * Get common paths where OpenCode CLI might be installed + */ +export function getOpenCodeCliPaths(): string[] { + const isWindows = process.platform === 'win32'; + const homeDir = os.homedir(); + + if (isWindows) { + const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'); + const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'); + return [ + path.join(homeDir, '.local', 'bin', 'opencode.exe'), + path.join(appData, 'npm', 'opencode.cmd'), + path.join(appData, 'npm', 'opencode'), + path.join(appData, '.npm-global', 'bin', 'opencode.cmd'), + path.join(appData, '.npm-global', 'bin', 'opencode'), + // Volta on Windows + path.join(homeDir, '.volta', 'bin', 'opencode.exe'), + // pnpm on Windows + path.join(localAppData, 'pnpm', 'opencode.cmd'), + path.join(localAppData, 'pnpm', 'opencode'), + // Go installation (if OpenCode is a Go binary) + path.join(homeDir, 'go', 'bin', 'opencode.exe'), + path.join(process.env.GOPATH || path.join(homeDir, 'go'), 'bin', 'opencode.exe'), + ]; + } + + // Include NVM bin paths for opencode installed via npm global under NVM + const nvmBinPaths = getNvmBinPaths().map((binPath) => path.join(binPath, 'opencode')); + + // Include fnm bin paths + const fnmBinPaths = getFnmBinPaths().map((binPath) => path.join(binPath, 'opencode')); + + // pnpm global bin path + const pnpmHome = process.env.PNPM_HOME || path.join(homeDir, '.local', 'share', 'pnpm'); + + return [ + // Standard locations + path.join(homeDir, '.local', 'bin', 'opencode'), + '/opt/homebrew/bin/opencode', + '/usr/local/bin/opencode', + '/usr/bin/opencode', + path.join(homeDir, '.npm-global', 'bin', 'opencode'), + // Linuxbrew + '/home/linuxbrew/.linuxbrew/bin/opencode', + // Volta + path.join(homeDir, '.volta', 'bin', 'opencode'), + // pnpm global + path.join(pnpmHome, 'opencode'), + // Yarn global + path.join(homeDir, '.yarn', 'bin', 'opencode'), + path.join(homeDir, '.config', 'yarn', 'global', 'node_modules', '.bin', 'opencode'), + // Go installation (if OpenCode is a Go binary) + path.join(homeDir, 'go', 'bin', 'opencode'), + path.join(process.env.GOPATH || path.join(homeDir, 'go'), 'bin', 'opencode'), + // Snap packages + '/snap/bin/opencode', + // NVM paths + ...nvmBinPaths, + // fnm paths + ...fnmBinPaths, + ]; +} + +/** + * Get the OpenCode configuration directory path + */ +export function getOpenCodeConfigDir(): string { + return path.join(os.homedir(), OPENCODE_CONFIG_DIR_NAME); +} + +/** + * Get path to OpenCode auth file + */ +export function getOpenCodeAuthPath(): string { + return path.join(getOpenCodeConfigDir(), OPENCODE_AUTH_FILENAME); +} + +/** + * Check if OpenCode CLI is installed and return its path + */ +export async function findOpenCodeCliPath(): Promise { + return findFirstExistingPath(getOpenCodeCliPaths()); +} + +export interface OpenCodeAuthIndicators { + hasAuthFile: boolean; + hasOAuthToken: boolean; + hasApiKey: boolean; +} + +const OPENCODE_OAUTH_KEYS = ['access_token', 'oauth_token'] as const; +const OPENCODE_API_KEY_KEYS = ['api_key', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY'] as const; + +function getOpenCodeNestedTokens(record: Record): Record | null { + const tokens = record[OPENCODE_TOKENS_KEY]; + if (tokens && typeof tokens === 'object' && !Array.isArray(tokens)) { + return tokens as Record; + } + return null; +} + +/** + * Get OpenCode authentication status by checking auth file indicators + */ +export async function getOpenCodeAuthIndicators(): Promise { + const result: OpenCodeAuthIndicators = { + hasAuthFile: false, + hasOAuthToken: false, + hasApiKey: false, + }; + + try { + const authContent = await systemPathReadFile(getOpenCodeAuthPath()); + result.hasAuthFile = true; + + try { + const authJson = JSON.parse(authContent) as Record; + result.hasOAuthToken = hasNonEmptyStringField(authJson, OPENCODE_OAUTH_KEYS); + result.hasApiKey = hasNonEmptyStringField(authJson, OPENCODE_API_KEY_KEYS); + const nestedTokens = getOpenCodeNestedTokens(authJson); + if (nestedTokens) { + result.hasOAuthToken = + result.hasOAuthToken || hasNonEmptyStringField(nestedTokens, OPENCODE_OAUTH_KEYS); + result.hasApiKey = + result.hasApiKey || hasNonEmptyStringField(nestedTokens, OPENCODE_API_KEY_KEYS); + } + } catch { + // Ignore parse errors; file exists but contents are unreadable + } + } catch { + // Auth file not found or inaccessible + } + + return result; +} diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index edc7dd0b..34c7f7a3 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -174,12 +174,16 @@ export type { export * from './cursor-models.js'; export * from './cursor-cli.js'; +// OpenCode types +export * from './opencode-models.js'; + // Provider utilities export { PROVIDER_PREFIXES, isCursorModel, isClaudeModel, isCodexModel, + isOpencodeModel, getModelProvider, stripProviderPrefix, addProviderPrefix, diff --git a/libs/types/src/opencode-models.ts b/libs/types/src/opencode-models.ts new file mode 100644 index 00000000..246f8770 --- /dev/null +++ b/libs/types/src/opencode-models.ts @@ -0,0 +1,397 @@ +/** + * OpenCode Model IDs + * Models available via OpenCode CLI (opencode models command) + */ +export type OpencodeModelId = + // OpenCode Free Tier Models + | 'opencode/big-pickle' + | 'opencode/glm-4.7-free' + | 'opencode/gpt-5-nano' + | 'opencode/grok-code' + | 'opencode/minimax-m2.1-free' + // Amazon Bedrock - Claude Models + | 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0' + | 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0' + | 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0' + | 'amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0' + | 'amazon-bedrock/anthropic.claude-opus-4-20250514-v1:0' + | 'amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0' + | 'amazon-bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0' + | 'amazon-bedrock/anthropic.claude-3-opus-20240229-v1:0' + // Amazon Bedrock - DeepSeek Models + | 'amazon-bedrock/deepseek.r1-v1:0' + | 'amazon-bedrock/deepseek.v3-v1:0' + // Amazon Bedrock - Amazon Nova Models + | 'amazon-bedrock/amazon.nova-premier-v1:0' + | 'amazon-bedrock/amazon.nova-pro-v1:0' + | 'amazon-bedrock/amazon.nova-lite-v1:0' + // Amazon Bedrock - Meta Llama Models + | 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0' + | 'amazon-bedrock/meta.llama3-3-70b-instruct-v1:0' + // Amazon Bedrock - Mistral Models + | 'amazon-bedrock/mistral.mistral-large-2402-v1:0' + // Amazon Bedrock - Qwen Models + | 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0' + | 'amazon-bedrock/qwen.qwen3-235b-a22b-2507-v1:0'; + +/** + * Provider type for OpenCode models + */ +export type OpencodeProvider = + | 'opencode' + | 'amazon-bedrock-anthropic' + | 'amazon-bedrock-deepseek' + | 'amazon-bedrock-amazon' + | 'amazon-bedrock-meta' + | 'amazon-bedrock-mistral' + | 'amazon-bedrock-qwen'; + +/** + * Friendly aliases mapped to full model IDs + */ +export const OPENCODE_MODEL_MAP: Record = { + // OpenCode free tier aliases + 'big-pickle': 'opencode/big-pickle', + pickle: 'opencode/big-pickle', + 'glm-free': 'opencode/glm-4.7-free', + 'gpt-nano': 'opencode/gpt-5-nano', + nano: 'opencode/gpt-5-nano', + 'grok-code': 'opencode/grok-code', + grok: 'opencode/grok-code', + minimax: 'opencode/minimax-m2.1-free', + + // Claude aliases (via Bedrock) + 'claude-sonnet-4.5': 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + 'sonnet-4.5': 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + sonnet: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + 'claude-opus-4.5': 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', + 'opus-4.5': 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', + opus: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', + 'claude-haiku-4.5': 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', + 'haiku-4.5': 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', + haiku: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', + + // DeepSeek aliases + 'deepseek-r1': 'amazon-bedrock/deepseek.r1-v1:0', + r1: 'amazon-bedrock/deepseek.r1-v1:0', + 'deepseek-v3': 'amazon-bedrock/deepseek.v3-v1:0', + + // Nova aliases + 'nova-premier': 'amazon-bedrock/amazon.nova-premier-v1:0', + 'nova-pro': 'amazon-bedrock/amazon.nova-pro-v1:0', + nova: 'amazon-bedrock/amazon.nova-pro-v1:0', + + // Llama aliases + llama4: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0', + 'llama-4': 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0', + llama3: 'amazon-bedrock/meta.llama3-3-70b-instruct-v1:0', + + // Qwen aliases + qwen: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0', + 'qwen-coder': 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0', +} as const; + +/** + * OpenCode model metadata + */ +export interface OpencodeModelConfig { + id: OpencodeModelId; + label: string; + description: string; + supportsVision: boolean; + provider: OpencodeProvider; + tier: 'free' | 'standard' | 'premium'; +} + +/** + * Complete list of OpenCode model configurations + */ +export const OPENCODE_MODELS: OpencodeModelConfig[] = [ + // OpenCode Free Tier Models + { + id: 'opencode/big-pickle', + label: 'Big Pickle', + description: 'OpenCode free tier model - great for general coding', + supportsVision: false, + provider: 'opencode', + tier: 'free', + }, + { + id: 'opencode/glm-4.7-free', + label: 'GLM 4.7 Free', + description: 'OpenCode free tier GLM model', + supportsVision: false, + provider: 'opencode', + tier: 'free', + }, + { + id: 'opencode/gpt-5-nano', + label: 'GPT-5 Nano', + description: 'OpenCode free tier nano model - fast and lightweight', + supportsVision: false, + provider: 'opencode', + tier: 'free', + }, + { + id: 'opencode/grok-code', + label: 'Grok Code', + description: 'OpenCode free tier Grok model for coding', + supportsVision: false, + provider: 'opencode', + tier: 'free', + }, + { + id: 'opencode/minimax-m2.1-free', + label: 'MiniMax M2.1 Free', + description: 'OpenCode free tier MiniMax model', + supportsVision: false, + provider: 'opencode', + tier: 'free', + }, + + // Amazon Bedrock - Claude Models + { + id: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + label: 'Claude Sonnet 4.5 (Bedrock)', + description: 'Latest Claude Sonnet via AWS Bedrock - fast and intelligent (default)', + supportsVision: true, + provider: 'amazon-bedrock-anthropic', + tier: 'premium', + }, + { + id: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', + label: 'Claude Opus 4.5 (Bedrock)', + description: 'Most capable Claude model via AWS Bedrock', + supportsVision: true, + provider: 'amazon-bedrock-anthropic', + tier: 'premium', + }, + { + id: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', + label: 'Claude Haiku 4.5 (Bedrock)', + description: 'Fastest Claude model via AWS Bedrock', + supportsVision: true, + provider: 'amazon-bedrock-anthropic', + tier: 'standard', + }, + { + id: 'amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0', + label: 'Claude Sonnet 4 (Bedrock)', + description: 'Claude Sonnet 4 via AWS Bedrock', + supportsVision: true, + provider: 'amazon-bedrock-anthropic', + tier: 'premium', + }, + { + id: 'amazon-bedrock/anthropic.claude-opus-4-20250514-v1:0', + label: 'Claude Opus 4 (Bedrock)', + description: 'Claude Opus 4 via AWS Bedrock', + supportsVision: true, + provider: 'amazon-bedrock-anthropic', + tier: 'premium', + }, + { + id: 'amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0', + label: 'Claude 3.7 Sonnet (Bedrock)', + description: 'Claude 3.7 Sonnet via AWS Bedrock', + supportsVision: true, + provider: 'amazon-bedrock-anthropic', + tier: 'standard', + }, + { + id: 'amazon-bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0', + label: 'Claude 3.5 Sonnet (Bedrock)', + description: 'Claude 3.5 Sonnet v2 via AWS Bedrock', + supportsVision: true, + provider: 'amazon-bedrock-anthropic', + tier: 'standard', + }, + { + id: 'amazon-bedrock/anthropic.claude-3-opus-20240229-v1:0', + label: 'Claude 3 Opus (Bedrock)', + description: 'Claude 3 Opus via AWS Bedrock', + supportsVision: true, + provider: 'amazon-bedrock-anthropic', + tier: 'premium', + }, + + // Amazon Bedrock - DeepSeek Models + { + id: 'amazon-bedrock/deepseek.r1-v1:0', + label: 'DeepSeek R1 (Bedrock)', + description: 'DeepSeek R1 reasoning model via AWS Bedrock - excellent for coding', + supportsVision: false, + provider: 'amazon-bedrock-deepseek', + tier: 'premium', + }, + { + id: 'amazon-bedrock/deepseek.v3-v1:0', + label: 'DeepSeek V3 (Bedrock)', + description: 'DeepSeek V3 via AWS Bedrock', + supportsVision: false, + provider: 'amazon-bedrock-deepseek', + tier: 'standard', + }, + + // Amazon Bedrock - Amazon Nova Models + { + id: 'amazon-bedrock/amazon.nova-premier-v1:0', + label: 'Amazon Nova Premier (Bedrock)', + description: 'Amazon Nova Premier - most capable Nova model', + supportsVision: true, + provider: 'amazon-bedrock-amazon', + tier: 'premium', + }, + { + id: 'amazon-bedrock/amazon.nova-pro-v1:0', + label: 'Amazon Nova Pro (Bedrock)', + description: 'Amazon Nova Pro - balanced performance', + supportsVision: true, + provider: 'amazon-bedrock-amazon', + tier: 'standard', + }, + { + id: 'amazon-bedrock/amazon.nova-lite-v1:0', + label: 'Amazon Nova Lite (Bedrock)', + description: 'Amazon Nova Lite - fast and efficient', + supportsVision: true, + provider: 'amazon-bedrock-amazon', + tier: 'standard', + }, + + // Amazon Bedrock - Meta Llama Models + { + id: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0', + label: 'Llama 4 Maverick 17B (Bedrock)', + description: 'Meta Llama 4 Maverick via AWS Bedrock', + supportsVision: false, + provider: 'amazon-bedrock-meta', + tier: 'standard', + }, + { + id: 'amazon-bedrock/meta.llama3-3-70b-instruct-v1:0', + label: 'Llama 3.3 70B (Bedrock)', + description: 'Meta Llama 3.3 70B via AWS Bedrock', + supportsVision: false, + provider: 'amazon-bedrock-meta', + tier: 'standard', + }, + + // Amazon Bedrock - Mistral Models + { + id: 'amazon-bedrock/mistral.mistral-large-2402-v1:0', + label: 'Mistral Large (Bedrock)', + description: 'Mistral Large via AWS Bedrock', + supportsVision: false, + provider: 'amazon-bedrock-mistral', + tier: 'standard', + }, + + // Amazon Bedrock - Qwen Models + { + id: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0', + label: 'Qwen3 Coder 480B (Bedrock)', + description: 'Qwen3 Coder 480B via AWS Bedrock - excellent for coding', + supportsVision: false, + provider: 'amazon-bedrock-qwen', + tier: 'premium', + }, + { + id: 'amazon-bedrock/qwen.qwen3-235b-a22b-2507-v1:0', + label: 'Qwen3 235B (Bedrock)', + description: 'Qwen3 235B via AWS Bedrock', + supportsVision: false, + provider: 'amazon-bedrock-qwen', + tier: 'premium', + }, +]; + +/** + * Complete model configuration map indexed by model ID + */ +export const OPENCODE_MODEL_CONFIG_MAP: Record = + OPENCODE_MODELS.reduce( + (acc, config) => { + acc[config.id] = config; + return acc; + }, + {} as Record + ); + +/** + * Default OpenCode model - Claude Sonnet 4.5 via Bedrock + */ +export const DEFAULT_OPENCODE_MODEL: OpencodeModelId = + 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0'; + +/** + * Helper: Get display name for model + */ +export function getOpencodeModelLabel(modelId: OpencodeModelId): string { + return OPENCODE_MODEL_CONFIG_MAP[modelId]?.label ?? modelId; +} + +/** + * Helper: Get all OpenCode model IDs + */ +export function getAllOpencodeModelIds(): OpencodeModelId[] { + return OPENCODE_MODELS.map((config) => config.id); +} + +/** + * Helper: Check if OpenCode model supports vision + */ +export function opencodeModelSupportsVision(modelId: OpencodeModelId): boolean { + return OPENCODE_MODEL_CONFIG_MAP[modelId]?.supportsVision ?? false; +} + +/** + * Helper: Get the provider for a model + */ +export function getOpencodeModelProvider(modelId: OpencodeModelId): OpencodeProvider { + return OPENCODE_MODEL_CONFIG_MAP[modelId]?.provider ?? 'opencode'; +} + +/** + * Helper: Resolve an alias or partial model ID to a full model ID + */ +export function resolveOpencodeModelId(input: string): OpencodeModelId | undefined { + // Check if it's already a valid model ID + if (OPENCODE_MODEL_CONFIG_MAP[input as OpencodeModelId]) { + return input as OpencodeModelId; + } + + // Check alias map + const normalized = input.toLowerCase(); + return OPENCODE_MODEL_MAP[normalized]; +} + +/** + * Helper: Check if a string is a valid OpenCode model ID + */ +export function isOpencodeModelId(value: string): value is OpencodeModelId { + return value in OPENCODE_MODEL_CONFIG_MAP; +} + +/** + * Helper: Get models filtered by provider + */ +export function getOpencodeModelsByProvider(provider: OpencodeProvider): OpencodeModelConfig[] { + return OPENCODE_MODELS.filter((config) => config.provider === provider); +} + +/** + * Helper: Get models filtered by tier + */ +export function getOpencodeModelsByTier( + tier: 'free' | 'standard' | 'premium' +): OpencodeModelConfig[] { + return OPENCODE_MODELS.filter((config) => config.tier === tier); +} + +/** + * Helper: Get free tier models + */ +export function getOpencodeFreeModels(): OpencodeModelConfig[] { + return getOpencodeModelsByTier('free'); +} diff --git a/libs/types/src/provider-utils.ts b/libs/types/src/provider-utils.ts index 51ebb85d..a92754de 100644 --- a/libs/types/src/provider-utils.ts +++ b/libs/types/src/provider-utils.ts @@ -9,11 +9,13 @@ import type { ModelProvider } from './settings.js'; import { CURSOR_MODEL_MAP, type CursorModelId } from './cursor-models.js'; import { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP, type CodexModelId } from './model.js'; +import { OPENCODE_MODEL_CONFIG_MAP } from './opencode-models.js'; /** Provider prefix constants */ export const PROVIDER_PREFIXES = { cursor: 'cursor-', codex: 'codex-', + opencode: 'opencode-', // Add new provider prefixes here } as const; @@ -82,6 +84,41 @@ export function isCodexModel(model: string | undefined | null): boolean { return modelValues.includes(model as CodexModelId); } +/** + * Check if a model string represents an OpenCode model + * + * OpenCode models can be identified by: + * - Explicit 'opencode-' prefix (for routing in Automaker) + * - 'opencode/' prefix (OpenCode free tier models) + * - 'amazon-bedrock/' prefix (AWS Bedrock models via OpenCode) + * - Full model ID from OPENCODE_MODEL_CONFIG_MAP + * + * @param model - Model string to check (e.g., "opencode-sonnet", "opencode/big-pickle", "amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0") + * @returns true if the model is an OpenCode model + */ +export function isOpencodeModel(model: string | undefined | null): boolean { + if (!model || typeof model !== 'string') return false; + + // Check for explicit opencode- prefix (Automaker routing prefix) + if (model.startsWith(PROVIDER_PREFIXES.opencode)) { + return true; + } + + // Check if it's a known OpenCode model ID + if (model in OPENCODE_MODEL_CONFIG_MAP) { + return true; + } + + // Check for OpenCode native model prefixes + // - opencode/ = OpenCode free tier models (e.g., opencode/big-pickle) + // - amazon-bedrock/ = AWS Bedrock models (e.g., amazon-bedrock/anthropic.claude-*) + if (model.startsWith('opencode/') || model.startsWith('amazon-bedrock/')) { + return true; + } + + return false; +} + /** * Get the provider for a model string * @@ -89,7 +126,11 @@ export function isCodexModel(model: string | undefined | null): boolean { * @returns The provider type, defaults to 'claude' for unknown models */ export function getModelProvider(model: string | undefined | null): ModelProvider { - // Check Codex first before Cursor, since Cursor also supports gpt models + // Check OpenCode first since it uses provider-prefixed formats that could conflict + if (isOpencodeModel(model)) { + return 'opencode'; + } + // Check Codex before Cursor, since Cursor also supports gpt models // but bare gpt-* should route to Codex if (isCodexModel(model)) { return 'codex'; @@ -145,6 +186,10 @@ export function addProviderPrefix(model: string, provider: ModelProvider): strin if (!model.startsWith(PROVIDER_PREFIXES.codex)) { return `${PROVIDER_PREFIXES.codex}${model}`; } + } else if (provider === 'opencode') { + if (!model.startsWith(PROVIDER_PREFIXES.opencode)) { + return `${PROVIDER_PREFIXES.opencode}${model}`; + } } // Claude models don't use prefixes return model; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 29fac9a5..ffd0b7eb 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -9,6 +9,8 @@ import type { ModelAlias, AgentModel, CodexModelId } from './model.js'; import type { CursorModelId } from './cursor-models.js'; import { CURSOR_MODEL_MAP, getAllCursorModelIds } from './cursor-models.js'; +import type { OpencodeModelId } from './opencode-models.js'; +import { getAllOpencodeModelIds, DEFAULT_OPENCODE_MODEL } from './opencode-models.js'; import type { PromptCustomization } from './prompts.js'; import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js'; @@ -96,7 +98,7 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number } /** ModelProvider - AI model provider for credentials and API key management */ -export type ModelProvider = 'claude' | 'cursor' | 'codex'; +export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode'; const DEFAULT_CODEX_AUTO_LOAD_AGENTS = false; const DEFAULT_CODEX_SANDBOX_MODE: CodexSandboxMode = 'workspace-write'; @@ -257,6 +259,10 @@ export interface AIProfile { // Codex-specific settings /** Which Codex/GPT model to use - only for Codex provider */ codexModel?: CodexModelId; + + // OpenCode-specific settings + /** Which OpenCode model to use - only for OpenCode provider */ + opencodeModel?: OpencodeModelId; } /** @@ -280,6 +286,11 @@ export function profileHasThinking(profile: AIProfile): boolean { return model.startsWith('o'); } + if (profile.provider === 'opencode') { + // OpenCode models don't expose thinking configuration + return false; + } + return false; } @@ -295,6 +306,10 @@ export function getProfileModelString(profile: AIProfile): string { return `codex:${profile.codexModel || 'gpt-5.2'}`; } + if (profile.provider === 'opencode') { + return `opencode:${profile.opencodeModel || DEFAULT_OPENCODE_MODEL}`; + } + // Claude return profile.model || 'sonnet'; } @@ -473,6 +488,12 @@ export interface GlobalSettings { /** Default Cursor model selection when switching to Cursor CLI */ cursorDefaultModel: CursorModelId; + // OpenCode CLI Settings (global) + /** Which OpenCode models are available in feature modal (empty = all) */ + enabledOpencodeModels?: OpencodeModelId[]; + /** Default OpenCode model selection when switching to OpenCode CLI */ + opencodeDefaultModel?: OpencodeModelId; + // Input Configuration /** User's keyboard shortcut bindings */ keyboardShortcuts: KeyboardShortcuts; @@ -717,6 +738,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { validationModel: 'opus', enabledCursorModels: getAllCursorModelIds(), cursorDefaultModel: 'auto', + enabledOpencodeModels: getAllOpencodeModelIds(), + opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, aiProfiles: [], projects: [], From be88a07329982144d4db1c656e19d4ba10370fb0 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Thu, 8 Jan 2026 23:15:35 -0500 Subject: [PATCH 2/6] feat: add OpenCode CLI support with status endpoint - Implemented OpenCode CLI installation and authentication status check. - Added new route for OpenCode status in setup routes. - Updated HttpApiClient to include method for fetching OpenCode status. - Enhanced system paths to include OpenCode's default installation directories. This commit introduces functionality to check the installation and authentication status of the OpenCode CLI, improving integration with the overall system. --- .../server/src/providers/opencode-provider.ts | 3 + apps/server/src/routes/setup/index.ts | 4 ++ .../routes/setup/routes/opencode-status.ts | 57 +++++++++++++++++++ apps/ui/src/lib/http-api-client.ts | 26 +++++++++ libs/platform/src/system-paths.ts | 4 ++ 5 files changed, 94 insertions(+) create mode 100644 apps/server/src/routes/setup/routes/opencode-status.ts diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index 6dbef31a..42a7045f 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -180,18 +180,21 @@ export class OpencodeProvider extends CliProvider { 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'), 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..7e8edd5e --- /dev/null +++ b/apps/server/src/routes/setup/routes/opencode-status.ts @@ -0,0 +1,57 @@ +/** + * 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'; + + 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: false, // OpenCode doesn't use OAuth + }, + recommendation: status.installed + ? undefined + : 'Install OpenCode CLI to use multi-provider AI models.', + 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/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 12172ee9..6853c775 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1284,6 +1284,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/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index f9f98ae6..a0cbff27 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -1034,6 +1034,8 @@ export function getOpenCodeCliPaths(): string[] { 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'), @@ -1060,6 +1062,8 @@ export function getOpenCodeCliPaths(): string[] { 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', From 41b4869068913dae9642d2fd963bac908f589f3a Mon Sep 17 00:00:00 2001 From: webdevcody Date: Fri, 9 Jan 2026 09:02:30 -0500 Subject: [PATCH 3/6] feat: enhance feature dialogs with OpenCode model support - Added OpenCode model selection to AddFeatureDialog and EditFeatureDialog. - Introduced ProfileTypeahead component for improved profile selection. - Updated model constants to include OpenCode models and integrated them into the PhaseModelSelector. - Enhanced planning mode options with new UI elements for OpenCode. - Refactored existing components to streamline model handling and improve user experience. This commit expands the functionality of the feature dialogs, allowing users to select and manage OpenCode models effectively. --- .../board-view/dialogs/add-feature-dialog.tsx | 723 +++++++++-------- .../dialogs/edit-feature-dialog.tsx | 741 +++++++++--------- .../views/board-view/shared/index.ts | 1 + .../board-view/shared/model-constants.ts | 29 +- .../shared/planning-mode-select.tsx | 79 +- .../board-view/shared/priority-selector.tsx | 84 +- .../board-view/shared/profile-typeahead.tsx | 237 ++++++ .../profiles-view/components/profile-form.tsx | 104 ++- .../model-defaults/phase-model-selector.tsx | 96 ++- 9 files changed, 1285 insertions(+), 809 deletions(-) create mode 100644 apps/ui/src/components/views/board-view/shared/profile-typeahead.tsx 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/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))} + + )} From 89248001e48207004a677bc64a1be85e7e076a28 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Fri, 9 Jan 2026 09:39:46 -0500 Subject: [PATCH 4/6] feat: implement OpenCode authentication and provider setup - Added OpenCode authentication status check to the OpencodeProvider class. - Introduced OpenCodeAuthStatus interface to manage authentication states. - Updated detectInstallation method to include authentication status in the response. - Created ProvidersSetupStep component to consolidate provider setup UI, including Claude, Cursor, Codex, and OpenCode. - Refactored setup view to streamline navigation and improve user experience. - Enhanced OpenCode CLI integration with updated installation paths and authentication checks. This commit enhances the setup process by allowing users to configure and authenticate multiple AI providers, improving overall functionality and user experience. --- .../server/src/providers/opencode-provider.ts | 60 +- .../routes/setup/routes/opencode-status.ts | 6 +- apps/ui/src/components/views/setup-view.tsx | 126 +- .../setup-view/steps/claude-setup-step.tsx | 5 + .../views/setup-view/steps/index.ts | 5 +- .../setup-view/steps/opencode-setup-step.tsx | 6 +- .../setup-view/steps/providers-setup-step.tsx | 1318 +++++++++++++++++ apps/ui/src/store/setup-store.ts | 1 + libs/platform/src/system-paths.ts | 63 +- 9 files changed, 1485 insertions(+), 105 deletions(-) create mode 100644 apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index 42a7045f..b54592c3 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -22,7 +22,18 @@ import type { ContentBlock, } from '@automaker/types'; import { stripProviderPrefix } from '@automaker/types'; -import { type SubprocessOptions } from '@automaker/platform'; +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 @@ -583,6 +594,48 @@ export class OpencodeProvider extends CliProvider { 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 // ========================================================================== @@ -593,16 +646,21 @@ export class OpencodeProvider extends CliProvider { * 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/routes/setup/routes/opencode-status.ts b/apps/server/src/routes/setup/routes/opencode-status.ts index 7e8edd5e..f474cfb1 100644 --- a/apps/server/src/routes/setup/routes/opencode-status.ts +++ b/apps/server/src/routes/setup/routes/opencode-status.ts @@ -12,7 +12,7 @@ import { getErrorMessage, logError } from '../common.js'; */ export function createOpencodeStatusHandler() { const installCommand = 'curl -fsSL https://opencode.ai/install | bash'; - const loginCommand = 'opencode auth'; + const loginCommand = 'opencode auth login'; return async (_req: Request, res: Response): Promise => { try { @@ -35,11 +35,13 @@ export function createOpencodeStatusHandler() { method: authMethod, hasApiKey: status.hasApiKey || false, hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY || !!process.env.OPENAI_API_KEY, - hasOAuthToken: false, // OpenCode doesn't use OAuth + hasOAuthToken: status.hasOAuthToken || false, }, recommendation: status.installed ? undefined : 'Install OpenCode CLI to use multi-provider AI models.', + installCommand, + loginCommand, installCommands: { macos: installCommand, linux: installCommand, diff --git a/apps/ui/src/components/views/setup-view.tsx b/apps/ui/src/components/views/setup-view.tsx index 82e399ea..f3e9d1dd 100644 --- a/apps/ui/src/components/views/setup-view.tsx +++ b/apps/ui/src/components/views/setup-view.tsx @@ -5,10 +5,7 @@ import { WelcomeStep, ThemeStep, CompleteStep, - ClaudeSetupStep, - CursorSetupStep, - CodexSetupStep, - OpencodeSetupStep, + ProvidersSetupStep, GitHubSetupStep, } from './setup-view/steps'; import { useNavigate } from '@tanstack/react-router'; @@ -17,30 +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', - 'opencode', - '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 === 'opencode') return 'opencode'; + 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) => { @@ -51,22 +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': - logger.debug('[Setup Flow] Moving to opencode step'); - setCurrentStep('opencode'); - break; - case 'opencode': + case 'providers': logger.debug('[Setup Flow] Moving to github step'); setCurrentStep('github'); break; @@ -83,45 +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 'opencode': - setCurrentStep('codex'); - break; case 'github': - setCurrentStep('opencode'); + 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('opencode'); - }; - - const handleSkipOpencode = () => { - logger.debug('[Setup Flow] Skipping OpenCode setup'); - setCurrentStep('github'); - }; - const handleSkipGithub = () => { logger.debug('[Setup Flow] Skipping GitHub setup'); setCurrentStep('complete'); @@ -160,35 +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 === 'opencode' && ( - handleNext('opencode')} - onBack={() => handleBack('opencode')} - onSkip={handleSkipOpencode} + {(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 0c25aaed..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,8 +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 { OpencodeSetupStep } from './opencode-setup-step'; -export { GitHubSetupStep } from './github-setup-step'; diff --git a/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx index a185d888..afb40b6d 100644 --- 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 @@ -96,7 +96,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP try { // Copy login command to clipboard and show instructions - const loginCommand = opencodeCliStatus?.loginCommand || 'opencode login'; + const loginCommand = opencodeCliStatus?.loginCommand || 'opencode auth login'; await navigator.clipboard.writeText(loginCommand); toast.info('Login command copied! Paste in terminal to authenticate.'); @@ -297,13 +297,13 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP

- {opencodeCliStatus?.loginCommand || 'opencode login'} + {opencodeCliStatus?.loginCommand || 'opencode auth login'} +
+ + Choose one of the following methods to authenticate with Claude: + + + + + {/* CLI Option */} + + +
+
+ +
+

Claude CLI

+

Use Claude Code subscription

+
+
+ +
+
+ + {!claudeCliStatus?.installed && ( +
+
+ +

Install Claude CLI

+
+
+ +
+ + curl -fsSL https://claude.ai/install.sh | bash + + +
+
+ {isInstalling && } + +
+ )} + + {cliVerificationStatus === 'verified' && ( +
+ +

CLI Authentication verified!

+
+ )} + + {cliVerificationStatus === 'error' && cliVerificationError && ( +
+ +
+

Verification failed

+

{cliVerificationError}

+
+
+ )} + + {cliVerificationStatus !== 'verified' && ( + + )} +
+
+ + {/* API Key Option */} + + +
+
+ +
+

Anthropic API Key

+

+ Pay-per-use with your own API key +

+
+
+ +
+
+ +
+
+ + setApiKey(e.target.value)} + className="bg-input border-border text-foreground" + /> +

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

+
+
+ + {hasApiKey && ( + + )} +
+
+ + {apiKeyVerificationStatus === 'verified' && ( +
+ +

API Key verified!

+
+ )} + + {apiKeyVerificationStatus === 'error' && apiKeyVerificationError && ( +
+ +
+

Verification failed

+

{apiKeyVerificationError}

+
+
+ )} + + {apiKeyVerificationStatus !== 'verified' && ( + + )} +
+
+
+
+ + ); +} + +// ============================================================================ +// 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 && ( +
+ +

Cursor CLI is ready!

+
+ )} + + {!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 && ( +
+
+ +
+

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, + }); + 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 && ( +
+ +

Codex CLI is ready!

+
+ )} + + {!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 && ( + + + +
+ + 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 && ( +
+ +

OpenCode CLI is ready!

+
+ )} + + {!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 && ( +
+
+ +
+

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 { claudeAuthStatus, cursorCliStatus, codexAuthStatus, opencodeCliStatus } = useSetupStore(); + + const isClaudeConfigured = + claudeAuthStatus?.authenticated === true && + (claudeAuthStatus?.method === 'cli_authenticated' || + claudeAuthStatus?.method === 'api_key' || + claudeAuthStatus?.method === 'api_key_env'); + + const isCursorConfigured = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated; + const isCodexConfigured = codexAuthStatus?.authenticated === true; + const isOpencodeConfigured = + opencodeCliStatus?.installed && opencodeCliStatus?.auth?.authenticated; + + const hasAtLeastOneProvider = + isClaudeConfigured || isCursorConfigured || isCodexConfigured || isOpencodeConfigured; + + const providers = [ + { + id: 'claude' as const, + label: 'Claude', + icon: AnthropicIcon, + configured: isClaudeConfigured, + color: 'text-brand-500', + }, + { + id: 'cursor' as const, + label: 'Cursor', + icon: CursorIcon, + configured: isCursorConfigured, + color: 'text-blue-500', + }, + { + id: 'codex' as const, + label: 'Codex', + icon: OpenAIIcon, + configured: isCodexConfigured, + color: 'text-emerald-500', + }, + { + id: 'opencode' as const, + label: 'OpenCode', + icon: OpenCodeIcon, + configured: isOpencodeConfigured, + color: 'text-green-500', + }, + ]; + + return ( +
+
+

AI Provider Setup

+

Configure at least one AI provider to continue

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

+ You can configure providers later in Settings +

+ )} +
+ ); +} diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index b8e7f717..386896ee 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -113,6 +113,7 @@ export interface InstallProgress { export type SetupStep = | 'welcome' | 'theme' + | 'providers' | 'claude_detect' | 'claude_auth' | 'cursor' diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index a0cbff27..c1faee26 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -1019,7 +1019,7 @@ export async function getCodexAuthIndicators(): Promise { // OpenCode CLI Detection // ============================================================================= -const OPENCODE_CONFIG_DIR_NAME = '.opencode'; +const OPENCODE_DATA_DIR = '.local/share/opencode'; const OPENCODE_AUTH_FILENAME = 'auth.json'; const OPENCODE_TOKENS_KEY = 'tokens'; @@ -1092,10 +1092,12 @@ export function getOpenCodeCliPaths(): string[] { } /** - * Get the OpenCode configuration directory path + * 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_CONFIG_DIR_NAME); + return path.join(os.homedir(), OPENCODE_DATA_DIR); } /** @@ -1121,6 +1123,9 @@ export interface OpenCodeAuthIndicators { 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)) { @@ -1129,6 +1134,49 @@ function getOpenCodeNestedTokens(record: Record): 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 */ @@ -1145,8 +1193,12 @@ export async function getOpenCodeAuthIndicators(): Promise; + + // 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 = @@ -1154,6 +1206,11 @@ export async function getOpenCodeAuthIndicators(): Promise Date: Fri, 9 Jan 2026 10:08:38 -0500 Subject: [PATCH 5/6] feat: enhance OpenCode provider tests and UI setup - Updated unit tests for OpenCode provider to include new authentication indicators. - Refactored ProvidersSetupStep component by removing unnecessary UI elements for better clarity. - Improved board background persistence tests by utilizing a setup function for initializing app state. - Enhanced settings synchronization tests to ensure proper handling of login and app state. These changes improve the testing framework and user interface for OpenCode integration, ensuring a smoother setup and authentication process. --- .../unit/providers/opencode-provider.test.ts | 19 ++- .../setup-view/steps/providers-setup-step.tsx | 19 --- .../board-background-persistence.spec.ts | 148 +++++++++++------- .../settings-startup-sync-race.spec.ts | 4 +- libs/types/src/provider.ts | 1 + 5 files changed, 111 insertions(+), 80 deletions(-) diff --git a/apps/server/tests/unit/providers/opencode-provider.test.ts b/apps/server/tests/unit/providers/opencode-provider.test.ts index e20e5e67..b33217a8 100644 --- a/apps/server/tests/unit/providers/opencode-provider.test.ts +++ b/apps/server/tests/unit/providers/opencode-provider.test.ts @@ -5,7 +5,7 @@ import { } from '../../../src/providers/opencode-provider.js'; import type { ProviderMessage } from '@automaker/types'; import { collectAsyncGenerator } from '../../utils/helpers.js'; -import { spawnJSONLProcess } from '@automaker/platform'; +import { spawnJSONLProcess, getOpenCodeAuthIndicators } from '@automaker/platform'; vi.mock('@automaker/platform', () => ({ spawnJSONLProcess: vi.fn(), @@ -13,6 +13,11 @@ vi.mock('@automaker/platform', () => ({ 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', () => { @@ -25,7 +30,8 @@ describe('opencode-provider.ts', () => { }); afterEach(() => { - vi.restoreAllMocks(); + // Note: Don't use vi.restoreAllMocks() here as it would undo the module-level + // mock implementations (like getOpenCodeAuthIndicators) set up with vi.mock() }); // ========================================================================== @@ -815,6 +821,15 @@ describe('opencode-provider.ts', () => { // ========================================================================== 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'; 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 index 3b2ab4db..d412444f 100644 --- 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 @@ -1271,25 +1271,6 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
-
- {providers.map((provider) => ( -
- {provider.configured ? ( - - ) : ( -
- )} - {provider.label} -
- ))} -
-
- Choose one of the following methods to authenticate with Claude: + {claudeCliStatus?.installed + ? claudeAuthStatus?.authenticated + ? `Authenticated${claudeCliStatus.version ? ` (v${claudeCliStatus.version})` : ''}` + : isVerifying + ? 'Verifying authentication...' + : 'Installed but not authenticated' + : 'Not installed on your system'} - - - {/* CLI Option */} - - -
-
- -
-

Claude CLI

-

Use Claude Code subscription

-
-
- + + {/* Success State - CLI Ready */} + {isReady && ( +
+
+ +
+

CLI Installed

+

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

- - - {!claudeCliStatus?.installed && ( -
-
- -

Install Claude CLI

-
-
- -
- - curl -fsSL https://claude.ai/install.sh | bash - - -
-
- {isInstalling && } +
+
+ +

+ {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 +
- )} - - {cliVerificationStatus === 'verified' && ( -
- -

CLI Authentication verified!

-
- )} - - {cliVerificationStatus === 'error' && cliVerificationError && ( -
- -
-

Verification failed

-

{cliVerificationError}

-
-
- )} - - {cliVerificationStatus !== 'verified' && ( - - )} - - - - {/* API Key Option */} - - -
-
- -
-

Anthropic API Key

-

- Pay-per-use with your own API key -

-
-
-
-
- -
-
- - setApiKey(e.target.value)} - className="bg-input border-border text-foreground" - /> -

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

+
+ )} + + {/* Installed but not authenticated */} + {claudeCliStatus?.installed && + !claudeAuthStatus?.authenticated && + !isChecking && + !isVerifying && ( +
+ {/* Show CLI installed toast */} +
+ +
+

CLI Installed

+

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

-
- - {hasApiKey && ( - - )} -
- {apiKeyVerificationStatus === 'verified' && ( -
- -

API Key verified!

-
- )} - - {apiKeyVerificationStatus === 'error' && apiKeyVerificationError && ( + {/* Error state */} + {verificationError && (
-

Verification failed

-

{apiKeyVerificationError}

+

Authentication failed

+

{verificationError}

)} - {apiKeyVerificationStatus !== 'verified' && ( - - )} - - - + {/* 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 && ( + + )} +
+
+
+
+
+ )} ); @@ -599,9 +566,20 @@ function CursorContent() { {isReady && ( -
- -

Cursor CLI is ready!

+
+
+ +
+

CLI Installed

+

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

+
+
+
+ +

Authenticated

+
)} @@ -640,6 +618,17 @@ function CursorContent() { {cursorCliStatus?.installed && !cursorCliStatus?.auth?.authenticated && !isChecking && (
+ {/* Show CLI installed toast */} +
+ +
+

CLI Installed

+

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

+
+
+
@@ -715,6 +704,7 @@ function CodexContent() { installed: result.installed ?? false, version: result.version, path: result.path, + method: 'none', }); if (result.auth?.authenticated) { setCodexAuthStatus({ @@ -830,9 +820,22 @@ function CodexContent() { {isReady && ( -
- -

Codex CLI is ready!

+
+
+ +
+

CLI Installed

+

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

+
+
+
+ +

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

+
)} @@ -866,78 +869,101 @@ function CodexContent() { )} {codexCliStatus?.installed && !codexAuthStatus?.authenticated && !isChecking && ( - - - -
- - Codex CLI Login -
-
- -
- - codex login - - -
- -
-
+
+ {/* Show CLI installed toast */} +
+ +
+

CLI Installed

+

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

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

- - Get an API key from OpenAI - - -

-
- -
-
- +
+ +
+

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 && ( @@ -1069,9 +1095,20 @@ function OpencodeContent() { {isReady && ( -
- -

OpenCode CLI is ready!

+
+
+ +
+

CLI Installed

+

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

+
+
+
+ +

Authenticated

+
)} @@ -1112,6 +1149,17 @@ function OpencodeContent() { {opencodeCliStatus?.installed && !opencodeCliStatus?.auth?.authenticated && !isChecking && (
+ {/* Show CLI installed toast */} +
+ +
+

CLI Installed

+

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

+
+
+
@@ -1170,54 +1218,215 @@ function OpencodeContent() { // ============================================================================ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) { const [activeTab, setActiveTab] = useState('claude'); + const [isInitialChecking, setIsInitialChecking] = useState(true); + const hasCheckedRef = useRef(false); - const { claudeAuthStatus, cursorCliStatus, codexAuthStatus, opencodeCliStatus } = useSetupStore(); + const { + claudeCliStatus, + claudeAuthStatus, + claudeIsVerifying, + cursorCliStatus, + codexCliStatus, + codexAuthStatus, + opencodeCliStatus, + setClaudeCliStatus, + setCursorCliStatus, + setCodexCliStatus, + setCodexAuthStatus, + setOpencodeCliStatus, + } = useSetupStore(); - const isClaudeConfigured = + // 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 isCursorConfigured = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated; - const isCodexConfigured = codexAuthStatus?.authenticated === true; - const isOpencodeConfigured = - opencodeCliStatus?.installed && opencodeCliStatus?.auth?.authenticated; + 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 = - isClaudeConfigured || isCursorConfigured || isCodexConfigured || isOpencodeConfigured; + 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, - configured: isClaudeConfigured, + status: getProviderStatus(isClaudeInstalled, isClaudeAuthenticated, claudeIsVerifying), color: 'text-brand-500', }, { id: 'cursor' as const, label: 'Cursor', icon: CursorIcon, - configured: isCursorConfigured, + status: getProviderStatus(isCursorInstalled, isCursorAuthenticated), color: 'text-blue-500', }, { id: 'codex' as const, label: 'Codex', icon: OpenAIIcon, - configured: isCodexConfigured, + status: getProviderStatus(isCodexInstalled, isCodexAuthenticated), color: 'text-emerald-500', }, { id: 'opencode' as const, label: 'OpenCode', icon: OpenCodeIcon, - configured: isOpencodeConfigured, + 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 (
@@ -1225,6 +1434,13 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)

Configure at least one AI provider to continue

+ {isInitialChecking && ( +
+ +

Checking provider status...

+
+ )} + setActiveTab(v as ProviderTab)}> {providers.map((provider) => { @@ -1242,12 +1458,16 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) - {provider.configured && ( - - )} + {!isInitialChecking && renderStatusIcon(provider.status)}
{provider.label} diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index 386896ee..6b872819 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -132,6 +132,7 @@ export interface SetupState { claudeCliStatus: CliStatus | null; claudeAuthStatus: ClaudeAuthStatus | null; claudeInstallProgress: InstallProgress; + claudeIsVerifying: boolean; // GitHub CLI state ghCliStatus: GhCliStatus | null; @@ -164,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; @@ -202,6 +204,7 @@ const initialState: SetupState = { claudeCliStatus: null, claudeAuthStatus: null, claudeInstallProgress: { ...initialInstallProgress }, + claudeIsVerifying: false, ghCliStatus: null, cursorCliStatus: null, @@ -255,6 +258,8 @@ export const useSetupStore = create()((set, get) => ( claudeInstallProgress: { ...initialInstallProgress }, }), + setClaudeIsVerifying: (isVerifying) => set({ claudeIsVerifying: isVerifying }), + // GitHub CLI setGhCliStatus: (status) => set({ ghCliStatus: status }),