- /diff command to compare config with backups - Shows added/removed/changed files - JSON-aware comparison for config files - List available backups - /template command for session templates - Built-in templates: daily-standup, code-review, troubleshoot, deploy - Each template includes checklist, initial commands, prompt - Create custom templates interactively or non-interactively - Updated shell completions with 21 aliases total - Test suite now covers 29 tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
295 lines
8.6 KiB
Python
Executable File
295 lines
8.6 KiB
Python
Executable File
#!/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())
|