Remove > AI refused whenever anything had Hermes's files open and told the user to close it and try again, and the thing open was Hermes: the agent in the terminal that choosing it as the default agent leaves running, the desktop app, a gateway. Those are the removal's to close. It now stops the gateway unit that upstream's `hermes gateway install` wrote, since that unit starts the runtime about to be deleted and would start it again the moment it was killed, then ends every process whose program lives in that runtime or in the package, and only then refuses over whatever is left, which is somebody else's: an editor on a skill, a shell sitting in ~/.hermes, a writer on the state database. Both are judged by the program, never by a later argument or by the home served, so an editor opened on a runtime file is the user's and a unit running a Hermes kept elsewhere is left alone whatever home it serves; for an interpreter the program is the script it runs, so a gateway started by hand as `python .../hermes gateway run` is found too. The refusal comes before anything is touched, so a run that stops leaves Hermes running as it was. A unit that will not stop still aborts the removal, as dropping the package would strand a live gateway on deleted code. Co-Authored-By: Codex XHigh <noreply@openai.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
238 lines
10 KiB
Bash
Executable File
238 lines
10 KiB
Bash
Executable File
#!/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, so a gateway started by hand as `python .../hermes` 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]
|
|
if started_as and re.fullmatch(r'(python[0-9.]*|node|bash|sh)', os.path.basename(started_as[0])):
|
|
started_as = argv[:2]
|
|
ours = any(under(target, runtime) for target in started_as + program)
|
|
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 ]] && omarchy-cmd-present gum; 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
|