25d0bcd424
Hermes installs runtimes for itself — `uv` at `$HERMES_HOME/bin/uv`, Node
at `$HERMES_HOME/node` — and neither directory is on an arbitrary
process's PATH. Every `shutil.which("node"/"npm"/"npx"/"uv")` in Hermes's
own code therefore has two failure modes: the managed runtime is invisible,
so the caller reports "not installed" or degrades to a slower tier on a
machine that has exactly what it needed; and when a system copy also
exists, the one Hermes does not own wins.
Routed the Hermes-owned call sites through managed-aware resolvers:
- `agent/lsp/install.py`, `hermes_cli/dep_ensure.py`, `hermes_cli/main.py`
(`_make_tui_argv`), `hermes_cli/tools_config.py` (`_run_post_setup`) now
use `find_node_executable()`.
- `hermes_cli/tools_config.py::_pip_install` and `hermes_cli/setup.py`'s
vercel install use `ensure_uv()` (installing uv is in scope during setup,
and the Windows installer's `uv venv` does not seed pip, so the fallback
tier is "No module named pip"). `tools/lazy_deps.py` uses `resolve_uv()`
— a lookup, not a bootstrap, because it runs mid-turn for an optional
dependency and downloading a runtime as a side effect exceeds what the
caller asked for.
- `hermes_cli/gateway.py`: extracted `_append_node_dir_for_service()`,
shared by the systemd unit and launchd plist generators, which appends
the managed dirs before the PATH-resolved one. A service definition is
written once and survives reboots, so resolving a system Node that
happens to lead the installing shell's PATH bakes the wrong interpreter
in permanently. Managed dirs are profile-scoped, so each profile's unit
still names its own Node; the existing symlink-parent rule (don't
`.resolve()`) is preserved verbatim.
- `tools/environments/local.py`: the terminal tool's subshell PATH gains
the managed dirs, appended alongside the sane entries rather than
prepended — a tool the user deliberately put on their own PATH still
wins, and the managed one only fills a gap. This is also what makes the
bare `which("uv")` in `tools/env_probe.py` correct: that probe reports
the environment the *model* sees, and the model can only run what is on
that subshell's PATH.
`scripts/install.ps1`: the persisted User PATH update becomes
`Set-ManagedNodeFirstOnUserPath`, a move-to-front rather than an
add-if-missing. Installs made by an older install.ps1 already have the
managed dir in User PATH — at the tail, behind a system Node — and an
add-if-missing check sees it present and leaves that ordering in place
forever, so the users the bug hurt would never be repaired. Unrelated
entries keep their relative order (empty segments included; a trailing
`;` is legal and the installer's other PATH code preserves them),
duplicates collapse, and it writes only when the string actually changes.
Tests:
- `tests/test_managed_runtime_resolution.py` — AST guard that fails any
new bare `which()` for a managed runtime, with a short justified
allow-list and a companion test that fails when an allow-list entry goes
stale. Reading source is banned by AGENTS.md and this is the documented
exception: the property is "no call site anywhere spells it this way",
which no runtime seam can observe.
- `scripts/ci/test_install_ps1_path_migration.ps1` — behavioral, not a
source regex: it lifts the real `Set-ManagedNodeFirstOnUserPath` out of
install.ps1's AST and rewrites only the two registry calls into an
in-memory store, so the shipped split/dedupe/prepend/change-detection
logic executes for real. Not in the default lane (Linux runners have no
PowerShell host); runs under `pwsh`. 13/13 assertions pass.
166 lines
5.6 KiB
Python
166 lines
5.6 KiB
Python
"""Lazy dependency bootstrapper for non-Python runtime deps.
|
|
|
|
Detection and prompting live here in Python — not in install.sh — because:
|
|
1. shutil.which() works on every platform; install.sh needs bash.
|
|
2. Detection is instant; spawning bash for a "is node installed?" check is waste.
|
|
3. Python controls the UX (rich prompts, non-interactive fallback, TTY detection).
|
|
|
|
install.sh is still the *installation* backend because it has 1900 lines of
|
|
battle-tested OS detection and package-manager logic (apt/brew/pacman/dnf/
|
|
zypper/Termux/…). Reimplementing that in Python would be huge duplication.
|
|
|
|
Deps that degrade gracefully (ripgrep → grep fallback, ffmpeg → skip conversion)
|
|
don't need ensure_dependency wired in — only hard-fail sites do (TUI needs node,
|
|
browser tool needs agent-browser).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from hermes_constants import agent_browser_runnable, find_node_executable
|
|
from tools.environments.local import hermes_subprocess_env
|
|
|
|
_IS_WINDOWS = platform.system() == "Windows"
|
|
|
|
_DEP_CHECKS = {
|
|
# find_node_executable() rather than a bare which(): $HERMES_HOME/node is
|
|
# not on PATH, so which() would report Node missing on an install that has
|
|
# a managed one and trigger a redundant re-install.
|
|
"node": lambda: find_node_executable("node") is not None,
|
|
"browser": lambda: (
|
|
agent_browser_runnable(shutil.which("agent-browser"))
|
|
or _has_system_browser()
|
|
or _has_hermes_agent_browser()
|
|
),
|
|
"ripgrep": lambda: shutil.which("rg") is not None,
|
|
"ffmpeg": lambda: shutil.which("ffmpeg") is not None,
|
|
}
|
|
|
|
_DEP_DESCRIPTIONS = {
|
|
"node": "Node.js (required for browser tools and TUI)",
|
|
"browser": "Browser engine (Chromium, for web browsing tools)",
|
|
"ripgrep": "ripgrep (fast file search)",
|
|
"ffmpeg": "ffmpeg (TTS voice messages)",
|
|
}
|
|
|
|
|
|
def _has_system_browser() -> bool:
|
|
if _IS_WINDOWS:
|
|
names = ("chrome", "msedge", "chromium")
|
|
else:
|
|
names = ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome")
|
|
for name in names:
|
|
if shutil.which(name):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _has_hermes_agent_browser() -> bool:
|
|
from hermes_constants import get_hermes_home
|
|
home = get_hermes_home()
|
|
if _IS_WINDOWS:
|
|
# npm -g --prefix puts .cmd shims directly in the prefix dir on Windows
|
|
return (home / "node" / "agent-browser.cmd").is_file()
|
|
# install.sh installs globally into $HERMES_HOME/node/bin/ via npm -g --prefix
|
|
# Also check legacy node_modules/.bin/ path for git-clone installs.
|
|
return (
|
|
(home / "node" / "bin" / "agent-browser").is_file()
|
|
or (home / "node_modules" / ".bin" / "agent-browser").is_file()
|
|
)
|
|
|
|
|
|
def _find_install_script(
|
|
package_dir: Path | None = None,
|
|
repo_root: Path | None = None,
|
|
) -> tuple[Path | None, str | None]:
|
|
"""Locate the install script — bundled in wheel or in git checkout.
|
|
|
|
On Windows, prefers install.ps1; on POSIX, prefers install.sh.
|
|
Returns a (path, shell) tuple, or (None, None) if neither is found.
|
|
"""
|
|
if package_dir is None:
|
|
package_dir = Path(__file__).parent
|
|
if repo_root is None:
|
|
repo_root = package_dir.parent
|
|
|
|
if _IS_WINDOWS:
|
|
preferred = ("install.ps1", "powershell")
|
|
fallback = ("install.sh", "bash")
|
|
else:
|
|
preferred = ("install.sh", "bash")
|
|
fallback = ("install.ps1", "powershell")
|
|
|
|
for script_name, shell in (preferred, fallback):
|
|
bundled = package_dir / "scripts" / script_name
|
|
if bundled.is_file():
|
|
return bundled, shell
|
|
repo = repo_root / "scripts" / script_name
|
|
if repo.is_file():
|
|
return repo, shell
|
|
|
|
return None, None
|
|
|
|
|
|
def ensure_dependency(
|
|
dep: str,
|
|
interactive: bool = True,
|
|
) -> bool:
|
|
"""Ensure a non-Python dependency is available. Returns True if available."""
|
|
check = _DEP_CHECKS.get(dep)
|
|
if check is None:
|
|
# Unknown dep — don't silently forward to install script.
|
|
return False
|
|
if check():
|
|
return True
|
|
|
|
script, shell = _find_install_script()
|
|
if script is None:
|
|
if interactive:
|
|
desc = _DEP_DESCRIPTIONS.get(dep, dep)
|
|
print(f" {desc} is not installed and no install script was found.")
|
|
print(f" Install {dep} manually and try again.")
|
|
return False
|
|
|
|
if interactive and sys.stdin.isatty():
|
|
desc = _DEP_DESCRIPTIONS.get(dep, dep)
|
|
try:
|
|
reply = input(f"{desc} is not installed. Install now? [Y/n] ").strip().lower()
|
|
except (EOFError, KeyboardInterrupt):
|
|
return False
|
|
if reply not in ("", "y", "yes"):
|
|
return False
|
|
|
|
if shell == "powershell":
|
|
from hermes_constants import get_hermes_home
|
|
ps_bin = shutil.which("powershell") or shutil.which("pwsh")
|
|
if not ps_bin:
|
|
if interactive:
|
|
print(" PowerShell not found. Install PowerShell or run install.ps1 manually.")
|
|
return False
|
|
cmd = [
|
|
ps_bin,
|
|
"-ExecutionPolicy", "Bypass",
|
|
"-File", str(script),
|
|
"-Ensure", dep,
|
|
"-HermesHome", str(get_hermes_home()),
|
|
]
|
|
else:
|
|
cmd = ["bash", str(script), "--ensure", dep]
|
|
|
|
run_env = hermes_subprocess_env(inherit_credentials=False)
|
|
run_env["IS_INTERACTIVE"] = "false"
|
|
result = subprocess.run(
|
|
cmd,
|
|
env=run_env,
|
|
)
|
|
if result.returncode != 0:
|
|
return False
|
|
|
|
if check:
|
|
return check()
|
|
return True
|