mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user