""" DSS Configuration Module ======================== Handles configuration management for the Design System Swarm (DSS) Claude Plugin. Supports local/remote mode detection, persistent configuration storage, and environment variable overrides. """ import os import json import uuid import asyncio import logging from enum import Enum from pathlib import Path from typing import Optional, Union, Any import aiohttp from pydantic import BaseModel, Field, HttpUrl, ValidationError # Configure module-level logger logger = logging.getLogger(__name__) CONFIG_DIR = Path.home() / ".dss" CONFIG_FILE = CONFIG_DIR / "config.json" DEFAULT_REMOTE_URL = "https://dss.overbits.luz.uy" DEFAULT_LOCAL_URL = "http://localhost:6006" class DSSMode(str, Enum): """Operation modes for the DSS plugin.""" LOCAL = "local" REMOTE = "remote" AUTO = "auto" class DSSConfig(BaseModel): """ Configuration model for DSS Plugin. Attributes: mode (DSSMode): The configured operation mode (default: AUTO). remote_url (str): URL for the remote DSS API. local_url (str): URL for the local DSS API (usually localhost). session_id (str): Unique identifier for this client instance. """ mode: DSSMode = Field(default=DSSMode.AUTO, description="Operation mode preference") remote_url: str = Field(default=DEFAULT_REMOTE_URL, description="Remote API endpoint") local_url: str = Field(default=DEFAULT_LOCAL_URL, description="Local API endpoint") session_id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Persistent session ID") class Config: validate_assignment = True extra = "ignore" # Allow forward compatibility with new config keys @classmethod def load(cls) -> "DSSConfig": """ Load configuration from ~/.dss/config.json. Returns a default instance if the file does not exist or is invalid. """ if not CONFIG_FILE.exists(): logger.debug(f"No config found at {CONFIG_FILE}, using defaults.") return cls() try: content = CONFIG_FILE.read_text(encoding="utf-8") data = json.loads(content) # Ensure complex types are handled by Pydantic validation return cls.model_validate(data) except (json.JSONDecodeError, ValidationError) as e: logger.warning(f"Failed to load config from {CONFIG_FILE}: {e}. Using defaults.") return cls() except Exception as e: logger.error(f"Unexpected error loading config: {e}") return cls() def save(self) -> None: """ Save the current configuration to ~/.dss/config.json. Creates the directory if it does not exist. """ try: CONFIG_DIR.mkdir(parents=True, exist_ok=True) # Export using mode='json' to handle enums and urls correctly json_data = self.model_dump_json(indent=2) CONFIG_FILE.write_text(json_data, encoding="utf-8") logger.debug(f"Configuration saved to {CONFIG_FILE}") except Exception as e: logger.error(f"Failed to save config to {CONFIG_FILE}: {e}") raise async def get_active_mode(self) -> DSSMode: """ Determine the actual runtime mode based on priority rules. Priority: 1. DSS_MODE environment variable 2. Configured 'mode' (if not AUTO) 3. Auto-detection (ping local health endpoint) 4. Fallback to REMOTE Returns: DSSMode: The resolved active mode (LOCAL or REMOTE). """ # 1. Check Environment Variable env_mode = os.getenv("DSS_MODE") if env_mode: try: # Normalize string to enum return DSSMode(env_mode.lower()) except ValueError: logger.warning(f"Invalid DSS_MODE env var '{env_mode}', ignoring.") # 2. Check Configuration (if explicit) if self.mode != DSSMode.AUTO: return self.mode # 3. Auto-detect logger.info("Auto-detecting DSS mode...") is_local_healthy = await self._check_local_health() if is_local_healthy: logger.info(f"Local server detected at {self.local_url}. Switching to LOCAL mode.") return DSSMode.LOCAL else: logger.info("Local server unreachable. Fallback to REMOTE mode.") # 4. Fallback return DSSMode.REMOTE async def _check_local_health(self) -> bool: """ Ping the local server health endpoint to check availability. Returns: bool: True if server responds with 200 OK, False otherwise. """ health_url = f"{self.local_url.rstrip('/')}/health" try: timeout = aiohttp.ClientTimeout(total=2.0) # Short timeout for responsiveness async with aiohttp.ClientSession(timeout=timeout) as session: async with session.get(health_url) as response: if response.status == 200: return True logger.debug(f"Local health check returned status {response.status}") except aiohttp.ClientError as e: logger.debug(f"Local health check connection failed: {e}") except Exception as e: logger.debug(f"Unexpected error during health check: {e}") return False def get_api_url(self, active_mode: DSSMode) -> str: """ Helper to get the correct API URL for the determined mode. """ if active_mode == DSSMode.LOCAL: return self.local_url return self.remote_url