Files
omarchy/bin/omarchy-theme-set-hermes
T
Spencer Bull 6baae0f556 Make Hermes follow the Omarchy theme as a skin
Hermes Desktop installed under Install > AI kept its own palette while every other agent app retinted with the theme. Hermes' skin is its one theme unit for the desktop app, the TUI and the CLI, and its gateway watches the active skin file and broadcasts changes to every surface, so Omarchy publishes a skin named omarchy from a template on every theme switch and nothing Omarchy-specific goes upstream.

Activation goes through hermes config set, which writes the active profile's config and touches the skin so a running gateway repaints at once, and it only replaces Hermes' default skin so a choice made in Hermes stays. A theme switch runs that activation too when the desktop package is present and Hermes is still on its default, so a hand-over the installer missed is finished by the next switch; once the config names the skin a switch never starts Hermes. The desktop adopts a skin from a change broadcast rather than from the config it finds at connect time, and its first launch builds the runtime over minutes, so the installer starts --wait as a transient user unit that outlives the install terminal, activates once the runtime marker appears, republishes after the gateway is up, and reports to the journal. A migration hands the skin to existing Hermes Desktop installs through --activate, which also renders the skin for a theme applied before the template existed.

The generated file is validated before it is published, because Hermes parses it as YAML: only the name, a plain description and #rrggbb colours pass, so an unresolved palette key or a cloned theme's own hermes.yaml leaves the previous skin in place.

🤖 Generated by Fable 5.1 in Claude Code. Reviewed by Fable 5.1 code-review at high.
2026-09-05 23:05:10 -05:00

200 lines
7.8 KiB
Bash
Executable File

