From 6baae0f55667a32cf028bf22bf9139a0a94e1c1e Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sat, 5 Sep 2026 17:06:37 -0500 Subject: [PATCH] Make Hermes follow the Omarchy theme as a skin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bin/omarchy-install-ai-hermes | 11 + bin/omarchy-theme-set | 1 + bin/omarchy-theme-set-hermes | 199 +++++++++++++++ default/themed/hermes.yaml.tpl | 47 ++++ manual/17-ai.md | 4 +- migrations/1788619462.sh | 12 + test/shell.d/hermes-skin-migration-test.sh | 57 +++++ test/shell.d/hermes-theme-test.sh | 267 +++++++++++++++++++++ test/shell.d/theme-staging-test.sh | 2 +- 9 files changed, 597 insertions(+), 3 deletions(-) create mode 100755 bin/omarchy-theme-set-hermes create mode 100644 default/themed/hermes.yaml.tpl create mode 100644 migrations/1788619462.sh create mode 100644 test/shell.d/hermes-skin-migration-test.sh create mode 100644 test/shell.d/hermes-theme-test.sh diff --git a/bin/omarchy-install-ai-hermes b/bin/omarchy-install-ai-hermes index 3f6abafe..55a3a271 100755 --- a/bin/omarchy-install-ai-hermes +++ b/bin/omarchy-install-ai-hermes @@ -21,6 +21,17 @@ omarchy-install-hermes-cli || true echo "Opening Hermes Desktop..." setsid uwsm-app -- /usr/bin/hermes-desktop >/dev/null 2>&1 & +# The app builds its runtime on first launch, which takes minutes, and only a +# running Hermes can be told which skin to show. So the theme is handed over +# after the launch above, by a helper that waits for that runtime and then +# names the Omarchy skin through Hermes itself; the running app repaints. Only +# Hermes' own default is replaced, so a skin chosen in Hermes is never undone. +# It runs as its own unit, so this terminal closing cannot take it along and +# what it did is in the journal: journalctl --user -u omarchy-hermes-theme +echo "Matching Hermes to the current theme once it is set up..." +systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true +systemd-run --user --quiet --collect --unit=omarchy-hermes-theme omarchy-theme-set-hermes --wait + echo "" echo "Hermes Desktop has been installed." echo "Its first launch installs the Hermes runtime, which takes a few minutes." diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index b0c1cda4..b6733803 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -326,6 +326,7 @@ post_theme_commands=( omarchy-theme-set-gnome omarchy-theme-set-pi omarchy-theme-set-claude + omarchy-theme-set-hermes omarchy-theme-set-browser omarchy-theme-set-vscode omarchy-theme-set-obsidian diff --git a/bin/omarchy-theme-set-hermes b/bin/omarchy-theme-set-hermes new file mode 100755 index 00000000..a4663738 --- /dev/null +++ b/bin/omarchy-theme-set-hermes @@ -0,0 +1,199 @@ +#!/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 diff --git a/default/themed/hermes.yaml.tpl b/default/themed/hermes.yaml.tpl new file mode 100644 index 00000000..2e39710e --- /dev/null +++ b/default/themed/hermes.yaml.tpl @@ -0,0 +1,47 @@ +name: omarchy +description: Omarchy system theme +colors: + background: "{{ background }}" + ui_text: "{{ foreground }}" + ui_primary: "{{ accent }}" + ui_accent: "{{ accent }}" + ui_border: "{{ muted }}" + ui_label: "{{ accent }}" + ui_ok: "{{ green }}" + ui_warn: "{{ yellow }}" + ui_error: "{{ red }}" + ui_tool: "{{ cyan }}" + ui_thinking: "{{ dark_foreground }}" + banner_border: "{{ muted }}" + banner_title: "{{ accent }}" + banner_accent: "{{ accent }}" + banner_dim: "{{ dark_foreground }}" + banner_text: "{{ foreground }}" + prompt: "{{ bright_foreground }}" + input_rule: "{{ muted }}" + response_border: "{{ accent }}" + shell_dollar: "{{ blue }}" + selection_bg: "{{ selection }}" + session_label: "{{ accent }}" + session_border: "{{ muted }}" + status_bar_bg: "{{ dark_background }}" + status_bar_text: "{{ foreground }}" + status_bar_strong: "{{ accent }}" + status_bar_dim: "{{ dark_foreground }}" + status_bar_good: "{{ green }}" + status_bar_warn: "{{ yellow }}" + status_bar_bad: "{{ red }}" + status_bar_critical: "{{ bright_red }}" + voice_status_bg: "{{ dark_background }}" + completion_menu_bg: "{{ lighter_background }}" + completion_menu_current_bg: "{{ selection }}" + completion_menu_meta_bg: "{{ lighter_background }}" + completion_menu_meta_current_bg: "{{ selection }}" + diff_added: "{{ mix background green 15% }}" + diff_removed: "{{ mix background red 15% }}" + diff_added_word: "{{ green }}" + diff_removed_word: "{{ red }}" + syntax_string: "{{ green }}" + syntax_number: "{{ yellow }}" + syntax_keyword: "{{ magenta }}" + syntax_comment: "{{ muted }}" diff --git a/manual/17-ai.md b/manual/17-ai.md index 9987cb5b..90d3a10e 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -26,7 +26,7 @@ Pick your default agent with `omarchy default agent ` or under _Setup > De Once you've chosen, `Super + Shift + Ctrl + A` launches the default agent in a dedicated terminal window (or brings up the picker if you haven't chosen yet). You can also launch it straight into a task with `omarchy agent prompt "Review this project"`. Agents launched this way run unattended in their respective don't-stop-to-ask modes, so be ready for them to actually do things! And since agents refuse to remember trust for your home directory, launches from `$HOME` start in `~/Work` instead. -There are terminal shortcuts too: `a` runs the default agent inline in the current terminal, while `c`, `cx`, and `cy` start OpenCode, Claude Code, and Codex directly (again in their auto-approving modes). Theme changes sync to the agents as well: Claude Code, Pi, and OpenCode all follow along when you switch the Omarchy theme. +There are terminal shortcuts too: `a` runs the default agent inline in the current terminal, while `c`, `cx`, and `cy` start OpenCode, Claude Code, and Codex directly (again in their auto-approving modes). Theme changes sync to the agents as well: Claude Code, Pi, OpenCode, and Hermes (once Hermes Desktop is installed) all follow along when you switch the Omarchy theme. ### The agents panel @@ -46,7 +46,7 @@ Crashes can also be silenced one program at a time, which is what the diagnosis The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, Grok Bot for chatting with xAI's models, Hermes Desktop, OpenClaw, and the Perplexity desktop app. -Hermes Desktop is the one to know about, because there is only ever one Hermes on a machine. The app only runs against a runtime built from its own commit, so it installs one of its own under `~/.hermes` on first launch, which takes a few minutes and shows its own progress. From then on that is the Hermes the terminal `hermes` command and the default agent use too, whichever order you installed them in. Removing the app under _Remove > AI_ takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself unless you tell it otherwise: it asks, defaulting to no, whether that data and your connection settings should go too. +Hermes Desktop is the one to know about, because there is only ever one Hermes on a machine. The app only runs against a runtime built from its own commit, so it installs one of its own under `~/.hermes` on first launch, which takes a few minutes and shows its own progress. From then on that is the Hermes the terminal `hermes` command and the default agent use too, whichever order you installed them in. Installing it also hands Hermes the Omarchy theme as a skin named `omarchy`, which every Hermes surface follows as you switch themes; pick another under Hermes' Appearance settings or with `/skin` if you'd rather it didn't, and Omarchy leaves that choice alone. Removing the app under _Remove > AI_ takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself unless you tell it otherwise: it asks, defaulting to no, whether that data and your connection settings should go too. OpenClaw's desktop experience is its Control UI, which opens as a web app backed by its own local gateway. OpenClaw updates arrive through Omarchy's package updates, so skip the Control UI's own "Update Gateway" button: it would try to write into the package-managed install and fail. Removing OpenClaw under _Remove > AI_ takes the gateway service and the app with it and then asks whether `~/.openclaw` should go too, since that holds your chats and credentials alongside the plugin runtimes OpenClaw downloads for itself; the default keeps it. diff --git a/migrations/1788619462.sh b/migrations/1788619462.sh new file mode 100644 index 00000000..56f6c39a --- /dev/null +++ b/migrations/1788619462.sh @@ -0,0 +1,12 @@ +echo "Hand Hermes Desktop the Omarchy theme as a skin" + +# Only the app Omarchy installed under Install > AI follows the theme by itself. +# A Hermes the user set up some other way keeps whatever skin they chose. +omarchy-pkg-present hermes-desktop || exit 0 + +# Renders the skin for a theme applied before the template existed, publishes +# it, and names it in Hermes' config the same way a fresh install does: through +# Hermes itself, and only when Hermes is still on its default skin, so a skin +# the user chose stays. A Hermes that is not ready is told and skipped; a Hermes +# that refuses the write is cosmetic and must not hold up later migrations. +omarchy-theme-set-hermes --activate || true diff --git a/test/shell.d/hermes-skin-migration-test.sh b/test/shell.d/hermes-skin-migration-test.sh new file mode 100644 index 00000000..9e27bf96 --- /dev/null +++ b/test/shell.d/hermes-skin-migration-test.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +set -euo pipefail + +# The migration hands an existing Hermes Desktop install the Omarchy skin. It +# is exercised here with the package probe and the skin hook stubbed, so a +# migration that reached a Hermes Omarchy did not install, or that let a +# Hermes refusing the write hold up later migrations, shows up in what it ran. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1788619462.sh" +[[ -f $migration ]] || fail "Hermes skin migration exists" +[[ $(stat -c %a "$migration") == "644" ]] || fail "migration is a plain 0644 file" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +calls="$test_tmp/calls" +mkdir -p "$mock_bin" + +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]] +SH + +cat >"$mock_bin/omarchy-theme-set-hermes" <<'SH' +#!/bin/bash +echo "omarchy-theme-set-hermes $*" >>"$OMARCHY_TEST_CALLS" +[[ ${OMARCHY_TEST_HOOK_FAILS:-0} == 0 ]] +SH + +chmod +x "$mock_bin"/* + +run_migration() { + : >"$calls" + OMARCHY_TEST_DESKTOP_INSTALLED="${OMARCHY_TEST_DESKTOP_INSTALLED:-1}" \ + OMARCHY_TEST_HOOK_FAILS="${OMARCHY_TEST_HOOK_FAILS:-0}" \ + OMARCHY_TEST_CALLS="$calls" \ + PATH="$mock_bin:$PATH" \ + HOME="$test_tmp/home" \ + OMARCHY_PATH="$ROOT" \ + bash -euo pipefail "$migration" >/dev/null +} + +OMARCHY_TEST_DESKTOP_INSTALLED=0 run_migration || fail "migration exits clean without Hermes Desktop" +[[ ! -s $calls ]] || fail "a machine without Hermes Desktop is left alone" "$(cat "$calls")" +pass "migration only applies where Omarchy installed Hermes Desktop" + +run_migration || fail "migration exits clean with Hermes Desktop installed" +[[ $(cat "$calls") == "omarchy-theme-set-hermes --activate" ]] || + fail "the skin is rendered, published and activated through the hook's deliberate form" "$(cat "$calls")" +pass "migration hands the skin over through the hook" + +OMARCHY_TEST_HOOK_FAILS=1 run_migration || fail "a skin the hook cannot hand over does not hold up later migrations" +pass "migration tolerates a skin the hook could not activate" diff --git a/test/shell.d/hermes-theme-test.sh b/test/shell.d/hermes-theme-test.sh new file mode 100644 index 00000000..6a68cb70 --- /dev/null +++ b/test/shell.d/hermes-theme-test.sh @@ -0,0 +1,267 @@ +#!/bin/bash + +set -euo pipefail + +# omarchy-theme-set-hermes writes a file another program parses and asks that +# program to switch to it while it is still on its default. Both are exercised +# here against a throwaway HOME with the Hermes readiness probe, the hermes +# command and the theme refresh stubbed, so a skin that stopped being +# validated, a write into a Hermes that was never set up, or an activation +# that trampled a chosen skin shows up in what landed on disk and what was run. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +mkdir -p "$mock_bin" + +cat >"$mock_bin/omarchy-install-hermes-cli" <<'SH' +#!/bin/bash +echo "check" >>"$OMARCHY_TEST_HERMES_CALLS" +[[ $1 == "--check" && ${OMARCHY_TEST_HERMES_READY:-0} == "1" ]] +SH + +# A theme switch finishes the hand-over only for the desktop app Omarchy +# installed; --activate is asked for by name and does not look. +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ $1 == "hermes-desktop" && ${OMARCHY_TEST_DESKTOP_INSTALLED:-1} == "1" ]] +SH + +cat >"$mock_bin/hermes" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$OMARCHY_TEST_HERMES_CALLS" +if [[ $1 == "config" && $2 == "get" ]]; then + printf '%s\n' "${OMARCHY_TEST_HERMES_SKIN:-default}" +fi +SH + +# A refresh re-stages the current theme, which is where the skin gets rendered. +cat >"$mock_bin/omarchy-theme-refresh" <<'SH' +#!/bin/bash +echo "refresh" >>"$OMARCHY_TEST_HERMES_CALLS" +printf 'name: omarchy\ndescription: Omarchy system theme\ncolors:\n background: "#1a1b26"\n' \ + >"$HOME/.local/state/omarchy/current/theme/hermes.yaml" +SH + +# --wait sleeps between its polls and once more after activating; the stub +# records the delays it was asked for and returns at once. +cat >"$mock_bin/sleep" <<'SH' +#!/bin/bash +printf 'sleep %s\n' "$1" >>"$OMARCHY_TEST_HERMES_CALLS" +SH + +chmod +x "$mock_bin"/* + +good_skin='name: omarchy +description: Omarchy system theme +colors: + background: "#1a1b26" + ui_text: "#a9b1d6" + ui_accent: "#7aa2f7"' + +test_home="$test_tmp/home" +hermes_home="$test_home/.hermes" +skin="$hermes_home/skins/omarchy.yaml" +hermes_calls="$test_tmp/hermes-calls" + +# Each case gets a fresh HOME so no file survives from the one before. The +# Hermes home is created the way provisioning does on every machine; only +# --set-up adds the config that says Hermes itself has run, on its default +# skin unless --on names another. +reset_home() { + local source="$good_skin" + + rm -rf "$test_home" + mkdir -p "$test_home/.local/state/omarchy/current/theme" "$hermes_home/skills" + : >"$hermes_calls" + + while (( $# > 0 )); do + case "$1" in + --set-up) printf 'display:\n skin: default\n' >"$hermes_home/config.yaml" ;; + --on) printf 'display:\n skin: %s\n' "$2" >"$hermes_home/config.yaml"; shift ;; + *) source="$1" ;; + esac + shift + done + + printf '%s\n' "$source" >"$test_home/.local/state/omarchy/current/theme/hermes.yaml" +} + +run_hook() { + OMARCHY_TEST_HERMES_READY="${OMARCHY_TEST_HERMES_READY:-0}" \ + OMARCHY_TEST_HERMES_SKIN="${OMARCHY_TEST_HERMES_SKIN:-default}" \ + OMARCHY_TEST_HERMES_CALLS="$hermes_calls" \ + OMARCHY_TEST_DESKTOP_INSTALLED="${OMARCHY_TEST_DESKTOP_INSTALLED:-1}" \ + PATH="$mock_bin:$PATH" \ + HOME="$test_home" \ + HERMES_HOME='' \ + "$ROOT/bin/omarchy-theme-set-hermes" "$@" +} + +# -- publishing --------------------------------------------------------------- + +reset_home +run_hook +[[ ! -e $hermes_home/skins ]] || fail "a Hermes home that only holds the Omarchy skill gets no skin" +[[ ! -s $hermes_calls ]] || fail "nothing is run for a Hermes that never ran" "$(cat "$hermes_calls")" +pass "a theme switch leaves a machine that never ran Hermes alone" + +reset_home --set-up +mkdir -p "$hermes_home/profiles/work" +run_hook 2>"$test_tmp/stderr" +diff -q "$test_home/.local/state/omarchy/current/theme/hermes.yaml" "$skin" >/dev/null || + fail "the generated skin is published to ~/.hermes/skins/omarchy.yaml" +diff -q "$skin" "$hermes_home/profiles/work/skins/omarchy.yaml" >/dev/null || + fail "an existing Hermes profile gets the skin too" +[[ $(ls "$hermes_home/skins") == "omarchy.yaml" ]] || fail "no temporary file is left beside the skin" +[[ $(cat "$hermes_calls") == "check" ]] || fail "a Hermes that is not ready is asked nothing more" "$(cat "$hermes_calls")" +[[ ! -s $test_tmp/stderr ]] || fail "a theme switch says nothing about Hermes" "$(cat "$test_tmp/stderr")" +pass "the skin is published to the Hermes home and every profile" + +reset_home --set-up 'name: omarchy +description: Omarchy system theme +colors: + background: "{{ background }}"' +mkdir -p "$hermes_home/skins" +printf 'name: omarchy\ncolors:\n background: "#000000"\n' >"$skin" +run_hook 2>"$test_tmp/stderr" +grep -q '#000000' "$skin" || fail "an unresolved placeholder keeps the previous skin in place" +grep -q 'not a plain color palette' "$test_tmp/stderr" || fail "an unresolved placeholder is reported" +pass "a skin with unresolved colors is not published" + +for bad in \ + $'name: omarchy\ndescription: Omarchy system theme\ncolors:\n background: "#1a1b26"\nbanner_logo: "[link=file:///etc/passwd]x[/link]"' \ + $'name: omarchy\ndescription: Omarchy system theme\ncolors:\n background: "#1a1b26\\"\\n ui_text: \\"#ffffff"' \ + $'name: nord\ndescription: Nord\ncolors:\n background: "#2e3440"' \ + $'description: Omarchy system theme\ncolors:\n background: "#1a1b26"' \ + $'name: omarchy\ndescription: Nord: arctic palette\ncolors:\n background: "#2e3440"'; do + reset_home --set-up "$bad" + run_hook 2>/dev/null + [[ ! -e $skin ]] || fail "a skin that is not exactly a named palette of hex colors is not published" "$bad" +done +pass "a skin is held to the shape Hermes loads" + +# -- a theme switch finishes a missed hand-over --------------------------------- + +reset_home --set-up +OMARCHY_TEST_HERMES_READY=1 run_hook 2>"$test_tmp/stderr" +[[ $(cat "$hermes_calls") == $'check\nconfig get display.skin\nconfig set display.skin omarchy' ]] || + fail "a ready Hermes still on its default is switched by a theme switch" "$(cat "$hermes_calls")" +[[ ! -s $test_tmp/stderr ]] || fail "a theme switch activates quietly" "$(cat "$test_tmp/stderr")" +pass "a theme switch activates the skin on a Hermes still on its default" + +reset_home --on omarchy +OMARCHY_TEST_HERMES_READY=1 run_hook +[[ -f $skin ]] || fail "the skin is published when it is already active" +[[ ! -s $hermes_calls ]] || fail "a Hermes already on the skin is not started" "$(cat "$hermes_calls")" +pass "a theme switch does not start a Hermes already on the skin" + +reset_home --set-up +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_DESKTOP_INSTALLED=0 run_hook +[[ -f $skin ]] || fail "a Hermes installed some other way still gets the skin published" +[[ ! -s $hermes_calls ]] || fail "a theme switch does not touch a Hermes Omarchy did not install as the app" "$(cat "$hermes_calls")" +pass "a theme switch activates only for the desktop app Omarchy installed" + +reset_home --set-up +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_DESKTOP_INSTALLED=0 run_hook --activate 2>/dev/null +grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || + fail "--activate switches whichever Hermes it is asked about" "$(cat "$hermes_calls")" +pass "--activate does not ask which Hermes it is" + +reset_home --on ares +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SKIN=ares run_hook 2>"$test_tmp/stderr" +! grep -q 'config set' "$hermes_calls" || fail "a theme switch never replaces a chosen skin" "$(cat "$hermes_calls")" +[[ ! -s $test_tmp/stderr ]] || fail "a chosen skin is left without comment on a theme switch" "$(cat "$test_tmp/stderr")" +pass "a theme switch leaves a skin the user chose in Hermes" + +# -- activation --------------------------------------------------------------- + +reset_home +run_hook --activate 2>"$test_tmp/stderr" +[[ ! -e $hermes_home/skins && ! -e $hermes_home/config.yaml ]] || + fail "--activate writes nothing into a Hermes that has never run" +grep -q 'not set up yet' "$test_tmp/stderr" || fail "--activate says why nothing happened" +pass "--activate waits for Hermes to have been set up" + +reset_home --set-up +run_hook --activate 2>"$test_tmp/stderr" +[[ -f $skin ]] || fail "--activate publishes the skin when Hermes is not ready" +[[ $(cat "$hermes_calls") == "check" ]] || fail "a Hermes that is not ready is not run" "$(cat "$hermes_calls")" +grep -q 'hermes config set display.skin omarchy' "$test_tmp/stderr" || fail "an unready Hermes gets the command that finishes the job" +pass "--activate publishes but does not run a Hermes that is not ready" + +reset_home --set-up +OMARCHY_TEST_HERMES_READY=1 run_hook --activate 2>"$test_tmp/stderr" +[[ $(cat "$hermes_calls") == $'check\nconfig get display.skin\nconfig set display.skin omarchy' ]] || + fail "a ready Hermes is asked for its skin and then to switch" "$(cat "$hermes_calls")" +grep -q 'on the Omarchy skin' "$test_tmp/stderr" || fail "--activate reports success" +pass "--activate goes through hermes config set when Hermes runs" + +reset_home --on omarchy +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SKIN=omarchy run_hook --activate 2>/dev/null +grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || + fail "--activate goes through Hermes even when the config already names the skin" "$(cat "$hermes_calls")" +pass "--activate always asks Hermes to switch" + +for chosen in omarchy ""; do + reset_home --set-up + OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SKIN="$chosen" run_hook --activate 2>/dev/null + grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || + fail "the default and the omarchy skin are both replaced" "skin='$chosen': $(cat "$hermes_calls")" +done +pass "--activate replaces Hermes' default skin" + +reset_home --set-up +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SKIN=ares run_hook --activate 2>"$test_tmp/stderr" +[[ -f $skin ]] || fail "a chosen skin still gets the Omarchy skin published beside it" +! grep -q 'config set' "$hermes_calls" || fail "a skin the user chose is not replaced" "$(cat "$hermes_calls")" +grep -q "'ares' skin" "$test_tmp/stderr" || fail "leaving a chosen skin is reported" +pass "--activate leaves a skin the user chose in Hermes" + +# -- a skin the current theme has not rendered yet ------------------------------- + +reset_home --set-up +rm "$test_home/.local/state/omarchy/current/theme/hermes.yaml" +run_hook +[[ ! -e $hermes_home/skins && ! -s $hermes_calls ]] || + fail "a theme switch without a rendered skin publishes nothing" "$(cat "$hermes_calls")" +pass "a theme switch has nothing to do without a rendered skin" + +reset_home --set-up +rm "$test_home/.local/state/omarchy/current/theme/hermes.yaml" +if run_hook --activate 2>"$test_tmp/stderr"; then + fail "--activate fails when no theme has been selected" +fi +grep -q 'Select an Omarchy theme' "$test_tmp/stderr" || fail "a missing theme is reported" +pass "--activate fails without a current theme to render the skin from" + +reset_home --set-up +rm "$test_home/.local/state/omarchy/current/theme/hermes.yaml" +echo tokyo-night >"$test_home/.local/state/omarchy/current/theme.name" +OMARCHY_TEST_HERMES_READY=1 run_hook --activate 2>/dev/null +[[ $(head -1 "$hermes_calls") == "refresh" ]] || fail "a theme applied before the template existed is re-staged" "$(cat "$hermes_calls")" +[[ -f $skin ]] || fail "the freshly rendered skin is published" +grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || fail "the freshly rendered skin is activated" "$(cat "$hermes_calls")" +pass "--activate renders the skin for a theme that predates it" + +# -- waiting for the desktop app's first launch ---------------------------------- + +reset_home --set-up +mkdir -p "$hermes_home/hermes-agent" +touch "$hermes_home/hermes-agent/.hermes-bootstrap-complete" +OMARCHY_TEST_HERMES_READY=1 run_hook --wait 2>/dev/null +[[ $(cat "$hermes_calls") == $'check\nconfig get display.skin\nconfig set display.skin omarchy\nsleep 60' ]] || + fail "--wait activates as soon as the runtime marker is there, then republishes after the gateway is up" "$(cat "$hermes_calls")" +[[ -f $skin ]] || fail "--wait publishes the skin" +pass "--wait activates once the desktop app has built its runtime" + +reset_home +OMARCHY_TEST_HERMES_READY=1 run_hook --wait 2>"$test_tmp/stderr" +[[ $(head -1 "$hermes_calls") == "sleep 10" && ! -e $hermes_home/skins ]] || + fail "--wait polls for the runtime instead of running Hermes" "$(head -3 "$hermes_calls")" +[[ $(grep -c 'sleep 10' "$hermes_calls") == 180 ]] || fail "--wait gives up after 30 minutes" "$(grep -c 'sleep 10' "$hermes_calls")" +grep -q 'did not finish setting up' "$test_tmp/stderr" || fail "giving up is reported" +pass "--wait polls until the desktop app has built its runtime and gives up in time" diff --git a/test/shell.d/theme-staging-test.sh b/test/shell.d/theme-staging-test.sh index cc749a9e..9ec3b0f9 100755 --- a/test/shell.d/theme-staging-test.sh +++ b/test/shell.d/theme-staging-test.sh @@ -207,7 +207,7 @@ pass "a theme name cannot climb out of the theme directories" # generates. Every generated theme file is either denied to an installed theme or # recorded here as carrying colour, so a new template fails until it is placed. denied=(alacritty.toml foot.ini ghostty.conf kitty.conf gum_env.lua hyprland.lua neovim.lua vscode.json) -colour_only=(btop.theme chromium.theme claude.json helix.toml hyprland-preview-share-picker.css keyboard.rgb obsidian.css pi.json shell.toml vscode-theme.json) +colour_only=(btop.theme chromium.theme claude.json helix.toml hermes.yaml hyprland-preview-share-picker.css keyboard.rgb obsidian.css pi.json shell.toml vscode-theme.json) for tpl in "$ROOT"/default/themed/*.tpl; do generated=$(basename "$tpl" .tpl)