mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
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:
committed by
GitHub
parent
7773db559d
commit
f480386905
815
apps/server/src/providers/gemini-provider.ts
Normal file
815
apps/server/src/providers/gemini-provider.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
42
apps/server/src/routes/setup/routes/auth-gemini.ts
Normal file
42
apps/server/src/routes/setup/routes/auth-gemini.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/setup/routes/deauth-gemini.ts
Normal file
42
apps/server/src/routes/setup/routes/deauth-gemini.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
79
apps/server/src/routes/setup/routes/gemini-status.ts
Normal file
79
apps/server/src/routes/setup/routes/gemini-status.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -395,6 +395,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
|
||||
cursor: CursorIcon,
|
||||
codex: OpenAIIcon,
|
||||
opencode: OpenCodeIcon,
|
||||
gemini: GeminiIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -8,6 +8,7 @@ export type SettingsViewId =
|
||||
| 'cursor-provider'
|
||||
| 'codex-provider'
|
||||
| 'opencode-provider'
|
||||
| 'gemini-provider'
|
||||
| 'mcp-servers'
|
||||
| 'prompts'
|
||||
| 'model-defaults'
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export {
|
||||
useCursorCliStatus,
|
||||
useCodexCliStatus,
|
||||
useOpencodeCliStatus,
|
||||
useGeminiCliStatus,
|
||||
useGitHubCliStatus,
|
||||
useApiKeysStatus,
|
||||
usePlatformInfo,
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
101
libs/types/src/gemini-models.ts
Normal file
101
libs/types/src/gemini-models.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user