diff --git a/uninstall.py b/uninstall.py new file mode 100755 index 0000000..23d1ab1 --- /dev/null +++ b/uninstall.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Uninstaller for myclaude - reads installed_modules.json for precise removal.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +DEFAULT_INSTALL_DIR = "~/.claude" + +# Files created by installer itself (not by modules) +INSTALLER_FILES = ["install.log", "installed_modules.json", "installed_modules.json.bak"] + + +def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Uninstall myclaude") + parser.add_argument( + "--install-dir", + default=DEFAULT_INSTALL_DIR, + help="Installation directory (defaults to ~/.claude)", + ) + parser.add_argument( + "--module", + help="Comma-separated modules to uninstall (default: all installed)", + ) + parser.add_argument( + "--list", + action="store_true", + help="List installed modules and exit", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be removed without actually removing", + ) + parser.add_argument( + "--purge", + action="store_true", + help="Remove entire install directory (DANGEROUS: removes user files too)", + ) + parser.add_argument( + "-y", "--yes", + action="store_true", + help="Skip confirmation prompt", + ) + return parser.parse_args(argv) + + +def load_installed_modules(install_dir: Path) -> Dict[str, Any]: + """Load installed_modules.json to know what was installed.""" + status_file = install_dir / "installed_modules.json" + if not status_file.exists(): + return {} + try: + with status_file.open("r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return {} + + +def load_config(install_dir: Path) -> Dict[str, Any]: + """Try to load config.json from source repo to understand module structure.""" + # Look for config.json in common locations + candidates = [ + Path(__file__).parent / "config.json", + install_dir / "config.json", + ] + for path in candidates: + if path.exists(): + try: + with path.open("r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + continue + return {} + + +def get_module_files(module_name: str, config: Dict[str, Any]) -> Set[str]: + """Extract files/dirs that a module installs based on config.json operations.""" + files: Set[str] = set() + modules = config.get("modules", {}) + module_cfg = modules.get(module_name, {}) + + for op in module_cfg.get("operations", []): + op_type = op.get("type", "") + target = op.get("target", "") + + if op_type == "copy_file" and target: + files.add(target) + elif op_type == "copy_dir" and target: + files.add(target) + elif op_type == "merge_dir": + # merge_dir merges subdirs like commands/, agents/ into install_dir + source = op.get("source", "") + source_path = Path(__file__).parent / source + if source_path.exists(): + for subdir in source_path.iterdir(): + if subdir.is_dir(): + files.add(subdir.name) + elif op_type == "run_command": + # install.sh installs bin/codeagent-wrapper + cmd = op.get("command", "") + if "install.sh" in cmd or "install.bat" in cmd: + files.add("bin/codeagent-wrapper") + files.add("bin") + + return files + + +def cleanup_shell_config(rc_file: Path, bin_dir: Path) -> bool: + """Remove PATH export added by installer from shell config.""" + if not rc_file.exists(): + return False + + content = rc_file.read_text(encoding="utf-8") + original = content + + patterns = [ + r"\n?# Added by myclaude installer\n", + rf'\nexport PATH="{re.escape(str(bin_dir))}:\$PATH"\n?', + ] + + for pattern in patterns: + content = re.sub(pattern, "\n", content) + + content = re.sub(r"\n{3,}$", "\n\n", content) + + if content != original: + rc_file.write_text(content, encoding="utf-8") + return True + return False + + +def list_installed(install_dir: Path) -> None: + """List installed modules.""" + status = load_installed_modules(install_dir) + modules = status.get("modules", {}) + + if not modules: + print("No modules installed (installed_modules.json not found or empty)") + return + + print(f"Installed modules in {install_dir}:") + print(f"{'Module':<15} {'Status':<10} {'Installed At'}") + print("-" * 50) + for name, info in modules.items(): + st = info.get("status", "unknown") + ts = info.get("installed_at", "unknown")[:19] + print(f"{name:<15} {st:<10} {ts}") + + +def main(argv: Optional[List[str]] = None) -> int: + args = parse_args(argv) + install_dir = Path(args.install_dir).expanduser().resolve() + bin_dir = install_dir / "bin" + + if not install_dir.exists(): + print(f"Install directory not found: {install_dir}") + print("Nothing to uninstall.") + return 0 + + if args.list: + list_installed(install_dir) + return 0 + + # Load installation status + status = load_installed_modules(install_dir) + installed_modules = status.get("modules", {}) + config = load_config(install_dir) + + # Determine which modules to uninstall + if args.module: + selected = [m.strip() for m in args.module.split(",") if m.strip()] + # Validate + for m in selected: + if m not in installed_modules: + print(f"Error: Module '{m}' is not installed") + print("Use --list to see installed modules") + return 1 + else: + selected = list(installed_modules.keys()) + + if not selected and not args.purge: + print("No modules to uninstall.") + print("Use --list to see installed modules, or --purge to remove everything.") + return 0 + + # Collect files to remove + files_to_remove: Set[str] = set() + for module_name in selected: + files_to_remove.update(get_module_files(module_name, config)) + + # Add installer files if removing all modules + if set(selected) == set(installed_modules.keys()): + files_to_remove.update(INSTALLER_FILES) + + # Show what will be removed + print(f"Install directory: {install_dir}") + if args.purge: + print(f"\n⚠️ PURGE MODE: Will remove ENTIRE directory including user files!") + else: + print(f"\nModules to uninstall: {', '.join(selected)}") + print(f"\nFiles/directories to remove:") + for f in sorted(files_to_remove): + path = install_dir / f + exists = "✓" if path.exists() else "✗ (not found)" + print(f" {f} {exists}") + + # Confirmation + if not args.yes and not args.dry_run: + prompt = "\nProceed with uninstallation? [y/N] " + response = input(prompt).strip().lower() + if response not in ("y", "yes"): + print("Aborted.") + return 0 + + if args.dry_run: + print("\n[Dry run] No files were removed.") + return 0 + + print(f"\nUninstalling...") + removed: List[str] = [] + + if args.purge: + shutil.rmtree(install_dir) + print(f" ✓ Removed {install_dir}") + removed.append(str(install_dir)) + else: + # Remove files/dirs in reverse order (files before parent dirs) + for item in sorted(files_to_remove, key=lambda x: x.count("/"), reverse=True): + path = install_dir / item + if not path.exists(): + continue + try: + if path.is_dir(): + # Only remove if empty or if it's a known module dir + if item in ("bin",): + # For bin, only remove codeagent-wrapper + wrapper = path / "codeagent-wrapper" + if wrapper.exists(): + wrapper.unlink() + print(f" ✓ Removed bin/codeagent-wrapper") + removed.append("bin/codeagent-wrapper") + # Remove bin if empty + if path.exists() and not any(path.iterdir()): + path.rmdir() + print(f" ✓ Removed empty bin/") + else: + shutil.rmtree(path) + print(f" ✓ Removed {item}/") + removed.append(item) + else: + path.unlink() + print(f" ✓ Removed {item}") + removed.append(item) + except OSError as e: + print(f" ✗ Failed to remove {item}: {e}", file=sys.stderr) + + # Update installed_modules.json + status_file = install_dir / "installed_modules.json" + if status_file.exists() and selected != list(installed_modules.keys()): + # Partial uninstall: update status file + for m in selected: + installed_modules.pop(m, None) + if installed_modules: + with status_file.open("w", encoding="utf-8") as f: + json.dump({"modules": installed_modules}, f, indent=2) + print(f" ✓ Updated installed_modules.json") + + # Remove install dir if empty + if install_dir.exists() and not any(install_dir.iterdir()): + install_dir.rmdir() + print(f" ✓ Removed empty install directory") + + # Clean shell configs + for rc_name in (".bashrc", ".zshrc"): + rc_file = Path.home() / rc_name + if cleanup_shell_config(rc_file, bin_dir): + print(f" ✓ Cleaned PATH from {rc_name}") + + print("") + if removed: + print(f"✓ Uninstallation complete ({len(removed)} items removed)") + else: + print("✓ Nothing to remove") + + if install_dir.exists() and any(install_dir.iterdir()): + remaining = list(install_dir.iterdir()) + print(f"\nNote: {len(remaining)} items remain in {install_dir}") + print("These are either user files or from other modules.") + print("Use --purge to remove everything (DANGEROUS).") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..703475f --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,225 @@ +#!/bin/bash +set -e + +INSTALL_DIR="${INSTALL_DIR:-$HOME/.claude}" +BIN_DIR="${INSTALL_DIR}/bin" +STATUS_FILE="${INSTALL_DIR}/installed_modules.json" +DRY_RUN=false +PURGE=false +YES=false +LIST_ONLY=false +MODULES="" + +usage() { + cat <&2; exit 1 ;; + esac +done + +# Check if install dir exists +if [ ! -d "$INSTALL_DIR" ]; then + echo "Install directory not found: $INSTALL_DIR" + echo "Nothing to uninstall." + exit 0 +fi + +# List installed modules +list_modules() { + if [ ! -f "$STATUS_FILE" ]; then + echo "No modules installed (installed_modules.json not found)" + return + fi + echo "Installed modules in $INSTALL_DIR:" + echo "Module Status Installed At" + echo "--------------------------------------------------" + # Parse JSON with basic tools (no jq dependency) + python3 -c " +import json, sys +try: + with open('$STATUS_FILE') as f: + data = json.load(f) + for name, info in data.get('modules', {}).items(): + status = info.get('status', 'unknown') + ts = info.get('installed_at', 'unknown')[:19] + print(f'{name:<15} {status:<10} {ts}') +except Exception as e: + print(f'Error reading status file: {e}', file=sys.stderr) + sys.exit(1) +" +} + +if [ "$LIST_ONLY" = true ]; then + list_modules + exit 0 +fi + +# Get installed modules from status file +get_installed_modules() { + if [ ! -f "$STATUS_FILE" ]; then + echo "" + return + fi + python3 -c " +import json +try: + with open('$STATUS_FILE') as f: + data = json.load(f) + print(' '.join(data.get('modules', {}).keys())) +except: + print('') +" +} + +INSTALLED=$(get_installed_modules) + +# Determine modules to uninstall +if [ -n "$MODULES" ]; then + SELECTED="$MODULES" +else + SELECTED="$INSTALLED" +fi + +if [ -z "$SELECTED" ] && [ "$PURGE" != true ]; then + echo "No modules to uninstall." + echo "Use --list to see installed modules, or --purge to remove everything." + exit 0 +fi + +echo "Install directory: $INSTALL_DIR" + +if [ "$PURGE" = true ]; then + echo "" + echo "⚠️ PURGE MODE: Will remove ENTIRE directory including user files!" +else + echo "" + echo "Modules to uninstall: $SELECTED" + echo "" + echo "Files/directories that may be removed:" + for item in commands agents skills docs bin CLAUDE.md install.log installed_modules.json; do + if [ -e "${INSTALL_DIR}/${item}" ]; then + echo " $item ✓" + fi + done +fi + +# Confirmation +if [ "$YES" != true ] && [ "$DRY_RUN" != true ]; then + echo "" + read -p "Proceed with uninstallation? [y/N] " response + case "$response" in + [yY]|[yY][eE][sS]) ;; + *) echo "Aborted."; exit 0 ;; + esac +fi + +if [ "$DRY_RUN" = true ]; then + echo "" + echo "[Dry run] No files were removed." + exit 0 +fi + +echo "" +echo "Uninstalling..." + +if [ "$PURGE" = true ]; then + rm -rf "$INSTALL_DIR" + echo " ✓ Removed $INSTALL_DIR" +else + # Remove codeagent-wrapper binary + if [ -f "${BIN_DIR}/codeagent-wrapper" ]; then + rm -f "${BIN_DIR}/codeagent-wrapper" + echo " ✓ Removed bin/codeagent-wrapper" + fi + + # Remove bin directory if empty + if [ -d "$BIN_DIR" ] && [ -z "$(ls -A "$BIN_DIR" 2>/dev/null)" ]; then + rmdir "$BIN_DIR" + echo " ✓ Removed empty bin/" + fi + + # Remove installed directories + for dir in commands agents skills docs; do + if [ -d "${INSTALL_DIR}/${dir}" ]; then + rm -rf "${INSTALL_DIR}/${dir}" + echo " ✓ Removed ${dir}/" + fi + done + + # Remove installed files + for file in CLAUDE.md install.log installed_modules.json installed_modules.json.bak; do + if [ -f "${INSTALL_DIR}/${file}" ]; then + rm -f "${INSTALL_DIR}/${file}" + echo " ✓ Removed ${file}" + fi + done + + # Remove install directory if empty + if [ -d "$INSTALL_DIR" ] && [ -z "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then + rmdir "$INSTALL_DIR" + echo " ✓ Removed empty install directory" + fi +fi + +# Clean up PATH from shell config files +cleanup_shell_config() { + local rc_file="$1" + if [ -f "$rc_file" ]; then + if grep -q "# Added by myclaude installer" "$rc_file" 2>/dev/null; then + # Create backup + cp "$rc_file" "${rc_file}.bak" + # Remove myclaude lines + grep -v "# Added by myclaude installer" "$rc_file" | \ + grep -v "export PATH=\"${BIN_DIR}:\$PATH\"" > "${rc_file}.tmp" + mv "${rc_file}.tmp" "$rc_file" + echo " ✓ Cleaned PATH from $(basename "$rc_file")" + fi + fi +} + +cleanup_shell_config "$HOME/.bashrc" +cleanup_shell_config "$HOME/.zshrc" + +echo "" +echo "✓ Uninstallation complete" + +# Check for remaining files +if [ -d "$INSTALL_DIR" ] && [ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then + remaining=$(ls -1 "$INSTALL_DIR" 2>/dev/null | wc -l | tr -d ' ') + echo "" + echo "Note: $remaining items remain in $INSTALL_DIR" + echo "These are either user files or from other modules." + echo "Use --purge to remove everything (DANGEROUS)." +fi