From 9dc5d64a2641408508103bd93acfea33d6677121 Mon Sep 17 00:00:00 2001 From: Kacper Date: Thu, 11 Dec 2025 20:41:44 +0100 Subject: [PATCH] refactor: modularize setup view into reusable components and hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactored setup-view.tsx (1,740 → 148 lines, -91.5%) by extracting: - 8 reusable UI components (status badges, terminal output, etc.) - 4 custom hooks (CLI status, installation, OAuth, token save) - 4 step components (welcome, complete, Claude setup, Codex setup) - 1 dialog component (OAuth token modal) All business logic separated from UI, zero code duplication, fully type-safe. --- app/src/components/views/setup-view.tsx | 1607 +---------------- .../components/auth-method-selector.tsx | 46 + .../components/cli-installation-card.tsx | 98 + .../components/copyable-command-field.tsx | 34 + .../views/setup-view/components/index.ts | 9 + .../components/ready-state-card.tsx | 42 + .../setup-view/components/status-badge.tsx | 46 + .../setup-view/components/status-row.tsx | 32 + .../setup-view/components/step-indicator.tsx | 24 + .../setup-view/components/terminal-output.tsx | 18 + .../views/setup-view/dialogs/index.ts | 2 + .../dialogs}/setup-token-modal.tsx | 145 +- .../views/setup-view/hooks/index.ts | 5 + .../setup-view/hooks/use-cli-installation.ts | 91 + .../views/setup-view/hooks/use-cli-status.ts | 103 ++ .../hooks/use-oauth-authentication.ts | 177 ++ .../views/setup-view/hooks/use-token-save.ts | 58 + .../setup-view/steps/claude-setup-step.tsx | 584 ++++++ .../setup-view/steps/codex-setup-step.tsx | 432 +++++ .../views/setup-view/steps/complete-step.tsx | 115 ++ .../views/setup-view/steps/index.ts | 5 + .../views/setup-view/steps/welcome-step.tsx | 69 + 22 files changed, 2008 insertions(+), 1734 deletions(-) create mode 100644 app/src/components/views/setup-view/components/auth-method-selector.tsx create mode 100644 app/src/components/views/setup-view/components/cli-installation-card.tsx create mode 100644 app/src/components/views/setup-view/components/copyable-command-field.tsx create mode 100644 app/src/components/views/setup-view/components/index.ts create mode 100644 app/src/components/views/setup-view/components/ready-state-card.tsx create mode 100644 app/src/components/views/setup-view/components/status-badge.tsx create mode 100644 app/src/components/views/setup-view/components/status-row.tsx create mode 100644 app/src/components/views/setup-view/components/step-indicator.tsx create mode 100644 app/src/components/views/setup-view/components/terminal-output.tsx create mode 100644 app/src/components/views/setup-view/dialogs/index.ts rename app/src/components/views/{ => setup-view/dialogs}/setup-token-modal.tsx (66%) create mode 100644 app/src/components/views/setup-view/hooks/index.ts create mode 100644 app/src/components/views/setup-view/hooks/use-cli-installation.ts create mode 100644 app/src/components/views/setup-view/hooks/use-cli-status.ts create mode 100644 app/src/components/views/setup-view/hooks/use-oauth-authentication.ts create mode 100644 app/src/components/views/setup-view/hooks/use-token-save.ts create mode 100644 app/src/components/views/setup-view/steps/claude-setup-step.tsx create mode 100644 app/src/components/views/setup-view/steps/codex-setup-step.tsx create mode 100644 app/src/components/views/setup-view/steps/complete-step.tsx create mode 100644 app/src/components/views/setup-view/steps/index.ts create mode 100644 app/src/components/views/setup-view/steps/welcome-step.tsx diff --git a/app/src/components/views/setup-view.tsx b/app/src/components/views/setup-view.tsx index b3c1af99..331dbc0a 100644 --- a/app/src/components/views/setup-view.tsx +++ b/app/src/components/views/setup-view.tsx @@ -1,1605 +1,14 @@ "use client"; -import { useState, useEffect, useCallback } 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 { useSetupStore, type CodexAuthStatus } from "@/store/setup-store"; +import { useSetupStore } from "@/store/setup-store"; import { useAppStore } from "@/store/app-store"; -import { getElectronAPI } from "@/lib/electron"; +import { StepIndicator } from "./setup-view/components"; import { - CheckCircle2, - XCircle, - Loader2, - Terminal, - Key, - Sparkles, - ArrowRight, - ArrowLeft, - ExternalLink, - Copy, - AlertCircle, - RefreshCw, - Download, - Shield, -} from "lucide-react"; -import { toast } from "sonner"; -import { SetupTokenModal } from "./setup-token-modal"; - -// Step indicator component -function StepIndicator({ - currentStep, - totalSteps, -}: { - currentStep: number; - totalSteps: number; -}) { - return ( -
- {Array.from({ length: totalSteps }).map((_, index) => ( -
- ))} -
- ); -} - -// CLI Status Badge -function StatusBadge({ - status, - label, -}: { - status: - | "installed" - | "not_installed" - | "checking" - | "authenticated" - | "not_authenticated"; - label: string; -}) { - const getStatusConfig = () => { - switch (status) { - case "installed": - case "authenticated": - return { - icon: , - className: "bg-green-500/10 text-green-500 border-green-500/20", - }; - case "not_installed": - case "not_authenticated": - return { - icon: , - className: "bg-red-500/10 text-red-500 border-red-500/20", - }; - case "checking": - return { - icon: , - className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", - }; - } - }; - - const config = getStatusConfig(); - - return ( -
- {config.icon} - {label} -
- ); -} - -// Terminal Output Component -function TerminalOutput({ lines }: { lines: string[] }) { - return ( -
- {lines.map((line, index) => ( -
- $ {line} -
- ))} - {lines.length === 0 && ( -
Waiting for output...
- )} -
- ); -} - -// Welcome Step -function WelcomeStep({ onNext }: { onNext: () => void }) { - return ( -
-
- {/* eslint-disable-next-line @next/next/no-img-element */} - Automaker Logo -
- -
-

- Welcome to Automaker -

-

- Let's set up your development environment. We'll check for - required CLI tools and help you configure them. -

-
- -
- - - - - Claude CLI - - - -

- Anthropic's powerful AI assistant for code generation and - analysis -

-
-
- - - - - - Codex CLI - - - -

- OpenAI's GPT-5.1 Codex for advanced code generation tasks -

