diff --git a/CLAUDE.md b/CLAUDE.md index 3d95b3d..d366031 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -326,6 +326,17 @@ For detailed setup instructions and troubleshooting, see [.conductor/GITHUB_TOKE 2. Add patterns for new frameworks/languages 3. Update role recommendations based on stack +### Uninstalling Code Conductor +To remove Code Conductor from a project: +```bash +python uninstall.py # Interactive removal +python uninstall.py --force # Remove without confirmation +python uninstall.py --dry-run # Preview what will be removed +``` + +The uninstaller safely removes all conductor components while preserving user files. +See `uninstall.py` for implementation details. + ## Important Notes - Always use GitHub CLI commands for state changes to ensure consistency diff --git a/README.md b/README.md index 26699a7..7bd8980 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,33 @@ I need to move faster. Install Code Conductor into this project so we can run mu Execute: curl -fsSL https://raw.githubusercontent.com/ryanmac/code-conductor/main/conductor-init.sh | bash -s -- --auto --create-pr --auto-merge ``` +## Uninstalling + +Need to remove Code Conductor? We've got you covered with a safe, interactive uninstaller: + +```bash +python uninstall.py +``` + +**What it removes:** +- ✅ `.conductor/` directory and all configuration +- ✅ `conductor` wrapper script +- ✅ GitHub workflows (only conductor-specific ones) +- ✅ Git worktrees created by conductor +- ✅ Conductor section from CLAUDE.md + +**What it preserves:** +- ✅ Your own GitHub workflows +- ✅ Your code and project files +- ✅ GitHub issues and labels (manual cleanup if desired) + +**Options:** +```bash +python uninstall.py --dry-run # See what would be removed +python uninstall.py --force # Skip confirmation prompt +python uninstall.py --verbose # Detailed output +``` + ## See It In Action ```bash diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py new file mode 100644 index 0000000..550914a --- /dev/null +++ b/tests/test_uninstall.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +""" +Test script for Code Conductor uninstall functionality. + +This script creates a test installation and verifies the uninstaller +removes all components correctly. +""" + +import os +import sys +import tempfile +import shutil +import subprocess +from pathlib import Path +import unittest + +# Add parent directory to path to import uninstall module +sys.path.insert(0, str(Path(__file__).parent.parent)) +from uninstall import ConductorUninstaller # noqa: E402 + + +class TestConductorUninstall(unittest.TestCase): + """Test cases for the Code Conductor uninstaller.""" + + def setUp(self): + """Create a temporary directory with a mock conductor installation.""" + self.test_dir = Path(tempfile.mkdtemp(prefix="conductor_test_")) + self.original_cwd = Path.cwd() + os.chdir(self.test_dir) + + # Initialize git repo + subprocess.run(["git", "init"], capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], capture_output=True + ) + subprocess.run(["git", "config", "user.name", "Test User"], capture_output=True) + + # Create mock conductor installation + self._create_mock_installation() + + def tearDown(self): + """Clean up temporary directory.""" + os.chdir(self.original_cwd) + shutil.rmtree(self.test_dir) + + def _create_mock_installation(self): + """Create a mock conductor installation.""" + # Create .conductor directory structure + conductor_dir = self.test_dir / ".conductor" + conductor_dir.mkdir() + + # Create config file + (conductor_dir / "config.yaml").write_text( + """ +project_name: test_project +technology_stack: + languages: ["python"] +""" + ) + + # Create scripts directory + scripts_dir = conductor_dir / "scripts" + scripts_dir.mkdir() + (scripts_dir / "conductor").write_text("#!/bin/bash\necho 'conductor'") + (scripts_dir / "conductor").chmod(0o755) + + # Create roles directory + roles_dir = conductor_dir / "roles" + roles_dir.mkdir() + (roles_dir / "dev.md").write_text("# Dev Role") + + # Create conductor wrapper + conductor_wrapper = self.test_dir / "conductor" + conductor_wrapper.write_text("#!/bin/bash\n.conductor/scripts/conductor") + conductor_wrapper.chmod(0o755) + + # Create GitHub workflows + workflows_dir = self.test_dir / ".github" / "workflows" + workflows_dir.mkdir(parents=True) + (workflows_dir / "conductor.yml").write_text("name: conductor") + (workflows_dir / "pr-review.yml").write_text("name: pr-review") + (workflows_dir / "user-workflow.yml").write_text("name: user-workflow") + + # Create issue template + template_dir = self.test_dir / ".github" / "ISSUE_TEMPLATE" + template_dir.mkdir(parents=True) + (template_dir / "conductor-task.yml").write_text("name: conductor-task") + + # Create CLAUDE.md with conductor section + claude_md = self.test_dir / "CLAUDE.md" + claude_md.write_text( + """# Project Instructions + +Some project content here. + + +## Conductor Section +This should be removed. + + +More project content. +""" + ) + + # Create worktrees directory + worktrees_dir = self.test_dir / "worktrees" + worktrees_dir.mkdir() + (worktrees_dir / "agent-dev-123").mkdir() + + def test_find_conductor_files(self): + """Test that the uninstaller finds all conductor files.""" + uninstaller = ConductorUninstaller() + items = uninstaller.find_conductor_files() + + # Check that all expected items are found + # Resolve paths to handle macOS /private symlinks + paths = [] + for item in items: + try: + paths.append( + str(item[0].resolve().relative_to(self.test_dir.resolve())) + ) + except ValueError: + # If relative_to fails, just use the name + paths.append(item[0].name) + + self.assertIn(".conductor", paths) + self.assertIn("conductor", paths) + self.assertIn(".github/workflows/conductor.yml", paths) + self.assertIn(".github/workflows/pr-review.yml", paths) + self.assertIn(".github/ISSUE_TEMPLATE/conductor-task.yml", paths) + self.assertIn("worktrees", paths) + + # Ensure user workflow is NOT in the list + self.assertNotIn(".github/workflows/user-workflow.yml", paths) + + def test_find_claude_md_section(self): + """Test detection of conductor section in CLAUDE.md.""" + uninstaller = ConductorUninstaller() + claude_md = uninstaller.find_claude_md_section() + + self.assertIsNotNone(claude_md) + self.assertEqual(claude_md.name, "CLAUDE.md") + + def test_dry_run(self): + """Test that dry run doesn't remove anything.""" + uninstaller = ConductorUninstaller(dry_run=True) + uninstaller.items_to_remove = uninstaller.find_conductor_files() + + # Run removal + uninstaller.remove_items() + + # Verify nothing was removed + self.assertTrue((self.test_dir / ".conductor").exists()) + self.assertTrue((self.test_dir / "conductor").exists()) + self.assertTrue( + (self.test_dir / ".github" / "workflows" / "conductor.yml").exists() + ) + + def test_full_removal(self): + """Test complete removal of conductor.""" + uninstaller = ConductorUninstaller(force=True) + uninstaller.items_to_remove = uninstaller.find_conductor_files() + + # Run removal + success = uninstaller.remove_items() + uninstaller.cleanup_empty_dirs() + + self.assertTrue(success) + + # Verify conductor files are removed + self.assertFalse((self.test_dir / ".conductor").exists()) + self.assertFalse((self.test_dir / "conductor").exists()) + self.assertFalse( + (self.test_dir / ".github" / "workflows" / "conductor.yml").exists() + ) + self.assertFalse( + (self.test_dir / ".github" / "workflows" / "pr-review.yml").exists() + ) + self.assertFalse( + ( + self.test_dir / ".github" / "ISSUE_TEMPLATE" / "conductor-task.yml" + ).exists() + ) + self.assertFalse((self.test_dir / "worktrees").exists()) + + # Verify user files are preserved + self.assertTrue( + (self.test_dir / ".github" / "workflows" / "user-workflow.yml").exists() + ) + + # Verify CLAUDE.md is updated + claude_content = (self.test_dir / "CLAUDE.md").read_text() + self.assertNotIn("", claude_content) + self.assertNotIn("Conductor Section", claude_content) + self.assertIn("Some project content here.", claude_content) + self.assertIn("More project content.", claude_content) + + def test_empty_directory_cleanup(self): + """Test that empty directories are cleaned up.""" + uninstaller = ConductorUninstaller(force=True) + uninstaller.items_to_remove = uninstaller.find_conductor_files() + + # Run removal + uninstaller.remove_items() + uninstaller.cleanup_empty_dirs() + + # Check that empty ISSUE_TEMPLATE dir is removed + self.assertFalse((self.test_dir / ".github" / "ISSUE_TEMPLATE").exists()) + + # But .github/workflows should still exist (has user-workflow.yml) + self.assertTrue((self.test_dir / ".github" / "workflows").exists()) + + def test_partial_installation(self): + """Test handling of partial installations.""" + # Remove some files to simulate partial installation + shutil.rmtree(self.test_dir / ".conductor" / "roles") + (self.test_dir / "conductor").unlink() + + uninstaller = ConductorUninstaller(force=True) + uninstaller.items_to_remove = uninstaller.find_conductor_files() + + # Should still find remaining items + paths = [] + for item in uninstaller.items_to_remove: + try: + paths.append( + str(item[0].resolve().relative_to(self.test_dir.resolve())) + ) + except ValueError: + # If relative_to fails, just use the name + paths.append(item[0].name) + self.assertIn(".conductor", paths) + self.assertNotIn("conductor", paths) # This was deleted + + # Run removal + success = uninstaller.remove_items() + self.assertTrue(success) + + # Verify all found items were removed + self.assertFalse((self.test_dir / ".conductor").exists()) + + +def create_mock_installation(test_dir): + """Create a mock conductor installation for testing.""" + # Create .conductor directory structure + conductor_dir = test_dir / ".conductor" + conductor_dir.mkdir() + + # Create config file + (conductor_dir / "config.yaml").write_text( + """ +project_name: test_project +technology_stack: + languages: ["python"] +""" + ) + + # Create scripts directory + scripts_dir = conductor_dir / "scripts" + scripts_dir.mkdir() + (scripts_dir / "conductor").write_text("#!/bin/bash\necho 'conductor'") + (scripts_dir / "conductor").chmod(0o755) + + # Create roles directory + roles_dir = conductor_dir / "roles" + roles_dir.mkdir() + (roles_dir / "dev.md").write_text("# Dev Role") + + # Create conductor wrapper + conductor_wrapper = test_dir / "conductor" + conductor_wrapper.write_text("#!/bin/bash\n.conductor/scripts/conductor") + conductor_wrapper.chmod(0o755) + + # Create GitHub workflows + workflows_dir = test_dir / ".github" / "workflows" + workflows_dir.mkdir(parents=True) + (workflows_dir / "conductor.yml").write_text("name: conductor") + (workflows_dir / "pr-review.yml").write_text("name: pr-review") + + # Create issue template + template_dir = test_dir / ".github" / "ISSUE_TEMPLATE" + template_dir.mkdir(parents=True) + (template_dir / "conductor-task.yml").write_text("name: conductor-task") + + # Create CLAUDE.md with conductor section + claude_md = test_dir / "CLAUDE.md" + claude_md.write_text( + """# Project Instructions + +Some project content here. + + +## Conductor Section +This should be removed. + + +More project content. +""" + ) + + +def run_integration_test(): + """Run a full integration test of the uninstaller.""" + print("Running integration test of uninstaller...") + + # Create a test directory + test_dir = Path(tempfile.mkdtemp(prefix="conductor_integration_")) + original_cwd = Path.cwd() + + try: + os.chdir(test_dir) + + # Initialize git repo + subprocess.run(["git", "init"], capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], capture_output=True + ) + subprocess.run(["git", "config", "user.name", "Test User"], capture_output=True) + + # Copy setup.py and run it, or create a mock installation + setup_src = original_cwd / "setup.py" + if setup_src.exists(): + shutil.copy(setup_src, test_dir) + + # Run setup in auto mode + print("Running setup.py --auto...") + result = subprocess.run( + [sys.executable, "setup.py", "--auto"], capture_output=True, text=True + ) + if result.returncode != 0: + # If setup fails, it might be because it's already configured + # Try creating a mock installation instead + print( + "Setup failed (this is expected in some cases), " + "creating mock installation..." + ) + create_mock_installation(test_dir) + else: + # Create a mock installation if setup.py doesn't exist + print("Creating mock Code Conductor installation...") + create_mock_installation(test_dir) + + # Copy uninstall.py + shutil.copy(original_cwd / "uninstall.py", test_dir) + + # Run uninstall with dry-run first + print("\nRunning uninstall.py --dry-run...") + result = subprocess.run( + [sys.executable, "uninstall.py", "--dry-run", "--verbose"], + capture_output=True, + text=True, + ) + print(result.stdout) + + # Run actual uninstall + print("\nRunning uninstall.py --force...") + result = subprocess.run( + [sys.executable, "uninstall.py", "--force"], capture_output=True, text=True + ) + print(result.stdout) + + if result.returncode != 0: + print(f"Uninstall failed: {result.stderr}") + return False + + # Verify removal + if (test_dir / ".conductor").exists(): + print("ERROR: .conductor directory still exists!") + return False + + print("\nIntegration test passed!") + return True + + finally: + os.chdir(original_cwd) + shutil.rmtree(test_dir) + + +if __name__ == "__main__": + # Run unit tests + print("Running unit tests...\n") + unittest.main(argv=[""], exit=False, verbosity=2) + + # Run integration test + print("\n" + "=" * 60 + "\n") + if run_integration_test(): + print("\nAll tests passed!") + else: + print("\nIntegration test failed!") + sys.exit(1) diff --git a/uninstall.py b/uninstall.py new file mode 100755 index 0000000..6d9557a --- /dev/null +++ b/uninstall.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +""" +Code Conductor Uninstall Script + +Safely removes Code Conductor from a project, preserving user files. +""" + +import sys +import shutil +import argparse +import subprocess +from pathlib import Path +from typing import List, Tuple, Optional +import re + +# ANSI color codes for better UX +GREEN = "\033[92m" +YELLOW = "\033[93m" +RED = "\033[91m" +BLUE = "\033[94m" +BOLD = "\033[1m" +RESET = "\033[0m" + + +class ConductorUninstaller: + """Handles the removal of Code Conductor from a project.""" + + def __init__( + self, force: bool = False, dry_run: bool = False, verbose: bool = False + ): + self.force = force + self.dry_run = dry_run + self.verbose = verbose + self.project_root = Path.cwd() + self.items_to_remove = [] + self.warnings = [] + + def log(self, message: str, color: str = ""): + """Print a message with optional color.""" + print(f"{color}{message}{RESET}") + + def log_verbose(self, message: str): + """Print verbose output if enabled.""" + if self.verbose: + self.log(f" {message}", BLUE) + + def check_git_repo(self) -> bool: + """Check if we're in a git repository.""" + try: + subprocess.run( + ["git", "rev-parse", "--git-dir"], capture_output=True, check=True + ) + return True + except subprocess.CalledProcessError: + return False + + def find_conductor_files(self) -> List[Tuple[Path, str]]: + """Find all Code Conductor files and directories.""" + items = [] + + # Core conductor directory + conductor_dir = self.project_root / ".conductor" + if conductor_dir.exists(): + items.append((conductor_dir, "Core configuration directory")) + + # Conductor wrapper script + conductor_script = self.project_root / "conductor" + if conductor_script.exists(): + items.append((conductor_script, "Conductor wrapper script")) + + # GitHub workflows + workflows_dir = self.project_root / ".github" / "workflows" + if workflows_dir.exists(): + conductor_workflows = [ + "conductor.yml", + "conductor-cleanup.yml", + "pr-review.yml", + ] + for workflow in conductor_workflows: + workflow_path = workflows_dir / workflow + if workflow_path.exists(): + items.append((workflow_path, f"GitHub workflow: {workflow}")) + + # GitHub issue template + template_path = ( + self.project_root / ".github" / "ISSUE_TEMPLATE" / "conductor-task.yml" + ) + if template_path.exists(): + items.append((template_path, "Conductor task issue template")) + + # Worktrees directory + worktrees_dir = self.project_root / "worktrees" + if worktrees_dir.exists(): + # Check if it contains conductor worktrees + conductor_worktrees = list(worktrees_dir.glob("agent-*")) + if conductor_worktrees: + items.append( + ( + worktrees_dir, + f"Worktrees directory " + f"({len(conductor_worktrees)} agent worktrees)", + ) + ) + + return items + + def find_claude_md_section(self) -> Optional[Path]: + """Check if CLAUDE.md has conductor section.""" + claude_md = self.project_root / "CLAUDE.md" + if claude_md.exists(): + content = claude_md.read_text() + if ( + "" in content + and "" in content + ): + return claude_md + return None + + def list_git_worktrees(self) -> List[str]: + """List all git worktrees created by conductor.""" + try: + result = subprocess.run( + ["git", "worktree", "list", "--porcelain"], + capture_output=True, + text=True, + check=True, + ) + worktrees = [] + lines = result.stdout.strip().split("\n") + + i = 0 + while i < len(lines): + if lines[i].startswith("worktree "): + path = lines[i].split(" ", 1)[1] + if "/worktrees/agent-" in path: + worktrees.append(path) + i += 1 + + return worktrees + except subprocess.CalledProcessError: + return [] + + def remove_git_worktree(self, path: str) -> bool: + """Remove a git worktree safely.""" + try: + self.log_verbose(f"Removing git worktree: {path}") + subprocess.run( + ["git", "worktree", "remove", path, "--force"], + capture_output=True, + check=True, + ) + return True + except subprocess.CalledProcessError as e: + self.warnings.append(f"Failed to remove worktree {path}: {e}") + return False + + def remove_claude_md_section(self, path: Path) -> bool: + """Remove conductor section from CLAUDE.md.""" + try: + content = path.read_text() + pattern = r".*?" + new_content = re.sub(pattern, "", content, flags=re.DOTALL) + + # Clean up extra newlines + new_content = re.sub(r"\n{3,}", "\n\n", new_content) + + if self.dry_run: + self.log_verbose("Would update CLAUDE.md to remove conductor section") + else: + path.write_text(new_content) + self.log_verbose("Updated CLAUDE.md to remove conductor section") + return True + except Exception as e: + self.warnings.append(f"Failed to update CLAUDE.md: {e}") + return False + + def check_github_items(self) -> Tuple[int, int]: + """Check for GitHub issues and labels (requires gh CLI).""" + issues_count = 0 + labels = [] + + try: + # Check for conductor issues + result = subprocess.run( + [ + "gh", + "issue", + "list", + "--label", + "conductor:task", + "--state", + "open", + "--json", + "number", + ], + capture_output=True, + text=True, + check=True, + ) + import json + + issues = json.loads(result.stdout) + issues_count = len(issues) + + # Check for conductor labels + result = subprocess.run( + ["gh", "label", "list", "--json", "name"], + capture_output=True, + text=True, + check=True, + ) + all_labels = json.loads(result.stdout) + conductor_labels = [ + label["name"] + for label in all_labels + if label["name"].startswith("conductor:") + ] + labels = conductor_labels + + except (subprocess.CalledProcessError, ImportError, KeyError): + # gh CLI not available or command failed + pass + + return issues_count, len(labels) + + def confirm_removal(self) -> bool: + """Ask user to confirm removal.""" + print(f"\n{BOLD}Code Conductor Uninstall Summary{RESET}") + print("=" * 50) + + if not self.items_to_remove: + self.log("No Code Conductor files found to remove.", GREEN) + return False + + print(f"\n{BOLD}Files and directories to be removed:{RESET}") + for item, description in self.items_to_remove: + print(f" • {item.relative_to(self.project_root)} - {description}") + + # Check for git worktrees + worktrees = self.list_git_worktrees() + if worktrees: + print(f"\n{BOLD}Git worktrees to be removed:{RESET}") + for worktree in worktrees: + print(f" • {worktree}") + + # Check for CLAUDE.md section + claude_md = self.find_claude_md_section() + if claude_md: + print(f"\n{BOLD}Files to be modified:{RESET}") + print(" • CLAUDE.md - Remove conductor section") + + # Check GitHub items + issues_count, labels_count = self.check_github_items() + if issues_count > 0 or labels_count > 0: + print(f"\n{BOLD}GitHub items (for information only):{RESET}") + if issues_count > 0: + print(f" • {issues_count} open conductor task issues") + if labels_count > 0: + print(f" • {labels_count} conductor labels") + print( + f" {YELLOW}Note: GitHub items must be cleaned up " + f"manually if desired{RESET}" + ) + + if self.dry_run: + print(f"\n{YELLOW}DRY RUN: No files will be removed{RESET}") + return False + + if self.force: + return True + + print(f"\n{YELLOW}This action cannot be undone!{RESET}") + response = input( + f"\n{BOLD}Remove Code Conductor from this project? [y/N]:{RESET} " + ) + return response.lower() in ["y", "yes"] + + def remove_items(self) -> bool: + """Remove all conductor files and directories.""" + success = True + + # Remove git worktrees first + worktrees = self.list_git_worktrees() + for worktree in worktrees: + if not self.dry_run: + if not self.remove_git_worktree(worktree): + success = False + + # Remove files and directories + for item, description in self.items_to_remove: + try: + if self.dry_run: + self.log( + f"Would remove: {item.relative_to(self.project_root)}", BLUE + ) + else: + if item.is_dir(): + shutil.rmtree(item) + else: + item.unlink() + self.log(f"Removed: {item.relative_to(self.project_root)}", GREEN) + except Exception as e: + self.log(f"Failed to remove {item}: {e}", RED) + success = False + + # Update CLAUDE.md + claude_md = self.find_claude_md_section() + if claude_md and not self.dry_run: + if not self.remove_claude_md_section(claude_md): + success = False + + return success + + def cleanup_empty_dirs(self): + """Remove empty directories left behind.""" + dirs_to_check = [ + self.project_root / ".github" / "ISSUE_TEMPLATE", + self.project_root / ".github" / "workflows", + self.project_root / ".github", + ] + + for dir_path in dirs_to_check: + if dir_path.exists() and not any(dir_path.iterdir()): + try: + if not self.dry_run: + dir_path.rmdir() + self.log_verbose( + f"Removed empty directory: " + f"{dir_path.relative_to(self.project_root)}" + ) + except Exception: + pass + + def run(self) -> int: + """Run the uninstall process.""" + print(f"{BOLD}Code Conductor Uninstaller{RESET}") + print("=" * 50) + + # Check if we're in a git repo + if not self.check_git_repo(): + self.log( + "Warning: Not in a git repository. Some features may not work.", YELLOW + ) + + # Find all conductor files + self.items_to_remove = self.find_conductor_files() + + # Confirm removal + if not self.confirm_removal(): + self.log("\nUninstall cancelled.", YELLOW) + return 0 + + # Remove items + print(f"\n{BOLD}Removing Code Conductor...{RESET}") + if self.remove_items(): + self.cleanup_empty_dirs() + + if not self.dry_run: + self.log( + f"\n{GREEN}✓ Code Conductor has been successfully " + f"removed from your project!{RESET}" + ) + + # Post-removal instructions + print(f"\n{BOLD}Post-removal notes:{RESET}") + print("• Any open conductor task issues remain in GitHub") + print( + "• Conductor labels remain in GitHub (remove manually if desired)" + ) + print("• Any active conductor branches should be cleaned up manually") + + if self.warnings: + print(f"\n{YELLOW}Warnings:{RESET}") + for warning in self.warnings: + print(f" • {warning}") + else: + self.log( + f"\n{RED}Some items could not be removed. " + f"Please check the errors above.{RESET}", + RED, + ) + return 1 + + return 0 + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Safely remove Code Conductor from a project", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python uninstall.py # Interactive removal + python uninstall.py --force # Remove without confirmation + python uninstall.py --dry-run # Show what would be removed + python uninstall.py --verbose # Show detailed output + """, + ) + + parser.add_argument( + "--force", "-f", action="store_true", help="Remove without confirmation prompt" + ) + + parser.add_argument( + "--dry-run", + "-n", + action="store_true", + help="Show what would be removed without actually removing", + ) + + parser.add_argument( + "--verbose", "-v", action="store_true", help="Show detailed output" + ) + + args = parser.parse_args() + + # Create and run uninstaller + uninstaller = ConductorUninstaller( + force=args.force, dry_run=args.dry_run, verbose=args.verbose + ) + + try: + return uninstaller.run() + except KeyboardInterrupt: + print(f"\n{YELLOW}Uninstall cancelled by user.{RESET}") + return 1 + except Exception as e: + print(f"\n{RED}Error: {e}{RESET}") + if args.verbose: + import traceback + + traceback.print_exc() + return 1 + + +if __name__ == "__main__": + sys.exit(main())