From a12a21c02fbc945ac1de73633494df67908ae28a Mon Sep 17 00:00:00 2001 From: Omabot Date: Wed, 26 Aug 2026 18:04:41 +0200 Subject: [PATCH 01/19] Add Hermes as a desktop app and a coding agent Hermes joins Install > AI as a desktop app, sits beside it under Remove > AI, and becomes a choice in the default-agent list. The CLI installs through omarchy-install-hermes-cli rather than a bare `mise use`, so its interpreter is pinned before mise builds it. Rebased onto quattro. Ori claimed U+E909 in #7709 while this branch was open, so the Hermes mark moves to U+E90A in the icon font, the menu entries, the font README, and the charset the menu test pins. The glyph outline itself is unchanged; it is spliced in beside Ori rather than over it. Co-Authored-By: witcheer Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SySdB3RtCA8BNv6Am246BP --- bin/omarchy-agent | 9 ++ bin/omarchy-default-agent | 24 ++++- bin/omarchy-install-ai-hermes | 26 ++++++ bin/omarchy-install-hermes-cli | 136 +++++++++++++++++++++++++++++ bin/omarchy-remove-ai-hermes | 49 +++++++++++ bin/omarchy-remove-preinstalls | 4 + default/fonts/omarchy/README.md | 1 + default/fonts/omarchy/omarchy.ttf | Bin 4668 -> 9008 bytes default/omarchy/omarchy-menu.jsonc | 3 + install/user/mise.sh | 1 + test/shell.d/hermes-cli-test.sh | 105 ++++++++++++++++++++++ test/shell.d/hermes-remove-test.sh | 77 ++++++++++++++++ test/shell.d/menu-test.sh | 5 +- 13 files changed, 434 insertions(+), 6 deletions(-) create mode 100755 bin/omarchy-install-ai-hermes create mode 100755 bin/omarchy-install-hermes-cli create mode 100755 bin/omarchy-remove-ai-hermes create mode 100755 test/shell.d/hermes-cli-test.sh create mode 100755 test/shell.d/hermes-remove-test.sh diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 3c009460..426ea85a 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -86,6 +86,15 @@ codex) command=(codex --approve-for-me) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") ;; +hermes) + # Hermes has no "start interactive, seeded with this prompt" mode. --oneshot + # answers the prompt and exits, which is the closest it offers. + if [[ -n ${prompt:-} ]]; then + command=(hermes --yolo --oneshot "$prompt") + else + command=(hermes --yolo) + fi + ;; omp) command=(omp --auto-approve) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") diff --git a/bin/omarchy-default-agent b/bin/omarchy-default-agent index 1f89b765..9d575514 100755 --- a/bin/omarchy-default-agent +++ b/bin/omarchy-default-agent @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Set and launch the default coding agent -# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|agy|copilot|crush] +# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|agy|hermes|copilot|crush] # omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude installing=false @@ -33,20 +33,36 @@ codex) agent="codex"; name="Codex" ;; crush) agent="crush"; name="Crush" ;; grok) agent="grok"; name="Grok"; agent_package="npm:@xai-official/grok" ;; agy | antigravity | antigravity-cli | gemini | gemini-cli) agent="agy"; name="Antigravity"; agent_package="antigravity-cli" ;; +hermes) agent="hermes"; name="Hermes"; agent_installer="omarchy-install-hermes-cli" ;; copilot | github-copilot) agent="copilot"; name="GitHub Copilot" ;; *) - echo "Usage: omarchy-default-agent " + echo "Usage: omarchy-default-agent " exit 1 ;; esac agent_package=${agent_package:-$agent} -if [[ $installing == "false" ]] && ! mise where "$agent_package" &>/dev/null; then +# Hermes reaches mise through its own installer rather than straight from +# here: it needs its interpreter pinned, and a bare `mise use` has nowhere to +# say so. See omarchy-install-hermes-cli. +if [[ -n ${agent_installer:-} ]]; then + # Not omarchy-cmd-present: the stub is on PATH from first boot and says + # nothing about whether Hermes is installed behind it. Treating a cold stub + # as installed skips the floating terminal and runs the minute-long install + # inside the menu action instead. + agent_present() { "$agent_installer" --check; } + agent_install() { "$agent_installer" --now; } +else + agent_present() { mise where "$agent_package" &>/dev/null; } + agent_install() { mise use -g "$agent_package"; } +fi + +if [[ $installing == "false" ]] && ! agent_present; then exec omarchy-launch-floating-terminal-with-presentation omarchy-default-agent --install "$agent" fi -if ! mise use -g "$agent_package"; then +if ! agent_install; then if [[ $installing == "true" ]]; then echo "Could not install $name with mise" >&2 else diff --git a/bin/omarchy-install-ai-hermes b/bin/omarchy-install-ai-hermes new file mode 100755 index 00000000..3f6abafe --- /dev/null +++ b/bin/omarchy-install-ai-hermes @@ -0,0 +1,26 @@ +#!/bin/bash + +# omarchy:summary=Install the Hermes desktop app +# omarchy:requires-sudo=true + +set -e + +# No CLI is installed here on purpose. Hermes Desktop only runs against a +# runtime built from its own commit, so it provisions one itself under +# ~/.hermes on first launch, which takes a few minutes and shows its own +# progress. Handing it the mise CLI instead fails: PyPI trails the tags, and +# the version gap fails the app's readiness probe with a 401. +echo "Installing Hermes Desktop..." +omarchy-pkg-add hermes-desktop + +# If Hermes was already installed for the terminal, the app supersedes it: one +# machine, one Hermes. This drops that copy so the terminal, the default agent +# and the app all end up on the app's installation. +omarchy-install-hermes-cli || true + +echo "Opening Hermes Desktop..." +setsid uwsm-app -- /usr/bin/hermes-desktop >/dev/null 2>&1 & + +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-install-hermes-cli b/bin/omarchy-install-hermes-cli new file mode 100755 index 00000000..c153013b --- /dev/null +++ b/bin/omarchy-install-hermes-cli @@ -0,0 +1,136 @@ +#!/bin/bash + +# omarchy:summary=Install the Hermes CLI as a mise-backed wrapper in ~/.local/bin +# omarchy:args=[--now] +# omarchy:examples=omarchy install hermes cli | omarchy install hermes cli --now + +# Hermes pins every one of its dependencies exactly and declares +# Requires-Python >=3.11,<3.14, so it can neither be built against Arch's +# Python nor share the python-* packages. mise builds it a private environment +# instead. +# +# It gets its own installer rather than a line in omarchy-mise-install because +# of the interpreter pin. Given no compatible interpreter to hand, uv builds +# the venv against the system Python in violation of Hermes' own bound, +# reports success, and leaves the breakage to surface later inside a +# dependency -- and omarchy-mise-install writes a fixed stub with nowhere to +# say otherwise. +# +# There is only ever one Hermes on a machine. hermes-desktop cannot run against +# this one -- it needs a runtime built from its own commit, and the version gap +# fails its readiness probe -- so it installs its own under ~/.hermes and puts +# that on PATH. When the package is present it therefore owns Hermes outright: +# this installer stands aside and removes its own copy, so the terminal, the +# default agent and the app are all the same installation. + +set -euo pipefail + +mode=${1:-} + +tool='pipx:hermes-agent[extras=all]' +python='3.13' + +# The package, not the runtime directory: it is installed before the app has +# ever run, and that is exactly when we must not start building a second copy. +desktop_owns_hermes() { + omarchy-pkg-present hermes-desktop +} + +# The venv appears at the python-deps stage, several stages before the one that +# installs the command, so its presence says nothing about being usable. The +# marker is written last, and the command is what the agent actually runs. +desktop_hermes_ready() { + [[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]] || return 1 + [[ -x $HOME/.local/bin/hermes ]] || return 1 + + # An executable of that name proves nothing about whose it is; the app's own + # points into ~/.hermes, and anything else is not the install we are asking + # about. + grep -q "$HOME/.hermes" "$HOME/.local/bin/hermes" +} + +# Whether Hermes is really installed, not merely whether the stub exists. A +# stub on its own is cold: running it installs Hermes, which takes minutes. +installed() { + [[ -d "$(mise where "$tool" 2>/dev/null)/hermes-agent/lib/python$python" ]] +} + +# --check lets callers tell a cold stub from a working one before they commit +# to a path that assumes Hermes is ready. +if [[ $mode == "--check" ]]; then + if desktop_owns_hermes; then + if desktop_hermes_ready; then exit 0; else exit 1; fi + fi + if installed; then exit 0; else exit 1; fi +fi + +# Hand Hermes over to the app rather than keeping a second copy beside it. +if desktop_owns_hermes; then + # Not gated on that copy being healthy: `mise up` can rebuild it against the + # wrong interpreter and a half-finished install answers to neither test, and + # either way it is still a second Hermes. Removing nothing is harmless. + if mise where "$tool" >/dev/null 2>&1; then + echo "Hermes Desktop provides Hermes; removing the separate CLI install..." >&2 + fi + + mise rm -g "$tool" >/dev/null 2>&1 || true + mise uninstall --all "$tool" >/dev/null 2>&1 || true + + # Our own stub has to go with it. Left in place it still answers `hermes` + # until the app's bootstrap overwrites it, and answering means building the + # second Hermes this whole arrangement exists to avoid. + if [[ -f $HOME/.local/bin/hermes ]] && grep -q omarchy-install-hermes-cli "$HOME/.local/bin/hermes"; then + rm -f "$HOME/.local/bin/hermes" + fi + + if desktop_hermes_ready; then + exit 0 + fi + + echo "Hermes Desktop is installed but has not set Hermes up yet." >&2 + echo "Launch Hermes Desktop once to finish installing it." >&2 + exit 1 +fi + +mkdir -p "$HOME/.local/bin" +rm -f "$HOME/.local/bin/hermes" + +cat >"$HOME/.local/bin/hermes" </dev/null)/hermes-agent/lib/python$python" ]]; then + echo "Installing Hermes on Python $python (this takes a minute)..." >&2 + + # mise's pipx backend shells out to uv, which a stock Omarchy does not have. + # It is fetched here rather than when this stub was written, so setting up a + # machine that never runs Hermes costs nothing. + if omarchy-cmd-missing uv && ! mise where uv >/dev/null 2>&1; then + mise use -g --quiet uv@latest || exit 1 + fi + + mise use -g --quiet --force '$tool' || exit 1 +fi + +exec mise x '$tool' -- hermes "\$@" +EOF + +chmod +x "$HOME/.local/bin/hermes" + +# The desktop app resolves a hermes on PATH by running `hermes --version` with +# a 15 second budget, then falls back to cloning its own copy when that times +# out. A first-run mise install does not fit in 15 seconds, so anything that +# hands Hermes to the GUI has to install it here rather than leave it stubbed. +if [[ $mode == "--now" ]]; then + "$HOME/.local/bin/hermes" --version +fi diff --git a/bin/omarchy-remove-ai-hermes b/bin/omarchy-remove-ai-hermes new file mode 100755 index 00000000..830ca2ad --- /dev/null +++ b/bin/omarchy-remove-ai-hermes @@ -0,0 +1,49 @@ +#!/bin/bash + +# omarchy:summary=Remove the Hermes desktop app along with the Hermes runtime it installed. +# omarchy:requires-sudo=true + +set -e + +omarchy-pkg-drop hermes-desktop + +# The app installs a Hermes of its own under ~/.hermes -- the checkout and venv, +# its own uv, its own node -- and puts its commands on PATH. None of it is any +# use once the app is gone. Not ~/.config/Hermes, which holds the gateway +# connections and their encrypted tokens, the active profile and the update +# settings. Not the rest of ~/.hermes either: +# the chats, memories and the skills Hermes wrote for itself are the user's, +# they are small, and finding them still there after a reinstall is the better +# surprise. +rm -rf \ + "$HOME/.hermes/hermes-agent" \ + "$HOME/.hermes/bootstrap-cache" \ + "$HOME/.hermes/bin" \ + "$HOME/.hermes/node" + +# Only the wrappers pointing into ~/.hermes. The app writes these at its own +# path stage, so a machine where it was installed but never launched still has +# whatever was there before, and that is not ours to delete. +for command in hermes hermes-agent hermes-acp; do + wrapper="$HOME/.local/bin/$command" + + if [[ -f $wrapper ]] && grep -q "$HOME/.hermes" "$wrapper"; then + rm -f "$wrapper" + fi +done + +# When Hermes brought its own Node it symlinked these next to its own commands, +# and they point at what we just deleted. Only the links into ~/.hermes: a +# system Node, or someone else's, lives somewhere else entirely. +for command in node npm npx; do + link="$HOME/.local/bin/$command" + + if [[ -L $link && $(readlink "$link") == "$HOME/.hermes"/* ]]; then + rm -f "$link" + fi +done + +echo "" +echo "Hermes Desktop has been removed." +echo "Your chats, memories, and skills are still in ~/.hermes," +echo "and your connections and settings in ~/.config/Hermes." diff --git a/bin/omarchy-remove-preinstalls b/bin/omarchy-remove-preinstalls index c096ca2e..8f5ed5a9 100755 --- a/bin/omarchy-remove-preinstalls +++ b/bin/omarchy-remove-preinstalls @@ -17,6 +17,10 @@ if gum confirm "Are you sure you want to remove all preinstalled web apps, TUI w ~/.local/bin/gh ~/.local/bin/opencode ~/.local/bin/playwright ~/.local/bin/playwright-cli ~/.local/bin/pi \ ~/.local/bin/omp ~/.local/bin/ori ~/.local/bin/grok ~/.local/bin/crush ~/.local/bin/ghui ~/.local/bin/hunk + # Hermes Desktop owns this path once installed, and its own CLI is not a + # preinstall to sweep away. + omarchy-pkg-present hermes-desktop || rm -f ~/.local/bin/hermes + omarchy-pkg-drop \ aether \ cliamp \ diff --git a/default/fonts/omarchy/README.md b/default/fonts/omarchy/README.md index 005fc874..11558a30 100644 --- a/default/fonts/omarchy/README.md +++ b/default/fonts/omarchy/README.md @@ -12,6 +12,7 @@ The private-use glyphs in `omarchy.ttf` are: - `U+E907` — Ollama, from - `U+E908` — T3 Code, traced from the app icon in , since upstream publishes no monochrome SVG - `U+E909` — Ori, from , OpenRouter's own mark: Ori ships no separate logo and its product page uses this one +- `U+E90A` — Hermes, traced from the Hermes app icon (), the same art the `hermes-desktop` package ships as its icon The agent marks are monochrome so the menu can render them using the active theme's foreground and selection colors. diff --git a/default/fonts/omarchy/omarchy.ttf b/default/fonts/omarchy/omarchy.ttf index fc47d0131ccb06e27d4a01e0014f5a22f393aea3..29145211c45d05ef88767dc30d750fbe84d238a2 100644 GIT binary patch delta 4770 zcmY*cTZklA8Lt1_Pu=@cRns%mnceQ0sx_O;rF*(tlb8#}Tt%aT4-t*)y1L@72}VPd zNEr|n6j4WlH}JtX@j+Nc#l#B;5=5gA1%2>A#Os3y`X&>aITL)cODa`@jEk zYM$Qx=3SpD+sRLYAh`0nHy+I1^s9UC4gz{P=5M=r^}-{6JNe=VF@HV?MEb5PAAD!~ zZ1R;LP;n4!zxDE^3-8$fTJRwDK8^44G8P=E2;+a@yLj&RH+09mG!*1umq``S!4+Op; zt{`}Ur$KOXjxL@&l%Y~DBd9+m9?D9o8_JIKKY=$BLAa=@@k-J^-A?B zb)R}by;)sUUslhnzvx%!x9O+!w@nfRt71M|E{>P0<#b|=wN1S^UM(@jwX&_Trm36y zK;VY!*i?2t?;b9Xj#*+oU+-T+fx%~SxLP)KH#GDA?T0$MmSjV`57NbazF6>x7lvzN z&16!QB|c5vveg%#8q*A4Le^QbW@T2lip8;64dDcybr=s>+0+z=6?E*GIl$mqQ}eVc zm&c3VbYkY+1h@4^o9Tr2Obw`*&#H2D2*cLEK0~fbK&_y4($vMIJeqY&6J4$_T7f<` z*-VOIQ?5OHaitSJf^ac}T3oudqIyKw*cD6%`<%Gg^StwfWA6vM}&%HlMK#>{~4XZtZ$i zE{-d#PYq~*VmMQ;+U1l5KsQer@%oE#14`E9Rpq=Rg!KXH0?1$;6NGEk1-CKmYUdX} z<8VP)voD&uBYSx?TdQhntXCYA`7!TkF{w%nj=M$1$jkL=#*8+{M+j9@^Bv~akD3OP z0`y?RI-fuXHu09}WXeJ6Y%nv}g^1!2Z{XpPf#oG{UDVB@DqD=!S2KhM4OlCnb+25P z%bw)~09kvGpU>bXlC4tXs3E~U1<;t;s$vg2 z_bV<&YkatU9b_tFnet1f^*h8o+_}Bbehv;Dq8<%+Nml%G_9@sI5Y6(@^(@BR$Zh(y~9uohQ;&nmRA(*UA{1 zM7T+t^`&gi4M*d|nXpWytg@{gCnbq08SF&;Cq2n7Gm_|guX(4s_ojo*-YBwZVk6o-eawz#jNRufO z5}{Tqib>|$04|7;Qu{24wGP7+ShNDMN@{GwqcAfPJ{!Rv;Deh~-brvzB*AhPh6;~i zM}|gAG7+{aqeHVL5u;wOtXgF@E+suEB4g7CS|3*l_DAY*ik!2Z-X!@IbPF>qM*YZA zw~sPyt#L#fp%JEXa4FF+D+CN-b5Cn1#1~sSe1J`6Tv>``JjhF{;GUvZz%AtynL6tA zbfSfnQfXnG%9M_cQb;_`FsOtoXjdw>Y(d5EHt&kSFTS!Xz>By%LXCe)X0Djn_-}4^22xv?JQDdME`PFU1I-&ud z*tAw*nx{Qy`zDJJ5iBFAtWwA^#8KE#u@Y?z5rPYR;KXBK1+}F@Rw6?1O{;fX>`uI) zH(0GHQ@tEnqLuTfPbq+d@7hYGoD(A15mqbxh7O|EQkYa|Z;AvG`EQVJ&gy6??ZyD< z;B3MCNZU(*m|`1}aahl7TTHH-?BBXIIA71sZpTu`QFMBu02zEBS@>&WuZ6~|Zh_Dy znGp$OM+h5|kin|3k}e-?heGW{x$>x1c(@BuBo*ZPC_0c0N0_gUUS<(IXww1(Pb@NR zy#YDQB^j~)AA%PyN27?YcD4L>?2x{t#Eo}t5|D40Z81T9iY7j{X(MbCvs zb7AodNMXo%fCAwkBmEx?$`!}i! zZIm5@Fjjv0anO97^{$sA#?tkpL9uDaXN(;=V{{auahNDd)4sUp850%TZJuScdwwK$ z>LznOuTrAK^@f_PA-SDZOO3ch4AM^Q0%wfAmROkbc=@HhKN6^=oGqOC5=pO+w?cSF zgN{gqCmGxjgR|X19|1kkUWQ>*#KIoRRvwgPkvK9*e_zt|FCW~#LS4rFI2!43CC6GHkZNe(5hu^H8uAmk~U zq_T8Od`Dyo@@5ZAr zy912CbJR*MDPTd{K-*`!5eLZN3sabKDWZ0`aj-Zi8hR3bxO}+k<*GkBJ9aT%siLHR*t|MLNv5$#M?;h@*M+iC(LbAlZ!mqv z=zP!?9)~e|xye~2e51*2jCVIt&f8(;21q!&wc{yGaiAJIsSJ*1o2KY@VMLz0iXyy# zc=iyd4BNq*+7KkBoFOH+7}zk9XqQ54#^b)9x*jJm$!R28qU#;!5x=$}a(Fo^hG~Z0 zjLXVFPMd~J=sxE)TjO{Dh~blRFZR&fu?^By)c07#yNylJ5F(d`XuiITkXPK}2}e52 zx-!Axk3%!}k=+3()@e-2ZB%uKHXm3b2BXGa`&;%8IB@d29?p~a#F<-f1$%+LS; delta 432 zcmZ9GJxD@P6vzMPy{Attvkamw5&K#S6sp0XscdQp93-fRSq|@|pixa$Lo^mQ=mT0x zXlW4MP(w{c5PeTUQ$ZgTtwLL!D^A_Z;oRT<{tow+22Vn3O$D3)Fh@qa!|{jVA%HrV zCz6(t{Sw=A%&UM>H*GFV9h|j40O}S{GnGymlU*ydKY)*Q^rSg(k=DrE#~eyq`S}j0 zN-7P<|1>OWz1(OL?yJtcg;ay#;{U1AMYD1eDkAM*-S3qoSTaP4aa=H zEY5?b=%b`~q!_tHKsj=|Ip-p${h~N%->W$YqSjqjbzQer&#kHe_PuwPT3KS3B9T2; zK0fY?mQkZ|0KDE^&dUD2c{RHJ-0Sp9(8P<NA9$Y2GZ zbgC?AZ;q71W=B5nry^W(0Q@g_cmux@E2|zC6-&B0Cw3w5MC2EVU`Q+mqdf8l9ky9G diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 59582db7..08f9bf4b 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -141,6 +141,7 @@ "setup.default.agent.copilot": {"icon":"","label":"Copilot","checked":"[[ \"$(omarchy-default-agent)\" == \"copilot\" ]]","action":"omarchy-default-agent copilot"}, "setup.default.agent.crush": {"icon":"󰋑","label":"Crush","checked":"[[ \"$(omarchy-default-agent)\" == \"crush\" ]]","action":"omarchy-default-agent crush"}, "setup.default.agent.grok": {"icon":"","iconFont":"omarchy","label":"Grok","checked":"[[ \"$(omarchy-default-agent)\" == \"grok\" ]]","action":"omarchy-default-agent grok"}, + "setup.default.agent.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes","checked":"[[ \"$(omarchy-default-agent)\" == \"hermes\" ]]","action":"omarchy-default-agent hermes"}, "setup.default.agent.omp": {"icon":"","iconFont":"omarchy","label":"omp","checked":"[[ \"$(omarchy-default-agent)\" == \"omp\" ]]","action":"omarchy-default-agent omp"}, "setup.default.agent.opencode": {"icon":"","iconFont":"omarchy","label":"OpenCode","checked":"[[ \"$(omarchy-default-agent)\" == \"opencode\" ]]","action":"omarchy-default-agent opencode"}, "setup.default.agent.ori": {"icon":"","iconFont":"omarchy","label":"Ori","checked":"[[ \"$(omarchy-default-agent)\" == \"ori\" ]]","action":"omarchy-default-agent ori"}, @@ -239,6 +240,7 @@ "install.ai.chatgpt": {"icon":"","iconFont":"omarchy","label":"ChatGPT Desktop","disabled":"omarchy-pkg-present openai-codex-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-chatgpt"}, "install.ai.dictation": {"icon":"","label":"Dictation","disabled":"omarchy-pkg-present voxtype-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install"}, "install.ai.grok-bot": {"icon":"","iconFont":"omarchy","label":"Grok Bot","disabled":"omarchy-pkg-present grok-bot","action":"omarchy-install-and-launch 'Grok Bot' grok-bot grok-bot"}, + "install.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","disabled":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-hermes"}, "install.ai.lm-studio": {"icon":"","iconFont":"omarchy","label":"LM Studio","disabled":"omarchy-pkg-present lmstudio-bin","action":"omarchy-install-app 'LM Studio' lmstudio-bin"}, "install.ai.ollama": {"icon":"","iconFont":"omarchy","label":"Ollama","disabled":"omarchy-cmd-present ollama","action":"if omarchy-cmd-present nvidia-smi; then ollama_pkg=ollama-cuda; elif omarchy-cmd-present rocminfo; then ollama_pkg=ollama-rocm; else ollama_pkg=ollama; fi; omarchy-install-app Ollama \"$ollama_pkg\""}, "install.ai.t3-code": {"icon":"","iconFont":"omarchy","label":"T3 Code","disabled":"omarchy-pkg-present t3code-bin","action":"omarchy-install-and-launch 'T3 Code' t3code-bin t3code"}, @@ -292,6 +294,7 @@ "remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"}, "remove.security.sshd": {"icon":"󰣀","label":"SSHD","when":"systemctl is-enabled --quiet sshd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sshd"}, "remove.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"! omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"}, + "remove.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","when":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-hermes"}, "remove.browser.chrome": {"icon":"","label":"Chrome","when":"omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser chrome'"}, "remove.browser.edge": {"icon":"󰇩","label":"Edge","when":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser edge'"}, "remove.browser.brave": {"icon":"","label":"Brave","when":"omarchy-pkg-present brave-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser brave'"}, diff --git a/install/user/mise.sh b/install/user/mise.sh index 0c8ab99b..a944d6cb 100644 --- a/install/user/mise.sh +++ b/install/user/mise.sh @@ -13,3 +13,4 @@ omarchy-mise-install npm:@kitlangton/ghui ghui omarchy-mise-install aqua:modem-dev/hunk hunk omarchy-mise-install github:basecamp/hey-cli hey omarchy-mise-install github:OpenRouterLabs/ori-releases ori +omarchy-install-hermes-cli diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh new file mode 100755 index 00000000..881dac47 --- /dev/null +++ b/test/shell.d/hermes-cli-test.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +set -euo pipefail + +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" +test_home="$test_tmp/home" +mise_log="$test_tmp/mise-log" +mkdir -p "$mock_bin" "$test_home/.local/bin" + +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]] +SH + +cat >"$mock_bin/omarchy-cmd-missing" <<'SH' +#!/bin/bash +! command -v "$1" >/dev/null 2>&1 +SH + +# `mise where` must fail so the installer sees no Hermes behind the stub. +cat >"$mock_bin/mise" <<'SH' +#!/bin/bash +printf '%s\0' "$@" >>"$OMARCHY_TEST_MISE_LOG" +[[ $1 == "where" && ${OMARCHY_TEST_MISE_WHERE_OK:-0} == 1 ]] && exit 0 +[[ $1 != "where" ]] +SH + +chmod +x "$mock_bin"/* + +run_installer() { + OMARCHY_TEST_DESKTOP_INSTALLED="$1" \ + OMARCHY_TEST_MISE_WHERE_OK="${OMARCHY_TEST_MISE_WHERE_OK:-0}" \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$test_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" ${2:+"$2"} >/dev/null 2>&1 +} + +stub_marker="omarchy-install-hermes-cli" +app_stub_body='#!/bin/bash +exec /home/x/.hermes/hermes-agent/venv/bin/hermes "$@"' + +# Writing the stub must not provision anything: user setup calls this on every +# machine, including the ones that never run Hermes. +: >"$mise_log" +rm -f "$test_home/.local/bin/hermes" +run_installer 0 || fail "installer failed with no desktop installed" +[[ -x $test_home/.local/bin/hermes ]] || fail "installer writes a hermes stub when the desktop is absent" +grep -q "$stub_marker" "$test_home/.local/bin/hermes" || fail "the stub records which command wrote it" +tr '\0' ' ' <"$mise_log" | grep -q "use -g --quiet uv" && + fail "writing the stub does not install uv" +pass "writing the Hermes stub provisions nothing" + +# The desktop app owns Hermes, so our own stub must go rather than sit there +# answering `hermes` until the app's bootstrap replaces it. +printf '%s\n' "#!/bin/bash" "# $stub_marker" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 1 || true +[[ ! -e $test_home/.local/bin/hermes ]] || + fail "the desktop taking over removes the stub this command wrote" +pass "installing the desktop app removes the CLI stub" + +# ...but the app's own hermes is not ours to delete. +printf '%s\n' "$app_stub_body" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 1 || true +[[ -x $test_home/.local/bin/hermes ]] || + fail "the desktop app's own hermes command survives" +pass "the app's own hermes command is left alone" + +# A copy mise cannot vouch for is still a second Hermes. +printf '%s\n' "#!/bin/bash" "# $stub_marker" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +: >"$mise_log" +OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 1 || true +tr '\0' '\n' <"$mise_log" | grep -q "uninstall" || + fail "takeover removes a mise copy even when it is not healthy" +pass "takeover removes an unhealthy mise copy" + +# --check answers about Hermes being usable, not about the venv appearing. The +# venv exists from the python-deps stage, several stages before the command. +rm -rf "$test_home/.hermes" +rm -f "$test_home/.local/bin/hermes" +run_installer 1 --check && fail "--check reports Hermes missing before the app installs it" +mkdir -p "$test_home/.hermes/hermes-agent/venv/bin" +printf '%s\n' "#!/bin/bash" >"$test_home/.hermes/hermes-agent/venv/bin/hermes" +chmod +x "$test_home/.hermes/hermes-agent/venv/bin/hermes" +run_installer 1 --check && fail "--check waits for the install to finish, not just the venv" +touch "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete" +printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 1 --check || fail "--check reports Hermes present once the app has finished" +pass "--check follows the app's completed install" + +# An executable called hermes that belongs to something else is not this +# install being ready. +printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/somebody-elses-hermes \"\$@\"" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 1 --check && fail "--check rejects a hermes command belonging to something else" +pass "--check rejects a foreign hermes command" diff --git a/test/shell.d/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh new file mode 100755 index 00000000..423a297e --- /dev/null +++ b/test/shell.d/hermes-remove-test.sh @@ -0,0 +1,77 @@ +#!/bin/bash + +set -euo pipefail + +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" +test_home="$test_tmp/home" +mkdir -p "$mock_bin" + +cat >"$mock_bin/omarchy-pkg-drop" <<'SH' +#!/bin/bash +printf '%s\0' "$@" >>"$OMARCHY_TEST_DROP_LOG" +SH +chmod +x "$mock_bin"/* + +seed_install() { + rm -rf "$test_home" + mkdir -p "$test_home/.hermes/hermes-agent" "$test_home/.hermes/bootstrap-cache" \ + "$test_home/.hermes/bin" "$test_home/.hermes/node/bin" \ + "$test_home/.hermes/memories" "$test_home/.hermes/sessions" \ + "$test_home/.config/Hermes" "$test_home/.local/bin" + printf 'chat\n' >"$test_home/.hermes/sessions/one.json" + printf 'memory\n' >"$test_home/.hermes/memories/one.md" + printf 'soul\n' >"$test_home/.hermes/SOUL.md" + printf 'uv\n' >"$test_home/.hermes/bin/uv" + ln -sf "$test_home/.hermes/node/bin/node" "$test_home/.local/bin/node" + ln -sf "$test_home/.hermes/node/bin/npm" "$test_home/.local/bin/npm" + ln -sf /usr/bin/npx "$test_home/.local/bin/npx" + printf 'node\n' >"$test_home/.hermes/node/bin/node" +} + +remove() { + OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" HOME="$test_home" PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-remove-ai-hermes" >/dev/null 2>&1 +} + +# The app brings its own uv and its own node; both are runtime, not data. +seed_install +printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \ + >"$test_home/.local/bin/hermes" +remove || fail "remove succeeds" +[[ ! -d $test_home/.hermes/hermes-agent ]] || fail "the runtime checkout is removed" +[[ ! -d $test_home/.hermes/bin ]] || fail "the uv the app installed is removed" +[[ ! -d $test_home/.hermes/node ]] || fail "the node the app installed is removed" +pass "removal takes the whole runtime the app installed" + +[[ -d $test_home/.config/Hermes ]] || + fail "gateway connections, tokens and settings survive removal" +pass "removal keeps the app's connections and settings" + +# -L, not -e: a dangling symlink fails -e while very much still being there. +[[ ! -L $test_home/.local/bin/node ]] || fail "a node symlink into ~/.hermes is removed" +[[ ! -L $test_home/.local/bin/npm ]] || fail "an npm symlink into ~/.hermes is removed" +[[ -L $test_home/.local/bin/npx ]] || fail "an npx symlink pointing elsewhere survives" +pass "removal clears only the managed Node links it stranded" + +[[ -f $test_home/.hermes/sessions/one.json ]] || fail "chats survive removal" +[[ -f $test_home/.hermes/memories/one.md ]] || fail "memories survive removal" +[[ -f $test_home/.hermes/SOUL.md ]] || fail "SOUL.md survives removal" +pass "removal keeps what belongs to the user" + +[[ ! -e $test_home/.local/bin/hermes ]] || fail "the app's own hermes command is removed" +pass "removal takes the command the app installed" + +# Installed but never launched: the app never wrote these, so they are somebody +# else's and must survive. +seed_install +printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/my-own-hermes \"\$@\"" \ + >"$test_home/.local/bin/hermes" +remove || fail "remove succeeds with a foreign hermes present" +[[ -f $test_home/.local/bin/hermes ]] || + fail "a hermes command the app did not write survives removal" +pass "removal leaves a hermes it does not own" diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index b3394678..eb5b3122 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -224,6 +224,7 @@ const expectedAgents = { claude: { icon: '󰛄', label: 'Claude' }, codex: { icon: '\ue905', iconFont: 'omarchy', label: 'Codex' }, grok: { icon: '\ue904', iconFont: 'omarchy', label: 'Grok' }, + hermes: { icon: '\ue90a', iconFont: 'omarchy', label: 'Hermes' }, copilot: { icon: '', label: 'Copilot' }, crush: { icon: '󰋑', label: 'Crush' }, } @@ -244,7 +245,7 @@ assertDeepEqual( defaultItems .filter(item => item.parent === 'setup.default.agent') .map(item => item.label), - ['Antigravity', 'Claude', 'Codex', 'Copilot', 'Crush', 'Grok', 'omp', 'OpenCode', 'Ori', 'Pi'], + ['Antigravity', 'Claude', 'Codex', 'Copilot', 'Crush', 'Grok', 'Hermes', 'omp', 'OpenCode', 'Ori', 'Pi'], 'menu sorts coding agents alphabetically' ) const expectedDefaults = { @@ -636,5 +637,5 @@ assert( JS font_charset=$(fc-query --format='%{charset}' "$ROOT/default/fonts/omarchy/omarchy.ttf") -[[ $font_charset == *"e900-e909"* ]] || fail "Omarchy icon font includes every custom menu glyph" +[[ $font_charset == *"e900-e90a"* ]] || fail "Omarchy icon font includes every custom menu glyph" pass "Omarchy icon font includes the official agent marks" From d56c1ba972b6a9b82c37aa3b5c33955ea2d3e41a Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 01:09:45 -0500 Subject: [PATCH 02/19] Harden Hermes wrapper ownership --- bin/omarchy-install-hermes-cli | 46 +++++++++- bin/omarchy-remove-preinstalls | 9 +- migrations/1787760281.sh | 20 +++++ test/shell.d/hermes-cli-migration-test.sh | 103 ++++++++++++++++++++++ test/shell.d/hermes-cli-test.sh | 86 +++++++++++++++++- test/shell.d/preinstalls-test.sh | 40 +++++++++ 6 files changed, 295 insertions(+), 9 deletions(-) create mode 100644 migrations/1787760281.sh create mode 100755 test/shell.d/hermes-cli-migration-test.sh diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index c153013b..7922e386 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -30,6 +30,10 @@ mode=${1:-} tool='pipx:hermes-agent[extras=all]' python='3.13' +# The line that identifies the stub as this installer's; matched whole, so a +# wrapper that merely mentions the command is not mistaken for ours. +marker='# Written by omarchy-install-hermes-cli.' + # The package, not the runtime directory: it is installed before the app has # ever run, and that is exactly when we must not start building a second copy. desktop_owns_hermes() { @@ -55,12 +59,38 @@ installed() { [[ -d "$(mise where "$tool" 2>/dev/null)/hermes-agent/lib/python$python" ]] } +# The stub is the only thing this installer owns. Anything else at that path +# -- Hermes' official installer, a hand-rolled wrapper, even a dangling link +# -- was put there by the user and is never deleted or overwritten here. +# Symlinks count as foreign even when they resolve to a marked file: the stub +# is written as a regular file, so a link is someone else's arrangement. +ours() { + [[ -f $HOME/.local/bin/hermes && ! -L $HOME/.local/bin/hermes ]] && + grep -qxF "$marker" "$HOME/.local/bin/hermes" +} + +foreign_hermes() { + [[ -e $HOME/.local/bin/hermes || -L $HOME/.local/bin/hermes ]] && ! ours +} + +# A foreign path is usable when it is a command: a regular file that runs. +# A directory passes -x on search permission alone, and is no more a command +# than a dangling link is. +foreign_hermes_runs() { + [[ -f $HOME/.local/bin/hermes && -x $HOME/.local/bin/hermes ]] +} + # --check lets callers tell a cold stub from a working one before they commit # to a path that assumes Hermes is ready. if [[ $mode == "--check" ]]; then if desktop_owns_hermes; then if desktop_hermes_ready; then exit 0; else exit 1; fi fi + # A foreign command is ready when it runs; a broken one is not, and since it + # is not ours to replace, nothing this installer does will make it ready. + if foreign_hermes; then + if foreign_hermes_runs; then exit 0; else exit 1; fi + fi if installed; then exit 0; else exit 1; fi fi @@ -79,7 +109,7 @@ if desktop_owns_hermes; then # Our own stub has to go with it. Left in place it still answers `hermes` # until the app's bootstrap overwrites it, and answering means building the # second Hermes this whole arrangement exists to avoid. - if [[ -f $HOME/.local/bin/hermes ]] && grep -q omarchy-install-hermes-cli "$HOME/.local/bin/hermes"; then + if ours; then rm -f "$HOME/.local/bin/hermes" fi @@ -92,13 +122,25 @@ if desktop_owns_hermes; then exit 1 fi +# The user already has a hermes of their own. Leave it be: a working one is +# what the default agent will run, and a broken one is theirs to fix. +if foreign_hermes; then + if foreign_hermes_runs; then + exit 0 + fi + + echo "~/.local/bin/hermes exists but is not runnable, and it was not installed by Omarchy." >&2 + echo "Fix or remove it, then run omarchy-install-hermes-cli again." >&2 + exit 1 +fi + mkdir -p "$HOME/.local/bin" rm -f "$HOME/.local/bin/hermes" cat >"$HOME/.local/bin/hermes" <"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]] +SH + +cat >"$mock_bin/omarchy-cmd-missing" <<'SH' +#!/bin/bash +! command -v "$1" >/dev/null 2>&1 +SH + +cat >"$mock_bin/mise" <<'SH' +#!/bin/bash +[[ $1 != "where" ]] +SH + +chmod +x "$mock_bin"/* + +# The real installer is on PATH so the migration writes today's stub, not a +# copy of it. +run_migration() { + OMARCHY_TEST_DESKTOP_INSTALLED="${1:-0}" \ + HOME="$test_home" \ + PATH="$mock_bin:$ROOT/bin:$PATH" \ + bash -euo pipefail "$migration" >/dev/null 2>&1 +} + +run_migration || fail "the migration installs the wrapper on a plain install" +[[ -x $hermes ]] && grep -qxF "$marker" "$hermes" || fail "the migration writes the Omarchy wrapper" +pass "the migration installs the Hermes wrapper" + +before=$(cat "$hermes") +run_migration || fail "rerunning the migration succeeds" +[[ $(cat "$hermes") == "$before" ]] || fail "rerunning the migration leaves the same wrapper" +pass "the migration is idempotent" + +chmod -x "$hermes" +run_migration || fail "the migration repairs a non-executable Omarchy wrapper" +[[ -x $hermes ]] && grep -qxF "$marker" "$hermes" || + fail "the migration restores a non-executable Omarchy wrapper" +pass "the migration repairs a non-executable Omarchy wrapper" + +rm -f "$hermes" +touch "$test_home/.local/state/omarchy/preinstalls-removed" +run_migration || fail "the migration succeeds for users who removed the preinstalls" +[[ ! -e $hermes ]] || fail "the migration respects the preinstalls opt-out" +pass "the migration skips users who removed the preinstalls" +rm -f "$test_home/.local/state/omarchy/preinstalls-removed" + +run_migration 1 || fail "the migration succeeds when Hermes Desktop owns Hermes" +[[ ! -e $hermes ]] || fail "the migration writes nothing when Hermes Desktop owns Hermes" +pass "the migration stands aside for Hermes Desktop" + +official_body="#!/bin/bash +unset PYTHONPATH +unset PYTHONHOME +exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" +printf '%s\n' "$official_body" >"$hermes" +chmod +x "$hermes" +run_migration || fail "the migration succeeds over a foreign hermes command" +[[ $(cat "$hermes") == "$official_body" ]] || fail "the migration leaves a foreign hermes command alone" +pass "the migration preserves a foreign hermes command" + +chmod -x "$hermes" +run_migration || fail "the migration succeeds over a non-executable foreign hermes" +[[ -f $hermes && ! -x $hermes && $(cat "$hermes") == "$official_body" ]] || + fail "the migration leaves a non-executable foreign hermes alone" +pass "the migration preserves a non-executable foreign hermes" + +rm -f "$hermes" +ln -s "$test_home/nowhere/hermes" "$hermes" +run_migration || fail "the migration succeeds over a dangling hermes link" +[[ -L $hermes && $(readlink "$hermes") == "$test_home/nowhere/hermes" ]] || + fail "the migration leaves a dangling hermes link alone" +pass "the migration preserves a dangling hermes link" + +rm -f "$hermes" +mkdir "$hermes" +run_migration || fail "the migration succeeds over a directory at the hermes path" +[[ -d $hermes ]] || fail "the migration leaves a directory at the hermes path alone" +pass "the migration preserves a directory at the hermes path" + +rmdir "$hermes" +printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." >"$hermes" +chmod +x "$hermes" +run_migration || fail "the migration succeeds over a wrapper that mentions the installer" +grep -qxF "$marker" "$hermes" && fail "the migration does not rewrite a wrapper that merely mentions the installer" +pass "the migration preserves a wrapper that merely mentions the installer" diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 881dac47..d73e621f 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -41,7 +41,7 @@ run_installer() { bash "$ROOT/bin/omarchy-install-hermes-cli" ${2:+"$2"} >/dev/null 2>&1 } -stub_marker="omarchy-install-hermes-cli" +stub_marker="# Written by omarchy-install-hermes-cli." app_stub_body='#!/bin/bash exec /home/x/.hermes/hermes-agent/venv/bin/hermes "$@"' @@ -51,14 +51,14 @@ exec /home/x/.hermes/hermes-agent/venv/bin/hermes "$@"' rm -f "$test_home/.local/bin/hermes" run_installer 0 || fail "installer failed with no desktop installed" [[ -x $test_home/.local/bin/hermes ]] || fail "installer writes a hermes stub when the desktop is absent" -grep -q "$stub_marker" "$test_home/.local/bin/hermes" || fail "the stub records which command wrote it" +grep -qxF "$stub_marker" "$test_home/.local/bin/hermes" || fail "the stub records which command wrote it" tr '\0' ' ' <"$mise_log" | grep -q "use -g --quiet uv" && fail "writing the stub does not install uv" pass "writing the Hermes stub provisions nothing" # The desktop app owns Hermes, so our own stub must go rather than sit there # answering `hermes` until the app's bootstrap replaces it. -printf '%s\n' "#!/bin/bash" "# $stub_marker" >"$test_home/.local/bin/hermes" +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$test_home/.local/bin/hermes" chmod +x "$test_home/.local/bin/hermes" run_installer 1 || true [[ ! -e $test_home/.local/bin/hermes ]] || @@ -74,7 +74,7 @@ run_installer 1 || true pass "the app's own hermes command is left alone" # A copy mise cannot vouch for is still a second Hermes. -printf '%s\n' "#!/bin/bash" "# $stub_marker" >"$test_home/.local/bin/hermes" +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$test_home/.local/bin/hermes" chmod +x "$test_home/.local/bin/hermes" : >"$mise_log" OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 1 || true @@ -103,3 +103,81 @@ printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/somebody-elses-hermes \"\$@\"" chmod +x "$test_home/.local/bin/hermes" run_installer 1 --check && fail "--check rejects a hermes command belonging to something else" pass "--check rejects a foreign hermes command" + +# A hermes the user installed themselves -- the official installer, a wrapper of +# their own -- is not ours to replace. --check follows whether it runs, and +# installing steps aside so the default agent uses it. +official_body="#!/bin/bash +unset PYTHONPATH +unset PYTHONHOME +exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" +printf '%s\n' "$official_body" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 0 --check || fail "--check accepts a working foreign hermes command" +run_installer 0 || fail "installing over a foreign hermes command returns success" +run_installer 0 --now || fail "--now over a foreign hermes command returns success" +[[ $(cat "$test_home/.local/bin/hermes") == "$official_body" ]] || + fail "a foreign hermes command is left untouched" +pass "a foreign hermes command is preserved and satisfies --check" + +# Broken foreign paths are still foreign. They cannot be used, so --check says +# so and the installer refuses rather than replacing them. +printf '%s\n' "$official_body" >"$test_home/.local/bin/hermes" +chmod -x "$test_home/.local/bin/hermes" +run_installer 0 --check && fail "--check rejects a non-executable foreign hermes" +run_installer 0 && fail "the installer does not succeed over a non-executable foreign hermes" +[[ -f $test_home/.local/bin/hermes && ! -x $test_home/.local/bin/hermes ]] || + fail "a non-executable foreign hermes is left untouched" +pass "a non-executable foreign hermes is preserved" + +foreign_target="$test_home/foreign/hermes" +mkdir -p "$(dirname "$foreign_target")" +printf '%s\n' "$official_body" >"$foreign_target" +chmod +x "$foreign_target" +rm -f "$test_home/.local/bin/hermes" +ln -s "$foreign_target" "$test_home/.local/bin/hermes" +run_installer 0 --check || fail "--check accepts a foreign link to a working hermes command" +run_installer 0 || fail "the installer succeeds over a foreign link to a working hermes command" +run_installer 0 --now || fail "--now succeeds over a foreign link to a working hermes command" +[[ -L $test_home/.local/bin/hermes && $(readlink "$test_home/.local/bin/hermes") == "$foreign_target" ]] || + fail "a foreign link to a working hermes command is left untouched" +pass "a foreign link to a working hermes command is preserved" + +rm -f "$test_home/.local/bin/hermes" +ln -s "$test_home/nowhere/hermes" "$test_home/.local/bin/hermes" +run_installer 0 --check && fail "--check rejects a dangling hermes link" +run_installer 0 && fail "the installer does not succeed over a dangling hermes link" +[[ -L $test_home/.local/bin/hermes && $(readlink "$test_home/.local/bin/hermes") == "$test_home/nowhere/hermes" ]] || + fail "a dangling hermes link is left untouched" +pass "a dangling hermes link is preserved" + +# A directory passes -x on search permission alone. It is still not a command. +rm -f "$test_home/.local/bin/hermes" +mkdir "$test_home/.local/bin/hermes" +run_installer 0 --check && fail "--check rejects a directory at the hermes path" +run_installer 0 && fail "the installer does not succeed over a directory at the hermes path" +[[ -d $test_home/.local/bin/hermes ]] || fail "a directory at the hermes path is left untouched" +pass "a directory at the hermes path is preserved and rejected" + +# Mentioning the installer is not the same as being written by it. +rmdir "$test_home/.local/bin/hermes" +mentions_body='#!/bin/bash +# Replaces the stub omarchy-install-hermes-cli used to write. +exec /usr/local/bin/hermes "$@"' +printf '%s\n' "$mentions_body" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 0 || fail "installing over a wrapper that mentions the installer returns success" +[[ $(cat "$test_home/.local/bin/hermes") == "$mentions_body" ]] || + fail "a wrapper that merely mentions the installer is left untouched" +pass "ownership needs the exact marker line, not a mention" + +# Our own stub is ours to rewrite, so reinstalling refreshes it to the current +# template. +rm -f "$test_home/.local/bin/hermes" +printf '%s\n' "#!/bin/bash" "$stub_marker" "# stale template" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 0 || fail "reinstalling over our own stub succeeds" +grep -qxF "$stub_marker" "$test_home/.local/bin/hermes" || fail "the refreshed stub still carries the marker" +grep -q "stale template" "$test_home/.local/bin/hermes" && fail "reinstalling rewrites our own stub" +grep -q "exec mise x" "$test_home/.local/bin/hermes" || fail "the refreshed stub is the current template" +pass "reinstalling refreshes the Omarchy stub" diff --git a/test/shell.d/preinstalls-test.sh b/test/shell.d/preinstalls-test.sh index fb4f19a2..7aba8085 100755 --- a/test/shell.d/preinstalls-test.sh +++ b/test/shell.d/preinstalls-test.sh @@ -89,3 +89,43 @@ pass "declining Remove Preinstalls changes nothing" "$ROOT/bin/omarchy-remove-preinstalls" >/dev/null [[ -f $marker ]] || fail "Remove Preinstalls records the opt-out" pass "Remove Preinstalls records the opt-out" + +# Hermes' wrapper is only a preinstall when omarchy-install-hermes-cli wrote it. +# The desktop app's command and an official install live at the same path and +# are the user's, whether or not any package says so. +hermes="$test_home/.local/bin/hermes" +mkdir -p "$(dirname "$hermes")" + +printf '%s\n' "#!/bin/bash" "# Written by omarchy-install-hermes-cli." >"$hermes" +chmod +x "$hermes" +"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null +[[ ! -e $hermes ]] || fail "Remove Preinstalls deletes the Omarchy Hermes wrapper" +pass "Remove Preinstalls deletes the Omarchy Hermes wrapper" + +printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" >"$hermes" +chmod +x "$hermes" +"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null +[[ -x $hermes ]] || fail "Remove Preinstalls keeps the desktop app's Hermes command" +pass "Remove Preinstalls keeps the desktop app's Hermes command" + +official_body="#!/bin/bash +unset PYTHONPATH +unset PYTHONHOME +exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" +printf '%s\n' "$official_body" >"$hermes" +chmod +x "$hermes" +"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null +[[ -x $hermes && $(cat "$hermes") == "$official_body" ]] || fail "Remove Preinstalls keeps an official Hermes install" +pass "Remove Preinstalls keeps an official Hermes install" + +printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." >"$hermes" +chmod +x "$hermes" +"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null +[[ -x $hermes ]] || fail "Remove Preinstalls keeps a wrapper that merely mentions the installer" +pass "Remove Preinstalls keeps a wrapper that merely mentions the installer" + +rm -f "$hermes" +ln -s "$test_home/nowhere/hermes" "$hermes" +"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null +[[ -L $hermes ]] || fail "Remove Preinstalls keeps a foreign hermes link" +pass "Remove Preinstalls keeps a foreign hermes link" From 5909210cb3ce0330f546fe0f54115efa2e69c11c Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 01:36:26 -0500 Subject: [PATCH 03/19] Address Hermes review feedback --- bin/omarchy-install-hermes-cli | 11 +++++--- migrations/1787760281.sh | 10 +++++-- test/shell.d/hermes-cli-migration-test.sh | 30 ++++++++++++++++++++ test/shell.d/hermes-cli-test.sh | 34 +++++++++++++++++++++-- 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index 7922e386..a69d33f0 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -73,11 +73,14 @@ foreign_hermes() { [[ -e $HOME/.local/bin/hermes || -L $HOME/.local/bin/hermes ]] && ! ours } -# A foreign path is usable when it is a command: a regular file that runs. -# A directory passes -x on search permission alone, and is no more a command -# than a dangling link is. +# A foreign path is usable when it is a command that runs: a regular executable +# whose --version answers. The executable bit alone proves little -- a directory +# passes -x on search permission, and a wrapper whose interpreter or target is +# gone passes it too. The desktop app applies the same probe with the same 15 +# second budget, so what passes here is what it will use. foreign_hermes_runs() { - [[ -f $HOME/.local/bin/hermes && -x $HOME/.local/bin/hermes ]] + [[ -f $HOME/.local/bin/hermes && -x $HOME/.local/bin/hermes ]] && + timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1 } # --check lets callers tell a cold stub from a working one before they commit diff --git a/migrations/1787760281.sh b/migrations/1787760281.sh index ea0b1d68..1c47421c 100644 --- a/migrations/1787760281.sh +++ b/migrations/1787760281.sh @@ -4,8 +4,14 @@ echo "Install the Hermes CLI wrapper for existing installs" # is one of them. [[ -f $HOME/.local/state/omarchy/preinstalls-removed ]] && exit 0 -# Hermes Desktop provides its own Hermes; the installer would only stand aside. -omarchy-pkg-present hermes-desktop && exit 0 +# Hermes Desktop provides its own Hermes. The installer stands aside for it, +# removing the mise copy and the Omarchy wrapper an earlier install may have +# left beside the app. It also reports when the app has not finished setting +# Hermes up, which is the app's to finish, not this migration's to fail on. +if omarchy-pkg-present hermes-desktop; then + omarchy-install-hermes-cli || true + exit 0 +fi # Anything already answering to hermes that this installer did not write -- # an official install, a hand-rolled wrapper, even a dangling link -- belongs to diff --git a/test/shell.d/hermes-cli-migration-test.sh b/test/shell.d/hermes-cli-migration-test.sh index cfbc952c..bd18f6fb 100755 --- a/test/shell.d/hermes-cli-migration-test.sh +++ b/test/shell.d/hermes-cli-migration-test.sh @@ -24,8 +24,10 @@ cat >"$mock_bin/omarchy-cmd-missing" <<'SH' ! command -v "$1" >/dev/null 2>&1 SH +mise_log="$test_tmp/mise-log" cat >"$mock_bin/mise" <<'SH' #!/bin/bash +printf '%s\0' "$@" >>"$OMARCHY_TEST_MISE_LOG" [[ $1 != "where" ]] SH @@ -35,6 +37,7 @@ chmod +x "$mock_bin"/* # copy of it. run_migration() { OMARCHY_TEST_DESKTOP_INSTALLED="${1:-0}" \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ HOME="$test_home" \ PATH="$mock_bin:$ROOT/bin:$PATH" \ bash -euo pipefail "$migration" >/dev/null 2>&1 @@ -66,6 +69,33 @@ run_migration 1 || fail "the migration succeeds when Hermes Desktop owns Hermes" [[ ! -e $hermes ]] || fail "the migration writes nothing when Hermes Desktop owns Hermes" pass "the migration stands aside for Hermes Desktop" +# Standing aside is not the same as leaving a second Hermes behind: the wrapper +# an earlier install wrote and the mise copy it points at both go when the +# desktop app owns Hermes, even though the app has not finished setting up. +printf '%s\n' "#!/bin/bash" "$marker" >"$hermes" +chmod +x "$hermes" +: >"$mise_log" +run_migration 1 || fail "the migration succeeds when Hermes Desktop owns Hermes and the old wrapper is present" +[[ ! -e $hermes ]] || fail "the migration removes the Omarchy wrapper when Hermes Desktop owns Hermes" +mise_calls=$(tr '\0' ' ' <"$mise_log") +[[ $mise_calls == *"rm -g "* ]] || fail "the migration removes the global mise Hermes for Hermes Desktop" +[[ $mise_calls == *"uninstall --all "* ]] || fail "the migration uninstalls the mise Hermes for Hermes Desktop" +pass "the migration clears the old Omarchy Hermes for Hermes Desktop" + +# ...while anyone else's hermes stays exactly where it is, and is not run. +foreign_ran="$test_tmp/foreign-ran" +foreign_body="#!/bin/bash +touch $foreign_ran +exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" +printf '%s\n' "$foreign_body" >"$hermes" +chmod +x "$hermes" +run_migration 1 || fail "the migration succeeds over a foreign hermes when Hermes Desktop owns Hermes" +[[ -x $hermes && $(cat "$hermes") == "$foreign_body" ]] || + fail "the migration leaves a foreign hermes alone when Hermes Desktop owns Hermes" +[[ ! -e $foreign_ran ]] || fail "the migration does not run a foreign hermes" +pass "the migration preserves a foreign hermes for Hermes Desktop" +rm -f "$hermes" + official_body="#!/bin/bash unset PYTHONPATH unset PYTHONHOME diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index d73e621f..523aa871 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -87,8 +87,10 @@ pass "takeover removes an unhealthy mise copy" rm -rf "$test_home/.hermes" rm -f "$test_home/.local/bin/hermes" run_installer 1 --check && fail "--check reports Hermes missing before the app installs it" +# The venv command answers --version, as the real one does: foreign wrappers +# below exec it, and the installer probes them by running exactly that. mkdir -p "$test_home/.hermes/hermes-agent/venv/bin" -printf '%s\n' "#!/bin/bash" >"$test_home/.hermes/hermes-agent/venv/bin/hermes" +printf '%s\n' "#!/bin/bash" 'echo "hermes-agent 0.0.0-test"' >"$test_home/.hermes/hermes-agent/venv/bin/hermes" chmod +x "$test_home/.hermes/hermes-agent/venv/bin/hermes" run_installer 1 --check && fail "--check waits for the install to finish, not just the venv" touch "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete" @@ -130,6 +132,32 @@ run_installer 0 && fail "the installer does not succeed over a non-executable fo fail "a non-executable foreign hermes is left untouched" pass "a non-executable foreign hermes is preserved" +# The executable bit is not enough: a wrapper whose interpreter is gone passes +# -x and still cannot run. The probe has to run it to find out, and finding +# out never touches the file. +broken_interp_body="#!$test_home/nowhere/python3 +print('hermes')" +printf '%s\n' "$broken_interp_body" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 0 --check && fail "--check rejects a foreign hermes whose interpreter is missing" +run_installer 0 && fail "the installer does not succeed over a foreign hermes whose interpreter is missing" +run_installer 0 --now && fail "--now does not succeed over a foreign hermes whose interpreter is missing" +[[ -x $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$broken_interp_body" ]] || + fail "a foreign hermes whose interpreter is missing is left untouched" +pass "a foreign hermes with a missing interpreter is preserved and rejected" + +# Likewise a wrapper that execs a target that is no longer there. +broken_target_body="#!/bin/bash +exec $test_home/nowhere/hermes \"\$@\"" +printf '%s\n' "$broken_target_body" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 0 --check && fail "--check rejects a foreign hermes whose target is missing" +run_installer 0 && fail "the installer does not succeed over a foreign hermes whose target is missing" +run_installer 0 --now && fail "--now does not succeed over a foreign hermes whose target is missing" +[[ -x $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$broken_target_body" ]] || + fail "a foreign hermes whose target is missing is left untouched" +pass "a foreign hermes with a missing target is preserved and rejected" + foreign_target="$test_home/foreign/hermes" mkdir -p "$(dirname "$foreign_target")" printf '%s\n' "$official_body" >"$foreign_target" @@ -161,9 +189,9 @@ pass "a directory at the hermes path is preserved and rejected" # Mentioning the installer is not the same as being written by it. rmdir "$test_home/.local/bin/hermes" -mentions_body='#!/bin/bash +mentions_body="#!/bin/bash # Replaces the stub omarchy-install-hermes-cli used to write. -exec /usr/local/bin/hermes "$@"' +exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" printf '%s\n' "$mentions_body" >"$test_home/.local/bin/hermes" chmod +x "$test_home/.local/bin/hermes" run_installer 0 || fail "installing over a wrapper that mentions the installer returns success" From 43d2fffaf099e1b921d768256676802fd990c565 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 10:47:53 +0200 Subject: [PATCH 04/19] Keep user setup running when Hermes cannot install install/user/mise.sh is sourced through run_logged under `bash -eE`, and its status reaches omarchy-provision-user's `set -euo pipefail`. Every other line in the file writes a mise stub and cannot fail; omarchy-install-hermes-cli can, and does whenever hermes-desktop is installed but the app has not been launched yet -- what a second user on a shared machine meets on their first login. The rest of provisioning runs after that source: refreshing applications, the default browser, the mailto handler, the first-install migration markers and the finalize-user marker. Without the marker the whole step retries and fails again at every login, and omarchy-provision-first-run calls it with `|| true`, so nothing surfaces. omarchy-install-ai-hermes and the migration already guard this call the same way. Co-Authored-By: Claude Opus 5 (1M context) --- install/user/mise.sh | 7 ++++++- test/shell.d/hermes-cli-test.sh | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/install/user/mise.sh b/install/user/mise.sh index a944d6cb..4baeffce 100644 --- a/install/user/mise.sh +++ b/install/user/mise.sh @@ -13,4 +13,9 @@ omarchy-mise-install npm:@kitlangton/ghui ghui omarchy-mise-install aqua:modem-dev/hunk hunk omarchy-mise-install github:basecamp/hey-cli hey omarchy-mise-install github:OpenRouterLabs/ori-releases ori -omarchy-install-hermes-cli +# Every line above writes a stub and cannot fail. This one can: it exits +# non-zero when Hermes Desktop owns Hermes but has not finished setting it up, +# and this leaf is sourced under `bash -eE`, so that would abort the rest of +# omarchy-provision-user -- the default browser, the mailto handler and the +# finalize-user marker all come after it. +omarchy-install-hermes-cli || true diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 523aa871..8f247184 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -209,3 +209,38 @@ grep -qxF "$stub_marker" "$test_home/.local/bin/hermes" || fail "the refreshed s grep -q "stale template" "$test_home/.local/bin/hermes" && fail "reinstalling rewrites our own stub" grep -q "exec mise x" "$test_home/.local/bin/hermes" || fail "the refreshed stub is the current template" pass "reinstalling refreshes the Omarchy stub" + +# install/user/mise.sh is sourced by install/user/all.sh through run_logged, +# which runs it under `bash -eE` and hands its exit code back to +# omarchy-provision-user's `set -euo pipefail`. Everything that finalizes a user +# -- the default browser, the mailto handler, the first-install migration +# markers, the finalize-user marker -- runs after that source, so this leaf +# returning non-zero costs the user all of it. The Hermes installer is the only +# line in it that can fail, and it does exactly that whenever hermes-desktop is +# installed but the app has not been launched yet: the case a second user on a +# shared machine hits on their first login. +mise_sh_home="$test_tmp/mise-sh-home" +mkdir -p "$mise_sh_home/.local/bin" + +cat >"$mock_bin/omarchy-mise-install" <<'SH' +#!/bin/bash +exit 0 +SH +chmod +x "$mock_bin/omarchy-mise-install" + +# Desktop installed, nothing bootstrapped: omarchy-install-hermes-cli exits 1. +OMARCHY_TEST_DESKTOP_INSTALLED=1 \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$mise_sh_home" \ + PATH="$mock_bin:$ROOT/bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" >/dev/null 2>&1 && + fail "the Hermes installer exits non-zero when the desktop app has not set Hermes up" + +# Sourced exactly as run_logged does it. +OMARCHY_TEST_DESKTOP_INSTALLED=1 \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$mise_sh_home" \ + PATH="$mock_bin:$ROOT/bin:$PATH" \ + bash -eE -c 'source "$1"' bash "$ROOT/install/user/mise.sh" >/dev/null 2>&1 || + fail "user setup survives a Hermes install that cannot finish" +pass "user setup survives a Hermes install that cannot finish" From 2f918a75ada3709373c5d7b53e8cf4445b2ec789 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 10:48:06 +0200 Subject: [PATCH 05/19] Stop the Hermes interpreter pin following the agent into the user's projects The stub exports UV_PYTHON so mise builds Hermes against 3.13, which Hermes requires and Arch's Python is past. Exported, it survived the exec into Hermes itself and reached every command the agent shells out to. Hermes is a coding agent that runs commands in the user's own repositories, so a `uv venv` or `uv sync` there resolved 3.13 as well: on a project declaring requires-python >=3.14, uv warns that the interpreter contradicts it and builds the venv anyway. Dropping it at the handover keeps the pin over the install, where it belongs. mise x resolves the tool it already installed without it. Co-Authored-By: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh --- bin/omarchy-install-hermes-cli | 6 ++++- test/shell.d/hermes-cli-test.sh | 40 ++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index a69d33f0..a24f7aca 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -167,7 +167,11 @@ if ! [[ -d "\$(mise where '$tool' 2>/dev/null)/hermes-agent/lib/python$python" ] mise use -g --quiet --force '$tool' || exit 1 fi -exec mise x '$tool' -- hermes "\$@" +# The pin belongs to building Hermes, not to everything Hermes then runs. +# Exported it would reach the agent and every command it shells out to, so a +# uv in the user's own project would resolve 3.13 there too -- uv only warns +# when that contradicts the project's requires-python, and builds it anyway. +exec env -u UV_PYTHON mise x '$tool' -- hermes "\$@" EOF chmod +x "$HOME/.local/bin/hermes" diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 8f247184..bfe94b95 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -42,6 +42,7 @@ run_installer() { } stub_marker="# Written by omarchy-install-hermes-cli." +python_pin="3.13" app_stub_body='#!/bin/bash exec /home/x/.hermes/hermes-agent/venv/bin/hermes "$@"' @@ -207,7 +208,7 @@ chmod +x "$test_home/.local/bin/hermes" run_installer 0 || fail "reinstalling over our own stub succeeds" grep -qxF "$stub_marker" "$test_home/.local/bin/hermes" || fail "the refreshed stub still carries the marker" grep -q "stale template" "$test_home/.local/bin/hermes" && fail "reinstalling rewrites our own stub" -grep -q "exec mise x" "$test_home/.local/bin/hermes" || fail "the refreshed stub is the current template" +grep -q "exec env -u UV_PYTHON mise x" "$test_home/.local/bin/hermes" || fail "the refreshed stub is the current template" pass "reinstalling refreshes the Omarchy stub" # install/user/mise.sh is sourced by install/user/all.sh through run_logged, @@ -244,3 +245,40 @@ OMARCHY_TEST_DESKTOP_INSTALLED=1 \ bash -eE -c 'source "$1"' bash "$ROOT/install/user/mise.sh" >/dev/null 2>&1 || fail "user setup survives a Hermes install that cannot finish" pass "user setup survives a Hermes install that cannot finish" + +# UV_PYTHON pins the interpreter Hermes is built against. Left in the +# environment it reaches Hermes itself and every command the agent shells out +# to, so a `uv` run in the user's own project resolves 3.13 there as well -- +# uv only warns that this contradicts the project's requires-python, then +# builds the venv anyway. The stub drops it before handing over. +leak_home="$test_tmp/leak-home" +leak_bin="$test_tmp/leak-bin" +leak_log="$test_tmp/leak-log" +leak_prefix="$test_tmp/leak-prefix" +mkdir -p "$leak_home/.local/bin" "$leak_bin" "$leak_prefix/hermes-agent/lib/python$python_pin" + +# A mise whose `where` satisfies the stub's probe, so the stub goes straight to +# handing over, and whose `x` records the UV_PYTHON it was handed. +cat >"$leak_bin/mise" <"$leak_log" ;; +esac +SH +chmod +x "$leak_bin/mise" + +OMARCHY_TEST_DESKTOP_INSTALLED=0 \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$leak_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" >/dev/null 2>&1 || + fail "the installer writes a stub for the leak check" + +HOME="$leak_home" PATH="$leak_bin:$mock_bin:$PATH" \ + "$leak_home/.local/bin/hermes" --version >/dev/null 2>&1 + +[[ -f $leak_log ]] || fail "the stub reaches the command it wraps" +[[ -z $(cat "$leak_log") ]] || + fail "the interpreter pin does not follow Hermes into the commands it runs" +pass "the interpreter pin does not follow Hermes into the commands it runs" From ba78e7df090ea04d25a39858cc940e4357bbdfe1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 11:44:39 +0200 Subject: [PATCH 06/19] Leave a Hermes the app never installed alone Remove > AI > Hermes deleted ~/.hermes/hermes-agent, bootstrap-cache, bin and node unconditionally, plus any wrapper on PATH pointing into ~/.hermes. The official Hermes installer uses those same paths, so a user who installed the CLI themselves, then installed the app and never launched it, lost their checkout, venv and any local changes -- while being told their chats, memories and skills were safe. The app provisions its runtime on first launch and writes .hermes-bootstrap-complete when it lands. Without that marker the app never got that far and everything under ~/.hermes predates it, so dropping the package is the whole job. Two smaller things in the same path. The wrapper test matched ~/.hermes as a pattern, and the dot made it claim a wrapper pointing at a sibling like ~/xhermes; it is a plain string now, and a symlink there is the user's arrangement rather than something to delete. And -u, so an unset HOME is an error instead of a set of rm -rf paths rooted at /. Co-Authored-By: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh --- bin/omarchy-remove-ai-hermes | 82 +++++++++++++++++------------- test/shell.d/hermes-remove-test.sh | 39 +++++++++++++- 2 files changed, 83 insertions(+), 38 deletions(-) diff --git a/bin/omarchy-remove-ai-hermes b/bin/omarchy-remove-ai-hermes index 830ca2ad..f67b1d6d 100755 --- a/bin/omarchy-remove-ai-hermes +++ b/bin/omarchy-remove-ai-hermes @@ -3,47 +3,57 @@ # omarchy:summary=Remove the Hermes desktop app along with the Hermes runtime it installed. # omarchy:requires-sudo=true -set -e +# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /. +set -euo pipefail omarchy-pkg-drop hermes-desktop -# The app installs a Hermes of its own under ~/.hermes -- the checkout and venv, -# its own uv, its own node -- and puts its commands on PATH. None of it is any -# use once the app is gone. Not ~/.config/Hermes, which holds the gateway -# connections and their encrypted tokens, the active profile and the update -# settings. Not the rest of ~/.hermes either: -# the chats, memories and the skills Hermes wrote for itself are the user's, -# they are small, and finding them still there after a reinstall is the better -# surprise. -rm -rf \ - "$HOME/.hermes/hermes-agent" \ - "$HOME/.hermes/bootstrap-cache" \ - "$HOME/.hermes/bin" \ - "$HOME/.hermes/node" +# The app writes this when the runtime it provisions under ~/.hermes has landed, +# and it is the only thing that tells that runtime apart from one the user +# installed themselves -- the paths are the same either way. Without it the app +# never got that far: a machine where it was installed but never launched still +# has whatever was there before, and none of it is ours to delete. +if [[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]]; then + # The checkout and venv, its own uv, its own node. None of it is any use once + # the app is gone. Not ~/.config/Hermes, which holds the gateway connections + # and their encrypted tokens, the active profile and the update settings. Not + # the rest of ~/.hermes either: the chats, memories and the skills Hermes + # wrote for itself are the user's, they are small, and finding them still + # there after a reinstall is the better surprise. + rm -rf \ + "$HOME/.hermes/hermes-agent" \ + "$HOME/.hermes/bootstrap-cache" \ + "$HOME/.hermes/bin" \ + "$HOME/.hermes/node" -# Only the wrappers pointing into ~/.hermes. The app writes these at its own -# path stage, so a machine where it was installed but never launched still has -# whatever was there before, and that is not ours to delete. -for command in hermes hermes-agent hermes-acp; do - wrapper="$HOME/.local/bin/$command" + # Only the wrappers pointing into ~/.hermes, matched as a plain string: the + # path carries a dot, so an unanchored pattern would also claim a wrapper + # pointing at a sibling like ~/xhermes. + for command in hermes hermes-agent hermes-acp; do + wrapper="$HOME/.local/bin/$command" - if [[ -f $wrapper ]] && grep -q "$HOME/.hermes" "$wrapper"; then - rm -f "$wrapper" - fi -done + if [[ -f $wrapper && ! -L $wrapper ]] && grep -qF "$HOME/.hermes" "$wrapper"; then + rm -f "$wrapper" + fi + done -# When Hermes brought its own Node it symlinked these next to its own commands, -# and they point at what we just deleted. Only the links into ~/.hermes: a -# system Node, or someone else's, lives somewhere else entirely. -for command in node npm npx; do - link="$HOME/.local/bin/$command" + # When Hermes brought its own Node it symlinked these next to its own commands, + # and they point at what we just deleted. Only the links into ~/.hermes: a + # system Node, or someone else's, lives somewhere else entirely. + for command in node npm npx; do + link="$HOME/.local/bin/$command" - if [[ -L $link && $(readlink "$link") == "$HOME/.hermes"/* ]]; then - rm -f "$link" - fi -done + if [[ -L $link && $(readlink "$link") == "$HOME/.hermes"/* ]]; then + rm -f "$link" + fi + done -echo "" -echo "Hermes Desktop has been removed." -echo "Your chats, memories, and skills are still in ~/.hermes," -echo "and your connections and settings in ~/.config/Hermes." + echo "" + echo "Hermes Desktop has been removed." + echo "Your chats, memories, and skills are still in ~/.hermes," + echo "and your connections and settings in ~/.config/Hermes." +else + echo "" + echo "Hermes Desktop has been removed." + echo "It never finished installing its own Hermes, so nothing in ~/.hermes was touched." +fi diff --git a/test/shell.d/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh index 423a297e..bc80859f 100755 --- a/test/shell.d/hermes-remove-test.sh +++ b/test/shell.d/hermes-remove-test.sh @@ -31,6 +31,7 @@ seed_install() { ln -sf "$test_home/.hermes/node/bin/npm" "$test_home/.local/bin/npm" ln -sf /usr/bin/npx "$test_home/.local/bin/npx" printf 'node\n' >"$test_home/.hermes/node/bin/node" + touch "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete" } remove() { @@ -66,8 +67,8 @@ pass "removal keeps what belongs to the user" [[ ! -e $test_home/.local/bin/hermes ]] || fail "the app's own hermes command is removed" pass "removal takes the command the app installed" -# Installed but never launched: the app never wrote these, so they are somebody -# else's and must survive. +# A hermes command the app did not write survives even when the app did install +# a runtime of its own. seed_install printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/my-own-hermes \"\$@\"" \ >"$test_home/.local/bin/hermes" @@ -75,3 +76,37 @@ remove || fail "remove succeeds with a foreign hermes present" [[ -f $test_home/.local/bin/hermes ]] || fail "a hermes command the app did not write survives removal" pass "removal leaves a hermes it does not own" + +# Installed but never launched. The app provisions its runtime on first launch +# and marks it complete when it lands, so without that marker everything under +# ~/.hermes predates the app -- an official install, or one built by hand -- and +# the paths are identical either way. Dropping the package is the whole job. +seed_install +rm -f "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete" +printf 'my local edit\n' >"$test_home/.hermes/hermes-agent/PATCH" +printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \ + >"$test_home/.local/bin/hermes" +remove || fail "remove succeeds when the app never finished installing Hermes" +[[ -d $test_home/.hermes/hermes-agent ]] || + fail "a Hermes runtime the app never installed survives removal" +[[ -f $test_home/.hermes/hermes-agent/PATCH ]] || + fail "local changes to a runtime the app never installed survive removal" +[[ -d $test_home/.hermes/bin && -d $test_home/.hermes/node ]] || + fail "the rest of a runtime the app never installed survives removal" +[[ -f $test_home/.local/bin/hermes ]] || + fail "the command a runtime the app never installed put on PATH survives removal" +[[ -L $test_home/.local/bin/node ]] || + fail "node links belonging to a runtime the app never installed survive removal" +pass "removal leaves a Hermes the app never installed" + +# ~/.hermes carries a dot, so a pattern rather than a plain string would also +# claim a wrapper pointing at a sibling directory that merely looks like it. +seed_install +mkdir -p "$test_home/xhermes/bin" +sibling_body="#!/bin/bash +exec $test_home/xhermes/bin/hermes \"\$@\"" +printf '%s\n' "$sibling_body" >"$test_home/.local/bin/hermes" +remove || fail "remove succeeds with a wrapper pointing at a sibling directory" +[[ -f $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$sibling_body" ]] || + fail "a wrapper pointing at ~/xhermes is not mistaken for one pointing into ~/.hermes" +pass "removal matches the runtime path as a plain string" From 12646eb5a13809d889795de859258874825c5c67 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 11:45:15 +0200 Subject: [PATCH 07/19] Run the app's Hermes before calling it ready desktop_hermes_ready decided from a marker file and a text match, while a hermes the user installed themselves had to answer --version before it counted. The marker says the app's install once landed, not that it is still there, so a runtime deleted afterwards left --check reporting success: the default agent records Hermes, skips the install terminal, and the launch fails. It now runs the command, on the same 15 second budget the app itself uses. The path match is a plain string for the same reason it is in the remover -- the dot in ~/.hermes would otherwise claim a wrapper pointing at ~/xhermes. foreign_hermes_runs never tested foreignness, only that the command runs, so it is hermes_runs now and both callers share it. Co-Authored-By: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh --- bin/omarchy-install-hermes-cli | 21 +++++++++++++-------- test/shell.d/hermes-cli-test.sh | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index a24f7aca..321ddac3 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -45,12 +45,17 @@ desktop_owns_hermes() { # marker is written last, and the command is what the agent actually runs. desktop_hermes_ready() { [[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]] || return 1 - [[ -x $HOME/.local/bin/hermes ]] || return 1 # An executable of that name proves nothing about whose it is; the app's own # points into ~/.hermes, and anything else is not the install we are asking - # about. - grep -q "$HOME/.hermes" "$HOME/.local/bin/hermes" + # about. Matched as a plain string, because the path carries a dot and an + # unanchored pattern would also claim a wrapper pointing at ~/xhermes. + [[ -f $HOME/.local/bin/hermes ]] || return 1 + grep -qF "$HOME/.hermes" "$HOME/.local/bin/hermes" || return 1 + + # And a marker left behind by an install whose venv has since gone answers + # for nothing, so the command has to run, exactly as a foreign one must. + hermes_runs } # Whether Hermes is really installed, not merely whether the stub exists. A @@ -73,12 +78,12 @@ foreign_hermes() { [[ -e $HOME/.local/bin/hermes || -L $HOME/.local/bin/hermes ]] && ! ours } -# A foreign path is usable when it is a command that runs: a regular executable -# whose --version answers. The executable bit alone proves little -- a directory +# A hermes at that path is usable when it is a command that runs: a regular +# executable whose --version answers. The executable bit alone proves little -- a directory # passes -x on search permission, and a wrapper whose interpreter or target is # gone passes it too. The desktop app applies the same probe with the same 15 # second budget, so what passes here is what it will use. -foreign_hermes_runs() { +hermes_runs() { [[ -f $HOME/.local/bin/hermes && -x $HOME/.local/bin/hermes ]] && timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1 } @@ -92,7 +97,7 @@ if [[ $mode == "--check" ]]; then # A foreign command is ready when it runs; a broken one is not, and since it # is not ours to replace, nothing this installer does will make it ready. if foreign_hermes; then - if foreign_hermes_runs; then exit 0; else exit 1; fi + if hermes_runs; then exit 0; else exit 1; fi fi if installed; then exit 0; else exit 1; fi fi @@ -128,7 +133,7 @@ fi # The user already has a hermes of their own. Leave it be: a working one is # what the default agent will run, and a broken one is theirs to fix. if foreign_hermes; then - if foreign_hermes_runs; then + if hermes_runs; then exit 0 fi diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index bfe94b95..b701eb3c 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -282,3 +282,29 @@ HOME="$leak_home" PATH="$leak_bin:$mock_bin:$PATH" \ [[ -z $(cat "$leak_log") ]] || fail "the interpreter pin does not follow Hermes into the commands it runs" pass "the interpreter pin does not follow Hermes into the commands it runs" + +# The app's marker says its install once landed, not that it is still there. A +# wrapper whose runtime has since gone answers for nothing, so readiness runs +# the command, exactly as it does for a hermes the user installed themselves. +ready_home="$test_tmp/ready-home" +mkdir -p "$ready_home/.hermes/hermes-agent/venv/bin" "$ready_home/.local/bin" +touch "$ready_home/.hermes/hermes-agent/.hermes-bootstrap-complete" +printf '%s\n' "#!/bin/bash" "exec $ready_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \ + >"$ready_home/.local/bin/hermes" +chmod +x "$ready_home/.local/bin/hermes" + +run_ready_check() { + OMARCHY_TEST_DESKTOP_INSTALLED=1 \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$ready_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" --check >/dev/null 2>&1 +} + +run_ready_check && fail "--check rejects the app's wrapper when its runtime is gone" + +printf '%s\n' "#!/bin/bash" 'echo "hermes-agent 0.0.0-test"' \ + >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" +chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes" +run_ready_check || fail "--check accepts the app's wrapper once it runs" +pass "readiness runs the app's command rather than trusting its marker" From cda02f0a8813b7c7325fe8450c82bf95bea653c2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 11:45:23 +0200 Subject: [PATCH 08/19] Ask the installer who owns the Hermes wrapper Three files spelled out the line that marks ~/.local/bin/hermes as Omarchy's: the installer that writes it, Remove Preinstalls, and the migration. Two of them were copies, and a change to what ownership means would have left them matching a line nobody writes any more -- Remove Preinstalls quietly sweeping nothing, the migration mistaking Omarchy's own wrapper for a stranger's. omarchy-install-hermes-cli --owns answers it now, and the other two ask. The installer's own metadata was also a flag behind: --check has been there since this landed and was never listed. A test pins the marker to one file, so a second copy fails rather than drifts. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-install-hermes-cli | 9 ++++++- bin/omarchy-remove-preinstalls | 5 ++-- migrations/1787760281.sh | 9 +++---- test/shell.d/hermes-cli-test.sh | 46 ++++++++++++++++++++++++++++++++ test/shell.d/preinstalls-test.sh | 5 +++- 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index 321ddac3..0c2e526e 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Install the Hermes CLI as a mise-backed wrapper in ~/.local/bin -# omarchy:args=[--now] +# omarchy:args=[--check|--now|--owns] # omarchy:examples=omarchy install hermes cli | omarchy install hermes cli --now # Hermes pins every one of its dependencies exactly and declares @@ -88,6 +88,13 @@ hermes_runs() { timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1 } +# --owns answers whether the wrapper on PATH is the one this command wrote, so +# the migration and Remove Preinstalls do not each carry their own copy of the +# marker and drift from it. +if [[ $mode == "--owns" ]]; then + if ours; then exit 0; else exit 1; fi +fi + # --check lets callers tell a cold stub from a working one before they commit # to a path that assumes Hermes is ready. if [[ $mode == "--check" ]]; then diff --git a/bin/omarchy-remove-preinstalls b/bin/omarchy-remove-preinstalls index 6faf2868..3dca7b22 100755 --- a/bin/omarchy-remove-preinstalls +++ b/bin/omarchy-remove-preinstalls @@ -19,8 +19,9 @@ if gum confirm "Are you sure you want to remove all preinstalled web apps, TUI w # Only the wrapper omarchy-install-hermes-cli wrote is a preinstall. Hermes # Desktop's command, an official install, or anything else at that path is - # the user's, so it is the marker that decides, not which packages are around. - if [[ -f ~/.local/bin/hermes && ! -L ~/.local/bin/hermes ]] && grep -qxF '# Written by omarchy-install-hermes-cli.' ~/.local/bin/hermes; then + # the user's, so it is the installer that decides whether the wrapper is its + # own, rather than a copy of its marker kept here. + if omarchy-install-hermes-cli --owns; then rm -f ~/.local/bin/hermes fi diff --git a/migrations/1787760281.sh b/migrations/1787760281.sh index 1c47421c..952fa9a2 100644 --- a/migrations/1787760281.sh +++ b/migrations/1787760281.sh @@ -15,12 +15,11 @@ fi # Anything already answering to hermes that this installer did not write -- # an official install, a hand-rolled wrapper, even a dangling link -- belongs to -# the user and stays exactly as it is. +# the user and stays exactly as it is. The installer is asked rather than +# matched against here, so there is one answer to who owns that wrapper. wrapper="$HOME/.local/bin/hermes" -if [[ -e $wrapper || -L $wrapper ]]; then - if [[ -L $wrapper || ! -f $wrapper ]] || ! grep -qxF '# Written by omarchy-install-hermes-cli.' "$wrapper"; then - exit 0 - fi +if [[ -e $wrapper || -L $wrapper ]] && ! omarchy-install-hermes-cli --owns; then + exit 0 fi omarchy-install-hermes-cli diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index b701eb3c..88319dc9 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -283,6 +283,52 @@ HOME="$leak_home" PATH="$leak_bin:$mock_bin:$PATH" \ fail "the interpreter pin does not follow Hermes into the commands it runs" pass "the interpreter pin does not follow Hermes into the commands it runs" +# --owns is the one answer to whether the wrapper on PATH is this installer's. +# Remove Preinstalls and the migration both ask it rather than carrying their +# own copy of the marker, so a change to what ownership means reaches them. +owns_home="$test_tmp/owns-home" +mkdir -p "$owns_home/.local/bin" + +run_owns() { + OMARCHY_TEST_DESKTOP_INSTALLED=0 \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$owns_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" --owns +} + +rm -f "$owns_home/.local/bin/hermes" +run_owns && fail "--owns says no when there is no wrapper at all" + +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$owns_home/.local/bin/hermes" +chmod +x "$owns_home/.local/bin/hermes" +run_owns || fail "--owns recognises the stub this installer wrote" + +printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." \ + >"$owns_home/.local/bin/hermes" +run_owns && fail "--owns needs the exact marker line, not a mention" + +# Quoting the marker inside a longer line is not the same as carrying it: the +# match is whole-line, so a wrapper describing what it replaced stays the +# user's. +printf '%s\n' "#!/bin/bash" "# Replaced '$stub_marker' with my own." \ + >"$owns_home/.local/bin/hermes" +run_owns && fail "--owns needs the marker to be the whole line, not part of one" + +rm -f "$owns_home/.local/bin/hermes" +ln -s "$test_home/.local/bin/hermes" "$owns_home/.local/bin/hermes" +run_owns && fail "--owns disclaims a symlink, whatever it resolves to" +rm -f "$owns_home/.local/bin/hermes" +pass "--owns answers for the wrapper this installer wrote and nothing else" + +# The marker lives in exactly one place. Every other caller asks --owns, so a +# second copy is drift waiting to happen. +marker_copies=$(grep -rl "Written by omarchy-install-hermes-cli" \ + "$ROOT/bin" "$ROOT/install" "$ROOT/migrations" 2>/dev/null | wc -l) +(( marker_copies == 1 )) || + fail "only omarchy-install-hermes-cli spells out the ownership marker" +pass "the ownership marker is written down once" + # The app's marker says its install once landed, not that it is still there. A # wrapper whose runtime has since gone answers for nothing, so readiness runs # the command, exactly as it does for a hermes the user installed themselves. diff --git a/test/shell.d/preinstalls-test.sh b/test/shell.d/preinstalls-test.sh index 7aba8085..0ca3e141 100755 --- a/test/shell.d/preinstalls-test.sh +++ b/test/shell.d/preinstalls-test.sh @@ -36,7 +36,10 @@ SH chmod +x "$mock_bin"/* -export PATH="$mock_bin:$PATH" +# $ROOT/bin after the mocks: Remove Preinstalls asks omarchy-install-hermes-cli +# whether the wrapper is Omarchy's rather than matching the marker itself, and +# that is the real command at runtime. The mocks still shadow what they name. +export PATH="$mock_bin:$ROOT/bin:$PATH" export HOME="$test_home" export OMARCHY_TEST_PKG_LOG="$pkg_log" From f70c55d81386a240702f73803b6bee63325f2852 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 11:45:33 +0200 Subject: [PATCH 09/19] Give Hermes the mark that reads at menu size U+E90A carried a trace of the Hermes app icon: a portrait whose detail collapses into a grey smudge beside the ten flat silhouettes the rest of the font is made of, which is what icon-font.md warns against when it says to pick a source whose silhouette alone reads. It is Font Awesome's staff-snake now, under CC BY 4.0 -- the mark Hermes serves as its favicon and titles its README with. The README records that, along with the licence the artwork carries, since it is the only note of where these come from. Co-Authored-By: Claude Opus 5 (1M context) --- default/fonts/omarchy/README.md | 2 +- default/fonts/omarchy/omarchy.ttf | Bin 9008 -> 5052 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/default/fonts/omarchy/README.md b/default/fonts/omarchy/README.md index 11558a30..03b8a909 100644 --- a/default/fonts/omarchy/README.md +++ b/default/fonts/omarchy/README.md @@ -12,7 +12,7 @@ The private-use glyphs in `omarchy.ttf` are: - `U+E907` — Ollama, from - `U+E908` — T3 Code, traced from the app icon in , since upstream publishes no monochrome SVG - `U+E909` — Ori, from , OpenRouter's own mark: Ori ships no separate logo and its product page uses this one -- `U+E90A` — Hermes, traced from the Hermes app icon (), the same art the `hermes-desktop` package ships as its icon +- `U+E90A` — Hermes, Font Awesome's staff-snake (CC BY 4.0) from , the mark Hermes serves as its favicon: their app icon is a portrait that reads as a smudge at menu size The agent marks are monochrome so the menu can render them using the active theme's foreground and selection colors. diff --git a/default/fonts/omarchy/omarchy.ttf b/default/fonts/omarchy/omarchy.ttf index 29145211c45d05ef88767dc30d750fbe84d238a2..1ddeb85ce255a899a563eb985e070a6f63f47258 100644 GIT binary patch delta 774 zcmZuvO=uHQ5T1Fv*T)qn++y`)fEEaN8hvwbC z0K8SwCyGQUDEbL665dyIs`IrV_|5kRj&c)-ccR&YNy!GhjnMD$~5=D?cv*_+nJQQGXdO)yFX3`hY5X>=9iEb@z^SL6+r~(DPPc z0Id~I18WSLkP=P`FJK0)(InXp>OozSCE1MIBgsU3)QXs<`F~oL1Ee&=R>X>1W`t5A znMm4O$ADiC+JsFm+Y%5JAB}Tujl1~+uC7y9mUs_K;B+PyWh@%Yq|}bgEL-hGWN7!DjL(3Yc+HPf7_vm`a80<3;jlMxuZ6Iozu4@|psjYPe zwRfSaa6{gFvom#L8-6;$I(oVwIdR7^E=dgyxfiIiu}kxE5ALP-8S1J=UaF4Wft|7R zhmePD8;#!!*|XZ=b=WBq1MubD>lvO`?=4T>{*;t_9EkPLY)_2@DT;>_&gPk89*di~ zM7ha3V0_y;17(Z5Cjq*d^0|fj6{s!l8_i;6p3i!ix0-A(I*ogP2+cN4c2n|-_AMT`OflJ zzgfL@$G7r&^tR`D7w`Y{?&LG?-F>I$u^Taev`gDm>;`z_R5p&F5UO09$Ul0=9kW1I{({${QbKaUWCwZo_yk|r~m!ze=d98hyZ=v zV|cRZ^o3u&{NcfKX(oRB^VbhvN|%A(>~wVO9Y46QRUBN$9zFO&y503&_AYt1H18kb zdC{vq@9NiBiyx$19vp4;-E;D<>QVJtynFQ}!PLPk_5+^qtKV_I6Iahgud@0$L~aq! zi(h)X-f8b)?} zwb4~smAf1dyl1*F(`mE0*q@QaYQ8$ZfC7!rd~dm^%4Vpi|343PW+h36=p3Z8>2yA) z5iN9A#;Vb%$a8$EvL>t7cD1g$A0cZj8NJlYoB4dEmt8o4y$0hRDI4o-uYitiJp~x- zRV7V}d@-B1#v?s#CU~qqN{>f$W~@QQbW-HYJs36`_6c&418M=SqpHkC`TnF?8mWAV z(Gv8L$!e5!t9<3*wUtJAAHrD|mK&@D5jYC^yE?m^l;d%$)u3$%n$N1T+=oQgt&QF; zmdmPy6~q9a@yvAh^>|F^i2rd3USWMag2DzJDat@XV>AP&EAx#Hd8XmpWI7=mIJaB? z+{*PLpU(=ck2Pq3VmMPS>&2J^KsQYZ@#>3l1xi-r75TIwg!BRG9LQiD6NGEo1h>-U zYU3B}QMjP2+Tm5%kiFQStW;Gc)(Z;CbVes?Oo|+XS+htOX}MfZh|y}ck5E-5Jz;LO zRaKZ|pa%z5`2;#}h>na$V+vAZgP6f7L=;iqt3_o+?gGQjWp>zq8^Mx7BXE+U-=Zj*6oye(*Zj+ft zlZU9s`K%^kV_B_((o4# zFfkrYCrBX;P4NGkE|d*~_ZP%J2~c>KlcV69lmh!Tt3V6Gc~t=lRUkM34yXfw70Ed& z6sqBTLto<;gefu;}MzI zgOe4l6Bo2if_P&{{*y_BX^IFg=S5R~2qhwqRDsc^S&)LNMDw9wA!E8loqxnD#do9y2kf5FdXvAz$ zkcW-?1r;OmZy!lrQFdkkM5PH&3dDX=>=lz`UP38?QdI;E;6VF6%uymxLQyLw_sDs& zLwF5ZJdKb6jgvV};yj{Jl^Brq`t-3p)rPU4E_`KTU$weD)lp2wdApNZ$CxVO*gC=f zD77|GfQK+iTZrmdw>ONe_VZYXA{lI0As8>B&PLFFo~MbDMhmSaG(=H<=$yZ^9<4iN z^s%43!R;W<6U(?1Ivt)2xUS1vEtkAC>UN^aFUrW*BKkdJEeTjIPE^ox$peasPP@0m z#N2j#c03j=>ndek)E1nb)y!mD+9ZNos|8~}z4vi>_sQLUs~4C!G6Cx!UN%fgW{gli zV@b?>#yLc#tfgd}G2h3Gr6~(JUX1Y%J>jQO2Nc;(d+2Wz9qs!nki8STkya`)Oq%U1 zx56l!I-_-9Wz@cPtAFRtP195A{QYkU703L7P-zf2k#GndzM*{wxv(vmA1FyCYc@Qh zRG^s7BgTCtWe_rvDh;?GMhNASC{)V#V_;Dd#7d!X3|oGp1$@?=JirGJA)OWAo-qNI z6W^EEh8^K+C72FyRBGkxfk2E}t-PqEUOOMPponP`2he&&MmQhHD=e_qG3T8QnAslWg1%>$HNP`csnI0B~g(B>vxsh;BvYNv! z=^_zZ)@rFpaUq0M+*p}N6>2Gwc$8sK302T8WoXEP2xZ#U{!p_eZIcy#_+>f@3~bN2 z2r(7z@EuSh^)_Q#H<1-wDyD?t!a#QoNg=)&xESI@q(%|I50kZ9&OsLg8l6E@Yp6qh zHOH`yXn-d&t)w5Pam$*vP69*(%Sb9CC2|aLDP$Y;bM}>^tLty~V(BV)NL?SGL79oZ8 zo1~zkr$S>>Ztxe7e4p|F1wwUFvoN`ge9%@K#8X-rqfNvWppc;|jI>Y|t2ROau>h{6 zq^!m7Z&VlBC^-gUto-@~(0s%=+e#5*VcS6`>zm=GHa)Ag3Ia3^9RzXQ=6AiNgKWJ{ zlZ0&@?}?4FN~}wZn6b#Vx{4Xi#ClRxajshJVl5uT))<=w1#;2$!`UER>lyLvv;MJu z61X4)$MGPm;KO9ojr3&HC7}LJ(#`E$IL->x<9gWgB|6sH^#K#THD>FLiVbvVS#RC2 zZbr;xLtiCErl*Emo0^+6YKh}teV)ykx^6TXed^}&^mMSJF5fNCt&|T$(?+01P&7P>mZk}h6Wjbo_2zLFAom-Zu%dj0L z9iZaj@h{%Dzq!_x?C{;gcU>n+04*x=Ua{^*%pYzH2LtQl)w%7ahmQw@-}ww}mckiZ z9--#ANVEmWTv1gaRE)<_b6}hdMuXJz=RDEKazXnkMZw z`}EWfh1pzS3_J(5pppU>v<hZ+ZoQ=*|K;SZHBwP3VbbXkYF zK+JF(LY54olJGLPmZU=oLnWpm$`6X6qFJ@?LxIP?;kvjmV+gvQ$lcPf$;wM!QwfCw|#3;!)MTb>?sxlb3zUD())9?Amavk8)o36Wc+;nZbr*af}O9n^B>0J)1a2zw-m; zsH;eZA0UoA#4SU1@Jp==5@X7c99(pa9|*Kd&imo8?Z&o63CzSe5H(}hTgoH)*@npB z$5Gae6ZB@hjp(GTugQdJQ(lubiU)vbx+%9q2h9!JAYBG+heiChF)mCdD17Zwk}s5Vz0ZJpuYe)Ho00AvWdGXMYp From fdb3755c7ddde199c93a16a5209a68da1803d30c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 11:45:33 +0200 Subject: [PATCH 10/19] Document Hermes in the manual The agent table lists every CLI Omarchy pre-wires, and Hermes was missing from it. Hermes Desktop earns a paragraph of its own under the graphical apps, because the one-Hermes-per-machine arrangement is something a user meets rather than reads about: the app installs its own runtime on first launch, the terminal command and the default agent then use that same one, and removing the app takes the runtime but keeps their chats, memories and skills. Co-Authored-By: Claude Opus 5 (1M context) --- manual/17-ai.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/manual/17-ai.md b/manual/17-ai.md index f5516b88..f6212146 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -14,6 +14,7 @@ Omarchy treats AI coding agents as first-class citizens, but it doesn't pick a f | `pi` | [Mario Zechner's Pi](https://github.com/badlogic/pi-mono) | | `omp` | [Oh My Pi](https://github.com/can1357/oh-my-pi) | | `ori` | [Ori](https://openrouter.ai/docs/guides/ori/harness), OpenRouter's harness | +| `hermes` | [Hermes](https://hermes-agent.nousresearch.com/), Nous Research's agent | `ori` is the odd one out: it runs the other harnesses against OpenRouter's whole model catalog, so `ori claude`, `ori codex`, or `ori opencode` start those agents on whichever model you point them at, and `ori code` is Ori's own agent. @@ -41,7 +42,9 @@ The watching is on by default. Turn it off under _Trigger > Toggle > Crash Captu ### Desktop apps -The _Install > AI_ menu also carries a couple of graphical AI apps: the ChatGPT desktop app, and Grok Bot for chatting with xAI's models. +The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, Grok Bot for chatting with xAI's models, and Hermes Desktop. + +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. ### Local LLMs From b609ae235578e648900f7fb9e8f26602c8fd557a Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 21:31:17 -0500 Subject: [PATCH 11/19] Keep prompted Hermes sessions interactive Hermes oneshot deliberately exits after answering, which closes the agent terminal. Seed the TUI chat session instead, keep inherited flags after the subcommand for older Hermes parsers, and bind the query as one argument so dash-prefixed prompts remain data. Co-Authored-By: Codex XHigh --- bin/omarchy-agent | 5 ++--- test/shell.d/default-agent-test.sh | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 426ea85a..80e0f182 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -87,10 +87,9 @@ codex) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") ;; hermes) - # Hermes has no "start interactive, seeded with this prompt" mode. --oneshot - # answers the prompt and exits, which is the closest it offers. if [[ -n ${prompt:-} ]]; then - command=(hermes --yolo --oneshot "$prompt") + # Keep inherited flags after chat so older Hermes subparsers do not clear them. + command=(hermes chat --yolo --tui "--query=$prompt") else command=(hermes --yolo) fi diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index 5b4512f1..d46bbdb2 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -462,6 +462,7 @@ assert_launch claude claude --permission-mode auto -- "Review this project" assert_launch codex codex --approve-for-me -- "Review this project" assert_launch crush crush run "Review this project" assert_launch grok grok --permission-mode bypassPermissions -- "Review this project" +assert_launch hermes hermes chat --yolo --tui "--query=Review this project" assert_launch agy agy --dangerously-skip-permissions --prompt-interactive "Review this project" assert_launch copilot copilot --allow-all --interactive "Review this project" pass "agent launcher adapts initial prompts for every supported agent" @@ -474,6 +475,7 @@ assert_bypass claude claude --permission-mode auto assert_bypass codex codex --approve-for-me assert_bypass crush crush --yolo assert_bypass grok grok --permission-mode bypassPermissions +assert_bypass hermes hermes --yolo assert_bypass agy agy --dangerously-skip-permissions assert_bypass copilot copilot --allow-all pass "agent launcher skips permission prompts for every supported agent" From 750dde5ed2759816aee0b8bd27fce581fa6466c8 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 22:37:03 -0500 Subject: [PATCH 12/19] Resume prompted Hermes sessions literally Hermes TUI startup queries execute slash, shell, interpolation, and multiline syntax before reaching the model. Run the prompt through literal one-shot mode, read its exact session ID from a private usage report, and resume that session in the TUI so arbitrary prompt text stays data while the conversation remains interactive. Co-Authored-By: Codex XHigh --- bin/omarchy-agent | 3 +- bin/omarchy-agent-hermes | 21 ++++++++ test/shell.d/default-agent-test.sh | 2 +- test/shell.d/hermes-agent-test.sh | 79 ++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 3 deletions(-) create mode 100755 bin/omarchy-agent-hermes create mode 100755 test/shell.d/hermes-agent-test.sh diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 80e0f182..5436ca0b 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -88,8 +88,7 @@ codex) ;; hermes) if [[ -n ${prompt:-} ]]; then - # Keep inherited flags after chat so older Hermes subparsers do not clear them. - command=(hermes chat --yolo --tui "--query=$prompt") + command=(omarchy-agent-hermes "$prompt") else command=(hermes --yolo) fi diff --git a/bin/omarchy-agent-hermes b/bin/omarchy-agent-hermes new file mode 100755 index 00000000..b40a5e67 --- /dev/null +++ b/bin/omarchy-agent-hermes @@ -0,0 +1,21 @@ +#!/bin/bash + +# omarchy:summary=Seed Hermes literally and resume the resulting interactive session +# omarchy:args= +# omarchy:hidden=true + +set -euo pipefail + +prompt=${1:?usage: omarchy-agent-hermes } +usage=$(mktemp) +trap 'rm -f "$usage"' EXIT + +# TUI startup queries pass through Hermes' slash, shell, and interpolation +# dispatcher. One-shot treats the prompt literally and records its session ID. +HERMES_SESSION_SOURCE=tui hermes --yolo --usage-file "$usage" --oneshot "$prompt" +session_id=$(jq -er '.session_id | strings | select(length > 0)' "$usage") + +rm -f "$usage" +trap - EXIT + +exec hermes chat --yolo --tui --resume "$session_id" diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index d46bbdb2..81f3985c 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -462,7 +462,7 @@ assert_launch claude claude --permission-mode auto -- "Review this project" assert_launch codex codex --approve-for-me -- "Review this project" assert_launch crush crush run "Review this project" assert_launch grok grok --permission-mode bypassPermissions -- "Review this project" -assert_launch hermes hermes chat --yolo --tui "--query=Review this project" +assert_launch hermes omarchy-agent-hermes "Review this project" assert_launch agy agy --dangerously-skip-permissions --prompt-interactive "Review this project" assert_launch copilot copilot --allow-all --interactive "Review this project" pass "agent launcher adapts initial prompts for every supported agent" diff --git a/test/shell.d/hermes-agent-test.sh b/test/shell.d/hermes-agent-test.sh new file mode 100755 index 00000000..37b8de55 --- /dev/null +++ b/test/shell.d/hermes-agent-test.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +set -euo pipefail + +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" +oneshot_log="$test_tmp/oneshot" +resume_log="$test_tmp/resume" +source_log="$test_tmp/source" +mkdir -p "$mock_bin" + +cat >"$mock_bin/hermes" <<'SH' +#!/bin/bash + +if [[ " $* " == *" --oneshot "* ]]; then + printf '%s\0' "$@" >"$HERMES_TEST_ONESHOT_LOG" + printf '%s' "${HERMES_SESSION_SOURCE:-}" >"$HERMES_TEST_SOURCE_LOG" + + while (( $# )); do + if [[ $1 == "--usage-file" ]]; then + usage=$2 + break + fi + shift + done + + [[ ${HERMES_TEST_ONESHOT_FAIL:-false} == "false" ]] || exit 42 + [[ ${HERMES_TEST_USAGE_FAIL:-false} == "false" ]] && printf '{"session_id":"session-123"}\n' >"$usage" + printf '%s\n' response + exit +fi + +printf '%s\0' "$@" >"$HERMES_TEST_RESUME_LOG" +SH + +chmod +x "$mock_bin/hermes" + +export PATH="$mock_bin:$PATH" +export HERMES_TEST_ONESHOT_LOG="$oneshot_log" +export HERMES_TEST_RESUME_LOG="$resume_log" +export HERMES_TEST_SOURCE_LOG="$source_log" + +sentinel="$test_tmp/hermes-seed-must-stay-literal" +prompt="!Crash /quit {!touch $sentinel}"$'\ntrailing\\' +"$ROOT/bin/omarchy-agent-hermes" "$prompt" >/dev/null + +mapfile -d '' -t oneshot_args <"$oneshot_log" +(( ${#oneshot_args[@]} == 5 )) || fail "Hermes literal seed has five one-shot arguments" +[[ ${oneshot_args[0]} == "--yolo" ]] || fail "Hermes literal seed enables yolo mode" +[[ ${oneshot_args[1]} == "--usage-file" ]] || fail "Hermes literal seed requests the session report" +usage_file=${oneshot_args[2]} +[[ ${oneshot_args[3]} == "--oneshot" && ${oneshot_args[4]} == "$prompt" ]] || + fail "Hermes literal seed remains one argument" +[[ ! -e $usage_file ]] || fail "Hermes literal seed removes its session report" +[[ ! -e $sentinel ]] || fail "Hermes literal seed never executes prompt interpolation" +[[ $(<"$source_log") == "tui" ]] || fail "Hermes literal seed records an interactive session" + +mapfile -d '' -t resume_args <"$resume_log" +[[ ${resume_args[*]} == "chat --yolo --tui --resume session-123" ]] || + fail "Hermes literal seed resumes the exact completed session" +pass "Hermes sends initial prompts literally and resumes their exact session" + +: >"$resume_log" +if HERMES_TEST_ONESHOT_FAIL=true "$ROOT/bin/omarchy-agent-hermes" failure >/dev/null 2>&1; then + fail "Hermes literal seed reports a failed initial turn" +fi +[[ ! -s $resume_log ]] || fail "Hermes literal seed does not resume a failed initial turn" +pass "Hermes does not resume after a failed initial turn" + +: >"$resume_log" +if HERMES_TEST_USAGE_FAIL=true "$ROOT/bin/omarchy-agent-hermes" missing-session >/dev/null 2>&1; then + fail "Hermes literal seed requires a recorded session ID" +fi +[[ ! -s $resume_log ]] || fail "Hermes literal seed does not guess which session to resume" +pass "Hermes resumes only the session recorded by the initial turn" From 288e387a22244a8f98a30c15ee94d048dab2ba18 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 22:50:21 -0500 Subject: [PATCH 13/19] Preserve Hermes session workspace metadata Keep the one-shot session on Hermes' native CLI source so it records the launch directory before the exact session is resumed in the TUI. Co-Authored-By: Codex XHigh --- bin/omarchy-agent-hermes | 2 +- test/shell.d/hermes-agent-test.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-agent-hermes b/bin/omarchy-agent-hermes index b40a5e67..28675cae 100755 --- a/bin/omarchy-agent-hermes +++ b/bin/omarchy-agent-hermes @@ -12,7 +12,7 @@ trap 'rm -f "$usage"' EXIT # TUI startup queries pass through Hermes' slash, shell, and interpolation # dispatcher. One-shot treats the prompt literally and records its session ID. -HERMES_SESSION_SOURCE=tui hermes --yolo --usage-file "$usage" --oneshot "$prompt" +hermes --yolo --usage-file "$usage" --oneshot "$prompt" session_id=$(jq -er '.session_id | strings | select(length > 0)' "$usage") rm -f "$usage" diff --git a/test/shell.d/hermes-agent-test.sh b/test/shell.d/hermes-agent-test.sh index 37b8de55..7472e3fc 100755 --- a/test/shell.d/hermes-agent-test.sh +++ b/test/shell.d/hermes-agent-test.sh @@ -57,7 +57,7 @@ usage_file=${oneshot_args[2]} fail "Hermes literal seed remains one argument" [[ ! -e $usage_file ]] || fail "Hermes literal seed removes its session report" [[ ! -e $sentinel ]] || fail "Hermes literal seed never executes prompt interpolation" -[[ $(<"$source_log") == "tui" ]] || fail "Hermes literal seed records an interactive session" +[[ ! -s $source_log ]] || fail "Hermes literal seed preserves native CLI session metadata" mapfile -d '' -t resume_args <"$resume_log" [[ ${resume_args[*]} == "chat --yolo --tui --resume session-123" ]] || From 36353296aad129e81b69a6249c01253c540eecab Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 23:01:30 -0500 Subject: [PATCH 14/19] Harden prompted Hermes session handoff Bind option-looking prompts to one-shot mode, require a successful completed usage report before resuming, replay the prompt after first-run setup, and reject Hermes runtimes that lack the session-report capability. Co-Authored-By: Codex XHigh --- bin/omarchy-agent-hermes | 16 +++++++-- bin/omarchy-install-hermes-cli | 39 ++++++++++++++++---- test/shell.d/hermes-agent-test.sh | 59 +++++++++++++++++++++++++++---- test/shell.d/hermes-cli-test.sh | 44 +++++++++++++++++++---- 4 files changed, 138 insertions(+), 20 deletions(-) diff --git a/bin/omarchy-agent-hermes b/bin/omarchy-agent-hermes index 28675cae..eb81495b 100755 --- a/bin/omarchy-agent-hermes +++ b/bin/omarchy-agent-hermes @@ -12,8 +12,20 @@ trap 'rm -f "$usage"' EXIT # TUI startup queries pass through Hermes' slash, shell, and interpolation # dispatcher. One-shot treats the prompt literally and records its session ID. -hermes --yolo --usage-file "$usage" --oneshot "$prompt" -session_id=$(jq -er '.session_id | strings | select(length > 0)' "$usage") +seed_session() { + hermes --yolo --usage-file "$usage" --oneshot="$prompt" +} + +if ! seed_session; then + if jq -e '.failed == true and (.failure | strings | startswith("No inference provider configured."))' "$usage" >/dev/null 2>&1; then + hermes setup + seed_session + else + exit 1 + fi +fi + +session_id=$(jq -er 'select(.completed == true and .failed != true) | .session_id | strings | select(length > 0)' "$usage") rm -f "$usage" trap - EXIT diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index 0c2e526e..f3a8614c 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -55,7 +55,7 @@ desktop_hermes_ready() { # And a marker left behind by an install whose venv has since gone answers # for nothing, so the command has to run, exactly as a foreign one must. - hermes_runs + hermes_prompt_ready } # Whether Hermes is really installed, not merely whether the stub exists. A @@ -88,6 +88,15 @@ hermes_runs() { timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1 } +# Prompted Omarchy sessions need Hermes' usage report to identify and validate +# the exact one-shot session before reopening it interactively. +hermes_prompt_ready() { + local help + hermes_runs && + help=$(timeout 15 "$HOME/.local/bin/hermes" --help 2>/dev/null) && + grep -qF -- '--usage-file' <<<"$help" +} + # --owns answers whether the wrapper on PATH is the one this command wrote, so # the migration and Remove Preinstalls do not each carry their own copy of the # marker and drift from it. @@ -101,12 +110,12 @@ if [[ $mode == "--check" ]]; then if desktop_owns_hermes; then if desktop_hermes_ready; then exit 0; else exit 1; fi fi - # A foreign command is ready when it runs; a broken one is not, and since it - # is not ours to replace, nothing this installer does will make it ready. + # A foreign command is ready only when it also supports prompted sessions; + # since it is not ours to replace, nothing this installer does will update it. if foreign_hermes; then - if hermes_runs; then exit 0; else exit 1; fi + if hermes_prompt_ready; then exit 0; else exit 1; fi fi - if installed; then exit 0; else exit 1; fi + if installed && hermes_prompt_ready; then exit 0; else exit 1; fi fi # Hand Hermes over to the app rather than keeping a second copy beside it. @@ -140,15 +149,29 @@ fi # The user already has a hermes of their own. Leave it be: a working one is # what the default agent will run, and a broken one is theirs to fix. if foreign_hermes; then - if hermes_runs; then + if hermes_prompt_ready; then exit 0 fi + if hermes_runs; then + echo "~/.local/bin/hermes does not support the session report Omarchy needs for prompted launches." >&2 + echo "Update it to Hermes Agent 0.18.1 or newer, then run omarchy-install-hermes-cli again." >&2 + exit 1 + fi + echo "~/.local/bin/hermes exists but is not runnable, and it was not installed by Omarchy." >&2 echo "Fix or remove it, then run omarchy-install-hermes-cli again." >&2 exit 1 fi +# An older mise environment may predate the session report. It belongs to this +# installer, so discard only that environment and let the current stub rebuild it. +if installed && ! hermes_prompt_ready; then + echo "Updating Hermes for prompted sessions..." >&2 + mise rm -g "$tool" >/dev/null 2>&1 || true + mise uninstall --all "$tool" >/dev/null 2>&1 || true +fi + mkdir -p "$HOME/.local/bin" rm -f "$HOME/.local/bin/hermes" @@ -194,4 +217,8 @@ chmod +x "$HOME/.local/bin/hermes" # hands Hermes to the GUI has to install it here rather than leave it stubbed. if [[ $mode == "--now" ]]; then "$HOME/.local/bin/hermes" --version + if ! hermes_prompt_ready; then + echo "Hermes installed without the session report Omarchy needs for prompted launches." >&2 + exit 1 + fi fi diff --git a/test/shell.d/hermes-agent-test.sh b/test/shell.d/hermes-agent-test.sh index 7472e3fc..174958d0 100755 --- a/test/shell.d/hermes-agent-test.sh +++ b/test/shell.d/hermes-agent-test.sh @@ -11,12 +11,21 @@ mock_bin="$test_tmp/bin" oneshot_log="$test_tmp/oneshot" resume_log="$test_tmp/resume" source_log="$test_tmp/source" +setup_log="$test_tmp/setup" +setup_marker="$test_tmp/setup-complete" mkdir -p "$mock_bin" cat >"$mock_bin/hermes" <<'SH' #!/bin/bash -if [[ " $* " == *" --oneshot "* ]]; then +if [[ ${1:-} == "setup" ]]; then + printf '%s\0' "$@" >"$HERMES_TEST_SETUP_LOG" + [[ ${HERMES_TEST_SETUP_FAIL:-false} == "false" ]] || exit 43 + touch "$HERMES_TEST_SETUP_MARKER" + exit +fi + +if [[ " $* " == *" --oneshot="* ]]; then printf '%s\0' "$@" >"$HERMES_TEST_ONESHOT_LOG" printf '%s' "${HERMES_SESSION_SOURCE:-}" >"$HERMES_TEST_SOURCE_LOG" @@ -28,8 +37,19 @@ if [[ " $* " == *" --oneshot "* ]]; then shift done + if [[ ${HERMES_TEST_NEEDS_SETUP:-false} == "true" && ! -e $HERMES_TEST_SETUP_MARKER ]]; then + printf '{"session_id":null,"completed":null,"failed":true,"failure":"No inference provider configured. Run hermes model."}\n' >"$usage" + exit 1 + fi + [[ ${HERMES_TEST_ONESHOT_FAIL:-false} == "false" ]] || exit 42 - [[ ${HERMES_TEST_USAGE_FAIL:-false} == "false" ]] && printf '{"session_id":"session-123"}\n' >"$usage" + if [[ ${HERMES_TEST_USAGE_FAIL:-false} == "false" ]]; then + completed=true + failed=false + [[ ${HERMES_TEST_USAGE_INCOMPLETE:-false} == "false" ]] || completed=false + [[ ${HERMES_TEST_USAGE_FAILED:-false} == "false" ]] || failed=true + printf '{"session_id":"session-123","completed":%s,"failed":%s}\n' "$completed" "$failed" >"$usage" + fi printf '%s\n' response exit fi @@ -43,18 +63,19 @@ export PATH="$mock_bin:$PATH" export HERMES_TEST_ONESHOT_LOG="$oneshot_log" export HERMES_TEST_RESUME_LOG="$resume_log" export HERMES_TEST_SOURCE_LOG="$source_log" +export HERMES_TEST_SETUP_LOG="$setup_log" +export HERMES_TEST_SETUP_MARKER="$setup_marker" sentinel="$test_tmp/hermes-seed-must-stay-literal" -prompt="!Crash /quit {!touch $sentinel}"$'\ntrailing\\' +prompt="--help !Crash /quit {!touch $sentinel}"$'\ntrailing\\' "$ROOT/bin/omarchy-agent-hermes" "$prompt" >/dev/null mapfile -d '' -t oneshot_args <"$oneshot_log" -(( ${#oneshot_args[@]} == 5 )) || fail "Hermes literal seed has five one-shot arguments" +(( ${#oneshot_args[@]} == 4 )) || fail "Hermes literal seed has four one-shot arguments" [[ ${oneshot_args[0]} == "--yolo" ]] || fail "Hermes literal seed enables yolo mode" [[ ${oneshot_args[1]} == "--usage-file" ]] || fail "Hermes literal seed requests the session report" usage_file=${oneshot_args[2]} -[[ ${oneshot_args[3]} == "--oneshot" && ${oneshot_args[4]} == "$prompt" ]] || - fail "Hermes literal seed remains one argument" +[[ ${oneshot_args[3]} == "--oneshot=$prompt" ]] || fail "Hermes literal seed binds option-looking prompts as data" [[ ! -e $usage_file ]] || fail "Hermes literal seed removes its session report" [[ ! -e $sentinel ]] || fail "Hermes literal seed never executes prompt interpolation" [[ ! -s $source_log ]] || fail "Hermes literal seed preserves native CLI session metadata" @@ -77,3 +98,29 @@ if HERMES_TEST_USAGE_FAIL=true "$ROOT/bin/omarchy-agent-hermes" missing-session fi [[ ! -s $resume_log ]] || fail "Hermes literal seed does not guess which session to resume" pass "Hermes resumes only the session recorded by the initial turn" + +for state in INCOMPLETE FAILED; do + : >"$resume_log" + if env "HERMES_TEST_USAGE_$state=true" "$ROOT/bin/omarchy-agent-hermes" "${state,,}" >/dev/null 2>&1; then + fail "Hermes literal seed rejects a reported ${state,,} initial turn" + fi + [[ ! -s $resume_log ]] || fail "Hermes literal seed does not resume a reported ${state,,} initial turn" +done +pass "Hermes resumes only completed successful initial turns" + +: >"$resume_log" +HERMES_TEST_NEEDS_SETUP=true "$ROOT/bin/omarchy-agent-hermes" setup-first >/dev/null +mapfile -d '' -t setup_args <"$setup_log" +[[ ${setup_args[*]} == "setup" ]] || fail "Hermes runs setup when no inference provider is configured" +mapfile -d '' -t resume_args <"$resume_log" +[[ ${resume_args[*]} == "chat --yolo --tui --resume session-123" ]] || + fail "Hermes replays the prompted turn after setup and resumes it" +pass "Hermes completes first-run setup before replaying the prompt" + +rm -f "$setup_marker" +: >"$resume_log" +if HERMES_TEST_NEEDS_SETUP=true HERMES_TEST_SETUP_FAIL=true "$ROOT/bin/omarchy-agent-hermes" setup-cancelled >/dev/null 2>&1; then + fail "Hermes reports a failed first-run setup" +fi +[[ ! -s $resume_log ]] || fail "Hermes does not resume when first-run setup fails" +pass "Hermes stops when first-run setup does not complete" diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 88319dc9..39435aa5 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -26,7 +26,10 @@ SH cat >"$mock_bin/mise" <<'SH' #!/bin/bash printf '%s\0' "$@" >>"$OMARCHY_TEST_MISE_LOG" -[[ $1 == "where" && ${OMARCHY_TEST_MISE_WHERE_OK:-0} == 1 ]] && exit 0 +if [[ $1 == "where" && ${OMARCHY_TEST_MISE_WHERE_OK:-0} == 1 ]]; then + printf '%s\n' "$OMARCHY_TEST_MISE_ROOT" + exit 0 +fi [[ $1 != "where" ]] SH @@ -35,6 +38,7 @@ chmod +x "$mock_bin"/* run_installer() { OMARCHY_TEST_DESKTOP_INSTALLED="$1" \ OMARCHY_TEST_MISE_WHERE_OK="${OMARCHY_TEST_MISE_WHERE_OK:-0}" \ + OMARCHY_TEST_MISE_ROOT="$test_tmp/mise" \ OMARCHY_TEST_MISE_LOG="$mise_log" \ HOME="$test_home" \ PATH="$mock_bin:$PATH" \ @@ -88,10 +92,17 @@ pass "takeover removes an unhealthy mise copy" rm -rf "$test_home/.hermes" rm -f "$test_home/.local/bin/hermes" run_installer 1 --check && fail "--check reports Hermes missing before the app installs it" -# The venv command answers --version, as the real one does: foreign wrappers -# below exec it, and the installer probes them by running exactly that. +# The venv command answers the readiness probes, as the real one does: foreign +# wrappers below exec it, and the installer runs both before trusting them. mkdir -p "$test_home/.hermes/hermes-agent/venv/bin" -printf '%s\n' "#!/bin/bash" 'echo "hermes-agent 0.0.0-test"' >"$test_home/.hermes/hermes-agent/venv/bin/hermes" +cat >"$test_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "--help" ]]; then + [[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "--usage-file PATH" +else + echo "hermes-agent 0.0.0-test" +fi +SH chmod +x "$test_home/.hermes/hermes-agent/venv/bin/hermes" run_installer 1 --check && fail "--check waits for the install to finish, not just the venv" touch "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete" @@ -123,6 +134,14 @@ run_installer 0 --now || fail "--now over a foreign hermes command returns succe fail "a foreign hermes command is left untouched" pass "a foreign hermes command is preserved and satisfies --check" +OMARCHY_TEST_HERMES_CAPABLE=0 run_installer 0 --check && + fail "--check rejects a foreign Hermes without prompted-session reports" +OMARCHY_TEST_HERMES_CAPABLE=0 run_installer 0 && + fail "installing refuses a foreign Hermes without prompted-session reports" +[[ $(cat "$test_home/.local/bin/hermes") == "$official_body" ]] || + fail "an older foreign Hermes command is left untouched" +pass "a foreign Hermes must support prompted-session reports" + # Broken foreign paths are still foreign. They cannot be used, so --check says # so and the installer refuses rather than replacing them. printf '%s\n' "$official_body" >"$test_home/.local/bin/hermes" @@ -211,6 +230,13 @@ grep -q "stale template" "$test_home/.local/bin/hermes" && fail "reinstalling re grep -q "exec env -u UV_PYTHON mise x" "$test_home/.local/bin/hermes" || fail "the refreshed stub is the current template" pass "reinstalling refreshes the Omarchy stub" +mkdir -p "$test_tmp/mise/hermes-agent/lib/python$python_pin" +: >"$mise_log" +OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 0 || fail "reinstalling replaces an older owned Hermes environment" +tr '\0' '\n' <"$mise_log" | grep -q '^rm$' || fail "an older owned Hermes environment is removed from mise config" +tr '\0' '\n' <"$mise_log" | grep -q '^uninstall$' || fail "an older owned Hermes environment is uninstalled" +pass "reinstalling replaces an older owned Hermes environment" + # install/user/mise.sh is sourced by install/user/all.sh through run_logged, # which runs it under `bash -eE` and hands its exit code back to # omarchy-provision-user's `set -euo pipefail`. Everything that finalizes a user @@ -349,8 +375,14 @@ run_ready_check() { run_ready_check && fail "--check rejects the app's wrapper when its runtime is gone" -printf '%s\n' "#!/bin/bash" 'echo "hermes-agent 0.0.0-test"' \ - >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" +cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "--help" ]]; then + echo "--usage-file PATH" +else + echo "hermes-agent 0.0.0-test" +fi +SH chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes" run_ready_check || fail "--check accepts the app's wrapper once it runs" pass "readiness runs the app's command rather than trusting its marker" From 64203cc2085717d29b7a13257bad1b48a2434cbf Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 27 Aug 2026 23:07:18 -0500 Subject: [PATCH 15/19] Keep prompted Hermes sessions local Clear inherited session-source tags for Omarchy's local one-shot process so Hermes records the launch directory before the exact session is resumed in the TUI. Co-Authored-By: Codex XHigh --- bin/omarchy-agent-hermes | 1 + test/shell.d/hermes-agent-test.sh | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-agent-hermes b/bin/omarchy-agent-hermes index eb81495b..93175818 100755 --- a/bin/omarchy-agent-hermes +++ b/bin/omarchy-agent-hermes @@ -7,6 +7,7 @@ set -euo pipefail prompt=${1:?usage: omarchy-agent-hermes } +unset HERMES_SESSION_SOURCE usage=$(mktemp) trap 'rm -f "$usage"' EXIT diff --git a/test/shell.d/hermes-agent-test.sh b/test/shell.d/hermes-agent-test.sh index 174958d0..3339df24 100755 --- a/test/shell.d/hermes-agent-test.sh +++ b/test/shell.d/hermes-agent-test.sh @@ -68,7 +68,7 @@ export HERMES_TEST_SETUP_MARKER="$setup_marker" sentinel="$test_tmp/hermes-seed-must-stay-literal" prompt="--help !Crash /quit {!touch $sentinel}"$'\ntrailing\\' -"$ROOT/bin/omarchy-agent-hermes" "$prompt" >/dev/null +HERMES_SESSION_SOURCE=gateway "$ROOT/bin/omarchy-agent-hermes" "$prompt" >/dev/null mapfile -d '' -t oneshot_args <"$oneshot_log" (( ${#oneshot_args[@]} == 4 )) || fail "Hermes literal seed has four one-shot arguments" From 5284be65828112b2882179797566979f005d1ace Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Fri, 28 Aug 2026 16:32:19 -0500 Subject: [PATCH 16/19] Use Hermes native prompted sessions Hermes now keeps chat queries interactive and literal to TUI control syntax, so launch it directly and let its own session flow replace the local one-shot, usage-file, and resume bridge. Gate installation on the capability added with native interactive queries, preserve unowned mise environments, and keep the unprompted launch path unchanged. Co-Authored-By: Codex XHigh --- bin/omarchy-agent | 2 +- bin/omarchy-agent-hermes | 34 -------- bin/omarchy-install-hermes-cli | 29 ++++--- test/shell.d/default-agent-test.sh | 15 +++- test/shell.d/hermes-agent-test.sh | 126 ----------------------------- test/shell.d/hermes-cli-test.sh | 24 ++++-- 6 files changed, 47 insertions(+), 183 deletions(-) delete mode 100755 bin/omarchy-agent-hermes delete mode 100755 test/shell.d/hermes-agent-test.sh diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 5436ca0b..05f1e1a1 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -88,7 +88,7 @@ codex) ;; hermes) if [[ -n ${prompt:-} ]]; then - command=(omarchy-agent-hermes "$prompt") + command=(env -u HERMES_SESSION_SOURCE hermes chat --yolo --tui "--query=$prompt") else command=(hermes --yolo) fi diff --git a/bin/omarchy-agent-hermes b/bin/omarchy-agent-hermes deleted file mode 100755 index 93175818..00000000 --- a/bin/omarchy-agent-hermes +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Seed Hermes literally and resume the resulting interactive session -# omarchy:args= -# omarchy:hidden=true - -set -euo pipefail - -prompt=${1:?usage: omarchy-agent-hermes } -unset HERMES_SESSION_SOURCE -usage=$(mktemp) -trap 'rm -f "$usage"' EXIT - -# TUI startup queries pass through Hermes' slash, shell, and interpolation -# dispatcher. One-shot treats the prompt literally and records its session ID. -seed_session() { - hermes --yolo --usage-file "$usage" --oneshot="$prompt" -} - -if ! seed_session; then - if jq -e '.failed == true and (.failure | strings | startswith("No inference provider configured."))' "$usage" >/dev/null 2>&1; then - hermes setup - seed_session - else - exit 1 - fi -fi - -session_id=$(jq -er 'select(.completed == true and .failed != true) | .session_id | strings | select(length > 0)' "$usage") - -rm -f "$usage" -trap - EXIT - -exec hermes chat --yolo --tui --resume "$session_id" diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index f3a8614c..5b6a3598 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -88,13 +88,13 @@ hermes_runs() { timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1 } -# Prompted Omarchy sessions need Hermes' usage report to identify and validate -# the exact one-shot session before reopening it interactively. +# The chat subcommand's --oneshot opt-out arrived with native interactive -q, +# so its presence is a stable capability check without relying on a version. hermes_prompt_ready() { local help hermes_runs && - help=$(timeout 15 "$HOME/.local/bin/hermes" --help 2>/dev/null) && - grep -qF -- '--usage-file' <<<"$help" + help=$(timeout 15 "$HOME/.local/bin/hermes" chat --help 2>/dev/null) && + grep -qF -- '--oneshot' <<<"$help" } # --owns answers whether the wrapper on PATH is the one this command wrote, so @@ -154,8 +154,8 @@ if foreign_hermes; then fi if hermes_runs; then - echo "~/.local/bin/hermes does not support the session report Omarchy needs for prompted launches." >&2 - echo "Update it to Hermes Agent 0.18.1 or newer, then run omarchy-install-hermes-cli again." >&2 + echo "~/.local/bin/hermes does not support the interactive seeded sessions Omarchy needs." >&2 + echo "Update it to a Hermes Agent release with interactive chat queries, then run omarchy-install-hermes-cli again." >&2 exit 1 fi @@ -164,12 +164,17 @@ if foreign_hermes; then exit 1 fi -# An older mise environment may predate the session report. It belongs to this -# installer, so discard only that environment and let the current stub rebuild it. +# Only the marked wrapper proves the matching mise environment is ours to replace. if installed && ! hermes_prompt_ready; then - echo "Updating Hermes for prompted sessions..." >&2 - mise rm -g "$tool" >/dev/null 2>&1 || true - mise uninstall --all "$tool" >/dev/null 2>&1 || true + if ours; then + echo "Updating Hermes for prompted sessions..." >&2 + mise rm -g "$tool" >/dev/null 2>&1 || true + mise uninstall --all "$tool" >/dev/null 2>&1 || true + else + echo "A Hermes mise environment exists without an Omarchy-owned wrapper." >&2 + echo "Update or remove it explicitly, then run omarchy-install-hermes-cli again." >&2 + exit 1 + fi fi mkdir -p "$HOME/.local/bin" @@ -218,7 +223,7 @@ chmod +x "$HOME/.local/bin/hermes" if [[ $mode == "--now" ]]; then "$HOME/.local/bin/hermes" --version if ! hermes_prompt_ready; then - echo "Hermes installed without the session report Omarchy needs for prompted launches." >&2 + echo "Hermes installed without the interactive seeded sessions Omarchy needs." >&2 exit 1 fi fi diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index 81f3985c..fbd60f31 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -431,8 +431,10 @@ assert_launched() { fail "$agent launch $description" "expected: ${expected[*]}\nactual: ${actual[*]}" for ((index = 0; index < ${#expected[@]}; index++)); do - [[ ${actual[$index]} == ${expected[$index]} ]] || - fail "$agent launch $description" "expected: ${expected[*]}\nactual: ${actual[*]}" + case ${actual[$index]} in + "${expected[$index]}") ;; + *) fail "$agent launch $description" "expected: ${expected[*]}\nactual: ${actual[*]}" ;; + esac done } @@ -462,11 +464,18 @@ assert_launch claude claude --permission-mode auto -- "Review this project" assert_launch codex codex --approve-for-me -- "Review this project" assert_launch crush crush run "Review this project" assert_launch grok grok --permission-mode bypassPermissions -- "Review this project" -assert_launch hermes omarchy-agent-hermes "Review this project" +assert_launch hermes env -u HERMES_SESSION_SOURCE hermes chat --yolo --tui "--query=Review this project" assert_launch agy agy --dangerously-skip-permissions --prompt-interactive "Review this project" assert_launch copilot copilot --allow-all --interactive "Review this project" pass "agent launcher adapts initial prompts for every supported agent" +literal_hermes_prompt=$' --help !Crash /quit {$(touch must-not-run)}\ntrailing\\ ' +printf '%s\n' "hermes" >"$agent_file" +omarchy-agent-prompt "$literal_hermes_prompt" +assert_launched hermes "binds its literal initial prompt" env -u HERMES_SESSION_SOURCE \ + hermes chat --yolo --tui "--query=$literal_hermes_prompt" +pass "Hermes receives prompted launches as one literal query argument" + assert_bypass pi pi assert_bypass omp omp --auto-approve assert_bypass opencode opencode --auto diff --git a/test/shell.d/hermes-agent-test.sh b/test/shell.d/hermes-agent-test.sh deleted file mode 100755 index 3339df24..00000000 --- a/test/shell.d/hermes-agent-test.sh +++ /dev/null @@ -1,126 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -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" -oneshot_log="$test_tmp/oneshot" -resume_log="$test_tmp/resume" -source_log="$test_tmp/source" -setup_log="$test_tmp/setup" -setup_marker="$test_tmp/setup-complete" -mkdir -p "$mock_bin" - -cat >"$mock_bin/hermes" <<'SH' -#!/bin/bash - -if [[ ${1:-} == "setup" ]]; then - printf '%s\0' "$@" >"$HERMES_TEST_SETUP_LOG" - [[ ${HERMES_TEST_SETUP_FAIL:-false} == "false" ]] || exit 43 - touch "$HERMES_TEST_SETUP_MARKER" - exit -fi - -if [[ " $* " == *" --oneshot="* ]]; then - printf '%s\0' "$@" >"$HERMES_TEST_ONESHOT_LOG" - printf '%s' "${HERMES_SESSION_SOURCE:-}" >"$HERMES_TEST_SOURCE_LOG" - - while (( $# )); do - if [[ $1 == "--usage-file" ]]; then - usage=$2 - break - fi - shift - done - - if [[ ${HERMES_TEST_NEEDS_SETUP:-false} == "true" && ! -e $HERMES_TEST_SETUP_MARKER ]]; then - printf '{"session_id":null,"completed":null,"failed":true,"failure":"No inference provider configured. Run hermes model."}\n' >"$usage" - exit 1 - fi - - [[ ${HERMES_TEST_ONESHOT_FAIL:-false} == "false" ]] || exit 42 - if [[ ${HERMES_TEST_USAGE_FAIL:-false} == "false" ]]; then - completed=true - failed=false - [[ ${HERMES_TEST_USAGE_INCOMPLETE:-false} == "false" ]] || completed=false - [[ ${HERMES_TEST_USAGE_FAILED:-false} == "false" ]] || failed=true - printf '{"session_id":"session-123","completed":%s,"failed":%s}\n' "$completed" "$failed" >"$usage" - fi - printf '%s\n' response - exit -fi - -printf '%s\0' "$@" >"$HERMES_TEST_RESUME_LOG" -SH - -chmod +x "$mock_bin/hermes" - -export PATH="$mock_bin:$PATH" -export HERMES_TEST_ONESHOT_LOG="$oneshot_log" -export HERMES_TEST_RESUME_LOG="$resume_log" -export HERMES_TEST_SOURCE_LOG="$source_log" -export HERMES_TEST_SETUP_LOG="$setup_log" -export HERMES_TEST_SETUP_MARKER="$setup_marker" - -sentinel="$test_tmp/hermes-seed-must-stay-literal" -prompt="--help !Crash /quit {!touch $sentinel}"$'\ntrailing\\' -HERMES_SESSION_SOURCE=gateway "$ROOT/bin/omarchy-agent-hermes" "$prompt" >/dev/null - -mapfile -d '' -t oneshot_args <"$oneshot_log" -(( ${#oneshot_args[@]} == 4 )) || fail "Hermes literal seed has four one-shot arguments" -[[ ${oneshot_args[0]} == "--yolo" ]] || fail "Hermes literal seed enables yolo mode" -[[ ${oneshot_args[1]} == "--usage-file" ]] || fail "Hermes literal seed requests the session report" -usage_file=${oneshot_args[2]} -[[ ${oneshot_args[3]} == "--oneshot=$prompt" ]] || fail "Hermes literal seed binds option-looking prompts as data" -[[ ! -e $usage_file ]] || fail "Hermes literal seed removes its session report" -[[ ! -e $sentinel ]] || fail "Hermes literal seed never executes prompt interpolation" -[[ ! -s $source_log ]] || fail "Hermes literal seed preserves native CLI session metadata" - -mapfile -d '' -t resume_args <"$resume_log" -[[ ${resume_args[*]} == "chat --yolo --tui --resume session-123" ]] || - fail "Hermes literal seed resumes the exact completed session" -pass "Hermes sends initial prompts literally and resumes their exact session" - -: >"$resume_log" -if HERMES_TEST_ONESHOT_FAIL=true "$ROOT/bin/omarchy-agent-hermes" failure >/dev/null 2>&1; then - fail "Hermes literal seed reports a failed initial turn" -fi -[[ ! -s $resume_log ]] || fail "Hermes literal seed does not resume a failed initial turn" -pass "Hermes does not resume after a failed initial turn" - -: >"$resume_log" -if HERMES_TEST_USAGE_FAIL=true "$ROOT/bin/omarchy-agent-hermes" missing-session >/dev/null 2>&1; then - fail "Hermes literal seed requires a recorded session ID" -fi -[[ ! -s $resume_log ]] || fail "Hermes literal seed does not guess which session to resume" -pass "Hermes resumes only the session recorded by the initial turn" - -for state in INCOMPLETE FAILED; do - : >"$resume_log" - if env "HERMES_TEST_USAGE_$state=true" "$ROOT/bin/omarchy-agent-hermes" "${state,,}" >/dev/null 2>&1; then - fail "Hermes literal seed rejects a reported ${state,,} initial turn" - fi - [[ ! -s $resume_log ]] || fail "Hermes literal seed does not resume a reported ${state,,} initial turn" -done -pass "Hermes resumes only completed successful initial turns" - -: >"$resume_log" -HERMES_TEST_NEEDS_SETUP=true "$ROOT/bin/omarchy-agent-hermes" setup-first >/dev/null -mapfile -d '' -t setup_args <"$setup_log" -[[ ${setup_args[*]} == "setup" ]] || fail "Hermes runs setup when no inference provider is configured" -mapfile -d '' -t resume_args <"$resume_log" -[[ ${resume_args[*]} == "chat --yolo --tui --resume session-123" ]] || - fail "Hermes replays the prompted turn after setup and resumes it" -pass "Hermes completes first-run setup before replaying the prompt" - -rm -f "$setup_marker" -: >"$resume_log" -if HERMES_TEST_NEEDS_SETUP=true HERMES_TEST_SETUP_FAIL=true "$ROOT/bin/omarchy-agent-hermes" setup-cancelled >/dev/null 2>&1; then - fail "Hermes reports a failed first-run setup" -fi -[[ ! -s $resume_log ]] || fail "Hermes does not resume when first-run setup fails" -pass "Hermes stops when first-run setup does not complete" diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 39435aa5..83b516e9 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -97,8 +97,8 @@ run_installer 1 --check && fail "--check reports Hermes missing before the app i mkdir -p "$test_home/.hermes/hermes-agent/venv/bin" cat >"$test_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' #!/bin/bash -if [[ ${1:-} == "--help" ]]; then - [[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "--usage-file PATH" +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + [[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "--oneshot" else echo "hermes-agent 0.0.0-test" fi @@ -135,12 +135,12 @@ run_installer 0 --now || fail "--now over a foreign hermes command returns succe pass "a foreign hermes command is preserved and satisfies --check" OMARCHY_TEST_HERMES_CAPABLE=0 run_installer 0 --check && - fail "--check rejects a foreign Hermes without prompted-session reports" + fail "--check rejects a foreign Hermes without native prompted sessions" OMARCHY_TEST_HERMES_CAPABLE=0 run_installer 0 && - fail "installing refuses a foreign Hermes without prompted-session reports" + fail "installing refuses a foreign Hermes without native prompted sessions" [[ $(cat "$test_home/.local/bin/hermes") == "$official_body" ]] || fail "an older foreign Hermes command is left untouched" -pass "a foreign Hermes must support prompted-session reports" +pass "a foreign Hermes must support native prompted sessions" # Broken foreign paths are still foreign. They cannot be used, so --check says # so and the installer refuses rather than replacing them. @@ -237,6 +237,16 @@ tr '\0' '\n' <"$mise_log" | grep -q '^rm$' || fail "an older owned Hermes enviro tr '\0' '\n' <"$mise_log" | grep -q '^uninstall$' || fail "an older owned Hermes environment is uninstalled" pass "reinstalling replaces an older owned Hermes environment" +rm -f "$test_home/.local/bin/hermes" +: >"$mise_log" +OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 0 && + fail "installing refuses to claim an unmarked Hermes mise environment" +tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' && + fail "an unmarked Hermes mise environment is never removed" +[[ ! -e $test_home/.local/bin/hermes ]] || + fail "an unmarked Hermes mise environment is not given an Omarchy wrapper" +pass "a Hermes mise environment needs wrapper ownership before replacement" + # install/user/mise.sh is sourced by install/user/all.sh through run_logged, # which runs it under `bash -eE` and hands its exit code back to # omarchy-provision-user's `set -euo pipefail`. Everything that finalizes a user @@ -377,8 +387,8 @@ run_ready_check && fail "--check rejects the app's wrapper when its runtime is g cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' #!/bin/bash -if [[ ${1:-} == "--help" ]]; then - echo "--usage-file PATH" +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + echo "--oneshot" else echo "hermes-agent 0.0.0-test" fi From c64e03d9c54e2c2265f5b5710ab3bc729257722a Mon Sep 17 00:00:00 2001 From: "James (SMF Works)" Date: Thu, 27 Aug 2026 11:19:05 -0400 Subject: [PATCH 17/19] Link Omarchy agent skills into Hermes skill directories Hermes was missing from the provision-user symlink list that already covers Claude, Codex, Pi, Antigravity, and ~/.agents. Add ~/.hermes/skills plus existing ~/.hermes/profiles/*/skills. Migration for current installs. --- bin/omarchy-provision-user | 10 +++++++++- docs/file-layout.md | 5 +++-- manual/17-ai.md | 2 +- migrations/1787843905.sh | 22 ++++++++++++++++++++++ test/shell.d/provision-user-test.sh | 12 ++++++++++-- 5 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 migrations/1787843905.sh diff --git a/bin/omarchy-provision-user b/bin/omarchy-provision-user index 57024b5e..3bcbf7a8 100755 --- a/bin/omarchy-provision-user +++ b/bin/omarchy-provision-user @@ -84,7 +84,7 @@ fi # Dev-aware skill symlinks. Cannot live in /etc/skel because OMARCHY_PATH may # point at a dev checkout (omarchy dev link) where the target differs. # Loops every skill directory, so shipping a new one needs no edit here. -mkdir -p ~/.agents/skills ~/.claude/skills ~/.codex/skills ~/.pi/agent/skills ~/.gemini/config/skills +mkdir -p ~/.agents/skills ~/.claude/skills ~/.codex/skills ~/.pi/agent/skills ~/.gemini/config/skills ~/.hermes/skills for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do skill=${skill%/} name=${skill##*/} @@ -93,6 +93,14 @@ for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do ln -sfn "$skill" ~/.codex/skills/"$name" ln -sfn "$skill" ~/.pi/agent/skills/"$name" ln -sfn "$skill" ~/.gemini/config/skills/"$name" + ln -sfn "$skill" ~/.hermes/skills/"$name" + if [[ -d ~/.hermes/profiles ]]; then + for profile in ~/.hermes/profiles/*/; do + [[ -d $profile ]] || continue + mkdir -p "$profile/skills" + ln -sfn "$skill" "$profile/skills/$name" + done + fi done mkdir -p ~/Downloads ~/Pictures ~/Videos ~/.config/gtk-3.0 diff --git a/docs/file-layout.md b/docs/file-layout.md index 2a9d965b..08516524 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -198,11 +198,12 @@ Runs once per user. It does **not** copy `~/.config/**`, `~/.bashrc`, `flags.lua`, or the nautilus extensions — `/etc/skel` already seeded those. It only does the things `/etc/skel` can't: -- Skill symlinks `~/.{agents,claude,codex,pi/agent}/skills/` → +- Skill symlinks `~/.{agents,claude,codex,pi/agent,hermes}/skills/` (and each `~/.hermes/profiles/*/skills/`) → `$OMARCHY_PATH/default/agents/skills/`, looping over every skill directory there (currently `omarchy` and `diagnose-crash`) so new skills need no edit. Symlinks (not copies) so `omarchy dev link` against a dev - checkout repoints them correctly. + checkout repoints them correctly. Hermes profile dirs are only linked when + they already exist — provision does not create Hermes profiles. - `xdg-user-dirs-update` (Templates/Public/Desktop folded back into `$HOME`) and `~/.config/gtk-3.0/bookmarks` (needs `$HOME` expansion). - Hyprland's package-owned default input reads `XKBLAYOUT` / `XKBVARIANT` diff --git a/manual/17-ai.md b/manual/17-ai.md index 57698f42..d9a30755 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -51,6 +51,6 @@ Omarchy recommends two ways of running local LLM models: LM Studio and Ollama. L ### The Omarchy Skill -Agent skills help AI use specific tools in a specific way, and Omarchy ships with a default skill for tailoring the system. Like tweaking your Hyprland config, adjusting the bar, or even creating a new theme from scratch. It's symlinked into the skill directories for Claude Code (`~/.claude/skills`), Codex (`~/.codex/skills`), Pi (`~/.pi/agent/skills`), Antigravity (`~/.gemini/config/skills`), and the generic `~/.agents/skills` location, so most harnesses pick it up automatically. +Agent skills help AI use specific tools in a specific way, and Omarchy ships with a default skill for tailoring the system. Like tweaking your Hyprland config, adjusting the bar, or even creating a new theme from scratch. It's symlinked into the skill directories for Claude Code (`~/.claude/skills`), Codex (`~/.codex/skills`), Pi (`~/.pi/agent/skills`), Antigravity (`~/.gemini/config/skills`), Hermes (`~/.hermes/skills` and each `~/.hermes/profiles/*/skills`), and the generic `~/.agents/skills` location, so most harnesses pick it up automatically. But you should treat this skill as experimental. Different models will use it to different effect. It's best to run in plan mode first, so you have an idea of what the agent would like to change. And then be ready to rollback changes or even invoking `omarchy reinstall configs`, if the agent makes a mess of everything. diff --git a/migrations/1787843905.sh b/migrations/1787843905.sh new file mode 100644 index 00000000..e0e58cd5 --- /dev/null +++ b/migrations/1787843905.sh @@ -0,0 +1,22 @@ +echo "Link Omarchy agent skills into Hermes skill directories" + +OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" +skills_source="$OMARCHY_PATH/default/agents/skills" + +[[ -d $skills_source ]] || exit 0 + +mkdir -p "$HOME/.hermes/skills" + +for skill in "$skills_source"/*/; do + [[ -d $skill ]] || continue + name=${skill%/} + name=${name##*/} + ln -sfn "$skills_source/$name" "$HOME/.hermes/skills/$name" + if [[ -d $HOME/.hermes/profiles ]]; then + for profile in "$HOME"/.hermes/profiles/*/; do + [[ -d $profile ]] || continue + mkdir -p "$profile/skills" + ln -sfn "$skills_source/$name" "$profile/skills/$name" + done + fi +done diff --git a/test/shell.d/provision-user-test.sh b/test/shell.d/provision-user-test.sh index b598242e..1acab3dc 100755 --- a/test/shell.d/provision-user-test.sh +++ b/test/shell.d/provision-user-test.sh @@ -8,7 +8,7 @@ test_tmp=$(mktemp -d) trap 'rm -rf "$test_tmp"' EXIT mock_bin="$test_tmp/bin" -mkdir -p "$mock_bin" "$test_tmp/home" +mkdir -p "$mock_bin" "$test_tmp/home" "$test_tmp/home/.hermes/profiles/james" for command in xdg-user-dirs-update xdg-settings xdg-mime; do printf '#!/bin/bash\nexit 0\n' >"$mock_bin/$command" @@ -30,6 +30,14 @@ for skill in omarchy diagnose-crash; do link="$test_tmp/home/.gemini/config/skills/$skill" [[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] || fail "omarchy-provision-user provisions the $skill skill for Antigravity" + + link="$test_tmp/home/.hermes/skills/$skill" + [[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] || + fail "omarchy-provision-user provisions the $skill skill for Hermes" + + link="$test_tmp/home/.hermes/profiles/james/skills/$skill" + [[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] || + fail "omarchy-provision-user provisions the $skill skill for a Hermes profile" done -pass "omarchy-provision-user provisions Antigravity skills" +pass "omarchy-provision-user provisions Antigravity and Hermes skills" From e482977f0928d9592c6bd29378f3a2a0e4f5f421 Mon Sep 17 00:00:00 2001 From: Michael Gannotti Date: Sat, 29 Aug 2026 15:31:29 -0400 Subject: [PATCH 18/19] Add a Hermes skills migration test and list Antigravity in file-layout The provision-user suite never ran the one-shot migration. Cover default-home links, a pre-existing profile, idempotency, and a missing skill source. Document ~/.gemini/config/skills and stop wrapping that bullet. --- docs/file-layout.md | 7 +- test/shell.d/hermes-skills-migration-test.sh | 75 ++++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) create mode 100755 test/shell.d/hermes-skills-migration-test.sh diff --git a/docs/file-layout.md b/docs/file-layout.md index 08516524..24c21af5 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -198,12 +198,7 @@ Runs once per user. It does **not** copy `~/.config/**`, `~/.bashrc`, `flags.lua`, or the nautilus extensions — `/etc/skel` already seeded those. It only does the things `/etc/skel` can't: -- Skill symlinks `~/.{agents,claude,codex,pi/agent,hermes}/skills/` (and each `~/.hermes/profiles/*/skills/`) → - `$OMARCHY_PATH/default/agents/skills/`, looping over every skill - directory there (currently `omarchy` and `diagnose-crash`) so new skills - need no edit. Symlinks (not copies) so `omarchy dev link` against a dev - checkout repoints them correctly. Hermes profile dirs are only linked when - they already exist — provision does not create Hermes profiles. +- Skill symlinks into `~/.agents/skills/`, `~/.claude/skills/`, `~/.codex/skills/`, `~/.pi/agent/skills/`, `~/.gemini/config/skills/` (Antigravity), `~/.hermes/skills/`, and each existing `~/.hermes/profiles/*/skills/` → `$OMARCHY_PATH/default/agents/skills/`, looping over every skill directory there (currently `omarchy` and `diagnose-crash`) so new skills need no edit. Symlinks (not copies) so `omarchy dev link` against a dev checkout repoints them correctly. Hermes profile dirs are only linked when they already exist — provision does not create Hermes profiles. - `xdg-user-dirs-update` (Templates/Public/Desktop folded back into `$HOME`) and `~/.config/gtk-3.0/bookmarks` (needs `$HOME` expansion). - Hyprland's package-owned default input reads `XKBLAYOUT` / `XKBVARIANT` diff --git a/test/shell.d/hermes-skills-migration-test.sh b/test/shell.d/hermes-skills-migration-test.sh new file mode 100755 index 00000000..f1145bd7 --- /dev/null +++ b/test/shell.d/hermes-skills-migration-test.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +migration="$ROOT/migrations/1787843905.sh" +[[ -f $migration ]] || fail "Hermes skills migration exists" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +home="$test_dir/home" + +run_migration() { + HOME="$home" OMARCHY_PATH="$ROOT" bash -euo pipefail "$migration" >/dev/null || + fail "migration exits clean" +} + +assert_link() { + local link="$1" + local skill="$2" + local description="$3" + + [[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] || + fail "$description" "$link -> $(readlink "$link" 2>/dev/null || echo missing)" +} + +# ------------------------------------------------------------------ default home, no profiles + +rm -rf "$home" +mkdir -p "$home" +run_migration + +for skill in omarchy diagnose-crash; do + assert_link "$home/.hermes/skills/$skill" "$skill" "migration links $skill into the default Hermes home" +done +[[ -e $home/.hermes/profiles ]] && fail "migration does not create Hermes profiles" +pass "migration links the default Hermes home and does not create profiles" + +run_migration +for skill in omarchy diagnose-crash; do + assert_link "$home/.hermes/skills/$skill" "$skill" "migration is idempotent on the default home for $skill" +done +pass "migration is idempotent on the default home" + +# ------------------------------------------------------------------ pre-existing profile + +rm -rf "$home" +mkdir -p "$home/.hermes/profiles/james" +run_migration + +for skill in omarchy diagnose-crash; do + assert_link "$home/.hermes/skills/$skill" "$skill" "migration links $skill into the default Hermes home when a profile exists" + assert_link "$home/.hermes/profiles/james/skills/$skill" "$skill" "migration links $skill into a pre-existing Hermes profile" +done +[[ -d $home/.hermes/profiles/james ]] || fail "migration leaves the pre-existing profile in place" +profile_count=$(find "$home/.hermes/profiles" -mindepth 1 -maxdepth 1 -type d | wc -l) +(( profile_count == 1 )) || fail "migration does not create extra profiles" "count=$profile_count" +pass "migration links a pre-existing Hermes profile and does not create extras" + +run_migration +for skill in omarchy diagnose-crash; do + assert_link "$home/.hermes/skills/$skill" "$skill" "migration is idempotent on the default home when a profile exists for $skill" + assert_link "$home/.hermes/profiles/james/skills/$skill" "$skill" "migration is idempotent on a pre-existing profile for $skill" +done +pass "migration is idempotent on a pre-existing profile" + +# ------------------------------------------------------------------ missing skill source + +rm -rf "$home" +mkdir -p "$home" "$test_dir/empty-omarchy" +HOME="$home" OMARCHY_PATH="$test_dir/empty-omarchy" bash -euo pipefail "$migration" >/dev/null || + fail "migration exits clean when the skill source is missing" +[[ -e $home/.hermes ]] && fail "migration no-ops when the skill source is missing" +pass "migration no-ops when the skill source is missing" From 7fec55e0ed9bacb560050541ba456db7dec7687e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 30 Aug 2026 10:30:34 +0200 Subject: [PATCH 19/19] Leave Hermes Desktop's HUD the transparency it draws itself The HUD is a frameless Electron window that paints its own per-pixel transparency. Under the default rules it gets a compositor border and Omarchy's window opacity on top, which turns the compact prompt into an outlined, muddy canvas. Scoped to the HUD by title, so the main Hermes window keeps the ordinary treatment. Co-authored-by: Luiz Filipe Co-Authored-By: Claude Opus 5 (1M context) --- default/hypr/apps/hermes.lua | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 default/hypr/apps/hermes.lua diff --git a/default/hypr/apps/hermes.lua b/default/hypr/apps/hermes.lua new file mode 100644 index 00000000..a7ad307f --- /dev/null +++ b/default/hypr/apps/hermes.lua @@ -0,0 +1,7 @@ +-- Hermes Desktop's frameless HUD manages its own geometry. +o.window({ class = "^Hermes$", title = "^Hermes HUD$" }, { + tag = "-default-opacity", + float = true, + border_size = 0, + opacity = "1 1", +})