#!/usr/bin/env python3 """ GenAI Installation Wrapper - CLI wrapper for GenAI installation libraries This module provides a CLI interface for setup-wizard Phase 0 GenAI integration, wrapping the core installation libraries with JSON output for agent consumption. Key Features: - check-staging: Validate staging directory - analyze: Detect installation type - execute: Perform installation with protected file handling - cleanup: Remove staging directory - summary: Generate installation summary report Usage: # Check staging python genai_install_wrapper.py check-staging /path/to/staging # Analyze installation type python genai_install_wrapper.py analyze /path/to/project # Execute installation python genai_install_wrapper.py execute /path/to/staging /path/to/project fresh # Cleanup staging python genai_install_wrapper.py cleanup /path/to/staging # Generate summary python genai_install_wrapper.py summary fresh /path/to/result.json /path/to/project Date: 2025-12-09 Issue: #109 (GenAI-first installation CLI wrapper) Agent: implementer """ import json import sys from pathlib import Path from typing import Dict, Any # Import installation libraries try: from plugins.autonomous_dev.lib.staging_manager import StagingManager from plugins.autonomous_dev.lib.installation_analyzer import ( InstallationAnalyzer, InstallationType, ) from plugins.autonomous_dev.lib.protected_file_detector import ( ProtectedFileDetector, ALWAYS_PROTECTED, ) from plugins.autonomous_dev.lib.copy_system import CopySystem from plugins.autonomous_dev.lib.install_audit import InstallAudit except ImportError: # Fallback for testing import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib")) from staging_manager import StagingManager from installation_analyzer import InstallationAnalyzer from protected_file_detector import ProtectedFileDetector from copy_system import CopySystem from install_audit import InstallAudit # Critical directories required in staging CRITICAL_DIRS = [ "plugins/autonomous-dev/commands", "plugins/autonomous-dev/agents", "plugins/autonomous-dev/hooks", "plugins/autonomous-dev/lib", ] def check_staging(staging_path: str) -> Dict[str, Any]: """Check if staging directory exists and is valid. Args: staging_path: Path to staging directory Returns: Dict with: - status: "valid", "invalid", or "missing" - staging_path: Path to staging (if exists) - missing_dirs: List of missing critical directories (if invalid) - fallback_needed: True if should skip to Phase 1 - message: Human-readable message (if missing) """ staging = Path(staging_path) # Check if staging exists if not staging.exists(): return { "status": "missing", "fallback_needed": True, "message": "Staging directory not found. Will skip to Phase 1 (manual setup).", } # Check for critical directories missing_dirs = [] for dir_path in CRITICAL_DIRS: if not (staging / dir_path).is_dir(): missing_dirs.append(dir_path) # If missing critical directories, staging is invalid if missing_dirs: return { "status": "invalid", "fallback_needed": True, "missing_dirs": missing_dirs, "message": f"Staging incomplete (missing {len(missing_dirs)} directories). Will skip to Phase 1.", } # Staging is valid return { "status": "valid", "staging_path": str(staging), "fallback_needed": False, } def analyze_installation_type(project_path: str) -> Dict[str, Any]: """Analyze installation type for project. Args: project_path: Path to project directory Returns: Dict with: - type: "fresh", "brownfield", or "upgrade" - has_project_md: True if PROJECT.md exists - has_claude_dir: True if .claude/ exists - existing_files: List of existing plugin files - protected_files: List of protected files that shouldn't be overwritten """ project_dir = Path(project_path) # Use InstallationAnalyzer analyzer = InstallationAnalyzer(project_dir) install_type = analyzer.detect_installation_type() # Check for PROJECT.md and .claude/ has_project_md = (project_dir / ".claude" / "PROJECT.md").exists() has_claude_dir = (project_dir / ".claude").is_dir() # Find existing files existing_files = [] if has_claude_dir: for file in (project_dir / ".claude").rglob("*"): if file.is_file(): relative_path = file.relative_to(project_dir) existing_files.append(str(relative_path)) # Check plugins directory plugins_dir = project_dir / "plugins" / "autonomous-dev" if plugins_dir.is_dir(): for file in plugins_dir.rglob("*"): if file.is_file(): relative_path = file.relative_to(project_dir) existing_files.append(str(relative_path)) # Detect protected files detector = ProtectedFileDetector() protected = detector.detect_protected_files(project_dir) protected_files = [p["path"] for p in protected] return { "type": install_type.value, "has_project_md": has_project_md, "has_claude_dir": has_claude_dir, "existing_files": existing_files, "protected_files": protected_files, } def execute_installation( staging_path: str, project_path: str, install_type: str, test_mode: bool = False ) -> Dict[str, Any]: """Execute installation from staging to project. Args: staging_path: Path to staging directory project_path: Path to project directory install_type: "fresh", "brownfield", or "upgrade" test_mode: If True, skip security validation (for testing) Returns: Dict with: - status: "success" or "error" - files_copied: Number of files copied - skipped_files: List of protected files that were skipped - backups_created: List of backup file paths (for upgrades) - error: Error message (if status is "error") """ try: staging = Path(staging_path) project = Path(project_path) # Validate install_type valid_types = ["fresh", "brownfield", "upgrade"] if install_type not in valid_types: return { "status": "error", "error": f"Invalid install_type: {install_type}. Must be one of: {', '.join(valid_types)}", } # Validate staging exists if not staging.exists(): return { "status": "error", "error": f"Staging directory does not exist: {staging}", } # Create project directory if it doesn't exist project.mkdir(parents=True, exist_ok=True) # Initialize audit log audit_file = project / ".claude" / "install_audit.jsonl" audit_file.parent.mkdir(parents=True, exist_ok=True) audit = InstallAudit(audit_file) install_id = audit.start_installation(install_type) # Detect protected files in project detector = ProtectedFileDetector() protected = detector.detect_protected_files(project) protected_paths = [p["path"] for p in protected] # Also add ALWAYS_PROTECTED files to the list (even if they don't exist yet in project) # This prevents staging files from overwriting them if they exist from plugins.autonomous_dev.lib.protected_file_detector import ALWAYS_PROTECTED for always_protected in ALWAYS_PROTECTED: if always_protected not in protected_paths: protected_paths.append(always_protected) # Log protected files for protected_file in protected: audit.record_protected_file( install_id, protected_file["path"], protected_file["reason"] ) # Build list of files to copy from staging files_to_copy = [] for file_path in staging.rglob("*"): if file_path.is_file() and not file_path.is_symlink(): files_to_copy.append(file_path.resolve()) # Copy files with protection copier = CopySystem(staging, project) # Determine conflict strategy based on install type if install_type == "upgrade": conflict_strategy = "backup" backup_conflicts = True else: conflict_strategy = "skip" backup_conflicts = False result = copier.copy_all( files=files_to_copy, protected_files=protected_paths, conflict_strategy=conflict_strategy, backup_conflicts=backup_conflicts, backup_timestamp=True, continue_on_error=False, ) # Log completion audit.log_success( install_id, files_copied=result["files_copied"], files_skipped=len(result.get("skipped_files", [])), backups_created=len(result.get("backed_up_files", [])), ) # Return success with details return { "status": "success", "files_copied": result["files_copied"], "skipped_files": result.get("skipped_files", []), "backups_created": [str(b) for b in result.get("backed_up_files", [])], } except Exception as e: return { "status": "error", "error": str(e), } def cleanup_staging(staging_path: str) -> Dict[str, Any]: """Remove staging directory. Args: staging_path: Path to staging directory Returns: Dict with: - status: "success" - message: Human-readable message """ staging = Path(staging_path) # Idempotent - return success if already removed if not staging.exists(): return { "status": "success", "message": "Staging directory already removed (idempotent).", } # Remove staging directory try: manager = StagingManager(staging) manager.cleanup() return { "status": "success", "message": f"Staging directory removed: {staging}", } except Exception as e: return { "status": "error", "error": str(e), } def generate_summary( install_type: str, install_result: Dict[str, Any], project_path: str ) -> Dict[str, Any]: """Generate installation summary report. Args: install_type: "fresh", "brownfield", or "upgrade" install_result: Result dict from execute_installation project_path: Path to project directory Returns: Dict with: - status: "success" - summary: Dict with installation details - next_steps: List of recommended next steps """ # Parse install_result (may be from JSON file) if isinstance(install_result, str): result_file = Path(install_result) if result_file.exists(): install_result = json.loads(result_file.read_text()) # Build summary summary = { "install_type": install_type, "files_copied": install_result.get("files_copied", 0), "skipped_files": len(install_result.get("skipped_files", [])), "backups_created": len(install_result.get("backups_created", [])), } # Generate next steps based on install type next_steps = [] if install_type == "fresh": next_steps.extend([ "Run setup wizard to configure PROJECT.md and hooks", "Review generated PROJECT.md and customize for your project", "Configure environment variables in .env file", "Test installation with: /status", ]) elif install_type == "brownfield": next_steps.extend([ f"Review {len(install_result.get('skipped_files', []))} protected files that were preserved", "Your PROJECT.md was preserved - review for updates", "Test installation with: /status", "Run /align-project to check for any conflicts", ]) elif install_type == "upgrade": if install_result.get("backups_created"): next_steps.extend([ f"Review {len(install_result.get('backups_created', []))} backup files created", "Compare backups with new versions to see changes", "Remove backup files once you've reviewed changes", ]) next_steps.extend([ "Test updated plugin with: /status", "Run /health-check to validate plugin integrity", "Check release notes for breaking changes", ]) return { "status": "success", "summary": summary, "next_steps": next_steps, } def main() -> int: """Main CLI entry point. Returns: Exit code: 0 for success, 1 for error """ if len(sys.argv) < 2: print(json.dumps({ "status": "error", "error": "Usage: genai_install_wrapper.py [args...]", "commands": { "check-staging": "check-staging ", "analyze": "analyze ", "execute": "execute ", "cleanup": "cleanup ", "summary": "summary ", } })) return 1 command = sys.argv[1] try: if command == "check-staging": if len(sys.argv) < 3: print(json.dumps({"status": "error", "error": "Missing staging_path"})) return 1 result = check_staging(sys.argv[2]) print(json.dumps(result)) return 0 elif command == "analyze": if len(sys.argv) < 3: print(json.dumps({"status": "error", "error": "Missing project_path"})) return 1 result = analyze_installation_type(sys.argv[2]) print(json.dumps(result)) return 0 elif command == "execute": if len(sys.argv) < 5: print(json.dumps({ "status": "error", "error": "Missing arguments: execute " })) return 1 result = execute_installation(sys.argv[2], sys.argv[3], sys.argv[4]) print(json.dumps(result)) return 0 if result["status"] == "success" else 1 elif command == "cleanup": if len(sys.argv) < 3: print(json.dumps({"status": "error", "error": "Missing staging_path"})) return 1 result = cleanup_staging(sys.argv[2]) print(json.dumps(result)) return 0 elif command == "summary": if len(sys.argv) < 5: print(json.dumps({ "status": "error", "error": "Missing arguments: summary " })) return 1 result = generate_summary(sys.argv[2], sys.argv[3], sys.argv[4]) print(json.dumps(result)) return 0 else: print(json.dumps({ "status": "error", "error": f"Unknown command: {command}", "valid_commands": ["check-staging", "analyze", "execute", "cleanup", "summary"] })) return 1 except Exception as e: print(json.dumps({ "status": "error", "error": str(e), "command": command, })) return 1 if __name__ == "__main__": sys.exit(main())