#!/bin/bash # omarchy:summary=Remove the Hermes desktop app along with the Hermes runtime it installed. # omarchy:requires-sudo=true # -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /. set -euo pipefail unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" # Only systemd's own word counts as "stopped": a non-zero exit from is-active # also covers an unreachable user manager, which says nothing about whether # the process is alive. unit_stopped() { local state state=$(systemctl --user is-active "$1" 2>/dev/null) || true [[ $state == "inactive" || $state == "failed" ]] } # Hermes is closed here rather than asked to be: the agent in its terminal, # the desktop app and its helpers, a gateway run by hand -- every process # whose program, by its executable or by what it was started as, lives in the # runtime about to be deleted or in the package; for an interpreter that is # the script it runs, or Hermes's own package named with -m, so a gateway # started by hand as `python .../hermes` or `python -m hermes_cli.main` is # found too. Judged by the program alone, never by a later argument: an # editor opened on a file in the runtime is the user's. `check` refuses first if anything else has Hermes's files open, by # its executable, its working directory or a descriptor, that editor or a # shell sitting in ~/.hermes, so a refusal touches nothing; that # one is the user's to close, because removing an open SQLite WAL leaves a # live writer on a deleted generation. `stop` then ends Hermes's own and # refuses again over whatever is left. hermes_holders() { python3 - "$1" "$HOME" <<'PY' import os import re import signal import sys import time from pathlib import Path mode, home = sys.argv[1], Path(sys.argv[2]) roots = [str((home / relative).resolve()) for relative in ('.hermes', '.config/Hermes')] roots.append('/opt/hermes-desktop') runtime = [str((home / relative).resolve()) for relative in ('.hermes/hermes-agent', '.hermes/node', '.hermes/bin')] runtime.append('/opt/hermes-desktop') def under(target, directories): target = target.removesuffix(' (deleted)') return any(target == directory or target.startswith(directory + '/') for directory in directories) def processes(): found = [] for process in Path('/proc').iterdir(): if not process.name.isdigit() or int(process.name) == os.getpid(): continue try: if process.stat().st_uid != os.getuid(): continue argv = [os.fsdecode(arg) for arg in (process / 'cmdline').read_bytes().split(b'\0')] program = [] try: program.append(os.readlink(process / 'exe')) except OSError: pass touched = list(program) try: touched.append(os.readlink(process / 'cwd')) except OSError: pass try: for entry in (process / 'fd').iterdir(): try: touched.append(os.readlink(entry)) except OSError: pass except PermissionError: pass started_as = argv[:1] interpreter = bool(started_as) and re.fullmatch(r'(python[0-9.]*|node|bash|sh)', os.path.basename(started_as[0])) if interpreter: started_as = argv[:2] # Hermes's own package, run as a module from an activated venv. ours = any(under(target, runtime) for target in started_as + program) or \ (bool(interpreter) and argv[1:3] and argv[1] == '-m' and argv[2].startswith('hermes_cli')) if ours or any(under(target, roots) for target in touched): try: name = (process / 'comm').read_text().strip() except OSError: name = '?' found.append((int(process.name), ours, name)) except (FileNotFoundError, ProcessLookupError, PermissionError): continue return found def named(entries): return ', '.join(f'{pid} {name}' for pid, ours, name in entries) def refuse(entries): print('Something other than Hermes still has its files open (PIDs: ' + named(entries) + '). Close it, then run the removal again.', file=sys.stderr) sys.exit(1) strangers = [entry for entry in processes() if not entry[1]] if strangers: refuse(strangers) if mode == 'check': sys.exit(0) mine = [entry for entry in processes() if entry[1]] if mine: print('Stopping Hermes (PIDs: ' + named(mine) + ')...') for signum, patience in ((signal.SIGTERM, 10), (signal.SIGKILL, 3)): for pid, ours, name in mine: try: os.kill(pid, signum) except ProcessLookupError: pass deadline = time.monotonic() + patience while mine and time.monotonic() < deadline: time.sleep(0.2) mine = [entry for entry in processes() if entry[1]] if not mine: break if mine: print('Hermes did not stop (PIDs: ' + named(mine) + '). Close it, then run the removal again.', file=sys.stderr) sys.exit(1) left = processes() if left: refuse(left) PY } hermes_holders check # The gateway unit that upstream's `hermes gateway install` writes starts the # runtime about to be deleted, so it goes with it, and it is stopped before # the processes are, or systemd would start the gateway again the moment it # was killed. Judged by what the unit starts, as the processes are: a unit # whose ExecStart runs this runtime is its own whatever home it serves, and # one that runs a Hermes kept elsewhere is somebody else's arrangement even # when its home sits under ~/.hermes. A unit that will not stop aborts the # removal: dropping the package would strand a live gateway on deleted code # with no way to restart it cleanly. for unit_file in "$unit_dir"/hermes-gateway*.service; do [[ -f $unit_file ]] || continue grep '^ExecStart=' "$unit_file" | grep -qF "$HOME/.hermes/hermes-agent/" || continue unit=${unit_file##*/} if systemctl --user disable --now "$unit" 2>/dev/null || unit_stopped "$unit"; then # .bak is what `gateway install --force` leaves behind when it rewrites a # unit, so it goes with the unit. rm -f "$unit_file" "$unit_file.bak" "$unit_dir/default.target.wants/$unit" systemctl --user daemon-reload 2>/dev/null || true # A unit that had been failing stays listed as "not-found failed" after # its file is gone until its failed state is reset. systemctl --user reset-failed "$unit" 2>/dev/null || true else echo "Could not stop $unit; Hermes was not removed." >&2 exit 1 fi done # The installer leaves a unit waiting to hand the app the Omarchy theme; it # runs Hermes itself to do so, so it is stopped before the sweep reaches it. systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true hermes_holders stop omarchy-pkg-drop hermes-desktop # Upstream's installer writes this when the runtime under ~/.hermes has landed, # whether Omarchy ran it for the app or the app's own first launch did, 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 install never # got that far: a machine where the package landed but nothing set Hermes up # still has whatever was there before, and none of it is ours to delete unasked. 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, so it goes without asking; what the user made with the app # is a different question, answered below. rm -rf \ "$HOME/.hermes/hermes-agent" \ "$HOME/.hermes/bootstrap-cache" \ "$HOME/.hermes/bin" \ "$HOME/.hermes/node" # 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 && ! -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" if [[ -L $link && $(readlink "$link") == "$HOME/.hermes"/* ]]; then rm -f "$link" fi done fi # What survives to here is the user's: the chats, memories and skills in # ~/.hermes, the connections and their encrypted tokens in ~/.config/Hermes. # Keeping them stays the default -- they are small, and finding them intact # after a reinstall is the better surprise -- but a removal meant to be # complete should not leave credentials behind either, so the choice is put in # front of the user with the size, default no. Asked whenever the directories # exist, marker or no marker: on a machine where the marker never appeared the # data came from the terminal CLI or an install the app never finished, and it # is still what removal is asked to clean up. Naming the paths keeps the # question honest there too -- ~/.hermes may still carry a runtime the app # never owned, a yes takes that with it, and saying so is the prompt's job. # Without a terminal to ask in, keeping everything is the answer. data_removed=false if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]]; then # du answers non-zero when either directory is missing, and pipefail would # turn that into an aborted removal; the size is worth no such thing. size=$(du -shc "$HOME/.hermes" "$HOME/.config/Hermes" 2>/dev/null | tail -1 | cut -f1 || true) if gum confirm --default=false "Also delete ~/.hermes and ~/.config/Hermes ($size: chats, memories, skills, connections and tokens)?"; then hermes_holders stop rm -rf "$HOME/.hermes" "$HOME/.config/Hermes" data_removed=true fi fi echo "" echo "Hermes Desktop has been removed." if [[ $data_removed == true ]]; then echo "Its chats, memories, and settings in ~/.hermes and ~/.config/Hermes are gone too." elif [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]]; then echo "Your chats, memories, and skills are still in ~/.hermes," echo "and your connections and settings in ~/.config/Hermes." fi