mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
fix(cli): use uv tool upgrade when Hermes is a uv tool install (#29700)
Hermes installed via `uv tool install hermes-agent` lives outside any
venv. `_cmd_update_pip` previously ran `uv pip install --upgrade`, which
errors with `No virtual environment found; run uv venv ...`. The user
hits this on the very first `hermes update` after a standard
non-`--system` install with `uv` on PATH.
Add `is_uv_tool_install()` in `hermes_cli/config.py`: fast path inspects
`sys.prefix` for the standard `uv/tools/hermes-agent/` layout, falls
back to `uv tool list` for non-standard prefixes. Both the
user-facing `recommended_update_command_for_method("pip")` string and
the actual subprocess invocation in `_cmd_update_pip` now switch to
`uv tool upgrade hermes-agent` when detected. Non-tool installs and the
no-`uv` fallback keep their existing commands unchanged.
This commit is contained in:
parent
39f6b6e9d2
commit
1bdb29d938
3 changed files with 223 additions and 1 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
180
tests/hermes_cli/test_uv_tool_update.py
Normal file
180
tests/hermes_cli/test_uv_tool_update.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue