Files
dss/dss-claude-plugin/core/config.py
Bruno Sarlo 4de266de61
Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
Revert "chore: Remove dss-claude-plugin directory"
This reverts commit 72cb7319f5.
2025-12-10 15:54:39 -03:00

162 lines
5.6 KiB
Python

"""
DSS Configuration Module
========================
Handles configuration management for the Design System Server (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