From 3bd28d30843708ac029a6c95cc209c3ed9a8d326 Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 10 Dec 2025 19:15:29 +0100 Subject: [PATCH] feat(setup): implement setup wizard for CLI tools configuration - Added a new SetupView component to guide users through the installation and authentication of Claude and Codex CLIs. - Integrated IPC handlers for checking CLI status, installing, and authenticating both CLIs. - Enhanced the app store to manage setup state, including first run detection and progress tracking. - Updated the main application view to redirect to the setup wizard on first run. - Improved user experience by providing clear instructions and feedback during the setup process. These changes streamline the initial configuration of CLI tools, ensuring users can easily set up their development environment. --- app/electron/main.js | 249 ++- app/electron/preload.js | 53 + app/electron/services/claude-cli-detector.js | 430 ++++- app/electron/services/codex-cli-detector.js | 366 ++++- app/electron/services/feature-executor.js | 6 +- app/electron/services/model-provider.js | 71 +- app/src/app/page.tsx | 40 +- app/src/components/views/setup-view.tsx | 1486 ++++++++++++++++++ app/src/lib/electron.ts | 220 +++ app/src/store/app-store.ts | 1 + app/src/store/setup-store.ts | 182 +++ app/tests/utils.ts | 170 ++ 12 files changed, 3191 insertions(+), 83 deletions(-) create mode 100644 app/src/components/views/setup-view.tsx create mode 100644 app/src/store/setup-store.ts diff --git a/app/electron/main.js b/app/electron/main.js index 7083789e..43118263 100644 --- a/app/electron/main.js +++ b/app/electron/main.js @@ -818,8 +818,23 @@ ipcMain.handle( ipcMain.handle("claude:check-cli", async () => { try { const claudeCliDetector = require("./services/claude-cli-detector"); - const info = claudeCliDetector.getInstallationInfo(); - return { success: true, ...info }; + const path = require("path"); + const credentialsPath = path.join(app.getPath("userData"), "credentials.json"); + const fullStatus = claudeCliDetector.getFullStatus(credentialsPath); + + // Return in format expected by settings view (status: "installed" | "not_installed") + return { + success: true, + status: fullStatus.installed ? "installed" : "not_installed", + method: fullStatus.auth?.method || null, + version: fullStatus.version || null, + path: fullStatus.path || null, + authenticated: fullStatus.auth?.authenticated || false, + recommendation: fullStatus.installed + ? null + : "Install Claude Code CLI for optimal performance with ultrathink.", + installCommands: fullStatus.installed ? null : claudeCliDetector.getInstallCommands(), + }; } catch (error) { console.error("[IPC] claude:check-cli error:", error); return { success: false, error: error.message }; @@ -1363,3 +1378,233 @@ ipcMain.handle("git:get-file-diff", async (_, { projectPath, filePath }) => { return { success: false, error: error.message }; } }); + +// ============================================================================ +// Setup & CLI Management IPC Handlers +// ============================================================================ + +/** + * Get comprehensive Claude CLI status including auth + */ +ipcMain.handle("setup:claude-status", async () => { + try { + const claudeCliDetector = require("./services/claude-cli-detector"); + const credentialsPath = path.join(app.getPath("userData"), "credentials.json"); + const result = claudeCliDetector.getFullStatus(credentialsPath); + console.log("[IPC] setup:claude-status result:", result); + return result; + } catch (error) { + console.error("[IPC] setup:claude-status error:", error); + return { success: false, error: error.message }; + } +}); + +/** + * Get comprehensive Codex CLI status including auth + */ +ipcMain.handle("setup:codex-status", async () => { + try { + const codexCliDetector = require("./services/codex-cli-detector"); + const info = codexCliDetector.getFullStatus(); + return { success: true, ...info }; + } catch (error) { + console.error("[IPC] setup:codex-status error:", error); + return { success: false, error: error.message }; + } +}); + +/** + * Install Claude CLI + */ +ipcMain.handle("setup:install-claude", async (event) => { + try { + const claudeCliDetector = require("./services/claude-cli-detector"); + + const sendProgress = (progress) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("setup:install-progress", { + cli: "claude", + ...progress + }); + } + }; + + const result = await claudeCliDetector.installCli(sendProgress); + return { success: true, ...result }; + } catch (error) { + console.error("[IPC] setup:install-claude error:", error); + return { success: false, error: error.message || error.error }; + } +}); + +/** + * Install Codex CLI + */ +ipcMain.handle("setup:install-codex", async (event) => { + try { + const codexCliDetector = require("./services/codex-cli-detector"); + + const sendProgress = (progress) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("setup:install-progress", { + cli: "codex", + ...progress + }); + } + }; + + const result = await codexCliDetector.installCli(sendProgress); + return { success: true, ...result }; + } catch (error) { + console.error("[IPC] setup:install-codex error:", error); + return { success: false, error: error.message || error.error }; + } +}); + +/** + * Authenticate Claude CLI (manual auth required) + */ +ipcMain.handle("setup:auth-claude", async (event) => { + try { + const claudeCliDetector = require("./services/claude-cli-detector"); + + const sendProgress = (progress) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("setup:auth-progress", { + cli: "claude", + ...progress + }); + } + }; + + const result = await claudeCliDetector.runSetupToken(sendProgress); + return { success: true, ...result }; + } catch (error) { + console.error("[IPC] setup:auth-claude error:", error); + return { success: false, error: error.message || error.error }; + } +}); + +/** + * Authenticate Codex CLI with optional API key + */ +ipcMain.handle("setup:auth-codex", async (event, { apiKey }) => { + try { + const codexCliDetector = require("./services/codex-cli-detector"); + + const sendProgress = (progress) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("setup:auth-progress", { + cli: "codex", + ...progress + }); + } + }; + + const result = await codexCliDetector.authenticate(apiKey, sendProgress); + return { success: true, ...result }; + } catch (error) { + console.error("[IPC] setup:auth-codex error:", error); + return { success: false, error: error.message || error.error }; + } +}); + +/** + * Store API key or OAuth token securely (using app's userData) + * @param {string} provider - Provider name (anthropic, openai, google, anthropic_oauth_token) + * @param {string} apiKey - The API key or OAuth token to store + */ +ipcMain.handle("setup:store-api-key", async (_, { provider, apiKey }) => { + try { + console.log("[IPC] setup:store-api-key called for provider:", provider); + const configPath = path.join(app.getPath("userData"), "credentials.json"); + let credentials = {}; + + // Read existing credentials + try { + const content = await fs.readFile(configPath, "utf-8"); + credentials = JSON.parse(content); + } catch (e) { + // File doesn't exist, start fresh + } + + // Store the new key/token + credentials[provider] = apiKey; + + // Write back + await fs.writeFile(configPath, JSON.stringify(credentials, null, 2), "utf-8"); + + console.log("[IPC] setup:store-api-key stored successfully for:", provider); + return { success: true }; + } catch (error) { + console.error("[IPC] setup:store-api-key error:", error); + return { success: false, error: error.message }; + } +}); + +/** + * Get stored API keys and tokens + */ +ipcMain.handle("setup:get-api-keys", async () => { + try { + const configPath = path.join(app.getPath("userData"), "credentials.json"); + + try { + const content = await fs.readFile(configPath, "utf-8"); + const credentials = JSON.parse(content); + + // Return which keys/tokens exist (not the actual values for security) + return { + success: true, + hasAnthropicKey: !!credentials.anthropic, + hasAnthropicOAuthToken: !!credentials.anthropic_oauth_token, + hasOpenAIKey: !!credentials.openai, + hasGoogleKey: !!credentials.google + }; + } catch (e) { + return { + success: true, + hasAnthropicKey: false, + hasAnthropicOAuthToken: false, + hasOpenAIKey: false, + hasGoogleKey: false + }; + } + } catch (error) { + console.error("[IPC] setup:get-api-keys error:", error); + return { success: false, error: error.message }; + } +}); + +/** + * Configure Codex MCP server for a project + */ +ipcMain.handle("setup:configure-codex-mcp", async (_, { projectPath }) => { + try { + const codexConfigManager = require("./services/codex-config-manager"); + const mcpServerPath = path.join(__dirname, "services", "mcp-server-factory.js"); + + const configPath = await codexConfigManager.configureMcpServer(projectPath, mcpServerPath); + + return { success: true, configPath }; + } catch (error) { + console.error("[IPC] setup:configure-codex-mcp error:", error); + return { success: false, error: error.message }; + } +}); + +/** + * Get platform information + */ +ipcMain.handle("setup:get-platform", async () => { + const os = require("os"); + return { + success: true, + platform: process.platform, + arch: process.arch, + homeDir: os.homedir(), + isWindows: process.platform === "win32", + isMac: process.platform === "darwin", + isLinux: process.platform === "linux" + }; +}); diff --git a/app/electron/preload.js b/app/electron/preload.js index 65d2b03a..dcb07e2c 100644 --- a/app/electron/preload.js +++ b/app/electron/preload.js @@ -252,6 +252,59 @@ contextBridge.exposeInMainWorld("electronAPI", { }; }, }, + + // Setup & CLI Management API + setup: { + // Get comprehensive Claude CLI status + getClaudeStatus: () => ipcRenderer.invoke("setup:claude-status"), + + // Get comprehensive Codex CLI status + getCodexStatus: () => ipcRenderer.invoke("setup:codex-status"), + + // Install Claude CLI + installClaude: () => ipcRenderer.invoke("setup:install-claude"), + + // Install Codex CLI + installCodex: () => ipcRenderer.invoke("setup:install-codex"), + + // Authenticate Claude CLI + authClaude: () => ipcRenderer.invoke("setup:auth-claude"), + + // Authenticate Codex CLI with optional API key + authCodex: (apiKey) => ipcRenderer.invoke("setup:auth-codex", { apiKey }), + + // Store API key securely + storeApiKey: (provider, apiKey) => + ipcRenderer.invoke("setup:store-api-key", { provider, apiKey }), + + // Get stored API keys status + getApiKeys: () => ipcRenderer.invoke("setup:get-api-keys"), + + // Configure Codex MCP server for a project + configureCodexMcp: (projectPath) => + ipcRenderer.invoke("setup:configure-codex-mcp", { projectPath }), + + // Get platform information + getPlatform: () => ipcRenderer.invoke("setup:get-platform"), + + // Listen for installation progress + onInstallProgress: (callback) => { + const subscription = (_, data) => callback(data); + ipcRenderer.on("setup:install-progress", subscription); + return () => { + ipcRenderer.removeListener("setup:install-progress", subscription); + }; + }, + + // Listen for auth progress + onAuthProgress: (callback) => { + const subscription = (_, data) => callback(data); + ipcRenderer.on("setup:auth-progress", subscription); + return () => { + ipcRenderer.removeListener("setup:auth-progress", subscription); + }; + }, + }, }); // Also expose a flag to detect if we're in Electron diff --git a/app/electron/services/claude-cli-detector.js b/app/electron/services/claude-cli-detector.js index 31030f0d..f8f4739e 100644 --- a/app/electron/services/claude-cli-detector.js +++ b/app/electron/services/claude-cli-detector.js @@ -1,71 +1,185 @@ -const { execSync } = require('child_process'); +const { execSync, spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); const os = require('os'); +/** + * Claude CLI Detector + * + * Authentication options: + * 1. OAuth Token (Subscription): User runs `claude setup-token` and provides the token to the app + * 2. API Key (Pay-per-use): User provides their Anthropic API key directly + */ class ClaudeCliDetector { /** * Check if Claude Code CLI is installed and accessible - * @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'sdk'|'none' } + * @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'none' } */ - static detectClaudeInstallation() { - try { - // Method 1: Check if 'claude' command is in PATH - try { - const claudePath = execSync('which claude', { encoding: 'utf-8' }).trim(); - const version = execSync('claude --version', { encoding: 'utf-8' }).trim(); - return { - installed: true, - path: claudePath, - version: version, - method: 'cli' - }; - } catch (error) { - // CLI not in PATH, check local installation - } - - // Method 2: Check for local installation - const localClaudePath = path.join(os.homedir(), '.claude', 'local', 'claude'); - if (fs.existsSync(localClaudePath)) { + /** + * Try to get updated PATH from shell config files + * This helps detect CLI installations that modify shell config but haven't updated the current process PATH + */ + static getUpdatedPathFromShellConfig() { + const homeDir = os.homedir(); + const shell = process.env.SHELL || '/bin/bash'; + const shellName = path.basename(shell); + + // Common shell config files + const configFiles = []; + if (shellName.includes('zsh')) { + configFiles.push(path.join(homeDir, '.zshrc')); + configFiles.push(path.join(homeDir, '.zshenv')); + configFiles.push(path.join(homeDir, '.zprofile')); + } else if (shellName.includes('bash')) { + configFiles.push(path.join(homeDir, '.bashrc')); + configFiles.push(path.join(homeDir, '.bash_profile')); + configFiles.push(path.join(homeDir, '.profile')); + } + + // Also check common locations + const commonPaths = [ + path.join(homeDir, '.local', 'bin'), + path.join(homeDir, '.cargo', 'bin'), + '/usr/local/bin', + '/opt/homebrew/bin', + path.join(homeDir, 'bin'), + ]; + + // Try to extract PATH additions from config files + for (const configFile of configFiles) { + if (fs.existsSync(configFile)) { try { - const version = execSync(`${localClaudePath} --version`, { encoding: 'utf-8' }).trim(); - return { - installed: true, - path: localClaudePath, - version: version, - method: 'cli-local' - }; + const content = fs.readFileSync(configFile, 'utf-8'); + // Look for PATH exports that might include claude installation paths + const pathMatches = content.match(/export\s+PATH=["']?([^"'\n]+)["']?/g); + if (pathMatches) { + for (const match of pathMatches) { + const pathValue = match.replace(/export\s+PATH=["']?/, '').replace(/["']?$/, ''); + const paths = pathValue.split(':').filter(p => p && !p.includes('$')); + commonPaths.push(...paths); + } + } } catch (error) { - // Local CLI exists but may not be executable + // Ignore errors reading config files + } + } + } + + return [...new Set(commonPaths)]; // Remove duplicates + } + + static detectClaudeInstallation() { + console.log('[ClaudeCliDetector] Detecting Claude installation...'); + + try { + // Method 1: Check if 'claude' command is in PATH (Unix) + if (process.platform !== 'win32') { + try { + const claudePath = execSync('which claude 2>/dev/null', { encoding: 'utf-8' }).trim(); + if (claudePath) { + const version = this.getClaudeVersion(claudePath); + console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version); + return { + installed: true, + path: claudePath, + version: version, + method: 'cli' + }; + } + } catch (error) { + // CLI not in PATH, continue checking other locations } } - // Method 3: Check Windows path + // Method 2: Check Windows path if (process.platform === 'win32') { try { - const claudePath = execSync('where claude', { encoding: 'utf-8' }).trim(); - const version = execSync('claude --version', { encoding: 'utf-8' }).trim(); - return { - installed: true, - path: claudePath, - version: version, - method: 'cli' - }; + const claudePath = execSync('where claude 2>nul', { encoding: 'utf-8' }).trim().split('\n')[0]; + if (claudePath) { + const version = this.getClaudeVersion(claudePath); + console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version); + return { + installed: true, + path: claudePath, + version: version, + method: 'cli' + }; + } } catch (error) { - // Not found + // Not found on Windows } } - // Method 4: SDK mode (using OAuth token) - if (process.env.CLAUDE_CODE_OAUTH_TOKEN) { + // Method 3: Check for local installation + const localClaudePath = path.join(os.homedir(), '.claude', 'local', 'claude'); + if (fs.existsSync(localClaudePath)) { + const version = this.getClaudeVersion(localClaudePath); + console.log('[ClaudeCliDetector] Found local claude at:', localClaudePath, 'version:', version); return { installed: true, - path: null, - version: 'SDK Mode', - method: 'sdk' + path: localClaudePath, + version: version, + method: 'cli-local' }; } + // Method 4: Check common installation locations (including those from shell config) + const commonPaths = this.getUpdatedPathFromShellConfig(); + const binaryNames = ['claude', 'claude-code']; + + for (const basePath of commonPaths) { + for (const binaryName of binaryNames) { + const claudePath = path.join(basePath, binaryName); + if (fs.existsSync(claudePath)) { + try { + const version = this.getClaudeVersion(claudePath); + console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version); + return { + installed: true, + path: claudePath, + version: version, + method: 'cli' + }; + } catch (error) { + // File exists but can't get version, might not be executable + } + } + } + } + + // Method 5: Try to source shell config and check PATH again (for Unix) + if (process.platform !== 'win32') { + try { + const shell = process.env.SHELL || '/bin/bash'; + const shellName = path.basename(shell); + const homeDir = os.homedir(); + + let sourceCmd = ''; + if (shellName.includes('zsh')) { + sourceCmd = `source ${homeDir}/.zshrc 2>/dev/null && which claude`; + } else if (shellName.includes('bash')) { + sourceCmd = `source ${homeDir}/.bashrc 2>/dev/null && which claude`; + } + + if (sourceCmd) { + const claudePath = execSync(`bash -c "${sourceCmd}"`, { encoding: 'utf-8', timeout: 2000 }).trim(); + if (claudePath && claudePath.startsWith('/')) { + const version = this.getClaudeVersion(claudePath); + console.log('[ClaudeCliDetector] Found claude via shell config at:', claudePath, 'version:', version); + return { + installed: true, + path: claudePath, + version: version, + method: 'cli' + }; + } + } + } catch (error) { + // Failed to source shell config or find claude + } + } + + console.log('[ClaudeCliDetector] Claude CLI not found'); return { installed: false, path: null, @@ -85,35 +199,223 @@ class ClaudeCliDetector { } /** - * Get installation recommendations + * Get Claude CLI version + * @param {string} claudePath Path to claude executable + * @returns {string|null} Version string or null */ - static getInstallationInfo() { + static getClaudeVersion(claudePath) { + try { + const version = execSync(`"${claudePath}" --version 2>/dev/null`, { + encoding: 'utf-8', + timeout: 5000 + }).trim(); + return version || null; + } catch (error) { + return null; + } + } + + /** + * Get authentication status + * Checks for: + * 1. OAuth token stored in app's credentials (from `claude setup-token`) + * 2. API key stored in app's credentials + * 3. API key in environment variable + * + * @param {string} appCredentialsPath Path to app's credentials.json + * @returns {Object} Authentication status + */ + static getAuthStatus(appCredentialsPath) { + console.log('[ClaudeCliDetector] Checking auth status...'); + + const envApiKey = process.env.ANTHROPIC_API_KEY; + console.log('[ClaudeCliDetector] Env ANTHROPIC_API_KEY:', !!envApiKey); + + // Check app's stored credentials + let storedOAuthToken = null; + let storedApiKey = null; + + if (appCredentialsPath && fs.existsSync(appCredentialsPath)) { + try { + const content = fs.readFileSync(appCredentialsPath, 'utf-8'); + const credentials = JSON.parse(content); + storedOAuthToken = credentials.anthropic_oauth_token || null; + storedApiKey = credentials.anthropic || credentials.anthropic_api_key || null; + console.log('[ClaudeCliDetector] App credentials:', { + hasOAuthToken: !!storedOAuthToken, + hasApiKey: !!storedApiKey + }); + } catch (error) { + console.error('[ClaudeCliDetector] Error reading app credentials:', error); + } + } + + // Determine authentication method + // Priority: Stored OAuth Token > Stored API Key > Env API Key + let authenticated = false; + let method = 'none'; + + if (storedOAuthToken) { + authenticated = true; + method = 'oauth_token'; + console.log('[ClaudeCliDetector] Using stored OAuth token (subscription)'); + } else if (storedApiKey) { + authenticated = true; + method = 'api_key'; + console.log('[ClaudeCliDetector] Using stored API key'); + } else if (envApiKey) { + authenticated = true; + method = 'api_key_env'; + console.log('[ClaudeCliDetector] Using environment API key'); + } else { + console.log('[ClaudeCliDetector] No authentication found'); + } + + const result = { + authenticated, + method, + hasStoredOAuthToken: !!storedOAuthToken, + hasStoredApiKey: !!storedApiKey, + hasEnvApiKey: !!envApiKey + }; + + console.log('[ClaudeCliDetector] Auth status result:', result); + return result; + } + + /** + * Get full status including installation and auth + * @param {string} appCredentialsPath Path to app's credentials.json + * @returns {Object} Full status + */ + static getFullStatus(appCredentialsPath) { + const installation = this.detectClaudeInstallation(); + const auth = this.getAuthStatus(appCredentialsPath); + + return { + success: true, + status: installation.installed ? 'installed' : 'not_installed', + installed: installation.installed, + path: installation.path, + version: installation.version, + method: installation.method, + auth + }; + } + + /** + * Get installation commands for different platforms + * @returns {Object} Installation commands + */ + static getInstallCommands() { + return { + macos: 'curl -fsSL https://claude.ai/install.sh | bash', + windows: 'irm https://claude.ai/install.ps1 | iex', + linux: 'curl -fsSL https://claude.ai/install.sh | bash' + }; + } + + /** + * Install Claude CLI using the official script + * @param {Function} onProgress Callback for progress updates + * @returns {Promise} Installation result + */ + static async installCli(onProgress) { + return new Promise((resolve, reject) => { + const platform = process.platform; + let command, args; + + if (platform === 'win32') { + command = 'powershell'; + args = ['-Command', 'irm https://claude.ai/install.ps1 | iex']; + } else { + command = 'bash'; + args = ['-c', 'curl -fsSL https://claude.ai/install.sh | bash']; + } + + console.log('[ClaudeCliDetector] Installing Claude CLI...'); + + const proc = spawn(command, args, { + stdio: ['pipe', 'pipe', 'pipe'], + shell: false + }); + + let output = ''; + let errorOutput = ''; + + proc.stdout.on('data', (data) => { + const text = data.toString(); + output += text; + if (onProgress) { + onProgress({ type: 'stdout', data: text }); + } + }); + + proc.stderr.on('data', (data) => { + const text = data.toString(); + errorOutput += text; + if (onProgress) { + onProgress({ type: 'stderr', data: text }); + } + }); + + proc.on('close', (code) => { + if (code === 0) { + console.log('[ClaudeCliDetector] Installation completed successfully'); + resolve({ + success: true, + output, + message: 'Claude CLI installed successfully' + }); + } else { + console.error('[ClaudeCliDetector] Installation failed with code:', code); + reject({ + success: false, + error: errorOutput || `Installation failed with code ${code}`, + output + }); + } + }); + + proc.on('error', (error) => { + console.error('[ClaudeCliDetector] Installation error:', error); + reject({ + success: false, + error: error.message, + output + }); + }); + }); + } + + /** + * Get instructions for setup-token command + * @returns {Object} Setup token instructions + */ + static getSetupTokenInstructions() { const detection = this.detectClaudeInstallation(); - - if (detection.installed) { + + if (!detection.installed) { return { - status: 'installed', - method: detection.method, - version: detection.version, - path: detection.path, - recommendation: detection.method === 'cli' - ? 'Using Claude Code CLI - optimal for long-running tasks' - : 'Using SDK mode - works well but CLI may provide better performance' + success: false, + error: 'Claude CLI is not installed. Please install it first.', + installCommands: this.getInstallCommands() }; } return { - status: 'not_installed', - recommendation: 'Consider installing Claude Code CLI for better performance with ultrathink', - installCommands: { - macos: 'curl -fsSL claude.ai/install.sh | bash', - windows: 'irm https://claude.ai/install.ps1 | iex', - linux: 'curl -fsSL claude.ai/install.sh | bash', - npm: 'npm install -g @anthropic-ai/claude-code' - } + success: true, + command: 'claude setup-token', + instructions: [ + '1. Open your terminal', + '2. Run: claude setup-token', + '3. Follow the prompts to authenticate', + '4. Copy the token that is displayed', + '5. Paste the token in the field below' + ], + note: 'This token is from your Claude subscription and allows you to use Claude without API charges.' }; } } module.exports = ClaudeCliDetector; - diff --git a/app/electron/services/codex-cli-detector.js b/app/electron/services/codex-cli-detector.js index 1a60e3b3..f45a8db2 100644 --- a/app/electron/services/codex-cli-detector.js +++ b/app/electron/services/codex-cli-detector.js @@ -1,4 +1,4 @@ -const { execSync } = require('child_process'); +const { execSync, spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); const os = require('os'); @@ -11,6 +11,205 @@ const os = require('os'); * for code generation and agentic tasks. */ class CodexCliDetector { + /** + * Get the path to Codex config directory + * @returns {string} Path to .codex directory + */ + static getConfigDir() { + return path.join(os.homedir(), '.codex'); + } + + /** + * Get the path to Codex auth file + * @returns {string} Path to auth.json + */ + static getAuthPath() { + return path.join(this.getConfigDir(), 'auth.json'); + } + + /** + * Check Codex authentication status + * @returns {Object} Authentication status + */ + static checkAuth() { + console.log('[CodexCliDetector] Checking auth status...'); + try { + const authPath = this.getAuthPath(); + const envApiKey = process.env.OPENAI_API_KEY; + console.log('[CodexCliDetector] Auth path:', authPath); + console.log('[CodexCliDetector] Has env API key:', !!envApiKey); + + // First, try to verify authentication using codex CLI command if available + try { + const detection = this.detectCodexInstallation(); + if (detection.installed) { + try { + // Use 'codex login status' to verify authentication + const statusOutput = execSync(`"${detection.path || 'codex'}" login status 2>/dev/null`, { + encoding: 'utf-8', + timeout: 5000 + }); + + // If command succeeds and shows logged in status + if (statusOutput && (statusOutput.includes('Logged in') || statusOutput.includes('Authenticated'))) { + const result = { + authenticated: true, + method: 'cli_verified', + hasAuthFile: fs.existsSync(authPath), + hasEnvKey: !!envApiKey, + authPath + }; + console.log('[CodexCliDetector] Auth result (cli_verified):', result); + return result; + } + } catch (statusError) { + // status command failed, continue with file-based check + } + } + } catch (verifyError) { + // CLI verification failed, continue with file-based check + } + + // Check if auth file exists + if (fs.existsSync(authPath)) { + const content = fs.readFileSync(authPath, 'utf-8'); + const auth = JSON.parse(content); + + // Check for token object structure (from codex auth login) + // Structure: { token: { Id_token, access_token, refresh_token }, last_refresh: ... } + if (auth.token && typeof auth.token === 'object') { + const token = auth.token; + if (token.Id_token || token.access_token || token.refresh_token || token.id_token) { + return { + authenticated: true, + method: 'auth_file', + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath + }; + } + } + + // Check for various possible auth fields that codex might use + if (auth.api_key || auth.openai_api_key || auth.access_token || auth.apiKey) { + return { + authenticated: true, + method: 'auth_file', + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath + }; + } + + // Also check if the file has any meaningful content (non-empty object) + const keys = Object.keys(auth); + if (keys.length > 0) { + // File exists and has content, likely authenticated + // Try to verify by checking if codex command works + try { + const detection = this.detectCodexInstallation(); + if (detection.installed) { + // Try to verify auth by running a simple command + try { + execSync(`"${detection.path || 'codex'}" --version 2>/dev/null`, { + encoding: 'utf-8', + timeout: 3000 + }); + // If command succeeds, assume authenticated + return { + authenticated: true, + method: 'auth_file', + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath + }; + } catch (cmdError) { + // Command failed, but file exists - might still be authenticated + // Return authenticated if file has content + return { + authenticated: true, + method: 'auth_file', + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath + }; + } + } + } catch (verifyError) { + // Verification failed, but file exists with content + return { + authenticated: true, + method: 'auth_file', + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath + }; + } + } + } + + // Check environment variable + if (envApiKey) { + const result = { + authenticated: true, + method: 'env_var', + hasAuthFile: false, + hasEnvKey: true, + authPath + }; + console.log('[CodexCliDetector] Auth result (env_var):', result); + return result; + } + + // If auth file exists but we didn't find standard keys, + // check if codex CLI is installed and try to verify auth + if (fs.existsSync(authPath)) { + try { + const detection = this.detectCodexInstallation(); + if (detection.installed) { + // Auth file exists and CLI is installed - likely authenticated + // The file existing is a good indicator that login was successful + return { + authenticated: true, + method: 'auth_file', + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath + }; + } + } catch (verifyError) { + // Verification attempt failed, but file exists + // Assume authenticated if file exists + return { + authenticated: true, + method: 'auth_file', + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath + }; + } + } + + const result = { + authenticated: false, + method: 'none', + hasAuthFile: false, + hasEnvKey: false, + authPath + }; + console.log('[CodexCliDetector] Auth result (not authenticated):', result); + return result; + } catch (error) { + console.error('[CodexCliDetector] Error checking auth:', error); + const result = { + authenticated: false, + method: 'none', + error: error.message + }; + console.log('[CodexCliDetector] Auth result (error):', result); + return result; + } + } /** * Check if Codex CLI is installed and accessible * @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'npm'|'brew'|'none' } @@ -224,6 +423,171 @@ class CodexCliDetector { static getDefaultModel() { return 'gpt-5.1-codex-max'; } + + /** + * Get comprehensive installation info including auth status + * @returns {Object} Full status object + */ + static getFullStatus() { + const installation = this.detectCodexInstallation(); + const auth = this.checkAuth(); + const info = this.getInstallationInfo(); + + return { + ...info, + auth, + installation + }; + } + + /** + * Install Codex CLI using npm + * @param {Function} onProgress Callback for progress updates + * @returns {Promise} Installation result + */ + static async installCli(onProgress) { + return new Promise((resolve, reject) => { + const command = 'npm'; + const args = ['install', '-g', '@openai/codex@latest']; + + const proc = spawn(command, args, { + stdio: ['pipe', 'pipe', 'pipe'], + shell: true + }); + + let output = ''; + let errorOutput = ''; + + proc.stdout.on('data', (data) => { + const text = data.toString(); + output += text; + if (onProgress) { + onProgress({ type: 'stdout', data: text }); + } + }); + + proc.stderr.on('data', (data) => { + const text = data.toString(); + errorOutput += text; + // npm often outputs progress to stderr + if (onProgress) { + onProgress({ type: 'stderr', data: text }); + } + }); + + proc.on('close', (code) => { + if (code === 0) { + resolve({ + success: true, + output, + message: 'Codex CLI installed successfully' + }); + } else { + reject({ + success: false, + error: errorOutput || `Installation failed with code ${code}`, + output + }); + } + }); + + proc.on('error', (error) => { + reject({ + success: false, + error: error.message, + output + }); + }); + }); + } + + /** + * Authenticate Codex CLI - opens browser for OAuth or stores API key + * @param {string} apiKey Optional API key to store + * @param {Function} onProgress Callback for progress updates + * @returns {Promise} Authentication result + */ + static async authenticate(apiKey, onProgress) { + return new Promise((resolve, reject) => { + const detection = this.detectCodexInstallation(); + + if (!detection.installed) { + reject({ + success: false, + error: 'Codex CLI is not installed' + }); + return; + } + + const codexPath = detection.path || 'codex'; + + if (apiKey) { + // Store API key directly using codex auth command + const proc = spawn(codexPath, ['auth', 'login', '--api-key', apiKey], { + stdio: ['pipe', 'pipe', 'pipe'], + shell: false + }); + + let output = ''; + let errorOutput = ''; + + proc.stdout.on('data', (data) => { + const text = data.toString(); + output += text; + if (onProgress) { + onProgress({ type: 'stdout', data: text }); + } + }); + + proc.stderr.on('data', (data) => { + const text = data.toString(); + errorOutput += text; + if (onProgress) { + onProgress({ type: 'stderr', data: text }); + } + }); + + proc.on('close', (code) => { + if (code === 0) { + resolve({ + success: true, + output, + message: 'Codex CLI authenticated successfully' + }); + } else { + reject({ + success: false, + error: errorOutput || `Authentication failed with code ${code}`, + output + }); + } + }); + + proc.on('error', (error) => { + reject({ + success: false, + error: error.message, + output + }); + }); + } else { + // Require manual authentication + if (onProgress) { + onProgress({ + type: 'info', + data: 'Please run the following command in your terminal to authenticate:\n\ncodex auth login\n\nThen return here to continue setup.' + }); + } + + resolve({ + success: true, + requiresManualAuth: true, + command: `${codexPath} auth login`, + message: 'Please authenticate Codex CLI manually' + }); + } + }); + } } module.exports = CodexCliDetector; diff --git a/app/electron/services/feature-executor.js b/app/electron/services/feature-executor.js index 8ab6e14e..10feda61 100644 --- a/app/electron/services/feature-executor.js +++ b/app/electron/services/feature-executor.js @@ -408,16 +408,16 @@ class FeatureExecutor { if (provider?.ensureAuthEnv && !provider.ensureAuthEnv()) { // Check if CLI is installed to provide better error message let authMsg = - "Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable."; + "Missing Anthropic auth. Go to Settings > Setup to configure your Claude authentication."; try { const claudeCliDetector = require("./claude-cli-detector"); const detection = claudeCliDetector.detectClaudeInstallation(); if (detection.installed && detection.method === "cli") { authMsg = - "Claude CLI is installed but not authenticated. Run `claude login` to authenticate, or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable."; + "Claude CLI is installed but not authenticated. Go to Settings > Setup to provide your subscription token (from `claude setup-token`) or API key."; } else { authMsg = - "Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN, or install Claude CLI and run `claude login`."; + "Missing Anthropic auth. Go to Settings > Setup to configure your Claude authentication, or set ANTHROPIC_API_KEY environment variable."; } } catch (err) { // Fallback to default message diff --git a/app/electron/services/model-provider.js b/app/electron/services/model-provider.js index d5a31850..b07588c9 100644 --- a/app/electron/services/model-provider.js +++ b/app/electron/services/model-provider.js @@ -94,9 +94,42 @@ class ClaudeProvider extends ModelProvider { this.sdk = null; } + /** + * Try to load credentials from the app's own credentials.json file. + * This is where we store OAuth tokens and API keys that users enter in the setup wizard. + * Returns { oauthToken, apiKey } or null values if not found. + */ + loadTokenFromAppCredentials() { + try { + const fs = require('fs'); + const path = require('path'); + const { app } = require('electron'); + const credentialsPath = path.join(app.getPath('userData'), 'credentials.json'); + + if (!fs.existsSync(credentialsPath)) { + console.log('[ClaudeProvider] App credentials file does not exist:', credentialsPath); + return { oauthToken: null, apiKey: null }; + } + + const raw = fs.readFileSync(credentialsPath, 'utf-8'); + const parsed = JSON.parse(raw); + + // Check for OAuth token first (from claude setup-token), then API key + const oauthToken = parsed.anthropic_oauth_token || null; + const apiKey = parsed.anthropic || parsed.anthropic_api_key || null; + + console.log('[ClaudeProvider] App credentials check - OAuth token:', !!oauthToken, ', API key:', !!apiKey); + return { oauthToken, apiKey }; + } catch (err) { + console.warn('[ClaudeProvider] Failed to read app credentials:', err?.message); + return { oauthToken: null, apiKey: null }; + } + } + /** * Try to load a Claude OAuth token from the local CLI config (~/.claude/config.json). * Returns the token string or null if not found. + * NOTE: Claude's credentials.json is encrypted, so we only try config.json */ loadTokenFromCliConfig() { try { @@ -117,30 +150,44 @@ class ClaudeProvider extends ModelProvider { } ensureAuthEnv() { - // If API key or token already present, keep as-is. + // If API key or token already present in environment, keep as-is. if (process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN) { console.log('[ClaudeProvider] Auth already present in environment'); return true; } - // Try to hydrate from CLI login config + + // Priority 1: Try to load from app's own credentials (setup wizard) + const appCredentials = this.loadTokenFromAppCredentials(); + if (appCredentials.oauthToken) { + process.env.CLAUDE_CODE_OAUTH_TOKEN = appCredentials.oauthToken; + console.log('[ClaudeProvider] Loaded CLAUDE_CODE_OAUTH_TOKEN from app credentials'); + return true; + } + if (appCredentials.apiKey) { + process.env.ANTHROPIC_API_KEY = appCredentials.apiKey; + console.log('[ClaudeProvider] Loaded ANTHROPIC_API_KEY from app credentials'); + return true; + } + + // Priority 2: Try to hydrate from CLI login config (legacy) const token = this.loadTokenFromCliConfig(); if (token) { process.env.CLAUDE_CODE_OAUTH_TOKEN = token; console.log('[ClaudeProvider] Loaded CLAUDE_CODE_OAUTH_TOKEN from ~/.claude/config.json'); return true; } - + // Check if CLI is installed but not logged in try { const claudeCliDetector = require('./claude-cli-detector'); const detection = claudeCliDetector.detectClaudeInstallation(); if (detection.installed && detection.method === 'cli') { - console.error('[ClaudeProvider] Claude CLI is installed but not logged in. Run `claude login` to authenticate.'); + console.error('[ClaudeProvider] Claude CLI is installed but not authenticated. Use the setup wizard or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.'); } else { - console.error('[ClaudeProvider] No Anthropic auth found (env empty, ~/.claude/config.json missing token)'); + console.error('[ClaudeProvider] No Anthropic auth found. Use the setup wizard or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN.'); } } catch (err) { - console.error('[ClaudeProvider] No Anthropic auth found (env empty, ~/.claude/config.json missing token)'); + console.error('[ClaudeProvider] No Anthropic auth found. Use the setup wizard or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN.'); } return false; } @@ -156,17 +203,17 @@ class ClaudeProvider extends ModelProvider { } async *executeQuery(options) { - // Ensure we have auth; fall back to CLI login token if available. + // Ensure we have auth; fall back to app credentials or CLI login token if available. if (!this.ensureAuthEnv()) { // Check if CLI is installed to provide better error message - let msg = 'Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.'; + let msg = 'Missing Anthropic auth. Go to Settings > Setup to configure your Claude authentication.'; try { const claudeCliDetector = require('./claude-cli-detector'); const detection = claudeCliDetector.detectClaudeInstallation(); if (detection.installed && detection.method === 'cli') { - msg = 'Claude CLI is installed but not authenticated. Run `claude login` to authenticate, or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.'; + msg = 'Claude CLI is installed but not authenticated. Go to Settings > Setup to provide your subscription token (from `claude setup-token`) or API key.'; } else { - msg = 'Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN, or install Claude CLI and run `claude login`.'; + msg = 'Missing Anthropic auth. Go to Settings > Setup to configure your Claude authentication, or set ANTHROPIC_API_KEY environment variable.'; } } catch (err) { // Fallback to default message @@ -239,11 +286,11 @@ class ClaudeProvider extends ModelProvider { validateConfig() { const errors = []; - // Ensure auth is available (try to auto-load from CLI config) + // Ensure auth is available (try to auto-load from app credentials or CLI config) this.ensureAuthEnv(); if (!process.env.CLAUDE_CODE_OAUTH_TOKEN && !process.env.ANTHROPIC_API_KEY) { - errors.push('No Claude authentication found. Set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY, or run `claude login` to populate ~/.claude/config.json.'); + errors.push('No Claude authentication found. Go to Settings > Setup to configure your subscription token or API key.'); } return { diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index 27cc5e90..d1fcf2cf 100644 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -11,11 +11,14 @@ import { AgentToolsView } from "@/components/views/agent-tools-view"; import { InterviewView } from "@/components/views/interview-view"; import { ContextView } from "@/components/views/context-view"; import { ProfilesView } from "@/components/views/profiles-view"; +import { SetupView } from "@/components/views/setup-view"; import { useAppStore } from "@/store/app-store"; +import { useSetupStore } from "@/store/setup-store"; import { getElectronAPI, isElectron } from "@/lib/electron"; export default function Home() { - const { currentView, setIpcConnected, theme } = useAppStore(); + const { currentView, setCurrentView, setIpcConnected, theme } = useAppStore(); + const { isFirstRun, setupComplete } = useSetupStore(); const [isMounted, setIsMounted] = useState(false); // Prevent hydration issues @@ -23,6 +26,24 @@ export default function Home() { setIsMounted(true); }, []); + // Check if this is first run and redirect to setup if needed + useEffect(() => { + console.log("[Setup Flow] Checking setup state:", { + isMounted, + isFirstRun, + setupComplete, + currentView, + shouldShowSetup: isMounted && isFirstRun && !setupComplete, + }); + + if (isMounted && isFirstRun && !setupComplete) { + console.log("[Setup Flow] Redirecting to setup wizard (first run, not complete)"); + setCurrentView("setup"); + } else if (isMounted && setupComplete) { + console.log("[Setup Flow] Setup already complete, showing normal view"); + } + }, [isMounted, isFirstRun, setupComplete, setCurrentView, currentView]); + // Test IPC connection on mount useEffect(() => { const testConnection = async () => { @@ -96,6 +117,8 @@ export default function Home() { switch (currentView) { case "welcome": return ; + case "setup": + return ; case "board": return ; case "spec": @@ -117,6 +140,21 @@ export default function Home() { } }; + // Setup view is full-screen without sidebar + if (currentView === "setup") { + return ( +
+ + {/* Environment indicator */} + {isMounted && !isElectron() && ( +
+ Web Mode (Mock IPC) +
+ )} +
+ ); + } + return (
diff --git a/app/src/components/views/setup-view.tsx b/app/src/components/views/setup-view.tsx new file mode 100644 index 00000000..229d955c --- /dev/null +++ b/app/src/components/views/setup-view.tsx @@ -0,0 +1,1486 @@ +"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, + XCircle, + Loader2, + Terminal, + Key, + Sparkles, + ArrowRight, + ArrowLeft, + ExternalLink, + Copy, + AlertCircle, + RefreshCw, + Download, + Shield, +} from "lucide-react"; +import { toast } from "sonner"; + +// 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 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.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) { + const authStatus = { + authenticated: result.auth.authenticated, + method: result.auth.method === "oauth_token" + ? "oauth" + : result.auth.method?.includes("api_key") + ? "api_key" + : "none", + hasCredentialsFile: false, + oauthTokenValid: result.auth.hasStoredOAuthToken, + apiKeyValid: result.auth.hasStoredApiKey || result.auth.hasEnvApiKey, + }; + console.log("[Claude Setup] Auth Status:", authStatus); + setClaudeAuthStatus(authStatus as any); + } + } + } + } 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", + 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"); + }; + + const isAuthenticated = claudeAuthStatus?.authenticated || apiKeys.anthropic; + + const getAuthMethodLabel = () => { + if (!isAuthenticated) return null; + if (claudeAuthStatus?.method === "oauth") return "Subscription Token"; + if (apiKeys.anthropic || claudeAuthStatus?.method === "api_key") 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 ? ( + <> +
+

1. Run this command in your terminal:

+
+ + claude setup-token + + +
+
+ +
+ + 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 */} +
+ +
+ + +
+
+
+ ); +} + +// 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); + + 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 authStatus = { + authenticated: result.auth.authenticated, + method: result.auth.method === "auth_file" ? "api_key" : result.auth.method === "env_var" ? "env" : "none", + apiKeyValid: result.auth.authenticated, + }; + console.log("[Codex Setup] Auth Status:", authStatus); + setCodexAuthStatus(authStatus as any); + } 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 +