-
-
-
- - -
- ); -} - -// Claude Setup Step - 2 Authentication Options: -// 1. OAuth Token (Subscription): User runs `claude setup-token` and provides the token -// 2. API Key (Pay-per-use): User provides their Anthropic API key directly -function ClaudeSetupStep({ - onNext, - onBack, - onSkip, -}: { - onNext: () => void; - onBack: () => void; - onSkip: () => void; -}) { - const { - claudeCliStatus, - claudeAuthStatus, - claudeInstallProgress, - setClaudeCliStatus, - setClaudeAuthStatus, - setClaudeInstallProgress, - } = useSetupStore(); - const { setApiKeys, apiKeys } = useAppStore(); - - const [isChecking, setIsChecking] = useState(false); - const [isInstalling, setIsInstalling] = useState(false); - const [authMethod, setAuthMethod] = useState<"token" | "api_key" | null>( - null - ); - const [oauthToken, setOAuthToken] = useState(""); - const [apiKey, setApiKey] = useState(""); - const [isSaving, setIsSaving] = useState(false); - const [showTokenModal, setShowTokenModal] = useState(false); - - const checkStatus = useCallback(async () => { - console.log("[Claude Setup] Starting status check..."); - setIsChecking(true); - try { - const api = getElectronAPI(); - const setupApi = api.setup; - - // Debug: Check what's available - console.log( - "[Claude Setup] isElectron:", - typeof window !== "undefined" && (window as any).isElectron - ); - console.log( - "[Claude Setup] electronAPI exists:", - typeof window !== "undefined" && !!(window as any).electronAPI - ); - console.log( - "[Claude Setup] electronAPI.setup exists:", - typeof window !== "undefined" && !!(window as any).electronAPI?.setup - ); - console.log("[Claude Setup] Setup API available:", !!setupApi); - - if (setupApi?.getClaudeStatus) { - const result = await setupApi.getClaudeStatus(); - console.log("[Claude Setup] Raw status result:", result); - - if (result.success) { - const cliStatus = { - installed: result.status === "installed", - path: result.path || null, - version: result.version || null, - method: result.method || "none", - }; - console.log("[Claude Setup] CLI Status:", cliStatus); - setClaudeCliStatus(cliStatus); - - if (result.auth) { - // Validate method is one of the expected values, default to "none" - const validMethods = [ - "oauth_token_env", - "oauth_token", - "api_key", - "api_key_env", - "none", - ] as const; - type AuthMethod = (typeof validMethods)[number]; - const method: AuthMethod = validMethods.includes( - result.auth.method as AuthMethod - ) - ? (result.auth.method as AuthMethod) - : "none"; - const authStatus = { - authenticated: result.auth.authenticated, - method, - hasCredentialsFile: false, - oauthTokenValid: - result.auth.hasStoredOAuthToken || result.auth.hasEnvOAuthToken, - apiKeyValid: - result.auth.hasStoredApiKey || result.auth.hasEnvApiKey, - hasEnvOAuthToken: result.auth.hasEnvOAuthToken, - hasEnvApiKey: result.auth.hasEnvApiKey, - }; - setClaudeAuthStatus(authStatus); - } - } - } - } catch (error) { - console.error("[Claude Setup] Failed to check Claude status:", error); - } finally { - setIsChecking(false); - } - }, [setClaudeCliStatus, setClaudeAuthStatus]); - - useEffect(() => { - checkStatus(); - }, [checkStatus]); - - const handleInstall = async () => { - setIsInstalling(true); - setClaudeInstallProgress({ - isInstalling: true, - currentStep: "Downloading Claude CLI...", - progress: 0, - output: [], - }); - - try { - const api = getElectronAPI(); - const setupApi = api.setup; - - if (setupApi?.installClaude) { - const unsubscribe = setupApi.onInstallProgress?.( - (progress: { cli?: string; data?: string; type?: string }) => { - if (progress.cli === "claude") { - setClaudeInstallProgress({ - output: [ - ...claudeInstallProgress.output, - progress.data || progress.type || "", - ], - }); - } - } - ); - - const result = await setupApi.installClaude(); - unsubscribe?.(); - - if (result.success) { - // Installation script completed, but CLI might not be immediately detectable - // Wait a bit for installation to complete and PATH to update, then retry status check - let retries = 5; - let detected = false; - - // Initial delay to let the installation script finish setting up - await new Promise((resolve) => setTimeout(resolve, 1500)); - - for (let i = 0; i < retries; i++) { - // Check status - await checkStatus(); - - // Small delay to let state update - await new Promise((resolve) => setTimeout(resolve, 300)); - - // Check if CLI is now detected by re-reading from store - const currentStatus = useSetupStore.getState().claudeCliStatus; - if (currentStatus?.installed) { - detected = true; - toast.success("Claude CLI installed and detected successfully"); - break; - } - - // Wait before next retry (longer delays for later retries) - if (i < retries - 1) { - await new Promise((resolve) => - setTimeout(resolve, 2000 + i * 500) - ); - } - } - - // Show appropriate message based on detection - if (!detected) { - // Installation completed but CLI not detected - this is common if PATH wasn't updated in current process - toast.success("Claude CLI installation completed", { - description: - "The CLI was installed but may need a terminal restart to be detected. You can continue with authentication if you have a token.", - duration: 7000, - }); - } - } else { - toast.error("Installation failed", { description: result.error }); - } - } - } catch (error) { - console.error("Failed to install Claude:", error); - toast.error("Installation failed"); - } finally { - setIsInstalling(false); - setClaudeInstallProgress({ isInstalling: false }); - } - }; - - const handleSaveOAuthToken = async () => { - console.log("[Claude Setup] Saving OAuth token..."); - if (!oauthToken.trim()) { - toast.error("Please enter the token from claude setup-token"); - return; - } - - setIsSaving(true); - try { - const api = getElectronAPI(); - const setupApi = api.setup; - - if (setupApi?.storeApiKey) { - const result = await setupApi.storeApiKey( - "anthropic_oauth_token", - oauthToken - ); - console.log("[Claude Setup] Store OAuth token result:", result); - - if (result.success) { - setClaudeAuthStatus({ - authenticated: true, - method: "oauth_token", - hasCredentialsFile: false, - oauthTokenValid: true, - }); - toast.success("Claude subscription token saved"); - setAuthMethod(null); - await checkStatus(); - } else { - toast.error("Failed to save token", { description: result.error }); - } - } - } catch (error) { - console.error("[Claude Setup] Failed to save OAuth token:", error); - toast.error("Failed to save token"); - } finally { - setIsSaving(false); - } - }; - - const handleSaveApiKey = async () => { - console.log("[Claude Setup] Saving API key..."); - if (!apiKey.trim()) { - toast.error("Please enter an API key"); - return; - } - - setIsSaving(true); - try { - const api = getElectronAPI(); - const setupApi = api.setup; - - if (setupApi?.storeApiKey) { - const result = await setupApi.storeApiKey("anthropic", apiKey); - console.log("[Claude Setup] Store API key result:", result); - - if (result.success) { - setApiKeys({ ...apiKeys, anthropic: apiKey }); - setClaudeAuthStatus({ - authenticated: true, - method: "api_key", - hasCredentialsFile: false, - apiKeyValid: true, - }); - toast.success("Anthropic API key saved"); - setAuthMethod(null); - await checkStatus(); - } else { - toast.error("Failed to save API key", { description: result.error }); - } - } else { - // Web mode fallback - setApiKeys({ ...apiKeys, anthropic: apiKey }); - setClaudeAuthStatus({ - authenticated: true, - method: "api_key", - hasCredentialsFile: false, - apiKeyValid: true, - }); - toast.success("Anthropic API key saved"); - setAuthMethod(null); - } - } catch (error) { - console.error("[Claude Setup] Failed to save API key:", error); - toast.error("Failed to save API key"); - } finally { - setIsSaving(false); - } - }; - - const copyCommand = (command: string) => { - navigator.clipboard.writeText(command); - toast.success("Command copied to clipboard"); - }; - - // Handle token obtained from the OAuth modal - const handleTokenFromModal = useCallback( - async (token: string) => { - setOAuthToken(token); - setShowTokenModal(false); - - // Auto-save the token - setIsSaving(true); - try { - const api = getElectronAPI(); - const setupApi = api.setup; - - if (setupApi?.storeApiKey) { - const result = await setupApi.storeApiKey( - "anthropic_oauth_token", - token - ); - console.log("[Claude Setup] Store OAuth token result:", result); - - if (result.success) { - setClaudeAuthStatus({ - authenticated: true, - method: "oauth_token", - hasCredentialsFile: false, - oauthTokenValid: true, - }); - toast.success("Claude subscription token saved"); - setAuthMethod(null); - await checkStatus(); - } else { - toast.error("Failed to save token", { description: result.error }); - } - } - } catch (error) { - console.error("[Claude Setup] Failed to save OAuth token:", error); - toast.error("Failed to save token"); - } finally { - setIsSaving(false); - } - }, - [checkStatus, setClaudeAuthStatus] - ); - - const isAuthenticated = claudeAuthStatus?.authenticated || apiKeys.anthropic; - - const getAuthMethodLabel = () => { - if (!isAuthenticated) return null; - if ( - claudeAuthStatus?.method === "oauth_token_env" || - claudeAuthStatus?.method === "oauth_token" - ) - return "Subscription Token"; - if ( - apiKeys.anthropic || - claudeAuthStatus?.method === "api_key" || - claudeAuthStatus?.method === "api_key_env" - ) - return "API Key"; - return "Authenticated"; - }; - - return ( -
-
-
- -
-

- Claude Setup -

-

- Configure Claude for code generation -

