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(); +}