783 lines
27 KiB
Python
783 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Uninstall Orchestrator - Complete uninstallation of autonomous-dev plugin
|
|
|
|
This module handles complete uninstallation of the autonomous-dev plugin with
|
|
backup and rollback capabilities. Implements three-phase execution:
|
|
Validate → Preview → Execute.
|
|
|
|
Security:
|
|
- Path traversal prevention (CWE-22)
|
|
- Symlink attack prevention (CWE-59)
|
|
- TOCTOU detection (CWE-367)
|
|
- Whitelist enforcement for allowed directories
|
|
- Audit logging for all operations
|
|
|
|
Features:
|
|
- Three-phase execution (validate, preview, execute)
|
|
- Automatic backup creation before deletion
|
|
- Rollback support to restore from backup
|
|
- Protected file preservation (PROJECT.md, .env, settings.local.json)
|
|
- Dry-run mode for safe preview
|
|
- Local-only mode to preserve global files
|
|
|
|
Usage:
|
|
from uninstall_orchestrator import uninstall_plugin
|
|
|
|
# Simple uninstall with preview
|
|
result = uninstall_plugin(project_root, dry_run=True)
|
|
print(f"Would remove {result.files_to_remove} files")
|
|
|
|
# Execute actual uninstall
|
|
result = uninstall_plugin(project_root, force=True)
|
|
if result.status == "success":
|
|
print(f"Removed {result.files_removed} files")
|
|
print(f"Backup: {result.backup_path}")
|
|
|
|
# Rollback if needed
|
|
orchestrator = UninstallOrchestrator(project_root)
|
|
rollback_result = orchestrator.rollback(result.backup_path)
|
|
|
|
Date: 2025-12-14
|
|
Issue: GitHub #131 - Add uninstall capability to install.sh and /sync command
|
|
Agent: implementer
|
|
|
|
Design Patterns:
|
|
See library-design-patterns skill for standardized design patterns.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import tarfile
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Dict, Any, List, Optional
|
|
|
|
# Import with fallback for both dev (plugins/) and installed (.claude/lib/) environments
|
|
try:
|
|
from plugins.autonomous_dev.lib.security_utils import validate_path, audit_log
|
|
from plugins.autonomous_dev.lib.protected_file_detector import ProtectedFileDetector
|
|
except ImportError:
|
|
# Fallback for installed environment (.claude/lib/)
|
|
from security_utils import audit_log
|
|
from protected_file_detector import ProtectedFileDetector
|
|
|
|
|
|
# Whitelist of allowed directories for uninstallation
|
|
ALLOWED_DIRECTORIES = [
|
|
".claude",
|
|
".autonomous-dev",
|
|
]
|
|
|
|
# Global directories (under home)
|
|
GLOBAL_DIRECTORIES = [
|
|
".claude",
|
|
".autonomous-dev",
|
|
]
|
|
|
|
|
|
@dataclass
|
|
class UninstallResult:
|
|
"""Result of uninstall operation.
|
|
|
|
Attributes:
|
|
status: Operation status ("success", "failure", "preview")
|
|
files_removed: Number of files actually removed
|
|
total_size_bytes: Total size of files removed/to be removed
|
|
backup_path: Path to backup tar.gz file
|
|
errors: List of error messages
|
|
dry_run: Whether this was a dry-run preview
|
|
files_to_remove: Number of files to be removed (preview mode)
|
|
file_list: List of file paths to be removed (preview mode)
|
|
manifest_found: Whether install manifest was found
|
|
files_restored: Number of files restored (rollback mode)
|
|
"""
|
|
|
|
status: str
|
|
files_removed: int = 0
|
|
total_size_bytes: int = 0
|
|
backup_path: Optional[Path] = None
|
|
errors: List[str] = field(default_factory=list)
|
|
dry_run: bool = False
|
|
files_to_remove: int = 0
|
|
file_list: List[Path] = field(default_factory=list)
|
|
manifest_found: bool = False
|
|
files_restored: int = 0
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert result to dictionary for JSON serialization.
|
|
|
|
Returns:
|
|
Dictionary representation of result
|
|
"""
|
|
return {
|
|
"status": self.status,
|
|
"files_removed": self.files_removed,
|
|
"total_size_bytes": self.total_size_bytes,
|
|
"backup_path": str(self.backup_path) if self.backup_path else None,
|
|
"errors": self.errors,
|
|
"dry_run": self.dry_run,
|
|
"files_to_remove": self.files_to_remove,
|
|
"file_list": [str(f) for f in self.file_list],
|
|
"manifest_found": self.manifest_found,
|
|
"files_restored": self.files_restored,
|
|
}
|
|
|
|
|
|
class UninstallError(Exception):
|
|
"""Exception raised for uninstall errors."""
|
|
|
|
pass
|
|
|
|
|
|
class UninstallOrchestrator:
|
|
"""Orchestrate complete uninstallation of autonomous-dev plugin.
|
|
|
|
This class handles three-phase uninstallation:
|
|
1. Validate: Check manifest exists and paths are valid
|
|
2. Preview: Show what will be deleted without deleting
|
|
3. Execute: Create backup and delete files
|
|
|
|
Examples:
|
|
>>> orchestrator = UninstallOrchestrator(project_root)
|
|
>>> # Phase 1: Validate
|
|
>>> result = orchestrator.validate()
|
|
>>> if result.status == "success":
|
|
... # Phase 2: Preview
|
|
... preview = orchestrator.preview()
|
|
... print(f"Would remove {preview.files_to_remove} files")
|
|
... # Phase 3: Execute
|
|
... result = orchestrator.execute(force=True)
|
|
... print(f"Backup: {result.backup_path}")
|
|
"""
|
|
|
|
def __init__(self, project_root: Path | str):
|
|
"""Initialize uninstall orchestrator.
|
|
|
|
Args:
|
|
project_root: Root directory of project to uninstall from
|
|
|
|
Raises:
|
|
ValueError: If path validation fails
|
|
"""
|
|
# Convert to Path if string
|
|
self.project_root = Path(project_root) if isinstance(project_root, str) else project_root
|
|
|
|
# Validate path (CWE-22: path traversal prevention)
|
|
self.project_root = self.project_root.resolve()
|
|
|
|
# Check for path traversal
|
|
try:
|
|
# Get the original path without resolving
|
|
original_path = Path(project_root) if isinstance(project_root, str) else project_root
|
|
original_str = str(original_path)
|
|
|
|
# Detect path traversal patterns
|
|
if ".." in original_str or "/./" in original_str:
|
|
raise ValueError(f"Path traversal detected: {original_str}")
|
|
|
|
except Exception as e:
|
|
audit_log("uninstall_orchestrator", "path_validation_error", {
|
|
"path": str(project_root),
|
|
"error": str(e)
|
|
})
|
|
raise ValueError(f"Path traversal detected: {project_root}")
|
|
|
|
self.claude_dir = self.project_root / ".claude"
|
|
self.manifest_path = self.claude_dir / "config" / "install_manifest.json"
|
|
self.protected_detector = ProtectedFileDetector()
|
|
|
|
# State tracking for TOCTOU detection
|
|
self._preview_state: Dict[str, Any] = {}
|
|
|
|
audit_log("uninstall_orchestrator", "initialized", {
|
|
"project_root": str(self.project_root),
|
|
"claude_dir": str(self.claude_dir),
|
|
})
|
|
|
|
def validate(self) -> UninstallResult:
|
|
"""Phase 1: Validate manifest exists and paths are valid.
|
|
|
|
Returns:
|
|
UninstallResult with validation status
|
|
"""
|
|
audit_log("uninstall_orchestrator", "validate_start", {
|
|
"manifest_path": str(self.manifest_path)
|
|
})
|
|
|
|
errors = []
|
|
|
|
# Check if manifest exists
|
|
if not self.manifest_path.exists():
|
|
errors.append(f"Install manifest not found: {self.manifest_path}")
|
|
audit_log("uninstall_orchestrator", "validate_failure", {
|
|
"error": "manifest_not_found"
|
|
})
|
|
return UninstallResult(
|
|
status="failure",
|
|
errors=errors,
|
|
manifest_found=False
|
|
)
|
|
|
|
# Check for multi-project installations
|
|
self._check_multi_project_installations()
|
|
|
|
audit_log("uninstall_orchestrator", "validate_success", {})
|
|
|
|
return UninstallResult(
|
|
status="success",
|
|
manifest_found=True
|
|
)
|
|
|
|
def preview(self) -> UninstallResult:
|
|
"""Phase 2: Preview files to be deleted without deleting.
|
|
|
|
Returns:
|
|
UninstallResult with preview information (files_to_remove, total_size_bytes, file_list)
|
|
|
|
Raises:
|
|
ValueError: If security validation fails
|
|
"""
|
|
audit_log("uninstall_orchestrator", "preview_start", {})
|
|
|
|
# Validate manifest exists
|
|
if not self.manifest_path.exists():
|
|
return UninstallResult(
|
|
status="failure",
|
|
errors=[f"Install manifest not found: {self.manifest_path}"],
|
|
manifest_found=False
|
|
)
|
|
|
|
# Load manifest
|
|
with open(self.manifest_path, "r") as f:
|
|
manifest = json.load(f)
|
|
|
|
# Get files to remove (this may raise ValueError for security violations)
|
|
files_to_remove, total_size, file_list = self._collect_files_to_remove(manifest)
|
|
|
|
# Store state for TOCTOU detection
|
|
self._preview_state = {
|
|
"files": {str(f): os.stat(f).st_mtime if f.exists() else None for f in file_list},
|
|
"timestamp": datetime.now().isoformat()
|
|
}
|
|
|
|
audit_log("uninstall_orchestrator", "preview_success", {
|
|
"files_to_remove": files_to_remove,
|
|
"total_size_bytes": total_size
|
|
})
|
|
|
|
return UninstallResult(
|
|
status="success",
|
|
files_to_remove=files_to_remove,
|
|
total_size_bytes=total_size,
|
|
file_list=file_list,
|
|
manifest_found=True
|
|
)
|
|
|
|
def execute(
|
|
self,
|
|
force: bool = False,
|
|
dry_run: bool = False,
|
|
local_only: bool = False
|
|
) -> UninstallResult:
|
|
"""Phase 3: Execute uninstallation with backup.
|
|
|
|
Args:
|
|
force: If True, execute deletion; if False, return error
|
|
dry_run: If True, only preview (same as preview() method)
|
|
local_only: If True, skip global ~/.claude/ and ~/.autonomous-dev/
|
|
|
|
Returns:
|
|
UninstallResult with execution status
|
|
|
|
Raises:
|
|
ValueError: If security validation fails
|
|
"""
|
|
audit_log("uninstall_orchestrator", "execute_start", {
|
|
"force": force,
|
|
"dry_run": dry_run,
|
|
"local_only": local_only
|
|
})
|
|
|
|
# Dry-run mode - just return preview
|
|
if dry_run:
|
|
result = self.preview()
|
|
result.dry_run = True
|
|
return result
|
|
|
|
# Force required for actual deletion
|
|
if not force:
|
|
audit_log("uninstall_orchestrator", "execute_force_required", {})
|
|
return UninstallResult(
|
|
status="failure",
|
|
errors=["Uninstall requires --force flag for confirmation"]
|
|
)
|
|
|
|
# Validate manifest exists
|
|
if not self.manifest_path.exists():
|
|
return UninstallResult(
|
|
status="failure",
|
|
errors=[f"Install manifest not found: {self.manifest_path}"],
|
|
manifest_found=False
|
|
)
|
|
|
|
try:
|
|
# Load manifest
|
|
with open(self.manifest_path, "r") as f:
|
|
manifest = json.load(f)
|
|
|
|
# Get files to remove
|
|
files_to_remove, total_size, file_list = self._collect_files_to_remove(
|
|
manifest,
|
|
local_only=local_only
|
|
)
|
|
|
|
# TOCTOU detection - check if files changed since preview
|
|
self._detect_toctou_changes(file_list)
|
|
|
|
# Create backup
|
|
backup_path = self._create_backup(file_list)
|
|
|
|
# Remove files
|
|
files_removed, errors = self._remove_files(file_list)
|
|
|
|
status = "success" if not errors or files_removed > 0 else "failure"
|
|
|
|
audit_log("uninstall_orchestrator", "execute_success", {
|
|
"files_removed": files_removed,
|
|
"backup_path": str(backup_path),
|
|
"errors": len(errors)
|
|
})
|
|
|
|
return UninstallResult(
|
|
status=status,
|
|
files_removed=files_removed,
|
|
total_size_bytes=total_size,
|
|
backup_path=backup_path,
|
|
errors=errors,
|
|
manifest_found=True
|
|
)
|
|
|
|
except Exception as e:
|
|
audit_log("uninstall_orchestrator", "execute_error", {
|
|
"error": str(e)
|
|
})
|
|
return UninstallResult(
|
|
status="failure",
|
|
errors=[f"Execution failed: {str(e)}"]
|
|
)
|
|
|
|
def rollback(self, backup_path: Path | str) -> UninstallResult:
|
|
"""Rollback uninstallation by restoring from backup.
|
|
|
|
Args:
|
|
backup_path: Path to backup tar.gz file
|
|
|
|
Returns:
|
|
UninstallResult with rollback status
|
|
"""
|
|
audit_log("uninstall_orchestrator", "rollback_start", {
|
|
"backup_path": str(backup_path)
|
|
})
|
|
|
|
backup = Path(backup_path) if isinstance(backup_path, str) else backup_path
|
|
|
|
# Validate backup exists
|
|
if not backup.exists():
|
|
return UninstallResult(
|
|
status="failure",
|
|
errors=[f"Backup file not found: {backup}"]
|
|
)
|
|
|
|
try:
|
|
# Extract backup with Zip Slip prevention (CVE-2007-4559)
|
|
with tarfile.open(backup, "r:gz") as tar:
|
|
# Validate all members before extraction (Zip Slip prevention)
|
|
project_root_resolved = self.project_root.resolve()
|
|
for member in tar.getmembers():
|
|
member_path = (self.project_root / member.name).resolve()
|
|
if not str(member_path).startswith(str(project_root_resolved)):
|
|
raise ValueError(f"Path traversal detected in archive: {member.name}")
|
|
|
|
# Safe to extract after validation
|
|
tar.extractall(path=self.project_root)
|
|
|
|
files_restored = len(tar.getmembers())
|
|
|
|
audit_log("uninstall_orchestrator", "rollback_success", {
|
|
"files_restored": files_restored
|
|
})
|
|
|
|
return UninstallResult(
|
|
status="success",
|
|
files_restored=files_restored
|
|
)
|
|
|
|
except Exception as e:
|
|
audit_log("uninstall_orchestrator", "rollback_error", {
|
|
"error": str(e)
|
|
})
|
|
return UninstallResult(
|
|
status="failure",
|
|
errors=[f"Rollback failed: {str(e)}"]
|
|
)
|
|
|
|
def _collect_files_to_remove(
|
|
self,
|
|
manifest: Dict[str, Any],
|
|
local_only: bool = False
|
|
) -> tuple[int, int, List[Path]]:
|
|
"""Collect files to remove from manifest.
|
|
|
|
Args:
|
|
manifest: Install manifest dictionary
|
|
local_only: If True, skip global directories
|
|
|
|
Returns:
|
|
Tuple of (count, total_size_bytes, file_list)
|
|
|
|
Raises:
|
|
ValueError: If security validation fails
|
|
"""
|
|
file_list = []
|
|
total_size = 0
|
|
|
|
# Get protected files
|
|
protected = self.protected_detector.detect_protected_files(self.project_root)
|
|
protected_paths = {Path(self.project_root) / p["path"] for p in protected}
|
|
|
|
# Process each component
|
|
components = manifest.get("components", {})
|
|
for component_name, component_data in components.items():
|
|
target = component_data.get("target", "")
|
|
files = component_data.get("files", [])
|
|
|
|
# Security: Check for path traversal in target (CWE-22)
|
|
if ".." in target or "/./" in target:
|
|
raise ValueError(f"Path traversal detected - target not in whitelist: {target}")
|
|
|
|
# Build target directory
|
|
target_dir = self.project_root / target
|
|
|
|
# Security: Validate target directory is within allowed paths
|
|
self._validate_file_path(target_dir)
|
|
|
|
# Skip global directories if local_only
|
|
if local_only and self._is_global_directory(target_dir):
|
|
audit_log("uninstall_orchestrator", "skip_global_directory", {
|
|
"target": str(target_dir),
|
|
"local_only": True
|
|
})
|
|
continue
|
|
|
|
for file_rel_path in files:
|
|
# Security: Check for path traversal in file path (CWE-22)
|
|
if ".." in file_rel_path or "/./" in file_rel_path:
|
|
raise ValueError(f"Path traversal detected in file path: {file_rel_path}")
|
|
|
|
# Extract relative structure from manifest path
|
|
# e.g., "plugins/autonomous-dev/commands/helpers/utility.md" -> "helpers/utility.md"
|
|
# This preserves nested directory structure
|
|
file_rel = Path(file_rel_path)
|
|
file_parts = file_rel.parts
|
|
|
|
# Manifest paths are like "plugins/autonomous-dev/component/..."
|
|
# We need everything after the component type (4th part onwards)
|
|
if len(file_parts) > 3:
|
|
# Has subdirectory structure (e.g., commands/helpers/utility.md)
|
|
relative_structure = Path(*file_parts[3:])
|
|
file_path = target_dir / relative_structure
|
|
else:
|
|
# Simple file (e.g., commands/auto-implement.md)
|
|
file_path = target_dir / file_rel.name
|
|
|
|
# Skip if file doesn't exist (partial install)
|
|
if not file_path.exists():
|
|
continue
|
|
|
|
# Security: Check for symlinks BEFORE resolving path (CWE-59)
|
|
if file_path.is_symlink():
|
|
real_path = file_path.resolve()
|
|
# Symlink detected - reject it
|
|
raise ValueError(f"Symlink detected: {file_path} -> {real_path}")
|
|
|
|
# Security: Validate path (CWE-22)
|
|
# Now safe to validate since we know it's not a symlink
|
|
self._validate_file_path(file_path)
|
|
|
|
# Skip protected files
|
|
if file_path in protected_paths:
|
|
audit_log("uninstall_orchestrator", "skip_protected_file", {
|
|
"file": str(file_path)
|
|
})
|
|
continue
|
|
|
|
# Add to list
|
|
file_list.append(file_path)
|
|
total_size += file_path.stat().st_size
|
|
|
|
return len(file_list), total_size, file_list
|
|
|
|
def _validate_file_path(self, file_path: Path) -> None:
|
|
"""Validate file path for security.
|
|
|
|
Args:
|
|
file_path: Path to validate
|
|
|
|
Raises:
|
|
ValueError: If path validation fails
|
|
"""
|
|
# Resolve to absolute path
|
|
resolved = file_path.resolve()
|
|
|
|
# Check path is within allowed directories
|
|
allowed = False
|
|
for allowed_dir in ALLOWED_DIRECTORIES:
|
|
# Check if path contains allowed directory (as a path component)
|
|
path_parts = resolved.parts
|
|
if allowed_dir in path_parts:
|
|
allowed = True
|
|
break
|
|
|
|
if not allowed:
|
|
raise ValueError(f"Path not in whitelist: {file_path}")
|
|
|
|
# Additional check: ensure path is within project_root or global home
|
|
project_root_resolved = self.project_root.resolve()
|
|
home_dir = Path.home()
|
|
|
|
within_project = str(resolved).startswith(str(project_root_resolved))
|
|
within_home = str(resolved).startswith(str(home_dir))
|
|
|
|
if not (within_project or within_home):
|
|
raise ValueError(f"Path not in whitelist: {file_path}")
|
|
|
|
def _is_global_directory(self, path: Path) -> bool:
|
|
"""Check if path is a global directory (~/.claude/ or ~/.autonomous-dev/).
|
|
|
|
Args:
|
|
path: Path to check
|
|
|
|
Returns:
|
|
True if path is under global directory
|
|
"""
|
|
resolved = path.resolve()
|
|
home_dir = Path.home()
|
|
|
|
for global_dir in GLOBAL_DIRECTORIES:
|
|
global_path = home_dir / global_dir
|
|
if str(resolved).startswith(str(global_path)):
|
|
return True
|
|
|
|
return False
|
|
|
|
def _detect_toctou_changes(self, file_list: List[Path]) -> None:
|
|
"""Detect TOCTOU race conditions (CWE-367).
|
|
|
|
Args:
|
|
file_list: List of files to check
|
|
"""
|
|
if not self._preview_state:
|
|
# No preview state to compare against
|
|
return
|
|
|
|
preview_files = self._preview_state.get("files", {})
|
|
|
|
for file_path in file_list:
|
|
file_key = str(file_path)
|
|
|
|
if file_key not in preview_files:
|
|
continue
|
|
|
|
preview_mtime = preview_files[file_key]
|
|
if preview_mtime is None:
|
|
continue
|
|
|
|
if not file_path.exists():
|
|
# File was deleted between preview and execute (TOCTOU race condition)
|
|
audit_log("uninstall_orchestrator", "TOCTOU_detected_file_deleted", {
|
|
"file": file_key
|
|
})
|
|
continue
|
|
|
|
current_mtime = os.stat(file_path).st_mtime
|
|
|
|
if current_mtime != preview_mtime:
|
|
# File was modified between preview and execute (TOCTOU race condition)
|
|
audit_log("uninstall_orchestrator", "TOCTOU_detected_file_changed", {
|
|
"file": file_key,
|
|
"preview_mtime": preview_mtime,
|
|
"current_mtime": current_mtime
|
|
})
|
|
|
|
def _create_backup(self, file_list: List[Path]) -> Path:
|
|
"""Create backup tar.gz of files before deletion.
|
|
|
|
Args:
|
|
file_list: List of files to backup
|
|
|
|
Returns:
|
|
Path to backup tar.gz file
|
|
"""
|
|
# Create backup directory
|
|
backup_dir = self.project_root / ".autonomous-dev"
|
|
backup_dir.mkdir(exist_ok=True)
|
|
|
|
# Generate timestamped backup filename
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
backup_path = backup_dir / f"uninstall_backup_{timestamp}.tar.gz"
|
|
|
|
audit_log("uninstall_orchestrator", "backup_start", {
|
|
"backup_path": str(backup_path),
|
|
"files": len(file_list)
|
|
})
|
|
|
|
# Create tar.gz backup
|
|
with tarfile.open(backup_path, "w:gz") as tar:
|
|
for file_path in file_list:
|
|
if file_path.exists():
|
|
# Add file with relative path from project root
|
|
arcname = file_path.relative_to(self.project_root)
|
|
tar.add(file_path, arcname=arcname)
|
|
|
|
audit_log("uninstall_orchestrator", "backup_success", {
|
|
"backup_path": str(backup_path)
|
|
})
|
|
|
|
return backup_path
|
|
|
|
def _remove_files(self, file_list: List[Path]) -> tuple[int, List[str]]:
|
|
"""Remove files from filesystem.
|
|
|
|
Args:
|
|
file_list: List of files to remove
|
|
|
|
Returns:
|
|
Tuple of (files_removed_count, errors_list)
|
|
"""
|
|
files_removed = 0
|
|
errors = []
|
|
|
|
for file_path in file_list:
|
|
try:
|
|
if file_path.exists():
|
|
file_path.unlink()
|
|
files_removed += 1
|
|
audit_log("uninstall_orchestrator", "file_removed", {
|
|
"file": str(file_path)
|
|
})
|
|
except PermissionError as e:
|
|
error_msg = f"Permission denied: {file_path}"
|
|
errors.append(error_msg)
|
|
audit_log("uninstall_orchestrator", "permission_error", {
|
|
"file": str(file_path),
|
|
"error": str(e)
|
|
})
|
|
except Exception as e:
|
|
error_msg = f"Error removing {file_path}: {str(e)}"
|
|
errors.append(error_msg)
|
|
audit_log("uninstall_orchestrator", "removal_error", {
|
|
"file": str(file_path),
|
|
"error": str(e)
|
|
})
|
|
|
|
return files_removed, errors
|
|
|
|
def _check_multi_project_installations(self) -> None:
|
|
"""Check for multiple project installations and log warning."""
|
|
try:
|
|
home_dir = Path.home()
|
|
|
|
# Find .claude directories (limit search to avoid hanging)
|
|
claude_dirs = []
|
|
|
|
# Only check immediate subdirectories to avoid deep recursive search
|
|
for search_dir in [home_dir, home_dir / "Documents", home_dir / "projects"]:
|
|
if not search_dir.exists():
|
|
continue
|
|
|
|
# Only check one level deep to avoid performance issues
|
|
for item in search_dir.iterdir():
|
|
if not item.is_dir():
|
|
continue
|
|
|
|
claude_path = item / ".claude"
|
|
if claude_path.exists() and claude_path.is_dir():
|
|
claude_dirs.append(claude_path)
|
|
|
|
# Limit to first 10 directories to avoid hanging
|
|
if len(claude_dirs) >= 10:
|
|
break
|
|
|
|
if len(claude_dirs) > 1:
|
|
audit_log("uninstall_orchestrator", "multi_project_warning", {
|
|
"count": len(claude_dirs),
|
|
"directories": [str(d) for d in claude_dirs[:5]] # Log first 5
|
|
})
|
|
|
|
except Exception as e:
|
|
# Don't fail validation on multi-project detection
|
|
audit_log("uninstall_orchestrator", "multi_project_check_error", {
|
|
"error": str(e)
|
|
})
|
|
|
|
|
|
def uninstall_plugin(
|
|
project_root: Path | str,
|
|
force: bool = False,
|
|
dry_run: bool = False,
|
|
local_only: bool = False
|
|
) -> UninstallResult:
|
|
"""Standalone function to uninstall autonomous-dev plugin.
|
|
|
|
This is a convenience wrapper around UninstallOrchestrator for simple usage.
|
|
|
|
Args:
|
|
project_root: Root directory of project to uninstall from
|
|
force: If True, execute deletion; if False, show preview only
|
|
dry_run: If True, only preview (overrides force)
|
|
local_only: If True, skip global ~/.claude/ and ~/.autonomous-dev/
|
|
|
|
Returns:
|
|
UninstallResult with operation status
|
|
|
|
Examples:
|
|
>>> # Preview uninstall
|
|
>>> result = uninstall_plugin("/path/to/project", dry_run=True)
|
|
>>> print(f"Would remove {result.files_to_remove} files")
|
|
>>>
|
|
>>> # Execute uninstall
|
|
>>> result = uninstall_plugin("/path/to/project", force=True)
|
|
>>> if result.status == "success":
|
|
... print(f"Backup: {result.backup_path}")
|
|
"""
|
|
orchestrator = UninstallOrchestrator(project_root)
|
|
|
|
# Validate first
|
|
validation = orchestrator.validate()
|
|
if validation.status != "success":
|
|
return validation
|
|
|
|
# Execute with requested mode
|
|
return orchestrator.execute(force=force, dry_run=dry_run, local_only=local_only)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python uninstall_orchestrator.py <project_root> [--force] [--dry-run] [--local-only]")
|
|
sys.exit(1)
|
|
|
|
project_root = sys.argv[1]
|
|
force = "--force" in sys.argv
|
|
dry_run = "--dry-run" in sys.argv
|
|
local_only = "--local-only" in sys.argv
|
|
|
|
result = uninstall_plugin(project_root, force=force, dry_run=dry_run, local_only=local_only)
|
|
|
|
print(json.dumps(result.to_dict(), indent=2))
|
|
|
|
sys.exit(0 if result.status == "success" else 1)
|