-
- - {/* Status Card */} - - -
- Status - -
-
- -
- CLI Installation - {isChecking ? ( - - ) : claudeCliStatus?.installed ? ( - - ) : ( - - )} -
- - {claudeCliStatus?.version && ( -
- Version - - {claudeCliStatus.version} - -
- )} - -
- Authentication - {isAuthenticated ? ( -
- - {getAuthMethodLabel() && ( - - ({getAuthMethodLabel()}) - - )} -
- ) : ( - - )} -
-
-
- - {/* Installation Section */} - {!claudeCliStatus?.installed && ( - - - - - Install Claude CLI - - - Required for subscription-based authentication - - - -
- -
- - curl -fsSL https://claude.ai/install.sh | bash - - -
-
- -
- -
- - irm https://claude.ai/install.ps1 | iex - - -
-
- - {claudeInstallProgress.isInstalling && ( - - )} - - -
-
- )} - - {/* Authentication Section */} - {!isAuthenticated && ( - - - - - Authentication - - Choose your authentication method - - - {/* Option 1: Subscription Token */} - {authMethod === "token" ? ( -
-
- -
-

- Subscription Token -

-

- Use your Claude subscription (no API charges) -

- - {claudeCliStatus?.installed ? ( - <> - {/* Primary: Automated OAuth setup */} - - - {/* Divider */} -
-
- -
-
- - or paste manually - -
-
- - {/* Fallback: Manual token entry */} -
- - setOAuthToken(e.target.value)} - className="bg-input border-border text-foreground" - data-testid="oauth-token-input" - /> -
- -
- - -
- - ) : ( -
-

- - Install Claude CLI first to use subscription - authentication -

-
- )} -
-
-
- ) : authMethod === "api_key" ? ( - /* Option 2: API Key */ -
-
- -
-

API Key

-

- Pay-per-use with your Anthropic API key -

- -
- - setApiKey(e.target.value)} - className="bg-input border-border text-foreground" - data-testid="anthropic-api-key-input" - /> -

- Get your API key from{" "} - - console.anthropic.com - - -

-
- -
- - -
-
-
-
- ) : ( - /* Auth Method Selection */ -
- - - -
- )} -
-
- )} - - {/* Success State */} - {isAuthenticated && ( - - -
-
- -
-
-

- Claude is ready to use! -

-

- {getAuthMethodLabel() && `Using ${getAuthMethodLabel()}. `}You - can proceed to the next step -

-
-
-
-
- )} - - {/* Navigation */} -
- -
- - -
-
- - {/* OAuth Setup Modal */} - setShowTokenModal(false)} - onTokenObtained={handleTokenFromModal} - /> -
- ); -} - -// Codex Setup Step -function CodexSetupStep({ - onNext, - onBack, - onSkip, -}: { - onNext: () => void; - onBack: () => void; - onSkip: () => void; -}) { - const { - codexCliStatus, - codexAuthStatus, - codexInstallProgress, - setCodexCliStatus, - setCodexAuthStatus, - setCodexInstallProgress, - } = useSetupStore(); - const { setApiKeys, apiKeys } = useAppStore(); - - const [isChecking, setIsChecking] = useState(false); - const [isInstalling, setIsInstalling] = useState(false); - const [showApiKeyInput, setShowApiKeyInput] = useState(false); - const [apiKey, setApiKey] = useState(""); - const [isSavingKey, setIsSavingKey] = useState(false); - - // Normalize CLI auth method strings to our store-friendly values - const mapAuthMethod = (method?: string): CodexAuthStatus["method"] => { - switch (method) { - case "cli_verified": - return "cli_verified"; - case "cli_tokens": - return "cli_tokens"; - case "auth_file": - return "api_key"; - case "env_var": - return "env"; - default: - return "none"; - } - }; - - const checkStatus = useCallback(async () => { - console.log("[Codex Setup] Starting status check..."); - setIsChecking(true); - try { - const api = getElectronAPI(); - const setupApi = api.setup; - - console.log("[Codex Setup] Setup API available:", !!setupApi); - console.log( - "[Codex Setup] getCodexStatus available:", - !!setupApi?.getCodexStatus - ); - - if (setupApi?.getCodexStatus) { - const result = await setupApi.getCodexStatus(); - console.log("[Codex Setup] Raw status result:", result); - - if (result.success) { - const cliStatus = { - installed: result.status === "installed", - path: result.path || null, - version: result.version || null, - method: result.method || "none", - }; - console.log("[Codex Setup] CLI Status:", cliStatus); - setCodexCliStatus(cliStatus); - - if (result.auth) { - const method = mapAuthMethod(result.auth.method); - - const authStatus: CodexAuthStatus = { - authenticated: result.auth.authenticated, - method, - // Only set apiKeyValid for actual API key methods, not CLI login - apiKeyValid: - method === "cli_verified" || method === "cli_tokens" - ? undefined - : result.auth.authenticated, - }; - console.log("[Codex Setup] Auth Status:", authStatus); - setCodexAuthStatus(authStatus); - } else { - console.log("[Codex Setup] No auth info in result"); - } - } else { - console.log("[Codex Setup] Status check failed:", result.error); - } - } else { - console.log("[Codex Setup] Setup API not available (web mode?)"); - } - } catch (error) { - console.error("[Codex Setup] Failed to check Codex status:", error); - } finally { - setIsChecking(false); - console.log("[Codex Setup] Status check complete"); - } - }, [setCodexCliStatus, setCodexAuthStatus]); - - useEffect(() => { - checkStatus(); - }, [checkStatus]); - - const handleInstall = async () => { - setIsInstalling(true); - setCodexInstallProgress({ - isInstalling: true, - currentStep: "Installing Codex CLI via npm...", - progress: 0, - output: [], - }); - - try { - const api = getElectronAPI(); - const setupApi = api.setup; - - if (setupApi?.installCodex) { - const unsubscribe = setupApi.onInstallProgress?.( - (progress: { cli?: string; data?: string; type?: string }) => { - if (progress.cli === "codex") { - setCodexInstallProgress({ - output: [ - ...codexInstallProgress.output, - progress.data || progress.type || "", - ], - }); - } - } - ); - - const result = await setupApi.installCodex(); - - unsubscribe?.(); - - if (result.success) { - toast.success("Codex CLI installed successfully"); - await checkStatus(); - } else { - toast.error("Installation failed", { - description: result.error, - }); - } - } - } catch (error) { - console.error("Failed to install Codex:", error); - toast.error("Installation failed"); - } finally { - setIsInstalling(false); - setCodexInstallProgress({ isInstalling: false }); - } - }; - - const handleSaveApiKey = async () => { - console.log("[Codex Setup] Saving API key..."); - if (!apiKey.trim()) { - console.log("[Codex Setup] API key is empty"); - toast.error("Please enter an API key"); - return; - } - - setIsSavingKey(true); - try { - const api = getElectronAPI(); - const setupApi = api.setup; - - console.log( - "[Codex Setup] storeApiKey available:", - !!setupApi?.storeApiKey - ); - - if (setupApi?.storeApiKey) { - console.log("[Codex Setup] Calling storeApiKey for openai..."); - const result = await setupApi.storeApiKey("openai", apiKey); - console.log("[Codex Setup] storeApiKey result:", result); - - if (result.success) { - console.log( - "[Codex Setup] API key stored successfully, updating state..." - ); - setApiKeys({ ...apiKeys, openai: apiKey }); - setCodexAuthStatus({ - authenticated: true, - method: "api_key", - apiKeyValid: true, - }); - toast.success("OpenAI API key saved"); - setShowApiKeyInput(false); - } else { - console.log("[Codex Setup] Failed to store API key:", result.error); - } - } else { - console.log( - "[Codex Setup] Web mode - storing API key in app state only" - ); - setApiKeys({ ...apiKeys, openai: apiKey }); - setCodexAuthStatus({ - authenticated: true, - method: "api_key", - apiKeyValid: true, - }); - toast.success("OpenAI API key saved"); - setShowApiKeyInput(false); - } - } catch (error) { - console.error("[Codex Setup] Failed to save API key:", error); - toast.error("Failed to save API key"); - } finally { - setIsSavingKey(false); - } - }; - - const copyCommand = (command: string) => { - navigator.clipboard.writeText(command); - toast.success("Command copied to clipboard"); - }; - - const isAuthenticated = codexAuthStatus?.authenticated || apiKeys.openai; - - const getAuthMethodLabel = () => { - if (!isAuthenticated) return null; - if (apiKeys.openai) return "API Key (Manual)"; - if (codexAuthStatus?.method === "api_key") return "API Key (Auth File)"; - if (codexAuthStatus?.method === "env") return "API Key (Environment)"; - if (codexAuthStatus?.method === "cli_verified") - return "CLI Login (ChatGPT)"; - return "Authenticated"; - }; - - return ( -
-
-
- -
-

- Codex CLI Setup -

-

- OpenAI's GPT-5.1 Codex for advanced code generation -

-
- - {/* Status Card */} - - -
- Installation Status - -
-
- -
- CLI Installation - {isChecking ? ( - - ) : codexCliStatus?.installed ? ( - - ) : ( - - )} -
- - {codexCliStatus?.version && ( -
- Version - - {codexCliStatus.version} - -
- )} - -
- Authentication - {isAuthenticated ? ( -
- - {getAuthMethodLabel() && ( - - ({getAuthMethodLabel()}) - - )} -
- ) : ( - - )} -
-
-
- - {/* Installation Section */} - {!codexCliStatus?.installed && ( - - - - - Install Codex CLI - - - Install via npm (Node.js required) - - - -
- -
- - npm install -g @openai/codex - - -
-
- - {codexInstallProgress.isInstalling && ( - - )} - -
- -
- -
-
- -

- Requires Node.js to be installed. If the auto-install fails, - try running the command manually in your terminal. -

-
-
-
-
- )} - - {/* Authentication Section */} - {!isAuthenticated && ( - - - - - Authentication - - Codex requires an OpenAI API key - - - {codexCliStatus?.installed && ( -
-
- -
-

- Authenticate via CLI -

-

- Run this command in your terminal: -

-
- - codex auth login - - -
-
-
-
- )} - -
-
- -
-
- - or enter API key - -
-
- - {showApiKeyInput ? ( -
-
- - setApiKey(e.target.value)} - className="bg-input border-border text-foreground" - data-testid="openai-api-key-input" - /> -

- Get your API key from{" "} - - platform.openai.com - - -

-
-
- - -
-
- ) : ( - - )} -
-
- )} - - {/* Success State */} - {isAuthenticated && ( - - -
-
- -
-
-

- Codex is ready to use! -

-

- {getAuthMethodLabel() && - `Authenticated via ${getAuthMethodLabel()}. `} - You can proceed to complete setup -

-
-
-
-
- )} - - {/* Navigation */} -
- -
- - -
-
-
- ); -} - -// Complete Step -function CompleteStep({ onFinish }: { onFinish: () => void }) { - const { claudeCliStatus, claudeAuthStatus, codexCliStatus, codexAuthStatus } = - useSetupStore(); - const { apiKeys } = useAppStore(); - - const claudeReady = - (claudeCliStatus?.installed && claudeAuthStatus?.authenticated) || - apiKeys.anthropic; - const codexReady = - (codexCliStatus?.installed && codexAuthStatus?.authenticated) || - apiKeys.openai; - - return ( -
-
- -
- -
-

