Spaces:
Paused
Paused
| """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/<name>.yaml`` following the schema above. | |
| Activate with ``/skin <name>`` in the CLI or ``display.skin: <name>`` in config.yaml. | |
| """ | |
| import logging | |
| 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 | |
| # ============================================================================= | |
| 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", | |
| } | |