From eb3759192221ac10aba59bab588d7ec2be69971e Mon Sep 17 00:00:00 2001 From: kazuki Date: Tue, 21 Oct 2025 11:58:20 +0900 Subject: [PATCH] refactor: remove legacy setup/ system and dependent tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove old installation system (setup/) that caused heavy token consumption: - Delete setup/core/ (installer, registry, validator) - Delete setup/components/ (agents, modes, commands installers) - Delete setup/cli/ (old CLI commands) - Delete setup/services/ (claude_md, config, files) - Delete setup/utils/ (logger, paths, security, etc.) Remove setup-dependent test files: - test_installer.py - test_get_components.py - test_mcp_component.py - test_install_command.py - test_mcp_docs_component.py Total: 38 files deleted New architecture (src/superclaude/) is self-contained and doesn't need setup/. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- setup/__init__.py | 24 - setup/cli/__init__.py | 11 - setup/cli/base.py | 83 -- setup/cli/commands/__init__.py | 18 - setup/cli/commands/backup.py | 609 --------------- setup/cli/commands/install.py | 765 ------------------- setup/cli/commands/uninstall.py | 983 ------------------------ setup/cli/commands/update.py | 512 ------------- setup/components/__init__.py | 24 - setup/components/agent_personas.py | 254 ------- setup/components/behavior_modes.py | 212 ------ setup/components/knowledge_base.py | 475 ------------ setup/components/mcp_integration.py | 1094 --------------------------- setup/components/slash_commands.py | 554 -------------- setup/core/__init__.py | 6 - setup/core/base.py | 467 ------------ setup/core/installer.py | 304 -------- setup/core/registry.py | 414 ---------- setup/core/validator.py | 723 ------------------ setup/data/__init__.py | 4 - setup/data/features.json | 49 -- setup/data/requirements.json | 54 -- setup/services/__init__.py | 11 - setup/services/claude_md.py | 334 -------- setup/services/config.py | 365 --------- setup/services/files.py | 442 ----------- setup/services/settings.py | 579 -------------- setup/utils/__init__.py | 10 - setup/utils/environment.py | 535 ------------- setup/utils/logger.py | 335 -------- setup/utils/paths.py | 54 -- setup/utils/security.py | 936 ----------------------- setup/utils/symbols.py | 198 ----- setup/utils/ui.py | 203 ----- setup/utils/updater.py | 329 -------- tests/test_get_components.py | 28 - tests/test_install_command.py | 67 -- tests/test_installer.py | 96 --- tests/test_mcp_component.py | 87 --- tests/test_mcp_docs_component.py | 41 - 40 files changed, 12289 deletions(-) delete mode 100644 setup/__init__.py delete mode 100644 setup/cli/__init__.py delete mode 100644 setup/cli/base.py delete mode 100644 setup/cli/commands/__init__.py delete mode 100644 setup/cli/commands/backup.py delete mode 100644 setup/cli/commands/install.py delete mode 100644 setup/cli/commands/uninstall.py delete mode 100644 setup/cli/commands/update.py delete mode 100644 setup/components/__init__.py delete mode 100644 setup/components/agent_personas.py delete mode 100644 setup/components/behavior_modes.py delete mode 100644 setup/components/knowledge_base.py delete mode 100644 setup/components/mcp_integration.py delete mode 100644 setup/components/slash_commands.py delete mode 100644 setup/core/__init__.py delete mode 100644 setup/core/base.py delete mode 100644 setup/core/installer.py delete mode 100644 setup/core/registry.py delete mode 100644 setup/core/validator.py delete mode 100644 setup/data/__init__.py delete mode 100644 setup/data/features.json delete mode 100644 setup/data/requirements.json delete mode 100644 setup/services/__init__.py delete mode 100644 setup/services/claude_md.py delete mode 100644 setup/services/config.py delete mode 100644 setup/services/files.py delete mode 100644 setup/services/settings.py delete mode 100644 setup/utils/__init__.py delete mode 100644 setup/utils/environment.py delete mode 100644 setup/utils/logger.py delete mode 100644 setup/utils/paths.py delete mode 100644 setup/utils/security.py delete mode 100644 setup/utils/symbols.py delete mode 100644 setup/utils/ui.py delete mode 100644 setup/utils/updater.py delete mode 100644 tests/test_get_components.py delete mode 100644 tests/test_install_command.py delete mode 100644 tests/test_installer.py delete mode 100644 tests/test_mcp_component.py delete mode 100644 tests/test_mcp_docs_component.py diff --git a/setup/__init__.py b/setup/__init__.py deleted file mode 100644 index 7947599..0000000 --- a/setup/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -SuperClaude Installation Suite -Pure Python installation system for SuperClaude framework -""" - -from pathlib import Path - -try: - __version__ = (Path(__file__).parent.parent / "VERSION").read_text().strip() -except Exception: - __version__ = "4.1.6" # Fallback - Deep Research Integration - -__author__ = "NomenAK, Mithun Gowda B" - -# Core paths -SETUP_DIR = Path(__file__).parent -PROJECT_ROOT = SETUP_DIR.parent -DATA_DIR = SETUP_DIR / "data" - -# Import home directory detection for immutable distros -from .utils.paths import get_home_directory - -# Installation target - SuperClaude components installed in subdirectory -DEFAULT_INSTALL_DIR = get_home_directory() / ".claude" / "superclaude" diff --git a/setup/cli/__init__.py b/setup/cli/__init__.py deleted file mode 100644 index 4fdd868..0000000 --- a/setup/cli/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -SuperClaude CLI Module -Command-line interface operations for SuperClaude installation system -""" - -from .base import OperationBase -from .commands import * - -__all__ = [ - "OperationBase", -] diff --git a/setup/cli/base.py b/setup/cli/base.py deleted file mode 100644 index cc4ae47..0000000 --- a/setup/cli/base.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -SuperClaude CLI Base Module - -Base class for all CLI operations providing common functionality -""" - -from pathlib import Path - -# Read version from VERSION file -try: - __version__ = (Path(__file__).parent.parent.parent / "VERSION").read_text().strip() -except Exception: - __version__ = "4.1.5" # Fallback - - -def get_command_info(): - """Get information about available commands""" - return { - "install": { - "name": "install", - "description": "Install SuperClaude framework components", - "module": "setup.cli.commands.install", - }, - "update": { - "name": "update", - "description": "Update existing SuperClaude installation", - "module": "setup.cli.commands.update", - }, - "uninstall": { - "name": "uninstall", - "description": "Remove SuperClaude framework installation", - "module": "setup.cli.commands.uninstall", - }, - "backup": { - "name": "backup", - "description": "Backup and restore SuperClaude installations", - "module": "setup.cli.commands.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 diff --git a/setup/cli/commands/__init__.py b/setup/cli/commands/__init__.py deleted file mode 100644 index c55c25a..0000000 --- a/setup/cli/commands/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -SuperClaude CLI Commands -Individual command implementations for the CLI interface -""" - -from ..base import OperationBase -from .install import InstallOperation -from .uninstall import UninstallOperation -from .update import UpdateOperation -from .backup import BackupOperation - -__all__ = [ - "OperationBase", - "InstallOperation", - "UninstallOperation", - "UpdateOperation", - "BackupOperation", -] diff --git a/setup/cli/commands/backup.py b/setup/cli/commands/backup.py deleted file mode 100644 index f5fbe50..0000000 --- a/setup/cli/commands/backup.py +++ /dev/null @@ -1,609 +0,0 @@ -""" -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 ...utils.paths import get_home_directory -from datetime import datetime, timedelta -from typing import List, Optional, Dict, Any, Tuple -import argparse - -from ...services.settings import SettingsService -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: /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 = SettingsService(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""" - from setup import __version__ - - metadata = { - "backup_version": __version__, - "created": datetime.now().isoformat(), - "install_dir": str(install_dir), - "components": {}, - "framework_version": "unknown", - } - - try: - # Get installed components from metadata - settings_manager = SettingsService(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 (excluding backups and local dirs) - 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) - - # Skip files in excluded directories - if rel_path.parts and rel_path.parts[0] in ["backups", "local"]: - continue - - 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 = get_home_directory().resolve() - actual_dir = args.install_dir.resolve() - - if not str(actual_dir).startswith(str(expected_home)): - print(f"\n[x] 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: - from setup.cli.base import __version__ - - display_header( - f"SuperClaude Backup v{__version__}", - "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) diff --git a/setup/cli/commands/install.py b/setup/cli/commands/install.py deleted file mode 100644 index 23d8611..0000000 --- a/setup/cli/commands/install.py +++ /dev/null @@ -1,765 +0,0 @@ -""" -SuperClaude Installation Operation Module -Refactored from install.py for unified CLI hub -""" - -import sys -import time -from pathlib import Path -from ...utils.paths import get_home_directory -from typing import List, Optional, Dict, Any -import argparse - -from ...core.installer import Installer -from ...core.registry import ComponentRegistry -from ...services.config import ConfigService -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, - prompt_api_key, -) -from ...utils.environment import setup_environment_variables -from ...utils.logger import get_logger -from ... import DEFAULT_INSTALL_DIR, PROJECT_ROOT, DATA_DIR -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 --dry-run # Dry-run mode - 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( - "--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", - ) - - parser.add_argument( - "--legacy", - action="store_true", - help="Use legacy mode: install individual official MCP servers instead of unified gateway", - ) - - 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 = ConfigService(DATA_DIR) - 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: ConfigService -) -> Optional[List[str]]: - """Determine which components to install""" - logger = get_logger() - - # Explicit components specified - if args.components: - if "all" in args.components: - components = ["knowledge_base", "commands", "agents", "modes", "mcp"] - else: - components = args.components - - # If mcp is specified, handle MCP server selection - if "mcp" in components and not args.yes: - selected_servers = select_mcp_servers(registry) - if not hasattr(config_manager, "_installation_context"): - config_manager._installation_context = {} - config_manager._installation_context["selected_mcp_servers"] = ( - selected_servers - ) - - # If the user selected some servers, ensure mcp is included - if selected_servers: - if "mcp" not in components: - components.append("mcp") - logger.debug( - f"Auto-added 'mcp' component for selected servers: {selected_servers}" - ) - - logger.info(f"Final components to install: {components}") - - return components - - # Interactive two-stage selection - return interactive_component_selection(registry, config_manager) - - -def collect_api_keys_for_servers( - selected_servers: List[str], mcp_instance -) -> Dict[str, str]: - """ - Collect API keys for servers that require them - - Args: - selected_servers: List of selected server keys - mcp_instance: MCP component instance - - Returns: - Dictionary of environment variable names to API key values - """ - # Filter servers needing keys - servers_needing_keys = [ - (server_key, mcp_instance.mcp_servers[server_key]) - for server_key in selected_servers - if server_key in mcp_instance.mcp_servers - and mcp_instance.mcp_servers[server_key].get("requires_api_key", False) - ] - - if not servers_needing_keys: - return {} - - # Display API key configuration header - print(f"\n{Colors.CYAN}{Colors.BRIGHT}=== API Key Configuration ==={Colors.RESET}") - print( - f"{Colors.YELLOW}The following servers require API keys for full functionality:{Colors.RESET}\n" - ) - - collected_keys = {} - for server_key, server_info in servers_needing_keys: - api_key_env = server_info.get("api_key_env") - service_name = server_info["name"] - - if api_key_env: - key = prompt_api_key(service_name, api_key_env) - if key: - collected_keys[api_key_env] = key - - return collected_keys - - -def select_mcp_servers(registry: ComponentRegistry) -> List[str]: - """Stage 1: MCP Server Selection with API Key Collection""" - logger = get_logger() - - try: - # Get MCP component to access server list - mcp_instance = registry.get_component_instance( - "mcp", DEFAULT_INSTALL_DIR - ) - if not mcp_instance or not hasattr(mcp_instance, "mcp_servers"): - logger.error("Could not access MCP server information") - return [] - - # Create MCP server menu - mcp_servers = mcp_instance.mcp_servers - server_options = [] - - for server_key, server_info in mcp_servers.items(): - description = server_info["description"] - api_key_note = ( - " (requires API key)" - if server_info.get("requires_api_key", False) - else "" - ) - server_options.append(f"{server_key} - {description}{api_key_note}") - - print(f"\n{Colors.CYAN}{Colors.BRIGHT}{'='*51}{Colors.RESET}") - print( - f"{Colors.CYAN}{Colors.BRIGHT}Stage 1: MCP Server Selection (Optional){Colors.RESET}" - ) - print(f"{Colors.CYAN}{Colors.BRIGHT}{'='*51}{Colors.RESET}") - print( - f"\n{Colors.BLUE}MCP servers extend Claude Code with specialized capabilities.{Colors.RESET}" - ) - print( - f"{Colors.BLUE}Select servers to configure (you can always add more later):{Colors.RESET}" - ) - - # Add option to skip MCP - server_options.append("Skip MCP Server installation") - - menu = Menu( - "Select MCP servers to configure:", server_options, multi_select=True - ) - selections = menu.display() - - if not selections: - logger.info("No MCP servers selected") - return [] - - # Filter out the "skip" option and return server keys - server_keys = list(mcp_servers.keys()) - selected_servers = [] - - for i in selections: - if i < len(server_keys): # Not the "skip" option - selected_servers.append(server_keys[i]) - - if selected_servers: - logger.info(f"Selected MCP servers: {', '.join(selected_servers)}") - - # NEW: Collect API keys for selected servers - collected_keys = collect_api_keys_for_servers( - selected_servers, mcp_instance - ) - - # Set up environment variables - if collected_keys: - setup_environment_variables(collected_keys) - - # Store keys for MCP component to use during installation - mcp_instance.collected_api_keys = collected_keys - else: - logger.info("No MCP servers selected") - - return selected_servers - - except Exception as e: - logger.error(f"Error in MCP server selection: {e}") - return [] - - -def select_framework_components( - registry: ComponentRegistry, - config_manager: ConfigService, - selected_mcp_servers: List[str], -) -> List[str]: - """Stage 2: Framework Component Selection""" - logger = get_logger() - - try: - # Framework components (excluding MCP-related ones) - framework_components = ["knowledge_base", "modes", "commands", "agents"] - - # Create component menu - component_options = [] - component_info = {} - - for component_name in framework_components: - metadata = registry.get_component_metadata(component_name) - if metadata: - description = metadata.get("description", "No description") - component_options.append(f"{component_name} - {description}") - component_info[component_name] = metadata - - # MCP documentation is integrated into airis-mcp-gateway, no separate component needed - - print(f"\n{Colors.CYAN}{Colors.BRIGHT}{'='*51}{Colors.RESET}") - print( - f"{Colors.CYAN}{Colors.BRIGHT}Stage 2: Framework Component Selection{Colors.RESET}" - ) - print(f"{Colors.CYAN}{Colors.BRIGHT}{'='*51}{Colors.RESET}") - print( - f"\n{Colors.BLUE}Select SuperClaude framework components to install:{Colors.RESET}" - ) - - menu = Menu( - "Select components (Core is recommended):", - component_options, - multi_select=True, - ) - selections = menu.display() - - if not selections: - # Default to knowledge_base if nothing selected - logger.info("No components selected, defaulting to knowledge_base") - selected_components = ["knowledge_base"] - else: - selected_components = [] - all_components = framework_components - - for i in selections: - if i < len(all_components): - selected_components.append(all_components[i]) - - # Always include MCP component if servers were selected - if selected_mcp_servers and "mcp" not in selected_components: - selected_components.append("mcp") - - logger.info(f"Selected framework components: {', '.join(selected_components)}") - return selected_components - - except Exception as e: - logger.error(f"Error in framework component selection: {e}") - return ["knowledge_base"] # Fallback to knowledge_base - - -def interactive_component_selection( - registry: ComponentRegistry, config_manager: ConfigService -) -> Optional[List[str]]: - """Two-stage interactive component selection""" - logger = get_logger() - - try: - print(f"\n{Colors.CYAN}SuperClaude Interactive Installation{Colors.RESET}") - print( - f"{Colors.BLUE}Select components to install using the two-stage process:{Colors.RESET}" - ) - - # Stage 1: MCP Server Selection - selected_mcp_servers = select_mcp_servers(registry) - - # Stage 2: Framework Component Selection - selected_components = select_framework_components( - registry, config_manager, selected_mcp_servers - ) - - # Store selected MCP servers for components to use - if not hasattr(config_manager, "_installation_context"): - config_manager._installation_context = {} - config_manager._installation_context["selected_mcp_servers"] = ( - selected_mcp_servers - ) - - return selected_components - - 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, - config_manager: ConfigService = None, -) -> 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())) - - # The 'components' list is already resolved, so we can use it directly. - ordered_components = 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, - "legacy_mode": getattr(args, "legacy", False), - "selected_mcp_servers": getattr( - config_manager, "_installation_context", {} - ).get("selected_mcp_servers", []), - } - - 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'])}") - - 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() - # βœ… Enhanced security validation with symlink protection - expected_home = get_home_directory().resolve() - install_dir_original = args.install_dir - install_dir_resolved = args.install_dir.resolve() - - # Check for symlink attacks - compare original vs resolved paths - try: - # Verify the resolved path is still within user home - install_dir_resolved.relative_to(expected_home) - - # Additional check: if there's a symlink in the path, verify it doesn't escape user home - if install_dir_original != install_dir_resolved: - # Path contains symlinks - verify each component stays within user home - current_path = expected_home - parts = install_dir_original.parts - home_parts = expected_home.parts - - # Skip home directory parts - if len(parts) >= len(home_parts) and parts[: len(home_parts)] == home_parts: - relative_parts = parts[len(home_parts) :] - - for part in relative_parts: - current_path = current_path / part - if current_path.is_symlink(): - symlink_target = current_path.resolve() - # Ensure symlink target is also within user home - symlink_target.relative_to(expected_home) - except ValueError: - print(f"\n[x] Installation must be inside your user profile directory.") - print(f" Expected prefix: {expected_home}") - print(f" Provided path: {install_dir_resolved}") - print(f" Security: Symlinks outside user directory are not allowed.") - sys.exit(1) - except Exception as e: - print(f"\n[x] Security validation failed: {e}") - print(f" Please use a standard directory path within your user profile.") - 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: - from setup.cli.base import __version__ - - display_header( - f"SuperClaude Installation v{__version__}", - "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 = ConfigService(DATA_DIR) - 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_to_install = get_components_to_install( - args, registry, config_manager - ) - if not components_to_install: - logger.error("No components selected for installation") - return 1 - - # Resolve dependencies - try: - resolved_components = registry.resolve_dependencies(components_to_install) - except ValueError as e: - logger.error(f"Dependency resolution error: {e}") - return 1 - - # Validate system requirements for all components - if not validate_system_requirements(validator, resolved_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.info( - f"Existing installation found: {args.install_dir} (will be updated)" - ) - 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(resolved_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(resolved_components, args, config_manager) - - 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) diff --git a/setup/cli/commands/uninstall.py b/setup/cli/commands/uninstall.py deleted file mode 100644 index 95b20be..0000000 --- a/setup/cli/commands/uninstall.py +++ /dev/null @@ -1,983 +0,0 @@ -""" -SuperClaude Uninstall Operation Module -Refactored from uninstall.py for unified CLI hub -""" - -import sys -import time -from pathlib import Path -from ...utils.paths import get_home_directory -from typing import List, Optional, Dict, Any -import argparse - -from ...core.registry import ComponentRegistry -from ...services.settings import SettingsService -from ...services.files import FileService -from ...utils.ui import ( - display_header, - display_info, - display_success, - display_error, - display_warning, - Menu, - confirm, - ProgressBar, - Colors, -) -from ...utils.environment import ( - get_superclaude_environment_variables, - cleanup_environment_variables, -) -from ...utils.logger import get_logger -from ... import DEFAULT_INSTALL_DIR, PROJECT_ROOT -from . import OperationBase - - -def verify_superclaude_file(file_path: Path, component: str) -> bool: - """ - Verify this is a SuperClaude file before removal - - Args: - file_path: Path to the file to verify - component: Component name this file belongs to - - Returns: - True if safe to remove, False if uncertain (preserve by default) - """ - try: - # Known SuperClaude file patterns by component - superclaude_patterns = { - "core": [ - "CLAUDE.md", - "FLAGS.md", - "PRINCIPLES.md", - "RULES.md", - "ORCHESTRATOR.md", - "SESSION_LIFECYCLE.md", - ], - "commands": [ - # Commands are only in sc/ subdirectory - ], - "agents": [ - "backend-engineer.md", - "brainstorm-PRD.md", - "code-educator.md", - "code-refactorer.md", - "devops-engineer.md", - "frontend-specialist.md", - "performance-optimizer.md", - "python-ultimate-expert.md", - "qa-specialist.md", - "root-cause-analyzer.md", - "security-auditor.md", - "system-architect.md", - "technical-writer.md", - ], - "modes": [ - "MODE_Brainstorming.md", - "MODE_Introspection.md", - "MODE_Task_Management.md", - "MODE_Token_Efficiency.md", - ], - } - - # For commands component, verify it's in the sc/ subdirectory - if component == "commands": - return "commands/sc/" in str(file_path) - - # For other components, check against known file lists - if component in superclaude_patterns: - filename = file_path.name - return filename in superclaude_patterns[component] - - # For MCP component, it doesn't remove files but modifies .claude.json - if component == "mcp": - return True # MCP component has its own safety logic - - # Default to preserve if uncertain - return False - - except Exception: - # If any error occurs in verification, preserve the file - return False - - -def verify_directory_safety(directory: Path, component: str) -> bool: - """ - Verify it's safe to remove a directory - - Args: - directory: Directory path to verify - component: Component name - - Returns: - True if safe to remove (only if empty or only contains SuperClaude files) - """ - try: - if not directory.exists(): - return True - - # Check if directory is empty - contents = list(directory.iterdir()) - if not contents: - return True - - # Check if all contents are SuperClaude files for this component - for item in contents: - if item.is_file(): - if not verify_superclaude_file(item, component): - return False - elif item.is_dir(): - # Don't remove directories that contain non-SuperClaude subdirectories - return False - - return True - - except Exception: - # If any error occurs, preserve the directory - return False - - -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)", - ) - - # Environment cleanup options - parser.add_argument( - "--cleanup-env", - action="store_true", - help="Remove SuperClaude environment variables", - ) - - parser.add_argument( - "--no-restore-script", - action="store_true", - help="Skip creating environment variable restore script", - ) - - return parser - - -def get_installed_components(install_dir: Path) -> Dict[str, Dict[str, Any]]: - """Get currently installed components and their versions""" - try: - settings_manager = SettingsService(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_environment_info() -> Dict[str, str]: - """Display SuperClaude environment variables and return them""" - env_vars = get_superclaude_environment_variables() - - if env_vars: - print(f"\n{Colors.CYAN}{Colors.BRIGHT}Environment Variables{Colors.RESET}") - print("=" * 50) - print( - f"{Colors.BLUE}SuperClaude API key environment variables found:{Colors.RESET}" - ) - for env_var, value in env_vars.items(): - # Show only first few and last few characters for security - masked_value = f"{value[:4]}...{value[-4:]}" if len(value) > 8 else "***" - print(f" {env_var}: {masked_value}") - - print( - f"\n{Colors.YELLOW}Note: These environment variables will remain unless you use --cleanup-env{Colors.RESET}" - ) - else: - print( - f"\n{Colors.GREEN}No SuperClaude environment variables found{Colors.RESET}" - ) - - return env_vars - - -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_component_selection( - installed_components: Dict[str, str], env_vars: Dict[str, str] -) -> Optional[tuple]: - """ - Enhanced interactive selection with granular component options - - Returns: - Tuple of (components_to_remove, cleanup_options) or None if cancelled - """ - if not installed_components: - return [] - - print(f"\n{Colors.CYAN}{Colors.BRIGHT}SuperClaude Uninstall Options{Colors.RESET}") - print("=" * 60) - - # Main uninstall type selection - main_options = [ - "Complete Uninstall (remove all SuperClaude components)", - "Custom Uninstall (choose specific components)", - "Cancel Uninstall", - ] - - print(f"\n{Colors.BLUE}Choose uninstall type:{Colors.RESET}") - main_menu = Menu("Select option:", main_options) - main_choice = main_menu.display() - - if main_choice == -1 or main_choice == 2: # Cancelled - return None - elif main_choice == 0: # Complete uninstall - # Complete uninstall - include all components and optional cleanup - cleanup_options = _ask_complete_uninstall_options(env_vars) - return list(installed_components.keys()), cleanup_options - elif main_choice == 1: # Custom uninstall - return _custom_component_selection(installed_components, env_vars) - - return None - - -def _ask_complete_uninstall_options(env_vars: Dict[str, str]) -> Dict[str, bool]: - """Ask for complete uninstall options""" - cleanup_options = { - "remove_mcp_configs": True, - "cleanup_env_vars": False, - "create_restore_script": True, - } - - print(f"\n{Colors.YELLOW}{Colors.BRIGHT}Complete Uninstall Options{Colors.RESET}") - print("This will remove ALL SuperClaude components.") - - if env_vars: - print(f"\n{Colors.BLUE}Environment variables found:{Colors.RESET}") - for env_var, value in env_vars.items(): - masked_value = f"{value[:4]}...{value[-4:]}" if len(value) > 8 else "***" - print(f" {env_var}: {masked_value}") - - cleanup_env = confirm( - "Also remove API key environment variables?", default=False - ) - cleanup_options["cleanup_env_vars"] = cleanup_env - - if cleanup_env: - create_script = confirm( - "Create restore script for environment variables?", default=True - ) - cleanup_options["create_restore_script"] = create_script - - return cleanup_options - - -def _custom_component_selection( - installed_components: Dict[str, str], env_vars: Dict[str, str] -) -> Optional[tuple]: - """Handle custom component selection with granular options""" - print( - f"\n{Colors.CYAN}{Colors.BRIGHT}Custom Uninstall - Choose Components{Colors.RESET}" - ) - print("Select which SuperClaude components to remove:") - - # Build component options with descriptions - component_options = [] - component_keys = [] - - component_descriptions = { - "core": "Core Framework Files (CLAUDE.md, FLAGS.md, PRINCIPLES.md, etc.)", - "commands": "superclaude Commands (commands/sc/*.md)", - "agents": "Specialized Agents (agents/*.md)", - "mcp": "MCP Server Configurations (airis-mcp-gateway)", - "modes": "superclaude Modes", - } - - for component, version in installed_components.items(): - description = component_descriptions.get(component, f"{component} component") - component_options.append(f"{description}") - component_keys.append(component) - - print(f"\n{Colors.BLUE}Select components to remove:{Colors.RESET}") - component_menu = Menu("Components:", component_options, multi_select=True) - selections = component_menu.display() - - if not selections: - return None - - selected_components = [component_keys[i] for i in selections] - - # If MCP component is selected, ask about related cleanup options - cleanup_options = { - "remove_mcp_configs": "mcp" in selected_components, - "cleanup_env_vars": False, - "create_restore_script": True, - } - - if "mcp" in selected_components: - cleanup_options.update(_ask_mcp_cleanup_options(env_vars)) - elif env_vars: - # Even if MCP not selected, ask about env vars if they exist - cleanup_env = confirm( - f"Remove {len(env_vars)} API key environment variables?", default=False - ) - cleanup_options["cleanup_env_vars"] = cleanup_env - if cleanup_env: - create_script = confirm( - "Create restore script for environment variables?", default=True - ) - cleanup_options["create_restore_script"] = create_script - - return selected_components, cleanup_options - - -def _ask_mcp_cleanup_options(env_vars: Dict[str, str]) -> Dict[str, bool]: - """Ask for MCP-related cleanup options""" - print(f"\n{Colors.YELLOW}{Colors.BRIGHT}MCP Cleanup Options{Colors.RESET}") - print("Since you're removing the MCP component:") - - cleanup_options = {} - - # Ask about MCP server configurations - remove_configs = confirm( - "Remove MCP server configurations from .claude.json?", default=True - ) - cleanup_options["remove_mcp_configs"] = remove_configs - - # Ask about API key environment variables - if env_vars: - print( - f"\n{Colors.BLUE}Related API key environment variables found:{Colors.RESET}" - ) - for env_var, value in env_vars.items(): - masked_value = f"{value[:4]}...{value[-4:]}" if len(value) > 8 else "***" - print(f" {env_var}: {masked_value}") - - cleanup_env = confirm( - f"Remove {len(env_vars)} API key environment variables?", default=False - ) - cleanup_options["cleanup_env_vars"] = cleanup_env - - if cleanup_env: - create_script = confirm( - "Create restore script for environment variables?", default=True - ) - cleanup_options["create_restore_script"] = create_script - else: - cleanup_options["create_restore_script"] = True - else: - cleanup_options["cleanup_env_vars"] = False - cleanup_options["create_restore_script"] = True - - return cleanup_options - - -def interactive_uninstall_selection( - installed_components: Dict[str, str], -) -> Optional[List[str]]: - """Legacy function - redirects to enhanced selection""" - env_vars = get_superclaude_environment_variables() - result = interactive_component_selection(installed_components, env_vars) - - if result is None: - return None - - # For backwards compatibility, return only component list - components, cleanup_options = result - return components - - -def display_preservation_info() -> None: - """Show what will NOT be removed (user's custom files)""" - print(f"\n{Colors.GREEN}{Colors.BRIGHT}Files that will be preserved:{Colors.RESET}") - print(f"{Colors.GREEN}+ User's custom commands (not in commands/sc/){Colors.RESET}") - print( - f"{Colors.GREEN}+ User's custom agents (not SuperClaude agents){Colors.RESET}" - ) - print(f"{Colors.GREEN}+ User's custom .claude.json configurations{Colors.RESET}") - print(f"{Colors.GREEN}+ User's custom files in shared directories{Colors.RESET}") - print( - f"{Colors.GREEN}+ Claude Code settings and other tools' configurations{Colors.RESET}" - ) - - -def display_component_details(component: str, info: Dict[str, Any]) -> Dict[str, Any]: - """Get detailed information about what will be removed for a component""" - details = {"files": [], "directories": [], "size": 0, "description": ""} - - install_dir = info["install_dir"] - - component_paths = { - "core": { - "files": [ - "CLAUDE.md", - "FLAGS.md", - "PRINCIPLES.md", - "RULES.md", - "ORCHESTRATOR.md", - "SESSION_LIFECYCLE.md", - ], - "description": "Core framework files in ~/.claude/", - }, - "commands": { - "files": "commands/sc/*.md", - "description": "superclaude commands in ~/.claude/commands/sc/", - }, - "agents": { - "files": "agents/*.md", - "description": "Specialized AI agents in ~/.claude/agents/", - }, - "mcp": { - "files": "MCP server configurations in .claude.json", - "description": "MCP server configurations (airis-mcp-gateway)", - }, - "modes": {"files": "MODE_*.md", "description": "superclaude operational modes"}, - } - - if component in component_paths: - details["description"] = component_paths[component]["description"] - - # Get actual file count from metadata if available - component_metadata = info["components"].get(component, {}) - if isinstance(component_metadata, dict): - if "files_count" in component_metadata: - details["file_count"] = component_metadata["files_count"] - elif "agents_count" in component_metadata: - details["file_count"] = component_metadata["agents_count"] - elif "servers_configured" in component_metadata: - details["file_count"] = component_metadata["servers_configured"] - - return details - - -def display_uninstall_plan( - components: List[str], - args: argparse.Namespace, - info: Dict[str, Any], - env_vars: Dict[str, str], -) -> None: - """Display detailed uninstall plan""" - print(f"\n{Colors.CYAN}{Colors.BRIGHT}Uninstall Plan{Colors.RESET}") - print("=" * 60) - - print(f"{Colors.BLUE}Installation Directory:{Colors.RESET} {info['install_dir']}") - - if components: - print(f"\n{Colors.BLUE}Components to remove:{Colors.RESET}") - total_files = 0 - - for i, component_name in enumerate(components, 1): - details = display_component_details(component_name, info) - version = info["components"].get(component_name, "unknown") - - if isinstance(version, dict): - version_str = version.get("version", "unknown") - file_count = details.get( - "file_count", - version.get( - "files_count", - version.get( - "agents_count", version.get("servers_configured", "?") - ), - ), - ) - else: - version_str = str(version) - file_count = details.get("file_count", "?") - - print(f" {i}. {component_name} (v{version_str}) - {file_count} files") - print(f" {details['description']}") - - if isinstance(file_count, int): - total_files += file_count - - print( - f"\n{Colors.YELLOW}Total estimated files to remove: {total_files}{Colors.RESET}" - ) - - # Show detailed preservation information - print( - f"\n{Colors.GREEN}{Colors.BRIGHT}Safety Guarantees - Will Preserve:{Colors.RESET}" - ) - print(f"{Colors.GREEN}+ User's custom commands (not in commands/sc/){Colors.RESET}") - print( - f"{Colors.GREEN}+ User's custom agents (not SuperClaude agents){Colors.RESET}" - ) - print(f"{Colors.GREEN}+ User's .claude.json customizations{Colors.RESET}") - print( - f"{Colors.GREEN}+ Claude Code settings and other tools' configurations{Colors.RESET}" - ) - - # Show additional preserved items - 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: - for item in preserved: - print(f"{Colors.GREEN}+ {item}{Colors.RESET}") - - if args.complete: - print( - f"\n{Colors.RED}⚠️ WARNING: Complete uninstall will remove all SuperClaude files{Colors.RESET}" - ) - - # Environment variable cleanup information - if env_vars: - print(f"\n{Colors.BLUE}Environment Variables:{Colors.RESET}") - if args.cleanup_env: - print( - f"{Colors.YELLOW}Will remove {len(env_vars)} API key environment variables:{Colors.RESET}" - ) - for env_var in env_vars.keys(): - print(f" - {env_var}") - if not args.no_restore_script: - print(f"{Colors.GREEN} + Restore script will be created{Colors.RESET}") - else: - print( - f"{Colors.BLUE}Will preserve {len(env_vars)} API key environment variables:{Colors.RESET}" - ) - for env_var in env_vars.keys(): - print(f" + {env_var}") - - 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 = SettingsService(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], - env_vars: Dict[str, str], -) -> 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) - - # Handle environment variable cleanup - env_cleanup_success = True - if args.cleanup_env and env_vars: - logger.info("Cleaning up environment variables...") - create_restore_script = not args.no_restore_script - env_cleanup_success = cleanup_environment_variables( - env_vars, create_restore_script - ) - - if env_cleanup_success: - logger.success(f"Removed {len(env_vars)} environment variables") - else: - logger.warning("Some environment variables could not be removed") - - # 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 = FileService() - - 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 = get_home_directory().resolve() - actual_dir = args.install_dir.resolve() - - if not str(actual_dir).startswith(str(expected_home)): - print(f"\n[x] 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: - from setup.cli.base import __version__ - - display_header( - f"SuperClaude Uninstall v{__version__}", - "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 for environment variables - env_vars = ( - display_environment_info() - if not args.quiet - else get_superclaude_environment_variables() - ) - - # 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 using enhanced selection - if args.components or args.complete: - # Non-interactive mode - use existing logic - components = get_components_to_uninstall(args, info["components"]) - cleanup_options = { - "remove_mcp_configs": "mcp" in (components or []), - "cleanup_env_vars": args.cleanup_env, - "create_restore_script": not args.no_restore_script, - } - if components is None: - logger.info("Uninstall cancelled by user") - return 0 - elif not components: - logger.info("No components selected for uninstall") - return 0 - else: - # Interactive mode - use enhanced selection - result = interactive_component_selection(info["components"], env_vars) - if result is None: - logger.info("Uninstall cancelled by user") - return 0 - elif not result: - logger.info("No components selected for uninstall") - return 0 - - components, cleanup_options = result - - # Override command-line args with interactive choices - args.cleanup_env = cleanup_options.get("cleanup_env_vars", False) - args.no_restore_script = not cleanup_options.get( - "create_restore_script", True - ) - - # Display uninstall plan - if not args.quiet: - display_uninstall_plan(components, args, info, env_vars) - - # 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, env_vars) - - 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) diff --git a/setup/cli/commands/update.py b/setup/cli/commands/update.py deleted file mode 100644 index d75569b..0000000 --- a/setup/cli/commands/update.py +++ /dev/null @@ -1,512 +0,0 @@ -""" -SuperClaude Update Operation Module -Refactored from update.py for unified CLI hub -""" - -import sys -import time -from pathlib import Path -from ...utils.paths import get_home_directory -from typing import List, Optional, Dict, Any -import argparse - -from ...core.installer import Installer -from ...core.registry import ComponentRegistry -from ...services.settings import SettingsService -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, - prompt_api_key, -) -from ...utils.environment import setup_environment_variables -from ...utils.logger import get_logger -from ... import DEFAULT_INSTALL_DIR, PROJECT_ROOT, DATA_DIR -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 = SettingsService(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 = SettingsService(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 collect_api_keys_for_servers( - selected_servers: List[str], mcp_instance -) -> Dict[str, str]: - """ - Collect API keys for servers that require them during update - - Args: - selected_servers: List of selected server keys - mcp_instance: MCP component instance - - Returns: - Dictionary of environment variable names to API key values - """ - # Filter servers needing keys - servers_needing_keys = [ - (server_key, mcp_instance.mcp_servers[server_key]) - for server_key in selected_servers - if server_key in mcp_instance.mcp_servers - and mcp_instance.mcp_servers[server_key].get("requires_api_key", False) - ] - - if not servers_needing_keys: - return {} - - # Display API key configuration header - print(f"\n{Colors.CYAN}{Colors.BRIGHT}=== API Key Configuration ==={Colors.RESET}") - print( - f"{Colors.YELLOW}New MCP servers require API keys for full functionality:{Colors.RESET}\n" - ) - - collected_keys = {} - for server_key, server_info in servers_needing_keys: - api_key_env = server_info.get("api_key_env") - service_name = server_info["name"] - - if api_key_env: - key = prompt_api_key(service_name, api_key_env) - if key: - collected_keys[api_key_env] = key - - return collected_keys - - -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, registry: ComponentRegistry -) -> 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 instances - component_instances = registry.create_component_instances( - components, args.install_dir - ) - - if not component_instances: - logger.error("No valid component instances created") - return False - - # Handle MCP component specially - collect API keys for new servers - collected_api_keys = {} - if "mcp" in components and "mcp" in component_instances: - mcp_instance = component_instances["mcp"] - if hasattr(mcp_instance, "mcp_servers"): - # Get all available MCP servers - all_server_keys = list(mcp_instance.mcp_servers.keys()) - - # Collect API keys for any servers that require them - collected_api_keys = collect_api_keys_for_servers( - all_server_keys, mcp_instance - ) - - # Set up environment variables if any keys were collected - if collected_api_keys: - setup_environment_variables(collected_api_keys) - - # Store keys for MCP component to use during update - mcp_instance.collected_api_keys = collected_api_keys - - logger.info( - f"Collected {len(collected_api_keys)} API keys for MCP server update" - ) - - # 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, - "selected_mcp_servers": ( - list(mcp_instance.mcp_servers.keys()) - if "mcp" in component_instances - else [] - ), - } - - 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'])}") - - 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() - - from setup.cli.base import __version__ - - # βœ… Inserted validation code - expected_home = get_home_directory().resolve() - actual_dir = args.install_dir.resolve() - - if not str(actual_dir).startswith(str(expected_home)): - print(f"\n[x] 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( - f"SuperClaude Update v{__version__}", - "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, registry) - - 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) diff --git a/setup/components/__init__.py b/setup/components/__init__.py deleted file mode 100644 index 111c91c..0000000 --- a/setup/components/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -Component Directory - -Each module defines an installable responsibility unit: -- knowledge_base: Framework knowledge initialization -- behavior_modes: Execution mode definitions -- agent_personas: AI agent personality definitions -- slash_commands: CLI command registration -- mcp_integration: External tool integration via MCP -""" - -from .knowledge_base import KnowledgeBaseComponent -from .behavior_modes import BehaviorModesComponent -from .agent_personas import AgentPersonasComponent -from .slash_commands import SlashCommandsComponent -from .mcp_integration import MCPIntegrationComponent - -__all__ = [ - "KnowledgeBaseComponent", - "BehaviorModesComponent", - "AgentPersonasComponent", - "SlashCommandsComponent", - "MCPIntegrationComponent", -] diff --git a/setup/components/agent_personas.py b/setup/components/agent_personas.py deleted file mode 100644 index 7ca33b9..0000000 --- a/setup/components/agent_personas.py +++ /dev/null @@ -1,254 +0,0 @@ -""" -Agent Personas Component - -Responsibility: Defines AI agent personalities and role-based behaviors. -Provides specialized personas for different task types. -""" - -from typing import Dict, List, Tuple, Optional, Any -from pathlib import Path - -from ..core.base import Component -from setup import __version__ - - -class AgentPersonasComponent(Component): - """SuperClaude specialized AI agents component""" - - def __init__(self, install_dir: Optional[Path] = None): - """Initialize agents component""" - super().__init__(install_dir, Path("agents")) - - def get_metadata(self) -> Dict[str, str]: - """Get component metadata""" - return { - "name": "agents", - "version": __version__, - "description": "15 specialized AI agents with domain expertise and intelligent routing", - "category": "agents", - } - - def is_reinstallable(self) -> bool: - """ - Agents should always be synced to latest version. - SuperClaude agent files always overwrite existing files. - """ - return True - - def get_metadata_modifications(self) -> Dict[str, Any]: - """Get metadata modifications for agents""" - return { - "components": { - "agents": { - "version": __version__, - "installed": True, - "agents_count": len(self.component_files), - "install_directory": str(self.install_component_subdir), - } - } - } - - def _install(self, config: Dict[str, Any]) -> bool: - """Install agents component - DISABLED: Agents migrated to Skills""" - self.logger.info("Skipping agents installation (migrated to Skills architecture)") - self.logger.info("Agents are now loaded on-demand via Skills system") - - # Still register component as "installed" but skip file copying - return self._post_install() - - def _post_install(self) -> bool: - """Post-install setup for agents""" - try: - # Update metadata with agents registration - metadata_mods = self.get_metadata_modifications() - self.settings_manager.update_metadata(metadata_mods) - self.logger.info("Updated metadata with agents configuration") - - # Add component registration (with file list for sync) - self.settings_manager.add_component_registration( - "agents", - { - "version": __version__, - "category": "agents", - "agents_count": len(self.component_files), - "files": list(self.component_files), # Track for sync/deletion - }, - ) - - self.logger.info("Registered agents component in metadata") - return True - - except Exception as e: - self.logger.error(f"Failed to complete agents post-install: {e}") - return False - - def uninstall(self) -> bool: - """Uninstall agents component""" - try: - self.logger.info("Uninstalling SuperClaude agents component...") - - # Remove agent files - removed_count = 0 - for filename in self.component_files: - file_path = self.install_component_subdir / filename - if self.file_manager.remove_file(file_path): - removed_count += 1 - self.logger.debug(f"Removed agent: {filename}") - else: - self.logger.warning(f"Could not remove agent: {filename}") - - # Remove agents directory if empty - try: - if self.install_component_subdir.exists() and not any( - self.install_component_subdir.iterdir() - ): - self.install_component_subdir.rmdir() - self.logger.debug("Removed empty agents directory") - except Exception as e: - self.logger.warning(f"Could not remove agents directory: {e}") - - # Update metadata to remove agents component - try: - if self.settings_manager.is_component_installed("agents"): - self.settings_manager.remove_component_registration("agents") - self.logger.info("Removed agents component from metadata") - except Exception as e: - self.logger.warning(f"Could not update metadata: {e}") - - self.logger.success( - f"Agents component uninstalled ({removed_count} agents removed)" - ) - return True - - except Exception as e: - self.logger.exception(f"Unexpected error during agents uninstallation: {e}") - return False - - def get_dependencies(self) -> List[str]: - """Get component dependencies""" - return ["knowledge_base"] - - def update(self, config: Dict[str, Any]) -> bool: - """ - Sync agents component (overwrite + delete obsolete files). - No backup needed - SuperClaude source files are always authoritative. - """ - try: - self.logger.info("Syncing SuperClaude agents component...") - - # Get previously installed files from metadata - metadata = self.settings_manager.load_metadata() - previous_files = set( - metadata.get("components", {}).get("agents", {}).get("files", []) - ) - - # Get current files from source - current_files = set(self.component_files) - - # Files to delete (were installed before, but no longer in source) - files_to_delete = previous_files - current_files - - # Delete obsolete files - deleted_count = 0 - for filename in files_to_delete: - file_path = self.install_component_subdir / filename - if file_path.exists(): - try: - file_path.unlink() - deleted_count += 1 - self.logger.info(f"Deleted obsolete agent: {filename}") - except Exception as e: - self.logger.warning(f"Could not delete {filename}: {e}") - - # Install/overwrite current files (no backup) - success = self._install(config) - - if success: - self.logger.success( - f"Agents synced: {len(current_files)} files, {deleted_count} obsolete files removed" - ) - else: - self.logger.error("Agents sync failed") - - return success - - except Exception as e: - self.logger.exception(f"Unexpected error during agents sync: {e}") - return False - - def _get_source_dir(self) -> Path: - """Get source directory for agent files""" - # Assume we're in superclaude/setup/components/agents.py - # and agent files are in superclaude/superclaude/Agents/ - project_root = Path(__file__).parent.parent.parent - return project_root / "superclaude" / "agents" - - 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 directories and metadata - 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"], - "agents_installed": len(self.component_files), - "agent_files": self.component_files, - "estimated_size": self.get_size_estimate(), - "install_directory": str(self.install_component_subdir), - "dependencies": self.get_dependencies(), - } - - def validate_installation(self) -> Tuple[bool, List[str]]: - """Validate that agents component is correctly installed""" - errors = [] - - # Check if agents directory exists - if not self.install_component_subdir.exists(): - errors.append( - f"Agents directory not found: {self.install_component_subdir}" - ) - return False, errors - - # Check if all agent files exist - missing_agents = [] - for filename in self.component_files: - agent_path = self.install_component_subdir / filename - if not agent_path.exists(): - missing_agents.append(filename) - - if missing_agents: - errors.append(f"Missing agent files: {missing_agents}") - - # Check version in metadata - if not self.get_installed_version(): - errors.append("Agents component not registered in metadata") - - # Check if at least some standard agents are present - expected_agents = [ - "system-architect.md", - "frontend-architect.md", - "backend-architect.md", - "security-engineer.md", - ] - - missing_core_agents = [] - for agent in expected_agents: - if agent not in self.component_files: - missing_core_agents.append(agent) - - if missing_core_agents: - errors.append(f"Missing core agent files: {missing_core_agents}") - - return len(errors) == 0, errors diff --git a/setup/components/behavior_modes.py b/setup/components/behavior_modes.py deleted file mode 100644 index 4b4e5ca..0000000 --- a/setup/components/behavior_modes.py +++ /dev/null @@ -1,212 +0,0 @@ -""" -Behavior Modes Component - -Responsibility: Defines and manages execution modes for Claude behavior. -Controls how Claude responds to different contexts and user intent. -""" - -from typing import Dict, List, Tuple, Optional, Any -from pathlib import Path - -from ..core.base import Component -from setup import __version__ -from ..services.claude_md import CLAUDEMdService - - -class BehaviorModesComponent(Component): - """SuperClaude behavioral modes component""" - - def __init__(self, install_dir: Optional[Path] = None): - """Initialize modes component""" - super().__init__(install_dir, Path("modes")) - - def get_metadata(self) -> Dict[str, str]: - """Get component metadata""" - return { - "name": "modes", - "version": __version__, - "description": "7 behavioral modes for enhanced Claude Code operation", - "category": "modes", - } - - def is_reinstallable(self) -> bool: - """ - Modes should always be synced to latest version. - SuperClaude mode files always overwrite existing files. - """ - return True - - def _install(self, config: Dict[str, Any]) -> bool: - """Install modes component - DISABLED: Modes migrated to Skills""" - self.logger.info("Skipping modes installation (migrated to Skills architecture)") - self.logger.info("Modes are now loaded on-demand via Skills system") - - # Still register component as "installed" but skip file copying - return self._post_install() - - def _post_install(self) -> bool: - """Post-installation tasks""" - try: - # Update metadata - metadata_mods = { - "components": { - "modes": { - "version": __version__, - "installed": True, - "files_count": len(self.component_files), - "files": list(self.component_files), # Track for sync/deletion - } - } - } - self.settings_manager.update_metadata(metadata_mods) - self.logger.info("Updated metadata with modes component registration") - - # Update CLAUDE.md with mode imports (include modes/ prefix) - try: - manager = CLAUDEMdService(self.install_dir) - mode_files_with_path = [f"modes/{f}" for f in self.component_files] - manager.add_imports(mode_files_with_path, category="Behavioral Modes") - self.logger.info("Updated CLAUDE.md with mode imports") - except Exception as e: - self.logger.warning( - f"Failed to update CLAUDE.md with mode imports: {e}" - ) - # Don't fail the whole installation for this - - return True - except Exception as e: - self.logger.error(f"Failed to update metadata: {e}") - return False - - def uninstall(self) -> bool: - """Uninstall modes component""" - try: - self.logger.info("Uninstalling SuperClaude modes component...") - - # Remove mode files - removed_count = 0 - for _, target in self.get_files_to_install(): - if self.file_manager.remove_file(target): - removed_count += 1 - self.logger.debug(f"Removed {target.name}") - - # Remove modes 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 modes directory") - except Exception as e: - self.logger.warning(f"Could not remove modes directory: {e}") - - # Update settings.json - try: - if self.settings_manager.is_component_installed("modes"): - self.settings_manager.remove_component_registration("modes") - self.logger.info("Removed modes component from settings.json") - except Exception as e: - self.logger.warning(f"Could not update settings.json: {e}") - - self.logger.success( - f"Modes component uninstalled ({removed_count} files removed)" - ) - return True - - except Exception as e: - self.logger.exception(f"Unexpected error during modes uninstallation: {e}") - return False - - def get_dependencies(self) -> List[str]: - """Get dependencies""" - return ["knowledge_base"] - - def update(self, config: Dict[str, Any]) -> bool: - """ - Sync modes component (overwrite + delete obsolete files). - No backup needed - SuperClaude source files are always authoritative. - """ - try: - self.logger.info("Syncing SuperClaude modes component...") - - # Get previously installed files from metadata - metadata = self.settings_manager.load_metadata() - previous_files = set( - metadata.get("components", {}).get("modes", {}).get("files", []) - ) - - # Get current files from source - current_files = set(self.component_files) - - # Files to delete (were installed before, but no longer in source) - files_to_delete = previous_files - current_files - - # Delete obsolete files - deleted_count = 0 - for filename in files_to_delete: - file_path = self.install_dir / filename - if file_path.exists(): - try: - file_path.unlink() - deleted_count += 1 - self.logger.info(f"Deleted obsolete mode: {filename}") - except Exception as e: - self.logger.warning(f"Could not delete {filename}: {e}") - - # Install/overwrite current files (no backup) - success = self.install(config) - - if success: - # Update metadata with current file list - metadata_mods = { - "components": { - "modes": { - "version": __version__, - "installed": True, - "files_count": len(current_files), - "files": list(current_files), # Track installed files - } - } - } - self.settings_manager.update_metadata(metadata_mods) - - self.logger.success( - f"Modes synced: {len(current_files)} files, {deleted_count} obsolete files removed" - ) - else: - self.logger.error("Modes sync failed") - - return success - - except Exception as e: - self.logger.exception(f"Unexpected error during modes sync: {e}") - return False - - def _get_source_dir(self) -> Optional[Path]: - """Get source directory for mode files""" - # Assume we're in superclaude/setup/components/modes.py - # and mode files are in superclaude/superclaude/Modes/ - project_root = Path(__file__).parent.parent.parent - modes_dir = project_root / "superclaude" / "modes" - - # Return None if directory doesn't exist to prevent warning - if not modes_dir.exists(): - return None - - return modes_dir - - def get_size_estimate(self) -> int: - """Get estimated installation size""" - source_dir = self._get_source_dir() - total_size = 0 - - if source_dir and source_dir.exists(): - for filename in self.component_files: - file_path = source_dir / filename - if file_path.exists(): - total_size += file_path.stat().st_size - - # Minimum size estimate - total_size = max(total_size, 20480) # At least 20KB - - return total_size diff --git a/setup/components/knowledge_base.py b/setup/components/knowledge_base.py deleted file mode 100644 index 324ce5a..0000000 --- a/setup/components/knowledge_base.py +++ /dev/null @@ -1,475 +0,0 @@ -""" -Knowledge Base Component for SuperClaude - -Responsibility: Provides structured knowledge initialization for the framework. -Manages framework knowledge documents (principles, rules, flags, research config, business patterns). -These files form the foundation of Claude's understanding of the SuperClaude framework. -""" - -from typing import Dict, List, Tuple, Optional, Any -from pathlib import Path -import shutil - -from ..core.base import Component -from ..services.claude_md import CLAUDEMdService -from setup import __version__ - - -class KnowledgeBaseComponent(Component): - """ - Knowledge Base Component - - Responsibility: Initialize and maintain SuperClaude's knowledge base. - Installs framework knowledge documents that guide Claude's behavior and decision-making. - """ - - def __init__(self, install_dir: Optional[Path] = None): - """Initialize knowledge base component""" - super().__init__(install_dir) - - def get_metadata(self) -> Dict[str, str]: - """Get component metadata""" - return { - "name": "knowledge_base", - "version": __version__, - "description": "SuperClaude knowledge base (principles, rules, flags, patterns)", - "category": "knowledge", - } - - def is_reinstallable(self) -> bool: - """ - Framework docs should always be updated to latest version. - SuperClaude-related documentation should always overwrite existing files. - """ - return True - - def validate_prerequisites( - self, installSubPath: Optional[Path] = None - ) -> Tuple[bool, List[str]]: - """ - Check prerequisites for framework docs component (multi-directory support) - - Returns: - Tuple of (success: bool, error_messages: List[str]) - """ - from ..utils.security import SecurityValidator - - errors = [] - - # Check if all source directories exist - for source_dir in self._get_source_dirs(): - if not source_dir.exists(): - errors.append(f"Source directory not found: {source_dir}") - - # Check if all required framework files exist - missing_files = [] - for source, _ in self.get_files_to_install(): - if not source.exists(): - missing_files.append(str(source.relative_to(Path(__file__).parent.parent.parent / "superclaude"))) - - 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) - - # Validate files individually (each file with its own source dir) - for source, target in self.get_files_to_install(): - # Get the appropriate base source directory for this file - source_parent = source.parent - - # Validate source path - is_safe, msg = SecurityValidator.validate_path(source, source_parent) - if not is_safe: - errors.append(f"Invalid source path {source}: {msg}") - - # Validate target path - is_safe, msg = SecurityValidator.validate_path(target, self.install_component_subdir) - if not is_safe: - errors.append(f"Invalid target path {target}: {msg}") - - # Validate file extension - is_allowed, msg = SecurityValidator.validate_file_extension(source) - if not is_allowed: - errors.append(f"File {source}: {msg}") - - 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_metadata_modifications(self) -> Dict[str, Any]: - """Get metadata modifications for SuperClaude""" - return { - "framework": { - "version": __version__, - "name": "superclaude", - "description": "AI-enhanced development framework for Claude Code", - "installation_type": "global", - "components": ["knowledge_base"], - }, - "superclaude": { - "enabled": True, - "version": __version__, - "profile": "default", - "auto_update": False, - }, - } - - def _install(self, config: Dict[str, Any]) -> bool: - """Install knowledge base component""" - self.logger.info("Installing SuperClaude knowledge base...") - - return super()._install(config) - - def _post_install(self) -> bool: - # 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 (with file list for sync) - self.settings_manager.add_component_registration( - "knowledge_base", - { - "version": __version__, - "category": "documentation", - "files_count": len(self.component_files), - "files": list(self.component_files), # Track for sync/deletion - }, - ) - - self.logger.info("Updated metadata with knowledge base 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", "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}") - - # Update CLAUDE.md with framework documentation imports - try: - manager = CLAUDEMdService(self.install_dir) - manager.add_imports(self.component_files, category="Framework Documentation") - self.logger.info("Updated CLAUDE.md with framework documentation imports") - except Exception as e: - self.logger.warning( - f"Failed to update CLAUDE.md with framework documentation imports: {e}" - ) - # Don't fail the whole installation for this - - # Auto-create repository index for token efficiency (94% reduction) - try: - self.logger.info("Creating repository index for optimal context loading...") - self._create_repository_index() - self.logger.info("βœ… Repository index created - 94% token savings enabled") - except Exception as e: - self.logger.warning(f"Could not create repository index: {e}") - # Don't fail installation if indexing fails - - return True - - def uninstall(self) -> bool: - """Uninstall knowledge base component""" - try: - self.logger.info("Uninstalling SuperClaude knowledge base component...") - - # Remove framework files - removed_count = 0 - for filename in self.component_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}") - else: - self.logger.warning(f"Could not remove {filename}") - - # Update metadata to remove knowledge base component - try: - if self.settings_manager.is_component_installed("knowledge_base"): - self.settings_manager.remove_component_registration("knowledge_base") - 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 knowledge base component from metadata") - except Exception as e: - self.logger.warning(f"Could not update metadata: {e}") - - self.logger.success( - f"Framework docs component uninstalled ({removed_count} files removed)" - ) - return True - - except Exception as e: - self.logger.exception(f"Unexpected error during knowledge base uninstallation: {e}") - return False - - def get_dependencies(self) -> List[str]: - """Get component dependencies (knowledge base has none)""" - return [] - - def update(self, config: Dict[str, Any]) -> bool: - """ - Sync knowledge base component (overwrite + delete obsolete files). - No backup needed - SuperClaude source files are always authoritative. - """ - try: - self.logger.info("Syncing SuperClaude knowledge base component...") - - # Get previously installed files from metadata - metadata = self.settings_manager.load_metadata() - previous_files = set( - metadata.get("components", {}) - .get("knowledge_base", {}) - .get("files", []) - ) - - # Get current files from source - current_files = set(self.component_files) - - # Files to delete (were installed before, but no longer in source) - files_to_delete = previous_files - current_files - - # Delete obsolete files - deleted_count = 0 - for filename in files_to_delete: - file_path = self.install_component_subdir / filename - if file_path.exists(): - try: - file_path.unlink() - deleted_count += 1 - self.logger.info(f"Deleted obsolete file: {filename}") - except Exception as e: - self.logger.warning(f"Could not delete {filename}: {e}") - - # Install/overwrite current files (no backup) - success = self.install(config) - - if success: - # Update metadata with current file list - self.settings_manager.add_component_registration( - "knowledge_base", - { - "version": __version__, - "category": "documentation", - "files_count": len(current_files), - "files": list(current_files), # Track installed files - }, - ) - - self.logger.success( - f"Framework docs synced: {len(current_files)} files, {deleted_count} obsolete files removed" - ) - else: - self.logger.error("Framework docs sync failed") - - return success - - except Exception as e: - self.logger.exception(f"Unexpected error during knowledge base sync: {e}") - return False - - def validate_installation(self) -> Tuple[bool, List[str]]: - """Validate knowledge base component installation""" - errors = [] - - # Check if all framework files exist - for filename in self.component_files: - file_path = self.install_component_subdir / 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("knowledge_base"): - errors.append("Knowledge base component not registered in metadata") - else: - # Check version matches - installed_version = self.settings_manager.get_component_version("knowledge_base") - 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_dirs(self): - """Get source directories for framework documentation files""" - # Assume we're in superclaude/setup/components/framework_docs.py - # Framework files are organized in superclaude/{framework,business,research} - project_root = Path(__file__).parent.parent.parent - return [ - project_root / "superclaude" / "framework", - project_root / "superclaude" / "business", - project_root / "superclaude" / "research", - ] - - def _get_source_dir(self): - """Get source directory (compatibility method, returns first directory)""" - dirs = self._get_source_dirs() - return dirs[0] if dirs else None - - def _discover_component_files(self) -> List[str]: - """ - Discover framework .md files across multiple directories - - Returns: - List of relative paths (e.g., ['framework/flags.md', 'business/examples.md']) - """ - all_files = [] - project_root = Path(__file__).parent.parent.parent / "superclaude" - - for source_dir in self._get_source_dirs(): - if not source_dir.exists(): - self.logger.warning(f"Source directory not found: {source_dir}") - continue - - # Get directory name relative to superclaude/ - dir_name = source_dir.relative_to(project_root) - - # Discover .md files in this directory - files = self._discover_files_in_directory( - source_dir, - extension=".md", - exclude_patterns=["README.md", "CHANGELOG.md", "LICENSE.md"], - ) - - # Add directory prefix to each file - for file in files: - all_files.append(str(dir_name / file)) - - return all_files - - def get_files_to_install(self) -> List[Tuple[Path, Path]]: - """ - Return list of files to install from multiple source directories - - Returns: - List of tuples (source_path, target_path) - """ - files = [] - project_root = Path(__file__).parent.parent.parent / "superclaude" - - for relative_path in self.component_files: - source = project_root / relative_path - # Install to superclaude/ subdirectory structure - target = self.install_component_subdir / relative_path - files.append((source, target)) - - return files - - def get_size_estimate(self) -> int: - """Get estimated installation size""" - total_size = 0 - - for source, _ in self.get_files_to_install(): - if source.exists(): - total_size += source.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(), - } - - def _create_repository_index(self) -> None: - """ - Create repository index for token-efficient context loading. - - Runs parallel indexing to analyze project structure. - Saves PROJECT_INDEX.md for fast future sessions (94% token reduction). - """ - import subprocess - import sys - from pathlib import Path - - # Get repository root (should be SuperClaude_Framework) - repo_root = Path(__file__).parent.parent.parent - - # Path to the indexing script - indexer_script = repo_root / "superclaude" / "indexing" / "parallel_repository_indexer.py" - - if not indexer_script.exists(): - self.logger.warning(f"Indexer script not found: {indexer_script}") - return - - # Run the indexer - try: - result = subprocess.run( - [sys.executable, str(indexer_script)], - cwd=repo_root, - capture_output=True, - text=True, - timeout=300, # 5 minutes max - ) - - if result.returncode == 0: - self.logger.info("Repository indexed successfully") - if result.stdout: - # Log summary line only - for line in result.stdout.splitlines(): - if "Indexing complete" in line or "Quality:" in line: - self.logger.info(line.strip()) - else: - self.logger.warning(f"Indexing failed with code {result.returncode}") - if result.stderr: - self.logger.debug(f"Indexing error: {result.stderr[:200]}") - - except subprocess.TimeoutExpired: - self.logger.warning("Repository indexing timed out (>5min)") - except Exception as e: - self.logger.warning(f"Could not run repository indexer: {e}") diff --git a/setup/components/mcp_integration.py b/setup/components/mcp_integration.py deleted file mode 100644 index 3ec50ef..0000000 --- a/setup/components/mcp_integration.py +++ /dev/null @@ -1,1094 +0,0 @@ -""" -MCP Integration Component - -Responsibility: Integrates Model Context Protocol for external tool access. -Manages connections to specialized MCP servers and capabilities. -""" - -import os -import platform -import shlex -import subprocess -import sys -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -from setup import __version__ - -from ..core.base import Component - - -class MCPIntegrationComponent(Component): - """MCP servers integration component""" - - def __init__(self, install_dir: Optional[Path] = None): - """Initialize MCP component""" - super().__init__(install_dir) - self.installed_servers_in_session: List[str] = [] - - # Define MCP servers to install - # Default: airis-mcp-gateway (unified gateway with all tools) - # Legacy mode (--legacy flag): individual official servers - self.mcp_servers_default = { - "airis-mcp-gateway": { - "name": "airis-mcp-gateway", - "description": "Unified MCP Gateway with all tools (sequential-thinking, context7, magic, playwright, serena, morphllm, tavily, chrome-devtools, git, puppeteer)", - "install_method": "github", - "install_command": "uvx --from git+https://github.com/agiletec-inc/airis-mcp-gateway airis-mcp-gateway --help", - "run_command": "uvx --from git+https://github.com/agiletec-inc/airis-mcp-gateway airis-mcp-gateway", - "required": True, - }, - } - - self.mcp_servers_legacy = { - "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, - }, - } - - # Default to unified gateway - self.mcp_servers = self.mcp_servers_default - - def get_metadata(self) -> Dict[str, str]: - """Get component metadata""" - return { - "name": "mcp", - "version": __version__, - "description": "Unified MCP Gateway (airis-mcp-gateway) with all integrated tools", - "category": "integration", - } - - def is_reinstallable(self) -> bool: - """This component manages sub-components (servers) and should be re-run.""" - return True - - def _run_command_cross_platform( - self, cmd: List[str], **kwargs - ) -> subprocess.CompletedProcess: - """ - Run a command with proper cross-platform shell handling. - - Args: - cmd: Command as list of strings - **kwargs: Additional subprocess.run arguments - - Returns: - CompletedProcess result - """ - if platform.system() == "Windows": - # On Windows, wrap command in 'cmd /c' to properly handle commands like npx - cmd = ["cmd", "/c"] + cmd - return subprocess.run(cmd, **kwargs) - else: - # macOS/Linux: Use string format with proper shell to support aliases - cmd_str = " ".join(shlex.quote(str(arg)) for arg in cmd) - - # Use the user's shell to execute the command, supporting aliases - user_shell = os.environ.get("SHELL", "/bin/bash") - return subprocess.run( - cmd_str, shell=True, env=os.environ, executable=user_shell, **kwargs - ) - - def validate_prerequisites( - self, installSubPath: Optional[Path] = None - ) -> Tuple[bool, List[str]]: - """Check prerequisites (varies based on legacy mode)""" - errors = [] - - # Check which server set we're using - is_legacy = self.mcp_servers == self.mcp_servers_legacy - - # Check if Claude CLI is available (always required) - try: - result = self._run_command_cross_platform( - ["claude", "--version"], capture_output=True, text=True, timeout=10 - ) - 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") - - if is_legacy: - # Legacy mode: requires Node.js and npm for official servers - try: - result = self._run_command_cross_platform( - ["node", "--version"], capture_output=True, text=True, timeout=10 - ) - if result.returncode != 0: - errors.append("Node.js not found - required for legacy 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 legacy MCP servers") - - try: - result = self._run_command_cross_platform( - ["npm", "--version"], capture_output=True, text=True, timeout=10 - ) - if result.returncode != 0: - errors.append("npm not found - required for legacy 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 legacy MCP server installation") - else: - # Default mode: requires uv for airis-mcp-gateway - try: - result = self._run_command_cross_platform( - ["uv", "--version"], capture_output=True, text=True, timeout=10 - ) - if result.returncode != 0: - errors.append("uv not found - required for airis-mcp-gateway installation") - else: - version = result.stdout.strip() - self.logger.debug(f"Found uv {version}") - except (subprocess.TimeoutExpired, FileNotFoundError): - errors.append("uv not found - required for airis-mcp-gateway 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": __version__, - "installed": True, - "servers_count": len(self.installed_servers_in_session), - } - }, - "mcp": { - "enabled": True, - "servers": self.installed_servers_in_session, - "auto_update": False, - }, - } - - def _install_uv_mcp_server( - self, server_info: Dict[str, Any], config: Dict[str, Any] - ) -> bool: - """Install a single MCP server using uv""" - server_name = server_info["name"] - install_command = server_info.get("install_command") - run_command = server_info.get("run_command") - - if not install_command: - self.logger.error( - f"No install_command found for uv-based server {server_name}" - ) - return False - if not run_command: - self.logger.error(f"No run_command found for uv-based server {server_name}") - return False - - try: - self.logger.info(f"Installing MCP server using uv: {server_name}") - - if self._check_mcp_server_installed(server_name): - self.logger.info(f"MCP server {server_name} already installed") - return True - - # Check if uv is available - try: - uv_check = self._run_command_cross_platform( - ["uv", "--version"], capture_output=True, text=True, timeout=10 - ) - if uv_check.returncode != 0: - self.logger.error( - f"uv not found - required for {server_name} installation" - ) - return False - except (subprocess.TimeoutExpired, FileNotFoundError): - self.logger.error( - f"uv not found - required for {server_name} installation" - ) - return False - - if config.get("dry_run"): - self.logger.info( - f"Would install MCP server (user scope): {install_command}" - ) - self.logger.info( - f"Would register MCP server run command: {run_command}" - ) - return True - - # Run install command - self.logger.debug(f"Running: {install_command}") - cmd_parts = shlex.split(install_command) - result = self._run_command_cross_platform( - cmd_parts, capture_output=True, text=True, timeout=900 # 15 minutes - ) - - if result.returncode == 0: - self.logger.success( - f"Successfully installed MCP server (user scope): {server_name}" - ) - - # For Serena, we need to handle the run command specially - if server_name == "serena": - # Serena needs project-specific registration, use current working directory - current_dir = os.getcwd() - serena_run_cmd = ( - f"{run_command} --project {shlex.quote(current_dir)}" - ) - self.logger.info( - f"Registering {server_name} with Claude CLI for project: {current_dir}" - ) - reg_cmd = [ - "claude", - "mcp", - "add", - "-s", - "user", - "--", - server_name, - ] + shlex.split(serena_run_cmd) - else: - self.logger.info( - f"Registering {server_name} with Claude CLI. Run command: {run_command}" - ) - reg_cmd = [ - "claude", - "mcp", - "add", - "-s", - "user", - "--", - server_name, - ] + shlex.split(run_command) - - reg_result = self._run_command_cross_platform( - reg_cmd, capture_output=True, text=True, timeout=120 - ) - - if reg_result.returncode == 0: - self.logger.success( - f"Successfully registered {server_name} with Claude CLI." - ) - return True - else: - error_msg = ( - reg_result.stderr.strip() - if reg_result.stderr - else "Unknown error" - ) - self.logger.error( - f"Failed to register MCP server {server_name} with Claude CLI: {error_msg}" - ) - return False - else: - error_msg = result.stderr.strip() if result.stderr else "Unknown error" - self.logger.error( - f"Failed to install MCP server {server_name} using uv: {error_msg}\n{result.stdout}" - ) - return False - - except subprocess.TimeoutExpired: - self.logger.error(f"Timeout installing MCP server {server_name} using uv") - return False - except Exception as e: - self.logger.error( - f"Error installing MCP server {server_name} using uv: {e}" - ) - return False - - def _install_github_mcp_server( - self, server_info: Dict[str, Any], config: Dict[str, Any] - ) -> bool: - """Install a single MCP server from GitHub using uvx""" - server_name = server_info["name"] - install_command = server_info.get("install_command") - run_command = server_info.get("run_command") - - if not install_command: - self.logger.error( - f"No install_command found for GitHub-based server {server_name}" - ) - return False - if not run_command: - self.logger.error( - f"No run_command found for GitHub-based server {server_name}" - ) - return False - - try: - self.logger.info(f"Installing MCP server from GitHub: {server_name}") - - if self._check_mcp_server_installed(server_name): - self.logger.info(f"MCP server {server_name} already installed") - return True - - # Check if uvx is available - try: - uvx_check = self._run_command_cross_platform( - ["uvx", "--version"], capture_output=True, text=True, timeout=10 - ) - if uvx_check.returncode != 0: - self.logger.error( - f"uvx not found - required for {server_name} installation" - ) - return False - except (subprocess.TimeoutExpired, FileNotFoundError): - self.logger.error( - f"uvx not found - required for {server_name} installation" - ) - return False - - if config.get("dry_run"): - self.logger.info( - f"Would install MCP server from GitHub: {install_command}" - ) - self.logger.info( - f"Would register MCP server run command: {run_command}" - ) - return True - - # Run install command to test the GitHub installation - self.logger.debug(f"Testing GitHub installation: {install_command}") - cmd_parts = shlex.split(install_command) - result = self._run_command_cross_platform( - cmd_parts, - capture_output=True, - text=True, - timeout=300, # 5 minutes for GitHub clone and build - ) - - if result.returncode == 0: - self.logger.success( - f"Successfully tested GitHub MCP server: {server_name}" - ) - - # Register with Claude CLI using the run command - self.logger.info( - f"Registering {server_name} with Claude CLI. Run command: {run_command}" - ) - reg_cmd = [ - "claude", - "mcp", - "add", - "-s", - "user", - "--", - server_name, - ] + shlex.split(run_command) - - reg_result = self._run_command_cross_platform( - reg_cmd, capture_output=True, text=True, timeout=120 - ) - - if reg_result.returncode == 0: - self.logger.success( - f"Successfully registered {server_name} with Claude CLI." - ) - return True - else: - error_msg = ( - reg_result.stderr.strip() - if reg_result.stderr - else "Unknown error" - ) - self.logger.error( - f"Failed to register MCP server {server_name} with Claude CLI: {error_msg}" - ) - return False - else: - error_msg = result.stderr.strip() if result.stderr else "Unknown error" - self.logger.error( - f"Failed to install MCP server {server_name} from GitHub: {error_msg}\n{result.stdout}" - ) - return False - - except subprocess.TimeoutExpired: - self.logger.error( - f"Timeout installing MCP server {server_name} from GitHub" - ) - return False - except Exception as e: - self.logger.error( - f"Error installing MCP server {server_name} from GitHub: {e}" - ) - return False - - def _check_mcp_server_installed(self, server_name: str) -> bool: - """Check if MCP server is already installed""" - try: - result = self._run_command_cross_platform( - ["claude", "mcp", "list"], capture_output=True, text=True, timeout=60 - ) - - 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 _detect_existing_mcp_servers_from_config(self) -> List[str]: - """Detect existing MCP servers from Claude Desktop config""" - detected_servers = [] - - try: - # Try to find Claude Desktop config file - config_paths = [ - self.install_dir / "claude_desktop_config.json", - Path.home() / ".claude" / "claude_desktop_config.json", - Path.home() / ".claude.json", # Claude CLI config - Path.home() - / "AppData" - / "Roaming" - / "Claude" - / "claude_desktop_config.json", # Windows - Path.home() - / "Library" - / "Application Support" - / "Claude" - / "claude_desktop_config.json", # macOS - ] - - config_file = None - for path in config_paths: - if path.exists(): - config_file = path - break - - if not config_file: - self.logger.debug("No Claude Desktop config file found") - return detected_servers - - import json - - with open(config_file, "r") as f: - config = json.load(f) - - # Extract MCP server names from mcpServers section - mcp_servers = config.get("mcpServers", {}) - for server_name in mcp_servers.keys(): - # Map common name variations to our standard names - normalized_name = self._normalize_server_name(server_name) - if normalized_name and normalized_name in self.mcp_servers: - detected_servers.append(normalized_name) - - if detected_servers: - self.logger.info( - f"Detected existing MCP servers from config: {detected_servers}" - ) - - except Exception as e: - self.logger.warning(f"Could not read Claude Desktop config: {e}") - - return detected_servers - - def _detect_existing_mcp_servers_from_cli(self) -> List[str]: - """Detect existing MCP servers from Claude CLI""" - detected_servers = [] - - try: - result = self._run_command_cross_platform( - ["claude", "mcp", "list"], capture_output=True, text=True, timeout=60 - ) - - if result.returncode != 0: - self.logger.debug(f"Could not list MCP servers: {result.stderr}") - return detected_servers - - # Parse the output to extract server names - output_lines = result.stdout.strip().split("\n") - for line in output_lines: - line = line.strip().lower() - if line and not line.startswith("#") and not line.startswith("no"): - # Extract server name (usually the first word or before first space/colon) - server_name = line.split()[0] if line.split() else "" - normalized_name = self._normalize_server_name(server_name) - if normalized_name and normalized_name in self.mcp_servers: - detected_servers.append(normalized_name) - - if detected_servers: - self.logger.info( - f"Detected existing MCP servers from CLI: {detected_servers}" - ) - - except Exception as e: - self.logger.warning(f"Could not detect existing MCP servers from CLI: {e}") - - return detected_servers - - def _normalize_server_name(self, server_name: str) -> Optional[str]: - """Normalize server name to match our internal naming""" - if not server_name: - return None - - server_name = server_name.lower().strip() - - # Map common variations to our standard names - name_mappings = { - "airis-mcp-gateway": "airis-mcp-gateway", - "airis": "airis-mcp-gateway", - "gateway": "airis-mcp-gateway", - } - - return name_mappings.get(server_name) - - def _merge_server_lists( - self, - existing_servers: List[str], - selected_servers: List[str], - previous_servers: List[str], - ) -> List[str]: - """Merge existing, selected, and previously installed servers""" - all_servers = set() - - # Add all detected servers - all_servers.update(existing_servers) - all_servers.update(selected_servers) - all_servers.update(previous_servers) - - # Filter to only include servers we know how to install - valid_servers = [s for s in all_servers if s in self.mcp_servers] - - if valid_servers: - self.logger.info(f"Total servers to manage: {valid_servers}") - if existing_servers: - self.logger.info(f" - Existing: {existing_servers}") - if selected_servers: - self.logger.info(f" - Newly selected: {selected_servers}") - if previous_servers: - self.logger.info(f" - Previously installed: {previous_servers}") - - return valid_servers - - def _install_mcp_server( - self, server_info: Dict[str, Any], config: Dict[str, Any] - ) -> bool: - """Install a single MCP server""" - if server_info.get("install_method") == "uv": - return self._install_uv_mcp_server(server_info, config) - elif server_info.get("install_method") == "github": - return self._install_github_mcp_server(server_info, config) - - server_name = server_info["name"] - npm_package = server_info.get("npm_package") - install_command = server_info.get("install_command") - - if not npm_package and not install_command: - self.logger.error( - f"No npm_package or install_command found for server {server_name}" - ) - return False - - 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): - self.logger.info(f"MCP server '{server_name}' requires an API key") - self.logger.info(f"Environment variable: {api_key_env}") - self.logger.info(f"Description: {api_key_desc}") - - # Check if API key is already set - import os - - if not os.getenv(api_key_env): - self.logger.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 install_command: - # Use the full install command (e.g., for tavily-mcp@0.1.2) - install_args = install_command.split() - if config.get("dry_run"): - self.logger.info( - f"Would install MCP server (user scope): claude mcp add -s user {server_name} {' '.join(install_args)}" - ) - return True - - self.logger.debug( - f"Running: claude mcp add -s user {server_name} {' '.join(install_args)}" - ) - - result = self._run_command_cross_platform( - ["claude", "mcp", "add", "-s", "user", "--", server_name] - + install_args, - capture_output=True, - text=True, - timeout=120, # 2 minutes timeout for installation - ) - else: - # Use npm_package - 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 = self._run_command_cross_platform( - [ - "claude", - "mcp", - "add", - "-s", - "user", - "--", - server_name, - command, - "-y", - npm_package, - ], - capture_output=True, - text=True, - timeout=120, # 2 minutes timeout for installation - ) - - 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 = self._run_command_cross_platform( - ["claude", "mcp", "remove", server_name], - capture_output=True, - text=True, - timeout=60, - ) - - 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 with auto-detection of existing servers""" - # Check for legacy mode flag - use_legacy = config.get("legacy_mode", False) or config.get("official_servers", False) - - if use_legacy: - self.logger.info("Installing individual official MCP servers (legacy mode)...") - self.mcp_servers = self.mcp_servers_legacy - else: - self.logger.info("Installing unified MCP gateway (airis-mcp-gateway)...") - self.mcp_servers = self.mcp_servers_default - - # Validate prerequisites - success, errors = self.validate_prerequisites() - if not success: - for error in errors: - self.logger.error(error) - return False - - # Auto-detect existing servers - self.logger.info("Auto-detecting existing MCP servers...") - existing_from_config = self._detect_existing_mcp_servers_from_config() - existing_from_cli = self._detect_existing_mcp_servers_from_cli() - existing_servers = list(set(existing_from_config + existing_from_cli)) - - # Get selected servers from config - selected_servers = config.get("selected_mcp_servers", []) - - # Get previously installed servers from metadata - previous_servers = self.settings_manager.get_metadata_setting("mcp.servers", []) - - # Merge all server lists - all_servers = self._merge_server_lists( - existing_servers, selected_servers, previous_servers - ) - - if not all_servers: - self.logger.info( - "No MCP servers detected or selected. Skipping MCP installation." - ) - # Still run post-install to update metadata - return self._post_install() - - self.logger.info(f"Managing MCP servers: {', '.join(all_servers)}") - - # Install/verify each server - installed_count = 0 - failed_servers = [] - verified_servers = [] - - for server_name in all_servers: - if server_name in self.mcp_servers: - server_info = self.mcp_servers[server_name] - - # Check if already installed and working - if self._check_mcp_server_installed(server_name): - self.logger.info( - f"MCP server {server_name} already installed and working" - ) - installed_count += 1 - verified_servers.append(server_name) - else: - # Try to install - if self._install_mcp_server(server_info, config): - installed_count += 1 - verified_servers.append(server_name) - 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 - else: - self.logger.warning( - f"Unknown MCP server '{server_name}' cannot be managed by SuperClaude" - ) - - # Update the list of successfully managed servers - self.installed_servers_in_session = verified_servers - - # Verify installation - if not config.get("dry_run", False): - self.logger.info("Verifying MCP server installation...") - try: - result = self._run_command_cross_platform( - ["claude", "mcp", "list"], - capture_output=True, - text=True, - timeout=60, - ) - - 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 managed ({installed_count} servers)" - ) - else: - self.logger.success( - f"MCP component successfully managing ({installed_count} servers)" - ) - - return self._post_install() - - def _post_install(self) -> bool: - """Post-installation tasks""" - # 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": __version__, - "category": "integration", - "servers_count": len(self.installed_servers_in_session), - "installed_servers": self.installed_servers_in_session, - }, - ) - - self.logger.info("Updated metadata with MCP component registration") - return True - except Exception as e: - self.logger.error(f"Failed to update metadata: {e}") - return False - - 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 ["knowledge_base"] - - 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 and validate installed servers - try: - result = self._run_command_cross_platform( - ["claude", "mcp", "list"], capture_output=True, text=True, timeout=60 - ) - - if result.returncode != 0: - errors.append( - "Could not communicate with Claude CLI for MCP server verification" - ) - else: - claude_mcp_output = result.stdout.lower() - - # Get the list of servers that should be installed from metadata - installed_servers = self.settings_manager.get_metadata_setting( - "mcp.servers", [] - ) - - for server_name in installed_servers: - if server_name.lower() not in claude_mcp_output: - errors.append( - f"Installed MCP server '{server_name}' not found in 'claude mcp list' output." - ) - - 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": 1, # Only airis-mcp-gateway - "mcp_servers": ["airis-mcp-gateway"], - "included_tools": [ - "sequential-thinking", - "context7", - "magic", - "playwright", - "serena", - "morphllm", - "tavily", - "chrome-devtools", - "git", - "puppeteer", - ], - "estimated_size": self.get_size_estimate(), - "dependencies": self.get_dependencies(), - "required_tools": ["uv", "claude"], - } diff --git a/setup/components/slash_commands.py b/setup/components/slash_commands.py deleted file mode 100644 index 039dcf6..0000000 --- a/setup/components/slash_commands.py +++ /dev/null @@ -1,554 +0,0 @@ -""" -Slash Commands Component - -Responsibility: Registers and manages slash commands for CLI interactions. -Provides custom command definitions and execution logic. -""" - -from typing import Dict, List, Tuple, Optional, Any -from pathlib import Path - -from ..core.base import Component -from setup import __version__ - - -class SlashCommandsComponent(Component): - """SuperClaude slash commands component""" - - def __init__(self, install_dir: Optional[Path] = None): - """Initialize commands component""" - if install_dir is None: - install_dir = Path.home() / ".claude" - - # Commands are installed directly to ~/.claude/commands/sc/ - # not under superclaude/ subdirectory (Claude Code official location) - if "superclaude" in str(install_dir): - # ~/.claude/superclaude -> ~/.claude - install_dir = install_dir.parent - - super().__init__(install_dir, Path("commands/sc")) - - def get_metadata(self) -> Dict[str, str]: - """Get component metadata""" - return { - "name": "commands", - "version": __version__, - "description": "SuperClaude slash command definitions", - "category": "commands", - } - - def is_reinstallable(self) -> bool: - """ - Commands should always be synced to latest version. - SuperClaude command files always overwrite existing files. - """ - return True - - def validate_prerequisites( - self, installSubPath: Optional[Path] = None - ) -> Tuple[bool, List[str]]: - """ - Check prerequisites for this component - Skills-aware validation - - Returns: - Tuple of (success: bool, error_messages: List[str]) - """ - from ..utils.security import SecurityValidator - - 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: - # Skills files are in parent/skills/, not source_dir - if filename.startswith("skills/"): - source_file = source_dir.parent / filename - else: - 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}") - return False, errors - - # 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 files - Skills files have different base directories - for source, target in files_to_install: - # Skills files install to ~/.claude/skills/, no base_dir check needed - if "skills/" in str(target): - # Only validate path safety, not base_dir - is_safe, error = SecurityValidator.validate_path(target, None) - else: - # Regular commands - validate with base_dir - is_safe, error = SecurityValidator.validate_path(target, self.install_component_subdir) - - if not is_safe: - errors.append(error) - - 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_metadata_modifications(self) -> Dict[str, Any]: - """Get metadata modifications for commands component""" - return { - "components": { - "commands": { - "version": __version__, - "installed": True, - "files_count": len(self.component_files), - } - }, - "commands": {"enabled": True, "version": __version__, "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) -> bool: - # 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 (with file list for sync) - self.settings_manager.add_component_registration( - "commands", - { - "version": __version__, - "category": "commands", - "files_count": len(self.component_files), - "files": list(self.component_files), # Track for sync/deletion - }, - ) - self.logger.info("Updated metadata with commands component registration") - except Exception as e: - self.logger.error(f"Failed to update metadata: {e}") - return False - - # Clean up old commands directory in superclaude/ (from previous versions) - try: - old_superclaude_commands = Path.home() / ".claude" / "superclaude" / "commands" - if old_superclaude_commands.exists(): - import shutil - shutil.rmtree(old_superclaude_commands) - self.logger.info("Removed old commands directory from superclaude/") - except Exception as e: - self.logger.debug(f"Could not remove old commands directory: {e}") - - 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 ["knowledge_base"] - - def update(self, config: Dict[str, Any]) -> bool: - """ - Sync commands component (overwrite + delete obsolete files). - No backup needed - SuperClaude source files are always authoritative. - """ - try: - self.logger.info("Syncing SuperClaude commands component...") - - # Get previously installed files from metadata - metadata = self.settings_manager.load_metadata() - previous_files = set( - metadata.get("components", {}).get("commands", {}).get("files", []) - ) - - # Get current files from source - current_files = set(self.component_files) - - # Files to delete (were installed before, but no longer in source) - files_to_delete = previous_files - current_files - - # Delete obsolete files - deleted_count = 0 - commands_dir = self.install_dir / "commands" / "sc" - for filename in files_to_delete: - file_path = commands_dir / filename - if file_path.exists(): - try: - file_path.unlink() - deleted_count += 1 - self.logger.info(f"Deleted obsolete command: {filename}") - except Exception as e: - self.logger.warning(f"Could not delete {filename}: {e}") - - # Install/overwrite current files (no backup) - success = self.install(config) - - if success: - # Update metadata with current file list - self.settings_manager.add_component_registration( - "commands", - { - "version": __version__, - "category": "commands", - "files_count": len(current_files), - "files": list(current_files), # Track installed files - }, - ) - - self.logger.success( - f"Commands synced: {len(current_files)} files, {deleted_count} obsolete files removed" - ) - else: - self.logger.error("Commands sync failed") - - return success - - except Exception as e: - self.logger.exception(f"Unexpected error during commands sync: {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 _discover_component_files(self) -> List[str]: - """ - Discover command files including modules subdirectory and Skills - - Returns: - List of relative file paths (e.g., ['pm.md', 'modules/token-counter.md', 'skills/pm/SKILL.md']) - """ - source_dir = self._get_source_dir() - - if not source_dir or not source_dir.exists(): - return [] - - files = [] - - # Discover top-level .md files (slash commands) - for file_path in source_dir.iterdir(): - if ( - file_path.is_file() - and file_path.suffix.lower() == ".md" - and file_path.name not in ["README.md", "CHANGELOG.md", "LICENSE.md"] - ): - files.append(file_path.name) - - # Discover modules subdirectory files - modules_dir = source_dir / "modules" - if modules_dir.exists() and modules_dir.is_dir(): - for file_path in modules_dir.iterdir(): - if file_path.is_file() and file_path.suffix.lower() == ".md": - # Store as relative path: modules/token-counter.md - files.append(f"modules/{file_path.name}") - - # Discover Skills directory structure - skills_dir = source_dir.parent / "skills" - if skills_dir.exists() and skills_dir.is_dir(): - for skill_path in skills_dir.iterdir(): - if skill_path.is_dir(): - skill_name = skill_path.name - # Add SKILL.md - skill_md = skill_path / "SKILL.md" - if skill_md.exists(): - files.append(f"skills/{skill_name}/SKILL.md") - - # Add implementation.md - impl_md = skill_path / "implementation.md" - if impl_md.exists(): - files.append(f"skills/{skill_name}/implementation.md") - - # Add modules subdirectory files - skill_modules = skill_path / "modules" - if skill_modules.exists() and skill_modules.is_dir(): - for module_file in skill_modules.iterdir(): - if module_file.is_file() and module_file.suffix.lower() == ".md": - files.append(f"skills/{skill_name}/modules/{module_file.name}") - - # Sort for consistent ordering - files.sort() - - self.logger.debug( - f"Discovered {len(files)} command files (including modules and skills)" - ) - if files: - self.logger.debug(f"Files found: {files}") - - return files - - def get_files_to_install(self) -> List[Tuple[Path, Path]]: - """ - Return list of files to install, including modules subdirectory and Skills - - Returns: - List of tuples (source_path, target_path) - """ - source_dir = self._get_source_dir() - files = [] - - if source_dir: - for filename in self.component_files: - # Handle Skills files - install to ~/.claude/skills/ instead of ~/.claude/commands/sc/ - if filename.startswith("skills/"): - source = source_dir.parent / filename - # Install to ~/.claude/skills/ (not ~/.claude/commands/sc/skills/) - skills_target = self.install_dir.parent if "commands" in str(self.install_dir) else self.install_dir - target = skills_target / filename - else: - source = source_dir / filename - target = self.install_component_subdir / filename - - files.append((source, target)) - - return files - - 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}") diff --git a/setup/core/__init__.py b/setup/core/__init__.py deleted file mode 100644 index 947dd14..0000000 --- a/setup/core/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Core modules for SuperClaude installation system""" - -from .validator import Validator -from .registry import ComponentRegistry - -__all__ = ["Validator", "ComponentRegistry"] diff --git a/setup/core/base.py b/setup/core/base.py deleted file mode 100644 index caf0534..0000000 --- a/setup/core/base.py +++ /dev/null @@ -1,467 +0,0 @@ -""" -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 ..services.files import FileService -from ..services.settings import SettingsService -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 - - # Initialize logger first - self.logger = get_logger() - # Resolve path safely - self.install_dir = self._resolve_path_safely(install_dir or DEFAULT_INSTALL_DIR) - self.settings_manager = SettingsService(self.install_dir) - self.component_files = self._discover_component_files() - self.file_manager = FileService() - 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 is_reinstallable(self) -> bool: - """ - Whether this component should be re-installed if already present. - Useful for container-like components that can install sub-parts. - """ - return False - - 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 - """ - self.logger.debug("Checking installed version") - metadata_file = self.install_dir / ".superclaude-metadata.json" - if metadata_file.exists(): - self.logger.debug("Metadata file exists, reading version") - try: - with open(metadata_file, "r") as f: - metadata = json.load(f) - component_name = self.get_metadata()["name"] - version = ( - metadata.get("components", {}) - .get(component_name, {}) - .get("version") - ) - self.logger.debug(f"Found version: {version}") - return version - except Exception as e: - self.logger.warning(f"Failed to read version from metadata: {e}") - else: - self.logger.debug("Metadata file does not exist") - 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 metadata - if not self.get_installed_version(): - errors.append("Component not registered in .superclaude-metadata.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']})>" - - def _resolve_path_safely(self, path: Path) -> Path: - """ - Safely resolve path with proper error handling and security validation - - Args: - path: Path to resolve - - Returns: - Resolved path - - Raises: - ValueError: If path resolution fails or path is unsafe - """ - try: - # Expand user directory (~) and resolve path - resolved_path = path.expanduser().resolve() - - # Basic security validation - only enforce for production directories - path_str = str(resolved_path).lower() - - # Check for most dangerous system patterns (but allow /tmp for testing) - dangerous_patterns = [ - "/etc/", - "/bin/", - "/sbin/", - "/usr/bin/", - "/usr/sbin/", - "/var/log/", - "/var/lib/", - "/dev/", - "/proc/", - "/sys/", - "c:\\windows\\", - "c:\\program files\\", - ] - - # Allow temporary directories for testing - if path_str.startswith("/tmp/") or "temp" in path_str: - self.logger.debug(f"Allowing temporary directory: {resolved_path}") - return resolved_path - - for pattern in dangerous_patterns: - if path_str.startswith(pattern): - raise ValueError(f"Cannot use system directory: {resolved_path}") - - return resolved_path - - except Exception as e: - self.logger.error(f"Failed to resolve path {path}: {e}") - raise ValueError(f"Invalid path: {path}") - - def _resolve_source_path_safely(self, path: Path) -> Optional[Path]: - """ - Safely resolve source path with existence check - - Args: - path: Source path to resolve - - Returns: - Resolved path if valid and exists, None otherwise - """ - try: - resolved_path = self._resolve_path_safely(path) - return resolved_path if resolved_path.exists() else None - except ValueError: - return None diff --git a/setup/core/installer.py b/setup/core/installer.py deleted file mode 100644 index a74e44d..0000000 --- a/setup/core/installer.py +++ /dev/null @@ -1,304 +0,0 @@ -""" -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 .base import Component -from ..utils.logger import get_logger - - -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] = {} - from ..services.settings import SettingsService - - settings_manager = SettingsService(self.install_dir) - self.installed_components: Set[str] = set( - settings_manager.get_installed_components().keys() - ) - self.updated_components: Set[str] = set() - - self.failed_components: Set[str] = set() - self.skipped_components: Set[str] = set() - self.logger = get_logger() - - 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) -> None: - 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 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] - - # Framework components are ALWAYS updated to latest version - # These are SuperClaude implementation files, not user configurations - framework_components = {'knowledge_base', 'agents', 'commands', 'modes', 'core', 'mcp'} - - if component_name in framework_components: - # Always update framework components to latest version - if component_name in self.installed_components: - self.logger.info(f"Updating framework component to latest version: {component_name}") - else: - self.logger.info(f"Installing framework component: {component_name}") - # Force update for framework components - config = {**config, 'force_update': True} - elif ( - not component.is_reinstallable() - and component_name in self.installed_components - and not config.get("update_mode") - and not config.get("force") - ): - # Only skip non-framework components that are already installed - self.skipped_components.add(component_name) - self.logger.info(f"Skipping already installed component: {component_name}") - return True - - # Check prerequisites - success, errors = component.validate_prerequisites() - if not success: - self.logger.error(f"Prerequisites failed for {component_name}:") - for error in errors: - self.logger.error(f" - {error}") - self.failed_components.add(component_name) - return False - - # Perform installation or update - try: - if self.dry_run: - self.logger.info(f"[DRY RUN] Would install {component_name}") - success = True - else: - # If component is already installed and this is a framework component, call update() instead of install() - if component_name in self.installed_components and component_name in framework_components: - success = component.update(config) - 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: - self.logger.error(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: - self.logger.error(f"Dependency resolution error: {e}") - return False - - # Validate system requirements - success, errors = self.validate_system_requirements() - if not success: - self.logger.error("System requirements not met:") - for error in errors: - self.logger.error(f" - {error}") - return False - - # Install each component - all_success = True - for name in ordered_names: - self.logger.info(f"Installing {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""" - self.logger.info("Running post-installation validation...") - - all_valid = True - for name in self.updated_components: - if name not in self.components: - self.logger.warning( - f"Cannot validate component '{name}' as it was not part of this installation session." - ) - continue - - component = self.components[name] - success, errors = component.validate_installation() - - if success: - self.logger.info(f" + {name}: Valid") - else: - self.logger.error(f" x {name}: Invalid") - for error in errors: - self.logger.error(f" - {error}") - all_valid = False - - if all_valid: - self.logger.info("All components validated successfully!") - else: - self.logger.error("Some 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)""" - config["update_mode"] = True - 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), - "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), - } diff --git a/setup/core/registry.py b/setup/core/registry.py deleted file mode 100644 index 9c27150..0000000 --- a/setup/core/registry.py +++ /dev/null @@ -1,414 +0,0 @@ -""" -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 import Component -from ..utils.logger import get_logger - - -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 - self.logger = get_logger() - - 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: - self.logger.warning( - f"Could not instantiate component {name}: {e}" - ) - - except Exception as e: - self.logger.warning(f"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: - self.logger.warning(f"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: - self.logger.error( - 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: - self.logger.warning(f"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(), - } diff --git a/setup/core/validator.py b/setup/core/validator.py deleted file mode 100644 index a4f0813..0000000 --- a/setup/core/validator.py +++ /dev/null @@ -1,723 +0,0 @@ -""" -System validation for SuperClaude installation requirements -""" - -import subprocess -import sys -import shutil -from typing import Tuple, List, Dict, Any, Optional -from pathlib import Path -import re -from ..utils.paths import get_home_directory - -# Handle packaging import - if not available, use a simple version comparison -try: - from packaging import version - - PACKAGING_AVAILABLE = True -except ImportError: - PACKAGING_AVAILABLE = False - - class SimpleVersion: - def __init__(self, version_str: str): - self.version_str = version_str - # Simple version parsing: split by dots and convert to integers - try: - self.parts = [int(x) for x in version_str.split(".")] - except ValueError: - self.parts = [0, 0, 0] - - def __lt__(self, other): - if isinstance(other, str): - other = SimpleVersion(other) - # Pad with zeros to same length - max_len = max(len(self.parts), len(other.parts)) - self_parts = self.parts + [0] * (max_len - len(self.parts)) - other_parts = other.parts + [0] * (max_len - len(other.parts)) - return self_parts < other_parts - - def __gt__(self, other): - if isinstance(other, str): - other = SimpleVersion(other) - return not (self < other) and not (self == other) - - def __eq__(self, other): - if isinstance(other, str): - other = SimpleVersion(other) - return self.parts == other.parts - - class version: - @staticmethod - def parse(version_str: str): - return SimpleVersion(version_str) - - -class Validator: - """System requirements validator""" - - def __init__(self): - """Initialize validator""" - self.validation_cache: Dict[str, Any] = {} - - def check_python( - self, min_version: str = "3.8", max_version: Optional[str] = None - ) -> Tuple[bool, str]: - """ - Check Python version requirements - - Args: - min_version: Minimum required Python version - max_version: Maximum supported Python version (optional) - - Returns: - Tuple of (success: bool, message: str) - """ - cache_key = f"python_{min_version}_{max_version}" - if cache_key in self.validation_cache: - return self.validation_cache[cache_key] - - try: - # Get current Python version - current_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" - - # Check minimum version - if version.parse(current_version) < version.parse(min_version): - help_msg = self.get_installation_help("python") - result = ( - False, - f"Python {min_version}+ required, found {current_version}{help_msg}", - ) - self.validation_cache[cache_key] = result - return result - - # Check maximum version if specified - if max_version and version.parse(current_version) > version.parse( - max_version - ): - result = ( - False, - f"Python version {current_version} exceeds maximum supported {max_version}", - ) - self.validation_cache[cache_key] = result - return result - - result = (True, f"Python {current_version} meets requirements") - self.validation_cache[cache_key] = result - return result - - except Exception as e: - result = (False, f"Could not check Python version: {e}") - self.validation_cache[cache_key] = result - return result - - def check_node( - self, min_version: str = "16.0", max_version: Optional[str] = None - ) -> Tuple[bool, str]: - """ - Check Node.js version requirements - - Args: - min_version: Minimum required Node.js version - max_version: Maximum supported Node.js version (optional) - - Returns: - Tuple of (success: bool, message: str) - """ - cache_key = f"node_{min_version}_{max_version}" - if cache_key in self.validation_cache: - return self.validation_cache[cache_key] - - try: - # Check if node is installed - use shell=True on Windows for better PATH resolution - result = subprocess.run( - ["node", "--version"], - capture_output=True, - text=True, - timeout=10, - shell=(sys.platform == "win32"), - ) - - if result.returncode != 0: - help_msg = self.get_installation_help("node") - result_tuple = (False, f"Node.js not found in PATH{help_msg}") - self.validation_cache[cache_key] = result_tuple - return result_tuple - - # Parse version (format: v18.17.0) - version_output = result.stdout.strip() - if version_output.startswith("v"): - current_version = version_output[1:] - else: - current_version = version_output - - # Check minimum version - if version.parse(current_version) < version.parse(min_version): - help_msg = self.get_installation_help("node") - result_tuple = ( - False, - f"Node.js {min_version}+ required, found {current_version}{help_msg}", - ) - self.validation_cache[cache_key] = result_tuple - return result_tuple - - # Check maximum version if specified - if max_version and version.parse(current_version) > version.parse( - max_version - ): - result_tuple = ( - False, - f"Node.js version {current_version} exceeds maximum supported {max_version}", - ) - self.validation_cache[cache_key] = result_tuple - return result_tuple - - result_tuple = (True, f"Node.js {current_version} meets requirements") - self.validation_cache[cache_key] = result_tuple - return result_tuple - - except subprocess.TimeoutExpired: - result_tuple = (False, "Node.js version check timed out") - self.validation_cache[cache_key] = result_tuple - return result_tuple - except FileNotFoundError: - help_msg = self.get_installation_help("node") - result_tuple = (False, f"Node.js not found in PATH{help_msg}") - self.validation_cache[cache_key] = result_tuple - return result_tuple - except Exception as e: - result_tuple = (False, f"Could not check Node.js version: {e}") - self.validation_cache[cache_key] = result_tuple - return result_tuple - - def check_claude_cli(self, min_version: Optional[str] = None) -> Tuple[bool, str]: - """ - Check Claude CLI installation and version - - Args: - min_version: Minimum required Claude CLI version (optional) - - Returns: - Tuple of (success: bool, message: str) - """ - cache_key = f"claude_cli_{min_version}" - if cache_key in self.validation_cache: - return self.validation_cache[cache_key] - - try: - # Check if claude is installed - use shell=True on Windows for better PATH resolution - result = subprocess.run( - ["claude", "--version"], - capture_output=True, - text=True, - timeout=10, - shell=(sys.platform == "win32"), - ) - - if result.returncode != 0: - help_msg = self.get_installation_help("claude_cli") - result_tuple = (False, f"Claude CLI not found in PATH{help_msg}") - self.validation_cache[cache_key] = result_tuple - return result_tuple - - # Parse version from output - version_output = result.stdout.strip() - version_match = re.search(r"(\d+\.\d+\.\d+)", version_output) - - if not version_match: - result_tuple = (True, "Claude CLI found (version format unknown)") - self.validation_cache[cache_key] = result_tuple - return result_tuple - - current_version = version_match.group(1) - - # Check minimum version if specified - if min_version and version.parse(current_version) < version.parse( - min_version - ): - result_tuple = ( - False, - f"Claude CLI {min_version}+ required, found {current_version}", - ) - self.validation_cache[cache_key] = result_tuple - return result_tuple - - result_tuple = (True, f"Claude CLI {current_version} found") - self.validation_cache[cache_key] = result_tuple - return result_tuple - - except subprocess.TimeoutExpired: - result_tuple = (False, "Claude CLI version check timed out") - self.validation_cache[cache_key] = result_tuple - return result_tuple - except FileNotFoundError: - help_msg = self.get_installation_help("claude_cli") - result_tuple = (False, f"Claude CLI not found in PATH{help_msg}") - self.validation_cache[cache_key] = result_tuple - return result_tuple - except Exception as e: - result_tuple = (False, f"Could not check Claude CLI: {e}") - self.validation_cache[cache_key] = result_tuple - return result_tuple - - def check_external_tool( - self, tool_name: str, command: str, min_version: Optional[str] = None - ) -> Tuple[bool, str]: - """ - Check external tool availability and version - - Args: - tool_name: Display name of tool - command: Command to check version - min_version: Minimum required version (optional) - - Returns: - Tuple of (success: bool, message: str) - """ - cache_key = f"tool_{tool_name}_{command}_{min_version}" - if cache_key in self.validation_cache: - return self.validation_cache[cache_key] - - try: - # Split command into parts - cmd_parts = command.split() - - result = subprocess.run( - cmd_parts, - capture_output=True, - text=True, - timeout=10, - shell=(sys.platform == "win32"), - ) - - if result.returncode != 0: - result_tuple = (False, f"{tool_name} not found or command failed") - self.validation_cache[cache_key] = result_tuple - return result_tuple - - # Extract version if min_version specified - if min_version: - version_output = result.stdout + result.stderr - version_match = re.search(r"(\d+\.\d+(?:\.\d+)?)", version_output) - - if version_match: - current_version = version_match.group(1) - - if version.parse(current_version) < version.parse(min_version): - result_tuple = ( - False, - f"{tool_name} {min_version}+ required, found {current_version}", - ) - self.validation_cache[cache_key] = result_tuple - return result_tuple - - result_tuple = (True, f"{tool_name} {current_version} found") - self.validation_cache[cache_key] = result_tuple - return result_tuple - else: - result_tuple = (True, f"{tool_name} found (version unknown)") - self.validation_cache[cache_key] = result_tuple - return result_tuple - else: - result_tuple = (True, f"{tool_name} found") - self.validation_cache[cache_key] = result_tuple - return result_tuple - - except subprocess.TimeoutExpired: - result_tuple = (False, f"{tool_name} check timed out") - self.validation_cache[cache_key] = result_tuple - return result_tuple - except FileNotFoundError: - result_tuple = (False, f"{tool_name} not found in PATH") - self.validation_cache[cache_key] = result_tuple - return result_tuple - except Exception as e: - result_tuple = (False, f"Could not check {tool_name}: {e}") - self.validation_cache[cache_key] = result_tuple - return result_tuple - - def check_disk_space(self, path: Path, required_mb: int = 500) -> Tuple[bool, str]: - """ - Check available disk space - - Args: - path: Path to check (file or directory) - required_mb: Required free space in MB - - Returns: - Tuple of (success: bool, message: str) - """ - cache_key = f"disk_{path}_{required_mb}" - if cache_key in self.validation_cache: - return self.validation_cache[cache_key] - - try: - # Get parent directory if path is a file - check_path = path.parent if path.is_file() else path - - # Get disk usage - stat_result = shutil.disk_usage(check_path) - free_mb = stat_result.free / (1024 * 1024) - - if free_mb < required_mb: - result = ( - False, - f"Insufficient disk space: {free_mb:.1f}MB free, {required_mb}MB required", - ) - else: - result = (True, f"Sufficient disk space: {free_mb:.1f}MB free") - - self.validation_cache[cache_key] = result - return result - - except Exception as e: - result = (False, f"Could not check disk space: {e}") - self.validation_cache[cache_key] = result - return result - - def check_write_permissions(self, path: Path) -> Tuple[bool, str]: - """ - Check write permissions for path - - Args: - path: Path to check - - Returns: - Tuple of (success: bool, message: str) - """ - cache_key = f"write_{path}" - if cache_key in self.validation_cache: - return self.validation_cache[cache_key] - - try: - # Create parent directories if needed - if not path.exists(): - path.mkdir(parents=True, exist_ok=True) - - # Test write access - test_file = path / ".write_test" - test_file.touch() - test_file.unlink() - - result = (True, f"Write access confirmed for {path}") - self.validation_cache[cache_key] = result - return result - - except Exception as e: - result = (False, f"No write access to {path}: {e}") - self.validation_cache[cache_key] = result - return result - - def validate_requirements( - self, requirements: Dict[str, Any] - ) -> Tuple[bool, List[str]]: - """ - Validate all system requirements - - Args: - requirements: Requirements configuration dict - - Returns: - Tuple of (all_passed: bool, error_messages: List[str]) - """ - errors = [] - - # Check Python requirements - if "python" in requirements: - python_req = requirements["python"] - success, message = self.check_python( - python_req["min_version"], python_req.get("max_version") - ) - if not success: - errors.append(f"Python: {message}") - - # Check Node.js requirements - if "node" in requirements: - node_req = requirements["node"] - success, message = self.check_node( - node_req["min_version"], node_req.get("max_version") - ) - if not success: - errors.append(f"Node.js: {message}") - - # Check disk space - if "disk_space_mb" in requirements: - success, message = self.check_disk_space( - get_home_directory(), requirements["disk_space_mb"] - ) - if not success: - errors.append(f"Disk space: {message}") - - # Check external tools - if "external_tools" in requirements: - for tool_name, tool_req in requirements["external_tools"].items(): - # Skip optional tools that fail - is_optional = tool_req.get("optional", False) - - success, message = self.check_external_tool( - tool_name, tool_req["command"], tool_req.get("min_version") - ) - - if not success and not is_optional: - errors.append(f"{tool_name}: {message}") - - return len(errors) == 0, errors - - def validate_component_requirements( - self, component_names: List[str], all_requirements: Dict[str, Any] - ) -> Tuple[bool, List[str]]: - """ - Validate requirements for specific components - - Args: - component_names: List of component names to validate - all_requirements: Full requirements configuration - - Returns: - Tuple of (all_passed: bool, error_messages: List[str]) - """ - errors = [] - - # Start with base requirements - base_requirements = { - "python": all_requirements.get("python", {}), - "disk_space_mb": all_requirements.get("disk_space_mb", 500), - } - - # Add conditional requirements based on components - external_tools = {} - - # Check if any component needs Node.js - node_components = [] - for component in component_names: - # This would be enhanced with actual component metadata - if component in ["mcp"]: # MCP component needs Node.js - node_components.append(component) - - if node_components and "node" in all_requirements: - base_requirements["node"] = all_requirements["node"] - - # Add external tools needed by components - if "external_tools" in all_requirements: - for tool_name, tool_req in all_requirements["external_tools"].items(): - required_for = tool_req.get("required_for", []) - - # Check if any of our components need this tool - if any(comp in required_for for comp in component_names): - external_tools[tool_name] = tool_req - - if external_tools: - base_requirements["external_tools"] = external_tools - - # Validate consolidated requirements - return self.validate_requirements(base_requirements) - - def get_system_info(self) -> Dict[str, Any]: - """ - Get comprehensive system information - - Returns: - Dict with system information - """ - info = { - "platform": sys.platform, - "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", - "python_executable": sys.executable, - } - - # Add Node.js info if available - node_success, node_msg = self.check_node() - info["node_available"] = node_success - if node_success: - info["node_message"] = node_msg - - # Add Claude CLI info if available - claude_success, claude_msg = self.check_claude_cli() - info["claude_cli_available"] = claude_success - if claude_success: - info["claude_cli_message"] = claude_msg - - # Add disk space info - try: - home_path = get_home_directory() - stat_result = shutil.disk_usage(home_path) - info["disk_space"] = { - "total_gb": stat_result.total / (1024**3), - "free_gb": stat_result.free / (1024**3), - "used_gb": (stat_result.total - stat_result.free) / (1024**3), - } - except Exception: - info["disk_space"] = {"error": "Could not determine disk space"} - - return info - - def get_platform(self) -> str: - """ - Get current platform for installation commands - - Returns: - Platform string (linux, darwin, win32) - """ - return sys.platform - - def load_installation_commands(self) -> Dict[str, Any]: - """ - Load installation commands from requirements configuration - - Returns: - Installation commands dict - """ - try: - from ..services.config import ConfigService - from .. import DATA_DIR - - config_manager = ConfigService(DATA_DIR) - requirements = config_manager.load_requirements() - return requirements.get("installation_commands", {}) - except Exception: - return {} - - def get_installation_help( - self, tool_name: str, platform: Optional[str] = None - ) -> str: - """ - Get installation help for a specific tool - - Args: - tool_name: Name of tool to get help for - platform: Target platform (auto-detected if None) - - Returns: - Installation help string - """ - if platform is None: - platform = self.get_platform() - - commands = self.load_installation_commands() - tool_commands = commands.get(tool_name, {}) - - if not tool_commands: - return f"No installation instructions available for {tool_name}" - - # Get platform-specific command or fallback to 'all' - install_cmd = tool_commands.get(platform, tool_commands.get("all", "")) - description = tool_commands.get("description", "") - - if install_cmd: - help_text = f"\nπŸ’‘ Installation Help for {tool_name}:\n" - if description: - help_text += f" {description}\n" - help_text += f" Command: {install_cmd}\n" - return help_text - - return f"No installation instructions available for {tool_name} on {platform}" - - def diagnose_system(self) -> Dict[str, Any]: - """ - Perform comprehensive system diagnostics - - Returns: - Diagnostic information dict - """ - diagnostics = { - "platform": self.get_platform(), - "checks": {}, - "issues": [], - "recommendations": [], - } - - # Check Python - python_success, python_msg = self.check_python() - diagnostics["checks"]["python"] = { - "status": "pass" if python_success else "fail", - "message": python_msg, - } - if not python_success: - diagnostics["issues"].append("Python version issue") - diagnostics["recommendations"].append(self.get_installation_help("python")) - - # Check Node.js - node_success, node_msg = self.check_node() - diagnostics["checks"]["node"] = { - "status": "pass" if node_success else "fail", - "message": node_msg, - } - if not node_success: - diagnostics["issues"].append("Node.js not found or version issue") - diagnostics["recommendations"].append(self.get_installation_help("node")) - - # Check Claude CLI - claude_success, claude_msg = self.check_claude_cli() - diagnostics["checks"]["claude_cli"] = { - "status": "pass" if claude_success else "fail", - "message": claude_msg, - } - if not claude_success: - diagnostics["issues"].append("Claude CLI not found") - diagnostics["recommendations"].append( - self.get_installation_help("claude_cli") - ) - - # Check disk space - disk_success, disk_msg = self.check_disk_space(get_home_directory()) - diagnostics["checks"]["disk_space"] = { - "status": "pass" if disk_success else "fail", - "message": disk_msg, - } - if not disk_success: - diagnostics["issues"].append("Insufficient disk space") - - # Check common PATH issues - self._diagnose_path_issues(diagnostics) - - return diagnostics - - def _diagnose_path_issues(self, diagnostics: Dict[str, Any]) -> None: - """Add PATH-related diagnostics""" - path_issues = [] - - # Check if tools are in PATH, with alternatives for some tools - tool_checks = [ - # For Python, check if either python3 OR python is available - (["python3", "python"], "Python (python3 or python)"), - (["node"], "Node.js"), - (["npm"], "npm"), - (["claude"], "Claude CLI"), - ] - - for tool_alternatives, display_name in tool_checks: - tool_found = False - for tool in tool_alternatives: - try: - result = subprocess.run( - ["which" if sys.platform != "win32" else "where", tool], - capture_output=True, - text=True, - timeout=5, - shell=(sys.platform == "win32"), - ) - if result.returncode == 0: - tool_found = True - break - except Exception: - continue - - if not tool_found: - # Only report as missing if none of the alternatives were found - if len(tool_alternatives) > 1: - path_issues.append(f"{display_name} not found in PATH") - else: - path_issues.append(f"{tool_alternatives[0]} not found in PATH") - - if path_issues: - diagnostics["issues"].extend(path_issues) - diagnostics["recommendations"].append( - "\nπŸ’‘ PATH Issue Help:\n" - " Some tools may not be in your PATH. Try:\n" - " - Restart your terminal after installation\n" - " - Check your shell configuration (.bashrc, .zshrc)\n" - " - Use full paths to tools if needed\n" - ) - - def clear_cache(self) -> None: - """Clear validation cache""" - self.validation_cache.clear() diff --git a/setup/data/__init__.py b/setup/data/__init__.py deleted file mode 100644 index 07e7621..0000000 --- a/setup/data/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -SuperClaude Data Module -Static configuration and data files -""" diff --git a/setup/data/features.json b/setup/data/features.json deleted file mode 100644 index bbbb303..0000000 --- a/setup/data/features.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "components": { - "core": { - "name": "core", - "version": "4.1.5", - "description": "SuperClaude framework documentation and core files", - "category": "core", - "dependencies": [], - "enabled": true, - "required_tools": [] - }, - "commands": { - "name": "commands", - "version": "4.1.5", - "description": "SuperClaude slash command definitions", - "category": "commands", - "dependencies": ["core"], - "enabled": true, - "required_tools": [] - }, - "mcp": { - "name": "mcp", - "version": "4.1.5", - "description": "MCP server configuration management via .claude.json", - "category": "integration", - "dependencies": ["core"], - "enabled": true, - "required_tools": [] - }, - "modes": { - "name": "modes", - "version": "4.1.5", - "description": "SuperClaude behavioral modes (Brainstorming, Introspection, Task Management, Token Efficiency)", - "category": "modes", - "dependencies": ["core"], - "enabled": true, - "required_tools": [] - }, - "agents": { - "name": "agents", - "version": "4.1.5", - "description": "14 specialized AI agents with domain expertise and intelligent routing", - "category": "agents", - "dependencies": ["core"], - "enabled": true, - "required_tools": [] - } - } -} \ No newline at end of file diff --git a/setup/data/requirements.json b/setup/data/requirements.json deleted file mode 100644 index b6133fb..0000000 --- a/setup/data/requirements.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "python": { - "min_version": "3.8.0" - }, - "node": { - "min_version": "16.0.0", - "required_for": ["mcp"] - }, - "disk_space_mb": 500, - "external_tools": { - "claude_cli": { - "command": "claude --version", - "min_version": "0.1.0", - "required_for": ["mcp"], - "optional": false - }, - "git": { - "command": "git --version", - "min_version": "2.0.0", - "required_for": ["development"], - "optional": true - } - }, - "installation_commands": { - "python": { - "linux": "sudo apt update && sudo apt install python3 python3-pip", - "darwin": "brew install python3", - "win32": "Download Python from https://python.org/downloads/", - "description": "Python 3.8+ is required for SuperClaude framework" - }, - "node": { - "linux": "sudo apt update && sudo apt install nodejs npm", - "darwin": "brew install node", - "win32": "Download Node.js from https://nodejs.org/", - "description": "Node.js 16+ is required for MCP server integration" - }, - "claude_cli": { - "all": "Visit https://claude.ai/code for installation instructions", - "description": "Claude CLI is required for MCP server management" - }, - "git": { - "linux": "sudo apt update && sudo apt install git", - "darwin": "brew install git", - "win32": "Download Git from https://git-scm.com/downloads", - "description": "Git is recommended for development workflows" - }, - "npm": { - "linux": "sudo apt update && sudo apt install npm", - "darwin": "npm is included with Node.js", - "win32": "npm is included with Node.js", - "description": "npm is required for installing MCP servers" - } - } -} \ No newline at end of file diff --git a/setup/services/__init__.py b/setup/services/__init__.py deleted file mode 100644 index 9b5188e..0000000 --- a/setup/services/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -SuperClaude Services Module -Business logic services for the SuperClaude installation system -""" - -from .claude_md import CLAUDEMdService -from .config import ConfigService -from .files import FileService -from .settings import SettingsService - -__all__ = ["CLAUDEMdService", "ConfigService", "FileService", "SettingsService"] diff --git a/setup/services/claude_md.py b/setup/services/claude_md.py deleted file mode 100644 index c493e87..0000000 --- a/setup/services/claude_md.py +++ /dev/null @@ -1,334 +0,0 @@ -""" -CLAUDE.md Manager for preserving user customizations while managing framework imports -""" - -import re -from pathlib import Path -from typing import List, Set, Dict, Optional -from ..utils.logger import get_logger - - -class CLAUDEMdService: - """Manages CLAUDE.md file updates while preserving user customizations""" - - def __init__(self, install_dir: Path): - """ - Initialize CLAUDEMdService - - Args: - install_dir: Installation directory (typically ~/.claude/superclaude) - """ - self.install_dir = install_dir - # CLAUDE.md is always in parent directory (~/.claude/) - self.claude_md_path = install_dir.parent / "CLAUDE.md" - self.logger = get_logger() - - def read_existing_imports(self) -> Set[str]: - """ - Parse CLAUDE.md for existing @import statements - - Returns: - Set of already imported filenames (without @) - """ - existing_imports = set() - - if not self.claude_md_path.exists(): - return existing_imports - - try: - with open(self.claude_md_path, "r", encoding="utf-8") as f: - content = f.read() - - # Find all @import statements using regex - # Supports both @superclaude/file.md and @file.md (legacy) - import_pattern = r"^@(?:superclaude/)?([^\s\n]+\.md)\s*$" - matches = re.findall(import_pattern, content, re.MULTILINE) - existing_imports.update(matches) - - self.logger.debug(f"Found existing imports: {existing_imports}") - - except Exception as e: - self.logger.warning(f"Could not read existing CLAUDE.md imports: {e}") - - return existing_imports - - def read_existing_content(self) -> str: - """ - Read existing CLAUDE.md content - - Returns: - Existing content or empty string if file doesn't exist - """ - if not self.claude_md_path.exists(): - return "" - - try: - with open(self.claude_md_path, "r", encoding="utf-8") as f: - return f.read() - except Exception as e: - self.logger.warning(f"Could not read existing CLAUDE.md: {e}") - return "" - - def extract_user_content(self, content: str) -> str: - """ - Extract user content (everything before framework imports section) - - Args: - content: Full CLAUDE.md content - - Returns: - User content without framework imports - """ - # Look for framework imports section marker - framework_marker = "# ===================================================\n# SuperClaude Framework Components" - - if framework_marker in content: - user_content = content.split(framework_marker)[0].rstrip() - else: - # If no framework section exists, preserve all content - user_content = content.rstrip() - - return user_content - - def organize_imports_by_category( - self, files_by_category: Dict[str, List[str]] - ) -> str: - """ - Organize imports into categorized sections - - Args: - files_by_category: Dict mapping category names to lists of files - - Returns: - Formatted import sections - """ - if not files_by_category: - return "" - - sections = [] - - # Framework imports section header - sections.append("# ===================================================") - sections.append("# SuperClaude Framework Components") - sections.append("# ===================================================") - sections.append("") - - # Add each category - for category, files in files_by_category.items(): - if files: - sections.append(f"# {category}") - for file in sorted(files): - # Add superclaude/ prefix for all imports - sections.append(f"@superclaude/{file}") - sections.append("") - - return "\n".join(sections) - - def add_imports(self, files: List[str], category: str = "Framework") -> bool: - """ - Add new imports with duplicate checking and user content preservation - - Args: - files: List of filenames to import - category: Category name for organizing imports - - Returns: - True if successful, False otherwise - """ - try: - # Check if CLAUDE.md exists (DO NOT create it) - if not self.ensure_claude_md_exists(): - self.logger.info("Skipping CLAUDE.md update (file does not exist)") - return False - - # Read existing content and imports - existing_content = self.read_existing_content() - existing_imports = self.read_existing_imports() - - # Filter out files already imported - new_files = [f for f in files if f not in existing_imports] - - if not new_files: - self.logger.info("All files already imported, no changes needed") - return True - - self.logger.info( - f"Adding {len(new_files)} new imports to category '{category}': {new_files}" - ) - - # Extract user content (preserve everything before framework section) - user_content = self.extract_user_content(existing_content) - - # Parse existing framework imports by category - existing_framework_imports = self._parse_existing_framework_imports( - existing_content - ) - - # Add new files to the specified category - if category not in existing_framework_imports: - existing_framework_imports[category] = [] - existing_framework_imports[category].extend(new_files) - - # Build new content - new_content_parts = [] - - # Add user content - if user_content.strip(): - new_content_parts.append(user_content) - new_content_parts.append("") # Add blank line before framework section - - # Add organized framework imports - framework_section = self.organize_imports_by_category( - existing_framework_imports - ) - if framework_section: - new_content_parts.append(framework_section) - - # Write updated content - new_content = "\n".join(new_content_parts) - - with open(self.claude_md_path, "w", encoding="utf-8") as f: - f.write(new_content) - - self.logger.success(f"Updated CLAUDE.md with {len(new_files)} new imports") - return True - - except Exception as e: - self.logger.error(f"Failed to update CLAUDE.md: {e}") - return False - - def _parse_existing_framework_imports(self, content: str) -> Dict[str, List[str]]: - """ - Parse existing framework imports organized by category - - Args: - content: Full CLAUDE.md content - - Returns: - Dict mapping category names to lists of imported files - """ - imports_by_category = {} - - # Look for framework imports section - framework_marker = "# ===================================================\n# SuperClaude Framework Components" - - if framework_marker not in content: - return imports_by_category - - # Extract framework section - framework_section = ( - content.split(framework_marker)[1] if framework_marker in content else "" - ) - - # Parse categories and imports - lines = framework_section.split("\n") - current_category = None - - for line in lines: - line = line.strip() - - # Skip section header lines and empty lines - if line.startswith("# ===") or not line: - continue - - # Category header (starts with # but not the section divider) - if line.startswith("# ") and not line.startswith("# ==="): - current_category = line[2:].strip() # Remove "# " - if current_category not in imports_by_category: - imports_by_category[current_category] = [] - - # Import line (starts with @) - elif line.startswith("@") and current_category: - import_file = line[1:].strip() # Remove "@" - # Remove superclaude/ prefix if present (normalize to filename only) - if import_file.startswith("superclaude/"): - import_file = import_file[len("superclaude/"):] - if import_file not in imports_by_category[current_category]: - imports_by_category[current_category].append(import_file) - - return imports_by_category - - def ensure_claude_md_exists(self) -> bool: - """ - Check if CLAUDE.md exists (DO NOT create it - Claude Code pure file) - - Returns: - True if CLAUDE.md exists, False otherwise - """ - if self.claude_md_path.exists(): - return True - - # CLAUDE.md is a Claude Code pure file - NEVER create or modify it - self.logger.warning( - f"⚠️ CLAUDE.md not found at {self.claude_md_path}\n" - f" SuperClaude will NOT create this file automatically.\n" - f" Please manually add the following to your CLAUDE.md:\n\n" - f" # SuperClaude Framework Components\n" - f" @superclaude/FLAGS.md\n" - f" @superclaude/PRINCIPLES.md\n" - f" @superclaude/RULES.md\n" - f" (and other SuperClaude components)\n" - ) - return False - - def remove_imports(self, files: List[str]) -> bool: - """ - Remove specific imports from CLAUDE.md - - Args: - files: List of filenames to remove from imports - - Returns: - True if successful, False otherwise - """ - try: - if not self.claude_md_path.exists(): - return True # Nothing to remove - - existing_content = self.read_existing_content() - user_content = self.extract_user_content(existing_content) - existing_framework_imports = self._parse_existing_framework_imports( - existing_content - ) - - # Remove files from all categories - removed_any = False - for category, category_files in existing_framework_imports.items(): - for file in files: - if file in category_files: - category_files.remove(file) - removed_any = True - - # Remove empty categories - existing_framework_imports = { - k: v for k, v in existing_framework_imports.items() if v - } - - if not removed_any: - return True # Nothing was removed - - # Rebuild content - new_content_parts = [] - - if user_content.strip(): - new_content_parts.append(user_content) - new_content_parts.append("") - - framework_section = self.organize_imports_by_category( - existing_framework_imports - ) - if framework_section: - new_content_parts.append(framework_section) - - # Write updated content - new_content = "\n".join(new_content_parts) - - with open(self.claude_md_path, "w", encoding="utf-8") as f: - f.write(new_content) - - self.logger.info(f"Removed {len(files)} imports from CLAUDE.md") - return True - - except Exception as e: - self.logger.error(f"Failed to remove imports from CLAUDE.md: {e}") - return False diff --git a/setup/services/config.py b/setup/services/config.py deleted file mode 100644 index fde8904..0000000 --- a/setup/services/config.py +++ /dev/null @@ -1,365 +0,0 @@ -""" -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 ConfigService: - """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: {str(e)}") - - 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: {str(e)}") - - 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 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 diff --git a/setup/services/files.py b/setup/services/files.py deleted file mode 100644 index 573bb6d..0000000 --- a/setup/services/files.py +++ /dev/null @@ -1,442 +0,0 @@ -""" -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 FileService: - """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], - } diff --git a/setup/services/settings.py b/setup/services/settings.py deleted file mode 100644 index 0119118..0000000 --- a/setup/services/settings.py +++ /dev/null @@ -1,579 +0,0 @@ -""" -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 SettingsService: - """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" - - # Always use ~/.claude/ for metadata (unified location) - # This ensures all components share the same metadata regardless of install_dir - from ..utils.paths import get_home_directory - self.metadata_root = get_home_directory() / ".claude" - self.metadata_file = self.metadata_root / ".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) - """ - # Migrate from old location if needed - self._migrate_old_metadata() - - 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 _migrate_old_metadata(self) -> None: - """ - Migrate metadata from old location (~/.claude/superclaude/) to unified location (~/.claude/) - This handles the transition from split metadata files to a single unified file. - """ - # Old metadata location (in superclaude subdirectory) - old_metadata_file = self.metadata_root / "superclaude" / ".superclaude-metadata.json" - - # If unified metadata already exists, skip migration - if self.metadata_file.exists(): - return - - # If old metadata exists, merge it into the new location - if old_metadata_file.exists(): - try: - with open(old_metadata_file, "r", encoding="utf-8") as f: - old_metadata = json.load(f) - - # Load current metadata (if any) - current_metadata = {} - if self.metadata_file.exists(): - with open(self.metadata_file, "r", encoding="utf-8") as f: - current_metadata = json.load(f) - - # Deep merge old into current - merged_metadata = self._deep_merge(current_metadata, old_metadata) - - # Save to unified location - self.save_metadata(merged_metadata) - - # Optionally backup old file (don't delete yet for safety) - backup_file = old_metadata_file.parent / ".superclaude-metadata.json.migrated" - shutil.copy2(old_metadata_file, backup_file) - - except Exception as e: - # Log but don't fail - old metadata migration is optional - pass - - 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 diff --git a/setup/utils/__init__.py b/setup/utils/__init__.py deleted file mode 100644 index d6190ca..0000000 --- a/setup/utils/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Utility modules for SuperClaude installation system - -Note: UI utilities (ProgressBar, Menu, confirm, Colors) have been removed. -The new CLI uses typer + rich natively via superclaude/cli/ -""" - -from .logger import Logger -from .security import SecurityValidator - -__all__ = ["Logger", "SecurityValidator"] diff --git a/setup/utils/environment.py b/setup/utils/environment.py deleted file mode 100644 index 81c6013..0000000 --- a/setup/utils/environment.py +++ /dev/null @@ -1,535 +0,0 @@ -""" -Environment variable management for SuperClaude -Cross-platform utilities for setting up persistent environment variables -""" - -import os -import sys -import subprocess -import json -from pathlib import Path -from typing import Dict, Optional -from datetime import datetime -from .ui import display_info, display_success, display_warning, Colors -from .logger import get_logger -from .paths import get_home_directory - - -def _get_env_tracking_file() -> Path: - """Get path to environment variable tracking file""" - from .. import DEFAULT_INSTALL_DIR - - install_dir = get_home_directory() / ".claude" - install_dir.mkdir(exist_ok=True) - return install_dir / "superclaude_env_vars.json" - - -def _load_env_tracking() -> Dict[str, Dict[str, str]]: - """Load environment variable tracking data""" - tracking_file = _get_env_tracking_file() - - try: - if tracking_file.exists(): - with open(tracking_file, "r") as f: - return json.load(f) - except Exception as e: - get_logger().warning(f"Could not load environment tracking: {e}") - - return {} - - -def _save_env_tracking(tracking_data: Dict[str, Dict[str, str]]) -> bool: - """Save environment variable tracking data""" - tracking_file = _get_env_tracking_file() - - try: - with open(tracking_file, "w") as f: - json.dump(tracking_data, f, indent=2) - return True - except Exception as e: - get_logger().error(f"Could not save environment tracking: {e}") - return False - - -def _add_env_tracking(env_vars: Dict[str, str]) -> None: - """Add environment variables to tracking""" - if not env_vars: - return - - tracking_data = _load_env_tracking() - timestamp = datetime.now().isoformat() - - for env_var, value in env_vars.items(): - tracking_data[env_var] = { - "set_by": "superclaude", - "timestamp": timestamp, - "value_hash": str(hash(value)), # Store hash, not actual value for security - } - - _save_env_tracking(tracking_data) - get_logger().info(f"Added {len(env_vars)} environment variables to tracking") - - -def _remove_env_tracking(env_vars: list) -> None: - """Remove environment variables from tracking""" - if not env_vars: - return - - tracking_data = _load_env_tracking() - - for env_var in env_vars: - if env_var in tracking_data: - del tracking_data[env_var] - - _save_env_tracking(tracking_data) - get_logger().info(f"Removed {len(env_vars)} environment variables from tracking") - - -def detect_shell_config() -> Optional[Path]: - """ - Detect user's shell configuration file - - Returns: - Path to the shell configuration file, or None if not found - """ - home = get_home_directory() - - # Check in order of preference - configs = [ - home / ".zshrc", # Zsh (Mac default) - home / ".bashrc", # Bash - home / ".profile", # Generic shell profile - home / ".bash_profile", # Mac Bash profile - ] - - for config in configs: - if config.exists(): - return config - - # Default to .bashrc if none exist (will be created) - return home / ".bashrc" - - -def setup_environment_variables(api_keys: Dict[str, str]) -> bool: - """ - Set up environment variables across platforms - - Args: - api_keys: Dictionary of environment variable names to values - - Returns: - True if all variables were set successfully, False otherwise - """ - logger = get_logger() - success = True - - if not api_keys: - return True - - print(f"\n{Colors.BLUE}[INFO] Setting up environment variables...{Colors.RESET}") - - for env_var, value in api_keys.items(): - try: - # Set for current session - os.environ[env_var] = value - - if os.name == "nt": # Windows - # Use setx for persistent user variable - result = subprocess.run( - ["setx", env_var, value], capture_output=True, text=True - ) - if result.returncode != 0: - display_warning( - f"Could not set {env_var} persistently: {result.stderr.strip()}" - ) - success = False - else: - logger.info( - f"Windows environment variable {env_var} set persistently" - ) - else: # Unix-like systems - shell_config = detect_shell_config() - - # Check if the export already exists - export_line = f'export {env_var}="{value}"' - - try: - with open(shell_config, "r") as f: - content = f.read() - - # Check if this environment variable is already set - if f"export {env_var}=" in content: - # Variable exists - don't duplicate - logger.info( - f"Environment variable {env_var} already exists in {shell_config.name}" - ) - else: - # Append export to shell config - with open(shell_config, "a") as f: - f.write(f"\n# SuperClaude API Key\n{export_line}\n") - - display_info(f"Added {env_var} to {shell_config.name}") - logger.info(f"Added {env_var} to {shell_config}") - - except Exception as e: - display_warning(f"Could not update {shell_config.name}: {e}") - success = False - - logger.info( - f"Environment variable {env_var} configured for current session" - ) - - except Exception as e: - logger.error(f"Failed to set {env_var}: {e}") - display_warning(f"Failed to set {env_var}: {e}") - success = False - - if success: - # Add to tracking - _add_env_tracking(api_keys) - - display_success("Environment variables configured successfully") - if os.name != "nt": - display_info( - "Restart your terminal or run 'source ~/.bashrc' to apply changes" - ) - else: - display_info( - "New environment variables will be available in new terminal sessions" - ) - else: - display_warning("Some environment variables could not be set persistently") - display_info("You can set them manually or check the logs for details") - - return success - - -def validate_environment_setup(env_vars: Dict[str, str]) -> bool: - """ - Validate that environment variables are properly set - - Args: - env_vars: Dictionary of environment variable names to expected values - - Returns: - True if all variables are set correctly, False otherwise - """ - logger = get_logger() - all_valid = True - - for env_var, expected_value in env_vars.items(): - current_value = os.environ.get(env_var) - - if current_value is None: - logger.warning(f"Environment variable {env_var} is not set") - all_valid = False - elif current_value != expected_value: - logger.warning(f"Environment variable {env_var} has unexpected value") - all_valid = False - else: - logger.info(f"Environment variable {env_var} is set correctly") - - return all_valid - - -def get_shell_name() -> str: - """ - Get the name of the current shell - - Returns: - Name of the shell (e.g., 'bash', 'zsh', 'fish') - """ - shell_path = os.environ.get("SHELL", "") - if shell_path: - return Path(shell_path).name - return "unknown" - - -def get_superclaude_environment_variables() -> Dict[str, str]: - """ - Get environment variables that were set by SuperClaude - - Returns: - Dictionary of environment variable names to their current values - """ - # Load tracking data to get SuperClaude-managed variables - tracking_data = _load_env_tracking() - - found_vars = {} - for env_var, metadata in tracking_data.items(): - if metadata.get("set_by") == "superclaude": - value = os.environ.get(env_var) - if value: - found_vars[env_var] = value - - # Fallback: check known SuperClaude API key environment variables - # (for backwards compatibility with existing installations) - known_superclaude_env_vars = [ - "TWENTYFIRST_API_KEY", # Magic server - "MORPH_API_KEY", # Morphllm server - ] - - for env_var in known_superclaude_env_vars: - if env_var not in found_vars: - value = os.environ.get(env_var) - if value: - found_vars[env_var] = value - - return found_vars - - -def cleanup_environment_variables( - env_vars_to_remove: Dict[str, str], create_restore_script: bool = True -) -> bool: - """ - Safely remove environment variables with backup and restore options - - Args: - env_vars_to_remove: Dictionary of environment variable names to remove - create_restore_script: Whether to create a script to restore the variables - - Returns: - True if cleanup was successful, False otherwise - """ - logger = get_logger() - success = True - - if not env_vars_to_remove: - return True - - # Create restore script if requested - if create_restore_script: - restore_script_path = _create_restore_script(env_vars_to_remove) - if restore_script_path: - display_info(f"Created restore script: {restore_script_path}") - else: - display_warning("Could not create restore script") - - print(f"\n{Colors.BLUE}[INFO] Removing environment variables...{Colors.RESET}") - - for env_var, value in env_vars_to_remove.items(): - try: - # Remove from current session - if env_var in os.environ: - del os.environ[env_var] - logger.info(f"Removed {env_var} from current session") - - if os.name == "nt": # Windows - # Remove persistent user variable using reg command - result = subprocess.run( - ["reg", "delete", "HKCU\\Environment", "/v", env_var, "/f"], - capture_output=True, - text=True, - ) - if result.returncode != 0: - # Variable might not exist in registry, which is fine - logger.debug( - f"Registry deletion for {env_var}: {result.stderr.strip()}" - ) - else: - logger.info(f"Removed {env_var} from Windows registry") - else: # Unix-like systems - shell_config = detect_shell_config() - if shell_config and shell_config.exists(): - _remove_env_var_from_shell_config(shell_config, env_var) - - except Exception as e: - logger.error(f"Failed to remove {env_var}: {e}") - display_warning(f"Could not remove {env_var}: {e}") - success = False - - if success: - # Remove from tracking - _remove_env_tracking(list(env_vars_to_remove.keys())) - - display_success("Environment variables removed successfully") - if os.name != "nt": - display_info( - "Restart your terminal or source your shell config to apply changes" - ) - else: - display_info("Changes will take effect in new terminal sessions") - else: - display_warning("Some environment variables could not be removed") - - return success - - -def _create_restore_script(env_vars: Dict[str, str]) -> Optional[Path]: - """Create a script to restore environment variables""" - try: - home = get_home_directory() - if os.name == "nt": # Windows - script_path = home / "restore_superclaude_env.bat" - with open(script_path, "w") as f: - f.write("@echo off\n") - f.write("REM SuperClaude Environment Variable Restore Script\n") - f.write("REM Generated during uninstall\n\n") - for env_var, value in env_vars.items(): - f.write(f'setx {env_var} "{value}"\n') - f.write("\necho Environment variables restored\n") - f.write("pause\n") - else: # Unix-like - script_path = home / "restore_superclaude_env.sh" - with open(script_path, "w") as f: - f.write("#!/bin/bash\n") - f.write("# SuperClaude Environment Variable Restore Script\n") - f.write("# Generated during uninstall\n\n") - shell_config = detect_shell_config() - for env_var, value in env_vars.items(): - f.write(f'export {env_var}="{value}"\n') - if shell_config: - f.write( - f"echo 'export {env_var}=\"{value}\"' >> {shell_config}\n" - ) - f.write("\necho 'Environment variables restored'\n") - - # Make script executable - script_path.chmod(0o755) - - return script_path - - except Exception as e: - get_logger().error(f"Failed to create restore script: {e}") - return None - - -def _remove_env_var_from_shell_config(shell_config: Path, env_var: str) -> bool: - """Remove environment variable export from shell configuration file""" - try: - # Read current content - with open(shell_config, "r") as f: - lines = f.readlines() - - # Filter out lines that export this variable - filtered_lines = [] - skip_next_blank = False - - for line in lines: - # Check if this line exports our variable - if f"export {env_var}=" in line or line.strip() == f"# SuperClaude API Key": - skip_next_blank = True - continue - - # Skip blank line after removed export - if skip_next_blank and line.strip() == "": - skip_next_blank = False - continue - - skip_next_blank = False - filtered_lines.append(line) - - # Write back the filtered content - with open(shell_config, "w") as f: - f.writelines(filtered_lines) - - get_logger().info(f"Removed {env_var} export from {shell_config.name}") - return True - - except Exception as e: - get_logger().error(f"Failed to remove {env_var} from {shell_config}: {e}") - return False - - -def create_env_file( - api_keys: Dict[str, str], env_file_path: Optional[Path] = None -) -> bool: - """ - Create a .env file with the API keys (alternative to shell config) - - Args: - api_keys: Dictionary of environment variable names to values - env_file_path: Path to the .env file (defaults to home directory) - - Returns: - True if .env file was created successfully, False otherwise - """ - if env_file_path is None: - env_file_path = get_home_directory() / ".env" - - logger = get_logger() - - try: - # Read existing .env file if it exists - existing_content = "" - if env_file_path.exists(): - with open(env_file_path, "r") as f: - existing_content = f.read() - - # Prepare new content - new_lines = [] - for env_var, value in api_keys.items(): - line = f'{env_var}="{value}"' - - # Check if this variable already exists - if f"{env_var}=" in existing_content: - logger.info(f"Variable {env_var} already exists in .env file") - else: - new_lines.append(line) - - # Append new lines if any - if new_lines: - with open(env_file_path, "a") as f: - if existing_content and not existing_content.endswith("\n"): - f.write("\n") - f.write("# SuperClaude API Keys\n") - for line in new_lines: - f.write(line + "\n") - - # Set file permissions (readable only by owner) - env_file_path.chmod(0o600) - - display_success(f"Created .env file at {env_file_path}") - logger.info(f"Created .env file with {len(new_lines)} new variables") - - return True - - except Exception as e: - logger.error(f"Failed to create .env file: {e}") - display_warning(f"Could not create .env file: {e}") - return False - - -def check_research_prerequisites() -> tuple[bool, list[str]]: - """ - Check if deep research prerequisites are met - - Returns: - Tuple of (success: bool, warnings: List[str]) - """ - warnings = [] - logger = get_logger() - - # Check Tavily API key - if not os.environ.get("TAVILY_API_KEY"): - warnings.append( - "TAVILY_API_KEY not set - Deep research web search will not work\n" - "Get your key from: https://app.tavily.com" - ) - logger.warning("TAVILY_API_KEY not found in environment") - else: - logger.info("Found TAVILY_API_KEY in environment") - - # Check Node.js for MCP - import shutil - - if not shutil.which("node"): - warnings.append( - "Node.js not found - Required for Tavily MCP\n" - "Install from: https://nodejs.org" - ) - logger.warning("Node.js not found - required for Tavily MCP") - else: - logger.info("Node.js found") - - # Check npm - if not shutil.which("npm"): - warnings.append( - "npm not found - Required for MCP server installation\n" - "Usually installed with Node.js" - ) - logger.warning("npm not found - required for MCP installation") - else: - logger.info("npm found") - - return len(warnings) == 0, warnings diff --git a/setup/utils/logger.py b/setup/utils/logger.py deleted file mode 100644 index cfa44a0..0000000 --- a/setup/utils/logger.py +++ /dev/null @@ -1,335 +0,0 @@ -""" -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 rich.console import Console -from .symbols import symbols -from .paths import get_home_directory - -# Rich console for colored output -console = Console() - - -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 (get_home_directory() / ".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 using rich""" - from rich.logging import RichHandler - - handler = RichHandler( - console=console, - show_time=False, - show_path=False, - markup=True, - rich_tracebacks=True, - tracebacks_show_locals=False, - ) - handler.setLevel(self.console_level.value) - - # Simple formatter (rich handles coloring) - formatter = logging.Formatter("%(message)s") - handler.setFormatter(formatter) - - 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 - console.print(f"[yellow][!] Could not setup file logging: {e}[/yellow]") - 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 rich markup for success messages - success_msg = f"[green]{symbols.checkmark} {message}[/green]" - self.logger.info(success_msg, **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) diff --git a/setup/utils/paths.py b/setup/utils/paths.py deleted file mode 100644 index d92a96b..0000000 --- a/setup/utils/paths.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Path utilities for SuperClaude installation system -Handles cross-platform path operations and immutable distro support -""" - -import os -from pathlib import Path - - -def get_home_directory() -> Path: - """ - Get the correct home directory path, handling immutable distros. - - On immutable distros like Fedora Silverblue/Universal Blue, - the home directory is at /var/home/$USER instead of /home/$USER. - This function properly detects the actual home directory. - - Returns: - Path: The actual home directory path - """ - # First try the standard method - try: - home = Path.home() - # Verify the path actually exists and is accessible - if home.exists() and home.is_dir(): - return home - except Exception: - pass - - # Fallback methods for edge cases and immutable distros - - # Method 1: Use $HOME environment variable - home_env = os.environ.get("HOME") - if home_env: - home_path = Path(home_env) - if home_path.exists() and home_path.is_dir(): - return home_path - - # Method 2: Check for immutable distro patterns - username = os.environ.get("USER") or os.environ.get("USERNAME") - if username: - # Check common immutable distro paths - immutable_paths = [ - Path(f"/var/home/{username}"), # Fedora Silverblue/Universal Blue - Path(f"/home/{username}"), # Standard Linux - ] - - for path in immutable_paths: - if path.exists() and path.is_dir(): - return path - - # Method 3: Last resort - use the original Path.home() even if it seems wrong - # This ensures we don't crash the installation - return Path.home() diff --git a/setup/utils/security.py b/setup/utils/security.py deleted file mode 100644 index 6cc6441..0000000 --- a/setup/utils/security.py +++ /dev/null @@ -1,936 +0,0 @@ -""" -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 -from .paths import get_home_directory - - -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 = get_home_directory() - 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 = get_home_directory() - 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 diff --git a/setup/utils/symbols.py b/setup/utils/symbols.py deleted file mode 100644 index f0f67a7..0000000 --- a/setup/utils/symbols.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -Windows-compatible symbol fallbacks for SuperClaude UI -Handles Unicode encoding issues on Windows terminals -""" - -import sys -import os -import platform - - -def can_display_unicode() -> bool: - """ - Detect if terminal can display Unicode symbols safely - - Returns: - True if Unicode is safe to use, False if fallbacks needed - """ - # Check if we're on Windows with problematic encoding - if platform.system() == "Windows": - # Check console encoding - try: - # Test if we can encode common Unicode symbols - test_symbols = "βœ“βœ—β–ˆβ–‘β ‹β•" - test_symbols.encode(sys.stdout.encoding or "cp1252") - return True - except (UnicodeEncodeError, LookupError): - return False - - # Check if stdout encoding supports Unicode - encoding = getattr(sys.stdout, "encoding", None) - if encoding and encoding.lower() in ["utf-8", "utf8"]: - return True - - # Conservative fallback for unknown systems - return False - - -class Symbols: - """Cross-platform symbol definitions with Windows fallbacks""" - - def __init__(self): - self.unicode_safe = can_display_unicode() - - @property - def checkmark(self) -> str: - """Success checkmark symbol""" - return "βœ“" if self.unicode_safe else "+" - - @property - def crossmark(self) -> str: - """Error/failure cross symbol""" - return "βœ—" if self.unicode_safe else "x" - - @property - def block_filled(self) -> str: - """Filled block for progress bars""" - return "β–ˆ" if self.unicode_safe else "#" - - @property - def block_empty(self) -> str: - """Empty block for progress bars""" - return "β–‘" if self.unicode_safe else "-" - - @property - def double_line(self) -> str: - """Double line separator""" - return "═" if self.unicode_safe else "=" - - @property - def spinner_chars(self) -> str: - """Spinner animation characters""" - if self.unicode_safe: - return "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" - else: - return "|/-\\|/-\\" - - @property - def box_top_left(self) -> str: - """Box drawing: top-left corner""" - return "β•”" if self.unicode_safe else "+" - - @property - def box_top_right(self) -> str: - """Box drawing: top-right corner""" - return "β•—" if self.unicode_safe else "+" - - @property - def box_bottom_left(self) -> str: - """Box drawing: bottom-left corner""" - return "β•š" if self.unicode_safe else "+" - - @property - def box_bottom_right(self) -> str: - """Box drawing: bottom-right corner""" - return "╝" if self.unicode_safe else "+" - - @property - def box_horizontal(self) -> str: - """Box drawing: horizontal line""" - return "═" if self.unicode_safe else "=" - - @property - def box_vertical(self) -> str: - """Box drawing: vertical line""" - return "β•‘" if self.unicode_safe else "|" - - def make_separator(self, length: int) -> str: - """Create a separator line of specified length""" - return self.double_line * length - - def make_box_line(self, length: int) -> str: - """Create a box horizontal line of specified length""" - return self.box_horizontal * length - - -# Global instance for easy import -symbols = Symbols() - - -def safe_print(*args, **kwargs): - """ - Print function that handles Unicode encoding errors gracefully - - Falls back to ASCII-safe representation if Unicode fails - """ - try: - print(*args, **kwargs) - except UnicodeEncodeError: - # Convert arguments to safe strings - safe_args = [] - for arg in args: - if isinstance(arg, str): - # Replace problematic Unicode characters - safe_arg = ( - arg.replace("βœ“", "+") - .replace("βœ—", "x") - .replace("β–ˆ", "#") - .replace("β–‘", "-") - .replace("═", "=") - .replace("β ‹", "|") - .replace("β ™", "/") - .replace("β Ή", "-") - .replace("β Έ", "\\") - .replace("β Ό", "|") - .replace("β ΄", "/") - .replace("β ¦", "-") - .replace("β §", "\\") - .replace("β ‡", "|") - .replace("⠏", "/") - .replace("β•”", "+") - .replace("β•—", "+") - .replace("β•š", "+") - .replace("╝", "+") - .replace("β•‘", "|") - ) - safe_args.append(safe_arg) - else: - safe_args.append(str(arg)) - - # Try printing with safe arguments - try: - print(*safe_args, **kwargs) - except UnicodeEncodeError: - # Last resort: encode to ASCII with replacement - final_args = [] - for arg in safe_args: - if isinstance(arg, str): - final_args.append(arg.encode("ascii", "replace").decode("ascii")) - else: - final_args.append(str(arg)) - print(*final_args, **kwargs) - - -def format_with_symbols(text: str) -> str: - """ - Replace Unicode symbols in text with Windows-compatible alternatives - """ - if symbols.unicode_safe: - return text - - # Replace symbols with safe alternatives - replacements = { - "βœ“": symbols.checkmark, - "βœ—": symbols.crossmark, - "β–ˆ": symbols.block_filled, - "β–‘": symbols.block_empty, - "═": symbols.double_line, - "β•”": symbols.box_top_left, - "β•—": symbols.box_top_right, - "β•š": symbols.box_bottom_left, - "╝": symbols.box_bottom_right, - "β•‘": symbols.box_vertical, - } - - for unicode_char, safe_char in replacements.items(): - text = text.replace(unicode_char, safe_char) - - return text diff --git a/setup/utils/ui.py b/setup/utils/ui.py deleted file mode 100644 index a890ad8..0000000 --- a/setup/utils/ui.py +++ /dev/null @@ -1,203 +0,0 @@ -""" -Minimal backward-compatible UI utilities -Stub implementation for legacy installer code -""" - - -class Colors: - """ANSI color codes for terminal output""" - - RESET = "\033[0m" - BRIGHT = "\033[1m" - DIM = "\033[2m" - - BLACK = "\033[30m" - RED = "\033[31m" - GREEN = "\033[32m" - YELLOW = "\033[33m" - BLUE = "\033[34m" - MAGENTA = "\033[35m" - CYAN = "\033[36m" - WHITE = "\033[37m" - - BG_BLACK = "\033[40m" - BG_RED = "\033[41m" - BG_GREEN = "\033[42m" - BG_YELLOW = "\033[43m" - BG_BLUE = "\033[44m" - BG_MAGENTA = "\033[45m" - BG_CYAN = "\033[46m" - BG_WHITE = "\033[47m" - - -def display_header(title: str, subtitle: str = "") -> None: - """Display a formatted header""" - print(f"\n{Colors.CYAN}{Colors.BRIGHT}{title}{Colors.RESET}") - if subtitle: - print(f"{Colors.DIM}{subtitle}{Colors.RESET}") - print() - - -def display_success(message: str) -> None: - """Display a success message""" - print(f"{Colors.GREEN}βœ“ {message}{Colors.RESET}") - - -def display_error(message: str) -> None: - """Display an error message""" - print(f"{Colors.RED}βœ— {message}{Colors.RESET}") - - -def display_warning(message: str) -> None: - """Display a warning message""" - print(f"{Colors.YELLOW}⚠ {message}{Colors.RESET}") - - -def display_info(message: str) -> None: - """Display an info message""" - print(f"{Colors.CYAN}β„Ή {message}{Colors.RESET}") - - -def confirm(prompt: str, default: bool = True) -> bool: - """ - Simple confirmation prompt - - Args: - prompt: The prompt message - default: Default response if user just presses Enter - - Returns: - True if confirmed, False otherwise - """ - default_str = "Y/n" if default else "y/N" - response = input(f"{prompt} [{default_str}]: ").strip().lower() - - if not response: - return default - - return response in ("y", "yes") - - -class Menu: - """Minimal menu implementation""" - - def __init__(self, title: str, options: list, multi_select: bool = False): - self.title = title - self.options = options - self.multi_select = multi_select - - def display(self): - """Display menu and get selection""" - print(f"\n{Colors.CYAN}{Colors.BRIGHT}{self.title}{Colors.RESET}\n") - - for i, option in enumerate(self.options, 1): - print(f"{i}. {option}") - - if self.multi_select: - print(f"\n{Colors.DIM}Enter comma-separated numbers (e.g., 1,3,5) or 'all' for all options{Colors.RESET}") - while True: - try: - choice = input(f"Select [1-{len(self.options)}]: ").strip().lower() - - if choice == "all": - return list(range(len(self.options))) - - if not choice: - return [] - - selections = [int(x.strip()) - 1 for x in choice.split(",")] - if all(0 <= s < len(self.options) for s in selections): - return selections - print(f"{Colors.RED}Invalid selection{Colors.RESET}") - except (ValueError, KeyboardInterrupt): - print(f"\n{Colors.RED}Invalid input{Colors.RESET}") - else: - while True: - try: - choice = input(f"\nSelect [1-{len(self.options)}]: ").strip() - choice_num = int(choice) - if 1 <= choice_num <= len(self.options): - return choice_num - 1 - print(f"{Colors.RED}Invalid selection{Colors.RESET}") - except (ValueError, KeyboardInterrupt): - print(f"\n{Colors.RED}Invalid input{Colors.RESET}") - - -class ProgressBar: - """Minimal progress bar implementation""" - - def __init__(self, total: int, prefix: str = "", suffix: str = ""): - self.total = total - self.prefix = prefix - self.suffix = suffix - self.current = 0 - - def update(self, current: int = None, message: str = None) -> None: - """Update progress""" - if current is not None: - self.current = current - else: - self.current += 1 - - percent = int((self.current / self.total) * 100) if self.total > 0 else 100 - display_msg = message or f"{self.prefix}{self.current}/{self.total} {self.suffix}" - print(f"\r{display_msg} {percent}%", end="", flush=True) - - if self.current >= self.total: - print() # New line when complete - - def finish(self, message: str = "Complete") -> None: - """Finish progress bar""" - self.current = self.total - print(f"\r{message} 100%") - - def close(self) -> None: - """Close progress bar""" - if self.current < self.total: - print() - - -def format_size(size: int) -> str: - """ - Format size in bytes to human-readable string - - Args: - size: Size in bytes - - Returns: - Formatted size string (e.g., "1.5 MB", "256 KB") - """ - if size < 1024: - return f"{size} B" - elif size < 1024 * 1024: - return f"{size / 1024:.1f} KB" - elif size < 1024 * 1024 * 1024: - return f"{size / (1024 * 1024):.1f} MB" - else: - return f"{size / (1024 * 1024 * 1024):.1f} GB" - - -def prompt_api_key(service_name: str, env_var_name: str) -> str: - """ - Prompt user for API key - - Args: - service_name: Name of the service requiring the key - env_var_name: Environment variable name for the key - - Returns: - API key string (empty if user skips) - """ - print(f"\n{Colors.CYAN}{service_name} API Key{Colors.RESET}") - print(f"{Colors.DIM}Environment variable: {env_var_name}{Colors.RESET}") - print(f"{Colors.YELLOW}Press Enter to skip{Colors.RESET}") - - try: - # Use getpass for password-like input (hidden) - import getpass - - key = getpass.getpass("Enter API key: ").strip() - return key - except (EOFError, KeyboardInterrupt): - print(f"\n{Colors.YELLOW}Skipped{Colors.RESET}") - return "" diff --git a/setup/utils/updater.py b/setup/utils/updater.py deleted file mode 100644 index f0981eb..0000000 --- a/setup/utils/updater.py +++ /dev/null @@ -1,329 +0,0 @@ -""" -Auto-update checker for SuperClaude Framework -Checks PyPI for newer versions and offers automatic updates -""" - -import os -import sys -import json -import time -import subprocess -from pathlib import Path -from typing import Optional, Tuple -from packaging import version -import urllib.request -import urllib.error -from datetime import datetime, timedelta - -from .ui import display_info, display_warning, display_success, Colors -from .logger import get_logger -from .paths import get_home_directory - - -class UpdateChecker: - """Handles automatic update checking for SuperClaude""" - - PYPI_URL = "https://pypi.org/pypi/superclaude/json" - CACHE_FILE = get_home_directory() / ".claude" / ".update_check" - CHECK_INTERVAL = 86400 # 24 hours in seconds - TIMEOUT = 2 # seconds - - def __init__(self, current_version: str): - """ - Initialize update checker - - Args: - current_version: Current installed version - """ - self.current_version = current_version - self.logger = get_logger() - - def should_check_update(self, force: bool = False) -> bool: - """ - Determine if we should check for updates based on last check time - - Args: - force: Force check regardless of last check time - - Returns: - True if update check should be performed - """ - if force: - return True - - if not self.CACHE_FILE.exists(): - return True - - try: - with open(self.CACHE_FILE, "r") as f: - data = json.load(f) - last_check = data.get("last_check", 0) - - # Check if 24 hours have passed - if time.time() - last_check > self.CHECK_INTERVAL: - return True - - except (json.JSONDecodeError, KeyError): - return True - - return False - - def save_check_timestamp(self): - """Save the current timestamp as last check time""" - self.CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) - - data = {} - if self.CACHE_FILE.exists(): - try: - with open(self.CACHE_FILE, "r") as f: - data = json.load(f) - except: - pass - - data["last_check"] = time.time() - - with open(self.CACHE_FILE, "w") as f: - json.dump(data, f) - - def get_latest_version(self) -> Optional[str]: - """ - Query PyPI for the latest version of SuperClaude - - Returns: - Latest version string or None if check fails - """ - try: - # Create request with timeout - req = urllib.request.Request( - self.PYPI_URL, headers={"User-Agent": "superclaude-Updater"} - ) - - # Set timeout for the request - with urllib.request.urlopen(req, timeout=self.TIMEOUT) as response: - data = json.loads(response.read().decode()) - latest = data.get("info", {}).get("version") - - if self.logger: - self.logger.debug(f"Latest PyPI version: {latest}") - - return latest - - except ( - urllib.error.URLError, - urllib.error.HTTPError, - json.JSONDecodeError, - ) as e: - if self.logger: - self.logger.debug(f"Failed to check PyPI: {e}") - return None - except Exception as e: - if self.logger: - self.logger.debug(f"Unexpected error checking updates: {e}") - return None - - def compare_versions(self, latest: str) -> bool: - """ - Compare current version with latest version - - Args: - latest: Latest version string - - Returns: - True if update is available - """ - try: - return version.parse(latest) > version.parse(self.current_version) - except Exception: - return False - - def detect_installation_method(self) -> str: - """ - Detect how SuperClaude was installed (pip, pipx, etc.) - - Returns: - Installation method string - """ - # Check pipx first - try: - result = subprocess.run( - ["pipx", "list"], capture_output=True, text=True, timeout=2 - ) - if "superclaude" in result.stdout or "superclaude" in result.stdout: - return "pipx" - except: - pass - - # Check if pip installation exists - try: - result = subprocess.run( - [sys.executable, "-m", "pip", "show", "superclaude"], - capture_output=True, - text=True, - timeout=2, - ) - if result.returncode == 0: - # Check if it's a user installation - if "--user" in result.stdout or get_home_directory() in Path( - result.stdout - ): - return "pip-user" - return "pip" - except: - pass - - return "unknown" - - def get_update_command(self) -> str: - """ - Get the appropriate update command based on installation method - - Returns: - Update command string - """ - method = self.detect_installation_method() - - commands = { - "pipx": "pipx upgrade SuperClaude", - "pip-user": "pip install --upgrade --user SuperClaude", - "pip": "pip install --upgrade SuperClaude", - "unknown": "pip install --upgrade SuperClaude", - } - - return commands.get(method, commands["unknown"]) - - def show_update_banner(self, latest: str, auto_update: bool = False) -> bool: - """ - Display update available banner - - Args: - latest: Latest version available - auto_update: Whether to auto-update without prompting - - Returns: - True if user wants to update - """ - update_cmd = self.get_update_command() - - # Display banner - print( - f"\n{Colors.CYAN}+================================================+{Colors.RESET}" - ) - print( - f"{Colors.CYAN}β•‘{Colors.YELLOW} πŸš€ Update Available: {self.current_version} β†’ {latest} {Colors.CYAN}β•‘{Colors.RESET}" - ) - print( - f"{Colors.CYAN}β•‘{Colors.GREEN} Run: {update_cmd:<30} {Colors.CYAN}β•‘{Colors.RESET}" - ) - print( - f"{Colors.CYAN}+================================================+{Colors.RESET}\n" - ) - - if auto_update: - return True - - # Check if running in non-interactive mode - if not sys.stdin.isatty(): - return False - - # Prompt user - try: - response = ( - input( - f"{Colors.YELLOW}Would you like to update now? (y/N): {Colors.RESET}" - ) - .strip() - .lower() - ) - return response in ["y", "yes"] - except (EOFError, KeyboardInterrupt): - return False - - def perform_update(self) -> bool: - """ - Execute the update command - - Returns: - True if update succeeded - """ - update_cmd = self.get_update_command() - - print(f"{Colors.CYAN}πŸ”„ Updating superclaude...{Colors.RESET}") - - try: - result = subprocess.run(update_cmd.split(), capture_output=False, text=True) - - if result.returncode == 0: - display_success("Update completed successfully!") - print( - f"{Colors.YELLOW}Please restart SuperClaude to use the new version.{Colors.RESET}" - ) - return True - else: - display_warning("Update failed. Please run manually:") - print(f" {update_cmd}") - return False - - except Exception as e: - display_warning(f"Could not auto-update: {e}") - print(f"Please run manually: {update_cmd}") - return False - - def check_and_notify(self, force: bool = False, auto_update: bool = False) -> bool: - """ - Main method to check for updates and notify user - - Args: - force: Force check regardless of last check time - auto_update: Automatically update if available - - Returns: - True if update was performed - """ - # Check if we should skip based on environment variable - if os.getenv("SUPERCLAUDE_NO_UPDATE_CHECK", "").lower() in ["true", "1", "yes"]: - return False - - # Check if auto-update is enabled via environment - if os.getenv("SUPERCLAUDE_AUTO_UPDATE", "").lower() in ["true", "1", "yes"]: - auto_update = True - - # Check if enough time has passed - if not self.should_check_update(force): - return False - - # Get latest version - latest = self.get_latest_version() - if not latest: - return False - - # Save timestamp - self.save_check_timestamp() - - # Compare versions - if not self.compare_versions(latest): - return False - - # Show banner and potentially update - if self.show_update_banner(latest, auto_update): - return self.perform_update() - - return False - - -def check_for_updates(current_version: str = None, **kwargs) -> bool: - """ - Convenience function to check for updates - - Args: - current_version: Current installed version (defaults to reading from setup) - **kwargs: Additional arguments passed to check_and_notify - - Returns: - True if update was performed - """ - if current_version is None: - from setup import __version__ - - current_version = __version__ - checker = UpdateChecker(current_version) - return checker.check_and_notify(**kwargs) diff --git a/tests/test_get_components.py b/tests/test_get_components.py deleted file mode 100644 index bbb8b8f..0000000 --- a/tests/test_get_components.py +++ /dev/null @@ -1,28 +0,0 @@ -import pytest -from unittest.mock import patch, MagicMock -import argparse -from setup.cli.commands.install import get_components_to_install - - -class TestGetComponents: - @patch("setup.cli.commands.install.select_mcp_servers") - def test_get_components_to_install_interactive_mcp(self, mock_select_mcp): - # Arrange - mock_registry = MagicMock() - mock_config_manager = MagicMock() - mock_config_manager._installation_context = {} - mock_select_mcp.return_value = ["magic"] - - args = argparse.Namespace(components=["mcp"]) - - # Act - components = get_components_to_install(args, mock_registry, mock_config_manager) - - # Assert - mock_select_mcp.assert_called_once() - assert "mcp" in components - assert "mcp_docs" in components # Should be added automatically - assert hasattr(mock_config_manager, "_installation_context") - assert mock_config_manager._installation_context["selected_mcp_servers"] == [ - "magic" - ] diff --git a/tests/test_install_command.py b/tests/test_install_command.py deleted file mode 100644 index ed4eaf7..0000000 --- a/tests/test_install_command.py +++ /dev/null @@ -1,67 +0,0 @@ -import pytest -from pathlib import Path -from unittest.mock import patch, MagicMock, ANY -import argparse -from setup.cli.commands import install - - -class TestInstallCommand: - @patch("setup.cli.commands.install.get_components_to_install") - @patch("setup.cli.commands.install.ComponentRegistry") - @patch("setup.cli.commands.install.ConfigService") - @patch("setup.cli.commands.install.Validator") - @patch("setup.cli.commands.install.display_installation_plan") - @patch("setup.cli.commands.install.perform_installation") - @patch("setup.cli.commands.install.confirm", return_value=True) - @patch("setup.cli.commands.install.validate_system_requirements", return_value=True) - @patch("pathlib.Path.home") - def test_run_resolves_dependencies_before_planning( - self, - mock_home, - mock_validate_reqs, - mock_confirm, - mock_perform, - mock_display, - mock_validator, - mock_config, - mock_registry_class, - mock_get_components, - tmp_path, - ): - # Arrange - mock_home.return_value = tmp_path - install_dir = tmp_path / ".claude" - - mock_args = argparse.Namespace( - components=["mcp"], - install_dir=install_dir, - quiet=True, # to avoid calling display_header - yes=True, - force=False, - dry_run=False, - diagnose=False, - list_components=False, - ) - - mock_registry_instance = MagicMock() - mock_registry_class.return_value = mock_registry_instance - - mock_config_instance = MagicMock() - mock_config.return_value = mock_config_instance - mock_config_instance.validate_config_files.return_value = [] - - mock_get_components.return_value = ["mcp"] - mock_registry_instance.resolve_dependencies.return_value = ["core", "mcp"] - - # Act - install.run(mock_args) - - # Assert - # Check that resolve_dependencies was called with the initial list - mock_registry_instance.resolve_dependencies.assert_called_once_with(["mcp"]) - - # Check that display_installation_plan was not called because of quiet=True - mock_display.assert_not_called() - - # Check that perform_installation was called with the resolved list - mock_perform.assert_called_once_with(["core", "mcp"], mock_args, ANY) diff --git a/tests/test_installer.py b/tests/test_installer.py deleted file mode 100644 index 8f94342..0000000 --- a/tests/test_installer.py +++ /dev/null @@ -1,96 +0,0 @@ -import pytest -from pathlib import Path -import shutil -import tarfile -import tempfile -from unittest.mock import MagicMock -from setup.core.installer import Installer - - -class TestInstaller: - def test_create_backup_empty_dir(self): - with tempfile.TemporaryDirectory() as temp_dir_str: - temp_dir = Path(temp_dir_str) - installer = Installer(install_dir=temp_dir) - - backup_path = installer.create_backup() - - assert backup_path is not None - assert backup_path.exists() - - # This is the crucial part: check if it's a valid tar file. - # An empty file created with .touch() is not a valid tar file. - try: - with tarfile.open(backup_path, "r:gz") as tar: - members = tar.getmembers() - # An empty archive can have 0 members, or 1 member (the root dir) - if len(members) == 1: - assert members[0].name == "." - else: - assert len(members) == 0 - except tarfile.ReadError as e: - pytest.fail(f"Backup file is not a valid tar.gz file: {e}") - - def test_skips_already_installed_component(self): - # Create a mock component that is NOT reinstallable - mock_component = MagicMock() - mock_component.get_metadata.return_value = {"name": "test_component"} - mock_component.is_reinstallable.return_value = False - mock_component.install.return_value = True - mock_component.validate_prerequisites.return_value = (True, []) - - installer = Installer() - installer.register_component(mock_component) - - # Simulate component is already installed - installer.installed_components = {"test_component"} - - installer.install_component("test_component", {}) - - # Assert that the install method was NOT called - mock_component.install.assert_not_called() - assert "test_component" in installer.skipped_components - - def test_installs_reinstallable_component(self): - # Create a mock component that IS reinstallable - mock_component = MagicMock() - mock_component.get_metadata.return_value = {"name": "reinstallable_component"} - mock_component.is_reinstallable.return_value = True - mock_component.install.return_value = True - mock_component.validate_prerequisites.return_value = (True, []) - - installer = Installer() - installer.register_component(mock_component) - - # Simulate component is already installed - installer.installed_components = {"reinstallable_component"} - - installer.install_component("reinstallable_component", {}) - - # Assert that the install method WAS called - mock_component.install.assert_called_once() - assert "reinstallable_component" not in installer.skipped_components - - def test_post_install_validation_only_validates_updated_components(self): - # Arrange - installer = Installer() - - mock_comp1 = MagicMock() - mock_comp1.get_metadata.return_value = {"name": "comp1"} - mock_comp1.validate_installation.return_value = (True, []) - - mock_comp2 = MagicMock() - mock_comp2.get_metadata.return_value = {"name": "comp2"} - mock_comp2.validate_installation.return_value = (True, []) - - installer.register_component(mock_comp1) - installer.register_component(mock_comp2) - - installer.updated_components = {"comp1"} - - # Act - installer._run_post_install_validation() - - # Assert - mock_comp1.validate_installation.assert_called_once() - mock_comp2.validate_installation.assert_not_called() diff --git a/tests/test_mcp_component.py b/tests/test_mcp_component.py deleted file mode 100644 index 0cbb228..0000000 --- a/tests/test_mcp_component.py +++ /dev/null @@ -1,87 +0,0 @@ -import pytest -from pathlib import Path -from unittest.mock import MagicMock, patch -from setup.components.mcp import MCPComponent - - -class TestMCPComponent: - @patch("setup.components.mcp.MCPComponent._post_install", return_value=True) - @patch( - "setup.components.mcp.MCPComponent.validate_prerequisites", - return_value=(True, []), - ) - @patch("setup.components.mcp.MCPComponent._install_mcp_server") - def test_install_selected_servers_only( - self, mock_install_mcp_server, mock_validate_prereqs, mock_post_install - ): - mock_install_mcp_server.return_value = True - - component = MCPComponent(install_dir=Path("/fake/dir")) - component.installed_servers_in_session = [] - - # Simulate selecting only the 'magic' server - config = {"selected_mcp_servers": ["magic"]} - - success = component._install(config) - - assert success is True - assert component.installed_servers_in_session == ["magic"] - - # Assert that _install_mcp_server was called exactly once - assert mock_install_mcp_server.call_count == 1 - - # Assert that it was called with the correct server info - called_args, _ = mock_install_mcp_server.call_args - server_info_arg = called_args[0] - - assert server_info_arg["name"] == "magic" - assert server_info_arg["npm_package"] == "@21st-dev/magic" - - @patch("subprocess.run") - def test_validate_installation_success(self, mock_subprocess_run): - component = MCPComponent(install_dir=Path("/fake/dir")) - - # Mock settings manager - component.settings_manager = MagicMock() - component.settings_manager.is_component_installed.return_value = True - component.settings_manager.get_component_version.return_value = ( - component.get_metadata()["version"] - ) - component.settings_manager.get_metadata_setting.return_value = [ - "magic", - "playwright", - ] - - # Mock `claude mcp list` output - mock_subprocess_run.return_value.returncode = 0 - mock_subprocess_run.return_value.stdout = "magic\nplaywright\n" - - success, errors = component.validate_installation() - - assert success is True - assert not errors - - @patch("subprocess.run") - def test_validate_installation_failure(self, mock_subprocess_run): - component = MCPComponent(install_dir=Path("/fake/dir")) - - # Mock settings manager - component.settings_manager = MagicMock() - component.settings_manager.is_component_installed.return_value = True - component.settings_manager.get_component_version.return_value = ( - component.get_metadata()["version"] - ) - component.settings_manager.get_metadata_setting.return_value = [ - "magic", - "playwright", - ] - - # Mock `claude mcp list` output - 'playwright' is missing - mock_subprocess_run.return_value.returncode = 0 - mock_subprocess_run.return_value.stdout = "magic\n" - - success, errors = component.validate_installation() - - assert success is False - assert len(errors) == 1 - assert "playwright" in errors[0] diff --git a/tests/test_mcp_docs_component.py b/tests/test_mcp_docs_component.py deleted file mode 100644 index 15dd0c6..0000000 --- a/tests/test_mcp_docs_component.py +++ /dev/null @@ -1,41 +0,0 @@ -import pytest -from pathlib import Path -from unittest.mock import MagicMock, patch -from setup.components.mcp_docs import MCPDocsComponent - - -class TestMCPDocsComponent: - @patch( - "setup.components.mcp_docs.MCPDocsComponent._post_install", return_value=True - ) - def test_install_calls_post_install_even_if_no_docs(self, mock_post_install): - component = MCPDocsComponent(install_dir=Path("/fake/dir")) - - # Simulate no servers selected - config = {"selected_mcp_servers": []} - - success = component._install(config) - - assert success is True - mock_post_install.assert_called_once() - - @patch( - "setup.components.mcp_docs.MCPDocsComponent._post_install", return_value=True - ) - @patch( - "setup.components.mcp_docs.MCPDocsComponent.get_files_to_install", - return_value=[], - ) - @patch("setup.core.base.Component.validate_prerequisites", return_value=(True, [])) - def test_install_calls_post_install_if_docs_not_found( - self, mock_validate_prereqs, mock_get_files, mock_post_install - ): - component = MCPDocsComponent(install_dir=Path("/tmp/fake_dir")) - - # Simulate a server was selected, but the doc file doesn't exist - config = {"selected_mcp_servers": ["some_server_with_no_doc_file"]} - - success = component._install(config) - - assert success is True - mock_post_install.assert_called_once()