Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
This reverts commit 72cb7319f5.
162 lines
5.6 KiB
Python
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
|