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 <noreply@anthropic.com>
This commit is contained in:
Stefan de Vogelaere
2026-01-23 01:42:17 +01:00
committed by GitHub
parent 7773db559d
commit f480386905
33 changed files with 2408 additions and 27 deletions

View File

@@ -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<string, unknown>;
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<string, string> = {
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<string, unknown>
): Record<string, unknown> {
// 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<string, unknown>
);
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<ProviderMessage> {
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<string | null> {
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<GeminiAuthStatus> {
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<InstallationStatus> {
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);
}
}

View File

@@ -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<string, string> = {
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)
});

View File

@@ -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());

View File

@@ -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<void> => {
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),
});
}
};
}

View File

@@ -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<void> => {
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),
});
}
};
}

View File

@@ -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<boolean> {
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<void> => {
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),
});
}
};
}

View File

@@ -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 () => {

View File

@@ -395,6 +395,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
cursor: CursorIcon,
codex: OpenAIIcon,
opencode: OpenCodeIcon,
gemini: GeminiIcon,
};
/**

View File

@@ -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<string, React.ComponentType<{ className?: str
Cursor: CursorIcon,
Codex: OpenAIIcon,
OpenCode: OpenCodeIcon,
Gemini: GeminiIcon,
};

View File

@@ -23,6 +23,7 @@ import {
CursorSettingsTab,
CodexSettingsTab,
OpencodeSettingsTab,
GeminiSettingsTab,
} from './settings-view/providers';
import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts';
@@ -123,6 +124,8 @@ export function SettingsView() {
return <CodexSettingsTab />;
case 'opencode-provider':
return <OpencodeSettingsTab />;
case 'gemini-provider':
return <GeminiSettingsTab />;
case 'providers':
case 'claude': // Backwards compatibility - redirect to claude-provider
return <ClaudeSettingsTab />;

View File

@@ -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 (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<SkeletonPulse className="w-9 h-9 rounded-xl" />
<SkeletonPulse className="h-6 w-36" />
</div>
<SkeletonPulse className="w-9 h-9 rounded-lg" />
</div>
<div className="ml-12">
<SkeletonPulse className="h-4 w-80" />
</div>
</div>
<div className="p-6 space-y-4">
{/* Installation status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-40" />
<SkeletonPulse className="h-3 w-32" />
<SkeletonPulse className="h-3 w-48" />
</div>
</div>
{/* Auth status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-28" />
<SkeletonPulse className="h-3 w-36" />
</div>
</div>
</div>
</div>
);
}
export function GeminiCliStatus({
status,
authStatus,
isChecking,
onRefresh,
}: GeminiCliStatusProps) {
if (!status) return <GeminiCliStatusSkeleton />;
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 flex items-center justify-center border border-blue-500/20">
<GeminiIcon className="w-5 h-5 text-blue-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Gemini CLI</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={isChecking}
data-testid="refresh-gemini-cli"
title="Refresh Gemini CLI detection"
className={cn(
'h-9 w-9 rounded-lg',
'hover:bg-accent/50 hover:scale-105',
'transition-all duration-200'
)}
>
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Gemini CLI provides access to Google&apos;s Gemini AI models with thinking capabilities.
</p>
</div>
<div className="p-6 space-y-4">
{status.success && status.status === 'installed' ? (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Gemini CLI Installed</p>
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
{status.method && (
<p>
Method: <span className="font-mono">{status.method}</span>
</p>
)}
{status.version && (
<p>
Version: <span className="font-mono">{status.version}</span>
</p>
)}
{status.path && (
<p className="truncate" title={status.path}>
Path: <span className="font-mono text-[10px]">{status.path}</span>
</p>
)}
</div>
</div>
</div>
{/* Authentication Status */}
{authStatus?.authenticated ? (
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
<div className="text-xs text-emerald-400/70 mt-1.5">
{authStatus.method !== 'none' && (
<p>
Method:{' '}
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
</p>
)}
</div>
</div>
</div>
) : (
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
<div className="w-10 h-10 rounded-xl bg-red-500/15 flex items-center justify-center border border-red-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-red-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-red-400">Authentication Failed</p>
{authStatus?.error && (
<p className="text-xs text-red-400/70 mt-1">{authStatus.error}</p>
)}
<p className="text-xs text-red-400/70 mt-2">
Run <code className="font-mono bg-red-500/10 px-1 rounded">gemini</code>{' '}
interactively in your terminal to log in with Google, or set the{' '}
<code className="font-mono bg-red-500/10 px-1 rounded">GEMINI_API_KEY</code>{' '}
environment variable.
</p>
</div>
</div>
)}
{status.recommendation && (
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">Gemini CLI Not Detected</p>
<p className="text-xs text-amber-400/70 mt-1">
{status.recommendation || 'Install Gemini CLI to use Google Gemini models.'}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-3">
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
<div className="space-y-2">
{status.installCommands.npm && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
npm
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.npm}
</code>
</div>
)}
{status.installCommands.macos && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
macOS/Linux
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.macos}
</code>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -17,6 +17,7 @@ const NAV_ID_TO_PROVIDER: Record<string, ModelProvider> = {
'cursor-provider': 'cursor',
'codex-provider': 'codex',
'opencode-provider': 'opencode',
'gemini-provider': 'gemini',
};
interface SettingsNavigationProps {

View File

@@ -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 },