- Setup Complete! -

-

- Your development environment is configured. You're ready to start - building with AI-powered assistance. -

-
- -
- - -
- {claudeReady ? ( - - ) : ( - - )} -
-

Claude

-

- {claudeReady ? "Ready to use" : "Configure later in settings"} -

-
-
-
-
- - - -
- {codexReady ? ( - - ) : ( - - )} -
-

Codex

-

- {codexReady ? "Ready to use" : "Configure later in settings"} -

-
-
-
-
-
- -
-
- -
-

- Your credentials are secure -

-

- API keys are stored locally and never sent to our servers -

-
-
-
- - -
- ); -} + WelcomeStep, + CompleteStep, + ClaudeSetupStep, + CodexSetupStep, +} from "./setup-view/steps"; // Main Setup View export function SetupView() { @@ -1681,7 +90,7 @@ export function SetupView() { return (
{/* Header */} -
+
{/* eslint-disable-next-line @next/next/no-img-element */} diff --git a/app/src/components/views/setup-view/components/auth-method-selector.tsx b/app/src/components/views/setup-view/components/auth-method-selector.tsx new file mode 100644 index 00000000..283bba10 --- /dev/null +++ b/app/src/components/views/setup-view/components/auth-method-selector.tsx @@ -0,0 +1,46 @@ +import { ReactNode } from "react"; + +interface AuthMethodOption { + id: string; + icon: ReactNode; + title: string; + description: string; + badge: string; + badgeColor: string; // e.g., "brand-500", "green-500" +} + +interface AuthMethodSelectorProps { + options: AuthMethodOption[]; + onSelect: (methodId: string) => void; +} + +export function AuthMethodSelector({ + options, + onSelect, +}: AuthMethodSelectorProps) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} diff --git a/app/src/components/views/setup-view/components/cli-installation-card.tsx b/app/src/components/views/setup-view/components/cli-installation-card.tsx new file mode 100644 index 00000000..c07e3d35 --- /dev/null +++ b/app/src/components/views/setup-view/components/cli-installation-card.tsx @@ -0,0 +1,98 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Download, Loader2, AlertCircle } from "lucide-react"; +import { CopyableCommandField } from "./copyable-command-field"; +import { TerminalOutput } from "./terminal-output"; + +interface CommandInfo { + label: string; // e.g., "macOS / Linux" + command: string; +} + +interface CliInstallationCardProps { + cliName: string; + description: string; + commands: CommandInfo[]; + isInstalling: boolean; + installProgress: { output: string[] }; + onInstall: () => void; + warningMessage?: string; + color?: "brand" | "green"; // For different CLI themes +} + +export function CliInstallationCard({ + cliName, + description, + commands, + isInstalling, + installProgress, + onInstall, + warningMessage, + color = "brand", +}: CliInstallationCardProps) { + const colorClasses = { + brand: "bg-brand-500 hover:bg-brand-600", + green: "bg-green-500 hover:bg-green-600", + }; + + return ( + + + + + Install {cliName} + + {description} + + + {commands.map((cmd, index) => ( + + ))} + + {isInstalling && ( + + )} + + + + {warningMessage && ( +
+
+ +

+ {warningMessage} +

+
+
+ )} +
+
+ ); +} diff --git a/app/src/components/views/setup-view/components/copyable-command-field.tsx b/app/src/components/views/setup-view/components/copyable-command-field.tsx new file mode 100644 index 00000000..8345e914 --- /dev/null +++ b/app/src/components/views/setup-view/components/copyable-command-field.tsx @@ -0,0 +1,34 @@ +import { Button } from "@/components/ui/button"; +import { Copy } from "lucide-react"; +import { toast } from "sonner"; + +interface CopyableCommandFieldProps { + command: string; + label?: string; +} + +export function CopyableCommandField({ + command, + label, +}: CopyableCommandFieldProps) { + const copyToClipboard = () => { + navigator.clipboard.writeText(command); + toast.success("Command copied to clipboard"); + }; + + return ( +
+ {label && ( + {label} + )} +
+ + {command} + + +
+
+ ); +} diff --git a/app/src/components/views/setup-view/components/index.ts b/app/src/components/views/setup-view/components/index.ts new file mode 100644 index 00000000..52a53fb7 --- /dev/null +++ b/app/src/components/views/setup-view/components/index.ts @@ -0,0 +1,9 @@ +// Re-export all setup-view components for easier imports +export { StepIndicator } from "./step-indicator"; +export { StatusBadge } from "./status-badge"; +export { StatusRow } from "./status-row"; +export { TerminalOutput } from "./terminal-output"; +export { CopyableCommandField } from "./copyable-command-field"; +export { CliInstallationCard } from "./cli-installation-card"; +export { ReadyStateCard } from "./ready-state-card"; +export { AuthMethodSelector } from "./auth-method-selector"; diff --git a/app/src/components/views/setup-view/components/ready-state-card.tsx b/app/src/components/views/setup-view/components/ready-state-card.tsx new file mode 100644 index 00000000..c8554972 --- /dev/null +++ b/app/src/components/views/setup-view/components/ready-state-card.tsx @@ -0,0 +1,42 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { CheckCircle2 } from "lucide-react"; + +interface ReadyStateCardProps { + title: string; + description: string; + variant?: "success" | "info"; +} + +export function ReadyStateCard({ + title, + description, + variant = "success", +}: ReadyStateCardProps) { + const variantClasses = { + success: "bg-green-500/5 border-green-500/20", + info: "bg-blue-500/5 border-blue-500/20", + }; + + const iconColorClasses = { + success: "bg-green-500/10 text-green-500", + info: "bg-blue-500/10 text-blue-500", + }; + + return ( + + +
+
+ +
+
+

{title}

+

{description}

