fix(bootstrap): discover agent dir via hermes CLI shebang
`discover_agent_dir()` only checked four hard-coded layouts:
- HERMES_WEBUI_AGENT_DIR
- $HERMES_HOME/hermes-agent
- <webui-parent>/hermes-agent
- ~/.hermes/hermes-agent / ~/hermes-agent
Users who clone hermes-agent somewhere else (e.g. ~/Projects/GitHub/hermes-agent)
hit:
[bootstrap] ERROR: Python environment cannot import both WebUI dependencies
and Hermes Agent. Set HERMES_WEBUI_PYTHON to the Hermes Agent venv Python
or install the WebUI requirements into that environment.
…even though the `hermes` CLI is on PATH and works fine. The CLI is a
console-script with a venv-relative shebang:
#!/path/to/hermes-agent/venv/bin/python3
After the explicit candidates miss, fall back to introspecting that shebang
and walking up parents until we find `run_agent.py`. That's a reliable
pointer to the install root regardless of where the user cloned the repo.
Tests cover happy path, no `hermes` on PATH, missing/invalid shebang,
shebang pointing outside any agent install (e.g. /usr/bin/python3), and
explicit candidates winning over the shebang fallback.
Verified end-to-end: with hermes-agent at a non-standard path,
`uv run bootstrap.py` now succeeds without any HERMES_WEBUI_AGENT_DIR
override.
This commit is contained in:
parent
1706bbdcef
commit
9f72472896
2 changed files with 143 additions and 1 deletions
37
bootstrap.py
37
bootstrap.py
|
|
@ -90,6 +90,41 @@ def ensure_supported_platform() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_dir_from_hermes_cli() -> Path | None:
|
||||||
|
"""Resolve the agent install root by inspecting the `hermes` CLI shebang.
|
||||||
|
|
||||||
|
The Hermes Agent installer drops a `hermes` console-script in the user's
|
||||||
|
PATH whose shebang points at the agent's bundled venv:
|
||||||
|
|
||||||
|
#!/path/to/hermes-agent/venv/bin/python3
|
||||||
|
|
||||||
|
Walking up the parents until we find a directory that contains
|
||||||
|
`run_agent.py` recovers the install root regardless of where the user
|
||||||
|
chose to clone the agent (e.g. ~/Projects/GitHub/hermes-agent), which
|
||||||
|
the hard-coded candidate list in :func:`discover_agent_dir` cannot.
|
||||||
|
"""
|
||||||
|
hermes_path = shutil.which("hermes")
|
||||||
|
if not hermes_path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(hermes_path, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
first_line = f.readline().strip()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if not first_line.startswith("#!"):
|
||||||
|
return None
|
||||||
|
interp_field = first_line[2:].strip().split(None, 1)
|
||||||
|
if not interp_field:
|
||||||
|
return None
|
||||||
|
interp = Path(interp_field[0])
|
||||||
|
if not interp.is_absolute():
|
||||||
|
return None
|
||||||
|
for parent in interp.parents:
|
||||||
|
if (parent / "run_agent.py").exists():
|
||||||
|
return parent.resolve()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def discover_agent_dir() -> Path | None:
|
def discover_agent_dir() -> Path | None:
|
||||||
home = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser()
|
home = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser()
|
||||||
candidates = [
|
candidates = [
|
||||||
|
|
@ -105,7 +140,7 @@ def discover_agent_dir() -> Path | None:
|
||||||
candidate = Path(raw).expanduser().resolve()
|
candidate = Path(raw).expanduser().resolve()
|
||||||
if candidate.exists() and (candidate / "run_agent.py").exists():
|
if candidate.exists() and (candidate / "run_agent.py").exists():
|
||||||
return candidate
|
return candidate
|
||||||
return None
|
return _agent_dir_from_hermes_cli()
|
||||||
|
|
||||||
|
|
||||||
def discover_launcher_python(agent_dir: Path | None) -> str:
|
def discover_launcher_python(agent_dir: Path | None) -> str:
|
||||||
|
|
|
||||||
107
tests/test_bootstrap_discover_agent.py
Normal file
107
tests/test_bootstrap_discover_agent.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
"""Tests for `discover_agent_dir` shebang-based fallback.
|
||||||
|
|
||||||
|
When the standard candidate paths (`~/.hermes/hermes-agent`, `~/hermes-agent`,
|
||||||
|
`<webui-parent>/hermes-agent`, `HERMES_WEBUI_AGENT_DIR`) don't match, bootstrap
|
||||||
|
should fall back to introspecting the `hermes` console-script's shebang —
|
||||||
|
that's a reliable pointer to the install root because the installer writes the
|
||||||
|
venv-relative interpreter path there.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
import bootstrap
|
||||||
|
|
||||||
|
|
||||||
|
def _make_agent_install(tmp_path, *, with_run_agent: bool = True):
|
||||||
|
"""Build a fake hermes-agent install with venv/bin/python3 + run_agent.py."""
|
||||||
|
install = tmp_path / "agent"
|
||||||
|
venv_python = install / "venv" / "bin" / "python3"
|
||||||
|
venv_python.parent.mkdir(parents=True)
|
||||||
|
venv_python.write_text("", encoding="utf-8")
|
||||||
|
if with_run_agent:
|
||||||
|
(install / "run_agent.py").write_text("", encoding="utf-8")
|
||||||
|
return install, venv_python
|
||||||
|
|
||||||
|
|
||||||
|
def _make_hermes_cli(tmp_path, shebang_target: str | None):
|
||||||
|
"""Write a `hermes` console-script with the given shebang interpreter."""
|
||||||
|
bin_dir = tmp_path / "user-bin"
|
||||||
|
bin_dir.mkdir()
|
||||||
|
hermes = bin_dir / "hermes"
|
||||||
|
if shebang_target is None:
|
||||||
|
hermes.write_text("not a script", encoding="utf-8")
|
||||||
|
else:
|
||||||
|
hermes.write_text(
|
||||||
|
textwrap.dedent(
|
||||||
|
f"""\
|
||||||
|
#!{shebang_target}
|
||||||
|
from hermes_cli.main import main
|
||||||
|
main()
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return hermes
|
||||||
|
|
||||||
|
|
||||||
|
def _isolate_discover_agent_dir(monkeypatch, tmp_path, hermes_path):
|
||||||
|
"""Point `which("hermes")` at our fake CLI and clear all standard candidates."""
|
||||||
|
monkeypatch.setattr(bootstrap.shutil, "which", lambda name: str(hermes_path) if name == "hermes" else None)
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "no-such-hermes-home"))
|
||||||
|
monkeypatch.delenv("HERMES_WEBUI_AGENT_DIR", raising=False)
|
||||||
|
# Force REPO_ROOT.parent to a dir that won't accidentally contain a
|
||||||
|
# `hermes-agent` sibling on the dev machine running these tests.
|
||||||
|
monkeypatch.setattr(bootstrap, "REPO_ROOT", tmp_path / "isolated-repo-root")
|
||||||
|
|
||||||
|
|
||||||
|
def test_discovers_agent_dir_from_hermes_shebang(monkeypatch, tmp_path):
|
||||||
|
"""Happy path: hermes shebang → walk up parents → find run_agent.py → return install."""
|
||||||
|
install, venv_python = _make_agent_install(tmp_path)
|
||||||
|
hermes = _make_hermes_cli(tmp_path, str(venv_python))
|
||||||
|
_isolate_discover_agent_dir(monkeypatch, tmp_path, hermes)
|
||||||
|
monkeypatch.chdir(tmp_path) # make Path.home() candidates won't match install
|
||||||
|
|
||||||
|
assert bootstrap.discover_agent_dir() == install.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_when_hermes_not_on_path(monkeypatch, tmp_path):
|
||||||
|
_make_agent_install(tmp_path) # install exists, but no `hermes` CLI to point at it
|
||||||
|
_isolate_discover_agent_dir(monkeypatch, tmp_path, hermes_path=tmp_path / "missing")
|
||||||
|
monkeypatch.setattr(bootstrap.shutil, "which", lambda name: None)
|
||||||
|
|
||||||
|
assert bootstrap.discover_agent_dir() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_when_hermes_has_no_shebang(monkeypatch, tmp_path):
|
||||||
|
"""A `hermes` file without a #! line gives us nothing to introspect."""
|
||||||
|
_make_agent_install(tmp_path)
|
||||||
|
hermes = _make_hermes_cli(tmp_path, shebang_target=None)
|
||||||
|
_isolate_discover_agent_dir(monkeypatch, tmp_path, hermes)
|
||||||
|
|
||||||
|
assert bootstrap.discover_agent_dir() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_when_shebang_interpreter_does_not_have_run_agent(monkeypatch, tmp_path):
|
||||||
|
"""Shebang points at /usr/bin/python3 — no install root walks up to run_agent.py."""
|
||||||
|
_make_agent_install(tmp_path, with_run_agent=False) # install exists but no run_agent.py
|
||||||
|
hermes = _make_hermes_cli(tmp_path, "/usr/bin/python3")
|
||||||
|
_isolate_discover_agent_dir(monkeypatch, tmp_path, hermes)
|
||||||
|
|
||||||
|
assert bootstrap.discover_agent_dir() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_candidate_takes_precedence_over_shebang(monkeypatch, tmp_path):
|
||||||
|
"""HERMES_WEBUI_AGENT_DIR and the standard layout still win when present."""
|
||||||
|
explicit_install = tmp_path / "explicit"
|
||||||
|
(explicit_install).mkdir()
|
||||||
|
(explicit_install / "run_agent.py").write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
# Also set up a hermes-shebang install at a different location — this should NOT win.
|
||||||
|
other_install, venv_python = _make_agent_install(tmp_path)
|
||||||
|
hermes = _make_hermes_cli(tmp_path, str(venv_python))
|
||||||
|
_isolate_discover_agent_dir(monkeypatch, tmp_path, hermes)
|
||||||
|
monkeypatch.setenv("HERMES_WEBUI_AGENT_DIR", str(explicit_install))
|
||||||
|
|
||||||
|
assert bootstrap.discover_agent_dir() == explicit_install.resolve()
|
||||||
Loading…
Reference in a new issue