Three files spelled out the line that marks ~/.local/bin/hermes as Omarchy's: the installer that writes it, Remove Preinstalls, and the migration. Two of them were copies, and a change to what ownership means would have left them matching a line nobody writes any more -- Remove Preinstalls quietly sweeping nothing, the migration mistaking Omarchy's own wrapper for a stranger's. omarchy-install-hermes-cli --owns answers it now, and the other two ask. The installer's own metadata was also a flag behind: --check has been there since this landed and was never listed. A test pins the marker to one file, so a second copy fails rather than drifts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
198 lines
8.0 KiB
Bash
Executable File
198 lines
8.0 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
# omarchy:summary=Install the Hermes CLI as a mise-backed wrapper in ~/.local/bin
|
|
# omarchy:args=[--check|--now|--owns]
|
|
# omarchy:examples=omarchy install hermes cli | omarchy install hermes cli --now
|
|
|
|
# Hermes pins every one of its dependencies exactly and declares
|
|
# Requires-Python >=3.11,<3.14, so it can neither be built against Arch's
|
|
# Python nor share the python-* packages. mise builds it a private environment
|
|
# instead.
|
|
#
|
|
# It gets its own installer rather than a line in omarchy-mise-install because
|
|
# of the interpreter pin. Given no compatible interpreter to hand, uv builds
|
|
# the venv against the system Python in violation of Hermes' own bound,
|
|
# reports success, and leaves the breakage to surface later inside a
|
|
# dependency -- and omarchy-mise-install writes a fixed stub with nowhere to
|
|
# say otherwise.
|
|
#
|
|
# There is only ever one Hermes on a machine. hermes-desktop cannot run against
|
|
# this one -- it needs a runtime built from its own commit, and the version gap
|
|
# fails its readiness probe -- so it installs its own under ~/.hermes and puts
|
|
# that on PATH. When the package is present it therefore owns Hermes outright:
|
|
# this installer stands aside and removes its own copy, so the terminal, the
|
|
# default agent and the app are all the same installation.
|
|
|
|
set -euo pipefail
|
|
|
|
mode=${1:-}
|
|
|
|
tool='pipx:hermes-agent[extras=all]'
|
|
python='3.13'
|
|
|
|
# The line that identifies the stub as this installer's; matched whole, so a
|
|
# wrapper that merely mentions the command is not mistaken for ours.
|
|
marker='# Written by omarchy-install-hermes-cli.'
|
|
|
|
# The package, not the runtime directory: it is installed before the app has
|
|
# ever run, and that is exactly when we must not start building a second copy.
|
|
desktop_owns_hermes() {
|
|
omarchy-pkg-present hermes-desktop
|
|
}
|
|
|
|
# The venv appears at the python-deps stage, several stages before the one that
|
|
# installs the command, so its presence says nothing about being usable. The
|
|
# marker is written last, and the command is what the agent actually runs.
|
|
desktop_hermes_ready() {
|
|
[[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]] || return 1
|
|
|
|
# An executable of that name proves nothing about whose it is; the app's own
|
|
# points into ~/.hermes, and anything else is not the install we are asking
|
|
# about. Matched as a plain string, because the path carries a dot and an
|
|
# unanchored pattern would also claim a wrapper pointing at ~/xhermes.
|
|
[[ -f $HOME/.local/bin/hermes ]] || return 1
|
|
grep -qF "$HOME/.hermes" "$HOME/.local/bin/hermes" || return 1
|
|
|
|
# And a marker left behind by an install whose venv has since gone answers
|
|
# for nothing, so the command has to run, exactly as a foreign one must.
|
|
hermes_runs
|
|
}
|
|
|
|
# Whether Hermes is really installed, not merely whether the stub exists. A
|
|
# stub on its own is cold: running it installs Hermes, which takes minutes.
|
|
installed() {
|
|
[[ -d "$(mise where "$tool" 2>/dev/null)/hermes-agent/lib/python$python" ]]
|
|
}
|
|
|
|
# The stub is the only thing this installer owns. Anything else at that path
|
|
# -- Hermes' official installer, a hand-rolled wrapper, even a dangling link
|
|
# -- was put there by the user and is never deleted or overwritten here.
|
|
# Symlinks count as foreign even when they resolve to a marked file: the stub
|
|
# is written as a regular file, so a link is someone else's arrangement.
|
|
ours() {
|
|
[[ -f $HOME/.local/bin/hermes && ! -L $HOME/.local/bin/hermes ]] &&
|
|
grep -qxF "$marker" "$HOME/.local/bin/hermes"
|
|
}
|
|
|
|
foreign_hermes() {
|
|
[[ -e $HOME/.local/bin/hermes || -L $HOME/.local/bin/hermes ]] && ! ours
|
|
}
|
|
|
|
# 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 $HOME/.local/bin/hermes && -x $HOME/.local/bin/hermes ]] &&
|
|
timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1
|
|
}
|
|
|
|
# --owns answers whether the wrapper on PATH is the one this command wrote, so
|
|
# the migration and Remove Preinstalls do not each carry their own copy of the
|
|
# marker and drift from it.
|
|
if [[ $mode == "--owns" ]]; then
|
|
if ours; then exit 0; else exit 1; fi
|
|
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
|
|
if desktop_owns_hermes; then
|
|
if desktop_hermes_ready; then exit 0; else exit 1; fi
|
|
fi
|
|
# A foreign command is ready when it runs; a broken one is not, and since it
|
|
# is not ours to replace, nothing this installer does will make it ready.
|
|
if foreign_hermes; then
|
|
if hermes_runs; then exit 0; else exit 1; fi
|
|
fi
|
|
if installed; then exit 0; else exit 1; fi
|
|
fi
|
|
|
|
# Hand Hermes over to the app rather than keeping a second copy beside it.
|
|
if desktop_owns_hermes; then
|
|
# Not gated on that copy being healthy: `mise up` can rebuild it against the
|
|
# wrong interpreter and a half-finished install answers to neither test, and
|
|
# either way it is still a second Hermes. Removing nothing is harmless.
|
|
if mise where "$tool" >/dev/null 2>&1; then
|
|
echo "Hermes Desktop provides Hermes; removing the separate CLI install..." >&2
|
|
fi
|
|
|
|
mise rm -g "$tool" >/dev/null 2>&1 || true
|
|
mise uninstall --all "$tool" >/dev/null 2>&1 || true
|
|
|
|
# Our own stub has to go with it. Left in place it still answers `hermes`
|
|
# until the app's bootstrap overwrites it, and answering means building the
|
|
# second Hermes this whole arrangement exists to avoid.
|
|
if ours; then
|
|
rm -f "$HOME/.local/bin/hermes"
|
|
fi
|
|
|
|
if desktop_hermes_ready; then
|
|
exit 0
|
|
fi
|
|
|
|
echo "Hermes Desktop is installed but has not set Hermes up yet." >&2
|
|
echo "Launch Hermes Desktop once to finish installing it." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# The user already has a hermes of their own. Leave it be: a working one is
|
|
# what the default agent will run, and a broken one is theirs to fix.
|
|
if foreign_hermes; then
|
|
if hermes_runs; then
|
|
exit 0
|
|
fi
|
|
|
|
echo "~/.local/bin/hermes exists but is not runnable, and it was not installed by Omarchy." >&2
|
|
echo "Fix or remove it, then run omarchy-install-hermes-cli again." >&2
|
|
exit 1
|
|
fi
|
|
|
|
mkdir -p "$HOME/.local/bin"
|
|
rm -f "$HOME/.local/bin/hermes"
|
|
|
|
cat >"$HOME/.local/bin/hermes" <<EOF
|
|
#!/bin/bash
|
|
|
|
$marker
|
|
|
|
export UV_PYTHON="$python"
|
|
|
|
# Exported rather than set on the install line alone, so the version resolved
|
|
# to run agrees with the one just installed. Hermes ships several times a week
|
|
# and mise's cooldown would otherwise hold a new release back for days.
|
|
export MISE_MINIMUM_RELEASE_AGE=0
|
|
|
|
# mise up -- which omarchy update runs -- reinstalls without that pin, so this
|
|
# asks which interpreter is actually there rather than whether anything is.
|
|
if ! [[ -d "\$(mise where '$tool' 2>/dev/null)/hermes-agent/lib/python$python" ]]; then
|
|
echo "Installing Hermes on Python $python (this takes a minute)..." >&2
|
|
|
|
# mise's pipx backend shells out to uv, which a stock Omarchy does not have.
|
|
# It is fetched here rather than when this stub was written, so setting up a
|
|
# machine that never runs Hermes costs nothing.
|
|
if omarchy-cmd-missing uv && ! mise where uv >/dev/null 2>&1; then
|
|
mise use -g --quiet uv@latest || exit 1
|
|
fi
|
|
|
|
mise use -g --quiet --force '$tool' || exit 1
|
|
fi
|
|
|
|
# The pin belongs to building Hermes, not to everything Hermes then runs.
|
|
# Exported it would reach the agent and every command it shells out to, so a
|
|
# uv in the user's own project would resolve 3.13 there too -- uv only warns
|
|
# when that contradicts the project's requires-python, and builds it anyway.
|
|
exec env -u UV_PYTHON mise x '$tool' -- hermes "\$@"
|
|
EOF
|
|
|
|
chmod +x "$HOME/.local/bin/hermes"
|
|
|
|
# The desktop app resolves a hermes on PATH by running `hermes --version` with
|
|
# a 15 second budget, then falls back to cloning its own copy when that times
|
|
# out. A first-run mise install does not fit in 15 seconds, so anything that
|
|
# hands Hermes to the GUI has to install it here rather than leave it stubbed.
|
|
if [[ $mode == "--now" ]]; then
|
|
"$HOME/.local/bin/hermes" --version
|
|
fi
|