diff --git a/setup/cli/commands/uninstall.py b/setup/cli/commands/uninstall.py index 4ba2da6..d87417d 100644 --- a/setup/cli/commands/uninstall.py +++ b/setup/cli/commands/uninstall.py @@ -16,11 +16,107 @@ from ...utils.ui import ( display_header, display_info, display_success, display_error, display_warning, Menu, confirm, ProgressBar, Colors ) +from ...utils.environment import get_superclaude_environment_variables, cleanup_environment_variables from ...utils.logger import get_logger from ... import DEFAULT_INSTALL_DIR, PROJECT_ROOT from . import OperationBase +def verify_superclaude_file(file_path: Path, component: str) -> bool: + """ + Verify this is a SuperClaude file before removal + + Args: + file_path: Path to the file to verify + component: Component name this file belongs to + + Returns: + True if safe to remove, False if uncertain (preserve by default) + """ + try: + # Known SuperClaude file patterns by component + superclaude_patterns = { + 'core': [ + 'CLAUDE.md', 'FLAGS.md', 'PRINCIPLES.md', 'RULES.md', + 'ORCHESTRATOR.md', 'SESSION_LIFECYCLE.md' + ], + 'commands': [ + # Commands are only in sc/ subdirectory + ], + 'agents': [ + 'backend-engineer.md', 'brainstorm-PRD.md', 'code-educator.md', + 'code-refactorer.md', 'devops-engineer.md', 'frontend-specialist.md', + 'performance-optimizer.md', 'python-ultimate-expert.md', 'qa-specialist.md', + 'root-cause-analyzer.md', 'security-auditor.md', 'system-architect.md', + 'technical-writer.md' + ], + 'modes': [ + 'MODE_Brainstorming.md', 'MODE_Introspection.md', + 'MODE_Task_Management.md', 'MODE_Token_Efficiency.md' + ], + 'mcp_docs': [ + 'MCP_Context7.md', 'MCP_Sequential.md', 'MCP_Magic.md', + 'MCP_Playwright.md', 'MCP_Morphllm.md', 'MCP_Serena.md' + ] + } + + # For commands component, verify it's in the sc/ subdirectory + if component == 'commands': + return 'commands/sc/' in str(file_path) + + # For other components, check against known file lists + if component in superclaude_patterns: + filename = file_path.name + return filename in superclaude_patterns[component] + + # For MCP component, it doesn't remove files but modifies .claude.json + if component == 'mcp': + return True # MCP component has its own safety logic + + # Default to preserve if uncertain + return False + + except Exception: + # If any error occurs in verification, preserve the file + return False + + +def verify_directory_safety(directory: Path, component: str) -> bool: + """ + Verify it's safe to remove a directory + + Args: + directory: Directory path to verify + component: Component name + + Returns: + True if safe to remove (only if empty or only contains SuperClaude files) + """ + try: + if not directory.exists(): + return True + + # Check if directory is empty + contents = list(directory.iterdir()) + if not contents: + return True + + # Check if all contents are SuperClaude files for this component + for item in contents: + if item.is_file(): + if not verify_superclaude_file(item, component): + return False + elif item.is_dir(): + # Don't remove directories that contain non-SuperClaude subdirectories + return False + + return True + + except Exception: + # If any error occurs, preserve the directory + return False + + class UninstallOperation(OperationBase): """Uninstall operation implementation""" @@ -87,6 +183,19 @@ Examples: help="Skip confirmation prompts (use with caution)" ) + # Environment cleanup options + parser.add_argument( + "--cleanup-env", + action="store_true", + help="Remove SuperClaude environment variables" + ) + + parser.add_argument( + "--no-restore-script", + action="store_true", + help="Skip creating environment variable restore script" + ) + return parser def get_installed_components(install_dir: Path) -> Dict[str, Dict[str, Any]]: @@ -129,6 +238,26 @@ def get_installation_info(install_dir: Path) -> Dict[str, Any]: return info +def display_environment_info() -> Dict[str, str]: + """Display SuperClaude environment variables and return them""" + env_vars = get_superclaude_environment_variables() + + if env_vars: + print(f"\n{Colors.CYAN}{Colors.BRIGHT}Environment Variables{Colors.RESET}") + print("=" * 50) + print(f"{Colors.BLUE}SuperClaude API key environment variables found:{Colors.RESET}") + for env_var, value in env_vars.items(): + # Show only first few and last few characters for security + masked_value = f"{value[:4]}...{value[-4:]}" if len(value) > 8 else "***" + print(f" {env_var}: {masked_value}") + + print(f"\n{Colors.YELLOW}Note: These environment variables will remain unless you use --cleanup-env{Colors.RESET}") + else: + print(f"\n{Colors.GREEN}No SuperClaude environment variables found{Colors.RESET}") + + return env_vars + + def display_uninstall_info(info: Dict[str, Any]) -> None: """Display installation information before uninstall""" print(f"\n{Colors.CYAN}{Colors.BRIGHT}Current Installation{Colors.RESET}") @@ -176,60 +305,269 @@ def get_components_to_uninstall(args: argparse.Namespace, installed_components: return interactive_uninstall_selection(installed_components) -def interactive_uninstall_selection(installed_components: Dict[str, str]) -> Optional[List[str]]: - """Interactive uninstall selection""" +def interactive_component_selection(installed_components: Dict[str, str], env_vars: Dict[str, str]) -> Optional[tuple]: + """ + Enhanced interactive selection with granular component options + + Returns: + Tuple of (components_to_remove, cleanup_options) or None if cancelled + """ if not installed_components: return [] - print(f"\n{Colors.CYAN}Uninstall Options:{Colors.RESET}") + print(f"\n{Colors.CYAN}{Colors.BRIGHT}SuperClaude Uninstall Options{Colors.RESET}") + print("=" * 60) - # Create menu options - preset_options = [ - "Complete Uninstall (remove everything)", - "Remove Specific Components", + # Main uninstall type selection + main_options = [ + "Complete Uninstall (remove all SuperClaude components)", + "Custom Uninstall (choose specific components)", "Cancel Uninstall" ] - menu = Menu("Select uninstall option:", preset_options) - choice = menu.display() + print(f"\n{Colors.BLUE}Choose uninstall type:{Colors.RESET}") + main_menu = Menu("Select option:", main_options) + main_choice = main_menu.display() - if choice == -1 or choice == 2: # Cancelled + if main_choice == -1 or main_choice == 2: # Cancelled return None - elif choice == 0: # Complete uninstall - return list(installed_components.keys()) - elif choice == 1: # Select specific components - component_options = [] - component_names = [] - - for component, version in installed_components.items(): - component_options.append(f"{component} (v{version})") - component_names.append(component) - - component_menu = Menu("Select components to uninstall:", component_options, multi_select=True) - selections = component_menu.display() - - if not selections: - return None - - return [component_names[i] for i in selections] + elif main_choice == 0: # Complete uninstall + # Complete uninstall - include all components and optional cleanup + cleanup_options = _ask_complete_uninstall_options(env_vars) + return list(installed_components.keys()), cleanup_options + elif main_choice == 1: # Custom uninstall + return _custom_component_selection(installed_components, env_vars) return None -def display_uninstall_plan(components: List[str], args: argparse.Namespace, info: Dict[str, Any]) -> None: - """Display uninstall plan""" +def _ask_complete_uninstall_options(env_vars: Dict[str, str]) -> Dict[str, bool]: + """Ask for complete uninstall options""" + cleanup_options = { + 'remove_mcp_configs': True, + 'cleanup_env_vars': False, + 'create_restore_script': True + } + + print(f"\n{Colors.YELLOW}{Colors.BRIGHT}Complete Uninstall Options{Colors.RESET}") + print("This will remove ALL SuperClaude components.") + + if env_vars: + print(f"\n{Colors.BLUE}Environment variables found:{Colors.RESET}") + for env_var, value in env_vars.items(): + masked_value = f"{value[:4]}...{value[-4:]}" if len(value) > 8 else "***" + print(f" {env_var}: {masked_value}") + + cleanup_env = confirm("Also remove API key environment variables?", default=False) + cleanup_options['cleanup_env_vars'] = cleanup_env + + if cleanup_env: + create_script = confirm("Create restore script for environment variables?", default=True) + cleanup_options['create_restore_script'] = create_script + + return cleanup_options + + +def _custom_component_selection(installed_components: Dict[str, str], env_vars: Dict[str, str]) -> Optional[tuple]: + """Handle custom component selection with granular options""" + print(f"\n{Colors.CYAN}{Colors.BRIGHT}Custom Uninstall - Choose Components{Colors.RESET}") + print("Select which SuperClaude components to remove:") + + # Build component options with descriptions + component_options = [] + component_keys = [] + + component_descriptions = { + 'core': 'Core Framework Files (CLAUDE.md, FLAGS.md, PRINCIPLES.md, etc.)', + 'commands': 'SuperClaude Commands (commands/sc/*.md)', + 'agents': 'Specialized Agents (agents/*.md)', + 'mcp': 'MCP Server Configurations', + 'mcp_docs': 'MCP Documentation', + 'modes': 'SuperClaude Modes' + } + + for component, version in installed_components.items(): + description = component_descriptions.get(component, f"{component} component") + component_options.append(f"{description}") + component_keys.append(component) + + print(f"\n{Colors.BLUE}Select components to remove:{Colors.RESET}") + component_menu = Menu("Components:", component_options, multi_select=True) + selections = component_menu.display() + + if not selections: + return None + + selected_components = [component_keys[i] for i in selections] + + # If MCP component is selected, ask about related cleanup options + cleanup_options = { + 'remove_mcp_configs': 'mcp' in selected_components, + 'cleanup_env_vars': False, + 'create_restore_script': True + } + + if 'mcp' in selected_components: + cleanup_options.update(_ask_mcp_cleanup_options(env_vars)) + elif env_vars: + # Even if MCP not selected, ask about env vars if they exist + cleanup_env = confirm(f"Remove {len(env_vars)} API key environment variables?", default=False) + cleanup_options['cleanup_env_vars'] = cleanup_env + if cleanup_env: + create_script = confirm("Create restore script for environment variables?", default=True) + cleanup_options['create_restore_script'] = create_script + + return selected_components, cleanup_options + + +def _ask_mcp_cleanup_options(env_vars: Dict[str, str]) -> Dict[str, bool]: + """Ask for MCP-related cleanup options""" + print(f"\n{Colors.YELLOW}{Colors.BRIGHT}MCP Cleanup Options{Colors.RESET}") + print("Since you're removing the MCP component:") + + cleanup_options = {} + + # Ask about MCP server configurations + remove_configs = confirm("Remove MCP server configurations from .claude.json?", default=True) + cleanup_options['remove_mcp_configs'] = remove_configs + + # Ask about API key environment variables + if env_vars: + print(f"\n{Colors.BLUE}Related API key environment variables found:{Colors.RESET}") + for env_var, value in env_vars.items(): + masked_value = f"{value[:4]}...{value[-4:]}" if len(value) > 8 else "***" + print(f" {env_var}: {masked_value}") + + cleanup_env = confirm(f"Remove {len(env_vars)} API key environment variables?", default=False) + cleanup_options['cleanup_env_vars'] = cleanup_env + + if cleanup_env: + create_script = confirm("Create restore script for environment variables?", default=True) + cleanup_options['create_restore_script'] = create_script + else: + cleanup_options['create_restore_script'] = True + else: + cleanup_options['cleanup_env_vars'] = False + cleanup_options['create_restore_script'] = True + + return cleanup_options + + +def interactive_uninstall_selection(installed_components: Dict[str, str]) -> Optional[List[str]]: + """Legacy function - redirects to enhanced selection""" + env_vars = get_superclaude_environment_variables() + result = interactive_component_selection(installed_components, env_vars) + + if result is None: + return None + + # For backwards compatibility, return only component list + components, cleanup_options = result + return components + + +def display_preservation_info() -> None: + """Show what will NOT be removed (user's custom files)""" + print(f"\n{Colors.GREEN}{Colors.BRIGHT}Files that will be preserved:{Colors.RESET}") + print(f"{Colors.GREEN}✓ User's custom commands (not in commands/sc/){Colors.RESET}") + print(f"{Colors.GREEN}✓ User's custom agents (not SuperClaude agents){Colors.RESET}") + print(f"{Colors.GREEN}✓ User's custom .claude.json configurations{Colors.RESET}") + print(f"{Colors.GREEN}✓ User's custom files in shared directories{Colors.RESET}") + print(f"{Colors.GREEN}✓ Claude Code settings and other tools' configurations{Colors.RESET}") + + +def display_component_details(component: str, info: Dict[str, Any]) -> Dict[str, Any]: + """Get detailed information about what will be removed for a component""" + details = { + 'files': [], + 'directories': [], + 'size': 0, + 'description': '' + } + + install_dir = info['install_dir'] + + component_paths = { + 'core': { + 'files': ['CLAUDE.md', 'FLAGS.md', 'PRINCIPLES.md', 'RULES.md', 'ORCHESTRATOR.md', 'SESSION_LIFECYCLE.md'], + 'description': 'Core framework files in ~/.claude/' + }, + 'commands': { + 'files': 'commands/sc/*.md', + 'description': 'SuperClaude commands in ~/.claude/commands/sc/' + }, + 'agents': { + 'files': 'agents/*.md', + 'description': 'Specialized AI agents in ~/.claude/agents/' + }, + 'mcp': { + 'files': 'MCP server configurations in .claude.json', + 'description': 'MCP server configurations' + }, + 'mcp_docs': { + 'files': 'MCP/*.md', + 'description': 'MCP documentation files' + }, + 'modes': { + 'files': 'MODE_*.md', + 'description': 'SuperClaude operational modes' + } + } + + if component in component_paths: + details['description'] = component_paths[component]['description'] + + # Get actual file count from metadata if available + component_metadata = info["components"].get(component, {}) + if isinstance(component_metadata, dict): + if 'files_count' in component_metadata: + details['file_count'] = component_metadata['files_count'] + elif 'agents_count' in component_metadata: + details['file_count'] = component_metadata['agents_count'] + elif 'servers_configured' in component_metadata: + details['file_count'] = component_metadata['servers_configured'] + + return details + + +def display_uninstall_plan(components: List[str], args: argparse.Namespace, info: Dict[str, Any], env_vars: Dict[str, str]) -> None: + """Display detailed uninstall plan""" print(f"\n{Colors.CYAN}{Colors.BRIGHT}Uninstall Plan{Colors.RESET}") - print("=" * 50) + print("=" * 60) print(f"{Colors.BLUE}Installation Directory:{Colors.RESET} {info['install_dir']}") if components: - print(f"{Colors.BLUE}Components to remove:{Colors.RESET}") + print(f"\n{Colors.BLUE}Components to remove:{Colors.RESET}") + total_files = 0 + for i, component_name in enumerate(components, 1): + details = display_component_details(component_name, info) version = info["components"].get(component_name, "unknown") - print(f" {i}. {component_name} (v{version})") + + if isinstance(version, dict): + version_str = version.get('version', 'unknown') + file_count = details.get('file_count', version.get('files_count', version.get('agents_count', version.get('servers_configured', '?')))) + else: + version_str = str(version) + file_count = details.get('file_count', '?') + + print(f" {i}. {component_name} (v{version_str}) - {file_count} files") + print(f" {details['description']}") + + if isinstance(file_count, int): + total_files += file_count + + print(f"\n{Colors.YELLOW}Total estimated files to remove: {total_files}{Colors.RESET}") - # Show what will be preserved + # Show detailed preservation information + print(f"\n{Colors.GREEN}{Colors.BRIGHT}Safety Guarantees - Will Preserve:{Colors.RESET}") + print(f"{Colors.GREEN}✓ User's custom commands (not in commands/sc/){Colors.RESET}") + print(f"{Colors.GREEN}✓ User's custom agents (not SuperClaude agents){Colors.RESET}") + print(f"{Colors.GREEN}✓ User's .claude.json customizations{Colors.RESET}") + print(f"{Colors.GREEN}✓ Claude Code settings and other tools' configurations{Colors.RESET}") + + # Show additional preserved items preserved = [] if args.keep_backups: preserved.append("backup files") @@ -239,10 +577,25 @@ def display_uninstall_plan(components: List[str], args: argparse.Namespace, info preserved.append("user settings") if preserved: - print(f"{Colors.GREEN}Will preserve:{Colors.RESET} {', '.join(preserved)}") + for item in preserved: + print(f"{Colors.GREEN}✓ {item}{Colors.RESET}") if args.complete: - print(f"{Colors.RED}WARNING: Complete uninstall will remove all SuperClaude files{Colors.RESET}") + print(f"\n{Colors.RED}⚠️ WARNING: Complete uninstall will remove all SuperClaude files{Colors.RESET}") + + # Environment variable cleanup information + if env_vars: + print(f"\n{Colors.BLUE}Environment Variables:{Colors.RESET}") + if args.cleanup_env: + print(f"{Colors.YELLOW}Will remove {len(env_vars)} API key environment variables:{Colors.RESET}") + for env_var in env_vars.keys(): + print(f" - {env_var}") + if not args.no_restore_script: + print(f"{Colors.GREEN} ✓ Restore script will be created{Colors.RESET}") + else: + print(f"{Colors.BLUE}Will preserve {len(env_vars)} API key environment variables:{Colors.RESET}") + for env_var in env_vars.keys(): + print(f" ✓ {env_var}") print() @@ -279,7 +632,7 @@ def create_uninstall_backup(install_dir: Path, components: List[str]) -> Optiona return None -def perform_uninstall(components: List[str], args: argparse.Namespace, info: Dict[str, Any]) -> bool: +def perform_uninstall(components: List[str], args: argparse.Namespace, info: Dict[str, Any], env_vars: Dict[str, str]) -> bool: """Perform the actual uninstall""" logger = get_logger() start_time = time.time() @@ -333,6 +686,18 @@ def perform_uninstall(components: List[str], args: argparse.Namespace, info: Dic if args.complete: cleanup_installation_directory(args.install_dir, args) + # Handle environment variable cleanup + env_cleanup_success = True + if args.cleanup_env and env_vars: + logger.info("Cleaning up environment variables...") + create_restore_script = not args.no_restore_script + env_cleanup_success = cleanup_environment_variables(env_vars, create_restore_script) + + if env_cleanup_success: + logger.success(f"Removed {len(env_vars)} environment variables") + else: + logger.warning("Some environment variables could not be removed") + # Show results duration = time.time() - start_time @@ -432,23 +797,48 @@ def run(args: argparse.Namespace) -> int: if not args.quiet: display_uninstall_info(info) + # Check for environment variables + env_vars = display_environment_info() if not args.quiet else get_superclaude_environment_variables() + # Check if SuperClaude is installed if not info["exists"]: logger.warning(f"No SuperClaude installation found in {args.install_dir}") return 0 - # Get components to uninstall - components = get_components_to_uninstall(args, info["components"]) - if components is None: - logger.info("Uninstall cancelled by user") - return 0 - elif not components: - logger.info("No components selected for uninstall") - return 0 + # Get components to uninstall using enhanced selection + if args.components or args.complete: + # Non-interactive mode - use existing logic + components = get_components_to_uninstall(args, info["components"]) + cleanup_options = { + 'remove_mcp_configs': 'mcp' in (components or []), + 'cleanup_env_vars': args.cleanup_env, + 'create_restore_script': not args.no_restore_script + } + if components is None: + logger.info("Uninstall cancelled by user") + return 0 + elif not components: + logger.info("No components selected for uninstall") + return 0 + else: + # Interactive mode - use enhanced selection + result = interactive_component_selection(info["components"], env_vars) + if result is None: + logger.info("Uninstall cancelled by user") + return 0 + elif not result: + logger.info("No components selected for uninstall") + return 0 + + components, cleanup_options = result + + # Override command-line args with interactive choices + args.cleanup_env = cleanup_options.get('cleanup_env_vars', False) + args.no_restore_script = not cleanup_options.get('create_restore_script', True) # Display uninstall plan if not args.quiet: - display_uninstall_plan(components, args, info) + display_uninstall_plan(components, args, info, env_vars) # Confirmation if not args.no_confirm and not args.yes: @@ -466,7 +856,7 @@ def run(args: argparse.Namespace) -> int: create_uninstall_backup(args.install_dir, components) # Perform uninstall - success = perform_uninstall(components, args, info) + success = perform_uninstall(components, args, info, env_vars) if success: if not args.quiet: diff --git a/setup/cli/commands/update.py b/setup/cli/commands/update.py index 628e59c..447bf42 100644 --- a/setup/cli/commands/update.py +++ b/setup/cli/commands/update.py @@ -15,8 +15,9 @@ 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 + display_warning, Menu, confirm, ProgressBar, Colors, format_size, prompt_api_key ) +from ...utils.environment import setup_environment_variables from ...utils.logger import get_logger from ... import DEFAULT_INSTALL_DIR, PROJECT_ROOT from . import OperationBase @@ -173,6 +174,45 @@ def get_components_to_update(args: argparse.Namespace, installed_components: Dic return [] +def collect_api_keys_for_servers(selected_servers: List[str], mcp_instance) -> Dict[str, str]: + """ + Collect API keys for servers that require them during update + + Args: + selected_servers: List of selected server keys + mcp_instance: MCP component instance + + Returns: + Dictionary of environment variable names to API key values + """ + # Filter servers needing keys + servers_needing_keys = [ + (server_key, mcp_instance.mcp_servers[server_key]) + for server_key in selected_servers + if server_key in mcp_instance.mcp_servers and + mcp_instance.mcp_servers[server_key].get("requires_api_key", False) + ] + + if not servers_needing_keys: + return {} + + # Display API key configuration header + print(f"\n{Colors.CYAN}{Colors.BRIGHT}═══ API Key Configuration ═══{Colors.RESET}") + print(f"{Colors.YELLOW}New MCP servers require API keys for full functionality:{Colors.RESET}\n") + + collected_keys = {} + for server_key, server_info in servers_needing_keys: + api_key_env = server_info.get("api_key_env") + service_name = server_info["name"] + + if api_key_env: + key = prompt_api_key(service_name, api_key_env) + if key: + collected_keys[api_key_env] = key + + return collected_keys + + def interactive_update_selection(available_updates: Dict[str, Dict[str, str]], installed_components: Dict[str, str]) -> Optional[List[str]]: """Interactive update selection""" @@ -255,6 +295,26 @@ def perform_update(components: List[str], args: argparse.Namespace) -> bool: logger.error("No valid component instances created") return False + # Handle MCP component specially - collect API keys for new servers + collected_api_keys = {} + if "mcp" in components and "mcp" in component_instances: + mcp_instance = component_instances["mcp"] + if hasattr(mcp_instance, 'mcp_servers'): + # Get all available MCP servers + all_server_keys = list(mcp_instance.mcp_servers.keys()) + + # Collect API keys for any servers that require them + collected_api_keys = collect_api_keys_for_servers(all_server_keys, mcp_instance) + + # Set up environment variables if any keys were collected + if collected_api_keys: + setup_environment_variables(collected_api_keys) + + # Store keys for MCP component to use during update + mcp_instance.collected_api_keys = collected_api_keys + + logger.info(f"Collected {len(collected_api_keys)} API keys for MCP server update") + # Register components with installer installer.register_components(list(component_instances.values())) @@ -275,7 +335,8 @@ def perform_update(components: List[str], args: argparse.Namespace) -> bool: "force": args.force, "backup": backup, "dry_run": args.dry_run, - "update_mode": True + "update_mode": True, + "selected_mcp_servers": list(mcp_instance.mcp_servers.keys()) if "mcp" in component_instances else [] } success = installer.update_components(components, config) diff --git a/setup/utils/environment.py b/setup/utils/environment.py index 9205e5e..fa0dd99 100644 --- a/setup/utils/environment.py +++ b/setup/utils/environment.py @@ -6,12 +6,83 @@ Cross-platform utilities for setting up persistent environment variables import os import sys import subprocess +import json from pathlib import Path from typing import Dict, Optional +from datetime import datetime from .ui import display_info, display_success, display_warning, Colors from .logger import get_logger +def _get_env_tracking_file() -> Path: + """Get path to environment variable tracking file""" + from .. import DEFAULT_INSTALL_DIR + install_dir = Path.home() / ".claude" + install_dir.mkdir(exist_ok=True) + return install_dir / "superclaude_env_vars.json" + + +def _load_env_tracking() -> Dict[str, Dict[str, str]]: + """Load environment variable tracking data""" + tracking_file = _get_env_tracking_file() + + try: + if tracking_file.exists(): + with open(tracking_file, 'r') as f: + return json.load(f) + except Exception as e: + get_logger().warning(f"Could not load environment tracking: {e}") + + return {} + + +def _save_env_tracking(tracking_data: Dict[str, Dict[str, str]]) -> bool: + """Save environment variable tracking data""" + tracking_file = _get_env_tracking_file() + + try: + with open(tracking_file, 'w') as f: + json.dump(tracking_data, f, indent=2) + return True + except Exception as e: + get_logger().error(f"Could not save environment tracking: {e}") + return False + + +def _add_env_tracking(env_vars: Dict[str, str]) -> None: + """Add environment variables to tracking""" + if not env_vars: + return + + tracking_data = _load_env_tracking() + timestamp = datetime.now().isoformat() + + for env_var, value in env_vars.items(): + tracking_data[env_var] = { + "set_by": "superclaude", + "timestamp": timestamp, + "value_hash": str(hash(value)) # Store hash, not actual value for security + } + + _save_env_tracking(tracking_data) + get_logger().info(f"Added {len(env_vars)} environment variables to tracking") + + +def _remove_env_tracking(env_vars: list) -> None: + """Remove environment variables from tracking""" + if not env_vars: + return + + tracking_data = _load_env_tracking() + + for env_var in env_vars: + if env_var in tracking_data: + del tracking_data[env_var] + + _save_env_tracking(tracking_data) + get_logger().info(f"Removed {len(env_vars)} environment variables from tracking") + + def detect_shell_config() -> Optional[Path]: """ Detect user's shell configuration file @@ -106,6 +177,9 @@ def setup_environment_variables(api_keys: Dict[str, str]) -> bool: 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") @@ -159,6 +233,184 @@ def get_shell_name() -> str: return 'unknown' +def get_superclaude_environment_variables() -> Dict[str, str]: + """ + Get environment variables that were set by SuperClaude + + Returns: + Dictionary of environment variable names to their current values + """ + # Load tracking data to get SuperClaude-managed variables + tracking_data = _load_env_tracking() + + found_vars = {} + for env_var, metadata in tracking_data.items(): + if metadata.get("set_by") == "superclaude": + value = os.environ.get(env_var) + if value: + found_vars[env_var] = value + + # Fallback: check known SuperClaude API key environment variables + # (for backwards compatibility with existing installations) + known_superclaude_env_vars = [ + "TWENTYFIRST_API_KEY", # Magic server + "MORPH_API_KEY" # Morphllm server + ] + + for env_var in known_superclaude_env_vars: + if env_var not in found_vars: + value = os.environ.get(env_var) + if value: + found_vars[env_var] = value + + return found_vars + + +def cleanup_environment_variables(env_vars_to_remove: Dict[str, str], create_restore_script: bool = True) -> bool: + """ + Safely remove environment variables with backup and restore options + + Args: + env_vars_to_remove: Dictionary of environment variable names to remove + create_restore_script: Whether to create a script to restore the variables + + Returns: + True if cleanup was successful, False otherwise + """ + logger = get_logger() + success = True + + if not env_vars_to_remove: + return True + + # Create restore script if requested + if create_restore_script: + restore_script_path = _create_restore_script(env_vars_to_remove) + if restore_script_path: + display_info(f"Created restore script: {restore_script_path}") + else: + display_warning("Could not create restore script") + + print(f"\n{Colors.BLUE}[INFO] Removing environment variables...{Colors.RESET}") + + for env_var, value in env_vars_to_remove.items(): + try: + # Remove from current session + if env_var in os.environ: + del os.environ[env_var] + logger.info(f"Removed {env_var} from current session") + + if os.name == 'nt': # Windows + # Remove persistent user variable using reg command + result = subprocess.run( + ['reg', 'delete', 'HKCU\\Environment', '/v', env_var, '/f'], + capture_output=True, + text=True + ) + if result.returncode != 0: + # Variable might not exist in registry, which is fine + logger.debug(f"Registry deletion for {env_var}: {result.stderr.strip()}") + else: + logger.info(f"Removed {env_var} from Windows registry") + else: # Unix-like systems + shell_config = detect_shell_config() + if shell_config and shell_config.exists(): + _remove_env_var_from_shell_config(shell_config, env_var) + + except Exception as e: + logger.error(f"Failed to remove {env_var}: {e}") + display_warning(f"Could not remove {env_var}: {e}") + success = False + + if success: + # Remove from tracking + _remove_env_tracking(list(env_vars_to_remove.keys())) + + display_success("Environment variables removed successfully") + if os.name != 'nt': + display_info("Restart your terminal or source your shell config to apply changes") + else: + display_info("Changes will take effect in new terminal sessions") + else: + display_warning("Some environment variables could not be removed") + + return success + + +def _create_restore_script(env_vars: Dict[str, str]) -> Optional[Path]: + """Create a script to restore environment variables""" + try: + home = Path.home() + if os.name == 'nt': # Windows + script_path = home / "restore_superclaude_env.bat" + with open(script_path, 'w') as f: + f.write("@echo off\n") + f.write("REM SuperClaude Environment Variable Restore Script\n") + f.write("REM Generated during uninstall\n\n") + for env_var, value in env_vars.items(): + f.write(f'setx {env_var} "{value}"\n') + f.write("\necho Environment variables restored\n") + f.write("pause\n") + else: # Unix-like + script_path = home / "restore_superclaude_env.sh" + with open(script_path, 'w') as f: + f.write("#!/bin/bash\n") + f.write("# SuperClaude Environment Variable Restore Script\n") + f.write("# Generated during uninstall\n\n") + shell_config = detect_shell_config() + for env_var, value in env_vars.items(): + f.write(f'export {env_var}="{value}"\n') + if shell_config: + f.write(f'echo \'export {env_var}="{value}"\' >> {shell_config}\n') + f.write("\necho 'Environment variables restored'\n") + + # Make script executable + script_path.chmod(0o755) + + return script_path + + except Exception as e: + get_logger().error(f"Failed to create restore script: {e}") + return None + + +def _remove_env_var_from_shell_config(shell_config: Path, env_var: str) -> bool: + """Remove environment variable export from shell configuration file""" + try: + # Read current content + with open(shell_config, 'r') as f: + lines = f.readlines() + + # Filter out lines that export this variable + filtered_lines = [] + skip_next_blank = False + + for line in lines: + # Check if this line exports our variable + if f'export {env_var}=' in line or line.strip() == f'# SuperClaude API Key': + skip_next_blank = True + continue + + # Skip blank line after removed export + if skip_next_blank and line.strip() == '': + skip_next_blank = False + continue + + skip_next_blank = False + filtered_lines.append(line) + + # Write back the filtered content + with open(shell_config, 'w') as f: + f.writelines(filtered_lines) + + get_logger().info(f"Removed {env_var} export from {shell_config.name}") + return True + + except Exception as e: + get_logger().error(f"Failed to remove {env_var} from {shell_config}: {e}") + return False + + def create_env_file(api_keys: Dict[str, str], env_file_path: Optional[Path] = None) -> bool: """ Create a .env file with the API keys (alternative to shell config)