#!/bin/bash # 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 # 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. # # 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 -qE '"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" } # 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: 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 if ! omarchy-pkg-present hermes-desktop; then echo "Installing Hermes Desktop..." 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 # 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 # 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 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