From ba78e7df090ea04d25a39858cc940e4357bbdfe1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 11:44:39 +0200 Subject: [PATCH] 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"