#!/bin/bash
# omarchy:summary=Sync the generated Omarchy theme to Hermes as a skin
# omarchy:args=[--activate] [--wait]
# omarchy:hidden=true
# Hermes has one theme unit for all of its surfaces, the skin: a YAML file in
# ~/.hermes/skins/ that the desktop app, the TUI and the CLI each turn into
# their own palette. The active one is named by display.skin in Hermes' config,
# and the gateway watches that file's mtime, so a rewrite of the active skin
# repaints every connected surface within a second -- no Omarchy code in
# Hermes, and nothing here that Hermes did not already offer any other system.
#
# Every run publishes the skin file, and for the desktop app Omarchy installed
# names it in the config through Hermes' own config writer if Hermes is still
# on its default skin, so a theme switch also picks up an install whose
# hand-over was missed. --activate is the deliberate form, for the installer
# and the migration: it renders a skin the current theme does not have yet and
# says why nothing happened when it cannot finish. --wait holds the whole thing
# until the desktop app has finished building the runtime its first launch
# provisions.
set -euo pipefail
HERMES_SOURCE_PATH="$HOME/.local/state/omarchy/current/theme/hermes.yaml"
HERMES_THEME_NAME_PATH="$HOME/.local/state/omarchy/current/theme.name"
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
HERMES_CONFIG_PATH="$HERMES_HOME/config.yaml"
# The filename is the skin name Hermes shows and display.skin refers to, and it
# stays put while the colors change underneath on every theme switch.
HERMES_SKIN_NAME="omarchy"
# The app writes this when the runtime it provisions has landed; the CLI
# installer's readiness probe passes only once it has.
HERMES_BOOTSTRAP_MARKER="$HERMES_HOME/hermes-agent/.hermes-bootstrap-complete"
HERMES_ACTIVATE=0
HERMES_WAIT=0
HERMES_WAIT_LIMIT=$((30 * 60))
usage() {
echo "Usage: omarchy-theme-set-hermes [--activate] [--wait]"
}
for arg in "$@"; do
case "$arg" in
--activate)
HERMES_ACTIVATE=1
;;
--wait)
HERMES_ACTIVATE=1
HERMES_WAIT=1
;;
-h | --help)
usage
exit 0
;;
*)
usage >&2
exit 1
;;
esac
done
# Only the deliberate form explains itself: the theme switch runs this beside
# a dozen other hooks, where a note about Hermes on every switch is noise.
note() {
if (( HERMES_ACTIVATE == 1 )); then
echo "$*" >&2
fi
}
# The skin is generated with the rest of the theme, so a theme applied before
# the template existed has none yet. Asked to activate, re-stage the current
# theme to render it; a theme switch simply has nothing to publish.
if [[ ! -f $HERMES_SOURCE_PATH ]]; then
(( HERMES_ACTIVATE == 1 )) || exit 0
if [[ ! -s $HERMES_THEME_NAME_PATH ]]; then
echo "Hermes skin source missing: $HERMES_SOURCE_PATH" >&2
echo "Select an Omarchy theme first." >&2
exit 1
fi
omarchy-theme-refresh
if [[ ! -f $HERMES_SOURCE_PATH ]]; then
echo "Hermes skin source missing after refreshing the theme: $HERMES_SOURCE_PATH" >&2
exit 1
fi
fi
# The desktop app builds its runtime on first launch, which takes minutes and
# may be abandoned. Poll for the marker rather than the readiness probe, which
# starts Hermes to answer, and give up quietly on a launch that never came.
if (( HERMES_WAIT == 1 )); then
waited=0
until [[ -f $HERMES_BOOTSTRAP_MARKER && -f $HERMES_CONFIG_PATH ]]; do
if (( waited >= HERMES_WAIT_LIMIT )); then
echo "Hermes did not finish setting up within $((HERMES_WAIT_LIMIT / 60)) minutes; run omarchy-theme-set-hermes --activate once it has." >&2
exit 0
fi
sleep 10
waited=$((waited + 10))
done
fi
# Only follow a Hermes that has been set up. Its config is what Hermes writes
# when it has; ~/.hermes itself proves nothing, because provisioning creates it
# on every machine to hold the Omarchy skill.
if [[ ! -f $HERMES_CONFIG_PATH ]]; then
note "Hermes is not set up yet; launch it once, then run omarchy-theme-set-hermes --activate."
exit 0
fi
# Hermes parses the skin as YAML and hands the strings to every surface, so the
# generated file is held to the shape the template promises before it is
# published: the name Hermes loads it by, a plain description, and colors that
# are all #rrggbb. A theme missing a palette key leaves its placeholder
# unresolved, and a theme cloned from a git repo may ship its own hermes.yaml;
# neither gets to write anything else into a file another program reads. The
# previous skin stays in place instead.
skin_is_well_formed() {
local skin="$1"
grep -qx "name: $HERMES_SKIN_NAME" "$skin" &&
grep -qx 'colors:' "$skin" &&
! grep -qvE "^(#.*|[[:space:]]*|name: $HERMES_SKIN_NAME|description: [A-Za-z0-9 ,.()-]*|colors:| [a-z_]+: \"#[0-9a-fA-F]{6}\")$" "$skin"
}
if ! skin_is_well_formed "$HERMES_SOURCE_PATH"; then
echo "Skipping Hermes skin: $(basename "$HERMES_SOURCE_PATH") is not a plain color palette." >&2
exit 0
fi
# The gateway watches the skin file's mtime and reads it whole on change, so
# the write has to be atomic: a half-written file would read as no skin at all.
publish_skin() {
local skins_dir="$1"
local tmp
mkdir -p "$skins_dir"
tmp=$(mktemp "$skins_dir/$HERMES_SKIN_NAME.yaml.XXXXXX")
cp "$HERMES_SOURCE_PATH" "$tmp"
mv "$tmp" "$skins_dir/$HERMES_SKIN_NAME.yaml"
}
publish_skin "$HERMES_HOME/skins"
# A Hermes profile is a home of its own, with its own skins and config, and the
# gateway watches whichever profile is active. Existing profiles get the skin
# too; none are created here.
for profile in "$HERMES_HOME"/profiles/*/; do
[[ -d $profile ]] || continue
publish_skin "${profile%/}/skins"
done
# A theme switch finishes the hand-over only for the app Omarchy installed
# under Install > AI; a Hermes set up some other way keeps whatever skin it
# has unless --activate asks for it by name. Hermes writes the active skin
# under display: at that indent, and once it names ours a theme switch is done
# here without starting Hermes; anything else is for Hermes itself to answer
# below, so the grep is only ever a shortcut.
if (( HERMES_ACTIVATE == 0 )); then
omarchy-pkg-present hermes-desktop || exit 0
grep -qx " skin: $HERMES_SKIN_NAME" "$HERMES_CONFIG_PATH" && exit 0
fi
# Naming the skin in the config is Hermes' own job: `hermes config set` is what
# `hermes skin use` runs, it writes the active profile's config, and it touches
# the skin file so a running gateway broadcasts the change and every surface
# repaints at once. Asked through the installer's readiness probe rather than by
# running whatever `hermes` is on PATH, because Omarchy's cold stub installs
# Hermes when run, and that takes minutes.
if ! omarchy-install-hermes-cli --check 2>/dev/null; then
note "Hermes is not ready, so the Omarchy skin is published but not active."
note "Once Hermes runs, activate it with: hermes config set display.skin $HERMES_SKIN_NAME"
exit 0
fi
# A skin the user picked in Hermes stays theirs. Only Hermes' own default is
# replaced, so a reinstall, a migration or a theme switch never undoes a choice.
current_skin=$(hermes config get display.skin 2>/dev/null || true)
if [[ -n $current_skin && $current_skin != "default" && $current_skin != "$HERMES_SKIN_NAME" ]]; then
note "Hermes is set to the '$current_skin' skin; leaving it. Switch with: hermes config set display.skin $HERMES_SKIN_NAME"
exit 0
fi
hermes config set display.skin "$HERMES_SKIN_NAME" >/dev/null
note "Hermes is on the Omarchy skin."
# The desktop app adopts a skin from a change broadcast, not from the config it
# finds at connect time, and the gateway seeds its change watcher when it starts.
# A first launch brings the gateway up moments after the marker, so a config
# written in that gap goes unannounced; republishing once the gateway is surely
# up moves the file's mtime again, and that broadcast the desktop does apply.
if (( HERMES_WAIT == 1 )); then
sleep 60
publish_skin "$HERMES_HOME/skins"
fi