Merge pull request #12844 from omacom/hermes-desktop-default-agent
Install Hermes for the default agent as the desktop app's self-updating runtime
This commit is contained in:
16 files changed
+1648
-1245
No files matched your search
@@ -50,15 +50,15 @@ esac
|
||||
|
||||
agent_package=${agent_package:-$agent}
|
||||
|
||||
# Hermes reaches mise through its own installer rather than straight from
|
||||
# here: it needs its interpreter pinned, and a bare `mise use` has nowhere to
|
||||
# say so. See omarchy-install-hermes-cli. OpenClaw comes from its pacman
|
||||
# package the same way; see omarchy-install-openclaw-cli.
|
||||
# Hermes and OpenClaw are not mise tools: Hermes is the desktop app's
|
||||
# self-updating runtime and OpenClaw comes from its pacman package. Each has
|
||||
# its own installer with the same --check/--now contract mise-backed agents
|
||||
# get from mise; see omarchy-install-hermes-cli and omarchy-install-openclaw-cli.
|
||||
if [[ -n ${agent_installer:-} ]]; then
|
||||
# Not omarchy-cmd-present: the stub is on PATH from first boot and says
|
||||
# nothing about whether Hermes is installed behind it. Treating a cold stub
|
||||
# as installed skips the floating terminal and runs the minute-long install
|
||||
# inside the menu action instead.
|
||||
# Not omarchy-cmd-present: a command on PATH says nothing about whether the
|
||||
# install behind it finished or runs the sessions omarchy-agent starts.
|
||||
# Treating it as installed would skip the floating terminal and run a
|
||||
# minutes-long install inside the menu action instead.
|
||||
agent_present() { "$agent_installer" --check; }
|
||||
agent_install() { "$agent_installer" --now; }
|
||||
else
|
||||
@@ -78,7 +78,9 @@ if [[ $installing == "false" ]] && ! agent_present; then
|
||||
fi
|
||||
|
||||
if ! agent_install; then
|
||||
if [[ $installing == "true" ]]; then
|
||||
if [[ $installing == "true" && -n ${agent_installer:-} ]]; then
|
||||
echo "Could not install $name" >&2
|
||||
elif [[ $installing == "true" ]]; then
|
||||
echo "Could not install $name with mise" >&2
|
||||
else
|
||||
echo "Could not set $name as the default coding agent" >&2
|
||||
|
||||
@@ -10,153 +10,11 @@ if (( EUID == 0 )); then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing Hermes Desktop..."
|
||||
omarchy-pkg-add hermes-desktop
|
||||
|
||||
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
|
||||
|
||||
# If Hermes was already installed for the terminal, the app supersedes it: one
|
||||
# machine, one Hermes. This drops that copy so the terminal, the default agent
|
||||
# and the app all end up on the app's installation.
|
||||
omarchy-install-hermes-cli || true
|
||||
|
||||
# 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"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
check_main() {
|
||||
local main_commit
|
||||
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
|
||||
echo "Hermes main has local commits. Keep that work and prepare the desktop with 'hermes desktop --build-only'." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
if ! runtime_ready; 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 [[ -e $runtime || -L $runtime ]]; then
|
||||
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
|
||||
check_main
|
||||
fi
|
||||
|
||||
# Upstream replaces these commands, including foreign files and symlinks.
|
||||
# Keep their original bytes/links before handing the names to the desktop.
|
||||
command_backup=""
|
||||
for command in hermes hermes-agent hermes-acp; do
|
||||
command_path="$HOME/.local/bin/$command"
|
||||
if [[ -e $command_path || -L $command_path ]]; then
|
||||
if [[ ! -f $command_path && ! -L $command_path ]]; then
|
||||
echo "Cannot replace $command_path: move it aside before installing Hermes Desktop." >&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 -- "$command_path" "$command_backup/"
|
||||
fi
|
||||
done
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
runtime_commit=$(git -C "$runtime" rev-parse HEAD)
|
||||
if [[ $runtime_commit == "$release_commit" ]]; then
|
||||
# The updater switches to main before checking for changes. Start main at
|
||||
# the packaged release, with enough history for its first fast-forward.
|
||||
check_main
|
||||
if [[ $(git -C "$runtime" rev-parse --is-shallow-repository) == "true" ]]; then
|
||||
git -C "$runtime" fetch --unshallow origin main
|
||||
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 [[ ! -f $native_app/Hermes || ! -x $native_app/Hermes || ! -f $native_app/resources/app.asar || ! -f $native_app/resources/install-stamp.json ]]; 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
|
||||
fi
|
||||
# The same install the default agent gets, plus opening the window.
|
||||
omarchy-install-hermes-cli --now
|
||||
|
||||
echo "Opening Hermes Desktop..."
|
||||
setsid uwsm-app -- /usr/bin/hermes-desktop >/dev/null 2>&1 &
|
||||
|
||||
# Only a running Hermes can be told which skin to show; a unit outlives this
|
||||
# terminal and reports to the journal.
|
||||
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
|
||||
|
||||
echo ""
|
||||
echo "Hermes Desktop has been installed."
|
||||
+483
-219
@@ -1,280 +1,544 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Install the Hermes CLI as a mise-backed wrapper in ~/.local/bin
|
||||
# omarchy:args=[--check|--now|--owns|--remove]
|
||||
# omarchy:examples=omarchy install hermes cli | omarchy install hermes cli --now
|
||||
# omarchy:summary=Install Hermes Desktop for the default agent; its runtime provides the hermes command
|
||||
# omarchy:args=<--check|--now|--retire-mise>
|
||||
# omarchy:examples=omarchy install hermes cli --now | omarchy install hermes cli --check
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
# Hermes is only ever installed through the app: --check asks whether the
|
||||
# app's Hermes is in and its command is what omarchy-agent would run, and
|
||||
# --now installs the app whatever else answered to hermes before, saving
|
||||
# whatever held the command's name aside before upstream's installer takes it.
|
||||
#
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
# 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:-}
|
||||
|
||||
tool='pipx:hermes-agent[extras=all]'
|
||||
python='3.13'
|
||||
# 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
|
||||
|
||||
# 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.'
|
||||
runtime="$HERMES_HOME/hermes-agent"
|
||||
native_app="$runtime/apps/desktop/release/linux-unpacked"
|
||||
command_path="$HOME/.local/bin/hermes"
|
||||
|
||||
# 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 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"
|
||||
}
|
||||
|
||||
# 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_prompt_ready
|
||||
# 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"
|
||||
}
|
||||
|
||||
# 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" ]]
|
||||
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
|
||||
}
|
||||
|
||||
# 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"
|
||||
# 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 -qE '"pipx:hermes-agent(\[|")' <<<"$requested"
|
||||
}
|
||||
|
||||
foreign_hermes() {
|
||||
[[ -e $HOME/.local/bin/hermes || -L $HOME/.local/bin/hermes ]] && ! ours
|
||||
# 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.
|
||||
# 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
|
||||
[[ -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:
|
||||
# what follows must be a shape argparse prints after a definition -- the usage
|
||||
# line's closing bracket, the gap before same-line help text, an uppercase
|
||||
# metavar, or the end of the line. Prose like "With --tui: run ..." stays
|
||||
# prose, and --tui-theme or --tui_mode never answers for --tui. Not probed by
|
||||
# parsing an actual invocation on purpose: a release that ignores unknown
|
||||
# arguments would turn the probe into a live session.
|
||||
# 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: the old probe keyed on chat carrying --oneshot, which no released
|
||||
# Hermes did (it lived at the top level until v0.21 added chat's own), so
|
||||
# every release read as "not ready".
|
||||
# for them.
|
||||
hermes_prompt_ready() {
|
||||
local help
|
||||
hermes_runs &&
|
||||
help=$(timeout 15 "$HOME/.local/bin/hermes" chat --help 2>/dev/null) &&
|
||||
help=$(timeout 15 "$command_path" chat --help 2>/dev/null) &&
|
||||
help_defines_flag '--tui' "$help" &&
|
||||
help_defines_flag '--query' "$help"
|
||||
}
|
||||
|
||||
# --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
|
||||
# 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"
|
||||
}
|
||||
|
||||
# --remove tears down a Hermes CLI this installer put in place -- the mise tool
|
||||
# it installed and the stub it marked -- so Remove Hermes clears a CLI the app
|
||||
# never superseded (an interrupted install, or the terminal CLI from before the
|
||||
# app existed) rather than leaving it stranded on PATH. The whole teardown
|
||||
# turns on the marked stub, exactly as replacement does further down: without
|
||||
# it nothing proves the mise environment is Omarchy's rather than one the user
|
||||
# built against the same spec, and a user's stays theirs. The desktop takeover
|
||||
# removes the environment without asking, but that is its own bargain -- a
|
||||
# second Hermes has to go whoever built it, and the app still provides the
|
||||
# command afterwards; here nothing would. Idempotent: nothing owned, nothing
|
||||
# to do.
|
||||
if [[ $mode == "--remove" ]]; then
|
||||
if ours; then
|
||||
mise rm -g "$tool" >/dev/null 2>&1 || true
|
||||
mise uninstall --all "$tool" >/dev/null 2>&1 || true
|
||||
rm -f "$HOME/.local/bin/hermes" 2>/dev/null || true
|
||||
# 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")" ]]
|
||||
}
|
||||
|
||||
# Every step is attempted before any is judged, and judged by what is left
|
||||
# rather than by what the commands claimed: the marked stub still answering
|
||||
# hermes, or mise still resolving the tool, is a CLI still installed no
|
||||
# matter how the removal exited.
|
||||
# Not "run --remove again": once the stub is gone nothing marks the mise
|
||||
# environment as ours, so a rerun would find nothing it owns and succeed
|
||||
# without touching what was left. Only the full commands finish the job.
|
||||
if ours || mise where "$tool" >/dev/null 2>&1; then
|
||||
echo "Could not remove the Hermes CLI Omarchy installed. Finish by hand:" >&2
|
||||
echo " rm -f ~/.local/bin/hermes" >&2
|
||||
echo " mise rm -g '$tool'" >&2
|
||||
echo " mise uninstall --all '$tool'" >&2
|
||||
exit 1
|
||||
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: the app
|
||||
# is installed and the Hermes on the machine is 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 -- and that command is the one PATH
|
||||
# finds, with 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
|
||||
omarchy-pkg-present hermes-desktop && ours && [[ -f $runtime/.hermes-bootstrap-complete ]] && native_app_complete && hermes_prompt_ready
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
# --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 only when it also supports prompted sessions;
|
||||
# since it is not ours to replace, nothing this installer does will update it.
|
||||
if foreign_hermes; then
|
||||
if hermes_prompt_ready; then exit 0; else exit 1; fi
|
||||
fi
|
||||
if installed && hermes_prompt_ready; then exit 0; else exit 1; fi
|
||||
if ! omarchy-pkg-present hermes-desktop; then
|
||||
echo "Installing Hermes Desktop..."
|
||||
omarchy-pkg-add hermes-desktop
|
||||
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
|
||||
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
|
||||
|
||||
# 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_prompt_ready; then
|
||||
exit 0
|
||||
fi
|
||||
runtime_present=false
|
||||
if runtime_ready; then
|
||||
runtime_present=true
|
||||
fi
|
||||
|
||||
if hermes_runs; then
|
||||
echo "~/.local/bin/hermes 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 again." >&2
|
||||
# A seeded app has to be whole, whatever state the runtime beside it is in.
|
||||
refuse_unless_app_whole() {
|
||||
if [[ -e $native_app || -L $native_app ]] && ! 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
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# The packaged app can be seeded only beside a runtime at the release it was
|
||||
# built from, with its desktop sources untouched and the Linux runtime patch
|
||||
# still able to land. Asked before a command is replaced, so that a refusal
|
||||
# leaves the user's Hermes exactly as it was, and again before seeding.
|
||||
# Asked through ||, so nothing in here stops on its own: every git answer is
|
||||
# checked, and one that cannot be read refuses rather than reading as clean.
|
||||
refuse_unless_seedable() {
|
||||
local runtime_commit changes
|
||||
refuse_unless_app_whole || return 1
|
||||
if [[ -e $native_app || -L $native_app ]]; then
|
||||
return 0
|
||||
fi
|
||||
if ! runtime_commit=$(git -C "$runtime" rev-parse HEAD 2>/dev/null) || [[ -z $runtime_commit ]]; then
|
||||
echo "Could not read the Hermes runtime's git state at $runtime. Repair that installation before trying again; existing files have been kept." >&2
|
||||
return 1
|
||||
fi
|
||||
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
|
||||
return 1
|
||||
fi
|
||||
if ! changes=$(git -C "$runtime" status --porcelain --untracked-files=all -- apps/desktop package.json package-lock.json 2>/dev/null); then
|
||||
echo "Could not read the Hermes runtime's git state at $runtime. Repair that installation before trying again; existing files have been kept." >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -n $changes ]]; then
|
||||
echo "Hermes desktop sources have local changes. Run 'hermes desktop --build-only', then try again; existing files have been kept." >&2
|
||||
return 1
|
||||
fi
|
||||
if ! git -C "$runtime" apply --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1 &&
|
||||
! 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
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ $runtime_present == "true" ]]; then
|
||||
refuse_unless_seedable || exit 1
|
||||
elif [[ -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,
|
||||
# and a git state that cannot be read is not a clean one.
|
||||
refuse_unless_app_whole || exit 1
|
||||
if ! runtime_commit=$(git -C "$runtime" rev-parse HEAD 2>/dev/null) || [[ $runtime_commit != "$release_commit" ]] ||
|
||||
! changes=$(git -C "$runtime" status --porcelain --untracked-files=all 2>/dev/null) || [[ -n $changes ]]; then
|
||||
echo "Hermes setup is incomplete at $runtime. Repair that installation before trying again; existing files have been kept." >&2
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
# 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
|
||||
|
||||
# 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..."
|
||||
# npx stops to ask before fetching Playwright for the browser tools, and in
|
||||
# the terminal the menu opens for this nobody is there to say yes.
|
||||
npm_config_yes=true 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
|
||||
|
||||
refuse_unless_seedable || exit 1
|
||||
if [[ ! -e $native_app && ! -L $native_app ]]; then
|
||||
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
|
||||
|
||||
# Only the marked wrapper proves the matching mise environment is ours to replace.
|
||||
if installed && ! hermes_prompt_ready; then
|
||||
if ours; then
|
||||
echo "Updating Hermes for prompted sessions..." >&2
|
||||
mise rm -g "$tool" >/dev/null 2>&1 || true
|
||||
mise uninstall --all "$tool" >/dev/null 2>&1 || true
|
||||
else
|
||||
echo "A Hermes mise environment exists without an Omarchy-owned wrapper." >&2
|
||||
echo "Update or remove it explicitly, then run omarchy-install-hermes-cli again." >&2
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
if ! hermes_prompt_ready; then
|
||||
echo "Hermes installed without the interactive seeded sessions Omarchy needs." >&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
|
||||
+148
-54
@@ -6,73 +6,173 @@
|
||||
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
|
||||
set -euo pipefail
|
||||
|
||||
ensure_hermes_stopped() {
|
||||
python3 - "$HOME" <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
|
||||
home = Path(sys.argv[1])
|
||||
# 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 belongs_to_hermes(target):
|
||||
def under(target, directories):
|
||||
target = target.removesuffix(' (deleted)')
|
||||
return any(target == root or target.startswith(root + '/') for root in roots)
|
||||
return any(target == directory or target.startswith(directory + '/') for directory in directories)
|
||||
|
||||
holders = []
|
||||
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():
|
||||
def processes():
|
||||
found = []
|
||||
for process in Path('/proc').iterdir():
|
||||
if not process.name.isdigit() or int(process.name) == os.getpid():
|
||||
continue
|
||||
targets = [os.fsdecode(arg) for arg in (process / 'cmdline').read_bytes().split(b'\0')]
|
||||
entries = [process / 'exe', process / 'cwd']
|
||||
try:
|
||||
entries.extend((process / 'fd').iterdir())
|
||||
except PermissionError:
|
||||
pass
|
||||
for entry in entries:
|
||||
if process.stat().st_uid != os.getuid():
|
||||
continue
|
||||
argv = [os.fsdecode(arg) for arg in (process / 'cmdline').read_bytes().split(b'\0')]
|
||||
program = []
|
||||
try:
|
||||
targets.append(os.readlink(entry))
|
||||
program.append(os.readlink(process / 'exe'))
|
||||
except OSError:
|
||||
pass
|
||||
if any(belongs_to_hermes(target) for target in targets):
|
||||
holders.append(process.name)
|
||||
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
||||
continue
|
||||
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
|
||||
|
||||
if holders:
|
||||
print('Close Hermes and processes using its files before removing it (PIDs: '
|
||||
+ ', '.join(holders) + '). Then try again.', file=sys.stderr)
|
||||
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
|
||||
}
|
||||
|
||||
# Removing an open SQLite WAL leaves a live writer on a deleted generation.
|
||||
ensure_hermes_stopped
|
||||
omarchy-pkg-drop hermes-desktop
|
||||
hermes_holders check
|
||||
|
||||
# The installer leaves a unit waiting to hand the app the Omarchy theme.
|
||||
# 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
|
||||
|
||||
# The mise CLI is the app's predecessor, not the app itself: Hermes Desktop takes
|
||||
# it over on install and runs its own runtime instead, so a copy still here is one
|
||||
# the app never superseded -- an interrupted install, or the terminal CLI from
|
||||
# before the app existed. Remove Hermes clears that too, scoped by the installer
|
||||
# to what Omarchy owns so a hermes the user set up themselves is left alone.
|
||||
# Tolerated here rather than fatal, so the ~/.hermes handling below still runs;
|
||||
# the failure is answered for at the end instead of being swallowed.
|
||||
cli_removed=true
|
||||
ensure_hermes_stopped
|
||||
omarchy-install-hermes-cli --remove || cli_removed=false
|
||||
hermes_holders stop
|
||||
omarchy-pkg-drop hermes-desktop
|
||||
|
||||
# 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 unasked.
|
||||
# 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
|
||||
@@ -125,7 +225,7 @@ if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]] && omarchy-cm
|
||||
# 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
|
||||
ensure_hermes_stopped
|
||||
hermes_holders stop
|
||||
rm -rf "$HOME/.hermes" "$HOME/.config/Hermes"
|
||||
data_removed=true
|
||||
fi
|
||||
@@ -139,9 +239,3 @@ 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
|
||||
|
||||
# The messages above still hold -- the app and its runtime are gone -- but a CLI
|
||||
# teardown that failed already said so on stderr, and that stands.
|
||||
if [[ $cli_removed == "false" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -30,14 +30,6 @@ if gum confirm "Are you sure you want to remove all preinstalled web apps, TUI w
|
||||
rm -f ~/.local/bin/muse
|
||||
fi
|
||||
|
||||
# Only the wrapper omarchy-install-hermes-cli wrote is a preinstall. Hermes
|
||||
# Desktop's command, an official install, or anything else at that path is
|
||||
# the user's, so it is the installer that decides whether the wrapper is its
|
||||
# own, rather than a copy of its marker kept here.
|
||||
if omarchy-install-hermes-cli --owns; then
|
||||
rm -f ~/.local/bin/hermes
|
||||
fi
|
||||
|
||||
omarchy-pkg-drop \
|
||||
aether \
|
||||
cliamp \
|
||||
|
||||
@@ -193,7 +193,8 @@ if (( HERMES_ACTIVATE == 0 )); then
|
||||
esac
|
||||
fi
|
||||
|
||||
# Omarchy's cold stub installs Hermes when run, so ask the probe before running it.
|
||||
# The probe vets the command before anything here runs it: a wrapper that is
|
||||
# not a working Hermes, or the retired mise stub that would build one.
|
||||
if ! omarchy-install-hermes-cli --check 2>/dev/null; then
|
||||
note "Hermes is not ready, so the Omarchy skin is published but not active."
|
||||
note "Once Hermes runs, activate it with: hermes config set display.skin $HERMES_SKIN_NAME"
|
||||
|
||||
Reference in new issue
Block a user