+
+
+
+ + +
+ ); +} + +// Main Setup View +export function SetupView() { + const { currentStep, setCurrentStep, completeSetup, setSkipClaudeSetup, setSkipCodexSetup } = + useSetupStore(); + const { setCurrentView } = useAppStore(); + + const steps = ["welcome", "claude", "codex", "complete"] as const; + type StepName = typeof steps[number]; + const getStepName = (): StepName => { + if (currentStep === "claude_detect" || currentStep === "claude_auth") return "claude"; + if (currentStep === "codex_detect" || currentStep === "codex_auth") return "codex"; + if (currentStep === "welcome") return "welcome"; + return "complete"; + }; + const currentIndex = steps.indexOf(getStepName()); + + const handleNext = (from: string) => { + console.log("[Setup Flow] handleNext called from:", from, "currentStep:", currentStep); + switch (from) { + case "welcome": + console.log("[Setup Flow] Moving to claude_detect step"); + setCurrentStep("claude_detect"); + break; + case "claude": + console.log("[Setup Flow] Moving to codex_detect step"); + setCurrentStep("codex_detect"); + break; + case "codex": + console.log("[Setup Flow] Moving to complete step"); + setCurrentStep("complete"); + break; + } + }; + + const handleBack = (from: string) => { + console.log("[Setup Flow] handleBack called from:", from); + switch (from) { + case "claude": + setCurrentStep("welcome"); + break; + case "codex": + setCurrentStep("claude_detect"); + break; + } + }; + + const handleSkipClaude = () => { + console.log("[Setup Flow] Skipping Claude setup"); + setSkipClaudeSetup(true); + setCurrentStep("codex_detect"); + }; + + const handleSkipCodex = () => { + console.log("[Setup Flow] Skipping Codex setup"); + setSkipCodexSetup(true); + setCurrentStep("complete"); + }; + + const handleFinish = () => { + console.log("[Setup Flow] handleFinish called - completing setup"); + completeSetup(); + console.log("[Setup Flow] Setup completed, redirecting to welcome view"); + setCurrentView("welcome"); + }; + + return ( +
+ {/* Header */} +
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Automaker + + Automaker Setup + +
+
+
+ + {/* Content */} +
+
+
+
+ +
+ +
+ {currentStep === "welcome" && ( + handleNext("welcome")} /> + )} + + {(currentStep === "claude_detect" || + currentStep === "claude_auth") && ( + handleNext("claude")} + onBack={() => handleBack("claude")} + onSkip={handleSkipClaude} + /> + )} + + {(currentStep === "codex_detect" || + currentStep === "codex_auth") && ( + handleNext("codex")} + onBack={() => handleBack("codex")} + onSkip={handleSkipCodex} + /> + )} + + {currentStep === "complete" && ( + + )} +
+
+
+
+
+ ); +} diff --git a/app/src/lib/electron.ts b/app/src/lib/electron.ts index 91392894..bf5c0c90 100644 --- a/app/src/lib/electron.ts +++ b/app/src/lib/electron.ts @@ -172,6 +172,54 @@ export interface ElectronAPI { git?: GitAPI; suggestions?: SuggestionsAPI; specRegeneration?: SpecRegenerationAPI; + setup?: { + getClaudeStatus: () => Promise<{ + success: boolean; + status?: string; + method?: string; + version?: string; + path?: string; + auth?: { + authenticated: boolean; + method: string; + hasCredentialsFile: boolean; + hasToken: boolean; + }; + error?: string; + }>; + getCodexStatus: () => Promise<{ + success: boolean; + status?: string; + method?: string; + version?: string; + path?: string; + auth?: { + authenticated: boolean; + method: string; + hasAuthFile: boolean; + hasEnvKey: boolean; + }; + error?: string; + }>; + installClaude: () => Promise<{ success: boolean; message?: string; error?: string }>; + installCodex: () => Promise<{ success: boolean; message?: string; error?: string }>; + authClaude: () => Promise<{ success: boolean; requiresManualAuth?: boolean; command?: string; error?: string }>; + authCodex: (apiKey?: string) => Promise<{ success: boolean; requiresManualAuth?: boolean; command?: string; error?: string }>; + storeApiKey: (provider: string, apiKey: string) => Promise<{ success: boolean; error?: string }>; + getApiKeys: () => Promise<{ success: boolean; hasAnthropicKey: boolean; hasOpenAIKey: boolean; hasGoogleKey: boolean }>; + configureCodexMcp: (projectPath: string) => Promise<{ success: boolean; configPath?: string; error?: string }>; + getPlatform: () => Promise<{ + success: boolean; + platform: string; + arch: string; + homeDir: string; + isWindows: boolean; + isMac: boolean; + isLinux: boolean; + }>; + onInstallProgress?: (callback: (progress: any) => void) => () => void; + onAuthProgress?: (callback: (progress: any) => void) => () => void; + }; } // Note: Window interface is declared in @/types/electron.d.ts @@ -461,6 +509,9 @@ export const getElectronAPI = (): ElectronAPI => { error: "OpenAI connection test is only available in the Electron app.", }), + // Mock Setup API + setup: createMockSetupAPI(), + // Mock Auto Mode API autoMode: createMockAutoModeAPI(), @@ -478,6 +529,175 @@ export const getElectronAPI = (): ElectronAPI => { }; }; +// Setup API interface +interface SetupAPI { + getClaudeStatus: () => Promise<{ + success: boolean; + status?: string; + method?: string; + version?: string; + path?: string; + auth?: { + authenticated: boolean; + method: string; + hasCredentialsFile: boolean; + hasToken: boolean; + }; + error?: string; + }>; + getCodexStatus: () => Promise<{ + success: boolean; + status?: string; + method?: string; + version?: string; + path?: string; + auth?: { + authenticated: boolean; + method: string; + hasAuthFile: boolean; + hasEnvKey: boolean; + }; + error?: string; + }>; + installClaude: () => Promise<{ success: boolean; message?: string; error?: string }>; + installCodex: () => Promise<{ success: boolean; message?: string; error?: string }>; + authClaude: () => Promise<{ success: boolean; requiresManualAuth?: boolean; command?: string; error?: string }>; + authCodex: (apiKey?: string) => Promise<{ success: boolean; requiresManualAuth?: boolean; command?: string; error?: string }>; + storeApiKey: (provider: string, apiKey: string) => Promise<{ success: boolean; error?: string }>; + getApiKeys: () => Promise<{ success: boolean; hasAnthropicKey: boolean; hasOpenAIKey: boolean; hasGoogleKey: boolean }>; + configureCodexMcp: (projectPath: string) => Promise<{ success: boolean; configPath?: string; error?: string }>; + getPlatform: () => Promise<{ + success: boolean; + platform: string; + arch: string; + homeDir: string; + isWindows: boolean; + isMac: boolean; + isLinux: boolean; + }>; + onInstallProgress?: (callback: (progress: any) => void) => () => void; + onAuthProgress?: (callback: (progress: any) => void) => () => void; +} + +// Mock Setup API implementation +function createMockSetupAPI(): SetupAPI { + return { + getClaudeStatus: async () => { + console.log("[Mock] Getting Claude status"); + return { + success: true, + status: "not_installed", + auth: { + authenticated: false, + method: "none", + hasCredentialsFile: false, + hasToken: false, + }, + }; + }, + + getCodexStatus: async () => { + console.log("[Mock] Getting Codex status"); + return { + success: true, + status: "not_installed", + auth: { + authenticated: false, + method: "none", + hasAuthFile: false, + hasEnvKey: false, + }, + }; + }, + + installClaude: async () => { + console.log("[Mock] Installing Claude CLI"); + // Simulate installation delay + await new Promise((resolve) => setTimeout(resolve, 1000)); + return { + success: false, + error: "CLI installation is only available in the Electron app. Please run the command manually.", + }; + }, + + installCodex: async () => { + console.log("[Mock] Installing Codex CLI"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + return { + success: false, + error: "CLI installation is only available in the Electron app. Please run the command manually.", + }; + }, + + authClaude: async () => { + console.log("[Mock] Auth Claude CLI"); + return { + success: true, + requiresManualAuth: true, + command: "claude login", + }; + }, + + authCodex: async (apiKey?: string) => { + console.log("[Mock] Auth Codex CLI", { hasApiKey: !!apiKey }); + if (apiKey) { + return { success: true }; + } + return { + success: true, + requiresManualAuth: true, + command: "codex auth login", + }; + }, + + storeApiKey: async (provider: string, apiKey: string) => { + console.log("[Mock] Storing API key for:", provider); + // In mock mode, we just pretend to store it (it's already in the app store) + return { success: true }; + }, + + getApiKeys: async () => { + console.log("[Mock] Getting API keys"); + return { + success: true, + hasAnthropicKey: false, + hasOpenAIKey: false, + hasGoogleKey: false, + }; + }, + + configureCodexMcp: async (projectPath: string) => { + console.log("[Mock] Configuring Codex MCP for:", projectPath); + return { + success: true, + configPath: `${projectPath}/.codex/config.toml`, + }; + }, + + getPlatform: async () => { + return { + success: true, + platform: "darwin", + arch: "arm64", + homeDir: "/Users/mock", + isWindows: false, + isMac: true, + isLinux: false, + }; + }, + + onInstallProgress: (callback) => { + // Mock progress events + return () => {}; + }, + + onAuthProgress: (callback) => { + // Mock auth events + return () => {}; + }, + }; +} + // Mock Worktree API implementation function createMockWorktreeAPI(): WorktreeAPI { return { diff --git a/app/src/store/app-store.ts b/app/src/store/app-store.ts index bb4d52f3..fe974c6f 100644 --- a/app/src/store/app-store.ts +++ b/app/src/store/app-store.ts @@ -4,6 +4,7 @@ import type { Project, TrashedProject } from "@/lib/electron"; export type ViewMode = | "welcome" + | "setup" | "spec" | "board" | "agent" diff --git a/app/src/store/setup-store.ts b/app/src/store/setup-store.ts new file mode 100644 index 00000000..6d4ded1d --- /dev/null +++ b/app/src/store/setup-store.ts @@ -0,0 +1,182 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +// CLI Installation Status +export interface CliStatus { + installed: boolean; + path: string | null; + version: string | null; + method: string; + error?: string; +} + +// Claude Auth Status +export interface ClaudeAuthStatus { + authenticated: boolean; + method: "oauth" | "api_key" | "none"; + hasCredentialsFile: boolean; + oauthTokenValid?: boolean; + apiKeyValid?: boolean; + error?: string; +} + +// Codex Auth Status +export interface CodexAuthStatus { + authenticated: boolean; + method: "api_key" | "env" | "none"; + apiKeyValid?: boolean; + mcpConfigured?: boolean; + error?: string; +} + +// Installation Progress +export interface InstallProgress { + isInstalling: boolean; + currentStep: string; + progress: number; // 0-100 + output: string[]; + error?: string; +} + +export type SetupStep = + | "welcome" + | "claude_detect" + | "claude_auth" + | "codex_detect" + | "codex_auth" + | "complete"; + +export interface SetupState { + // Setup wizard state + isFirstRun: boolean; + setupComplete: boolean; + currentStep: SetupStep; + + // Claude CLI state + claudeCliStatus: CliStatus | null; + claudeAuthStatus: ClaudeAuthStatus | null; + claudeInstallProgress: InstallProgress; + + // Codex CLI state + codexCliStatus: CliStatus | null; + codexAuthStatus: CodexAuthStatus | null; + codexInstallProgress: InstallProgress; + + // Setup preferences + skipClaudeSetup: boolean; + skipCodexSetup: boolean; +} + +export interface SetupActions { + // Setup flow + setCurrentStep: (step: SetupStep) => void; + completeSetup: () => void; + resetSetup: () => void; + setIsFirstRun: (isFirstRun: boolean) => void; + + // Claude CLI + setClaudeCliStatus: (status: CliStatus | null) => void; + setClaudeAuthStatus: (status: ClaudeAuthStatus | null) => void; + setClaudeInstallProgress: (progress: Partial) => void; + resetClaudeInstallProgress: () => void; + + // Codex CLI + setCodexCliStatus: (status: CliStatus | null) => void; + setCodexAuthStatus: (status: CodexAuthStatus | null) => void; + setCodexInstallProgress: (progress: Partial) => void; + resetCodexInstallProgress: () => void; + + // Preferences + setSkipClaudeSetup: (skip: boolean) => void; + setSkipCodexSetup: (skip: boolean) => void; +} + +const initialInstallProgress: InstallProgress = { + isInstalling: false, + currentStep: "", + progress: 0, + output: [], +}; + +const initialState: SetupState = { + isFirstRun: true, + setupComplete: false, + currentStep: "welcome", + + claudeCliStatus: null, + claudeAuthStatus: null, + claudeInstallProgress: { ...initialInstallProgress }, + + codexCliStatus: null, + codexAuthStatus: null, + codexInstallProgress: { ...initialInstallProgress }, + + skipClaudeSetup: false, + skipCodexSetup: false, +}; + +export const useSetupStore = create()( + persist( + (set, get) => ({ + ...initialState, + + // Setup flow + setCurrentStep: (step) => set({ currentStep: step }), + + completeSetup: () => set({ setupComplete: true, currentStep: "complete" }), + + resetSetup: () => set({ + ...initialState, + isFirstRun: false, // Don't reset first run flag + }), + + setIsFirstRun: (isFirstRun) => set({ isFirstRun }), + + // Claude CLI + setClaudeCliStatus: (status) => set({ claudeCliStatus: status }), + + setClaudeAuthStatus: (status) => set({ claudeAuthStatus: status }), + + setClaudeInstallProgress: (progress) => set({ + claudeInstallProgress: { + ...get().claudeInstallProgress, + ...progress, + }, + }), + + resetClaudeInstallProgress: () => set({ + claudeInstallProgress: { ...initialInstallProgress }, + }), + + // Codex CLI + setCodexCliStatus: (status) => set({ codexCliStatus: status }), + + setCodexAuthStatus: (status) => set({ codexAuthStatus: status }), + + setCodexInstallProgress: (progress) => set({ + codexInstallProgress: { + ...get().codexInstallProgress, + ...progress, + }, + }), + + resetCodexInstallProgress: () => set({ + codexInstallProgress: { ...initialInstallProgress }, + }), + + // Preferences + setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }), + + setSkipCodexSetup: (skip) => set({ skipCodexSetup: skip }), + }), + { + name: "automaker-setup", + partialize: (state) => ({ + isFirstRun: state.isFirstRun, + setupComplete: state.setupComplete, + skipClaudeSetup: state.skipClaudeSetup, + skipCodexSetup: state.skipCodexSetup, + }), + } + ) +); diff --git a/app/tests/utils.ts b/app/tests/utils.ts index 9168e372..d08a8b01 100644 --- a/app/tests/utils.ts +++ b/app/tests/utils.ts @@ -2338,3 +2338,173 @@ export async function setupMockProjectWithWaitingApprovalFeatures( options ); } + +// ============================================================================ +// Setup View Utilities +// ============================================================================ + +/** + * Set up the app store to show setup view (simulate first run) + */ +export async function setupFirstRun(page: Page): Promise { + await page.addInitScript(() => { + // Clear any existing setup state to simulate first run + localStorage.removeItem("automaker-setup"); + localStorage.removeItem("automaker-storage"); + + // Set up the setup store state for first run + const setupState = { + state: { + isFirstRun: true, + setupComplete: false, + currentStep: "welcome", + claudeCliStatus: null, + claudeAuthStatus: null, + claudeInstallProgress: { + isInstalling: false, + currentStep: "", + progress: 0, + output: [], + }, + codexCliStatus: null, + codexAuthStatus: null, + codexInstallProgress: { + isInstalling: false, + currentStep: "", + progress: 0, + output: [], + }, + skipClaudeSetup: false, + skipCodexSetup: false, + }, + version: 0, + }; + + localStorage.setItem("automaker-setup", JSON.stringify(setupState)); + + // Also set up app store to show setup view + const appState = { + state: { + projects: [], + currentProject: null, + theme: "dark", + sidebarOpen: true, + apiKeys: { anthropic: "", google: "", openai: "" }, + chatSessions: [], + chatHistoryOpen: false, + maxConcurrency: 3, + isAutoModeRunning: false, + runningAutoTasks: [], + autoModeActivityLog: [], + currentView: "setup", + }, + version: 0, + }; + + localStorage.setItem("automaker-storage", JSON.stringify(appState)); + }); +} + +/** + * Set up the app to skip the setup wizard (setup already complete) + */ +export async function setupComplete(page: Page): Promise { + await page.addInitScript(() => { + // Mark setup as complete + const setupState = { + state: { + isFirstRun: false, + setupComplete: true, + currentStep: "complete", + skipClaudeSetup: false, + skipCodexSetup: false, + }, + version: 0, + }; + + localStorage.setItem("automaker-setup", JSON.stringify(setupState)); + }); +} + +/** + * Navigate to the setup view directly + */ +export async function navigateToSetup(page: Page): Promise { + await setupFirstRun(page); + await page.goto("/"); + await page.waitForLoadState("networkidle"); + await waitForElement(page, "setup-view", { timeout: 10000 }); +} + +/** + * Wait for setup view to be visible + */ +export async function waitForSetupView(page: Page): Promise { + return waitForElement(page, "setup-view", { timeout: 10000 }); +} + +/** + * Click "Get Started" button on setup welcome step + */ +export async function clickSetupGetStarted(page: Page): Promise { + const button = await getByTestId(page, "setup-start-button"); + await button.click(); +} + +/** + * Click continue on Claude setup step + */ +export async function clickClaudeContinue(page: Page): Promise { + const button = await getByTestId(page, "claude-next-button"); + await button.click(); +} + +/** + * Click continue on Codex setup step + */ +export async function clickCodexContinue(page: Page): Promise { + const button = await getByTestId(page, "codex-next-button"); + await button.click(); +} + +/** + * Click finish on setup complete step + */ +export async function clickSetupFinish(page: Page): Promise { + const button = await getByTestId(page, "setup-finish-button"); + await button.click(); +} + +/** + * Enter Anthropic API key in setup + */ +export async function enterAnthropicApiKey(page: Page, apiKey: string): Promise { + // Click "Use Anthropic API Key Instead" button + const useApiKeyButton = await getByTestId(page, "use-api-key-button"); + await useApiKeyButton.click(); + + // Enter the API key + const input = await getByTestId(page, "anthropic-api-key-input"); + await input.fill(apiKey); + + // Click save button + const saveButton = await getByTestId(page, "save-anthropic-key-button"); + await saveButton.click(); +} + +/** + * Enter OpenAI API key in setup + */ +export async function enterOpenAIApiKey(page: Page, apiKey: string): Promise { + // Click "Enter OpenAI API Key" button + const useApiKeyButton = await getByTestId(page, "use-openai-key-button"); + await useApiKeyButton.click(); + + // Enter the API key + const input = await getByTestId(page, "openai-api-key-input"); + await input.fill(apiKey); + + // Click save button + const saveButton = await getByTestId(page, "save-openai-key-button"); + await saveButton.click(); +}