mirror of
https://github.com/SuperClaude-Org/SuperClaude_Framework.git
synced 2025-12-29 16:16:08 +00:00
Added the Setup implimentation
This commit is contained in:
18
setup/__init__.py
Normal file
18
setup/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
SuperClaude Installation Suite
|
||||
Pure Python installation system for SuperClaude framework
|
||||
"""
|
||||
|
||||
__version__ = "3.0.0"
|
||||
__author__ = "SuperClaude Team"
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Core paths
|
||||
SETUP_DIR = Path(__file__).parent
|
||||
PROJECT_ROOT = SETUP_DIR.parent
|
||||
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||
PROFILES_DIR = PROJECT_ROOT / "profiles"
|
||||
|
||||
# Installation target
|
||||
DEFAULT_INSTALL_DIR = Path.home() / ".claude"
|
||||
6
setup/base/__init__.py
Normal file
6
setup/base/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Base classes for SuperClaude installation system"""
|
||||
|
||||
from .component import Component
|
||||
from .installer import Installer
|
||||
|
||||
__all__ = ['Component', 'Installer']
|
||||
361
setup/base/component.py
Normal file
361
setup/base/component.py
Normal file
@@ -0,0 +1,361 @@
|
||||
"""
|
||||
Abstract base class for installable components
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Tuple, Optional, Any
|
||||
from pathlib import Path
|
||||
import json
|
||||
from ..managers.file_manager import FileManager
|
||||
from ..managers.settings_manager import SettingsManager
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.security import SecurityValidator
|
||||
|
||||
|
||||
class Component(ABC):
|
||||
"""Base class for all installable components"""
|
||||
|
||||
def __init__(self, install_dir: Optional[Path] = None, component_subdir: Path = Path('')):
|
||||
"""
|
||||
Initialize component with installation directory
|
||||
|
||||
Args:
|
||||
install_dir: Target installation directory (defaults to ~/.claude)
|
||||
"""
|
||||
from .. import DEFAULT_INSTALL_DIR
|
||||
self.install_dir = install_dir or DEFAULT_INSTALL_DIR
|
||||
self.settings_manager = SettingsManager(self.install_dir)
|
||||
self.logger = get_logger()
|
||||
self.component_files = self._discover_component_files()
|
||||
self.file_manager = FileManager()
|
||||
self.install_component_subdir = self.install_dir / component_subdir
|
||||
|
||||
@abstractmethod
|
||||
def get_metadata(self) -> Dict[str, str]:
|
||||
"""
|
||||
Return component metadata
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- name: Component name
|
||||
- version: Component version
|
||||
- description: Component description
|
||||
- category: Component category (core, command, integration, etc.)
|
||||
"""
|
||||
pass
|
||||
|
||||
def validate_prerequisites(self, installSubPath: Optional[Path] = None) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Check prerequisites for this component
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, error_messages: List[str])
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check if we have read access to source files
|
||||
source_dir = self._get_source_dir()
|
||||
if not source_dir or (source_dir and not source_dir.exists()):
|
||||
errors.append(f"Source directory not found: {source_dir}")
|
||||
return False, errors
|
||||
|
||||
# Check if all required framework files exist
|
||||
missing_files = []
|
||||
for filename in self.component_files:
|
||||
source_file = source_dir / filename
|
||||
if not source_file.exists():
|
||||
missing_files.append(filename)
|
||||
|
||||
if missing_files:
|
||||
errors.append(f"Missing component files: {missing_files}")
|
||||
|
||||
# Check write permissions to install directory
|
||||
has_perms, missing = SecurityValidator.check_permissions(
|
||||
self.install_dir, {'write'}
|
||||
)
|
||||
if not has_perms:
|
||||
errors.append(f"No write permissions to {self.install_dir}: {missing}")
|
||||
|
||||
# Validate installation target
|
||||
is_safe, validation_errors = SecurityValidator.validate_installation_target(self.install_component_subdir)
|
||||
if not is_safe:
|
||||
errors.extend(validation_errors)
|
||||
|
||||
# Get files to install
|
||||
files_to_install = self.get_files_to_install()
|
||||
|
||||
# Validate all files for security
|
||||
is_safe, security_errors = SecurityValidator.validate_component_files(
|
||||
files_to_install, source_dir, self.install_component_subdir
|
||||
)
|
||||
if not is_safe:
|
||||
errors.extend(security_errors)
|
||||
|
||||
if not self.file_manager.ensure_directory(self.install_component_subdir):
|
||||
errors.append(f"Could not create install directory: {self.install_component_subdir}")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def get_files_to_install(self) -> List[Tuple[Path, Path]]:
|
||||
"""
|
||||
Return list of files to install
|
||||
|
||||
Returns:
|
||||
List of tuples (source_path, target_path)
|
||||
"""
|
||||
source_dir = self._get_source_dir()
|
||||
files = []
|
||||
|
||||
if source_dir:
|
||||
for filename in self.component_files:
|
||||
source = source_dir / filename
|
||||
target = self.install_component_subdir / filename
|
||||
files.append((source, target))
|
||||
|
||||
return files
|
||||
|
||||
def get_settings_modifications(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Return settings.json modifications to apply
|
||||
(now only Claude Code compatible settings)
|
||||
|
||||
Returns:
|
||||
Dict of settings to merge into settings.json
|
||||
"""
|
||||
# Return empty dict as we don't modify Claude Code settings
|
||||
return {}
|
||||
|
||||
def install(self, config: Dict[str, Any]) -> bool:
|
||||
try:
|
||||
return self._install(config)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Unexpected error during {repr(self)} installation: {e}")
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def _install(self, config: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Perform component-specific installation logic
|
||||
|
||||
Args:
|
||||
config: Installation configuration
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
# Validate installation
|
||||
success, errors = self.validate_prerequisites()
|
||||
if not success:
|
||||
for error in errors:
|
||||
self.logger.error(error)
|
||||
return False
|
||||
|
||||
# Get files to install
|
||||
files_to_install = self.get_files_to_install()
|
||||
|
||||
# Copy framework files
|
||||
success_count = 0
|
||||
for source, target in files_to_install:
|
||||
self.logger.debug(f"Copying {source.name} to {target}")
|
||||
|
||||
if self.file_manager.copy_file(source, target):
|
||||
success_count += 1
|
||||
self.logger.debug(f"Successfully copied {source.name}")
|
||||
else:
|
||||
self.logger.error(f"Failed to copy {source.name}")
|
||||
|
||||
if success_count != len(files_to_install):
|
||||
self.logger.error(f"Only {success_count}/{len(files_to_install)} files copied successfully")
|
||||
return False
|
||||
|
||||
self.logger.success(f"{repr(self)} component installed successfully ({success_count} files)")
|
||||
|
||||
return self._post_install()
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def _post_install(self) -> bool:
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def uninstall(self) -> bool:
|
||||
"""
|
||||
Remove component
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_dependencies(self) -> List[str]:
|
||||
"""
|
||||
Return list of component dependencies
|
||||
|
||||
Returns:
|
||||
List of component names this component depends on
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _get_source_dir(self) -> Optional[Path]:
|
||||
"""Get source directory for component files"""
|
||||
pass
|
||||
|
||||
def update(self, config: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Update component (default: uninstall then install)
|
||||
|
||||
Args:
|
||||
config: Installation configuration
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
# Default implementation: uninstall and reinstall
|
||||
if self.uninstall():
|
||||
return self.install(config)
|
||||
return False
|
||||
|
||||
def get_installed_version(self) -> Optional[str]:
|
||||
"""
|
||||
Get currently installed version of component
|
||||
|
||||
Returns:
|
||||
Version string if installed, None otherwise
|
||||
"""
|
||||
print("GETTING INSTALLED VERSION")
|
||||
settings_file = self.install_dir / "settings.json"
|
||||
if settings_file.exists():
|
||||
print("SETTINGS.JSON EXISTS")
|
||||
try:
|
||||
with open(settings_file, 'r') as f:
|
||||
settings = json.load(f)
|
||||
component_name = self.get_metadata()['name']
|
||||
return settings.get('components', {}).get(component_name, {}).get('version')
|
||||
except Exception:
|
||||
pass
|
||||
print("SETTINGS.JSON DOESNT EXIST RETURNING NONE")
|
||||
return None
|
||||
|
||||
def is_installed(self) -> bool:
|
||||
"""
|
||||
Check if component is installed
|
||||
|
||||
Returns:
|
||||
True if installed, False otherwise
|
||||
"""
|
||||
return self.get_installed_version() is not None
|
||||
|
||||
def validate_installation(self) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate that component is correctly installed
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, error_messages: List[str])
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check if all files exist
|
||||
for _, target in self.get_files_to_install():
|
||||
if not target.exists():
|
||||
errors.append(f"Missing file: {target}")
|
||||
|
||||
# Check version in settings
|
||||
if not self.get_installed_version():
|
||||
errors.append("Component not registered in settings.json")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def get_size_estimate(self) -> int:
|
||||
"""
|
||||
Estimate installed size in bytes
|
||||
|
||||
Returns:
|
||||
Estimated size in bytes
|
||||
"""
|
||||
total_size = 0
|
||||
for source, _ in self.get_files_to_install():
|
||||
if source.exists():
|
||||
if source.is_file():
|
||||
total_size += source.stat().st_size
|
||||
elif source.is_dir():
|
||||
total_size += sum(f.stat().st_size for f in source.rglob('*') if f.is_file())
|
||||
return total_size
|
||||
|
||||
def _discover_component_files(self) -> List[str]:
|
||||
"""
|
||||
Dynamically discover framework .md files in the Core directory
|
||||
|
||||
Returns:
|
||||
List of framework filenames (e.g., ['CLAUDE.md', 'COMMANDS.md', ...])
|
||||
"""
|
||||
source_dir = self._get_source_dir()
|
||||
|
||||
if not source_dir:
|
||||
return []
|
||||
|
||||
return self._discover_files_in_directory(
|
||||
source_dir,
|
||||
extension='.md',
|
||||
exclude_patterns=['README.md', 'CHANGELOG.md', 'LICENSE.md']
|
||||
)
|
||||
|
||||
def _discover_files_in_directory(self, directory: Path, extension: str = '.md',
|
||||
exclude_patterns: Optional[List[str]] = None) -> List[str]:
|
||||
"""
|
||||
Shared utility for discovering files in a directory
|
||||
|
||||
Args:
|
||||
directory: Directory to scan
|
||||
extension: File extension to look for (default: '.md')
|
||||
exclude_patterns: List of filename patterns to exclude
|
||||
|
||||
Returns:
|
||||
List of filenames found in the directory
|
||||
"""
|
||||
if exclude_patterns is None:
|
||||
exclude_patterns = []
|
||||
|
||||
try:
|
||||
if not directory.exists():
|
||||
self.logger.warning(f"Source directory not found: {directory}")
|
||||
return []
|
||||
|
||||
if not directory.is_dir():
|
||||
self.logger.warning(f"Source path is not a directory: {directory}")
|
||||
return []
|
||||
|
||||
# Discover files with the specified extension
|
||||
files = []
|
||||
for file_path in directory.iterdir():
|
||||
if (file_path.is_file() and
|
||||
file_path.suffix.lower() == extension.lower() and
|
||||
file_path.name not in exclude_patterns):
|
||||
files.append(file_path.name)
|
||||
|
||||
# Sort for consistent ordering
|
||||
files.sort()
|
||||
|
||||
self.logger.debug(f"Discovered {len(files)} {extension} files in {directory}")
|
||||
if files:
|
||||
self.logger.debug(f"Files found: {files}")
|
||||
|
||||
return files
|
||||
|
||||
except PermissionError:
|
||||
self.logger.error(f"Permission denied accessing directory: {directory}")
|
||||
return []
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error discovering files in {directory}: {e}")
|
||||
return []
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of component"""
|
||||
metadata = self.get_metadata()
|
||||
return f"{metadata['name']} v{metadata['version']}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Developer representation of component"""
|
||||
return f"<{self.__class__.__name__}({self.get_metadata()['name']})>"
|
||||
331
setup/base/installer.py
Normal file
331
setup/base/installer.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
Base installer logic for SuperClaude installation system fixed some issues
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Optional, Set, Tuple, Any
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from .component import Component
|
||||
|
||||
|
||||
class Installer:
|
||||
"""Main installer orchestrator"""
|
||||
|
||||
def __init__(self,
|
||||
install_dir: Optional[Path] = None,
|
||||
dry_run: bool = False):
|
||||
"""
|
||||
Initialize installer
|
||||
|
||||
Args:
|
||||
install_dir: Target installation directory
|
||||
dry_run: If True, only simulate installation
|
||||
"""
|
||||
from .. import DEFAULT_INSTALL_DIR
|
||||
self.install_dir = install_dir or DEFAULT_INSTALL_DIR
|
||||
self.dry_run = dry_run
|
||||
self.components: Dict[str, Component] = {}
|
||||
self.installed_components: Set[str] = set()
|
||||
self.updated_components: Set[str] = set()
|
||||
|
||||
self.failed_components: Set[str] = set()
|
||||
self.skipped_components: Set[str] = set()
|
||||
self.backup_path: Optional[Path] = None
|
||||
|
||||
def register_component(self, component: Component) -> None:
|
||||
"""
|
||||
Register a component for installation
|
||||
|
||||
Args:
|
||||
component: Component instance to register
|
||||
"""
|
||||
metadata = component.get_metadata()
|
||||
self.components[metadata['name']] = component
|
||||
|
||||
def register_components(self, components: List[Component]) -> None:
|
||||
"""
|
||||
Register multiple components
|
||||
|
||||
Args:
|
||||
components: List of component instances
|
||||
"""
|
||||
for component in components:
|
||||
self.register_component(component)
|
||||
|
||||
def resolve_dependencies(self, component_names: List[str]) -> List[str]:
|
||||
"""
|
||||
Resolve component dependencies in correct installation order
|
||||
|
||||
Args:
|
||||
component_names: List of component names to install
|
||||
|
||||
Returns:
|
||||
Ordered list of component names including dependencies
|
||||
|
||||
Raises:
|
||||
ValueError: If circular dependencies detected or unknown component
|
||||
"""
|
||||
resolved = []
|
||||
resolving = set()
|
||||
|
||||
def resolve(name: str):
|
||||
if name in resolved:
|
||||
return
|
||||
|
||||
if name in resolving:
|
||||
raise ValueError(
|
||||
f"Circular dependency detected involving {name}")
|
||||
|
||||
if name not in self.components:
|
||||
raise ValueError(f"Unknown component: {name}")
|
||||
|
||||
resolving.add(name)
|
||||
|
||||
# Resolve dependencies first
|
||||
for dep in self.components[name].get_dependencies():
|
||||
resolve(dep)
|
||||
|
||||
resolving.remove(name)
|
||||
resolved.append(name)
|
||||
|
||||
# Resolve each requested component
|
||||
for name in component_names:
|
||||
resolve(name)
|
||||
|
||||
return resolved
|
||||
|
||||
def validate_system_requirements(self) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate system requirements for all registered components
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, error_messages: List[str])
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check disk space (500MB minimum)
|
||||
try:
|
||||
stat = shutil.disk_usage(self.install_dir.parent)
|
||||
free_mb = stat.free / (1024 * 1024)
|
||||
if free_mb < 500:
|
||||
errors.append(
|
||||
f"Insufficient disk space: {free_mb:.1f}MB free (500MB required)"
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(f"Could not check disk space: {e}")
|
||||
|
||||
# Check write permissions
|
||||
test_file = self.install_dir / ".write_test"
|
||||
try:
|
||||
self.install_dir.mkdir(parents=True, exist_ok=True)
|
||||
test_file.touch()
|
||||
test_file.unlink()
|
||||
except Exception as e:
|
||||
errors.append(f"No write permission to {self.install_dir}: {e}")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def create_backup(self) -> Optional[Path]:
|
||||
"""
|
||||
Create backup of existing installation
|
||||
|
||||
Returns:
|
||||
Path to backup archive or None if no existing installation
|
||||
"""
|
||||
if not self.install_dir.exists():
|
||||
return None
|
||||
|
||||
if self.dry_run:
|
||||
return self.install_dir / "backup_dryrun.tar.gz"
|
||||
|
||||
# Create backup directory
|
||||
backup_dir = self.install_dir / "backups"
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Create timestamped backup
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_name = f"superclaude_backup_{timestamp}"
|
||||
backup_path = backup_dir / f"{backup_name}.tar.gz"
|
||||
|
||||
# Create temporary directory for backup
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_backup = Path(temp_dir) / backup_name
|
||||
|
||||
# Ensure temp backup directory exists
|
||||
temp_backup.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy all files except backups directory
|
||||
for item in self.install_dir.iterdir():
|
||||
if item.name != "backups":
|
||||
try:
|
||||
if item.is_file():
|
||||
shutil.copy2(item, temp_backup / item.name)
|
||||
elif item.is_dir():
|
||||
shutil.copytree(item, temp_backup / item.name)
|
||||
except Exception as e:
|
||||
# Log warning but continue backup process
|
||||
print(f"Warning: Could not backup {item.name}: {e}")
|
||||
|
||||
# Create archive only if there are files to backup
|
||||
if any(temp_backup.iterdir()):
|
||||
shutil.make_archive(backup_path.with_suffix(''), 'gztar',
|
||||
temp_dir, backup_name)
|
||||
else:
|
||||
# Create empty backup file to indicate backup was attempted
|
||||
backup_path.touch()
|
||||
print(
|
||||
f"Warning: No files to backup, created empty backup marker: {backup_path.name}"
|
||||
)
|
||||
|
||||
self.backup_path = backup_path
|
||||
return backup_path
|
||||
|
||||
def install_component(self, component_name: str,
|
||||
config: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Install a single component
|
||||
|
||||
Args:
|
||||
component_name: Name of component to install
|
||||
config: Installation configuration
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if component_name not in self.components:
|
||||
raise ValueError(f"Unknown component: {component_name}")
|
||||
|
||||
component = self.components[component_name]
|
||||
|
||||
# Skip if already installed
|
||||
if component_name in self.installed_components:
|
||||
return True
|
||||
|
||||
# Check prerequisites
|
||||
success, errors = component.validate_prerequisites()
|
||||
if not success:
|
||||
print(f"Prerequisites failed for {component_name}:")
|
||||
for error in errors:
|
||||
print(f" - {error}")
|
||||
self.failed_components.add(component_name)
|
||||
return False
|
||||
|
||||
# Perform installation
|
||||
try:
|
||||
if self.dry_run:
|
||||
print(f"[DRY RUN] Would install {component_name}")
|
||||
success = True
|
||||
else:
|
||||
success = component.install(config)
|
||||
|
||||
if success:
|
||||
self.installed_components.add(component_name)
|
||||
self.updated_components.add(component_name)
|
||||
else:
|
||||
self.failed_components.add(component_name)
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error installing {component_name}: {e}")
|
||||
self.failed_components.add(component_name)
|
||||
return False
|
||||
|
||||
def install_components(self,
|
||||
component_names: List[str],
|
||||
config: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""
|
||||
Install multiple components in dependency order
|
||||
|
||||
Args:
|
||||
component_names: List of component names to install
|
||||
config: Installation configuration
|
||||
|
||||
Returns:
|
||||
True if all successful, False if any failed
|
||||
"""
|
||||
config = config or {}
|
||||
|
||||
# Resolve dependencies
|
||||
try:
|
||||
ordered_names = self.resolve_dependencies(component_names)
|
||||
except ValueError as e:
|
||||
print(f"Dependency resolution error: {e}")
|
||||
return False
|
||||
|
||||
# Validate system requirements
|
||||
success, errors = self.validate_system_requirements()
|
||||
if not success:
|
||||
print("System requirements not met:")
|
||||
for error in errors:
|
||||
print(f" - {error}")
|
||||
return False
|
||||
|
||||
# Create backup if updating
|
||||
if self.install_dir.exists() and not self.dry_run:
|
||||
print("Creating backup of existing installation...")
|
||||
self.create_backup()
|
||||
|
||||
# Install each component
|
||||
all_success = True
|
||||
for name in ordered_names:
|
||||
print(f"\nInstalling {name}...")
|
||||
if not self.install_component(name, config):
|
||||
all_success = False
|
||||
# Continue installing other components even if one fails
|
||||
|
||||
if not self.dry_run:
|
||||
self._run_post_install_validation()
|
||||
|
||||
return all_success
|
||||
|
||||
def _run_post_install_validation(self) -> None:
|
||||
"""Run post-installation validation for all installed components"""
|
||||
print("\nRunning post-installation validation...")
|
||||
|
||||
all_valid = True
|
||||
for name in self.installed_components:
|
||||
component = self.components[name]
|
||||
success, errors = component.validate_installation()
|
||||
|
||||
if success:
|
||||
print(f" ✓ {name}: Valid")
|
||||
else:
|
||||
print(f" ✗ {name}: Invalid")
|
||||
for error in errors:
|
||||
print(f" - {error}")
|
||||
all_valid = False
|
||||
|
||||
if all_valid:
|
||||
print("\nAll components validated successfully!")
|
||||
else:
|
||||
print("\nSome components failed validation. Check errors above.")
|
||||
def update_components(self, component_names: List[str], config: Dict[str, Any]) -> bool:
|
||||
"""Alias for update operation (uses install logic)"""
|
||||
return self.install_components(component_names, config)
|
||||
|
||||
|
||||
def get_installation_summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get summary of installation results
|
||||
|
||||
Returns:
|
||||
Dict with installation statistics and results
|
||||
"""
|
||||
return {
|
||||
'installed': list(self.installed_components),
|
||||
'failed': list(self.failed_components),
|
||||
'skipped': list(self.skipped_components),
|
||||
'backup_path': str(self.backup_path) if self.backup_path else None,
|
||||
'install_dir': str(self.install_dir),
|
||||
'dry_run': self.dry_run
|
||||
}
|
||||
|
||||
def get_update_summary(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'updated': list(self.updated_components),
|
||||
'failed': list(self.failed_components),
|
||||
'backup_path': str(self.backup_path) if self.backup_path else None
|
||||
}
|
||||
13
setup/components/__init__.py
Normal file
13
setup/components/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Component implementations for SuperClaude installation system"""
|
||||
|
||||
from .core import CoreComponent
|
||||
from .commands import CommandsComponent
|
||||
from .mcp import MCPComponent
|
||||
from .hooks import HooksComponent
|
||||
|
||||
__all__ = [
|
||||
'CoreComponent',
|
||||
'CommandsComponent',
|
||||
'MCPComponent',
|
||||
'HooksComponent'
|
||||
]
|
||||
329
setup/components/commands.py
Normal file
329
setup/components/commands.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
Commands component for SuperClaude slash command definitions
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Tuple, Optional, Any
|
||||
from pathlib import Path
|
||||
|
||||
from ..base.component import Component
|
||||
|
||||
class CommandsComponent(Component):
|
||||
"""SuperClaude slash commands component"""
|
||||
|
||||
def __init__(self, install_dir: Optional[Path] = None):
|
||||
"""Initialize commands component"""
|
||||
super().__init__(install_dir, Path("commands/sc"))
|
||||
|
||||
def get_metadata(self) -> Dict[str, str]:
|
||||
"""Get component metadata"""
|
||||
return {
|
||||
"name": "commands",
|
||||
"version": "3.0.0",
|
||||
"description": "SuperClaude slash command definitions",
|
||||
"category": "commands"
|
||||
}
|
||||
|
||||
def get_metadata_modifications(self) -> Dict[str, Any]:
|
||||
"""Get metadata modifications for commands component"""
|
||||
return {
|
||||
"components": {
|
||||
"commands": {
|
||||
"version": "3.0.0",
|
||||
"installed": True,
|
||||
"files_count": len(self.component_files)
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
"enabled": True,
|
||||
"version": "3.0.0",
|
||||
"auto_update": False
|
||||
}
|
||||
}
|
||||
|
||||
def _install(self, config: Dict[str, Any]) -> bool:
|
||||
"""Install commands component"""
|
||||
self.logger.info("Installing SuperClaude command definitions...")
|
||||
|
||||
# Check for and migrate existing commands from old location
|
||||
self._migrate_existing_commands()
|
||||
|
||||
return super()._install(config);
|
||||
|
||||
def _post_install(self):
|
||||
# Update metadata
|
||||
try:
|
||||
metadata_mods = self.get_metadata_modifications()
|
||||
self.settings_manager.update_metadata(metadata_mods)
|
||||
self.logger.info("Updated metadata with commands configuration")
|
||||
|
||||
# Add component registration to metadata
|
||||
self.settings_manager.add_component_registration("commands", {
|
||||
"version": "3.0.0",
|
||||
"category": "commands",
|
||||
"files_count": len(self.component_files)
|
||||
})
|
||||
self.logger.info("Updated metadata with commands component registration")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to update metadata: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def uninstall(self) -> bool:
|
||||
"""Uninstall commands component"""
|
||||
try:
|
||||
self.logger.info("Uninstalling SuperClaude commands component...")
|
||||
|
||||
# Remove command files from sc subdirectory
|
||||
commands_dir = self.install_dir / "commands" / "sc"
|
||||
removed_count = 0
|
||||
|
||||
for filename in self.component_files:
|
||||
file_path = commands_dir / filename
|
||||
if self.file_manager.remove_file(file_path):
|
||||
removed_count += 1
|
||||
self.logger.debug(f"Removed {filename}")
|
||||
else:
|
||||
self.logger.warning(f"Could not remove {filename}")
|
||||
|
||||
# Also check and remove any old commands in root commands directory
|
||||
old_commands_dir = self.install_dir / "commands"
|
||||
old_removed_count = 0
|
||||
|
||||
for filename in self.component_files:
|
||||
old_file_path = old_commands_dir / filename
|
||||
if old_file_path.exists() and old_file_path.is_file():
|
||||
if self.file_manager.remove_file(old_file_path):
|
||||
old_removed_count += 1
|
||||
self.logger.debug(f"Removed old {filename}")
|
||||
else:
|
||||
self.logger.warning(f"Could not remove old {filename}")
|
||||
|
||||
if old_removed_count > 0:
|
||||
self.logger.info(f"Also removed {old_removed_count} commands from old location")
|
||||
|
||||
removed_count += old_removed_count
|
||||
|
||||
# Remove sc subdirectory if empty
|
||||
try:
|
||||
if commands_dir.exists():
|
||||
remaining_files = list(commands_dir.iterdir())
|
||||
if not remaining_files:
|
||||
commands_dir.rmdir()
|
||||
self.logger.debug("Removed empty sc commands directory")
|
||||
|
||||
# Also remove parent commands directory if empty
|
||||
parent_commands_dir = self.install_dir / "commands"
|
||||
if parent_commands_dir.exists():
|
||||
remaining_files = list(parent_commands_dir.iterdir())
|
||||
if not remaining_files:
|
||||
parent_commands_dir.rmdir()
|
||||
self.logger.debug("Removed empty parent commands directory")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not remove commands directory: {e}")
|
||||
|
||||
# Update metadata to remove commands component
|
||||
try:
|
||||
if self.settings_manager.is_component_installed("commands"):
|
||||
self.settings_manager.remove_component_registration("commands")
|
||||
# Also remove commands configuration from metadata
|
||||
metadata = self.settings_manager.load_metadata()
|
||||
if "commands" in metadata:
|
||||
del metadata["commands"]
|
||||
self.settings_manager.save_metadata(metadata)
|
||||
self.logger.info("Removed commands component from metadata")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not update metadata: {e}")
|
||||
|
||||
self.logger.success(f"Commands component uninstalled ({removed_count} files removed)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Unexpected error during commands uninstallation: {e}")
|
||||
return False
|
||||
|
||||
def get_dependencies(self) -> List[str]:
|
||||
"""Get dependencies"""
|
||||
return ["core"]
|
||||
|
||||
def update(self, config: Dict[str, Any]) -> bool:
|
||||
"""Update commands component"""
|
||||
try:
|
||||
self.logger.info("Updating SuperClaude commands component...")
|
||||
|
||||
# Check current version
|
||||
current_version = self.settings_manager.get_component_version("commands")
|
||||
target_version = self.get_metadata()["version"]
|
||||
|
||||
if current_version == target_version:
|
||||
self.logger.info(f"Commands component already at version {target_version}")
|
||||
return True
|
||||
|
||||
self.logger.info(f"Updating commands component from {current_version} to {target_version}")
|
||||
|
||||
# Create backup of existing command files
|
||||
commands_dir = self.install_dir / "commands" / "sc"
|
||||
backup_files = []
|
||||
|
||||
if commands_dir.exists():
|
||||
for filename in self.component_files:
|
||||
file_path = commands_dir / filename
|
||||
if file_path.exists():
|
||||
backup_path = self.file_manager.backup_file(file_path)
|
||||
if backup_path:
|
||||
backup_files.append(backup_path)
|
||||
self.logger.debug(f"Backed up {filename}")
|
||||
|
||||
# Perform installation (overwrites existing files)
|
||||
success = self.install(config)
|
||||
|
||||
if success:
|
||||
# Remove backup files on successful update
|
||||
for backup_path in backup_files:
|
||||
try:
|
||||
backup_path.unlink()
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
self.logger.success(f"Commands component updated to version {target_version}")
|
||||
else:
|
||||
# Restore from backup on failure
|
||||
self.logger.warning("Update failed, restoring from backup...")
|
||||
for backup_path in backup_files:
|
||||
try:
|
||||
original_path = backup_path.with_suffix('')
|
||||
backup_path.rename(original_path)
|
||||
self.logger.debug(f"Restored {original_path.name}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Could not restore {backup_path}: {e}")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Unexpected error during commands update: {e}")
|
||||
return False
|
||||
|
||||
def validate_installation(self) -> Tuple[bool, List[str]]:
|
||||
"""Validate commands component installation"""
|
||||
errors = []
|
||||
|
||||
# Check if sc commands directory exists
|
||||
commands_dir = self.install_dir / "commands" / "sc"
|
||||
if not commands_dir.exists():
|
||||
errors.append("SC commands directory not found")
|
||||
return False, errors
|
||||
|
||||
# Check if all command files exist
|
||||
for filename in self.component_files:
|
||||
file_path = commands_dir / filename
|
||||
if not file_path.exists():
|
||||
errors.append(f"Missing command file: {filename}")
|
||||
elif not file_path.is_file():
|
||||
errors.append(f"Command file is not a regular file: {filename}")
|
||||
|
||||
# Check metadata registration
|
||||
if not self.settings_manager.is_component_installed("commands"):
|
||||
errors.append("Commands component not registered in metadata")
|
||||
else:
|
||||
# Check version matches
|
||||
installed_version = self.settings_manager.get_component_version("commands")
|
||||
expected_version = self.get_metadata()["version"]
|
||||
if installed_version != expected_version:
|
||||
errors.append(f"Version mismatch: installed {installed_version}, expected {expected_version}")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def _get_source_dir(self) -> Path:
|
||||
"""Get source directory for command files"""
|
||||
# Assume we're in SuperClaude/setup/components/commands.py
|
||||
# and command files are in SuperClaude/SuperClaude/Commands/
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
return project_root / "SuperClaude" / "Commands"
|
||||
|
||||
def get_size_estimate(self) -> int:
|
||||
"""Get estimated installation size"""
|
||||
total_size = 0
|
||||
source_dir = self._get_source_dir()
|
||||
|
||||
for filename in self.component_files:
|
||||
file_path = source_dir / filename
|
||||
if file_path.exists():
|
||||
total_size += file_path.stat().st_size
|
||||
|
||||
# Add overhead for directory and settings
|
||||
total_size += 5120 # ~5KB overhead
|
||||
|
||||
return total_size
|
||||
|
||||
def get_installation_summary(self) -> Dict[str, Any]:
|
||||
"""Get installation summary"""
|
||||
return {
|
||||
"component": self.get_metadata()["name"],
|
||||
"version": self.get_metadata()["version"],
|
||||
"files_installed": len(self.component_files),
|
||||
"command_files": self.component_files,
|
||||
"estimated_size": self.get_size_estimate(),
|
||||
"install_directory": str(self.install_dir / "commands" / "sc"),
|
||||
"dependencies": self.get_dependencies()
|
||||
}
|
||||
|
||||
def _migrate_existing_commands(self) -> None:
|
||||
"""Migrate existing commands from old location to new sc subdirectory"""
|
||||
try:
|
||||
old_commands_dir = self.install_dir / "commands"
|
||||
new_commands_dir = self.install_dir / "commands" / "sc"
|
||||
|
||||
# Check if old commands exist in root commands directory
|
||||
migrated_count = 0
|
||||
commands_to_migrate = []
|
||||
|
||||
if old_commands_dir.exists():
|
||||
for filename in self.component_files:
|
||||
old_file_path = old_commands_dir / filename
|
||||
if old_file_path.exists() and old_file_path.is_file():
|
||||
commands_to_migrate.append(filename)
|
||||
|
||||
if commands_to_migrate:
|
||||
self.logger.info(f"Found {len(commands_to_migrate)} existing commands to migrate to sc/ subdirectory")
|
||||
|
||||
# Ensure new directory exists
|
||||
if not self.file_manager.ensure_directory(new_commands_dir):
|
||||
self.logger.error(f"Could not create sc commands directory: {new_commands_dir}")
|
||||
return
|
||||
|
||||
# Move files from old to new location
|
||||
for filename in commands_to_migrate:
|
||||
old_file_path = old_commands_dir / filename
|
||||
new_file_path = new_commands_dir / filename
|
||||
|
||||
try:
|
||||
# Copy file to new location
|
||||
if self.file_manager.copy_file(old_file_path, new_file_path):
|
||||
# Remove old file
|
||||
if self.file_manager.remove_file(old_file_path):
|
||||
migrated_count += 1
|
||||
self.logger.debug(f"Migrated {filename} to sc/ subdirectory")
|
||||
else:
|
||||
self.logger.warning(f"Could not remove old {filename}")
|
||||
else:
|
||||
self.logger.warning(f"Could not copy {filename} to sc/ subdirectory")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error migrating {filename}: {e}")
|
||||
|
||||
if migrated_count > 0:
|
||||
self.logger.success(f"Successfully migrated {migrated_count} commands to /sc: namespace")
|
||||
self.logger.info("Commands are now available as /sc:analyze, /sc:build, etc.")
|
||||
|
||||
# Try to remove old commands directory if empty
|
||||
try:
|
||||
if old_commands_dir.exists():
|
||||
remaining_files = [f for f in old_commands_dir.iterdir() if f.is_file()]
|
||||
if not remaining_files:
|
||||
# Only remove if no user files remain
|
||||
old_commands_dir.rmdir()
|
||||
self.logger.debug("Removed empty old commands directory")
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not remove old commands directory: {e}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error during command migration: {e}")
|
||||
248
setup/components/core.py
Normal file
248
setup/components/core.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
Core component for SuperClaude framework files installation
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Tuple, Optional, Any
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
from ..base.component import Component
|
||||
|
||||
class CoreComponent(Component):
|
||||
"""Core SuperClaude framework files component"""
|
||||
|
||||
def __init__(self, install_dir: Optional[Path] = None):
|
||||
"""Initialize core component"""
|
||||
super().__init__(install_dir)
|
||||
|
||||
def get_metadata(self) -> Dict[str, str]:
|
||||
"""Get component metadata"""
|
||||
return {
|
||||
"name": "core",
|
||||
"version": "3.0.0",
|
||||
"description": "SuperClaude framework documentation and core files",
|
||||
"category": "core"
|
||||
}
|
||||
|
||||
def get_metadata_modifications(self) -> Dict[str, Any]:
|
||||
"""Get metadata modifications for SuperClaude"""
|
||||
return {
|
||||
"framework": {
|
||||
"version": "3.0.0",
|
||||
"name": "SuperClaude",
|
||||
"description": "AI-enhanced development framework for Claude Code",
|
||||
"installation_type": "global",
|
||||
"components": ["core"]
|
||||
},
|
||||
"superclaude": {
|
||||
"enabled": True,
|
||||
"version": "3.0.0",
|
||||
"profile": "default",
|
||||
"auto_update": False
|
||||
}
|
||||
}
|
||||
|
||||
def _install(self, config: Dict[str, Any]) -> bool:
|
||||
"""Install core component"""
|
||||
self.logger.info("Installing SuperClaude core framework files...")
|
||||
|
||||
return super()._install(config);
|
||||
|
||||
def _post_install(self):
|
||||
# Create or update metadata
|
||||
try:
|
||||
metadata_mods = self.get_metadata_modifications()
|
||||
self.settings_manager.update_metadata(metadata_mods)
|
||||
self.logger.info("Updated metadata with framework configuration")
|
||||
|
||||
# Add component registration to metadata
|
||||
self.settings_manager.add_component_registration("core", {
|
||||
"version": "3.0.0",
|
||||
"category": "core",
|
||||
"files_count": len(self.component_files)
|
||||
})
|
||||
|
||||
self.logger.info("Updated metadata with core component registration")
|
||||
|
||||
# Migrate any existing SuperClaude data from settings.json
|
||||
if self.settings_manager.migrate_superclaude_data():
|
||||
self.logger.info("Migrated existing SuperClaude data from settings.json")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to update metadata: {e}")
|
||||
return False
|
||||
|
||||
# Create additional directories for other components
|
||||
additional_dirs = ["commands", "hooks", "backups", "logs"]
|
||||
for dirname in additional_dirs:
|
||||
dir_path = self.install_dir / dirname
|
||||
if not self.file_manager.ensure_directory(dir_path):
|
||||
self.logger.warning(f"Could not create directory: {dir_path}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def uninstall(self) -> bool:
|
||||
"""Uninstall core component"""
|
||||
try:
|
||||
self.logger.info("Uninstalling SuperClaude core component...")
|
||||
|
||||
# Remove framework files
|
||||
removed_count = 0
|
||||
for filename in self.component_files:
|
||||
file_path = self.install_dir / filename
|
||||
if self.file_manager.remove_file(file_path):
|
||||
removed_count += 1
|
||||
self.logger.debug(f"Removed {filename}")
|
||||
else:
|
||||
self.logger.warning(f"Could not remove {filename}")
|
||||
|
||||
# Update metadata to remove core component
|
||||
try:
|
||||
if self.settings_manager.is_component_installed("core"):
|
||||
self.settings_manager.remove_component_registration("core")
|
||||
metadata_mods = self.get_metadata_modifications()
|
||||
metadata = self.settings_manager.load_metadata()
|
||||
for key in metadata_mods.keys():
|
||||
if key in metadata:
|
||||
del metadata[key]
|
||||
|
||||
self.settings_manager.save_metadata(metadata)
|
||||
self.logger.info("Removed core component from metadata")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not update metadata: {e}")
|
||||
|
||||
self.logger.success(f"Core component uninstalled ({removed_count} files removed)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Unexpected error during core uninstallation: {e}")
|
||||
return False
|
||||
|
||||
def get_dependencies(self) -> List[str]:
|
||||
"""Get component dependencies (core has none)"""
|
||||
return []
|
||||
|
||||
def update(self, config: Dict[str, Any]) -> bool:
|
||||
"""Update core component"""
|
||||
try:
|
||||
self.logger.info("Updating SuperClaude core component...")
|
||||
|
||||
# Check current version
|
||||
current_version = self.settings_manager.get_component_version("core")
|
||||
target_version = self.get_metadata()["version"]
|
||||
|
||||
if current_version == target_version:
|
||||
self.logger.info(f"Core component already at version {target_version}")
|
||||
return True
|
||||
|
||||
self.logger.info(f"Updating core component from {current_version} to {target_version}")
|
||||
|
||||
# Create backup of existing files
|
||||
backup_files = []
|
||||
for filename in self.component_files:
|
||||
file_path = self.install_dir / filename
|
||||
if file_path.exists():
|
||||
backup_path = self.file_manager.backup_file(file_path)
|
||||
if backup_path:
|
||||
backup_files.append(backup_path)
|
||||
self.logger.debug(f"Backed up {filename}")
|
||||
|
||||
# Perform installation (overwrites existing files)
|
||||
success = self.install(config)
|
||||
|
||||
if success:
|
||||
# Remove backup files on successful update
|
||||
for backup_path in backup_files:
|
||||
try:
|
||||
backup_path.unlink()
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
self.logger.success(f"Core component updated to version {target_version}")
|
||||
else:
|
||||
# Restore from backup on failure
|
||||
self.logger.warning("Update failed, restoring from backup...")
|
||||
for backup_path in backup_files:
|
||||
try:
|
||||
original_path = backup_path.with_suffix('')
|
||||
shutil.move(str(backup_path), str(original_path))
|
||||
self.logger.debug(f"Restored {original_path.name}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Could not restore {backup_path}: {e}")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Unexpected error during core update: {e}")
|
||||
return False
|
||||
|
||||
def validate_installation(self) -> Tuple[bool, List[str]]:
|
||||
"""Validate core component installation"""
|
||||
errors = []
|
||||
|
||||
# Check if all framework files exist
|
||||
for filename in self.component_files:
|
||||
file_path = self.install_dir / filename
|
||||
if not file_path.exists():
|
||||
errors.append(f"Missing framework file: {filename}")
|
||||
elif not file_path.is_file():
|
||||
errors.append(f"Framework file is not a regular file: {filename}")
|
||||
|
||||
# Check metadata registration
|
||||
if not self.settings_manager.is_component_installed("core"):
|
||||
errors.append("Core component not registered in metadata")
|
||||
else:
|
||||
# Check version matches
|
||||
installed_version = self.settings_manager.get_component_version("core")
|
||||
expected_version = self.get_metadata()["version"]
|
||||
if installed_version != expected_version:
|
||||
errors.append(f"Version mismatch: installed {installed_version}, expected {expected_version}")
|
||||
|
||||
# Check metadata structure
|
||||
try:
|
||||
framework_config = self.settings_manager.get_metadata_setting("framework")
|
||||
if not framework_config:
|
||||
errors.append("Missing framework configuration in metadata")
|
||||
else:
|
||||
required_keys = ["version", "name", "description"]
|
||||
for key in required_keys:
|
||||
if key not in framework_config:
|
||||
errors.append(f"Missing framework.{key} in metadata")
|
||||
except Exception as e:
|
||||
errors.append(f"Could not validate metadata: {e}")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def _get_source_dir(self):
|
||||
"""Get source directory for framework files"""
|
||||
# Assume we're in SuperClaude/setup/components/core.py
|
||||
# and framework files are in SuperClaude/SuperClaude/Core/
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
return project_root / "SuperClaude" / "Core"
|
||||
|
||||
def get_size_estimate(self) -> int:
|
||||
"""Get estimated installation size"""
|
||||
total_size = 0
|
||||
source_dir = self._get_source_dir()
|
||||
|
||||
for filename in self.component_files:
|
||||
file_path = source_dir / filename
|
||||
if file_path.exists():
|
||||
total_size += file_path.stat().st_size
|
||||
|
||||
# Add overhead for settings.json and directories
|
||||
total_size += 10240 # ~10KB overhead
|
||||
|
||||
return total_size
|
||||
|
||||
def get_installation_summary(self) -> Dict[str, Any]:
|
||||
"""Get installation summary"""
|
||||
return {
|
||||
"component": self.get_metadata()["name"],
|
||||
"version": self.get_metadata()["version"],
|
||||
"files_installed": len(self.component_files),
|
||||
"framework_files": self.component_files,
|
||||
"estimated_size": self.get_size_estimate(),
|
||||
"install_directory": str(self.install_dir),
|
||||
"dependencies": self.get_dependencies()
|
||||
}
|
||||
366
setup/components/hooks.py
Normal file
366
setup/components/hooks.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
Hooks component for Claude Code hooks integration (future-ready)
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Tuple, Optional, Any
|
||||
from pathlib import Path
|
||||
|
||||
from ..base.component import Component
|
||||
|
||||
|
||||
class HooksComponent(Component):
|
||||
"""Claude Code hooks integration component"""
|
||||
|
||||
def __init__(self, install_dir: Optional[Path] = None):
|
||||
"""Initialize hooks component"""
|
||||
super().__init__(install_dir, Path("hooks"))
|
||||
|
||||
# Define hook files to install (when hooks are ready)
|
||||
self.hook_files = [
|
||||
"pre_tool_use.py",
|
||||
"post_tool_use.py",
|
||||
"error_handler.py",
|
||||
"context_accumulator.py",
|
||||
"performance_monitor.py"
|
||||
]
|
||||
|
||||
def get_metadata(self) -> Dict[str, str]:
|
||||
"""Get component metadata"""
|
||||
return {
|
||||
"name": "hooks",
|
||||
"version": "3.0.0",
|
||||
"description": "Claude Code hooks integration (future-ready)",
|
||||
"category": "integration"
|
||||
}
|
||||
def get_metadata_modifications(self) -> Dict[str, Any]:
|
||||
# Build hooks configuration based on available files
|
||||
hook_config = {}
|
||||
for filename in self.hook_files:
|
||||
hook_path = self.install_component_subdir / filename
|
||||
if hook_path.exists():
|
||||
hook_name = filename.replace('.py', '')
|
||||
hook_config[hook_name] = [str(hook_path)]
|
||||
|
||||
metadata_mods = {
|
||||
"components": {
|
||||
"hooks": {
|
||||
"version": "3.0.0",
|
||||
"installed": True,
|
||||
"files_count": len(hook_config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Only add hooks configuration if we have actual hook files
|
||||
if hook_config:
|
||||
metadata_mods["hooks"] = {
|
||||
"enabled": True,
|
||||
**hook_config
|
||||
}
|
||||
|
||||
|
||||
return metadata_mods
|
||||
|
||||
def _install(self, config: Dict[str, Any]) -> bool:
|
||||
"""Install hooks component"""
|
||||
self.logger.info("Installing SuperClaude hooks component...")
|
||||
|
||||
# This component is future-ready - hooks aren't implemented yet
|
||||
source_dir = self._get_source_dir()
|
||||
|
||||
if not source_dir.exists() or (source_dir / "PLACEHOLDER.py").exists :
|
||||
self.logger.info("Hooks are not yet implemented - installing placeholder component")
|
||||
|
||||
# Create placeholder hooks directory
|
||||
if not self.file_manager.ensure_directory(self.install_component_subdir):
|
||||
self.logger.error(f"Could not create hooks directory: {self.install_component_subdir}")
|
||||
return False
|
||||
|
||||
# Create placeholder file
|
||||
placeholder_content = '''"""
|
||||
SuperClaude Hooks - Future Implementation
|
||||
|
||||
This directory is reserved for Claude Code hooks integration.
|
||||
Hooks will provide lifecycle management and automation capabilities.
|
||||
|
||||
Planned hooks:
|
||||
- pre_tool_use: Execute before tool usage
|
||||
- post_tool_use: Execute after tool completion
|
||||
- error_handler: Handle tool errors and recovery
|
||||
- context_accumulator: Manage context across operations
|
||||
- performance_monitor: Track and optimize performance
|
||||
|
||||
For more information, see SuperClaude documentation.
|
||||
"""
|
||||
|
||||
# Placeholder for future hooks implementation
|
||||
def placeholder_hook():
|
||||
"""Placeholder hook function"""
|
||||
pass
|
||||
'''
|
||||
|
||||
placeholder_path = self.install_component_subdir / "PLACEHOLDER.py"
|
||||
try:
|
||||
with open(placeholder_path, 'w') as f:
|
||||
f.write(placeholder_content)
|
||||
self.logger.debug("Created hooks placeholder file")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create placeholder file: {e}")
|
||||
|
||||
# Update settings with placeholder registration
|
||||
try:
|
||||
metadata_mods = {
|
||||
"components": {
|
||||
"hooks": {
|
||||
"version": "3.0.0",
|
||||
"installed": True,
|
||||
"status": "placeholder",
|
||||
"files_count": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
self.settings_manager.update_metadata(metadata_mods)
|
||||
self.logger.info("Updated metadata with hooks component registration")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to update metadata for hooks component: {e}")
|
||||
return False
|
||||
|
||||
self.logger.success("Hooks component installed successfully (placeholder)")
|
||||
return True
|
||||
|
||||
# If hooks source directory exists, install actual hooks
|
||||
self.logger.info("Installing actual hook files...")
|
||||
|
||||
# Validate installation
|
||||
success, errors = self.validate_prerequisites(Path("hooks"))
|
||||
if not success:
|
||||
for error in errors:
|
||||
self.logger.error(error)
|
||||
return False
|
||||
|
||||
# Get files to install
|
||||
files_to_install = self.get_files_to_install()
|
||||
|
||||
if not files_to_install:
|
||||
self.logger.warning("No hook files found to install")
|
||||
return False
|
||||
|
||||
# Copy hook files
|
||||
success_count = 0
|
||||
for source, target in files_to_install:
|
||||
self.logger.debug(f"Copying {source.name} to {target}")
|
||||
|
||||
if self.file_manager.copy_file(source, target):
|
||||
success_count += 1
|
||||
self.logger.debug(f"Successfully copied {source.name}")
|
||||
else:
|
||||
self.logger.error(f"Failed to copy {source.name}")
|
||||
|
||||
if success_count != len(files_to_install):
|
||||
self.logger.error(f"Only {success_count}/{len(files_to_install)} hook files copied successfully")
|
||||
return False
|
||||
|
||||
self.logger.success(f"Hooks component installed successfully ({success_count} hook files)")
|
||||
|
||||
return self._post_install()
|
||||
|
||||
def _post_install(self):
|
||||
# Update metadata
|
||||
try:
|
||||
metadata_mods = self.get_metadata_modifications()
|
||||
self.settings_manager.update_metadata(metadata_mods)
|
||||
self.logger.info("Updated metadata with hooks configuration")
|
||||
|
||||
# Add hook registration to metadata
|
||||
self.settings_manager.add_component_registration("hooks", {
|
||||
"version": "3.0.0",
|
||||
"category": "commands",
|
||||
"files_count": len(self.hook_files)
|
||||
})
|
||||
|
||||
self.logger.info("Updated metadata with commands component registration")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to update metadata: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def uninstall(self) -> bool:
|
||||
"""Uninstall hooks component"""
|
||||
try:
|
||||
self.logger.info("Uninstalling SuperClaude hooks component...")
|
||||
|
||||
# Remove hook files and placeholder
|
||||
removed_count = 0
|
||||
|
||||
# Remove actual hook files
|
||||
for filename in self.hook_files:
|
||||
file_path = self.install_component_subdir / filename
|
||||
if self.file_manager.remove_file(file_path):
|
||||
removed_count += 1
|
||||
self.logger.debug(f"Removed {filename}")
|
||||
|
||||
# Remove placeholder file
|
||||
placeholder_path = self.install_component_subdir / "PLACEHOLDER.py"
|
||||
if self.file_manager.remove_file(placeholder_path):
|
||||
removed_count += 1
|
||||
self.logger.debug("Removed hooks placeholder")
|
||||
|
||||
# Remove hooks directory if empty
|
||||
try:
|
||||
if self.install_component_subdir.exists():
|
||||
remaining_files = list(self.install_component_subdir.iterdir())
|
||||
if not remaining_files:
|
||||
self.install_component_subdir.rmdir()
|
||||
self.logger.debug("Removed empty hooks directory")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not remove hooks directory: {e}")
|
||||
|
||||
# Update settings.json to remove hooks component and configuration
|
||||
try:
|
||||
if self.settings_manager.is_component_installed("hooks"):
|
||||
self.settings_manager.remove_component_registration("hooks")
|
||||
|
||||
# Also remove hooks configuration section if it exists
|
||||
settings = self.settings_manager.load_settings()
|
||||
if "hooks" in settings:
|
||||
del settings["hooks"]
|
||||
self.settings_manager.save_settings(settings)
|
||||
|
||||
self.logger.info("Removed hooks component and configuration from settings.json")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not update settings.json: {e}")
|
||||
|
||||
self.logger.success(f"Hooks component uninstalled ({removed_count} files removed)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Unexpected error during hooks uninstallation: {e}")
|
||||
return False
|
||||
|
||||
def get_dependencies(self) -> List[str]:
|
||||
"""Get dependencies"""
|
||||
return ["core"]
|
||||
|
||||
def update(self, config: Dict[str, Any]) -> bool:
|
||||
"""Update hooks component"""
|
||||
try:
|
||||
self.logger.info("Updating SuperClaude hooks component...")
|
||||
|
||||
# Check current version
|
||||
current_version = self.settings_manager.get_component_version("hooks")
|
||||
target_version = self.get_metadata()["version"]
|
||||
|
||||
if current_version == target_version:
|
||||
self.logger.info(f"Hooks component already at version {target_version}")
|
||||
return True
|
||||
|
||||
self.logger.info(f"Updating hooks component from {current_version} to {target_version}")
|
||||
|
||||
# Create backup of existing hook files
|
||||
backup_files = []
|
||||
|
||||
if self.install_component_subdir.exists():
|
||||
for filename in self.hook_files + ["PLACEHOLDER.py"]:
|
||||
file_path = self.install_component_subdir / filename
|
||||
if file_path.exists():
|
||||
backup_path = self.file_manager.backup_file(file_path)
|
||||
if backup_path:
|
||||
backup_files.append(backup_path)
|
||||
self.logger.debug(f"Backed up {filename}")
|
||||
|
||||
# Perform installation (overwrites existing files)
|
||||
success = self.install(config)
|
||||
|
||||
if success:
|
||||
# Remove backup files on successful update
|
||||
for backup_path in backup_files:
|
||||
try:
|
||||
backup_path.unlink()
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
self.logger.success(f"Hooks component updated to version {target_version}")
|
||||
else:
|
||||
# Restore from backup on failure
|
||||
self.logger.warning("Update failed, restoring from backup...")
|
||||
for backup_path in backup_files:
|
||||
try:
|
||||
original_path = backup_path.with_suffix('')
|
||||
backup_path.rename(original_path)
|
||||
self.logger.debug(f"Restored {original_path.name}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Could not restore {backup_path}: {e}")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Unexpected error during hooks update: {e}")
|
||||
return False
|
||||
|
||||
def validate_installation(self) -> Tuple[bool, List[str]]:
|
||||
"""Validate hooks component installation"""
|
||||
errors = []
|
||||
|
||||
# Check if hooks directory exists
|
||||
if not self.install_component_subdir.exists():
|
||||
errors.append("Hooks directory not found")
|
||||
return False, errors
|
||||
|
||||
# Check settings.json registration
|
||||
if not self.settings_manager.is_component_installed("hooks"):
|
||||
errors.append("Hooks component not registered in settings.json")
|
||||
else:
|
||||
# Check version matches
|
||||
installed_version = self.settings_manager.get_component_version("hooks")
|
||||
expected_version = self.get_metadata()["version"]
|
||||
if installed_version != expected_version:
|
||||
errors.append(f"Version mismatch: installed {installed_version}, expected {expected_version}")
|
||||
|
||||
# Check if we have either actual hooks or placeholder
|
||||
has_placeholder = (self.install_component_subdir / "PLACEHOLDER.py").exists()
|
||||
has_actual_hooks = any((self.install_component_subdir / filename).exists() for filename in self.hook_files)
|
||||
|
||||
if not has_placeholder and not has_actual_hooks:
|
||||
errors.append("No hook files or placeholder found")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def _get_source_dir(self) -> Path:
|
||||
"""Get source directory for hook files"""
|
||||
# Assume we're in SuperClaude/setup/components/hooks.py
|
||||
# and hook files are in SuperClaude/SuperClaude/Hooks/
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
return project_root / "SuperClaude" / "Hooks"
|
||||
|
||||
def get_size_estimate(self) -> int:
|
||||
"""Get estimated installation size"""
|
||||
# Estimate based on placeholder or actual files
|
||||
source_dir = self._get_source_dir()
|
||||
total_size = 0
|
||||
|
||||
if source_dir.exists():
|
||||
for filename in self.hook_files:
|
||||
file_path = source_dir / filename
|
||||
if file_path.exists():
|
||||
total_size += file_path.stat().st_size
|
||||
|
||||
# Add placeholder overhead or minimum size
|
||||
total_size = max(total_size, 10240) # At least 10KB
|
||||
|
||||
return total_size
|
||||
|
||||
def get_installation_summary(self) -> Dict[str, Any]:
|
||||
"""Get installation summary"""
|
||||
source_dir = self._get_source_dir()
|
||||
status = "placeholder" if not source_dir.exists() else "implemented"
|
||||
|
||||
return {
|
||||
"component": self.get_metadata()["name"],
|
||||
"version": self.get_metadata()["version"],
|
||||
"status": status,
|
||||
"hook_files": self.hook_files if source_dir.exists() else ["PLACEHOLDER.py"],
|
||||
"estimated_size": self.get_size_estimate(),
|
||||
"install_directory": str(self.install_dir / "hooks"),
|
||||
"dependencies": self.get_dependencies()
|
||||
}
|
||||
498
setup/components/mcp.py
Normal file
498
setup/components/mcp.py
Normal file
@@ -0,0 +1,498 @@
|
||||
"""
|
||||
MCP component for MCP server integration
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Dict, List, Tuple, Optional, Any
|
||||
from pathlib import Path
|
||||
|
||||
from ..base.component import Component
|
||||
from ..utils.ui import display_info, display_warning
|
||||
|
||||
|
||||
class MCPComponent(Component):
|
||||
"""MCP servers integration component"""
|
||||
|
||||
def __init__(self, install_dir: Optional[Path] = None):
|
||||
"""Initialize MCP component"""
|
||||
super().__init__(install_dir)
|
||||
|
||||
# Define MCP servers to install
|
||||
self.mcp_servers = {
|
||||
"sequential-thinking": {
|
||||
"name": "sequential-thinking",
|
||||
"description": "Multi-step problem solving and systematic analysis",
|
||||
"npm_package": "@modelcontextprotocol/server-sequential-thinking",
|
||||
"required": True
|
||||
},
|
||||
"context7": {
|
||||
"name": "context7",
|
||||
"description": "Official library documentation and code examples",
|
||||
"npm_package": "@upstash/context7-mcp",
|
||||
"required": True
|
||||
},
|
||||
"magic": {
|
||||
"name": "magic",
|
||||
"description": "Modern UI component generation and design systems",
|
||||
"npm_package": "@21st-dev/magic",
|
||||
"required": False,
|
||||
"api_key_env": "TWENTYFIRST_API_KEY",
|
||||
"api_key_description": "21st.dev API key for UI component generation"
|
||||
},
|
||||
"playwright": {
|
||||
"name": "playwright",
|
||||
"description": "Cross-browser E2E testing and automation",
|
||||
"npm_package": "@playwright/mcp@latest",
|
||||
"required": False
|
||||
}
|
||||
}
|
||||
|
||||
def get_metadata(self) -> Dict[str, str]:
|
||||
"""Get component metadata"""
|
||||
return {
|
||||
"name": "mcp",
|
||||
"version": "3.0.0",
|
||||
"description": "MCP server integration (Context7, Sequential, Magic, Playwright)",
|
||||
"category": "integration"
|
||||
}
|
||||
|
||||
def validate_prerequisites(self, installSubPath: Optional[Path] = None) -> Tuple[bool, List[str]]:
|
||||
"""Check prerequisites"""
|
||||
errors = []
|
||||
|
||||
# Check if Node.js is available
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["node", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
shell=(sys.platform == "win32")
|
||||
)
|
||||
if result.returncode != 0:
|
||||
errors.append("Node.js not found - required for MCP servers")
|
||||
else:
|
||||
version = result.stdout.strip()
|
||||
self.logger.debug(f"Found Node.js {version}")
|
||||
|
||||
# Check version (require 18+)
|
||||
try:
|
||||
version_num = int(version.lstrip('v').split('.')[0])
|
||||
if version_num < 18:
|
||||
errors.append(f"Node.js version {version} found, but version 18+ required")
|
||||
except:
|
||||
self.logger.warning(f"Could not parse Node.js version: {version}")
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
errors.append("Node.js not found - required for MCP servers")
|
||||
|
||||
# Check if Claude CLI is available
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
shell=(sys.platform == "win32")
|
||||
)
|
||||
if result.returncode != 0:
|
||||
errors.append("Claude CLI not found - required for MCP server management")
|
||||
else:
|
||||
version = result.stdout.strip()
|
||||
self.logger.debug(f"Found Claude CLI {version}")
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
errors.append("Claude CLI not found - required for MCP server management")
|
||||
|
||||
# Check if npm is available
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["npm", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
shell=(sys.platform == "win32")
|
||||
)
|
||||
if result.returncode != 0:
|
||||
errors.append("npm not found - required for MCP server installation")
|
||||
else:
|
||||
version = result.stdout.strip()
|
||||
self.logger.debug(f"Found npm {version}")
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
errors.append("npm not found - required for MCP server installation")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def get_files_to_install(self) -> List[Tuple[Path, Path]]:
|
||||
"""Get files to install (none for MCP component)"""
|
||||
return []
|
||||
|
||||
def get_metadata_modifications(self) -> Dict[str, Any]:
|
||||
"""Get metadata modifications for MCP component"""
|
||||
return {
|
||||
"components": {
|
||||
"mcp": {
|
||||
"version": "3.0.0",
|
||||
"installed": True,
|
||||
"servers_count": len(self.mcp_servers)
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
"enabled": True,
|
||||
"servers": list(self.mcp_servers.keys()),
|
||||
"auto_update": False
|
||||
}
|
||||
}
|
||||
|
||||
def _check_mcp_server_installed(self, server_name: str) -> bool:
|
||||
"""Check if MCP server is already installed"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "mcp", "list"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
shell=(sys.platform == "win32")
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
self.logger.warning(f"Could not list MCP servers: {result.stderr}")
|
||||
return False
|
||||
|
||||
# Parse output to check if server is installed
|
||||
output = result.stdout.lower()
|
||||
return server_name.lower() in output
|
||||
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
|
||||
self.logger.warning(f"Error checking MCP server status: {e}")
|
||||
return False
|
||||
|
||||
def _install_mcp_server(self, server_info: Dict[str, Any], config: Dict[str, Any]) -> bool:
|
||||
"""Install a single MCP server"""
|
||||
server_name = server_info["name"]
|
||||
npm_package = server_info["npm_package"]
|
||||
|
||||
command = "npx"
|
||||
|
||||
try:
|
||||
self.logger.info(f"Installing MCP server: {server_name}")
|
||||
|
||||
# Check if already installed
|
||||
if self._check_mcp_server_installed(server_name):
|
||||
self.logger.info(f"MCP server {server_name} already installed")
|
||||
return True
|
||||
|
||||
# Handle API key requirements
|
||||
if "api_key_env" in server_info:
|
||||
api_key_env = server_info["api_key_env"]
|
||||
api_key_desc = server_info.get("api_key_description", f"API key for {server_name}")
|
||||
|
||||
if not config.get("dry_run", False):
|
||||
display_info(f"MCP server '{server_name}' requires an API key")
|
||||
display_info(f"Environment variable: {api_key_env}")
|
||||
display_info(f"Description: {api_key_desc}")
|
||||
|
||||
# Check if API key is already set
|
||||
import os
|
||||
if not os.getenv(api_key_env):
|
||||
display_warning(f"API key {api_key_env} not found in environment")
|
||||
self.logger.warning(f"Proceeding without {api_key_env} - server may not function properly")
|
||||
|
||||
# Install using Claude CLI
|
||||
if config.get("dry_run"):
|
||||
self.logger.info(f"Would install MCP server (user scope): claude mcp add -s user {server_name} {command} -y {npm_package}")
|
||||
return True
|
||||
|
||||
self.logger.debug(f"Running: claude mcp add -s user {server_name} {command} -y {npm_package}")
|
||||
|
||||
result = subprocess.run(
|
||||
["claude", "mcp", "add", "-s", "user", "--", server_name, command, "-y", npm_package],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120, # 2 minutes timeout for installation
|
||||
shell=(sys.platform == "win32")
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
self.logger.success(f"Successfully installed MCP server (user scope): {server_name}")
|
||||
return True
|
||||
else:
|
||||
error_msg = result.stderr.strip() if result.stderr else "Unknown error"
|
||||
self.logger.error(f"Failed to install MCP server {server_name}: {error_msg}")
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error(f"Timeout installing MCP server {server_name}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error installing MCP server {server_name}: {e}")
|
||||
return False
|
||||
|
||||
def _uninstall_mcp_server(self, server_name: str) -> bool:
|
||||
"""Uninstall a single MCP server"""
|
||||
try:
|
||||
self.logger.info(f"Uninstalling MCP server: {server_name}")
|
||||
|
||||
# Check if installed
|
||||
if not self._check_mcp_server_installed(server_name):
|
||||
self.logger.info(f"MCP server {server_name} not installed")
|
||||
return True
|
||||
|
||||
self.logger.debug(f"Running: claude mcp remove {server_name} (auto-detect scope)")
|
||||
|
||||
result = subprocess.run(
|
||||
["claude", "mcp", "remove", server_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
shell=(sys.platform == "win32")
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
self.logger.success(f"Successfully uninstalled MCP server: {server_name}")
|
||||
return True
|
||||
else:
|
||||
error_msg = result.stderr.strip() if result.stderr else "Unknown error"
|
||||
self.logger.error(f"Failed to uninstall MCP server {server_name}: {error_msg}")
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error(f"Timeout uninstalling MCP server {server_name}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error uninstalling MCP server {server_name}: {e}")
|
||||
return False
|
||||
|
||||
def _install(self, config: Dict[str, Any]) -> bool:
|
||||
"""Install MCP component"""
|
||||
self.logger.info("Installing SuperClaude MCP servers...")
|
||||
|
||||
# Validate prerequisites
|
||||
success, errors = self.validate_prerequisites()
|
||||
if not success:
|
||||
for error in errors:
|
||||
self.logger.error(error)
|
||||
return False
|
||||
|
||||
# Install each MCP server
|
||||
installed_count = 0
|
||||
failed_servers = []
|
||||
|
||||
for server_name, server_info in self.mcp_servers.items():
|
||||
if self._install_mcp_server(server_info, config):
|
||||
installed_count += 1
|
||||
else:
|
||||
failed_servers.append(server_name)
|
||||
|
||||
# Check if this is a required server
|
||||
if server_info.get("required", False):
|
||||
self.logger.error(f"Required MCP server {server_name} failed to install")
|
||||
return False
|
||||
|
||||
# Verify installation
|
||||
if not config.get("dry_run", False):
|
||||
self.logger.info("Verifying MCP server installation...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "mcp", "list"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
shell=(sys.platform == "win32")
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
self.logger.debug("MCP servers list:")
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if line.strip():
|
||||
self.logger.debug(f" {line.strip()}")
|
||||
else:
|
||||
self.logger.warning("Could not verify MCP server installation")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not verify MCP installation: {e}")
|
||||
|
||||
if failed_servers:
|
||||
self.logger.warning(f"Some MCP servers failed to install: {failed_servers}")
|
||||
self.logger.success(f"MCP component partially installed ({installed_count} servers)")
|
||||
else:
|
||||
self.logger.success(f"MCP component installed successfully ({installed_count} servers)")
|
||||
|
||||
return self._post_install()
|
||||
|
||||
def _post_install(self) -> bool:
|
||||
# Update metadata
|
||||
try:
|
||||
metadata_mods = self.get_metadata_modifications()
|
||||
self.settings_manager.update_metadata(metadata_mods)
|
||||
|
||||
# Add component registration to metadata
|
||||
self.settings_manager.add_component_registration("mcp", {
|
||||
"version": "3.0.0",
|
||||
"category": "integration",
|
||||
"servers_count": len(self.mcp_servers)
|
||||
})
|
||||
|
||||
self.logger.info("Updated metadata with MCP component registration")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to update metadata: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def uninstall(self) -> bool:
|
||||
"""Uninstall MCP component"""
|
||||
try:
|
||||
self.logger.info("Uninstalling SuperClaude MCP servers...")
|
||||
|
||||
# Uninstall each MCP server
|
||||
uninstalled_count = 0
|
||||
|
||||
for server_name in self.mcp_servers.keys():
|
||||
if self._uninstall_mcp_server(server_name):
|
||||
uninstalled_count += 1
|
||||
|
||||
# Update metadata to remove MCP component
|
||||
try:
|
||||
if self.settings_manager.is_component_installed("mcp"):
|
||||
self.settings_manager.remove_component_registration("mcp")
|
||||
# Also remove MCP configuration from metadata
|
||||
metadata = self.settings_manager.load_metadata()
|
||||
if "mcp" in metadata:
|
||||
del metadata["mcp"]
|
||||
self.settings_manager.save_metadata(metadata)
|
||||
self.logger.info("Removed MCP component from metadata")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not update metadata: {e}")
|
||||
|
||||
self.logger.success(f"MCP component uninstalled ({uninstalled_count} servers removed)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Unexpected error during MCP uninstallation: {e}")
|
||||
return False
|
||||
|
||||
def get_dependencies(self) -> List[str]:
|
||||
"""Get dependencies"""
|
||||
return ["core"]
|
||||
|
||||
def update(self, config: Dict[str, Any]) -> bool:
|
||||
"""Update MCP component"""
|
||||
try:
|
||||
self.logger.info("Updating SuperClaude MCP servers...")
|
||||
|
||||
# Check current version
|
||||
current_version = self.settings_manager.get_component_version("mcp")
|
||||
target_version = self.get_metadata()["version"]
|
||||
|
||||
if current_version == target_version:
|
||||
self.logger.info(f"MCP component already at version {target_version}")
|
||||
return True
|
||||
|
||||
self.logger.info(f"Updating MCP component from {current_version} to {target_version}")
|
||||
|
||||
# For MCP servers, update means reinstall to get latest versions
|
||||
updated_count = 0
|
||||
failed_servers = []
|
||||
|
||||
for server_name, server_info in self.mcp_servers.items():
|
||||
try:
|
||||
# Uninstall old version
|
||||
if self._check_mcp_server_installed(server_name):
|
||||
self._uninstall_mcp_server(server_name)
|
||||
|
||||
# Install new version
|
||||
if self._install_mcp_server(server_info, config):
|
||||
updated_count += 1
|
||||
else:
|
||||
failed_servers.append(server_name)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating MCP server {server_name}: {e}")
|
||||
failed_servers.append(server_name)
|
||||
|
||||
# Update metadata
|
||||
try:
|
||||
# Update component version in metadata
|
||||
metadata = self.settings_manager.load_metadata()
|
||||
if "components" in metadata and "mcp" in metadata["components"]:
|
||||
metadata["components"]["mcp"]["version"] = target_version
|
||||
metadata["components"]["mcp"]["servers_count"] = len(self.mcp_servers)
|
||||
if "mcp" in metadata:
|
||||
metadata["mcp"]["servers"] = list(self.mcp_servers.keys())
|
||||
self.settings_manager.save_metadata(metadata)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not update metadata: {e}")
|
||||
|
||||
if failed_servers:
|
||||
self.logger.warning(f"Some MCP servers failed to update: {failed_servers}")
|
||||
return False
|
||||
else:
|
||||
self.logger.success(f"MCP component updated to version {target_version}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Unexpected error during MCP update: {e}")
|
||||
return False
|
||||
|
||||
def validate_installation(self) -> Tuple[bool, List[str]]:
|
||||
"""Validate MCP component installation"""
|
||||
errors = []
|
||||
|
||||
# Check metadata registration
|
||||
if not self.settings_manager.is_component_installed("mcp"):
|
||||
errors.append("MCP component not registered in metadata")
|
||||
return False, errors
|
||||
|
||||
# Check version matches
|
||||
installed_version = self.settings_manager.get_component_version("mcp")
|
||||
expected_version = self.get_metadata()["version"]
|
||||
if installed_version != expected_version:
|
||||
errors.append(f"Version mismatch: installed {installed_version}, expected {expected_version}")
|
||||
|
||||
# Check if Claude CLI is available
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "mcp", "list"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
shell=(sys.platform == "win32")
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
errors.append("Could not communicate with Claude CLI for MCP server verification")
|
||||
else:
|
||||
# Check if required servers are installed
|
||||
output = result.stdout.lower()
|
||||
for server_name, server_info in self.mcp_servers.items():
|
||||
if server_info.get("required", False):
|
||||
if server_name.lower() not in output:
|
||||
errors.append(f"Required MCP server not found: {server_name}")
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Could not verify MCP server installation: {e}")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def _get_source_dir(self):
|
||||
"""Get source directory for framework files"""
|
||||
return None
|
||||
|
||||
def get_size_estimate(self) -> int:
|
||||
"""Get estimated installation size"""
|
||||
# MCP servers are installed via npm, estimate based on typical sizes
|
||||
base_size = 50 * 1024 * 1024 # ~50MB for all servers combined
|
||||
return base_size
|
||||
|
||||
def get_installation_summary(self) -> Dict[str, Any]:
|
||||
"""Get installation summary"""
|
||||
return {
|
||||
"component": self.get_metadata()["name"],
|
||||
"version": self.get_metadata()["version"],
|
||||
"servers_count": len(self.mcp_servers),
|
||||
"mcp_servers": list(self.mcp_servers.keys()),
|
||||
"estimated_size": self.get_size_estimate(),
|
||||
"dependencies": self.get_dependencies(),
|
||||
"required_tools": ["node", "npm", "claude"]
|
||||
}
|
||||
9
setup/core/__init__.py
Normal file
9
setup/core/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Core modules for SuperClaude installation system"""
|
||||
|
||||
from .validator import Validator
|
||||
from .registry import ComponentRegistry
|
||||
|
||||
__all__ = [
|
||||
'Validator',
|
||||
'ComponentRegistry'
|
||||
]
|
||||
395
setup/core/registry.py
Normal file
395
setup/core/registry.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
Component registry for auto-discovery and dependency resolution
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
from typing import Dict, List, Set, Optional, Type
|
||||
from pathlib import Path
|
||||
from ..base.component import Component
|
||||
|
||||
|
||||
class ComponentRegistry:
|
||||
"""Auto-discovery and management of installable components"""
|
||||
|
||||
def __init__(self, components_dir: Path):
|
||||
"""
|
||||
Initialize component registry
|
||||
|
||||
Args:
|
||||
components_dir: Directory containing component modules
|
||||
"""
|
||||
self.components_dir = components_dir
|
||||
self.component_classes: Dict[str, Type[Component]] = {}
|
||||
self.component_instances: Dict[str, Component] = {}
|
||||
self.dependency_graph: Dict[str, Set[str]] = {}
|
||||
self._discovered = False
|
||||
|
||||
def discover_components(self, force_reload: bool = False) -> None:
|
||||
"""
|
||||
Auto-discover all component classes in components directory
|
||||
|
||||
Args:
|
||||
force_reload: Force rediscovery even if already done
|
||||
"""
|
||||
if self._discovered and not force_reload:
|
||||
return
|
||||
|
||||
self.component_classes.clear()
|
||||
self.component_instances.clear()
|
||||
self.dependency_graph.clear()
|
||||
|
||||
if not self.components_dir.exists():
|
||||
return
|
||||
|
||||
# Add components directory to Python path temporarily
|
||||
import sys
|
||||
original_path = sys.path.copy()
|
||||
|
||||
try:
|
||||
# Add parent directory to path so we can import setup.components
|
||||
setup_dir = self.components_dir.parent
|
||||
if str(setup_dir) not in sys.path:
|
||||
sys.path.insert(0, str(setup_dir))
|
||||
|
||||
# Discover all Python files in components directory
|
||||
for py_file in self.components_dir.glob("*.py"):
|
||||
if py_file.name.startswith("__"):
|
||||
continue
|
||||
|
||||
module_name = py_file.stem
|
||||
self._load_component_module(module_name)
|
||||
|
||||
finally:
|
||||
# Restore original Python path
|
||||
sys.path = original_path
|
||||
|
||||
# Build dependency graph
|
||||
self._build_dependency_graph()
|
||||
self._discovered = True
|
||||
|
||||
def _load_component_module(self, module_name: str) -> None:
|
||||
"""
|
||||
Load component classes from a module
|
||||
|
||||
Args:
|
||||
module_name: Name of module to load
|
||||
"""
|
||||
try:
|
||||
# Import the module
|
||||
full_module_name = f"setup.components.{module_name}"
|
||||
module = importlib.import_module(full_module_name)
|
||||
|
||||
# Find all Component subclasses in the module
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if (inspect.isclass(obj) and
|
||||
issubclass(obj, Component) and
|
||||
obj is not Component):
|
||||
|
||||
# Create instance to get metadata
|
||||
try:
|
||||
instance = obj()
|
||||
metadata = instance.get_metadata()
|
||||
component_name = metadata["name"]
|
||||
|
||||
self.component_classes[component_name] = obj
|
||||
self.component_instances[component_name] = instance
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not instantiate component {name}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load component module {module_name}: {e}")
|
||||
|
||||
def _build_dependency_graph(self) -> None:
|
||||
"""Build dependency graph for all discovered components"""
|
||||
for name, instance in self.component_instances.items():
|
||||
try:
|
||||
dependencies = instance.get_dependencies()
|
||||
self.dependency_graph[name] = set(dependencies)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not get dependencies for {name}: {e}")
|
||||
self.dependency_graph[name] = set()
|
||||
|
||||
def get_component_class(self, component_name: str) -> Optional[Type[Component]]:
|
||||
"""
|
||||
Get component class by name
|
||||
|
||||
Args:
|
||||
component_name: Name of component
|
||||
|
||||
Returns:
|
||||
Component class or None if not found
|
||||
"""
|
||||
self.discover_components()
|
||||
return self.component_classes.get(component_name)
|
||||
|
||||
def get_component_instance(self, component_name: str, install_dir: Optional[Path] = None) -> Optional[Component]:
|
||||
"""
|
||||
Get component instance by name
|
||||
|
||||
Args:
|
||||
component_name: Name of component
|
||||
install_dir: Installation directory (creates new instance with this dir)
|
||||
|
||||
Returns:
|
||||
Component instance or None if not found
|
||||
"""
|
||||
self.discover_components()
|
||||
|
||||
if install_dir is not None:
|
||||
# Create new instance with specified install directory
|
||||
component_class = self.component_classes.get(component_name)
|
||||
if component_class:
|
||||
try:
|
||||
return component_class(install_dir)
|
||||
except Exception as e:
|
||||
print(f"Error creating component instance {component_name}: {e}")
|
||||
return None
|
||||
|
||||
return self.component_instances.get(component_name)
|
||||
|
||||
def list_components(self) -> List[str]:
|
||||
"""
|
||||
Get list of all discovered component names
|
||||
|
||||
Returns:
|
||||
List of component names
|
||||
"""
|
||||
self.discover_components()
|
||||
return list(self.component_classes.keys())
|
||||
|
||||
def get_component_metadata(self, component_name: str) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Get metadata for a component
|
||||
|
||||
Args:
|
||||
component_name: Name of component
|
||||
|
||||
Returns:
|
||||
Component metadata dict or None if not found
|
||||
"""
|
||||
self.discover_components()
|
||||
instance = self.component_instances.get(component_name)
|
||||
if instance:
|
||||
try:
|
||||
return instance.get_metadata()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def resolve_dependencies(self, component_names: List[str]) -> List[str]:
|
||||
"""
|
||||
Resolve component dependencies in correct installation order
|
||||
|
||||
Args:
|
||||
component_names: List of component names to install
|
||||
|
||||
Returns:
|
||||
Ordered list of component names including dependencies
|
||||
|
||||
Raises:
|
||||
ValueError: If circular dependencies detected or unknown component
|
||||
"""
|
||||
self.discover_components()
|
||||
|
||||
resolved = []
|
||||
resolving = set()
|
||||
|
||||
def resolve(name: str):
|
||||
if name in resolved:
|
||||
return
|
||||
|
||||
if name in resolving:
|
||||
raise ValueError(f"Circular dependency detected involving {name}")
|
||||
|
||||
if name not in self.dependency_graph:
|
||||
raise ValueError(f"Unknown component: {name}")
|
||||
|
||||
resolving.add(name)
|
||||
|
||||
# Resolve dependencies first
|
||||
for dep in self.dependency_graph[name]:
|
||||
resolve(dep)
|
||||
|
||||
resolving.remove(name)
|
||||
resolved.append(name)
|
||||
|
||||
# Resolve each requested component
|
||||
for name in component_names:
|
||||
resolve(name)
|
||||
|
||||
return resolved
|
||||
|
||||
def get_dependencies(self, component_name: str) -> Set[str]:
|
||||
"""
|
||||
Get direct dependencies for a component
|
||||
|
||||
Args:
|
||||
component_name: Name of component
|
||||
|
||||
Returns:
|
||||
Set of dependency component names
|
||||
"""
|
||||
self.discover_components()
|
||||
return self.dependency_graph.get(component_name, set())
|
||||
|
||||
def get_dependents(self, component_name: str) -> Set[str]:
|
||||
"""
|
||||
Get components that depend on the given component
|
||||
|
||||
Args:
|
||||
component_name: Name of component
|
||||
|
||||
Returns:
|
||||
Set of component names that depend on this component
|
||||
"""
|
||||
self.discover_components()
|
||||
dependents = set()
|
||||
|
||||
for name, deps in self.dependency_graph.items():
|
||||
if component_name in deps:
|
||||
dependents.add(name)
|
||||
|
||||
return dependents
|
||||
|
||||
def validate_dependency_graph(self) -> List[str]:
|
||||
"""
|
||||
Validate dependency graph for cycles and missing dependencies
|
||||
|
||||
Returns:
|
||||
List of validation errors (empty if valid)
|
||||
"""
|
||||
self.discover_components()
|
||||
errors = []
|
||||
|
||||
# Check for missing dependencies
|
||||
all_components = set(self.dependency_graph.keys())
|
||||
for name, deps in self.dependency_graph.items():
|
||||
missing_deps = deps - all_components
|
||||
if missing_deps:
|
||||
errors.append(f"Component {name} has missing dependencies: {missing_deps}")
|
||||
|
||||
# Check for circular dependencies
|
||||
for name in all_components:
|
||||
try:
|
||||
self.resolve_dependencies([name])
|
||||
except ValueError as e:
|
||||
errors.append(str(e))
|
||||
|
||||
return errors
|
||||
|
||||
def get_components_by_category(self, category: str) -> List[str]:
|
||||
"""
|
||||
Get components filtered by category
|
||||
|
||||
Args:
|
||||
category: Component category to filter by
|
||||
|
||||
Returns:
|
||||
List of component names in the category
|
||||
"""
|
||||
self.discover_components()
|
||||
components = []
|
||||
|
||||
for name, instance in self.component_instances.items():
|
||||
try:
|
||||
metadata = instance.get_metadata()
|
||||
if metadata.get("category") == category:
|
||||
components.append(name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return components
|
||||
|
||||
def get_installation_order(self, component_names: List[str]) -> List[List[str]]:
|
||||
"""
|
||||
Get installation order grouped by dependency levels
|
||||
|
||||
Args:
|
||||
component_names: List of component names to install
|
||||
|
||||
Returns:
|
||||
List of lists, where each inner list contains components
|
||||
that can be installed in parallel at that dependency level
|
||||
"""
|
||||
self.discover_components()
|
||||
|
||||
# Get all components including dependencies
|
||||
all_components = set(self.resolve_dependencies(component_names))
|
||||
|
||||
# Group by dependency level
|
||||
levels = []
|
||||
remaining = all_components.copy()
|
||||
|
||||
while remaining:
|
||||
# Find components with no unresolved dependencies
|
||||
current_level = []
|
||||
for name in list(remaining):
|
||||
deps = self.dependency_graph.get(name, set())
|
||||
unresolved_deps = deps & remaining
|
||||
|
||||
if not unresolved_deps:
|
||||
current_level.append(name)
|
||||
|
||||
if not current_level:
|
||||
# This shouldn't happen if dependency graph is valid
|
||||
raise ValueError("Circular dependency detected in installation order calculation")
|
||||
|
||||
levels.append(current_level)
|
||||
remaining -= set(current_level)
|
||||
|
||||
return levels
|
||||
|
||||
def create_component_instances(self, component_names: List[str], install_dir: Optional[Path] = None) -> Dict[str, Component]:
|
||||
"""
|
||||
Create instances for multiple components
|
||||
|
||||
Args:
|
||||
component_names: List of component names
|
||||
install_dir: Installation directory for instances
|
||||
|
||||
Returns:
|
||||
Dict mapping component names to instances
|
||||
"""
|
||||
self.discover_components()
|
||||
instances = {}
|
||||
|
||||
for name in component_names:
|
||||
instance = self.get_component_instance(name, install_dir)
|
||||
if instance:
|
||||
instances[name] = instance
|
||||
else:
|
||||
print(f"Warning: Could not create instance for component {name}")
|
||||
|
||||
return instances
|
||||
|
||||
def get_registry_info(self) -> Dict[str, any]:
|
||||
"""
|
||||
Get comprehensive registry information
|
||||
|
||||
Returns:
|
||||
Dict with registry statistics and component info
|
||||
"""
|
||||
self.discover_components()
|
||||
|
||||
# Group components by category
|
||||
categories = {}
|
||||
for name, instance in self.component_instances.items():
|
||||
try:
|
||||
metadata = instance.get_metadata()
|
||||
category = metadata.get("category", "unknown")
|
||||
if category not in categories:
|
||||
categories[category] = []
|
||||
categories[category].append(name)
|
||||
except Exception:
|
||||
if "unknown" not in categories:
|
||||
categories["unknown"] = []
|
||||
categories["unknown"].append(name)
|
||||
|
||||
return {
|
||||
"total_components": len(self.component_classes),
|
||||
"categories": categories,
|
||||
"dependency_graph": {name: list(deps) for name, deps in self.dependency_graph.items()},
|
||||
"validation_errors": self.validate_dependency_graph()
|
||||
}
|
||||
685
setup/core/validator.py
Normal file
685
setup/core/validator.py
Normal file
@@ -0,0 +1,685 @@
|
||||
"""
|
||||
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
|
||||
|
||||
# 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(
|
||||
Path.home(),
|
||||
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 = Path.home()
|
||||
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 ..managers.config_manager import ConfigManager
|
||||
from .. import PROJECT_ROOT
|
||||
|
||||
config_manager = ConfigManager(PROJECT_ROOT / "config")
|
||||
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(Path.home())
|
||||
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()
|
||||
9
setup/managers/__init__.py
Normal file
9
setup/managers/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .config_manager import ConfigManager
|
||||
from .settings_manager import SettingsManager
|
||||
from .file_manager import FileManager
|
||||
|
||||
__all__ = [
|
||||
'ConfigManager',
|
||||
'SettingsManager',
|
||||
'FileManager'
|
||||
]
|
||||
399
setup/managers/config_manager.py
Normal file
399
setup/managers/config_manager.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
Configuration management for SuperClaude installation system
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pathlib import Path
|
||||
|
||||
# Handle jsonschema import - if not available, use basic validation
|
||||
try:
|
||||
import jsonschema
|
||||
from jsonschema import validate, ValidationError
|
||||
JSONSCHEMA_AVAILABLE = True
|
||||
except ImportError:
|
||||
JSONSCHEMA_AVAILABLE = False
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Simple validation error for when jsonschema is not available"""
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
def validate(instance, schema):
|
||||
"""Dummy validation function"""
|
||||
# Basic type checking only
|
||||
if "type" in schema:
|
||||
expected_type = schema["type"]
|
||||
if expected_type == "object" and not isinstance(instance, dict):
|
||||
raise ValidationError(f"Expected object, got {type(instance).__name__}")
|
||||
elif expected_type == "array" and not isinstance(instance, list):
|
||||
raise ValidationError(f"Expected array, got {type(instance).__name__}")
|
||||
elif expected_type == "string" and not isinstance(instance, str):
|
||||
raise ValidationError(f"Expected string, got {type(instance).__name__}")
|
||||
elif expected_type == "integer" and not isinstance(instance, int):
|
||||
raise ValidationError(f"Expected integer, got {type(instance).__name__}")
|
||||
# Skip detailed validation if jsonschema not available
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""Manages configuration files and validation"""
|
||||
|
||||
def __init__(self, config_dir: Path):
|
||||
"""
|
||||
Initialize config manager
|
||||
|
||||
Args:
|
||||
config_dir: Directory containing configuration files
|
||||
"""
|
||||
self.config_dir = config_dir
|
||||
self.features_file = config_dir / "features.json"
|
||||
self.requirements_file = config_dir / "requirements.json"
|
||||
self._features_cache = None
|
||||
self._requirements_cache = None
|
||||
|
||||
# Schema for features.json
|
||||
self.features_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"components": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z_][a-zA-Z0-9_]*$": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"version": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"category": {"type": "string"},
|
||||
"dependencies": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
},
|
||||
"enabled": {"type": "boolean"},
|
||||
"required_tools": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"required": ["name", "version", "description", "category"],
|
||||
"additionalProperties": False
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["components"],
|
||||
"additionalProperties": False
|
||||
}
|
||||
|
||||
# Schema for requirements.json
|
||||
self.requirements_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"python": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min_version": {"type": "string"},
|
||||
"max_version": {"type": "string"}
|
||||
},
|
||||
"required": ["min_version"]
|
||||
},
|
||||
"node": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min_version": {"type": "string"},
|
||||
"max_version": {"type": "string"},
|
||||
"required_for": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"required": ["min_version"]
|
||||
},
|
||||
"disk_space_mb": {"type": "integer"},
|
||||
"external_tools": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z_][a-zA-Z0-9_-]*$": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "string"},
|
||||
"min_version": {"type": "string"},
|
||||
"required_for": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
},
|
||||
"optional": {"type": "boolean"}
|
||||
},
|
||||
"required": ["command"],
|
||||
"additionalProperties": False
|
||||
}
|
||||
}
|
||||
},
|
||||
"installation_commands": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z_][a-zA-Z0-9_-]*$": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"linux": {"type": "string"},
|
||||
"darwin": {"type": "string"},
|
||||
"win32": {"type": "string"},
|
||||
"all": {"type": "string"},
|
||||
"description": {"type": "string"}
|
||||
},
|
||||
"additionalProperties": False
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["python", "disk_space_mb"],
|
||||
"additionalProperties": False
|
||||
}
|
||||
|
||||
def load_features(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Load and validate features configuration
|
||||
|
||||
Returns:
|
||||
Features configuration dict
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If features.json not found
|
||||
ValidationError: If features.json is invalid
|
||||
"""
|
||||
if self._features_cache is not None:
|
||||
return self._features_cache
|
||||
|
||||
if not self.features_file.exists():
|
||||
raise FileNotFoundError(f"Features config not found: {self.features_file}")
|
||||
|
||||
try:
|
||||
with open(self.features_file, 'r') as f:
|
||||
features = json.load(f)
|
||||
|
||||
# Validate schema
|
||||
validate(instance=features, schema=self.features_schema)
|
||||
|
||||
self._features_cache = features
|
||||
return features
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValidationError(f"Invalid JSON in {self.features_file}: {e}")
|
||||
except ValidationError as e:
|
||||
raise ValidationError(f"Invalid features schema: {e.message}")
|
||||
|
||||
def load_requirements(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Load and validate requirements configuration
|
||||
|
||||
Returns:
|
||||
Requirements configuration dict
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If requirements.json not found
|
||||
ValidationError: If requirements.json is invalid
|
||||
"""
|
||||
if self._requirements_cache is not None:
|
||||
return self._requirements_cache
|
||||
|
||||
if not self.requirements_file.exists():
|
||||
raise FileNotFoundError(f"Requirements config not found: {self.requirements_file}")
|
||||
|
||||
try:
|
||||
with open(self.requirements_file, 'r') as f:
|
||||
requirements = json.load(f)
|
||||
|
||||
# Validate schema
|
||||
validate(instance=requirements, schema=self.requirements_schema)
|
||||
|
||||
self._requirements_cache = requirements
|
||||
return requirements
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValidationError(f"Invalid JSON in {self.requirements_file}: {e}")
|
||||
except ValidationError as e:
|
||||
raise ValidationError(f"Invalid requirements schema: {e.message}")
|
||||
|
||||
def get_component_info(self, component_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get information about a specific component
|
||||
|
||||
Args:
|
||||
component_name: Name of component
|
||||
|
||||
Returns:
|
||||
Component info dict or None if not found
|
||||
"""
|
||||
features = self.load_features()
|
||||
return features.get("components", {}).get(component_name)
|
||||
|
||||
def get_enabled_components(self) -> List[str]:
|
||||
"""
|
||||
Get list of enabled component names
|
||||
|
||||
Returns:
|
||||
List of enabled component names
|
||||
"""
|
||||
features = self.load_features()
|
||||
enabled = []
|
||||
|
||||
for name, info in features.get("components", {}).items():
|
||||
if info.get("enabled", True): # Default to enabled
|
||||
enabled.append(name)
|
||||
|
||||
return enabled
|
||||
|
||||
def get_components_by_category(self, category: str) -> List[str]:
|
||||
"""
|
||||
Get component names by category
|
||||
|
||||
Args:
|
||||
category: Component category
|
||||
|
||||
Returns:
|
||||
List of component names in category
|
||||
"""
|
||||
features = self.load_features()
|
||||
components = []
|
||||
|
||||
for name, info in features.get("components", {}).items():
|
||||
if info.get("category") == category:
|
||||
components.append(name)
|
||||
|
||||
return components
|
||||
|
||||
def get_component_dependencies(self, component_name: str) -> List[str]:
|
||||
"""
|
||||
Get dependencies for a component
|
||||
|
||||
Args:
|
||||
component_name: Name of component
|
||||
|
||||
Returns:
|
||||
List of dependency component names
|
||||
"""
|
||||
component_info = self.get_component_info(component_name)
|
||||
if component_info:
|
||||
return component_info.get("dependencies", [])
|
||||
return []
|
||||
|
||||
def load_profile(self, profile_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Load installation profile
|
||||
|
||||
Args:
|
||||
profile_path: Path to profile JSON file
|
||||
|
||||
Returns:
|
||||
Profile configuration dict
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If profile not found
|
||||
ValidationError: If profile is invalid
|
||||
"""
|
||||
if not profile_path.exists():
|
||||
raise FileNotFoundError(f"Profile not found: {profile_path}")
|
||||
|
||||
try:
|
||||
with open(profile_path, 'r') as f:
|
||||
profile = json.load(f)
|
||||
|
||||
# Basic validation
|
||||
if "components" not in profile:
|
||||
raise ValidationError("Profile must contain 'components' field")
|
||||
|
||||
if not isinstance(profile["components"], list):
|
||||
raise ValidationError("Profile 'components' must be a list")
|
||||
|
||||
# Validate that all components exist
|
||||
features = self.load_features()
|
||||
available_components = set(features.get("components", {}).keys())
|
||||
|
||||
for component in profile["components"]:
|
||||
if component not in available_components:
|
||||
raise ValidationError(f"Unknown component in profile: {component}")
|
||||
|
||||
return profile
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValidationError(f"Invalid JSON in {profile_path}: {e}")
|
||||
|
||||
def get_system_requirements(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get system requirements
|
||||
|
||||
Returns:
|
||||
System requirements dict
|
||||
"""
|
||||
return self.load_requirements()
|
||||
|
||||
def get_requirements_for_components(self, component_names: List[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
Get consolidated requirements for specific components
|
||||
|
||||
Args:
|
||||
component_names: List of component names
|
||||
|
||||
Returns:
|
||||
Consolidated requirements dict
|
||||
"""
|
||||
requirements = self.load_requirements()
|
||||
features = self.load_features()
|
||||
|
||||
# Start with base requirements
|
||||
result = {
|
||||
"python": requirements["python"],
|
||||
"disk_space_mb": requirements["disk_space_mb"],
|
||||
"external_tools": {}
|
||||
}
|
||||
|
||||
# Add Node.js requirements if needed
|
||||
node_required = False
|
||||
for component_name in component_names:
|
||||
component_info = features.get("components", {}).get(component_name, {})
|
||||
required_tools = component_info.get("required_tools", [])
|
||||
|
||||
if "node" in required_tools:
|
||||
node_required = True
|
||||
break
|
||||
|
||||
if node_required and "node" in requirements:
|
||||
result["node"] = requirements["node"]
|
||||
|
||||
# Add external tool requirements
|
||||
for component_name in component_names:
|
||||
component_info = features.get("components", {}).get(component_name, {})
|
||||
required_tools = component_info.get("required_tools", [])
|
||||
|
||||
for tool in required_tools:
|
||||
if tool in requirements.get("external_tools", {}):
|
||||
result["external_tools"][tool] = requirements["external_tools"][tool]
|
||||
|
||||
return result
|
||||
|
||||
def validate_config_files(self) -> List[str]:
|
||||
"""
|
||||
Validate all configuration files
|
||||
|
||||
Returns:
|
||||
List of validation errors (empty if all valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
try:
|
||||
self.load_features()
|
||||
except Exception as e:
|
||||
errors.append(f"Features config error: {e}")
|
||||
|
||||
try:
|
||||
self.load_requirements()
|
||||
except Exception as e:
|
||||
errors.append(f"Requirements config error: {e}")
|
||||
|
||||
return errors
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear cached configuration data"""
|
||||
self._features_cache = None
|
||||
self._requirements_cache = None
|
||||
428
setup/managers/file_manager.py
Normal file
428
setup/managers/file_manager.py
Normal file
@@ -0,0 +1,428 @@
|
||||
"""
|
||||
Cross-platform file management for SuperClaude installation system
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import stat
|
||||
from typing import List, Optional, Callable, Dict, Any
|
||||
from pathlib import Path
|
||||
import fnmatch
|
||||
import hashlib
|
||||
|
||||
|
||||
class FileManager:
|
||||
"""Cross-platform file operations manager"""
|
||||
|
||||
def __init__(self, dry_run: bool = False):
|
||||
"""
|
||||
Initialize file manager
|
||||
|
||||
Args:
|
||||
dry_run: If True, only simulate file operations
|
||||
"""
|
||||
self.dry_run = dry_run
|
||||
self.copied_files: List[Path] = []
|
||||
self.created_dirs: List[Path] = []
|
||||
|
||||
def copy_file(self, source: Path, target: Path, preserve_permissions: bool = True) -> bool:
|
||||
"""
|
||||
Copy single file with permission preservation
|
||||
|
||||
Args:
|
||||
source: Source file path
|
||||
target: Target file path
|
||||
preserve_permissions: Whether to preserve file permissions
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if not source.exists():
|
||||
raise FileNotFoundError(f"Source file not found: {source}")
|
||||
|
||||
if not source.is_file():
|
||||
raise ValueError(f"Source is not a file: {source}")
|
||||
|
||||
if self.dry_run:
|
||||
print(f"[DRY RUN] Would copy {source} -> {target}")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Ensure target directory exists
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy file
|
||||
if preserve_permissions:
|
||||
shutil.copy2(source, target)
|
||||
else:
|
||||
shutil.copy(source, target)
|
||||
|
||||
self.copied_files.append(target)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error copying {source} to {target}: {e}")
|
||||
return False
|
||||
|
||||
def copy_directory(self, source: Path, target: Path, ignore_patterns: Optional[List[str]] = None) -> bool:
|
||||
"""
|
||||
Recursively copy directory with gitignore-style patterns
|
||||
|
||||
Args:
|
||||
source: Source directory path
|
||||
target: Target directory path
|
||||
ignore_patterns: List of patterns to ignore (gitignore style)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if not source.exists():
|
||||
raise FileNotFoundError(f"Source directory not found: {source}")
|
||||
|
||||
if not source.is_dir():
|
||||
raise ValueError(f"Source is not a directory: {source}")
|
||||
|
||||
ignore_patterns = ignore_patterns or []
|
||||
default_ignores = ['.git', '.gitignore', '__pycache__', '*.pyc', '.DS_Store']
|
||||
all_ignores = ignore_patterns + default_ignores
|
||||
|
||||
if self.dry_run:
|
||||
print(f"[DRY RUN] Would copy directory {source} -> {target}")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Create ignore function
|
||||
def ignore_func(directory: str, contents: List[str]) -> List[str]:
|
||||
ignored = []
|
||||
for item in contents:
|
||||
item_path = Path(directory) / item
|
||||
rel_path = item_path.relative_to(source)
|
||||
|
||||
# Check against ignore patterns
|
||||
for pattern in all_ignores:
|
||||
if fnmatch.fnmatch(item, pattern) or fnmatch.fnmatch(str(rel_path), pattern):
|
||||
ignored.append(item)
|
||||
break
|
||||
|
||||
return ignored
|
||||
|
||||
# Copy tree
|
||||
shutil.copytree(source, target, ignore=ignore_func, dirs_exist_ok=True)
|
||||
|
||||
# Track created directories and files
|
||||
for item in target.rglob('*'):
|
||||
if item.is_dir():
|
||||
self.created_dirs.append(item)
|
||||
else:
|
||||
self.copied_files.append(item)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error copying directory {source} to {target}: {e}")
|
||||
return False
|
||||
|
||||
def ensure_directory(self, directory: Path, mode: int = 0o755) -> bool:
|
||||
"""
|
||||
Create directory and parents if they don't exist
|
||||
|
||||
Args:
|
||||
directory: Directory path to create
|
||||
mode: Directory permissions (Unix only)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if self.dry_run:
|
||||
print(f"[DRY RUN] Would create directory {directory}")
|
||||
return True
|
||||
|
||||
try:
|
||||
directory.mkdir(parents=True, exist_ok=True, mode=mode)
|
||||
|
||||
if directory not in self.created_dirs:
|
||||
self.created_dirs.append(directory)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating directory {directory}: {e}")
|
||||
return False
|
||||
|
||||
def remove_file(self, file_path: Path) -> bool:
|
||||
"""
|
||||
Remove single file
|
||||
|
||||
Args:
|
||||
file_path: Path to file to remove
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if not file_path.exists():
|
||||
return True # Already gone
|
||||
|
||||
if self.dry_run:
|
||||
print(f"[DRY RUN] Would remove file {file_path}")
|
||||
return True
|
||||
|
||||
try:
|
||||
if file_path.is_file():
|
||||
file_path.unlink()
|
||||
else:
|
||||
print(f"Warning: {file_path} is not a file, skipping")
|
||||
return False
|
||||
|
||||
# Remove from tracking
|
||||
if file_path in self.copied_files:
|
||||
self.copied_files.remove(file_path)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error removing file {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def remove_directory(self, directory: Path, recursive: bool = False) -> bool:
|
||||
"""
|
||||
Remove directory
|
||||
|
||||
Args:
|
||||
directory: Directory path to remove
|
||||
recursive: Whether to remove recursively
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if not directory.exists():
|
||||
return True # Already gone
|
||||
|
||||
if self.dry_run:
|
||||
action = "recursively remove" if recursive else "remove"
|
||||
print(f"[DRY RUN] Would {action} directory {directory}")
|
||||
return True
|
||||
|
||||
try:
|
||||
if recursive:
|
||||
shutil.rmtree(directory)
|
||||
else:
|
||||
directory.rmdir() # Only works if empty
|
||||
|
||||
# Remove from tracking
|
||||
if directory in self.created_dirs:
|
||||
self.created_dirs.remove(directory)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error removing directory {directory}: {e}")
|
||||
return False
|
||||
|
||||
def resolve_home_path(self, path: str) -> Path:
|
||||
"""
|
||||
Convert path with ~ to actual home path on any OS
|
||||
|
||||
Args:
|
||||
path: Path string potentially containing ~
|
||||
|
||||
Returns:
|
||||
Resolved Path object
|
||||
"""
|
||||
return Path(path).expanduser().resolve()
|
||||
|
||||
def make_executable(self, file_path: Path) -> bool:
|
||||
"""
|
||||
Make file executable (Unix/Linux/macOS)
|
||||
|
||||
Args:
|
||||
file_path: Path to file to make executable
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if not file_path.exists():
|
||||
return False
|
||||
|
||||
if self.dry_run:
|
||||
print(f"[DRY RUN] Would make {file_path} executable")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Get current permissions
|
||||
current_mode = file_path.stat().st_mode
|
||||
|
||||
# Add execute permissions for owner, group, and others
|
||||
new_mode = current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
||||
|
||||
file_path.chmod(new_mode)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error making {file_path} executable: {e}")
|
||||
return False
|
||||
|
||||
def get_file_hash(self, file_path: Path, algorithm: str = 'sha256') -> Optional[str]:
|
||||
"""
|
||||
Calculate file hash
|
||||
|
||||
Args:
|
||||
file_path: Path to file
|
||||
algorithm: Hash algorithm (md5, sha1, sha256, etc.)
|
||||
|
||||
Returns:
|
||||
Hex hash string or None if error
|
||||
"""
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
hasher = hashlib.new(algorithm)
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
# Read in chunks for large files
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
hasher.update(chunk)
|
||||
|
||||
return hasher.hexdigest()
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def verify_file_integrity(self, file_path: Path, expected_hash: str, algorithm: str = 'sha256') -> bool:
|
||||
"""
|
||||
Verify file integrity using hash
|
||||
|
||||
Args:
|
||||
file_path: Path to file to verify
|
||||
expected_hash: Expected hash value
|
||||
algorithm: Hash algorithm used
|
||||
|
||||
Returns:
|
||||
True if file matches expected hash, False otherwise
|
||||
"""
|
||||
actual_hash = self.get_file_hash(file_path, algorithm)
|
||||
return actual_hash is not None and actual_hash.lower() == expected_hash.lower()
|
||||
|
||||
def get_directory_size(self, directory: Path) -> int:
|
||||
"""
|
||||
Calculate total size of directory in bytes
|
||||
|
||||
Args:
|
||||
directory: Directory path
|
||||
|
||||
Returns:
|
||||
Total size in bytes
|
||||
"""
|
||||
if not directory.exists() or not directory.is_dir():
|
||||
return 0
|
||||
|
||||
total_size = 0
|
||||
try:
|
||||
for file_path in directory.rglob('*'):
|
||||
if file_path.is_file():
|
||||
total_size += file_path.stat().st_size
|
||||
except Exception:
|
||||
pass # Skip files we can't access
|
||||
|
||||
return total_size
|
||||
|
||||
def find_files(self, directory: Path, pattern: str = '*', recursive: bool = True) -> List[Path]:
|
||||
"""
|
||||
Find files matching pattern
|
||||
|
||||
Args:
|
||||
directory: Directory to search
|
||||
pattern: Glob pattern to match
|
||||
recursive: Whether to search recursively
|
||||
|
||||
Returns:
|
||||
List of matching file paths
|
||||
"""
|
||||
if not directory.exists() or not directory.is_dir():
|
||||
return []
|
||||
|
||||
try:
|
||||
if recursive:
|
||||
return list(directory.rglob(pattern))
|
||||
else:
|
||||
return list(directory.glob(pattern))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def backup_file(self, file_path: Path, backup_suffix: str = '.backup') -> Optional[Path]:
|
||||
"""
|
||||
Create backup copy of file
|
||||
|
||||
Args:
|
||||
file_path: Path to file to backup
|
||||
backup_suffix: Suffix to add to backup file
|
||||
|
||||
Returns:
|
||||
Path to backup file or None if failed
|
||||
"""
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
return None
|
||||
|
||||
backup_path = file_path.with_suffix(file_path.suffix + backup_suffix)
|
||||
|
||||
if self.copy_file(file_path, backup_path):
|
||||
return backup_path
|
||||
return None
|
||||
|
||||
def get_free_space(self, path: Path) -> int:
|
||||
"""
|
||||
Get free disk space at path in bytes
|
||||
|
||||
Args:
|
||||
path: Path to check (can be file or directory)
|
||||
|
||||
Returns:
|
||||
Free space in bytes
|
||||
"""
|
||||
try:
|
||||
if path.is_file():
|
||||
path = path.parent
|
||||
|
||||
stat_result = shutil.disk_usage(path)
|
||||
return stat_result.free
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def cleanup_tracked_files(self) -> None:
|
||||
"""Remove all files and directories created during this session"""
|
||||
if self.dry_run:
|
||||
print("[DRY RUN] Would cleanup tracked files")
|
||||
return
|
||||
|
||||
# Remove files first
|
||||
for file_path in reversed(self.copied_files):
|
||||
try:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Remove directories (in reverse order of creation)
|
||||
for directory in reversed(self.created_dirs):
|
||||
try:
|
||||
if directory.exists() and not any(directory.iterdir()):
|
||||
directory.rmdir()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.copied_files.clear()
|
||||
self.created_dirs.clear()
|
||||
|
||||
def get_operation_summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get summary of file operations performed
|
||||
|
||||
Returns:
|
||||
Dict with operation statistics
|
||||
"""
|
||||
return {
|
||||
'files_copied': len(self.copied_files),
|
||||
'directories_created': len(self.created_dirs),
|
||||
'dry_run': self.dry_run,
|
||||
'copied_files': [str(f) for f in self.copied_files],
|
||||
'created_directories': [str(d) for d in self.created_dirs]
|
||||
}
|
||||
515
setup/managers/settings_manager.py
Normal file
515
setup/managers/settings_manager.py
Normal file
@@ -0,0 +1,515 @@
|
||||
"""
|
||||
Settings management for SuperClaude installation system
|
||||
Handles settings.json migration to the new SuperClaude metadata json file
|
||||
Allows for manipulation of these json files with deep merge and backup
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from typing import Dict, Any, Optional, List
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import copy
|
||||
|
||||
|
||||
class SettingsManager:
|
||||
"""Manages settings.json file operations"""
|
||||
|
||||
def __init__(self, install_dir: Path):
|
||||
"""
|
||||
Initialize settings manager
|
||||
|
||||
Args:
|
||||
install_dir: Installation directory containing settings.json
|
||||
"""
|
||||
self.install_dir = install_dir
|
||||
self.settings_file = install_dir / "settings.json"
|
||||
self.metadata_file = install_dir / ".superclaude-metadata.json"
|
||||
self.backup_dir = install_dir / "backups" / "settings"
|
||||
|
||||
def load_settings(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Load settings from settings.json
|
||||
|
||||
Returns:
|
||||
Settings dict (empty if file doesn't exist)
|
||||
"""
|
||||
if not self.settings_file.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
raise ValueError(f"Could not load settings from {self.settings_file}: {e}")
|
||||
|
||||
def save_settings(self, settings: Dict[str, Any], create_backup: bool = True) -> None:
|
||||
"""
|
||||
Save settings to settings.json with optional backup
|
||||
|
||||
Args:
|
||||
settings: Settings dict to save
|
||||
create_backup: Whether to create backup before saving
|
||||
"""
|
||||
# Create backup if requested and file exists
|
||||
if create_backup and self.settings_file.exists():
|
||||
self._create_settings_backup()
|
||||
|
||||
# Ensure directory exists
|
||||
self.settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save with pretty formatting
|
||||
try:
|
||||
with open(self.settings_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(settings, f, indent=2, ensure_ascii=False, sort_keys=True)
|
||||
except IOError as e:
|
||||
raise ValueError(f"Could not save settings to {self.settings_file}: {e}")
|
||||
|
||||
def load_metadata(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Load SuperClaude metadata from .superclaude-metadata.json
|
||||
|
||||
Returns:
|
||||
Metadata dict (empty if file doesn't exist)
|
||||
"""
|
||||
if not self.metadata_file.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(self.metadata_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
raise ValueError(f"Could not load metadata from {self.metadata_file}: {e}")
|
||||
|
||||
def save_metadata(self, metadata: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Save SuperClaude metadata to .superclaude-metadata.json
|
||||
|
||||
Args:
|
||||
metadata: Metadata dict to save
|
||||
"""
|
||||
# Ensure directory exists
|
||||
self.metadata_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save with pretty formatting
|
||||
try:
|
||||
with open(self.metadata_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(metadata, f, indent=2, ensure_ascii=False, sort_keys=True)
|
||||
except IOError as e:
|
||||
raise ValueError(f"Could not save metadata to {self.metadata_file}: {e}")
|
||||
|
||||
def merge_metadata(self, modifications: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Deep merge modifications into existing settings
|
||||
|
||||
Args:
|
||||
modifications: Settings modifications to merge
|
||||
|
||||
Returns:
|
||||
Merged settings dict
|
||||
"""
|
||||
existing = self.load_metadata()
|
||||
return self._deep_merge(existing, modifications)
|
||||
|
||||
def update_metadata(self, modifications: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Update settings with modifications
|
||||
|
||||
Args:
|
||||
modifications: Settings modifications to apply
|
||||
create_backup: Whether to create backup before updating
|
||||
"""
|
||||
merged = self.merge_metadata(modifications)
|
||||
self.save_metadata(merged)
|
||||
|
||||
def migrate_superclaude_data(self) -> bool:
|
||||
"""
|
||||
Migrate SuperClaude-specific data from settings.json to metadata file
|
||||
|
||||
Returns:
|
||||
True if migration occurred, False if no data to migrate
|
||||
"""
|
||||
settings = self.load_settings()
|
||||
|
||||
# SuperClaude-specific fields to migrate
|
||||
superclaude_fields = ["components", "framework", "superclaude", "mcp"]
|
||||
data_to_migrate = {}
|
||||
fields_found = False
|
||||
|
||||
# Extract SuperClaude data
|
||||
for field in superclaude_fields:
|
||||
if field in settings:
|
||||
data_to_migrate[field] = settings[field]
|
||||
fields_found = True
|
||||
|
||||
if not fields_found:
|
||||
return False
|
||||
|
||||
# Load existing metadata (if any) and merge
|
||||
existing_metadata = self.load_metadata()
|
||||
merged_metadata = self._deep_merge(existing_metadata, data_to_migrate)
|
||||
|
||||
# Save to metadata file
|
||||
self.save_metadata(merged_metadata)
|
||||
|
||||
# Remove SuperClaude fields from settings
|
||||
clean_settings = {k: v for k, v in settings.items() if k not in superclaude_fields}
|
||||
|
||||
# Save cleaned settings
|
||||
self.save_settings(clean_settings, create_backup=True)
|
||||
|
||||
return True
|
||||
|
||||
def merge_settings(self, modifications: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Deep merge modifications into existing settings
|
||||
|
||||
Args:
|
||||
modifications: Settings modifications to merge
|
||||
|
||||
Returns:
|
||||
Merged settings dict
|
||||
"""
|
||||
existing = self.load_settings()
|
||||
return self._deep_merge(existing, modifications)
|
||||
|
||||
def update_settings(self, modifications: Dict[str, Any], create_backup: bool = True) -> None:
|
||||
"""
|
||||
Update settings with modifications
|
||||
|
||||
Args:
|
||||
modifications: Settings modifications to apply
|
||||
create_backup: Whether to create backup before updating
|
||||
"""
|
||||
merged = self.merge_settings(modifications)
|
||||
self.save_settings(merged, create_backup)
|
||||
|
||||
def get_setting(self, key_path: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get setting value using dot-notation path
|
||||
|
||||
Args:
|
||||
key_path: Dot-separated path (e.g., "hooks.enabled")
|
||||
default: Default value if key not found
|
||||
|
||||
Returns:
|
||||
Setting value or default
|
||||
"""
|
||||
settings = self.load_settings()
|
||||
|
||||
try:
|
||||
value = settings
|
||||
for key in key_path.split('.'):
|
||||
value = value[key]
|
||||
return value
|
||||
except (KeyError, TypeError):
|
||||
return default
|
||||
|
||||
def set_setting(self, key_path: str, value: Any, create_backup: bool = True) -> None:
|
||||
"""
|
||||
Set setting value using dot-notation path
|
||||
|
||||
Args:
|
||||
key_path: Dot-separated path (e.g., "hooks.enabled")
|
||||
value: Value to set
|
||||
create_backup: Whether to create backup before updating
|
||||
"""
|
||||
# Build nested dict structure
|
||||
keys = key_path.split('.')
|
||||
modification = {}
|
||||
current = modification
|
||||
|
||||
for key in keys[:-1]:
|
||||
current[key] = {}
|
||||
current = current[key]
|
||||
|
||||
current[keys[-1]] = value
|
||||
|
||||
self.update_settings(modification, create_backup)
|
||||
|
||||
def remove_setting(self, key_path: str, create_backup: bool = True) -> bool:
|
||||
"""
|
||||
Remove setting using dot-notation path
|
||||
|
||||
Args:
|
||||
key_path: Dot-separated path to remove
|
||||
create_backup: Whether to create backup before updating
|
||||
|
||||
Returns:
|
||||
True if setting was removed, False if not found
|
||||
"""
|
||||
settings = self.load_settings()
|
||||
keys = key_path.split('.')
|
||||
|
||||
# Navigate to parent of target key
|
||||
current = settings
|
||||
try:
|
||||
for key in keys[:-1]:
|
||||
current = current[key]
|
||||
|
||||
# Remove the target key
|
||||
if keys[-1] in current:
|
||||
del current[keys[-1]]
|
||||
self.save_settings(settings, create_backup)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
except (KeyError, TypeError):
|
||||
return False
|
||||
|
||||
def add_component_registration(self, component_name: str, component_info: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Add component to registry in metadata
|
||||
|
||||
Args:
|
||||
component_name: Name of component
|
||||
component_info: Component metadata dict
|
||||
"""
|
||||
metadata = self.load_metadata()
|
||||
if "components" not in metadata:
|
||||
metadata["components"] = {}
|
||||
|
||||
metadata["components"][component_name] = {
|
||||
**component_info,
|
||||
"installed_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
self.save_metadata(metadata)
|
||||
|
||||
def remove_component_registration(self, component_name: str) -> bool:
|
||||
"""
|
||||
Remove component from registry in metadata
|
||||
|
||||
Args:
|
||||
component_name: Name of component to remove
|
||||
|
||||
Returns:
|
||||
True if component was removed, False if not found
|
||||
"""
|
||||
metadata = self.load_metadata()
|
||||
if "components" in metadata and component_name in metadata["components"]:
|
||||
del metadata["components"][component_name]
|
||||
self.save_metadata(metadata)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_installed_components(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get all installed components from registry
|
||||
|
||||
Returns:
|
||||
Dict of component_name -> component_info
|
||||
"""
|
||||
metadata = self.load_metadata()
|
||||
return metadata.get("components", {})
|
||||
|
||||
def is_component_installed(self, component_name: str) -> bool:
|
||||
"""
|
||||
Check if component is registered as installed
|
||||
|
||||
Args:
|
||||
component_name: Name of component to check
|
||||
|
||||
Returns:
|
||||
True if component is installed, False otherwise
|
||||
"""
|
||||
components = self.get_installed_components()
|
||||
return component_name in components
|
||||
|
||||
def get_component_version(self, component_name: str) -> Optional[str]:
|
||||
"""
|
||||
Get installed version of component
|
||||
|
||||
Args:
|
||||
component_name: Name of component
|
||||
|
||||
Returns:
|
||||
Version string or None if not installed
|
||||
"""
|
||||
components = self.get_installed_components()
|
||||
component_info = components.get(component_name, {})
|
||||
return component_info.get("version")
|
||||
|
||||
def update_framework_version(self, version: str) -> None:
|
||||
"""
|
||||
Update SuperClaude framework version in metadata
|
||||
|
||||
Args:
|
||||
version: Framework version string
|
||||
"""
|
||||
metadata = self.load_metadata()
|
||||
if "framework" not in metadata:
|
||||
metadata["framework"] = {}
|
||||
|
||||
metadata["framework"]["version"] = version
|
||||
metadata["framework"]["updated_at"] = datetime.now().isoformat()
|
||||
|
||||
self.save_metadata(metadata)
|
||||
|
||||
def check_installation_exists(self) -> bool:
|
||||
"""
|
||||
Get SuperClaude framework version from metadata
|
||||
|
||||
Returns:
|
||||
Version string or None if not set
|
||||
"""
|
||||
return self.metadata_file.exists()
|
||||
|
||||
def check_v2_installation_exists(self) -> bool:
|
||||
"""
|
||||
Get SuperClaude framework version from metadata
|
||||
|
||||
Returns:
|
||||
Version string or None if not set
|
||||
"""
|
||||
return self.settings_file.exists()
|
||||
|
||||
def get_metadata_setting(self, key_path: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get metadata value using dot-notation path
|
||||
|
||||
Args:
|
||||
key_path: Dot-separated path (e.g., "framework.version")
|
||||
default: Default value if key not found
|
||||
|
||||
Returns:
|
||||
Metadata value or default
|
||||
"""
|
||||
metadata = self.load_metadata()
|
||||
|
||||
try:
|
||||
value = metadata
|
||||
for key in key_path.split('.'):
|
||||
value = value[key]
|
||||
return value
|
||||
except (KeyError, TypeError):
|
||||
return default
|
||||
|
||||
def _deep_merge(self, base: Dict[str, Any], overlay: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Deep merge two dictionaries
|
||||
|
||||
Args:
|
||||
base: Base dictionary
|
||||
overlay: Dictionary to merge on top
|
||||
|
||||
Returns:
|
||||
Merged dictionary
|
||||
"""
|
||||
result = copy.deepcopy(base)
|
||||
|
||||
for key, value in overlay.items():
|
||||
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||
result[key] = self._deep_merge(result[key], value)
|
||||
else:
|
||||
result[key] = copy.deepcopy(value)
|
||||
|
||||
return result
|
||||
|
||||
def _create_settings_backup(self) -> Path:
|
||||
"""
|
||||
Create timestamped backup of settings.json
|
||||
|
||||
Returns:
|
||||
Path to backup file
|
||||
"""
|
||||
if not self.settings_file.exists():
|
||||
raise ValueError("Cannot backup non-existent settings file")
|
||||
|
||||
# Create backup directory
|
||||
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create timestamped backup
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_file = self.backup_dir / f"settings_{timestamp}.json"
|
||||
|
||||
shutil.copy2(self.settings_file, backup_file)
|
||||
|
||||
# Keep only last 10 backups
|
||||
self._cleanup_old_backups()
|
||||
|
||||
return backup_file
|
||||
|
||||
def _cleanup_old_backups(self, keep_count: int = 10) -> None:
|
||||
"""
|
||||
Remove old backup files, keeping only the most recent
|
||||
|
||||
Args:
|
||||
keep_count: Number of backups to keep
|
||||
"""
|
||||
if not self.backup_dir.exists():
|
||||
return
|
||||
|
||||
# Get all backup files sorted by modification time
|
||||
backup_files = []
|
||||
for file in self.backup_dir.glob("settings_*.json"):
|
||||
backup_files.append((file.stat().st_mtime, file))
|
||||
|
||||
backup_files.sort(reverse=True) # Most recent first
|
||||
|
||||
# Remove old backups
|
||||
for _, file in backup_files[keep_count:]:
|
||||
try:
|
||||
file.unlink()
|
||||
except OSError:
|
||||
pass # Ignore errors when cleaning up
|
||||
|
||||
def list_backups(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List available settings backups
|
||||
|
||||
Returns:
|
||||
List of backup info dicts with name, path, and timestamp
|
||||
"""
|
||||
if not self.backup_dir.exists():
|
||||
return []
|
||||
|
||||
backups = []
|
||||
for file in self.backup_dir.glob("settings_*.json"):
|
||||
try:
|
||||
stat = file.stat()
|
||||
backups.append({
|
||||
"name": file.name,
|
||||
"path": str(file),
|
||||
"size": stat.st_size,
|
||||
"created": datetime.fromtimestamp(stat.st_ctime).isoformat(),
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||
})
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Sort by creation time, most recent first
|
||||
backups.sort(key=lambda x: x["created"], reverse=True)
|
||||
return backups
|
||||
|
||||
def restore_backup(self, backup_name: str) -> bool:
|
||||
"""
|
||||
Restore settings from backup
|
||||
|
||||
Args:
|
||||
backup_name: Name of backup file to restore
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
backup_file = self.backup_dir / backup_name
|
||||
|
||||
if not backup_file.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
# Validate backup file first
|
||||
with open(backup_file, 'r', encoding='utf-8') as f:
|
||||
json.load(f) # Will raise exception if invalid
|
||||
|
||||
# Create backup of current settings
|
||||
if self.settings_file.exists():
|
||||
self._create_settings_backup()
|
||||
|
||||
# Restore backup
|
||||
shutil.copy2(backup_file, self.settings_file)
|
||||
return True
|
||||
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return False
|
||||
85
setup/operations/__init__.py
Normal file
85
setup/operations/__init__.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
SuperClaude Operations Module
|
||||
|
||||
This module contains all SuperClaude management operations that can be
|
||||
executed through the unified CLI hub (SuperClaude).
|
||||
|
||||
Each operation module should implement:
|
||||
- register_parser(subparsers): Register CLI arguments for the operation
|
||||
- run(args): Execute the operation with parsed arguments
|
||||
|
||||
Available operations:
|
||||
- install: Install SuperClaude framework components
|
||||
- update: Update existing SuperClaude installation
|
||||
- uninstall: Remove SuperClaude framework installation
|
||||
- backup: Backup and restore SuperClaude installations
|
||||
"""
|
||||
|
||||
__version__ = "3.0.0"
|
||||
__all__ = ["install", "update", "uninstall", "backup"]
|
||||
|
||||
|
||||
def get_operation_info():
|
||||
"""Get information about available operations"""
|
||||
return {
|
||||
"install": {
|
||||
"name": "install",
|
||||
"description": "Install SuperClaude framework components",
|
||||
"module": "setup.operations.install"
|
||||
},
|
||||
"update": {
|
||||
"name": "update",
|
||||
"description": "Update existing SuperClaude installation",
|
||||
"module": "setup.operations.update"
|
||||
},
|
||||
"uninstall": {
|
||||
"name": "uninstall",
|
||||
"description": "Remove SuperClaude framework installation",
|
||||
"module": "setup.operations.uninstall"
|
||||
},
|
||||
"backup": {
|
||||
"name": "backup",
|
||||
"description": "Backup and restore SuperClaude installations",
|
||||
"module": "setup.operations.backup"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class OperationBase:
|
||||
"""Base class for all operations providing common functionality"""
|
||||
|
||||
def __init__(self, operation_name: str):
|
||||
self.operation_name = operation_name
|
||||
self.logger = None
|
||||
|
||||
def setup_operation_logging(self, args):
|
||||
"""Setup operation-specific logging"""
|
||||
from ..utils.logger import get_logger
|
||||
self.logger = get_logger()
|
||||
self.logger.info(f"Starting {self.operation_name} operation")
|
||||
|
||||
def validate_global_args(self, args):
|
||||
"""Validate global arguments common to all operations"""
|
||||
errors = []
|
||||
|
||||
# Validate install directory
|
||||
if hasattr(args, 'install_dir') and args.install_dir:
|
||||
from ..utils.security import SecurityValidator
|
||||
is_safe, validation_errors = SecurityValidator.validate_installation_target(args.install_dir)
|
||||
if not is_safe:
|
||||
errors.extend(validation_errors)
|
||||
|
||||
# Check for conflicting flags
|
||||
if hasattr(args, 'verbose') and hasattr(args, 'quiet'):
|
||||
if args.verbose and args.quiet:
|
||||
errors.append("Cannot specify both --verbose and --quiet")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def handle_operation_error(self, operation: str, error: Exception):
|
||||
"""Standard error handling for operations"""
|
||||
if self.logger:
|
||||
self.logger.exception(f"Error in {operation} operation: {error}")
|
||||
else:
|
||||
print(f"Error in {operation} operation: {error}")
|
||||
return 1
|
||||
589
setup/operations/backup.py
Normal file
589
setup/operations/backup.py
Normal file
@@ -0,0 +1,589 @@
|
||||
"""
|
||||
SuperClaude Backup Operation Module
|
||||
Refactored from backup.py for unified CLI hub
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import tarfile
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
import argparse
|
||||
|
||||
from ..managers.settings_manager import SettingsManager
|
||||
from ..utils.ui import (
|
||||
display_header, display_info, display_success, display_error,
|
||||
display_warning, Menu, confirm, ProgressBar, Colors, format_size
|
||||
)
|
||||
from ..utils.logger import get_logger
|
||||
from .. import DEFAULT_INSTALL_DIR
|
||||
from . import OperationBase
|
||||
|
||||
|
||||
class BackupOperation(OperationBase):
|
||||
"""Backup operation implementation"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("backup")
|
||||
|
||||
|
||||
def register_parser(subparsers, global_parser=None) -> argparse.ArgumentParser:
|
||||
"""Register backup CLI arguments"""
|
||||
parents = [global_parser] if global_parser else []
|
||||
|
||||
parser = subparsers.add_parser(
|
||||
"backup",
|
||||
help="Backup and restore SuperClaude installations",
|
||||
description="Create, list, restore, and manage SuperClaude installation backups",
|
||||
epilog="""
|
||||
Examples:
|
||||
SuperClaude backup --create # Create new backup
|
||||
SuperClaude backup --list --verbose # List available backups (verbose)
|
||||
SuperClaude backup --restore # Interactive restore
|
||||
SuperClaude backup --restore backup.tar.gz # Restore specific backup
|
||||
SuperClaude backup --info backup.tar.gz # Show backup information
|
||||
SuperClaude backup --cleanup --force # Clean up old backups (forced)
|
||||
""",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
parents=parents
|
||||
)
|
||||
|
||||
# Backup operations (mutually exclusive)
|
||||
operation_group = parser.add_mutually_exclusive_group(required=True)
|
||||
|
||||
operation_group.add_argument(
|
||||
"--create",
|
||||
action="store_true",
|
||||
help="Create a new backup"
|
||||
)
|
||||
|
||||
operation_group.add_argument(
|
||||
"--list",
|
||||
action="store_true",
|
||||
help="List available backups"
|
||||
)
|
||||
|
||||
operation_group.add_argument(
|
||||
"--restore",
|
||||
nargs="?",
|
||||
const="interactive",
|
||||
help="Restore from backup (optionally specify backup file)"
|
||||
)
|
||||
|
||||
operation_group.add_argument(
|
||||
"--info",
|
||||
type=str,
|
||||
help="Show information about a specific backup file"
|
||||
)
|
||||
|
||||
operation_group.add_argument(
|
||||
"--cleanup",
|
||||
action="store_true",
|
||||
help="Clean up old backup files"
|
||||
)
|
||||
|
||||
# Backup options
|
||||
parser.add_argument(
|
||||
"--backup-dir",
|
||||
type=Path,
|
||||
help="Backup directory (default: <install-dir>/backups)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
type=str,
|
||||
help="Custom backup name (for --create)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--compress",
|
||||
choices=["none", "gzip", "bzip2"],
|
||||
default="gzip",
|
||||
help="Compression method (default: gzip)"
|
||||
)
|
||||
|
||||
# Restore options
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="Overwrite existing files during restore"
|
||||
)
|
||||
|
||||
# Cleanup options
|
||||
parser.add_argument(
|
||||
"--keep",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of backups to keep during cleanup (default: 5)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--older-than",
|
||||
type=int,
|
||||
help="Remove backups older than N days"
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def get_backup_directory(args: argparse.Namespace) -> Path:
|
||||
"""Get the backup directory path"""
|
||||
if args.backup_dir:
|
||||
return args.backup_dir
|
||||
else:
|
||||
return args.install_dir / "backups"
|
||||
|
||||
|
||||
def check_installation_exists(install_dir: Path) -> bool:
|
||||
"""Check if SuperClaude installation (v2 included) exists"""
|
||||
settings_manager = SettingsManager(install_dir)
|
||||
|
||||
return settings_manager.check_installation_exists() or settings_manager.check_v2_installation_exists()
|
||||
|
||||
|
||||
def get_backup_info(backup_path: Path) -> Dict[str, Any]:
|
||||
"""Get information about a backup file"""
|
||||
info = {
|
||||
"path": backup_path,
|
||||
"exists": backup_path.exists(),
|
||||
"size": 0,
|
||||
"created": None,
|
||||
"metadata": {}
|
||||
}
|
||||
|
||||
if not backup_path.exists():
|
||||
return info
|
||||
|
||||
try:
|
||||
# Get file stats
|
||||
stats = backup_path.stat()
|
||||
info["size"] = stats.st_size
|
||||
info["created"] = datetime.fromtimestamp(stats.st_mtime)
|
||||
|
||||
# Try to read metadata from backup
|
||||
if backup_path.suffix == ".gz":
|
||||
mode = "r:gz"
|
||||
elif backup_path.suffix == ".bz2":
|
||||
mode = "r:bz2"
|
||||
else:
|
||||
mode = "r"
|
||||
|
||||
with tarfile.open(backup_path, mode) as tar:
|
||||
# Look for metadata file
|
||||
try:
|
||||
metadata_member = tar.getmember("backup_metadata.json")
|
||||
metadata_file = tar.extractfile(metadata_member)
|
||||
if metadata_file:
|
||||
info["metadata"] = json.loads(metadata_file.read().decode())
|
||||
except KeyError:
|
||||
pass # No metadata file
|
||||
|
||||
# Get list of files in backup
|
||||
info["files"] = len(tar.getnames())
|
||||
|
||||
except Exception as e:
|
||||
info["error"] = str(e)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def list_backups(backup_dir: Path) -> List[Dict[str, Any]]:
|
||||
"""List all available backups"""
|
||||
backups = []
|
||||
|
||||
if not backup_dir.exists():
|
||||
return backups
|
||||
|
||||
# Find all backup files
|
||||
for backup_file in backup_dir.glob("*.tar*"):
|
||||
if backup_file.is_file():
|
||||
info = get_backup_info(backup_file)
|
||||
backups.append(info)
|
||||
|
||||
# Sort by creation date (newest first)
|
||||
backups.sort(key=lambda x: x.get("created", datetime.min), reverse=True)
|
||||
|
||||
return backups
|
||||
|
||||
|
||||
def display_backup_list(backups: List[Dict[str, Any]]) -> None:
|
||||
"""Display list of available backups"""
|
||||
print(f"\n{Colors.CYAN}{Colors.BRIGHT}Available Backups{Colors.RESET}")
|
||||
print("=" * 70)
|
||||
|
||||
if not backups:
|
||||
print(f"{Colors.YELLOW}No backups found{Colors.RESET}")
|
||||
return
|
||||
|
||||
print(f"{'Name':<30} {'Size':<10} {'Created':<20} {'Files':<8}")
|
||||
print("-" * 70)
|
||||
|
||||
for backup in backups:
|
||||
name = backup["path"].name
|
||||
size = format_size(backup["size"]) if backup["size"] > 0 else "unknown"
|
||||
created = backup["created"].strftime("%Y-%m-%d %H:%M") if backup["created"] else "unknown"
|
||||
files = str(backup.get("files", "unknown"))
|
||||
|
||||
print(f"{name:<30} {size:<10} {created:<20} {files:<8}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def create_backup_metadata(install_dir: Path) -> Dict[str, Any]:
|
||||
"""Create metadata for the backup"""
|
||||
metadata = {
|
||||
"backup_version": "3.0.0",
|
||||
"created": datetime.now().isoformat(),
|
||||
"install_dir": str(install_dir),
|
||||
"components": {},
|
||||
"framework_version": "unknown"
|
||||
}
|
||||
|
||||
try:
|
||||
# Get installed components from metadata
|
||||
settings_manager = SettingsManager(install_dir)
|
||||
framework_config = settings_manager.get_metadata_setting("framework")
|
||||
|
||||
if framework_config:
|
||||
metadata["framework_version"] = framework_config.get("version", "unknown")
|
||||
|
||||
if "components" in framework_config:
|
||||
for component_name in framework_config["components"]:
|
||||
version = settings_manager.get_component_version(component_name)
|
||||
if version:
|
||||
metadata["components"][component_name] = version
|
||||
except Exception:
|
||||
pass # Continue without metadata
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def create_backup(args: argparse.Namespace) -> bool:
|
||||
"""Create a new backup"""
|
||||
logger = get_logger()
|
||||
|
||||
try:
|
||||
# Check if installation exists
|
||||
if not check_installation_exists(args.install_dir):
|
||||
logger.error(f"No SuperClaude installation found in {args.install_dir}")
|
||||
return False
|
||||
|
||||
# Setup backup directory
|
||||
backup_dir = get_backup_directory(args)
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate backup filename
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
if args.name:
|
||||
backup_name = f"{args.name}_{timestamp}"
|
||||
else:
|
||||
backup_name = f"superclaude_backup_{timestamp}"
|
||||
|
||||
# Determine compression
|
||||
if args.compress == "gzip":
|
||||
backup_file = backup_dir / f"{backup_name}.tar.gz"
|
||||
mode = "w:gz"
|
||||
elif args.compress == "bzip2":
|
||||
backup_file = backup_dir / f"{backup_name}.tar.bz2"
|
||||
mode = "w:bz2"
|
||||
else:
|
||||
backup_file = backup_dir / f"{backup_name}.tar"
|
||||
mode = "w"
|
||||
|
||||
logger.info(f"Creating backup: {backup_file}")
|
||||
|
||||
# Create metadata
|
||||
metadata = create_backup_metadata(args.install_dir)
|
||||
|
||||
# Create backup
|
||||
start_time = time.time()
|
||||
|
||||
with tarfile.open(backup_file, mode) as tar:
|
||||
# Add metadata file
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as temp_file:
|
||||
json.dump(metadata, temp_file, indent=2)
|
||||
temp_file.flush()
|
||||
tar.add(temp_file.name, arcname="backup_metadata.json")
|
||||
Path(temp_file.name).unlink() # Clean up temp file
|
||||
|
||||
# Add installation directory contents
|
||||
files_added = 0
|
||||
for item in args.install_dir.rglob("*"):
|
||||
if item.is_file() and item != backup_file:
|
||||
try:
|
||||
# Create relative path for archive
|
||||
rel_path = item.relative_to(args.install_dir)
|
||||
tar.add(item, arcname=str(rel_path))
|
||||
files_added += 1
|
||||
|
||||
if files_added % 10 == 0:
|
||||
logger.debug(f"Added {files_added} files to backup")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not add {item} to backup: {e}")
|
||||
|
||||
duration = time.time() - start_time
|
||||
file_size = backup_file.stat().st_size
|
||||
|
||||
logger.success(f"Backup created successfully in {duration:.1f} seconds")
|
||||
logger.info(f"Backup file: {backup_file}")
|
||||
logger.info(f"Files archived: {files_added}")
|
||||
logger.info(f"Backup size: {format_size(file_size)}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to create backup: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def restore_backup(backup_path: Path, args: argparse.Namespace) -> bool:
|
||||
"""Restore from a backup file"""
|
||||
logger = get_logger()
|
||||
|
||||
try:
|
||||
if not backup_path.exists():
|
||||
logger.error(f"Backup file not found: {backup_path}")
|
||||
return False
|
||||
|
||||
# Check backup file
|
||||
info = get_backup_info(backup_path)
|
||||
if "error" in info:
|
||||
logger.error(f"Invalid backup file: {info['error']}")
|
||||
return False
|
||||
|
||||
logger.info(f"Restoring from backup: {backup_path}")
|
||||
|
||||
# Determine compression
|
||||
if backup_path.suffix == ".gz":
|
||||
mode = "r:gz"
|
||||
elif backup_path.suffix == ".bz2":
|
||||
mode = "r:bz2"
|
||||
else:
|
||||
mode = "r"
|
||||
|
||||
# Create backup of current installation if it exists
|
||||
if check_installation_exists(args.install_dir) and not args.dry_run:
|
||||
logger.info("Creating backup of current installation before restore")
|
||||
# This would call create_backup internally
|
||||
|
||||
# Extract backup
|
||||
start_time = time.time()
|
||||
files_restored = 0
|
||||
|
||||
with tarfile.open(backup_path, mode) as tar:
|
||||
# Extract all files except metadata
|
||||
for member in tar.getmembers():
|
||||
if member.name == "backup_metadata.json":
|
||||
continue
|
||||
|
||||
try:
|
||||
target_path = args.install_dir / member.name
|
||||
|
||||
# Check if file exists and overwrite flag
|
||||
if target_path.exists() and not args.overwrite:
|
||||
logger.warning(f"Skipping existing file: {target_path}")
|
||||
continue
|
||||
|
||||
# Extract file
|
||||
tar.extract(member, args.install_dir)
|
||||
files_restored += 1
|
||||
|
||||
if files_restored % 10 == 0:
|
||||
logger.debug(f"Restored {files_restored} files")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not restore {member.name}: {e}")
|
||||
|
||||
duration = time.time() - start_time
|
||||
|
||||
logger.success(f"Restore completed successfully in {duration:.1f} seconds")
|
||||
logger.info(f"Files restored: {files_restored}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to restore backup: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def interactive_restore_selection(backups: List[Dict[str, Any]]) -> Optional[Path]:
|
||||
"""Interactive backup selection for restore"""
|
||||
if not backups:
|
||||
print(f"{Colors.YELLOW}No backups available for restore{Colors.RESET}")
|
||||
return None
|
||||
|
||||
print(f"\n{Colors.CYAN}Select Backup to Restore:{Colors.RESET}")
|
||||
|
||||
# Create menu options
|
||||
backup_options = []
|
||||
for backup in backups:
|
||||
name = backup["path"].name
|
||||
size = format_size(backup["size"]) if backup["size"] > 0 else "unknown"
|
||||
created = backup["created"].strftime("%Y-%m-%d %H:%M") if backup["created"] else "unknown"
|
||||
backup_options.append(f"{name} ({size}, {created})")
|
||||
|
||||
menu = Menu("Select backup:", backup_options)
|
||||
choice = menu.display()
|
||||
|
||||
if choice == -1 or choice >= len(backups):
|
||||
return None
|
||||
|
||||
return backups[choice]["path"]
|
||||
|
||||
|
||||
def cleanup_old_backups(backup_dir: Path, args: argparse.Namespace) -> bool:
|
||||
"""Clean up old backup files"""
|
||||
logger = get_logger()
|
||||
|
||||
try:
|
||||
backups = list_backups(backup_dir)
|
||||
if not backups:
|
||||
logger.info("No backups found to clean up")
|
||||
return True
|
||||
|
||||
to_remove = []
|
||||
|
||||
# Remove by age
|
||||
if args.older_than:
|
||||
cutoff_date = datetime.now() - timedelta(days=args.older_than)
|
||||
for backup in backups:
|
||||
if backup["created"] and backup["created"] < cutoff_date:
|
||||
to_remove.append(backup)
|
||||
|
||||
# Keep only N most recent
|
||||
if args.keep and len(backups) > args.keep:
|
||||
# Sort by date and take oldest ones to remove
|
||||
backups.sort(key=lambda x: x.get("created", datetime.min), reverse=True)
|
||||
to_remove.extend(backups[args.keep:])
|
||||
|
||||
# Remove duplicates
|
||||
to_remove = list({backup["path"]: backup for backup in to_remove}.values())
|
||||
|
||||
if not to_remove:
|
||||
logger.info("No backups need to be cleaned up")
|
||||
return True
|
||||
|
||||
logger.info(f"Cleaning up {len(to_remove)} old backups")
|
||||
|
||||
for backup in to_remove:
|
||||
try:
|
||||
backup["path"].unlink()
|
||||
logger.info(f"Removed backup: {backup['path'].name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not remove {backup['path'].name}: {e}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to cleanup backups: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
"""Execute backup operation with parsed arguments"""
|
||||
operation = BackupOperation()
|
||||
operation.setup_operation_logging(args)
|
||||
logger = get_logger()
|
||||
# ✅ Inserted validation code
|
||||
expected_home = Path.home().resolve()
|
||||
actual_dir = args.install_dir.resolve()
|
||||
|
||||
if not str(actual_dir).startswith(str(expected_home)):
|
||||
print(f"\n[✗] Installation must be inside your user profile directory.")
|
||||
print(f" Expected prefix: {expected_home}")
|
||||
print(f" Provided path: {actual_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# Validate global arguments
|
||||
success, errors = operation.validate_global_args(args)
|
||||
if not success:
|
||||
for error in errors:
|
||||
logger.error(error)
|
||||
return 1
|
||||
|
||||
# Display header
|
||||
if not args.quiet:
|
||||
display_header(
|
||||
"SuperClaude Backup v3.0",
|
||||
"Backup and restore SuperClaude installations"
|
||||
)
|
||||
|
||||
backup_dir = get_backup_directory(args)
|
||||
|
||||
# Handle different backup operations
|
||||
if args.create:
|
||||
success = create_backup(args)
|
||||
|
||||
elif args.list:
|
||||
backups = list_backups(backup_dir)
|
||||
display_backup_list(backups)
|
||||
success = True
|
||||
|
||||
elif args.restore:
|
||||
if args.restore == "interactive":
|
||||
# Interactive restore
|
||||
backups = list_backups(backup_dir)
|
||||
backup_path = interactive_restore_selection(backups)
|
||||
if not backup_path:
|
||||
logger.info("Restore cancelled by user")
|
||||
return 0
|
||||
else:
|
||||
# Specific backup file
|
||||
backup_path = Path(args.restore)
|
||||
if not backup_path.is_absolute():
|
||||
backup_path = backup_dir / backup_path
|
||||
|
||||
success = restore_backup(backup_path, args)
|
||||
|
||||
elif args.info:
|
||||
backup_path = Path(args.info)
|
||||
if not backup_path.is_absolute():
|
||||
backup_path = backup_dir / backup_path
|
||||
|
||||
info = get_backup_info(backup_path)
|
||||
if info["exists"]:
|
||||
print(f"\n{Colors.CYAN}Backup Information:{Colors.RESET}")
|
||||
print(f"File: {info['path']}")
|
||||
print(f"Size: {format_size(info['size'])}")
|
||||
print(f"Created: {info['created']}")
|
||||
print(f"Files: {info.get('files', 'unknown')}")
|
||||
|
||||
if info["metadata"]:
|
||||
metadata = info["metadata"]
|
||||
print(f"Framework Version: {metadata.get('framework_version', 'unknown')}")
|
||||
if metadata.get("components"):
|
||||
print("Components:")
|
||||
for comp, ver in metadata["components"].items():
|
||||
print(f" {comp}: v{ver}")
|
||||
else:
|
||||
logger.error(f"Backup file not found: {backup_path}")
|
||||
success = False
|
||||
success = True
|
||||
|
||||
elif args.cleanup:
|
||||
success = cleanup_old_backups(backup_dir, args)
|
||||
|
||||
else:
|
||||
logger.error("No backup operation specified")
|
||||
success = False
|
||||
|
||||
if success:
|
||||
if not args.quiet and args.create:
|
||||
display_success("Backup operation completed successfully!")
|
||||
elif not args.quiet and args.restore:
|
||||
display_success("Restore operation completed successfully!")
|
||||
return 0
|
||||
else:
|
||||
display_error("Backup operation failed. Check logs for details.")
|
||||
return 1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{Colors.YELLOW}Backup operation cancelled by user{Colors.RESET}")
|
||||
return 130
|
||||
except Exception as e:
|
||||
return operation.handle_operation_error("backup", e)
|
||||
542
setup/operations/install.py
Normal file
542
setup/operations/install.py
Normal file
@@ -0,0 +1,542 @@
|
||||
"""
|
||||
SuperClaude Installation Operation Module
|
||||
Refactored from install.py for unified CLI hub
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
import argparse
|
||||
|
||||
from ..base.installer import Installer
|
||||
from ..core.registry import ComponentRegistry
|
||||
from ..managers.config_manager import ConfigManager
|
||||
from ..core.validator import Validator
|
||||
from ..utils.ui import (
|
||||
display_header, display_info, display_success, display_error,
|
||||
display_warning, Menu, confirm, ProgressBar, Colors, format_size
|
||||
)
|
||||
from ..utils.logger import get_logger
|
||||
from .. import DEFAULT_INSTALL_DIR, PROJECT_ROOT
|
||||
from . import OperationBase
|
||||
|
||||
|
||||
class InstallOperation(OperationBase):
|
||||
"""Installation operation implementation"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("install")
|
||||
|
||||
|
||||
def register_parser(subparsers, global_parser=None) -> argparse.ArgumentParser:
|
||||
"""Register installation CLI arguments"""
|
||||
parents = [global_parser] if global_parser else []
|
||||
|
||||
parser = subparsers.add_parser(
|
||||
"install",
|
||||
help="Install SuperClaude framework components",
|
||||
description="Install SuperClaude Framework with various options and profiles",
|
||||
epilog="""
|
||||
Examples:
|
||||
SuperClaude install # Interactive installation
|
||||
SuperClaude install --quick --dry-run # Quick installation (dry-run)
|
||||
SuperClaude install --profile developer # Developer profile
|
||||
SuperClaude install --components core mcp # Specific components
|
||||
SuperClaude install --verbose --force # Verbose with force mode
|
||||
""",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
parents=parents
|
||||
)
|
||||
|
||||
# Installation mode options
|
||||
parser.add_argument(
|
||||
"--quick",
|
||||
action="store_true",
|
||||
help="Quick installation with pre-selected components"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--minimal",
|
||||
action="store_true",
|
||||
help="Minimal installation (core only)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--profile",
|
||||
type=str,
|
||||
help="Installation profile (quick, minimal, developer, etc.)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--components",
|
||||
type=str,
|
||||
nargs="+",
|
||||
help="Specific components to install"
|
||||
)
|
||||
|
||||
# Installation options
|
||||
parser.add_argument(
|
||||
"--no-backup",
|
||||
action="store_true",
|
||||
help="Skip backup creation"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--list-components",
|
||||
action="store_true",
|
||||
help="List available components and exit"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--diagnose",
|
||||
action="store_true",
|
||||
help="Run system diagnostics and show installation help"
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def validate_system_requirements(validator: Validator, component_names: List[str]) -> bool:
|
||||
"""Validate system requirements"""
|
||||
logger = get_logger()
|
||||
|
||||
logger.info("Validating system requirements...")
|
||||
|
||||
try:
|
||||
# Load requirements configuration
|
||||
config_manager = ConfigManager(PROJECT_ROOT / "config")
|
||||
requirements = config_manager.get_requirements_for_components(component_names)
|
||||
|
||||
# Validate requirements
|
||||
success, errors = validator.validate_component_requirements(component_names, requirements)
|
||||
|
||||
if success:
|
||||
logger.success("All system requirements met")
|
||||
return True
|
||||
else:
|
||||
logger.error("System requirements not met:")
|
||||
for error in errors:
|
||||
logger.error(f" - {error}")
|
||||
|
||||
# Provide additional guidance
|
||||
print(f"\n{Colors.CYAN}💡 Installation Help:{Colors.RESET}")
|
||||
print(" Run 'SuperClaude install --diagnose' for detailed system diagnostics")
|
||||
print(" and step-by-step installation instructions.")
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Could not validate system requirements: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_components_to_install(args: argparse.Namespace, registry: ComponentRegistry, config_manager: ConfigManager) -> Optional[List[str]]:
|
||||
"""Determine which components to install"""
|
||||
logger = get_logger()
|
||||
|
||||
# Explicit components specified
|
||||
if args.components:
|
||||
if 'all' in args.components:
|
||||
return ["core", "commands", "hooks", "mcp"]
|
||||
return args.components
|
||||
|
||||
# Profile-based selection
|
||||
if args.profile:
|
||||
try:
|
||||
profile_path = PROJECT_ROOT / "profiles" / f"{args.profile}.json"
|
||||
profile = config_manager.load_profile(profile_path)
|
||||
return profile["components"]
|
||||
except Exception as e:
|
||||
logger.error(f"Could not load profile '{args.profile}': {e}")
|
||||
return None
|
||||
|
||||
# Quick installation
|
||||
if args.quick:
|
||||
try:
|
||||
profile_path = PROJECT_ROOT / "profiles" / "quick.json"
|
||||
profile = config_manager.load_profile(profile_path)
|
||||
return profile["components"]
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load quick profile: {e}")
|
||||
return ["core"] # Fallback to core only
|
||||
|
||||
# Minimal installation
|
||||
if args.minimal:
|
||||
return ["core"]
|
||||
|
||||
# Interactive selection
|
||||
return interactive_component_selection(registry, config_manager)
|
||||
|
||||
|
||||
def interactive_component_selection(registry: ComponentRegistry, config_manager: ConfigManager) -> Optional[List[str]]:
|
||||
"""Interactive component selection"""
|
||||
logger = get_logger()
|
||||
|
||||
try:
|
||||
# Get available components
|
||||
available_components = registry.list_components()
|
||||
|
||||
if not available_components:
|
||||
logger.error("No components available for installation")
|
||||
return None
|
||||
|
||||
# Create component menu with descriptions
|
||||
menu_options = []
|
||||
component_info = {}
|
||||
|
||||
for component_name in available_components:
|
||||
metadata = registry.get_component_metadata(component_name)
|
||||
if metadata:
|
||||
description = metadata.get("description", "No description")
|
||||
category = metadata.get("category", "unknown")
|
||||
menu_options.append(f"{component_name} ({category}) - {description}")
|
||||
component_info[component_name] = metadata
|
||||
else:
|
||||
menu_options.append(f"{component_name} - Component description unavailable")
|
||||
component_info[component_name] = {"description": "Unknown"}
|
||||
|
||||
# Add preset options
|
||||
preset_options = [
|
||||
"Quick Installation (recommended components)",
|
||||
"Minimal Installation (core only)",
|
||||
"Custom Selection"
|
||||
]
|
||||
|
||||
print(f"\n{Colors.CYAN}SuperClaude Installation Options:{Colors.RESET}")
|
||||
menu = Menu("Select installation type:", preset_options)
|
||||
choice = menu.display()
|
||||
|
||||
if choice == -1: # Cancelled
|
||||
return None
|
||||
elif choice == 0: # Quick
|
||||
try:
|
||||
profile_path = PROJECT_ROOT / "profiles" / "quick.json"
|
||||
profile = config_manager.load_profile(profile_path)
|
||||
return profile["components"]
|
||||
except Exception:
|
||||
return ["core"]
|
||||
elif choice == 1: # Minimal
|
||||
return ["core"]
|
||||
elif choice == 2: # Custom
|
||||
print(f"\n{Colors.CYAN}Available Components:{Colors.RESET}")
|
||||
component_menu = Menu("Select components to install:", menu_options, multi_select=True)
|
||||
selections = component_menu.display()
|
||||
|
||||
if not selections:
|
||||
logger.warning("No components selected")
|
||||
return None
|
||||
|
||||
return [available_components[i] for i in selections]
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in component selection: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def display_installation_plan(components: List[str], registry: ComponentRegistry, install_dir: Path) -> None:
|
||||
"""Display installation plan"""
|
||||
logger = get_logger()
|
||||
|
||||
print(f"\n{Colors.CYAN}{Colors.BRIGHT}Installation Plan{Colors.RESET}")
|
||||
print("=" * 50)
|
||||
|
||||
# Resolve dependencies
|
||||
try:
|
||||
ordered_components = registry.resolve_dependencies(components)
|
||||
|
||||
print(f"{Colors.BLUE}Installation Directory:{Colors.RESET} {install_dir}")
|
||||
print(f"{Colors.BLUE}Components to install:{Colors.RESET}")
|
||||
|
||||
total_size = 0
|
||||
for i, component_name in enumerate(ordered_components, 1):
|
||||
metadata = registry.get_component_metadata(component_name)
|
||||
if metadata:
|
||||
description = metadata.get("description", "No description")
|
||||
print(f" {i}. {component_name} - {description}")
|
||||
|
||||
# Get size estimate if component supports it
|
||||
try:
|
||||
instance = registry.get_component_instance(component_name, install_dir)
|
||||
if instance and hasattr(instance, 'get_size_estimate'):
|
||||
size = instance.get_size_estimate()
|
||||
total_size += size
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
print(f" {i}. {component_name} - Unknown component")
|
||||
|
||||
if total_size > 0:
|
||||
print(f"\n{Colors.BLUE}Estimated size:{Colors.RESET} {format_size(total_size)}")
|
||||
|
||||
print()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Could not resolve dependencies: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def run_system_diagnostics(validator: Validator) -> None:
|
||||
"""Run comprehensive system diagnostics"""
|
||||
logger = get_logger()
|
||||
|
||||
print(f"\n{Colors.CYAN}{Colors.BRIGHT}SuperClaude System Diagnostics{Colors.RESET}")
|
||||
print("=" * 50)
|
||||
|
||||
# Run diagnostics
|
||||
diagnostics = validator.diagnose_system()
|
||||
|
||||
# Display platform info
|
||||
print(f"{Colors.BLUE}Platform:{Colors.RESET} {diagnostics['platform']}")
|
||||
|
||||
# Display check results
|
||||
print(f"\n{Colors.BLUE}System Checks:{Colors.RESET}")
|
||||
all_passed = True
|
||||
|
||||
for check_name, check_info in diagnostics['checks'].items():
|
||||
status = check_info['status']
|
||||
message = check_info['message']
|
||||
|
||||
if status == 'pass':
|
||||
print(f" ✅ {check_name}: {message}")
|
||||
else:
|
||||
print(f" ❌ {check_name}: {message}")
|
||||
all_passed = False
|
||||
|
||||
# Display issues and recommendations
|
||||
if diagnostics['issues']:
|
||||
print(f"\n{Colors.YELLOW}Issues Found:{Colors.RESET}")
|
||||
for issue in diagnostics['issues']:
|
||||
print(f" ⚠️ {issue}")
|
||||
|
||||
print(f"\n{Colors.CYAN}Recommendations:{Colors.RESET}")
|
||||
for recommendation in diagnostics['recommendations']:
|
||||
print(recommendation)
|
||||
|
||||
# Summary
|
||||
if all_passed:
|
||||
print(f"\n{Colors.GREEN}✅ All system checks passed! Your system is ready for SuperClaude.{Colors.RESET}")
|
||||
else:
|
||||
print(f"\n{Colors.YELLOW}⚠️ Some issues found. Please address the recommendations above.{Colors.RESET}")
|
||||
|
||||
print(f"\n{Colors.BLUE}Next steps:{Colors.RESET}")
|
||||
if all_passed:
|
||||
print(" 1. Run 'SuperClaude install' to proceed with installation")
|
||||
print(" 2. Choose your preferred installation mode (quick, minimal, or custom)")
|
||||
else:
|
||||
print(" 1. Install missing dependencies using the commands above")
|
||||
print(" 2. Restart your terminal after installing tools")
|
||||
print(" 3. Run 'SuperClaude install --diagnose' again to verify")
|
||||
|
||||
|
||||
def perform_installation(components: List[str], args: argparse.Namespace) -> bool:
|
||||
"""Perform the actual installation"""
|
||||
logger = get_logger()
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Create installer
|
||||
installer = Installer(args.install_dir, dry_run=args.dry_run)
|
||||
|
||||
# Create component registry
|
||||
registry = ComponentRegistry(PROJECT_ROOT / "setup" / "components")
|
||||
registry.discover_components()
|
||||
|
||||
# Create component instances
|
||||
component_instances = registry.create_component_instances(components, args.install_dir)
|
||||
|
||||
if not component_instances:
|
||||
logger.error("No valid component instances created")
|
||||
return False
|
||||
|
||||
# Register components with installer
|
||||
installer.register_components(list(component_instances.values()))
|
||||
|
||||
# Resolve dependencies
|
||||
ordered_components = registry.resolve_dependencies(components)
|
||||
|
||||
# Setup progress tracking
|
||||
progress = ProgressBar(
|
||||
total=len(ordered_components),
|
||||
prefix="Installing: ",
|
||||
suffix=""
|
||||
)
|
||||
|
||||
# Install components
|
||||
logger.info(f"Installing {len(ordered_components)} components...")
|
||||
|
||||
config = {
|
||||
"force": args.force,
|
||||
"backup": not args.no_backup,
|
||||
"dry_run": args.dry_run
|
||||
}
|
||||
|
||||
success = installer.install_components(ordered_components, config)
|
||||
|
||||
# Update progress
|
||||
for i, component_name in enumerate(ordered_components):
|
||||
if component_name in installer.installed_components:
|
||||
progress.update(i + 1, f"Installed {component_name}")
|
||||
else:
|
||||
progress.update(i + 1, f"Failed {component_name}")
|
||||
time.sleep(0.1) # Brief pause for visual effect
|
||||
|
||||
progress.finish("Installation complete")
|
||||
|
||||
# Show results
|
||||
duration = time.time() - start_time
|
||||
|
||||
if success:
|
||||
logger.success(f"Installation completed successfully in {duration:.1f} seconds")
|
||||
|
||||
# Show summary
|
||||
summary = installer.get_installation_summary()
|
||||
if summary['installed']:
|
||||
logger.info(f"Installed components: {', '.join(summary['installed'])}")
|
||||
|
||||
if summary['backup_path']:
|
||||
logger.info(f"Backup created: {summary['backup_path']}")
|
||||
|
||||
else:
|
||||
logger.error(f"Installation completed with errors in {duration:.1f} seconds")
|
||||
|
||||
summary = installer.get_installation_summary()
|
||||
if summary['failed']:
|
||||
logger.error(f"Failed components: {', '.join(summary['failed'])}")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error during installation: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
"""Execute installation operation with parsed arguments"""
|
||||
operation = InstallOperation()
|
||||
operation.setup_operation_logging(args)
|
||||
logger = get_logger()
|
||||
# ✅ Inserted validation code
|
||||
expected_home = Path.home().resolve()
|
||||
actual_dir = args.install_dir.resolve()
|
||||
|
||||
if not str(actual_dir).startswith(str(expected_home)):
|
||||
print(f"\n[✗] Installation must be inside your user profile directory.")
|
||||
print(f" Expected prefix: {expected_home}")
|
||||
print(f" Provided path: {actual_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# Validate global arguments
|
||||
success, errors = operation.validate_global_args(args)
|
||||
if not success:
|
||||
for error in errors:
|
||||
logger.error(error)
|
||||
return 1
|
||||
|
||||
# Display header
|
||||
if not args.quiet:
|
||||
display_header(
|
||||
"SuperClaude Installation v3.0",
|
||||
"Installing SuperClaude framework components"
|
||||
)
|
||||
|
||||
# Handle special modes
|
||||
if args.list_components:
|
||||
registry = ComponentRegistry(PROJECT_ROOT / "setup" / "components")
|
||||
registry.discover_components()
|
||||
|
||||
components = registry.list_components()
|
||||
if components:
|
||||
print(f"\n{Colors.CYAN}Available Components:{Colors.RESET}")
|
||||
for component_name in components:
|
||||
metadata = registry.get_component_metadata(component_name)
|
||||
if metadata:
|
||||
desc = metadata.get("description", "No description")
|
||||
category = metadata.get("category", "unknown")
|
||||
print(f" {component_name} ({category}) - {desc}")
|
||||
else:
|
||||
print(f" {component_name} - Unknown component")
|
||||
else:
|
||||
print("No components found")
|
||||
return 0
|
||||
|
||||
# Handle diagnostic mode
|
||||
if args.diagnose:
|
||||
validator = Validator()
|
||||
run_system_diagnostics(validator)
|
||||
return 0
|
||||
|
||||
# Create component registry and load configuration
|
||||
logger.info("Initializing installation system...")
|
||||
|
||||
registry = ComponentRegistry(PROJECT_ROOT / "setup" / "components")
|
||||
registry.discover_components()
|
||||
|
||||
config_manager = ConfigManager(PROJECT_ROOT / "config")
|
||||
validator = Validator()
|
||||
|
||||
# Validate configuration
|
||||
config_errors = config_manager.validate_config_files()
|
||||
if config_errors:
|
||||
logger.error("Configuration validation failed:")
|
||||
for error in config_errors:
|
||||
logger.error(f" - {error}")
|
||||
return 1
|
||||
|
||||
# Get components to install
|
||||
components = get_components_to_install(args, registry, config_manager)
|
||||
if not components:
|
||||
logger.error("No components selected for installation")
|
||||
return 1
|
||||
|
||||
# Validate system requirements
|
||||
if not validate_system_requirements(validator, components):
|
||||
if not args.force:
|
||||
logger.error("System requirements not met. Use --force to override.")
|
||||
return 1
|
||||
else:
|
||||
logger.warning("System requirements not met, but continuing due to --force flag")
|
||||
|
||||
# Check for existing installation
|
||||
if args.install_dir.exists() and not args.force:
|
||||
if not args.dry_run:
|
||||
logger.warning(f"Installation directory already exists: {args.install_dir}")
|
||||
if not args.yes and not confirm("Continue and update existing installation?", default=False):
|
||||
logger.info("Installation cancelled by user")
|
||||
return 0
|
||||
|
||||
# Display installation plan
|
||||
if not args.quiet:
|
||||
display_installation_plan(components, registry, args.install_dir)
|
||||
|
||||
if not args.dry_run:
|
||||
if not args.yes and not confirm("Proceed with installation?", default=True):
|
||||
logger.info("Installation cancelled by user")
|
||||
return 0
|
||||
|
||||
# Perform installation
|
||||
success = perform_installation(components, args)
|
||||
|
||||
if success:
|
||||
if not args.quiet:
|
||||
display_success("SuperClaude installation completed successfully!")
|
||||
|
||||
if not args.dry_run:
|
||||
print(f"\n{Colors.CYAN}Next steps:{Colors.RESET}")
|
||||
print(f"1. Restart your Claude Code session")
|
||||
print(f"2. Framework files are now available in {args.install_dir}")
|
||||
print(f"3. Use SuperClaude commands and features in Claude Code")
|
||||
|
||||
return 0
|
||||
else:
|
||||
display_error("Installation failed. Check logs for details.")
|
||||
return 1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{Colors.YELLOW}Installation cancelled by user{Colors.RESET}")
|
||||
return 130
|
||||
except Exception as e:
|
||||
return operation.handle_operation_error("install", e)
|
||||
490
setup/operations/uninstall.py
Normal file
490
setup/operations/uninstall.py
Normal file
@@ -0,0 +1,490 @@
|
||||
"""
|
||||
SuperClaude Uninstall Operation Module
|
||||
Refactored from uninstall.py for unified CLI hub
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
import argparse
|
||||
|
||||
from ..core.registry import ComponentRegistry
|
||||
from ..managers.settings_manager import SettingsManager
|
||||
from ..managers.file_manager import FileManager
|
||||
from ..utils.ui import (
|
||||
display_header, display_info, display_success, display_error,
|
||||
display_warning, Menu, confirm, ProgressBar, Colors
|
||||
)
|
||||
from ..utils.logger import get_logger
|
||||
from .. import DEFAULT_INSTALL_DIR, PROJECT_ROOT
|
||||
from . import OperationBase
|
||||
|
||||
|
||||
class UninstallOperation(OperationBase):
|
||||
"""Uninstall operation implementation"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("uninstall")
|
||||
|
||||
|
||||
def register_parser(subparsers, global_parser=None) -> argparse.ArgumentParser:
|
||||
"""Register uninstall CLI arguments"""
|
||||
parents = [global_parser] if global_parser else []
|
||||
|
||||
parser = subparsers.add_parser(
|
||||
"uninstall",
|
||||
help="Remove SuperClaude framework installation",
|
||||
description="Uninstall SuperClaude Framework components",
|
||||
epilog="""
|
||||
Examples:
|
||||
SuperClaude uninstall # Interactive uninstall
|
||||
SuperClaude uninstall --components core # Remove specific components
|
||||
SuperClaude uninstall --complete --force # Complete removal (forced)
|
||||
SuperClaude uninstall --keep-backups # Keep backup files
|
||||
""",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
parents=parents
|
||||
)
|
||||
|
||||
# Uninstall mode options
|
||||
parser.add_argument(
|
||||
"--components",
|
||||
type=str,
|
||||
nargs="+",
|
||||
help="Specific components to uninstall"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--complete",
|
||||
action="store_true",
|
||||
help="Complete uninstall (remove all files and directories)"
|
||||
)
|
||||
|
||||
# Data preservation options
|
||||
parser.add_argument(
|
||||
"--keep-backups",
|
||||
action="store_true",
|
||||
help="Keep backup files during uninstall"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--keep-logs",
|
||||
action="store_true",
|
||||
help="Keep log files during uninstall"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--keep-settings",
|
||||
action="store_true",
|
||||
help="Keep user settings during uninstall"
|
||||
)
|
||||
|
||||
# Safety options
|
||||
parser.add_argument(
|
||||
"--no-confirm",
|
||||
action="store_true",
|
||||
help="Skip confirmation prompts (use with caution)"
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
def get_installed_components(install_dir: Path) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get currently installed components and their versions"""
|
||||
try:
|
||||
settings_manager = SettingsManager(install_dir)
|
||||
return settings_manager.get_installed_components()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def get_installation_info(install_dir: Path) -> Dict[str, Any]:
|
||||
"""Get detailed installation information"""
|
||||
info = {
|
||||
"install_dir": install_dir,
|
||||
"exists": False,
|
||||
"components": {},
|
||||
"directories": [],
|
||||
"files": [],
|
||||
"total_size": 0
|
||||
}
|
||||
|
||||
if not install_dir.exists():
|
||||
return info
|
||||
|
||||
info["exists"] = True
|
||||
info["components"] = get_installed_components(install_dir)
|
||||
|
||||
# Scan installation directory
|
||||
try:
|
||||
for item in install_dir.rglob("*"):
|
||||
if item.is_file():
|
||||
info["files"].append(item)
|
||||
info["total_size"] += item.stat().st_size
|
||||
elif item.is_dir():
|
||||
info["directories"].append(item)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def display_uninstall_info(info: Dict[str, Any]) -> None:
|
||||
"""Display installation information before uninstall"""
|
||||
print(f"\n{Colors.CYAN}{Colors.BRIGHT}Current Installation{Colors.RESET}")
|
||||
print("=" * 50)
|
||||
|
||||
if not info["exists"]:
|
||||
print(f"{Colors.YELLOW}No SuperClaude installation found{Colors.RESET}")
|
||||
return
|
||||
|
||||
print(f"{Colors.BLUE}Installation Directory:{Colors.RESET} {info['install_dir']}")
|
||||
|
||||
if info["components"]:
|
||||
print(f"{Colors.BLUE}Installed Components:{Colors.RESET}")
|
||||
for component, version in info["components"].items():
|
||||
print(f" {component}: v{version}")
|
||||
|
||||
print(f"{Colors.BLUE}Files:{Colors.RESET} {len(info['files'])}")
|
||||
print(f"{Colors.BLUE}Directories:{Colors.RESET} {len(info['directories'])}")
|
||||
|
||||
if info["total_size"] > 0:
|
||||
from ..utils.ui import format_size
|
||||
print(f"{Colors.BLUE}Total Size:{Colors.RESET} {format_size(info['total_size'])}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def get_components_to_uninstall(args: argparse.Namespace, installed_components: Dict[str, str]) -> Optional[List[str]]:
|
||||
"""Determine which components to uninstall"""
|
||||
logger = get_logger()
|
||||
|
||||
# Complete uninstall
|
||||
if args.complete:
|
||||
return list(installed_components.keys())
|
||||
|
||||
# Explicit components specified
|
||||
if args.components:
|
||||
# Validate that specified components are installed
|
||||
invalid_components = [c for c in args.components if c not in installed_components]
|
||||
if invalid_components:
|
||||
logger.error(f"Components not installed: {invalid_components}")
|
||||
return None
|
||||
return args.components
|
||||
|
||||
# Interactive selection
|
||||
return interactive_uninstall_selection(installed_components)
|
||||
|
||||
|
||||
def interactive_uninstall_selection(installed_components: Dict[str, str]) -> Optional[List[str]]:
|
||||
"""Interactive uninstall selection"""
|
||||
if not installed_components:
|
||||
return []
|
||||
|
||||
print(f"\n{Colors.CYAN}Uninstall Options:{Colors.RESET}")
|
||||
|
||||
# Create menu options
|
||||
preset_options = [
|
||||
"Complete Uninstall (remove everything)",
|
||||
"Remove Specific Components",
|
||||
"Cancel Uninstall"
|
||||
]
|
||||
|
||||
menu = Menu("Select uninstall option:", preset_options)
|
||||
choice = menu.display()
|
||||
|
||||
if choice == -1 or choice == 2: # Cancelled
|
||||
return None
|
||||
elif choice == 0: # Complete uninstall
|
||||
return list(installed_components.keys())
|
||||
elif choice == 1: # Select specific components
|
||||
component_options = []
|
||||
component_names = []
|
||||
|
||||
for component, version in installed_components.items():
|
||||
component_options.append(f"{component} (v{version})")
|
||||
component_names.append(component)
|
||||
|
||||
component_menu = Menu("Select components to uninstall:", component_options, multi_select=True)
|
||||
selections = component_menu.display()
|
||||
|
||||
if not selections:
|
||||
return None
|
||||
|
||||
return [component_names[i] for i in selections]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def display_uninstall_plan(components: List[str], args: argparse.Namespace, info: Dict[str, Any]) -> None:
|
||||
"""Display uninstall plan"""
|
||||
print(f"\n{Colors.CYAN}{Colors.BRIGHT}Uninstall Plan{Colors.RESET}")
|
||||
print("=" * 50)
|
||||
|
||||
print(f"{Colors.BLUE}Installation Directory:{Colors.RESET} {info['install_dir']}")
|
||||
|
||||
if components:
|
||||
print(f"{Colors.BLUE}Components to remove:{Colors.RESET}")
|
||||
for i, component_name in enumerate(components, 1):
|
||||
version = info["components"].get(component_name, "unknown")
|
||||
print(f" {i}. {component_name} (v{version})")
|
||||
|
||||
# Show what will be preserved
|
||||
preserved = []
|
||||
if args.keep_backups:
|
||||
preserved.append("backup files")
|
||||
if args.keep_logs:
|
||||
preserved.append("log files")
|
||||
if args.keep_settings:
|
||||
preserved.append("user settings")
|
||||
|
||||
if preserved:
|
||||
print(f"{Colors.GREEN}Will preserve:{Colors.RESET} {', '.join(preserved)}")
|
||||
|
||||
if args.complete:
|
||||
print(f"{Colors.RED}WARNING: Complete uninstall will remove all SuperClaude files{Colors.RESET}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def create_uninstall_backup(install_dir: Path, components: List[str]) -> Optional[Path]:
|
||||
"""Create backup before uninstall"""
|
||||
logger = get_logger()
|
||||
|
||||
try:
|
||||
from datetime import datetime
|
||||
backup_dir = install_dir / "backups"
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_name = f"pre_uninstall_{timestamp}.tar.gz"
|
||||
backup_path = backup_dir / backup_name
|
||||
|
||||
import tarfile
|
||||
|
||||
logger.info(f"Creating uninstall backup: {backup_path}")
|
||||
|
||||
with tarfile.open(backup_path, "w:gz") as tar:
|
||||
for component in components:
|
||||
# Add component files to backup
|
||||
settings_manager = SettingsManager(install_dir)
|
||||
# This would need component-specific backup logic
|
||||
pass
|
||||
|
||||
logger.success(f"Backup created: {backup_path}")
|
||||
return backup_path
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create backup: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def perform_uninstall(components: List[str], args: argparse.Namespace, info: Dict[str, Any]) -> bool:
|
||||
"""Perform the actual uninstall"""
|
||||
logger = get_logger()
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Create component registry
|
||||
registry = ComponentRegistry(PROJECT_ROOT / "setup" / "components")
|
||||
registry.discover_components()
|
||||
|
||||
# Create component instances
|
||||
component_instances = registry.create_component_instances(components, args.install_dir)
|
||||
|
||||
# Setup progress tracking
|
||||
progress = ProgressBar(
|
||||
total=len(components),
|
||||
prefix="Uninstalling: ",
|
||||
suffix=""
|
||||
)
|
||||
|
||||
# Uninstall components
|
||||
logger.info(f"Uninstalling {len(components)} components...")
|
||||
|
||||
uninstalled_components = []
|
||||
failed_components = []
|
||||
|
||||
for i, component_name in enumerate(components):
|
||||
progress.update(i, f"Uninstalling {component_name}")
|
||||
|
||||
try:
|
||||
if component_name in component_instances:
|
||||
instance = component_instances[component_name]
|
||||
if instance.uninstall():
|
||||
uninstalled_components.append(component_name)
|
||||
logger.debug(f"Successfully uninstalled {component_name}")
|
||||
else:
|
||||
failed_components.append(component_name)
|
||||
logger.error(f"Failed to uninstall {component_name}")
|
||||
else:
|
||||
logger.warning(f"Component {component_name} not found, skipping")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error uninstalling {component_name}: {e}")
|
||||
failed_components.append(component_name)
|
||||
|
||||
progress.update(i + 1, f"Processed {component_name}")
|
||||
time.sleep(0.1) # Brief pause for visual effect
|
||||
|
||||
progress.finish("Uninstall complete")
|
||||
|
||||
# Handle complete uninstall cleanup
|
||||
if args.complete:
|
||||
cleanup_installation_directory(args.install_dir, args)
|
||||
|
||||
# Show results
|
||||
duration = time.time() - start_time
|
||||
|
||||
if failed_components:
|
||||
logger.warning(f"Uninstall completed with some failures in {duration:.1f} seconds")
|
||||
logger.warning(f"Failed components: {', '.join(failed_components)}")
|
||||
else:
|
||||
logger.success(f"Uninstall completed successfully in {duration:.1f} seconds")
|
||||
|
||||
if uninstalled_components:
|
||||
logger.info(f"Uninstalled components: {', '.join(uninstalled_components)}")
|
||||
|
||||
return len(failed_components) == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error during uninstall: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_installation_directory(install_dir: Path, args: argparse.Namespace) -> None:
|
||||
"""Clean up installation directory for complete uninstall"""
|
||||
logger = get_logger()
|
||||
file_manager = FileManager()
|
||||
|
||||
try:
|
||||
# Preserve specific directories/files if requested
|
||||
preserve_patterns = []
|
||||
|
||||
if args.keep_backups:
|
||||
preserve_patterns.append("backups/*")
|
||||
if args.keep_logs:
|
||||
preserve_patterns.append("logs/*")
|
||||
if args.keep_settings and not args.complete:
|
||||
preserve_patterns.append("settings.json")
|
||||
|
||||
# Remove installation directory contents
|
||||
if args.complete and not preserve_patterns:
|
||||
# Complete removal
|
||||
if file_manager.remove_directory(install_dir):
|
||||
logger.info(f"Removed installation directory: {install_dir}")
|
||||
else:
|
||||
logger.warning(f"Could not remove installation directory: {install_dir}")
|
||||
else:
|
||||
# Selective removal
|
||||
for item in install_dir.iterdir():
|
||||
should_preserve = False
|
||||
|
||||
for pattern in preserve_patterns:
|
||||
if item.match(pattern):
|
||||
should_preserve = True
|
||||
break
|
||||
|
||||
if not should_preserve:
|
||||
if item.is_file():
|
||||
file_manager.remove_file(item)
|
||||
elif item.is_dir():
|
||||
file_manager.remove_directory(item)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup: {e}")
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
"""Execute uninstall operation with parsed arguments"""
|
||||
operation = UninstallOperation()
|
||||
operation.setup_operation_logging(args)
|
||||
logger = get_logger()
|
||||
# ✅ Inserted validation code
|
||||
expected_home = Path.home().resolve()
|
||||
actual_dir = args.install_dir.resolve()
|
||||
|
||||
if not str(actual_dir).startswith(str(expected_home)):
|
||||
print(f"\n[✗] Installation must be inside your user profile directory.")
|
||||
print(f" Expected prefix: {expected_home}")
|
||||
print(f" Provided path: {actual_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# Validate global arguments
|
||||
success, errors = operation.validate_global_args(args)
|
||||
if not success:
|
||||
for error in errors:
|
||||
logger.error(error)
|
||||
return 1
|
||||
|
||||
# Display header
|
||||
if not args.quiet:
|
||||
display_header(
|
||||
"SuperClaude Uninstall v3.0",
|
||||
"Removing SuperClaude framework components"
|
||||
)
|
||||
|
||||
# Get installation information
|
||||
info = get_installation_info(args.install_dir)
|
||||
|
||||
# Display current installation
|
||||
if not args.quiet:
|
||||
display_uninstall_info(info)
|
||||
|
||||
# Check if SuperClaude is installed
|
||||
if not info["exists"]:
|
||||
logger.warning(f"No SuperClaude installation found in {args.install_dir}")
|
||||
return 0
|
||||
|
||||
# Get components to uninstall
|
||||
components = get_components_to_uninstall(args, info["components"])
|
||||
if components is None:
|
||||
logger.info("Uninstall cancelled by user")
|
||||
return 0
|
||||
elif not components:
|
||||
logger.info("No components selected for uninstall")
|
||||
return 0
|
||||
|
||||
# Display uninstall plan
|
||||
if not args.quiet:
|
||||
display_uninstall_plan(components, args, info)
|
||||
|
||||
# Confirmation
|
||||
if not args.no_confirm and not args.yes:
|
||||
if args.complete:
|
||||
warning_msg = "This will completely remove SuperClaude. Continue?"
|
||||
else:
|
||||
warning_msg = f"This will remove {len(components)} component(s). Continue?"
|
||||
|
||||
if not confirm(warning_msg, default=False):
|
||||
logger.info("Uninstall cancelled by user")
|
||||
return 0
|
||||
|
||||
# Create backup if not dry run and not keeping backups
|
||||
if not args.dry_run and not args.keep_backups:
|
||||
create_uninstall_backup(args.install_dir, components)
|
||||
|
||||
# Perform uninstall
|
||||
success = perform_uninstall(components, args, info)
|
||||
|
||||
if success:
|
||||
if not args.quiet:
|
||||
display_success("SuperClaude uninstall completed successfully!")
|
||||
|
||||
if not args.dry_run:
|
||||
print(f"\n{Colors.CYAN}Uninstall complete:{Colors.RESET}")
|
||||
print(f"SuperClaude has been removed from {args.install_dir}")
|
||||
if not args.complete:
|
||||
print(f"You can reinstall anytime using 'SuperClaude install'")
|
||||
|
||||
return 0
|
||||
else:
|
||||
display_error("Uninstall completed with some failures. Check logs for details.")
|
||||
return 1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{Colors.YELLOW}Uninstall cancelled by user{Colors.RESET}")
|
||||
return 130
|
||||
except Exception as e:
|
||||
return operation.handle_operation_error("uninstall", e)
|
||||
420
setup/operations/update.py
Normal file
420
setup/operations/update.py
Normal file
@@ -0,0 +1,420 @@
|
||||
"""
|
||||
SuperClaude Update Operation Module
|
||||
Refactored from update.py for unified CLI hub
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
import argparse
|
||||
|
||||
from ..base.installer import Installer
|
||||
from ..core.registry import ComponentRegistry
|
||||
from ..managers.settings_manager import SettingsManager
|
||||
from ..core.validator import Validator
|
||||
from ..utils.ui import (
|
||||
display_header, display_info, display_success, display_error,
|
||||
display_warning, Menu, confirm, ProgressBar, Colors, format_size
|
||||
)
|
||||
from ..utils.logger import get_logger
|
||||
from .. import DEFAULT_INSTALL_DIR, PROJECT_ROOT
|
||||
from . import OperationBase
|
||||
|
||||
|
||||
class UpdateOperation(OperationBase):
|
||||
"""Update operation implementation"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("update")
|
||||
|
||||
|
||||
def register_parser(subparsers, global_parser=None) -> argparse.ArgumentParser:
|
||||
"""Register update CLI arguments"""
|
||||
parents = [global_parser] if global_parser else []
|
||||
|
||||
parser = subparsers.add_parser(
|
||||
"update",
|
||||
help="Update existing SuperClaude installation",
|
||||
description="Update SuperClaude Framework components to latest versions",
|
||||
epilog="""
|
||||
Examples:
|
||||
SuperClaude update # Interactive update
|
||||
SuperClaude update --check --verbose # Check for updates (verbose)
|
||||
SuperClaude update --components core mcp # Update specific components
|
||||
SuperClaude update --backup --force # Create backup before update (forced)
|
||||
""",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
parents=parents
|
||||
)
|
||||
|
||||
# Update mode options
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Check for available updates without installing"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--components",
|
||||
type=str,
|
||||
nargs="+",
|
||||
help="Specific components to update"
|
||||
)
|
||||
|
||||
# Backup options
|
||||
parser.add_argument(
|
||||
"--backup",
|
||||
action="store_true",
|
||||
help="Create backup before update"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--no-backup",
|
||||
action="store_true",
|
||||
help="Skip backup creation"
|
||||
)
|
||||
|
||||
# Update options
|
||||
parser.add_argument(
|
||||
"--reinstall",
|
||||
action="store_true",
|
||||
help="Reinstall components even if versions match"
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
def check_installation_exists(install_dir: Path) -> bool:
|
||||
"""Check if SuperClaude installation exists"""
|
||||
settings_manager = SettingsManager(install_dir)
|
||||
|
||||
return settings_manager.check_installation_exists()
|
||||
|
||||
def get_installed_components(install_dir: Path) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get currently installed components and their versions"""
|
||||
try:
|
||||
settings_manager = SettingsManager(install_dir)
|
||||
return settings_manager.get_installed_components()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def get_available_updates(installed_components: Dict[str, str], registry: ComponentRegistry) -> Dict[str, Dict[str, str]]:
|
||||
"""Check for available updates"""
|
||||
updates = {}
|
||||
|
||||
for component_name, current_version in installed_components.items():
|
||||
try:
|
||||
metadata = registry.get_component_metadata(component_name)
|
||||
if metadata:
|
||||
available_version = metadata.get("version", "unknown")
|
||||
if available_version != current_version:
|
||||
updates[component_name] = {
|
||||
"current": current_version,
|
||||
"available": available_version,
|
||||
"description": metadata.get("description", "No description")
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return updates
|
||||
|
||||
|
||||
def display_update_check(installed_components: Dict[str, str], available_updates: Dict[str, Dict[str, str]]) -> None:
|
||||
"""Display update check results"""
|
||||
print(f"\n{Colors.CYAN}{Colors.BRIGHT}Update Check Results{Colors.RESET}")
|
||||
print("=" * 50)
|
||||
|
||||
if not installed_components:
|
||||
print(f"{Colors.YELLOW}No SuperClaude installation found{Colors.RESET}")
|
||||
return
|
||||
|
||||
print(f"{Colors.BLUE}Currently installed components:{Colors.RESET}")
|
||||
for component, version in installed_components.items():
|
||||
print(f" {component}: v{version}")
|
||||
|
||||
if available_updates:
|
||||
print(f"\n{Colors.GREEN}Available updates:{Colors.RESET}")
|
||||
for component, info in available_updates.items():
|
||||
print(f" {component}: v{info['current']} → v{info['available']}")
|
||||
print(f" {info['description']}")
|
||||
else:
|
||||
print(f"\n{Colors.GREEN}All components are up to date{Colors.RESET}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def get_components_to_update(args: argparse.Namespace, installed_components: Dict[str, str],
|
||||
available_updates: Dict[str, Dict[str, str]]) -> Optional[List[str]]:
|
||||
"""Determine which components to update"""
|
||||
logger = get_logger()
|
||||
|
||||
# Explicit components specified
|
||||
if args.components:
|
||||
# Validate that specified components are installed
|
||||
invalid_components = [c for c in args.components if c not in installed_components]
|
||||
if invalid_components:
|
||||
logger.error(f"Components not installed: {invalid_components}")
|
||||
return None
|
||||
return args.components
|
||||
|
||||
# If no updates available and not forcing reinstall
|
||||
if not available_updates and not args.reinstall:
|
||||
logger.info("No updates available")
|
||||
return []
|
||||
|
||||
# Interactive selection
|
||||
if available_updates:
|
||||
return interactive_update_selection(available_updates, installed_components)
|
||||
elif args.reinstall:
|
||||
# Reinstall all components
|
||||
return list(installed_components.keys())
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def interactive_update_selection(available_updates: Dict[str, Dict[str, str]],
|
||||
installed_components: Dict[str, str]) -> Optional[List[str]]:
|
||||
"""Interactive update selection"""
|
||||
if not available_updates:
|
||||
return []
|
||||
|
||||
print(f"\n{Colors.CYAN}Available Updates:{Colors.RESET}")
|
||||
|
||||
# Create menu options
|
||||
update_options = []
|
||||
component_names = []
|
||||
|
||||
for component, info in available_updates.items():
|
||||
update_options.append(f"{component}: v{info['current']} → v{info['available']}")
|
||||
component_names.append(component)
|
||||
|
||||
# Add bulk options
|
||||
preset_options = [
|
||||
"Update All Components",
|
||||
"Select Individual Components",
|
||||
"Cancel Update"
|
||||
]
|
||||
|
||||
menu = Menu("Select update option:", preset_options)
|
||||
choice = menu.display()
|
||||
|
||||
if choice == -1 or choice == 2: # Cancelled
|
||||
return None
|
||||
elif choice == 0: # Update all
|
||||
return component_names
|
||||
elif choice == 1: # Select individual
|
||||
component_menu = Menu("Select components to update:", update_options, multi_select=True)
|
||||
selections = component_menu.display()
|
||||
|
||||
if not selections:
|
||||
return None
|
||||
|
||||
return [component_names[i] for i in selections]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def display_update_plan(components: List[str], available_updates: Dict[str, Dict[str, str]],
|
||||
installed_components: Dict[str, str], install_dir: Path) -> None:
|
||||
"""Display update plan"""
|
||||
print(f"\n{Colors.CYAN}{Colors.BRIGHT}Update Plan{Colors.RESET}")
|
||||
print("=" * 50)
|
||||
|
||||
print(f"{Colors.BLUE}Installation Directory:{Colors.RESET} {install_dir}")
|
||||
print(f"{Colors.BLUE}Components to update:{Colors.RESET}")
|
||||
|
||||
for i, component_name in enumerate(components, 1):
|
||||
if component_name in available_updates:
|
||||
info = available_updates[component_name]
|
||||
print(f" {i}. {component_name}: v{info['current']} → v{info['available']}")
|
||||
else:
|
||||
current_version = installed_components.get(component_name, "unknown")
|
||||
print(f" {i}. {component_name}: v{current_version} (reinstall)")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def perform_update(components: List[str], args: argparse.Namespace) -> bool:
|
||||
"""Perform the actual update"""
|
||||
logger = get_logger()
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Create installer
|
||||
installer = Installer(args.install_dir, dry_run=args.dry_run)
|
||||
|
||||
# Create component registry
|
||||
registry = ComponentRegistry(PROJECT_ROOT / "setup" / "components")
|
||||
registry.discover_components()
|
||||
|
||||
# Create component instances
|
||||
component_instances = registry.create_component_instances(components, args.install_dir)
|
||||
|
||||
if not component_instances:
|
||||
logger.error("No valid component instances created")
|
||||
return False
|
||||
|
||||
# Register components with installer
|
||||
installer.register_components(list(component_instances.values()))
|
||||
|
||||
# Setup progress tracking
|
||||
progress = ProgressBar(
|
||||
total=len(components),
|
||||
prefix="Updating: ",
|
||||
suffix=""
|
||||
)
|
||||
|
||||
# Update components
|
||||
logger.info(f"Updating {len(components)} components...")
|
||||
|
||||
# Determine backup strategy
|
||||
backup = args.backup or (not args.no_backup and not args.dry_run)
|
||||
|
||||
config = {
|
||||
"force": args.force,
|
||||
"backup": backup,
|
||||
"dry_run": args.dry_run,
|
||||
"update_mode": True
|
||||
}
|
||||
|
||||
success = installer.update_components(components, config)
|
||||
|
||||
# Update progress
|
||||
for i, component_name in enumerate(components):
|
||||
if component_name in installer.updated_components:
|
||||
progress.update(i + 1, f"Updated {component_name}")
|
||||
else:
|
||||
progress.update(i + 1, f"Failed {component_name}")
|
||||
time.sleep(0.1) # Brief pause for visual effect
|
||||
|
||||
progress.finish("Update complete")
|
||||
|
||||
# Show results
|
||||
duration = time.time() - start_time
|
||||
|
||||
if success:
|
||||
logger.success(f"Update completed successfully in {duration:.1f} seconds")
|
||||
|
||||
# Show summary
|
||||
summary = installer.get_update_summary()
|
||||
if summary.get('updated'):
|
||||
logger.info(f"Updated components: {', '.join(summary['updated'])}")
|
||||
|
||||
if summary.get('backup_path'):
|
||||
logger.info(f"Backup created: {summary['backup_path']}")
|
||||
|
||||
else:
|
||||
logger.error(f"Update completed with errors in {duration:.1f} seconds")
|
||||
|
||||
summary = installer.get_update_summary()
|
||||
if summary.get('failed'):
|
||||
logger.error(f"Failed components: {', '.join(summary['failed'])}")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error during update: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
"""Execute update operation with parsed arguments"""
|
||||
operation = UpdateOperation()
|
||||
operation.setup_operation_logging(args)
|
||||
logger = get_logger()
|
||||
# ✅ Inserted validation code
|
||||
expected_home = Path.home().resolve()
|
||||
actual_dir = args.install_dir.resolve()
|
||||
|
||||
if not str(actual_dir).startswith(str(expected_home)):
|
||||
print(f"\n[✗] Installation must be inside your user profile directory.")
|
||||
print(f" Expected prefix: {expected_home}")
|
||||
print(f" Provided path: {actual_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# Validate global arguments
|
||||
success, errors = operation.validate_global_args(args)
|
||||
if not success:
|
||||
for error in errors:
|
||||
logger.error(error)
|
||||
return 1
|
||||
|
||||
# Display header
|
||||
if not args.quiet:
|
||||
display_header(
|
||||
"SuperClaude Update v3.0",
|
||||
"Updating SuperClaude framework components"
|
||||
)
|
||||
|
||||
# Check if SuperClaude is installed
|
||||
if not check_installation_exists(args.install_dir):
|
||||
logger.error(f"SuperClaude installation not found in {args.install_dir}")
|
||||
logger.info("Use 'SuperClaude install' to install SuperClaude first")
|
||||
return 1
|
||||
|
||||
# Create component registry
|
||||
logger.info("Checking for available updates...")
|
||||
|
||||
registry = ComponentRegistry(PROJECT_ROOT / "setup" / "components")
|
||||
registry.discover_components()
|
||||
|
||||
# Get installed components
|
||||
installed_components = get_installed_components(args.install_dir)
|
||||
if not installed_components:
|
||||
logger.error("Could not determine installed components")
|
||||
return 1
|
||||
|
||||
# Check for available updates
|
||||
available_updates = get_available_updates(installed_components, registry)
|
||||
|
||||
# Display update check results
|
||||
if not args.quiet:
|
||||
display_update_check(installed_components, available_updates)
|
||||
|
||||
# If only checking for updates, exit here
|
||||
if args.check:
|
||||
return 0
|
||||
|
||||
# Get components to update
|
||||
components = get_components_to_update(args, installed_components, available_updates)
|
||||
if components is None:
|
||||
logger.info("Update cancelled by user")
|
||||
return 0
|
||||
elif not components:
|
||||
logger.info("No components selected for update")
|
||||
return 0
|
||||
|
||||
# Display update plan
|
||||
if not args.quiet:
|
||||
display_update_plan(components, available_updates, installed_components, args.install_dir)
|
||||
|
||||
if not args.dry_run:
|
||||
if not args.yes and not confirm("Proceed with update?", default=True):
|
||||
logger.info("Update cancelled by user")
|
||||
return 0
|
||||
|
||||
# Perform update
|
||||
success = perform_update(components, args)
|
||||
|
||||
if success:
|
||||
if not args.quiet:
|
||||
display_success("SuperClaude update completed successfully!")
|
||||
|
||||
if not args.dry_run:
|
||||
print(f"\n{Colors.CYAN}Next steps:{Colors.RESET}")
|
||||
print(f"1. Restart your Claude Code session")
|
||||
print(f"2. Updated components are now available")
|
||||
print(f"3. Check for any breaking changes in documentation")
|
||||
|
||||
return 0
|
||||
else:
|
||||
display_error("Update failed. Check logs for details.")
|
||||
return 1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{Colors.YELLOW}Update cancelled by user{Colors.RESET}")
|
||||
return 130
|
||||
except Exception as e:
|
||||
return operation.handle_operation_error("update", e)
|
||||
14
setup/utils/__init__.py
Normal file
14
setup/utils/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Utility modules for SuperClaude installation system"""
|
||||
|
||||
from .ui import ProgressBar, Menu, confirm, Colors
|
||||
from .logger import Logger
|
||||
from .security import SecurityValidator
|
||||
|
||||
__all__ = [
|
||||
'ProgressBar',
|
||||
'Menu',
|
||||
'confirm',
|
||||
'Colors',
|
||||
'Logger',
|
||||
'SecurityValidator'
|
||||
]
|
||||
330
setup/utils/logger.py
Normal file
330
setup/utils/logger.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
Logging system for SuperClaude installation suite
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
from .ui import Colors
|
||||
|
||||
|
||||
class LogLevel(Enum):
|
||||
"""Log levels"""
|
||||
DEBUG = logging.DEBUG
|
||||
INFO = logging.INFO
|
||||
WARNING = logging.WARNING
|
||||
ERROR = logging.ERROR
|
||||
CRITICAL = logging.CRITICAL
|
||||
|
||||
|
||||
class Logger:
|
||||
"""Enhanced logger with console and file output"""
|
||||
|
||||
def __init__(self, name: str = "superclaude", log_dir: Optional[Path] = None, console_level: LogLevel = LogLevel.INFO, file_level: LogLevel = LogLevel.DEBUG):
|
||||
"""
|
||||
Initialize logger
|
||||
|
||||
Args:
|
||||
name: Logger name
|
||||
log_dir: Directory for log files (defaults to ~/.claude/logs)
|
||||
console_level: Minimum level for console output
|
||||
file_level: Minimum level for file output
|
||||
"""
|
||||
self.name = name
|
||||
self.log_dir = log_dir or (Path.home() / ".claude" / "logs")
|
||||
self.console_level = console_level
|
||||
self.file_level = file_level
|
||||
self.session_start = datetime.now()
|
||||
|
||||
# Create logger
|
||||
self.logger = logging.getLogger(name)
|
||||
self.logger.setLevel(logging.DEBUG) # Accept all levels, handlers will filter
|
||||
|
||||
# Remove existing handlers to avoid duplicates
|
||||
self.logger.handlers.clear()
|
||||
|
||||
# Setup handlers
|
||||
self._setup_console_handler()
|
||||
self._setup_file_handler()
|
||||
|
||||
self.log_counts: Dict[str, int] = {
|
||||
'debug': 0,
|
||||
'info': 0,
|
||||
'warning': 0,
|
||||
'error': 0,
|
||||
'critical': 0
|
||||
}
|
||||
|
||||
def _setup_console_handler(self) -> None:
|
||||
"""Setup colorized console handler"""
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setLevel(self.console_level.value)
|
||||
|
||||
# Custom formatter with colors
|
||||
class ColorFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
# Color mapping
|
||||
colors = {
|
||||
'DEBUG': Colors.WHITE,
|
||||
'INFO': Colors.BLUE,
|
||||
'WARNING': Colors.YELLOW,
|
||||
'ERROR': Colors.RED,
|
||||
'CRITICAL': Colors.RED + Colors.BRIGHT
|
||||
}
|
||||
|
||||
# Prefix mapping
|
||||
prefixes = {
|
||||
'DEBUG': '[DEBUG]',
|
||||
'INFO': '[INFO]',
|
||||
'WARNING': '[!]',
|
||||
'ERROR': '[✗]',
|
||||
'CRITICAL': '[CRITICAL]'
|
||||
}
|
||||
|
||||
color = colors.get(record.levelname, Colors.WHITE)
|
||||
prefix = prefixes.get(record.levelname, '[LOG]')
|
||||
|
||||
return f"{color}{prefix} {record.getMessage()}{Colors.RESET}"
|
||||
|
||||
handler.setFormatter(ColorFormatter())
|
||||
self.logger.addHandler(handler)
|
||||
|
||||
def _setup_file_handler(self) -> None:
|
||||
"""Setup file handler with rotation"""
|
||||
try:
|
||||
# Ensure log directory exists
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create timestamped log file
|
||||
timestamp = self.session_start.strftime("%Y%m%d_%H%M%S")
|
||||
log_file = self.log_dir / f"{self.name}_{timestamp}.log"
|
||||
|
||||
handler = logging.FileHandler(log_file, encoding='utf-8')
|
||||
handler.setLevel(self.file_level.value)
|
||||
|
||||
# Detailed formatter for files
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s | %(levelname)-8s | %(name)s | %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
self.logger.addHandler(handler)
|
||||
self.log_file = log_file
|
||||
|
||||
# Clean up old log files (keep last 10)
|
||||
self._cleanup_old_logs()
|
||||
|
||||
except Exception as e:
|
||||
# If file logging fails, continue with console only
|
||||
print(f"{Colors.YELLOW}[!] Could not setup file logging: {e}{Colors.RESET}")
|
||||
self.log_file = None
|
||||
|
||||
def _cleanup_old_logs(self, keep_count: int = 10) -> None:
|
||||
"""Clean up old log files"""
|
||||
try:
|
||||
# Get all log files for this logger
|
||||
log_files = list(self.log_dir.glob(f"{self.name}_*.log"))
|
||||
|
||||
# Sort by modification time, newest first
|
||||
log_files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
|
||||
|
||||
# Remove old files
|
||||
for old_file in log_files[keep_count:]:
|
||||
try:
|
||||
old_file.unlink()
|
||||
except OSError:
|
||||
pass # Ignore errors when cleaning up
|
||||
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
def debug(self, message: str, **kwargs) -> None:
|
||||
"""Log debug message"""
|
||||
self.logger.debug(message, **kwargs)
|
||||
self.log_counts['debug'] += 1
|
||||
|
||||
def info(self, message: str, **kwargs) -> None:
|
||||
"""Log info message"""
|
||||
self.logger.info(message, **kwargs)
|
||||
self.log_counts['info'] += 1
|
||||
|
||||
def warning(self, message: str, **kwargs) -> None:
|
||||
"""Log warning message"""
|
||||
self.logger.warning(message, **kwargs)
|
||||
self.log_counts['warning'] += 1
|
||||
|
||||
def error(self, message: str, **kwargs) -> None:
|
||||
"""Log error message"""
|
||||
self.logger.error(message, **kwargs)
|
||||
self.log_counts['error'] += 1
|
||||
|
||||
def critical(self, message: str, **kwargs) -> None:
|
||||
"""Log critical message"""
|
||||
self.logger.critical(message, **kwargs)
|
||||
self.log_counts['critical'] += 1
|
||||
|
||||
def success(self, message: str, **kwargs) -> None:
|
||||
"""Log success message (info level with special formatting)"""
|
||||
# Use a custom success formatter for console
|
||||
if self.logger.handlers:
|
||||
console_handler = self.logger.handlers[0]
|
||||
if hasattr(console_handler, 'formatter'):
|
||||
original_format = console_handler.formatter.format
|
||||
|
||||
def success_format(record):
|
||||
return f"{Colors.GREEN}[✓] {record.getMessage()}{Colors.RESET}"
|
||||
|
||||
console_handler.formatter.format = success_format
|
||||
self.logger.info(message, **kwargs)
|
||||
console_handler.formatter.format = original_format
|
||||
else:
|
||||
self.logger.info(f"SUCCESS: {message}", **kwargs)
|
||||
else:
|
||||
self.logger.info(f"SUCCESS: {message}", **kwargs)
|
||||
|
||||
self.log_counts['info'] += 1
|
||||
|
||||
def step(self, step: int, total: int, message: str, **kwargs) -> None:
|
||||
"""Log step progress"""
|
||||
step_msg = f"[{step}/{total}] {message}"
|
||||
self.info(step_msg, **kwargs)
|
||||
|
||||
def section(self, title: str, **kwargs) -> None:
|
||||
"""Log section header"""
|
||||
separator = "=" * min(50, len(title) + 4)
|
||||
self.info(separator, **kwargs)
|
||||
self.info(f" {title}", **kwargs)
|
||||
self.info(separator, **kwargs)
|
||||
|
||||
def exception(self, message: str, exc_info: bool = True, **kwargs) -> None:
|
||||
"""Log exception with traceback"""
|
||||
self.logger.error(message, exc_info=exc_info, **kwargs)
|
||||
self.log_counts['error'] += 1
|
||||
|
||||
def log_system_info(self, info: Dict[str, Any]) -> None:
|
||||
"""Log system information"""
|
||||
self.section("System Information")
|
||||
for key, value in info.items():
|
||||
self.info(f"{key}: {value}")
|
||||
|
||||
def log_operation_start(self, operation: str, details: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Log start of operation"""
|
||||
self.section(f"Starting: {operation}")
|
||||
if details:
|
||||
for key, value in details.items():
|
||||
self.info(f"{key}: {value}")
|
||||
|
||||
def log_operation_end(self, operation: str, success: bool, duration: float, details: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Log end of operation"""
|
||||
status = "SUCCESS" if success else "FAILED"
|
||||
self.info(f"Operation {operation} completed: {status} (Duration: {duration:.2f}s)")
|
||||
|
||||
if details:
|
||||
for key, value in details.items():
|
||||
self.info(f"{key}: {value}")
|
||||
|
||||
def get_statistics(self) -> Dict[str, Any]:
|
||||
"""Get logging statistics"""
|
||||
runtime = datetime.now() - self.session_start
|
||||
|
||||
return {
|
||||
'session_start': self.session_start.isoformat(),
|
||||
'runtime_seconds': runtime.total_seconds(),
|
||||
'log_counts': self.log_counts.copy(),
|
||||
'total_messages': sum(self.log_counts.values()),
|
||||
'log_file': str(self.log_file) if hasattr(self, 'log_file') and self.log_file else None,
|
||||
'has_errors': self.log_counts['error'] + self.log_counts['critical'] > 0
|
||||
}
|
||||
|
||||
def set_console_level(self, level: LogLevel) -> None:
|
||||
"""Change console logging level"""
|
||||
self.console_level = level
|
||||
if self.logger.handlers:
|
||||
self.logger.handlers[0].setLevel(level.value)
|
||||
|
||||
def set_file_level(self, level: LogLevel) -> None:
|
||||
"""Change file logging level"""
|
||||
self.file_level = level
|
||||
if len(self.logger.handlers) > 1:
|
||||
self.logger.handlers[1].setLevel(level.value)
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Flush all handlers"""
|
||||
for handler in self.logger.handlers:
|
||||
if hasattr(handler, 'flush'):
|
||||
handler.flush()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close logger and handlers"""
|
||||
self.section("Installation Session Complete")
|
||||
stats = self.get_statistics()
|
||||
|
||||
self.info(f"Total runtime: {stats['runtime_seconds']:.1f} seconds")
|
||||
self.info(f"Messages logged: {stats['total_messages']}")
|
||||
if stats['has_errors']:
|
||||
self.warning(f"Errors/warnings: {stats['log_counts']['error'] + stats['log_counts']['warning']}")
|
||||
|
||||
if stats['log_file']:
|
||||
self.info(f"Full log saved to: {stats['log_file']}")
|
||||
|
||||
# Close all handlers
|
||||
for handler in self.logger.handlers[:]:
|
||||
handler.close()
|
||||
self.logger.removeHandler(handler)
|
||||
|
||||
|
||||
# Global logger instance
|
||||
_global_logger: Optional[Logger] = None
|
||||
|
||||
|
||||
def get_logger(name: str = "superclaude") -> Logger:
|
||||
"""Get or create global logger instance"""
|
||||
global _global_logger
|
||||
|
||||
if _global_logger is None or _global_logger.name != name:
|
||||
_global_logger = Logger(name)
|
||||
|
||||
return _global_logger
|
||||
|
||||
|
||||
def setup_logging(name: str = "superclaude", log_dir: Optional[Path] = None, console_level: LogLevel = LogLevel.INFO, file_level: LogLevel = LogLevel.DEBUG) -> Logger:
|
||||
"""Setup logging with specified configuration"""
|
||||
global _global_logger
|
||||
_global_logger = Logger(name, log_dir, console_level, file_level)
|
||||
return _global_logger
|
||||
|
||||
|
||||
# Convenience functions using global logger
|
||||
def debug(message: str, **kwargs) -> None:
|
||||
"""Log debug message using global logger"""
|
||||
get_logger().debug(message, **kwargs)
|
||||
|
||||
|
||||
def info(message: str, **kwargs) -> None:
|
||||
"""Log info message using global logger"""
|
||||
get_logger().info(message, **kwargs)
|
||||
|
||||
|
||||
def warning(message: str, **kwargs) -> None:
|
||||
"""Log warning message using global logger"""
|
||||
get_logger().warning(message, **kwargs)
|
||||
|
||||
|
||||
def error(message: str, **kwargs) -> None:
|
||||
"""Log error message using global logger"""
|
||||
get_logger().error(message, **kwargs)
|
||||
|
||||
|
||||
def critical(message: str, **kwargs) -> None:
|
||||
"""Log critical message using global logger"""
|
||||
get_logger().critical(message, **kwargs)
|
||||
|
||||
|
||||
def success(message: str, **kwargs) -> None:
|
||||
"""Log success message using global logger"""
|
||||
get_logger().success(message, **kwargs)
|
||||
788
setup/utils/security.py
Normal file
788
setup/utils/security.py
Normal file
@@ -0,0 +1,788 @@
|
||||
"""
|
||||
Security utilities for SuperClaude installation system
|
||||
Path validation and input sanitization
|
||||
|
||||
This module provides comprehensive security validation for file paths and user inputs
|
||||
during SuperClaude installation. It includes protection against:
|
||||
- Directory traversal attacks
|
||||
- Installation to system directories
|
||||
- Path injection attacks
|
||||
- Cross-platform security issues
|
||||
|
||||
Key Features:
|
||||
- Platform-specific validation (Windows vs Unix)
|
||||
- User-friendly error messages with actionable suggestions
|
||||
- Comprehensive path normalization
|
||||
- Backward compatibility with existing validation logic
|
||||
|
||||
Fixed Issues:
|
||||
- GitHub Issue #129: Fixed overly broad regex patterns that prevented installation
|
||||
in legitimate paths containing "dev", "tmp", "bin", etc.
|
||||
- Enhanced cross-platform compatibility
|
||||
- Improved error message clarity
|
||||
|
||||
Architecture:
|
||||
- Separated pattern categories for better maintainability
|
||||
- Platform-aware validation logic
|
||||
- Comprehensive test coverage
|
||||
"""
|
||||
|
||||
import re
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple, Set
|
||||
import urllib.parse
|
||||
|
||||
|
||||
class SecurityValidator:
|
||||
"""Security validation utilities"""
|
||||
|
||||
# Directory traversal patterns (match anywhere in path - platform independent)
|
||||
# These patterns detect common directory traversal attack vectors
|
||||
TRAVERSAL_PATTERNS = [
|
||||
r'\.\./', # Directory traversal using ../
|
||||
r'\.\.\.', # Directory traversal using ...
|
||||
r'//+', # Multiple consecutive slashes (path injection)
|
||||
]
|
||||
|
||||
# Unix system directories (match only at start of path)
|
||||
# These patterns identify Unix/Linux system directories that should not be writable
|
||||
# by regular users. Using ^ anchor to match only at path start prevents false positives
|
||||
# for user directories containing these names (e.g., /home/user/dev/ is allowed)
|
||||
UNIX_SYSTEM_PATTERNS = [
|
||||
r'^/etc/', # System configuration files
|
||||
r'^/bin/', # Essential command binaries
|
||||
r'^/sbin/', # System binaries
|
||||
r'^/usr/bin/', # User command binaries
|
||||
r'^/usr/sbin/', # Non-essential system binaries
|
||||
r'^/var/', # Variable data files
|
||||
r'^/tmp/', # Temporary files (system-wide)
|
||||
r'^/dev/', # Device files - FIXED: was r'/dev/' (GitHub Issue #129)
|
||||
r'^/proc/', # Process information pseudo-filesystem
|
||||
r'^/sys/', # System information pseudo-filesystem
|
||||
]
|
||||
|
||||
# Windows system directories (match only at start of path)
|
||||
# These patterns identify Windows system directories using flexible separator matching
|
||||
# to handle both forward slashes and backslashes consistently
|
||||
WINDOWS_SYSTEM_PATTERNS = [
|
||||
r'^c:[/\\]windows[/\\]', # Windows system directory
|
||||
r'^c:[/\\]program files[/\\]', # Program Files directory
|
||||
# Note: Removed c:\\users\\ to allow installation in user directories
|
||||
# Claude Code installs to user home directory by default
|
||||
]
|
||||
|
||||
# Combined dangerous patterns for backward compatibility
|
||||
# This maintains compatibility with existing code while providing the new categorized approach
|
||||
DANGEROUS_PATTERNS = TRAVERSAL_PATTERNS + UNIX_SYSTEM_PATTERNS + WINDOWS_SYSTEM_PATTERNS
|
||||
|
||||
# Dangerous filename patterns
|
||||
DANGEROUS_FILENAMES = [
|
||||
r'\.exe$', # Executables
|
||||
r'\.bat$',
|
||||
r'\.cmd$',
|
||||
r'\.scr$',
|
||||
r'\.dll$',
|
||||
r'\.so$',
|
||||
r'\.dylib$',
|
||||
r'passwd', # System files
|
||||
r'shadow',
|
||||
r'hosts',
|
||||
r'\.ssh/',
|
||||
r'\.aws/',
|
||||
r'\.env', # Environment files
|
||||
r'\.secret',
|
||||
]
|
||||
|
||||
# Allowed file extensions for installation
|
||||
ALLOWED_EXTENSIONS = {
|
||||
'.md', '.json', '.py', '.js', '.ts', '.jsx', '.tsx',
|
||||
'.txt', '.yml', '.yaml', '.toml', '.cfg', '.conf',
|
||||
'.sh', '.ps1', '.html', '.css', '.svg', '.png', '.jpg', '.gif'
|
||||
}
|
||||
|
||||
# Maximum path lengths
|
||||
MAX_PATH_LENGTH = 4096
|
||||
MAX_FILENAME_LENGTH = 255
|
||||
|
||||
@classmethod
|
||||
def validate_path(cls, path: Path, base_dir: Optional[Path] = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate path for security issues with enhanced cross-platform support
|
||||
|
||||
This method performs comprehensive security validation including:
|
||||
- Directory traversal attack detection
|
||||
- System directory protection (platform-specific)
|
||||
- Path length and filename validation
|
||||
- Cross-platform path normalization
|
||||
- User-friendly error messages
|
||||
|
||||
Architecture:
|
||||
- Uses both original and resolved paths for validation
|
||||
- Applies platform-specific patterns for system directories
|
||||
- Checks traversal patterns against original path to catch attacks before normalization
|
||||
- Provides detailed error messages with actionable suggestions
|
||||
|
||||
Args:
|
||||
path: Path to validate (can be relative or absolute)
|
||||
base_dir: Base directory that path should be within (optional)
|
||||
|
||||
Returns:
|
||||
Tuple of (is_safe: bool, error_message: str)
|
||||
- is_safe: True if path passes all security checks
|
||||
- error_message: Detailed error message with suggestions if validation fails
|
||||
"""
|
||||
try:
|
||||
# Convert to absolute path
|
||||
abs_path = path.resolve()
|
||||
|
||||
# For system directory validation, use the original path structure
|
||||
# to avoid issues with symlinks and cross-platform path resolution
|
||||
original_path_str = cls._normalize_path_for_validation(path)
|
||||
resolved_path_str = cls._normalize_path_for_validation(abs_path)
|
||||
|
||||
# Check path length
|
||||
if len(str(abs_path)) > cls.MAX_PATH_LENGTH:
|
||||
return False, f"Path too long: {len(str(abs_path))} > {cls.MAX_PATH_LENGTH}"
|
||||
|
||||
# Check filename length
|
||||
if len(abs_path.name) > cls.MAX_FILENAME_LENGTH:
|
||||
return False, f"Filename too long: {len(abs_path.name)} > {cls.MAX_FILENAME_LENGTH}"
|
||||
|
||||
# Check for dangerous patterns using platform-specific validation
|
||||
# Always check traversal patterns (platform independent) - use original path string
|
||||
# to detect patterns before normalization removes them
|
||||
original_str = str(path).lower()
|
||||
for pattern in cls.TRAVERSAL_PATTERNS:
|
||||
if re.search(pattern, original_str, re.IGNORECASE):
|
||||
return False, cls._get_user_friendly_error_message("traversal", pattern, abs_path)
|
||||
|
||||
# Check platform-specific system directory patterns - use original path first, then resolved
|
||||
# Always check both Windows and Unix patterns to handle cross-platform scenarios
|
||||
|
||||
# Check Windows system directory patterns
|
||||
for pattern in cls.WINDOWS_SYSTEM_PATTERNS:
|
||||
if (re.search(pattern, original_path_str, re.IGNORECASE) or
|
||||
re.search(pattern, resolved_path_str, re.IGNORECASE)):
|
||||
return False, cls._get_user_friendly_error_message("windows_system", pattern, abs_path)
|
||||
|
||||
# Check Unix system directory patterns
|
||||
for pattern in cls.UNIX_SYSTEM_PATTERNS:
|
||||
if (re.search(pattern, original_path_str, re.IGNORECASE) or
|
||||
re.search(pattern, resolved_path_str, re.IGNORECASE)):
|
||||
return False, cls._get_user_friendly_error_message("unix_system", pattern, abs_path)
|
||||
|
||||
# Check for dangerous filenames
|
||||
for pattern in cls.DANGEROUS_FILENAMES:
|
||||
if re.search(pattern, abs_path.name, re.IGNORECASE):
|
||||
return False, f"Dangerous filename pattern detected: {pattern}"
|
||||
|
||||
# Check if path is within base directory
|
||||
if base_dir:
|
||||
base_abs = base_dir.resolve()
|
||||
try:
|
||||
abs_path.relative_to(base_abs)
|
||||
except ValueError:
|
||||
return False, f"Path outside allowed directory: {abs_path} not in {base_abs}"
|
||||
|
||||
# Check for null bytes
|
||||
if '\x00' in str(path):
|
||||
return False, "Null byte detected in path"
|
||||
|
||||
# Check for Windows reserved names
|
||||
if os.name == 'nt':
|
||||
reserved_names = [
|
||||
'CON', 'PRN', 'AUX', 'NUL',
|
||||
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
||||
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
|
||||
]
|
||||
|
||||
name_without_ext = abs_path.stem.upper()
|
||||
if name_without_ext in reserved_names:
|
||||
return False, f"Reserved Windows filename: {name_without_ext}"
|
||||
|
||||
return True, "Path is safe"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Path validation error: {e}"
|
||||
|
||||
@classmethod
|
||||
def validate_file_extension(cls, path: Path) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate file extension is allowed
|
||||
|
||||
Args:
|
||||
path: Path to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_allowed: bool, message: str)
|
||||
"""
|
||||
extension = path.suffix.lower()
|
||||
|
||||
if not extension:
|
||||
return True, "No extension (allowed)"
|
||||
|
||||
if extension in cls.ALLOWED_EXTENSIONS:
|
||||
return True, f"Extension {extension} is allowed"
|
||||
else:
|
||||
return False, f"Extension {extension} is not allowed"
|
||||
|
||||
@classmethod
|
||||
def sanitize_filename(cls, filename: str) -> str:
|
||||
"""
|
||||
Sanitize filename by removing dangerous characters
|
||||
|
||||
Args:
|
||||
filename: Original filename
|
||||
|
||||
Returns:
|
||||
Sanitized filename
|
||||
"""
|
||||
# Remove null bytes
|
||||
filename = filename.replace('\x00', '')
|
||||
|
||||
# Remove or replace dangerous characters
|
||||
dangerous_chars = r'[<>:"/\\|?*\x00-\x1f]'
|
||||
filename = re.sub(dangerous_chars, '_', filename)
|
||||
|
||||
# Remove leading/trailing dots and spaces
|
||||
filename = filename.strip('. ')
|
||||
|
||||
# Ensure not empty
|
||||
if not filename:
|
||||
filename = 'unnamed'
|
||||
|
||||
# Truncate if too long
|
||||
if len(filename) > cls.MAX_FILENAME_LENGTH:
|
||||
name, ext = os.path.splitext(filename)
|
||||
max_name_len = cls.MAX_FILENAME_LENGTH - len(ext)
|
||||
filename = name[:max_name_len] + ext
|
||||
|
||||
# Check for Windows reserved names
|
||||
if os.name == 'nt':
|
||||
name_without_ext = os.path.splitext(filename)[0].upper()
|
||||
reserved_names = [
|
||||
'CON', 'PRN', 'AUX', 'NUL',
|
||||
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
||||
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
|
||||
]
|
||||
|
||||
if name_without_ext in reserved_names:
|
||||
filename = f"safe_{filename}"
|
||||
|
||||
return filename
|
||||
|
||||
@classmethod
|
||||
def sanitize_input(cls, user_input: str, max_length: int = 1000) -> str:
|
||||
"""
|
||||
Sanitize user input
|
||||
|
||||
Args:
|
||||
user_input: Raw user input
|
||||
max_length: Maximum allowed length
|
||||
|
||||
Returns:
|
||||
Sanitized input
|
||||
"""
|
||||
if not user_input:
|
||||
return ""
|
||||
|
||||
# Remove null bytes and control characters
|
||||
sanitized = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', user_input)
|
||||
|
||||
# Trim whitespace
|
||||
sanitized = sanitized.strip()
|
||||
|
||||
# Truncate if too long
|
||||
if len(sanitized) > max_length:
|
||||
sanitized = sanitized[:max_length]
|
||||
|
||||
return sanitized
|
||||
|
||||
@classmethod
|
||||
def validate_url(cls, url: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate URL for security issues
|
||||
|
||||
Args:
|
||||
url: URL to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_safe: bool, message: str)
|
||||
"""
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
|
||||
# Check scheme
|
||||
if parsed.scheme not in ['http', 'https']:
|
||||
return False, f"Invalid scheme: {parsed.scheme}"
|
||||
|
||||
# Check for localhost/private IPs (basic check)
|
||||
hostname = parsed.hostname
|
||||
if hostname:
|
||||
if hostname.lower() in ['localhost', '127.0.0.1', '::1']:
|
||||
return False, "Localhost URLs not allowed"
|
||||
|
||||
# Basic private IP check
|
||||
if hostname.startswith('192.168.') or hostname.startswith('10.') or hostname.startswith('172.'):
|
||||
return False, "Private IP addresses not allowed"
|
||||
|
||||
# Check URL length
|
||||
if len(url) > 2048:
|
||||
return False, "URL too long"
|
||||
|
||||
return True, "URL is safe"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"URL validation error: {e}"
|
||||
|
||||
@classmethod
|
||||
def check_permissions(cls, path: Path, required_permissions: Set[str]) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Check file/directory permissions
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
required_permissions: Set of required permissions ('read', 'write', 'execute')
|
||||
|
||||
Returns:
|
||||
Tuple of (has_permissions: bool, missing_permissions: List[str])
|
||||
"""
|
||||
missing = []
|
||||
|
||||
try:
|
||||
if not path.exists():
|
||||
# For non-existent paths, check parent directory
|
||||
parent = path.parent
|
||||
if not parent.exists():
|
||||
missing.append("path does not exist")
|
||||
return False, missing
|
||||
path = parent
|
||||
|
||||
if 'read' in required_permissions:
|
||||
if not os.access(path, os.R_OK):
|
||||
missing.append('read')
|
||||
|
||||
if 'write' in required_permissions:
|
||||
if not os.access(path, os.W_OK):
|
||||
missing.append('write')
|
||||
|
||||
if 'execute' in required_permissions:
|
||||
if not os.access(path, os.X_OK):
|
||||
missing.append('execute')
|
||||
|
||||
return len(missing) == 0, missing
|
||||
|
||||
except Exception as e:
|
||||
missing.append(f"permission check error: {e}")
|
||||
return False, missing
|
||||
|
||||
@classmethod
|
||||
def validate_installation_target(cls, target_dir: Path) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate installation target directory with enhanced Windows compatibility
|
||||
|
||||
Args:
|
||||
target_dir: Target installation directory
|
||||
|
||||
Returns:
|
||||
Tuple of (is_safe: bool, error_messages: List[str])
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Enhanced path resolution with Windows normalization
|
||||
try:
|
||||
abs_target = target_dir.resolve()
|
||||
except Exception as e:
|
||||
errors.append(f"Cannot resolve target path: {e}")
|
||||
return False, errors
|
||||
|
||||
# Windows-specific path normalization
|
||||
if os.name == 'nt':
|
||||
# Normalize Windows paths for consistent comparison
|
||||
abs_target_str = str(abs_target).lower().replace('/', '\\')
|
||||
else:
|
||||
abs_target_str = str(abs_target).lower()
|
||||
|
||||
# Special handling for Claude installation directory
|
||||
claude_patterns = ['.claude', '.claude' + os.sep, '.claude\\', '.claude/']
|
||||
is_claude_dir = any(abs_target_str.endswith(pattern) for pattern in claude_patterns)
|
||||
|
||||
if is_claude_dir:
|
||||
try:
|
||||
home_path = Path.home()
|
||||
except (RuntimeError, OSError):
|
||||
# If we can't determine home directory, skip .claude special handling
|
||||
cls._log_security_decision("WARN", f"Cannot determine home directory for .claude validation: {abs_target}")
|
||||
# Fall through to regular validation
|
||||
else:
|
||||
try:
|
||||
# Verify it's specifically the current user's home directory
|
||||
abs_target.relative_to(home_path)
|
||||
|
||||
# Enhanced Windows security checks for .claude directories
|
||||
if os.name == 'nt':
|
||||
# Check for junction points and symbolic links on Windows
|
||||
if cls._is_windows_junction_or_symlink(abs_target):
|
||||
errors.append("Installation to junction points or symbolic links is not allowed for security")
|
||||
return False, errors
|
||||
|
||||
# Additional validation: verify it's in the current user's profile directory
|
||||
# Use actual home directory comparison instead of username-based path construction
|
||||
if ':' in abs_target_str and '\\users\\' in abs_target_str:
|
||||
try:
|
||||
# Check if target is within the user's actual home directory
|
||||
home_path = Path.home()
|
||||
abs_target.relative_to(home_path)
|
||||
# Path is valid - within user's home directory
|
||||
except ValueError:
|
||||
# Path is outside user's home directory
|
||||
current_user = os.environ.get('USERNAME', home_path.name)
|
||||
errors.append(f"Installation must be in current user's directory ({current_user})")
|
||||
return False, errors
|
||||
|
||||
# Check permissions
|
||||
has_perms, missing = cls.check_permissions(target_dir, {'read', 'write'})
|
||||
if not has_perms:
|
||||
if os.name == 'nt':
|
||||
errors.append(f"Insufficient permissions for Windows installation: {missing}. Try running as administrator or check folder permissions.")
|
||||
else:
|
||||
errors.append(f"Insufficient permissions: missing {missing}")
|
||||
|
||||
# Log successful validation for audit trail
|
||||
cls._log_security_decision("ALLOW", f"Claude directory installation validated: {abs_target}")
|
||||
return len(errors) == 0, errors
|
||||
|
||||
except ValueError:
|
||||
# Not under current user's home directory
|
||||
if os.name == 'nt':
|
||||
errors.append("Claude installation must be in your user directory (e.g., C:\\Users\\YourName\\.claude)")
|
||||
else:
|
||||
errors.append("Claude installation must be in your home directory (e.g., ~/.claude)")
|
||||
cls._log_security_decision("DENY", f"Claude directory outside user home: {abs_target}")
|
||||
return False, errors
|
||||
|
||||
# Validate path for non-.claude directories
|
||||
is_safe, msg = cls.validate_path(target_dir)
|
||||
if not is_safe:
|
||||
if os.name == 'nt':
|
||||
# Enhanced Windows error messages
|
||||
if "dangerous path pattern" in msg.lower():
|
||||
errors.append(f"Invalid Windows path: {msg}. Ensure path doesn't contain dangerous patterns or reserved directories.")
|
||||
elif "path too long" in msg.lower():
|
||||
errors.append(f"Windows path too long: {msg}. Windows has a 260 character limit for most paths.")
|
||||
elif "reserved" in msg.lower():
|
||||
errors.append(f"Windows reserved name: {msg}. Avoid names like CON, PRN, AUX, NUL, COM1-9, LPT1-9.")
|
||||
else:
|
||||
errors.append(f"Invalid target path: {msg}")
|
||||
else:
|
||||
errors.append(f"Invalid target path: {msg}")
|
||||
|
||||
# Check permissions with platform-specific guidance
|
||||
has_perms, missing = cls.check_permissions(target_dir, {'read', 'write'})
|
||||
if not has_perms:
|
||||
if os.name == 'nt':
|
||||
errors.append(f"Insufficient Windows permissions: {missing}. Try running as administrator or check folder security settings in Properties > Security.")
|
||||
else:
|
||||
errors.append(f"Insufficient permissions: {missing}. Try: chmod 755 {target_dir}")
|
||||
|
||||
# Check if it's a system directory with enhanced messages
|
||||
system_dirs = [
|
||||
Path('/etc'), Path('/bin'), Path('/sbin'), Path('/usr/bin'), Path('/usr/sbin'),
|
||||
Path('/var'), Path('/tmp'), Path('/dev'), Path('/proc'), Path('/sys')
|
||||
]
|
||||
|
||||
if os.name == 'nt':
|
||||
system_dirs.extend([
|
||||
Path('C:\\Windows'), Path('C:\\Program Files'), Path('C:\\Program Files (x86)')
|
||||
])
|
||||
|
||||
for sys_dir in system_dirs:
|
||||
try:
|
||||
if abs_target.is_relative_to(sys_dir):
|
||||
if os.name == 'nt':
|
||||
errors.append(f"Cannot install to Windows system directory: {sys_dir}. Use a location in your user profile instead (e.g., C:\\Users\\YourName\\).")
|
||||
else:
|
||||
errors.append(f"Cannot install to system directory: {sys_dir}. Use a location in your home directory instead (~/).")
|
||||
cls._log_security_decision("DENY", f"Attempted installation to system directory: {sys_dir}")
|
||||
break
|
||||
except (ValueError, AttributeError):
|
||||
# is_relative_to not available in older Python versions
|
||||
try:
|
||||
abs_target.relative_to(sys_dir)
|
||||
errors.append(f"Cannot install to system directory: {sys_dir}")
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
@classmethod
|
||||
def validate_component_files(cls, file_list: List[Tuple[Path, Path]], base_source_dir: Path, base_target_dir: Path) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate list of files for component installation
|
||||
|
||||
Args:
|
||||
file_list: List of (source, target) path tuples
|
||||
base_source_dir: Base source directory
|
||||
base_target_dir: Base target directory
|
||||
|
||||
Returns:
|
||||
Tuple of (all_safe: bool, error_messages: List[str])
|
||||
"""
|
||||
errors = []
|
||||
|
||||
for source, target in file_list:
|
||||
# Validate source path
|
||||
is_safe, msg = cls.validate_path(source, base_source_dir)
|
||||
if not is_safe:
|
||||
errors.append(f"Invalid source path {source}: {msg}")
|
||||
|
||||
# Validate target path
|
||||
is_safe, msg = cls.validate_path(target, base_target_dir)
|
||||
if not is_safe:
|
||||
errors.append(f"Invalid target path {target}: {msg}")
|
||||
|
||||
# Validate file extension
|
||||
is_allowed, msg = cls.validate_file_extension(source)
|
||||
if not is_allowed:
|
||||
errors.append(f"File {source}: {msg}")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
@classmethod
|
||||
def _normalize_path_for_validation(cls, path: Path) -> str:
|
||||
"""
|
||||
Normalize path for consistent validation across platforms
|
||||
|
||||
Args:
|
||||
path: Path to normalize
|
||||
|
||||
Returns:
|
||||
Normalized path string for validation
|
||||
"""
|
||||
path_str = str(path)
|
||||
|
||||
# Convert to lowercase for case-insensitive comparison
|
||||
path_str = path_str.lower()
|
||||
|
||||
# Normalize path separators for consistent pattern matching
|
||||
if os.name == 'nt': # Windows
|
||||
# Convert forward slashes to backslashes for Windows
|
||||
path_str = path_str.replace('/', '\\')
|
||||
# Ensure consistent drive letter format
|
||||
if len(path_str) >= 2 and path_str[1] == ':':
|
||||
path_str = path_str[0] + ':\\' + path_str[3:].lstrip('\\')
|
||||
else: # Unix-like systems
|
||||
# Convert backslashes to forward slashes for Unix
|
||||
path_str = path_str.replace('\\', '/')
|
||||
# Ensure single leading slash
|
||||
if path_str.startswith('//'):
|
||||
path_str = '/' + path_str.lstrip('/')
|
||||
|
||||
return path_str
|
||||
|
||||
@classmethod
|
||||
def _get_user_friendly_error_message(cls, error_type: str, pattern: str, path: Path) -> str:
|
||||
"""
|
||||
Generate user-friendly error messages with actionable suggestions
|
||||
|
||||
Args:
|
||||
error_type: Type of error (traversal, windows_system, unix_system)
|
||||
pattern: The regex pattern that matched
|
||||
path: The path that caused the error
|
||||
|
||||
Returns:
|
||||
User-friendly error message with suggestions
|
||||
"""
|
||||
if error_type == "traversal":
|
||||
return (
|
||||
f"Security violation: Directory traversal pattern detected in path '{path}'. "
|
||||
f"Paths containing '..' or '//' are not allowed for security reasons. "
|
||||
f"Please use an absolute path without directory traversal characters."
|
||||
)
|
||||
elif error_type == "windows_system":
|
||||
if pattern == r'^c:\\windows\\':
|
||||
return (
|
||||
f"Cannot install to Windows system directory '{path}'. "
|
||||
f"Please choose a location in your user directory instead, "
|
||||
f"such as C:\\Users\\{os.environ.get('USERNAME', 'YourName')}\\.claude\\"
|
||||
)
|
||||
elif pattern == r'^c:\\program files\\':
|
||||
return (
|
||||
f"Cannot install to Program Files directory '{path}'. "
|
||||
f"Please choose a location in your user directory instead, "
|
||||
f"such as C:\\Users\\{os.environ.get('USERNAME', 'YourName')}\\.claude\\"
|
||||
)
|
||||
else:
|
||||
return (
|
||||
f"Cannot install to Windows system directory '{path}'. "
|
||||
f"Please choose a location in your user directory instead."
|
||||
)
|
||||
elif error_type == "unix_system":
|
||||
system_dirs = {
|
||||
r'^/dev/': "/dev (device files)",
|
||||
r'^/etc/': "/etc (system configuration)",
|
||||
r'^/bin/': "/bin (system binaries)",
|
||||
r'^/sbin/': "/sbin (system binaries)",
|
||||
r'^/usr/bin/': "/usr/bin (user binaries)",
|
||||
r'^/usr/sbin/': "/usr/sbin (user system binaries)",
|
||||
r'^/var/': "/var (variable data)",
|
||||
r'^/tmp/': "/tmp (temporary files)",
|
||||
r'^/proc/': "/proc (process information)",
|
||||
r'^/sys/': "/sys (system information)"
|
||||
}
|
||||
|
||||
dir_desc = system_dirs.get(pattern, "system directory")
|
||||
return (
|
||||
f"Cannot install to {dir_desc} '{path}'. "
|
||||
f"Please choose a location in your home directory instead, "
|
||||
f"such as ~/.claude/ or ~/SuperClaude/"
|
||||
)
|
||||
else:
|
||||
return f"Security validation failed for path '{path}'"
|
||||
|
||||
@classmethod
|
||||
def _is_windows_junction_or_symlink(cls, path: Path) -> bool:
|
||||
"""
|
||||
Check if path is a Windows junction point or symbolic link
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
|
||||
Returns:
|
||||
True if path is a junction point or symlink, False otherwise
|
||||
"""
|
||||
if os.name != 'nt':
|
||||
return False
|
||||
|
||||
try:
|
||||
# Only check if path exists to avoid filesystem errors during testing
|
||||
if not path.exists():
|
||||
return False
|
||||
|
||||
# Check if path is a symlink (covers most cases)
|
||||
if path.is_symlink():
|
||||
return True
|
||||
|
||||
# Additional Windows-specific checks for junction points
|
||||
try:
|
||||
import stat
|
||||
st = path.stat()
|
||||
# Check for reparse point (junction points have this attribute)
|
||||
if hasattr(st, 'st_reparse_tag') and st.st_reparse_tag != 0:
|
||||
return True
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
|
||||
# Alternative method using os.path.islink
|
||||
try:
|
||||
if os.path.islink(str(path)):
|
||||
return True
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
|
||||
except (OSError, AttributeError, NotImplementedError):
|
||||
# If we can't determine safely, default to False
|
||||
# This ensures the function doesn't break validation
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _log_security_decision(cls, action: str, message: str) -> None:
|
||||
"""
|
||||
Log security validation decisions for audit trail
|
||||
|
||||
Args:
|
||||
action: Security action taken (ALLOW, DENY, WARN)
|
||||
message: Description of the decision
|
||||
"""
|
||||
try:
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
# Create security logger if it doesn't exist
|
||||
security_logger = logging.getLogger('superclaude.security')
|
||||
if not security_logger.handlers:
|
||||
# Set up basic logging if not already configured
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - SECURITY - %(levelname)s - %(message)s'
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
security_logger.addHandler(handler)
|
||||
security_logger.setLevel(logging.INFO)
|
||||
|
||||
# Log the security decision
|
||||
timestamp = datetime.datetime.now().isoformat()
|
||||
log_message = f"[{action}] {message} (PID: {os.getpid()})"
|
||||
|
||||
if action == "DENY":
|
||||
security_logger.warning(log_message)
|
||||
else:
|
||||
security_logger.info(log_message)
|
||||
|
||||
except Exception:
|
||||
# Don't fail security validation if logging fails
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def create_secure_temp_dir(cls, prefix: str = "superclaude_") -> Path:
|
||||
"""
|
||||
Create secure temporary directory
|
||||
|
||||
Args:
|
||||
prefix: Prefix for temp directory name
|
||||
|
||||
Returns:
|
||||
Path to secure temporary directory
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
# Create with secure permissions (0o700)
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix=prefix))
|
||||
temp_dir.chmod(0o700)
|
||||
|
||||
return temp_dir
|
||||
|
||||
@classmethod
|
||||
def secure_delete(cls, path: Path) -> bool:
|
||||
"""
|
||||
Securely delete file or directory
|
||||
|
||||
Args:
|
||||
path: Path to delete
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not path.exists():
|
||||
return True
|
||||
|
||||
if path.is_file():
|
||||
# Overwrite file with random data before deletion
|
||||
try:
|
||||
import secrets
|
||||
file_size = path.stat().st_size
|
||||
|
||||
with open(path, 'r+b') as f:
|
||||
# Overwrite with random data
|
||||
f.write(secrets.token_bytes(file_size))
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
except Exception:
|
||||
pass # If overwrite fails, still try to delete
|
||||
|
||||
path.unlink()
|
||||
|
||||
elif path.is_dir():
|
||||
# Recursively delete directory contents
|
||||
import shutil
|
||||
shutil.rmtree(path)
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
434
setup/utils/ui.py
Normal file
434
setup/utils/ui.py
Normal file
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
User interface utilities for SuperClaude installation system
|
||||
Cross-platform console UI with colors and progress indication
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import shutil
|
||||
from typing import List, Optional, Any, Dict, Union
|
||||
from enum import Enum
|
||||
|
||||
# Try to import colorama for cross-platform color support
|
||||
try:
|
||||
import colorama
|
||||
from colorama import Fore, Back, Style
|
||||
colorama.init(autoreset=True)
|
||||
COLORAMA_AVAILABLE = True
|
||||
except ImportError:
|
||||
COLORAMA_AVAILABLE = False
|
||||
# Fallback color codes for Unix-like systems
|
||||
class MockFore:
|
||||
RED = '\033[91m' if sys.platform != 'win32' else ''
|
||||
GREEN = '\033[92m' if sys.platform != 'win32' else ''
|
||||
YELLOW = '\033[93m' if sys.platform != 'win32' else ''
|
||||
BLUE = '\033[94m' if sys.platform != 'win32' else ''
|
||||
MAGENTA = '\033[95m' if sys.platform != 'win32' else ''
|
||||
CYAN = '\033[96m' if sys.platform != 'win32' else ''
|
||||
WHITE = '\033[97m' if sys.platform != 'win32' else ''
|
||||
|
||||
class MockStyle:
|
||||
RESET_ALL = '\033[0m' if sys.platform != 'win32' else ''
|
||||
BRIGHT = '\033[1m' if sys.platform != 'win32' else ''
|
||||
|
||||
Fore = MockFore()
|
||||
Style = MockStyle()
|
||||
|
||||
|
||||
class Colors:
|
||||
"""Color constants for console output"""
|
||||
RED = Fore.RED
|
||||
GREEN = Fore.GREEN
|
||||
YELLOW = Fore.YELLOW
|
||||
BLUE = Fore.BLUE
|
||||
MAGENTA = Fore.MAGENTA
|
||||
CYAN = Fore.CYAN
|
||||
WHITE = Fore.WHITE
|
||||
RESET = Style.RESET_ALL
|
||||
BRIGHT = Style.BRIGHT
|
||||
|
||||
|
||||
class ProgressBar:
|
||||
"""Cross-platform progress bar with customizable display"""
|
||||
|
||||
def __init__(self, total: int, width: int = 50, prefix: str = '', suffix: str = ''):
|
||||
"""
|
||||
Initialize progress bar
|
||||
|
||||
Args:
|
||||
total: Total number of items to process
|
||||
width: Width of progress bar in characters
|
||||
prefix: Text to display before progress bar
|
||||
suffix: Text to display after progress bar
|
||||
"""
|
||||
self.total = total
|
||||
self.width = width
|
||||
self.prefix = prefix
|
||||
self.suffix = suffix
|
||||
self.current = 0
|
||||
self.start_time = time.time()
|
||||
|
||||
# Get terminal width for responsive display
|
||||
try:
|
||||
self.terminal_width = shutil.get_terminal_size().columns
|
||||
except OSError:
|
||||
self.terminal_width = 80
|
||||
|
||||
def update(self, current: int, message: str = '') -> None:
|
||||
"""
|
||||
Update progress bar
|
||||
|
||||
Args:
|
||||
current: Current progress value
|
||||
message: Optional message to display
|
||||
"""
|
||||
self.current = current
|
||||
percent = min(100, (current / self.total) * 100) if self.total > 0 else 100
|
||||
|
||||
# Calculate filled and empty portions
|
||||
filled_width = int(self.width * current / self.total) if self.total > 0 else self.width
|
||||
filled = '█' * filled_width
|
||||
empty = '░' * (self.width - filled_width)
|
||||
|
||||
# Calculate elapsed time and ETA
|
||||
elapsed = time.time() - self.start_time
|
||||
if current > 0:
|
||||
eta = (elapsed / current) * (self.total - current)
|
||||
eta_str = f" ETA: {self._format_time(eta)}"
|
||||
else:
|
||||
eta_str = ""
|
||||
|
||||
# Format progress line
|
||||
if message:
|
||||
status = f" {message}"
|
||||
else:
|
||||
status = ""
|
||||
|
||||
progress_line = (
|
||||
f"\r{self.prefix}[{Colors.GREEN}{filled}{Colors.WHITE}{empty}{Colors.RESET}] "
|
||||
f"{percent:5.1f}%{status}{eta_str}"
|
||||
)
|
||||
|
||||
# Truncate if too long for terminal
|
||||
max_length = self.terminal_width - 5
|
||||
if len(progress_line) > max_length:
|
||||
# Remove color codes for length calculation
|
||||
plain_line = progress_line.replace(Colors.GREEN, '').replace(Colors.WHITE, '').replace(Colors.RESET, '')
|
||||
if len(plain_line) > max_length:
|
||||
progress_line = progress_line[:max_length] + "..."
|
||||
|
||||
print(progress_line, end='', flush=True)
|
||||
|
||||
def increment(self, message: str = '') -> None:
|
||||
"""
|
||||
Increment progress by 1
|
||||
|
||||
Args:
|
||||
message: Optional message to display
|
||||
"""
|
||||
self.update(self.current + 1, message)
|
||||
|
||||
def finish(self, message: str = 'Complete') -> None:
|
||||
"""
|
||||
Complete progress bar
|
||||
|
||||
Args:
|
||||
message: Completion message
|
||||
"""
|
||||
self.update(self.total, message)
|
||||
print() # New line after completion
|
||||
|
||||
def _format_time(self, seconds: float) -> str:
|
||||
"""Format time duration as human-readable string"""
|
||||
if seconds < 60:
|
||||
return f"{seconds:.0f}s"
|
||||
elif seconds < 3600:
|
||||
return f"{seconds/60:.0f}m {seconds%60:.0f}s"
|
||||
else:
|
||||
hours = seconds // 3600
|
||||
minutes = (seconds % 3600) // 60
|
||||
return f"{hours:.0f}h {minutes:.0f}m"
|
||||
|
||||
|
||||
class Menu:
|
||||
"""Interactive menu system with keyboard navigation"""
|
||||
|
||||
def __init__(self, title: str, options: List[str], multi_select: bool = False):
|
||||
"""
|
||||
Initialize menu
|
||||
|
||||
Args:
|
||||
title: Menu title
|
||||
options: List of menu options
|
||||
multi_select: Allow multiple selections
|
||||
"""
|
||||
self.title = title
|
||||
self.options = options
|
||||
self.multi_select = multi_select
|
||||
self.selected = set() if multi_select else None
|
||||
|
||||
def display(self) -> Union[int, List[int]]:
|
||||
"""
|
||||
Display menu and get user selection
|
||||
|
||||
Returns:
|
||||
Selected option index (single) or list of indices (multi-select)
|
||||
"""
|
||||
print(f"\n{Colors.CYAN}{Colors.BRIGHT}{self.title}{Colors.RESET}")
|
||||
print("=" * len(self.title))
|
||||
|
||||
for i, option in enumerate(self.options, 1):
|
||||
if self.multi_select:
|
||||
marker = "[x]" if i-1 in (self.selected or set()) else "[ ]"
|
||||
print(f"{Colors.YELLOW}{i:2d}.{Colors.RESET} {marker} {option}")
|
||||
else:
|
||||
print(f"{Colors.YELLOW}{i:2d}.{Colors.RESET} {option}")
|
||||
|
||||
if self.multi_select:
|
||||
print(f"\n{Colors.BLUE}Enter numbers separated by commas (e.g., 1,3,5) or 'all' for all options:{Colors.RESET}")
|
||||
else:
|
||||
print(f"\n{Colors.BLUE}Enter your choice (1-{len(self.options)}):{Colors.RESET}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = input("> ").strip().lower()
|
||||
|
||||
if self.multi_select:
|
||||
if user_input == 'all':
|
||||
return list(range(len(self.options)))
|
||||
elif user_input == '':
|
||||
return []
|
||||
else:
|
||||
# Parse comma-separated numbers
|
||||
selections = []
|
||||
for part in user_input.split(','):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
idx = int(part) - 1
|
||||
if 0 <= idx < len(self.options):
|
||||
selections.append(idx)
|
||||
else:
|
||||
raise ValueError(f"Invalid option: {part}")
|
||||
else:
|
||||
raise ValueError(f"Invalid input: {part}")
|
||||
return list(set(selections)) # Remove duplicates
|
||||
else:
|
||||
if user_input.isdigit():
|
||||
choice = int(user_input) - 1
|
||||
if 0 <= choice < len(self.options):
|
||||
return choice
|
||||
else:
|
||||
print(f"{Colors.RED}Invalid choice. Please enter a number between 1 and {len(self.options)}.{Colors.RESET}")
|
||||
else:
|
||||
print(f"{Colors.RED}Please enter a valid number.{Colors.RESET}")
|
||||
|
||||
except (ValueError, KeyboardInterrupt) as e:
|
||||
if isinstance(e, KeyboardInterrupt):
|
||||
print(f"\n{Colors.YELLOW}Operation cancelled.{Colors.RESET}")
|
||||
return [] if self.multi_select else -1
|
||||
else:
|
||||
print(f"{Colors.RED}Invalid input: {e}{Colors.RESET}")
|
||||
|
||||
|
||||
def confirm(message: str, default: bool = True) -> bool:
|
||||
"""
|
||||
Ask for user confirmation
|
||||
|
||||
Args:
|
||||
message: Confirmation message
|
||||
default: Default response if user just presses Enter
|
||||
|
||||
Returns:
|
||||
True if confirmed, False otherwise
|
||||
"""
|
||||
suffix = "[Y/n]" if default else "[y/N]"
|
||||
print(f"{Colors.BLUE}{message} {suffix}{Colors.RESET}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
response = input("> ").strip().lower()
|
||||
|
||||
if response == '':
|
||||
return default
|
||||
elif response in ['y', 'yes', 'true', '1']:
|
||||
return True
|
||||
elif response in ['n', 'no', 'false', '0']:
|
||||
return False
|
||||
else:
|
||||
print(f"{Colors.RED}Please enter 'y' or 'n' (or press Enter for default).{Colors.RESET}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{Colors.YELLOW}Operation cancelled.{Colors.RESET}")
|
||||
return False
|
||||
|
||||
|
||||
def display_header(title: str, subtitle: str = '') -> None:
|
||||
"""
|
||||
Display formatted header
|
||||
|
||||
Args:
|
||||
title: Main title
|
||||
subtitle: Optional subtitle
|
||||
"""
|
||||
print(f"\n{Colors.CYAN}{Colors.BRIGHT}{'='*60}{Colors.RESET}")
|
||||
print(f"{Colors.CYAN}{Colors.BRIGHT}{title:^60}{Colors.RESET}")
|
||||
if subtitle:
|
||||
print(f"{Colors.WHITE}{subtitle:^60}{Colors.RESET}")
|
||||
print(f"{Colors.CYAN}{Colors.BRIGHT}{'='*60}{Colors.RESET}\n")
|
||||
|
||||
|
||||
def display_info(message: str) -> None:
|
||||
"""Display info message"""
|
||||
print(f"{Colors.BLUE}[INFO] {message}{Colors.RESET}")
|
||||
|
||||
|
||||
def display_success(message: str) -> None:
|
||||
"""Display success message"""
|
||||
print(f"{Colors.GREEN}[✓] {message}{Colors.RESET}")
|
||||
|
||||
|
||||
def display_warning(message: str) -> None:
|
||||
"""Display warning message"""
|
||||
print(f"{Colors.YELLOW}[!] {message}{Colors.RESET}")
|
||||
|
||||
|
||||
def display_error(message: str) -> None:
|
||||
"""Display error message"""
|
||||
print(f"{Colors.RED}[✗] {message}{Colors.RESET}")
|
||||
|
||||
|
||||
def display_step(step: int, total: int, message: str) -> None:
|
||||
"""Display step progress"""
|
||||
print(f"{Colors.CYAN}[{step}/{total}] {message}{Colors.RESET}")
|
||||
|
||||
|
||||
def display_table(headers: List[str], rows: List[List[str]], title: str = '') -> None:
|
||||
"""
|
||||
Display data in table format
|
||||
|
||||
Args:
|
||||
headers: Column headers
|
||||
rows: Data rows
|
||||
title: Optional table title
|
||||
"""
|
||||
if not rows:
|
||||
return
|
||||
|
||||
# Calculate column widths
|
||||
col_widths = [len(header) for header in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
col_widths[i] = max(col_widths[i], len(str(cell)))
|
||||
|
||||
# Display title
|
||||
if title:
|
||||
print(f"\n{Colors.CYAN}{Colors.BRIGHT}{title}{Colors.RESET}")
|
||||
print()
|
||||
|
||||
# Display headers
|
||||
header_line = " | ".join(f"{header:<{col_widths[i]}}" for i, header in enumerate(headers))
|
||||
print(f"{Colors.YELLOW}{header_line}{Colors.RESET}")
|
||||
print("-" * len(header_line))
|
||||
|
||||
# Display rows
|
||||
for row in rows:
|
||||
row_line = " | ".join(f"{str(cell):<{col_widths[i]}}" for i, cell in enumerate(row))
|
||||
print(row_line)
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def wait_for_key(message: str = "Press Enter to continue...") -> None:
|
||||
"""Wait for user to press a key"""
|
||||
try:
|
||||
input(f"{Colors.BLUE}{message}{Colors.RESET}")
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{Colors.YELLOW}Operation cancelled.{Colors.RESET}")
|
||||
|
||||
|
||||
def clear_screen() -> None:
|
||||
"""Clear terminal screen"""
|
||||
import os
|
||||
os.system('cls' if os.name == 'nt' else 'clear')
|
||||
|
||||
|
||||
class StatusSpinner:
|
||||
"""Simple status spinner for long operations"""
|
||||
|
||||
def __init__(self, message: str = "Working..."):
|
||||
"""
|
||||
Initialize spinner
|
||||
|
||||
Args:
|
||||
message: Message to display with spinner
|
||||
"""
|
||||
self.message = message
|
||||
self.spinning = False
|
||||
self.chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
||||
self.current = 0
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start spinner in background thread"""
|
||||
import threading
|
||||
|
||||
def spin():
|
||||
while self.spinning:
|
||||
char = self.chars[self.current % len(self.chars)]
|
||||
print(f"\r{Colors.BLUE}{char} {self.message}{Colors.RESET}", end='', flush=True)
|
||||
self.current += 1
|
||||
time.sleep(0.1)
|
||||
|
||||
self.spinning = True
|
||||
self.thread = threading.Thread(target=spin, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def stop(self, final_message: str = '') -> None:
|
||||
"""
|
||||
Stop spinner
|
||||
|
||||
Args:
|
||||
final_message: Final message to display
|
||||
"""
|
||||
self.spinning = False
|
||||
if hasattr(self, 'thread'):
|
||||
self.thread.join(timeout=0.2)
|
||||
|
||||
# Clear spinner line
|
||||
print(f"\r{' ' * (len(self.message) + 5)}\r", end='')
|
||||
|
||||
if final_message:
|
||||
print(final_message)
|
||||
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""Format file size in human-readable format"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.1f} {unit}"
|
||||
size_bytes /= 1024.0
|
||||
return f"{size_bytes:.1f} PB"
|
||||
|
||||
|
||||
def format_duration(seconds: float) -> str:
|
||||
"""Format duration in human-readable format"""
|
||||
if seconds < 1:
|
||||
return f"{seconds*1000:.0f}ms"
|
||||
elif seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
elif seconds < 3600:
|
||||
minutes = seconds // 60
|
||||
secs = seconds % 60
|
||||
return f"{minutes:.0f}m {secs:.0f}s"
|
||||
else:
|
||||
hours = seconds // 3600
|
||||
minutes = (seconds % 3600) // 60
|
||||
return f"{hours:.0f}h {minutes:.0f}m"
|
||||
|
||||
|
||||
def truncate_text(text: str, max_length: int, suffix: str = "...") -> str:
|
||||
"""Truncate text to maximum length with optional suffix"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
|
||||
return text[:max_length - len(suffix)] + suffix
|
||||
Reference in New Issue
Block a user