Files
automaker/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx
Stefan de Vogelaere f480386905 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>
2026-01-23 01:42:17 +01:00

1871 lines
69 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { useSetupStore } from '@/store/setup-store';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import {
ArrowRight,
ArrowLeft,
CheckCircle2,
Key,
ExternalLink,
Copy,
RefreshCw,
Download,
XCircle,
Trash2,
AlertTriangle,
Terminal,
AlertCircle,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
OpenCodeIcon,
GeminiIcon,
} from '@/components/ui/provider-icon';
import { TerminalOutput } from '../components';
import { useCliInstallation, useTokenSave } from '../hooks';
interface ProvidersSetupStepProps {
onNext: () => void;
onBack: () => void;
}
type ProviderTab = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini';
// ============================================================================
// Claude Content
// ============================================================================
function ClaudeContent() {
const {
claudeCliStatus,
claudeAuthStatus,
setClaudeCliStatus,
setClaudeAuthStatus,
setClaudeInstallProgress,
setClaudeIsVerifying,
} = useSetupStore();
const { setApiKeys, apiKeys } = useAppStore();
const [apiKey, setApiKey] = useState('');
const [isChecking, setIsChecking] = useState(false);
const [isVerifying, setIsVerifying] = useState(false);
const [verificationError, setVerificationError] = useState<string | null>(null);
const [isDeletingApiKey, setIsDeletingApiKey] = useState(false);
const hasVerifiedRef = useRef(false);
const installApi = useCallback(
() => getElectronAPI().setup?.installClaude() || Promise.reject(),
[]
);
const getStoreState = useCallback(() => useSetupStore.getState().claudeCliStatus, []);
// Auto-verify CLI authentication
const verifyAuth = useCallback(async () => {
// Guard against duplicate verification
if (hasVerifiedRef.current) {
return;
}
setIsVerifying(true);
setClaudeIsVerifying(true); // Update store for parent to see
setVerificationError(null);
try {
const api = getElectronAPI();
if (!api.setup?.verifyClaudeAuth) {
return;
}
const result = await api.setup.verifyClaudeAuth('cli');
const hasLimitReachedError =
result.error?.toLowerCase().includes('limit reached') ||
result.error?.toLowerCase().includes('rate limit');
if (result.authenticated && !hasLimitReachedError) {
hasVerifiedRef.current = true;
// Use getState() to avoid dependency on claudeAuthStatus
const currentAuthStatus = useSetupStore.getState().claudeAuthStatus;
setClaudeAuthStatus({
authenticated: true,
method: 'cli_authenticated',
hasCredentialsFile: currentAuthStatus?.hasCredentialsFile || false,
});
toast.success('Claude CLI authenticated!');
} else if (hasLimitReachedError) {
setVerificationError('Rate limit reached. Please try again later.');
} else if (result.error) {
setVerificationError(result.error);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Verification failed';
setVerificationError(errorMessage);
} finally {
setIsVerifying(false);
setClaudeIsVerifying(false); // Update store when done
}
}, [setClaudeAuthStatus, setClaudeIsVerifying]);
// Check status and auto-verify
const checkStatus = useCallback(async () => {
setIsChecking(true);
setVerificationError(null);
// Reset verification guard to allow fresh verification (for manual refresh)
hasVerifiedRef.current = false;
try {
const api = getElectronAPI();
if (!api.setup?.getClaudeStatus) return;
const result = await api.setup.getClaudeStatus();
if (result.success) {
setClaudeCliStatus({
installed: result.installed ?? false,
version: result.version,
path: result.path,
method: 'none',
});
if (result.installed) {
toast.success('Claude CLI installed!');
// Auto-verify if CLI is installed
setIsChecking(false);
await verifyAuth();
return;
}
}
} catch {
// Ignore errors
} finally {
setIsChecking(false);
}
}, [setClaudeCliStatus, verifyAuth]);
const onInstallSuccess = useCallback(() => {
hasVerifiedRef.current = false;
checkStatus();
}, [checkStatus]);
const { isInstalling, installProgress, install } = useCliInstallation({
cliType: 'claude',
installApi,
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
onSuccess: onInstallSuccess,
getStoreState,
});
const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave({
provider: 'anthropic',
onSuccess: () => {
setClaudeAuthStatus({
authenticated: true,
method: 'api_key',
hasCredentialsFile: false,
apiKeyValid: true,
});
setApiKeys({ ...apiKeys, anthropic: apiKey });
toast.success('API key saved successfully!');
},
});
const deleteApiKey = useCallback(async () => {
setIsDeletingApiKey(true);
try {
const api = getElectronAPI();
if (!api.setup?.deleteApiKey) {
toast.error('Delete API not available');
return;
}
const result = await api.setup.deleteApiKey('anthropic');
if (result.success) {
setApiKey('');
setApiKeys({ ...apiKeys, anthropic: '' });
// Use getState() to avoid dependency on claudeAuthStatus
const currentAuthStatus = useSetupStore.getState().claudeAuthStatus;
setClaudeAuthStatus({
authenticated: false,
method: 'none',
hasCredentialsFile: currentAuthStatus?.hasCredentialsFile || false,
});
// Reset verification guard so next check can verify again
hasVerifiedRef.current = false;
toast.success('API key deleted successfully');
}
} catch {
toast.error('Failed to delete API key');
} finally {
setIsDeletingApiKey(false);
}
}, [apiKeys, setApiKeys, setClaudeAuthStatus]);
useEffect(() => {
setClaudeInstallProgress({ isInstalling, output: installProgress.output });
}, [isInstalling, installProgress, setClaudeInstallProgress]);
useEffect(() => {
checkStatus();
}, [checkStatus]);
const copyCommand = (command: string) => {
navigator.clipboard.writeText(command);
toast.success('Command copied to clipboard');
};
const hasApiKey =
!!apiKeys.anthropic ||
claudeAuthStatus?.method === 'api_key' ||
claudeAuthStatus?.method === 'api_key_env';
const isCliAuthenticated = claudeAuthStatus?.method === 'cli_authenticated';
const isApiKeyAuthenticated =
claudeAuthStatus?.method === 'api_key' || claudeAuthStatus?.method === 'api_key_env';
const isReady = claudeCliStatus?.installed && claudeAuthStatus?.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">
<AnthropicIcon className="w-5 h-5" />
Claude CLI Status
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={checkStatus}
disabled={isChecking || isVerifying}
>
{isChecking || isVerifying ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<CardDescription>
{claudeCliStatus?.installed
? claudeAuthStatus?.authenticated
? `Authenticated${claudeCliStatus.version ? ` (v${claudeCliStatus.version})` : ''}`
: isVerifying
? 'Verifying authentication...'
: 'Installed but not authenticated'
: 'Not installed on your system'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Success State - CLI Ready */}
{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">
{claudeCliStatus?.version && `Version: ${claudeCliStatus.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">
{isCliAuthenticated ? 'CLI Authenticated' : 'API Key Configured'}
</p>
</div>
</div>
)}
{/* Checking/Verifying State */}
{(isChecking || isVerifying) && (
<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">
{isChecking ? 'Checking Claude CLI status...' : 'Verifying authentication...'}
</p>
</div>
)}
{/* Not Installed */}
{!claudeCliStatus?.installed && !isChecking && !isVerifying && (
<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">Claude CLI not found</p>
<p className="text-sm text-muted-foreground mt-1">
Install Claude CLI to use Claude Code subscription.
</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 Claude CLI:</p>
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">macOS / Linux</Label>
<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">
curl -fsSL https://claude.ai/install.sh | bash
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand('curl -fsSL https://claude.ai/install.sh | bash')}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
{isInstalling && <TerminalOutput lines={installProgress.output} />}
<Button
onClick={install}
disabled={isInstalling}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
>
{isInstalling ? (
<>
<Spinner size="sm" className="mr-2" />
Installing...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Auto Install
</>
)}
</Button>
</div>
</div>
)}
{/* Installed but not authenticated */}
{claudeCliStatus?.installed &&
!claudeAuthStatus?.authenticated &&
!isChecking &&
!isVerifying && (
<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">
{claudeCliStatus?.version && `Version: ${claudeCliStatus.version}`}
</p>
</div>
</div>
{/* Error state */}
{verificationError && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
<div>
<p className="font-medium text-foreground">Authentication failed</p>
<p className="text-sm text-red-400 mt-1">{verificationError}</p>
</div>
</div>
)}
{/* Not authenticated warning */}
<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">Claude CLI not authenticated</p>
<p className="text-sm text-muted-foreground mt-1">
Run <code className="bg-muted px-1 rounded">claude login</code> in your terminal
or provide an API key below.
</p>
</div>
</div>
{/* API Key alternative */}
<Accordion type="single" collapsible className="w-full">
<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">Use Anthropic API Key instead</span>
</div>
</AccordionTrigger>
<AccordionContent className="pt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="anthropic-key" className="text-foreground">
Anthropic API Key
</Label>
<Input
id="anthropic-key"
type="password"
placeholder="sk-ant-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="bg-input border-border text-foreground"
/>
<p className="text-xs text-muted-foreground">
Don&apos;t have an API key?{' '}
<a
href="https://console.anthropic.com/settings/keys"
target="_blank"
rel="noopener noreferrer"
className="text-brand-500 hover:underline"
>
Get one from Anthropic Console
<ExternalLink className="w-3 h-3 inline ml-1" />
</a>
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => saveApiKeyToken(apiKey)}
disabled={isSavingApiKey || !apiKey.trim()}
className="flex-1 bg-brand-500 hover:bg-brand-600 text-white"
>
{isSavingApiKey ? <Spinner size="sm" /> : 'Save API Key'}
</Button>
{hasApiKey && (
<Button
onClick={deleteApiKey}
disabled={isDeletingApiKey}
variant="outline"
className="border-red-500/50 text-red-500 hover:bg-red-500/10"
>
{isDeletingApiKey ? (
<Spinner size="sm" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// Cursor Content
// ============================================================================
function CursorContent() {
const { cursorCliStatus, setCursorCliStatus } = useSetupStore();
const [isChecking, setIsChecking] = useState(false);
const [isLoggingIn, setIsLoggingIn] = useState(false);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const checkStatus = useCallback(async () => {
setIsChecking(true);
try {
const api = getElectronAPI();
if (!api.setup?.getCursorStatus) return;
const result = await api.setup.getCursorStatus();
if (result.success) {
setCursorCliStatus({
installed: result.installed ?? false,
version: result.version,
path: result.path,
auth: result.auth,
installCommand: result.installCommand,
loginCommand: result.loginCommand,
});
if (result.auth?.authenticated) {
toast.success('Cursor CLI is ready!');
}
}
} catch {
// Ignore errors
} finally {
setIsChecking(false);
}
}, [setCursorCliStatus]);
useEffect(() => {
checkStatus();
return () => {
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
};
}, [checkStatus]);
const copyCommand = (command: string) => {
navigator.clipboard.writeText(command);
toast.success('Command copied to clipboard');
};
const handleLogin = async () => {
setIsLoggingIn(true);
try {
const loginCommand = cursorCliStatus?.loginCommand || 'cursor-agent login';
await navigator.clipboard.writeText(loginCommand);
toast.info('Login command copied! Paste in terminal to authenticate.');
let attempts = 0;
pollIntervalRef.current = setInterval(async () => {
attempts++;
try {
const api = getElectronAPI();
if (!api.setup?.getCursorStatus) return;
const result = await api.setup.getCursorStatus();
if (result.auth?.authenticated) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setCursorCliStatus({
...cursorCliStatus,
installed: result.installed ?? true,
version: result.version,
path: result.path,
auth: result.auth,
});
setIsLoggingIn(false);
toast.success('Successfully logged in to Cursor!');
}
} catch {
// Ignore
}
if (attempts >= 60) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setIsLoggingIn(false);
toast.error('Login timed out. Please try again.');
}
}, 2000);
} catch {
toast.error('Failed to start login process');
setIsLoggingIn(false);
}
};
const isReady = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated;
return (
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<CursorIcon className="w-5 h-5" />
Cursor 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>
{cursorCliStatus?.installed
? cursorCliStatus.auth?.authenticated
? `Authenticated${cursorCliStatus.version ? ` (v${cursorCliStatus.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">
{cursorCliStatus?.version && `Version: ${cursorCliStatus.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>
)}
{!cursorCliStatus?.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">Cursor CLI not found</p>
<p className="text-sm text-muted-foreground mt-1">
Install Cursor IDE to use Cursor AI agent.
</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 Cursor:</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">
{cursorCliStatus?.installCommand || 'npm install -g @anthropic/cursor-agent'}
</code>
<Button
variant="ghost"
size="icon"
onClick={() =>
copyCommand(
cursorCliStatus?.installCommand || 'npm install -g @anthropic/cursor-agent'
)
}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
</div>
)}
{cursorCliStatus?.installed && !cursorCliStatus?.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">
{cursorCliStatus?.version && `Version: ${cursorCliStatus.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">Cursor CLI not authenticated</p>
<p className="text-sm text-muted-foreground mt-1">
Run the login command to authenticate.
</p>
</div>
</div>
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
{cursorCliStatus?.loginCommand || 'cursor-agent login'}
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand(cursorCliStatus?.loginCommand || 'cursor-agent 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>
</div>
</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 Cursor CLI status...</p>
</div>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// Codex Content
// ============================================================================
function CodexContent() {
const { codexCliStatus, codexAuthStatus, setCodexCliStatus, setCodexAuthStatus } =
useSetupStore();
const { setApiKeys, apiKeys } = useAppStore();
const [isChecking, setIsChecking] = useState(false);
const [apiKey, setApiKey] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isLoggingIn, setIsLoggingIn] = useState(false);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const checkStatus = useCallback(async () => {
setIsChecking(true);
try {
const api = getElectronAPI();
if (!api.setup?.getCodexStatus) return;
const result = await api.setup.getCodexStatus();
if (result.success) {
setCodexCliStatus({
installed: result.installed ?? false,
version: result.version,
path: result.path,
method: 'none',
});
if (result.auth?.authenticated) {
setCodexAuthStatus({
authenticated: true,
method: result.auth.method || 'cli_authenticated',
});
toast.success('Codex CLI is ready!');
}
}
} catch {
// Ignore
} finally {
setIsChecking(false);
}
}, [setCodexCliStatus, setCodexAuthStatus]);
useEffect(() => {
checkStatus();
return () => {
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
};
}, [checkStatus]);
const copyCommand = (command: string) => {
navigator.clipboard.writeText(command);
toast.success('Command copied to clipboard');
};
const handleSaveApiKey = async () => {
if (!apiKey.trim()) return;
setIsSaving(true);
try {
const api = getElectronAPI();
if (!api.setup?.saveApiKey) {
toast.error('Save API not available');
return;
}
const result = await api.setup.saveApiKey('openai', apiKey);
if (result.success) {
setApiKeys({ ...apiKeys, openai: apiKey });
setCodexAuthStatus({ authenticated: true, method: 'api_key' });
toast.success('API key saved successfully!');
}
} catch {
toast.error('Failed to save API key');
} finally {
setIsSaving(false);
}
};
const handleLogin = async () => {
setIsLoggingIn(true);
try {
await navigator.clipboard.writeText('codex login');
toast.info('Login command copied! Paste in terminal to authenticate.');
let attempts = 0;
pollIntervalRef.current = setInterval(async () => {
attempts++;
try {
const api = getElectronAPI();
if (!api.setup?.getCodexStatus) return;
const result = await api.setup.getCodexStatus();
if (result.auth?.authenticated) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setCodexAuthStatus({ authenticated: true, method: 'cli_authenticated' });
setIsLoggingIn(false);
toast.success('Successfully logged in to Codex!');
}
} catch {
// Ignore
}
if (attempts >= 60) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setIsLoggingIn(false);
toast.error('Login timed out. Please try again.');
}
}, 2000);
} catch {
toast.error('Failed to start login process');
setIsLoggingIn(false);
}
};
const isReady = codexCliStatus?.installed && codexAuthStatus?.authenticated;
const hasApiKey = !!apiKeys.openai || codexAuthStatus?.method === 'api_key';
return (
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<OpenAIIcon className="w-5 h-5" />
Codex 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>
{codexCliStatus?.installed
? codexAuthStatus?.authenticated
? `Authenticated${codexCliStatus.version ? ` (v${codexCliStatus.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">
{codexCliStatus?.version && `Version: ${codexCliStatus.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">
{codexAuthStatus?.method === 'api_key' ? 'API Key Configured' : 'Authenticated'}
</p>
</div>
</div>
)}
{!codexCliStatus?.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">Codex CLI not found</p>
<p className="text-sm text-muted-foreground mt-1">
Install the Codex CLI to use OpenAI 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 Codex 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">
npm install -g @openai/codex
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand('npm install -g @openai/codex')}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
</div>
)}
{codexCliStatus?.installed && !codexAuthStatus?.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">
{codexCliStatus?.version && `Version: ${codexCliStatus.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">Codex CLI not authenticated</p>
<p className="text-sm text-muted-foreground mt-1">
Run the login command or provide an 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">Codex CLI 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">
codex login
</code>
<Button variant="ghost" size="icon" onClick={() => copyCommand('codex 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">OpenAI API Key</span>
</div>
</AccordionTrigger>
<AccordionContent className="pt-4 space-y-4">
<div className="space-y-2">
<Input
type="password"
placeholder="sk-..."
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://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-brand-500 hover:underline"
>
Get an API key from OpenAI
<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 Codex CLI status...</p>
</div>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// OpenCode Content
// ============================================================================
function OpencodeContent() {
const { opencodeCliStatus, setOpencodeCliStatus } = useSetupStore();
const [isChecking, setIsChecking] = useState(false);
const [isLoggingIn, setIsLoggingIn] = useState(false);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const checkStatus = useCallback(async () => {
setIsChecking(true);
try {
const api = getElectronAPI();
if (!api.setup?.getOpencodeStatus) return;
const result = await api.setup.getOpencodeStatus();
if (result.success) {
setOpencodeCliStatus({
installed: result.installed ?? false,
version: result.version,
path: result.path,
auth: result.auth,
installCommand: result.installCommand,
loginCommand: result.loginCommand,
});
if (result.auth?.authenticated) {
toast.success('OpenCode CLI is ready!');
}
}
} catch {
// Ignore
} finally {
setIsChecking(false);
}
}, [setOpencodeCliStatus]);
useEffect(() => {
checkStatus();
return () => {
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
};
}, [checkStatus]);
const copyCommand = (command: string) => {
navigator.clipboard.writeText(command);
toast.success('Command copied to clipboard');
};
const handleLogin = async () => {
setIsLoggingIn(true);
try {
const loginCommand = opencodeCliStatus?.loginCommand || 'opencode auth login';
await navigator.clipboard.writeText(loginCommand);
toast.info('Login command copied! Paste in terminal to authenticate.');
let attempts = 0;
pollIntervalRef.current = setInterval(async () => {
attempts++;
try {
const api = getElectronAPI();
if (!api.setup?.getOpencodeStatus) return;
const result = await api.setup.getOpencodeStatus();
if (result.auth?.authenticated) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setOpencodeCliStatus({
...opencodeCliStatus,
installed: result.installed ?? true,
version: result.version,
path: result.path,
auth: result.auth,
});
setIsLoggingIn(false);
toast.success('Successfully logged in to OpenCode!');
}
} catch {
// Ignore
}
if (attempts >= 60) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setIsLoggingIn(false);
toast.error('Login timed out. Please try again.');
}
}, 2000);
} catch {
toast.error('Failed to start login process');
setIsLoggingIn(false);
}
};
const isReady = opencodeCliStatus?.installed && opencodeCliStatus?.auth?.authenticated;
return (
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<OpenCodeIcon className="w-5 h-5" />
OpenCode 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>
{opencodeCliStatus?.installed
? opencodeCliStatus.auth?.authenticated
? `Authenticated${opencodeCliStatus.version ? ` (v${opencodeCliStatus.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">
{opencodeCliStatus?.version && `Version: ${opencodeCliStatus.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>
)}
{!opencodeCliStatus?.installed && !isChecking && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-lg bg-muted/30 border border-border">
<XCircle className="w-5 h-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">OpenCode CLI not found</p>
<p className="text-sm text-muted-foreground mt-1">
Install the OpenCode CLI for free tier models and connected providers.
</p>
</div>
</div>
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
<p className="font-medium text-foreground text-sm">Install OpenCode CLI:</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground overflow-x-auto">
{opencodeCliStatus?.installCommand ||
'curl -fsSL https://opencode.ai/install | bash'}
</code>
<Button
variant="ghost"
size="icon"
onClick={() =>
copyCommand(
opencodeCliStatus?.installCommand ||
'curl -fsSL https://opencode.ai/install | bash'
)
}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
</div>
)}
{opencodeCliStatus?.installed && !opencodeCliStatus?.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">
{opencodeCliStatus?.version && `Version: ${opencodeCliStatus.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">OpenCode CLI not authenticated</p>
<p className="text-sm text-muted-foreground mt-1">
Run the login command to authenticate.
</p>
</div>
</div>
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
{opencodeCliStatus?.loginCommand || 'opencode auth login'}
</code>
<Button
variant="ghost"
size="icon"
onClick={() =>
copyCommand(opencodeCliStatus?.loginCommand || 'opencode 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>
</div>
</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 OpenCode CLI status...</p>
</div>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// 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
// ============================================================================
export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) {
const [activeTab, setActiveTab] = useState<ProviderTab>('claude');
const [isInitialChecking, setIsInitialChecking] = useState(true);
const hasCheckedRef = useRef(false);
const {
claudeCliStatus,
claudeAuthStatus,
claudeIsVerifying,
cursorCliStatus,
codexCliStatus,
codexAuthStatus,
opencodeCliStatus,
geminiCliStatus,
setClaudeCliStatus,
setCursorCliStatus,
setCodexCliStatus,
setCodexAuthStatus,
setOpencodeCliStatus,
setGeminiCliStatus,
} = useSetupStore();
// Check all providers on mount
const checkAllProviders = useCallback(async () => {
const api = getElectronAPI();
// Check Claude - only check CLI status, let ClaudeContent handle auth verification
const checkClaude = async () => {
try {
if (!api.setup?.getClaudeStatus) return;
const result = await api.setup.getClaudeStatus();
if (result.success) {
setClaudeCliStatus({
installed: result.installed ?? false,
version: result.version,
path: result.path,
method: 'none',
});
// Note: Auth verification is handled by ClaudeContent component to avoid duplicate calls
}
} catch {
// Ignore errors
}
};
// Check Cursor
const checkCursor = async () => {
try {
if (!api.setup?.getCursorStatus) return;
const result = await api.setup.getCursorStatus();
if (result.success) {
setCursorCliStatus({
installed: result.installed ?? false,
version: result.version,
path: result.path,
auth: result.auth,
installCommand: result.installCommand,
loginCommand: result.loginCommand,
});
}
} catch {
// Ignore errors
}
};
// Check Codex
const checkCodex = async () => {
try {
if (!api.setup?.getCodexStatus) return;
const result = await api.setup.getCodexStatus();
if (result.success) {
setCodexCliStatus({
installed: result.installed ?? false,
version: result.version,
path: result.path,
method: 'none',
});
if (result.auth?.authenticated) {
setCodexAuthStatus({
authenticated: true,
method: result.auth.method || 'cli_authenticated',
});
}
}
} catch {
// Ignore errors
}
};
// Check OpenCode
const checkOpencode = async () => {
try {
if (!api.setup?.getOpencodeStatus) return;
const result = await api.setup.getOpencodeStatus();
if (result.success) {
setOpencodeCliStatus({
installed: result.installed ?? false,
version: result.version,
path: result.path,
auth: result.auth,
installCommand: result.installCommand,
loginCommand: result.loginCommand,
});
}
} catch {
// Ignore errors
}
};
// 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(), checkGemini()]);
setIsInitialChecking(false);
}, [
setClaudeCliStatus,
setCursorCliStatus,
setCodexCliStatus,
setCodexAuthStatus,
setOpencodeCliStatus,
setGeminiCliStatus,
]);
useEffect(() => {
if (!hasCheckedRef.current) {
hasCheckedRef.current = true;
checkAllProviders();
}
}, [checkAllProviders]);
// Determine status for each provider
const isClaudeInstalled = claudeCliStatus?.installed === true;
const isClaudeAuthenticated =
claudeAuthStatus?.authenticated === true &&
(claudeAuthStatus?.method === 'cli_authenticated' ||
claudeAuthStatus?.method === 'api_key' ||
claudeAuthStatus?.method === 'api_key_env');
const isCursorInstalled = cursorCliStatus?.installed === true;
const isCursorAuthenticated = cursorCliStatus?.auth?.authenticated === true;
const isCodexInstalled = codexCliStatus?.installed === true;
const isCodexAuthenticated = codexAuthStatus?.authenticated === true;
const isOpencodeInstalled = opencodeCliStatus?.installed === true;
const isOpencodeAuthenticated = opencodeCliStatus?.auth?.authenticated === true;
const isGeminiInstalled = geminiCliStatus?.installed === true;
const isGeminiAuthenticated = geminiCliStatus?.auth?.authenticated === true;
const hasAtLeastOneProvider =
isClaudeAuthenticated ||
isCursorAuthenticated ||
isCodexAuthenticated ||
isOpencodeAuthenticated ||
isGeminiAuthenticated;
type ProviderStatus = 'not_installed' | 'installed_not_auth' | 'authenticated' | 'verifying';
const getProviderStatus = (
installed: boolean,
authenticated: boolean,
isVerifying?: boolean
): ProviderStatus => {
if (!installed) return 'not_installed';
if (isVerifying) return 'verifying';
if (!authenticated) return 'installed_not_auth';
return 'authenticated';
};
const providers = [
{
id: 'claude' as const,
label: 'Claude',
icon: AnthropicIcon,
status: getProviderStatus(isClaudeInstalled, isClaudeAuthenticated, claudeIsVerifying),
color: 'text-brand-500',
},
{
id: 'cursor' as const,
label: 'Cursor',
icon: CursorIcon,
status: getProviderStatus(isCursorInstalled, isCursorAuthenticated),
color: 'text-blue-500',
},
{
id: 'codex' as const,
label: 'Codex',
icon: OpenAIIcon,
status: getProviderStatus(isCodexInstalled, isCodexAuthenticated),
color: 'text-emerald-500',
},
{
id: 'opencode' as const,
label: 'OpenCode',
icon: OpenCodeIcon,
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) => {
switch (status) {
case 'authenticated':
return (
<CheckCircle2 className="w-3 h-3 text-green-500 absolute -top-1 -right-1.5 bg-background rounded-full" />
);
case 'verifying':
return (
<Spinner size="xs" className="absolute -top-1 -right-1.5 bg-background rounded-full" />
);
case 'installed_not_auth':
return (
<AlertCircle className="w-3 h-3 text-red-500 absolute -top-1 -right-1.5 bg-background rounded-full" />
);
default:
return null;
}
};
return (
<div className="space-y-6">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-foreground mb-2">AI Provider Setup</h2>
<p className="text-muted-foreground">Configure at least one AI provider to continue</p>
</div>
{isInitialChecking && (
<div className="flex items-center justify-center gap-2 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Spinner size="md" />
<p className="font-medium text-foreground">Checking provider status...</p>
</div>
)}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as ProviderTab)}>
<TabsList className="grid w-full grid-cols-5 h-auto p-1">
{providers.map((provider) => {
const Icon = provider.icon;
return (
<TabsTrigger
key={provider.id}
value={provider.id}
className={cn(
'relative flex flex-col items-center gap-1 py-3 px-2',
'data-[state=active]:bg-muted'
)}
>
<div className="relative">
<Icon
className={cn(
'w-5 h-5',
provider.status === 'authenticated'
? provider.color
: provider.status === 'verifying'
? 'text-blue-500'
: provider.status === 'installed_not_auth'
? 'text-amber-500'
: 'text-muted-foreground'
)}
/>
{!isInitialChecking && renderStatusIcon(provider.status)}
</div>
<span className="text-xs font-medium">{provider.label}</span>
</TabsTrigger>
);
})}
</TabsList>
<div className="mt-6">
<TabsContent value="claude" className="mt-0">
<ClaudeContent />
</TabsContent>
<TabsContent value="cursor" className="mt-0">
<CursorContent />
</TabsContent>
<TabsContent value="codex" className="mt-0">
<CodexContent />
</TabsContent>
<TabsContent value="opencode" className="mt-0">
<OpencodeContent />
</TabsContent>
<TabsContent value="gemini" className="mt-0">
<GeminiContent />
</TabsContent>
</div>
</Tabs>
<div className="flex justify-between pt-4">
<Button variant="ghost" onClick={onBack} className="text-muted-foreground">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button
onClick={onNext}
className={cn(
'bg-brand-500 hover:bg-brand-600 text-white',
!hasAtLeastOneProvider && 'opacity-50'
)}
data-testid="providers-next-button"
>
{hasAtLeastOneProvider ? 'Continue' : 'Skip for now'}
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
{!hasAtLeastOneProvider && (
<p className="text-xs text-muted-foreground text-center">
You can configure providers later in Settings
</p>
)}
</div>
);
}