feat: add data-driven skin/theme engine for CLI customization
Adds a skin system that lets users customize the CLI's visual appearance
through data files (YAML) rather than code changes. Skins define: color
palette, spinner faces/verbs/wings, branding text, and tool output prefix.
New files:
- hermes_cli/skin_engine.py — SkinConfig dataclass, built-in skins
(default, ares, mono, slate), YAML loader for user skins from
~/.hermes/skins/, skin management API
- tests/hermes_cli/test_skin_engine.py — 26 tests covering config,
built-in skins, user YAML skins, display integration
Modified files:
- agent/display.py — skin-aware spinner wings, faces, verbs, tool prefix
- hermes_cli/banner.py — skin-aware banner colors (title, border, accent,
dim, text, session) via _skin_color()/_skin_branding() helpers
- cli.py — /skin command handler, skin init from config, skin-aware
response box label and welcome message
- hermes_cli/config.py — add display.skin default
- hermes_cli/commands.py — add /skin to slash commands
Built-in skins:
- default: classic Hermes gold/kawaii
- ares: crimson/bronze war-god theme (from community PRs #579/#725)
- mono: clean grayscale
- slate: cool blue developer theme
User skins: drop a YAML file in ~/.hermes/skins/ with name, colors,
spinner, branding, and tool_prefix fields. Missing values inherit from
the default skin.
2026-03-10 00:37:28 -07:00
|
|
|
|
"""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.
|
2026-03-10 00:51:27 -07:00
|
|
|
|
No code changes are needed to add a new skin.
|
feat: add data-driven skin/theme engine for CLI customization
Adds a skin system that lets users customize the CLI's visual appearance
through data files (YAML) rather than code changes. Skins define: color
palette, spinner faces/verbs/wings, branding text, and tool output prefix.
New files:
- hermes_cli/skin_engine.py — SkinConfig dataclass, built-in skins
(default, ares, mono, slate), YAML loader for user skins from
~/.hermes/skins/, skin management API
- tests/hermes_cli/test_skin_engine.py — 26 tests covering config,
built-in skins, user YAML skins, display integration
Modified files:
- agent/display.py — skin-aware spinner wings, faces, verbs, tool prefix
- hermes_cli/banner.py — skin-aware banner colors (title, border, accent,
dim, text, session) via _skin_color()/_skin_branding() helpers
- cli.py — /skin command handler, skin init from config, skin-aware
response box label and welcome message
- hermes_cli/config.py — add display.skin default
- hermes_cli/commands.py — add /skin to slash commands
Built-in skins:
- default: classic Hermes gold/kawaii
- ares: crimson/bronze war-god theme (from community PRs #579/#725)
- mono: clean grayscale
- slate: cool blue developer theme
User skins: drop a YAML file in ~/.hermes/skins/ with name, colors,
spinner, branding, and tool_prefix fields. Missing values inherit from
the default skin.
2026-03-10 00:37:28 -07:00
|
|
|
|
|
2026-03-10 00:51:27 -07:00
|
|
|
|
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
|
feat: add data-driven skin/theme engine for CLI customization
Adds a skin system that lets users customize the CLI's visual appearance
through data files (YAML) rather than code changes. Skins define: color
palette, spinner faces/verbs/wings, branding text, and tool output prefix.
New files:
- hermes_cli/skin_engine.py — SkinConfig dataclass, built-in skins
(default, ares, mono, slate), YAML loader for user skins from
~/.hermes/skins/, skin management API
- tests/hermes_cli/test_skin_engine.py — 26 tests covering config,
built-in skins, user YAML skins, display integration
Modified files:
- agent/display.py — skin-aware spinner wings, faces, verbs, tool prefix
- hermes_cli/banner.py — skin-aware banner colors (title, border, accent,
dim, text, session) via _skin_color()/_skin_branding() helpers
- cli.py — /skin command handler, skin init from config, skin-aware
response box label and welcome message
- hermes_cli/config.py — add display.skin default
- hermes_cli/commands.py — add /skin to slash commands
Built-in skins:
- default: classic Hermes gold/kawaii
- ares: crimson/bronze war-god theme (from community PRs #579/#725)
- mono: clean grayscale
- slate: cool blue developer theme
User skins: drop a YAML file in ~/.hermes/skins/ with name, colors,
spinner, branding, and tool_prefix fields. Missing values inherit from
the default skin.
2026-03-10 00:37:28 -07:00
|
|
|
|
|
|
|
|
|
|
from hermes_cli.skin_engine import get_active_skin, list_skins, set_active_skin
|
|
|
|
|
|
|
|
|
|
|
|
skin = get_active_skin()
|
2026-03-10 00:51:27 -07:00
|
|
|
|
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.
|
feat: add data-driven skin/theme engine for CLI customization
Adds a skin system that lets users customize the CLI's visual appearance
through data files (YAML) rather than code changes. Skins define: color
palette, spinner faces/verbs/wings, branding text, and tool output prefix.
New files:
- hermes_cli/skin_engine.py — SkinConfig dataclass, built-in skins
(default, ares, mono, slate), YAML loader for user skins from
~/.hermes/skins/, skin management API
- tests/hermes_cli/test_skin_engine.py — 26 tests covering config,
built-in skins, user YAML skins, display integration
Modified files:
- agent/display.py — skin-aware spinner wings, faces, verbs, tool prefix
- hermes_cli/banner.py — skin-aware banner colors (title, border, accent,
dim, text, session) via _skin_color()/_skin_branding() helpers
- cli.py — /skin command handler, skin init from config, skin-aware
response box label and welcome message
- hermes_cli/config.py — add display.skin default
- hermes_cli/commands.py — add /skin to slash commands
Built-in skins:
- default: classic Hermes gold/kawaii
- ares: crimson/bronze war-god theme (from community PRs #579/#725)
- mono: clean grayscale
- slate: cool blue developer theme
User skins: drop a YAML file in ~/.hermes/skins/ with name, colors,
spinner, branding, and tool_prefix fields. Missing values inherit from
the default skin.
2026-03-10 00:37:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
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")
|