fix: improve Claude CLI detection and add installation selector (#1004)

* fix: improve Claude CLI detection and add installation selector

This PR addresses the "Claude Code not found" error when starting tasks by
improving CLI path detection across all platforms.

Backend changes:
- Add cross-platform `find_claude_cli()` function in `client.py` that checks:
  - CLAUDE_CLI_PATH environment variable for user override
  - System PATH via shutil.which()
  - Homebrew paths on macOS
  - NVM paths for Node.js version manager installations
  - Platform-specific standard locations (Windows: AppData, Program Files; Unix: .local/bin)
- Pass detected `cli_path` to ClaudeAgentOptions in both `create_client()` and `create_simple_client()`
- Improve Windows .cmd/.bat file execution using proper cmd.exe flags (/d, /s, /c)
  and correct quoting for paths with spaces

Frontend changes:
- Add IPC handlers for scanning all Claude CLI installations and switching active path
- Update ClaudeCodeStatusBadge to show current CLI path and allow selection when
  multiple installations are detected
- Add `writeSettingsFile()` to settings-utils for persisting CLI path selection
- Add translation keys for new UI elements (English and French)

Closes #1001

* fix: address PR review findings for Claude CLI detection

Addresses all 8 findings from Auto Claude PR Review:

Security improvements:
- Add path sanitization (_is_secure_path) to backend CLI validation
  to prevent command injection via malicious paths
- Add isSecurePath validation in frontend IPC handler before CLI execution
- Normalize paths with path.resolve() before execution

Architecture improvements:
- Refactor scanClaudeInstallations to use getClaudeDetectionPaths() from
  cli-tool-manager.ts as single source of truth (addresses code duplication)
- Add cross-reference comments between backend _get_claude_detection_paths()
  and frontend getClaudeDetectionPaths() to keep them in sync

Bug fixes:
- Fix path display truncation to use regex /[/\\]/ for cross-platform
  compatibility (Windows uses backslashes)
- Add null check for version in UI rendering (shows "version unknown"
  instead of "vnull")
- Use DEFAULT_APP_SETTINGS merge pattern for settings persistence

Debugging improvements:
- Add error logging in validateClaudeCliAsync catch block for better
  debugging of CLI detection issues

Translation additions:
- Add "versionUnknown" key to English and French navigation.json
This commit is contained in:
Andy
2026-01-13 10:14:17 +01:00
committed by AndyMik90
parent aa9fbe9dab
commit e07a0dbdd0
13 changed files with 902 additions and 26 deletions
+299 -1
View File
@@ -17,6 +17,8 @@ import json
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:
+20 -11
View File
@@ -26,6 +26,7 @@ from pathlib import Path
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))
@@ -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
*
@@ -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<ClaudeInstallationInfo[]> {
const installations: ClaudeInstallationInfo[] = [];
const seenPaths = new Set<string>();
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<IPCResult<ClaudeInstallationList>> => {
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<IPCResult<{ path: string }>> => {
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<string, unknown>;
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');
}
+18 -1
View File
@@ -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<string, unknown> | undefined {
return undefined;
}
}
/**
* Write settings to disk.
*
* @param settings - The settings object to write
*/
export function writeSettingsFile(settings: Record<string, unknown>): 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');
}
@@ -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<ClaudeCodeInstallVersionResult>;
/**
* Get all Claude CLI installations found on the system
* Returns list of installations with paths, versions, and sources
*/
getClaudeCodeInstallations: () => Promise<ClaudeCodeInstallationsResult>;
/**
* Set the active Claude CLI path
* Updates settings and CLI tool manager cache
*/
setClaudeCodeActivePath: (cliPath: string) => Promise<ClaudeCodeSetActivePathResult>;
}
/**
@@ -96,5 +128,11 @@ export const createClaudeCodeAPI = (): ClaudeCodeAPI => ({
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_GET_VERSIONS),
installClaudeCodeVersion: (version: string): Promise<ClaudeCodeInstallVersionResult> =>
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL_VERSION, version)
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL_VERSION, version),
getClaudeCodeInstallations: (): Promise<ClaudeCodeInstallationsResult> =>
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_GET_INSTALLATIONS),
setClaudeCodeActivePath: (cliPath: string): Promise<ClaudeCodeSetActivePathResult> =>
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_SET_ACTIVE_PATH, cliPath),
});
@@ -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<string | null>(null);
// CLI path selection state
const [installations, setInstallations] = useState<ClaudeInstallationInfo[]>([]);
const [isLoadingInstallations, setIsLoadingInstallations] = useState(false);
const [installationsError, setInstallationsError] = useState<string | null>(null);
const [selectedInstallation, setSelectedInstallation] = useState<string | null>(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)
<span className="font-mono">{versionInfo.latest}</span>
</div>
)}
{versionInfo.path && (
<div className="flex justify-between items-center gap-2">
<span className="text-muted-foreground flex items-center gap-1">
<FolderOpen className="h-3 w-3" />
{t("navigation:claudeCode.path", "Path")}:
</span>
<span
className="font-mono text-[10px] truncate max-w-[140px]"
title={versionInfo.path}
>
{versionInfo.path}
</span>
</div>
)}
{lastChecked && (
<div className="flex justify-between text-muted-foreground">
<span>{t("navigation:claudeCode.lastChecked", "Last checked")}:</span>
@@ -397,7 +496,7 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
{/* Install/Update error display */}
{installError && (
<div className="text-xs p-2 bg-destructive/10 text-destructive rounded-md flex items-center gap-2">
<AlertTriangle className="h-3 w-3 flex-shrink-0" />
<AlertTriangle className="h-3 w-3 shrink-0" />
<span>{installError}</span>
</div>
)}
@@ -448,6 +547,53 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
</div>
)}
{/* CLI Installation selector - show when multiple installations are found */}
{installations.length > 1 && (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">
{t("navigation:claudeCode.switchInstallation", "Switch Installation")}
</label>
<Select
value={selectedInstallation || ""}
onValueChange={handleInstallationSelect}
disabled={isLoadingInstallations || isInstalling}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue
placeholder={
isLoadingInstallations
? t("navigation:claudeCode.loadingInstallations", "Loading installations...")
: installationsError
? t("navigation:claudeCode.failedToLoadInstallations", "Failed to load installations")
: t("navigation:claudeCode.selectInstallation", "Select installation")
}
/>
</SelectTrigger>
<SelectContent>
{installations.map((installation) => (
<SelectItem
key={installation.path}
value={installation.path}
className="text-xs"
disabled={installation.isActive}
>
<div className="flex flex-col">
<span className="font-mono text-[10px] truncate max-w-[180px]" title={installation.path}>
{/* Split on both path separators for cross-platform compatibility */}
{installation.path.split(/[/\\]/).slice(-2).join('/') || installation.path}
</span>
<span className="text-muted-foreground text-[9px]">
{installation.version ? `v${installation.version}` : t("navigation:claudeCode.versionUnknown", "version unknown")} ({installation.source})
{installation.isActive && ` - ${t("navigation:claudeCode.activeInstallation", "Active")}`}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Learn more link */}
<Button
variant="link"
@@ -533,6 +679,34 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Path change warning dialog */}
<AlertDialog open={showPathChangeWarning} onOpenChange={setShowPathChangeWarning}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("navigation:claudeCode.pathChangeWarningTitle", "Switch CLI installation?")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"navigation:claudeCode.pathChangeWarningDescription",
"Switching CLI installations will use a different Claude Code binary. Any running sessions will continue using the previous installation until restarted."
)}
<span className="block mt-2 font-mono text-xs break-all">
{selectedInstallation}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setSelectedInstallation(null)}>
{t("common:cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={performPathSwitch}>
{t("navigation:claudeCode.switchInstallationConfirm", "Switch")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Popover>
);
}
@@ -260,6 +260,24 @@ const browserMockAPI: ElectronAPI = {
success: true,
data: { command: `npm install -g @anthropic-ai/claude-code@${version}`, version }
}),
getClaudeCodeInstallations: async () => ({
success: true,
data: {
installations: [
{
path: '/usr/local/bin/claude',
version: '1.0.0',
source: 'system-path' as const,
isActive: true,
}
],
activePath: '/usr/local/bin/claude',
}
}),
setClaudeCodeActivePath: async (cliPath: string) => ({
success: true,
data: { path: cliPath }
}),
// Terminal Worktree Operations
createTerminalWorktree: async () => ({
@@ -510,6 +510,8 @@ export const IPC_CHANNELS = {
CLAUDE_CODE_INSTALL: 'claudeCode:install',
CLAUDE_CODE_GET_VERSIONS: 'claudeCode:getVersions',
CLAUDE_CODE_INSTALL_VERSION: 'claudeCode:installVersion',
CLAUDE_CODE_GET_INSTALLATIONS: 'claudeCode:getInstallations',
CLAUDE_CODE_SET_ACTIVE_PATH: 'claudeCode:setActivePath',
// MCP Server health checks
MCP_CHECK_HEALTH: 'mcp:checkHealth', // Quick connectivity check
@@ -41,6 +41,7 @@
"missing": "Not installed",
"current": "Current",
"latest": "Latest",
"path": "Path",
"lastChecked": "Last checked",
"learnMore": "Learn more about Claude Code",
"learnMoreAriaLabel": "Learn more about Claude Code (opens in new window)",
@@ -57,6 +58,15 @@
"rollbackWarningTitle": "Switch to version {{version}}?",
"rollbackWarningDescription": "Switching versions will close all running Claude Code sessions. Any unsaved work in those sessions may be lost. Make sure to save your work before proceeding.",
"switchAnyway": "Switch Anyway",
"currentVersion": "Current"
"currentVersion": "Current",
"switchInstallation": "Switch Installation",
"selectInstallation": "Select installation",
"loadingInstallations": "Loading installations...",
"failedToLoadInstallations": "Failed to load installations",
"activeInstallation": "Active",
"pathChangeWarningTitle": "Switch CLI installation?",
"pathChangeWarningDescription": "Switching CLI installations will use a different Claude Code binary. Any running sessions will continue using the previous installation until restarted.",
"switchInstallationConfirm": "Switch",
"versionUnknown": "version unknown"
}
}
@@ -41,6 +41,7 @@
"missing": "Non installé",
"current": "Actuelle",
"latest": "Dernière",
"path": "Chemin",
"lastChecked": "Dernière vérification",
"learnMore": "En savoir plus sur Claude Code",
"learnMoreAriaLabel": "En savoir plus sur Claude Code (s'ouvre dans une nouvelle fenêtre)",
@@ -57,6 +58,15 @@
"rollbackWarningTitle": "Passer à la version {{version}} ?",
"rollbackWarningDescription": "Le changement de version fermera toutes les sessions Claude Code en cours. Tout travail non sauvegardé dans ces sessions pourrait être perdu. Assurez-vous de sauvegarder votre travail avant de continuer.",
"switchAnyway": "Changer quand même",
"currentVersion": "Actuelle"
"currentVersion": "Actuelle",
"switchInstallation": "Changer d'installation",
"selectInstallation": "Sélectionner une installation",
"loadingInstallations": "Chargement des installations...",
"failedToLoadInstallations": "Échec du chargement des installations",
"activeInstallation": "Active",
"pathChangeWarningTitle": "Changer d'installation CLI ?",
"pathChangeWarningDescription": "Le changement d'installation CLI utilisera un binaire Claude Code différent. Les sessions en cours continueront à utiliser l'installation précédente jusqu'à leur redémarrage.",
"switchInstallationConfirm": "Changer",
"versionUnknown": "version inconnue"
}
}
+25
View File
@@ -49,3 +49,28 @@ export interface ClaudeCodeVersionList {
/** List of available versions, sorted newest first */
versions: string[];
}
/**
* Information about a detected Claude CLI installation
* Used for displaying available installations and allowing user selection
*/
export interface ClaudeInstallationInfo {
/** Full path to the Claude CLI executable */
path: string;
/** Version string if detected, null if validation failed */
version: string | null;
/** Source of detection (user-config, homebrew, system-path, nvm, etc.) */
source: ToolDetectionResult['source'];
/** Whether this is the currently active/configured installation */
isActive: boolean;
}
/**
* List of all detected Claude CLI installations
*/
export interface ClaudeInstallationList {
/** All detected Claude CLI installations */
installations: ClaudeInstallationInfo[];
/** Path to the currently active installation (from settings or auto-detected) */
activePath: string | null;
}
+2
View File
@@ -774,6 +774,8 @@ export interface ElectronAPI {
installClaudeCode: () => Promise<IPCResult<{ command: string }>>;
getClaudeCodeVersions: () => Promise<IPCResult<import('./cli').ClaudeCodeVersionList>>;
installClaudeCodeVersion: (version: string) => Promise<IPCResult<{ command: string; version: string }>>;
getClaudeCodeInstallations: () => Promise<IPCResult<import('./cli').ClaudeInstallationList>>;
setClaudeCodeActivePath: (cliPath: string) => Promise<IPCResult<{ path: string }>>;
// Debug operations
getDebugInfo: () => Promise<{