"""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: "┊" # Tool emojis: override the default emoji for any tool (used in spinners & progress) tool_emojis: terminal: "⚔" # Override terminal tool emoji web_search: "🔮" # Override web_search tool emoji # Any tool not listed here uses its registry default 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 from hermes_constants import get_hermes_home 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 = "┊" tool_emojis: Dict[str, str] = field(default_factory=dict) # per-tool emoji overrides 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]╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""", "banner_hero": """[#9F1C1C]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣤⣤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] [#9F1C1C]⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⠟⠻⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] [#C7A96B]⠀⠀⠀⠀⠀⠀⠀⣠⣾⡿⠋⠀⠀⠀⠙⢿⣷⣄⠀⠀⠀⠀⠀⠀⠀[/] [#C7A96B]⠀⠀⠀⠀⠀⢀⣾⡿⠋⠀⠀⢠⡄⠀⠀⠙⢿⣷⡀⠀⠀⠀⠀⠀[/] [#DD4A3A]⠀⠀⠀⠀⣰⣿⠟⠀⠀⠀⣰⣿⣿⣆⠀⠀⠀⠻⣿⣆⠀⠀⠀⠀[/] [#DD4A3A]⠀⠀⠀⢰⣿⠏⠀⠀⢀⣾⡿⠉⢿⣷⡀⠀⠀⠹⣿⡆⠀⠀⠀[/] [#9F1C1C]⠀⠀⠀⣿⡟⠀⠀⣠⣿⠟⠀⠀⠀⠻⣿⣄⠀⠀⢻⣿⠀⠀⠀[/] [#9F1C1C]⠀⠀⠀⣿⡇⠀⠀⠙⠋⠀⠀⚔⠀⠀⠙⠋⠀⠀⢸⣿⠀⠀⠀[/] [#6B1717]⠀⠀⠀⢿⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⡿⠀⠀⠀[/] [#6B1717]⠀⠀⠀⠘⢿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⡿⠃⠀⠀⠀[/] [#C7A96B]⠀⠀⠀⠀⠈⠻⣿⣷⣦⣤⣀⣀⣤⣤⣶⣿⠿⠋⠀⠀⠀⠀[/] [#C7A96B]⠀⠀⠀⠀⠀⠀⠀⠉⠛⠿⠿⠿⠿⠛⠉⠀⠀⠀⠀⠀⠀⠀[/] [#DD4A3A]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⚔⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] [dim #6B1717]⠀⠀⠀⠀⠀⠀⠀⠀war god online⠀⠀⠀⠀⠀⠀⠀⠀[/]""", }, "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]╚═╝ ╚═════╝ ╚══════╝╚══════╝╚═╝╚═════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""", "banner_hero": """[#2A6FB9]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] [#5DB8F5]⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] [#5DB8F5]⠀⠀⠀⠀⠀⠀⠀⢠⣿⠏⠀Ψ⠀⠹⣿⡄⠀⠀⠀⠀⠀⠀⠀[/] [#A9DFFF]⠀⠀⠀⠀⠀⠀⠀⣿⡟⠀⠀⠀⠀⠀⢻⣿⠀⠀⠀⠀⠀⠀⠀[/] [#A9DFFF]⠀⠀⠀≈≈≈≈≈⣿⡇⠀⠀⠀⠀⠀⢸⣿≈≈≈≈≈⠀⠀⠀[/] [#5DB8F5]⠀⠀⠀⠀⠀⠀⠀⣿⡇⠀⠀⠀⠀⠀⢸⣿⠀⠀⠀⠀⠀⠀⠀[/] [#2A6FB9]⠀⠀⠀⠀⠀⠀⠀⢿⣧⠀⠀⠀⠀⠀⣼⡿⠀⠀⠀⠀⠀⠀⠀[/] [#2A6FB9]⠀⠀⠀⠀⠀⠀⠀⠘⢿⣷⣄⣀⣠⣾⡿⠃⠀⠀⠀⠀⠀⠀⠀[/] [#153C73]⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⣿⣿⡿⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀[/] [#153C73]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] [#5DB8F5]⠀⠀⠀⠀⠀≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈⠀⠀⠀⠀⠀[/] [#A9DFFF]⠀⠀⠀⠀⠀⠀≈≈≈≈≈≈≈≈≈≈≈≈≈⠀⠀⠀⠀⠀⠀[/] [dim #153C73]⠀⠀⠀⠀⠀⠀⠀deep waters hold⠀⠀⠀⠀⠀⠀⠀[/]""", }, "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]╚══════╝╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""", "banner_hero": """[#B7B7B7]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] [#D3D3D3]⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀[/] [#E7E7E7]⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⠀⠀[/] [#F5F5F5]⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀[/] [#E7E7E7]⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀[/] [#D3D3D3]⠀⠀⠀⠀⠀⠀⠘⢿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀[/] [#B7B7B7]⠀⠀⠀⠀⠀⠀⠀⠀⠙⠿⣿⠿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] [#919191]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] [#656565]⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] [#656565]⠀⠀⠀⠀⠀⠀⠀⠀⣰⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] [#4A4A4A]⠀⠀⠀⠀⠀⠀⠀⣰⣿⣿⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] [#4A4A4A]⠀⠀⠀⠀⠀⣀⣴⣿⣿⣿⣿⣿⣿⣦⣀⠀⠀⠀⠀⠀⠀[/] [#656565]⠀⠀⠀━━━━━━━━━━━━━━━━━━━━━━━⠀⠀⠀[/] [dim #4A4A4A]⠀⠀⠀⠀⠀⠀⠀⠀⠀the boulder⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]""", }, "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] ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""", "banner_hero": """[#FFD39A]⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⠶⠶⠶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀[/] [#F29C38]⠀⠀⠀⠀⠀⠀⣴⠟⠁⠀⠀⠀⠀⠈⠻⣦⠀⠀⠀⠀⠀⠀[/] [#F29C38]⠀⠀⠀⠀⠀⣼⠏⠀⠀⠀✦⠀⠀⠀⠀⠹⣧⠀⠀⠀⠀⠀[/] [#E2832B]⠀⠀⠀⠀⢰⡟⠀⠀⣀⣤⣤⣤⣀⠀⠀⠀⢻⡆⠀⠀⠀⠀[/] [#E2832B]⠀⠀⣠⡾⠛⠁⣠⣾⠟⠉⠀⠉⠻⣷⣄⠀⠈⠛⢷⣄⠀⠀[/] [#C75B1D]⠀⣼⠟⠀⢀⣾⠟⠁⠀⠀⠀⠀⠀⠈⠻⣷⡀⠀⠻⣧⠀[/] [#C75B1D]⢸⡟⠀⠀⣿⡟⠀⠀⠀🔥⠀⠀⠀⠀⢻⣿⠀⠀⢻⡇[/] [#7A3511]⠀⠻⣦⡀⠘⢿⣧⡀⠀⠀⠀⠀⠀⢀⣼⡿⠃⢀⣴⠟⠀[/] [#7A3511]⠀⠀⠈⠻⣦⣀⠙⢿⣷⣤⣤⣤⣾⡿⠋⣀⣴⠟⠁⠀⠀[/] [#C75B1D]⠀⠀⠀⠀⠈⠙⠛⠶⠤⠭⠭⠤⠶⠛⠋⠁⠀⠀⠀⠀[/] [#F29C38]⠀⠀⠀⠀⠀⠀⠀⠀⣰⡿⢿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] [#F29C38]⠀⠀⠀⠀⠀⠀⠀⣼⡟⠀⠀⢻⣧⠀⠀⠀⠀⠀⠀⠀⠀[/] [dim #7A3511]⠀⠀⠀⠀⠀⠀⠀tail flame lit⠀⠀⠀⠀⠀⠀⠀⠀[/]""", }, } # ============================================================================= # Skin loading and management # ============================================================================= _active_skin: Optional[SkinConfig] = None _active_skin_name: str = "default" def _skins_dir() -> Path: """User skins directory.""" return get_hermes_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", "┊")), tool_emojis=data.get("tool_emojis", {}), 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") # ============================================================================= # Convenience helpers for CLI modules # ============================================================================= def get_active_prompt_symbol(fallback: str = "❯ ") -> str: """Get the interactive prompt symbol from the active skin.""" try: return get_active_skin().get_branding("prompt_symbol", fallback) except Exception: return fallback def get_active_help_header(fallback: str = "(^_^)? Available Commands") -> str: """Get the /help header from the active skin.""" try: return get_active_skin().get_branding("help_header", fallback) except Exception: return fallback def get_active_goodbye(fallback: str = "Goodbye! ⚕") -> str: """Get the goodbye line from the active skin.""" try: return get_active_skin().get_branding("goodbye", fallback) except Exception: return fallback def get_prompt_toolkit_style_overrides() -> Dict[str, str]: """Return prompt_toolkit style overrides derived from the active skin. These are layered on top of the CLI's base TUI style so /skin can refresh the live prompt_toolkit UI immediately without rebuilding the app. """ try: skin = get_active_skin() except Exception: return {} prompt = skin.get_color("prompt", "#FFF8DC") input_rule = skin.get_color("input_rule", "#CD7F32") title = skin.get_color("banner_title", "#FFD700") text = skin.get_color("banner_text", prompt) dim = skin.get_color("banner_dim", "#555555") label = skin.get_color("ui_label", title) warn = skin.get_color("ui_warn", "#FF8C00") error = skin.get_color("ui_error", "#FF6B6B") return { "input-area": prompt, "placeholder": f"{dim} italic", "prompt": prompt, "prompt-working": f"{dim} italic", "hint": f"{dim} italic", "input-rule": input_rule, "image-badge": f"{label} bold", "completion-menu": f"bg:#1a1a2e {text}", "completion-menu.completion": f"bg:#1a1a2e {text}", "completion-menu.completion.current": f"bg:#333355 {title}", "completion-menu.meta.completion": f"bg:#1a1a2e {dim}", "completion-menu.meta.completion.current": f"bg:#333355 {label}", "clarify-border": input_rule, "clarify-title": f"{title} bold", "clarify-question": f"{text} bold", "clarify-choice": dim, "clarify-selected": f"{title} bold", "clarify-active-other": f"{title} italic", "clarify-countdown": input_rule, "sudo-prompt": f"{error} bold", "sudo-border": input_rule, "sudo-title": f"{error} bold", "sudo-text": text, "approval-border": input_rule, "approval-title": f"{warn} bold", "approval-desc": f"{text} bold", "approval-cmd": f"{dim} italic", "approval-choice": dim, "approval-selected": f"{title} bold", }