mirror of
https://github.com/SuperClaude-Org/SuperClaude_Framework.git
synced 2025-12-29 16:16:08 +00:00
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:
@@ -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"
|
||||
@@ -1,11 +0,0 @@
|
||||
"""
|
||||
SuperClaude CLI Module
|
||||
Command-line interface operations for SuperClaude installation system
|
||||
"""
|
||||
|
||||
from .base import OperationBase
|
||||
from .commands import *
|
||||
|
||||
__all__ = [
|
||||
"OperationBase",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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}")
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Core modules for SuperClaude installation system"""
|
||||
|
||||
from .validator import Validator
|
||||
from .registry import ComponentRegistry
|
||||
|
||||
__all__ = ["Validator", "ComponentRegistry"]
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -1,4 +0,0 @@
|
||||
"""
|
||||
SuperClaude Data Module
|
||||
Static configuration and data files
|
||||
"""
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user