From f08840d6cc98b81b78290d8e6ff61567b4b21079 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 1 Sep 2026 14:25:37 -0500 Subject: [PATCH 1/6] Probe Hermes readiness by the flags omarchy-agent passes Hermes v0.20 removed chat's --oneshot flag, which hermes_prompt_ready used as its capability marker. A fully bootstrapped Hermes Desktop install then read as not ready: --check failed forever, the default agent flow looped back into the installer, and --now dead-ended with "Launch Hermes Desktop once to finish installing it" on a machine where it already had. Probe for --tui and --query instead: the flags omarchy-agent actually passes to seed an interactive session, rather than one that merely shipped alongside them. --- bin/omarchy-install-hermes-cli | 9 ++++++--- test/shell.d/hermes-cli-test.sh | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index 5b6a3598..150364c0 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -88,13 +88,16 @@ hermes_runs() { timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1 } -# 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. +# Probed for the flags omarchy-agent actually passes -- --query to seed the +# session and --tui to keep it interactive -- rather than a flag that merely +# shipped alongside them: the previous marker, --oneshot, was dropped in Hermes +# v0.20 and left working installs stranded as "not ready". hermes_prompt_ready() { local help hermes_runs && help=$(timeout 15 "$HOME/.local/bin/hermes" chat --help 2>/dev/null) && - grep -qF -- '--oneshot' <<<"$help" + grep -qF -- '--tui' <<<"$help" && + grep -qF -- '--query' <<<"$help" } # --owns answers whether the wrapper on PATH is the one this command wrote, so diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 83b516e9..2144a795 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -98,7 +98,7 @@ mkdir -p "$test_home/.hermes/hermes-agent/venv/bin" cat >"$test_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' #!/bin/bash if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then - [[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "--oneshot" + [[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "[-q QUERY, --query QUERY] [--tui]" else echo "hermes-agent 0.0.0-test" fi @@ -388,7 +388,7 @@ 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:-} == "chat" && ${2:-} == "--help" ]]; then - echo "--oneshot" + echo "[-q QUERY, --query QUERY] [--tui]" else echo "hermes-agent 0.0.0-test" fi @@ -396,3 +396,17 @@ 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" + +# A release whose help lists only the retired --oneshot marker cannot run the +# seeded --tui --query session omarchy-agent starts, so it is not ready. +cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + echo "--oneshot" +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 a release without the flags omarchy-agent passes" +pass "a release listing only --oneshot is not prompt-ready" From f7078b9136e5241b2080a41575c85502e2b7c7b0 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 1 Sep 2026 15:53:52 -0500 Subject: [PATCH 2/6] Match Hermes flag probe at a boundary, cover the mise path Review follow-up on the readiness probe. The two greps were fixed-string substring matches, so a future release listing only --tui-theme or --query-log while dropping the bare --tui/--query omarchy-agent passes would read as ready -- the same false verdict inverted. Anchor both to a flag boundary. Add a regression case pinning that a substring-only help is rejected, and one exercising --check through a mise-installed hermes in both capability directions: the desktop and foreign cases only covered their own wrappers, and the mise path is what a machine without the app runs. Co-Authored-By: Claude --- bin/omarchy-install-hermes-cli | 9 ++++-- test/shell.d/hermes-cli-test.sh | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index 150364c0..bdd0daab 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -92,12 +92,17 @@ hermes_runs() { # session and --tui to keep it interactive -- rather than a flag that merely # shipped alongside them: the previous marker, --oneshot, was dropped in Hermes # v0.20 and left working installs stranded as "not ready". +# +# Matched at a flag boundary, not as a substring: a bare grep for --tui also +# accepts a release that lists only --tui-theme while having dropped --tui +# itself, which would call an install ready that omarchy-agent cannot drive -- +# the same false verdict this check exists to prevent, merely inverted. hermes_prompt_ready() { local help hermes_runs && help=$(timeout 15 "$HOME/.local/bin/hermes" chat --help 2>/dev/null) && - grep -qF -- '--tui' <<<"$help" && - grep -qF -- '--query' <<<"$help" + grep -qE -- '--tui([^[:alnum:]-]|$)' <<<"$help" && + grep -qE -- '--query([^[:alnum:]-]|$)' <<<"$help" } # --owns answers whether the wrapper on PATH is the one this command wrote, so diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 2144a795..fa5c1db9 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -23,6 +23,11 @@ cat >"$mock_bin/omarchy-cmd-missing" <<'SH' SH # `mise where` must fail so the installer sees no Hermes behind the stub. +# +# With OMARCHY_TEST_MISE_X_HERMES=1, `mise x -- hermes ...` emulates the Hermes +# the Omarchy stub runs, so the readiness probe can be exercised through a +# mise-installed hermes and not only the foreign and desktop wrappers. Off by +# default, so `mise x` stays silent for every test that does not opt in. cat >"$mock_bin/mise" <<'SH' #!/bin/bash printf '%s\0' "$@" >>"$OMARCHY_TEST_MISE_LOG" @@ -30,6 +35,18 @@ if [[ $1 == "where" && ${OMARCHY_TEST_MISE_WHERE_OK:-0} == 1 ]]; then printf '%s\n' "$OMARCHY_TEST_MISE_ROOT" exit 0 fi +if [[ $1 == "x" && ${OMARCHY_TEST_MISE_X_HERMES:-0} == 1 ]]; then + # Args are `x -- hermes `; skip to what follows hermes. + shift + while (( $# )) && [[ $1 != "--" ]]; do shift; done + shift 2 + if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + [[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "[-q QUERY, --query QUERY] [--tui]" + else + echo "hermes-agent 0.0.0-test" + fi + exit 0 +fi [[ $1 != "where" ]] SH @@ -237,6 +254,26 @@ 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" +# The mise-installed path is what a machine without the desktop app runs, and +# --check gates the default agent there too. The stub is present and its mise +# environment resolves, so readiness turns on the hermes mise runs -- exercised +# here in both directions, since the desktop and foreign cases cover only their +# own wrappers. +run_mise_check() { + OMARCHY_TEST_DESKTOP_INSTALLED=0 \ + OMARCHY_TEST_MISE_WHERE_OK=1 \ + OMARCHY_TEST_MISE_ROOT="$test_tmp/mise" \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + OMARCHY_TEST_MISE_X_HERMES=1 \ + OMARCHY_TEST_HERMES_CAPABLE="$1" \ + HOME="$test_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" --check >/dev/null 2>&1 +} +run_mise_check 1 || fail "--check accepts a mise-installed hermes that runs the seeded session" +run_mise_check 0 && fail "--check rejects a mise-installed hermes without the flags omarchy-agent passes" +pass "--check follows the mise-installed hermes it would actually run" + rm -f "$test_home/.local/bin/hermes" : >"$mise_log" OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 0 && @@ -410,3 +447,18 @@ SH chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes" run_ready_check && fail "--check accepts a release without the flags omarchy-agent passes" pass "a release listing only --oneshot is not prompt-ready" + +# A release that lists --tui-theme and --query-log but has dropped the bare +# --tui/--query omarchy-agent passes must not read as ready on the substring +# alone. The probe matches at a flag boundary for exactly this case. +cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + echo "[--tui-theme THEME] [--query-log FILE]" +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 a release whose flags only contain --tui/--query as a substring" +pass "a flag that merely contains --tui or --query is not prompt-ready" From 36d52254a7962e468ce75d637afc7bedeb090cb1 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 1 Sep 2026 22:51:03 -0500 Subject: [PATCH 3/6] Tear down the mise Hermes CLI on Remove Hermes Remove Hermes dropped the desktop package and its ~/.hermes runtime but never touched the mise CLI, on the assumption the install-time handoff had already removed it. A CLI the app never superseded -- an interrupted install, or the terminal CLI from before the app existed -- was left stranded on PATH after uninstall. Add a --remove mode to omarchy-install-hermes-cli that performs the same teardown the desktop takeover already does (mise rm -g + mise uninstall, and the marked stub), and call it from omarchy-remove-ai-hermes. The tool spec and ownership marker stay defined in one place, so the takeover and teardown paths cannot drift. Scoped to what Omarchy owns: a Hermes the user installed themselves is left alone. Co-Authored-By: Claude --- bin/omarchy-install-hermes-cli | 21 +++++++++++++++- bin/omarchy-remove-ai-hermes | 7 ++++++ test/shell.d/hermes-cli-test.sh | 39 ++++++++++++++++++++++++++++++ test/shell.d/hermes-remove-test.sh | 19 ++++++++++++++- 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index bdd0daab..7ae9410a 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=[--check|--now|--owns] +# omarchy:args=[--check|--now|--owns|--remove] # omarchy:examples=omarchy install hermes cli | omarchy install hermes cli --now # Hermes pins every one of its dependencies exactly and declares @@ -112,6 +112,25 @@ if [[ $mode == "--owns" ]]; then if ours; then exit 0; else exit 1; fi fi +# --remove tears down a Hermes CLI this installer put in place -- the mise tool +# it installs and the stub it marks -- so Remove Hermes clears a CLI the app +# never superseded (an interrupted install, or the terminal CLI from before the +# app existed) rather than leaving it stranded on PATH. Scoped to what is ours: +# the tool spec is this installer's, and the stub goes only when it carries the +# marker, so a Hermes the user installed themselves is untouched. Idempotent -- +# nothing installed means nothing to do -- and it is the same teardown the +# desktop takeover above performs, kept in one place so the two cannot drift. +if [[ $mode == "--remove" ]]; then + mise rm -g "$tool" >/dev/null 2>&1 || true + mise uninstall --all "$tool" >/dev/null 2>&1 || true + + if ours; then + rm -f "$HOME/.local/bin/hermes" + fi + + exit 0 +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-ai-hermes b/bin/omarchy-remove-ai-hermes index f67b1d6d..f332dc4b 100755 --- a/bin/omarchy-remove-ai-hermes +++ b/bin/omarchy-remove-ai-hermes @@ -8,6 +8,13 @@ set -euo pipefail omarchy-pkg-drop hermes-desktop +# The mise CLI is the app's predecessor, not the app itself: Hermes Desktop takes +# it over on install and runs its own runtime instead, so a copy still here is one +# the app never superseded -- an interrupted install, or the terminal CLI from +# before the app existed. Remove Hermes clears that too, scoped by the installer +# to what Omarchy owns so a hermes the user set up themselves is left alone. +omarchy-install-hermes-cli --remove + # 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 diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index fa5c1db9..a70589ef 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -402,6 +402,45 @@ marker_copies=$(grep -rl "Written by omarchy-install-hermes-cli" \ fail "only omarchy-install-hermes-cli spells out the ownership marker" pass "the ownership marker is written down once" +# --remove tears down a Hermes CLI this installer owns, so Remove Hermes can +# clear one the desktop app never superseded. It turns on the same ownership as +# the rest of the file, so its cases mirror that split. +remove_home="$test_tmp/remove-home" +mkdir -p "$remove_home/.local/bin" + +run_remove() { + OMARCHY_TEST_DESKTOP_INSTALLED=0 \ + 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="$remove_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" --remove +} + +rm -f "$remove_home/.local/bin/hermes" +: >"$mise_log" +run_remove || fail "--remove succeeds when there is nothing to remove" +pass "--remove is idempotent when no Hermes CLI is present" + +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes" +chmod +x "$remove_home/.local/bin/hermes" +: >"$mise_log" +OMARCHY_TEST_MISE_WHERE_OK=1 run_remove || fail "--remove succeeds tearing down an owned CLI" +tr '\0' '\n' <"$mise_log" | grep -q '^rm$' || fail "--remove drops the mise tool from config" +tr '\0' '\n' <"$mise_log" | grep -q '^uninstall$' || fail "--remove uninstalls the mise tool" +[[ ! -e $remove_home/.local/bin/hermes ]] || fail "--remove takes the stub it owns" +pass "--remove tears down the mise CLI and the stub this installer owns" + +foreign_remove_body="#!/bin/bash +exec /usr/local/bin/my-own-hermes \"\$@\"" +printf '%s\n' "$foreign_remove_body" >"$remove_home/.local/bin/hermes" +chmod +x "$remove_home/.local/bin/hermes" +run_remove || fail "--remove succeeds with a foreign hermes present" +[[ -f $remove_home/.local/bin/hermes && $(cat "$remove_home/.local/bin/hermes") == "$foreign_remove_body" ]] || + fail "--remove leaves a hermes it does not own untouched" +pass "--remove leaves a Hermes the user installed themselves" + # 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/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh index bc80859f..c590f0d3 100755 --- a/test/shell.d/hermes-remove-test.sh +++ b/test/shell.d/hermes-remove-test.sh @@ -15,6 +15,14 @@ cat >"$mock_bin/omarchy-pkg-drop" <<'SH' #!/bin/bash printf '%s\0' "$@" >>"$OMARCHY_TEST_DROP_LOG" SH + +# The CLI teardown is the installer's own, exercised in hermes-cli-test.sh; here +# it is mocked to a logger so this test stays about what Remove Hermes does with +# ~/.hermes, and to keep real mise out of a run with HOME pointed at a fixture. +cat >"$mock_bin/omarchy-install-hermes-cli" <<'SH' +#!/bin/bash +printf '%s\0' "$@" >>"$OMARCHY_TEST_INSTALLER_LOG" +SH chmod +x "$mock_bin"/* seed_install() { @@ -35,7 +43,10 @@ seed_install() { } remove() { - OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" HOME="$test_home" PATH="$mock_bin:$PATH" \ + : >"$test_tmp/installer-log" + OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" \ + OMARCHY_TEST_INSTALLER_LOG="$test_tmp/installer-log" \ + HOME="$test_home" PATH="$mock_bin:$PATH" \ bash "$ROOT/bin/omarchy-remove-ai-hermes" >/dev/null 2>&1 } @@ -67,6 +78,12 @@ 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" +# Removal also asks the installer to tear down a mise CLI the app superseded, so +# a copy left from before the app took over does not linger once Hermes is gone. +tr '\0' '\n' <"$test_tmp/installer-log" | grep -qx -- '--remove' || + fail "removal asks the installer to tear down its own CLI" +pass "removal tears down the mise CLI through the installer" + # A hermes command the app did not write survives even when the app did install # a runtime of its own. seed_install From c462aad9eec6b1cff29027cb88fe2ec628402734 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 1 Sep 2026 23:32:35 -0500 Subject: [PATCH 4/6] Prove ownership before --remove touches mise The teardown removed the Omarchy tool spec from mise unconditionally, so removing Hermes Desktop could destroy a mise environment the user had built against the same spec while sparing their wrapper -- the very command the removal claims to preserve, broken behind its back. The whole teardown now turns on the marked stub, as replacement already does; the desktop takeover keeps its own bargain, where a second Hermes goes whoever built it and the app still provides the command after. Also close the probe over underscores -- _ continues a flag name just as - does, so --tui_mode no longer answers for --tui -- and pin the gaps review found in the tests: each flag must match on its own (either grep could be deleted before without a failure), a foreign wrapper's mise environment must survive --remove, and Remove Hermes must tear down the CLI in the interrupted-install case, not only after the app's runtime landed. Findings from an independent codex review at xhigh, each verified against the source and proven by mutation before landing. Co-Authored-By: Codex Co-Authored-By: Claude --- bin/omarchy-install-hermes-cli | 24 +++++++++-------- test/shell.d/hermes-cli-test.sh | 42 +++++++++++++++++++++++++++++- test/shell.d/hermes-remove-test.sh | 4 +++ 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index 7ae9410a..4a6923a3 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -101,8 +101,8 @@ hermes_prompt_ready() { local help hermes_runs && help=$(timeout 15 "$HOME/.local/bin/hermes" chat --help 2>/dev/null) && - grep -qE -- '--tui([^[:alnum:]-]|$)' <<<"$help" && - grep -qE -- '--query([^[:alnum:]-]|$)' <<<"$help" + grep -qE -- '--tui([^[:alnum:]_-]|$)' <<<"$help" && + grep -qE -- '--query([^[:alnum:]_-]|$)' <<<"$help" } # --owns answers whether the wrapper on PATH is the one this command wrote, so @@ -113,18 +113,20 @@ if [[ $mode == "--owns" ]]; then fi # --remove tears down a Hermes CLI this installer put in place -- the mise tool -# it installs and the stub it marks -- so Remove Hermes clears a CLI the app +# it installed and the stub it marked -- so Remove Hermes clears a CLI the app # never superseded (an interrupted install, or the terminal CLI from before the -# app existed) rather than leaving it stranded on PATH. Scoped to what is ours: -# the tool spec is this installer's, and the stub goes only when it carries the -# marker, so a Hermes the user installed themselves is untouched. Idempotent -- -# nothing installed means nothing to do -- and it is the same teardown the -# desktop takeover above performs, kept in one place so the two cannot drift. +# app existed) rather than leaving it stranded on PATH. The whole teardown +# turns on the marked stub, exactly as replacement does further down: without +# it nothing proves the mise environment is Omarchy's rather than one the user +# built against the same spec, and a user's stays theirs. The desktop takeover +# removes the environment without asking, but that is its own bargain -- a +# second Hermes has to go whoever built it, and the app still provides the +# command afterwards; here nothing would. Idempotent: nothing owned, nothing +# to do. if [[ $mode == "--remove" ]]; then - mise rm -g "$tool" >/dev/null 2>&1 || true - mise uninstall --all "$tool" >/dev/null 2>&1 || true - if ours; then + mise rm -g "$tool" >/dev/null 2>&1 || true + mise uninstall --all "$tool" >/dev/null 2>&1 || true rm -f "$HOME/.local/bin/hermes" fi diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index a70589ef..69a52f61 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -421,6 +421,10 @@ run_remove() { rm -f "$remove_home/.local/bin/hermes" : >"$mise_log" run_remove || fail "--remove succeeds when there is nothing to remove" +# No stub means no proof the mise environment -- if one even exists -- is +# Omarchy's, so nothing may reach mise at all. +tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' && + fail "--remove leaves mise alone when nothing proves ownership" pass "--remove is idempotent when no Hermes CLI is present" printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes" @@ -436,9 +440,14 @@ foreign_remove_body="#!/bin/bash exec /usr/local/bin/my-own-hermes \"\$@\"" printf '%s\n' "$foreign_remove_body" >"$remove_home/.local/bin/hermes" chmod +x "$remove_home/.local/bin/hermes" -run_remove || fail "--remove succeeds with a foreign hermes present" +: >"$mise_log" +OMARCHY_TEST_MISE_WHERE_OK=1 run_remove || fail "--remove succeeds with a foreign hermes present" [[ -f $remove_home/.local/bin/hermes && $(cat "$remove_home/.local/bin/hermes") == "$foreign_remove_body" ]] || fail "--remove leaves a hermes it does not own untouched" +# The wrapper may front a mise environment the user built against the very same +# spec; without the marker there is no telling, so the environment stays too. +tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' && + fail "--remove never removes a mise environment it cannot prove is Omarchy's" pass "--remove leaves a Hermes the user installed themselves" # The app's marker says its install once landed, not that it is still there. A @@ -501,3 +510,34 @@ SH chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes" run_ready_check && fail "--check accepts a release whose flags only contain --tui/--query as a substring" pass "a flag that merely contains --tui or --query is not prompt-ready" + +# Each flag answers for itself: a release that kept --tui but dropped --query, +# or the reverse, cannot run the seeded session either, so neither grep may +# ride on the other's match. +for kept in '--tui' '-q QUERY, --query QUERY'; do + cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + echo "[--tui_mode MODE] [--query_log FILE]" +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 flags that extend --tui/--query with an underscore" +pass "an underscore continuation is not the bare flag" diff --git a/test/shell.d/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh index c590f0d3..6045c9a1 100755 --- a/test/shell.d/hermes-remove-test.sh +++ b/test/shell.d/hermes-remove-test.sh @@ -104,6 +104,10 @@ 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" +# The stranded pre-desktop CLI is exactly the interrupted-install case, so the +# teardown must be asked for here too, not only when the app's runtime landed. +tr '\0' '\n' <"$test_tmp/installer-log" | grep -qx -- '--remove' || + fail "removal tears down the CLI even when the app never finished installing" [[ -d $test_home/.hermes/hermes-agent ]] || fail "a Hermes runtime the app never installed survives removal" [[ -f $test_home/.hermes/hermes-agent/PATCH ]] || From 46cfc4ada5d9efe849d0f789178e492d98801011 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Wed, 2 Sep 2026 01:01:13 -0500 Subject: [PATCH 5/6] Judge the CLI teardown by what is left, and match flags as definitions Two review follow-ups. The probe counted any mention of --tui/--query in the help as support -- Hermes already writes "With --tui:" into --dev's description, so a release that dropped the option while keeping the prose would still read as ready. A flag now counts only when followed by a shape argparse prints after a definition: the usage bracket, the gap before same-line help text, an uppercase metavar, or the line end. Not probed by parsing a real invocation on purpose -- a release that ignores unknown arguments would turn the probe into a live session. And the teardown trusted its commands: a stub rm that failed aborted Remove Hermes under set -e before any ~/.hermes handling, while mise failures vanished into || true. --remove now attempts every step, then judges by what is left -- the marked stub still present, or mise still resolving the tool -- and Remove Hermes tolerates the failure until the runtime is handled, then carries it in its exit code. Findings from the same codex review at xhigh, verified and proven by mutation before landing. Co-Authored-By: Codex Co-Authored-By: Claude --- bin/omarchy-install-hermes-cli | 32 ++++++++++++++++++++------- bin/omarchy-remove-ai-hermes | 11 +++++++++- test/shell.d/hermes-cli-test.sh | 35 +++++++++++++++++++++++++++++- test/shell.d/hermes-remove-test.sh | 13 +++++++++++ 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index 4a6923a3..35405269 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -88,21 +88,27 @@ hermes_runs() { timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1 } +# A flag counts only when the help defines it, not whenever it is mentioned: +# what follows must be a shape argparse prints after a definition -- the usage +# line's closing bracket, the gap before same-line help text, an uppercase +# metavar, or the end of the line. Prose like "With --tui: run ..." stays +# prose, and --tui-theme or --tui_mode never answers for --tui. Not probed by +# parsing an actual invocation on purpose: a release that ignores unknown +# arguments would turn the probe into a live session. +help_defines_flag() { + grep -qE -- "$1(]|[[:space:]][[:upper:]]|[[:space:]]{2}|$)" <<<"$2" +} + # Probed for the flags omarchy-agent actually passes -- --query to seed the # session and --tui to keep it interactive -- rather than a flag that merely # shipped alongside them: the previous marker, --oneshot, was dropped in Hermes # v0.20 and left working installs stranded as "not ready". -# -# Matched at a flag boundary, not as a substring: a bare grep for --tui also -# accepts a release that lists only --tui-theme while having dropped --tui -# itself, which would call an install ready that omarchy-agent cannot drive -- -# the same false verdict this check exists to prevent, merely inverted. hermes_prompt_ready() { local help hermes_runs && help=$(timeout 15 "$HOME/.local/bin/hermes" chat --help 2>/dev/null) && - grep -qE -- '--tui([^[:alnum:]_-]|$)' <<<"$help" && - grep -qE -- '--query([^[:alnum:]_-]|$)' <<<"$help" + help_defines_flag '--tui' "$help" && + help_defines_flag '--query' "$help" } # --owns answers whether the wrapper on PATH is the one this command wrote, so @@ -127,7 +133,17 @@ if [[ $mode == "--remove" ]]; then if ours; then mise rm -g "$tool" >/dev/null 2>&1 || true mise uninstall --all "$tool" >/dev/null 2>&1 || true - rm -f "$HOME/.local/bin/hermes" + rm -f "$HOME/.local/bin/hermes" 2>/dev/null || true + + # Every step is attempted before any is judged, and judged by what is left + # rather than by what the commands claimed: the marked stub still answering + # hermes, or mise still resolving the tool, is a CLI still installed no + # matter how the removal exited. + if ours || mise where "$tool" >/dev/null 2>&1; then + echo "Could not remove the Hermes CLI Omarchy installed." >&2 + echo "Remove ~/.local/bin/hermes and the mise tool '$tool', then run omarchy-install-hermes-cli --remove again." >&2 + exit 1 + fi fi exit 0 diff --git a/bin/omarchy-remove-ai-hermes b/bin/omarchy-remove-ai-hermes index f332dc4b..1145fbd1 100755 --- a/bin/omarchy-remove-ai-hermes +++ b/bin/omarchy-remove-ai-hermes @@ -13,7 +13,10 @@ omarchy-pkg-drop hermes-desktop # the app never superseded -- an interrupted install, or the terminal CLI from # before the app existed. Remove Hermes clears that too, scoped by the installer # to what Omarchy owns so a hermes the user set up themselves is left alone. -omarchy-install-hermes-cli --remove +# Tolerated here rather than fatal, so the ~/.hermes handling below still runs; +# the failure is answered for at the end instead of being swallowed. +cli_removed=true +omarchy-install-hermes-cli --remove || cli_removed=false # 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 @@ -64,3 +67,9 @@ else echo "Hermes Desktop has been removed." echo "It never finished installing its own Hermes, so nothing in ~/.hermes was touched." fi + +# The messages above still hold -- the app and its runtime are gone -- but a CLI +# teardown that failed already said so on stderr, and that stands. +if [[ $cli_removed == "false" ]]; then + exit 1 +fi diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 69a52f61..24f8e8bc 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -430,12 +430,19 @@ pass "--remove is idempotent when no Hermes CLI is present" printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes" chmod +x "$remove_home/.local/bin/hermes" : >"$mise_log" -OMARCHY_TEST_MISE_WHERE_OK=1 run_remove || fail "--remove succeeds tearing down an owned CLI" +run_remove || fail "--remove succeeds tearing down an owned CLI" tr '\0' '\n' <"$mise_log" | grep -q '^rm$' || fail "--remove drops the mise tool from config" tr '\0' '\n' <"$mise_log" | grep -q '^uninstall$' || fail "--remove uninstalls the mise tool" [[ ! -e $remove_home/.local/bin/hermes ]] || fail "--remove takes the stub it owns" pass "--remove tears down the mise CLI and the stub this installer owns" +# When mise still resolves the tool after the teardown, the environment +# survived whatever uninstall claimed, and --remove has to say so. +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes" +chmod +x "$remove_home/.local/bin/hermes" +OMARCHY_TEST_MISE_WHERE_OK=1 run_remove && fail "--remove claims success while mise still resolves the tool" +pass "--remove fails when the mise environment survives the teardown" + foreign_remove_body="#!/bin/bash exec /usr/local/bin/my-own-hermes \"\$@\"" printf '%s\n' "$foreign_remove_body" >"$remove_home/.local/bin/hermes" @@ -450,6 +457,16 @@ tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' && fail "--remove never removes a mise environment it cannot prove is Omarchy's" pass "--remove leaves a Hermes the user installed themselves" +# Judged by what is left, not by what rm claimed: a stub that survives the +# teardown is a CLI still installed, and --remove has to say so. +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes" +chmod +x "$remove_home/.local/bin/hermes" +chmod 555 "$remove_home/.local/bin" +run_remove && fail "--remove claims success while the stub survives" +chmod 755 "$remove_home/.local/bin" +rm -f "$remove_home/.local/bin/hermes" +pass "--remove fails when the stub cannot be removed" + # 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. @@ -541,3 +558,19 @@ SH chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes" run_ready_check && fail "--check accepts flags that extend --tui/--query with an underscore" pass "an underscore continuation is not the bare flag" + +# A flag mentioned in another option's help text is not that option. Hermes +# already writes "With --tui:" into --dev's description, so prose has to stay +# prose even when both names appear in it. +cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + echo " --dev With --tui: run sources via tsx" + echo " --log FILE Where --query output lands" +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 flags that appear only in option descriptions" +pass "a flag mentioned in prose is not a defined option" diff --git a/test/shell.d/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh index 6045c9a1..d3389087 100755 --- a/test/shell.d/hermes-remove-test.sh +++ b/test/shell.d/hermes-remove-test.sh @@ -22,6 +22,7 @@ SH cat >"$mock_bin/omarchy-install-hermes-cli" <<'SH' #!/bin/bash printf '%s\0' "$@" >>"$OMARCHY_TEST_INSTALLER_LOG" +exit "${OMARCHY_TEST_INSTALLER_STATUS:-0}" SH chmod +x "$mock_bin"/* @@ -46,6 +47,7 @@ remove() { : >"$test_tmp/installer-log" OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" \ OMARCHY_TEST_INSTALLER_LOG="$test_tmp/installer-log" \ + OMARCHY_TEST_INSTALLER_STATUS="${OMARCHY_TEST_INSTALLER_STATUS:-0}" \ HOME="$test_home" PATH="$mock_bin:$PATH" \ bash "$ROOT/bin/omarchy-remove-ai-hermes" >/dev/null 2>&1 } @@ -131,3 +133,14 @@ 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" + +# A CLI teardown that fails must not stop the runtime handling, and must not be +# papered over either: the data work still happens, and the failure reaches the +# caller's exit code. +seed_install +printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \ + >"$test_home/.local/bin/hermes" +OMARCHY_TEST_INSTALLER_STATUS=1 remove && fail "a failed CLI teardown surfaces in the exit code" +[[ ! -d $test_home/.hermes/hermes-agent ]] || + fail "a failed CLI teardown does not stop the runtime removal" +pass "a failed CLI teardown is reported after the runtime is handled" From 21470fd1eacd8c41d26ab018923a732ae0e61528 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Wed, 2 Sep 2026 09:40:44 -0500 Subject: [PATCH 6/6] Say how to finish a failed teardown, and record the probe's real history A failed --remove told the user to run it again, but that advice could never work: rm -f has usually taken the marked stub by the time the failure is judged, and a rerun that finds nothing it owns succeeds without touching the mise environment it was asked to finish removing. Spell out the three commands that complete the job by hand instead. Also correct the story the probe comment told: chat never lost --oneshot in v0.20 -- no released Hermes defined it there. It lived at the top level until v0.21 added chat's own, so the old probe was keyed to a flag no release ever carried under chat, and every install read as not ready. Recorded straight so a future hermes-desktop bump to v0.21+, which would make the old probe pass on the desktop path alone, cannot read as the fix. Findings from omarchybot's review (Opus 5, with Codex at xhigh). Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude --- bin/omarchy-install-hermes-cli | 16 +++++++++++----- test/shell.d/hermes-cli-test.sh | 4 ++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli index 35405269..4f4b99d7 100755 --- a/bin/omarchy-install-hermes-cli +++ b/bin/omarchy-install-hermes-cli @@ -100,9 +100,10 @@ help_defines_flag() { } # Probed for the flags omarchy-agent actually passes -- --query to seed the -# session and --tui to keep it interactive -- rather than a flag that merely -# shipped alongside them: the previous marker, --oneshot, was dropped in Hermes -# v0.20 and left working installs stranded as "not ready". +# session and --tui to keep it interactive -- rather than a marker standing in +# for them: the old probe keyed on chat carrying --oneshot, which no released +# Hermes did (it lived at the top level until v0.21 added chat's own), so +# every release read as "not ready". hermes_prompt_ready() { local help hermes_runs && @@ -139,9 +140,14 @@ if [[ $mode == "--remove" ]]; then # rather than by what the commands claimed: the marked stub still answering # hermes, or mise still resolving the tool, is a CLI still installed no # matter how the removal exited. + # Not "run --remove again": once the stub is gone nothing marks the mise + # environment as ours, so a rerun would find nothing it owns and succeed + # without touching what was left. Only the full commands finish the job. if ours || mise where "$tool" >/dev/null 2>&1; then - echo "Could not remove the Hermes CLI Omarchy installed." >&2 - echo "Remove ~/.local/bin/hermes and the mise tool '$tool', then run omarchy-install-hermes-cli --remove again." >&2 + echo "Could not remove the Hermes CLI Omarchy installed. Finish by hand:" >&2 + echo " rm -f ~/.local/bin/hermes" >&2 + echo " mise rm -g '$tool'" >&2 + echo " mise uninstall --all '$tool'" >&2 exit 1 fi fi diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 24f8e8bc..1cf033ab 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -499,8 +499,8 @@ 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" -# A release whose help lists only the retired --oneshot marker cannot run the -# seeded --tui --query session omarchy-agent starts, so it is not ready. +# A release whose help lists only the old probe's --oneshot marker cannot run +# the seeded --tui --query session omarchy-agent starts, so it is not ready. cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' #!/bin/bash if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then