Added the Setup implimentation

This commit is contained in:
mithun50
2025-08-14 08:56:04 +05:30
parent 6cfd975d00
commit 41b5924934
107 changed files with 9771 additions and 504 deletions

18
setup/__init__.py Normal file
View 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
View 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
View 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
View 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
}

View 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'
]

View 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
View 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
View 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
View 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
View 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
View 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
View 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()

View File

@@ -0,0 +1,9 @@
from .config_manager import ConfigManager
from .settings_manager import SettingsManager
from .file_manager import FileManager
__all__ = [
'ConfigManager',
'SettingsManager',
'FileManager'
]

View 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

View 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]
}

View 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

View 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
View 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
View 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)

View 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
View 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
View 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
View 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
View 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
View 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