From f480386905dc783d2af7c654e0b7f4e32cd12bbe Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Fri, 23 Jan 2026 01:42:17 +0100 Subject: [PATCH] feat: add Gemini CLI provider integration (#647) * feat: add Gemini CLI provider for AI model execution - Add GeminiProvider class extending CliProvider for Gemini CLI integration - Add Gemini models (Gemini 3 Pro/Flash Preview, 2.5 Pro/Flash/Flash-Lite) - Add gemini-models.ts with model definitions and types - Update ModelProvider type to include 'gemini' - Add isGeminiModel() to provider-utils.ts for model detection - Register Gemini provider in provider-factory with priority 4 - Add Gemini setup detection routes (status, auth, deauth) - Add GeminiCliStatus to setup store for UI state management - Add Gemini to PROVIDER_ICON_COMPONENTS for UI icon display - Add GEMINI_MODELS to model-display for dropdown population - Support thinking levels: off, low, medium, high Based on https://github.com/google-gemini/gemini-cli * chore: update package-lock.json * feat(ui): add Gemini provider to settings and setup wizard - Add GeminiCliStatus component for CLI detection display - Add GeminiSettingsTab component for global settings - Update provider-tabs.tsx to include Gemini as 5th tab - Update providers-setup-step.tsx with Gemini provider detection - Add useGeminiCliStatus hook for querying CLI status - Add getGeminiStatus, authGemini, deauthGemini to HTTP API client - Add gemini query key for React Query - Fix GeminiModelId type to not double-prefix model IDs * feat(ui): add Gemini to settings sidebar navigation - Add 'gemini-provider' to SettingsViewId type - Add GeminiIcon and gemini-provider to navigation config - Add gemini-provider to NAV_ID_TO_PROVIDER mapping - Add gemini-provider case in settings-view switch - Export GeminiSettingsTab from providers index This fixes the missing Gemini entry in the AI Providers sidebar menu. * feat(ui): add Gemini model configuration in settings - Create GeminiModelConfiguration component for model selection - Add enabledGeminiModels and geminiDefaultModel state to app-store - Add setEnabledGeminiModels, setGeminiDefaultModel, toggleGeminiModel actions - Update GeminiSettingsTab to show model configuration when CLI is installed - Import GeminiModelId and getAllGeminiModelIds from types This adds the ability to configure which Gemini models are available in the feature modal, similar to other providers like Codex and OpenCode. * feat(ui): add Gemini models to all model dropdowns - Add GEMINI_MODELS to model-constants.ts for UI dropdowns - Add Gemini to ALL_MODELS array used throughout the app - Add GeminiIcon to PROFILE_ICONS mapping - Fix GEMINI_MODELS in model-display.ts to use correct model IDs - Update getModelDisplayName to handle Gemini models correctly Gemini models now appear in all model selection dropdowns including Model Defaults, Feature Defaults, and feature card settings. * fix(gemini): fix CLI integration and event handling - Fix model ID prefix handling: strip gemini- prefix in agent-service, add it back in buildCliArgs for CLI invocation - Fix event normalization to match actual Gemini CLI output format: - type: 'init' (not 'system') - type: 'message' with role (not 'assistant') - tool_name/tool_id/parameters/output field names - Add --sandbox false and --approval-mode yolo for faster execution - Remove thinking level selector from UI (Gemini CLI doesn't support it) - Update auth status to show errors properly * test: update provider-factory tests for Gemini provider - Add GeminiProvider import and spy mock - Update expected provider count from 4 to 5 - Add test for GeminiProvider inclusion - Add gemini key to checkAllProviders test * fix(gemini): address PR review feedback - Fix npm package name from @anthropic-ai/gemini-cli to @google/gemini-cli - Fix comments in gemini-provider.ts to match actual CLI output format - Convert sync fs operations to async using fs/promises * fix(settings): add Gemini and Codex settings to sync Add enabledGeminiModels, geminiDefaultModel, enabledCodexModels, and codexDefaultModel to SETTINGS_FIELDS_TO_SYNC for persistence across sessions. * fix(gemini): address additional PR review feedback - Use 'Speed' badge for non-thinking Gemini models (consistency) - Fix installCommand mapping in gemini-settings-tab.tsx - Add hasEnvApiKey to GeminiCliStatus interface for API parity - Clarify GeminiThinkingLevel comment (CLI doesn't support --thinking-level) * fix(settings): restore Codex and Gemini settings from server Add sanitization and restoration logic for enabledCodexModels, codexDefaultModel, enabledGeminiModels, and geminiDefaultModel in refreshSettingsFromServer() to match the fields in SETTINGS_FIELDS_TO_SYNC. * feat(gemini): normalize tool names and fix workspace restrictions - Add tool name mapping to normalize Gemini CLI tool names to standard names (e.g., write_todos -> TodoWrite, read_file -> Read) - Add normalizeGeminiToolInput to convert write_todos format to TodoWrite format (description -> content, handle cancelled status) - Pass --include-directories with cwd to fix workspace restriction errors when Gemini CLI has a different cached workspace from previous sessions --------- Co-authored-by: Claude --- apps/server/src/providers/gemini-provider.ts | 815 ++++++++++++++++++ apps/server/src/providers/provider-factory.ts | 22 +- apps/server/src/routes/setup/index.ts | 8 + .../src/routes/setup/routes/auth-gemini.ts | 42 + .../src/routes/setup/routes/deauth-gemini.ts | 42 + .../src/routes/setup/routes/gemini-status.ts | 79 ++ .../unit/providers/provider-factory.test.ts | 19 +- apps/ui/src/components/ui/provider-icon.tsx | 1 + .../board-view/shared/model-constants.ts | 28 +- .../ui/src/components/views/settings-view.tsx | 3 + .../cli-status/gemini-cli-status.tsx | 250 ++++++ .../components/settings-navigation.tsx | 1 + .../views/settings-view/config/navigation.ts | 9 +- .../settings-view/hooks/use-settings-view.ts | 1 + .../model-defaults/phase-model-selector.tsx | 102 ++- .../providers/gemini-model-configuration.tsx | 146 ++++ .../providers/gemini-settings-tab.tsx | 130 +++ .../views/settings-view/providers/index.ts | 1 + .../settings-view/providers/provider-tabs.tsx | 24 +- .../setup-view/steps/providers-setup-step.tsx | 365 +++++++- apps/ui/src/hooks/queries/index.ts | 1 + apps/ui/src/hooks/queries/use-cli-status.ts | 20 + apps/ui/src/hooks/use-settings-sync.ts | 44 + apps/ui/src/lib/http-api-client.ts | 42 + apps/ui/src/lib/query-keys.ts | 2 + apps/ui/src/store/app-store.ts | 24 + apps/ui/src/store/setup-store.ts | 28 + libs/types/src/gemini-models.ts | 101 +++ libs/types/src/index.ts | 5 + libs/types/src/model-display.ts | 36 +- libs/types/src/model.ts | 6 +- libs/types/src/provider-utils.ts | 36 +- libs/types/src/settings.ts | 2 +- 33 files changed, 2408 insertions(+), 27 deletions(-) create mode 100644 apps/server/src/providers/gemini-provider.ts create mode 100644 apps/server/src/routes/setup/routes/auth-gemini.ts create mode 100644 apps/server/src/routes/setup/routes/deauth-gemini.ts create mode 100644 apps/server/src/routes/setup/routes/gemini-status.ts create mode 100644 apps/ui/src/components/views/settings-view/cli-status/gemini-cli-status.tsx create mode 100644 apps/ui/src/components/views/settings-view/providers/gemini-model-configuration.tsx create mode 100644 apps/ui/src/components/views/settings-view/providers/gemini-settings-tab.tsx create mode 100644 libs/types/src/gemini-models.ts diff --git a/apps/server/src/providers/gemini-provider.ts b/apps/server/src/providers/gemini-provider.ts new file mode 100644 index 00000000..9e09c462 --- /dev/null +++ b/apps/server/src/providers/gemini-provider.ts @@ -0,0 +1,815 @@ +/** + * Gemini Provider - Executes queries using the Gemini CLI + * + * Extends CliProvider with Gemini-specific: + * - Event normalization for Gemini's JSONL streaming format + * - Google account and API key authentication support + * - Thinking level configuration + * + * Based on https://github.com/google-gemini/gemini-cli + */ + +import { execSync } from 'child_process'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js'; +import type { + ProviderConfig, + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, + ContentBlock, +} from './types.js'; +import { validateBareModelId } from '@automaker/types'; +import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types'; +import { createLogger, isAbortError } from '@automaker/utils'; +import { spawnJSONLProcess } from '@automaker/platform'; + +// Create logger for this module +const logger = createLogger('GeminiProvider'); + +// ============================================================================= +// Gemini Stream Event Types +// ============================================================================= + +/** + * Base event structure from Gemini CLI --output-format stream-json + * + * Actual CLI output format: + * {"type":"init","timestamp":"...","session_id":"...","model":"..."} + * {"type":"message","timestamp":"...","role":"user","content":"..."} + * {"type":"message","timestamp":"...","role":"assistant","content":"...","delta":true} + * {"type":"tool_use","timestamp":"...","tool_name":"...","tool_id":"...","parameters":{...}} + * {"type":"tool_result","timestamp":"...","tool_id":"...","status":"success","output":"..."} + * {"type":"result","timestamp":"...","status":"success","stats":{...}} + */ +interface GeminiStreamEvent { + type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result' | 'error'; + timestamp?: string; + session_id?: string; +} + +interface GeminiInitEvent extends GeminiStreamEvent { + type: 'init'; + session_id: string; + model: string; +} + +interface GeminiMessageEvent extends GeminiStreamEvent { + type: 'message'; + role: 'user' | 'assistant'; + content: string; + delta?: boolean; + session_id?: string; +} + +interface GeminiToolUseEvent extends GeminiStreamEvent { + type: 'tool_use'; + tool_id: string; + tool_name: string; + parameters: Record; + session_id?: string; +} + +interface GeminiToolResultEvent extends GeminiStreamEvent { + type: 'tool_result'; + tool_id: string; + status: 'success' | 'error'; + output: string; + session_id?: string; +} + +interface GeminiResultEvent extends GeminiStreamEvent { + type: 'result'; + status: 'success' | 'error'; + stats?: { + total_tokens?: number; + input_tokens?: number; + output_tokens?: number; + cached?: number; + input?: number; + duration_ms?: number; + tool_calls?: number; + }; + error?: string; + session_id?: string; +} + +// ============================================================================= +// Error Codes +// ============================================================================= + +export enum GeminiErrorCode { + NOT_INSTALLED = 'GEMINI_NOT_INSTALLED', + NOT_AUTHENTICATED = 'GEMINI_NOT_AUTHENTICATED', + RATE_LIMITED = 'GEMINI_RATE_LIMITED', + MODEL_UNAVAILABLE = 'GEMINI_MODEL_UNAVAILABLE', + NETWORK_ERROR = 'GEMINI_NETWORK_ERROR', + PROCESS_CRASHED = 'GEMINI_PROCESS_CRASHED', + TIMEOUT = 'GEMINI_TIMEOUT', + UNKNOWN = 'GEMINI_UNKNOWN_ERROR', +} + +export interface GeminiError extends Error { + code: GeminiErrorCode; + recoverable: boolean; + suggestion?: string; +} + +// ============================================================================= +// Tool Name Normalization +// ============================================================================= + +/** + * Gemini CLI tool name to standard tool name mapping + * This allows the UI to properly categorize and display Gemini tool calls + */ +const GEMINI_TOOL_NAME_MAP: Record = { + write_todos: 'TodoWrite', + read_file: 'Read', + read_many_files: 'Read', + replace: 'Edit', + write_file: 'Write', + run_shell_command: 'Bash', + search_file_content: 'Grep', + glob: 'Glob', + list_directory: 'Ls', + web_fetch: 'WebFetch', + google_web_search: 'WebSearch', +}; + +/** + * Normalize Gemini tool names to standard tool names + */ +function normalizeGeminiToolName(geminiToolName: string): string { + return GEMINI_TOOL_NAME_MAP[geminiToolName] || geminiToolName; +} + +/** + * Normalize Gemini tool input parameters to standard format + * + * Gemini `write_todos` format: + * {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]} + * + * Claude `TodoWrite` format: + * {"todos": [{"content": "Task text", "status": "pending|in_progress|completed", "activeForm": "..."}]} + */ +function normalizeGeminiToolInput( + toolName: string, + input: Record +): Record { + // Normalize write_todos: map 'description' to 'content', handle 'cancelled' status + if (toolName === 'write_todos' && Array.isArray(input.todos)) { + return { + todos: input.todos.map((todo: { description?: string; status?: string }) => ({ + content: todo.description || '', + // Map 'cancelled' to 'completed' since Claude doesn't have cancelled status + status: todo.status === 'cancelled' ? 'completed' : todo.status, + // Use description as activeForm since Gemini doesn't have it + activeForm: todo.description || '', + })), + }; + } + return input; +} + +/** + * GeminiProvider - Integrates Gemini CLI as an AI provider + * + * Features: + * - Google account OAuth login support + * - API key authentication (GEMINI_API_KEY) + * - Vertex AI support + * - Thinking level configuration + * - Streaming JSON output + */ +export class GeminiProvider extends CliProvider { + constructor(config: ProviderConfig = {}) { + super(config); + // Trigger CLI detection on construction + this.ensureCliDetected(); + } + + // ========================================================================== + // CliProvider Abstract Method Implementations + // ========================================================================== + + getName(): string { + return 'gemini'; + } + + getCliName(): string { + return 'gemini'; + } + + getSpawnConfig(): CliSpawnConfig { + return { + windowsStrategy: 'npx', // Gemini CLI can be run via npx + npxPackage: '@google/gemini-cli', // Official Google Gemini CLI package + commonPaths: { + linux: [ + path.join(os.homedir(), '.local/bin/gemini'), + '/usr/local/bin/gemini', + path.join(os.homedir(), '.npm-global/bin/gemini'), + ], + darwin: [ + path.join(os.homedir(), '.local/bin/gemini'), + '/usr/local/bin/gemini', + '/opt/homebrew/bin/gemini', + path.join(os.homedir(), '.npm-global/bin/gemini'), + ], + win32: [ + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'), + path.join(os.homedir(), '.npm-global', 'gemini.cmd'), + ], + }, + }; + } + + /** + * Extract prompt text from ExecuteOptions + */ + private extractPromptText(options: ExecuteOptions): string { + if (typeof options.prompt === 'string') { + return options.prompt; + } else if (Array.isArray(options.prompt)) { + return options.prompt + .filter((p) => p.type === 'text' && p.text) + .map((p) => p.text) + .join('\n'); + } else { + throw new Error('Invalid prompt format'); + } + } + + buildCliArgs(options: ExecuteOptions): string[] { + // Model comes in stripped of provider prefix (e.g., '2.5-flash' from 'gemini-2.5-flash') + // We need to add 'gemini-' back since it's part of the actual CLI model name + const bareModel = options.model || '2.5-flash'; + const cliArgs: string[] = []; + + // Streaming JSON output format for real-time updates + cliArgs.push('--output-format', 'stream-json'); + + // Model selection - Gemini CLI expects full model names like "gemini-2.5-flash" + // Unlike Cursor CLI where 'cursor-' is just a routing prefix, for Gemini CLI + // the 'gemini-' is part of the actual model name Google expects + if (bareModel && bareModel !== 'auto') { + // Add gemini- prefix if not already present (handles edge cases) + const cliModel = bareModel.startsWith('gemini-') ? bareModel : `gemini-${bareModel}`; + cliArgs.push('--model', cliModel); + } + + // Disable sandbox mode for faster execution (sandbox adds overhead) + cliArgs.push('--sandbox', 'false'); + + // YOLO mode for automatic approval (required for non-interactive use) + // Use explicit approval-mode for clearer semantics + cliArgs.push('--approval-mode', 'yolo'); + + // Explicitly include the working directory in allowed workspace directories + // This ensures Gemini CLI allows file operations in the project directory, + // even if it has a different workspace cached from a previous session + if (options.cwd) { + cliArgs.push('--include-directories', options.cwd); + } + + // Note: Gemini CLI doesn't have a --thinking-level flag. + // Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro). + // The model handles thinking internally based on the task complexity. + + // The prompt will be passed as the last positional argument + // We'll append it in executeQuery after extracting the text + + return cliArgs; + } + + /** + * Convert Gemini event to AutoMaker ProviderMessage format + */ + normalizeEvent(event: unknown): ProviderMessage | null { + const geminiEvent = event as GeminiStreamEvent; + + switch (geminiEvent.type) { + case 'init': { + // Init event - capture session but don't yield a message + const initEvent = geminiEvent as GeminiInitEvent; + logger.debug( + `Gemini init event: session=${initEvent.session_id}, model=${initEvent.model}` + ); + return null; + } + + case 'message': { + const messageEvent = geminiEvent as GeminiMessageEvent; + + // Skip user messages - already handled by caller + if (messageEvent.role === 'user') { + return null; + } + + // Handle assistant messages + if (messageEvent.role === 'assistant') { + return { + type: 'assistant', + session_id: messageEvent.session_id, + message: { + role: 'assistant', + content: [{ type: 'text', text: messageEvent.content }], + }, + }; + } + + return null; + } + + case 'tool_use': { + const toolEvent = geminiEvent as GeminiToolUseEvent; + const normalizedName = normalizeGeminiToolName(toolEvent.tool_name); + const normalizedInput = normalizeGeminiToolInput( + toolEvent.tool_name, + toolEvent.parameters as Record + ); + + return { + type: 'assistant', + session_id: toolEvent.session_id, + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: normalizedName, + tool_use_id: toolEvent.tool_id, + input: normalizedInput, + }, + ], + }, + }; + } + + case 'tool_result': { + const toolResultEvent = geminiEvent as GeminiToolResultEvent; + // If tool result is an error, prefix with error indicator + const content = + toolResultEvent.status === 'error' + ? `[ERROR] ${toolResultEvent.output}` + : toolResultEvent.output; + return { + type: 'assistant', + session_id: toolResultEvent.session_id, + message: { + role: 'assistant', + content: [ + { + type: 'tool_result', + tool_use_id: toolResultEvent.tool_id, + content, + }, + ], + }, + }; + } + + case 'result': { + const resultEvent = geminiEvent as GeminiResultEvent; + + if (resultEvent.status === 'error') { + return { + type: 'error', + session_id: resultEvent.session_id, + error: resultEvent.error || 'Unknown error', + }; + } + + // Success result - include stats for logging + logger.debug( + `Gemini result: status=${resultEvent.status}, tokens=${resultEvent.stats?.total_tokens}` + ); + return { + type: 'result', + subtype: 'success', + session_id: resultEvent.session_id, + }; + } + + case 'error': { + const errorEvent = geminiEvent as GeminiResultEvent; + return { + type: 'error', + session_id: errorEvent.session_id, + error: errorEvent.error || 'Unknown error', + }; + } + + default: + logger.debug(`Unknown Gemini event type: ${geminiEvent.type}`); + return null; + } + } + + // ========================================================================== + // CliProvider Overrides + // ========================================================================== + + /** + * Override error mapping for Gemini-specific error codes + */ + protected mapError(stderr: string, exitCode: number | null): CliErrorInfo { + const lower = stderr.toLowerCase(); + + if ( + lower.includes('not authenticated') || + lower.includes('please log in') || + lower.includes('unauthorized') || + lower.includes('login required') || + lower.includes('error authenticating') || + lower.includes('loadcodeassist') || + (lower.includes('econnrefused') && lower.includes('8888')) + ) { + return { + code: GeminiErrorCode.NOT_AUTHENTICATED, + message: 'Gemini CLI is not authenticated', + recoverable: true, + suggestion: + 'Run "gemini" interactively to log in, or set GEMINI_API_KEY environment variable', + }; + } + + if ( + lower.includes('rate limit') || + lower.includes('too many requests') || + lower.includes('429') || + lower.includes('quota exceeded') + ) { + return { + code: GeminiErrorCode.RATE_LIMITED, + message: 'Gemini API rate limit exceeded', + recoverable: true, + suggestion: 'Wait a few minutes and try again. Free tier: 60 req/min, 1000 req/day', + }; + } + + if ( + lower.includes('model not available') || + lower.includes('invalid model') || + lower.includes('unknown model') || + lower.includes('modelnotfounderror') || + lower.includes('model not found') || + (lower.includes('not found') && lower.includes('404')) + ) { + return { + code: GeminiErrorCode.MODEL_UNAVAILABLE, + message: 'Requested model is not available', + recoverable: true, + suggestion: 'Try using "gemini-2.5-flash" or select a different model', + }; + } + + if ( + lower.includes('network') || + lower.includes('connection') || + lower.includes('econnrefused') || + lower.includes('timeout') + ) { + return { + code: GeminiErrorCode.NETWORK_ERROR, + message: 'Network connection error', + recoverable: true, + suggestion: 'Check your internet connection and try again', + }; + } + + if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) { + return { + code: GeminiErrorCode.PROCESS_CRASHED, + message: 'Gemini CLI process was terminated', + recoverable: true, + suggestion: 'The process may have run out of memory. Try a simpler task.', + }; + } + + return { + code: GeminiErrorCode.UNKNOWN, + message: stderr || `Gemini CLI exited with code ${exitCode}`, + recoverable: false, + }; + } + + /** + * Override install instructions for Gemini-specific guidance + */ + protected getInstallInstructions(): string { + return 'Install with: npm install -g @google/gemini-cli (or visit https://github.com/google-gemini/gemini-cli)'; + } + + /** + * Execute a prompt using Gemini CLI with streaming + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + this.ensureCliDetected(); + + // Validate that model doesn't have a provider prefix + validateBareModelId(options.model, 'GeminiProvider'); + + if (!this.cliPath) { + throw this.createError( + GeminiErrorCode.NOT_INSTALLED, + 'Gemini CLI is not installed', + true, + this.getInstallInstructions() + ); + } + + // Extract prompt text to pass as positional argument + const promptText = this.extractPromptText(options); + + // Build CLI args and append the prompt as the last positional argument + const cliArgs = this.buildCliArgs(options); + cliArgs.push(promptText); // Gemini CLI uses positional args for the prompt + + const subprocessOptions = this.buildSubprocessOptions(options, cliArgs); + + let sessionId: string | undefined; + + logger.debug(`GeminiProvider.executeQuery called with model: "${options.model}"`); + + try { + for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) { + const event = rawEvent as GeminiStreamEvent; + + // Capture session ID from init event + if (event.type === 'init') { + const initEvent = event as GeminiInitEvent; + sessionId = initEvent.session_id; + logger.debug(`Session started: ${sessionId}, model: ${initEvent.model}`); + } + + // Normalize and yield the event + const normalized = this.normalizeEvent(event); + if (normalized) { + if (!normalized.session_id && sessionId) { + normalized.session_id = sessionId; + } + yield normalized; + } + } + } catch (error) { + if (isAbortError(error)) { + logger.debug('Query aborted'); + return; + } + + // Map CLI errors to GeminiError + if (error instanceof Error && 'stderr' in error) { + const errorInfo = this.mapError( + (error as { stderr?: string }).stderr || error.message, + (error as { exitCode?: number | null }).exitCode ?? null + ); + throw this.createError( + errorInfo.code as GeminiErrorCode, + errorInfo.message, + errorInfo.recoverable, + errorInfo.suggestion + ); + } + throw error; + } + } + + // ========================================================================== + // Gemini-Specific Methods + // ========================================================================== + + /** + * Create a GeminiError with details + */ + private createError( + code: GeminiErrorCode, + message: string, + recoverable: boolean = false, + suggestion?: string + ): GeminiError { + const error = new Error(message) as GeminiError; + error.code = code; + error.recoverable = recoverable; + error.suggestion = suggestion; + error.name = 'GeminiError'; + return error; + } + + /** + * Get Gemini CLI version + */ + async getVersion(): Promise { + this.ensureCliDetected(); + if (!this.cliPath) return null; + + try { + const result = execSync(`"${this.cliPath}" --version`, { + encoding: 'utf8', + timeout: 5000, + stdio: 'pipe', + }).trim(); + return result; + } catch { + return null; + } + } + + /** + * Check authentication status + * + * Uses a fast credential check approach: + * 1. Check for GEMINI_API_KEY environment variable + * 2. Check for Google Cloud credentials + * 3. Check for Gemini settings file with stored credentials + * 4. Quick CLI auth test with --help (fast, doesn't make API calls) + */ + async checkAuth(): Promise { + this.ensureCliDetected(); + if (!this.cliPath) { + logger.debug('checkAuth: CLI not found'); + return { authenticated: false, method: 'none' }; + } + + logger.debug('checkAuth: Starting credential check'); + + // Determine the likely auth method based on environment + const hasApiKey = !!process.env.GEMINI_API_KEY; + const hasEnvApiKey = hasApiKey; + const hasVertexAi = !!( + process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.GOOGLE_CLOUD_PROJECT + ); + + logger.debug(`checkAuth: hasApiKey=${hasApiKey}, hasVertexAi=${hasVertexAi}`); + + // Check for Gemini credentials file (~/.gemini/settings.json) + const geminiConfigDir = path.join(os.homedir(), '.gemini'); + const settingsPath = path.join(geminiConfigDir, 'settings.json'); + let hasCredentialsFile = false; + let authType: string | null = null; + + try { + await fs.access(settingsPath); + logger.debug(`checkAuth: Found settings file at ${settingsPath}`); + try { + const content = await fs.readFile(settingsPath, 'utf8'); + const settings = JSON.parse(content); + + // Auth config is at security.auth.selectedType (e.g., "oauth-personal", "oauth-adc", "api-key") + const selectedType = settings?.security?.auth?.selectedType; + if (selectedType) { + hasCredentialsFile = true; + authType = selectedType; + logger.debug(`checkAuth: Settings file has auth config, selectedType=${selectedType}`); + } else { + logger.debug(`checkAuth: Settings file found but no auth type configured`); + } + } catch (e) { + logger.debug(`checkAuth: Failed to parse settings file: ${e}`); + } + } catch { + logger.debug('checkAuth: No settings file found'); + } + + // If we have an API key, we're authenticated + if (hasApiKey) { + logger.debug('checkAuth: Using API key authentication'); + return { + authenticated: true, + method: 'api_key', + hasApiKey, + hasEnvApiKey, + hasCredentialsFile, + }; + } + + // If we have Vertex AI credentials, we're authenticated + if (hasVertexAi) { + logger.debug('checkAuth: Using Vertex AI authentication'); + return { + authenticated: true, + method: 'vertex_ai', + hasApiKey, + hasEnvApiKey, + hasCredentialsFile, + }; + } + + // Check if settings file indicates configured authentication + if (hasCredentialsFile && authType) { + // OAuth types: "oauth-personal", "oauth-adc" + // API key type: "api-key" + // Code assist: "code-assist" (requires IDE integration) + if (authType.startsWith('oauth')) { + logger.debug(`checkAuth: OAuth authentication configured (${authType})`); + return { + authenticated: true, + method: 'google_login', + hasApiKey, + hasEnvApiKey, + hasCredentialsFile, + }; + } + + if (authType === 'api-key') { + logger.debug('checkAuth: API key authentication configured in settings'); + return { + authenticated: true, + method: 'api_key', + hasApiKey, + hasEnvApiKey, + hasCredentialsFile, + }; + } + + if (authType === 'code-assist' || authType === 'codeassist') { + logger.debug('checkAuth: Code Assist auth configured but requires local server'); + return { + authenticated: false, + method: 'google_login', + hasApiKey, + hasEnvApiKey, + hasCredentialsFile, + error: + 'Code Assist authentication requires IDE integration. Please use "gemini" CLI to log in with a different method, or set GEMINI_API_KEY.', + }; + } + + // Unknown auth type but something is configured + logger.debug(`checkAuth: Unknown auth type configured: ${authType}`); + return { + authenticated: true, + method: 'google_login', + hasApiKey, + hasEnvApiKey, + hasCredentialsFile, + }; + } + + // No credentials found + logger.debug('checkAuth: No valid credentials found'); + return { + authenticated: false, + method: 'none', + hasApiKey, + hasEnvApiKey, + hasCredentialsFile, + error: + 'No authentication configured. Run "gemini" interactively to log in, or set GEMINI_API_KEY.', + }; + } + + /** + * Detect installation status (required by BaseProvider) + */ + async detectInstallation(): Promise { + const installed = await this.isInstalled(); + const version = installed ? await this.getVersion() : undefined; + const auth = await this.checkAuth(); + + return { + installed, + version: version || undefined, + path: this.cliPath || undefined, + method: 'cli', + hasApiKey: !!process.env.GEMINI_API_KEY, + authenticated: auth.authenticated, + }; + } + + /** + * Get the detected CLI path (public accessor for status endpoints) + */ + getCliPath(): string | null { + this.ensureCliDetected(); + return this.cliPath; + } + + /** + * Get available Gemini models + */ + getAvailableModels(): ModelDefinition[] { + return Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => ({ + id, // Full model ID with gemini- prefix (e.g., 'gemini-2.5-flash') + name: config.label, + modelString: id, // Same as id - CLI uses the full model name + provider: 'gemini', + description: config.description, + supportsTools: true, + supportsVision: config.supportsVision, + contextWindow: config.contextWindow, + })); + } + + /** + * Check if a feature is supported + */ + supportsFeature(feature: string): boolean { + const supported = ['tools', 'text', 'streaming', 'vision', 'thinking']; + return supported.includes(feature); + } +} diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts index c2a18120..40a9872b 100644 --- a/apps/server/src/providers/provider-factory.ts +++ b/apps/server/src/providers/provider-factory.ts @@ -7,7 +7,13 @@ import { BaseProvider } from './base-provider.js'; import type { InstallationStatus, ModelDefinition } from './types.js'; -import { isCursorModel, isCodexModel, isOpencodeModel, type ModelProvider } from '@automaker/types'; +import { + isCursorModel, + isCodexModel, + isOpencodeModel, + isGeminiModel, + type ModelProvider, +} from '@automaker/types'; import * as fs from 'fs'; import * as path from 'path'; @@ -16,6 +22,7 @@ const DISCONNECTED_MARKERS: Record = { codex: '.codex-disconnected', cursor: '.cursor-disconnected', opencode: '.opencode-disconnected', + gemini: '.gemini-disconnected', }; /** @@ -239,8 +246,8 @@ export class ProviderFactory { model.modelString === modelId || model.id.endsWith(`-${modelId}`) || model.modelString.endsWith(`-${modelId}`) || - model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') || - model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '') + model.modelString === modelId.replace(/^(claude|cursor|codex|gemini)-/, '') || + model.modelString === modelId.replace(/-(claude|cursor|codex|gemini)$/, '') ) { return model.supportsVision ?? true; } @@ -267,6 +274,7 @@ import { ClaudeProvider } from './claude-provider.js'; import { CursorProvider } from './cursor-provider.js'; import { CodexProvider } from './codex-provider.js'; import { OpencodeProvider } from './opencode-provider.js'; +import { GeminiProvider } from './gemini-provider.js'; // Register Claude provider registerProvider('claude', { @@ -301,3 +309,11 @@ registerProvider('opencode', { canHandleModel: (model: string) => isOpencodeModel(model), priority: 3, // Between codex (5) and claude (0) }); + +// Register Gemini provider +registerProvider('gemini', { + factory: () => new GeminiProvider(), + aliases: ['google'], + canHandleModel: (model: string) => isGeminiModel(model), + priority: 4, // Between opencode (3) and codex (5) +}); diff --git a/apps/server/src/routes/setup/index.ts b/apps/server/src/routes/setup/index.ts index a35c5e6b..d2a9fde3 100644 --- a/apps/server/src/routes/setup/index.ts +++ b/apps/server/src/routes/setup/index.ts @@ -24,6 +24,9 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js'; import { createAuthOpencodeHandler } from './routes/auth-opencode.js'; import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js'; import { createOpencodeStatusHandler } from './routes/opencode-status.js'; +import { createGeminiStatusHandler } from './routes/gemini-status.js'; +import { createAuthGeminiHandler } from './routes/auth-gemini.js'; +import { createDeauthGeminiHandler } from './routes/deauth-gemini.js'; import { createGetOpencodeModelsHandler, createRefreshOpencodeModelsHandler, @@ -72,6 +75,11 @@ export function createSetupRoutes(): Router { router.post('/auth-opencode', createAuthOpencodeHandler()); router.post('/deauth-opencode', createDeauthOpencodeHandler()); + // Gemini CLI routes + router.get('/gemini-status', createGeminiStatusHandler()); + router.post('/auth-gemini', createAuthGeminiHandler()); + router.post('/deauth-gemini', createDeauthGeminiHandler()); + // OpenCode Dynamic Model Discovery routes router.get('/opencode/models', createGetOpencodeModelsHandler()); router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler()); diff --git a/apps/server/src/routes/setup/routes/auth-gemini.ts b/apps/server/src/routes/setup/routes/auth-gemini.ts new file mode 100644 index 00000000..5faad8db --- /dev/null +++ b/apps/server/src/routes/setup/routes/auth-gemini.ts @@ -0,0 +1,42 @@ +/** + * POST /auth-gemini endpoint - Connect Gemini CLI to the app + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const DISCONNECTED_MARKER_FILE = '.gemini-disconnected'; + +/** + * Creates handler for POST /api/setup/auth-gemini + * Removes the disconnection marker to allow Gemini CLI to be used + */ +export function createAuthGeminiHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const projectRoot = process.cwd(); + const automakerDir = path.join(projectRoot, '.automaker'); + const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE); + + // Remove the disconnection marker if it exists + try { + await fs.unlink(markerPath); + } catch { + // File doesn't exist, nothing to remove + } + + res.json({ + success: true, + message: 'Gemini CLI connected to app', + }); + } catch (error) { + logError(error, 'Auth Gemini failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/routes/setup/routes/deauth-gemini.ts b/apps/server/src/routes/setup/routes/deauth-gemini.ts new file mode 100644 index 00000000..d5b08a0a --- /dev/null +++ b/apps/server/src/routes/setup/routes/deauth-gemini.ts @@ -0,0 +1,42 @@ +/** + * POST /deauth-gemini endpoint - Disconnect Gemini CLI from the app + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const DISCONNECTED_MARKER_FILE = '.gemini-disconnected'; + +/** + * Creates handler for POST /api/setup/deauth-gemini + * Creates a marker file to disconnect Gemini CLI from the app + */ +export function createDeauthGeminiHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const projectRoot = process.cwd(); + const automakerDir = path.join(projectRoot, '.automaker'); + + // Ensure .automaker directory exists + await fs.mkdir(automakerDir, { recursive: true }); + + const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE); + + // Create the disconnection marker + await fs.writeFile(markerPath, 'Gemini CLI disconnected from app'); + + res.json({ + success: true, + message: 'Gemini CLI disconnected from app', + }); + } catch (error) { + logError(error, 'Deauth Gemini failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/routes/setup/routes/gemini-status.ts b/apps/server/src/routes/setup/routes/gemini-status.ts new file mode 100644 index 00000000..ec4fbee4 --- /dev/null +++ b/apps/server/src/routes/setup/routes/gemini-status.ts @@ -0,0 +1,79 @@ +/** + * GET /gemini-status endpoint - Get Gemini CLI installation and auth status + */ + +import type { Request, Response } from 'express'; +import { GeminiProvider } from '../../../providers/gemini-provider.js'; +import { getErrorMessage, logError } from '../common.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const DISCONNECTED_MARKER_FILE = '.gemini-disconnected'; + +async function isGeminiDisconnectedFromApp(): Promise { + try { + const projectRoot = process.cwd(); + const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE); + await fs.access(markerPath); + return true; + } catch { + return false; + } +} + +/** + * Creates handler for GET /api/setup/gemini-status + * Returns Gemini CLI installation and authentication status + */ +export function createGeminiStatusHandler() { + const installCommand = 'npm install -g @google/gemini-cli'; + const loginCommand = 'gemini'; + + return async (_req: Request, res: Response): Promise => { + try { + // Check if user has manually disconnected from the app + if (await isGeminiDisconnectedFromApp()) { + res.json({ + success: true, + installed: true, + version: null, + path: null, + auth: { + authenticated: false, + method: 'none', + hasApiKey: false, + }, + installCommand, + loginCommand, + }); + return; + } + + const provider = new GeminiProvider(); + const status = await provider.detectInstallation(); + const auth = await provider.checkAuth(); + + res.json({ + success: true, + installed: status.installed, + version: status.version || null, + path: status.path || null, + auth: { + authenticated: auth.authenticated, + method: auth.method, + hasApiKey: auth.hasApiKey || false, + hasEnvApiKey: auth.hasEnvApiKey || false, + error: auth.error, + }, + installCommand, + loginCommand, + }); + } catch (error) { + logError(error, 'Get Gemini status failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/tests/unit/providers/provider-factory.test.ts b/apps/server/tests/unit/providers/provider-factory.test.ts index 5b717364..fdbf1c4a 100644 --- a/apps/server/tests/unit/providers/provider-factory.test.ts +++ b/apps/server/tests/unit/providers/provider-factory.test.ts @@ -4,6 +4,7 @@ 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'; +import { GeminiProvider } from '@/providers/gemini-provider.js'; describe('provider-factory.ts', () => { let consoleSpy: any; @@ -11,6 +12,7 @@ describe('provider-factory.ts', () => { let detectCursorSpy: any; let detectCodexSpy: any; let detectOpencodeSpy: any; + let detectGeminiSpy: any; beforeEach(() => { consoleSpy = { @@ -30,6 +32,9 @@ describe('provider-factory.ts', () => { detectOpencodeSpy = vi .spyOn(OpencodeProvider.prototype, 'detectInstallation') .mockResolvedValue({ installed: true }); + detectGeminiSpy = vi + .spyOn(GeminiProvider.prototype, 'detectInstallation') + .mockResolvedValue({ installed: true }); }); afterEach(() => { @@ -38,6 +43,7 @@ describe('provider-factory.ts', () => { detectCursorSpy.mockRestore(); detectCodexSpy.mockRestore(); detectOpencodeSpy.mockRestore(); + detectGeminiSpy.mockRestore(); }); describe('getProviderForModel', () => { @@ -166,9 +172,15 @@ describe('provider-factory.ts', () => { expect(hasClaudeProvider).toBe(true); }); - it('should return exactly 4 providers', () => { + it('should return exactly 5 providers', () => { const providers = ProviderFactory.getAllProviders(); - expect(providers).toHaveLength(4); + expect(providers).toHaveLength(5); + }); + + it('should include GeminiProvider', () => { + const providers = ProviderFactory.getAllProviders(); + const hasGeminiProvider = providers.some((p) => p instanceof GeminiProvider); + expect(hasGeminiProvider).toBe(true); }); it('should include CursorProvider', () => { @@ -206,7 +218,8 @@ describe('provider-factory.ts', () => { expect(keys).toContain('cursor'); expect(keys).toContain('codex'); expect(keys).toContain('opencode'); - expect(keys).toHaveLength(4); + expect(keys).toContain('gemini'); + expect(keys).toHaveLength(5); }); it('should include cursor status', async () => { diff --git a/apps/ui/src/components/ui/provider-icon.tsx b/apps/ui/src/components/ui/provider-icon.tsx index 984c9a2a..6c99cbad 100644 --- a/apps/ui/src/components/ui/provider-icon.tsx +++ b/apps/ui/src/components/ui/provider-icon.tsx @@ -395,6 +395,7 @@ export const PROVIDER_ICON_COMPONENTS: Record< cursor: CursorIcon, codex: OpenAIIcon, opencode: OpenCodeIcon, + gemini: GeminiIcon, }; /** diff --git a/apps/ui/src/components/views/board-view/shared/model-constants.ts b/apps/ui/src/components/views/board-view/shared/model-constants.ts index 33bd624a..a619c112 100644 --- a/apps/ui/src/components/views/board-view/shared/model-constants.ts +++ b/apps/ui/src/components/views/board-view/shared/model-constants.ts @@ -4,9 +4,16 @@ import { CURSOR_MODEL_MAP, CODEX_MODEL_MAP, OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS, + GEMINI_MODEL_MAP, } from '@automaker/types'; import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react'; -import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon'; +import { + AnthropicIcon, + CursorIcon, + OpenAIIcon, + OpenCodeIcon, + GeminiIcon, +} from '@/components/ui/provider-icon'; export type ModelOption = { id: string; // All model IDs use canonical prefixed format (e.g., "claude-sonnet", "cursor-auto") @@ -118,13 +125,29 @@ export const OPENCODE_MODELS: ModelOption[] = OPENCODE_MODEL_CONFIGS.map((config })); /** - * All available models (Claude + Cursor + Codex + OpenCode) + * Gemini models derived from GEMINI_MODEL_MAP + * Model IDs already have 'gemini-' prefix (like Cursor models) + */ +export const GEMINI_MODELS: ModelOption[] = Object.entries(GEMINI_MODEL_MAP).map( + ([id, config]) => ({ + id, // IDs already have gemini- prefix (e.g., 'gemini-2.5-flash') + label: config.label, + description: config.description, + badge: config.supportsThinking ? 'Thinking' : 'Speed', + provider: 'gemini' as ModelProvider, + hasThinking: config.supportsThinking, + }) +); + +/** + * All available models (Claude + Cursor + Codex + OpenCode + Gemini) */ export const ALL_MODELS: ModelOption[] = [ ...CLAUDE_MODELS, ...CURSOR_MODELS, ...CODEX_MODELS, ...OPENCODE_MODELS, + ...GEMINI_MODELS, ]; export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink']; @@ -171,4 +194,5 @@ export const PROFILE_ICONS: Record; case 'opencode-provider': return ; + case 'gemini-provider': + return ; case 'providers': case 'claude': // Backwards compatibility - redirect to claude-provider return ; diff --git a/apps/ui/src/components/views/settings-view/cli-status/gemini-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/gemini-cli-status.tsx new file mode 100644 index 00000000..8e94e705 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/cli-status/gemini-cli-status.tsx @@ -0,0 +1,250 @@ +import { Button } from '@/components/ui/button'; +import { SkeletonPulse } from '@/components/ui/skeleton'; +import { Spinner } from '@/components/ui/spinner'; +import { CheckCircle2, AlertCircle, RefreshCw, Key } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { CliStatus } from '../shared/types'; +import { GeminiIcon } from '@/components/ui/provider-icon'; + +export type GeminiAuthMethod = + | 'api_key' // API key authentication + | 'google_login' // Google OAuth authentication + | 'vertex_ai' // Vertex AI authentication + | 'none'; + +export interface GeminiAuthStatus { + authenticated: boolean; + method: GeminiAuthMethod; + hasApiKey?: boolean; + hasEnvApiKey?: boolean; + hasCredentialsFile?: boolean; + error?: string; +} + +function getAuthMethodLabel(method: GeminiAuthMethod): string { + switch (method) { + case 'api_key': + return 'API Key'; + case 'google_login': + return 'Google OAuth'; + case 'vertex_ai': + return 'Vertex AI'; + default: + return method || 'Unknown'; + } +} + +interface GeminiCliStatusProps { + status: CliStatus | null; + authStatus?: GeminiAuthStatus | null; + isChecking: boolean; + onRefresh: () => void; +} + +export function GeminiCliStatusSkeleton() { + return ( +
+
+
+
+ + +
+ +
+
+ +
+
+
+ {/* Installation status skeleton */} +
+ +
+ + + +
+
+ {/* Auth status skeleton */} +
+ +
+ + +
+
+
+
+ ); +} + +export function GeminiCliStatus({ + status, + authStatus, + isChecking, + onRefresh, +}: GeminiCliStatusProps) { + if (!status) return ; + + return ( +
+
+
+
+
+ +
+

Gemini CLI

+
+ +
+

+ Gemini CLI provides access to Google's Gemini AI models with thinking capabilities. +

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

Gemini CLI Installed

+
+ {status.method && ( +

+ Method: {status.method} +

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

+ Version: {status.version} +

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

+ Path: {status.path} +

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

Authenticated

+
+ {authStatus.method !== 'none' && ( +

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

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

Authentication Failed

+ {authStatus?.error && ( +

{authStatus.error}

+ )} +

+ Run gemini{' '} + interactively in your terminal to log in with Google, or set the{' '} + GEMINI_API_KEY{' '} + environment variable. +

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

{status.recommendation}

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

Gemini CLI Not Detected

+

+ {status.recommendation || 'Install Gemini CLI to use Google Gemini models.'} +

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

Installation Commands:

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

+ npm +

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

+ macOS/Linux +

+ + {status.installCommands.macos} + +
+ )} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/components/settings-navigation.tsx b/apps/ui/src/components/views/settings-view/components/settings-navigation.tsx index 5e8f0fa1..44c7fd84 100644 --- a/apps/ui/src/components/views/settings-view/components/settings-navigation.tsx +++ b/apps/ui/src/components/views/settings-view/components/settings-navigation.tsx @@ -17,6 +17,7 @@ const NAV_ID_TO_PROVIDER: Record = { 'cursor-provider': 'cursor', 'codex-provider': 'codex', 'opencode-provider': 'opencode', + 'gemini-provider': 'gemini', }; interface SettingsNavigationProps { diff --git a/apps/ui/src/components/views/settings-view/config/navigation.ts b/apps/ui/src/components/views/settings-view/config/navigation.ts index 107d8678..deb086d2 100644 --- a/apps/ui/src/components/views/settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/settings-view/config/navigation.ts @@ -17,7 +17,13 @@ import { Code2, Webhook, } from 'lucide-react'; -import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon'; +import { + AnthropicIcon, + CursorIcon, + OpenAIIcon, + OpenCodeIcon, + GeminiIcon, +} from '@/components/ui/provider-icon'; import type { SettingsViewId } from '../hooks/use-settings-view'; export interface NavigationItem { @@ -51,6 +57,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [ { id: 'cursor-provider', label: 'Cursor', icon: CursorIcon }, { id: 'codex-provider', label: 'Codex', icon: OpenAIIcon }, { id: 'opencode-provider', label: 'OpenCode', icon: OpenCodeIcon }, + { id: 'gemini-provider', label: 'Gemini', icon: GeminiIcon }, ], }, { id: 'mcp-servers', label: 'MCP Servers', icon: Plug }, diff --git a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts index 6f61aa9f..26976233 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts @@ -8,6 +8,7 @@ export type SettingsViewId = | 'cursor-provider' | 'codex-provider' | 'opencode-provider' + | 'gemini-provider' | 'mcp-servers' | 'prompts' | 'model-defaults' diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index ef946238..9b908d5a 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -7,6 +7,7 @@ import type { CursorModelId, CodexModelId, OpencodeModelId, + GeminiModelId, GroupedModel, PhaseModelEntry, ClaudeCompatibleProvider, @@ -25,6 +26,7 @@ import { CLAUDE_MODELS, CURSOR_MODELS, OPENCODE_MODELS, + GEMINI_MODELS, THINKING_LEVELS, THINKING_LEVEL_LABELS, REASONING_EFFORT_LEVELS, @@ -39,6 +41,7 @@ import { OpenRouterIcon, GlmIcon, MiniMaxIcon, + GeminiIcon, getProviderIconForModel, } from '@/components/ui/provider-icon'; import { Button } from '@/components/ui/button'; @@ -168,6 +171,7 @@ export function PhaseModelSelector({ const expandedProviderTriggerRef = useRef(null); const { enabledCursorModels, + enabledGeminiModels, favoriteModels, toggleFavoriteModel, codexModels, @@ -322,6 +326,11 @@ export function PhaseModelSelector({ return enabledCursorModels.includes(model.id as CursorModelId); }); + // Filter Gemini models to only show enabled ones + const availableGeminiModels = GEMINI_MODELS.filter((model) => { + return enabledGeminiModels.includes(model.id as GeminiModelId); + }); + // Helper to find current selected model details const currentModel = useMemo(() => { const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel); @@ -359,6 +368,16 @@ export function PhaseModelSelector({ const codexModel = transformedCodexModels.find((m) => m.id === selectedModel); if (codexModel) return { ...codexModel, icon: OpenAIIcon }; + // Check Gemini models + // Note: Gemini CLI doesn't support thinking level configuration + const geminiModel = availableGeminiModels.find((m) => m.id === selectedModel); + if (geminiModel) { + return { + ...geminiModel, + icon: GeminiIcon, + }; + } + // Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel); if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) }; @@ -459,6 +478,7 @@ export function PhaseModelSelector({ selectedProviderId, selectedThinkingLevel, availableCursorModels, + availableGeminiModels, transformedCodexModels, dynamicOpencodeModels, enabledProviders, @@ -524,17 +544,20 @@ export function PhaseModelSelector({ // Check if providers are disabled (needed for rendering conditions) const isCursorDisabled = disabledProviders.includes('cursor'); + const isGeminiDisabled = disabledProviders.includes('gemini'); // Group models (filtering out disabled providers) - const { favorites, claude, cursor, codex, opencode } = useMemo(() => { + const { favorites, claude, cursor, codex, gemini, opencode } = useMemo(() => { const favs: typeof CLAUDE_MODELS = []; const cModels: typeof CLAUDE_MODELS = []; const curModels: typeof CURSOR_MODELS = []; const codModels: typeof transformedCodexModels = []; + const gemModels: typeof GEMINI_MODELS = []; const ocModels: ModelOption[] = []; const isClaudeDisabled = disabledProviders.includes('claude'); const isCodexDisabled = disabledProviders.includes('codex'); + const isGeminiDisabledInner = disabledProviders.includes('gemini'); const isOpencodeDisabled = disabledProviders.includes('opencode'); // Process Claude Models (skip if provider is disabled) @@ -570,6 +593,17 @@ export function PhaseModelSelector({ }); } + // Process Gemini Models (skip if provider is disabled) + if (!isGeminiDisabledInner) { + availableGeminiModels.forEach((model) => { + if (favoriteModels.includes(model.id)) { + favs.push(model); + } else { + gemModels.push(model); + } + }); + } + // Process OpenCode Models (skip if provider is disabled) if (!isOpencodeDisabled) { allOpencodeModels.forEach((model) => { @@ -586,11 +620,13 @@ export function PhaseModelSelector({ claude: cModels, cursor: curModels, codex: codModels, + gemini: gemModels, opencode: ocModels, }; }, [ favoriteModels, availableCursorModels, + availableGeminiModels, transformedCodexModels, allOpencodeModels, disabledProviders, @@ -1027,6 +1063,60 @@ export function PhaseModelSelector({ ); }; + // Render Gemini model item - simple selector without thinking level + // Note: Gemini CLI doesn't support a --thinking-level flag, thinking is model-internal + const renderGeminiModelItem = (model: (typeof GEMINI_MODELS)[0]) => { + const isSelected = selectedModel === model.id; + const isFavorite = favoriteModels.includes(model.id); + + return ( + { + onChange({ model: model.id as GeminiModelId }); + setOpen(false); + }} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + {model.description} +
+
+ +
+ + {isSelected && } +
+
+ ); + }; + // Render ClaudeCompatibleProvider model item with thinking level support const renderProviderModelItem = ( provider: ClaudeCompatibleProvider, @@ -1839,6 +1929,10 @@ export function PhaseModelSelector({ if (model.provider === 'codex') { return renderCodexModelItem(model as (typeof transformedCodexModels)[0]); } + // Gemini model + if (model.provider === 'gemini') { + return renderGeminiModelItem(model as (typeof GEMINI_MODELS)[0]); + } // OpenCode model if (model.provider === 'opencode') { return renderOpencodeModelItem(model); @@ -1917,6 +2011,12 @@ export function PhaseModelSelector({ )} + {!isGeminiDisabled && gemini.length > 0 && ( + + {gemini.map((model) => renderGeminiModelItem(model))} + + )} + {opencodeSections.length > 0 && ( {opencodeSections.map((section, sectionIndex) => ( diff --git a/apps/ui/src/components/views/settings-view/providers/gemini-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/gemini-model-configuration.tsx new file mode 100644 index 00000000..4d1d8e80 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/gemini-model-configuration.tsx @@ -0,0 +1,146 @@ +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 { cn } from '@/lib/utils'; +import type { GeminiModelId } from '@automaker/types'; +import { GeminiIcon } from '@/components/ui/provider-icon'; +import { GEMINI_MODEL_MAP } from '@automaker/types'; + +interface GeminiModelConfigurationProps { + enabledGeminiModels: GeminiModelId[]; + geminiDefaultModel: GeminiModelId; + isSaving: boolean; + onDefaultModelChange: (model: GeminiModelId) => void; + onModelToggle: (model: GeminiModelId, enabled: boolean) => void; +} + +interface GeminiModelInfo { + id: GeminiModelId; + label: string; + description: string; + supportsThinking: boolean; +} + +// Build model info from the GEMINI_MODEL_MAP +const GEMINI_MODEL_INFO: Record = Object.fromEntries( + Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => [ + id as GeminiModelId, + { + id: id as GeminiModelId, + label: config.label, + description: config.description, + supportsThinking: config.supportsThinking, + }, + ]) +) as Record; + +export function GeminiModelConfiguration({ + enabledGeminiModels, + geminiDefaultModel, + isSaving, + onDefaultModelChange, + onModelToggle, +}: GeminiModelConfigurationProps) { + const availableModels = Object.values(GEMINI_MODEL_INFO); + + return ( +
+
+
+
+ +
+

+ Model Configuration +

+
+

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

+
+
+
+ + +
+ +
+ +
+ {availableModels.map((model) => { + const isEnabled = enabledGeminiModels.includes(model.id); + const isDefault = model.id === geminiDefaultModel; + + return ( +
+
+ onModelToggle(model.id, !!checked)} + disabled={isSaving || isDefault} + /> +
+
+ {model.label} + {model.supportsThinking && ( + + Thinking + + )} + {isDefault && ( + + Default + + )} +
+

{model.description}

+
+
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/providers/gemini-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/gemini-settings-tab.tsx new file mode 100644 index 00000000..4cc43783 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/gemini-settings-tab.tsx @@ -0,0 +1,130 @@ +import { useState, useCallback, useMemo } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { useAppStore } from '@/store/app-store'; +import { GeminiCliStatus, GeminiCliStatusSkeleton } from '../cli-status/gemini-cli-status'; +import { GeminiModelConfiguration } from './gemini-model-configuration'; +import { ProviderToggle } from './provider-toggle'; +import { useGeminiCliStatus } from '@/hooks/queries'; +import { queryKeys } from '@/lib/query-keys'; +import type { CliStatus as SharedCliStatus } from '../shared/types'; +import type { GeminiAuthStatus } from '../cli-status/gemini-cli-status'; +import type { GeminiModelId } from '@automaker/types'; + +export function GeminiSettingsTab() { + const queryClient = useQueryClient(); + const { enabledGeminiModels, geminiDefaultModel, setGeminiDefaultModel, toggleGeminiModel } = + useAppStore(); + + const [isSaving, setIsSaving] = useState(false); + + // React Query hooks for data fetching + const { + data: cliStatusData, + isLoading: isCheckingGeminiCli, + refetch: refetchCliStatus, + } = useGeminiCliStatus(); + + const isCliInstalled = cliStatusData?.installed ?? false; + + // Transform CLI status to the expected format + const cliStatus = useMemo((): SharedCliStatus | null => { + if (!cliStatusData) return null; + return { + success: cliStatusData.success ?? false, + status: cliStatusData.installed ? 'installed' : 'not_installed', + method: cliStatusData.auth?.method, + version: cliStatusData.version, + path: cliStatusData.path, + recommendation: cliStatusData.recommendation, + // Server sends installCommand (singular), transform to expected format + installCommands: cliStatusData.installCommand + ? { npm: cliStatusData.installCommand } + : cliStatusData.installCommands, + }; + }, [cliStatusData]); + + // Transform auth status to the expected format + const authStatus = useMemo((): GeminiAuthStatus | null => { + if (!cliStatusData?.auth) return null; + return { + authenticated: cliStatusData.auth.authenticated, + method: (cliStatusData.auth.method as GeminiAuthStatus['method']) || 'none', + hasApiKey: cliStatusData.auth.hasApiKey, + hasEnvApiKey: cliStatusData.auth.hasEnvApiKey, + error: cliStatusData.auth.error, + }; + }, [cliStatusData]); + + // Refresh all gemini-related queries + const handleRefreshGeminiCli = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.cli.gemini() }); + await refetchCliStatus(); + toast.success('Gemini CLI refreshed'); + }, [queryClient, refetchCliStatus]); + + const handleDefaultModelChange = useCallback( + (model: GeminiModelId) => { + setIsSaving(true); + try { + setGeminiDefaultModel(model); + toast.success('Default model updated'); + } catch { + toast.error('Failed to update default model'); + } finally { + setIsSaving(false); + } + }, + [setGeminiDefaultModel] + ); + + const handleModelToggle = useCallback( + (model: GeminiModelId, enabled: boolean) => { + setIsSaving(true); + try { + toggleGeminiModel(model, enabled); + } catch { + toast.error('Failed to update models'); + } finally { + setIsSaving(false); + } + }, + [toggleGeminiModel] + ); + + // Show skeleton only while checking CLI status initially + if (!cliStatus && isCheckingGeminiCli) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Provider Visibility Toggle */} + + + + + {/* Model Configuration - Only show when CLI is installed */} + {isCliInstalled && ( + + )} +
+ ); +} + +export default GeminiSettingsTab; diff --git a/apps/ui/src/components/views/settings-view/providers/index.ts b/apps/ui/src/components/views/settings-view/providers/index.ts index 19d3226e..31560019 100644 --- a/apps/ui/src/components/views/settings-view/providers/index.ts +++ b/apps/ui/src/components/views/settings-view/providers/index.ts @@ -3,3 +3,4 @@ export { ClaudeSettingsTab } from './claude-settings-tab'; export { CursorSettingsTab } from './cursor-settings-tab'; export { CodexSettingsTab } from './codex-settings-tab'; export { OpencodeSettingsTab } from './opencode-settings-tab'; +export { GeminiSettingsTab } from './gemini-settings-tab'; diff --git a/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx b/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx index 6df2a4c5..6802626a 100644 --- a/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx +++ b/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx @@ -1,20 +1,26 @@ 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 { + AnthropicIcon, + CursorIcon, + OpenAIIcon, + GeminiIcon, + OpenCodeIcon, +} from '@/components/ui/provider-icon'; import { CursorSettingsTab } from './cursor-settings-tab'; import { ClaudeSettingsTab } from './claude-settings-tab'; import { CodexSettingsTab } from './codex-settings-tab'; import { OpencodeSettingsTab } from './opencode-settings-tab'; +import { GeminiSettingsTab } from './gemini-settings-tab'; interface ProviderTabsProps { - defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode'; + defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini'; } export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) { return ( - + Claude @@ -28,9 +34,13 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) { Codex - + OpenCode + + + Gemini + @@ -48,6 +58,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) { + + + + ); } diff --git a/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx index 53b3ca0b..f534e425 100644 --- a/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx @@ -31,7 +31,13 @@ import { import { Spinner } from '@/components/ui/spinner'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; -import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon'; +import { + AnthropicIcon, + CursorIcon, + OpenAIIcon, + OpenCodeIcon, + GeminiIcon, +} from '@/components/ui/provider-icon'; import { TerminalOutput } from '../components'; import { useCliInstallation, useTokenSave } from '../hooks'; @@ -40,7 +46,7 @@ interface ProvidersSetupStepProps { onBack: () => void; } -type ProviderTab = 'claude' | 'cursor' | 'codex' | 'opencode'; +type ProviderTab = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini'; // ============================================================================ // Claude Content @@ -1209,6 +1215,318 @@ function OpencodeContent() { ); } +// ============================================================================ +// Gemini Content +// ============================================================================ +function GeminiContent() { + const { geminiCliStatus, setGeminiCliStatus } = useSetupStore(); + const { setApiKeys, apiKeys } = useAppStore(); + const [isChecking, setIsChecking] = useState(false); + const [apiKey, setApiKey] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const pollIntervalRef = useRef(null); + + const checkStatus = useCallback(async () => { + setIsChecking(true); + try { + const api = getElectronAPI(); + if (!api.setup?.getGeminiStatus) return; + const result = await api.setup.getGeminiStatus(); + if (result.success) { + setGeminiCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.path, + auth: result.auth, + installCommand: result.installCommand, + loginCommand: result.loginCommand, + }); + if (result.auth?.authenticated) { + toast.success('Gemini CLI is ready!'); + } + } + } catch { + // Ignore + } finally { + setIsChecking(false); + } + }, [setGeminiCliStatus]); + + useEffect(() => { + checkStatus(); + return () => { + if (pollIntervalRef.current) clearInterval(pollIntervalRef.current); + }; + }, [checkStatus]); + + const copyCommand = (command: string) => { + navigator.clipboard.writeText(command); + toast.success('Command copied to clipboard'); + }; + + const handleSaveApiKey = async () => { + if (!apiKey.trim()) return; + setIsSaving(true); + try { + const api = getElectronAPI(); + if (!api.setup?.saveApiKey) { + toast.error('Save API not available'); + return; + } + const result = await api.setup.saveApiKey('google', apiKey); + if (result.success) { + setApiKeys({ ...apiKeys, google: apiKey }); + setGeminiCliStatus({ + ...geminiCliStatus, + installed: geminiCliStatus?.installed ?? false, + auth: { authenticated: true, method: 'api_key' }, + }); + toast.success('API key saved successfully!'); + } + } catch { + toast.error('Failed to save API key'); + } finally { + setIsSaving(false); + } + }; + + const handleLogin = async () => { + setIsLoggingIn(true); + try { + const loginCommand = geminiCliStatus?.loginCommand || 'gemini auth login'; + await navigator.clipboard.writeText(loginCommand); + toast.info('Login command copied! Paste in terminal to authenticate.'); + + let attempts = 0; + pollIntervalRef.current = setInterval(async () => { + attempts++; + try { + const api = getElectronAPI(); + if (!api.setup?.getGeminiStatus) return; + const result = await api.setup.getGeminiStatus(); + if (result.auth?.authenticated) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setGeminiCliStatus({ + ...geminiCliStatus, + installed: result.installed ?? true, + version: result.version, + path: result.path, + auth: result.auth, + }); + setIsLoggingIn(false); + toast.success('Successfully logged in to Gemini!'); + } + } catch { + // Ignore + } + if (attempts >= 60) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setIsLoggingIn(false); + toast.error('Login timed out. Please try again.'); + } + }, 2000); + } catch { + toast.error('Failed to start login process'); + setIsLoggingIn(false); + } + }; + + const isReady = geminiCliStatus?.installed && geminiCliStatus?.auth?.authenticated; + + return ( + + +
+ + + Gemini CLI Status + + +
+ + {geminiCliStatus?.installed + ? geminiCliStatus.auth?.authenticated + ? `Authenticated${geminiCliStatus.version ? ` (v${geminiCliStatus.version})` : ''}` + : 'Installed but not authenticated' + : 'Not installed on your system'} + +
+ + {isReady && ( +
+
+ +
+

CLI Installed

+

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

+
+
+
+ +

Authenticated

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

Gemini CLI not found

+

+ Install the Gemini CLI to use Google Gemini models. +

+
+
+
+

Install Gemini CLI:

+
+ + {geminiCliStatus?.installCommand || 'npm install -g @google/gemini-cli'} + + +
+
+
+ )} + + {geminiCliStatus?.installed && !geminiCliStatus?.auth?.authenticated && !isChecking && ( +
+ {/* Show CLI installed toast */} +
+ +
+

CLI Installed

+

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

+
+
+ +
+ +
+

Gemini CLI not authenticated

+

+ Run the login command or provide a Google API key below. +

+
+
+ + + + +
+ + Google OAuth Login +
+
+ +
+ + {geminiCliStatus?.loginCommand || 'gemini auth login'} + + +
+ +
+
+ + + +
+ + Google API Key +
+
+ +
+ setApiKey(e.target.value)} + className="bg-input border-border text-foreground" + /> +

+ + Get an API key from Google AI Studio + + +

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

Checking Gemini CLI status...

+
+ )} +
+
+ ); +} + // ============================================================================ // Main Component // ============================================================================ @@ -1225,11 +1543,13 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) codexCliStatus, codexAuthStatus, opencodeCliStatus, + geminiCliStatus, setClaudeCliStatus, setCursorCliStatus, setCodexCliStatus, setCodexAuthStatus, setOpencodeCliStatus, + setGeminiCliStatus, } = useSetupStore(); // Check all providers on mount @@ -1319,8 +1639,28 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) } }; + // Check Gemini + const checkGemini = async () => { + try { + if (!api.setup?.getGeminiStatus) return; + const result = await api.setup.getGeminiStatus(); + if (result.success) { + setGeminiCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.path, + auth: result.auth, + installCommand: result.installCommand, + loginCommand: result.loginCommand, + }); + } + } catch { + // Ignore errors + } + }; + // Run all checks in parallel - await Promise.all([checkClaude(), checkCursor(), checkCodex(), checkOpencode()]); + await Promise.all([checkClaude(), checkCursor(), checkCodex(), checkOpencode(), checkGemini()]); setIsInitialChecking(false); }, [ setClaudeCliStatus, @@ -1328,6 +1668,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) setCodexCliStatus, setCodexAuthStatus, setOpencodeCliStatus, + setGeminiCliStatus, ]); useEffect(() => { @@ -1354,11 +1695,15 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) const isOpencodeInstalled = opencodeCliStatus?.installed === true; const isOpencodeAuthenticated = opencodeCliStatus?.auth?.authenticated === true; + const isGeminiInstalled = geminiCliStatus?.installed === true; + const isGeminiAuthenticated = geminiCliStatus?.auth?.authenticated === true; + const hasAtLeastOneProvider = isClaudeAuthenticated || isCursorAuthenticated || isCodexAuthenticated || - isOpencodeAuthenticated; + isOpencodeAuthenticated || + isGeminiAuthenticated; type ProviderStatus = 'not_installed' | 'installed_not_auth' | 'authenticated' | 'verifying'; @@ -1402,6 +1747,13 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) status: getProviderStatus(isOpencodeInstalled, isOpencodeAuthenticated), color: 'text-green-500', }, + { + id: 'gemini' as const, + label: 'Gemini', + icon: GeminiIcon, + status: getProviderStatus(isGeminiInstalled, isGeminiAuthenticated), + color: 'text-blue-500', + }, ]; const renderStatusIcon = (status: ProviderStatus) => { @@ -1438,7 +1790,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) )} setActiveTab(v as ProviderTab)}> - + {providers.map((provider) => { const Icon = provider.icon; return ( @@ -1484,6 +1836,9 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) + + + diff --git a/apps/ui/src/hooks/queries/index.ts b/apps/ui/src/hooks/queries/index.ts index 18e38120..e58b5945 100644 --- a/apps/ui/src/hooks/queries/index.ts +++ b/apps/ui/src/hooks/queries/index.ts @@ -63,6 +63,7 @@ export { useCursorCliStatus, useCodexCliStatus, useOpencodeCliStatus, + useGeminiCliStatus, useGitHubCliStatus, useApiKeysStatus, usePlatformInfo, diff --git a/apps/ui/src/hooks/queries/use-cli-status.ts b/apps/ui/src/hooks/queries/use-cli-status.ts index 71ea2ae9..4b6705aa 100644 --- a/apps/ui/src/hooks/queries/use-cli-status.ts +++ b/apps/ui/src/hooks/queries/use-cli-status.ts @@ -89,6 +89,26 @@ export function useOpencodeCliStatus() { }); } +/** + * Fetch Gemini CLI status + * + * @returns Query result with Gemini CLI status + */ +export function useGeminiCliStatus() { + return useQuery({ + queryKey: queryKeys.cli.gemini(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getGeminiStatus(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch Gemini status'); + } + return result; + }, + staleTime: STALE_TIMES.CLI_STATUS, + }); +} + /** * Fetch GitHub CLI status * diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index c7492387..80f30267 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -21,15 +21,20 @@ import { useAuthStore } from '@/store/auth-store'; import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration'; import { DEFAULT_OPENCODE_MODEL, + DEFAULT_GEMINI_MODEL, DEFAULT_MAX_CONCURRENCY, getAllOpencodeModelIds, getAllCursorModelIds, + getAllCodexModelIds, + getAllGeminiModelIds, migrateCursorModelIds, migrateOpencodeModelIds, migratePhaseModelEntry, type GlobalSettings, type CursorModelId, type OpencodeModelId, + type CodexModelId, + type GeminiModelId, } from '@automaker/types'; const logger = createLogger('SettingsSync'); @@ -66,6 +71,10 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'cursorDefaultModel', 'enabledOpencodeModels', 'opencodeDefaultModel', + 'enabledCodexModels', + 'codexDefaultModel', + 'enabledGeminiModels', + 'geminiDefaultModel', 'enabledDynamicModelIds', 'disabledProviders', 'autoLoadClaudeMd', @@ -567,6 +576,37 @@ export async function refreshSettingsFromServer(): Promise { sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel); } + // Sanitize Codex models + const validCodexModelIds = new Set(getAllCodexModelIds()); + const DEFAULT_CODEX_MODEL: CodexModelId = 'codex-gpt-5.2-codex'; + const sanitizedEnabledCodexModels = (serverSettings.enabledCodexModels ?? []).filter( + (id): id is CodexModelId => validCodexModelIds.has(id as CodexModelId) + ); + const sanitizedCodexDefaultModel = validCodexModelIds.has( + serverSettings.codexDefaultModel as CodexModelId + ) + ? (serverSettings.codexDefaultModel as CodexModelId) + : DEFAULT_CODEX_MODEL; + + if (!sanitizedEnabledCodexModels.includes(sanitizedCodexDefaultModel)) { + sanitizedEnabledCodexModels.push(sanitizedCodexDefaultModel); + } + + // Sanitize Gemini models + const validGeminiModelIds = new Set(getAllGeminiModelIds()); + const sanitizedEnabledGeminiModels = (serverSettings.enabledGeminiModels ?? []).filter( + (id): id is GeminiModelId => validGeminiModelIds.has(id as GeminiModelId) + ); + const sanitizedGeminiDefaultModel = validGeminiModelIds.has( + serverSettings.geminiDefaultModel as GeminiModelId + ) + ? (serverSettings.geminiDefaultModel as GeminiModelId) + : DEFAULT_GEMINI_MODEL; + + if (!sanitizedEnabledGeminiModels.includes(sanitizedGeminiDefaultModel)) { + sanitizedEnabledGeminiModels.push(sanitizedGeminiDefaultModel); + } + const persistedDynamicModelIds = serverSettings.enabledDynamicModelIds ?? currentAppState.enabledDynamicModelIds; const sanitizedDynamicModelIds = persistedDynamicModelIds.filter( @@ -659,6 +699,10 @@ export async function refreshSettingsFromServer(): Promise { cursorDefaultModel: sanitizedCursorDefault, enabledOpencodeModels: sanitizedEnabledOpencodeModels, opencodeDefaultModel: sanitizedOpencodeDefaultModel, + enabledCodexModels: sanitizedEnabledCodexModels, + codexDefaultModel: sanitizedCodexDefaultModel, + enabledGeminiModels: sanitizedEnabledGeminiModels, + geminiDefaultModel: sanitizedGeminiDefaultModel, enabledDynamicModelIds: sanitizedDynamicModelIds, disabledProviders: serverSettings.disabledProviders ?? [], autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false, diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index e902c693..5282374d 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1655,6 +1655,48 @@ export class HttpApiClient implements ElectronAPI { error?: string; }> => this.post('/api/setup/opencode/cache/clear'), + // Gemini CLI methods + getGeminiStatus: (): Promise<{ + success: boolean; + status?: string; + installed?: boolean; + method?: string; + version?: string; + path?: string; + recommendation?: string; + installCommands?: { + macos?: string; + linux?: string; + npm?: string; + }; + auth?: { + authenticated: boolean; + method: string; + hasApiKey?: boolean; + hasEnvApiKey?: boolean; + error?: string; + }; + loginCommand?: string; + installCommand?: string; + error?: string; + }> => this.get('/api/setup/gemini-status'), + + authGemini: (): Promise<{ + success: boolean; + requiresManualAuth?: boolean; + command?: string; + message?: string; + error?: string; + }> => this.post('/api/setup/auth-gemini'), + + deauthGemini: (): Promise<{ + success: boolean; + requiresManualDeauth?: boolean; + command?: string; + message?: string; + error?: string; + }> => this.post('/api/setup/deauth-gemini'), + onInstallProgress: (callback: (progress: unknown) => void) => { return this.subscribeToEvent('agent:stream', callback); }, diff --git a/apps/ui/src/lib/query-keys.ts b/apps/ui/src/lib/query-keys.ts index feb69c65..794515c4 100644 --- a/apps/ui/src/lib/query-keys.ts +++ b/apps/ui/src/lib/query-keys.ts @@ -176,6 +176,8 @@ export const queryKeys = { codex: () => ['cli', 'codex'] as const, /** OpenCode CLI status */ opencode: () => ['cli', 'opencode'] as const, + /** Gemini CLI status */ + gemini: () => ['cli', 'gemini'] as const, /** GitHub CLI status */ github: () => ['cli', 'github'] as const, /** API keys status */ diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 5eef480d..a8098262 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -21,6 +21,7 @@ import type { CursorModelId, CodexModelId, OpencodeModelId, + GeminiModelId, PhaseModelConfig, PhaseModelKey, PhaseModelEntry, @@ -39,8 +40,10 @@ import { getAllCursorModelIds, getAllCodexModelIds, getAllOpencodeModelIds, + getAllGeminiModelIds, DEFAULT_PHASE_MODELS, DEFAULT_OPENCODE_MODEL, + DEFAULT_GEMINI_MODEL, DEFAULT_MAX_CONCURRENCY, DEFAULT_GLOBAL_SETTINGS, } from '@automaker/types'; @@ -729,6 +732,10 @@ export interface AppState { opencodeModelsLastFetched: number | null; // Timestamp of last successful fetch opencodeModelsLastFailedAt: number | null; // Timestamp of last failed fetch + // Gemini CLI Settings (global) + enabledGeminiModels: GeminiModelId[]; // Which Gemini models are available in feature modal + geminiDefaultModel: GeminiModelId; // Default Gemini model selection + // Provider Visibility Settings disabledProviders: ModelProvider[]; // Providers that are disabled and hidden from dropdowns @@ -1218,6 +1225,11 @@ export interface AppActions { providers: Array<{ id: string; name: string; authenticated: boolean; authMethod?: string }> ) => void; + // Gemini CLI Settings actions + setEnabledGeminiModels: (models: GeminiModelId[]) => void; + setGeminiDefaultModel: (model: GeminiModelId) => void; + toggleGeminiModel: (model: GeminiModelId, enabled: boolean) => void; + // Provider Visibility Settings actions setDisabledProviders: (providers: ModelProvider[]) => void; toggleProviderDisabled: (provider: ModelProvider, disabled: boolean) => void; @@ -1503,6 +1515,8 @@ const initialState: AppState = { opencodeModelsError: null, opencodeModelsLastFetched: null, opencodeModelsLastFailedAt: null, + enabledGeminiModels: getAllGeminiModelIds(), // All Gemini models enabled by default + geminiDefaultModel: DEFAULT_GEMINI_MODEL, // Default to Gemini 2.5 Flash disabledProviders: [], // No providers disabled by default autoLoadClaudeMd: false, // Default to disabled (user must opt-in) skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) @@ -2735,6 +2749,16 @@ export const useAppStore = create()((set, get) => ({ ), }), + // Gemini CLI Settings actions + setEnabledGeminiModels: (models) => set({ enabledGeminiModels: models }), + setGeminiDefaultModel: (model) => set({ geminiDefaultModel: model }), + toggleGeminiModel: (model, enabled) => + set((state) => ({ + enabledGeminiModels: enabled + ? [...state.enabledGeminiModels, model] + : state.enabledGeminiModels.filter((m) => m !== model), + })), + // Provider Visibility Settings actions setDisabledProviders: (providers) => set({ disabledProviders: providers }), toggleProviderDisabled: (provider, disabled) => diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index 277eeb7e..c2f9821b 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -63,6 +63,22 @@ export interface OpencodeCliStatus { error?: string; } +// Gemini CLI Status +export interface GeminiCliStatus { + installed: boolean; + version?: string | null; + path?: string | null; + auth?: { + authenticated: boolean; + method: string; + hasApiKey?: boolean; + hasEnvApiKey?: boolean; + }; + installCommand?: string; + loginCommand?: string; + error?: string; +} + // Codex Auth Method export type CodexAuthMethod = | 'api_key_env' // OPENAI_API_KEY environment variable @@ -120,6 +136,7 @@ export type SetupStep = | 'cursor' | 'codex' | 'opencode' + | 'gemini' | 'github' | 'complete'; @@ -149,6 +166,9 @@ export interface SetupState { // OpenCode CLI state opencodeCliStatus: OpencodeCliStatus | null; + // Gemini CLI state + geminiCliStatus: GeminiCliStatus | null; + // Setup preferences skipClaudeSetup: boolean; } @@ -183,6 +203,9 @@ export interface SetupActions { // OpenCode CLI setOpencodeCliStatus: (status: OpencodeCliStatus | null) => void; + // Gemini CLI + setGeminiCliStatus: (status: GeminiCliStatus | null) => void; + // Preferences setSkipClaudeSetup: (skip: boolean) => void; } @@ -216,6 +239,8 @@ const initialState: SetupState = { opencodeCliStatus: null, + geminiCliStatus: null, + skipClaudeSetup: shouldSkipSetup, }; @@ -288,6 +313,9 @@ export const useSetupStore = create()((set, get) => ( // OpenCode CLI setOpencodeCliStatus: (status) => set({ opencodeCliStatus: status }), + // Gemini CLI + setGeminiCliStatus: (status) => set({ geminiCliStatus: status }), + // Preferences setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }), })); diff --git a/libs/types/src/gemini-models.ts b/libs/types/src/gemini-models.ts new file mode 100644 index 00000000..340ee4d8 --- /dev/null +++ b/libs/types/src/gemini-models.ts @@ -0,0 +1,101 @@ +/** + * Gemini CLI Model Definitions + * + * Defines available models for Gemini CLI integration. + * Based on https://github.com/google-gemini/gemini-cli + */ + +/** + * Gemini model configuration + */ +export interface GeminiModelConfig { + label: string; + description: string; + supportsVision: boolean; + supportsThinking: boolean; + contextWindow?: number; +} + +/** + * Available Gemini models via the Gemini CLI + * Models from Gemini 2.5 and 3.0 series + * + * Model IDs use 'gemini-' prefix for consistent provider routing (like Cursor). + * When passed to the CLI, the prefix is part of the actual model name. + */ +export const GEMINI_MODEL_MAP = { + // Gemini 3 Series (latest) + 'gemini-3-pro-preview': { + label: 'Gemini 3 Pro Preview', + description: 'Most advanced Gemini model with deep reasoning capabilities.', + supportsVision: true, + supportsThinking: true, + contextWindow: 1000000, + }, + 'gemini-3-flash-preview': { + label: 'Gemini 3 Flash Preview', + description: 'Fast Gemini 3 model for quick tasks.', + supportsVision: true, + supportsThinking: true, + contextWindow: 1000000, + }, + // Gemini 2.5 Series + 'gemini-2.5-pro': { + label: 'Gemini 2.5 Pro', + description: 'Advanced model with strong reasoning and 1M context.', + supportsVision: true, + supportsThinking: true, + contextWindow: 1000000, + }, + 'gemini-2.5-flash': { + label: 'Gemini 2.5 Flash', + description: 'Balanced speed and capability for most tasks.', + supportsVision: true, + supportsThinking: true, + contextWindow: 1000000, + }, + 'gemini-2.5-flash-lite': { + label: 'Gemini 2.5 Flash Lite', + description: 'Fastest Gemini model for simple tasks.', + supportsVision: true, + supportsThinking: false, + contextWindow: 1000000, + }, +} as const satisfies Record; + +/** + * Gemini model ID type (keys already have gemini- prefix) + */ +export type GeminiModelId = keyof typeof GEMINI_MODEL_MAP; + +/** + * Get all Gemini model IDs + */ +export function getAllGeminiModelIds(): GeminiModelId[] { + return Object.keys(GEMINI_MODEL_MAP) as GeminiModelId[]; +} + +/** + * Default Gemini model (balanced choice) + */ +export const DEFAULT_GEMINI_MODEL: GeminiModelId = 'gemini-2.5-flash'; + +/** + * Thinking level configuration for Gemini models + * Note: The Gemini CLI does not currently expose a --thinking-level flag. + * Thinking control (thinkingLevel/thinkingBudget) is available via the Gemini API. + * This type is defined for potential future CLI support or API-level configuration. + */ +export type GeminiThinkingLevel = 'off' | 'low' | 'medium' | 'high'; + +/** + * Gemini CLI authentication status + */ +export interface GeminiAuthStatus { + authenticated: boolean; + method: 'google_login' | 'api_key' | 'vertex_ai' | 'none'; + hasApiKey?: boolean; + hasEnvApiKey?: boolean; + hasCredentialsFile?: boolean; + error?: string; +} diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 5e939c41..54d8cf3c 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -205,6 +205,7 @@ export { export type { ModelOption, ThinkingLevelOption, ReasoningEffortOption } from './model-display.js'; export { CLAUDE_MODELS, + GEMINI_MODELS, THINKING_LEVELS, THINKING_LEVEL_LABELS, REASONING_EFFORT_LEVELS, @@ -249,6 +250,9 @@ export * from './cursor-cli.js'; // OpenCode types export * from './opencode-models.js'; +// Gemini types +export * from './gemini-models.js'; + // Provider utilities export { PROVIDER_PREFIXES, @@ -256,6 +260,7 @@ export { isClaudeModel, isCodexModel, isOpencodeModel, + isGeminiModel, getModelProvider, stripProviderPrefix, addProviderPrefix, diff --git a/libs/types/src/model-display.ts b/libs/types/src/model-display.ts index 235466cd..28670328 100644 --- a/libs/types/src/model-display.ts +++ b/libs/types/src/model-display.ts @@ -10,20 +10,21 @@ import type { ReasoningEffort } from './provider.js'; import type { CursorModelId } from './cursor-models.js'; import type { AgentModel, CodexModelId } from './model.js'; import { CODEX_MODEL_MAP } from './model.js'; +import { GEMINI_MODEL_MAP, type GeminiModelId } from './gemini-models.js'; /** * ModelOption - Display metadata for a model option in the UI */ export interface ModelOption { - /** Model identifier (supports both Claude and Cursor models) */ - id: ModelAlias | CursorModelId; + /** Model identifier (supports Claude, Cursor, Gemini models) */ + id: ModelAlias | CursorModelId | GeminiModelId; /** Display name shown to user */ label: string; /** Descriptive text explaining model capabilities */ description: string; /** Optional badge text (e.g., "Speed", "Balanced", "Premium") */ badge?: string; - /** AI provider (supports 'claude' and 'cursor') */ + /** AI provider */ provider: ModelProvider; } @@ -113,6 +114,22 @@ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [ }, ]; +/** + * Gemini model options with full metadata for UI display + * Based on https://github.com/google-gemini/gemini-cli + * Model IDs match the keys in GEMINI_MODEL_MAP (e.g., 'gemini-2.5-flash') + */ +export const GEMINI_MODELS: (ModelOption & { hasThinking?: boolean })[] = Object.entries( + GEMINI_MODEL_MAP +).map(([id, config]) => ({ + id: id as GeminiModelId, + label: config.label, + description: config.description, + badge: config.supportsThinking ? 'Thinking' : 'Speed', + provider: 'gemini' as const, + hasThinking: config.supportsThinking, +})); + /** * Thinking level options with display labels * @@ -200,5 +217,16 @@ export function getModelDisplayName(model: ModelAlias | string): string { [CODEX_MODEL_MAP.gpt52]: 'GPT-5.2', [CODEX_MODEL_MAP.gpt51]: 'GPT-5.1', }; - return displayNames[model] || model; + + // Check direct match first + if (model in displayNames) { + return displayNames[model]; + } + + // Check Gemini model map - IDs are like 'gemini-2.5-flash' + if (model in GEMINI_MODEL_MAP) { + return GEMINI_MODEL_MAP[model as keyof typeof GEMINI_MODEL_MAP].label; + } + + return model; } diff --git a/libs/types/src/model.ts b/libs/types/src/model.ts index 2973a892..5538989e 100644 --- a/libs/types/src/model.ts +++ b/libs/types/src/model.ts @@ -3,6 +3,7 @@ */ import type { CursorModelId } from './cursor-models.js'; import type { OpencodeModelId } from './opencode-models.js'; +import type { GeminiModelId } from './gemini-models.js'; /** * Canonical Claude model IDs with provider prefix @@ -119,6 +120,7 @@ export type DynamicModelId = `${string}/${string}`; */ export type PrefixedCursorModelId = `cursor-${string}`; export type PrefixedOpencodeModelId = `opencode-${string}`; +export type PrefixedGeminiModelId = `gemini-${string}`; /** * ModelId - Unified model identifier across providers @@ -127,7 +129,9 @@ export type ModelId = | ModelAlias | CodexModelId | CursorModelId + | GeminiModelId | OpencodeModelId | DynamicModelId | PrefixedCursorModelId - | PrefixedOpencodeModelId; + | PrefixedOpencodeModelId + | PrefixedGeminiModelId; diff --git a/libs/types/src/provider-utils.ts b/libs/types/src/provider-utils.ts index af776cb2..fc84783e 100644 --- a/libs/types/src/provider-utils.ts +++ b/libs/types/src/provider-utils.ts @@ -10,12 +10,14 @@ import type { ModelProvider } from './settings.js'; import { CURSOR_MODEL_MAP, LEGACY_CURSOR_MODEL_MAP } from './cursor-models.js'; import { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP } from './model.js'; import { OPENCODE_MODEL_CONFIG_MAP, LEGACY_OPENCODE_MODEL_MAP } from './opencode-models.js'; +import { GEMINI_MODEL_MAP } from './gemini-models.js'; /** Provider prefix constants */ export const PROVIDER_PREFIXES = { cursor: 'cursor-', codex: 'codex-', opencode: 'opencode-', + gemini: 'gemini-', } as const; /** @@ -90,6 +92,28 @@ export function isCodexModel(model: string | undefined | null): boolean { return model in CODEX_MODEL_MAP; } +/** + * Check if a model string represents a Gemini model + * + * @param model - Model string to check (e.g., "gemini-2.5-pro", "gemini-3-pro-preview") + * @returns true if the model is a Gemini model + */ +export function isGeminiModel(model: string | undefined | null): boolean { + if (!model || typeof model !== 'string') return false; + + // Canonical format: gemini- prefix (e.g., "gemini-2.5-flash") + if (model.startsWith(PROVIDER_PREFIXES.gemini)) { + return true; + } + + // Check if it's a known Gemini model ID (map keys include gemini- prefix) + if (model in GEMINI_MODEL_MAP) { + return true; + } + + return false; +} + /** * Check if a model string represents an OpenCode model * @@ -151,7 +175,11 @@ export function isOpencodeModel(model: string | undefined | null): boolean { * @returns The provider type, defaults to 'claude' for unknown models */ export function getModelProvider(model: string | undefined | null): ModelProvider { - // Check OpenCode first since it uses provider-prefixed formats that could conflict + // Check Gemini first since it uses gemini- prefix + if (isGeminiModel(model)) { + return 'gemini'; + } + // Check OpenCode next since it uses provider-prefixed formats that could conflict if (isOpencodeModel(model)) { return 'opencode'; } @@ -199,6 +227,7 @@ export function stripProviderPrefix(model: string): string { * addProviderPrefix('cursor-composer-1', 'cursor') // 'cursor-composer-1' (no change) * addProviderPrefix('gpt-5.2', 'codex') // 'codex-gpt-5.2' * addProviderPrefix('sonnet', 'claude') // 'sonnet' (Claude doesn't use prefix) + * addProviderPrefix('2.5-flash', 'gemini') // 'gemini-2.5-flash' */ export function addProviderPrefix(model: string, provider: ModelProvider): string { if (!model || typeof model !== 'string') return model; @@ -215,6 +244,10 @@ export function addProviderPrefix(model: string, provider: ModelProvider): strin if (!model.startsWith(PROVIDER_PREFIXES.opencode)) { return `${PROVIDER_PREFIXES.opencode}${model}`; } + } else if (provider === 'gemini') { + if (!model.startsWith(PROVIDER_PREFIXES.gemini)) { + return `${PROVIDER_PREFIXES.gemini}${model}`; + } } // Claude models don't use prefixes return model; @@ -250,6 +283,7 @@ export function normalizeModelString(model: string | undefined | null): string { model.startsWith(PROVIDER_PREFIXES.cursor) || model.startsWith(PROVIDER_PREFIXES.codex) || model.startsWith(PROVIDER_PREFIXES.opencode) || + model.startsWith(PROVIDER_PREFIXES.gemini) || model.startsWith('claude-') ) { return model; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 54ada432..e67af911 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -99,7 +99,7 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number } /** ModelProvider - AI model provider for credentials and API key management */ -export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode'; +export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini'; // ============================================================================ // Claude-Compatible Providers - Configuration for Claude-compatible API endpoints