342 lines
11 KiB
Python
342 lines
11 KiB
Python
|
|
"""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")
|