#!/usr/bin/env python3 """ Compare current configuration with backup or default. Usage: python3 config-diff.py [--backup FILE] [--default] [--json] """ import argparse import json import os import sys import tarfile import tempfile from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Tuple CLAUDE_DIR = Path.home() / ".claude" BACKUP_DIR = CLAUDE_DIR / "backups" def load_json_safe(path: Path) -> Optional[Dict]: """Load JSON file safely.""" try: with open(path) as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return None def get_config_files() -> List[Path]: """Get list of configuration files to compare.""" patterns = [ "CLAUDE.md", "VERSION", ".claude-plugin/plugin.json", "hooks/hooks.json", "state/*.json", "state/**/*.json", ] files = [] for pattern in patterns: files.extend(CLAUDE_DIR.glob(pattern)) return sorted(files) def extract_backup(backup_path: Path) -> Path: """Extract backup to temporary directory.""" temp_dir = Path(tempfile.mkdtemp()) with tarfile.open(backup_path, "r:gz") as tar: tar.extractall(temp_dir) return temp_dir def compare_files(current: Path, other: Path) -> Dict: """Compare two files and return differences.""" result = { "current_exists": current.exists(), "other_exists": other.exists(), "same": False, "diff": None } if not current.exists() and not other.exists(): result["same"] = True return result if not current.exists() or not other.exists(): return result # Compare content try: current_content = current.read_text() other_content = other.read_text() if current_content == other_content: result["same"] = True return result # For JSON files, do structural comparison if current.suffix == ".json": try: current_json = json.loads(current_content) other_json = json.loads(other_content) # Find differences result["diff"] = { "type": "json", "added": [], "removed": [], "changed": [] } # Simple key comparison for top-level current_keys = set(current_json.keys()) if isinstance(current_json, dict) else set() other_keys = set(other_json.keys()) if isinstance(other_json, dict) else set() result["diff"]["added"] = list(current_keys - other_keys) result["diff"]["removed"] = list(other_keys - current_keys) # Check for changed values for key in current_keys & other_keys: if current_json.get(key) != other_json.get(key): result["diff"]["changed"].append(key) return result except json.JSONDecodeError: pass # Text comparison current_lines = current_content.split("\n") other_lines = other_content.split("\n") result["diff"] = { "type": "text", "current_lines": len(current_lines), "other_lines": len(other_lines), "line_diff": len(current_lines) - len(other_lines) } except Exception as e: result["error"] = str(e) return result def get_latest_backup() -> Optional[Path]: """Get the most recent backup file.""" if not BACKUP_DIR.exists(): return None backups = list(BACKUP_DIR.glob("*.tar.gz")) if not backups: return None return max(backups, key=lambda p: p.stat().st_mtime) def compare_with_backup(backup_path: Path) -> Dict[str, Dict]: """Compare current config with a backup.""" temp_dir = extract_backup(backup_path) try: results = {} config_files = get_config_files() for current_file in config_files: rel_path = current_file.relative_to(CLAUDE_DIR) backup_file = temp_dir / rel_path results[str(rel_path)] = compare_files(current_file, backup_file) # Check for files in backup that don't exist in current for backup_file in temp_dir.rglob("*.json"): rel_path = backup_file.relative_to(temp_dir) current_file = CLAUDE_DIR / rel_path if str(rel_path) not in results: results[str(rel_path)] = compare_files(current_file, backup_file) return results finally: # Cleanup temp directory import shutil shutil.rmtree(temp_dir, ignore_errors=True) def format_results(results: Dict[str, Dict], json_output: bool = False) -> str: """Format comparison results.""" if json_output: return json.dumps(results, indent=2) lines = ["\nšŸ“Š Configuration Diff\n"] # Group by status same = [] added = [] removed = [] changed = [] for path, info in results.items(): if info.get("same"): same.append(path) elif info.get("current_exists") and not info.get("other_exists"): added.append(path) elif not info.get("current_exists") and info.get("other_exists"): removed.append(path) else: changed.append((path, info)) # Summary lines.append(f"Unchanged: {len(same)}") lines.append(f"Added: {len(added)}") lines.append(f"Removed: {len(removed)}") lines.append(f"Changed: {len(changed)}") lines.append("") # Details if added: lines.append("=== Added Files ===") for path in added: lines.append(f" + {path}") lines.append("") if removed: lines.append("=== Removed Files ===") for path in removed: lines.append(f" - {path}") lines.append("") if changed: lines.append("=== Changed Files ===") for path, info in changed: lines.append(f" ~ {path}") diff = info.get("diff", {}) if diff: if diff.get("type") == "json": if diff.get("added"): lines.append(f" Added keys: {', '.join(diff['added'][:5])}") if diff.get("removed"): lines.append(f" Removed keys: {', '.join(diff['removed'][:5])}") if diff.get("changed"): lines.append(f" Changed keys: {', '.join(diff['changed'][:5])}") elif diff.get("type") == "text": lines.append(f" Lines: {diff['other_lines']} -> {diff['current_lines']}") lines.append("") return "\n".join(lines) def main(): parser = argparse.ArgumentParser(description="Compare configuration with backup") parser.add_argument("--backup", "-b", type=str, help="Backup file to compare with (default: latest)") parser.add_argument("--list", "-l", action="store_true", help="List available backups") parser.add_argument("--json", "-j", action="store_true", help="Output as JSON") args = parser.parse_args() if args.list: if not BACKUP_DIR.exists(): print("No backups directory found.") return 0 backups = sorted(BACKUP_DIR.glob("*.tar.gz"), key=lambda p: p.stat().st_mtime, reverse=True) if not backups: print("No backups found.") return 0 print("\nšŸ“¦ Available Backups\n") for b in backups[:10]: mtime = datetime.fromtimestamp(b.stat().st_mtime) size = b.stat().st_size if size > 1024 * 1024: size_str = f"{size / 1024 / 1024:.1f}M" else: size_str = f"{size / 1024:.1f}K" print(f" {b.name:<40} {size_str:<8} {mtime:%Y-%m-%d %H:%M}") return 0 # Get backup to compare if args.backup: backup_path = Path(args.backup) if not backup_path.exists(): backup_path = BACKUP_DIR / args.backup if not backup_path.exists(): print(f"Backup not found: {args.backup}") return 1 else: backup_path = get_latest_backup() if not backup_path: print("No backups found. Create one with: claude-backup") return 1 print(f"Comparing with: {backup_path.name}") results = compare_with_backup(backup_path) print(format_results(results, args.json)) return 0 if __name__ == "__main__": sys.exit(main())