"""Hermes CLI skin/theme engine. A data-driven skin system that lets users customize the CLI's visual appearance. Skins are defined as YAML files in ~/.hermes/skins/ or as built-in presets. Each skin defines: - colors: banner and UI color palette (hex values for Rich markup) - spinner: kawaii faces, thinking verbs, optional wings - branding: agent name, welcome/goodbye messages, prompt symbol - tool_prefix: character used for tool output lines (default: ┊) Usage: from hermes_cli.skin_engine import get_active_skin, list_skins, set_active_skin skin = get_active_skin() print(skin.colors["banner_title"]) # "#FFD700" print(skin.spinner["thinking_verbs"]) # ["pondering", ...] """ import logging import os from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional, Tuple logger = logging.getLogger(__name__) # ============================================================================= # Skin data structure # ============================================================================= @dataclass class SkinConfig: """Complete skin configuration.""" name: str description: str = "" colors: Dict[str, str] = field(default_factory=dict) spinner: Dict[str, Any] = field(default_factory=dict) branding: Dict[str, str] = field(default_factory=dict) tool_prefix: str = "┊" def get_color(self, key: str, fallback: str = "") -> str: """Get a color value with fallback.""" return self.colors.get(key, fallback) def get_spinner_list(self, key: str) -> List[str]: """Get a spinner list (faces, verbs, etc.).""" return self.spinner.get(key, []) def get_spinner_wings(self) -> List[Tuple[str, str]]: """Get spinner wing pairs, or empty list if none.""" raw = self.spinner.get("wings", []) result = [] for pair in raw: if isinstance(pair, (list, tuple)) and len(pair) == 2: result.append((str(pair[0]), str(pair[1]))) return result def get_branding(self, key: str, fallback: str = "") -> str: """Get a branding value with fallback.""" return self.branding.get(key, fallback) # ============================================================================= # Built-in skin definitions # ============================================================================= _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "default": { "name": "default", "description": "Classic Hermes — gold and kawaii", "colors": { "banner_border": "#CD7F32", "banner_title": "#FFD700", "banner_accent": "#FFBF00", "banner_dim": "#B8860B", "banner_text": "#FFF8DC", "ui_accent": "#FFBF00", "ui_label": "#4dd0e1", "ui_ok": "#4caf50", "ui_error": "#ef5350", "ui_warn": "#ffa726", "prompt": "#FFF8DC", "input_rule": "#CD7F32", "response_border": "#FFD700", "session_label": "#DAA520", "session_border": "#8B8682", }, "spinner": { # Empty = use hardcoded defaults in display.py }, "branding": { "agent_name": "Hermes Agent", "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", "goodbye": "Goodbye! ⚕", "response_label": " ⚕ Hermes ", "prompt_symbol": "❯ ", "help_header": "(^_^)? Available Commands", }, "tool_prefix": "┊", }, "ares": { "name": "ares", "description": "War-god theme — crimson and bronze", "colors": { "banner_border": "#9F1C1C", "banner_title": "#C7A96B", "banner_accent": "#DD4A3A", "banner_dim": "#6B1717", "banner_text": "#F1E6CF", "ui_accent": "#DD4A3A", "ui_label": "#C7A96B", "ui_ok": "#4caf50", "ui_error": "#ef5350", "ui_warn": "#ffa726", "prompt": "#F1E6CF", "input_rule": "#9F1C1C", "response_border": "#C7A96B", "session_label": "#C7A96B", "session_border": "#6E584B", }, "spinner": { "waiting_faces": ["(⚔)", "(⛨)", "(▲)", "(<>)", "(/)"], "thinking_faces": ["(⚔)", "(⛨)", "(▲)", "(⌁)", "(<>)"], "thinking_verbs": [ "forging", "marching", "sizing the field", "holding the line", "hammering plans", "tempering steel", "plotting impact", "raising the shield", ], "wings": [ ["⟪⚔", "⚔⟫"], ["⟪▲", "▲⟫"], ["⟪╸", "╺⟫"], ["⟪⛨", "⛨⟫"], ], }, "branding": { "agent_name": "Ares Agent", "welcome": "Welcome to Ares Agent! Type your message or /help for commands.", "goodbye": "Farewell, warrior! ⚔", "response_label": " ⚔ Ares ", "prompt_symbol": "⚔ ❯ ", "help_header": "(⚔) Available Commands", }, "tool_prefix": "╎", }, "mono": { "name": "mono", "description": "Monochrome — clean grayscale", "colors": { "banner_border": "#555555", "banner_title": "#e6edf3", "banner_accent": "#aaaaaa", "banner_dim": "#444444", "banner_text": "#c9d1d9", "ui_accent": "#aaaaaa", "ui_label": "#888888", "ui_ok": "#888888", "ui_error": "#cccccc", "ui_warn": "#999999", "prompt": "#c9d1d9", "input_rule": "#444444", "response_border": "#aaaaaa", "session_label": "#888888", "session_border": "#555555", }, "spinner": {}, "branding": { "agent_name": "Hermes Agent", "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", "goodbye": "Goodbye! ⚕", "response_label": " ⚕ Hermes ", "prompt_symbol": "❯ ", "help_header": "[?] Available Commands", }, "tool_prefix": "┊", }, "slate": { "name": "slate", "description": "Cool blue — developer-focused", "colors": { "banner_border": "#4169e1", "banner_title": "#7eb8f6", "banner_accent": "#8EA8FF", "banner_dim": "#4b5563", "banner_text": "#c9d1d9", "ui_accent": "#7eb8f6", "ui_label": "#8EA8FF", "ui_ok": "#63D0A6", "ui_error": "#F7A072", "ui_warn": "#e6a855", "prompt": "#c9d1d9", "input_rule": "#4169e1", "response_border": "#7eb8f6", "session_label": "#7eb8f6", "session_border": "#4b5563", }, "spinner": {}, "branding": { "agent_name": "Hermes Agent", "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", "goodbye": "Goodbye! ⚕", "response_label": " ⚕ Hermes ", "prompt_symbol": "❯ ", "help_header": "(^_^)? Available Commands", }, "tool_prefix": "┊", }, } # ============================================================================= # Skin loading and management # ============================================================================= _active_skin: Optional[SkinConfig] = None _active_skin_name: str = "default" def _skins_dir() -> Path: """User skins directory.""" home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) return home / "skins" def _load_skin_from_yaml(path: Path) -> Optional[Dict[str, Any]]: """Load a skin definition from a YAML file.""" try: import yaml with open(path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) if isinstance(data, dict) and "name" in data: return data except Exception as e: logger.debug("Failed to load skin from %s: %s", path, e) return None def _build_skin_config(data: Dict[str, Any]) -> SkinConfig: """Build a SkinConfig from a raw dict (built-in or loaded from YAML).""" # Start with default values as base for missing keys default = _BUILTIN_SKINS["default"] colors = dict(default.get("colors", {})) colors.update(data.get("colors", {})) spinner = dict(default.get("spinner", {})) spinner.update(data.get("spinner", {})) branding = dict(default.get("branding", {})) branding.update(data.get("branding", {})) return SkinConfig( name=data.get("name", "unknown"), description=data.get("description", ""), colors=colors, spinner=spinner, branding=branding, tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "┊")), ) def list_skins() -> List[Dict[str, str]]: """List all available skins (built-in + user-installed). Returns list of {"name": ..., "description": ..., "source": "builtin"|"user"}. """ result = [] for name, data in _BUILTIN_SKINS.items(): result.append({ "name": name, "description": data.get("description", ""), "source": "builtin", }) skins_path = _skins_dir() if skins_path.is_dir(): for f in sorted(skins_path.glob("*.yaml")): data = _load_skin_from_yaml(f) if data: skin_name = data.get("name", f.stem) # Skip if it shadows a built-in if any(s["name"] == skin_name for s in result): continue result.append({ "name": skin_name, "description": data.get("description", ""), "source": "user", }) return result def load_skin(name: str) -> SkinConfig: """Load a skin by name. Checks user skins first, then built-in.""" # Check user skins directory skins_path = _skins_dir() user_file = skins_path / f"{name}.yaml" if user_file.is_file(): data = _load_skin_from_yaml(user_file) if data: return _build_skin_config(data) # Check built-in skins if name in _BUILTIN_SKINS: return _build_skin_config(_BUILTIN_SKINS[name]) # Fallback to default logger.warning("Skin '%s' not found, using default", name) return _build_skin_config(_BUILTIN_SKINS["default"]) def get_active_skin() -> SkinConfig: """Get the currently active skin config (cached).""" global _active_skin if _active_skin is None: _active_skin = load_skin(_active_skin_name) return _active_skin def set_active_skin(name: str) -> SkinConfig: """Switch the active skin. Returns the new SkinConfig.""" global _active_skin, _active_skin_name _active_skin_name = name _active_skin = load_skin(name) return _active_skin def get_active_skin_name() -> str: """Get the name of the currently active skin.""" return _active_skin_name def init_skin_from_config(config: dict) -> None: """Initialize the active skin from CLI config at startup. Call this once during CLI init with the loaded config dict. """ display = config.get("display", {}) skin_name = display.get("skin", "default") if isinstance(skin_name, str) and skin_name.strip(): set_active_skin(skin_name.strip()) else: set_active_skin("default")