feat: add uninstall scripts with selective module removal

- uninstall.py: Python uninstaller with --list, --dry-run, --module options
- uninstall.sh: Bash uninstaller with same functionality
- Reads installed_modules.json for precise removal
- Supports partial uninstall (--module dev)
- --purge option for complete removal
- Cleans PATH from shell configs (.bashrc/.zshrc)

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
This commit is contained in:
cexll
2026-01-04 22:53:11 +08:00
parent 1d2f28101a
commit b81953a1d7
2 changed files with 527 additions and 0 deletions

302
uninstall.py Executable file
View File

@@ -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())