hermes update command broken across install methods
NousResearch/hermes-agent · cluster 2648 · hermes cli
Data as of
Nothing open on GitHub. Fix merged in #3099. Check it covers your case.
The short answer
- Known?
- Yes. 17 related items grouped here, 6 issues already closed.
- Fix event
- A fix was merged to main in PR #3099. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #3099, #35224, #35248. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple bugs and proposed fixes around hermes update failing under various installation scenarios, including uv tool installs, editable/PyPI installs, system Python, and Debian/Ubuntu PEP 668 restrictions due to incorrect pip or interpreter targeting.
Open issues0
Open PRs0
Fixes on record
-
#3099 fix: use sys.executable for pip in update commands to fix PEP 668
files:
hermes_cli/main.py -
#35224 fix(update): export launcher virtualenv to uv (#35031)
files:
hermes_cli/main.py,tests/hermes_cli/test_cmd_update.pyShow the diff
-
hermes_cli/main.py@@ -8929,7 +8929,10 @@ def _cmd_update_pip(args): cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"] print(f"→ Running: {' '.join(cmd)}") - result = subprocess.run(cmd) + run_kwargs = {} + if sys.prefix != sys.base_prefix: + run_kwargs["env"] = {**os.environ, "VIRTUAL_ENV": sys.prefix} + result = subprocess.run(cmd, **run_kwargs) if result.returncode != 0: print("✗ Update failed") sys.exit(1) -
tests/hermes_cli/test_cmd_update.py@@ -39,6 +39,45 @@ def mock_args(): return SimpleNamespace() +class TestCmdUpdatePip: + """Regression tests for pip-install update flows.""" + + @patch("shutil.which", return_value="/usr/bin/uv") + @patch("subprocess.run") + def test_update_pip_exports_virtualenv_from_sys_prefix( + self, mock_run, _mock_which, mock_args, monkeypatch + ): + from hermes_cli import main as hm + + mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + monkeypatch.setattr(hm.sys, "prefix", "/tmp/hermes-launcher-venv") + monkeypatch.setattr(hm.sys, "base_prefix", "/usr") + + hm._cmd_update_pip(mock_args) + + assert mock_run.call_count == 1 + assert mock_run.call_args.args[0] == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"] + assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/tmp/hermes-launcher-venv" + + @patch("shutil.which", return_value="/usr/bin/uv") + @patch("subprocess.run") + def test_update_pip_does_not_export_virtualenv_for_system_python( + self, mock_run, _mock_which, mock_args, monkeypatch + ): + from hermes_cli import main as hm + + mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + monkeypatch.setattr(hm.sys, "prefix", "/usr") + monkeypatch.setattr(hm.sys, "base_prefix", "/usr") + + hm._cmd_update_pip(mock_args) + + assert mock_run.call_count == 1 + assert "env" not in mock_run.call_args.kwargs + + class TestCmdUpdateBranchFallback: """cmd_update falls back to main when current branch has no remote counterpart."""
-
-
#35248 fix(update): handle uv-tool, pipx, and system-Python installs in hermes update (#29700)
files:
hermes_cli/config.py,hermes_cli/main.py,scripts/release.py,tests/hermes_cli/test_uv_tool_update.pyShow the diff
-
hermes_cli/config.py@@ -329,6 +329,34 @@ def stamp_install_method(method: str) -> None: pass +def is_uv_tool_install() -> bool: + """Return True when the *running* Hermes lives in a ``uv tool`` layout. + + ``uv tool install hermes-agent`` places the install at + ``.../uv/tools/hermes-agent/...`` (default ``~/.local/share/uv/tools``, + or ``$UV_TOOL_DIR/...``). Such installs live outside any virtualenv, so + ``uv pip install`` fails with ``No virtual environment found`` and the + update path must use ``uv tool upgrade`` instead. + + Detection is intentionally restricted to properties of the running + interpreter (``sys.prefix`` / ``sys.executable``). We deliberately do + NOT consult ``uv tool list``: it would also return True when + ``hermes-agent`` happens to be uv-tool-installed on the machine while + the *active* Hermes is a regular pip/venv install, causing + ``hermes update`` to upgrade the wrong copy. It would also block on a + subprocess call (~seconds) just to compute a recommendation string. + """ + def _has_uv_tool_marker(path: str) -> bool: + norm = os.path.normpath(path).replace(os.sep, "/").lower() + return "/uv/tools/hermes-agent/" in norm + "/" + + if _has_uv_tool_marker(sys.prefix): + return True + if _has_uv_tool_marker(sys.executable or ""): + return True + return False + + def recommended_update_command_for_method(method: str) -> str: """Return the update command or guidance for a given install method.""" if method == "nixos": @@ -338,9 +366,10 @@ def recommended_update_command_for_method(method: str) -> str: if method == "docker": return "docker pull nousresearch/hermes-agent:latest" if method == "pip": + if is_uv_tool_install(): + return "uv tool upgrade hermes-agent" import shutil - uv = shutil.which("uv") - if uv: + if shutil.which("uv"): return "uv pip install --upgrade hermes-agent" return "pip install --upgrade hermes-agent" return "hermes update" -
hermes_cli/main.py@@ -8971,19 +8971,49 @@ def cmd_update(args): def _cmd_update_pip(args): """Update Hermes via pip (for PyPI installs).""" from hermes_cli import __version__ + from hermes_cli.config import is_uv_tool_install print(f"→ Current version: {__version__}") print("→ Checking PyPI for updates...") uv = shutil.which("uv") - if uv: + in_venv = sys.prefix != sys.base_prefix + # pipx-managed installs live under .../pipx/venvs/<name>/... + pipx_managed = "pipx" in sys.prefix.split(os.sep) + pipx = shutil.which("pipx") if pipx_managed else None + + # Only the ``uv pip install`` path inside a venv needs VIRTUAL_ENV + # exported (uv refuses to install without it when the launcher shim + # didn't activate the venv). ``uv tool upgrade`` / ``pipx upgrade`` + # operate on a named environment and ignore VIRTUAL_ENV, so we don't + # set it for them. + export_virtualenv = False + + if is_uv_tool_install(): + if not uv: + print("✗ Detected a uv-tool install but `uv` is not on PATH; install uv and retry.") + sys.exit(1) + cmd = [uv, "tool", "upgrade", "hermes-agent"] + elif pipx_managed and pipx: + # pipx owns its own venv; ``pipx upgrade`` is the only correct path. + # Matches scripts/auto-update.sh, which already uses pipx upgrade. + cmd = [pipx, "upgrade", "hermes-agent"] + elif uv: cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"] + if in_venv: + # Launcher shim runs the venv interpreter but doesn't export + # VIRTUAL_ENV; without it uv errors "No virtual environment found". + export_virtualenv = True + else: + # Outside any venv, ``--system`` lets uv target the active + # interpreter, matching pip's default behaviour. + cmd.insert(3, "--system") else: cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"] print(f"→ Running: {' '.join(cmd)}") run_kwargs = {} - if sys.prefix != sys.base_prefix: + if export_virtualenv: run_kwargs["env"] = {**os.environ, "VIRTUAL_ENV": sys.prefix} result = subprocess.run(cmd, **run_kwargs) if result.returncode != 0: -
scripts/release.py@@ -651,6 +651,7 @@ "[redacted:email]": "alexzhu0", "[redacted:email]": "AntAISecurityLab", "[redacted:email]": "briandevans", + "[redacted:email]": "inchargeautomation-lab", "[redacted:email]": "Bartok9", "[redacted:email]": "sweetcornna", "[redacted:email]": "CruxExperts", -
tests/hermes_cli/test_uv_tool_update.py@@ -0,0 +1,311 @@ +"""Tests for uv-tool install detection in the update path (issue #29700). + +``uv tool install hermes-agent`` lives outside any venv, so the previous +``uv pip install --upgrade`` update path failed with ``No virtual +environment found``. ``is_uv_tool_install`` should detect this layout and +both the user-facing recommended command and the actual +``_cmd_update_pip`` subprocess invocation should switch to +``uv tool upgrade hermes-agent``. + +Detection is restricted to properties of the running interpreter +(``sys.prefix`` / ``sys.executable``) so a pip/venv install on a machine +that also has ``uv tool install hermes-agent`` does not get misclassified. +""" +from __future__ import annotations + +import subprocess +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + + +# --------------------------------------------------------------------------- +# is_uv_tool_install +# --------------------------------------------------------------------------- + + +class TestIsUvToolInstall: + def test_returns_true_when_sys_prefix_matches_uv_tool_layout(self): + from hermes_cli import config + + with patch.object(config.sys, "prefix", "/home/user/.local/share/uv/tools/hermes-agent"): + assert config.is_uv_tool_install() is True + + def test_returns_true_when_sys_executable_matches_uv_tool_layout(self): + """Some uv-tool layouts surface the marker on ``sys.executable`` (bin/python).""" + from hermes_cli import config + + with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ + patch.object( + config.sys, + "executable", + "/home/user/.local/share/uv/tools/hermes-agent/bin/python", + ): + assert config.is_uv_tool_install() is True + + def test_returns_false_when_neither_prefix_nor_executable_matches(self): + from hermes_cli import config + + with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ + patch.object(config.sys, "executable", "/usr/bin/python3"): + assert config.is_uv_tool_install() is False + + def test_does_not_consult_uv_tool_list(self): + """Detection must NOT shell out: ``uv tool list`` would false-positive + when the active install is pip/venv but the machine also has + ``uv tool install hermes-agent`` somewhere on disk. Copilot review on + PR #29703 flagged this; the fix is to never call ``uv tool list`` + from the detection path.""" + from hermes_cli import config + + with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ + patch.object(config.sys, "executable", "/usr/bin/python3"), \ + patch("subprocess.run") as mock_run: + assert config.is_uv_tool_install() is False + mock_run.assert_not_called() + + def test_case_insensitive_match(self): + """Match must be case-insensitive — Windows paths preserve case + (e.g. ``...AppData\\Local\\UV\\Tools\\hermes-agent``) and a case-sensitive + check would miss them. We exercise the lower-cased compare path here + without monkey-patching ``os.sep``, which would break the whole suite.""" + from hermes_cli import config + + with patch.object( + config.sys, "prefix", "/HOME/USER/.local/share/UV/Tools/hermes-agent" + ): + assert config.is_uv_tool_install() is True + + def test_handles_empty_executable(self): + from hermes_cli import config + + with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ + patch.object(config.sys, "executable", ""): + assert config.is_uv_tool_install() is False + + +# --------------------------------------------------------------------------- +# recommended_update_command_for_method +# --------------------------------------------------------------------------- + + +class TestRecommendedUpdateCommandForUvTool: + def test_uv_tool_install_recommends_uv_tool_upgrade(self): + from hermes_cli import config + + with patch("shutil.which", return_value="/usr/local/bin/uv"), \ + patch.object(config, "is_uv_tool_install", return_value=True): + cmd = config.recommended_update_command_for_method("pip") + assert cmd == "uv tool upgrade hermes-agent" + + def test_uv_tool_install_recommends_uv_tool_upgrade_even_without_uv_on_path(self): + """Recommendation reflects the *install method*, not whether ``uv`` is + currently on PATH — the user needs to know the right command to run.""" + from hermes_cli import config + + with patch("shutil.which", return_value=None), \ + patch.object(config, "is_uv_tool_install", return_value=True): + cmd = config.recommended_update_command_for_method("pip") + assert cmd == "uv tool upgrade hermes-agent" + + def test_uv_pip_install_keeps_legacy_recommendation(self): + """Existing behavior: uv is on PATH but Hermes is a regular pip install.""" + from hermes_cli import config + + with patch("shutil.which", return_value="/usr/local/bin/uv"), \ + patch.object(config, "is_uv_tool_install", return_value=False): + cmd = config.recommended_update_command_for_method("pip") + assert cmd == "uv pip install --upgrade hermes-agent" + + def test_no_uv_falls_back_to_plain_pip(self): + from hermes_cli import config + + with patch("shutil.which", return_value=None), \ + patch.object(config, "is_uv_tool_install", return_value=False): + cmd = config.recommended_update_command_for_method("pip") + assert cmd == "pip install --upgrade hermes-agent" + + def test_recommendation_does_not_spawn_subprocess(self): + """Computing the recommendation string must be cheap — no ``uv tool list`` + spawn. Copilot review on PR #29703 flagged the prior subprocess hop + as adding overhead and a multi-second timeout window for what is + purely a display string.""" + from hermes_cli import config + + with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ + patch.object(config.sys, "executable", "/usr/bin/python3"), \ + patch("shutil.which", return_value="/usr/local/bin/uv"), \ + patch("subprocess.run") as mock_run: + cmd = config.recommended_update_command_for_method("pip") + mock_run.assert_not_called() + assert cmd == "uv pip install --upgrade hermes-agent" + + +# --------------------------------------------------------------------------- +# _cmd_update_pip subprocess command +# --------------------------------------------------------------------------- + + +class TestCmdUpdatePipUsesUvTool: + @patch("subprocess.run") + def test_runs_uv_tool_upgrade_when_uv_tool_install(self, mock_run): + """The actual subprocess invocation must switch to ``uv tool upgrade``.""" + from hermes_cli.main import _cmd_update_pip + + mock_run.return_value = subprocess.CompletedProcess(["uv"], 0, stdout="", stderr="") + with patch("shutil.which", return_value="/usr/local/bin/uv"), \ + patch("hermes_cli.config.is_uv_tool_install", return_value=True): + _cmd_update_pip(SimpleNamespace()) + + assert mock_run.call_args[0][0] == ["/usr/local/bin/uv", "tool", "upgrade", "hermes-agent"] + + @patch("subprocess.run") + def test_runs_uv_pip_install_when_not_uv_tool(self, mock_run): + """Existing behavior preserved when uv is present but Hermes isn't a tool install.""" + from hermes_cli.main import _cmd_update_pip + + mock_run.return_value = subprocess.CompletedProcess(["uv"], 0, stdout="", stderr="") + with patch("shutil.which", return_value="/usr/local/bin/uv"), \ + patch("hermes_cli.config.is_uv_tool_install", return_value=False): + _cmd_update_pip(SimpleNamespace()) + + assert mock_run.call_args[0][0] == [ + "/usr/local/bin/uv", + "pip", + "install", + "--upgrade", + "hermes-agent", + ] + + @patch("subprocess.run") + def test_falls_back_to_pip_when_no_uv(self, mock_run): + from hermes_cli.main import _cmd_update_pip + + mock_run.return_value = subprocess.CompletedProcess(["pip"], 0, stdout="", stderr="") + with patch("shutil.which", return_value=None), \ + patch("hermes_cli.config.is_uv_tool_install", return_value=False): + _cmd_update_pip(SimpleNamespace()) + + cmd = mock_run.call_args[0][0] + assert cmd[1:] == ["-m", "pip", "install", "--upgrade", "hermes-agent"] + + @patch("subprocess.run") + def test_exits_nonzero_on_subprocess_failure(self, mock_run): + from hermes_cli.main import _cmd_update_pip + + mock_run.return_value = subprocess.CompletedProcess(["uv"], 1, stdout="", stderr="") + with patch("shutil.which", return_value="/usr/local/bin/uv"), \ + patch("hermes_cli.config.is_uv_tool_install", return_value=True): + with pytest.raises(SystemExit) as exc_info: + _cmd_update_pip(SimpleNamespace()) + assert exc_info.value.code == 1 + + @patch("subprocess.run") + def test_uv_tool_install_without_uv_on_path_exits_with_hint(self, mock_run): + """If the running interpreter looks like a uv-tool install but ``uv`` is + somehow missing from PATH, surface a clear hint instead of silently + falling back to ``python -m pip``, which would either fail (no venv) + or upgrade the wrong copy.""" + from hermes_cli.main import _cmd_update_pip + + with patch("shutil.which", return_value=None), \ + patch("hermes_cli.config.is_uv_tool_install", return_value=True): + with pytest.raises(SystemExit) as exc_info: + _cmd_update_pip(SimpleNamespace()) + assert exc_info.value.code == 1 + mock_run.assert_not_called() + + +# --------------------------------------------------------------------------- +# pipx-managed installs, --system fallback, and VIRTUAL_ENV overlay +# (issue #29700 / #35031 family — consolidated update-path handling) +# --------------------------------------------------------------------------- + + +class TestCmdUpdatePipInstallLayouts: + """The uv pip path must adapt to where the running interpreter lives: + + - inside a venv (launcher shim) -> export VIRTUAL_ENV, no ``--system`` + - bare pip outside any venv -> add ``--system``, no overlay + - pipx-managed -> ``pipx upgrade`` + """ + + @patch("subprocess.run") + def test_pipx_managed_uses_pipx_upgrade(self, mock_run, monkeypatch): + from hermes_cli import main as hm + + mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") + monkeypatch.setattr(hm.sys, "prefix", "/home/u/.local/pipx/venvs/hermes-agent") + monkeypatch.setattr(hm.sys, "base_prefix", "/usr") + + def _which(name): + return {"uv": "/usr/bin/uv", "pipx": "/usr/bin/pipx"}.get(name) + + with patch("shutil.which", side_effect=_which), \ + patch("hermes_cli.config.is_uv_tool_install", return_value=False): + hm._cmd_update_pip(SimpleNamespace()) + + assert mock_run.call_args[0][0] == ["/usr/bin/pipx", "upgrade", "hermes-agent"] + # pipx upgrade ignores VIRTUAL_ENV; we must not set it. + assert "env" not in mock_run.call_args.kwargs + + @patch("subprocess.run") + def test_pipx_layout_without_pipx_binary_treated_as_venv( + self, mock_run, monkeypatch + ): + from hermes_cli import main as hm + + mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") + monkeypatch.setattr(hm.sys, "prefix", "/home/u/.local/pipx/venvs/hermes-agent") + monkeypatch.setattr(hm.sys, "base_prefix", "/usr") + + # pipx layout detected via prefix, but pipx binary missing on PATH. + def _which(name): + return "/usr/bin/uv" if name == "uv" else None + + with patch("shutil.which", side_effect=_which), \ + patch("hermes_cli.config.is_uv_tool_install", return_value=False): + hm._cmd_update_pip(SimpleNamespace()) + + # prefix != base_prefix, so this is treated as a venv -> overlay, no --system. + assert mock_run.call_args[0][0] == [ + "/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent", + ] + assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"].endswith("hermes-agent") + + @patch("subprocess.run") + def test_bare_pip_outside_venv_adds_system(self, mock_run, monkeypatch): + from hermes_cli import main as hm + + mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") + # No venv: prefix == base_prefix. + monkeypatch.setattr(hm.sys, "prefix", "/usr") + monkeypatch.setattr(hm.sys, "base_prefix", "/usr") + + with patch("shutil.which", return_value="/usr/bin/uv"), \ + patch("hermes_cli.config.is_uv_tool_install", return_value=False): + hm._cmd_update_pip(SimpleNamespace()) + + assert mock_run.call_args[0][0] == [ + "/usr/bin/uv", "pip", "install", "--system", "--upgrade", "hermes-agent", + ] + assert "env" not in mock_run.call_args.kwargs + + @patch("subprocess.run") + def test_venv_exports_virtualenv_and_omits_system(self, mock_run, monkeypatch): + from hermes_cli import main as hm + + mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + monkeypatch.setattr(hm.sys, "prefix", "/home/u/.hermes/hermes-agent/venv") + monkeypatch.setattr(hm.sys, "base_prefix", "/usr") + + with patch("shutil.which", return_value="/usr/bin/uv"), \ + patch("hermes_cli.config.is_uv_tool_install", return_value=False): + hm._cmd_update_pip(SimpleNamespace()) + + cmd = mock_run.call_args[0][0] + assert "--system" not in cmd + assert cmd == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"] + assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/home/u/.hermes/hermes-agent/venv"
-
8 PRs closed without merging
From the close comments: merged via another change 3 · closed without comment 1 · done elsewhere 1 · duplicate 1 · superseded by another PR 1 · other 1
- #2655 fix: use sys.executable for pip in update script to fix PEP 668 (#2648) · merged via another change
-
#2715 fix(cli): use venv pip in update command instead of system pip · done elsewhere →
#3099,#2655 - #29701 fix(cli): add --system flag to uv pip install for uv tool installs (#29700) · closed without comment
-
#29703 fix(cli): use `uv tool upgrade` when Hermes is a uv tool install (#29700) · merged via another change →
#35248 -
#29707 fix(cli): use 'uv tool upgrade' when hermes was installed via 'uv tool install' (#29700) · superseded by another PR →
#35248
Show 3 more
-
#35064 fix(update): export launcher virtualenv to uv (#35031) · merged via another change →
#35224 -
#48726 fix(cli): pin --python and add --break-system-packages for no-venv uv update · other →
#48721 -
#48756 fix(cli): target correct Python in no-venv uv update path (#48721) · duplicate →
#48726,#48721
Closed issues · 6
- Closed#2648Update script uses bare 'pip' instead of venv pip, fails on Debian/Ubuntu with externally-managed-environment error
- Closed#29700`hermes update` fails when Hermes is installed via `uv tool install`
- Closed#35031hermes update fails with 'No virtual environment found' when launcher venv is not active in shell
- Closed#35105[Bug]: `hermes update` fails on a PyPI/editable install when `uv` is available
- Closed#39444fix: `hermes update` fails with 'No virtual environment found' when using uv
- Closed#48721[Bug]: hermes update on system Python targets wrong interpreter and hits PEP 668 (no-venv + uv branch)
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.10: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab