diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 86b525469..b55d3f65a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8977,19 +8977,43 @@ def _cmd_update_pip(args): print("→ Checking PyPI for updates...") uv = shutil.which("uv") + in_venv = sys.prefix != sys.base_prefix + # pipx-managed installs live under .../pipx/venvs//... + 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: diff --git a/tests/hermes_cli/test_uv_tool_update.py b/tests/hermes_cli/test_uv_tool_update.py index b51fefe3b..b5905c9b7 100644 --- a/tests/hermes_cli/test_uv_tool_update.py +++ b/tests/hermes_cli/test_uv_tool_update.py @@ -215,3 +215,97 @@ class TestCmdUpdatePipUsesUvTool: _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"