- AGENTS.md: add Skin/Theme System section with architecture, skinnable elements table, built-in skins list, adding built-in/user skins guide, YAML example; add skin_engine.py to project structure; mention skin engine in CLI Architecture section - CONTRIBUTING.md: add skin_engine.py to project structure; add 'Adding a Skin/Theme' section with YAML schema, activation instructions - cli-config.yaml.example: add full skin config documentation with schema reference, built-in skins list, all color/spinner/branding keys - docs/skins/example-skin.yaml: complete annotated skin template with all available fields and inline documentation - hermes_cli/skin_engine.py: expand module docstring to full schema reference with all fields documented, usage examples, built-in skins list
414 lines
14 KiB
Python
414 lines
14 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.
|
||
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/<name>.yaml`` following the schema above.
|
||
Activate with ``/skin <name>`` in the CLI or ``display.skin: <name>`` 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")
|