refactor: remove legacy setup/ system and dependent tests

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 <noreply@anthropic.com>
This commit is contained in:
kazuki
2025-10-21 11:58:20 +09:00
parent 1f3d4a37f4
commit eb37591922
40 changed files with 0 additions and 12289 deletions

View File

@@ -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"

View File

@@ -1,11 +0,0 @@
"""
SuperClaude CLI Module
Command-line interface operations for SuperClaude installation system
"""
from .base import OperationBase
from .commands import *
__all__ = [
"OperationBase",
]

View File

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

View File

@@ -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",
]

View File

@@ -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: <install-dir>/backups)",
)
parser.add_argument("--name", type=str, help="Custom backup name (for --create)")
parser.add_argument(
"--compress",
choices=["none", "gzip", "bzip2"],
default="gzip",
help="Compression method (default: gzip)",
)
# Restore options
parser.add_argument(
"--overwrite",
action="store_true",
help="Overwrite existing files during restore",
)
# Cleanup options
parser.add_argument(
"--keep",
type=int,
default=5,
help="Number of backups to keep during cleanup (default: 5)",
)
parser.add_argument(
"--older-than", type=int, help="Remove backups older than N days"
)
return parser
def get_backup_directory(args: argparse.Namespace) -> Path:
"""Get the backup directory path"""
if args.backup_dir:
return args.backup_dir
else:
return args.install_dir / "backups"
def check_installation_exists(install_dir: Path) -> bool:
"""Check if SuperClaude installation (v2 included) exists"""
settings_manager = 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)

View File

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

View File

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

View File

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

View File

@@ -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",
]

View File

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

View File

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

View File

@@ -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}")

File diff suppressed because it is too large Load Diff

View File

@@ -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}")

View File

@@ -1,6 +0,0 @@
"""Core modules for SuperClaude installation system"""
from .validator import Validator
from .registry import ComponentRegistry
__all__ = ["Validator", "ComponentRegistry"]

View File

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

View File

@@ -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),
}

View File

@@ -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(),
}

View File

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

View File

@@ -1,4 +0,0 @@
"""
SuperClaude Data Module
Static configuration and data files
"""

View File

@@ -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": []
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"]

View File

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

View File

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

View File

@@ -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],
}

View File

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

View File

@@ -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"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ""

View File

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

View File

@@ -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"
]

View File

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

View File

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

View File

@@ -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]

View File

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