Merge pull request #7469 from omacom/hermes-agent
Add Hermes as a desktop app and a coding agent
This commit is contained in:
@@ -86,6 +86,13 @@ codex)
|
||||
command=(codex --approve-for-me)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
hermes)
|
||||
if [[ -n ${prompt:-} ]]; then
|
||||
command=(env -u HERMES_SESSION_SOURCE hermes chat --yolo --tui "--query=$prompt")
|
||||
else
|
||||
command=(hermes --yolo)
|
||||
fi
|
||||
;;
|
||||
omp)
|
||||
command=(omp --auto-approve)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Set and launch the default coding agent
|
||||
# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|agy|copilot|crush]
|
||||
# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|agy|hermes|copilot|crush]
|
||||
# omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude
|
||||
|
||||
installing=false
|
||||
@@ -33,20 +33,36 @@ codex) agent="codex"; name="Codex" ;;
|
||||
crush) agent="crush"; name="Crush" ;;
|
||||
grok) agent="grok"; name="Grok"; agent_package="npm:@xai-official/grok" ;;
|
||||
agy | antigravity | antigravity-cli | gemini | gemini-cli) agent="agy"; name="Antigravity"; agent_package="antigravity-cli" ;;
|
||||
hermes) agent="hermes"; name="Hermes"; agent_installer="omarchy-install-hermes-cli" ;;
|
||||
copilot | github-copilot) agent="copilot"; name="GitHub Copilot" ;;
|
||||
*)
|
||||
echo "Usage: omarchy-default-agent <pi|omp|opencode|ori|claude|codex|grok|agy|copilot|crush>"
|
||||
echo "Usage: omarchy-default-agent <pi|omp|opencode|ori|claude|codex|grok|agy|hermes|copilot|crush>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
agent_package=${agent_package:-$agent}
|
||||
|
||||
if [[ $installing == "false" ]] && ! mise where "$agent_package" &>/dev/null; then
|
||||
# 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.
|
||||
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.
|
||||
agent_present() { "$agent_installer" --check; }
|
||||
agent_install() { "$agent_installer" --now; }
|
||||
else
|
||||
agent_present() { mise where "$agent_package" &>/dev/null; }
|
||||
agent_install() { mise use -g "$agent_package"; }
|
||||
fi
|
||||
|
||||
if [[ $installing == "false" ]] && ! agent_present; then
|
||||
exec omarchy-launch-floating-terminal-with-presentation omarchy-default-agent --install "$agent"
|
||||
fi
|
||||
|
||||
if ! mise use -g "$agent_package"; then
|
||||
if ! agent_install; then
|
||||
if [[ $installing == "true" ]]; then
|
||||
echo "Could not install $name with mise" >&2
|
||||
else
|
||||
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Install the Hermes desktop app
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
set -e
|
||||
|
||||
# No CLI is installed here on purpose. Hermes Desktop only runs against a
|
||||
# runtime built from its own commit, so it provisions one itself under
|
||||
# ~/.hermes on first launch, which takes a few minutes and shows its own
|
||||
# progress. Handing it the mise CLI instead fails: PyPI trails the tags, and
|
||||
# the version gap fails the app's readiness probe with a 401.
|
||||
echo "Installing Hermes Desktop..."
|
||||
omarchy-pkg-add hermes-desktop
|
||||
|
||||
# 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
|
||||
|
||||
echo "Opening Hermes Desktop..."
|
||||
setsid uwsm-app -- /usr/bin/hermes-desktop >/dev/null 2>&1 &
|
||||
|
||||
echo ""
|
||||
echo "Hermes Desktop has been installed."
|
||||
echo "Its first launch installs the Hermes runtime, which takes a few minutes."
|
||||
Executable
+229
@@ -0,0 +1,229 @@
|
||||
#!/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_prompt_ready
|
||||
}
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
# The chat subcommand's --oneshot opt-out arrived with native interactive -q,
|
||||
# so its presence is a stable capability check without relying on a version.
|
||||
hermes_prompt_ready() {
|
||||
local help
|
||||
hermes_runs &&
|
||||
help=$(timeout 15 "$HOME/.local/bin/hermes" chat --help 2>/dev/null) &&
|
||||
grep -qF -- '--oneshot' <<<"$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
|
||||
|
||||
# --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
|
||||
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_prompt_ready; then
|
||||
exit 0
|
||||
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
|
||||
exit 1
|
||||
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
|
||||
|
||||
# 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
|
||||
fi
|
||||
@@ -84,7 +84,7 @@ fi
|
||||
# Dev-aware skill symlinks. Cannot live in /etc/skel because OMARCHY_PATH may
|
||||
# point at a dev checkout (omarchy dev link) where the target differs.
|
||||
# Loops every skill directory, so shipping a new one needs no edit here.
|
||||
mkdir -p ~/.agents/skills ~/.claude/skills ~/.codex/skills ~/.pi/agent/skills ~/.gemini/config/skills
|
||||
mkdir -p ~/.agents/skills ~/.claude/skills ~/.codex/skills ~/.pi/agent/skills ~/.gemini/config/skills ~/.hermes/skills
|
||||
for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do
|
||||
skill=${skill%/}
|
||||
name=${skill##*/}
|
||||
@@ -93,6 +93,14 @@ for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do
|
||||
ln -sfn "$skill" ~/.codex/skills/"$name"
|
||||
ln -sfn "$skill" ~/.pi/agent/skills/"$name"
|
||||
ln -sfn "$skill" ~/.gemini/config/skills/"$name"
|
||||
ln -sfn "$skill" ~/.hermes/skills/"$name"
|
||||
if [[ -d ~/.hermes/profiles ]]; then
|
||||
for profile in ~/.hermes/profiles/*/; do
|
||||
[[ -d $profile ]] || continue
|
||||
mkdir -p "$profile/skills"
|
||||
ln -sfn "$skill" "$profile/skills/$name"
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p ~/Downloads ~/Pictures ~/Videos ~/.config/gtk-3.0
|
||||
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Remove the Hermes desktop app along with the Hermes runtime it installed.
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
|
||||
set -euo pipefail
|
||||
|
||||
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.
|
||||
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. Not ~/.config/Hermes, which holds the gateway connections
|
||||
# and their encrypted tokens, the active profile and the update settings. Not
|
||||
# the rest of ~/.hermes either: the chats, memories and the skills Hermes
|
||||
# wrote for itself are the user's, they are small, and finding them still
|
||||
# there after a reinstall is the better surprise.
|
||||
rm -rf \
|
||||
"$HOME/.hermes/hermes-agent" \
|
||||
"$HOME/.hermes/bootstrap-cache" \
|
||||
"$HOME/.hermes/bin" \
|
||||
"$HOME/.hermes/node"
|
||||
|
||||
# Only the wrappers pointing into ~/.hermes, matched as a plain string: the
|
||||
# path carries a dot, so an unanchored pattern would also claim a wrapper
|
||||
# pointing at a sibling like ~/xhermes.
|
||||
for command in hermes hermes-agent hermes-acp; do
|
||||
wrapper="$HOME/.local/bin/$command"
|
||||
|
||||
if [[ -f $wrapper && ! -L $wrapper ]] && grep -qF "$HOME/.hermes" "$wrapper"; then
|
||||
rm -f "$wrapper"
|
||||
fi
|
||||
done
|
||||
|
||||
# When Hermes brought its own Node it symlinked these next to its own commands,
|
||||
# and they point at what we just deleted. Only the links into ~/.hermes: a
|
||||
# system Node, or someone else's, lives somewhere else entirely.
|
||||
for command in node npm npx; do
|
||||
link="$HOME/.local/bin/$command"
|
||||
|
||||
if [[ -L $link && $(readlink "$link") == "$HOME/.hermes"/* ]]; then
|
||||
rm -f "$link"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Hermes Desktop has been removed."
|
||||
echo "Your chats, memories, and skills are still in ~/.hermes,"
|
||||
echo "and your connections and settings in ~/.config/Hermes."
|
||||
else
|
||||
echo ""
|
||||
echo "Hermes Desktop has been removed."
|
||||
echo "It never finished installing its own Hermes, so nothing in ~/.hermes was touched."
|
||||
fi
|
||||
@@ -17,6 +17,14 @@ if gum confirm "Are you sure you want to remove all preinstalled web apps, TUI w
|
||||
~/.local/bin/gh ~/.local/bin/opencode ~/.local/bin/playwright ~/.local/bin/playwright-cli ~/.local/bin/pi \
|
||||
~/.local/bin/omp ~/.local/bin/ori ~/.local/bin/grok ~/.local/bin/crush ~/.local/bin/ghui ~/.local/bin/hunk
|
||||
|
||||
# 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 \
|
||||
|
||||
@@ -12,6 +12,7 @@ The private-use glyphs in `omarchy.ttf` are:
|
||||
- `U+E907` — Ollama, from <https://simpleicons.org/icons/ollama.svg>
|
||||
- `U+E908` — T3 Code, traced from the app icon in <https://aur.archlinux.org/cgit/aur.git/plain/t3code-icon.png?h=t3code-bin>, since upstream publishes no monochrome SVG
|
||||
- `U+E909` — Ori, from <https://openrouter.ai/brand/v2/openrouter-glyph-dark.svg>, OpenRouter's own mark: Ori ships no separate logo and its product page uses this one
|
||||
- `U+E90A` — Hermes, Font Awesome's staff-snake (CC BY 4.0) from <https://fontawesome.com/icons/staff-snake>, the mark Hermes serves as its favicon: their app icon is a portrait that reads as a smudge at menu size
|
||||
|
||||
The agent marks are monochrome so the menu can render them using the active
|
||||
theme's foreground and selection colors.
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
-- Hermes Desktop's frameless HUD manages its own geometry.
|
||||
o.window({ class = "^Hermes$", title = "^Hermes HUD$" }, {
|
||||
tag = "-default-opacity",
|
||||
float = true,
|
||||
border_size = 0,
|
||||
opacity = "1 1",
|
||||
})
|
||||
@@ -141,6 +141,7 @@
|
||||
"setup.default.agent.copilot": {"icon":"","label":"Copilot","checked":"[[ \"$(omarchy-default-agent)\" == \"copilot\" ]]","action":"omarchy-default-agent copilot"},
|
||||
"setup.default.agent.crush": {"icon":"","label":"Crush","checked":"[[ \"$(omarchy-default-agent)\" == \"crush\" ]]","action":"omarchy-default-agent crush"},
|
||||
"setup.default.agent.grok": {"icon":"","iconFont":"omarchy","label":"Grok","checked":"[[ \"$(omarchy-default-agent)\" == \"grok\" ]]","action":"omarchy-default-agent grok"},
|
||||
"setup.default.agent.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes","checked":"[[ \"$(omarchy-default-agent)\" == \"hermes\" ]]","action":"omarchy-default-agent hermes"},
|
||||
"setup.default.agent.omp": {"icon":"","iconFont":"omarchy","label":"omp","checked":"[[ \"$(omarchy-default-agent)\" == \"omp\" ]]","action":"omarchy-default-agent omp"},
|
||||
"setup.default.agent.opencode": {"icon":"","iconFont":"omarchy","label":"OpenCode","checked":"[[ \"$(omarchy-default-agent)\" == \"opencode\" ]]","action":"omarchy-default-agent opencode"},
|
||||
"setup.default.agent.ori": {"icon":"","iconFont":"omarchy","label":"Ori","checked":"[[ \"$(omarchy-default-agent)\" == \"ori\" ]]","action":"omarchy-default-agent ori"},
|
||||
@@ -239,6 +240,7 @@
|
||||
"install.ai.chatgpt": {"icon":"","iconFont":"omarchy","label":"ChatGPT Desktop","disabled":"omarchy-pkg-present openai-codex-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-chatgpt"},
|
||||
"install.ai.dictation": {"icon":"","label":"Dictation","disabled":"omarchy-pkg-present voxtype-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install"},
|
||||
"install.ai.grok-bot": {"icon":"","iconFont":"omarchy","label":"Grok Bot","disabled":"omarchy-pkg-present grok-bot","action":"omarchy-install-and-launch 'Grok Bot' grok-bot grok-bot"},
|
||||
"install.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","disabled":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-hermes"},
|
||||
"install.ai.lm-studio": {"icon":"","iconFont":"omarchy","label":"LM Studio","disabled":"omarchy-pkg-present lmstudio-bin","action":"omarchy-install-app 'LM Studio' lmstudio-bin"},
|
||||
"install.ai.ollama": {"icon":"","iconFont":"omarchy","label":"Ollama","disabled":"omarchy-cmd-present ollama","action":"if omarchy-cmd-present nvidia-smi; then ollama_pkg=ollama-cuda; elif omarchy-cmd-present rocminfo; then ollama_pkg=ollama-rocm; else ollama_pkg=ollama; fi; omarchy-install-app Ollama \"$ollama_pkg\""},
|
||||
"install.ai.t3-code": {"icon":"","iconFont":"omarchy","label":"T3 Code","disabled":"omarchy-pkg-present t3code-bin","action":"omarchy-install-and-launch 'T3 Code' t3code-bin t3code"},
|
||||
@@ -292,6 +294,7 @@
|
||||
"remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"},
|
||||
"remove.security.sshd": {"icon":"","label":"SSHD","when":"systemctl is-enabled --quiet sshd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sshd"},
|
||||
"remove.security.sudoless-docker": {"icon":"","label":"Sudoless Docker","when":"! omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"},
|
||||
"remove.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","when":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-hermes"},
|
||||
"remove.browser.chrome": {"icon":"","label":"Chrome","when":"omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser chrome'"},
|
||||
"remove.browser.edge": {"icon":"","label":"Edge","when":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser edge'"},
|
||||
"remove.browser.brave": {"icon":"","label":"Brave","when":"omarchy-pkg-present brave-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser brave'"},
|
||||
|
||||
+1
-5
@@ -198,11 +198,7 @@ Runs once per user. It does **not** copy `~/.config/**`, `~/.bashrc`,
|
||||
`flags.lua`, or the nautilus extensions — `/etc/skel` already seeded those.
|
||||
It only does the things `/etc/skel` can't:
|
||||
|
||||
- Skill symlinks `~/.{agents,claude,codex,pi/agent}/skills/<name>` →
|
||||
`$OMARCHY_PATH/default/agents/skills/<name>`, looping over every skill
|
||||
directory there (currently `omarchy` and `diagnose-crash`) so new skills
|
||||
need no edit. Symlinks (not copies) so `omarchy dev link` against a dev
|
||||
checkout repoints them correctly.
|
||||
- Skill symlinks into `~/.agents/skills/<name>`, `~/.claude/skills/<name>`, `~/.codex/skills/<name>`, `~/.pi/agent/skills/<name>`, `~/.gemini/config/skills/<name>` (Antigravity), `~/.hermes/skills/<name>`, and each existing `~/.hermes/profiles/*/skills/<name>` → `$OMARCHY_PATH/default/agents/skills/<name>`, looping over every skill directory there (currently `omarchy` and `diagnose-crash`) so new skills need no edit. Symlinks (not copies) so `omarchy dev link` against a dev checkout repoints them correctly. Hermes profile dirs are only linked when they already exist — provision does not create Hermes profiles.
|
||||
- `xdg-user-dirs-update` (Templates/Public/Desktop folded back into `$HOME`)
|
||||
and `~/.config/gtk-3.0/bookmarks` (needs `$HOME` expansion).
|
||||
- Hyprland's package-owned default input reads `XKBLAYOUT` / `XKBVARIANT`
|
||||
|
||||
@@ -13,3 +13,9 @@ omarchy-mise-install npm:@kitlangton/ghui ghui
|
||||
omarchy-mise-install aqua:modem-dev/hunk hunk
|
||||
omarchy-mise-install github:basecamp/hey-cli hey
|
||||
omarchy-mise-install github:OpenRouterLabs/ori-releases ori
|
||||
# Every line above writes a stub and cannot fail. This one can: it exits
|
||||
# non-zero when Hermes Desktop owns Hermes but has not finished setting it up,
|
||||
# and this leaf is sourced under `bash -eE`, so that would abort the rest of
|
||||
# omarchy-provision-user -- the default browser, the mailto handler and the
|
||||
# finalize-user marker all come after it.
|
||||
omarchy-install-hermes-cli || true
|
||||
|
||||
+5
-2
@@ -14,6 +14,7 @@ Omarchy treats AI coding agents as first-class citizens, but it doesn't pick a f
|
||||
| `pi` | [Mario Zechner's Pi](https://github.com/badlogic/pi-mono) |
|
||||
| `omp` | [Oh My Pi](https://github.com/can1357/oh-my-pi) |
|
||||
| `ori` | [Ori](https://openrouter.ai/docs/guides/ori/harness), OpenRouter's harness |
|
||||
| `hermes` | [Hermes](https://hermes-agent.nousresearch.com/), Nous Research's agent |
|
||||
|
||||
`ori` is the odd one out: it runs the other harnesses against OpenRouter's whole model catalog, so `ori claude`, `ori codex`, or `ori opencode` start those agents on whichever model you point them at, and `ori code` is Ori's own agent.
|
||||
|
||||
@@ -43,7 +44,9 @@ Crashes can also be silenced one program at a time, which is what the diagnosis
|
||||
|
||||
### Desktop apps
|
||||
|
||||
The _Install > AI_ menu also carries a couple of graphical AI apps: the ChatGPT desktop app, and Grok Bot for chatting with xAI's models.
|
||||
The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, Grok Bot for chatting with xAI's models, and Hermes Desktop.
|
||||
|
||||
Hermes Desktop is the one to know about, because there is only ever one Hermes on a machine. The app only runs against a runtime built from its own commit, so it installs one of its own under `~/.hermes` on first launch, which takes a few minutes and shows its own progress. From then on that is the Hermes the terminal `hermes` command and the default agent use too, whichever order you installed them in. Removing the app under _Remove > AI_ takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself.
|
||||
|
||||
### Local LLMs
|
||||
|
||||
@@ -51,6 +54,6 @@ Omarchy recommends two ways of running local LLM models: LM Studio and Ollama. L
|
||||
|
||||
### The Omarchy Skill
|
||||
|
||||
Agent skills help AI use specific tools in a specific way, and Omarchy ships with a default skill for tailoring the system. Like tweaking your Hyprland config, adjusting the bar, or even creating a new theme from scratch. It's symlinked into the skill directories for Claude Code (`~/.claude/skills`), Codex (`~/.codex/skills`), Pi (`~/.pi/agent/skills`), Antigravity (`~/.gemini/config/skills`), and the generic `~/.agents/skills` location, so most harnesses pick it up automatically.
|
||||
Agent skills help AI use specific tools in a specific way, and Omarchy ships with a default skill for tailoring the system. Like tweaking your Hyprland config, adjusting the bar, or even creating a new theme from scratch. It's symlinked into the skill directories for Claude Code (`~/.claude/skills`), Codex (`~/.codex/skills`), Pi (`~/.pi/agent/skills`), Antigravity (`~/.gemini/config/skills`), Hermes (`~/.hermes/skills` and each `~/.hermes/profiles/*/skills`), and the generic `~/.agents/skills` location, so most harnesses pick it up automatically.
|
||||
|
||||
But you should treat this skill as experimental. Different models will use it to different effect. It's best to run in plan mode first, so you have an idea of what the agent would like to change. And then be ready to rollback changes or even invoking `omarchy reinstall configs`, if the agent makes a mess of everything.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
echo "Install the Hermes CLI wrapper for existing installs"
|
||||
|
||||
# Users who removed the preinstalls opted out of the mise wrappers, and Hermes
|
||||
# is one of them.
|
||||
[[ -f $HOME/.local/state/omarchy/preinstalls-removed ]] && exit 0
|
||||
|
||||
# Hermes Desktop provides its own Hermes. The installer stands aside for it,
|
||||
# removing the mise copy and the Omarchy wrapper an earlier install may have
|
||||
# left beside the app. It also reports when the app has not finished setting
|
||||
# Hermes up, which is the app's to finish, not this migration's to fail on.
|
||||
if omarchy-pkg-present hermes-desktop; then
|
||||
omarchy-install-hermes-cli || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Anything already answering to hermes that this installer did not write --
|
||||
# an official install, a hand-rolled wrapper, even a dangling link -- belongs to
|
||||
# the user and stays exactly as it is. The installer is asked rather than
|
||||
# matched against here, so there is one answer to who owns that wrapper.
|
||||
wrapper="$HOME/.local/bin/hermes"
|
||||
if [[ -e $wrapper || -L $wrapper ]] && ! omarchy-install-hermes-cli --owns; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
omarchy-install-hermes-cli
|
||||
@@ -0,0 +1,22 @@
|
||||
echo "Link Omarchy agent skills into Hermes skill directories"
|
||||
|
||||
OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}"
|
||||
skills_source="$OMARCHY_PATH/default/agents/skills"
|
||||
|
||||
[[ -d $skills_source ]] || exit 0
|
||||
|
||||
mkdir -p "$HOME/.hermes/skills"
|
||||
|
||||
for skill in "$skills_source"/*/; do
|
||||
[[ -d $skill ]] || continue
|
||||
name=${skill%/}
|
||||
name=${name##*/}
|
||||
ln -sfn "$skills_source/$name" "$HOME/.hermes/skills/$name"
|
||||
if [[ -d $HOME/.hermes/profiles ]]; then
|
||||
for profile in "$HOME"/.hermes/profiles/*/; do
|
||||
[[ -d $profile ]] || continue
|
||||
mkdir -p "$profile/skills"
|
||||
ln -sfn "$skills_source/$name" "$profile/skills/$name"
|
||||
done
|
||||
fi
|
||||
done
|
||||
@@ -431,8 +431,10 @@ assert_launched() {
|
||||
fail "$agent launch $description" "expected: ${expected[*]}\nactual: ${actual[*]}"
|
||||
|
||||
for ((index = 0; index < ${#expected[@]}; index++)); do
|
||||
[[ ${actual[$index]} == ${expected[$index]} ]] ||
|
||||
fail "$agent launch $description" "expected: ${expected[*]}\nactual: ${actual[*]}"
|
||||
case ${actual[$index]} in
|
||||
"${expected[$index]}") ;;
|
||||
*) fail "$agent launch $description" "expected: ${expected[*]}\nactual: ${actual[*]}" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
@@ -462,10 +464,18 @@ assert_launch claude claude --permission-mode auto -- "Review this project"
|
||||
assert_launch codex codex --approve-for-me -- "Review this project"
|
||||
assert_launch crush crush run "Review this project"
|
||||
assert_launch grok grok --permission-mode bypassPermissions -- "Review this project"
|
||||
assert_launch hermes env -u HERMES_SESSION_SOURCE hermes chat --yolo --tui "--query=Review this project"
|
||||
assert_launch agy agy --dangerously-skip-permissions --prompt-interactive "Review this project"
|
||||
assert_launch copilot copilot --allow-all --interactive "Review this project"
|
||||
pass "agent launcher adapts initial prompts for every supported agent"
|
||||
|
||||
literal_hermes_prompt=$' --help !Crash /quit {$(touch must-not-run)}\ntrailing\\ '
|
||||
printf '%s\n' "hermes" >"$agent_file"
|
||||
omarchy-agent-prompt "$literal_hermes_prompt"
|
||||
assert_launched hermes "binds its literal initial prompt" env -u HERMES_SESSION_SOURCE \
|
||||
hermes chat --yolo --tui "--query=$literal_hermes_prompt"
|
||||
pass "Hermes receives prompted launches as one literal query argument"
|
||||
|
||||
assert_bypass pi pi
|
||||
assert_bypass omp omp --auto-approve
|
||||
assert_bypass opencode opencode --auto
|
||||
@@ -474,6 +484,7 @@ assert_bypass claude claude --permission-mode auto
|
||||
assert_bypass codex codex --approve-for-me
|
||||
assert_bypass crush crush --yolo
|
||||
assert_bypass grok grok --permission-mode bypassPermissions
|
||||
assert_bypass hermes hermes --yolo
|
||||
assert_bypass agy agy --dangerously-skip-permissions
|
||||
assert_bypass copilot copilot --allow-all
|
||||
pass "agent launcher skips permission prompts for every supported agent"
|
||||
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
migration="$ROOT/migrations/1787760281.sh"
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
test_home="$test_tmp/home"
|
||||
hermes="$test_home/.local/bin/hermes"
|
||||
marker="# Written by omarchy-install-hermes-cli."
|
||||
mkdir -p "$mock_bin" "$test_home/.local/bin" "$test_home/.local/state/omarchy"
|
||||
|
||||
cat >"$mock_bin/omarchy-pkg-present" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]]
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/omarchy-cmd-missing" <<'SH'
|
||||
#!/bin/bash
|
||||
! command -v "$1" >/dev/null 2>&1
|
||||
SH
|
||||
|
||||
mise_log="$test_tmp/mise-log"
|
||||
cat >"$mock_bin/mise" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' "$@" >>"$OMARCHY_TEST_MISE_LOG"
|
||||
[[ $1 != "where" ]]
|
||||
SH
|
||||
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
# The real installer is on PATH so the migration writes today's stub, not a
|
||||
# copy of it.
|
||||
run_migration() {
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED="${1:-0}" \
|
||||
OMARCHY_TEST_MISE_LOG="$mise_log" \
|
||||
HOME="$test_home" \
|
||||
PATH="$mock_bin:$ROOT/bin:$PATH" \
|
||||
bash -euo pipefail "$migration" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
run_migration || fail "the migration installs the wrapper on a plain install"
|
||||
[[ -x $hermes ]] && grep -qxF "$marker" "$hermes" || fail "the migration writes the Omarchy wrapper"
|
||||
pass "the migration installs the Hermes wrapper"
|
||||
|
||||
before=$(cat "$hermes")
|
||||
run_migration || fail "rerunning the migration succeeds"
|
||||
[[ $(cat "$hermes") == "$before" ]] || fail "rerunning the migration leaves the same wrapper"
|
||||
pass "the migration is idempotent"
|
||||
|
||||
chmod -x "$hermes"
|
||||
run_migration || fail "the migration repairs a non-executable Omarchy wrapper"
|
||||
[[ -x $hermes ]] && grep -qxF "$marker" "$hermes" ||
|
||||
fail "the migration restores a non-executable Omarchy wrapper"
|
||||
pass "the migration repairs a non-executable Omarchy wrapper"
|
||||
|
||||
rm -f "$hermes"
|
||||
touch "$test_home/.local/state/omarchy/preinstalls-removed"
|
||||
run_migration || fail "the migration succeeds for users who removed the preinstalls"
|
||||
[[ ! -e $hermes ]] || fail "the migration respects the preinstalls opt-out"
|
||||
pass "the migration skips users who removed the preinstalls"
|
||||
rm -f "$test_home/.local/state/omarchy/preinstalls-removed"
|
||||
|
||||
run_migration 1 || fail "the migration succeeds when Hermes Desktop owns Hermes"
|
||||
[[ ! -e $hermes ]] || fail "the migration writes nothing when Hermes Desktop owns Hermes"
|
||||
pass "the migration stands aside for Hermes Desktop"
|
||||
|
||||
# Standing aside is not the same as leaving a second Hermes behind: the wrapper
|
||||
# an earlier install wrote and the mise copy it points at both go when the
|
||||
# desktop app owns Hermes, even though the app has not finished setting up.
|
||||
printf '%s\n' "#!/bin/bash" "$marker" >"$hermes"
|
||||
chmod +x "$hermes"
|
||||
: >"$mise_log"
|
||||
run_migration 1 || fail "the migration succeeds when Hermes Desktop owns Hermes and the old wrapper is present"
|
||||
[[ ! -e $hermes ]] || fail "the migration removes the Omarchy wrapper when Hermes Desktop owns Hermes"
|
||||
mise_calls=$(tr '\0' ' ' <"$mise_log")
|
||||
[[ $mise_calls == *"rm -g "* ]] || fail "the migration removes the global mise Hermes for Hermes Desktop"
|
||||
[[ $mise_calls == *"uninstall --all "* ]] || fail "the migration uninstalls the mise Hermes for Hermes Desktop"
|
||||
pass "the migration clears the old Omarchy Hermes for Hermes Desktop"
|
||||
|
||||
# ...while anyone else's hermes stays exactly where it is, and is not run.
|
||||
foreign_ran="$test_tmp/foreign-ran"
|
||||
foreign_body="#!/bin/bash
|
||||
touch $foreign_ran
|
||||
exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\""
|
||||
printf '%s\n' "$foreign_body" >"$hermes"
|
||||
chmod +x "$hermes"
|
||||
run_migration 1 || fail "the migration succeeds over a foreign hermes when Hermes Desktop owns Hermes"
|
||||
[[ -x $hermes && $(cat "$hermes") == "$foreign_body" ]] ||
|
||||
fail "the migration leaves a foreign hermes alone when Hermes Desktop owns Hermes"
|
||||
[[ ! -e $foreign_ran ]] || fail "the migration does not run a foreign hermes"
|
||||
pass "the migration preserves a foreign hermes for Hermes Desktop"
|
||||
rm -f "$hermes"
|
||||
|
||||
official_body="#!/bin/bash
|
||||
unset PYTHONPATH
|
||||
unset PYTHONHOME
|
||||
exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\""
|
||||
printf '%s\n' "$official_body" >"$hermes"
|
||||
chmod +x "$hermes"
|
||||
run_migration || fail "the migration succeeds over a foreign hermes command"
|
||||
[[ $(cat "$hermes") == "$official_body" ]] || fail "the migration leaves a foreign hermes command alone"
|
||||
pass "the migration preserves a foreign hermes command"
|
||||
|
||||
chmod -x "$hermes"
|
||||
run_migration || fail "the migration succeeds over a non-executable foreign hermes"
|
||||
[[ -f $hermes && ! -x $hermes && $(cat "$hermes") == "$official_body" ]] ||
|
||||
fail "the migration leaves a non-executable foreign hermes alone"
|
||||
pass "the migration preserves a non-executable foreign hermes"
|
||||
|
||||
rm -f "$hermes"
|
||||
ln -s "$test_home/nowhere/hermes" "$hermes"
|
||||
run_migration || fail "the migration succeeds over a dangling hermes link"
|
||||
[[ -L $hermes && $(readlink "$hermes") == "$test_home/nowhere/hermes" ]] ||
|
||||
fail "the migration leaves a dangling hermes link alone"
|
||||
pass "the migration preserves a dangling hermes link"
|
||||
|
||||
rm -f "$hermes"
|
||||
mkdir "$hermes"
|
||||
run_migration || fail "the migration succeeds over a directory at the hermes path"
|
||||
[[ -d $hermes ]] || fail "the migration leaves a directory at the hermes path alone"
|
||||
pass "the migration preserves a directory at the hermes path"
|
||||
|
||||
rmdir "$hermes"
|
||||
printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." >"$hermes"
|
||||
chmod +x "$hermes"
|
||||
run_migration || fail "the migration succeeds over a wrapper that mentions the installer"
|
||||
grep -qxF "$marker" "$hermes" && fail "the migration does not rewrite a wrapper that merely mentions the installer"
|
||||
pass "the migration preserves a wrapper that merely mentions the installer"
|
||||
Executable
+398
@@ -0,0 +1,398 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
test_home="$test_tmp/home"
|
||||
mise_log="$test_tmp/mise-log"
|
||||
mkdir -p "$mock_bin" "$test_home/.local/bin"
|
||||
|
||||
cat >"$mock_bin/omarchy-pkg-present" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]]
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/omarchy-cmd-missing" <<'SH'
|
||||
#!/bin/bash
|
||||
! command -v "$1" >/dev/null 2>&1
|
||||
SH
|
||||
|
||||
# `mise where` must fail so the installer sees no Hermes behind the stub.
|
||||
cat >"$mock_bin/mise" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' "$@" >>"$OMARCHY_TEST_MISE_LOG"
|
||||
if [[ $1 == "where" && ${OMARCHY_TEST_MISE_WHERE_OK:-0} == 1 ]]; then
|
||||
printf '%s\n' "$OMARCHY_TEST_MISE_ROOT"
|
||||
exit 0
|
||||
fi
|
||||
[[ $1 != "where" ]]
|
||||
SH
|
||||
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
run_installer() {
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED="$1" \
|
||||
OMARCHY_TEST_MISE_WHERE_OK="${OMARCHY_TEST_MISE_WHERE_OK:-0}" \
|
||||
OMARCHY_TEST_MISE_ROOT="$test_tmp/mise" \
|
||||
OMARCHY_TEST_MISE_LOG="$mise_log" \
|
||||
HOME="$test_home" \
|
||||
PATH="$mock_bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-install-hermes-cli" ${2:+"$2"} >/dev/null 2>&1
|
||||
}
|
||||
|
||||
stub_marker="# Written by omarchy-install-hermes-cli."
|
||||
python_pin="3.13"
|
||||
app_stub_body='#!/bin/bash
|
||||
exec /home/x/.hermes/hermes-agent/venv/bin/hermes "$@"'
|
||||
|
||||
# Writing the stub must not provision anything: user setup calls this on every
|
||||
# machine, including the ones that never run Hermes.
|
||||
: >"$mise_log"
|
||||
rm -f "$test_home/.local/bin/hermes"
|
||||
run_installer 0 || fail "installer failed with no desktop installed"
|
||||
[[ -x $test_home/.local/bin/hermes ]] || fail "installer writes a hermes stub when the desktop is absent"
|
||||
grep -qxF "$stub_marker" "$test_home/.local/bin/hermes" || fail "the stub records which command wrote it"
|
||||
tr '\0' ' ' <"$mise_log" | grep -q "use -g --quiet uv" &&
|
||||
fail "writing the stub does not install uv"
|
||||
pass "writing the Hermes stub provisions nothing"
|
||||
|
||||
# The desktop app owns Hermes, so our own stub must go rather than sit there
|
||||
# answering `hermes` until the app's bootstrap replaces it.
|
||||
printf '%s\n' "#!/bin/bash" "$stub_marker" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 1 || true
|
||||
[[ ! -e $test_home/.local/bin/hermes ]] ||
|
||||
fail "the desktop taking over removes the stub this command wrote"
|
||||
pass "installing the desktop app removes the CLI stub"
|
||||
|
||||
# ...but the app's own hermes is not ours to delete.
|
||||
printf '%s\n' "$app_stub_body" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 1 || true
|
||||
[[ -x $test_home/.local/bin/hermes ]] ||
|
||||
fail "the desktop app's own hermes command survives"
|
||||
pass "the app's own hermes command is left alone"
|
||||
|
||||
# A copy mise cannot vouch for is still a second Hermes.
|
||||
printf '%s\n' "#!/bin/bash" "$stub_marker" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
: >"$mise_log"
|
||||
OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 1 || true
|
||||
tr '\0' '\n' <"$mise_log" | grep -q "uninstall" ||
|
||||
fail "takeover removes a mise copy even when it is not healthy"
|
||||
pass "takeover removes an unhealthy mise copy"
|
||||
|
||||
# --check answers about Hermes being usable, not about the venv appearing. The
|
||||
# venv exists from the python-deps stage, several stages before the command.
|
||||
rm -rf "$test_home/.hermes"
|
||||
rm -f "$test_home/.local/bin/hermes"
|
||||
run_installer 1 --check && fail "--check reports Hermes missing before the app installs it"
|
||||
# The venv command answers the readiness probes, as the real one does: foreign
|
||||
# wrappers below exec it, and the installer runs both before trusting them.
|
||||
mkdir -p "$test_home/.hermes/hermes-agent/venv/bin"
|
||||
cat >"$test_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
|
||||
[[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "--oneshot"
|
||||
else
|
||||
echo "hermes-agent 0.0.0-test"
|
||||
fi
|
||||
SH
|
||||
chmod +x "$test_home/.hermes/hermes-agent/venv/bin/hermes"
|
||||
run_installer 1 --check && fail "--check waits for the install to finish, not just the venv"
|
||||
touch "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 1 --check || fail "--check reports Hermes present once the app has finished"
|
||||
pass "--check follows the app's completed install"
|
||||
|
||||
# An executable called hermes that belongs to something else is not this
|
||||
# install being ready.
|
||||
printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/somebody-elses-hermes \"\$@\"" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 1 --check && fail "--check rejects a hermes command belonging to something else"
|
||||
pass "--check rejects a foreign hermes command"
|
||||
|
||||
# A hermes the user installed themselves -- the official installer, a wrapper of
|
||||
# their own -- is not ours to replace. --check follows whether it runs, and
|
||||
# installing steps aside so the default agent uses it.
|
||||
official_body="#!/bin/bash
|
||||
unset PYTHONPATH
|
||||
unset PYTHONHOME
|
||||
exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\""
|
||||
printf '%s\n' "$official_body" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 0 --check || fail "--check accepts a working foreign hermes command"
|
||||
run_installer 0 || fail "installing over a foreign hermes command returns success"
|
||||
run_installer 0 --now || fail "--now over a foreign hermes command returns success"
|
||||
[[ $(cat "$test_home/.local/bin/hermes") == "$official_body" ]] ||
|
||||
fail "a foreign hermes command is left untouched"
|
||||
pass "a foreign hermes command is preserved and satisfies --check"
|
||||
|
||||
OMARCHY_TEST_HERMES_CAPABLE=0 run_installer 0 --check &&
|
||||
fail "--check rejects a foreign Hermes without native prompted sessions"
|
||||
OMARCHY_TEST_HERMES_CAPABLE=0 run_installer 0 &&
|
||||
fail "installing refuses a foreign Hermes without native prompted sessions"
|
||||
[[ $(cat "$test_home/.local/bin/hermes") == "$official_body" ]] ||
|
||||
fail "an older foreign Hermes command is left untouched"
|
||||
pass "a foreign Hermes must support native prompted sessions"
|
||||
|
||||
# Broken foreign paths are still foreign. They cannot be used, so --check says
|
||||
# so and the installer refuses rather than replacing them.
|
||||
printf '%s\n' "$official_body" >"$test_home/.local/bin/hermes"
|
||||
chmod -x "$test_home/.local/bin/hermes"
|
||||
run_installer 0 --check && fail "--check rejects a non-executable foreign hermes"
|
||||
run_installer 0 && fail "the installer does not succeed over a non-executable foreign hermes"
|
||||
[[ -f $test_home/.local/bin/hermes && ! -x $test_home/.local/bin/hermes ]] ||
|
||||
fail "a non-executable foreign hermes is left untouched"
|
||||
pass "a non-executable foreign hermes is preserved"
|
||||
|
||||
# The executable bit is not enough: a wrapper whose interpreter is gone passes
|
||||
# -x and still cannot run. The probe has to run it to find out, and finding
|
||||
# out never touches the file.
|
||||
broken_interp_body="#!$test_home/nowhere/python3
|
||||
print('hermes')"
|
||||
printf '%s\n' "$broken_interp_body" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 0 --check && fail "--check rejects a foreign hermes whose interpreter is missing"
|
||||
run_installer 0 && fail "the installer does not succeed over a foreign hermes whose interpreter is missing"
|
||||
run_installer 0 --now && fail "--now does not succeed over a foreign hermes whose interpreter is missing"
|
||||
[[ -x $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$broken_interp_body" ]] ||
|
||||
fail "a foreign hermes whose interpreter is missing is left untouched"
|
||||
pass "a foreign hermes with a missing interpreter is preserved and rejected"
|
||||
|
||||
# Likewise a wrapper that execs a target that is no longer there.
|
||||
broken_target_body="#!/bin/bash
|
||||
exec $test_home/nowhere/hermes \"\$@\""
|
||||
printf '%s\n' "$broken_target_body" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 0 --check && fail "--check rejects a foreign hermes whose target is missing"
|
||||
run_installer 0 && fail "the installer does not succeed over a foreign hermes whose target is missing"
|
||||
run_installer 0 --now && fail "--now does not succeed over a foreign hermes whose target is missing"
|
||||
[[ -x $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$broken_target_body" ]] ||
|
||||
fail "a foreign hermes whose target is missing is left untouched"
|
||||
pass "a foreign hermes with a missing target is preserved and rejected"
|
||||
|
||||
foreign_target="$test_home/foreign/hermes"
|
||||
mkdir -p "$(dirname "$foreign_target")"
|
||||
printf '%s\n' "$official_body" >"$foreign_target"
|
||||
chmod +x "$foreign_target"
|
||||
rm -f "$test_home/.local/bin/hermes"
|
||||
ln -s "$foreign_target" "$test_home/.local/bin/hermes"
|
||||
run_installer 0 --check || fail "--check accepts a foreign link to a working hermes command"
|
||||
run_installer 0 || fail "the installer succeeds over a foreign link to a working hermes command"
|
||||
run_installer 0 --now || fail "--now succeeds over a foreign link to a working hermes command"
|
||||
[[ -L $test_home/.local/bin/hermes && $(readlink "$test_home/.local/bin/hermes") == "$foreign_target" ]] ||
|
||||
fail "a foreign link to a working hermes command is left untouched"
|
||||
pass "a foreign link to a working hermes command is preserved"
|
||||
|
||||
rm -f "$test_home/.local/bin/hermes"
|
||||
ln -s "$test_home/nowhere/hermes" "$test_home/.local/bin/hermes"
|
||||
run_installer 0 --check && fail "--check rejects a dangling hermes link"
|
||||
run_installer 0 && fail "the installer does not succeed over a dangling hermes link"
|
||||
[[ -L $test_home/.local/bin/hermes && $(readlink "$test_home/.local/bin/hermes") == "$test_home/nowhere/hermes" ]] ||
|
||||
fail "a dangling hermes link is left untouched"
|
||||
pass "a dangling hermes link is preserved"
|
||||
|
||||
# A directory passes -x on search permission alone. It is still not a command.
|
||||
rm -f "$test_home/.local/bin/hermes"
|
||||
mkdir "$test_home/.local/bin/hermes"
|
||||
run_installer 0 --check && fail "--check rejects a directory at the hermes path"
|
||||
run_installer 0 && fail "the installer does not succeed over a directory at the hermes path"
|
||||
[[ -d $test_home/.local/bin/hermes ]] || fail "a directory at the hermes path is left untouched"
|
||||
pass "a directory at the hermes path is preserved and rejected"
|
||||
|
||||
# Mentioning the installer is not the same as being written by it.
|
||||
rmdir "$test_home/.local/bin/hermes"
|
||||
mentions_body="#!/bin/bash
|
||||
# Replaces the stub omarchy-install-hermes-cli used to write.
|
||||
exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\""
|
||||
printf '%s\n' "$mentions_body" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 0 || fail "installing over a wrapper that mentions the installer returns success"
|
||||
[[ $(cat "$test_home/.local/bin/hermes") == "$mentions_body" ]] ||
|
||||
fail "a wrapper that merely mentions the installer is left untouched"
|
||||
pass "ownership needs the exact marker line, not a mention"
|
||||
|
||||
# Our own stub is ours to rewrite, so reinstalling refreshes it to the current
|
||||
# template.
|
||||
rm -f "$test_home/.local/bin/hermes"
|
||||
printf '%s\n' "#!/bin/bash" "$stub_marker" "# stale template" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 0 || fail "reinstalling over our own stub succeeds"
|
||||
grep -qxF "$stub_marker" "$test_home/.local/bin/hermes" || fail "the refreshed stub still carries the marker"
|
||||
grep -q "stale template" "$test_home/.local/bin/hermes" && fail "reinstalling rewrites our own stub"
|
||||
grep -q "exec env -u UV_PYTHON mise x" "$test_home/.local/bin/hermes" || fail "the refreshed stub is the current template"
|
||||
pass "reinstalling refreshes the Omarchy stub"
|
||||
|
||||
mkdir -p "$test_tmp/mise/hermes-agent/lib/python$python_pin"
|
||||
: >"$mise_log"
|
||||
OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 0 || fail "reinstalling replaces an older owned Hermes environment"
|
||||
tr '\0' '\n' <"$mise_log" | grep -q '^rm$' || fail "an older owned Hermes environment is removed from mise config"
|
||||
tr '\0' '\n' <"$mise_log" | grep -q '^uninstall$' || fail "an older owned Hermes environment is uninstalled"
|
||||
pass "reinstalling replaces an older owned Hermes environment"
|
||||
|
||||
rm -f "$test_home/.local/bin/hermes"
|
||||
: >"$mise_log"
|
||||
OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 0 &&
|
||||
fail "installing refuses to claim an unmarked Hermes mise environment"
|
||||
tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' &&
|
||||
fail "an unmarked Hermes mise environment is never removed"
|
||||
[[ ! -e $test_home/.local/bin/hermes ]] ||
|
||||
fail "an unmarked Hermes mise environment is not given an Omarchy wrapper"
|
||||
pass "a Hermes mise environment needs wrapper ownership before replacement"
|
||||
|
||||
# install/user/mise.sh is sourced by install/user/all.sh through run_logged,
|
||||
# which runs it under `bash -eE` and hands its exit code back to
|
||||
# omarchy-provision-user's `set -euo pipefail`. Everything that finalizes a user
|
||||
# -- the default browser, the mailto handler, the first-install migration
|
||||
# markers, the finalize-user marker -- runs after that source, so this leaf
|
||||
# returning non-zero costs the user all of it. The Hermes installer is the only
|
||||
# line in it that can fail, and it does exactly that whenever hermes-desktop is
|
||||
# installed but the app has not been launched yet: the case a second user on a
|
||||
# shared machine hits on their first login.
|
||||
mise_sh_home="$test_tmp/mise-sh-home"
|
||||
mkdir -p "$mise_sh_home/.local/bin"
|
||||
|
||||
cat >"$mock_bin/omarchy-mise-install" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$mock_bin/omarchy-mise-install"
|
||||
|
||||
# Desktop installed, nothing bootstrapped: omarchy-install-hermes-cli exits 1.
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED=1 \
|
||||
OMARCHY_TEST_MISE_LOG="$mise_log" \
|
||||
HOME="$mise_sh_home" \
|
||||
PATH="$mock_bin:$ROOT/bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-install-hermes-cli" >/dev/null 2>&1 &&
|
||||
fail "the Hermes installer exits non-zero when the desktop app has not set Hermes up"
|
||||
|
||||
# Sourced exactly as run_logged does it.
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED=1 \
|
||||
OMARCHY_TEST_MISE_LOG="$mise_log" \
|
||||
HOME="$mise_sh_home" \
|
||||
PATH="$mock_bin:$ROOT/bin:$PATH" \
|
||||
bash -eE -c 'source "$1"' bash "$ROOT/install/user/mise.sh" >/dev/null 2>&1 ||
|
||||
fail "user setup survives a Hermes install that cannot finish"
|
||||
pass "user setup survives a Hermes install that cannot finish"
|
||||
|
||||
# UV_PYTHON pins the interpreter Hermes is built against. Left in the
|
||||
# environment it reaches Hermes itself and every command the agent shells out
|
||||
# to, so a `uv` run in the user's own project resolves 3.13 there as well --
|
||||
# uv only warns that this contradicts the project's requires-python, then
|
||||
# builds the venv anyway. The stub drops it before handing over.
|
||||
leak_home="$test_tmp/leak-home"
|
||||
leak_bin="$test_tmp/leak-bin"
|
||||
leak_log="$test_tmp/leak-log"
|
||||
leak_prefix="$test_tmp/leak-prefix"
|
||||
mkdir -p "$leak_home/.local/bin" "$leak_bin" "$leak_prefix/hermes-agent/lib/python$python_pin"
|
||||
|
||||
# A mise whose `where` satisfies the stub's probe, so the stub goes straight to
|
||||
# handing over, and whose `x` records the UV_PYTHON it was handed.
|
||||
cat >"$leak_bin/mise" <<SH
|
||||
#!/bin/bash
|
||||
case \$1 in
|
||||
where) echo "$leak_prefix" ;;
|
||||
x) printf '%s' "\${UV_PYTHON-}" >"$leak_log" ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$leak_bin/mise"
|
||||
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED=0 \
|
||||
OMARCHY_TEST_MISE_LOG="$mise_log" \
|
||||
HOME="$leak_home" \
|
||||
PATH="$mock_bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-install-hermes-cli" >/dev/null 2>&1 ||
|
||||
fail "the installer writes a stub for the leak check"
|
||||
|
||||
HOME="$leak_home" PATH="$leak_bin:$mock_bin:$PATH" \
|
||||
"$leak_home/.local/bin/hermes" --version >/dev/null 2>&1
|
||||
|
||||
[[ -f $leak_log ]] || fail "the stub reaches the command it wraps"
|
||||
[[ -z $(cat "$leak_log") ]] ||
|
||||
fail "the interpreter pin does not follow Hermes into the commands it runs"
|
||||
pass "the interpreter pin does not follow Hermes into the commands it runs"
|
||||
|
||||
# --owns is the one answer to whether the wrapper on PATH is this installer's.
|
||||
# Remove Preinstalls and the migration both ask it rather than carrying their
|
||||
# own copy of the marker, so a change to what ownership means reaches them.
|
||||
owns_home="$test_tmp/owns-home"
|
||||
mkdir -p "$owns_home/.local/bin"
|
||||
|
||||
run_owns() {
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED=0 \
|
||||
OMARCHY_TEST_MISE_LOG="$mise_log" \
|
||||
HOME="$owns_home" \
|
||||
PATH="$mock_bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-install-hermes-cli" --owns
|
||||
}
|
||||
|
||||
rm -f "$owns_home/.local/bin/hermes"
|
||||
run_owns && fail "--owns says no when there is no wrapper at all"
|
||||
|
||||
printf '%s\n' "#!/bin/bash" "$stub_marker" >"$owns_home/.local/bin/hermes"
|
||||
chmod +x "$owns_home/.local/bin/hermes"
|
||||
run_owns || fail "--owns recognises the stub this installer wrote"
|
||||
|
||||
printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." \
|
||||
>"$owns_home/.local/bin/hermes"
|
||||
run_owns && fail "--owns needs the exact marker line, not a mention"
|
||||
|
||||
# Quoting the marker inside a longer line is not the same as carrying it: the
|
||||
# match is whole-line, so a wrapper describing what it replaced stays the
|
||||
# user's.
|
||||
printf '%s\n' "#!/bin/bash" "# Replaced '$stub_marker' with my own." \
|
||||
>"$owns_home/.local/bin/hermes"
|
||||
run_owns && fail "--owns needs the marker to be the whole line, not part of one"
|
||||
|
||||
rm -f "$owns_home/.local/bin/hermes"
|
||||
ln -s "$test_home/.local/bin/hermes" "$owns_home/.local/bin/hermes"
|
||||
run_owns && fail "--owns disclaims a symlink, whatever it resolves to"
|
||||
rm -f "$owns_home/.local/bin/hermes"
|
||||
pass "--owns answers for the wrapper this installer wrote and nothing else"
|
||||
|
||||
# The marker lives in exactly one place. Every other caller asks --owns, so a
|
||||
# second copy is drift waiting to happen.
|
||||
marker_copies=$(grep -rl "Written by omarchy-install-hermes-cli" \
|
||||
"$ROOT/bin" "$ROOT/install" "$ROOT/migrations" 2>/dev/null | wc -l)
|
||||
(( marker_copies == 1 )) ||
|
||||
fail "only omarchy-install-hermes-cli spells out the ownership marker"
|
||||
pass "the ownership marker is written down once"
|
||||
|
||||
# The app's marker says its install once landed, not that it is still there. A
|
||||
# wrapper whose runtime has since gone answers for nothing, so readiness runs
|
||||
# the command, exactly as it does for a hermes the user installed themselves.
|
||||
ready_home="$test_tmp/ready-home"
|
||||
mkdir -p "$ready_home/.hermes/hermes-agent/venv/bin" "$ready_home/.local/bin"
|
||||
touch "$ready_home/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
printf '%s\n' "#!/bin/bash" "exec $ready_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \
|
||||
>"$ready_home/.local/bin/hermes"
|
||||
chmod +x "$ready_home/.local/bin/hermes"
|
||||
|
||||
run_ready_check() {
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED=1 \
|
||||
OMARCHY_TEST_MISE_LOG="$mise_log" \
|
||||
HOME="$ready_home" \
|
||||
PATH="$mock_bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-install-hermes-cli" --check >/dev/null 2>&1
|
||||
}
|
||||
|
||||
run_ready_check && fail "--check rejects the app's wrapper when its runtime is gone"
|
||||
|
||||
cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
|
||||
echo "--oneshot"
|
||||
else
|
||||
echo "hermes-agent 0.0.0-test"
|
||||
fi
|
||||
SH
|
||||
chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes"
|
||||
run_ready_check || fail "--check accepts the app's wrapper once it runs"
|
||||
pass "readiness runs the app's command rather than trusting its marker"
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
test_home="$test_tmp/home"
|
||||
mkdir -p "$mock_bin"
|
||||
|
||||
cat >"$mock_bin/omarchy-pkg-drop" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' "$@" >>"$OMARCHY_TEST_DROP_LOG"
|
||||
SH
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
seed_install() {
|
||||
rm -rf "$test_home"
|
||||
mkdir -p "$test_home/.hermes/hermes-agent" "$test_home/.hermes/bootstrap-cache" \
|
||||
"$test_home/.hermes/bin" "$test_home/.hermes/node/bin" \
|
||||
"$test_home/.hermes/memories" "$test_home/.hermes/sessions" \
|
||||
"$test_home/.config/Hermes" "$test_home/.local/bin"
|
||||
printf 'chat\n' >"$test_home/.hermes/sessions/one.json"
|
||||
printf 'memory\n' >"$test_home/.hermes/memories/one.md"
|
||||
printf 'soul\n' >"$test_home/.hermes/SOUL.md"
|
||||
printf 'uv\n' >"$test_home/.hermes/bin/uv"
|
||||
ln -sf "$test_home/.hermes/node/bin/node" "$test_home/.local/bin/node"
|
||||
ln -sf "$test_home/.hermes/node/bin/npm" "$test_home/.local/bin/npm"
|
||||
ln -sf /usr/bin/npx "$test_home/.local/bin/npx"
|
||||
printf 'node\n' >"$test_home/.hermes/node/bin/node"
|
||||
touch "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
}
|
||||
|
||||
remove() {
|
||||
OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" HOME="$test_home" PATH="$mock_bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-remove-ai-hermes" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# The app brings its own uv and its own node; both are runtime, not data.
|
||||
seed_install
|
||||
printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \
|
||||
>"$test_home/.local/bin/hermes"
|
||||
remove || fail "remove succeeds"
|
||||
[[ ! -d $test_home/.hermes/hermes-agent ]] || fail "the runtime checkout is removed"
|
||||
[[ ! -d $test_home/.hermes/bin ]] || fail "the uv the app installed is removed"
|
||||
[[ ! -d $test_home/.hermes/node ]] || fail "the node the app installed is removed"
|
||||
pass "removal takes the whole runtime the app installed"
|
||||
|
||||
[[ -d $test_home/.config/Hermes ]] ||
|
||||
fail "gateway connections, tokens and settings survive removal"
|
||||
pass "removal keeps the app's connections and settings"
|
||||
|
||||
# -L, not -e: a dangling symlink fails -e while very much still being there.
|
||||
[[ ! -L $test_home/.local/bin/node ]] || fail "a node symlink into ~/.hermes is removed"
|
||||
[[ ! -L $test_home/.local/bin/npm ]] || fail "an npm symlink into ~/.hermes is removed"
|
||||
[[ -L $test_home/.local/bin/npx ]] || fail "an npx symlink pointing elsewhere survives"
|
||||
pass "removal clears only the managed Node links it stranded"
|
||||
|
||||
[[ -f $test_home/.hermes/sessions/one.json ]] || fail "chats survive removal"
|
||||
[[ -f $test_home/.hermes/memories/one.md ]] || fail "memories survive removal"
|
||||
[[ -f $test_home/.hermes/SOUL.md ]] || fail "SOUL.md survives removal"
|
||||
pass "removal keeps what belongs to the user"
|
||||
|
||||
[[ ! -e $test_home/.local/bin/hermes ]] || fail "the app's own hermes command is removed"
|
||||
pass "removal takes the command the app installed"
|
||||
|
||||
# A hermes command the app did not write survives even when the app did install
|
||||
# a runtime of its own.
|
||||
seed_install
|
||||
printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/my-own-hermes \"\$@\"" \
|
||||
>"$test_home/.local/bin/hermes"
|
||||
remove || fail "remove succeeds with a foreign hermes present"
|
||||
[[ -f $test_home/.local/bin/hermes ]] ||
|
||||
fail "a hermes command the app did not write survives removal"
|
||||
pass "removal leaves a hermes it does not own"
|
||||
|
||||
# Installed but never launched. The app provisions its runtime on first launch
|
||||
# and marks it complete when it lands, so without that marker everything under
|
||||
# ~/.hermes predates the app -- an official install, or one built by hand -- and
|
||||
# the paths are identical either way. Dropping the package is the whole job.
|
||||
seed_install
|
||||
rm -f "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
printf 'my local edit\n' >"$test_home/.hermes/hermes-agent/PATCH"
|
||||
printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \
|
||||
>"$test_home/.local/bin/hermes"
|
||||
remove || fail "remove succeeds when the app never finished installing Hermes"
|
||||
[[ -d $test_home/.hermes/hermes-agent ]] ||
|
||||
fail "a Hermes runtime the app never installed survives removal"
|
||||
[[ -f $test_home/.hermes/hermes-agent/PATCH ]] ||
|
||||
fail "local changes to a runtime the app never installed survive removal"
|
||||
[[ -d $test_home/.hermes/bin && -d $test_home/.hermes/node ]] ||
|
||||
fail "the rest of a runtime the app never installed survives removal"
|
||||
[[ -f $test_home/.local/bin/hermes ]] ||
|
||||
fail "the command a runtime the app never installed put on PATH survives removal"
|
||||
[[ -L $test_home/.local/bin/node ]] ||
|
||||
fail "node links belonging to a runtime the app never installed survive removal"
|
||||
pass "removal leaves a Hermes the app never installed"
|
||||
|
||||
# ~/.hermes carries a dot, so a pattern rather than a plain string would also
|
||||
# claim a wrapper pointing at a sibling directory that merely looks like it.
|
||||
seed_install
|
||||
mkdir -p "$test_home/xhermes/bin"
|
||||
sibling_body="#!/bin/bash
|
||||
exec $test_home/xhermes/bin/hermes \"\$@\""
|
||||
printf '%s\n' "$sibling_body" >"$test_home/.local/bin/hermes"
|
||||
remove || fail "remove succeeds with a wrapper pointing at a sibling directory"
|
||||
[[ -f $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$sibling_body" ]] ||
|
||||
fail "a wrapper pointing at ~/xhermes is not mistaken for one pointing into ~/.hermes"
|
||||
pass "removal matches the runtime path as a plain string"
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
migration="$ROOT/migrations/1787843905.sh"
|
||||
[[ -f $migration ]] || fail "Hermes skills migration exists"
|
||||
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
home="$test_dir/home"
|
||||
|
||||
run_migration() {
|
||||
HOME="$home" OMARCHY_PATH="$ROOT" bash -euo pipefail "$migration" >/dev/null ||
|
||||
fail "migration exits clean"
|
||||
}
|
||||
|
||||
assert_link() {
|
||||
local link="$1"
|
||||
local skill="$2"
|
||||
local description="$3"
|
||||
|
||||
[[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] ||
|
||||
fail "$description" "$link -> $(readlink "$link" 2>/dev/null || echo missing)"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ default home, no profiles
|
||||
|
||||
rm -rf "$home"
|
||||
mkdir -p "$home"
|
||||
run_migration
|
||||
|
||||
for skill in omarchy diagnose-crash; do
|
||||
assert_link "$home/.hermes/skills/$skill" "$skill" "migration links $skill into the default Hermes home"
|
||||
done
|
||||
[[ -e $home/.hermes/profiles ]] && fail "migration does not create Hermes profiles"
|
||||
pass "migration links the default Hermes home and does not create profiles"
|
||||
|
||||
run_migration
|
||||
for skill in omarchy diagnose-crash; do
|
||||
assert_link "$home/.hermes/skills/$skill" "$skill" "migration is idempotent on the default home for $skill"
|
||||
done
|
||||
pass "migration is idempotent on the default home"
|
||||
|
||||
# ------------------------------------------------------------------ pre-existing profile
|
||||
|
||||
rm -rf "$home"
|
||||
mkdir -p "$home/.hermes/profiles/james"
|
||||
run_migration
|
||||
|
||||
for skill in omarchy diagnose-crash; do
|
||||
assert_link "$home/.hermes/skills/$skill" "$skill" "migration links $skill into the default Hermes home when a profile exists"
|
||||
assert_link "$home/.hermes/profiles/james/skills/$skill" "$skill" "migration links $skill into a pre-existing Hermes profile"
|
||||
done
|
||||
[[ -d $home/.hermes/profiles/james ]] || fail "migration leaves the pre-existing profile in place"
|
||||
profile_count=$(find "$home/.hermes/profiles" -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
(( profile_count == 1 )) || fail "migration does not create extra profiles" "count=$profile_count"
|
||||
pass "migration links a pre-existing Hermes profile and does not create extras"
|
||||
|
||||
run_migration
|
||||
for skill in omarchy diagnose-crash; do
|
||||
assert_link "$home/.hermes/skills/$skill" "$skill" "migration is idempotent on the default home when a profile exists for $skill"
|
||||
assert_link "$home/.hermes/profiles/james/skills/$skill" "$skill" "migration is idempotent on a pre-existing profile for $skill"
|
||||
done
|
||||
pass "migration is idempotent on a pre-existing profile"
|
||||
|
||||
# ------------------------------------------------------------------ missing skill source
|
||||
|
||||
rm -rf "$home"
|
||||
mkdir -p "$home" "$test_dir/empty-omarchy"
|
||||
HOME="$home" OMARCHY_PATH="$test_dir/empty-omarchy" bash -euo pipefail "$migration" >/dev/null ||
|
||||
fail "migration exits clean when the skill source is missing"
|
||||
[[ -e $home/.hermes ]] && fail "migration no-ops when the skill source is missing"
|
||||
pass "migration no-ops when the skill source is missing"
|
||||
@@ -224,6 +224,7 @@ const expectedAgents = {
|
||||
claude: { icon: '', label: 'Claude' },
|
||||
codex: { icon: '\ue905', iconFont: 'omarchy', label: 'Codex' },
|
||||
grok: { icon: '\ue904', iconFont: 'omarchy', label: 'Grok' },
|
||||
hermes: { icon: '\ue90a', iconFont: 'omarchy', label: 'Hermes' },
|
||||
copilot: { icon: '', label: 'Copilot' },
|
||||
crush: { icon: '', label: 'Crush' },
|
||||
}
|
||||
@@ -244,7 +245,7 @@ assertDeepEqual(
|
||||
defaultItems
|
||||
.filter(item => item.parent === 'setup.default.agent')
|
||||
.map(item => item.label),
|
||||
['Antigravity', 'Claude', 'Codex', 'Copilot', 'Crush', 'Grok', 'omp', 'OpenCode', 'Ori', 'Pi'],
|
||||
['Antigravity', 'Claude', 'Codex', 'Copilot', 'Crush', 'Grok', 'Hermes', 'omp', 'OpenCode', 'Ori', 'Pi'],
|
||||
'menu sorts coding agents alphabetically'
|
||||
)
|
||||
const expectedDefaults = {
|
||||
@@ -636,5 +637,5 @@ assert(
|
||||
JS
|
||||
|
||||
font_charset=$(fc-query --format='%{charset}' "$ROOT/default/fonts/omarchy/omarchy.ttf")
|
||||
[[ $font_charset == *"e900-e909"* ]] || fail "Omarchy icon font includes every custom menu glyph"
|
||||
[[ $font_charset == *"e900-e90a"* ]] || fail "Omarchy icon font includes every custom menu glyph"
|
||||
pass "Omarchy icon font includes the official agent marks"
|
||||
|
||||
@@ -36,7 +36,10 @@ SH
|
||||
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
export PATH="$mock_bin:$PATH"
|
||||
# $ROOT/bin after the mocks: Remove Preinstalls asks omarchy-install-hermes-cli
|
||||
# whether the wrapper is Omarchy's rather than matching the marker itself, and
|
||||
# that is the real command at runtime. The mocks still shadow what they name.
|
||||
export PATH="$mock_bin:$ROOT/bin:$PATH"
|
||||
export HOME="$test_home"
|
||||
export OMARCHY_TEST_PKG_LOG="$pkg_log"
|
||||
|
||||
@@ -89,3 +92,43 @@ pass "declining Remove Preinstalls changes nothing"
|
||||
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
|
||||
[[ -f $marker ]] || fail "Remove Preinstalls records the opt-out"
|
||||
pass "Remove Preinstalls records the opt-out"
|
||||
|
||||
# Hermes' wrapper is only a preinstall when omarchy-install-hermes-cli wrote it.
|
||||
# The desktop app's command and an official install live at the same path and
|
||||
# are the user's, whether or not any package says so.
|
||||
hermes="$test_home/.local/bin/hermes"
|
||||
mkdir -p "$(dirname "$hermes")"
|
||||
|
||||
printf '%s\n' "#!/bin/bash" "# Written by omarchy-install-hermes-cli." >"$hermes"
|
||||
chmod +x "$hermes"
|
||||
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
|
||||
[[ ! -e $hermes ]] || fail "Remove Preinstalls deletes the Omarchy Hermes wrapper"
|
||||
pass "Remove Preinstalls deletes the Omarchy Hermes wrapper"
|
||||
|
||||
printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" >"$hermes"
|
||||
chmod +x "$hermes"
|
||||
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
|
||||
[[ -x $hermes ]] || fail "Remove Preinstalls keeps the desktop app's Hermes command"
|
||||
pass "Remove Preinstalls keeps the desktop app's Hermes command"
|
||||
|
||||
official_body="#!/bin/bash
|
||||
unset PYTHONPATH
|
||||
unset PYTHONHOME
|
||||
exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\""
|
||||
printf '%s\n' "$official_body" >"$hermes"
|
||||
chmod +x "$hermes"
|
||||
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
|
||||
[[ -x $hermes && $(cat "$hermes") == "$official_body" ]] || fail "Remove Preinstalls keeps an official Hermes install"
|
||||
pass "Remove Preinstalls keeps an official Hermes install"
|
||||
|
||||
printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." >"$hermes"
|
||||
chmod +x "$hermes"
|
||||
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
|
||||
[[ -x $hermes ]] || fail "Remove Preinstalls keeps a wrapper that merely mentions the installer"
|
||||
pass "Remove Preinstalls keeps a wrapper that merely mentions the installer"
|
||||
|
||||
rm -f "$hermes"
|
||||
ln -s "$test_home/nowhere/hermes" "$hermes"
|
||||
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
|
||||
[[ -L $hermes ]] || fail "Remove Preinstalls keeps a foreign hermes link"
|
||||
pass "Remove Preinstalls keeps a foreign hermes link"
|
||||
|
||||
@@ -8,7 +8,7 @@ test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
mkdir -p "$mock_bin" "$test_tmp/home"
|
||||
mkdir -p "$mock_bin" "$test_tmp/home" "$test_tmp/home/.hermes/profiles/james"
|
||||
|
||||
for command in xdg-user-dirs-update xdg-settings xdg-mime; do
|
||||
printf '#!/bin/bash\nexit 0\n' >"$mock_bin/$command"
|
||||
@@ -30,6 +30,14 @@ for skill in omarchy diagnose-crash; do
|
||||
link="$test_tmp/home/.gemini/config/skills/$skill"
|
||||
[[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] ||
|
||||
fail "omarchy-provision-user provisions the $skill skill for Antigravity"
|
||||
|
||||
link="$test_tmp/home/.hermes/skills/$skill"
|
||||
[[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] ||
|
||||
fail "omarchy-provision-user provisions the $skill skill for Hermes"
|
||||
|
||||
link="$test_tmp/home/.hermes/profiles/james/skills/$skill"
|
||||
[[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] ||
|
||||
fail "omarchy-provision-user provisions the $skill skill for a Hermes profile"
|
||||
done
|
||||
|
||||
pass "omarchy-provision-user provisions Antigravity skills"
|
||||
pass "omarchy-provision-user provisions Antigravity and Hermes skills"
|
||||
|
||||
Reference in New Issue
Block a user