""" System validation for SuperClaude installation requirements """ import subprocess import sys import shutil from typing import Tuple, List, Dict, Any, Optional from pathlib import Path import re from ..utils.paths import get_home_directory # Handle packaging import - if not available, use a simple version comparison try: from packaging import version PACKAGING_AVAILABLE = True except ImportError: PACKAGING_AVAILABLE = False class SimpleVersion: def __init__(self, version_str: str): self.version_str = version_str # Simple version parsing: split by dots and convert to integers try: self.parts = [int(x) for x in version_str.split('.')] except ValueError: self.parts = [0, 0, 0] def __lt__(self, other): if isinstance(other, str): other = SimpleVersion(other) # Pad with zeros to same length max_len = max(len(self.parts), len(other.parts)) self_parts = self.parts + [0] * (max_len - len(self.parts)) other_parts = other.parts + [0] * (max_len - len(other.parts)) return self_parts < other_parts def __gt__(self, other): if isinstance(other, str): other = SimpleVersion(other) return not (self < other) and not (self == other) def __eq__(self, other): if isinstance(other, str): other = SimpleVersion(other) return self.parts == other.parts class version: @staticmethod def parse(version_str: str): return SimpleVersion(version_str) class Validator: """System requirements validator""" def __init__(self): """Initialize validator""" self.validation_cache: Dict[str, Any] = {} def check_python(self, min_version: str = "3.8", max_version: Optional[str] = None) -> Tuple[bool, str]: """ Check Python version requirements Args: min_version: Minimum required Python version max_version: Maximum supported Python version (optional) Returns: Tuple of (success: bool, message: str) """ cache_key = f"python_{min_version}_{max_version}" if cache_key in self.validation_cache: return self.validation_cache[cache_key] try: # Get current Python version current_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" # Check minimum version if version.parse(current_version) < version.parse(min_version): help_msg = self.get_installation_help("python") result = (False, f"Python {min_version}+ required, found {current_version}{help_msg}") self.validation_cache[cache_key] = result return result # Check maximum version if specified if max_version and version.parse(current_version) > version.parse(max_version): result = (False, f"Python version {current_version} exceeds maximum supported {max_version}") self.validation_cache[cache_key] = result return result result = (True, f"Python {current_version} meets requirements") self.validation_cache[cache_key] = result return result except Exception as e: result = (False, f"Could not check Python version: {e}") self.validation_cache[cache_key] = result return result def check_node(self, min_version: str = "16.0", max_version: Optional[str] = None) -> Tuple[bool, str]: """ Check Node.js version requirements Args: min_version: Minimum required Node.js version max_version: Maximum supported Node.js version (optional) Returns: Tuple of (success: bool, message: str) """ cache_key = f"node_{min_version}_{max_version}" if cache_key in self.validation_cache: return self.validation_cache[cache_key] try: # Check if node is installed - use shell=True on Windows for better PATH resolution result = subprocess.run( ['node', '--version'], capture_output=True, text=True, timeout=10, shell=(sys.platform == "win32") ) if result.returncode != 0: help_msg = self.get_installation_help("node") result_tuple = (False, f"Node.js not found in PATH{help_msg}") self.validation_cache[cache_key] = result_tuple return result_tuple # Parse version (format: v18.17.0) version_output = result.stdout.strip() if version_output.startswith('v'): current_version = version_output[1:] else: current_version = version_output # Check minimum version if version.parse(current_version) < version.parse(min_version): help_msg = self.get_installation_help("node") result_tuple = (False, f"Node.js {min_version}+ required, found {current_version}{help_msg}") self.validation_cache[cache_key] = result_tuple return result_tuple # Check maximum version if specified if max_version and version.parse(current_version) > version.parse(max_version): result_tuple = (False, f"Node.js version {current_version} exceeds maximum supported {max_version}") self.validation_cache[cache_key] = result_tuple return result_tuple result_tuple = (True, f"Node.js {current_version} meets requirements") self.validation_cache[cache_key] = result_tuple return result_tuple except subprocess.TimeoutExpired: result_tuple = (False, "Node.js version check timed out") self.validation_cache[cache_key] = result_tuple return result_tuple except FileNotFoundError: help_msg = self.get_installation_help("node") result_tuple = (False, f"Node.js not found in PATH{help_msg}") self.validation_cache[cache_key] = result_tuple return result_tuple except Exception as e: result_tuple = (False, f"Could not check Node.js version: {e}") self.validation_cache[cache_key] = result_tuple return result_tuple def check_claude_cli(self, min_version: Optional[str] = None) -> Tuple[bool, str]: """ Check Claude CLI installation and version Args: min_version: Minimum required Claude CLI version (optional) Returns: Tuple of (success: bool, message: str) """ cache_key = f"claude_cli_{min_version}" if cache_key in self.validation_cache: return self.validation_cache[cache_key] try: # Check if claude is installed - use shell=True on Windows for better PATH resolution result = subprocess.run( ['claude', '--version'], capture_output=True, text=True, timeout=10, shell=(sys.platform == "win32") ) if result.returncode != 0: help_msg = self.get_installation_help("claude_cli") result_tuple = (False, f"Claude CLI not found in PATH{help_msg}") self.validation_cache[cache_key] = result_tuple return result_tuple # Parse version from output version_output = result.stdout.strip() version_match = re.search(r'(\d+\.\d+\.\d+)', version_output) if not version_match: result_tuple = (True, "Claude CLI found (version format unknown)") self.validation_cache[cache_key] = result_tuple return result_tuple current_version = version_match.group(1) # Check minimum version if specified if min_version and version.parse(current_version) < version.parse(min_version): result_tuple = (False, f"Claude CLI {min_version}+ required, found {current_version}") self.validation_cache[cache_key] = result_tuple return result_tuple result_tuple = (True, f"Claude CLI {current_version} found") self.validation_cache[cache_key] = result_tuple return result_tuple except subprocess.TimeoutExpired: result_tuple = (False, "Claude CLI version check timed out") self.validation_cache[cache_key] = result_tuple return result_tuple except FileNotFoundError: help_msg = self.get_installation_help("claude_cli") result_tuple = (False, f"Claude CLI not found in PATH{help_msg}") self.validation_cache[cache_key] = result_tuple return result_tuple except Exception as e: result_tuple = (False, f"Could not check Claude CLI: {e}") self.validation_cache[cache_key] = result_tuple return result_tuple def check_external_tool(self, tool_name: str, command: str, min_version: Optional[str] = None) -> Tuple[bool, str]: """ Check external tool availability and version Args: tool_name: Display name of tool command: Command to check version min_version: Minimum required version (optional) Returns: Tuple of (success: bool, message: str) """ cache_key = f"tool_{tool_name}_{command}_{min_version}" if cache_key in self.validation_cache: return self.validation_cache[cache_key] try: # Split command into parts cmd_parts = command.split() result = subprocess.run( cmd_parts, capture_output=True, text=True, timeout=10, shell=(sys.platform == "win32") ) if result.returncode != 0: result_tuple = (False, f"{tool_name} not found or command failed") self.validation_cache[cache_key] = result_tuple return result_tuple # Extract version if min_version specified if min_version: version_output = result.stdout + result.stderr version_match = re.search(r'(\d+\.\d+(?:\.\d+)?)', version_output) if version_match: current_version = version_match.group(1) if version.parse(current_version) < version.parse(min_version): result_tuple = (False, f"{tool_name} {min_version}+ required, found {current_version}") self.validation_cache[cache_key] = result_tuple return result_tuple result_tuple = (True, f"{tool_name} {current_version} found") self.validation_cache[cache_key] = result_tuple return result_tuple else: result_tuple = (True, f"{tool_name} found (version unknown)") self.validation_cache[cache_key] = result_tuple return result_tuple else: result_tuple = (True, f"{tool_name} found") self.validation_cache[cache_key] = result_tuple return result_tuple except subprocess.TimeoutExpired: result_tuple = (False, f"{tool_name} check timed out") self.validation_cache[cache_key] = result_tuple return result_tuple except FileNotFoundError: result_tuple = (False, f"{tool_name} not found in PATH") self.validation_cache[cache_key] = result_tuple return result_tuple except Exception as e: result_tuple = (False, f"Could not check {tool_name}: {e}") self.validation_cache[cache_key] = result_tuple return result_tuple def check_disk_space(self, path: Path, required_mb: int = 500) -> Tuple[bool, str]: """ Check available disk space Args: path: Path to check (file or directory) required_mb: Required free space in MB Returns: Tuple of (success: bool, message: str) """ cache_key = f"disk_{path}_{required_mb}" if cache_key in self.validation_cache: return self.validation_cache[cache_key] try: # Get parent directory if path is a file check_path = path.parent if path.is_file() else path # Get disk usage stat_result = shutil.disk_usage(check_path) free_mb = stat_result.free / (1024 * 1024) if free_mb < required_mb: result = (False, f"Insufficient disk space: {free_mb:.1f}MB free, {required_mb}MB required") else: result = (True, f"Sufficient disk space: {free_mb:.1f}MB free") self.validation_cache[cache_key] = result return result except Exception as e: result = (False, f"Could not check disk space: {e}") self.validation_cache[cache_key] = result return result def check_write_permissions(self, path: Path) -> Tuple[bool, str]: """ Check write permissions for path Args: path: Path to check Returns: Tuple of (success: bool, message: str) """ cache_key = f"write_{path}" if cache_key in self.validation_cache: return self.validation_cache[cache_key] try: # Create parent directories if needed if not path.exists(): path.mkdir(parents=True, exist_ok=True) # Test write access test_file = path / ".write_test" test_file.touch() test_file.unlink() result = (True, f"Write access confirmed for {path}") self.validation_cache[cache_key] = result return result except Exception as e: result = (False, f"No write access to {path}: {e}") self.validation_cache[cache_key] = result return result def validate_requirements(self, requirements: Dict[str, Any]) -> Tuple[bool, List[str]]: """ Validate all system requirements Args: requirements: Requirements configuration dict Returns: Tuple of (all_passed: bool, error_messages: List[str]) """ errors = [] # Check Python requirements if "python" in requirements: python_req = requirements["python"] success, message = self.check_python( python_req["min_version"], python_req.get("max_version") ) if not success: errors.append(f"Python: {message}") # Check Node.js requirements if "node" in requirements: node_req = requirements["node"] success, message = self.check_node( node_req["min_version"], node_req.get("max_version") ) if not success: errors.append(f"Node.js: {message}") # Check disk space if "disk_space_mb" in requirements: success, message = self.check_disk_space( get_home_directory(), requirements["disk_space_mb"] ) if not success: errors.append(f"Disk space: {message}") # Check external tools if "external_tools" in requirements: for tool_name, tool_req in requirements["external_tools"].items(): # Skip optional tools that fail is_optional = tool_req.get("optional", False) success, message = self.check_external_tool( tool_name, tool_req["command"], tool_req.get("min_version") ) if not success and not is_optional: errors.append(f"{tool_name}: {message}") return len(errors) == 0, errors def validate_component_requirements(self, component_names: List[str], all_requirements: Dict[str, Any]) -> Tuple[bool, List[str]]: """ Validate requirements for specific components Args: component_names: List of component names to validate all_requirements: Full requirements configuration Returns: Tuple of (all_passed: bool, error_messages: List[str]) """ errors = [] # Start with base requirements base_requirements = { "python": all_requirements.get("python", {}), "disk_space_mb": all_requirements.get("disk_space_mb", 500) } # Add conditional requirements based on components external_tools = {} # Check if any component needs Node.js node_components = [] for component in component_names: # This would be enhanced with actual component metadata if component in ["mcp"]: # MCP component needs Node.js node_components.append(component) if node_components and "node" in all_requirements: base_requirements["node"] = all_requirements["node"] # Add external tools needed by components if "external_tools" in all_requirements: for tool_name, tool_req in all_requirements["external_tools"].items(): required_for = tool_req.get("required_for", []) # Check if any of our components need this tool if any(comp in required_for for comp in component_names): external_tools[tool_name] = tool_req if external_tools: base_requirements["external_tools"] = external_tools # Validate consolidated requirements return self.validate_requirements(base_requirements) def get_system_info(self) -> Dict[str, Any]: """ Get comprehensive system information Returns: Dict with system information """ info = { "platform": sys.platform, "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", "python_executable": sys.executable } # Add Node.js info if available node_success, node_msg = self.check_node() info["node_available"] = node_success if node_success: info["node_message"] = node_msg # Add Claude CLI info if available claude_success, claude_msg = self.check_claude_cli() info["claude_cli_available"] = claude_success if claude_success: info["claude_cli_message"] = claude_msg # Add disk space info try: home_path = get_home_directory() stat_result = shutil.disk_usage(home_path) info["disk_space"] = { "total_gb": stat_result.total / (1024**3), "free_gb": stat_result.free / (1024**3), "used_gb": (stat_result.total - stat_result.free) / (1024**3) } except Exception: info["disk_space"] = {"error": "Could not determine disk space"} return info def get_platform(self) -> str: """ Get current platform for installation commands Returns: Platform string (linux, darwin, win32) """ return sys.platform def load_installation_commands(self) -> Dict[str, Any]: """ Load installation commands from requirements configuration Returns: Installation commands dict """ try: from ..services.config import ConfigService from .. import DATA_DIR config_manager = ConfigService(DATA_DIR) requirements = config_manager.load_requirements() return requirements.get("installation_commands", {}) except Exception: return {} def get_installation_help(self, tool_name: str, platform: Optional[str] = None) -> str: """ Get installation help for a specific tool Args: tool_name: Name of tool to get help for platform: Target platform (auto-detected if None) Returns: Installation help string """ if platform is None: platform = self.get_platform() commands = self.load_installation_commands() tool_commands = commands.get(tool_name, {}) if not tool_commands: return f"No installation instructions available for {tool_name}" # Get platform-specific command or fallback to 'all' install_cmd = tool_commands.get(platform, tool_commands.get("all", "")) description = tool_commands.get("description", "") if install_cmd: help_text = f"\n💡 Installation Help for {tool_name}:\n" if description: help_text += f" {description}\n" help_text += f" Command: {install_cmd}\n" return help_text return f"No installation instructions available for {tool_name} on {platform}" def diagnose_system(self) -> Dict[str, Any]: """ Perform comprehensive system diagnostics Returns: Diagnostic information dict """ diagnostics = { "platform": self.get_platform(), "checks": {}, "issues": [], "recommendations": [] } # Check Python python_success, python_msg = self.check_python() diagnostics["checks"]["python"] = { "status": "pass" if python_success else "fail", "message": python_msg } if not python_success: diagnostics["issues"].append("Python version issue") diagnostics["recommendations"].append(self.get_installation_help("python")) # Check Node.js node_success, node_msg = self.check_node() diagnostics["checks"]["node"] = { "status": "pass" if node_success else "fail", "message": node_msg } if not node_success: diagnostics["issues"].append("Node.js not found or version issue") diagnostics["recommendations"].append(self.get_installation_help("node")) # Check Claude CLI claude_success, claude_msg = self.check_claude_cli() diagnostics["checks"]["claude_cli"] = { "status": "pass" if claude_success else "fail", "message": claude_msg } if not claude_success: diagnostics["issues"].append("Claude CLI not found") diagnostics["recommendations"].append(self.get_installation_help("claude_cli")) # Check disk space disk_success, disk_msg = self.check_disk_space(get_home_directory()) diagnostics["checks"]["disk_space"] = { "status": "pass" if disk_success else "fail", "message": disk_msg } if not disk_success: diagnostics["issues"].append("Insufficient disk space") # Check common PATH issues self._diagnose_path_issues(diagnostics) return diagnostics def _diagnose_path_issues(self, diagnostics: Dict[str, Any]) -> None: """Add PATH-related diagnostics""" path_issues = [] # Check if tools are in PATH, with alternatives for some tools tool_checks = [ # For Python, check if either python3 OR python is available (["python3", "python"], "Python (python3 or python)"), (["node"], "Node.js"), (["npm"], "npm"), (["claude"], "Claude CLI") ] for tool_alternatives, display_name in tool_checks: tool_found = False for tool in tool_alternatives: try: result = subprocess.run( ["which" if sys.platform != "win32" else "where", tool], capture_output=True, text=True, timeout=5, shell=(sys.platform == "win32") ) if result.returncode == 0: tool_found = True break except Exception: continue if not tool_found: # Only report as missing if none of the alternatives were found if len(tool_alternatives) > 1: path_issues.append(f"{display_name} not found in PATH") else: path_issues.append(f"{tool_alternatives[0]} not found in PATH") if path_issues: diagnostics["issues"].extend(path_issues) diagnostics["recommendations"].append( "\n💡 PATH Issue Help:\n" " Some tools may not be in your PATH. Try:\n" " - Restart your terminal after installation\n" " - Check your shell configuration (.bashrc, .zshrc)\n" " - Use full paths to tools if needed\n" ) def clear_cache(self) -> None: """Clear validation cache""" self.validation_cache.clear()