diff --git a/apps/backend/core/client.py b/apps/backend/core/client.py index 69c9c0e239..91f384cf80 100644 --- a/apps/backend/core/client.py +++ b/apps/backend/core/client.py @@ -17,6 +17,8 @@ import logging import os import platform +import shutil +import subprocess import threading import time from pathlib import Path @@ -121,6 +123,290 @@ def invalidate_project_cache(project_dir: Path | None = None) -> None: logger.debug(f"Invalidated project index cache for {project_dir}") +# ============================================================================= +# Claude CLI Path Detection +# ============================================================================= +# Cross-platform detection of Claude Code CLI binary. +# This mirrors the frontend's cli-tool-manager.ts logic to ensure consistency. + +_CLAUDE_CLI_CACHE: dict[str, str | None] = {} +_CLI_CACHE_LOCK = threading.Lock() + + +def _get_claude_detection_paths() -> dict[str, list[str]]: + """ + Get all candidate paths for Claude CLI detection. + + Returns platform-specific paths where Claude CLI might be installed. + + IMPORTANT: This function mirrors the frontend's getClaudeDetectionPaths() + in apps/frontend/src/main/cli-tool-manager.ts. Both implementations MUST + be kept in sync to ensure consistent detection behavior across the + Python backend and Electron frontend. + + When adding new detection paths, update BOTH: + 1. This function (_get_claude_detection_paths in client.py) + 2. getClaudeDetectionPaths() in cli-tool-manager.ts + + Returns: + Dict with 'homebrew', 'platform', and 'nvm' path lists + """ + home_dir = Path.home() + is_windows = platform.system() == "Windows" + + homebrew_paths = [ + "/opt/homebrew/bin/claude", # Apple Silicon + "/usr/local/bin/claude", # Intel Mac + ] + + if is_windows: + platform_paths = [ + str(home_dir / "AppData" / "Local" / "Programs" / "claude" / "claude.exe"), + str(home_dir / "AppData" / "Roaming" / "npm" / "claude.cmd"), + str(home_dir / ".local" / "bin" / "claude.exe"), + "C:\\Program Files\\Claude\\claude.exe", + "C:\\Program Files (x86)\\Claude\\claude.exe", + ] + else: + platform_paths = [ + str(home_dir / ".local" / "bin" / "claude"), + str(home_dir / "bin" / "claude"), + ] + + nvm_versions_dir = str(home_dir / ".nvm" / "versions" / "node") + + return { + "homebrew": homebrew_paths, + "platform": platform_paths, + "nvm_versions_dir": nvm_versions_dir, + } + + +def _is_secure_path(path_str: str) -> bool: + """ + Validate that a path doesn't contain dangerous characters. + + Prevents command injection attacks by rejecting paths with shell metacharacters, + directory traversal patterns, or environment variable expansion. + + Args: + path_str: Path to validate + + Returns: + True if the path is safe, False otherwise + """ + import re + + dangerous_patterns = [ + r'[;&|`${}[\]<>!"^]', # Shell metacharacters + r"%[^%]+%", # Windows environment variable expansion + r"\.\./", # Unix directory traversal + r"\.\.\\", # Windows directory traversal + r"[\r\n]", # Newlines (command injection) + ] + + for pattern in dangerous_patterns: + if re.search(pattern, path_str): + return False + + return True + + +def _validate_claude_cli(cli_path: str) -> tuple[bool, str | None]: + """ + Validate that a Claude CLI path is executable and returns a version. + + Includes security validation to prevent command injection attacks. + + Args: + cli_path: Path to the Claude CLI executable + + Returns: + Tuple of (is_valid, version_string or None) + + Note: + Cross-references with frontend's validateClaudeCliAsync() in + apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts + Both should be kept in sync for consistent behavior. + """ + import re + + # Security validation: reject paths with shell metacharacters or directory traversal + if not _is_secure_path(cli_path): + logger.warning(f"Rejecting insecure Claude CLI path: {cli_path}") + return False, None + + try: + is_windows = platform.system() == "Windows" + + # Augment PATH with the CLI directory for proper resolution + env = os.environ.copy() + cli_dir = os.path.dirname(cli_path) + if cli_dir: + env["PATH"] = cli_dir + os.pathsep + env.get("PATH", "") + + # For Windows .cmd/.bat files, use cmd.exe with proper quoting + # /d = disable AutoRun registry commands + # /s = strip first and last quotes, preserving inner quotes + # /c = run command then terminate + if is_windows and cli_path.lower().endswith((".cmd", ".bat")): + # Get cmd.exe path from environment or use default + cmd_exe = os.environ.get("ComSpec") or os.path.join( + os.environ.get("SystemRoot", "C:\\Windows"), "System32", "cmd.exe" + ) + # Use double-quoted command line for paths with spaces + cmd_line = f'""{cli_path}" --version"' + result = subprocess.run( + [cmd_exe, "/d", "/s", "/c", cmd_line], + capture_output=True, + text=True, + timeout=5, + env=env, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + else: + result = subprocess.run( + [cli_path, "--version"], + capture_output=True, + text=True, + timeout=5, + env=env, + creationflags=subprocess.CREATE_NO_WINDOW if is_windows else 0, + ) + + if result.returncode == 0: + # Extract version from output (e.g., "claude-code version 1.0.0") + output = result.stdout.strip() + match = re.search(r"(\d+\.\d+\.\d+)", output) + version = match.group(1) if match else output.split("\n")[0] + return True, version + + return False, None + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e: + logger.debug(f"Claude CLI validation failed for {cli_path}: {e}") + return False, None + + +def find_claude_cli() -> str | None: + """ + Find the Claude Code CLI binary path. + + Uses cross-platform detection with the following priority: + 1. CLAUDE_CLI_PATH environment variable (user override) + 2. shutil.which() - system PATH lookup + 3. Homebrew paths (macOS) + 4. NVM paths (Unix - checks Node.js version manager) + 5. Platform-specific standard locations + + Returns: + Path to Claude CLI if found and valid, None otherwise + """ + # Check cache first + cache_key = "claude_cli" + with _CLI_CACHE_LOCK: + if cache_key in _CLAUDE_CLI_CACHE: + cached = _CLAUDE_CLI_CACHE[cache_key] + logger.debug(f"Using cached Claude CLI path: {cached}") + return cached + + is_windows = platform.system() == "Windows" + paths = _get_claude_detection_paths() + + # 1. Check environment variable override + env_path = os.environ.get("CLAUDE_CLI_PATH") + if env_path: + if Path(env_path).exists(): + valid, version = _validate_claude_cli(env_path) + if valid: + logger.info(f"Using CLAUDE_CLI_PATH: {env_path} (v{version})") + with _CLI_CACHE_LOCK: + _CLAUDE_CLI_CACHE[cache_key] = env_path + return env_path + logger.warning(f"CLAUDE_CLI_PATH is set but invalid: {env_path}") + + # 2. Try shutil.which() - most reliable cross-platform PATH lookup + which_path = shutil.which("claude") + if which_path: + valid, version = _validate_claude_cli(which_path) + if valid: + logger.info(f"Found Claude CLI in PATH: {which_path} (v{version})") + with _CLI_CACHE_LOCK: + _CLAUDE_CLI_CACHE[cache_key] = which_path + return which_path + + # 3. Homebrew paths (macOS) + if platform.system() == "Darwin": + for hb_path in paths["homebrew"]: + if Path(hb_path).exists(): + valid, version = _validate_claude_cli(hb_path) + if valid: + logger.info(f"Found Claude CLI (Homebrew): {hb_path} (v{version})") + with _CLI_CACHE_LOCK: + _CLAUDE_CLI_CACHE[cache_key] = hb_path + return hb_path + + # 4. NVM paths (Unix only) - check Node.js version manager installations + if not is_windows: + nvm_dir = Path(paths["nvm_versions_dir"]) + if nvm_dir.exists(): + try: + # Get all version directories and sort by version (newest first) + version_dirs = [] + for entry in nvm_dir.iterdir(): + if entry.is_dir() and entry.name.startswith("v"): + # Parse version: v20.0.0 -> (20, 0, 0) + try: + parts = entry.name[1:].split(".") + if len(parts) == 3: + version_dirs.append( + (tuple(int(p) for p in parts), entry.name) + ) + except ValueError: + continue + + # Sort by version descending (newest first) + version_dirs.sort(reverse=True) + + for _, version_name in version_dirs: + nvm_claude = nvm_dir / version_name / "bin" / "claude" + if nvm_claude.exists(): + valid, version = _validate_claude_cli(str(nvm_claude)) + if valid: + logger.info( + f"Found Claude CLI (NVM): {nvm_claude} (v{version})" + ) + with _CLI_CACHE_LOCK: + _CLAUDE_CLI_CACHE[cache_key] = str(nvm_claude) + return str(nvm_claude) + except OSError as e: + logger.debug(f"Error scanning NVM directory: {e}") + + # 5. Platform-specific standard locations + for plat_path in paths["platform"]: + if Path(plat_path).exists(): + valid, version = _validate_claude_cli(plat_path) + if valid: + logger.info(f"Found Claude CLI: {plat_path} (v{version})") + with _CLI_CACHE_LOCK: + _CLAUDE_CLI_CACHE[cache_key] = plat_path + return plat_path + + # Not found + logger.warning( + "Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code" + ) + with _CLI_CACHE_LOCK: + _CLAUDE_CLI_CACHE[cache_key] = None + return None + + +def clear_claude_cli_cache() -> None: + """Clear the Claude CLI path cache, forcing re-detection on next call.""" + with _CLI_CACHE_LOCK: + _CLAUDE_CLI_CACHE.clear() + logger.debug("Claude CLI cache cleared") + + from agents.tools_pkg import ( CONTEXT7_TOOLS, ELECTRON_TOOLS, @@ -780,8 +1066,16 @@ def create_client( print(" - CLAUDE.md: disabled by project settings") print() + # Find Claude CLI path for SDK + # This ensures the SDK can find the Claude Code binary even if it's not in PATH + cli_path = find_claude_cli() + if cli_path: + print(f" - Claude CLI: {cli_path}") + else: + print(" - Claude CLI: using SDK default detection") + # Build options dict, conditionally including output_format - options_kwargs = { + options_kwargs: dict[str, Any] = { "model": model, "system_prompt": base_prompt, "allowed_tools": allowed_tools_list, @@ -804,6 +1098,10 @@ def create_client( "enable_file_checkpointing": True, } + # Add CLI path if found (helps SDK find Claude Code in non-standard locations) + if cli_path: + options_kwargs["cli_path"] = cli_path + # Add structured output format if specified # See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs if output_format: diff --git a/apps/backend/core/simple_client.py b/apps/backend/core/simple_client.py index 9d910aadbd..4245ba1a2d 100644 --- a/apps/backend/core/simple_client.py +++ b/apps/backend/core/simple_client.py @@ -26,6 +26,7 @@ from agents.tools_pkg import get_agent_config, get_default_thinking_level from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient from core.auth import get_sdk_env_vars, require_auth_token +from core.client import find_claude_cli from phase_config import get_thinking_budget @@ -84,14 +85,22 @@ def create_simple_client( thinking_level = get_default_thinking_level(agent_type) max_thinking_tokens = get_thinking_budget(thinking_level) - return ClaudeSDKClient( - options=ClaudeAgentOptions( - model=model, - system_prompt=system_prompt, - allowed_tools=allowed_tools, - max_turns=max_turns, - cwd=str(cwd.resolve()) if cwd else None, - env=sdk_env, - max_thinking_tokens=max_thinking_tokens, - ) - ) + # Find Claude CLI path (handles non-standard installations) + cli_path = find_claude_cli() + + # Build options dict + options_kwargs = { + "model": model, + "system_prompt": system_prompt, + "allowed_tools": allowed_tools, + "max_turns": max_turns, + "cwd": str(cwd.resolve()) if cwd else None, + "env": sdk_env, + "max_thinking_tokens": max_thinking_tokens, + } + + # Add CLI path if found + if cli_path: + options_kwargs["cli_path"] = cli_path + + return ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs)) diff --git a/apps/frontend/src/main/cli-tool-manager.ts b/apps/frontend/src/main/cli-tool-manager.ts index 82cdc465ff..6ecc8f8cc9 100644 --- a/apps/frontend/src/main/cli-tool-manager.ts +++ b/apps/frontend/src/main/cli-tool-manager.ts @@ -138,6 +138,16 @@ interface ClaudeDetectionPaths { * This pure function consolidates path configuration used by both sync * and async detection methods. * + * IMPORTANT: This function has a corresponding implementation in the Python backend: + * apps/backend/core/client.py (_get_claude_detection_paths) + * + * Both implementations MUST be kept in sync to ensure consistent detection behavior + * across the Electron frontend and Python backend. + * + * When adding new detection paths, update BOTH: + * 1. This function (getClaudeDetectionPaths in cli-tool-manager.ts) + * 2. _get_claude_detection_paths() in client.py + * * @param homeDir - User's home directory (from os.homedir()) * @returns Object containing homebrew, platform, and NVM paths * diff --git a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts index a3d2cd4fd3..28f912d6c0 100644 --- a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts @@ -7,23 +7,200 @@ * - Open terminal with installation command */ -import { ipcMain, shell } from 'electron'; -import { execFileSync, spawn } from 'child_process'; -import { existsSync, statSync } from 'fs'; +import { ipcMain } from 'electron'; +import { execFileSync, spawn, execFile } from 'child_process'; +import { existsSync, promises as fsPromises } from 'fs'; import path from 'path'; -import { IPC_CHANNELS } from '../../shared/constants/ipc'; +import os from 'os'; +import { promisify } from 'util'; +import { IPC_CHANNELS, DEFAULT_APP_SETTINGS } from '../../shared/constants'; import type { IPCResult } from '../../shared/types'; -import type { ClaudeCodeVersionInfo } from '../../shared/types/cli'; -import { getToolInfo } from '../cli-tool-manager'; -import { readSettingsFile } from '../settings-utils'; +import type { ClaudeCodeVersionInfo, ClaudeInstallationList, ClaudeInstallationInfo } from '../../shared/types/cli'; +import { getToolInfo, configureTools, sortNvmVersionDirs, getClaudeDetectionPaths } from '../cli-tool-manager'; +import { readSettingsFile, writeSettingsFile } from '../settings-utils'; +import { isSecurePath } from '../utils/windows-paths'; import semver from 'semver'; +const execFileAsync = promisify(execFile); + // Cache for latest version (avoid hammering npm registry) let cachedLatestVersion: { version: string; timestamp: number } | null = null; let cachedVersionList: { versions: string[]; timestamp: number } | null = null; const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours const VERSION_LIST_CACHE_DURATION_MS = 60 * 60 * 1000; // 1 hour for version list +/** + * Validate a Claude CLI path and get its version + * @param cliPath - Path to the Claude CLI executable + * @returns Tuple of [isValid, version or null] + */ +async function validateClaudeCliAsync(cliPath: string): Promise<[boolean, string | null]> { + try { + const isWindows = process.platform === 'win32'; + + // Augment PATH with the CLI directory for proper resolution + const cliDir = path.dirname(cliPath); + const env = { + ...process.env, + PATH: cliDir ? `${cliDir}${path.delimiter}${process.env.PATH || ''}` : process.env.PATH, + }; + + let stdout: string; + // For Windows .cmd/.bat files, use cmd.exe with proper quoting + // /d = disable AutoRun registry commands + // /s = strip first and last quotes, preserving inner quotes + // /c = run command then terminate + if (isWindows && /\.(cmd|bat)$/i.test(cliPath)) { + // Get cmd.exe path from environment or use default + const cmdExe = process.env.ComSpec + || path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'cmd.exe'); + // Use double-quoted command line for paths with spaces + const cmdLine = `""${cliPath}" --version"`; + const result = await execFileAsync(cmdExe, ['/d', '/s', '/c', cmdLine], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + env, + }); + stdout = result.stdout; + } else { + const result = await execFileAsync(cliPath, ['--version'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + env, + }); + stdout = result.stdout; + } + + const version = String(stdout).trim(); + const match = version.match(/(\d+\.\d+\.\d+)/); + return [true, match ? match[1] : version.split('\n')[0]]; + } catch (error) { + // Log validation errors to help debug CLI detection issues + console.warn('[Claude Code] CLI validation failed for', cliPath, ':', error); + return [false, null]; + } +} + +/** + * Scan all known locations for Claude CLI installations. + * Returns all found installations with their paths, versions, and sources. + * + * Uses getClaudeDetectionPaths() from cli-tool-manager.ts as the single source + * of truth for detection paths to avoid duplication and ensure consistency. + * + * @see cli-tool-manager.ts getClaudeDetectionPaths() for path configuration + */ +async function scanClaudeInstallations(activePath: string | null): Promise { + const installations: ClaudeInstallationInfo[] = []; + const seenPaths = new Set(); + const homeDir = os.homedir(); + const isWindows = process.platform === 'win32'; + + // Get detection paths from cli-tool-manager (single source of truth) + const detectionPaths = getClaudeDetectionPaths(homeDir); + + const addInstallation = async ( + cliPath: string, + source: ClaudeInstallationInfo['source'] + ) => { + // Normalize path for comparison + const normalizedPath = path.resolve(cliPath); + if (seenPaths.has(normalizedPath)) return; + + if (!existsSync(cliPath)) return; + + // Security validation: reject paths with shell metacharacters or directory traversal + if (!isSecurePath(cliPath)) { + console.warn('[Claude Code] Rejecting insecure path:', cliPath); + return; + } + + const [isValid, version] = await validateClaudeCliAsync(cliPath); + if (!isValid) return; + + seenPaths.add(normalizedPath); + installations.push({ + path: normalizedPath, + version, + source, + isActive: activePath ? path.resolve(activePath) === normalizedPath : false, + }); + }; + + // 1. Check user-configured path first (if set) + if (activePath && existsSync(activePath)) { + await addInstallation(activePath, 'user-config'); + } + + // 2. Check system PATH via which/where + try { + if (isWindows) { + const result = await execFileAsync('where', ['claude'], { timeout: 5000 }); + const paths = result.stdout.trim().split('\n').filter(p => p.trim()); + for (const p of paths) { + await addInstallation(p.trim(), 'system-path'); + } + } else { + const result = await execFileAsync('which', ['-a', 'claude'], { timeout: 5000 }); + const paths = result.stdout.trim().split('\n').filter(p => p.trim()); + for (const p of paths) { + await addInstallation(p.trim(), 'system-path'); + } + } + } catch { + // which/where failed, continue with other methods + } + + // 3. Homebrew paths (macOS) - from getClaudeDetectionPaths + if (process.platform === 'darwin') { + for (const p of detectionPaths.homebrewPaths) { + await addInstallation(p, 'homebrew'); + } + } + + // 4. NVM paths (Unix) - check Node.js version manager + if (!isWindows && existsSync(detectionPaths.nvmVersionsDir)) { + try { + const entries = await fsPromises.readdir(detectionPaths.nvmVersionsDir, { withFileTypes: true }); + const versionDirs = sortNvmVersionDirs(entries); + for (const versionName of versionDirs) { + const nvmClaudePath = path.join(detectionPaths.nvmVersionsDir, versionName, 'bin', 'claude'); + await addInstallation(nvmClaudePath, 'nvm'); + } + } catch { + // Failed to read NVM directory + } + } + + // 5. Platform-specific standard locations - from getClaudeDetectionPaths + for (const p of detectionPaths.platformPaths) { + await addInstallation(p, 'system-path'); + } + + // 6. Additional common paths not in getClaudeDetectionPaths (for broader scanning) + const additionalPaths = isWindows + ? [] // Windows paths are well covered by detectionPaths.platformPaths + : [ + path.join(homeDir, '.npm-global', 'bin', 'claude'), + path.join(homeDir, '.yarn', 'bin', 'claude'), + path.join(homeDir, '.claude', 'local', 'claude'), + path.join(homeDir, 'node_modules', '.bin', 'claude'), + ]; + + for (const p of additionalPaths) { + await addInstallation(p, 'system-path'); + } + + // Mark the first installation as active if none is explicitly active + if (installations.length > 0 && !installations.some(i => i.isActive)) { + installations[0].isActive = true; + } + + return installations; +} + /** * Fetch the latest version of Claude Code from npm registry */ @@ -751,5 +928,91 @@ export function registerClaudeCodeHandlers(): void { } ); + // Get all Claude CLI installations found on the system + ipcMain.handle( + IPC_CHANNELS.CLAUDE_CODE_GET_INSTALLATIONS, + async (): Promise> => { + try { + console.log('[Claude Code] Scanning for installations...'); + + // Get current active path from settings + const settings = readSettingsFile(); + const activePath = settings?.claudePath as string | undefined; + + const installations = await scanClaudeInstallations(activePath || null); + console.log('[Claude Code] Found', installations.length, 'installations'); + + return { + success: true, + data: { + installations, + activePath: activePath || (installations.length > 0 ? installations[0].path : null), + }, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Claude Code] Failed to scan installations:', errorMsg, error); + return { + success: false, + error: `Failed to scan Claude CLI installations: ${errorMsg}`, + }; + } + } + ); + + // Set the active Claude CLI path + ipcMain.handle( + IPC_CHANNELS.CLAUDE_CODE_SET_ACTIVE_PATH, + async (_event, cliPath: string): Promise> => { + try { + console.log('[Claude Code] Setting active path:', cliPath); + + // Security validation: reject paths with shell metacharacters or directory traversal + if (!isSecurePath(cliPath)) { + throw new Error('Invalid path: contains potentially unsafe characters'); + } + + // Normalize path to prevent directory traversal + const normalizedPath = path.resolve(cliPath); + + // Validate the path exists and is executable + if (!existsSync(normalizedPath)) { + throw new Error('Claude CLI not found at specified path'); + } + + const [isValid, version] = await validateClaudeCliAsync(normalizedPath); + if (!isValid) { + throw new Error('Claude CLI at specified path is not valid or not executable'); + } + + // Save to settings using established pattern: merge with DEFAULT_APP_SETTINGS + const currentSettings = readSettingsFile() || {}; + const mergedSettings = { + ...DEFAULT_APP_SETTINGS, + ...currentSettings, + claudePath: normalizedPath, + } as Record; + writeSettingsFile(mergedSettings); + + // Update CLI tool manager cache + configureTools({ claudePath: normalizedPath }); + + console.log('[Claude Code] Active path set:', normalizedPath, 'version:', version); + + return { + success: true, + data: { path: normalizedPath }, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Claude Code] Failed to set active path:', errorMsg, error); + return { + success: false, + error: `Failed to set active Claude CLI path: ${errorMsg}`, + }; + } + } + ); + console.warn('[IPC] Claude Code handlers registered'); } diff --git a/apps/frontend/src/main/settings-utils.ts b/apps/frontend/src/main/settings-utils.ts index 923658ff34..96c07beb5f 100644 --- a/apps/frontend/src/main/settings-utils.ts +++ b/apps/frontend/src/main/settings-utils.ts @@ -9,7 +9,7 @@ */ import { app } from 'electron'; -import { existsSync, readFileSync } from 'fs'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import path from 'path'; /** @@ -41,3 +41,20 @@ export function readSettingsFile(): Record | undefined { return undefined; } } + +/** + * Write settings to disk. + * + * @param settings - The settings object to write + */ +export function writeSettingsFile(settings: Record): void { + const settingsPath = getSettingsPath(); + + // Ensure the directory exists + const dir = path.dirname(settingsPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); +} diff --git a/apps/frontend/src/preload/api/modules/claude-code-api.ts b/apps/frontend/src/preload/api/modules/claude-code-api.ts index b5c9359099..a9a4c63346 100644 --- a/apps/frontend/src/preload/api/modules/claude-code-api.ts +++ b/apps/frontend/src/preload/api/modules/claude-code-api.ts @@ -9,7 +9,7 @@ */ import { IPC_CHANNELS } from '../../../shared/constants'; -import type { ClaudeCodeVersionInfo, ClaudeCodeVersionList } from '../../../shared/types/cli'; +import type { ClaudeCodeVersionInfo, ClaudeCodeVersionList, ClaudeInstallationList } from '../../../shared/types/cli'; import { invokeIpc } from './ipc-utils'; /** @@ -53,6 +53,26 @@ export interface ClaudeCodeInstallVersionResult { error?: string; } +/** + * Result of getting installations + */ +export interface ClaudeCodeInstallationsResult { + success: boolean; + data?: ClaudeInstallationList; + error?: string; +} + +/** + * Result of setting active path + */ +export interface ClaudeCodeSetActivePathResult { + success: boolean; + data?: { + path: string; + }; + error?: string; +} + /** * Claude Code API interface exposed to renderer */ @@ -80,6 +100,18 @@ export interface ClaudeCodeAPI { * Opens the user's terminal with the install command for the specified version */ installClaudeCodeVersion: (version: string) => Promise; + + /** + * Get all Claude CLI installations found on the system + * Returns list of installations with paths, versions, and sources + */ + getClaudeCodeInstallations: () => Promise; + + /** + * Set the active Claude CLI path + * Updates settings and CLI tool manager cache + */ + setClaudeCodeActivePath: (cliPath: string) => Promise; } /** @@ -96,5 +128,11 @@ export const createClaudeCodeAPI = (): ClaudeCodeAPI => ({ invokeIpc(IPC_CHANNELS.CLAUDE_CODE_GET_VERSIONS), installClaudeCodeVersion: (version: string): Promise => - invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL_VERSION, version) + invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL_VERSION, version), + + getClaudeCodeInstallations: (): Promise => + invokeIpc(IPC_CHANNELS.CLAUDE_CODE_GET_INSTALLATIONS), + + setClaudeCodeActivePath: (cliPath: string): Promise => + invokeIpc(IPC_CHANNELS.CLAUDE_CODE_SET_ACTIVE_PATH, cliPath), }); diff --git a/apps/frontend/src/renderer/components/ClaudeCodeStatusBadge.tsx b/apps/frontend/src/renderer/components/ClaudeCodeStatusBadge.tsx index 69fe2066e7..a46010d698 100644 --- a/apps/frontend/src/renderer/components/ClaudeCodeStatusBadge.tsx +++ b/apps/frontend/src/renderer/components/ClaudeCodeStatusBadge.tsx @@ -9,6 +9,7 @@ import { Download, RefreshCw, ExternalLink, + FolderOpen, } from "lucide-react"; import { Button } from "./ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; @@ -31,7 +32,7 @@ import { SelectValue, } from "./ui/select"; import { cn } from "../lib/utils"; -import type { ClaudeCodeVersionInfo } from "../../shared/types/cli"; +import type { ClaudeCodeVersionInfo, ClaudeInstallationInfo } from "../../shared/types/cli"; interface ClaudeCodeStatusBadgeProps { className?: string; @@ -65,6 +66,13 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps) const [showRollbackWarning, setShowRollbackWarning] = useState(false); const [installError, setInstallError] = useState(null); + // CLI path selection state + const [installations, setInstallations] = useState([]); + const [isLoadingInstallations, setIsLoadingInstallations] = useState(false); + const [installationsError, setInstallationsError] = useState(null); + const [selectedInstallation, setSelectedInstallation] = useState(null); + const [showPathChangeWarning, setShowPathChangeWarning] = useState(false); + // Check Claude Code version const checkVersion = useCallback(async () => { try { @@ -119,6 +127,30 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps) } }, []); + // Fetch CLI installations + const fetchInstallations = useCallback(async () => { + if (!window.electronAPI?.getClaudeCodeInstallations) { + return; + } + + setIsLoadingInstallations(true); + setInstallationsError(null); + + try { + const result = await window.electronAPI.getClaudeCodeInstallations(); + if (result.success && result.data) { + setInstallations(result.data.installations); + } else { + setInstallationsError(result.error || "Failed to load installations"); + } + } catch (err) { + console.error("Failed to fetch installations:", err); + setInstallationsError("Failed to load installations"); + } finally { + setIsLoadingInstallations(false); + } + }, []); + // Initial check and periodic re-check useEffect(() => { checkVersion(); @@ -138,6 +170,13 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps) } }, [isOpen, versionInfo?.installed, availableVersions.length, fetchVersions]); + // Fetch installations when popover opens + useEffect(() => { + if (isOpen && installations.length === 0) { + fetchInstallations(); + } + }, [isOpen, installations.length, fetchInstallations]); + // Perform the actual install/update const performInstall = async () => { setIsInstalling(true); @@ -200,6 +239,40 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps) } }; + // Perform CLI path switch + const performPathSwitch = async () => { + if (!selectedInstallation) return; + + setIsInstalling(true); + setShowPathChangeWarning(false); + setInstallError(null); + + try { + if (!window.electronAPI?.setClaudeCodeActivePath) { + setInstallError("Path switching not available"); + return; + } + + const result = await window.electronAPI.setClaudeCodeActivePath(selectedInstallation); + + if (result.success) { + // Re-check version and refresh installations + setTimeout(() => { + checkVersion(); + fetchInstallations(); + }, VERSION_RECHECK_DELAY_MS); + } else { + setInstallError(result.error || "Failed to switch CLI path"); + } + } catch (err) { + console.error("Failed to switch Claude CLI path:", err); + setInstallError(err instanceof Error ? err.message : "Failed to switch CLI path"); + } finally { + setIsInstalling(false); + setSelectedInstallation(null); + } + }; + // Handle install/update button click const handleInstall = () => { if (status === "outdated") { @@ -211,6 +284,18 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps) } }; + // Handle installation selection + const handleInstallationSelect = (cliPath: string) => { + // Don't do anything if it's the currently active installation + const installation = installations.find(i => i.path === cliPath); + if (installation?.isActive) { + return; + } + setInstallError(null); + setSelectedInstallation(cliPath); + setShowPathChangeWarning(true); + }; + // Normalize version string by removing 'v' prefix for comparison const normalizeVersion = (v: string) => v.replace(/^v/, ''); @@ -354,6 +439,20 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps) {versionInfo.latest} )} + {versionInfo.path && ( +
+ + + {t("navigation:claudeCode.path", "Path")}: + + + {versionInfo.path} + +
+ )} {lastChecked && (
{t("navigation:claudeCode.lastChecked", "Last checked")}: @@ -397,7 +496,7 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps) {/* Install/Update error display */} {installError && (
- + {installError}
)} @@ -448,6 +547,53 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
)} + {/* CLI Installation selector - show when multiple installations are found */} + {installations.length > 1 && ( +
+ + +
+ )} + {/* Learn more link */}