Skip to main content

#!/usr/bin/env python3 """ CODITECT Uninstall Script

Safely removes the CODITECT installation from the system. Optionally preserves user data (context database, session logs).

Usage: python3 uninstall-coditect.py # Interactive uninstall python3 uninstall-coditect.py --yes # Skip confirmation python3 uninstall-coditect.py --keep-data # Preserve user data python3 uninstall-coditect.py --dry-run # Preview without removing """

import argparse import json import os import platform import shutil import subprocess import sys from datetime import datetime from pathlib import Path

============================================================================

Configuration

============================================================================

Installation locations

if platform.system() == "Darwin": PROTECTED_DIR = Path.home() / "Library" / "Application Support" / "CODITECT" / "core" LAUNCHD_PLIST = Path.home() / "Library" / "LaunchAgents" / "ai.coditect.context-watcher.plist" elif platform.system() == "Linux": PROTECTED_DIR = Path.home() / ".local" / "share" / "coditect" / "core" LAUNCHD_PLIST = None # Linux uses systemd else: PROTECTED_DIR = Path.home() / ".coditect-core" LAUNCHD_PLIST = None

Symlinks to remove

HOME_SYMLINK = Path.home() / ".coditect" PROJECTS_DIR = Path.home() / "PROJECTS" PROJECTS_CODITECT_SYMLINK = PROJECTS_DIR / ".coditect" PROJECTS_CLAUDE_SYMLINK = PROJECTS_DIR / ".claude"

ADR-114: User data directory (separate from framework)

USER_DATA_DIR = PROJECTS_DIR / ".coditect-data"

User data directories (optionally preserved) - within PROTECTED_DIR

USER_DATA_DIRS = [ "context-storage", "session-logs", "logs", ]

Claude Code settings

CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json"

============================================================================

Colors

============================================================================

Shared Colors module (consolidates 36 duplicate definitions)

_script_dir = Path(file).parent sys.path.insert(0, str(_script_dir / "core")) from colors import Colors

def print_success(msg): print(f" {Colors.GREEN}✓{Colors.RESET} {msg}") def print_warning(msg): print(f" {Colors.YELLOW}⚠{Colors.RESET} {msg}") def print_error(msg): print(f" {Colors.RED}✗{Colors.RESET} {msg}") def print_info(msg): print(f" {Colors.CYAN}ℹ{Colors.RESET} {msg}")

============================================================================

Helper Functions

============================================================================

def is_coditect_installed() -> bool: """Check if CODITECT is installed.""" return HOME_SYMLINK.exists() or PROTECTED_DIR.exists()

def get_installation_info() -> dict: """Get information about the current installation.""" info = { "protected_dir": None, "protected_dir_size": 0, "symlinks": [], "version": "unknown", "machine_id": None, "user_data_size": 0, "context_db_exists": False, "session_logs_count": 0, }

# Check protected directory
if PROTECTED_DIR.exists():
info["protected_dir"] = str(PROTECTED_DIR)
info["protected_dir_size"] = sum(
f.stat().st_size for f in PROTECTED_DIR.rglob('*') if f.is_file()
) / (1024 * 1024) # MB

# Get version
version_file = PROTECTED_DIR / "VERSION"
if version_file.exists():
info["version"] = version_file.read_text().strip()

# Get machine ID
machine_id_file = PROTECTED_DIR / "machine-id.json"
if machine_id_file.exists():
try:
machine_data = json.loads(machine_id_file.read_text())
info["machine_id"] = machine_data.get("machine_uuid", "unknown")[:8] + "..."
except:
pass

# Check user data (ADR-118 Four-Tier Architecture)
# Tier 2: org.db (CRITICAL - decisions, learnings)
org_db = PROTECTED_DIR / "context-storage" / "org.db"
if org_db.exists():
info["org_db_exists"] = True
info["user_data_size"] += org_db.stat().st_size / (1024 * 1024)

# Tier 3: sessions.db (messages, analytics)
sessions_db = PROTECTED_DIR / "context-storage" / "sessions.db"
if sessions_db.exists():
info["sessions_db_exists"] = True
info["user_data_size"] += sessions_db.stat().st_size / (1024 * 1024)

# Legacy: context.db (deprecated, may still exist during migration)
context_db = PROTECTED_DIR / "context-storage" / "context.db"
if context_db.exists():
info["context_db_exists"] = True
info["user_data_size"] += context_db.stat().st_size / (1024 * 1024)

session_logs = PROTECTED_DIR / "session-logs"
if session_logs.exists():
info["session_logs_count"] = len(list(session_logs.glob("*.md")))
info["user_data_size"] += sum(
f.stat().st_size for f in session_logs.rglob('*') if f.is_file()
) / (1024 * 1024)

# Check symlinks
for symlink in [HOME_SYMLINK, PROJECTS_CODITECT_SYMLINK, PROJECTS_CLAUDE_SYMLINK]:
if symlink.is_symlink():
info["symlinks"].append(str(symlink))

return info

