"""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. No code changes are needed to add a new skin. SKIN YAML SCHEMA ================ All fields are optional. Missing values inherit from the ``default`` skin. .. code-block:: yaml # Required: skin identity name: mytheme # Unique skin name (lowercase, hyphens ok) description: Short description # Shown in /skin listing # Colors: hex values for Rich markup (banner, UI, response box) colors: banner_border: "#CD7F32" # Panel border color banner_title: "#FFD700" # Panel title text color banner_accent: "#FFBF00" # Section headers (Available Tools, etc.) banner_dim: "#B8860B" # Dim/muted text (separators, labels) banner_text: "#FFF8DC" # Body text (tool names, skill names) ui_accent: "#FFBF00" # General UI accent ui_label: "#4dd0e1" # UI labels ui_ok: "#4caf50" # Success indicators ui_error: "#ef5350" # Error indicators ui_warn: "#ffa726" # Warning indicators prompt: "#FFF8DC" # Prompt text color input_rule: "#CD7F32" # Input area horizontal rule response_border: "#FFD700" # Response box border (ANSI) session_label: "#DAA520" # Session label color session_border: "#8B8682" # Session ID dim color # Spinner: customize the animated spinner during API calls spinner: waiting_faces: # Faces shown while waiting for API - "(⚔)" - "(⛨)" thinking_faces: # Faces shown during reasoning - "(⌁)" - "(<>)" thinking_verbs: # Verbs for spinner messages - "forging" - "plotting" wings: # Optional left/right spinner decorations - ["⟪⚔", "⚔⟫"] # Each entry is [left, right] pair - ["⟪▲", "▲⟫"] # Branding: text strings used throughout the CLI branding: agent_name: "Hermes Agent" # Banner title, status display welcome: "Welcome message" # Shown at CLI startup goodbye: "Goodbye! ⚕" # Shown on exit response_label: " ⚕ Hermes " # Response box header label prompt_symbol: "❯ " # Input prompt symbol help_header: "(^_^)? Commands" # /help header text # Tool prefix: character for tool output lines (default: ┊) tool_prefix: "┊" USAGE ===== .. code-block:: python 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.get_branding("agent_name")) # "Hermes Agent" set_active_skin("ares") # Switch to built-in ares skin set_active_skin("mytheme") # Switch to user skin from ~/.hermes/skins/ BUILT-IN SKINS ============== - ``default`` — Classic Hermes gold/kawaii (the current look) - ``ares`` — Crimson/bronze war-god theme with custom spinner wings - ``mono`` — Clean grayscale monochrome - ``slate`` — Cool blue developer-focused theme USER SKINS ========== Drop a YAML file in ``~/.hermes/skins/.yaml`` following the schema above. Activate with ``/skin `` in the CLI or ``display.skin: `` in config.yaml. """ 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")