Adds 3 new built-in skins (poseidon, sisyphus, charizard) with full customization — colors, spinner faces/verbs/wings, branding text, and custom ASCII art banner logos. Total: 7 built-in skins. Also adds banner_logo and banner_hero fields to SkinConfig, allowing any skin to replace the HERMES-AGENT ASCII art logo and the caduceus hero art with custom artwork. The CLI now renders the skin's logo when available, falling back to the default Hermes logo. Skins with custom logos: ares, poseidon, sisyphus, charizard Skins using default logo: default, mono, slate
577 lines
27 KiB
Python
577 lines
27 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 = "┊"
|
||
banner_logo: str = "" # Rich-markup ASCII art logo (replaces HERMES_AGENT_LOGO)
|
||
banner_hero: str = "" # Rich-markup hero art (replaces HERMES_CADUCEUS)
|
||
|
||
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": "╎",
|
||
"banner_logo": """[bold #A3261F] █████╗ ██████╗ ███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
|
||
[bold #B73122]██╔══██╗██╔══██╗██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
|
||
[#C93C24]███████║██████╔╝█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
|
||
[#D84A28]██╔══██║██╔══██╗██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
|
||
[#E15A2D]██║ ██║██║ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
|
||
[#EB6C32]╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""",
|
||
},
|
||
"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": "┊",
|
||
},
|
||
"poseidon": {
|
||
"name": "poseidon",
|
||
"description": "Ocean-god theme — deep blue and seafoam",
|
||
"colors": {
|
||
"banner_border": "#2A6FB9",
|
||
"banner_title": "#A9DFFF",
|
||
"banner_accent": "#5DB8F5",
|
||
"banner_dim": "#153C73",
|
||
"banner_text": "#EAF7FF",
|
||
"ui_accent": "#5DB8F5",
|
||
"ui_label": "#A9DFFF",
|
||
"ui_ok": "#4caf50",
|
||
"ui_error": "#ef5350",
|
||
"ui_warn": "#ffa726",
|
||
"prompt": "#EAF7FF",
|
||
"input_rule": "#2A6FB9",
|
||
"response_border": "#5DB8F5",
|
||
"session_label": "#A9DFFF",
|
||
"session_border": "#496884",
|
||
},
|
||
"spinner": {
|
||
"waiting_faces": ["(≈)", "(Ψ)", "(∿)", "(◌)", "(◠)"],
|
||
"thinking_faces": ["(Ψ)", "(∿)", "(≈)", "(⌁)", "(◌)"],
|
||
"thinking_verbs": [
|
||
"charting currents", "sounding the depth", "reading foam lines",
|
||
"steering the trident", "tracking undertow", "plotting sea lanes",
|
||
"calling the swell", "measuring pressure",
|
||
],
|
||
"wings": [
|
||
["⟪≈", "≈⟫"],
|
||
["⟪Ψ", "Ψ⟫"],
|
||
["⟪∿", "∿⟫"],
|
||
["⟪◌", "◌⟫"],
|
||
],
|
||
},
|
||
"branding": {
|
||
"agent_name": "Poseidon Agent",
|
||
"welcome": "Welcome to Poseidon Agent! Type your message or /help for commands.",
|
||
"goodbye": "Fair winds! Ψ",
|
||
"response_label": " Ψ Poseidon ",
|
||
"prompt_symbol": "Ψ ❯ ",
|
||
"help_header": "(Ψ) Available Commands",
|
||
},
|
||
"tool_prefix": "│",
|
||
"banner_logo": """[bold #B8E8FF]██████╗ ██████╗ ███████╗██╗██████╗ ███████╗ ██████╗ ███╗ ██╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
|
||
[bold #97D6FF]██╔══██╗██╔═══██╗██╔════╝██║██╔══██╗██╔════╝██╔═══██╗████╗ ██║ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
|
||
[#75C1F6]██████╔╝██║ ██║███████╗██║██║ ██║█████╗ ██║ ██║██╔██╗ ██║█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
|
||
[#4FA2E0]██╔═══╝ ██║ ██║╚════██║██║██║ ██║██╔══╝ ██║ ██║██║╚██╗██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
|
||
[#2E7CC7]██║ ╚██████╔╝███████║██║██████╔╝███████╗╚██████╔╝██║ ╚████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
|
||
[#1B4F95]╚═╝ ╚═════╝ ╚══════╝╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""",
|
||
},
|
||
"sisyphus": {
|
||
"name": "sisyphus",
|
||
"description": "Sisyphean theme — austere grayscale with persistence",
|
||
"colors": {
|
||
"banner_border": "#B7B7B7",
|
||
"banner_title": "#F5F5F5",
|
||
"banner_accent": "#E7E7E7",
|
||
"banner_dim": "#4A4A4A",
|
||
"banner_text": "#D3D3D3",
|
||
"ui_accent": "#E7E7E7",
|
||
"ui_label": "#D3D3D3",
|
||
"ui_ok": "#919191",
|
||
"ui_error": "#E7E7E7",
|
||
"ui_warn": "#B7B7B7",
|
||
"prompt": "#F5F5F5",
|
||
"input_rule": "#656565",
|
||
"response_border": "#B7B7B7",
|
||
"session_label": "#919191",
|
||
"session_border": "#656565",
|
||
},
|
||
"spinner": {
|
||
"waiting_faces": ["(◉)", "(◌)", "(◬)", "(⬤)", "(::)"],
|
||
"thinking_faces": ["(◉)", "(◬)", "(◌)", "(○)", "(●)"],
|
||
"thinking_verbs": [
|
||
"finding traction", "measuring the grade", "resetting the boulder",
|
||
"counting the ascent", "testing leverage", "setting the shoulder",
|
||
"pushing uphill", "enduring the loop",
|
||
],
|
||
"wings": [
|
||
["⟪◉", "◉⟫"],
|
||
["⟪◬", "◬⟫"],
|
||
["⟪◌", "◌⟫"],
|
||
["⟪⬤", "⬤⟫"],
|
||
],
|
||
},
|
||
"branding": {
|
||
"agent_name": "Sisyphus Agent",
|
||
"welcome": "Welcome to Sisyphus Agent! Type your message or /help for commands.",
|
||
"goodbye": "The boulder waits. ◉",
|
||
"response_label": " ◉ Sisyphus ",
|
||
"prompt_symbol": "◉ ❯ ",
|
||
"help_header": "(◉) Available Commands",
|
||
},
|
||
"tool_prefix": "│",
|
||
"banner_logo": """[bold #F5F5F5]███████╗██╗███████╗██╗ ██╗██████╗ ██╗ ██╗██╗ ██╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
|
||
[bold #E7E7E7]██╔════╝██║██╔════╝╚██╗ ██╔╝██╔══██╗██║ ██║██║ ██║██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
|
||
[#D7D7D7]███████╗██║███████╗ ╚████╔╝ ██████╔╝███████║██║ ██║███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
|
||
[#BFBFBF]╚════██║██║╚════██║ ╚██╔╝ ██╔═══╝ ██╔══██║██║ ██║╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
|
||
[#8F8F8F]███████║██║███████║ ██║ ██║ ██║ ██║╚██████╔╝███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
|
||
[#626262]╚══════╝╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""",
|
||
},
|
||
"charizard": {
|
||
"name": "charizard",
|
||
"description": "Volcanic theme — burnt orange and ember",
|
||
"colors": {
|
||
"banner_border": "#C75B1D",
|
||
"banner_title": "#FFD39A",
|
||
"banner_accent": "#F29C38",
|
||
"banner_dim": "#7A3511",
|
||
"banner_text": "#FFF0D4",
|
||
"ui_accent": "#F29C38",
|
||
"ui_label": "#FFD39A",
|
||
"ui_ok": "#4caf50",
|
||
"ui_error": "#ef5350",
|
||
"ui_warn": "#ffa726",
|
||
"prompt": "#FFF0D4",
|
||
"input_rule": "#C75B1D",
|
||
"response_border": "#F29C38",
|
||
"session_label": "#FFD39A",
|
||
"session_border": "#6C4724",
|
||
},
|
||
"spinner": {
|
||
"waiting_faces": ["(✦)", "(▲)", "(◇)", "(<>)", "(🔥)"],
|
||
"thinking_faces": ["(✦)", "(▲)", "(◇)", "(⌁)", "(🔥)"],
|
||
"thinking_verbs": [
|
||
"banking into the draft", "measuring burn", "reading the updraft",
|
||
"tracking ember fall", "setting wing angle", "holding the flame core",
|
||
"plotting a hot landing", "coiling for lift",
|
||
],
|
||
"wings": [
|
||
["⟪✦", "✦⟫"],
|
||
["⟪▲", "▲⟫"],
|
||
["⟪◌", "◌⟫"],
|
||
["⟪◇", "◇⟫"],
|
||
],
|
||
},
|
||
"branding": {
|
||
"agent_name": "Charizard Agent",
|
||
"welcome": "Welcome to Charizard Agent! Type your message or /help for commands.",
|
||
"goodbye": "Flame out! ✦",
|
||
"response_label": " ✦ Charizard ",
|
||
"prompt_symbol": "✦ ❯ ",
|
||
"help_header": "(✦) Available Commands",
|
||
},
|
||
"tool_prefix": "│",
|
||
"banner_logo": """[bold #FFF0D4] ██████╗██╗ ██╗ █████╗ ██████╗ ██╗███████╗ █████╗ ██████╗ ██████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
|
||
[bold #FFD39A]██╔════╝██║ ██║██╔══██╗██╔══██╗██║╚══███╔╝██╔══██╗██╔══██╗██╔══██╗ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
|
||
[#F29C38]██║ ███████║███████║██████╔╝██║ ███╔╝ ███████║██████╔╝██║ ██║█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
|
||
[#E2832B]██║ ██╔══██║██╔══██║██╔══██╗██║ ███╔╝ ██╔══██║██╔══██╗██║ ██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
|
||
[#C75B1D]╚██████╗██║ ██║██║ ██║██║ ██║██║███████╗██║ ██║██║ ██║██████╔╝ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
|
||
[#7A3511] ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""",
|
||
},
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 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", "┊")),
|
||
banner_logo=data.get("banner_logo", ""),
|
||
banner_hero=data.get("banner_hero", ""),
|
||
)
|
||
|
||
|
||
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")
|