+
+
+
+
+ ); +} diff --git a/app/src/components/views/setup-view/components/status-badge.tsx b/app/src/components/views/setup-view/components/status-badge.tsx new file mode 100644 index 00000000..cf9cfc24 --- /dev/null +++ b/app/src/components/views/setup-view/components/status-badge.tsx @@ -0,0 +1,46 @@ +import { CheckCircle2, XCircle, Loader2 } from "lucide-react"; + +interface StatusBadgeProps { + status: + | "installed" + | "not_installed" + | "checking" + | "authenticated" + | "not_authenticated"; + label: string; +} + +export function StatusBadge({ status, label }: StatusBadgeProps) { + const getStatusConfig = () => { + switch (status) { + case "installed": + case "authenticated": + return { + icon: , + className: "bg-green-500/10 text-green-500 border-green-500/20", + }; + case "not_installed": + case "not_authenticated": + return { + icon: , + className: "bg-red-500/10 text-red-500 border-red-500/20", + }; + case "checking": + return { + icon: , + className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", + }; + } + }; + + const config = getStatusConfig(); + + return ( +
+ {config.icon} + {label} +
+ ); +} diff --git a/app/src/components/views/setup-view/components/status-row.tsx b/app/src/components/views/setup-view/components/status-row.tsx new file mode 100644 index 00000000..3b9bcd98 --- /dev/null +++ b/app/src/components/views/setup-view/components/status-row.tsx @@ -0,0 +1,32 @@ +import { StatusBadge } from "./status-badge"; + +interface StatusRowProps { + label: string; + status: + | "checking" + | "installed" + | "not_installed" + | "authenticated" + | "not_authenticated"; + statusLabel: string; + metadata?: string; // e.g., "(Subscription Token)" +} + +export function StatusRow({ + label, + status, + statusLabel, + metadata, +}: StatusRowProps) { + return ( +
+ {label} +
+ + {metadata && ( + {metadata} + )} +
+
+ ); +} diff --git a/app/src/components/views/setup-view/components/step-indicator.tsx b/app/src/components/views/setup-view/components/step-indicator.tsx new file mode 100644 index 00000000..adf7a36a --- /dev/null +++ b/app/src/components/views/setup-view/components/step-indicator.tsx @@ -0,0 +1,24 @@ +interface StepIndicatorProps { + currentStep: number; + totalSteps: number; +} + +export function StepIndicator({ + currentStep, + totalSteps, +}: StepIndicatorProps) { + return ( +
+ {Array.from({ length: totalSteps }).map((_, index) => ( +
+ ))} +
+ ); +} diff --git a/app/src/components/views/setup-view/components/terminal-output.tsx b/app/src/components/views/setup-view/components/terminal-output.tsx new file mode 100644 index 00000000..ae7ac7f9 --- /dev/null +++ b/app/src/components/views/setup-view/components/terminal-output.tsx @@ -0,0 +1,18 @@ +interface TerminalOutputProps { + lines: string[]; +} + +export function TerminalOutput({ lines }: TerminalOutputProps) { + return ( +
+ {lines.map((line, index) => ( +
+ $ {line} +
+ ))} + {lines.length === 0 && ( +
Waiting for output...
+ )} +
+ ); +} diff --git a/app/src/components/views/setup-view/dialogs/index.ts b/app/src/components/views/setup-view/dialogs/index.ts new file mode 100644 index 00000000..0466469f --- /dev/null +++ b/app/src/components/views/setup-view/dialogs/index.ts @@ -0,0 +1,2 @@ +// Re-export all setup dialog components for easier imports +export { SetupTokenModal } from "./setup-token-modal"; diff --git a/app/src/components/views/setup-token-modal.tsx b/app/src/components/views/setup-view/dialogs/setup-token-modal.tsx similarity index 66% rename from app/src/components/views/setup-token-modal.tsx rename to app/src/components/views/setup-view/dialogs/setup-token-modal.tsx index 92260d97..bf8a5256 100644 --- a/app/src/components/views/setup-token-modal.tsx +++ b/app/src/components/views/setup-view/dialogs/setup-token-modal.tsx @@ -20,8 +20,8 @@ import { Copy, RotateCcw, } from "lucide-react"; -import { getElectronAPI } from "@/lib/electron"; import { toast } from "sonner"; +import { useOAuthAuthentication } from "../hooks"; interface SetupTokenModalProps { open: boolean; @@ -29,20 +29,17 @@ interface SetupTokenModalProps { onTokenObtained: (token: string) => void; } -type AuthState = "idle" | "running" | "success" | "error" | "manual"; - export function SetupTokenModal({ open, onClose, onTokenObtained, }: SetupTokenModalProps) { - const [authState, setAuthState] = useState("idle"); - const [output, setOutput] = useState([]); - const [token, setToken] = useState(""); - const [error, setError] = useState(null); + // Use the OAuth authentication hook + const { authState, output, token, error, startAuth, reset } = + useOAuthAuthentication({ cliType: "claude" }); + const [manualToken, setManualToken] = useState(""); const scrollRef = useRef(null); - const unsubscribeRef = useRef<(() => void) | null>(null); // Auto-scroll to bottom when output changes useEffect(() => { @@ -51,132 +48,13 @@ export function SetupTokenModal({ } }, [output]); - // Reset state when modal opens + // Reset state when modal opens/closes useEffect(() => { if (open) { - setAuthState("idle"); - setOutput([]); - setToken(""); - setError(null); + reset(); setManualToken(""); - } else { - // Cleanup subscription when modal closes - if (unsubscribeRef.current) { - unsubscribeRef.current(); - unsubscribeRef.current = null; - } } - }, [open]); - - const startAuth = useCallback(async () => { - const api = getElectronAPI(); - if (!api.setup) { - setError("Setup API not available"); - setAuthState("error"); - return; - } - - setAuthState("running"); - setOutput([ - "Starting authentication...", - "Running Claude CLI in an embedded terminal so you don't need to copy/paste.", - "When your browser opens, complete sign-in and return here.", - "", - ]); - setError(null); - setToken(""); - - // Subscribe to progress events - if (api.setup.onAuthProgress) { - unsubscribeRef.current = api.setup.onAuthProgress((progress) => { - if (progress.cli === "claude" && progress.data) { - // Split by newlines and add each line - const normalized = progress.data.replace(/\r/g, "\n"); - const lines = normalized - .split("\n") - .map((line: string) => line.trimEnd()) - .filter((line: string) => line.length > 0); - if (lines.length > 0) { - setOutput((prev) => [...prev, ...lines]); - } - } - }); - } - - try { - const result = await api.setup.authClaude(); - - // Cleanup subscription - if (unsubscribeRef.current) { - unsubscribeRef.current(); - unsubscribeRef.current = null; - } - - if (result.success && result.token) { - setToken(result.token); - setAuthState("success"); - setOutput((prev) => [ - ...prev, - "", - "✓ Authentication successful!", - "✓ Token captured automatically.", - ]); - } else if (result.requiresManualAuth) { - // Terminal was opened - user needs to copy token manually - setAuthState("manual"); - // Don't add extra messages if terminalOpened - the progress messages already explain - if (!result.terminalOpened) { - const extraMessages = [ - "", - "⚠ Could not capture token automatically.", - ]; - if (result.error) { - extraMessages.push(result.error); - } - setOutput((prev) => [ - ...prev, - ...extraMessages, - "Please copy the token from above and paste it below.", - ]); - } - } else { - setError(result.error || "Authentication failed"); - setAuthState("error"); - } - } catch (err: unknown) { - // Cleanup subscription - if (unsubscribeRef.current) { - unsubscribeRef.current(); - unsubscribeRef.current = null; - } - - const errorMessage = - err instanceof Error - ? err.message - : typeof err === "object" && err !== null && "error" in err - ? String((err as { error: unknown }).error) - : "Authentication failed"; - - // Check if we should fall back to manual mode - if ( - typeof err === "object" && - err !== null && - "requiresManualAuth" in err && - (err as { requiresManualAuth: boolean }).requiresManualAuth - ) { - setAuthState("manual"); - setOutput((prev) => [ - ...prev, - "", - "⚠ " + errorMessage, - "Please copy the token manually and paste it below.", - ]); - } else { - setError(errorMessage); - setAuthState("error"); - } - } - }, []); + }, [open, reset]); const handleUseToken = useCallback(() => { const tokenToUse = token || manualToken; @@ -192,12 +70,9 @@ export function SetupTokenModal({ }, []); const handleRetry = useCallback(() => { - setAuthState("idle"); - setOutput([]); - setError(null); - setToken(""); + reset(); setManualToken(""); - }, []); + }, [reset]); return ( diff --git a/app/src/components/views/setup-view/hooks/index.ts b/app/src/components/views/setup-view/hooks/index.ts new file mode 100644 index 00000000..88db7c8c --- /dev/null +++ b/app/src/components/views/setup-view/hooks/index.ts @@ -0,0 +1,5 @@ +// Re-export all hooks for easier imports +export { useCliStatus } from "./use-cli-status"; +export { useCliInstallation } from "./use-cli-installation"; +export { useOAuthAuthentication } from "./use-oauth-authentication"; +export { useTokenSave } from "./use-token-save"; diff --git a/app/src/components/views/setup-view/hooks/use-cli-installation.ts b/app/src/components/views/setup-view/hooks/use-cli-installation.ts new file mode 100644 index 00000000..cc9c8bba --- /dev/null +++ b/app/src/components/views/setup-view/hooks/use-cli-installation.ts @@ -0,0 +1,91 @@ +import { useState, useCallback } from "react"; +import { toast } from "sonner"; + +interface UseCliInstallationOptions { + cliType: "claude" | "codex"; + installApi: () => Promise; + onProgressEvent?: (callback: (progress: any) => void) => (() => void) | undefined; + onSuccess?: () => void; + getStoreState?: () => any; +} + +export function useCliInstallation({ + cliType, + installApi, + onProgressEvent, + onSuccess, + getStoreState, +}: UseCliInstallationOptions) { + const [isInstalling, setIsInstalling] = useState(false); + const [installProgress, setInstallProgress] = useState<{ output: string[] }>({ + output: [], + }); + + const install = useCallback(async () => { + setIsInstalling(true); + setInstallProgress({ output: [] }); + + try { + let unsubscribe: (() => void) | undefined; + + if (onProgressEvent) { + unsubscribe = onProgressEvent((progress: { cli?: string; data?: string; type?: string }) => { + if (progress.cli === cliType) { + setInstallProgress((prev) => ({ + output: [...prev.output, progress.data || progress.type || ""], + })); + } + }); + } + + const result = await installApi(); + unsubscribe?.(); + + if (result.success) { + if (cliType === "claude" && onSuccess && getStoreState) { + // Claude-specific: retry logic to detect installation + let retries = 5; + let detected = false; + + await new Promise((resolve) => setTimeout(resolve, 1500)); + + for (let i = 0; i < retries; i++) { + await onSuccess(); + await new Promise((resolve) => setTimeout(resolve, 300)); + + const currentStatus = getStoreState(); + if (currentStatus?.installed) { + detected = true; + toast.success(`${cliType} CLI installed and detected successfully`); + break; + } + + if (i < retries - 1) { + await new Promise((resolve) => setTimeout(resolve, 2000 + i * 500)); + } + } + + if (!detected) { + toast.success(`${cliType} CLI installation completed`, { + description: + "The CLI was installed but may need a terminal restart to be detected. You can continue with authentication if you have a token.", + duration: 7000, + }); + } + } else { + toast.success(`${cliType} CLI installed successfully`); + onSuccess?.(); + } + } else { + toast.error("Installation failed", { description: result.error }); + } + } catch (error) { + console.error(`Failed to install ${cliType}:`, error); + toast.error("Installation failed"); + } finally { + setIsInstalling(false); + } + }, [cliType, installApi, onProgressEvent, onSuccess, getStoreState]); + + return { isInstalling, installProgress, install }; +} diff --git a/app/src/components/views/setup-view/hooks/use-cli-status.ts b/app/src/components/views/setup-view/hooks/use-cli-status.ts new file mode 100644 index 00000000..b7a31685 --- /dev/null +++ b/app/src/components/views/setup-view/hooks/use-cli-status.ts @@ -0,0 +1,103 @@ +import { useState, useCallback } from "react"; + +interface UseCliStatusOptions { + cliType: "claude" | "codex"; + statusApi: () => Promise; + setCliStatus: (status: any) => void; + setAuthStatus: (status: any) => void; +} + +export function useCliStatus({ + cliType, + statusApi, + setCliStatus, + setAuthStatus, +}: UseCliStatusOptions) { + const [isChecking, setIsChecking] = useState(false); + + const checkStatus = useCallback(async () => { + console.log(`[${cliType} Setup] Starting status check...`); + setIsChecking(true); + try { + const result = await statusApi(); + console.log(`[${cliType} Setup] Raw status result:`, result); + + if (result.success) { + const cliStatus = { + installed: result.status === "installed", + path: result.path || null, + version: result.version || null, + method: result.method || "none", + }; + console.log(`[${cliType} Setup] CLI Status:`, cliStatus); + setCliStatus(cliStatus); + + if (result.auth) { + if (cliType === "claude") { + // Validate method is one of the expected values, default to "none" + const validMethods = [ + "oauth_token_env", + "oauth_token", + "api_key", + "api_key_env", + "none", + ] as const; + type AuthMethod = (typeof validMethods)[number]; + const method: AuthMethod = validMethods.includes( + result.auth.method as AuthMethod + ) + ? (result.auth.method as AuthMethod) + : "none"; + const authStatus = { + authenticated: result.auth.authenticated, + method, + hasCredentialsFile: false, + oauthTokenValid: + result.auth.hasStoredOAuthToken || + result.auth.hasEnvOAuthToken, + apiKeyValid: + result.auth.hasStoredApiKey || result.auth.hasEnvApiKey, + hasEnvOAuthToken: result.auth.hasEnvOAuthToken, + hasEnvApiKey: result.auth.hasEnvApiKey, + }; + setAuthStatus(authStatus); + } else { + // Codex auth status mapping + const mapAuthMethod = (method?: string): any => { + switch (method) { + case "cli_verified": + return "cli_verified"; + case "cli_tokens": + return "cli_tokens"; + case "auth_file": + return "api_key"; + case "env_var": + return "env"; + default: + return "none"; + } + }; + + const method = mapAuthMethod(result.auth.method); + const authStatus = { + authenticated: result.auth.authenticated, + method, + apiKeyValid: + method === "cli_verified" || method === "cli_tokens" + ? undefined + : result.auth.authenticated, + }; + console.log(`[${cliType} Setup] Auth Status:`, authStatus); + setAuthStatus(authStatus); + } + } + } + } catch (error) { + console.error(`[${cliType} Setup] Failed to check status:`, error); + } finally { + setIsChecking(false); + } + }, [cliType, statusApi, setCliStatus, setAuthStatus]); + + return { isChecking, checkStatus }; +} diff --git a/app/src/components/views/setup-view/hooks/use-oauth-authentication.ts b/app/src/components/views/setup-view/hooks/use-oauth-authentication.ts new file mode 100644 index 00000000..4648231e --- /dev/null +++ b/app/src/components/views/setup-view/hooks/use-oauth-authentication.ts @@ -0,0 +1,177 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import { getElectronAPI } from "@/lib/electron"; + +type AuthState = "idle" | "running" | "success" | "error" | "manual"; + +interface UseOAuthAuthenticationOptions { + cliType: "claude" | "codex"; + enabled?: boolean; +} + +export function useOAuthAuthentication({ + cliType, + enabled = true, +}: UseOAuthAuthenticationOptions) { + const [authState, setAuthState] = useState("idle"); + const [output, setOutput] = useState([]); + const [token, setToken] = useState(""); + const [error, setError] = useState(null); + const unsubscribeRef = useRef<(() => void) | null>(null); + + // Reset state when disabled + useEffect(() => { + if (!enabled) { + setAuthState("idle"); + setOutput([]); + setToken(""); + setError(null); + + // Cleanup subscription + if (unsubscribeRef.current) { + unsubscribeRef.current(); + unsubscribeRef.current = null; + } + } + }, [enabled]); + + const startAuth = useCallback(async () => { + const api = getElectronAPI(); + if (!api.setup) { + setError("Setup API not available"); + setAuthState("error"); + return; + } + + setAuthState("running"); + setOutput([ + "Starting authentication...", + `Running ${cliType} CLI in an embedded terminal so you don't need to copy/paste.`, + "When your browser opens, complete sign-in and return here.", + "", + ]); + setError(null); + setToken(""); + + // Subscribe to progress events + if (api.setup.onAuthProgress) { + unsubscribeRef.current = api.setup.onAuthProgress((progress) => { + if (progress.cli === cliType && progress.data) { + // Split by newlines and add each line + const normalized = progress.data.replace(/\r/g, "\n"); + const lines = normalized + .split("\n") + .map((line: string) => line.trimEnd()) + .filter((line: string) => line.length > 0); + if (lines.length > 0) { + setOutput((prev) => [...prev, ...lines]); + } + } + }); + } + + try { + // Call the appropriate auth API based on cliType + const result = + cliType === "claude" + ? await api.setup.authClaude() + : await api.setup.authCodex?.(); + + // Cleanup subscription + if (unsubscribeRef.current) { + unsubscribeRef.current(); + unsubscribeRef.current = null; + } + + if (!result) { + setError("Authentication API not available"); + setAuthState("error"); + return; + } + + // Check for token (only available for Claude) + const resultToken = + cliType === "claude" && "token" in result ? result.token : undefined; + const resultTerminalOpened = + cliType === "claude" && "terminalOpened" in result + ? result.terminalOpened + : false; + + if (result.success && resultToken && typeof resultToken === "string") { + setToken(resultToken); + setAuthState("success"); + setOutput((prev) => [ + ...prev, + "", + "✓ Authentication successful!", + "✓ Token captured automatically.", + ]); + } else if (result.requiresManualAuth) { + // Terminal was opened - user needs to copy token manually + setAuthState("manual"); + // Don't add extra messages if terminalOpened - the progress messages already explain + if (!resultTerminalOpened) { + const extraMessages = [ + "", + "⚠ Could not capture token automatically.", + ]; + if (result.error) { + extraMessages.push(result.error); + } + setOutput((prev) => [ + ...prev, + ...extraMessages, + "Please copy the token from above and paste it below.", + ]); + } + } else { + setError(result.error || "Authentication failed"); + setAuthState("error"); + } + } catch (err: unknown) { + // Cleanup subscription + if (unsubscribeRef.current) { + unsubscribeRef.current(); + unsubscribeRef.current = null; + } + + const errorMessage = + err instanceof Error + ? err.message + : typeof err === "object" && err !== null && "error" in err + ? String((err as { error: unknown }).error) + : "Authentication failed"; + + // Check if we should fall back to manual mode + if ( + typeof err === "object" && + err !== null && + "requiresManualAuth" in err && + (err as { requiresManualAuth: boolean }).requiresManualAuth + ) { + setAuthState("manual"); + setOutput((prev) => [ + ...prev, + "", + "⚠ " + errorMessage, + "Please copy the token manually and paste it below.", + ]); + } else { + setError(errorMessage); + setAuthState("error"); + } + } + }, [cliType]); + + const reset = useCallback(() => { + setAuthState("idle"); + setOutput([]); + setToken(""); + setError(null); + if (unsubscribeRef.current) { + unsubscribeRef.current(); + unsubscribeRef.current = null; + } + }, []); + + return { authState, output, token, error, startAuth, reset }; +} diff --git a/app/src/components/views/setup-view/hooks/use-token-save.ts b/app/src/components/views/setup-view/hooks/use-token-save.ts new file mode 100644 index 00000000..01166328 --- /dev/null +++ b/app/src/components/views/setup-view/hooks/use-token-save.ts @@ -0,0 +1,58 @@ +import { useState, useCallback } from "react"; +import { toast } from "sonner"; +import { getElectronAPI } from "@/lib/electron"; + +interface UseTokenSaveOptions { + provider: string; // e.g., "anthropic_oauth_token", "anthropic", "openai" + onSuccess?: () => void; +} + +export function useTokenSave({ provider, onSuccess }: UseTokenSaveOptions) { + const [isSaving, setIsSaving] = useState(false); + + const saveToken = useCallback( + async (tokenValue: string) => { + if (!tokenValue.trim()) { + toast.error("Please enter a valid token"); + return false; + } + + setIsSaving(true); + try { + const api = getElectronAPI(); + const setupApi = api.setup; + + if (setupApi?.storeApiKey) { + const result = await setupApi.storeApiKey(provider, tokenValue); + console.log(`[Token Save] Store result for ${provider}:`, result); + + if (result.success) { + const tokenType = provider.includes("oauth") + ? "subscription token" + : "API key"; + toast.success(`${tokenType} saved successfully`); + onSuccess?.(); + return true; + } else { + toast.error("Failed to save token", { description: result.error }); + return false; + } + } else { + // Web mode fallback - just show success + toast.success("Token saved"); + onSuccess?.(); + return true; + } + } catch (error) { + console.error(`[Token Save] Failed to save ${provider}:`, error); + toast.error("Failed to save token"); + return false; + } finally { + setIsSaving(false); + } + }, + [provider, onSuccess] + ); + + return { isSaving, saveToken }; +} diff --git a/app/src/components/views/setup-view/steps/claude-setup-step.tsx b/app/src/components/views/setup-view/steps/claude-setup-step.tsx new file mode 100644 index 00000000..73015d87 --- /dev/null +++ b/app/src/components/views/setup-view/steps/claude-setup-step.tsx @@ -0,0 +1,584 @@ +"use client"; + +import { useState, useEffect, useCallback } 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 { useSetupStore } from "@/store/setup-store"; +import { useAppStore } from "@/store/app-store"; +import { getElectronAPI } from "@/lib/electron"; +import { + CheckCircle2, + Loader2, + Terminal, + Key, + ArrowRight, + ArrowLeft, + ExternalLink, + Copy, + AlertCircle, + RefreshCw, + Download, + Shield, +} from "lucide-react"; +import { toast } from "sonner"; +import { SetupTokenModal } from "../dialogs"; +import { StatusBadge, TerminalOutput } from "../components"; +import { + useCliStatus, + useCliInstallation, + useTokenSave, +} from "../hooks"; + +interface ClaudeSetupStepProps { + onNext: () => void; + onBack: () => void; + onSkip: () => void; +} + +// Claude Setup Step - 2 Authentication Options: +// 1. OAuth Token (Subscription): User runs `claude setup-token` and provides the token +// 2. API Key (Pay-per-use): User provides their Anthropic API key directly +export function ClaudeSetupStep({ + onNext, + onBack, + onSkip, +}: ClaudeSetupStepProps) { + const { + claudeCliStatus, + claudeAuthStatus, + setClaudeCliStatus, + setClaudeAuthStatus, + setClaudeInstallProgress, + } = useSetupStore(); + const { setApiKeys, apiKeys } = useAppStore(); + + const [authMethod, setAuthMethod] = useState<"token" | "api_key" | null>(null); + const [oauthToken, setOAuthToken] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [showTokenModal, setShowTokenModal] = useState(false); + + // Use custom hooks + const { isChecking, checkStatus } = useCliStatus({ + cliType: "claude", + statusApi: () => + getElectronAPI().setup?.getClaudeStatus() || Promise.reject(), + setCliStatus: setClaudeCliStatus, + setAuthStatus: setClaudeAuthStatus, + }); + + const { isInstalling, installProgress, install } = useCliInstallation({ + cliType: "claude", + installApi: () => + getElectronAPI().setup?.installClaude() || Promise.reject(), + onProgressEvent: getElectronAPI().setup?.onInstallProgress, + onSuccess: checkStatus, + getStoreState: () => useSetupStore.getState().claudeCliStatus, + }); + + const { isSaving: isSavingOAuth, saveToken: saveOAuthToken } = useTokenSave({ + provider: "anthropic_oauth_token", + onSuccess: () => { + setClaudeAuthStatus({ + authenticated: true, + method: "oauth_token", + hasCredentialsFile: false, + oauthTokenValid: true, + }); + setAuthMethod(null); + checkStatus(); + }, + }); + + const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave({ + provider: "anthropic", + onSuccess: () => { + setClaudeAuthStatus({ + authenticated: true, + method: "api_key", + hasCredentialsFile: false, + apiKeyValid: true, + }); + setApiKeys({ ...apiKeys, anthropic: apiKey }); + setAuthMethod(null); + checkStatus(); + }, + }); + + // Sync install progress to store + useEffect(() => { + setClaudeInstallProgress({ + isInstalling, + output: installProgress.output, + }); + }, [isInstalling, installProgress, setClaudeInstallProgress]); + + // Check status on mount + useEffect(() => { + checkStatus(); + }, [checkStatus]); + + const copyCommand = (command: string) => { + navigator.clipboard.writeText(command); + toast.success("Command copied to clipboard"); + }; + + // Handle token obtained from the OAuth modal + const handleTokenFromModal = useCallback( + async (token: string) => { + setOAuthToken(token); + setShowTokenModal(false); + await saveOAuthToken(token); + }, + [saveOAuthToken] + ); + + const isAuthenticated = claudeAuthStatus?.authenticated || apiKeys.anthropic; + + const getAuthMethodLabel = () => { + if (!isAuthenticated) return null; + if ( + claudeAuthStatus?.method === "oauth_token_env" || + claudeAuthStatus?.method === "oauth_token" + ) + return "Subscription Token"; + if ( + apiKeys.anthropic || + claudeAuthStatus?.method === "api_key" || + claudeAuthStatus?.method === "api_key_env" + ) + return "API Key"; + return "Authenticated"; + }; + + return ( +
+
+
+ +
+

+ Claude Setup +

+

+ Configure Claude for code generation +

+
+ + {/* Status Card */} + + +
+ Status + +
+
+ +
+ CLI Installation + {isChecking ? ( + + ) : claudeCliStatus?.installed ? ( + + ) : ( + + )} +
+ + {claudeCliStatus?.version && ( +
+ Version + + {claudeCliStatus.version} + +
+ )} + +
+ Authentication + {isAuthenticated ? ( +
+ + {getAuthMethodLabel() && ( + + ({getAuthMethodLabel()}) + + )} +
+ ) : ( + + )} +
+
+
+ + {/* Installation Section */} + {!claudeCliStatus?.installed && ( + + + + + Install Claude CLI + + + Required for subscription-based authentication + + + +
+ +
+ + curl -fsSL https://claude.ai/install.sh | bash + + +
+
+ +
+ +
+ + irm https://claude.ai/install.ps1 | iex + + +
+
+ + {isInstalling && ( + + )} + + +
+
+ )} + + {/* Authentication Section */} + {!isAuthenticated && ( + + + + + Authentication + + Choose your authentication method + + + {/* Option 1: Subscription Token */} + {authMethod === "token" ? ( +
+
+ +
+

+ Subscription Token +

+

+ Use your Claude subscription (no API charges) +

+ + {claudeCliStatus?.installed ? ( + <> + {/* Primary: Automated OAuth setup */} + + + {/* Divider */} +
+
+ +
+
+ + or paste manually + +
+
+ + {/* Fallback: Manual token entry */} +
+ + setOAuthToken(e.target.value)} + className="bg-input border-border text-foreground" + data-testid="oauth-token-input" + /> +
+ +
+ + +
+ + ) : ( +
+

+ + Install Claude CLI first to use subscription + authentication +

+
+ )} +
+
+
+ ) : authMethod === "api_key" ? ( + /* Option 2: API Key */ +
+
+ +
+

API Key

+

+ Pay-per-use with your Anthropic API key +

+ +
+ + setApiKey(e.target.value)} + className="bg-input border-border text-foreground" + data-testid="anthropic-api-key-input" + /> +

+ Get your API key from{" "} + + console.anthropic.com + + +

+
+ +
+ + +
+
+
+
+ ) : ( + /* Auth Method Selection */ +
+ + + +
+ )} +
+
+ )} + + {/* Success State */} + {isAuthenticated && ( + + +
+
+ +
+
+

+ Claude is ready to use! +

+

+ {getAuthMethodLabel() && `Using ${getAuthMethodLabel()}. `}You + can proceed to the next step +

+
+
+
+
+ )} + + {/* Navigation */} +
+ +
+ + +
+
+ + {/* OAuth Setup Modal */} + setShowTokenModal(false)} + onTokenObtained={handleTokenFromModal} + /> +
+ ); +} diff --git a/app/src/components/views/setup-view/steps/codex-setup-step.tsx b/app/src/components/views/setup-view/steps/codex-setup-step.tsx new file mode 100644 index 00000000..69f8b292 --- /dev/null +++ b/app/src/components/views/setup-view/steps/codex-setup-step.tsx @@ -0,0 +1,432 @@ +"use client"; + +import { useState, useEffect } 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 { useSetupStore } from "@/store/setup-store"; +import { useAppStore } from "@/store/app-store"; +import { getElectronAPI } from "@/lib/electron"; +import { + CheckCircle2, + Loader2, + Terminal, + Key, + ArrowRight, + ArrowLeft, + ExternalLink, + Copy, + AlertCircle, + RefreshCw, + Download, +} from "lucide-react"; +import { toast } from "sonner"; +import { StatusBadge, TerminalOutput } from "../components"; +import { + useCliStatus, + useCliInstallation, + useTokenSave, +} from "../hooks"; + +interface CodexSetupStepProps { + onNext: () => void; + onBack: () => void; + onSkip: () => void; +} + +export function CodexSetupStep({ + onNext, + onBack, + onSkip, +}: CodexSetupStepProps) { + const { + codexCliStatus, + codexAuthStatus, + setCodexCliStatus, + setCodexAuthStatus, + setCodexInstallProgress, + } = useSetupStore(); + const { setApiKeys, apiKeys } = useAppStore(); + + const [showApiKeyInput, setShowApiKeyInput] = useState(false); + const [apiKey, setApiKey] = useState(""); + + // Use custom hooks + const { isChecking, checkStatus } = useCliStatus({ + cliType: "codex", + statusApi: () => + getElectronAPI().setup?.getCodexStatus() || Promise.reject(), + setCliStatus: setCodexCliStatus, + setAuthStatus: setCodexAuthStatus, + }); + + const { isInstalling, installProgress, install } = useCliInstallation({ + cliType: "codex", + installApi: () => + getElectronAPI().setup?.installCodex() || Promise.reject(), + onProgressEvent: getElectronAPI().setup?.onInstallProgress, + onSuccess: checkStatus, + }); + + const { isSaving: isSavingKey, saveToken: saveApiKeyToken } = useTokenSave({ + provider: "openai", + onSuccess: () => { + setCodexAuthStatus({ + authenticated: true, + method: "api_key", + apiKeyValid: true, + }); + setApiKeys({ ...apiKeys, openai: apiKey }); + setShowApiKeyInput(false); + checkStatus(); + }, + }); + + // Sync install progress to store + useEffect(() => { + setCodexInstallProgress({ + isInstalling, + output: installProgress.output, + }); + }, [isInstalling, installProgress, setCodexInstallProgress]); + + // Check status on mount + useEffect(() => { + checkStatus(); + }, [checkStatus]); + + const copyCommand = (command: string) => { + navigator.clipboard.writeText(command); + toast.success("Command copied to clipboard"); + }; + + const isAuthenticated = codexAuthStatus?.authenticated || apiKeys.openai; + + const getAuthMethodLabel = () => { + if (!isAuthenticated) return null; + if (apiKeys.openai) return "API Key (Manual)"; + if (codexAuthStatus?.method === "api_key") return "API Key (Auth File)"; + if (codexAuthStatus?.method === "env") return "API Key (Environment)"; + if (codexAuthStatus?.method === "cli_verified") + return "CLI Login (ChatGPT)"; + return "Authenticated"; + }; + + return ( +
+
+
+ +
+

+ Codex CLI Setup +

+

+ OpenAI's GPT-5.1 Codex for advanced code generation +

+
+ + {/* Status Card */} + + +
+ Installation Status + +
+
+ +
+ CLI Installation + {isChecking ? ( + + ) : codexCliStatus?.installed ? ( + + ) : ( + + )} +
+ + {codexCliStatus?.version && ( +
+ Version + + {codexCliStatus.version} + +
+ )} + +
+ Authentication + {isAuthenticated ? ( +
+ + {getAuthMethodLabel() && ( + + ({getAuthMethodLabel()}) + + )} +
+ ) : ( + + )} +
+
+
+ + {/* Installation Section */} + {!codexCliStatus?.installed && ( + + + + + Install Codex CLI + + + Install via npm (Node.js required) + + + +
+ +
+ + npm install -g @openai/codex + + +
+
+ + {isInstalling && ( + + )} + +
+ +
+ +
+
+ +

+ Requires Node.js to be installed. If the auto-install fails, + try running the command manually in your terminal. +

+
+
+
+
+ )} + + {/* Authentication Section */} + {!isAuthenticated && ( + + + + + Authentication + + Codex requires an OpenAI API key + + + {codexCliStatus?.installed && ( +
+
+ +
+

+ Authenticate via CLI +

+

+ Run this command in your terminal: +

+
+ + codex auth login + + +
+
+
+
+ )} + +
+
+ +
+
+ + or enter API key + +
+
+ + {showApiKeyInput ? ( +
+
+ + setApiKey(e.target.value)} + className="bg-input border-border text-foreground" + data-testid="openai-api-key-input" + /> +

+ Get your API key from{" "} + + platform.openai.com + + +

+
+
+ + +
+
+ ) : ( + + )} +
+
+ )} + + {/* Success State */} + {isAuthenticated && ( + + +
+
+ +
+
+

+ Codex is ready to use! +

+

+ {getAuthMethodLabel() && + `Authenticated via ${getAuthMethodLabel()}. `} + You can proceed to complete setup +

+
+
+
+
+ )} + + {/* Navigation */} +
+ +
+ + +
+
+
+ ); +} diff --git a/app/src/components/views/setup-view/steps/complete-step.tsx b/app/src/components/views/setup-view/steps/complete-step.tsx new file mode 100644 index 00000000..447c6465 --- /dev/null +++ b/app/src/components/views/setup-view/steps/complete-step.tsx @@ -0,0 +1,115 @@ +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + CheckCircle2, + AlertCircle, + Shield, + Sparkles, +} from "lucide-react"; +import { useSetupStore } from "@/store/setup-store"; +import { useAppStore } from "@/store/app-store"; + +interface CompleteStepProps { + onFinish: () => void; +} + +export function CompleteStep({ onFinish }: CompleteStepProps) { + const { claudeCliStatus, claudeAuthStatus, codexCliStatus, codexAuthStatus } = + useSetupStore(); + const { apiKeys } = useAppStore(); + + const claudeReady = + (claudeCliStatus?.installed && claudeAuthStatus?.authenticated) || + apiKeys.anthropic; + const codexReady = + (codexCliStatus?.installed && codexAuthStatus?.authenticated) || + apiKeys.openai; + + return ( +
+
+ +
+ +
+

+ Setup Complete! +

+

+ Your development environment is configured. You're ready to start + building with AI-powered assistance. +

+
+ +
+ + +
+ {claudeReady ? ( + + ) : ( + + )} +
+

Claude

+

+ {claudeReady ? "Ready to use" : "Configure later in settings"} +

+
+
+
+
+ + + +
+ {codexReady ? ( + + ) : ( + + )} +
+

Codex

+

+ {codexReady ? "Ready to use" : "Configure later in settings"} +

+
+
+
+
+
+ +
+
+ +
+

+ Your credentials are secure +

+

+ API keys are stored locally and never sent to our servers +

+
+
+
+ + +
+ ); +} diff --git a/app/src/components/views/setup-view/steps/index.ts b/app/src/components/views/setup-view/steps/index.ts new file mode 100644 index 00000000..5fa3a01c --- /dev/null +++ b/app/src/components/views/setup-view/steps/index.ts @@ -0,0 +1,5 @@ +// Re-export all setup step components for easier imports +export { WelcomeStep } from "./welcome-step"; +export { CompleteStep } from "./complete-step"; +export { ClaudeSetupStep } from "./claude-setup-step"; +export { CodexSetupStep } from "./codex-setup-step"; diff --git a/app/src/components/views/setup-view/steps/welcome-step.tsx b/app/src/components/views/setup-view/steps/welcome-step.tsx new file mode 100644 index 00000000..1c7e945b --- /dev/null +++ b/app/src/components/views/setup-view/steps/welcome-step.tsx @@ -0,0 +1,69 @@ +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Terminal, ArrowRight } from "lucide-react"; + +interface WelcomeStepProps { + onNext: () => void; +} + +export function WelcomeStep({ onNext }: WelcomeStepProps) { + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Automaker Logo +
+ +
+

+ Welcome to Automaker +

+

+ Let's set up your development environment. We'll check for + required CLI tools and help you configure them. +

+
+ +
+ + + + + Claude CLI + + + +

+ Anthropic's powerful AI assistant for code generation and + analysis +

+
+
+ + + + + + Codex CLI + + + +

+ OpenAI's GPT-5.1 Codex for advanced code generation tasks +

+
+
+
+ + +
+ ); +}