Files
omarchy/bin/omarchy-install-hermes-cli
T
7eb818e37b Install Hermes for the default agent as the desktop app's self-updating runtime
Choosing Hermes as the default agent built it through mise: a pipx environment with no checkout, so `hermes update` had nothing to move, and the only Hermes that could update itself was the one Hermes Desktop set up. Both paths now run the same setup. omarchy-install-hermes-cli installs the hermes-desktop package and runs upstream's installer from it, pinned to the packaged release and started on main, exactly as Install > AI did; omarchy-install-ai-hermes is that plus opening the app. The terminal, the default agent and the app share one runtime, and it updates itself.

--check answers whether --now has anything left to do, not merely whether a hermes runs: choosing Hermes from the menu asks first and opens a terminal only on a no, so a yes has to mean no minutes-long step would run where nobody can see it. With the app installed that means the runtime's own command, its completion marker and the seeded packaged app; a finished runtime whose command is gone, somebody else's, or its own but unable to run gets it back from upstream's path stage without bootstrapping again. Either way the command has to be the one PATH finds, because omarchy-agent runs bare `hermes` and Omarchy puts mise's shims ahead of ~/.local/bin; a command in the way is named rather than installed over. The modes are named outright because the app's launcher used to call this command with no arguments to reconcile a mise copy; a default of --now would turn every launch into an install. --check still refuses to run the retired wrapper, since running it built Hermes through mise, and a machine whose migration is pending can still have it on PATH.

Provisioning no longer writes the wrapper, Remove Preinstalls no longer looks for it, and the wrapper, the environment it built and what proves them Omarchy's are known to the installer alone: --retire-mise is the migration's whole job, and --now runs the same removal once the runtime installer has saved the wrapper aside, so a user who chose Hermes before their migration ran is not left with mise's shim answering `hermes`. Only the wrapper proves the environment is Omarchy's, at its path or in that saved copy, so the environment goes first and the wrapper last, judged by mise neither having it installed nor still requesting it; a removal that leaves either behind, or a listing that cannot be read, mise missing included, stops with the commands to finish by hand and leaves the migration pending. The migration that once installed the wrapper is kept as a no-op for late updaters, and one whose default agent was Hermes is told to choose it again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-09-21 19:26:02 -05:00

534 lines
21 KiB
Bash
Executable File

