refactor: remove redundant backend CLI detection (~230 lines) (#1367)

* refactor: remove redundant backend CLI detection (~230 lines)

The Claude Agent SDK bundles its own CLI, making the backend's CLI
detection code unnecessary. This removes:

- find_claude_cli() and related functions from client.py
- _validate_claude_cli() security validation
- clear_claude_cli_cache() cache management
- CLI cache variables and threading locks

Changes:
- client.py: Remove ~200 lines of CLI detection, add simple
  CLAUDE_CLI_PATH env var override for SDK
- simple_client.py: Remove find_claude_cli import/usage
- auth.py: Replace find_executable() with shutil.which()
- cli-tool-manager.ts: Update comment (no longer synced with backend)

The frontend retains CLI detection for the Terminal tab feature.
Backend agents now rely on the SDK's bundled CLI by default.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add validate_cli_path() security validation and cleanup unused imports

- Add validate_cli_path() to CLAUDE_CLI_PATH handling in client.py and simple_client.py
  to prevent command injection via shell metacharacters, directory traversal, etc.
- Add logging for CLAUDE_CLI_PATH override in simple_client.py for consistency
- Remove unused imports: shutil, subprocess, get_comspec_path from client.py
- Fix import sorting in auth.py (ruff isort)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-20 18:44:29 +01:00
committed by GitHub
parent d8f4de9a06
commit c7bc01d575
4 changed files with 21 additions and 249 deletions
+2 -3
View File
@@ -9,12 +9,11 @@ for custom API endpoints.
import json import json
import logging import logging
import os import os
import shutil
import subprocess import subprocess
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from core.platform import ( from core.platform import (
find_executable,
get_claude_detection_paths,
is_linux, is_linux,
is_macos, is_macos,
is_windows, is_windows,
@@ -239,7 +238,7 @@ def _decrypt_token_macos(encrypted_data: str) -> str:
ValueError: If decryption fails or Claude CLI not available ValueError: If decryption fails or Claude CLI not available
""" """
# Verify Claude CLI is installed (required for future decryption implementation) # Verify Claude CLI is installed (required for future decryption implementation)
if not find_executable("claude", get_claude_detection_paths()): if not shutil.which("claude"):
raise ValueError( raise ValueError(
"Claude Code CLI not found. Please install it from https://code.claude.com" "Claude Code CLI not found. Please install it from https://code.claude.com"
) )
+6 -230
View File
@@ -16,17 +16,12 @@ import copy
import json import json
import logging import logging
import os import os
import shutil
import subprocess
import threading import threading
import time import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from core.platform import ( from core.platform import (
get_claude_detection_paths_structured,
get_comspec_path,
is_macos,
is_windows, is_windows,
validate_cli_path, validate_cli_path,
) )
@@ -130,220 +125,6 @@ def invalidate_project_cache(project_dir: Path | None = None) -> None:
logger.debug(f"Invalidated project index cache for {project_dir}") 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] | str]:
"""
Get all candidate paths for Claude CLI detection.
This is a thin wrapper around the platform module's implementation.
See core/platform/__init__.py:get_claude_detection_paths_structured()
for the canonical implementation.
Returns:
Dict with 'homebrew', 'platform', and 'nvm_versions_dir' keys
"""
return get_claude_detection_paths_structured()
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 validate_cli_path(cli_path):
logger.warning(f"Rejecting insecure Claude CLI path: {cli_path}")
return False, None
try:
# 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 platform module
cmd_exe = get_comspec_path()
# 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
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 is_macos():
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 ( from agents.tools_pkg import (
CONTEXT7_TOOLS, CONTEXT7_TOOLS,
ELECTRON_TOOLS, ELECTRON_TOOLS,
@@ -1014,14 +795,6 @@ def create_client(
print(" - CLAUDE.md: disabled by project settings") print(" - CLAUDE.md: disabled by project settings")
print() 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 # Build options dict, conditionally including output_format
options_kwargs: dict[str, Any] = { options_kwargs: dict[str, Any] = {
"model": model, "model": model,
@@ -1046,9 +819,12 @@ def create_client(
"enable_file_checkpointing": True, "enable_file_checkpointing": True,
} }
# Add CLI path if found (helps SDK find Claude Code in non-standard locations) # Optional: Allow CLI path override via environment variable
if cli_path: # The SDK bundles its own CLI, but users can override if needed
options_kwargs["cli_path"] = cli_path env_cli_path = os.environ.get("CLAUDE_CLI_PATH")
if env_cli_path and validate_cli_path(env_cli_path):
options_kwargs["cli_path"] = env_cli_path
logger.info(f"Using CLAUDE_CLI_PATH override: {env_cli_path}")
# Add structured output format if specified # Add structured output format if specified
# See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs # See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
+10 -7
View File
@@ -21,6 +21,7 @@ Example usage:
client = create_simple_client(agent_type="insights", cwd=project_dir) client = create_simple_client(agent_type="insights", cwd=project_dir)
""" """
import logging
from pathlib import Path from pathlib import Path
from agents.tools_pkg import get_agent_config, get_default_thinking_level from agents.tools_pkg import get_agent_config, get_default_thinking_level
@@ -30,9 +31,11 @@ from core.auth import (
require_auth_token, require_auth_token,
validate_token_not_encrypted, validate_token_not_encrypted,
) )
from core.client import find_claude_cli from core.platform import validate_cli_path
from phase_config import get_thinking_budget from phase_config import get_thinking_budget
logger = logging.getLogger(__name__)
def create_simple_client( def create_simple_client(
agent_type: str = "merge_resolver", agent_type: str = "merge_resolver",
@@ -95,10 +98,8 @@ def create_simple_client(
thinking_level = get_default_thinking_level(agent_type) thinking_level = get_default_thinking_level(agent_type)
max_thinking_tokens = get_thinking_budget(thinking_level) max_thinking_tokens = get_thinking_budget(thinking_level)
# Find Claude CLI path (handles non-standard installations)
cli_path = find_claude_cli()
# Build options dict # Build options dict
# Note: SDK bundles its own CLI, so no cli_path detection needed
options_kwargs = { options_kwargs = {
"model": model, "model": model,
"system_prompt": system_prompt, "system_prompt": system_prompt,
@@ -112,8 +113,10 @@ def create_simple_client(
if max_thinking_tokens is not None: if max_thinking_tokens is not None:
options_kwargs["max_thinking_tokens"] = max_thinking_tokens options_kwargs["max_thinking_tokens"] = max_thinking_tokens
# Add CLI path if found # Optional: Allow CLI path override via environment variable
if cli_path: env_cli_path = os.environ.get("CLAUDE_CLI_PATH")
options_kwargs["cli_path"] = cli_path if env_cli_path and validate_cli_path(env_cli_path):
options_kwargs["cli_path"] = env_cli_path
logger.info(f"Using CLAUDE_CLI_PATH override: {env_cli_path}")
return ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs)) return ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs))
+3 -9
View File
@@ -146,15 +146,9 @@ interface ClaudeDetectionPaths {
* This pure function consolidates path configuration used by both sync * This pure function consolidates path configuration used by both sync
* and async detection methods. * and async detection methods.
* *
* IMPORTANT: This function has a corresponding implementation in the Python backend: * Note: This is the single source of truth for CLI detection paths.
* apps/backend/core/client.py (_get_claude_detection_paths) * The Python backend relies on the Claude Agent SDK's bundled CLI,
* * so it no longer needs its own path detection logic.
* 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()) * @param homeDir - User's home directory (from os.homedir())
* @returns Object containing homebrew, platform, and NVM paths * @returns Object containing homebrew, platform, and NVM paths