From a12a21c02fbc945ac1de73633494df67908ae28a Mon Sep 17 00:00:00 2001 From: Omabot Date: Wed, 26 Aug 2026 18:04:41 +0200 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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 41a40ccc78b60c9698a0e45dc358f0e4b5ffd5a3 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Fri, 28 Aug 2026 20:03:19 -0500 Subject: [PATCH 17/21] Show captive portal status and sign-in action in network panel --- shell/plugins/panels/network/Model.js | 24 ++- shell/plugins/panels/network/Panel.qml | 141 +++++++++++++-- .../mocks/NetworkMock.qml | 31 ++++ .../network-captive-portal/mocks/qmldir | 1 + .../fixtures/network-captive-portal/shell.qml | 161 ++++++++++++++++++ test/shell.d/network-captive-portal-test.sh | 95 +++++++++++ 6 files changed, 438 insertions(+), 15 deletions(-) create mode 100644 test/shell.d/fixtures/network-captive-portal/mocks/NetworkMock.qml create mode 100644 test/shell.d/fixtures/network-captive-portal/mocks/qmldir create mode 100644 test/shell.d/fixtures/network-captive-portal/shell.qml create mode 100755 test/shell.d/network-captive-portal-test.sh diff --git a/shell/plugins/panels/network/Model.js b/shell/plugins/panels/network/Model.js index b4c84c68..5ed2c0d6 100644 --- a/shell/plugins/panels/network/Model.js +++ b/shell/plugins/panels/network/Model.js @@ -14,9 +14,25 @@ function wifiIconFor(strength) { return icons[index] } -function connectionIcon(kind, signalStrength) { - if (kind === "wifi") return wifiIconFor(signalStrength) - if (kind === "ethernet") return "󰈀" +// A known plain-HTTP endpoint lets the network redirect the browser to its +// login page. Never execute or automatically open an untrusted Location header. +var captivePortalUrl = "http://ping.archlinux.org/nm-check.txt" + +function connectivityState(kind, connectivity, states, checksEnabled) { + if (kind === "disconnected") return "none" + // Ignore stale cached results when the operator has disabled probing. + if (!checksEnabled) return "unknown" + if (connectivity === states.Portal) return "portal" + if (connectivity === states.Limited) return "limited" + if (connectivity === states.Full) return "full" + if (connectivity === states.None) return "none" + return "unknown" +} + +function connectionIcon(kind, signalStrength, connectivity) { + var restricted = connectivity === "portal" || connectivity === "limited" + if (kind === "wifi") return restricted ? "󰤩" : wifiIconFor(signalStrength) + if (kind === "ethernet") return restricted ? "󰈂" : "󰈀" return "󰤮" } @@ -352,6 +368,8 @@ if (typeof module !== "undefined") { parseNetworkStatus: parseNetworkStatus, wifiIconFor: wifiIconFor, connectionIcon: connectionIcon, + connectivityState: connectivityState, + captivePortalUrl: captivePortalUrl, formatHeaderSpeed: formatHeaderSpeed, formatHeaderFreq: formatHeaderFreq, headerDetail: headerDetail, diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index d1e41149..97729dcc 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -119,9 +119,9 @@ Panel { property bool cursorActive: false // Keyboard focus zone for the panel. j/k crosses row boundaries: - // header actions ⇄ band ⇄ DNS row ⇄ Wi-Fi networks. h/l move + // header actions ⇄ portal ⇄ band ⇄ DNS row ⇄ Wi-Fi networks. h/l move // within header actions, band pills, or DNS providers. - property string focusSection: "dns" // "header" | "band" | "dns" | "wifi" + property string focusSection: "dns" // "header" | "portal" | "band" | "dns" | "wifi" property int headerIndex: 0 readonly property bool canDisconnect: !!connectedWifiNetwork readonly property bool headerHasDisconnect: false @@ -220,6 +220,8 @@ Panel { // network target; both cards are their own plugins now. function showQr() { root.summonWifiQr(true) } function speedTest() { root.summonSpeedTest() } + function openCaptivePortal() { root.openCaptivePortal() } + function checkConnectivity() { root.checkConnectivity() } } function activateHeader() { @@ -322,11 +324,11 @@ Panel { refresh(true) selectedIndex = wifiNetworks.length > 0 ? 0 : -1 wifiActionFocused = false - focusSection = wifiNetworks.length > 0 ? "wifi" : "dns" + focusSection = hasCaptivePortal ? "portal" : (wifiNetworks.length > 0 ? "wifi" : "dns") var idx = dnsProviders.indexOf(dnsProvider) dnsIndex = idx >= 0 ? idx : 0 syncBandIndex() - cursorActive = false + cursorActive = hasCaptivePortal } else { // Drop a restart armed by this open: without it a close/reopen inside // the 100ms window reuses the running timer and re-enables the scanner @@ -450,7 +452,59 @@ Panel { Quickshell.execDetached(["bash", "-c", "printf %s " + Util.shellQuote(value) + " | wl-copy"]) } - readonly property string icon: Model.connectionIcon(kind, signalStrength) + // NetworkManager performs the HTTP probe (including unexpected page bodies, + // not just redirects). Consume its native notifications rather than running + // a second curl loop or mistaking an ordinary timeout for a captive portal. + readonly property bool connectivityChecksEnabled: networkManagerAvailable + && Networking.canCheckConnectivity && Networking.connectivityCheckEnabled + readonly property string connectivity: Model.connectivityState(kind, Networking.connectivity, { + Portal: NetworkConnectivity.Portal, Limited: NetworkConnectivity.Limited, + Full: NetworkConnectivity.Full, None: NetworkConnectivity.None + }, connectivityChecksEnabled) + readonly property bool hasCaptivePortal: connectivity === "portal" + readonly property bool restricted: hasCaptivePortal || connectivity === "limited" + readonly property string icon: Model.connectionIcon(kind, signalStrength, connectivity) + readonly property string connectionKey: kind === "wifi" && wifiDevice && connectedWifiNetwork + ? kind + ":" + wifiDevice.name + ":" + connectedWifiNetwork.name + : (kind === "ethernet" && wiredDevice ? kind + ":" + wiredDevice.name : "") + + onConnectionKeyChanged: Qt.callLater(checkConnectivity) + onConnectivityChecksEnabledChanged: Qt.callLater(checkConnectivity) + onHasCaptivePortalChanged: { + if (hasCaptivePortal && opened && passwordSsid === "") { + focusSection = "portal" + cursorActive = true + } else if (!hasCaptivePortal && focusSection === "portal") { + focusSection = headerActionCount > 0 ? "header" : "dns" + headerIndex = 0 + } + } + onRestrictedChanged: { + connectionPhraseSwap.stop() + heroMeta.opacity = 1.0 + } + + function checkConnectivity() { + if (connectivityChecksEnabled && kind !== "disconnected") Networking.checkConnectivity() + } + + function openCaptivePortal() { + if (!hasCaptivePortal) return + // Explicit user action only. argv (not a shell string), and a fixed HTTP + // URL: let the browser handle the redirect without trusting portal input. + Quickshell.execDetached(["omarchy-launch-browser", Model.captivePortalUrl]) + close() + } + + // Keep checking while login is needed, even with the panel closed in favour + // of the browser. Normal connected operation relies on NM's own schedule. + Timer { + id: connectivityPoll + interval: 10000 + repeat: true + running: root.restricted && root.connectivityChecksEnabled + onTriggered: root.checkConnectivity() + } // The share card is its own panel plugin (omarchy.wifiqr) so a replacement // design can take it over; summon() routes to whichever implementation is @@ -469,6 +523,7 @@ Panel { } function refresh(scanWifi) { + checkConnectivity() if (scanWifi === undefined) scanWifi = false if (!detailsProc.running) detailsProc.running = true if (!dnsProc.running) { @@ -901,7 +956,7 @@ Panel { Timer { id: connectionPhraseTimer interval: 2800 - running: root.opened && (root.info.type === "ethernet" || (root.info.type === "wifi" && root.canDisconnect)) + running: root.opened && !root.restricted && (root.info.type === "ethernet" || (root.info.type === "wifi" && root.canDisconnect)) repeat: true onTriggered: connectionPhraseSwap.restart() } @@ -958,6 +1013,9 @@ Panel { anchors.fill: parent bar: root.bar text: root.icon + active: root.restricted + tooltipText: root.hasCaptivePortal ? "Sign in to this network" + : (root.restricted ? "Limited internet access" : "") onPressed: function(b) { if (root.opened) root.close() @@ -1001,23 +1059,34 @@ Panel { if (dy >= 0) return } if (dy !== 0) { - // Vertical order is header ⇄ band ⇄ DNS ⇄ wifi, with the band section - // dropping out of the chain entirely when it isn't on screen. + // Hidden sections drop out of the keyboard chain entirely. if (root.focusSection === "header") { if (dy > 0) { - if (root.canSelectBand) { + if (root.hasCaptivePortal) { + root.focusSection = "portal" + } else if (root.canSelectBand) { root.focusSection = "band" root.bandAutoFocused = true } else { root.focusSection = "dns" } } + } else if (root.focusSection === "portal") { + if (dy < 0 && root.headerActionCount > 0) { + root.focusSection = "header" + root.headerIndex = 0 + } else if (dy > 0) { + root.focusSection = root.canSelectBand ? "band" : "dns" + root.bandAutoFocused = true + } } else if (root.focusSection === "band") { // Automatic on the header line, then the pills -- which collapse // away under Automatic, leaving a single row to walk. if (dy < 0) { if (!root.bandAutoFocused) { root.bandAutoFocused = true + } else if (root.hasCaptivePortal) { + root.focusSection = "portal" } else if (root.headerActionCount > 0) { root.focusSection = "header" root.headerIndex = 0 @@ -1035,6 +1104,8 @@ Panel { if (root.canSelectBand) { root.focusSection = "band" root.bandAutoFocused = !root.bandPillsVisible + } else if (root.hasCaptivePortal) { + root.focusSection = "portal" } else if (root.headerActionCount > 0) { root.focusSection = "header" root.headerIndex = 0 @@ -1063,6 +1134,7 @@ Panel { onActivateRequested: { if (root.cursorActive) { if (root.focusSection === "header") root.activateHeader() + else if (root.focusSection === "portal") root.openCaptivePortal() else if (root.focusSection === "band") root.activateBand() else if (root.focusSection === "dns") root.activateDns() else root.activateSelected() @@ -1092,7 +1164,7 @@ Panel { id: heroIcon textFormat: Text.PlainText text: root.icon - color: root.bar.foreground + color: root.restricted ? root.bar.urgent : root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.display opacity: root.networkManagerAvailable ? 1.0 : 0.5 @@ -1175,6 +1247,9 @@ Panel { width: parent.width readonly property string title: { + // The HTTP restriction does not undo association. Show the live + // SSID even before route/details polling has returned anything. + if (root.kind === "wifi" && root.connectedWifiNetwork) return root.connectedWifiNetwork.name || "Wi-Fi" if (root.info.type === "wifi") return root.info.ssid || "Wi-Fi" if (root.info.type === "ethernet") return "Ethernet" return root.info.iface || (root.kind === "disconnected" ? "Disconnected" : "No connection") @@ -1194,6 +1269,8 @@ Panel { textFormat: Text.PlainText width: parent.width text: { + if (root.hasCaptivePortal) return "SIGN-IN REQUIRED" + if (root.restricted) return "LIMITED INTERNET ACCESS" if (root.info.type === "wifi") { if (root.canDisconnect) return root.connectionPhrase.toUpperCase() if (root.kind === "disconnected") return "NOT CONNECTED" @@ -1204,7 +1281,7 @@ Panel { return "" } visible: text !== "" - color: Qt.darker(root.bar.foreground, 1.4) + color: root.restricted ? root.bar.urgent : Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption font.bold: true @@ -1215,6 +1292,43 @@ Panel { } + Column { + visible: root.hasCaptivePortal + width: parent.width + spacing: Style.space(6) + + Button { + id: portalAction + width: parent.width + text: "Open Captive Portal" + iconText: "󰏌" + foreground: root.bar.urgent + accent: root.bar.urgent + fontFamily: root.bar.fontFamily + verticalPadding: Style.space(10) + bordered: true + active: true + hasCursor: root.cursorActive && root.focusSection === "portal" + onHovered: function(on) { + if (!on) return + root.cursorActive = true + root.focusSection = "portal" + } + onClicked: root.openCaptivePortal() + } + + Text { + width: parent.width + text: "Sign in or accept this network’s terms to access the internet." + textFormat: Text.PlainText + wrapMode: Text.WordWrap + color: root.bar.foreground + opacity: 0.7 + font.family: root.bar.fontFamily + font.pixelSize: Style.font.bodySmall + } + } + // Connection details: transfer metrics first, then IP/Gateway. Column { visible: !!root.info.iface @@ -1654,6 +1768,7 @@ Panel { if (isBusy && root.actionKind === "disconnect") return "Disconnecting…" if (isBusy && root.actionKind === "forget") return "Forgetting…" if (isFailed) return root.failureReason || "Failed" + if (isConnected && root.kind === "wifi" && root.hasCaptivePortal) return "Sign-in required" if (isConnected) return "Connected" return "" } @@ -1661,6 +1776,7 @@ Panel { readonly property color statusColor: { if (isFailed) return root.bar.urgent if (isBusy) return root.bar.foreground + if (isConnected && root.kind === "wifi" && root.hasCaptivePortal) return root.bar.urgent if (isConnected) return root.bar.foreground return Qt.darker(root.bar.foreground, 1.5) } @@ -1715,7 +1831,8 @@ Panel { Text { id: networkIcon textFormat: Text.PlainText - text: row.net ? root.wifiIconFor(row.net.signal) : "" + text: row.net ? Model.connectionIcon("wifi", row.net.signal, + row.isConnected && root.kind === "wifi" ? root.connectivity : "") : "" color: row.statusColor font.family: root.bar.fontFamily font.pixelSize: Style.font.title diff --git a/test/shell.d/fixtures/network-captive-portal/mocks/NetworkMock.qml b/test/shell.d/fixtures/network-captive-portal/mocks/NetworkMock.qml new file mode 100644 index 00000000..b41c6513 --- /dev/null +++ b/test/shell.d/fixtures/network-captive-portal/mocks/NetworkMock.qml @@ -0,0 +1,31 @@ +pragma Singleton +import QtQuick +import Quickshell.Networking + +QtObject { + property int backend: NetworkBackendType.NetworkManager + property bool wifiEnabled: true + property bool canCheckConnectivity: true + property bool connectivityCheckEnabled: true + property int connectivity: NetworkConnectivity.Full + property int checks: 0 + function checkConnectivity() { checks++ } + + property var devices: ({ values: [wifi] }) + property QtObject wifi: QtObject { + property int type: DeviceType.Wifi + property string name: "test-wifi" + property bool connected: true + property bool scannerEnabled: false + property var networks: ({ values: [network] }) + } + property QtObject network: QtObject { + property string name: "Guest Wi-Fi" + property bool connected: true + property bool known: true + property bool stateChanging: false + property real signalStrength: 0.8 + property int security: WifiSecurityType.Open + signal connectionFailed(int reason) + } +} diff --git a/test/shell.d/fixtures/network-captive-portal/mocks/qmldir b/test/shell.d/fixtures/network-captive-portal/mocks/qmldir new file mode 100644 index 00000000..03b0cb27 --- /dev/null +++ b/test/shell.d/fixtures/network-captive-portal/mocks/qmldir @@ -0,0 +1 @@ +singleton NetworkMock 1.0 NetworkMock.qml diff --git a/test/shell.d/fixtures/network-captive-portal/shell.qml b/test/shell.d/fixtures/network-captive-portal/shell.qml new file mode 100644 index 00000000..24c361bc --- /dev/null +++ b/test/shell.d/fixtures/network-captive-portal/shell.qml @@ -0,0 +1,161 @@ +import QtQuick +import Quickshell +import Quickshell.Networking +import qs.Commons +import "mocks" +import "network" as Network + +ShellRoot { + id: test + property bool failed: false + function check(ok, message) { + if (!ok) { + failed = true + console.log("RESULT fail " + message) + } + } + + // Not visible in the normal test run. The optional preview maps the real + // KeyboardPanel for a screenshot, without ever altering the host network. + Item { + Network.Panel { + id: panel + bar: QtObject { + property color foreground: Color.foreground + property color barForeground: Color.foreground + property color urgent: Color.urgent + property string fontFamily: Style.font.family + property string position: "top" + property int barSize: 24 + property bool vertical: false + property bool foregroundAnimationEnabled: false + property var activePopout: null + function requestPopout(owner) { activePopout = owner } + function releasePopout(owner) { activePopout = null } + function registerClickTarget(target) {} + function unregisterClickTarget(target) {} + function hideTooltip(target) {} + function showTooltip(target, text) {} + } + } + } + + Timer { + interval: 250 + running: true + onTriggered: { + test.check(panel.kind === "wifi", "connected Wi-Fi fixture") + test.check(panel.connectivity === "full", "normal connectivity") + test.check(!panel.testButton.visible && !panel.testBarButton.active, "no false portal banner") + test.check(!panel.testPoll.running, "normal connectivity adds no polling") + test.check(NetworkMock.checks > 0, "checks at connection/startup") + var before = NetworkMock.checks + panel.checkConnectivity() + test.check(NetworkMock.checks === before + 1, "manual check delegates to NM") + NetworkMock.connectivity = NetworkConnectivity.Portal + Qt.callLater(portalChecks) + } + } + + function portalChecks() { + check(panel.hasCaptivePortal && panel.restricted, "native portal activates restricted mode") + check(panel.testButton.visible, "portal button visible") + check(panel.testButton.text === "Open Captive Portal", "prominent action label") + check(panel.icon === "󰤩" && panel.testBarButton.active, "blocked bar icon and warning color") + check(panel.testMeta.text === "SIGN-IN REQUIRED", "status replaces cheerful connection phrase") + check(panel.testTitle.text === "Guest Wi-Fi", "connected SSID survives missing route details") + check(panel.testPoll.running && panel.testPoll.interval === 10000, "restricted recheck runs while closed") + var before = NetworkMock.checks + panel.testPoll.triggered() + check(NetworkMock.checks === before + 1, "background timer rechecks through NM") + panel.testKeys.textKey("r") + check(NetworkMock.checks === before + 2, "r requests fresh connectivity") + // Exercise the existing cursor model, not a separate test-only action. + panel.cursorActive = true + panel.focusSection = "header" + panel.testKeys.moveRequested(0, 1) + check(panel.focusSection === "portal", "down from header reaches portal") + panel.testKeys.moveRequested(0, 1) + check(panel.focusSection === "dns", "down from portal skips absent band") + panel.testKeys.moveRequested(0, -1) + check(panel.focusSection === "portal", "up from DNS reaches portal") + panel.bandAvailable = ["2.4", "5"] + panel.testKeys.moveRequested(0, 1) + check(panel.focusSection === "band", "down from portal reaches available band") + panel.testKeys.moveRequested(0, -1) + check(panel.focusSection === "portal", "up from band reaches portal") + panel.testKeys.activateRequested() + NetworkMock.connectivity = NetworkConnectivity.Full + Qt.callLater(recoveryChecks) + } + + function recoveryChecks() { + check(!panel.hasCaptivePortal && !panel.restricted, "login recovery clears restriction") + check(!panel.testPoll.running, "recovery stops extra checks") + check(!panel.testButton.visible && !panel.testBarButton.active, "recovery hides button and warning color") + check(panel.focusSection === "header", "disappearing button leaves valid cursor") + check(panel.icon !== "󰤩", "signal icon returns") + // No browser launch when the portal is gone (runner asserts one launch). + panel.openCaptivePortal() + NetworkMock.connectivity = NetworkConnectivity.Limited + Qt.callLater(limitedChecks) + } + + function limitedChecks() { + check(panel.restricted && !panel.hasCaptivePortal, "outage is not mislabelled as a portal") + check(!panel.testButton.visible && panel.testMeta.text === "LIMITED INTERNET ACCESS", "limited state has no login button") + NetworkMock.connectivity = NetworkConnectivity.Portal + NetworkMock.connectivityCheckEnabled = false + Qt.callLater(disabledChecks) + } + + function disabledChecks() { + check(!panel.hasCaptivePortal && panel.connectivity === "unknown", "disabled checks ignore cached portal") + check(!panel.testPoll.running, "disabled checks stop polling") + var before = NetworkMock.checks + panel.checkConnectivity() + check(NetworkMock.checks === before, "does not enable or invoke disabled checks") + NetworkMock.connectivityCheckEnabled = true + NetworkMock.network.connected = false + NetworkMock.wifi.connected = false + Qt.callLater(disconnectedChecks) + } + + function disconnectedChecks() { + check(panel.kind === "disconnected" && !panel.hasCaptivePortal, "disconnect clears stale portal") + check(!panel.testButton.visible && panel.icon === "󰤮", "disconnected icon not portal icon") + if (failed) { Qt.quit(); return } + console.log("RESULT pass") + var preview = Quickshell.env("NETWORK_TEST_PREVIEW") + if (preview === "portal" || preview === "full") { + NetworkMock.network.connected = true + NetworkMock.wifi.connected = true + NetworkMock.connectivity = preview === "portal" ? NetworkConnectivity.Portal : NetworkConnectivity.Full + panel.open() + previewCapture.start() + previewDone.start() + } else { + // Give the detached, stubbed browser command time to append its argv. + done.start() + } + } + + // Optional fresh, panel-only captures. Rendering the card itself excludes + // the host desktop, and the network details above come only from fixtures. + // NETWORK_TEST_PREVIEW=portal (or full), NETWORK_TEST_SCREENSHOT=/tmp/new.png + Timer { + id: previewCapture + interval: 750 + onTriggered: { + var path = Quickshell.env("NETWORK_TEST_SCREENSHOT") + if (!path) return + var card = panel.testKeys.parent.parent + card.grabToImage(function(result) { + test.check(result.saveToFile(path), "save fresh preview screenshot") + Qt.quit() + }) + } + } + Timer { id: done; interval: 300; onTriggered: Qt.quit() } + Timer { id: previewDone; interval: 15000; onTriggered: Qt.quit() } +} diff --git a/test/shell.d/network-captive-portal-test.sh b/test/shell.d/network-captive-portal-test.sh new file mode 100755 index 00000000..0802eeb7 --- /dev/null +++ b/test/shell.d/network-captive-portal-test.sh @@ -0,0 +1,95 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const network = requireFromRoot('shell/plugins/panels/network/Model.js') +const states = { Unknown: 0, None: 1, Portal: 2, Limited: 3, Full: 4 } + +for (const kind of ['wifi', 'ethernet']) { + for (const [native, expected] of [ + ['Unknown', 'unknown'], ['None', 'none'], ['Portal', 'portal'], + ['Limited', 'limited'], ['Full', 'full'] + ]) { + assertEqual(network.connectivityState(kind, states[native], states, true), expected, + `${kind} maps native ${native} connectivity without confusing an outage with a portal`) + } + assertEqual(network.connectivityState(kind, 99, states, true), 'unknown', `${kind} handles unknown connectivity`) + for (const native of Object.values(states)) { + assertEqual(network.connectivityState(kind, native, states, false), 'unknown', `${kind} ignores stale results with probing disabled (${native})`) + } +} +for (const native of Object.values(states)) { + assertEqual(network.connectivityState('disconnected', native, states, true), 'none', `disconnect clears stale connectivity (${native})`) +} +for (const state of ['portal', 'limited']) { + assertEqual(network.connectionIcon('wifi', 80, state), '󰤩', `${state} uses a blocked Wi-Fi icon`) + assertEqual(network.connectionIcon('ethernet', 80, state), '󰈂', `${state} uses a blocked Ethernet icon`) + assertEqual(network.connectionIcon('disconnected', 80, state), '󰤮', `${state} does not override the disconnected icon`) +} +for (const state of ['full', 'unknown', 'none', undefined]) { + for (const signal of [-1, 0, 20, 40, 60, 80, 100]) { + assertEqual(network.connectionIcon('wifi', signal, state), network.wifiIconFor(signal), `${state} preserves Wi-Fi strength ${signal}`) + } + assertEqual(network.connectionIcon('ethernet', -1, state), '󰈀', `${state} preserves the Ethernet icon`) +} +const url = new URL(network.captivePortalUrl) +assertEqual(url.protocol, 'http:', 'browser entry point uses plain HTTP so a portal can intercept it') +assertEqual(url.hostname, 'ping.archlinux.org', 'browser entry point is fixed rather than portal-supplied') +assertEqual(url.username + url.password, '', 'browser entry point contains no credentials') +JS + +require_compositor "network captive-portal runtime test" +require_command quickshell + +stage=$(mktemp -d) +trap 'rm -rf -- "$stage"' EXIT +fixture="$SHELL_TEST_DIR/fixtures/network-captive-portal" +mkdir -p "$stage/network" "$stage/bin" "$stage/home" +ln -s "$ROOT/shell/Ui" "$stage/Ui" +ln -s "$ROOT/shell/Commons" "$stage/Commons" +cp -r "$fixture/mocks" "$stage/mocks" +cp "$fixture/shell.qml" "$stage/shell.qml" +cp "$ROOT/shell/plugins/panels/network/Model.js" "$stage/network/Model.js" +node - "$ROOT" "$stage" <<'JS' +const fs = require('fs') +const [root, stage] = process.argv.slice(2) +let source = fs.readFileSync(`${root}/shell/plugins/panels/network/Panel.qml`, 'utf8') +// Keep installed enum values and actual UI bindings. Only replace the singleton +// and expose private IDs in the disposable copy, never in production code. +source = source.replace('import Quickshell.Networking', 'import Quickshell.Networking\nimport "../mocks"') +source = source.replace(/\bNetworking\./g, 'NetworkMock.') +source = source.replace(' id: root', ` id: root + property alias testButton: portalAction + property alias testKeys: keyCatcher + property alias testMeta: heroMeta + property alias testTitle: heroSsid + property alias testPoll: connectivityPoll + property alias testBarButton: button`) +fs.writeFileSync(`${stage}/network/Panel.qml`, source) +JS +printf '#!/bin/bash\nexit 0\n' > "$stage/bin/noop" +chmod +x "$stage/bin/noop" +for command in omarchy-dns omarchy-network-band; do + ln -s noop "$stage/bin/$command" +done +# Preview uses only synthetic details, never the host's SSID or addresses. +# Normal assertions keep the details empty to exercise missing-route handling. +printf '#!/bin/bash\nif [[ -n ${NETWORK_TEST_PREVIEW:-} ]]; then\n printf "type\\twifi\\niface\\ttest-wifi\\nssid\\tGuest Wi-Fi\\nip\\t192.0.2.10\\ngateway\\t192.0.2.1\\n"\nfi\n' > "$stage/bin/omarchy-network-status" +chmod +x "$stage/bin/omarchy-network-status" +printf '#!/bin/bash\nprintf "%%s\\n" "$@" >> "$NETWORK_TEST_BROWSER_LOG"\n' > "$stage/bin/omarchy-launch-browser" +chmod +x "$stage/bin/omarchy-launch-browser" + +# All networking and external actions are mocked; the real connection and +# browser are never touched, and the fixture writes only to its scratch HOME. +output=$(HOME="$stage/home" OMARCHY_PATH="$ROOT" PATH="$stage/bin:$PATH" \ + NETWORK_TEST_BROWSER_LOG="$stage/browser.log" \ + timeout 30 quickshell -p "$stage" --no-color 2>&1) || fail "network portal fixture exits cleanly" "$output" +[[ $output == *"RESULT pass"* ]] || fail "network portal runtime assertions pass" "$output" +if rg -q 'RESULT fail|ReferenceError|TypeError|Error:|Unable to assign|Binding loop' <<< "$output"; then + fail "network portal fixture has no QML errors" "$output" +fi +[[ -f $stage/browser.log ]] || fail "portal action launches the browser" +[[ $(<"$stage/browser.log") == "http://ping.archlinux.org/nm-check.txt" ]] || fail "portal opens exactly one fixed HTTP URL" +pass "network portal, recovery, disabled checks, outage, disconnect, keyboard navigation, and browser argv work in QML" From c64e03d9c54e2c2265f5b5710ab3bc729257722a Mon Sep 17 00:00:00 2001 From: "James (SMF Works)" Date: Thu, 27 Aug 2026 11:19:05 -0400 Subject: [PATCH 18/21] 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 19/21] 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 20/21] 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", +}) From d3d23fdddef846ebb98b52122a6ece66211c0daf Mon Sep 17 00:00:00 2001 From: Brian Armstrong Date: Wed, 2 Sep 2026 03:33:44 -0700 Subject: [PATCH 21/21] Honor keepLoaded for services during plugin hot-reload (#9485) * Honor keepLoaded for services during plugin hot-reload Plugin reload destroyed every service, including omarchy.lock, which drops the ext-session-lock client while Hyprland still holds the lock and surfaces the crashed-lockscreen fallback. * Prove keepLoaded service survival with a fixture service A fresh lock service also reports an empty lastEventAt, so comparing it across the rescan passed whether or not the instance survived. A fixture keepLoaded service whose in-memory marker is set before the rescan and read back after can only pass when the same instance is still mounted. Co-Authored-By: Claude Fable 5 * Drop kept services whose plugin no longer declares a service The _syncServices cleanup only asked whether the plugin was still installed and enabled, so a kept service whose plugin dropped its service kind or entry point kept running as a zombie until shell restart. Apply the same eligibility checks used at creation, and hand kept instances the refreshed manifest after a rescan. Co-Authored-By: Claude Fable 5 * Cover omarchy.media in keepLoaded expectations; note kept services reload on restart Co-Authored-By: Claude Fable 5 --------- Co-authored-by: David Heinemeier Hansson Co-authored-by: Claude Fable 5 --- docs/omarchy-shell.md | 4 +- shell/README.md | 8 +++- shell/plugins/README.md | 3 ++ shell/shell.qml | 31 +++++++++++-- test/shell.d/plugins-test.sh | 13 ++++++ test/shell.d/runtime-smoke-test.sh | 72 ++++++++++++++++++++++++++++++ 6 files changed, 122 insertions(+), 9 deletions(-) diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index a7c1b389..3142d991 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -37,9 +37,7 @@ wait). Only one full bar option is active at a time. The built-in `omarchy.bar` is used when `bar.id` is omitted or when a selected third-party bar cannot load. -Panels, overlays, and menus are loaded when summoned. Plugins can set the -top-level manifest key `keepLoaded: true` to survive between summons. -First-party services are loaded at startup. +Panels, overlays, and menus are loaded when summoned. Plugins can set the top-level manifest key `keepLoaded: true` to survive between summons, and to keep a service mounted across plugin hot-reload (so `omarchy.lock` is not destroyed while Hyprland still holds the session lock). First-party services are loaded at startup. Entry points are QML `Item`s. Panel, overlay, and menu entry points expose `open(payloadJson)` and `close()` for summon/hide; on load the host injects diff --git a/shell/README.md b/shell/README.md index e72d02ce..16162b79 100644 --- a/shell/README.md +++ b/shell/README.md @@ -86,8 +86,12 @@ Only one `bar` plugin is active at a time. Missing or invalid selections fall back to the built-in `omarchy.bar`, so users always have a safe path home. Panels, overlays, and menus are loaded when summoned. Plugins that need to outlive a single summon can set `keepLoaded: true` (e.g. the image -picker keeps its overlay window mounted between summons). First-party -services are loaded at startup. +picker keeps its overlay window mounted between summons). The same flag +keeps a service mounted across plugin hot-reload, so tearing down a +changed bar widget cannot destroy `omarchy.lock` while Hyprland still +holds the session lock. The kept instance is not replaced, so code +changes to a `keepLoaded` service itself only take effect on a shell +restart. First-party services are loaded at startup. The full schema lives in `services/PluginRegistry.qml`. diff --git a/shell/plugins/README.md b/shell/plugins/README.md index d4252515..66cb74e1 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -83,6 +83,9 @@ separate PAM services: `omarchy-lock-password` for password auth and, only when fingerprints are enrolled, `omarchy-lock-fingerprint` for fingerprint auth. It mirrors the previous lock screen field dimensions, colors, blurred wallpaper, placeholder, and Hyprland-driven corners. +The plugin sets `keepLoaded: true` so a plugin hot-reload (for example +an installed bar widget changing on disk) does not destroy the lock +client while Hyprland still holds the session lock. ## Polkit agent diff --git a/shell/shell.qml b/shell/shell.qml index 71a9834f..7e1cf75d 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -329,14 +329,23 @@ ShellRoot { if (!Array.isArray(m.kinds) || m.kinds.indexOf("service") === -1) continue if (!m.entryPoints || !m.entryPoints.service) continue if (!pluginRegistry.isEnabled(id)) continue - if (_services[id]) continue + if (_services[id]) { + // A kept instance outlives the rescan; hand it the fresh manifest. + var kept = _services[id] + if (kept && "manifest" in kept) kept.manifest = m + continue + } ensureService(id) } - // Drop services for plugins that have been disabled or removed. + // Drop services for plugins that have been disabled or removed, or that + // no longer declare a service entry point. for (var existingId in _services) { var stillThere = plugins[existingId] + var stillService = stillThere && Array.isArray(stillThere.kinds) + && stillThere.kinds.indexOf("service") !== -1 + && stillThere.entryPoints && stillThere.entryPoints.service var stillEnabled = stillThere && pluginRegistry.isEnabled(existingId) - if (stillThere && stillEnabled) continue + if (stillService && stillEnabled) continue var inst = _services[existingId] if (inst && typeof inst.destroy === "function") inst.destroy() var next = ({}) @@ -345,12 +354,26 @@ ShellRoot { } } + function serviceKeepLoaded(pluginId) { + var plugins = pluginRegistry && pluginRegistry.installedPlugins + var manifest = plugins ? plugins[pluginId] : null + return !!(manifest && manifest.keepLoaded === true) + } + + // keepLoaded services (lock, idle, polkit) must survive plugin hot-reload. + // Destroying omarchy.lock drops the ext-session-lock client while Hyprland + // still holds the lock, which surfaces the crashed-lockscreen fallback. function unloadPluginServices() { + var next = ({}) for (var existingId in _services) { + if (serviceKeepLoaded(existingId)) { + next[existingId] = _services[existingId] + continue + } var inst = _services[existingId] if (inst && typeof inst.destroy === "function") inst.destroy() } - _services = ({}) + _services = next } Connections { diff --git a/test/shell.d/plugins-test.sh b/test/shell.d/plugins-test.sh index 141b4323..5d634dc9 100644 --- a/test/shell.d/plugins-test.sh +++ b/test/shell.d/plugins-test.sh @@ -197,5 +197,18 @@ for (const [id, section] of Object.entries({ } check(byId['omarchy.media']?.barWidget?.defaultSection === undefined, 'omarchy.media must use the center fallback') +for (const id of ['omarchy.lock', 'omarchy.idle', 'omarchy.polkit', 'omarchy.notifications', 'omarchy.media']) { + check(byId[id]?.keepLoaded === true, `${id} must stay loaded across plugin reloads`) +} + +const shellSource = fs.readFileSync(path.join(root, 'shell/shell.qml'), 'utf8') +const unloadMatch = shellSource.match(/function unloadPluginServices\(\) \{[\s\S]*?\n \}/) +check(!!unloadMatch, 'unloadPluginServices is defined') +check(!!unloadMatch && /serviceKeepLoaded/.test(unloadMatch[0]), 'unloadPluginServices honors keepLoaded') +check( + /function _syncServices\(\) \{[\s\S]*Drop services for plugins that have been disabled/.test(shellSource), + '_syncServices still drops disabled or removed services' +) + assert(errors.length === 0, 'plugin manifests match shell registry contract', errors.join('\n')) JS diff --git a/test/shell.d/runtime-smoke-test.sh b/test/shell.d/runtime-smoke-test.sh index f9e52bfd..1b0e5fe9 100755 --- a/test/shell.d/runtime-smoke-test.sh +++ b/test/shell.d/runtime-smoke-test.sh @@ -74,6 +74,44 @@ Item { } QML +# A keepLoaded service must keep its instance (and in-memory state) across a +# plugin rescan. The marker below can only survive if the object does. +keep_service_id="acme.keep-service" +keep_service_dir="$test_home/.config/omarchy/plugins/$keep_service_id" +mkdir -p "$keep_service_dir" +cat >"$keep_service_dir/manifest.json" <"$keep_service_dir/Service.qml" <<'QML' +import QtQuick +import Quickshell.Io + +Item { + property string marker: "" + + IpcHandler { + target: "acme-keep" + + function set(value: string): string { + marker = value + return "ok" + } + + function get(): string { + return marker + } + } +} +QML + cat >"$stub_bin/omarchy-update-available" <<'SH' #!/bin/bash echo "Omarchy update available (test)" @@ -188,6 +226,15 @@ pass "shell IPC summon and hide contract works" jq -e '.hasPlayer | type == "boolean"' <<<"$(shell_ipc media status)" >/dev/null || fail_with_log "media IPC returns status JSON" jq -e '.enabled | type == "boolean"' <<<"$(shell_ipc idle status)" >/dev/null || fail_with_log "idle IPC returns status JSON" jq -e '.locked | type == "boolean"' <<<"$(shell_ipc lock status)" >/dev/null || fail_with_log "lock IPC returns status JSON" +[[ $(shell_ipc shell setPluginEnabled "$keep_service_id" true) == "ok" ]] || + fail_with_log "keepLoaded fixture service could not be enabled" +keep_marker_set="" +for _ in {1..80}; do + keep_marker_set=$(shell_ipc acme-keep set "survived" 2>/dev/null || true) + [[ $keep_marker_set == "ok" ]] && break + sleep 0.1 +done +[[ $keep_marker_set == "ok" ]] || fail_with_log "keepLoaded fixture service IPC responds" [[ $(shell_ipc image-selector ping) == "ok" ]] || fail_with_log "image selector IPC responds" [[ $(shell_ipc osd ping) == "ok" ]] || fail_with_log "OSD IPC responds" [[ $(shell_ipc osd show '{"message":"Runtime smoke","duration":0}') == "ok" ]] || fail_with_log "OSD IPC opens" @@ -215,6 +262,31 @@ shell_ipc_quiet image-selector cancel "$selector_done_file" >/dev/null rm -f "$selector_selection_file" "$selector_done_file" pass "image selector IPC survives plugin rescan" +lock_status_after=$(shell_ipc lock status) +jq -e '.locked | type == "boolean"' <<<"$lock_status_after" >/dev/null || fail_with_log "lock IPC survives plugin rescan" +lock_event_after=$(jq -r '.lastEvent // empty' <<<"$lock_status_after") +[[ $lock_event_after != lock-stranded* ]] || + fail_with_log "plugin rescan does not strand the session lock ($lock_event_after)" +# A recreated instance would answer with a fresh, empty marker. +[[ $(shell_ipc acme-keep get) == "survived" ]] || + fail_with_log "plugin rescan keeps the keepLoaded service instance mounted" +pass "keepLoaded service instance survives plugin rescan" + +# Dropping the service entry point from the manifest must drop the kept +# instance instead of leaving a zombie behind. +jq 'del(.keepLoaded) | .kinds = ["overlay"] | .entryPoints = {"overlay": "Service.qml"}' \ + "$keep_service_dir/manifest.json" >"$keep_service_dir/manifest.json.tmp" +mv "$keep_service_dir/manifest.json.tmp" "$keep_service_dir/manifest.json" +keep_gone="" +for _ in {1..80}; do + keep_gone=$(shell_ipc acme-keep get 2>/dev/null || true) + [[ $keep_gone != "survived" ]] && break + sleep 0.1 +done +[[ $keep_gone != "survived" ]] || + fail_with_log "kept service is dropped when its plugin stops declaring a service" +pass "kept service is dropped when its plugin stops declaring a service" + shell_ipc_quiet omarchy.system-update refresh >/dev/null 2>&1 || true sleep 0.8