mirror of
https://github.com/SuperClaude-Org/SuperClaude_Framework.git
synced 2025-12-29 16:16:08 +00:00
refactor: PEP8 compliance - directory rename and code formatting (#425)
* fix(orchestration): add WebFetch auto-trigger for infrastructure configuration Problem: Infrastructure configuration changes (e.g., Traefik port settings) were being made based on assumptions without consulting official documentation, violating the 'Evidence > assumptions' principle in PRINCIPLES.md. Solution: - Added Infrastructure Configuration Validation section to MODE_Orchestration.md - Auto-triggers WebFetch for infrastructure tools (Traefik, nginx, Docker, etc.) - Enforces MODE_DeepResearch activation for investigation - BLOCKS assumption-based configuration changes Testing: Verified WebFetch successfully retrieves Traefik official docs (port 80 default) This prevents production outages from infrastructure misconfiguration by ensuring all technical recommendations are backed by official documentation. * feat: Add PM Agent (Project Manager Agent) for seamless orchestration Introduces PM Agent as the default orchestration layer that coordinates all sub-agents and manages workflows automatically. Key Features: - Default orchestration: All user interactions handled by PM Agent - Auto-delegation: Intelligent sub-agent selection based on task analysis - Docker Gateway integration: Zero-token baseline with dynamic MCP loading - Self-improvement loop: Automatic documentation of patterns and mistakes - Optional override: Users can specify sub-agents explicitly if desired Architecture: - Agent spec: SuperClaude/Agents/pm-agent.md - Command: SuperClaude/Commands/pm.md - Updated docs: README.md (15→16 agents), agents.md (new Orchestration category) User Experience: - Default: PM Agent handles everything (seamless, no manual routing) - Optional: Explicit --agent flag for direct sub-agent access - Both modes available simultaneously (no user downside) Implementation Status: - ✅ Specification complete - ✅ Documentation complete - ⏳ Prototype implementation needed - ⏳ Docker Gateway integration needed - ⏳ Testing and validation needed Refs: kazukinakai/docker-mcp-gateway (IRIS MCP Gateway integration) * feat: Add Agent Orchestration rules for PM Agent default activation Implements PM Agent as the default orchestration layer in RULES.md. Key Changes: - New 'Agent Orchestration' section (CRITICAL priority) - PM Agent receives ALL user requests by default - Manual override with @agent-[name] bypasses PM Agent - Agent Selection Priority clearly defined: 1. Manual override → Direct routing 2. Default → PM Agent → Auto-delegation 3. Delegation based on keywords, file types, complexity, context User Experience: - Default: PM Agent handles everything (seamless) - Override: @agent-[name] for direct specialist access - Transparent: PM Agent reports delegation decisions This establishes PM Agent as the orchestration layer while respecting existing auto-activation patterns and manual overrides. Next Steps: - Local testing in agiletec project - Iteration based on actual behavior - Documentation updates as needed * refactor(pm-agent): redesign as self-improvement meta-layer Problem Resolution: PM Agent's initial design competed with existing auto-activation for task routing, creating confusion about orchestration responsibilities and adding unnecessary complexity. Design Change: Redefined PM Agent as a meta-layer agent that operates AFTER specialist agents complete tasks, focusing on: - Post-implementation documentation and pattern recording - Immediate mistake analysis with prevention checklists - Monthly documentation maintenance and noise reduction - Pattern extraction and knowledge synthesis Two-Layer Orchestration System: 1. Task Execution Layer: Existing auto-activation handles task routing (unchanged) 2. Self-Improvement Layer: PM Agent meta-layer handles documentation (new) Files Modified: - SuperClaude/Agents/pm-agent.md: Complete rewrite with meta-layer design - Category: orchestration → meta - Triggers: All user interactions → Post-implementation, mistakes, monthly - Behavioral Mindset: Continuous learning system - Self-Improvement Workflow: BEFORE/DURING/AFTER/MISTAKE RECOVERY/MAINTENANCE - SuperClaude/Core/RULES.md: Agent Orchestration section updated - Split into Task Execution Layer + Self-Improvement Layer - Added orchestration flow diagram - Clarified PM Agent activates AFTER task completion - README.md: Updated PM Agent description - "orchestrates all interactions" → "ensures continuous learning" - Docs/User-Guide/agents.md: PM Agent section rewritten - Section: Orchestration Agent → Meta-Layer Agent - Expertise: Project orchestration → Self-improvement workflow executor - Examples: Task coordination → Post-implementation documentation - PR_DOCUMENTATION.md: Comprehensive PR documentation added - Summary, motivation, changes, testing, breaking changes - Two-layer orchestration system diagram - Verification checklist Integration Validated: Tested with agiletec project's self-improvement-workflow.md: ✅ PM Agent aligns with existing BEFORE/DURING/AFTER/MISTAKE RECOVERY phases ✅ Complements (not competes with) existing workflow ✅ agiletec workflow defines WHAT, PM Agent defines WHO executes it Breaking Changes: None - Existing auto-activation continues unchanged - Specialist agents unaffected - User workflows remain the same - New capability: Automatic documentation and knowledge maintenance Value Proposition: Transforms SuperClaude into a continuously learning system that accumulates knowledge, prevents recurring mistakes, and maintains fresh documentation without manual intervention. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * docs: add Claude Code conversation history management research Research covering .jsonl file structure, performance impact, and retention policies. Content: - Claude Code .jsonl file format and message types - Performance issues from GitHub (memory leaks, conversation compaction) - Retention policies (consumer vs enterprise) - Rotation recommendations based on actual data - File history snapshot tracking mechanics Source: Moved from agiletec project (research applicable to all Claude Code projects) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: add Development documentation structure Phase 1: Documentation Structure complete - Add Docs/Development/ directory for development documentation - Add ARCHITECTURE.md - System architecture with PM Agent meta-layer - Add ROADMAP.md - 5-phase development plan with checkboxes - Add TASKS.md - Daily task tracking with progress indicators - Add PROJECT_STATUS.md - Current status dashboard and metrics - Add pm-agent-integration.md - Implementation guide for PM Agent mode This establishes comprehensive documentation foundation for: - System architecture understanding - Development planning and tracking - Implementation guidance - Progress visibility Related: #pm-agent-mode #documentation #phase-1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: PM Agent session lifecycle and PDCA implementation Phase 2: PM Agent Mode Integration (Design Phase) Commands/pm.md updates: - Add "Always-Active Foundation Layer" concept - Add Session Lifecycle (Session Start/During Work/Session End) - Add PDCA Cycle (Plan/Do/Check/Act) automation - Add Serena MCP Memory Integration (list/read/write_memory) - Document auto-activation triggers Agents/pm-agent.md updates: - Add Session Start Protocol (MANDATORY auto-activation) - Add During Work PDCA Cycle with example workflows - Add Session End Protocol with state preservation - Add PDCA Self-Evaluation Pattern - Add Documentation Strategy (temp → patterns/mistakes) - Add Memory Operations Reference Key Features: - Session start auto-activation for context restoration - 30-minute checkpoint saves during work - Self-evaluation with think_about_* operations - Systematic documentation lifecycle - Knowledge evolution to CLAUDE.md Implementation Status: - ✅ Design complete (Commands/pm.md, Agents/pm-agent.md) - ⏳ Implementation pending (Core components) - ⏳ Serena MCP integration pending Salvaged from mistaken development in ~/.claude directory Related: #pm-agent-mode #session-lifecycle #pdca-cycle #phase-2 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: disable Serena MCP auto-browser launch Disable web dashboard and GUI log window auto-launch in Serena MCP server to prevent intrusive browser popups on startup. Users can still manually access the dashboard at http://localhost:24282/dashboard/ if needed. Changes: - Add CLI flags to Serena run command: - --enable-web-dashboard false - --enable-gui-log-window false - Ensures Git-tracked configuration (no reliance on ~/.serena/serena_config.yml) - Aligns with AIRIS MCP Gateway integration approach 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: rename directories to lowercase for PEP8 compliance - Rename superclaude/Agents -> superclaude/agents - Rename superclaude/Commands -> superclaude/commands - Rename superclaude/Core -> superclaude/core - Rename superclaude/Examples -> superclaude/examples - Rename superclaude/MCP -> superclaude/mcp - Rename superclaude/Modes -> superclaude/modes This change follows Python PEP8 naming conventions for package directories. * style: fix PEP8 violations and update package name to lowercase Changes: - Format all Python files with black (43 files reformatted) - Update package name from 'SuperClaude' to 'superclaude' in pyproject.toml - Fix import statements to use lowercase package name - Add missing imports (timedelta, __version__) - Remove old SuperClaude.egg-info directory PEP8 violations reduced from 2672 to 701 (mostly E501 line length due to black's 88 char vs flake8's 79 char limit). * docs: add PM Agent development documentation Add comprehensive PM Agent development documentation: - PM Agent ideal workflow (7-phase autonomous cycle) - Project structure understanding (Git vs installed environment) - Installation flow understanding (CommandsComponent behavior) - Task management system (current-tasks.md) Purpose: Eliminate repeated explanations and enable autonomous PDCA cycles 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat(pm-agent): add self-correcting execution and warning investigation culture ## Changes ### superclaude/commands/pm.md - Add "Self-Correcting Execution" section with root cause analysis protocol - Add "Warning/Error Investigation Culture" section enforcing zero-tolerance for dismissal - Define error detection protocol: STOP → Investigate → Hypothesis → Different Solution → Execute - Document anti-patterns (retry without understanding) and correct patterns (research-first) ### docs/Development/hypothesis-pm-autonomous-enhancement-2025-10-14.md - Add PDCA workflow hypothesis document for PM Agent autonomous enhancement ## Rationale PM Agent must never retry failed operations without understanding root causes. All warnings and errors require investigation via context7/WebFetch/documentation to ensure production-quality code and prevent technical debt accumulation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat(installer): add airis-mcp-gateway MCP server option ## Changes - Add airis-mcp-gateway to MCP server options in installer - Configuration: GitHub-based installation via uvx - Repository: https://github.com/oraios/airis-mcp-gateway - Purpose: Dynamic MCP Gateway for zero-token baseline and on-demand tool loading ## Implementation Added to setup/components/mcp.py self.mcp_servers dictionary with: - install_method: github - install_command: uvx test installation - run_command: uvx runtime execution - required: False (optional server) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: kazuki <kazuki@kazukinoMacBook-Air.local> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -21,4 +21,4 @@ DATA_DIR = SETUP_DIR / "data"
|
||||
from .utils.paths import get_home_directory
|
||||
|
||||
# Installation target
|
||||
DEFAULT_INSTALL_DIR = get_home_directory() / ".claude"
|
||||
DEFAULT_INSTALL_DIR = get_home_directory() / ".claude"
|
||||
|
||||
@@ -7,5 +7,5 @@ from .base import OperationBase
|
||||
from .commands import *
|
||||
|
||||
__all__ = [
|
||||
'OperationBase',
|
||||
]
|
||||
"OperationBase",
|
||||
]
|
||||
|
||||
@@ -19,61 +19,65 @@ def get_command_info():
|
||||
"install": {
|
||||
"name": "install",
|
||||
"description": "Install SuperClaude framework components",
|
||||
"module": "setup.cli.commands.install"
|
||||
"module": "setup.cli.commands.install",
|
||||
},
|
||||
"update": {
|
||||
"name": "update",
|
||||
"name": "update",
|
||||
"description": "Update existing SuperClaude installation",
|
||||
"module": "setup.cli.commands.update"
|
||||
"module": "setup.cli.commands.update",
|
||||
},
|
||||
"uninstall": {
|
||||
"name": "uninstall",
|
||||
"description": "Remove SuperClaude framework installation",
|
||||
"module": "setup.cli.commands.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"
|
||||
}
|
||||
"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:
|
||||
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)
|
||||
|
||||
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 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
|
||||
return 1
|
||||
|
||||
@@ -10,9 +10,9 @@ from .update import UpdateOperation
|
||||
from .backup import BackupOperation
|
||||
|
||||
__all__ = [
|
||||
'OperationBase',
|
||||
'InstallOperation',
|
||||
'UninstallOperation',
|
||||
'UpdateOperation',
|
||||
'BackupOperation'
|
||||
]
|
||||
"OperationBase",
|
||||
"InstallOperation",
|
||||
"UninstallOperation",
|
||||
"UpdateOperation",
|
||||
"BackupOperation",
|
||||
]
|
||||
|
||||
@@ -9,14 +9,22 @@ import tarfile
|
||||
import json
|
||||
from pathlib import Path
|
||||
from ...utils.paths import get_home_directory
|
||||
from datetime import datetime
|
||||
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
|
||||
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
|
||||
@@ -25,7 +33,7 @@ from . import OperationBase
|
||||
|
||||
class BackupOperation(OperationBase):
|
||||
"""Backup operation implementation"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("backup")
|
||||
|
||||
@@ -33,7 +41,7 @@ class BackupOperation(OperationBase):
|
||||
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",
|
||||
@@ -48,84 +56,70 @@ Examples:
|
||||
SuperClaude backup --cleanup --force # Clean up old backups (forced)
|
||||
""",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
parents=parents
|
||||
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"
|
||||
"--create", action="store_true", help="Create a new backup"
|
||||
)
|
||||
|
||||
|
||||
operation_group.add_argument(
|
||||
"--list",
|
||||
action="store_true",
|
||||
help="List available backups"
|
||||
"--list", action="store_true", help="List available backups"
|
||||
)
|
||||
|
||||
|
||||
operation_group.add_argument(
|
||||
"--restore",
|
||||
nargs="?",
|
||||
const="interactive",
|
||||
help="Restore from backup (optionally specify backup file)"
|
||||
help="Restore from backup (optionally specify backup file)",
|
||||
)
|
||||
|
||||
|
||||
operation_group.add_argument(
|
||||
"--info",
|
||||
type=str,
|
||||
help="Show information about a specific backup file"
|
||||
"--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"
|
||||
"--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)"
|
||||
help="Backup directory (default: <install-dir>/backups)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
type=str,
|
||||
help="Custom backup name (for --create)"
|
||||
)
|
||||
|
||||
|
||||
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)"
|
||||
help="Compression method (default: gzip)",
|
||||
)
|
||||
|
||||
|
||||
# Restore options
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="Overwrite existing files during restore"
|
||||
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)"
|
||||
help="Number of backups to keep during cleanup (default: 5)",
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
"--older-than",
|
||||
type=int,
|
||||
help="Remove backups older than N days"
|
||||
"--older-than", type=int, help="Remove backups older than N days"
|
||||
)
|
||||
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
@@ -141,7 +135,10 @@ 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()
|
||||
return (
|
||||
settings_manager.check_installation_exists()
|
||||
or settings_manager.check_v2_installation_exists()
|
||||
)
|
||||
|
||||
|
||||
def get_backup_info(backup_path: Path) -> Dict[str, Any]:
|
||||
@@ -151,18 +148,18 @@ def get_backup_info(backup_path: Path) -> Dict[str, Any]:
|
||||
"exists": backup_path.exists(),
|
||||
"size": 0,
|
||||
"created": None,
|
||||
"metadata": {}
|
||||
"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"
|
||||
@@ -170,7 +167,7 @@ def get_backup_info(backup_path: Path) -> Dict[str, Any]:
|
||||
mode = "r:bz2"
|
||||
else:
|
||||
mode = "r"
|
||||
|
||||
|
||||
with tarfile.open(backup_path, mode) as tar:
|
||||
# Look for metadata file
|
||||
try:
|
||||
@@ -180,32 +177,32 @@ def get_backup_info(backup_path: Path) -> Dict[str, Any]:
|
||||
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
|
||||
|
||||
|
||||
@@ -213,43 +210,49 @@ 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"
|
||||
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"
|
||||
"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)
|
||||
@@ -257,31 +260,31 @@ def create_backup_metadata(install_dir: Path) -> Dict[str, Any]:
|
||||
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"
|
||||
@@ -292,24 +295,27 @@ def create_backup(args: argparse.Namespace) -> bool:
|
||||
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:
|
||||
|
||||
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("*"):
|
||||
@@ -317,30 +323,30 @@ def create_backup(args: argparse.Namespace) -> bool:
|
||||
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
|
||||
@@ -349,20 +355,20 @@ def create_backup(args: argparse.Namespace) -> bool:
|
||||
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"
|
||||
@@ -370,47 +376,47 @@ def restore_backup(backup_path: Path, args: argparse.Namespace) -> bool:
|
||||
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
|
||||
@@ -421,69 +427,73 @@ def interactive_restore_selection(backups: List[Dict[str, Any]]) -> Optional[Pat
|
||||
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"
|
||||
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:])
|
||||
|
||||
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
|
||||
@@ -503,7 +513,7 @@ def run(args: argparse.Namespace) -> int:
|
||||
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)
|
||||
@@ -511,26 +521,27 @@ def run(args: argparse.Namespace) -> int:
|
||||
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 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
|
||||
@@ -544,14 +555,14 @@ def run(args: argparse.Namespace) -> int:
|
||||
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}")
|
||||
@@ -559,10 +570,12 @@ def run(args: argparse.Namespace) -> int:
|
||||
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')}")
|
||||
print(
|
||||
f"Framework Version: {metadata.get('framework_version', 'unknown')}"
|
||||
)
|
||||
if metadata.get("components"):
|
||||
print("Components:")
|
||||
for comp, ver in metadata["components"].items():
|
||||
@@ -571,14 +584,14 @@ def run(args: argparse.Namespace) -> int:
|
||||
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!")
|
||||
@@ -588,7 +601,7 @@ def run(args: argparse.Namespace) -> int:
|
||||
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
|
||||
|
||||
@@ -15,8 +15,17 @@ 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
|
||||
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
|
||||
@@ -26,7 +35,7 @@ from . import OperationBase
|
||||
|
||||
class InstallOperation(OperationBase):
|
||||
"""Installation operation implementation"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("install")
|
||||
|
||||
@@ -34,7 +43,7 @@ class InstallOperation(OperationBase):
|
||||
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",
|
||||
@@ -47,54 +56,51 @@ Examples:
|
||||
SuperClaude install --verbose --force # Verbose with force mode
|
||||
""",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
parents=parents
|
||||
parents=parents,
|
||||
)
|
||||
|
||||
|
||||
# Installation mode options
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
"--components",
|
||||
type=str,
|
||||
nargs="+",
|
||||
help="Specific components to install"
|
||||
"--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("--no-backup", action="store_true", help="Skip backup creation")
|
||||
|
||||
parser.add_argument(
|
||||
"--list-components",
|
||||
action="store_true",
|
||||
help="List available components and exit"
|
||||
help="List available components and exit",
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
"--diagnose",
|
||||
action="store_true",
|
||||
help="Run system diagnostics and show installation help"
|
||||
help="Run system diagnostics and show installation help",
|
||||
)
|
||||
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def validate_system_requirements(validator: Validator, component_names: List[str]) -> bool:
|
||||
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)
|
||||
|
||||
success, errors = validator.validate_component_requirements(
|
||||
component_names, requirements
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.success("All system requirements met")
|
||||
return True
|
||||
@@ -102,67 +108,79 @@ def validate_system_requirements(validator: Validator, component_names: List[str
|
||||
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(
|
||||
" 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]]:
|
||||
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:
|
||||
if "all" in args.components:
|
||||
components = ["core", "commands", "agents", "modes", "mcp", "mcp_docs"]
|
||||
else:
|
||||
components = args.components
|
||||
|
||||
# If mcp or mcp_docs is specified non-interactively, we should still ask which servers to install.
|
||||
if 'mcp' in components or 'mcp_docs' in components:
|
||||
if "mcp" in components or "mcp_docs" in components:
|
||||
selected_servers = select_mcp_servers(registry)
|
||||
if not hasattr(config_manager, '_installation_context'):
|
||||
if not hasattr(config_manager, "_installation_context"):
|
||||
config_manager._installation_context = {}
|
||||
config_manager._installation_context["selected_mcp_servers"] = selected_servers
|
||||
config_manager._installation_context["selected_mcp_servers"] = (
|
||||
selected_servers
|
||||
)
|
||||
|
||||
# If the user selected some servers, ensure both mcp and mcp_docs are included
|
||||
if selected_servers:
|
||||
if 'mcp' not in components:
|
||||
components.append('mcp')
|
||||
logger.debug(f"Auto-added 'mcp' component for selected servers: {selected_servers}")
|
||||
if 'mcp_docs' not in components:
|
||||
components.append('mcp_docs')
|
||||
logger.debug(f"Auto-added 'mcp_docs' component for selected servers: {selected_servers}")
|
||||
if "mcp" not in components:
|
||||
components.append("mcp")
|
||||
logger.debug(
|
||||
f"Auto-added 'mcp' component for selected servers: {selected_servers}"
|
||||
)
|
||||
if "mcp_docs" not in components:
|
||||
components.append("mcp_docs")
|
||||
logger.debug(
|
||||
f"Auto-added 'mcp_docs' component for selected servers: {selected_servers}"
|
||||
)
|
||||
|
||||
logger.info(f"Final components to install: {components}")
|
||||
|
||||
# If mcp_docs was explicitly requested but no servers selected, allow auto-detection
|
||||
elif not selected_servers and 'mcp_docs' in components:
|
||||
elif not selected_servers and "mcp_docs" in components:
|
||||
logger.info("mcp_docs component will auto-detect existing MCP servers")
|
||||
logger.info("Documentation will be installed for any detected servers")
|
||||
|
||||
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]:
|
||||
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
|
||||
"""
|
||||
@@ -170,132 +188,164 @@ def collect_api_keys_for_servers(selected_servers: List[str], mcp_instance) -> D
|
||||
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 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")
|
||||
|
||||
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", get_home_directory() / ".claude")
|
||||
if not mcp_instance or not hasattr(mcp_instance, 'mcp_servers'):
|
||||
mcp_instance = registry.get_component_instance(
|
||||
"mcp", get_home_directory() / ".claude"
|
||||
)
|
||||
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 ""
|
||||
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}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}")
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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]:
|
||||
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 = ["core", "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
|
||||
|
||||
|
||||
# Add MCP documentation option
|
||||
if selected_mcp_servers:
|
||||
mcp_docs_desc = f"MCP documentation for {', '.join(selected_mcp_servers)} (auto-selected)"
|
||||
component_options.append(f"mcp_docs - {mcp_docs_desc}")
|
||||
auto_selected_mcp_docs = True
|
||||
else:
|
||||
component_options.append("mcp_docs - MCP server documentation (none selected)")
|
||||
component_options.append(
|
||||
"mcp_docs - MCP server documentation (none selected)"
|
||||
)
|
||||
auto_selected_mcp_docs = False
|
||||
|
||||
|
||||
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}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)
|
||||
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 core if nothing selected
|
||||
logger.info("No components selected, defaulting to core")
|
||||
@@ -303,11 +353,11 @@ def select_framework_components(registry: ComponentRegistry, config_manager: Con
|
||||
else:
|
||||
selected_components = []
|
||||
all_components = framework_components + ["mcp_docs"]
|
||||
|
||||
|
||||
for i in selections:
|
||||
if i < len(all_components):
|
||||
selected_components.append(all_components[i])
|
||||
|
||||
|
||||
# Auto-select MCP docs if not explicitly deselected and we have MCP servers
|
||||
if auto_selected_mcp_docs and "mcp_docs" not in selected_components:
|
||||
# Check if user explicitly deselected it
|
||||
@@ -316,82 +366,96 @@ def select_framework_components(registry: ComponentRegistry, config_manager: Con
|
||||
# User didn't select it, but we auto-select it
|
||||
selected_components.append("mcp_docs")
|
||||
logger.info("Auto-selected MCP documentation for configured servers")
|
||||
|
||||
|
||||
# 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 ["core"] # Fallback to core
|
||||
|
||||
|
||||
def interactive_component_selection(registry: ComponentRegistry, config_manager: ConfigService) -> Optional[List[str]]:
|
||||
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}")
|
||||
|
||||
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)
|
||||
|
||||
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'):
|
||||
if not hasattr(config_manager, "_installation_context"):
|
||||
config_manager._installation_context = {}
|
||||
config_manager._installation_context["selected_mcp_servers"] = selected_mcp_servers
|
||||
|
||||
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:
|
||||
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'):
|
||||
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(
|
||||
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
|
||||
@@ -400,101 +464,113 @@ def display_installation_plan(components: List[str], registry: ComponentRegistry
|
||||
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':
|
||||
|
||||
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']:
|
||||
if diagnostics["issues"]:
|
||||
print(f"\n{Colors.YELLOW}Issues Found:{Colors.RESET}")
|
||||
for issue in diagnostics['issues']:
|
||||
for issue in diagnostics["issues"]:
|
||||
print(f" ⚠️ {issue}")
|
||||
|
||||
|
||||
print(f"\n{Colors.CYAN}Recommendations:{Colors.RESET}")
|
||||
for recommendation in diagnostics['recommendations']:
|
||||
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}")
|
||||
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.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)")
|
||||
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")
|
||||
print(" 3. Run 'superclaude install --diagnose' again to verify")
|
||||
|
||||
|
||||
def perform_installation(components: List[str], args: argparse.Namespace, config_manager: ConfigService = None) -> bool:
|
||||
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)
|
||||
|
||||
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=""
|
||||
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,
|
||||
"selected_mcp_servers": getattr(config_manager, '_installation_context', {}).get("selected_mcp_servers", [])
|
||||
"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:
|
||||
@@ -502,32 +578,36 @@ def perform_installation(components: List[str], args: argparse.Namespace, config
|
||||
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")
|
||||
|
||||
logger.success(
|
||||
f"Installation completed successfully in {duration:.1f} seconds"
|
||||
)
|
||||
|
||||
# Show summary
|
||||
summary = installer.get_installation_summary()
|
||||
if summary['installed']:
|
||||
if summary["installed"]:
|
||||
logger.info(f"Installed components: {', '.join(summary['installed'])}")
|
||||
|
||||
if summary['backup_path']:
|
||||
|
||||
if summary["backup_path"]:
|
||||
logger.info(f"Backup created: {summary['backup_path']}")
|
||||
|
||||
|
||||
else:
|
||||
logger.error(f"Installation completed with errors in {duration:.1f} seconds")
|
||||
|
||||
logger.error(
|
||||
f"Installation completed with errors in {duration:.1f} seconds"
|
||||
)
|
||||
|
||||
summary = installer.get_installation_summary()
|
||||
if summary['failed']:
|
||||
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
|
||||
@@ -547,18 +627,18 @@ def run(args: argparse.Namespace) -> int:
|
||||
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):]
|
||||
|
||||
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():
|
||||
@@ -575,7 +655,7 @@ def run(args: argparse.Namespace) -> int:
|
||||
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)
|
||||
@@ -583,20 +663,21 @@ def run(args: argparse.Namespace) -> int:
|
||||
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"
|
||||
"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}")
|
||||
@@ -611,22 +692,22 @@ def run(args: argparse.Namespace) -> int:
|
||||
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:
|
||||
@@ -634,9 +715,11 @@ def run(args: argparse.Namespace) -> int:
|
||||
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)
|
||||
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
|
||||
@@ -647,50 +730,58 @@ def run(args: argparse.Namespace) -> int:
|
||||
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")
|
||||
|
||||
logger.warning(
|
||||
"System requirements not met, but continuing due to --force flag"
|
||||
)
|
||||
|
||||
# Check for existing installation
|
||||
if args.install_dir.exists() and not args.force:
|
||||
if not args.dry_run:
|
||||
logger.warning(f"Installation directory already exists: {args.install_dir}")
|
||||
if not args.yes and not confirm("Continue and update existing installation?", default=False):
|
||||
logger.warning(
|
||||
f"Installation directory already exists: {args.install_dir}"
|
||||
)
|
||||
if not args.yes and not confirm(
|
||||
"Continue and update existing installation?", default=False
|
||||
):
|
||||
logger.info("Installation cancelled by user")
|
||||
return 0
|
||||
|
||||
|
||||
# Display installation plan
|
||||
if not args.quiet:
|
||||
display_installation_plan(resolved_components, registry, args.install_dir)
|
||||
|
||||
|
||||
if not args.dry_run:
|
||||
if not args.yes and not confirm("Proceed with installation?", default=True):
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,8 +15,17 @@ 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
|
||||
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
|
||||
@@ -26,7 +35,7 @@ from . import OperationBase
|
||||
|
||||
class UpdateOperation(OperationBase):
|
||||
"""Update operation implementation"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("update")
|
||||
|
||||
@@ -34,7 +43,7 @@ class UpdateOperation(OperationBase):
|
||||
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",
|
||||
@@ -47,51 +56,44 @@ Examples:
|
||||
SuperClaude update --backup --force # Create backup before update (forced)
|
||||
""",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
parents=parents
|
||||
parents=parents,
|
||||
)
|
||||
|
||||
|
||||
# Update mode options
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Check for available updates without installing"
|
||||
help="Check for available updates without installing",
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
"--components",
|
||||
type=str,
|
||||
nargs="+",
|
||||
help="Specific components to update"
|
||||
"--components", type=str, nargs="+", help="Specific components to update"
|
||||
)
|
||||
|
||||
|
||||
# Backup options
|
||||
parser.add_argument(
|
||||
"--backup",
|
||||
action="store_true",
|
||||
help="Create backup before update"
|
||||
"--backup", action="store_true", help="Create backup before update"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--no-backup",
|
||||
action="store_true",
|
||||
help="Skip backup creation"
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
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:
|
||||
@@ -101,10 +103,12 @@ def get_installed_components(install_dir: Path) -> Dict[str, Dict[str, Any]]:
|
||||
return {}
|
||||
|
||||
|
||||
def get_available_updates(installed_components: Dict[str, str], registry: ComponentRegistry) -> Dict[str, Dict[str, str]]:
|
||||
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)
|
||||
@@ -114,27 +118,29 @@ def get_available_updates(installed_components: Dict[str, str], registry: Compon
|
||||
updates[component_name] = {
|
||||
"current": current_version,
|
||||
"available": available_version,
|
||||
"description": metadata.get("description", "No description")
|
||||
"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:
|
||||
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():
|
||||
@@ -142,47 +148,54 @@ def display_update_check(installed_components: Dict[str, str], available_updates
|
||||
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]]:
|
||||
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]
|
||||
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]:
|
||||
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
|
||||
"""
|
||||
@@ -190,81 +203,90 @@ def collect_api_keys_for_servers(selected_servers: List[str], mcp_instance) -> D
|
||||
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 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")
|
||||
|
||||
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]]:
|
||||
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"
|
||||
"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)
|
||||
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:
|
||||
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]
|
||||
@@ -272,72 +294,80 @@ def display_update_plan(components: List[str], available_updates: Dict[str, Dict
|
||||
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:
|
||||
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)
|
||||
|
||||
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'):
|
||||
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)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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=""
|
||||
)
|
||||
|
||||
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 []
|
||||
"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:
|
||||
@@ -345,32 +375,32 @@ def perform_update(components: List[str], args: argparse.Namespace, registry: Co
|
||||
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'):
|
||||
if summary.get("updated"):
|
||||
logger.info(f"Updated components: {', '.join(summary['updated'])}")
|
||||
|
||||
if summary.get('backup_path'):
|
||||
|
||||
if summary.get("backup_path"):
|
||||
logger.info(f"Backup created: {summary['backup_path']}")
|
||||
|
||||
|
||||
else:
|
||||
logger.error(f"Update completed with errors in {duration:.1f} seconds")
|
||||
|
||||
|
||||
summary = installer.get_update_summary()
|
||||
if summary.get('failed'):
|
||||
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
|
||||
@@ -393,7 +423,7 @@ def run(args: argparse.Namespace) -> int:
|
||||
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)
|
||||
@@ -401,79 +431,83 @@ def run(args: argparse.Namespace) -> int:
|
||||
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"
|
||||
"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")
|
||||
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)
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
@@ -8,10 +8,10 @@ from .modes import ModesComponent
|
||||
from .mcp_docs import MCPDocsComponent
|
||||
|
||||
__all__ = [
|
||||
'CoreComponent',
|
||||
'CommandsComponent',
|
||||
'MCPComponent',
|
||||
'AgentsComponent',
|
||||
'ModesComponent',
|
||||
'MCPDocsComponent'
|
||||
]
|
||||
"CoreComponent",
|
||||
"CommandsComponent",
|
||||
"MCPComponent",
|
||||
"AgentsComponent",
|
||||
"ModesComponent",
|
||||
"MCPDocsComponent",
|
||||
]
|
||||
|
||||
@@ -11,20 +11,20 @@ from setup import __version__
|
||||
|
||||
class AgentsComponent(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"
|
||||
"category": "agents",
|
||||
}
|
||||
|
||||
|
||||
def get_metadata_modifications(self) -> Dict[str, Any]:
|
||||
"""Get metadata modifications for agents"""
|
||||
return {
|
||||
@@ -33,27 +33,29 @@ class AgentsComponent(Component):
|
||||
"version": __version__,
|
||||
"installed": True,
|
||||
"agents_count": len(self.component_files),
|
||||
"install_directory": str(self.install_component_subdir)
|
||||
"install_directory": str(self.install_component_subdir),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _install(self, config: Dict[str, Any]) -> bool:
|
||||
"""Install agents component"""
|
||||
self.logger.info("Installing SuperClaude specialized agents...")
|
||||
|
||||
|
||||
# Call parent install method
|
||||
success = super()._install(config)
|
||||
|
||||
|
||||
if success:
|
||||
# Run post-install setup
|
||||
success = self._post_install()
|
||||
|
||||
|
||||
if success:
|
||||
self.logger.success(f"Successfully installed {len(self.component_files)} specialized agents")
|
||||
|
||||
self.logger.success(
|
||||
f"Successfully installed {len(self.component_files)} specialized agents"
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def _post_install(self) -> bool:
|
||||
"""Post-install setup for agents"""
|
||||
try:
|
||||
@@ -61,27 +63,30 @@ class AgentsComponent(Component):
|
||||
metadata_mods = self.get_metadata_modifications()
|
||||
self.settings_manager.update_metadata(metadata_mods)
|
||||
self.logger.info("Updated metadata with agents configuration")
|
||||
|
||||
|
||||
# Add component registration
|
||||
self.settings_manager.add_component_registration("agents", {
|
||||
"version": __version__,
|
||||
"category": "agents",
|
||||
"agents_count": len(self.component_files),
|
||||
"agents_list": self.component_files
|
||||
})
|
||||
|
||||
self.settings_manager.add_component_registration(
|
||||
"agents",
|
||||
{
|
||||
"version": __version__,
|
||||
"category": "agents",
|
||||
"agents_count": len(self.component_files),
|
||||
"agents_list": self.component_files,
|
||||
},
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -91,15 +96,17 @@ class AgentsComponent(Component):
|
||||
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()):
|
||||
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"):
|
||||
@@ -107,33 +114,39 @@ class AgentsComponent(Component):
|
||||
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)")
|
||||
|
||||
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 ["core"]
|
||||
|
||||
|
||||
def update(self, config: Dict[str, Any]) -> bool:
|
||||
"""Update agents component"""
|
||||
try:
|
||||
self.logger.info("Updating SuperClaude agents component...")
|
||||
|
||||
|
||||
# Check current version
|
||||
current_version = self.settings_manager.get_component_version("agents")
|
||||
target_version = self.get_metadata()["version"]
|
||||
|
||||
|
||||
if current_version == target_version:
|
||||
self.logger.info(f"Agents component already at version {target_version}")
|
||||
self.logger.info(
|
||||
f"Agents component already at version {target_version}"
|
||||
)
|
||||
return True
|
||||
|
||||
self.logger.info(f"Updating agents component from {current_version} to {target_version}")
|
||||
|
||||
|
||||
self.logger.info(
|
||||
f"Updating agents component from {current_version} to {target_version}"
|
||||
)
|
||||
|
||||
# Create backup of existing agents
|
||||
backup_files = []
|
||||
for filename in self.component_files:
|
||||
@@ -143,49 +156,54 @@ class AgentsComponent(Component):
|
||||
if backup_path:
|
||||
backup_files.append(backup_path)
|
||||
self.logger.debug(f"Backed up agent: {filename}")
|
||||
|
||||
|
||||
# Perform installation (will overwrite existing files)
|
||||
if self._install(config):
|
||||
self.logger.success(f"Agents component updated to version {target_version}")
|
||||
self.logger.success(
|
||||
f"Agents component updated to version {target_version}"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
# Restore backups on failure
|
||||
self.logger.error("Agents update failed, restoring backups...")
|
||||
for backup_path in backup_files:
|
||||
try:
|
||||
original_path = self.install_component_subdir / backup_path.name.replace('.backup', '')
|
||||
original_path = (
|
||||
self.install_component_subdir
|
||||
/ backup_path.name.replace(".backup", "")
|
||||
)
|
||||
self.file_manager.copy_file(backup_path, original_path)
|
||||
self.logger.debug(f"Restored {original_path.name}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not restore {backup_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Unexpected error during agents update: {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/
|
||||
# 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"
|
||||
|
||||
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 {
|
||||
@@ -195,46 +213,48 @@ class AgentsComponent(Component):
|
||||
"agent_files": self.component_files,
|
||||
"estimated_size": self.get_size_estimate(),
|
||||
"install_directory": str(self.install_component_subdir),
|
||||
"dependencies": self.get_dependencies()
|
||||
"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}")
|
||||
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",
|
||||
"frontend-architect.md",
|
||||
"backend-architect.md",
|
||||
"security-engineer.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
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
@@ -8,22 +8,23 @@ from pathlib import Path
|
||||
from ..core.base import Component
|
||||
from setup import __version__
|
||||
|
||||
|
||||
class CommandsComponent(Component):
|
||||
"""SuperClaude slash commands component"""
|
||||
|
||||
|
||||
def __init__(self, install_dir: Optional[Path] = None):
|
||||
"""Initialize commands component"""
|
||||
super().__init__(install_dir, Path("commands/sc"))
|
||||
|
||||
|
||||
def get_metadata(self) -> Dict[str, str]:
|
||||
"""Get component metadata"""
|
||||
return {
|
||||
"name": "commands",
|
||||
"version": __version__,
|
||||
"description": "SuperClaude slash command definitions",
|
||||
"category": "commands"
|
||||
"category": "commands",
|
||||
}
|
||||
|
||||
|
||||
def get_metadata_modifications(self) -> Dict[str, Any]:
|
||||
"""Get metadata modifications for commands component"""
|
||||
return {
|
||||
@@ -31,16 +32,12 @@ class CommandsComponent(Component):
|
||||
"commands": {
|
||||
"version": __version__,
|
||||
"installed": True,
|
||||
"files_count": len(self.component_files)
|
||||
"files_count": len(self.component_files),
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
"enabled": True,
|
||||
"version": __version__,
|
||||
"auto_update": False
|
||||
}
|
||||
"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...")
|
||||
@@ -48,7 +45,7 @@ class CommandsComponent(Component):
|
||||
# Check for and migrate existing commands from old location
|
||||
self._migrate_existing_commands()
|
||||
|
||||
return super()._install(config);
|
||||
return super()._install(config)
|
||||
|
||||
def _post_install(self) -> bool:
|
||||
# Update metadata
|
||||
@@ -58,27 +55,30 @@ class CommandsComponent(Component):
|
||||
self.logger.info("Updated metadata with commands configuration")
|
||||
|
||||
# Add component registration to metadata
|
||||
self.settings_manager.add_component_registration("commands", {
|
||||
"version": __version__,
|
||||
"category": "commands",
|
||||
"files_count": len(self.component_files)
|
||||
})
|
||||
self.settings_manager.add_component_registration(
|
||||
"commands",
|
||||
{
|
||||
"version": __version__,
|
||||
"category": "commands",
|
||||
"files_count": len(self.component_files),
|
||||
},
|
||||
)
|
||||
self.logger.info("Updated metadata with commands component registration")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to update metadata: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def uninstall(self) -> bool:
|
||||
"""Uninstall commands component"""
|
||||
try:
|
||||
self.logger.info("Uninstalling SuperClaude commands component...")
|
||||
|
||||
|
||||
# Remove command files from sc subdirectory
|
||||
commands_dir = self.install_dir / "commands" / "sc"
|
||||
removed_count = 0
|
||||
|
||||
|
||||
for filename in self.component_files:
|
||||
file_path = commands_dir / filename
|
||||
if self.file_manager.remove_file(file_path):
|
||||
@@ -86,11 +86,11 @@ class CommandsComponent(Component):
|
||||
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():
|
||||
@@ -99,12 +99,14 @@ class CommandsComponent(Component):
|
||||
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")
|
||||
|
||||
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():
|
||||
@@ -112,17 +114,19 @@ class CommandsComponent(Component):
|
||||
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")
|
||||
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"):
|
||||
@@ -135,37 +139,45 @@ class CommandsComponent(Component):
|
||||
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)")
|
||||
|
||||
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}")
|
||||
self.logger.exception(
|
||||
f"Unexpected error during commands uninstallation: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def get_dependencies(self) -> List[str]:
|
||||
"""Get dependencies"""
|
||||
return ["core"]
|
||||
|
||||
|
||||
def update(self, config: Dict[str, Any]) -> bool:
|
||||
"""Update commands component"""
|
||||
try:
|
||||
self.logger.info("Updating SuperClaude commands component...")
|
||||
|
||||
|
||||
# Check current version
|
||||
current_version = self.settings_manager.get_component_version("commands")
|
||||
target_version = self.get_metadata()["version"]
|
||||
|
||||
|
||||
if current_version == target_version:
|
||||
self.logger.info(f"Commands component already at version {target_version}")
|
||||
self.logger.info(
|
||||
f"Commands component already at version {target_version}"
|
||||
)
|
||||
return True
|
||||
|
||||
self.logger.info(f"Updating commands component from {current_version} to {target_version}")
|
||||
|
||||
|
||||
self.logger.info(
|
||||
f"Updating commands component from {current_version} to {target_version}"
|
||||
)
|
||||
|
||||
# Create backup of existing command files
|
||||
commands_dir = self.install_dir / "commands" / "sc"
|
||||
backup_files = []
|
||||
|
||||
|
||||
if commands_dir.exists():
|
||||
for filename in self.component_files:
|
||||
file_path = commands_dir / filename
|
||||
@@ -174,10 +186,10 @@ class CommandsComponent(Component):
|
||||
if backup_path:
|
||||
backup_files.append(backup_path)
|
||||
self.logger.debug(f"Backed up {filename}")
|
||||
|
||||
|
||||
# Perform installation (overwrites existing files)
|
||||
success = self.install(config)
|
||||
|
||||
|
||||
if success:
|
||||
# Remove backup files on successful update
|
||||
for backup_path in backup_files:
|
||||
@@ -185,35 +197,37 @@ class CommandsComponent(Component):
|
||||
backup_path.unlink()
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
self.logger.success(f"Commands component updated to version {target_version}")
|
||||
|
||||
self.logger.success(
|
||||
f"Commands component updated to version {target_version}"
|
||||
)
|
||||
else:
|
||||
# Restore from backup on failure
|
||||
self.logger.warning("Update failed, restoring from backup...")
|
||||
for backup_path in backup_files:
|
||||
try:
|
||||
original_path = backup_path.with_suffix('')
|
||||
original_path = backup_path.with_suffix("")
|
||||
backup_path.rename(original_path)
|
||||
self.logger.debug(f"Restored {original_path.name}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Could not restore {backup_path}: {e}")
|
||||
|
||||
|
||||
return success
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Unexpected error during commands update: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def validate_installation(self) -> Tuple[bool, List[str]]:
|
||||
"""Validate commands component installation"""
|
||||
errors = []
|
||||
|
||||
|
||||
# Check if sc commands directory exists
|
||||
commands_dir = self.install_dir / "commands" / "sc"
|
||||
if not commands_dir.exists():
|
||||
errors.append("SC commands directory not found")
|
||||
return False, errors
|
||||
|
||||
|
||||
# Check if all command files exist
|
||||
for filename in self.component_files:
|
||||
file_path = commands_dir / filename
|
||||
@@ -221,7 +235,7 @@ class CommandsComponent(Component):
|
||||
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")
|
||||
@@ -230,32 +244,34 @@ class CommandsComponent(Component):
|
||||
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}")
|
||||
|
||||
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/
|
||||
# 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"
|
||||
|
||||
return project_root / "superclaude" / "commands"
|
||||
|
||||
def get_size_estimate(self) -> int:
|
||||
"""Get estimated installation size"""
|
||||
total_size = 0
|
||||
source_dir = self._get_source_dir()
|
||||
|
||||
|
||||
for filename in self.component_files:
|
||||
file_path = source_dir / filename
|
||||
if file_path.exists():
|
||||
total_size += file_path.stat().st_size
|
||||
|
||||
|
||||
# Add overhead for directory and settings
|
||||
total_size += 5120 # ~5KB overhead
|
||||
|
||||
|
||||
return total_size
|
||||
|
||||
|
||||
def get_installation_summary(self) -> Dict[str, Any]:
|
||||
"""Get installation summary"""
|
||||
return {
|
||||
@@ -265,66 +281,84 @@ class CommandsComponent(Component):
|
||||
"command_files": self.component_files,
|
||||
"estimated_size": self.get_size_estimate(),
|
||||
"install_directory": str(self.install_dir / "commands" / "sc"),
|
||||
"dependencies": self.get_dependencies()
|
||||
"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")
|
||||
|
||||
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}")
|
||||
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")
|
||||
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")
|
||||
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.")
|
||||
|
||||
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()]
|
||||
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")
|
||||
self.logger.debug(
|
||||
"Removed empty old commands directory"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not remove old commands directory: {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}")
|
||||
|
||||
@@ -10,45 +10,46 @@ from ..core.base import Component
|
||||
from ..services.claude_md import CLAUDEMdService
|
||||
from setup import __version__
|
||||
|
||||
|
||||
class CoreComponent(Component):
|
||||
"""Core SuperClaude framework files component"""
|
||||
|
||||
|
||||
def __init__(self, install_dir: Optional[Path] = None):
|
||||
"""Initialize core component"""
|
||||
super().__init__(install_dir)
|
||||
|
||||
|
||||
def get_metadata(self) -> Dict[str, str]:
|
||||
"""Get component metadata"""
|
||||
return {
|
||||
"name": "core",
|
||||
"version": __version__,
|
||||
"description": "SuperClaude framework documentation and core files",
|
||||
"category": "core"
|
||||
"category": "core",
|
||||
}
|
||||
|
||||
|
||||
def get_metadata_modifications(self) -> Dict[str, Any]:
|
||||
"""Get metadata modifications for SuperClaude"""
|
||||
return {
|
||||
"framework": {
|
||||
"version": __version__,
|
||||
"name": "SuperClaude",
|
||||
"name": "superclaude",
|
||||
"description": "AI-enhanced development framework for Claude Code",
|
||||
"installation_type": "global",
|
||||
"components": ["core"]
|
||||
"components": ["core"],
|
||||
},
|
||||
"superclaude": {
|
||||
"enabled": True,
|
||||
"version": __version__,
|
||||
"profile": "default",
|
||||
"auto_update": False
|
||||
}
|
||||
"auto_update": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _install(self, config: Dict[str, Any]) -> bool:
|
||||
"""Install core component"""
|
||||
self.logger.info("Installing SuperClaude core framework files...")
|
||||
|
||||
return super()._install(config);
|
||||
return super()._install(config)
|
||||
|
||||
def _post_install(self) -> bool:
|
||||
# Create or update metadata
|
||||
@@ -56,19 +57,24 @@ class CoreComponent(Component):
|
||||
metadata_mods = self.get_metadata_modifications()
|
||||
self.settings_manager.update_metadata(metadata_mods)
|
||||
self.logger.info("Updated metadata with framework configuration")
|
||||
|
||||
|
||||
# Add component registration to metadata
|
||||
self.settings_manager.add_component_registration("core", {
|
||||
"version": __version__,
|
||||
"category": "core",
|
||||
"files_count": len(self.component_files)
|
||||
})
|
||||
self.settings_manager.add_component_registration(
|
||||
"core",
|
||||
{
|
||||
"version": __version__,
|
||||
"category": "core",
|
||||
"files_count": len(self.component_files),
|
||||
},
|
||||
)
|
||||
|
||||
self.logger.info("Updated metadata with core component registration")
|
||||
|
||||
|
||||
# Migrate any existing SuperClaude data from settings.json
|
||||
if self.settings_manager.migrate_superclaude_data():
|
||||
self.logger.info("Migrated existing SuperClaude data from settings.json")
|
||||
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
|
||||
@@ -79,24 +85,25 @@ class CoreComponent(Component):
|
||||
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 core framework imports
|
||||
try:
|
||||
manager = CLAUDEMdService(self.install_dir)
|
||||
manager.add_imports(self.component_files, category="Core Framework")
|
||||
self.logger.info("Updated CLAUDE.md with core framework imports")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to update CLAUDE.md with core framework imports: {e}")
|
||||
self.logger.warning(
|
||||
f"Failed to update CLAUDE.md with core framework imports: {e}"
|
||||
)
|
||||
# Don't fail the whole installation for this
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def uninstall(self) -> bool:
|
||||
"""Uninstall core component"""
|
||||
try:
|
||||
self.logger.info("Uninstalling SuperClaude core component...")
|
||||
|
||||
|
||||
# Remove framework files
|
||||
removed_count = 0
|
||||
for filename in self.component_files:
|
||||
@@ -106,7 +113,7 @@ class CoreComponent(Component):
|
||||
self.logger.debug(f"Removed {filename}")
|
||||
else:
|
||||
self.logger.warning(f"Could not remove {filename}")
|
||||
|
||||
|
||||
# Update metadata to remove core component
|
||||
try:
|
||||
if self.settings_manager.is_component_installed("core"):
|
||||
@@ -121,33 +128,37 @@ class CoreComponent(Component):
|
||||
self.logger.info("Removed core component from metadata")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not update metadata: {e}")
|
||||
|
||||
self.logger.success(f"Core component uninstalled ({removed_count} files removed)")
|
||||
|
||||
self.logger.success(
|
||||
f"Core component uninstalled ({removed_count} files removed)"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Unexpected error during core uninstallation: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_dependencies(self) -> List[str]:
|
||||
"""Get component dependencies (core has none)"""
|
||||
return []
|
||||
|
||||
|
||||
def update(self, config: Dict[str, Any]) -> bool:
|
||||
"""Update core component"""
|
||||
try:
|
||||
self.logger.info("Updating SuperClaude core component...")
|
||||
|
||||
|
||||
# Check current version
|
||||
current_version = self.settings_manager.get_component_version("core")
|
||||
target_version = self.get_metadata()["version"]
|
||||
|
||||
|
||||
if current_version == target_version:
|
||||
self.logger.info(f"Core component already at version {target_version}")
|
||||
return True
|
||||
|
||||
self.logger.info(f"Updating core component from {current_version} to {target_version}")
|
||||
|
||||
|
||||
self.logger.info(
|
||||
f"Updating core component from {current_version} to {target_version}"
|
||||
)
|
||||
|
||||
# Create backup of existing files
|
||||
backup_files = []
|
||||
for filename in self.component_files:
|
||||
@@ -157,10 +168,10 @@ class CoreComponent(Component):
|
||||
if backup_path:
|
||||
backup_files.append(backup_path)
|
||||
self.logger.debug(f"Backed up {filename}")
|
||||
|
||||
|
||||
# Perform installation (overwrites existing files)
|
||||
success = self.install(config)
|
||||
|
||||
|
||||
if success:
|
||||
# Remove backup files on successful update
|
||||
for backup_path in backup_files:
|
||||
@@ -168,29 +179,31 @@ class CoreComponent(Component):
|
||||
backup_path.unlink()
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
self.logger.success(f"Core component updated to version {target_version}")
|
||||
|
||||
self.logger.success(
|
||||
f"Core component updated to version {target_version}"
|
||||
)
|
||||
else:
|
||||
# Restore from backup on failure
|
||||
self.logger.warning("Update failed, restoring from backup...")
|
||||
for backup_path in backup_files:
|
||||
try:
|
||||
original_path = backup_path.with_suffix('')
|
||||
original_path = backup_path.with_suffix("")
|
||||
shutil.move(str(backup_path), str(original_path))
|
||||
self.logger.debug(f"Restored {original_path.name}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Could not restore {backup_path}: {e}")
|
||||
|
||||
|
||||
return success
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Unexpected error during core update: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def validate_installation(self) -> Tuple[bool, List[str]]:
|
||||
"""Validate core component installation"""
|
||||
errors = []
|
||||
|
||||
|
||||
# Check if all framework files exist
|
||||
for filename in self.component_files:
|
||||
file_path = self.install_dir / filename
|
||||
@@ -198,7 +211,7 @@ class CoreComponent(Component):
|
||||
errors.append(f"Missing framework file: {filename}")
|
||||
elif not file_path.is_file():
|
||||
errors.append(f"Framework file is not a regular file: {filename}")
|
||||
|
||||
|
||||
# Check metadata registration
|
||||
if not self.settings_manager.is_component_installed("core"):
|
||||
errors.append("Core component not registered in metadata")
|
||||
@@ -207,8 +220,10 @@ class CoreComponent(Component):
|
||||
installed_version = self.settings_manager.get_component_version("core")
|
||||
expected_version = self.get_metadata()["version"]
|
||||
if installed_version != expected_version:
|
||||
errors.append(f"Version mismatch: installed {installed_version}, expected {expected_version}")
|
||||
|
||||
errors.append(
|
||||
f"Version mismatch: installed {installed_version}, expected {expected_version}"
|
||||
)
|
||||
|
||||
# Check metadata structure
|
||||
try:
|
||||
framework_config = self.settings_manager.get_metadata_setting("framework")
|
||||
@@ -221,31 +236,31 @@ class CoreComponent(Component):
|
||||
errors.append(f"Missing framework.{key} in metadata")
|
||||
except Exception as e:
|
||||
errors.append(f"Could not validate metadata: {e}")
|
||||
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
|
||||
def _get_source_dir(self):
|
||||
"""Get source directory for framework files"""
|
||||
# Assume we're in SuperClaude/setup/components/core.py
|
||||
# and framework files are in SuperClaude/SuperClaude/Core/
|
||||
# Assume we're in superclaude/setup/components/core.py
|
||||
# and framework files are in superclaude/superclaude/Core/
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
return project_root / "SuperClaude" / "Core"
|
||||
|
||||
return project_root / "superclaude" / "core"
|
||||
|
||||
def get_size_estimate(self) -> int:
|
||||
"""Get estimated installation size"""
|
||||
total_size = 0
|
||||
source_dir = self._get_source_dir()
|
||||
|
||||
|
||||
for filename in self.component_files:
|
||||
file_path = source_dir / filename
|
||||
if file_path.exists():
|
||||
total_size += file_path.stat().st_size
|
||||
|
||||
|
||||
# Add overhead for settings.json and directories
|
||||
total_size += 10240 # ~10KB overhead
|
||||
|
||||
|
||||
return total_size
|
||||
|
||||
|
||||
def get_installation_summary(self) -> Dict[str, Any]:
|
||||
"""Get installation summary"""
|
||||
return {
|
||||
@@ -255,5 +270,5 @@ class CoreComponent(Component):
|
||||
"framework_files": self.component_files,
|
||||
"estimated_size": self.get_size_estimate(),
|
||||
"install_directory": str(self.install_dir),
|
||||
"dependencies": self.get_dependencies()
|
||||
"dependencies": self.get_dependencies(),
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,13 +12,13 @@ from ..services.claude_md import CLAUDEMdService
|
||||
|
||||
class MCPDocsComponent(Component):
|
||||
"""MCP documentation component - installs docs for selected MCP servers"""
|
||||
|
||||
|
||||
def __init__(self, install_dir: Optional[Path] = None):
|
||||
"""Initialize MCP docs component"""
|
||||
# Initialize attributes before calling parent constructor
|
||||
# because parent calls _discover_component_files() which needs these
|
||||
self.selected_servers: List[str] = []
|
||||
|
||||
|
||||
# Map server names to documentation files
|
||||
self.server_docs_map = {
|
||||
"context7": "MCP_Context7.md",
|
||||
@@ -29,18 +29,18 @@ class MCPDocsComponent(Component):
|
||||
"serena": "MCP_Serena.md",
|
||||
"morphllm": "MCP_Morphllm.md",
|
||||
"morphllm-fast-apply": "MCP_Morphllm.md", # Handle both naming conventions
|
||||
"tavily": "MCP_Tavily.md"
|
||||
"tavily": "MCP_Tavily.md",
|
||||
}
|
||||
|
||||
|
||||
super().__init__(install_dir, Path(""))
|
||||
|
||||
|
||||
def get_metadata(self) -> Dict[str, str]:
|
||||
"""Get component metadata"""
|
||||
return {
|
||||
"name": "mcp_docs",
|
||||
"version": __version__,
|
||||
"description": "MCP server documentation and usage guides",
|
||||
"category": "documentation"
|
||||
"category": "documentation",
|
||||
}
|
||||
|
||||
def is_reinstallable(self) -> bool:
|
||||
@@ -54,11 +54,11 @@ class MCPDocsComponent(Component):
|
||||
"""Set which MCP servers were selected for documentation installation"""
|
||||
self.selected_servers = selected_servers
|
||||
self.logger.debug(f"MCP docs will be installed for: {selected_servers}")
|
||||
|
||||
|
||||
def get_files_to_install(self) -> List[Tuple[Path, Path]]:
|
||||
"""
|
||||
Return list of files to install based on selected MCP servers
|
||||
|
||||
|
||||
Returns:
|
||||
List of tuples (source_path, target_path)
|
||||
"""
|
||||
@@ -73,12 +73,16 @@ class MCPDocsComponent(Component):
|
||||
target = self.install_dir / doc_file
|
||||
if source.exists():
|
||||
files.append((source, target))
|
||||
self.logger.debug(f"Will install documentation for {server_name}: {doc_file}")
|
||||
self.logger.debug(
|
||||
f"Will install documentation for {server_name}: {doc_file}"
|
||||
)
|
||||
else:
|
||||
self.logger.warning(f"Documentation file not found for {server_name}: {doc_file}")
|
||||
self.logger.warning(
|
||||
f"Documentation file not found for {server_name}: {doc_file}"
|
||||
)
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def _discover_component_files(self) -> List[str]:
|
||||
"""
|
||||
Override parent method to dynamically discover files based on selected servers
|
||||
@@ -90,7 +94,7 @@ class MCPDocsComponent(Component):
|
||||
if server_name in self.server_docs_map:
|
||||
files.append(self.server_docs_map[server_name])
|
||||
return files
|
||||
|
||||
|
||||
def _detect_existing_mcp_servers_from_config(self) -> List[str]:
|
||||
"""Detect existing MCP servers from Claude Desktop config"""
|
||||
detected_servers = []
|
||||
@@ -101,8 +105,16 @@ class MCPDocsComponent(Component):
|
||||
self.install_dir / "claude_desktop_config.json",
|
||||
Path.home() / ".claude" / "claude_desktop_config.json",
|
||||
Path.home() / ".claude.json", # Claude CLI config
|
||||
Path.home() / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json", # Windows
|
||||
Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json", # macOS
|
||||
Path.home()
|
||||
/ "AppData"
|
||||
/ "Roaming"
|
||||
/ "Claude"
|
||||
/ "claude_desktop_config.json", # Windows
|
||||
Path.home()
|
||||
/ "Library"
|
||||
/ "Application Support"
|
||||
/ "Claude"
|
||||
/ "claude_desktop_config.json", # macOS
|
||||
]
|
||||
|
||||
config_file = None
|
||||
@@ -116,7 +128,8 @@ class MCPDocsComponent(Component):
|
||||
return detected_servers
|
||||
|
||||
import json
|
||||
with open(config_file, 'r') as f:
|
||||
|
||||
with open(config_file, "r") as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Extract MCP server names from mcpServers section
|
||||
@@ -128,7 +141,9 @@ class MCPDocsComponent(Component):
|
||||
detected_servers.append(normalized_name)
|
||||
|
||||
if detected_servers:
|
||||
self.logger.info(f"Detected existing MCP servers from config: {detected_servers}")
|
||||
self.logger.info(
|
||||
f"Detected existing MCP servers from config: {detected_servers}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not read Claude Desktop config: {e}")
|
||||
@@ -152,7 +167,7 @@ class MCPDocsComponent(Component):
|
||||
"serena": "serena",
|
||||
"morphllm": "morphllm",
|
||||
"morphllm-fast-apply": "morphllm",
|
||||
"morph": "morphllm"
|
||||
"morph": "morphllm",
|
||||
}
|
||||
|
||||
return name_mappings.get(server_name)
|
||||
@@ -169,7 +184,9 @@ class MCPDocsComponent(Component):
|
||||
selected_servers = config.get("selected_mcp_servers", [])
|
||||
|
||||
# Get previously documented servers from metadata
|
||||
previous_servers = self.settings_manager.get_metadata_setting("components.mcp_docs.servers_documented", [])
|
||||
previous_servers = self.settings_manager.get_metadata_setting(
|
||||
"components.mcp_docs.servers_documented", []
|
||||
)
|
||||
|
||||
# Merge all server lists
|
||||
all_servers = list(set(detected_servers + selected_servers + previous_servers))
|
||||
@@ -178,13 +195,17 @@ class MCPDocsComponent(Component):
|
||||
valid_servers = [s for s in all_servers if s in self.server_docs_map]
|
||||
|
||||
if not valid_servers:
|
||||
self.logger.info("No MCP servers detected or selected for documentation installation")
|
||||
self.logger.info(
|
||||
"No MCP servers detected or selected for documentation installation"
|
||||
)
|
||||
# Still proceed to update metadata
|
||||
self.set_selected_servers([])
|
||||
self.component_files = []
|
||||
return self._post_install()
|
||||
|
||||
self.logger.info(f"Installing documentation for MCP servers: {', '.join(valid_servers)}")
|
||||
self.logger.info(
|
||||
f"Installing documentation for MCP servers: {', '.join(valid_servers)}"
|
||||
)
|
||||
if detected_servers:
|
||||
self.logger.info(f" - Detected from config: {detected_servers}")
|
||||
if selected_servers:
|
||||
@@ -225,12 +246,16 @@ class MCPDocsComponent(Component):
|
||||
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)} documentation files copied successfully")
|
||||
self.logger.error(
|
||||
f"Only {success_count}/{len(files_to_install)} documentation files copied successfully"
|
||||
)
|
||||
return False
|
||||
|
||||
# Update component_files to only include successfully copied files
|
||||
self.component_files = successfully_copied_files
|
||||
self.logger.success(f"MCP documentation installed successfully ({success_count} files for {len(valid_servers)} servers)")
|
||||
self.logger.success(
|
||||
f"MCP documentation installed successfully ({success_count} files for {len(valid_servers)} servers)"
|
||||
)
|
||||
|
||||
return self._post_install()
|
||||
|
||||
@@ -244,36 +269,38 @@ class MCPDocsComponent(Component):
|
||||
"version": __version__,
|
||||
"installed": True,
|
||||
"files_count": len(self.component_files),
|
||||
"servers_documented": self.selected_servers
|
||||
"servers_documented": self.selected_servers,
|
||||
}
|
||||
}
|
||||
}
|
||||
self.settings_manager.update_metadata(metadata_mods)
|
||||
self.logger.info("Updated metadata with MCP docs component registration")
|
||||
|
||||
|
||||
# Update CLAUDE.md with MCP documentation imports
|
||||
try:
|
||||
manager = CLAUDEMdService(self.install_dir)
|
||||
manager.add_imports(self.component_files, category="MCP Documentation")
|
||||
self.logger.info("Updated CLAUDE.md with MCP documentation imports")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to update CLAUDE.md with MCP documentation imports: {e}")
|
||||
self.logger.warning(
|
||||
f"Failed to update CLAUDE.md with MCP documentation 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 MCP documentation component"""
|
||||
try:
|
||||
self.logger.info("Uninstalling MCP documentation component...")
|
||||
|
||||
|
||||
# Remove all MCP documentation files
|
||||
removed_count = 0
|
||||
source_dir = self._get_source_dir()
|
||||
|
||||
|
||||
if source_dir and source_dir.exists():
|
||||
# Remove all possible MCP doc files
|
||||
for doc_file in self.server_docs_map.values():
|
||||
@@ -281,7 +308,7 @@ class MCPDocsComponent(Component):
|
||||
if self.file_manager.remove_file(file_path):
|
||||
removed_count += 1
|
||||
self.logger.debug(f"Removed {doc_file}")
|
||||
|
||||
|
||||
# Remove mcp directory if empty
|
||||
try:
|
||||
if self.install_component_subdir.exists():
|
||||
@@ -291,7 +318,7 @@ class MCPDocsComponent(Component):
|
||||
self.logger.debug("Removed empty mcp directory")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not remove mcp directory: {e}")
|
||||
|
||||
|
||||
# Update settings.json
|
||||
try:
|
||||
if self.settings_manager.is_component_installed("mcp_docs"):
|
||||
@@ -299,36 +326,40 @@ class MCPDocsComponent(Component):
|
||||
self.logger.info("Removed MCP docs component from settings.json")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not update settings.json: {e}")
|
||||
|
||||
self.logger.success(f"MCP documentation uninstalled ({removed_count} files removed)")
|
||||
|
||||
self.logger.success(
|
||||
f"MCP documentation uninstalled ({removed_count} files removed)"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Unexpected error during MCP docs uninstallation: {e}")
|
||||
self.logger.exception(
|
||||
f"Unexpected error during MCP docs uninstallation: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def get_dependencies(self) -> List[str]:
|
||||
"""Get dependencies"""
|
||||
return ["core"]
|
||||
|
||||
|
||||
def _get_source_dir(self) -> Optional[Path]:
|
||||
"""Get source directory for MCP documentation files"""
|
||||
# Assume we're in SuperClaude/setup/components/mcp_docs.py
|
||||
# and MCP docs are in SuperClaude/SuperClaude/MCP/
|
||||
# Assume we're in superclaude/setup/components/mcp_docs.py
|
||||
# and MCP docs are in superclaude/superclaude/MCP/
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
mcp_dir = project_root / "SuperClaude" / "MCP"
|
||||
|
||||
mcp_dir = project_root / "superclaude" / "mcp"
|
||||
|
||||
# Return None if directory doesn't exist to prevent warning
|
||||
if not mcp_dir.exists():
|
||||
return None
|
||||
|
||||
|
||||
return mcp_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() and self.selected_servers:
|
||||
for server_name in self.selected_servers:
|
||||
if server_name in self.server_docs_map:
|
||||
@@ -336,8 +367,8 @@ class MCPDocsComponent(Component):
|
||||
file_path = source_dir / doc_file
|
||||
if file_path.exists():
|
||||
total_size += file_path.stat().st_size
|
||||
|
||||
|
||||
# Minimum size estimate
|
||||
total_size = max(total_size, 10240) # At least 10KB
|
||||
|
||||
return total_size
|
||||
|
||||
return total_size
|
||||
|
||||
@@ -12,20 +12,20 @@ from ..services.claude_md import CLAUDEMdService
|
||||
|
||||
class ModesComponent(Component):
|
||||
"""SuperClaude behavioral modes component"""
|
||||
|
||||
|
||||
def __init__(self, install_dir: Optional[Path] = None):
|
||||
"""Initialize modes component"""
|
||||
super().__init__(install_dir, Path(""))
|
||||
|
||||
|
||||
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"
|
||||
"category": "modes",
|
||||
}
|
||||
|
||||
|
||||
def _install(self, config: Dict[str, Any]) -> bool:
|
||||
"""Install modes component"""
|
||||
self.logger.info("Installing SuperClaude behavioral modes...")
|
||||
@@ -48,7 +48,7 @@ class ModesComponent(Component):
|
||||
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}")
|
||||
@@ -56,10 +56,14 @@ class ModesComponent(Component):
|
||||
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)} mode files copied successfully")
|
||||
self.logger.error(
|
||||
f"Only {success_count}/{len(files_to_install)} mode files copied successfully"
|
||||
)
|
||||
return False
|
||||
|
||||
self.logger.success(f"Modes component installed successfully ({success_count} mode files)")
|
||||
self.logger.success(
|
||||
f"Modes component installed successfully ({success_count} mode files)"
|
||||
)
|
||||
|
||||
return self._post_install()
|
||||
|
||||
@@ -72,39 +76,41 @@ class ModesComponent(Component):
|
||||
"modes": {
|
||||
"version": __version__,
|
||||
"installed": True,
|
||||
"files_count": len(self.component_files)
|
||||
"files_count": len(self.component_files),
|
||||
}
|
||||
}
|
||||
}
|
||||
self.settings_manager.update_metadata(metadata_mods)
|
||||
self.logger.info("Updated metadata with modes component registration")
|
||||
|
||||
|
||||
# Update CLAUDE.md with mode imports
|
||||
try:
|
||||
manager = CLAUDEMdService(self.install_dir)
|
||||
manager.add_imports(self.component_files, 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}")
|
||||
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():
|
||||
@@ -114,7 +120,7 @@ class ModesComponent(Component):
|
||||
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"):
|
||||
@@ -122,43 +128,45 @@ class ModesComponent(Component):
|
||||
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)")
|
||||
|
||||
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 ["core"]
|
||||
|
||||
|
||||
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/
|
||||
# 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"
|
||||
|
||||
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
|
||||
|
||||
return total_size
|
||||
|
||||
@@ -3,7 +3,4 @@
|
||||
from .validator import Validator
|
||||
from .registry import ComponentRegistry
|
||||
|
||||
__all__ = [
|
||||
'Validator',
|
||||
'ComponentRegistry'
|
||||
]
|
||||
__all__ = ["Validator", "ComponentRegistry"]
|
||||
|
||||
@@ -14,15 +14,18 @@ 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('')):
|
||||
|
||||
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
|
||||
@@ -31,12 +34,12 @@ class Component(ABC):
|
||||
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
|
||||
@@ -52,11 +55,13 @@ class Component(ABC):
|
||||
Useful for container-like components that can install sub-parts.
|
||||
"""
|
||||
return False
|
||||
|
||||
def validate_prerequisites(self, installSubPath: Optional[Path] = None) -> Tuple[bool, List[str]]:
|
||||
|
||||
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])
|
||||
"""
|
||||
@@ -80,13 +85,15 @@ class Component(ABC):
|
||||
|
||||
# Check write permissions to install directory
|
||||
has_perms, missing = SecurityValidator.check_permissions(
|
||||
self.install_dir, {'write'}
|
||||
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)
|
||||
is_safe, validation_errors = SecurityValidator.validate_installation_target(
|
||||
self.install_component_subdir
|
||||
)
|
||||
if not is_safe:
|
||||
errors.extend(validation_errors)
|
||||
|
||||
@@ -101,14 +108,16 @@ class Component(ABC):
|
||||
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}")
|
||||
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)
|
||||
"""
|
||||
@@ -122,7 +131,7 @@ class Component(ABC):
|
||||
files.append((source, target))
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def get_settings_modifications(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Return settings.json modifications to apply
|
||||
@@ -133,22 +142,24 @@ class Component(ABC):
|
||||
"""
|
||||
# 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}")
|
||||
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
|
||||
"""
|
||||
@@ -174,34 +185,36 @@ class Component(ABC):
|
||||
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")
|
||||
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)")
|
||||
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
|
||||
"""
|
||||
@@ -211,14 +224,14 @@ class Component(ABC):
|
||||
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
|
||||
"""
|
||||
@@ -226,11 +239,11 @@ class Component(ABC):
|
||||
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
|
||||
"""
|
||||
@@ -239,10 +252,14 @@ class Component(ABC):
|
||||
if metadata_file.exists():
|
||||
self.logger.debug("Metadata file exists, reading version")
|
||||
try:
|
||||
with open(metadata_file, 'r') as f:
|
||||
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')
|
||||
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:
|
||||
@@ -250,40 +267,40 @@ class Component(ABC):
|
||||
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
|
||||
"""
|
||||
@@ -293,7 +310,9 @@ class Component(ABC):
|
||||
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())
|
||||
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]:
|
||||
@@ -310,12 +329,16 @@ class Component(ABC):
|
||||
|
||||
return self._discover_files_in_directory(
|
||||
source_dir,
|
||||
extension='.md',
|
||||
exclude_patterns=['README.md', 'CHANGELOG.md', 'LICENSE.md']
|
||||
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]:
|
||||
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
|
||||
|
||||
@@ -342,15 +365,19 @@ class Component(ABC):
|
||||
# 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):
|
||||
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}")
|
||||
self.logger.debug(
|
||||
f"Discovered {len(files)} {extension} files in {directory}"
|
||||
)
|
||||
if files:
|
||||
self.logger.debug(f"Files found: {files}")
|
||||
|
||||
@@ -362,65 +389,74 @@ class Component(ABC):
|
||||
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\\'
|
||||
"/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:
|
||||
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
|
||||
"""
|
||||
|
||||
@@ -14,23 +14,25 @@ from ..utils.logger import get_logger
|
||||
class Installer:
|
||||
"""Main installer orchestrator"""
|
||||
|
||||
def __init__(self,
|
||||
install_dir: Optional[Path] = None,
|
||||
dry_run: bool = False):
|
||||
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.installed_components: Set[str] = set(
|
||||
settings_manager.get_installed_components().keys()
|
||||
)
|
||||
self.updated_components: Set[str] = set()
|
||||
|
||||
self.failed_components: Set[str] = set()
|
||||
@@ -41,17 +43,17 @@ class Installer:
|
||||
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
|
||||
self.components[metadata["name"]] = component
|
||||
|
||||
def register_components(self, components: List[Component]) -> None:
|
||||
"""
|
||||
Register multiple components
|
||||
|
||||
|
||||
Args:
|
||||
components: List of component instances
|
||||
"""
|
||||
@@ -61,13 +63,13 @@ class Installer:
|
||||
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
|
||||
"""
|
||||
@@ -79,8 +81,7 @@ class Installer:
|
||||
return
|
||||
|
||||
if name in resolving:
|
||||
raise ValueError(
|
||||
f"Circular dependency detected involving {name}")
|
||||
raise ValueError(f"Circular dependency detected involving {name}")
|
||||
|
||||
if name not in self.components:
|
||||
raise ValueError(f"Unknown component: {name}")
|
||||
@@ -103,7 +104,7 @@ class Installer:
|
||||
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])
|
||||
"""
|
||||
@@ -134,7 +135,7 @@ class Installer:
|
||||
def create_backup(self) -> Optional[Path]:
|
||||
"""
|
||||
Create backup of existing installation
|
||||
|
||||
|
||||
Returns:
|
||||
Path to backup archive or None if no existing installation
|
||||
"""
|
||||
@@ -174,7 +175,7 @@ class Installer:
|
||||
|
||||
# Always create an archive, even if empty, to ensure it's a valid tarball
|
||||
base_path = backup_dir / backup_name
|
||||
shutil.make_archive(str(base_path), 'gztar', temp_backup)
|
||||
shutil.make_archive(str(base_path), "gztar", temp_backup)
|
||||
|
||||
if not any(temp_backup.iterdir()):
|
||||
self.logger.warning(
|
||||
@@ -184,15 +185,14 @@ class Installer:
|
||||
self.backup_path = backup_path
|
||||
return backup_path
|
||||
|
||||
def install_component(self, component_name: str,
|
||||
config: Dict[str, Any]) -> bool:
|
||||
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
|
||||
"""
|
||||
@@ -202,7 +202,11 @@ class Installer:
|
||||
component = self.components[component_name]
|
||||
|
||||
# Skip if already installed and not in update mode, unless component is reinstallable
|
||||
if not component.is_reinstallable() and component_name in self.installed_components and not config.get("update_mode"):
|
||||
if (
|
||||
not component.is_reinstallable()
|
||||
and component_name in self.installed_components
|
||||
and not config.get("update_mode")
|
||||
):
|
||||
self.skipped_components.add(component_name)
|
||||
self.logger.info(f"Skipping already installed component: {component_name}")
|
||||
return True
|
||||
@@ -237,16 +241,16 @@ class Installer:
|
||||
self.failed_components.add(component_name)
|
||||
return False
|
||||
|
||||
def install_components(self,
|
||||
component_names: List[str],
|
||||
config: Optional[Dict[str, Any]] = None) -> bool:
|
||||
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
|
||||
"""
|
||||
@@ -296,7 +300,9 @@ class Installer:
|
||||
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.")
|
||||
self.logger.warning(
|
||||
f"Cannot validate component '{name}' as it was not part of this installation session."
|
||||
)
|
||||
continue
|
||||
|
||||
component = self.components[name]
|
||||
@@ -315,12 +321,13 @@ class Installer:
|
||||
else:
|
||||
self.logger.error("Some components failed validation. Check errors above.")
|
||||
|
||||
def update_components(self, component_names: List[str], config: Dict[str, Any]) -> bool:
|
||||
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
|
||||
@@ -329,17 +336,17 @@ class Installer:
|
||||
Dict with installation statistics and results
|
||||
"""
|
||||
return {
|
||||
'installed': list(self.installed_components),
|
||||
'failed': list(self.failed_components),
|
||||
'skipped': list(self.skipped_components),
|
||||
'backup_path': str(self.backup_path) if self.backup_path else None,
|
||||
'install_dir': str(self.install_dir),
|
||||
'dry_run': self.dry_run
|
||||
"installed": list(self.installed_components),
|
||||
"failed": list(self.failed_components),
|
||||
"skipped": list(self.skipped_components),
|
||||
"backup_path": str(self.backup_path) if self.backup_path else None,
|
||||
"install_dir": str(self.install_dir),
|
||||
"dry_run": self.dry_run,
|
||||
}
|
||||
|
||||
def get_update_summary(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'updated': list(self.updated_components),
|
||||
'failed': list(self.failed_components),
|
||||
'backup_path': str(self.backup_path) if self.backup_path else None
|
||||
"updated": list(self.updated_components),
|
||||
"failed": list(self.failed_components),
|
||||
"backup_path": str(self.backup_path) if self.backup_path else None,
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@ 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
|
||||
"""
|
||||
@@ -26,54 +26,55 @@ class ComponentRegistry:
|
||||
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
|
||||
"""
|
||||
@@ -81,28 +82,32 @@ class ComponentRegistry:
|
||||
# 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):
|
||||
|
||||
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}")
|
||||
|
||||
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():
|
||||
@@ -112,33 +117,35 @@ class ComponentRegistry:
|
||||
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]:
|
||||
|
||||
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)
|
||||
@@ -146,28 +153,30 @@ class ComponentRegistry:
|
||||
try:
|
||||
return component_class(install_dir)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating component instance {component_name}: {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
|
||||
"""
|
||||
@@ -179,121 +188,123 @@ class ComponentRegistry:
|
||||
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}")
|
||||
|
||||
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()
|
||||
@@ -301,80 +312,84 @@ class ComponentRegistry:
|
||||
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")
|
||||
|
||||
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]:
|
||||
|
||||
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():
|
||||
@@ -388,10 +403,12 @@ class ComponentRegistry:
|
||||
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()
|
||||
}
|
||||
"dependency_graph": {
|
||||
name: list(deps) for name, deps in self.dependency_graph.items()
|
||||
},
|
||||
"validation_errors": self.validate_dependency_graph(),
|
||||
}
|
||||
|
||||
@@ -13,19 +13,20 @@ 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('.')]
|
||||
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)
|
||||
@@ -34,17 +35,17 @@ except ImportError:
|
||||
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):
|
||||
@@ -53,107 +54,127 @@ except ImportError:
|
||||
|
||||
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]:
|
||||
|
||||
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}")
|
||||
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}")
|
||||
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]:
|
||||
|
||||
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'],
|
||||
["node", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
shell=(sys.platform == "win32")
|
||||
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'):
|
||||
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}")
|
||||
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}")
|
||||
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
|
||||
@@ -167,58 +188,63 @@ class Validator:
|
||||
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'],
|
||||
["claude", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
shell=(sys.platform == "win32")
|
||||
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)
|
||||
|
||||
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}")
|
||||
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
|
||||
@@ -232,53 +258,58 @@ class Validator:
|
||||
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]:
|
||||
|
||||
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")
|
||||
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)
|
||||
|
||||
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}")
|
||||
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
|
||||
@@ -290,7 +321,7 @@ class Validator:
|
||||
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
|
||||
@@ -303,206 +334,208 @@ class Validator:
|
||||
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")
|
||||
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]]:
|
||||
|
||||
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")
|
||||
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")
|
||||
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"]
|
||||
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")
|
||||
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]]:
|
||||
|
||||
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)
|
||||
"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
|
||||
"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()
|
||||
@@ -510,76 +543,78 @@ class Validator:
|
||||
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)
|
||||
"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:
|
||||
|
||||
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
|
||||
"""
|
||||
@@ -587,66 +622,68 @@ class Validator:
|
||||
"platform": self.get_platform(),
|
||||
"checks": {},
|
||||
"issues": [],
|
||||
"recommendations": []
|
||||
"recommendations": [],
|
||||
}
|
||||
|
||||
|
||||
# Check Python
|
||||
python_success, python_msg = self.check_python()
|
||||
diagnostics["checks"]["python"] = {
|
||||
"status": "pass" if python_success else "fail",
|
||||
"message": python_msg
|
||||
"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
|
||||
"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
|
||||
"message": claude_msg,
|
||||
}
|
||||
if not claude_success:
|
||||
diagnostics["issues"].append("Claude CLI not found")
|
||||
diagnostics["recommendations"].append(self.get_installation_help("claude_cli"))
|
||||
|
||||
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
|
||||
"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")
|
||||
(["claude"], "Claude CLI"),
|
||||
]
|
||||
|
||||
|
||||
for tool_alternatives, display_name in tool_checks:
|
||||
tool_found = False
|
||||
for tool in tool_alternatives:
|
||||
@@ -656,21 +693,21 @@ class Validator:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
shell=(sys.platform == "win32")
|
||||
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(
|
||||
@@ -680,7 +717,7 @@ class Validator:
|
||||
" - 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 +1,4 @@
|
||||
"""
|
||||
SuperClaude Data Module
|
||||
Static configuration and data files
|
||||
"""
|
||||
"""
|
||||
|
||||
@@ -8,9 +8,4 @@ from .config import ConfigService
|
||||
from .files import FileService
|
||||
from .settings import SettingsService
|
||||
|
||||
__all__ = [
|
||||
'CLAUDEMdService',
|
||||
'ConfigService',
|
||||
'FileService',
|
||||
'SettingsService'
|
||||
]
|
||||
__all__ = ["CLAUDEMdService", "ConfigService", "FileService", "SettingsService"]
|
||||
|
||||
@@ -10,105 +10,107 @@ 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)
|
||||
"""
|
||||
self.install_dir = install_dir
|
||||
self.claude_md_path = install_dir / "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:
|
||||
with open(self.claude_md_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
|
||||
# Find all @import statements using regex
|
||||
import_pattern = r'^@([^\s\n]+\.md)\s*$'
|
||||
import_pattern = r"^@([^\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:
|
||||
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:
|
||||
|
||||
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:
|
||||
@@ -116,131 +118,139 @@ class CLAUDEMdService:
|
||||
for file in sorted(files):
|
||||
sections.append(f"@{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:
|
||||
# Ensure CLAUDE.md exists
|
||||
self.ensure_claude_md_exists()
|
||||
|
||||
|
||||
# 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}")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
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:
|
||||
|
||||
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 ""
|
||||
|
||||
framework_section = (
|
||||
content.split(framework_marker)[1] if framework_marker in content else ""
|
||||
)
|
||||
|
||||
# Parse categories and imports
|
||||
lines = framework_section.split('\n')
|
||||
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:
|
||||
if line.startswith("# ===") or not line:
|
||||
continue
|
||||
|
||||
|
||||
# Category header (starts with # but not the section divider)
|
||||
if line.startswith('# ') and not line.startswith('# ==='):
|
||||
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:
|
||||
elif line.startswith("@") and current_category:
|
||||
import_file = line[1:].strip() # Remove "@"
|
||||
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) -> None:
|
||||
"""
|
||||
Create CLAUDE.md with default content if it doesn't exist
|
||||
"""
|
||||
if self.claude_md_path.exists():
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
# Create directory if it doesn't exist
|
||||
self.claude_md_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# Default CLAUDE.md content
|
||||
default_content = """# SuperClaude Entry Point
|
||||
|
||||
@@ -249,34 +259,36 @@ You can add your own custom instructions and configurations here.
|
||||
|
||||
The SuperClaude framework components will be automatically imported below.
|
||||
"""
|
||||
|
||||
with open(self.claude_md_path, 'w', encoding='utf-8') as f:
|
||||
|
||||
with open(self.claude_md_path, "w", encoding="utf-8") as f:
|
||||
f.write(default_content)
|
||||
|
||||
|
||||
self.logger.info("Created CLAUDE.md with default content")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create CLAUDE.md: {e}")
|
||||
raise
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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():
|
||||
@@ -284,33 +296,37 @@ The SuperClaude framework components will be automatically imported below.
|
||||
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}
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
|
||||
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
|
||||
return False
|
||||
|
||||
@@ -10,16 +10,18 @@ from pathlib import Path
|
||||
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
|
||||
@@ -32,17 +34,19 @@ except ImportError:
|
||||
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__}")
|
||||
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
|
||||
"""
|
||||
@@ -51,7 +55,7 @@ class ConfigService:
|
||||
self.requirements_file = config_dir / "requirements.json"
|
||||
self._features_cache = None
|
||||
self._requirements_cache = None
|
||||
|
||||
|
||||
# Schema for features.json
|
||||
self.features_schema = {
|
||||
"type": "object",
|
||||
@@ -68,24 +72,24 @@ class ConfigService:
|
||||
"category": {"type": "string"},
|
||||
"dependencies": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"enabled": {"type": "boolean"},
|
||||
"required_tools": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
}
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
},
|
||||
"required": ["name", "version", "description", "category"],
|
||||
"additionalProperties": False
|
||||
"additionalProperties": False,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["components"],
|
||||
"additionalProperties": False
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
# Schema for requirements.json
|
||||
self.requirements_schema = {
|
||||
"type": "object",
|
||||
@@ -94,21 +98,18 @@ class ConfigService:
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min_version": {"type": "string"},
|
||||
"max_version": {"type": "string"}
|
||||
"max_version": {"type": "string"},
|
||||
},
|
||||
"required": ["min_version"]
|
||||
"required": ["min_version"],
|
||||
},
|
||||
"node": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min_version": {"type": "string"},
|
||||
"max_version": {"type": "string"},
|
||||
"required_for": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
}
|
||||
"required_for": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["min_version"]
|
||||
"required": ["min_version"],
|
||||
},
|
||||
"disk_space_mb": {"type": "integer"},
|
||||
"external_tools": {
|
||||
@@ -121,14 +122,14 @@ class ConfigService:
|
||||
"min_version": {"type": "string"},
|
||||
"required_for": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"optional": {"type": "boolean"}
|
||||
"optional": {"type": "boolean"},
|
||||
},
|
||||
"required": ["command"],
|
||||
"additionalProperties": False
|
||||
"additionalProperties": False,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"installation_commands": {
|
||||
"type": "object",
|
||||
@@ -140,136 +141,138 @@ class ConfigService:
|
||||
"darwin": {"type": "string"},
|
||||
"win32": {"type": "string"},
|
||||
"all": {"type": "string"},
|
||||
"description": {"type": "string"}
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": False
|
||||
"additionalProperties": False,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["python", "disk_space_mb"],
|
||||
"additionalProperties": False
|
||||
"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:
|
||||
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}")
|
||||
|
||||
raise FileNotFoundError(
|
||||
f"Requirements config not found: {self.requirements_file}"
|
||||
)
|
||||
|
||||
try:
|
||||
with open(self.requirements_file, 'r') as f:
|
||||
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
|
||||
"""
|
||||
@@ -277,82 +280,86 @@ class ConfigService:
|
||||
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]:
|
||||
|
||||
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": {}
|
||||
"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]
|
||||
|
||||
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
|
||||
self._requirements_cache = None
|
||||
|
||||
@@ -12,83 +12,87 @@ 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:
|
||||
|
||||
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:
|
||||
|
||||
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']
|
||||
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]:
|
||||
@@ -96,250 +100,258 @@ class FileService:
|
||||
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):
|
||||
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('*'):
|
||||
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]:
|
||||
|
||||
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:
|
||||
|
||||
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:
|
||||
|
||||
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('*'):
|
||||
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]:
|
||||
|
||||
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))
|
||||
@@ -347,52 +359,54 @@ class FileService:
|
||||
return list(directory.glob(pattern))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def backup_file(self, file_path: Path, backup_suffix: str = '.backup') -> Optional[Path]:
|
||||
|
||||
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:
|
||||
@@ -400,7 +414,7 @@ class FileService:
|
||||
file_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Remove directories (in reverse order of creation)
|
||||
for directory in reversed(self.created_dirs):
|
||||
try:
|
||||
@@ -408,21 +422,21 @@ class FileService:
|
||||
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]
|
||||
}
|
||||
"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],
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ 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
|
||||
"""
|
||||
@@ -26,27 +26,29 @@ class SettingsService:
|
||||
self.settings_file = install_dir / "settings.json"
|
||||
self.metadata_file = install_dir / ".superclaude-metadata.json"
|
||||
self.backup_dir = install_dir / "backups" / "settings"
|
||||
|
||||
|
||||
def load_settings(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Load settings from settings.json
|
||||
|
||||
|
||||
Returns:
|
||||
Settings dict (empty if file doesn't exist)
|
||||
"""
|
||||
if not self.settings_file.exists():
|
||||
return {}
|
||||
|
||||
|
||||
try:
|
||||
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
||||
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:
|
||||
|
||||
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
|
||||
@@ -54,46 +56,46 @@ class SettingsService:
|
||||
# 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:
|
||||
with open(self.settings_file, "w", encoding="utf-8") as f:
|
||||
json.dump(settings, f, indent=2, ensure_ascii=False, sort_keys=True)
|
||||
except IOError as e:
|
||||
raise ValueError(f"Could not save settings to {self.settings_file}: {e}")
|
||||
|
||||
|
||||
def load_metadata(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Load SuperClaude metadata from .superclaude-metadata.json
|
||||
|
||||
|
||||
Returns:
|
||||
Metadata dict (empty if file doesn't exist)
|
||||
"""
|
||||
if not self.metadata_file.exists():
|
||||
return {}
|
||||
|
||||
|
||||
try:
|
||||
with open(self.metadata_file, 'r', encoding='utf-8') as f:
|
||||
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:
|
||||
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}")
|
||||
@@ -125,128 +127,134 @@ class SettingsService:
|
||||
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}
|
||||
|
||||
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:
|
||||
|
||||
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('.'):
|
||||
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:
|
||||
|
||||
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('.')
|
||||
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('.')
|
||||
|
||||
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]]
|
||||
@@ -254,14 +262,16 @@ class SettingsService:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
except (KeyError, TypeError):
|
||||
return False
|
||||
|
||||
def add_component_registration(self, component_name: str, component_info: Dict[str, Any]) -> None:
|
||||
|
||||
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
|
||||
@@ -269,21 +279,21 @@ class SettingsService:
|
||||
metadata = self.load_metadata()
|
||||
if "components" not in metadata:
|
||||
metadata["components"] = {}
|
||||
|
||||
|
||||
metadata["components"][component_name] = {
|
||||
**component_info,
|
||||
"installed_at": datetime.now().isoformat()
|
||||
"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
|
||||
"""
|
||||
@@ -293,64 +303,64 @@ class SettingsService:
|
||||
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
|
||||
"""
|
||||
@@ -364,152 +374,160 @@ class SettingsService:
|
||||
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('.'):
|
||||
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]:
|
||||
|
||||
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):
|
||||
if (
|
||||
key in result
|
||||
and isinstance(result[key], dict)
|
||||
and isinstance(value, dict)
|
||||
):
|
||||
result[key] = self._deep_merge(result[key], value)
|
||||
else:
|
||||
result[key] = copy.deepcopy(value)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _create_settings_backup(self) -> Path:
|
||||
"""
|
||||
Create timestamped backup of settings.json
|
||||
|
||||
|
||||
Returns:
|
||||
Path to backup file
|
||||
"""
|
||||
if not self.settings_file.exists():
|
||||
raise ValueError("Cannot backup non-existent settings file")
|
||||
|
||||
|
||||
# Create backup directory
|
||||
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# Create timestamped backup
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_file = self.backup_dir / f"settings_{timestamp}.json"
|
||||
|
||||
|
||||
shutil.copy2(self.settings_file, backup_file)
|
||||
|
||||
|
||||
# Keep only last 10 backups
|
||||
self._cleanup_old_backups()
|
||||
|
||||
|
||||
return backup_file
|
||||
|
||||
|
||||
def _cleanup_old_backups(self, keep_count: int = 10) -> None:
|
||||
"""
|
||||
Remove old backup files, keeping only the most recent
|
||||
|
||||
|
||||
Args:
|
||||
keep_count: Number of backups to keep
|
||||
"""
|
||||
if not self.backup_dir.exists():
|
||||
return
|
||||
|
||||
|
||||
# Get all backup files sorted by modification time
|
||||
backup_files = []
|
||||
for file in self.backup_dir.glob("settings_*.json"):
|
||||
backup_files.append((file.stat().st_mtime, file))
|
||||
|
||||
|
||||
backup_files.sort(reverse=True) # Most recent first
|
||||
|
||||
|
||||
# Remove old backups
|
||||
for _, file in backup_files[keep_count:]:
|
||||
try:
|
||||
file.unlink()
|
||||
except OSError:
|
||||
pass # Ignore errors when cleaning up
|
||||
|
||||
|
||||
def list_backups(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List available settings backups
|
||||
|
||||
|
||||
Returns:
|
||||
List of backup info dicts with name, path, and timestamp
|
||||
"""
|
||||
if not self.backup_dir.exists():
|
||||
return []
|
||||
|
||||
|
||||
backups = []
|
||||
for file in self.backup_dir.glob("settings_*.json"):
|
||||
try:
|
||||
stat = file.stat()
|
||||
backups.append({
|
||||
"name": file.name,
|
||||
"path": str(file),
|
||||
"size": stat.st_size,
|
||||
"created": datetime.fromtimestamp(stat.st_ctime).isoformat(),
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||
})
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -4,11 +4,4 @@ from .ui import ProgressBar, Menu, confirm, Colors
|
||||
from .logger import Logger
|
||||
from .security import SecurityValidator
|
||||
|
||||
__all__ = [
|
||||
'ProgressBar',
|
||||
'Menu',
|
||||
'confirm',
|
||||
'Colors',
|
||||
'Logger',
|
||||
'SecurityValidator'
|
||||
]
|
||||
__all__ = ["ProgressBar", "Menu", "confirm", "Colors", "Logger", "SecurityValidator"]
|
||||
|
||||
@@ -18,6 +18,7 @@ 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"
|
||||
@@ -26,23 +27,23 @@ def _get_env_tracking_file() -> Path:
|
||||
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:
|
||||
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:
|
||||
with open(tracking_file, "w") as f:
|
||||
json.dump(tracking_data, f, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -54,17 +55,17 @@ 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
|
||||
"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")
|
||||
|
||||
@@ -73,13 +74,13 @@ 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")
|
||||
|
||||
@@ -87,24 +88,24 @@ def _remove_env_tracking(env_vars: list) -> None:
|
||||
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
|
||||
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"
|
||||
|
||||
@@ -112,103 +113,113 @@ def detect_shell_config() -> Optional[Path]:
|
||||
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
|
||||
|
||||
if os.name == "nt": # Windows
|
||||
# Use setx for persistent user variable
|
||||
result = subprocess.run(
|
||||
['setx', env_var, value],
|
||||
capture_output=True,
|
||||
text=True
|
||||
["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()}")
|
||||
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")
|
||||
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:
|
||||
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:
|
||||
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}")
|
||||
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')
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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")
|
||||
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")
|
||||
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
|
||||
@@ -217,73 +228,75 @@ def validate_environment_setup(env_vars: Dict[str, str]) -> bool:
|
||||
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', '')
|
||||
shell_path = os.environ.get("SHELL", "")
|
||||
if shell_path:
|
||||
return Path(shell_path).name
|
||||
return 'unknown'
|
||||
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
|
||||
"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:
|
||||
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)
|
||||
@@ -291,50 +304,54 @@ def cleanup_environment_variables(env_vars_to_remove: Dict[str, str], create_res
|
||||
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
|
||||
|
||||
if os.name == "nt": # Windows
|
||||
# Remove persistent user variable using reg command
|
||||
result = subprocess.run(
|
||||
['reg', 'delete', 'HKCU\\Environment', '/v', env_var, '/f'],
|
||||
["reg", "delete", "HKCU\\Environment", "/v", env_var, "/f"],
|
||||
capture_output=True,
|
||||
text=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()}")
|
||||
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")
|
||||
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
|
||||
|
||||
|
||||
@@ -342,9 +359,9 @@ 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
|
||||
if os.name == "nt": # Windows
|
||||
script_path = home / "restore_superclaude_env.bat"
|
||||
with open(script_path, 'w') as f:
|
||||
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")
|
||||
@@ -354,7 +371,7 @@ def _create_restore_script(env_vars: Dict[str, str]) -> Optional[Path]:
|
||||
f.write("pause\n")
|
||||
else: # Unix-like
|
||||
script_path = home / "restore_superclaude_env.sh"
|
||||
with open(script_path, 'w') as f:
|
||||
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")
|
||||
@@ -362,14 +379,16 @@ def _create_restore_script(env_vars: Dict[str, str]) -> Optional[Path]:
|
||||
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(
|
||||
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
|
||||
@@ -379,90 +398,92 @@ 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:
|
||||
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':
|
||||
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() == '':
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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')
|
||||
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')
|
||||
|
||||
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}")
|
||||
@@ -472,13 +493,13 @@ def create_env_file(api_keys: Dict[str, str], env_file_path: Optional[Path] = No
|
||||
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(
|
||||
@@ -488,9 +509,10 @@ def check_research_prerequisites() -> tuple[bool, list[str]]:
|
||||
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"
|
||||
@@ -499,7 +521,7 @@ def check_research_prerequisites() -> tuple[bool, list[str]]:
|
||||
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(
|
||||
@@ -509,5 +531,5 @@ def check_research_prerequisites() -> tuple[bool, list[str]]:
|
||||
logger.warning("npm not found - required for MCP installation")
|
||||
else:
|
||||
logger.info("npm found")
|
||||
|
||||
return len(warnings) == 0, warnings
|
||||
|
||||
return len(warnings) == 0, warnings
|
||||
|
||||
@@ -16,6 +16,7 @@ from .paths import get_home_directory
|
||||
|
||||
class LogLevel(Enum):
|
||||
"""Log levels"""
|
||||
|
||||
DEBUG = logging.DEBUG
|
||||
INFO = logging.INFO
|
||||
WARNING = logging.WARNING
|
||||
@@ -25,11 +26,17 @@ class LogLevel(Enum):
|
||||
|
||||
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):
|
||||
|
||||
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)
|
||||
@@ -41,146 +48,146 @@ class Logger:
|
||||
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
|
||||
"debug": 0,
|
||||
"info": 0,
|
||||
"warning": 0,
|
||||
"error": 0,
|
||||
"critical": 0,
|
||||
}
|
||||
|
||||
|
||||
def _setup_console_handler(self) -> None:
|
||||
"""Setup colorized console handler"""
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setLevel(self.console_level.value)
|
||||
|
||||
|
||||
# Custom formatter with colors
|
||||
class ColorFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
# Color mapping
|
||||
colors = {
|
||||
'DEBUG': Colors.WHITE,
|
||||
'INFO': Colors.BLUE,
|
||||
'WARNING': Colors.YELLOW,
|
||||
'ERROR': Colors.RED,
|
||||
'CRITICAL': Colors.RED + Colors.BRIGHT
|
||||
"DEBUG": Colors.WHITE,
|
||||
"INFO": Colors.BLUE,
|
||||
"WARNING": Colors.YELLOW,
|
||||
"ERROR": Colors.RED,
|
||||
"CRITICAL": Colors.RED + Colors.BRIGHT,
|
||||
}
|
||||
|
||||
|
||||
# Prefix mapping
|
||||
prefixes = {
|
||||
'DEBUG': '[DEBUG]',
|
||||
'INFO': '[INFO]',
|
||||
'WARNING': '[!]',
|
||||
'ERROR': f'[{symbols.crossmark}]',
|
||||
'CRITICAL': '[CRITICAL]'
|
||||
"DEBUG": "[DEBUG]",
|
||||
"INFO": "[INFO]",
|
||||
"WARNING": "[!]",
|
||||
"ERROR": f"[{symbols.crossmark}]",
|
||||
"CRITICAL": "[CRITICAL]",
|
||||
}
|
||||
|
||||
|
||||
color = colors.get(record.levelname, Colors.WHITE)
|
||||
prefix = prefixes.get(record.levelname, '[LOG]')
|
||||
|
||||
prefix = prefixes.get(record.levelname, "[LOG]")
|
||||
|
||||
return f"{color}{prefix} {record.getMessage()}{Colors.RESET}"
|
||||
|
||||
|
||||
handler.setFormatter(ColorFormatter())
|
||||
self.logger.addHandler(handler)
|
||||
|
||||
|
||||
def _setup_file_handler(self) -> None:
|
||||
"""Setup file handler with rotation"""
|
||||
try:
|
||||
# Ensure log directory exists
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# Create timestamped log file
|
||||
timestamp = self.session_start.strftime("%Y%m%d_%H%M%S")
|
||||
log_file = self.log_dir / f"{self.name}_{timestamp}.log"
|
||||
|
||||
handler = logging.FileHandler(log_file, encoding='utf-8')
|
||||
|
||||
handler = 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'
|
||||
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
|
||||
self.logger.addHandler(handler)
|
||||
self.log_file = log_file
|
||||
|
||||
|
||||
# Clean up old log files (keep last 10)
|
||||
self._cleanup_old_logs()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
# If file logging fails, continue with console only
|
||||
print(f"{Colors.YELLOW}[!] Could not setup file logging: {e}{Colors.RESET}")
|
||||
self.log_file = None
|
||||
|
||||
|
||||
def _cleanup_old_logs(self, keep_count: int = 10) -> None:
|
||||
"""Clean up old log files"""
|
||||
try:
|
||||
# Get all log files for this logger
|
||||
log_files = list(self.log_dir.glob(f"{self.name}_*.log"))
|
||||
|
||||
|
||||
# Sort by modification time, newest first
|
||||
log_files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
|
||||
|
||||
|
||||
# Remove old files
|
||||
for old_file in log_files[keep_count:]:
|
||||
try:
|
||||
old_file.unlink()
|
||||
except OSError:
|
||||
pass # Ignore errors when cleaning up
|
||||
|
||||
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
|
||||
def debug(self, message: str, **kwargs) -> None:
|
||||
"""Log debug message"""
|
||||
self.logger.debug(message, **kwargs)
|
||||
self.log_counts['debug'] += 1
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
self.log_counts["critical"] += 1
|
||||
|
||||
def success(self, message: str, **kwargs) -> None:
|
||||
"""Log success message (info level with special formatting)"""
|
||||
# Use a custom success formatter for console
|
||||
if self.logger.handlers:
|
||||
console_handler = self.logger.handlers[0]
|
||||
if hasattr(console_handler, 'formatter'):
|
||||
if hasattr(console_handler, "formatter"):
|
||||
original_format = console_handler.formatter.format
|
||||
|
||||
|
||||
def success_format(record):
|
||||
return f"{Colors.GREEN}[{symbols.checkmark}] {record.getMessage()}{Colors.RESET}"
|
||||
|
||||
|
||||
console_handler.formatter.format = success_format
|
||||
self.logger.info(message, **kwargs)
|
||||
console_handler.formatter.format = original_format
|
||||
@@ -188,92 +195,108 @@ class Logger:
|
||||
self.logger.info(f"SUCCESS: {message}", **kwargs)
|
||||
else:
|
||||
self.logger.info(f"SUCCESS: {message}", **kwargs)
|
||||
|
||||
self.log_counts['info'] += 1
|
||||
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
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:
|
||||
|
||||
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)")
|
||||
|
||||
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
|
||||
"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'):
|
||||
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']:
|
||||
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()
|
||||
@@ -287,14 +310,19 @@ _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:
|
||||
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)
|
||||
@@ -329,4 +357,4 @@ def critical(message: str, **kwargs) -> None:
|
||||
|
||||
def success(message: str, **kwargs) -> None:
|
||||
"""Log success message using global logger"""
|
||||
get_logger().success(message, **kwargs)
|
||||
get_logger().success(message, **kwargs)
|
||||
|
||||
@@ -30,19 +30,19 @@ def get_home_directory() -> Path:
|
||||
# Fallback methods for edge cases and immutable distros
|
||||
|
||||
# Method 1: Use $HOME environment variable
|
||||
home_env = os.environ.get('HOME')
|
||||
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')
|
||||
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
|
||||
Path(f"/var/home/{username}"), # Fedora Silverblue/Universal Blue
|
||||
Path(f"/home/{username}"), # Standard Linux
|
||||
]
|
||||
|
||||
for path in immutable_paths:
|
||||
@@ -51,4 +51,4 @@ def get_home_directory() -> 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()
|
||||
return Path.home()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,14 +21,14 @@ def can_display_unicode() -> bool:
|
||||
try:
|
||||
# Test if we can encode common Unicode symbols
|
||||
test_symbols = "✓✗█░⠋═"
|
||||
test_symbols.encode(sys.stdout.encoding or 'cp1252')
|
||||
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']:
|
||||
encoding = getattr(sys.stdout, "encoding", None)
|
||||
if encoding and encoding.lower() in ["utf-8", "utf8"]:
|
||||
return True
|
||||
|
||||
# Conservative fallback for unknown systems
|
||||
@@ -131,8 +131,8 @@ def safe_print(*args, **kwargs):
|
||||
for arg in args:
|
||||
if isinstance(arg, str):
|
||||
# Replace problematic Unicode characters
|
||||
safe_arg = (arg
|
||||
.replace("✓", "+")
|
||||
safe_arg = (
|
||||
arg.replace("✓", "+")
|
||||
.replace("✗", "x")
|
||||
.replace("█", "#")
|
||||
.replace("░", "-")
|
||||
@@ -165,7 +165,7 @@ def safe_print(*args, **kwargs):
|
||||
final_args = []
|
||||
for arg in safe_args:
|
||||
if isinstance(arg, str):
|
||||
final_args.append(arg.encode('ascii', 'replace').decode('ascii'))
|
||||
final_args.append(arg.encode("ascii", "replace").decode("ascii"))
|
||||
else:
|
||||
final_args.append(str(arg))
|
||||
print(*final_args, **kwargs)
|
||||
@@ -195,4 +195,4 @@ def format_with_symbols(text: str) -> str:
|
||||
for unicode_char, safe_char in replacements.items():
|
||||
text = text.replace(unicode_char, safe_char)
|
||||
|
||||
return text
|
||||
return text
|
||||
|
||||
@@ -15,30 +15,33 @@ from .symbols import symbols, safe_print, format_with_symbols
|
||||
try:
|
||||
import colorama
|
||||
from colorama import Fore, Back, Style
|
||||
|
||||
colorama.init(autoreset=True)
|
||||
COLORAMA_AVAILABLE = True
|
||||
except ImportError:
|
||||
COLORAMA_AVAILABLE = False
|
||||
|
||||
# Fallback color codes for Unix-like systems
|
||||
class MockFore:
|
||||
RED = '\033[91m' if sys.platform != 'win32' else ''
|
||||
GREEN = '\033[92m' if sys.platform != 'win32' else ''
|
||||
YELLOW = '\033[93m' if sys.platform != 'win32' else ''
|
||||
BLUE = '\033[94m' if sys.platform != 'win32' else ''
|
||||
MAGENTA = '\033[95m' if sys.platform != 'win32' else ''
|
||||
CYAN = '\033[96m' if sys.platform != 'win32' else ''
|
||||
WHITE = '\033[97m' if sys.platform != 'win32' else ''
|
||||
|
||||
RED = "\033[91m" if sys.platform != "win32" else ""
|
||||
GREEN = "\033[92m" if sys.platform != "win32" else ""
|
||||
YELLOW = "\033[93m" if sys.platform != "win32" else ""
|
||||
BLUE = "\033[94m" if sys.platform != "win32" else ""
|
||||
MAGENTA = "\033[95m" if sys.platform != "win32" else ""
|
||||
CYAN = "\033[96m" if sys.platform != "win32" else ""
|
||||
WHITE = "\033[97m" if sys.platform != "win32" else ""
|
||||
|
||||
class MockStyle:
|
||||
RESET_ALL = '\033[0m' if sys.platform != 'win32' else ''
|
||||
BRIGHT = '\033[1m' if sys.platform != 'win32' else ''
|
||||
|
||||
RESET_ALL = "\033[0m" if sys.platform != "win32" else ""
|
||||
BRIGHT = "\033[1m" if sys.platform != "win32" else ""
|
||||
|
||||
Fore = MockFore()
|
||||
Style = MockStyle()
|
||||
|
||||
|
||||
class Colors:
|
||||
"""Color constants for console output"""
|
||||
|
||||
RED = Fore.RED
|
||||
GREEN = Fore.GREEN
|
||||
YELLOW = Fore.YELLOW
|
||||
@@ -52,11 +55,11 @@ class Colors:
|
||||
|
||||
class ProgressBar:
|
||||
"""Cross-platform progress bar with customizable display"""
|
||||
|
||||
def __init__(self, total: int, width: int = 50, prefix: str = '', suffix: str = ''):
|
||||
|
||||
def __init__(self, total: int, width: int = 50, prefix: str = "", suffix: str = ""):
|
||||
"""
|
||||
Initialize progress bar
|
||||
|
||||
|
||||
Args:
|
||||
total: Total number of items to process
|
||||
width: Width of progress bar in characters
|
||||
@@ -69,29 +72,31 @@ class ProgressBar:
|
||||
self.suffix = suffix
|
||||
self.current = 0
|
||||
self.start_time = time.time()
|
||||
|
||||
|
||||
# Get terminal width for responsive display
|
||||
try:
|
||||
self.terminal_width = shutil.get_terminal_size().columns
|
||||
except OSError:
|
||||
self.terminal_width = 80
|
||||
|
||||
def update(self, current: int, message: str = '') -> None:
|
||||
|
||||
def update(self, current: int, message: str = "") -> None:
|
||||
"""
|
||||
Update progress bar
|
||||
|
||||
|
||||
Args:
|
||||
current: Current progress value
|
||||
message: Optional message to display
|
||||
"""
|
||||
self.current = current
|
||||
percent = min(100, (current / self.total) * 100) if self.total > 0 else 100
|
||||
|
||||
|
||||
# Calculate filled and empty portions
|
||||
filled_width = int(self.width * current / self.total) if self.total > 0 else self.width
|
||||
filled_width = (
|
||||
int(self.width * current / self.total) if self.total > 0 else self.width
|
||||
)
|
||||
filled = symbols.block_filled * filled_width
|
||||
empty = symbols.block_empty * (self.width - filled_width)
|
||||
|
||||
|
||||
# Calculate elapsed time and ETA
|
||||
elapsed = time.time() - self.start_time
|
||||
if current > 0:
|
||||
@@ -99,47 +104,51 @@ class ProgressBar:
|
||||
eta_str = f" ETA: {self._format_time(eta)}"
|
||||
else:
|
||||
eta_str = ""
|
||||
|
||||
|
||||
# Format progress line
|
||||
if message:
|
||||
status = f" {message}"
|
||||
else:
|
||||
status = ""
|
||||
|
||||
|
||||
progress_line = (
|
||||
f"\r{self.prefix}[{Colors.GREEN}{filled}{Colors.WHITE}{empty}{Colors.RESET}] "
|
||||
f"{percent:5.1f}%{status}{eta_str}"
|
||||
)
|
||||
|
||||
|
||||
# Truncate if too long for terminal
|
||||
max_length = self.terminal_width - 5
|
||||
if len(progress_line) > max_length:
|
||||
# Remove color codes for length calculation
|
||||
plain_line = progress_line.replace(Colors.GREEN, '').replace(Colors.WHITE, '').replace(Colors.RESET, '')
|
||||
plain_line = (
|
||||
progress_line.replace(Colors.GREEN, "")
|
||||
.replace(Colors.WHITE, "")
|
||||
.replace(Colors.RESET, "")
|
||||
)
|
||||
if len(plain_line) > max_length:
|
||||
progress_line = progress_line[:max_length] + "..."
|
||||
|
||||
safe_print(progress_line, end='', flush=True)
|
||||
|
||||
def increment(self, message: str = '') -> None:
|
||||
|
||||
safe_print(progress_line, end="", flush=True)
|
||||
|
||||
def increment(self, message: str = "") -> None:
|
||||
"""
|
||||
Increment progress by 1
|
||||
|
||||
|
||||
Args:
|
||||
message: Optional message to display
|
||||
"""
|
||||
self.update(self.current + 1, message)
|
||||
|
||||
def finish(self, message: str = 'Complete') -> None:
|
||||
|
||||
def finish(self, message: str = "Complete") -> None:
|
||||
"""
|
||||
Complete progress bar
|
||||
|
||||
|
||||
Args:
|
||||
message: Completion message
|
||||
"""
|
||||
self.update(self.total, message)
|
||||
print() # New line after completion
|
||||
|
||||
|
||||
def _format_time(self, seconds: float) -> str:
|
||||
"""Format time duration as human-readable string"""
|
||||
if seconds < 60:
|
||||
@@ -154,11 +163,11 @@ class ProgressBar:
|
||||
|
||||
class Menu:
|
||||
"""Interactive menu system with keyboard navigation"""
|
||||
|
||||
|
||||
def __init__(self, title: str, options: List[str], multi_select: bool = False):
|
||||
"""
|
||||
Initialize menu
|
||||
|
||||
|
||||
Args:
|
||||
title: Menu title
|
||||
options: List of menu options
|
||||
@@ -168,42 +177,46 @@ class Menu:
|
||||
self.options = options
|
||||
self.multi_select = multi_select
|
||||
self.selected = set() if multi_select else None
|
||||
|
||||
|
||||
def display(self) -> Union[int, List[int]]:
|
||||
"""
|
||||
Display menu and get user selection
|
||||
|
||||
|
||||
Returns:
|
||||
Selected option index (single) or list of indices (multi-select)
|
||||
"""
|
||||
print(f"\n{Colors.CYAN}{Colors.BRIGHT}{self.title}{Colors.RESET}")
|
||||
print("=" * len(self.title))
|
||||
|
||||
|
||||
for i, option in enumerate(self.options, 1):
|
||||
if self.multi_select:
|
||||
marker = "[x]" if i-1 in (self.selected or set()) else "[ ]"
|
||||
marker = "[x]" if i - 1 in (self.selected or set()) else "[ ]"
|
||||
print(f"{Colors.YELLOW}{i:2d}.{Colors.RESET} {marker} {option}")
|
||||
else:
|
||||
print(f"{Colors.YELLOW}{i:2d}.{Colors.RESET} {option}")
|
||||
|
||||
|
||||
if self.multi_select:
|
||||
print(f"\n{Colors.BLUE}Enter numbers separated by commas (e.g., 1,3,5) or 'all' for all options:{Colors.RESET}")
|
||||
print(
|
||||
f"\n{Colors.BLUE}Enter numbers separated by commas (e.g., 1,3,5) or 'all' for all options:{Colors.RESET}"
|
||||
)
|
||||
else:
|
||||
print(f"\n{Colors.BLUE}Enter your choice (1-{len(self.options)}):{Colors.RESET}")
|
||||
|
||||
print(
|
||||
f"\n{Colors.BLUE}Enter your choice (1-{len(self.options)}):{Colors.RESET}"
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = input("> ").strip().lower()
|
||||
|
||||
|
||||
if self.multi_select:
|
||||
if user_input == 'all':
|
||||
if user_input == "all":
|
||||
return list(range(len(self.options)))
|
||||
elif user_input == '':
|
||||
elif user_input == "":
|
||||
return []
|
||||
else:
|
||||
# Parse comma-separated numbers
|
||||
selections = []
|
||||
for part in user_input.split(','):
|
||||
for part in user_input.split(","):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
idx = int(part) - 1
|
||||
@@ -220,10 +233,12 @@ class Menu:
|
||||
if 0 <= choice < len(self.options):
|
||||
return choice
|
||||
else:
|
||||
print(f"{Colors.RED}Invalid choice. Please enter a number between 1 and {len(self.options)}.{Colors.RESET}")
|
||||
print(
|
||||
f"{Colors.RED}Invalid choice. Please enter a number between 1 and {len(self.options)}.{Colors.RESET}"
|
||||
)
|
||||
else:
|
||||
print(f"{Colors.RED}Please enter a valid number.{Colors.RESET}")
|
||||
|
||||
|
||||
except (ValueError, KeyboardInterrupt) as e:
|
||||
if isinstance(e, KeyboardInterrupt):
|
||||
print(f"\n{Colors.YELLOW}Operation cancelled.{Colors.RESET}")
|
||||
@@ -235,44 +250,46 @@ class Menu:
|
||||
def confirm(message: str, default: bool = True) -> bool:
|
||||
"""
|
||||
Ask for user confirmation
|
||||
|
||||
|
||||
Args:
|
||||
message: Confirmation message
|
||||
default: Default response if user just presses Enter
|
||||
|
||||
|
||||
Returns:
|
||||
True if confirmed, False otherwise
|
||||
"""
|
||||
suffix = "[Y/n]" if default else "[y/N]"
|
||||
print(f"{Colors.BLUE}{message} {suffix}{Colors.RESET}")
|
||||
|
||||
|
||||
while True:
|
||||
try:
|
||||
response = input("> ").strip().lower()
|
||||
|
||||
if response == '':
|
||||
|
||||
if response == "":
|
||||
return default
|
||||
elif response in ['y', 'yes', 'true', '1']:
|
||||
elif response in ["y", "yes", "true", "1"]:
|
||||
return True
|
||||
elif response in ['n', 'no', 'false', '0']:
|
||||
elif response in ["n", "no", "false", "0"]:
|
||||
return False
|
||||
else:
|
||||
print(f"{Colors.RED}Please enter 'y' or 'n' (or press Enter for default).{Colors.RESET}")
|
||||
|
||||
print(
|
||||
f"{Colors.RED}Please enter 'y' or 'n' (or press Enter for default).{Colors.RESET}"
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{Colors.YELLOW}Operation cancelled.{Colors.RESET}")
|
||||
return False
|
||||
|
||||
|
||||
def display_header(title: str, subtitle: str = '') -> None:
|
||||
def display_header(title: str, subtitle: str = "") -> None:
|
||||
"""
|
||||
Display formatted header
|
||||
|
||||
|
||||
Args:
|
||||
title: Main title
|
||||
subtitle: Optional subtitle
|
||||
"""
|
||||
from SuperClaude import __author__, __email__
|
||||
from superclaude import __author__, __email__
|
||||
|
||||
print(f"\n{Colors.CYAN}{Colors.BRIGHT}{'='*60}{Colors.RESET}")
|
||||
print(f"{Colors.CYAN}{Colors.BRIGHT}{title:^60}{Colors.RESET}")
|
||||
@@ -280,13 +297,13 @@ def display_header(title: str, subtitle: str = '') -> None:
|
||||
print(f"{Colors.WHITE}{subtitle:^60}{Colors.RESET}")
|
||||
|
||||
# Display authors
|
||||
authors = [a.strip() for a in __author__.split(',')]
|
||||
emails = [e.strip() for e in __email__.split(',')]
|
||||
authors = [a.strip() for a in __author__.split(",")]
|
||||
emails = [e.strip() for e in __email__.split(",")]
|
||||
|
||||
author_lines = []
|
||||
for i in range(len(authors)):
|
||||
name = authors[i]
|
||||
email = emails[i] if i < len(emails) else ''
|
||||
email = emails[i] if i < len(emails) else ""
|
||||
author_lines.append(f"{name} <{email}>")
|
||||
|
||||
authors_str = " | ".join(author_lines)
|
||||
@@ -297,20 +314,20 @@ def display_header(title: str, subtitle: str = '') -> None:
|
||||
|
||||
def display_authors() -> None:
|
||||
"""Display author information"""
|
||||
from SuperClaude import __author__, __email__, __github__
|
||||
from superclaude import __author__, __email__, __github__
|
||||
|
||||
print(f"\n{Colors.CYAN}{Colors.BRIGHT}{'='*60}{Colors.RESET}")
|
||||
print(f"{Colors.CYAN}{Colors.BRIGHT}{'SuperClaude Authors':^60}{Colors.RESET}")
|
||||
print(f"{Colors.CYAN}{Colors.BRIGHT}{'superclaude Authors':^60}{Colors.RESET}")
|
||||
print(f"{Colors.CYAN}{Colors.BRIGHT}{'='*60}{Colors.RESET}\n")
|
||||
|
||||
authors = [a.strip() for a in __author__.split(',')]
|
||||
emails = [e.strip() for e in __email__.split(',')]
|
||||
github_users = [g.strip() for g in __github__.split(',')]
|
||||
authors = [a.strip() for a in __author__.split(",")]
|
||||
emails = [e.strip() for e in __email__.split(",")]
|
||||
github_users = [g.strip() for g in __github__.split(",")]
|
||||
|
||||
for i in range(len(authors)):
|
||||
name = authors[i]
|
||||
email = emails[i] if i < len(emails) else 'N/A'
|
||||
github = github_users[i] if i < len(github_users) else 'N/A'
|
||||
email = emails[i] if i < len(emails) else "N/A"
|
||||
github = github_users[i] if i < len(github_users) else "N/A"
|
||||
|
||||
print(f" {Colors.BRIGHT}{name}{Colors.RESET}")
|
||||
print(f" Email: {Colors.YELLOW}{email}{Colors.RESET}")
|
||||
@@ -345,10 +362,10 @@ def display_step(step: int, total: int, message: str) -> None:
|
||||
print(f"{Colors.CYAN}[{step}/{total}] {message}{Colors.RESET}")
|
||||
|
||||
|
||||
def display_table(headers: List[str], rows: List[List[str]], title: str = '') -> None:
|
||||
def display_table(headers: List[str], rows: List[List[str]], title: str = "") -> None:
|
||||
"""
|
||||
Display data in table format
|
||||
|
||||
|
||||
Args:
|
||||
headers: Column headers
|
||||
rows: Data rows
|
||||
@@ -356,64 +373,80 @@ def display_table(headers: List[str], rows: List[List[str]], title: str = '') ->
|
||||
"""
|
||||
if not rows:
|
||||
return
|
||||
|
||||
|
||||
# Calculate column widths
|
||||
col_widths = [len(header) for header in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
col_widths[i] = max(col_widths[i], len(str(cell)))
|
||||
|
||||
|
||||
# Display title
|
||||
if title:
|
||||
print(f"\n{Colors.CYAN}{Colors.BRIGHT}{title}{Colors.RESET}")
|
||||
print()
|
||||
|
||||
|
||||
# Display headers
|
||||
header_line = " | ".join(f"{header:<{col_widths[i]}}" for i, header in enumerate(headers))
|
||||
header_line = " | ".join(
|
||||
f"{header:<{col_widths[i]}}" for i, header in enumerate(headers)
|
||||
)
|
||||
print(f"{Colors.YELLOW}{header_line}{Colors.RESET}")
|
||||
print("-" * len(header_line))
|
||||
|
||||
|
||||
# Display rows
|
||||
for row in rows:
|
||||
row_line = " | ".join(f"{str(cell):<{col_widths[i]}}" for i, cell in enumerate(row))
|
||||
row_line = " | ".join(
|
||||
f"{str(cell):<{col_widths[i]}}" for i, cell in enumerate(row)
|
||||
)
|
||||
print(row_line)
|
||||
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def prompt_api_key(service_name: str, env_var_name: str) -> Optional[str]:
|
||||
"""
|
||||
Prompt for API key with security and UX best practices
|
||||
|
||||
|
||||
Args:
|
||||
service_name: Human-readable service name (e.g., "Magic", "Morphllm")
|
||||
env_var_name: Environment variable name (e.g., "TWENTYFIRST_API_KEY")
|
||||
|
||||
|
||||
Returns:
|
||||
API key string if provided, None if skipped
|
||||
"""
|
||||
print(f"{Colors.BLUE}[API KEY] {service_name} requires: {Colors.BRIGHT}{env_var_name}{Colors.RESET}")
|
||||
print(f"{Colors.WHITE}Visit the service documentation to obtain your API key{Colors.RESET}")
|
||||
print(f"{Colors.YELLOW}Press Enter to skip (you can set this manually later){Colors.RESET}")
|
||||
|
||||
print(
|
||||
f"{Colors.BLUE}[API KEY] {service_name} requires: {Colors.BRIGHT}{env_var_name}{Colors.RESET}"
|
||||
)
|
||||
print(
|
||||
f"{Colors.WHITE}Visit the service documentation to obtain your API key{Colors.RESET}"
|
||||
)
|
||||
print(
|
||||
f"{Colors.YELLOW}Press Enter to skip (you can set this manually later){Colors.RESET}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Use getpass for hidden input
|
||||
api_key = getpass.getpass(f"Enter {env_var_name}: ").strip()
|
||||
|
||||
|
||||
if not api_key:
|
||||
print(f"{Colors.YELLOW}[SKIPPED] {env_var_name} - set manually later{Colors.RESET}")
|
||||
print(
|
||||
f"{Colors.YELLOW}[SKIPPED] {env_var_name} - set manually later{Colors.RESET}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# Basic validation (non-empty, reasonable length)
|
||||
if len(api_key) < 10:
|
||||
print(f"{Colors.RED}[WARNING] API key seems too short. Continue anyway? (y/N){Colors.RESET}")
|
||||
print(
|
||||
f"{Colors.RED}[WARNING] API key seems too short. Continue anyway? (y/N){Colors.RESET}"
|
||||
)
|
||||
if not confirm("", default=False):
|
||||
return None
|
||||
|
||||
safe_print(f"{Colors.GREEN}[{symbols.checkmark}] {env_var_name} configured{Colors.RESET}")
|
||||
|
||||
safe_print(
|
||||
f"{Colors.GREEN}[{symbols.checkmark}] {env_var_name} configured{Colors.RESET}"
|
||||
)
|
||||
return api_key
|
||||
|
||||
|
||||
except KeyboardInterrupt:
|
||||
safe_print(f"\n{Colors.YELLOW}[SKIPPED] {env_var_name}{Colors.RESET}")
|
||||
return None
|
||||
@@ -430,16 +463,17 @@ def wait_for_key(message: str = "Press Enter to continue...") -> None:
|
||||
def clear_screen() -> None:
|
||||
"""Clear terminal screen"""
|
||||
import os
|
||||
os.system('cls' if os.name == 'nt' else 'clear')
|
||||
|
||||
os.system("cls" if os.name == "nt" else "clear")
|
||||
|
||||
|
||||
class StatusSpinner:
|
||||
"""Simple status spinner for long operations"""
|
||||
|
||||
|
||||
def __init__(self, message: str = "Working..."):
|
||||
"""
|
||||
Initialize spinner
|
||||
|
||||
|
||||
Args:
|
||||
message: Message to display with spinner
|
||||
"""
|
||||
@@ -447,35 +481,39 @@ class StatusSpinner:
|
||||
self.spinning = False
|
||||
self.chars = symbols.spinner_chars
|
||||
self.current = 0
|
||||
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start spinner in background thread"""
|
||||
import threading
|
||||
|
||||
|
||||
def spin():
|
||||
while self.spinning:
|
||||
char = self.chars[self.current % len(self.chars)]
|
||||
safe_print(f"\r{Colors.BLUE}{char} {self.message}{Colors.RESET}", end='', flush=True)
|
||||
safe_print(
|
||||
f"\r{Colors.BLUE}{char} {self.message}{Colors.RESET}",
|
||||
end="",
|
||||
flush=True,
|
||||
)
|
||||
self.current += 1
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
self.spinning = True
|
||||
self.thread = threading.Thread(target=spin, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def stop(self, final_message: str = '') -> None:
|
||||
|
||||
def stop(self, final_message: str = "") -> None:
|
||||
"""
|
||||
Stop spinner
|
||||
|
||||
|
||||
Args:
|
||||
final_message: Final message to display
|
||||
"""
|
||||
self.spinning = False
|
||||
if hasattr(self, 'thread'):
|
||||
if hasattr(self, "thread"):
|
||||
self.thread.join(timeout=0.2)
|
||||
|
||||
|
||||
# Clear spinner line
|
||||
safe_print(f"\r{' ' * (len(self.message) + 5)}\r", end='')
|
||||
safe_print(f"\r{' ' * (len(self.message) + 5)}\r", end="")
|
||||
|
||||
if final_message:
|
||||
safe_print(final_message)
|
||||
@@ -483,7 +521,7 @@ class StatusSpinner:
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""Format file size in human-readable format"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.1f} {unit}"
|
||||
size_bytes /= 1024.0
|
||||
@@ -510,5 +548,5 @@ def truncate_text(text: str, max_length: int, suffix: str = "...") -> str:
|
||||
"""Truncate text to maximum length with optional suffix"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
|
||||
return text[:max_length - len(suffix)] + suffix
|
||||
|
||||
return text[: max_length - len(suffix)] + suffix
|
||||
|
||||
@@ -22,94 +22,97 @@ from .paths import get_home_directory
|
||||
|
||||
class UpdateChecker:
|
||||
"""Handles automatic update checking for SuperClaude"""
|
||||
|
||||
PYPI_URL = "https://pypi.org/pypi/SuperClaude/json"
|
||||
|
||||
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:
|
||||
with open(self.CACHE_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
last_check = data.get('last_check', 0)
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
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'}
|
||||
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')
|
||||
|
||||
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:
|
||||
|
||||
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
|
||||
@@ -117,14 +120,14 @@ class UpdateChecker:
|
||||
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
|
||||
"""
|
||||
@@ -132,183 +135,195 @@ class UpdateChecker:
|
||||
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
|
||||
["pipx", "list"], capture_output=True, text=True, timeout=2
|
||||
)
|
||||
if 'SuperClaude' in result.stdout or 'superclaude' in result.stdout:
|
||||
return 'pipx'
|
||||
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'],
|
||||
[sys.executable, "-m", "pip", "show", "superclaude"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2
|
||||
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'
|
||||
if "--user" in result.stdout or get_home_directory() in Path(
|
||||
result.stdout
|
||||
):
|
||||
return "pip-user"
|
||||
return "pip"
|
||||
except:
|
||||
pass
|
||||
|
||||
return 'unknown'
|
||||
|
||||
|
||||
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'
|
||||
"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'])
|
||||
|
||||
|
||||
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")
|
||||
|
||||
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']
|
||||
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}")
|
||||
|
||||
|
||||
print(f"{Colors.CYAN}🔄 Updating superclaude...{Colors.RESET}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
update_cmd.split(),
|
||||
capture_output=False,
|
||||
text=True
|
||||
)
|
||||
|
||||
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}")
|
||||
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']:
|
||||
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']:
|
||||
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)
|
||||
return checker.check_and_notify(**kwargs)
|
||||
|
||||
Reference in New Issue
Block a user