def stop_services(dry_run: bool = False) -> bool: """Stop CODITECT background services.""" success = True

# Stop launchd service (macOS)
if LAUNCHD_PLIST and LAUNCHD_PLIST.exists():
if dry_run:
print_info(f"Would unload: {LAUNCHD_PLIST}")
else:
result = subprocess.run(
["launchctl", "unload", str(LAUNCHD_PLIST)],
capture_output=True,
text=True
)
if result.returncode == 0:
print_success("Stopped context watcher service")
else:
print_warning(f"Could not stop service: {result.stderr.strip()}")

# Check for running processes
result = subprocess.run(
["pgrep", "-f", "coditect"],
capture_output=True,
text=True
)

if result.stdout.strip():
pids = result.stdout.strip().split('\n')
if dry_run:
print_info(f"Would kill {len(pids)} CODITECT process(es)")
else:
for pid in pids:
try:
subprocess.run(["kill", pid], capture_output=True)
except:
pass
print_success(f"Stopped {len(pids)} CODITECT process(es)")

return success

def backup_user_data(target_dir: Path, dry_run: bool = False) -> bool: """Backup user data before uninstall.""" if not PROTECTED_DIR.exists(): return True

timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_dir = target_dir / f"coditect-backup-{timestamp}"

data_to_backup = []
for data_dir in USER_DATA_DIRS:
src = PROTECTED_DIR / data_dir
if src.exists():
data_to_backup.append(src)

if not data_to_backup:
print_info("No user data to backup")
return True

if dry_run:
print_info(f"Would backup to: {backup_dir}")
for src in data_to_backup:
print_info(f" - {src.name}")
return True

try:
backup_dir.mkdir(parents=True, exist_ok=True)

for src in data_to_backup:
dst = backup_dir / src.name
if src.is_dir():
shutil.copytree(src, dst)
else:
shutil.copy2(src, dst)

print_success(f"Backed up user data to: {backup_dir}")
return True
except Exception as e:
print_error(f"Backup failed: {e}")
return False

def remove_symlinks(dry_run: bool = False) -> bool: """Remove CODITECT symlinks.""" symlinks = [HOME_SYMLINK, PROJECTS_CODITECT_SYMLINK, PROJECTS_CLAUDE_SYMLINK]

for symlink in symlinks:
if symlink.is_symlink():
if dry_run:
print_info(f"Would remove symlink: {symlink}")
else:
try:
symlink.unlink()
print_success(f"Removed: {symlink}")
except Exception as e:
print_error(f"Could not remove {symlink}: {e}")
return False
elif symlink.exists():
# It's a real directory, not a symlink - don't remove
print_warning(f"Skipping (not a symlink): {symlink}")

return True

def clean_claude_settings(dry_run: bool = False) -> bool: """Remove CODITECT entries from Claude Code settings.""" if not CLAUDE_SETTINGS.exists(): return True

try:
settings = json.loads(CLAUDE_SETTINGS.read_text())
modified = False

# Remove statusLine if it references CODITECT
if "statusLine" in settings:
sl = settings.get("statusLine", {})
if isinstance(sl, dict) and "coditect" in str(sl.get("command", "")).lower():
if dry_run:
print_info("Would remove statusLine from Claude settings")
else:
del settings["statusLine"]
modified = True

# Remove hooks that reference CODITECT
hooks_to_clean = ["hooks", "userHooks"]
for hook_key in hooks_to_clean:
if hook_key in settings:
original_count = len(settings[hook_key]) if isinstance(settings[hook_key], list) else 0
if isinstance(settings[hook_key], list):
settings[hook_key] = [
h for h in settings[hook_key]
if not ("coditect" in str(h).lower() or ".coditect" in str(h))
]
if len(settings[hook_key]) != original_count:
modified = True
if dry_run:
print_info(f"Would remove {original_count - len(settings[hook_key])} hook(s)")

if modified and not dry_run:
CLAUDE_SETTINGS.write_text(json.dumps(settings, indent=2))
print_success("Cleaned Claude Code settings")
elif not modified:
print_info("No CODITECT entries in Claude settings")

return True
except Exception as e:
print_warning(f"Could not clean Claude settings: {e}")
return True # Non-fatal

def remove_launchd_plist(dry_run: bool = False) -> bool: """Remove launchd plist file.""" if not LAUNCHD_PLIST or not LAUNCHD_PLIST.exists(): return True

if dry_run:
print_info(f"Would remove: {LAUNCHD_PLIST}")
else:
try:
LAUNCHD_PLIST.unlink()
print_success(f"Removed: {LAUNCHD_PLIST}")
except Exception as e:
print_warning(f"Could not remove plist: {e}")

return True

def remove_protected_directory(keep_data: bool = False, dry_run: bool = False) -> bool: """Remove the protected installation directory.""" if not PROTECTED_DIR.exists(): print_info("Protected directory not found") return True

