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:
+299
-1
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
Reference in New Issue
Block a user