View File

@@ -8,6 +8,7 @@ export type SettingsViewId =
| 'cursor-provider'
| 'codex-provider'
| 'opencode-provider'
| 'gemini-provider'
| 'mcp-servers'
| 'prompts'
| 'model-defaults'

View File

@@ -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<HTMLDivElement>(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 (
<CommandItem
key={model.id}
value={model.label}
onSelect={() => {
onChange({ model: model.id as GeminiModelId });
setOpen(false);
}}
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<GeminiIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
{model.label}
</span>
<span className="truncate text-xs text-muted-foreground">{model.description}</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
isFavorite
? 'text-yellow-500 opacity-100'
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
)}
onClick={(e) => {
e.stopPropagation();
toggleFavoriteModel(model.id);
}}
>
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
</Button>
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
</div>
</CommandItem>
);
};
// 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({
</CommandGroup>
)}
{!isGeminiDisabled && gemini.length > 0 && (
<CommandGroup heading="Gemini Models">
{gemini.map((model) => renderGeminiModelItem(model))}
</CommandGroup>
)}
{opencodeSections.length > 0 && (
<CommandGroup heading={OPENCODE_CLI_GROUP_LABEL}>
{opencodeSections.map((section, sectionIndex) => (

View File

@@ -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<GeminiModelId, GeminiModelInfo> = 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<GeminiModelId, GeminiModelInfo>;
export function GeminiModelConfiguration({
enabledGeminiModels,
geminiDefaultModel,
isSaving,
onDefaultModelChange,
onModelToggle,
}: GeminiModelConfigurationProps) {
const availableModels = Object.values(GEMINI_MODEL_INFO);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 flex items-center justify-center border border-blue-500/20">
<GeminiIcon className="w-5 h-5 text-blue-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Model Configuration
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure which Gemini models are available in the feature modal
</p>
</div>
<div className="p-6 space-y-6">
<div className="space-y-2">
<Label>Default Model</Label>
<Select
value={geminiDefaultModel}
onValueChange={(v) => onDefaultModelChange(v as GeminiModelId)}
disabled={isSaving}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span>{model.label}</span>
{model.supportsThinking && (
<Badge variant="outline" className="text-xs">
Thinking
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label>Available Models</Label>
<div className="grid gap-3">
{availableModels.map((model) => {
const isEnabled = enabledGeminiModels.includes(model.id);
const isDefault = model.id === geminiDefaultModel;
return (
<div
key={model.id}
className="flex items-center justify-between p-3 rounded-xl border border-border/50 bg-card/50 hover:bg-accent/30 transition-colors"
>
<div className="flex items-center gap-3">
<Checkbox
checked={isEnabled}
onCheckedChange={(checked) => onModelToggle(model.id, !!checked)}
disabled={isSaving || isDefault}
/>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{model.label}</span>
{model.supportsThinking && (
<Badge variant="outline" className="text-xs">
Thinking
</Badge>
)}
{isDefault && (
<Badge variant="secondary" className="text-xs">
Default
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{model.description}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<GeminiCliStatusSkeleton />
</div>
);
}
return (
<div className="space-y-6">
{/* Provider Visibility Toggle */}
<ProviderToggle provider="gemini" providerLabel="Gemini" />
<GeminiCliStatus
status={cliStatus}
authStatus={authStatus}
isChecking={isCheckingGeminiCli}
onRefresh={handleRefreshGeminiCli}
/>
{/* Model Configuration - Only show when CLI is installed */}
{isCliInstalled && (
<GeminiModelConfiguration
enabledGeminiModels={enabledGeminiModels}
geminiDefaultModel={geminiDefaultModel}
isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle}
/>
)}
</div>
);
}
export default GeminiSettingsTab;

View File

@@ -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';

View File

@@ -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 (
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="grid w-full grid-cols-4 mb-6">
<TabsList className="grid w-full grid-cols-5 mb-6">
<TabsTrigger value="claude" className="flex items-center gap-2">
<AnthropicIcon className="w-4 h-4" />
Claude
@@ -28,9 +34,13 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
Codex
</TabsTrigger>
<TabsTrigger value="opencode" className="flex items-center gap-2">
<Cpu className="w-4 h-4" />
<OpenCodeIcon className="w-4 h-4" />
OpenCode
</TabsTrigger>
<TabsTrigger value="gemini" className="flex items-center gap-2">
<GeminiIcon className="w-4 h-4" />
Gemini
</TabsTrigger>
</TabsList>
<TabsContent value="claude">
@@ -48,6 +58,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
<TabsContent value="opencode">
<OpencodeSettingsTab />
</TabsContent>
<TabsContent value="gemini">
<GeminiSettingsTab />
</TabsContent>
</Tabs>
);
}

View File

@@ -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<NodeJS.Timeout | null>(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 (
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<GeminiIcon className="w-5 h-5" />
Gemini CLI Status
</CardTitle>
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<CardDescription>
{geminiCliStatus?.installed
? geminiCliStatus.auth?.authenticated
? `Authenticated${geminiCliStatus.version ? ` (v${geminiCliStatus.version})` : ''}`
: 'Installed but not authenticated'
: 'Not installed on your system'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isReady && (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">CLI Installed</p>
<p className="text-sm text-muted-foreground">
{geminiCliStatus?.version && `Version: ${geminiCliStatus.version}`}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<p className="font-medium text-foreground">Authenticated</p>
</div>
</div>
)}
{!geminiCliStatus?.installed && !isChecking && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-lg bg-muted/30 border border-border">
<XCircle className="w-5 h-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">Gemini CLI not found</p>
<p className="text-sm text-muted-foreground mt-1">
Install the Gemini CLI to use Google Gemini models.
</p>
</div>
</div>
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
<p className="font-medium text-foreground text-sm">Install Gemini CLI:</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground overflow-x-auto">
{geminiCliStatus?.installCommand || 'npm install -g @google/gemini-cli'}
</code>
<Button
variant="ghost"
size="icon"
onClick={() =>
copyCommand(
geminiCliStatus?.installCommand || 'npm install -g @google/gemini-cli'
)
}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
</div>
)}
{geminiCliStatus?.installed && !geminiCliStatus?.auth?.authenticated && !isChecking && (
<div className="space-y-4">
{/* Show CLI installed toast */}
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">CLI Installed</p>
<p className="text-sm text-muted-foreground">
{geminiCliStatus?.version && `Version: ${geminiCliStatus.version}`}
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">Gemini CLI not authenticated</p>
<p className="text-sm text-muted-foreground mt-1">
Run the login command or provide a Google API key below.
</p>
</div>
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="cli" className="border-border">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<Terminal className="w-5 h-5 text-muted-foreground" />
<span className="font-medium">Google OAuth Login</span>
</div>
</AccordionTrigger>
<AccordionContent className="pt-4 space-y-4">
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
{geminiCliStatus?.loginCommand || 'gemini auth login'}
</code>
<Button
variant="ghost"
size="icon"
onClick={() =>
copyCommand(geminiCliStatus?.loginCommand || 'gemini auth login')
}
>
<Copy className="w-4 h-4" />
</Button>
</div>
<Button
onClick={handleLogin}
disabled={isLoggingIn}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
Waiting for login...
</>
) : (
'Copy Command & Wait for Login'
)}
</Button>
</AccordionContent>
</AccordionItem>
<AccordionItem value="api-key" className="border-border">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<Key className="w-5 h-5 text-muted-foreground" />
<span className="font-medium">Google API Key</span>
</div>
</AccordionTrigger>
<AccordionContent className="pt-4 space-y-4">
<div className="space-y-2">
<Input
type="password"
placeholder="AIza..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="bg-input border-border text-foreground"
/>
<p className="text-xs text-muted-foreground">
<a
href="https://aistudio.google.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="text-brand-500 hover:underline"
>
Get an API key from Google AI Studio
<ExternalLink className="w-3 h-3 inline ml-1" />
</a>
</p>
</div>
<Button
onClick={handleSaveApiKey}
disabled={isSaving || !apiKey.trim()}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
>
{isSaving ? <Spinner size="sm" /> : 'Save API Key'}
</Button>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)}
{isChecking && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Spinner size="md" />
<p className="font-medium text-foreground">Checking Gemini CLI status...</p>
</div>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// 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)
)}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as ProviderTab)}>
<TabsList className="grid w-full grid-cols-4 h-auto p-1">
<TabsList className="grid w-full grid-cols-5 h-auto p-1">
{providers.map((provider) => {
const Icon = provider.icon;
return (
@@ -1484,6 +1836,9 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
<TabsContent value="opencode" className="mt-0">
<OpencodeContent />
</TabsContent>
<TabsContent value="gemini" className="mt-0">
<GeminiContent />
</TabsContent>
</div>
</Tabs>

View File

@@ -63,6 +63,7 @@ export {
useCursorCliStatus,
useCodexCliStatus,
useOpencodeCliStatus,
useGeminiCliStatus,
useGitHubCliStatus,
useApiKeysStatus,
usePlatformInfo,

View File

@@ -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
*

View File

@@ -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<boolean> {
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<boolean> {
cursorDefaultModel: sanitizedCursorDefault,
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
enabledCodexModels: sanitizedEnabledCodexModels,
codexDefaultModel: sanitizedCodexDefaultModel,
enabledGeminiModels: sanitizedEnabledGeminiModels,
geminiDefaultModel: sanitizedGeminiDefaultModel,
enabledDynamicModelIds: sanitizedDynamicModelIds,
disabledProviders: serverSettings.disabledProviders ?? [],
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,

View File

@@ -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);
},

View File

@@ -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 */

View File

@@ -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<AppState & AppActions>()((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) =>

View File

@@ -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<SetupState & SetupActions>()((set, get) => (
// OpenCode CLI
setOpencodeCliStatus: (status) => set({ opencodeCliStatus: status }),
// Gemini CLI
setGeminiCliStatus: (status) => set({ geminiCliStatus: status }),
// Preferences
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
}));

View File

@@ -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<string, GeminiModelConfig>;
/**
* 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;
}

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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