#!/bin/bash
# omarchy:summary=Install Hermes for the default agent: the desktop app's self-updating runtime
# omarchy:args=<--check|--now|--retire-mise>
# omarchy:examples=omarchy install hermes cli --now | omarchy install hermes cli --check
# omarchy:requires-sudo=true
# There is one Hermes on a machine, and it is the desktop app's. hermes-desktop
# ships upstream's installer and the release it was built from, and that
# installer makes the only Hermes that can update itself: a checkout under
# ~/.hermes with its own venv, which `hermes update` fast-forwards. The mise
# build this replaced had no checkout, so nothing an update could move.
# Choosing Hermes as the default agent therefore installs the app the way
# Install > AI does, short of opening its window; see omarchy-install-ai-hermes.
#
# --check asks whether a Hermes omarchy-agent can run, not whose it is: a
# working one the user installed themselves is as good an answer as the app's,
# and without the app --now leaves it be rather than putting a second Hermes
# beside it. Once the app is installed its runtime is the one Hermes, and
# whatever held the command's name is saved aside before upstream's installer
# takes it.
#
# The wrapper the retired installer wrote, and the mise environment it built,
# are known here and nowhere else: --retire-mise is what the migration runs,
# and --now runs the same removal once it has replaced the wrapper.
#
# Each mode is named outright. The app's launcher used to call this with no
# arguments to reconcile a mise copy, and a default of --now would turn every
# launch of the app into an install.
set -euo pipefail
mode=${1:-}
# Keep the runtime at the root even when invoked from a Hermes profile.
HERMES_HOME=$(realpath -ms -- "${HERMES_HOME:-$HOME/.hermes}")
home_parent=$(dirname -- "$HERMES_HOME")
if [[ ${home_parent##*/} == [Pp][Rr][Oo][Ff][Ii][Ll][Ee][Ss] ]]; then
HERMES_HOME=$(dirname -- "$home_parent")
fi
export HERMES_HOME
runtime="$HERMES_HOME/hermes-agent"
native_app="$runtime/apps/desktop/release/linux-unpacked"
command_path="$HOME/.local/bin/hermes"
# The wrapper the retired mise-backed installer wrote, matched whole so a
# wrapper that merely mentions it is not mistaken for it, and the tool it
# built. Running the wrapper built Hermes through mise, so it is never run to
# find out whether Hermes is there.
legacy_marker='# Written by omarchy-install-hermes-cli.'
legacy_tool='pipx:hermes-agent[extras=all]'
legacy_stub() {
[[ -f $command_path ]] && grep -qxF "$legacy_marker" "$command_path"
}
# Only the wrapper Omarchy wrote proves the environment is Omarchy's to remove:
# the regular file at its path, or the copy a run of this installer saved aside
# before upstream's installer took the name, since a user who chose Hermes
# before the migration ran has it there and the environment still requested.
# A link is someone else's arrangement, even when it lands on the wrapper, and
# so is a linked backup directory: the installer makes real ones.
saved_wrapper() {
[[ ! -L ${1%/hermes} && -f $1 && ! -L $1 ]] && grep -qxF "$legacy_marker" "$1"
}
legacy_owned() {
local saved
if legacy_stub && [[ ! -L $command_path ]]; then
return 0
fi
for saved in "$HOME/.local/bin"/.hermes-before-desktop.*/hermes; do
if saved_wrapper "$saved"; then
return 0
fi
done
return 1
}
# Gone means neither installed nor still asked for in the global config, where
# `mise up` would build it again. `mise rm -g` exits 0 whether or not it removed
# anything, so the listing is read instead, and a listing that cannot be read
# -- mise broken, or not there to read it -- is not an answer. The key carries
# the backend and name, with or without the options.
mise_retired() {
local requested
requested=$(mise ls -g --json 2>/dev/null) || return 1
! mise where "$legacy_tool" >/dev/null 2>&1 && ! grep -qF '"pipx:hermes-agent' <<<"$requested"
}
# Removes the environment the retired wrapper built, judged by what is left
# rather than by what the commands claimed. Only for a caller that has proved
# it Omarchy's.
retire_mise() {
if mise_retired; then
return 0
fi
mise rm -g "$legacy_tool" >/dev/null 2>&1 || true
mise uninstall --all "$legacy_tool" >/dev/null 2>&1 || true
if mise_retired; then
return 0
fi
echo "Could not remove the Hermes that mise built. Finish by hand:" >&2
if omarchy-cmd-missing mise; then
echo " omarchy pkg add mise" >&2
fi
echo " mise rm -g '$legacy_tool'" >&2
echo " mise uninstall --all '$legacy_tool'" >&2
return 1
}
# Once the environment is gone the proof has done its work. Left behind it
# would keep --check answering no for an install that is finished, and claim
# an environment the user builds later as Omarchy's. The copies are Omarchy's
# own wrapper, so removing them takes nothing of the user's; a backup directory
# that held nothing else goes with them.
forget_legacy() {
local saved
if legacy_stub && [[ ! -L $command_path ]]; then
rm -f "$command_path"
fi
for saved in "$HOME/.local/bin"/.hermes-before-desktop.*/hermes; do
if saved_wrapper "$saved"; then
rm -f "$saved"
rmdir "${saved%/hermes}" 2>/dev/null || true
fi
done
}
# 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.
hermes_runs() {
[[ -f $command_path && -x $command_path ]] &&
timeout 15 "$command_path" --version >/dev/null 2>&1
}
# A flag counts only when the help defines it, not whenever it is mentioned:
# closing its short form, followed by its metavar, or padded to a description
# column. Matched against the help text rather than 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 marker standing in
# for them.
hermes_prompt_ready() {
local help
hermes_runs &&
help=$(timeout 15 "$command_path" chat --help 2>/dev/null) &&
help_defines_flag '--tui' "$help" &&
help_defines_flag '--query' "$help"
}
# A Hermes omarchy-agent can run, whoever installed it.
usable() {
! legacy_stub && hermes_prompt_ready
}
# The command upstream's installer writes execs into the runtime. Anything else
# at that path -- an official install elsewhere, a hand-rolled wrapper, even a
# dangling link -- is the user's. Matched as a plain string with its trailing
# slash, because the path carries a dot and a bare prefix would also claim a
# wrapper pointing at ~/.hermes-old.
ours() {
[[ -f $command_path && ! -L $command_path ]] && grep -qF "$HERMES_HOME/" "$command_path"
}
# What omarchy-agent runs is whichever hermes is first on PATH, and Omarchy puts
# mise's shims ahead of ~/.local/bin. So the command the probe vets has to be
# the one PATH finds, or the agent is on a different Hermes than the one set
# up. A PATH that finds none is not in the way: the theme unit asks from a
# service whose PATH has no ~/.local/bin, and runs the command by its path.
on_path() {
local found
found=$(type -P hermes) || return 0
[[ $(realpath -m -- "$found") == "$(realpath -m -- "$command_path")" ]]
}
shadowing_hermes() {
type -P hermes || true
}
# The installer writes the marker last, so it is the one thing that says a
# runtime finished installing rather than merely started; the venv appears
# several stages earlier. And a marker left behind by an install whose venv has
# since gone answers for nothing, so the command has to run.
runtime_ready() {
[[ -f $runtime/.hermes-bootstrap-complete && -f $runtime/venv/bin/hermes && -x $runtime/venv/bin/hermes && -f $runtime/venv/bin/python && -x $runtime/venv/bin/python ]] &&
timeout 15 "$runtime/venv/bin/hermes" --version >/dev/null 2>&1
}
native_app_complete() {
[[ -f $native_app/Hermes && -x $native_app/Hermes && -f $native_app/resources/app.asar && -f $native_app/resources/install-stamp.json ]]
}
# What --check answers, and what --now has nothing left to do about. Without
# the app, a Hermes that runs, whoever installed it. With the app, its own: the
# command upstream's installer wrote, the runtime's completion marker and the
# seeded packaged app, because the app only runs against a runtime it prepared
# and the terminal has to be on the same one. Either way the command PATH finds.
# And nothing of the retired wrapper's left to remove. The two agree on
# purpose: choosing Hermes from the menu asks first and opens a terminal only
# on a no, so a yes here has to mean --now would take no minutes-long step
# where nobody can watch it.
installed() {
on_path || return 1
if legacy_owned; then
return 1
fi
if omarchy-pkg-present hermes-desktop; then
ours && [[ -f $runtime/.hermes-bootstrap-complete ]] && native_app_complete && hermes_prompt_ready
else
usable
fi
}
# Once a Hermes answers at the command's path, the environment the retired
# wrapper built goes with whatever proof is left of it, and then PATH has to
# agree with the probe: a mise shim, or anything else ahead of ~/.local/bin, is
# what the default agent would run instead.
settle_path() {
if legacy_owned; then
echo "Removing the Hermes that mise built; Hermes now updates itself..."
retire_mise || return 1
forget_legacy
fi
if ! on_path; then
echo "$command_path is ready, but 'hermes' on PATH is $(shadowing_hermes), which is what the default agent runs." >&2
echo "Remove or reorder it, then run omarchy-install-hermes-cli --now again." >&2
return 1
fi
}
# The updater switches to main before checking for changes, so main has to
# start at the packaged release. Whatever main pointed at first is kept under
# another name rather than judged: upstream rewrites its history often enough
# that a clone's main sits off origin/main with no local commit on it, and a
# user's own commits are exactly what must not be lost either way.
keep_main() {
local main_commit kept
main_commit=$(git -C "$runtime" rev-parse --verify refs/heads/main 2>/dev/null || true)
if [[ -n $main_commit && $main_commit != "$release_commit" && $main_commit != "$(git -C "$runtime" rev-parse --verify refs/remotes/origin/main 2>/dev/null)" ]]; then
# A run that stopped after this point keeps it again on the next try; one
# branch at that commit is enough.
if [[ -z $(git -C "$runtime" for-each-ref --points-at "$main_commit" 'refs/heads/main-before-omarchy-*') ]]; then
kept="main-before-omarchy-$(date +%s)"
git -C "$runtime" branch "$kept" "$main_commit"
echo "Kept what Hermes main pointed at as the branch $kept; main now starts at the packaged release."
fi
fi
}
# The repository a process's GIT_DIR names, canonical: relative to its working
# directory when relative, and with no trailing slash or dot segments either
# way. Read NUL-delimited as the kernel writes it, never through a pipe: a
# path may carry a newline, and grep stopping at a match would fail a pipe
# under pipefail and call a busy git idle.
git_dir_of() {
local pid=$1 entry dir="" cwd
while IFS= read -r -d '' entry; do
if [[ $entry == GIT_DIR=* ]]; then
dir=${entry#GIT_DIR=}
break
fi
done <"/proc/$pid/environ" 2>/dev/null || return 1
[[ -n $dir ]] || return 1
if [[ $dir != /* ]]; then
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || return 1
dir="$cwd/$dir"
fi
realpath -m -- "$dir"
}
# A git of this user working in the runtime: a fetch Hermes started to work
# out its --version, or its updater. Found by working directory, or by a
# GIT_DIR in its environment, rather than by command line, since the remote
# helpers git spawns carry no repository path on theirs. Compared canonical,
# because /proc resolves every link and the runtime path keeps them.
git_busy_in_runtime() {
local pid cwd dir real
real=$(realpath -e -- "$runtime" 2>/dev/null) || return 1
for pid in $(pgrep -u "$(id -u)" -f '^git(-remote-[a-z]+)?( |$)' 2>/dev/null); do
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || continue
if [[ $cwd == "$real" || $cwd == "$real/"* ]]; then
return 0
fi
if dir=$(git_dir_of "$pid") && [[ $dir == "$real/.git" ]]; then
return 0
fi
done
return 1
}
# Hermes works out its --version against origin, and the probe's 15 second
# budget can kill it mid-fetch, which leaves git's shallow.lock behind; a fetch
# of ours that a network blip interrupted leaves the same. Git refuses to clear
# a lock it did not take, and a file's age alone cannot say whether anything
# still holds it, so a live git in the runtime is waited for first, and only a
# lock nobody holds and nothing has touched for a minute is cleared. The fetch
# itself gets three tries.
unshallow_runtime() {
local attempt waited lock="$runtime/.git/shallow.lock"
for attempt in 1 2 3; do
waited=0
while git_busy_in_runtime && (( waited < 60 )); do
if (( waited == 0 )); then
echo "Waiting for Hermes to finish working in $runtime..."
fi
sleep 2
waited=$(( waited + 2 ))
done
if git_busy_in_runtime; then
echo "Hermes is still working in $runtime. Let it finish, then run omarchy-install-hermes-cli --now again." >&2
return 1
fi
if [[ -e $lock && -n $(find "$lock" -mmin +1 2>/dev/null) ]]; then
rm -f "$lock"
fi
if git -C "$runtime" fetch --unshallow origin main; then
return 0
fi
if (( attempt < 3 )); then
sleep 2
fi
done
echo "Could not fetch the Hermes history from origin. Check the network, then run omarchy-install-hermes-cli --now again." >&2
return 1
}
case "$mode" in
--check)
if installed; then exit 0; else exit 1; fi
;;
--retire-mise)
# The migration's whole job. The environment goes before the wrapper, since
# once the wrapper is gone nothing marks the environment as Omarchy's and a
# rerun could not finish what a failed removal left behind.
if legacy_owned; then
retire_mise || exit 1
forget_legacy
fi
exit 0
;;
--now) ;;
*)
echo "Usage: omarchy-install-hermes-cli <--check|--now|--retire-mise>" >&2
exit 1
;;
esac
if (( EUID == 0 )); then
echo "Run this command as your desktop user, without sudo." >&2
exit 1
fi
if installed; then
exit 0
fi
if ! omarchy-pkg-present hermes-desktop; then
# The user's own Hermes is what the default agent will run; one that runs
# but predates seeded sessions is theirs to update, not ours to replace.
# The retired wrapper is neither: it is replaced, and never run to find out.
if usable; then
if settle_path; then exit 0; else exit 1; fi
fi
if ! legacy_stub && hermes_runs; then
echo "$command_path 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 --now again." >&2
exit 1
fi
echo "Installing Hermes..."
omarchy-pkg-add hermes-desktop
fi
if [[ ! -r /usr/share/hermes-desktop/install.sh || ! -r /usr/share/hermes-desktop/runtime.patch ]] ||
! release_commit=$(jq -er 'select(.branch == "main") | .commit | select(test("^[0-9a-f]{40}$"))' /opt/hermes-desktop/resources/install-stamp.json 2>/dev/null); then
echo "The installed Hermes package cannot prepare in-app updates. Run 'omarchy update', then try again." >&2
exit 1
fi
runtime_present=false
if runtime_ready; then
runtime_present=true
fi
# Whether this run gave Hermes something new to show the theme to: a runtime
# it set up, or the app it seeded. Putting a command back is neither.
set_up=false
if [[ $runtime_present == "false" && ( -e $runtime || -L $runtime ) ]]; then
# The upstream installer can reset an existing checkout. Do not pin a newer
# or modified runtime back to the package release while repairing setup.
if [[ $(git -C "$runtime" rev-parse HEAD 2>/dev/null) != "$release_commit" ]] ||
[[ -n $(git -C "$runtime" status --porcelain --untracked-files=all) ]]; then
echo "Hermes setup is incomplete at $runtime. Repair that installation before trying again; existing files have been kept." >&2
exit 1
fi
fi
# The command is the runtime's own and runs; anything else at the path gets
# replaced below. The retired wrapper is never run to find out.
command_ready=false
if ! legacy_stub && ours && hermes_runs; then
command_ready=true
fi
# Upstream replaces these commands, including foreign files and symlinks,
# whether it sets the runtime up or only writes the commands again. Keep their
# original bytes/links before handing the names to the runtime.
if [[ $runtime_present == "false" || $command_ready == "false" ]]; then
command_backup=""
for command in hermes hermes-agent hermes-acp; do
existing="$HOME/.local/bin/$command"
if [[ -e $existing || -L $existing ]]; then
if [[ ! -f $existing && ! -L $existing ]]; then
echo "Cannot replace $existing: move it aside before installing Hermes." >&2
exit 1
fi
if [[ -z $command_backup ]]; then
command_backup=$(mktemp -d "$HOME/.local/bin/.hermes-before-desktop.XXXXXX")
echo "Saving existing Hermes commands in $command_backup"
fi
cp -a -- "$existing" "$command_backup/"
fi
done
fi
if [[ $runtime_present == "false" ]]; then
echo "Setting up the Hermes runtime..."
bash /usr/share/hermes-desktop/install.sh --skip-setup --branch main --commit "$release_commit" --force-commit --dir "$runtime" --hermes-home "$HERMES_HOME"
if ! runtime_ready; then
echo "Hermes runtime setup did not complete. Re-run this command after resolving the installer error." >&2
exit 1
fi
set_up=true
elif [[ $command_ready == "false" ]]; then
# The runtime is in place but the command is not its own, or does not run:
# gone, the retired wrapper, one the user put there, or the runtime's own
# with its mode bits stripped. Upstream's path stage writes its commands
# without touching the checkout.
echo "Restoring the Hermes commands..."
bash /usr/share/hermes-desktop/install.sh --stage path --dir "$runtime" --hermes-home "$HERMES_HOME"
fi
runtime_commit=$(git -C "$runtime" rev-parse HEAD)
if [[ $runtime_commit == "$release_commit" ]]; then
# Start main at the packaged release, with enough history for its first
# fast-forward.
keep_main
if [[ $(git -C "$runtime" rev-parse --is-shallow-repository) == "true" ]]; then
unshallow_runtime
fi
git -C "$runtime" switch -C main "$release_commit"
if git -C "$runtime" apply --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then
git -C "$runtime" apply /usr/share/hermes-desktop/runtime.patch
elif ! git -C "$runtime" apply --reverse --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then
echo "The Hermes Linux runtime patch conflicts with local changes. Existing files have been kept." >&2
exit 1
fi
fi
if [[ -e $native_app || -L $native_app ]]; then
if ! native_app_complete; then
echo "The Hermes desktop app at $native_app is incomplete. Repair it with 'hermes desktop --build-only' before trying again." >&2
exit 1
fi
else
if [[ $runtime_commit != "$release_commit" ]]; then
echo "The Hermes runtime has moved beyond the packaged desktop release. Run 'hermes desktop --build-only', then try again." >&2
exit 1
fi
desktop_changes=$(git -C "$runtime" status --porcelain --untracked-files=all -- apps/desktop package.json package-lock.json)
if [[ -n $desktop_changes ]]; then
echo "Hermes desktop sources have local changes. Run 'hermes desktop --build-only', then try again; existing files have been kept." >&2
exit 1
fi
mkdir -p -- "${native_app%/*}"
staging=$(mktemp -d "${native_app%/*}/.linux-unpacked.XXXXXX")
trap 'rm -rf -- "$staging"' EXIT
cp -a /opt/hermes-desktop/. "$staging/"
chmod 0755 "$staging/chrome-sandbox"
mv -T --no-clobber -- "$staging" "$native_app"
if [[ -e $staging ]]; then
echo "A Hermes desktop app appeared during setup. It has been kept; please try again." >&2
exit 1
fi
trap - EXIT
# Record this matching prebuilt app using the CLI's own content hash, so
# subsequent menu launches do not rebuild an app that is already current.
env -u PYTHONPATH -u PYTHONHOME "$runtime/venv/bin/python" - "$runtime" <<'PY'
import sys
from pathlib import Path
sys.path.insert(0, sys.argv[1])
if Path(sys.argv[1], "hermes_cli/main_desktop.py").is_file():
from hermes_cli.main_desktop import _write_desktop_build_stamp
else:
from hermes_cli.main import _write_desktop_build_stamp
_write_desktop_build_stamp(Path(sys.argv[1]), source_mode=False)
PY
set_up=true
fi
# What omarchy-agent runs is the command, not the venv, so the command is what
# has to answer for the seeded sessions.
if ! hermes_prompt_ready; then
echo "Hermes is installed at $runtime, but $command_path does not run the interactive seeded sessions Omarchy needs." >&2
echo "Update Hermes with 'hermes update', then run omarchy-install-hermes-cli --now again." >&2
exit 1
fi
settle_path || exit 1
# Only a running Hermes can be told which skin to show; a unit outlives this
# terminal and reports to the journal.
if [[ $set_up == "true" ]]; then
echo "Matching Hermes to the current theme once it is set up..."
systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true
systemd-run --user --quiet --collect --unit=omarchy-hermes-theme omarchy-theme-set-hermes --wait
fi