Skip to content

Instantly share code, notes, and snippets.

@robbiemu
Created July 8, 2026 20:31
Show Gist options
  • Select an option

  • Save robbiemu/5935025412002eddbe642ecd991f41f1 to your computer and use it in GitHub Desktop.

Select an option

Save robbiemu/5935025412002eddbe642ecd991f41f1 to your computer and use it in GitHub Desktop.
Hermes autoskin: remember separate light/dark skins, including system appearance mode

hermes-autoskin-system-mode

Remember separate light and dark Hermes skins, and when Appearance is set to system, apply the skin matching the resolved OS color scheme.

This gist has two parts:

  1. A normal Hermes plugin for CLI / TUI / gateway sessions.
  2. A tiny Hermes Desktop renderer patch, because Desktop owns its live theme state in Electron / localStorage.

A pure Python Hermes plugin can update ~/.hermes/config.yaml, register /autoskin, and apply CLI / TUI skins on session start. It cannot by itself force an already-running Desktop renderer to repaint, because Desktop keeps its own per-profile theme / mode state. The Desktop patch fixes that by storing:

  • lightSkin
  • darkSkin
  • mode: light | dark | system

and resolving system through window.matchMedia("(prefers-color-scheme: dark)").

Files

File What it is
hermes-autoskin/plugin.yaml Plugin manifest
hermes-autoskin/__init__.py Plugin: detects OS appearance, applies matching skin
DESKTOP_PATCH.md Renderer theme persistence patch for Hermes Desktop
README.md This file

Key idea

System mode chooses between remembered light and dark skins using the resolved system appearance; choosing a skin updates only the currently resolved side.

Install the plugin

mkdir -p ~/.hermes/plugins
cp -R hermes-autoskin ~/.hermes/plugins/autoskin
hermes plugins enable autoskin

hermes autoskin enable --light daylight --dark mono
hermes autoskin status

Config block (in ~/.hermes/config.yaml):

display:
  autoskin_enabled: true
  autoskin_light: daylight
  autoskin_dark: mono
  skin: daylight

How the plugin resolves appearance

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

Why this is split

Hermes CLI / TUI skin is driven by display.skin in ~/.hermes/config.yaml. Hermes Desktop is a native Electron renderer with its own theme state. The Python plugin handles backend / config / session behavior; the Desktop patch handles live renderer behavior.

This is intentionally small: no new theme model, no Dashboard sync requirement, no palette conversion. It only fixes the missing remembered-light / default-dark behavior.

Desktop patch

The Desktop fix is not a backend plugin hook. It belongs in the renderer theme persistence layer.

Goal

  • Keep mode as light | dark | system.
  • Store separate remembered skins:
    • lightSkin
    • darkSkin
  • When mode === "system", resolve the active side using prefers-color-scheme.
  • When the user changes skin while resolved mode is light, update only lightSkin.
  • When the user changes skin while resolved mode is dark, update only darkSkin.
  • When the OS flips light / dark while mode is system, re-apply the corresponding remembered skin live.

Pseudo-patch

type AppearanceMode = "light" | "dark" | "system";
type ResolvedMode = "light" | "dark";

type AutoskinPrefs = {
  mode: AppearanceMode;
  lightSkin: string;
  darkSkin: string;
};

const DEFAULT_AUTOSKIN_PREFS: AutoskinPrefs = {
  mode: "system",
  lightSkin: "daylight",
  darkSkin: "mono",
};

function resolveMode(mode: AppearanceMode): ResolvedMode {
  if (mode === "system") {
    return window.matchMedia("(prefers-color-scheme: dark)").matches
      ? "dark"
      : "light";
  }

  return mode;
}

function skinForResolvedMode(prefs: AutoskinPrefs): string {
  return resolveMode(prefs.mode) === "dark"
    ? prefs.darkSkin
    : prefs.lightSkin;
}

function updateSkinForCurrentResolvedMode(
  prefs: AutoskinPrefs,
  nextSkin: string,
): AutoskinPrefs {
  return resolveMode(prefs.mode) === "dark"
    ? { ...prefs, darkSkin: nextSkin }
    : { ...prefs, lightSkin: nextSkin };
}

React hook shape

useEffect(() => {
  const media = window.matchMedia("(prefers-color-scheme: dark)");

  function onSystemModeChanged() {
    if (prefs.mode !== "system") return;
    applySkin(skinForResolvedMode(prefs));
  }

  media.addEventListener("change", onSystemModeChanged);
  return () => media.removeEventListener("change", onSystemModeChanged);
}, [prefs]);

Acceptance test

  1. Set Appearance to system.
  2. While OS is light, choose daylight.
  3. Switch OS to dark.
  4. Choose mono.
  5. Switch OS back to light.
  6. Desktop should return to daylight, not stay stuck on mono.
  7. Switch OS back to dark.
  8. Desktop should return to mono.

Why this is split

Hermes CLI / TUI skin is driven by display.skin in ~/.hermes/config.yaml. Hermes Desktop is a native Electron renderer with its own theme state. The Python plugin handles backend / config / session behavior; the Desktop patch handles live renderer behavior.

This is intentionally small: no new theme model, no Dashboard sync requirement, no palette conversion. It only fixes the missing remembered-light / default-dark behavior.

"""
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}"
)
name: autoskin
version: 0.2.0
description: "Remember light/dark Hermes skins and apply the one matching the resolved system appearance."
author: robbiemu
hooks:
- on_session_start
provides:
commands:
- autoskin
cli_commands:
- autoskin
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment