""" Settings management for SuperClaude installation system Handles settings.json manipulation with deep merge and backup """ import json import shutil from typing import Dict, Any, Optional, List from pathlib import Path from datetime import datetime import copy class SettingsManager: """Manages settings.json file operations""" def __init__(self, install_dir: Path): """ Initialize settings manager Args: install_dir: Installation directory containing settings.json """ self.install_dir = install_dir self.settings_file = install_dir / "settings.json" 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: return json.load(f) except (json.JSONDecodeError, IOError) as e: raise ValueError(f"Could not load settings from {self.settings_file}: {e}") def save_settings(self, settings: Dict[str, Any], create_backup: bool = True) -> None: """ Save settings to settings.json with optional backup Args: settings: Settings dict to save create_backup: Whether to create backup before saving """ # Create backup if requested and file exists if create_backup and self.settings_file.exists(): self._create_settings_backup() # Ensure directory exists self.settings_file.parent.mkdir(parents=True, exist_ok=True) # Save with pretty formatting try: with open(self.settings_file, 'w', encoding='utf-8') as f: json.dump(settings, f, indent=2, ensure_ascii=False, sort_keys=True) except IOError as e: raise ValueError(f"Could not save settings to {self.settings_file}: {e}") def load_metadata(self) -> Dict[str, Any]: """ Load SuperClaude metadata from .superclaude-metadata.json Returns: Metadata dict (empty if file doesn't exist) """ if not self.metadata_file.exists(): return {} try: with open(self.metadata_file, 'r', encoding='utf-8') as f: return json.load(f) except (json.JSONDecodeError, IOError) as e: raise ValueError(f"Could not load metadata from {self.metadata_file}: {e}") def save_metadata(self, metadata: Dict[str, Any]) -> None: """ Save SuperClaude metadata to .superclaude-metadata.json Args: metadata: Metadata dict to save """ # Ensure directory exists self.metadata_file.parent.mkdir(parents=True, exist_ok=True) # Save with pretty formatting try: with open(self.metadata_file, 'w', encoding='utf-8') as f: json.dump(metadata, f, indent=2, ensure_ascii=False, sort_keys=True) except IOError as e: raise ValueError(f"Could not save metadata to {self.metadata_file}: {e}") def migrate_superclaude_data(self) -> bool: """ Migrate SuperClaude-specific data from settings.json to metadata file Returns: True if migration occurred, False if no data to migrate """ settings = self.load_settings() # SuperClaude-specific fields to migrate superclaude_fields = ["components", "framework", "superclaude", "mcp"] data_to_migrate = {} fields_found = False # Extract SuperClaude data for field in superclaude_fields: if field in settings: data_to_migrate[field] = settings[field] fields_found = True if not fields_found: return False # Load existing metadata (if any) and merge existing_metadata = self.load_metadata() merged_metadata = self._deep_merge(existing_metadata, data_to_migrate) # Save to metadata file self.save_metadata(merged_metadata) # Remove SuperClaude fields from settings clean_settings = {k: v for k, v in settings.items() if k not in superclaude_fields} # Save cleaned settings self.save_settings(clean_settings, create_backup=True) return True def merge_settings(self, modifications: Dict[str, Any]) -> Dict[str, Any]: """ Deep merge modifications into existing settings Args: modifications: Settings modifications to merge Returns: Merged settings dict """ existing = self.load_settings() return self._deep_merge(existing, modifications) def update_settings(self, modifications: Dict[str, Any], create_backup: bool = True) -> None: """ Update settings with modifications Args: modifications: Settings modifications to apply create_backup: Whether to create backup before updating """ merged = self.merge_settings(modifications) self.save_settings(merged, create_backup) def get_setting(self, key_path: str, default: Any = None) -> Any: """ Get setting value using dot-notation path Args: key_path: Dot-separated path (e.g., "hooks.enabled") default: Default value if key not found Returns: Setting value or default """ settings = self.load_settings() try: value = settings for key in key_path.split('.'): value = value[key] return value except (KeyError, TypeError): return default def set_setting(self, key_path: str, value: Any, create_backup: bool = True) -> None: """ Set setting value using dot-notation path Args: key_path: Dot-separated path (e.g., "hooks.enabled") value: Value to set create_backup: Whether to create backup before updating """ # Build nested dict structure keys = key_path.split('.') modification = {} current = modification for key in keys[:-1]: current[key] = {} current = current[key] current[keys[-1]] = value self.update_settings(modification, create_backup) def remove_setting(self, key_path: str, create_backup: bool = True) -> bool: """ Remove setting using dot-notation path Args: key_path: Dot-separated path to remove create_backup: Whether to create backup before updating Returns: True if setting was removed, False if not found """ settings = self.load_settings() keys = key_path.split('.') # Navigate to parent of target key current = settings try: for key in keys[:-1]: current = current[key] # Remove the target key if keys[-1] in current: del current[keys[-1]] self.save_settings(settings, create_backup) return True else: return False except (KeyError, TypeError): return False def add_component_registration(self, component_name: str, component_info: Dict[str, Any]) -> None: """ Add component to registry in metadata Args: component_name: Name of component component_info: Component metadata dict """ metadata = self.load_metadata() if "components" not in metadata: metadata["components"] = {} metadata["components"][component_name] = { **component_info, "installed_at": datetime.now().isoformat() } self.save_metadata(metadata) def remove_component_registration(self, component_name: str) -> bool: """ Remove component from registry in metadata Args: component_name: Name of component to remove Returns: True if component was removed, False if not found """ metadata = self.load_metadata() if "components" in metadata and component_name in metadata["components"]: del metadata["components"][component_name] self.save_metadata(metadata) return True return False def get_installed_components(self) -> Dict[str, Dict[str, Any]]: """ Get all installed components from registry Returns: Dict of component_name -> component_info """ metadata = self.load_metadata() return metadata.get("components", {}) def is_component_installed(self, component_name: str) -> bool: """ Check if component is registered as installed Args: component_name: Name of component to check Returns: True if component is installed, False otherwise """ components = self.get_installed_components() return component_name in components def get_component_version(self, component_name: str) -> Optional[str]: """ Get installed version of component Args: component_name: Name of component Returns: Version string or None if not installed """ components = self.get_installed_components() component_info = components.get(component_name, {}) return component_info.get("version") def update_framework_version(self, version: str) -> None: """ Update SuperClaude framework version in metadata Args: version: Framework version string """ metadata = self.load_metadata() if "framework" not in metadata: metadata["framework"] = {} metadata["framework"]["version"] = version metadata["framework"]["updated_at"] = datetime.now().isoformat() self.save_metadata(metadata) def get_framework_version(self) -> Optional[str]: """ Get SuperClaude framework version from metadata Returns: Version string or None if not set """ metadata = self.load_metadata() framework = metadata.get("framework", {}) return framework.get("version") def get_metadata_setting(self, key_path: str, default: Any = None) -> Any: """ Get metadata value using dot-notation path Args: key_path: Dot-separated path (e.g., "framework.version") default: Default value if key not found Returns: Metadata value or default """ metadata = self.load_metadata() try: value = metadata for key in key_path.split('.'): value = value[key] return value except (KeyError, TypeError): return default def _deep_merge(self, base: Dict[str, Any], overlay: Dict[str, Any]) -> Dict[str, Any]: """ Deep merge two dictionaries Args: base: Base dictionary overlay: Dictionary to merge on top Returns: Merged dictionary """ result = copy.deepcopy(base) for key, value in overlay.items(): if key in result and isinstance(result[key], dict) and isinstance(value, dict): result[key] = self._deep_merge(result[key], value) else: result[key] = copy.deepcopy(value) return result def _create_settings_backup(self) -> Path: """ Create timestamped backup of settings.json Returns: Path to backup file """ if not self.settings_file.exists(): raise ValueError("Cannot backup non-existent settings file") # Create backup directory self.backup_dir.mkdir(parents=True, exist_ok=True) # Create timestamped backup timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup_file = self.backup_dir / f"settings_{timestamp}.json" shutil.copy2(self.settings_file, backup_file) # Keep only last 10 backups self._cleanup_old_backups() return backup_file def _cleanup_old_backups(self, keep_count: int = 10) -> None: """ Remove old backup files, keeping only the most recent Args: keep_count: Number of backups to keep """ if not self.backup_dir.exists(): return # Get all backup files sorted by modification time backup_files = [] for file in self.backup_dir.glob("settings_*.json"): backup_files.append((file.stat().st_mtime, file)) backup_files.sort(reverse=True) # Most recent first # Remove old backups for _, file in backup_files[keep_count:]: try: file.unlink() except OSError: pass # Ignore errors when cleaning up def list_backups(self) -> List[Dict[str, Any]]: """ List available settings backups Returns: List of backup info dicts with name, path, and timestamp """ if not self.backup_dir.exists(): return [] backups = [] for file in self.backup_dir.glob("settings_*.json"): try: stat = file.stat() backups.append({ "name": file.name, "path": str(file), "size": stat.st_size, "created": datetime.fromtimestamp(stat.st_ctime).isoformat(), "modified": datetime.fromtimestamp(stat.st_mtime).isoformat() }) except OSError: continue # Sort by creation time, most recent first backups.sort(key=lambda x: x["created"], reverse=True) return backups def restore_backup(self, backup_name: str) -> bool: """ Restore settings from backup Args: backup_name: Name of backup file to restore Returns: True if successful, False otherwise """ backup_file = self.backup_dir / backup_name if not backup_file.exists(): return False try: # Validate backup file first with open(backup_file, 'r', encoding='utf-8') as f: json.load(f) # Will raise exception if invalid # Create backup of current settings if self.settings_file.exists(): self._create_settings_backup() # Restore backup shutil.copy2(backup_file, self.settings_file) return True except (json.JSONDecodeError, IOError): return False