diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a24af13aafc..40aedf6258f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -329,6 +329,42 @@ def stamp_install_method(method: str) -> None: pass +def is_uv_tool_install(uv_path: Optional[str] = None) -> bool: + """Return True when Hermes is installed via ``uv tool install hermes-agent``. + + ``uv tool`` installs live outside any virtualenv, so ``uv pip install`` + (the previous update path) fails with ``No virtual environment found``. + The fast path inspects ``sys.prefix`` for the standard uv tool layout + (``.../uv/tools/hermes-agent/...``); the authoritative fallback shells + out to ``uv tool list``. Returns False on any error so callers fall + back to the legacy pip path. + """ + prefix = os.path.normpath(sys.prefix).replace(os.sep, "/").lower() + if "/uv/tools/hermes-agent/" in prefix + "/": + return True + if uv_path is None: + import shutil + uv_path = shutil.which("uv") + if not uv_path: + return False + try: + result = subprocess.run( + [uv_path, "tool", "list"], + capture_output=True, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + return False + if result.returncode != 0: + return False + for line in result.stdout.splitlines(): + tokens = line.strip().split() + if tokens and tokens[0] == "hermes-agent": + 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": @@ -341,6 +377,8 @@ def recommended_update_command_for_method(method: str) -> str: import shutil uv = shutil.which("uv") if uv: + if is_uv_tool_install(uv): + return "uv tool upgrade hermes-agent" return "uv pip install --upgrade hermes-agent" return "pip install --upgrade hermes-agent" return "hermes update" diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 165866cc67e..50e24fc837b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8971,13 +8971,17 @@ 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: - cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"] + if is_uv_tool_install(uv): + cmd = [uv, "tool", "upgrade", "hermes-agent"] + else: + cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"] else: cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"] diff --git a/tests/hermes_cli/test_uv_tool_update.py b/tests/hermes_cli/test_uv_tool_update.py new file mode 100644 index 00000000000..4e097887308 --- /dev/null +++ b/tests/hermes_cli/test_uv_tool_update.py @@ -0,0 +1,180 @@ +"""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``. +""" +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("uv") is True + + def test_returns_true_when_uv_tool_list_includes_hermes_agent(self): + from hermes_cli import config + + completed = subprocess.CompletedProcess( + ["uv", "tool", "list"], + 0, + stdout="hermes-agent v0.14.0\n- hermes\n- hermes-bot\nblack v23.0.0\n- black\n", + stderr="", + ) + with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ + patch("subprocess.run", return_value=completed) as mock_run: + assert config.is_uv_tool_install("/usr/local/bin/uv") is True + mock_run.assert_called_once() + assert mock_run.call_args[0][0] == ["/usr/local/bin/uv", "tool", "list"] + + def test_returns_false_when_uv_tool_list_lacks_hermes_agent(self): + from hermes_cli import config + + completed = subprocess.CompletedProcess( + ["uv", "tool", "list"], 0, stdout="black v23.0.0\n- black\nruff v0.5.0\n- ruff\n", stderr="" + ) + with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ + patch("subprocess.run", return_value=completed): + assert config.is_uv_tool_install("uv") is False + + def test_returns_false_when_uv_tool_list_fails(self): + from hermes_cli import config + + completed = subprocess.CompletedProcess(["uv", "tool", "list"], 2, stdout="", stderr="oops") + with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ + patch("subprocess.run", return_value=completed): + assert config.is_uv_tool_install("uv") is False + + def test_returns_false_when_subprocess_raises(self): + from hermes_cli import config + + with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ + patch("subprocess.run", side_effect=subprocess.TimeoutExpired(["uv"], 15)): + assert config.is_uv_tool_install("uv") is False + + def test_returns_false_when_no_uv_available(self): + from hermes_cli import config + + with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ + patch("shutil.which", return_value=None): + assert config.is_uv_tool_install() is False + + def test_indented_alias_line_does_not_false_positive(self): + """A tool whose alias line is ``- hermes-agent`` shouldn't match.""" + from hermes_cli import config + + completed = subprocess.CompletedProcess( + ["uv", "tool", "list"], + 0, + stdout="some-other-tool v1.0.0\n- hermes-agent\n", + stderr="", + ) + with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ + patch("subprocess.run", return_value=completed): + assert config.is_uv_tool_install("uv") 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_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.config import recommended_update_command_for_method + + with patch("shutil.which", return_value=None): + cmd = recommended_update_command_for_method("pip") + assert cmd == "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): + _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