mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
opencode support
This commit is contained in:
@@ -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';
|
||||
|
||||
605
apps/server/src/providers/opencode-provider.ts
Normal file
605
apps/server/src/providers/opencode-provider.ts
Normal file
@@ -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', '<cwd>' for working directory
|
||||
* - '--model', '<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<InstallationStatus> {
|
||||
this.ensureCliDetected();
|
||||
|
||||
const installed = await this.isInstalled();
|
||||
|
||||
return {
|
||||
installed,
|
||||
path: this.cliPath || undefined,
|
||||
method: this.detectedStrategy === 'npx' ? 'npm' : 'cli',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
1262
apps/server/tests/unit/providers/opencode-provider.test.ts
Normal file
1262
apps/server/tests/unit/providers/opencode-provider.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 () => {
|
||||
|
||||
@@ -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<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.grok} {...props} />;
|
||||
}
|
||||
|
||||
export function OpenCodeIcon({ className, ...props }: { className?: string }) {
|
||||
return <Cpu className={cn('inline-block', className)} {...props} />;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 <CursorSettingsTab />;
|
||||
case 'codex-provider':
|
||||
return <CodexSettingsTab />;
|
||||
case 'opencode-provider':
|
||||
return <OpencodeSettingsTab />;
|
||||
case 'providers':
|
||||
case 'claude': // Backwards compatibility - redirect to claude-provider
|
||||
return <ClaudeSettingsTab />;
|
||||
|
||||
@@ -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 <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
|
||||
}
|
||||
|
||||
export function OpencodeCliStatusSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<SkeletonPulse className="w-9 h-9 rounded-xl" />
|
||||
<SkeletonPulse className="h-6 w-36" />
|
||||
</div>
|
||||
<SkeletonPulse className="w-9 h-9 rounded-lg" />
|
||||
</div>
|
||||
<div className="ml-12">
|
||||
<SkeletonPulse className="h-4 w-80" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Installation status skeleton */}
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
|
||||
<SkeletonPulse className="w-10 h-10 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<SkeletonPulse className="h-4 w-40" />
|
||||
<SkeletonPulse className="h-3 w-32" />
|
||||
<SkeletonPulse className="h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Auth status skeleton */}
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
|
||||
<SkeletonPulse className="w-10 h-10 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<SkeletonPulse className="h-4 w-28" />
|
||||
<SkeletonPulse className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpencodeModelConfigSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<SkeletonPulse className="w-9 h-9 rounded-xl" />
|
||||
<SkeletonPulse className="h-6 w-40" />
|
||||
</div>
|
||||
<div className="ml-12">
|
||||
<SkeletonPulse className="h-4 w-72" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Default Model skeleton */}
|
||||
<div className="space-y-2">
|
||||
<SkeletonPulse className="h-4 w-24" />
|
||||
<SkeletonPulse className="h-10 w-full rounded-md" />
|
||||
</div>
|
||||
{/* Available Models skeleton */}
|
||||
<div className="space-y-3">
|
||||
<SkeletonPulse className="h-4 w-32" />
|
||||
{/* Provider group skeleton */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<SkeletonPulse className="w-4 h-4 rounded" />
|
||||
<SkeletonPulse className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between p-3 rounded-xl border border-border/30 bg-muted/10"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<SkeletonPulse className="w-5 h-5 rounded" />
|
||||
<div className="space-y-1.5">
|
||||
<SkeletonPulse className="h-4 w-32" />
|
||||
<SkeletonPulse className="h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonPulse className="h-5 w-12 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpencodeCliStatus({
|
||||
status,
|
||||
authStatus,
|
||||
isChecking,
|
||||
onRefresh,
|
||||
}: OpencodeCliStatusProps) {
|
||||
if (!status) return <OpencodeCliStatusSkeleton />;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Bot className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">OpenCode CLI</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={isChecking}
|
||||
data-testid="refresh-opencode-cli"
|
||||
title="Refresh OpenCode CLI detection"
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-lg',
|
||||
'hover:bg-accent/50 hover:scale-105',
|
||||
'transition-all duration-200'
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
OpenCode CLI provides multi-provider AI support with Claude, GPT, and Gemini models.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{status.success && status.status === 'installed' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-emerald-400">OpenCode CLI Installed</p>
|
||||
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
|
||||
{status.method && (
|
||||
<p>
|
||||
Method: <span className="font-mono">{status.method}</span>
|
||||
</p>
|
||||
)}
|
||||
{status.version && (
|
||||
<p>
|
||||
Version: <span className="font-mono">{status.version}</span>
|
||||
</p>
|
||||
)}
|
||||
{status.path && (
|
||||
<p className="truncate" title={status.path}>
|
||||
Path: <span className="font-mono text-[10px]">{status.path}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication Status */}
|
||||
{authStatus?.authenticated ? (
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
|
||||
<div className="text-xs text-emerald-400/70 mt-1.5">
|
||||
<p>
|
||||
Method:{' '}
|
||||
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
|
||||
<XCircle className="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-amber-400">Not Authenticated</p>
|
||||
<p className="text-xs text-amber-400/70 mt-1">
|
||||
Run{' '}
|
||||
<code className="font-mono bg-amber-500/10 px-1 rounded">opencode auth</code> or
|
||||
set an API key to authenticate.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.recommendation && (
|
||||
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-amber-400">OpenCode CLI Not Detected</p>
|
||||
<p className="text-xs text-amber-400/70 mt-1">
|
||||
{status.recommendation || 'Install OpenCode CLI to use multi-provider AI models.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status.installCommands && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
|
||||
<div className="space-y-2">
|
||||
{status.installCommands.npm && (
|
||||
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
|
||||
npm
|
||||
</p>
|
||||
<code className="text-xs text-foreground/80 font-mono break-all">
|
||||
{status.installCommands.npm}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{status.installCommands.macos && (
|
||||
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
|
||||
macOS/Linux
|
||||
</p>
|
||||
<code className="text-xs text-foreground/80 font-mono break-all">
|
||||
{status.installCommands.macos}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -7,6 +7,7 @@ export type SettingsViewId =
|
||||
| 'claude-provider'
|
||||
| 'cursor-provider'
|
||||
| 'codex-provider'
|
||||
| 'opencode-provider'
|
||||
| 'mcp-servers'
|
||||
| 'prompts'
|
||||
| 'model-defaults'
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<OpencodeProvider, OpencodeModelConfig[]>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Terminal className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Model Configuration
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure which OpenCode models are available in the feature modal
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Default Model Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Default Model</Label>
|
||||
<Select
|
||||
value={opencodeDefaultModel}
|
||||
onValueChange={(v) => onDefaultModelChange(v as OpencodeModelId)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledOpencodeModels.map((modelId) => {
|
||||
const model = OPENCODE_MODEL_CONFIG_MAP[modelId];
|
||||
if (!model) return null;
|
||||
const ProviderIconComponent = getProviderIcon(model.provider);
|
||||
return (
|
||||
<SelectItem key={modelId} value={modelId}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconComponent className="w-4 h-4" />
|
||||
<span>{model.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Available Models grouped by provider */}
|
||||
<div className="space-y-4">
|
||||
<Label>Available Models</Label>
|
||||
{providerOrder.map((provider) => {
|
||||
const models = modelsByProvider[provider];
|
||||
if (!models || models.length === 0) return null;
|
||||
|
||||
const ProviderIconComponent = getProviderIcon(provider);
|
||||
|
||||
return (
|
||||
<div key={provider} className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ProviderIconComponent className="w-4 h-4" />
|
||||
<span className="font-medium">{getProviderLabel(provider)}</span>
|
||||
{provider === 'opencode' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-green-500/10 text-green-500 border-green-500/30"
|
||||
>
|
||||
Free
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{models.map((model) => {
|
||||
const isEnabled = enabledOpencodeModels.includes(model.id);
|
||||
const isDefault = model.id === opencodeDefaultModel;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl border border-border/50 bg-card/50 hover:bg-accent/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) => onModelToggle(model.id, !!checked)}
|
||||
disabled={isSaving || isDefault}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{model.label}</span>
|
||||
{model.supportsVision && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Vision
|
||||
</Badge>
|
||||
)}
|
||||
{model.tier === 'free' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-green-500/10 text-green-500 border-green-500/30"
|
||||
>
|
||||
Free
|
||||
</Badge>
|
||||
)}
|
||||
{isDefault && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{model.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<SharedCliStatus | null>(null);
|
||||
const [authStatus, setAuthStatus] = useState<OpencodeAuthStatus | null>(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 (
|
||||
<div className="space-y-6">
|
||||
<OpencodeCliStatusSkeleton />
|
||||
<OpencodeModelConfigSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isCliInstalled = cliStatus?.success && cliStatus?.status === 'installed';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<OpencodeCliStatus
|
||||
status={cliStatus}
|
||||
authStatus={authStatus}
|
||||
isChecking={isCheckingOpencodeCli}
|
||||
onRefresh={handleRefreshOpencodeCli}
|
||||
/>
|
||||
|
||||
{/* Model Configuration - Only show when CLI is installed */}
|
||||
{isCliInstalled && (
|
||||
<OpencodeModelConfiguration
|
||||
enabledOpencodeModels={enabledOpencodeModels}
|
||||
opencodeDefaultModel={opencodeDefaultModel}
|
||||
isSaving={isSaving}
|
||||
onDefaultModelChange={handleDefaultModelChange}
|
||||
onModelToggle={handleModelToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OpencodeSettingsTab;
|
||||
@@ -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 (
|
||||
<Tabs defaultValue={defaultTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 mb-6">
|
||||
<TabsList className="grid w-full grid-cols-4 mb-6">
|
||||
<TabsTrigger value="claude" className="flex items-center gap-2">
|
||||
<AnthropicIcon className="w-4 h-4" />
|
||||
Claude
|
||||
@@ -25,6 +27,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
||||
<OpenAIIcon className="w-4 h-4" />
|
||||
Codex
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="opencode" className="flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4" />
|
||||
OpenCode
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="claude">
|
||||
@@ -38,6 +44,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
||||
<TabsContent value="codex">
|
||||
<CodexSettingsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="opencode">
|
||||
<OpencodeSettingsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' && (
|
||||
<OpencodeSetupStep
|
||||
onNext={() => handleNext('opencode')}
|
||||
onBack={() => handleBack('opencode')}
|
||||
onSkip={handleSkipOpencode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'github' && (
|
||||
<GitHubSetupStep
|
||||
onNext={() => handleNext('github')}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<NodeJS.Timeout | null>(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 <StatusBadge status="checking" label="Checking..." />;
|
||||
}
|
||||
if (opencodeCliStatus?.auth?.authenticated) {
|
||||
return <StatusBadge status="authenticated" label="Ready" />;
|
||||
}
|
||||
if (opencodeCliStatus?.installed) {
|
||||
return <StatusBadge status="unverified" label="Not Logged In" />;
|
||||
}
|
||||
return <StatusBadge status="not_installed" label="Not Installed" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-xl bg-green-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<Terminal className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">OpenCode CLI Setup</h2>
|
||||
<p className="text-muted-foreground">Optional - Use OpenCode as an AI provider</p>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<Card className="bg-green-500/10 border-green-500/20">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-green-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">This step is optional</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status Card */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5" />
|
||||
OpenCode CLI Status
|
||||
<Badge variant="outline" className="ml-2">
|
||||
Optional
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge()}
|
||||
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
|
||||
<RefreshCw className={`w-4 h-4 ${isChecking ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{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'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Success State */}
|
||||
{isReady && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">OpenCode CLI is ready!</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can use OpenCode models for AI tasks.
|
||||
{opencodeCliStatus?.version && (
|
||||
<span className="ml-1">Version: {opencodeCliStatus.version}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Not Installed */}
|
||||
{!opencodeCliStatus?.installed && !isChecking && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-muted/30 border border-border">
|
||||
<XCircle className="w-5 h-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-foreground">OpenCode CLI not found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Install the OpenCode CLI to use free tier and AWS Bedrock models.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
|
||||
<p className="font-medium text-foreground text-sm">Install OpenCode CLI:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground overflow-x-auto">
|
||||
{opencodeCliStatus?.installCommand || 'npm install -g opencode'}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
copyCommand(opencodeCliStatus?.installCommand || 'npm install -g opencode')
|
||||
}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<a
|
||||
href="https://github.com/opencode-ai/opencode"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-sm text-brand-500 hover:underline mt-2"
|
||||
>
|
||||
View installation docs
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Installed but not authenticated */}
|
||||
{opencodeCliStatus?.installed &&
|
||||
!opencodeCliStatus?.auth?.authenticated &&
|
||||
!isChecking && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-foreground">OpenCode CLI not authenticated</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Run the login command to authenticate with OpenCode.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run the login command in your terminal, then complete authentication in your
|
||||
browser:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
{opencodeCliStatus?.loginCommand || 'opencode login'}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
copyCommand(opencodeCliStatus?.loginCommand || 'opencode login')
|
||||
}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoggingIn}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Waiting for login...
|
||||
</>
|
||||
) : (
|
||||
'Copy Command & Wait for Login'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isChecking && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Checking OpenCode CLI status...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="ghost" onClick={onBack} className="text-muted-foreground">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onSkip} className="text-muted-foreground">
|
||||
{isReady ? 'Skip' : 'Skip for now'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid="opencode-next-button"
|
||||
>
|
||||
{isReady ? 'Continue' : 'Continue without OpenCode'}
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info note */}
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
You can always configure OpenCode later in Settings
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
setCodexEnableImages: (enabled: boolean) => Promise<void>;
|
||||
|
||||
// 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<void>;
|
||||
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
|
||||
@@ -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<AppState & AppActions>()((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;
|
||||
|
||||
@@ -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<InstallProgress>) => 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<SetupState & SetupActions>()((set, get) => (
|
||||
codexInstallProgress: { ...initialInstallProgress },
|
||||
}),
|
||||
|
||||
// OpenCode CLI
|
||||
setOpencodeCliStatus: (status) => set({ opencodeCliStatus: status }),
|
||||
|
||||
// Preferences
|
||||
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CodexAuthIndicators> {
|
||||
|
||||
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<string | null> {
|
||||
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<string, unknown>): Record<string, unknown> | null {
|
||||
const tokens = record[OPENCODE_TOKENS_KEY];
|
||||
if (tokens && typeof tokens === 'object' && !Array.isArray(tokens)) {
|
||||
return tokens as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OpenCode authentication status by checking auth file indicators
|
||||
*/
|
||||
export async function getOpenCodeAuthIndicators(): Promise<OpenCodeAuthIndicators> {
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
397
libs/types/src/opencode-models.ts
Normal file
397
libs/types/src/opencode-models.ts
Normal file
@@ -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<string, OpencodeModelId> = {
|
||||
// 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<OpencodeModelId, OpencodeModelConfig> =
|
||||
OPENCODE_MODELS.reduce(
|
||||
(acc, config) => {
|
||||
acc[config.id] = config;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<OpencodeModelId, OpencodeModelConfig>
|
||||
);
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user