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