#!/usr/bin/env python3 """ Sync Mode Detector - Intelligent context detection for unified /sync command This module provides automatic detection of sync context based on directory structure and parses command-line flags to override auto-detection. Sync Modes: - GITHUB: Fetch latest from GitHub (default for users) - PLUGIN_DEV: Sync plugin development environment (plugins/autonomous-dev/ exists) - ENVIRONMENT: Sync development environment (.claude/ directory exists) - MARKETPLACE: Update plugin from Claude marketplace - ALL: Execute all sync modes in sequence Auto-Detection Logic: 1. Check for plugins/autonomous-dev/ → PLUGIN_DEV (developer mode) 2. Default → GITHUB (fetch latest from GitHub for users) Security: - All paths validated through security_utils.validate_path() - CWE-22 (path traversal) protection - CWE-59 (symlink) protection - Audit logging for all detections Usage: from sync_mode_detector import detect_sync_mode, parse_sync_flags # Auto-detect mode mode = detect_sync_mode("/path/to/project") # Parse flags mode = parse_sync_flags(["--env", "--force"]) # Full control detector = SyncModeDetector("/path/to/project") mode = detector.detect_mode() reason = detector.get_detection_reason() Date: 2025-11-08 Issue: GitHub #44 - Unified /sync command Agent: implementer """ import os from enum import Enum from pathlib import Path from typing import 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, validate_input_length, ) except ImportError: from security_utils import validate_path, audit_log, validate_input_length class SyncMode(Enum): """Sync mode enumeration for different sync contexts.""" ENVIRONMENT = "environment" MARKETPLACE = "marketplace" PLUGIN_DEV = "plugin-dev" GITHUB = "github" # Fetch latest from GitHub UNINSTALL = "uninstall" # Uninstall plugin completely ALL = "all" class SyncModeError(Exception): """Exception raised for sync mode detection errors.""" pass class SyncModeDetector: """Intelligent sync mode detector with caching and validation. Attributes: project_path: Validated project root path _cached_mode: Cached detection result for performance _detection_reason: Human-readable reason for detected mode """ def __init__( self, project_path: str, explicit_mode: Optional[SyncMode] = None ): """Initialize detector with project path. Args: project_path: Path to project root directory explicit_mode: Optional explicit mode to override auto-detection Raises: ValueError: If path is invalid or fails security validation SyncModeError: If project path doesn't exist or is not a directory """ # Quick check for path traversal patterns (before any file operations) if ".." in str(project_path): raise SyncModeError( f"Invalid path: Path traversal detected in {project_path}\n" f"Paths containing '..' are not allowed\n" f"See: docs/SECURITY.md for path validation rules" ) # Then validate with security_utils (comprehensive security check) try: validated_path = validate_path(project_path, "sync mode detection") self.project_path = Path(validated_path).resolve() except ValueError as e: # If validation failed AND path doesn't exist, give clearer error if not Path(project_path).resolve().exists() and "outside allowed directories" in str(e).lower(): raise SyncModeError( f"Project path does not exist: {project_path}\n" f"Expected: Valid directory path\n" f"See: docs/SYNC-COMMAND.md for usage" ) audit_log( "sync_mode_detection", "failure", { "operation": "init", "project_path": project_path, "error": str(e), }, ) # Re-raise as SyncModeError for consistent API raise SyncModeError( f"Invalid path: {project_path}\n" f"Security validation failed: {str(e)}\n" f"See: docs/SECURITY.md for path validation rules" ) except PermissionError as e: raise SyncModeError( f"Permission denied: {project_path}\n" f"Expected: Read access to directory\n" f"See: docs/SYNC-COMMAND.md for usage" ) # Verify path exists and is a directory try: if not self.project_path.exists(): raise SyncModeError( f"Project path does not exist: {project_path}\n" f"Expected: Valid directory path\n" f"See: docs/SYNC-COMMAND.md for usage" ) if not self.project_path.is_dir(): raise SyncModeError( f"Path must be a directory: {project_path}\n" f"Expected: Directory path, got file\n" f"See: docs/SYNC-COMMAND.md for usage" ) # Check if we can actually read the directory (test for permissions) list(self.project_path.iterdir()) except PermissionError as e: raise SyncModeError( f"Permission denied: {project_path}\n" f"Expected: Read access to directory\n" f"Error: {str(e)}\n" f"See: docs/SYNC-COMMAND.md for usage" ) self._explicit_mode = explicit_mode self._cached_mode: Optional[SyncMode] = None self._detection_reason: Optional[str] = None # Allow test injection of installed_plugins path self._installed_plugins_path: Optional[Path] = None def detect_mode(self) -> SyncMode: """Auto-detect sync mode based on project structure. Detection Priority (highest to lowest): 1. Explicit mode (if provided) → override 2. plugins/autonomous-dev/plugin.json → PLUGIN_DEV 3. .claude/PROJECT.md → ENVIRONMENT 4. ~/.claude/installed_plugins.json → MARKETPLACE 5. Default → ENVIRONMENT Returns: Detected SyncMode enum value Security: - All paths validated before checking existence - Symlinks resolved and validated - Detection logged to audit log """ # Return cached result if available if self._cached_mode is not None: return self._cached_mode # Check for explicit mode override (highest priority) if self._explicit_mode is not None: self._cached_mode = self._explicit_mode self._detection_reason = f"Explicit mode: {self._explicit_mode.value}" return self._explicit_mode # Delegate to filesystem scan detected_mode = self._scan_filesystem() # Cache result self._cached_mode = detected_mode # Audit log audit_log( "sync_mode_detection", "success", { "operation": "detect_mode", "project_path": str(self.project_path), "detected_mode": detected_mode.value, "reason": self._detection_reason, "user": os.getenv("USER", "unknown"), }, ) return detected_mode def _scan_filesystem(self) -> SyncMode: """Scan filesystem to detect sync mode. Returns: Detected SyncMode enum value Note: This is separate from detect_mode() to allow mocking in tests. Detection Priority: 1. plugins/autonomous-dev/ exists → PLUGIN_DEV (developer mode) 2. Default → GITHUB (fetch latest from GitHub for users) The GITHUB default ensures users can always update to latest without needing to be in the autonomous-dev repository. """ detected_mode = SyncMode.GITHUB # Default: fetch from GitHub reason = "Default (fetching latest from GitHub)" # Check for plugin development context (highest priority) # Only developers working on autonomous-dev repo itself use PLUGIN_DEV plugin_dir = self.project_path / "plugins" / "autonomous-dev" if plugin_dir.exists() and plugin_dir.is_dir(): detected_mode = SyncMode.PLUGIN_DEV reason = f"Plugin directory detected: {plugin_dir}" self._detection_reason = reason return detected_mode def reset_cache(self) -> None: """Reset cached detection result. Useful when project structure changes during execution. """ self._cached_mode = None self._detection_reason = None def get_detection_reason(self) -> str: """Get human-readable reason for detected mode. Returns: Description of why mode was detected Raises: RuntimeError: If detect_mode() hasn't been called yet """ if self._detection_reason is None: raise RuntimeError( "Detection reason not available. Call detect_mode() first." ) return self._detection_reason def parse_sync_flags(flags: Optional[List[str]]) -> Optional[SyncMode]: """Parse command-line flags to determine sync mode. Supported Flags: - --env: Force environment sync - --marketplace: Force marketplace sync - --plugin-dev: Force plugin development sync - --all: Execute all sync modes Args: flags: List of command-line flag strings Returns: SyncMode enum if flag matched, None if no flags or empty list Raises: SyncModeError: If flags conflict or contain unknown values ValueError: If flag validation fails (length, format, etc.) Security: - Flag length limited to prevent DoS - Only allow known flag values (whitelist) - Log all flag parsing attempts """ # Handle None or empty flags if flags is None or len(flags) == 0: return None # Validate flag list is actually a list if not isinstance(flags, list): raise ValueError( f"Flags must be a list, got {type(flags).__name__}\n" f"Expected: List[str] (e.g., ['--env'])\n" f"See: /sync --help for usage" ) # Validate each flag validated_flags = [] for flag in flags: # Type check if not isinstance(flag, str): raise ValueError( f"Flag must be string, got {type(flag).__name__}: {flag}\n" f"Expected: String starting with '--'\n" f"See: /sync --help for usage" ) # Length check (prevent DoS) validate_input_length(flag, 100, "sync flag") validated_flags.append(flag) # Map flags to modes flag_map = { "--env": SyncMode.ENVIRONMENT, "--marketplace": SyncMode.MARKETPLACE, "--plugin-dev": SyncMode.PLUGIN_DEV, "--github": SyncMode.GITHUB, "--all": SyncMode.ALL, } # Find matching flags matched_modes = [] for flag in validated_flags: if flag in flag_map: matched_modes.append((flag, flag_map[flag])) else: # Unknown flag - ensure lowercase for test compatibility raise SyncModeError( f"Unknown flag: {flag}\n" f"Expected: {', '.join(flag_map.keys())}\n" f"See: /sync --help for usage" ) # Check for conflicts if len(matched_modes) == 0: return None if len(matched_modes) > 1: # Check if --all is mixed with specific flags flag_names = [f for f, m in matched_modes] if "--all" in flag_names: raise SyncModeError( f"Flag --all cannot be combined with specific flags: {', '.join(flag_names)}\n" f"Expected: Either --all OR specific flags (not both)\n" f"See: /sync --help for usage" ) else: raise SyncModeError( f"Conflicting sync flags: {', '.join(flag_names)}\n" f"Expected: Only one flag (or --all)\n" f"See: /sync --help for usage" ) # Return the single matched mode flag, mode = matched_modes[0] # Audit log audit_log( "sync_flag_parsing", "success", { "operation": "parse_flags", "flags": validated_flags, "matched_mode": mode.value, "user": os.getenv("USER", "unknown"), }, ) return mode def detect_sync_mode( project_path: str, flags: Optional[List[str]] = None ) -> SyncMode: """Convenience function to detect sync mode with optional flag override. Args: project_path: Path to project root flags: Optional command-line flags to override detection Returns: SyncMode enum value Raises: ValueError: If path or flags are invalid SyncModeError: If detection fails Example: >>> mode = detect_sync_mode("/path/to/project") >>> mode = detect_sync_mode("/path/to/project", ["--env"]) """ # Try flag parsing first if flags: flag_mode = parse_sync_flags(flags) if flag_mode is not None: return flag_mode # Fall back to auto-detection detector = SyncModeDetector(project_path) return detector.detect_mode() def get_all_sync_modes() -> List[SyncMode]: """Get list of all sync modes including ALL. Returns: List of all SyncMode enum values Usage: For programmatic access to all available modes """ return [SyncMode.GITHUB, SyncMode.ENVIRONMENT, SyncMode.MARKETPLACE, SyncMode.PLUGIN_DEV, SyncMode.ALL] def get_individual_sync_modes() -> List[SyncMode]: """Get list of individual sync modes (excludes ALL). Returns: List of individual SyncMode values for sequential execution Usage: Used by dispatcher to execute ALL mode in sequence """ return [SyncMode.GITHUB, SyncMode.ENVIRONMENT, SyncMode.MARKETPLACE, SyncMode.PLUGIN_DEV]