TradingAgents/.claude/lib/sync_mode_detector.py

441 lines
14 KiB
Python

#!/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]