if keep_data:
# Remove everything except user data directories
if dry_run:
print_info(f"Would remove (keeping user data): {PROTECTED_DIR}")
else:
for item in PROTECTED_DIR.iterdir():
if item.name not in USER_DATA_DIRS:
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
print_success(f"Removed installation (kept user data): {PROTECTED_DIR}")
else:
if dry_run:
print_info(f"Would remove: {PROTECTED_DIR}")
else:
try:
shutil.rmtree(PROTECTED_DIR)
print_success(f"Removed: {PROTECTED_DIR}")

# Also remove parent CODITECT directory if empty
parent = PROTECTED_DIR.parent
if parent.exists() and not any(parent.iterdir()):
parent.rmdir()
print_success(f"Removed empty parent: {parent}")
except Exception as e:
print_error(f"Could not remove directory: {e}")
return False

return True

============================================================================

Main Functions

============================================================================

def do_uninstall(skip_confirm: bool = False, keep_data: bool = False, dry_run: bool = False) -> bool: """Perform the uninstallation."""

print(f"\n{Colors.BOLD}CODITECT Uninstall{Colors.RESET}")
print("=" * 50)

# Check if installed
if not is_coditect_installed():
print()
print_error("CODITECT is not installed")
return False

# Get installation info
info = get_installation_info()

# Show what will be removed
print()
print(f" {Colors.BOLD}Installation Details:{Colors.RESET}")
print(f" Version: {Colors.CYAN}{info['version']}{Colors.RESET}")
print(f" Location: {info['protected_dir']}")
print(f" Size: {info['protected_dir_size']:.1f} MB")

if info['machine_id']:
print(f" Machine ID: {info['machine_id']}")

if info['symlinks']:
print()
print(f" {Colors.BOLD}Symlinks:{Colors.RESET}")
for symlink in info['symlinks']:
print(f" - {symlink}")

if info['context_db_exists'] or info['session_logs_count'] > 0:
print()
print(f" {Colors.BOLD}User Data:{Colors.RESET}")
if info['context_db_exists']:
print(f" - Context database (sessions, messages, decisions)")
if info['session_logs_count'] > 0:
print(f" - {info['session_logs_count']} session log(s)")
print(f" - Size: {info['user_data_size']:.1f} MB")

if dry_run:
print()
print(f" {Colors.YELLOW}DRY RUN - No changes will be made{Colors.RESET}")

# Warn about data loss
if not keep_data and (info['context_db_exists'] or info['session_logs_count'] > 0):
print()
print(f" {Colors.RED}{Colors.BOLD}WARNING:{Colors.RESET} User data will be permanently deleted!")
print(f" Use {Colors.CYAN}--keep-data{Colors.RESET} to preserve your data")

# Confirm
if not skip_confirm and not dry_run:
print()
if keep_data:
prompt = "Uninstall CODITECT (keeping user data)? [y/N]: "
else:
prompt = f"{Colors.RED}Uninstall CODITECT and DELETE ALL DATA?{Colors.RESET} [y/N]: "

response = input(f" {prompt}").strip().lower()
if response != 'y':
print()
print_info("Uninstall cancelled")
return False

# Backup user data if requested
if keep_data and not dry_run:
print()
print(" Preserving user data...")
elif not keep_data and (info['context_db_exists'] or info['session_logs_count'] > 0):
# Offer to backup before deletion
if not skip_confirm and not dry_run:
print()
response = input(" Backup user data before removal? [Y/n]: ").strip().lower()
if response != 'n':
backup_user_data(Path.home() / "Desktop", dry_run=dry_run)

# Stop services
print()
print(" Stopping services...")
stop_services(dry_run=dry_run)

# Remove symlinks
print()
print(" Removing symlinks...")
remove_symlinks(dry_run=dry_run)

# Clean Claude settings
print()
print(" Cleaning Claude Code settings...")
clean_claude_settings(dry_run=dry_run)

# Remove launchd plist
remove_launchd_plist(dry_run=dry_run)

# Remove protected directory
print()
print(" Removing installation...")
if not remove_protected_directory(keep_data=keep_data, dry_run=dry_run):
return False

# Final message
print()
if dry_run:
print(f" {Colors.CYAN}Dry run complete - no changes made{Colors.RESET}")
else:
print(f" {Colors.GREEN}✓ CODITECT has been uninstalled{Colors.RESET}")
if keep_data:
print()
print_info(f"User data preserved at: {PROTECTED_DIR}")
print_info("To completely remove, delete this directory manually")
else:
print()
print_info("To reinstall: curl -fsSL https://coditect.ai/install | python3")

return True

def main(): parser = argparse.ArgumentParser(description="Uninstall CODITECT") parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt") parser.add_argument("--keep-data", "-k", action="store_true", help="Preserve user data (context database, session logs)") parser.add_argument("--dry-run", "-n", action="store_true", help="Preview what would be removed without making changes")

args = parser.parse_args()

success = do_uninstall(
skip_confirm=args.yes,
keep_data=args.keep_data,
dry_run=args.dry_run
)

sys.exit(0 if success else 1)

if name == "main": main()