|
""" |
|
hermes-autoskin: remember separate light/dark skins and apply the one matching |
|
the resolved system appearance. |
|
|
|
Config (~/.hermes/config.yaml): |
|
|
|
display: |
|
autoskin_enabled: true |
|
autoskin_light: daylight |
|
autoskin_dark: mono |
|
skin: daylight |
|
|
|
Behavior: |
|
- On session start, resolve the OS appearance and set display.skin to the |
|
matching remembered skin. |
|
- HERMES_APPEARANCE=light|dark overrides detection for testing. |
|
- The /autoskin command and `hermes autoskin` CLI re-apply on demand. |
|
""" |
|
|
|
from __future__ import annotations |
|
|
|
import os |
|
import platform |
|
import shutil |
|
import subprocess |
|
from pathlib import Path |
|
from typing import Any, Literal |
|
|
|
import yaml |
|
|
|
HERMES_CONFIG_PATH = Path(os.path.expanduser("~/.hermes/config.yaml")) |
|
|
|
Appearance = Literal["light", "dark"] |
|
|
|
|
|
# --- config helpers --------------------------------------------------------- |
|
|
|
def load_config() -> dict[str, Any]: |
|
if not HERMES_CONFIG_PATH.is_file(): |
|
return {} |
|
try: |
|
data = yaml.safe_load(HERMES_CONFIG_PATH.read_text(encoding="utf-8")) |
|
except (yaml.YAMLError, OSError): |
|
return {} |
|
return data if isinstance(data, dict) else {} |
|
|
|
|
|
def save_config(config: dict[str, Any]) -> None: |
|
HERMES_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) |
|
HERMES_CONFIG_PATH.write_text( |
|
yaml.safe_dump(config, default_flow_style=False, sort_keys=False), |
|
encoding="utf-8", |
|
) |
|
|
|
|
|
def set_active_skin(skin: str) -> None: |
|
"""Apply the skin to the running session if possible. |
|
|
|
For CLI/TUI this is a thin shim; the real application of a skin is owned by |
|
the Hermes display layer, which reads display.skin at render time. We only |
|
make sure config is up to date before the renderer reads it. Keeping this |
|
no-op-safe means a plugin load can never crash a session over theming. |
|
""" |
|
|
|
|
|
# --- appearance detection --------------------------------------------------- |
|
|
|
def _detect_macos() -> Appearance: |
|
# `defaults read -g AppleInterfaceStyle` prints "Dark" in dark mode and |
|
# exits non-zero (domain not found) in light mode. |
|
try: |
|
out = subprocess.run( |
|
["defaults", "read", "-g", "AppleInterfaceStyle"], |
|
capture_output=True, |
|
text=True, |
|
check=False, |
|
timeout=2, |
|
) |
|
except (FileNotFoundError, subprocess.TimeoutExpired): |
|
return "light" |
|
return "dark" if "Dark" in (out.stdout or "") else "light" |
|
|
|
|
|
def _detect_gnome() -> Appearance: |
|
gsettings = shutil.which("gsettings") |
|
if not gsettings: |
|
return "light" |
|
try: |
|
out = subprocess.run( |
|
[gsettings, "get", "org.gnome.desktop.interface", "color-scheme"], |
|
capture_output=True, |
|
text=True, |
|
check=False, |
|
timeout=2, |
|
) |
|
except subprocess.TimeoutExpired: |
|
return "light" |
|
return "dark" if "dark" in (out.stdout or "") else "light" |
|
|
|
|
|
def _detect_kde() -> Appearance: |
|
kreadconfig = shutil.which("kreadconfig6") or shutil.which("kreadconfig") |
|
if not kreadconfig: |
|
return "light" |
|
# Heuristic: KDE's default color scheme file names contain "Dark" for the |
|
# shipped dark themes (e.g. "Breeze Dark"). Not exhaustive; treat as a hint. |
|
try: |
|
out = subprocess.run( |
|
[kreadconfig, "--file", "kdeglobals", "--group", "General", |
|
"--key", "ColorScheme"], |
|
capture_output=True, |
|
text=True, |
|
check=False, |
|
timeout=2, |
|
) |
|
except subprocess.TimeoutExpired: |
|
return "light" |
|
return "dark" if "Dark" in (out.stdout or "") else "light" |
|
|
|
|
|
def _detect_windows() -> Appearance: |
|
# AppsUseLightTheme: 0 = dark, 1 = light (absent => light, pre-2019). |
|
reg = shutil.which("reg") |
|
if not reg: |
|
return "light" |
|
try: |
|
out = subprocess.run( |
|
["reg", "query", |
|
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes" |
|
"\\Personalize", "/v", "AppsUseLightTheme"], |
|
capture_output=True, |
|
text=True, |
|
check=False, |
|
timeout=2, |
|
) |
|
except subprocess.TimeoutExpired: |
|
return "light" |
|
text = out.stdout or "" |
|
if "0x0" in text: |
|
return "dark" |
|
return "light" |
|
|
|
|
|
def resolved_appearance() -> Appearance: |
|
"""Resolve the OS appearance to 'light' or 'dark'. |
|
|
|
HERMES_APPEARANCE=light/dark override for testing. |
|
macOS: defaults read -g AppleInterfaceStyle |
|
GNOME: gsettings org.gnome.desktop.interface color-scheme |
|
KDE: kreadconfig color scheme heuristic |
|
Windows: AppsUseLightTheme registry key |
|
fallback: light |
|
""" |
|
override = os.environ.get("HERMES_APPEARANCE", "").strip().lower() |
|
if override in ("light", "dark"): |
|
return override # type: ignore[return-value] |
|
|
|
system = platform.system() |
|
if system == "Darwin": |
|
return _detect_macos() |
|
if system == "Windows": |
|
return _detect_windows() |
|
# Linux: try GNOME, then KDE. |
|
if _have("gsettings"): |
|
return _detect_gnome() |
|
if _have("kreadconfig") or _have("kreadconfig6"): |
|
return _detect_kde() |
|
return "light" |
|
|
|
|
|
def _have(binary: str) -> bool: |
|
return shutil.which(binary) is not None |
|
|
|
|
|
# --- core action ------------------------------------------------------------ |
|
|
|
def apply_autoskin() -> str: |
|
config = load_config() |
|
display = config.setdefault("display", {}) |
|
|
|
if not display.get("autoskin_enabled"): |
|
return "Autoskin is disabled." |
|
|
|
appearance = resolved_appearance() |
|
skin = ( |
|
display.get("autoskin_dark", "mono") |
|
if appearance == "dark" |
|
else display.get("autoskin_light", "daylight") |
|
) |
|
|
|
display["skin"] = skin |
|
save_config(config) |
|
set_active_skin(skin) |
|
return f"Autoskin applied: {appearance} -> {skin}" |
|
|
|
|
|
# --- Hermes plugin surface -------------------------------------------------- |
|
# These names are discovered by the Hermes plugin loader per the manifest's |
|
# `hooks` and `provides` declarations. Keeping them small and side-effect-free |
|
# on import. |
|
|
|
def on_session_start(**_kwargs: Any) -> None: |
|
"""Hook: apply the resolved skin when a CLI/TUI/gateway session begins.""" |
|
try: |
|
apply_autoskin() |
|
except Exception: |
|
# Theming must never break session startup. |
|
pass |
|
|
|
|
|
def autoskin(**_kwargs: Any) -> str: |
|
"""/autoskin slash command: re-resolve and apply the skin now.""" |
|
return apply_autoskin() |
|
|
|
|
|
# CLI subcommands used by `hermes autoskin ...`: |
|
|
|
def cli_enable(light: str | None = None, dark: str | None = None) -> str: |
|
config = load_config() |
|
display = config.setdefault("display", {}) |
|
display["autoskin_enabled"] = True |
|
if light: |
|
display["autoskin_light"] = light |
|
if dark: |
|
display["autoskin_dark"] = dark |
|
save_config(config) |
|
return apply_autoskin() |
|
|
|
|
|
def cli_disable() -> str: |
|
config = load_config() |
|
display = config.setdefault("display", {}) |
|
display["autoskin_enabled"] = False |
|
save_config(config) |
|
return "Autoskin disabled." |
|
|
|
|
|
def cli_status() -> str: |
|
config = load_config() |
|
display = config.get("display", {}) |
|
enabled = bool(display.get("autoskin_enabled")) |
|
appearance = resolved_appearance() |
|
active = display.get("skin", "(unset)") |
|
light = display.get("autoskin_light", "daylight") |
|
dark = display.get("autoskin_dark", "mono") |
|
return ( |
|
f"autoskin: {'enabled' if enabled else 'disabled'}\n" |
|
f"resolved appearance: {appearance}\n" |
|
f"light skin: {light}\n" |
|
f"dark skin: {dark}\n" |
|
f"active skin: {active}" |
|
) |