Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 55 additions & 11 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import os
import sys
import sysconfig
import subprocess
import shutil
from pathlib import Path
Expand Down Expand Up @@ -1492,12 +1493,35 @@ def run_doctor(args):
_section("Command Installation")
# Determine the venv entry point location
_venv_bin = None
_uses_environment_entry_point = False
for _venv_name in ("venv", ".venv"):
_candidate = PROJECT_ROOT / _venv_name / "bin" / "hermes"
if _candidate.exists():
_venv_bin = _candidate
break

# A wheel-installed virtualenv keeps the console script under the
# environment's scripts directory while PROJECT_ROOT points inside
# site-packages. The source-checkout candidates above cannot describe
# that layout, so consult Python's platform-aware install scheme before
# reporting a missing entry point (#49529).
_active_venv = sys.prefix != getattr(sys, "base_prefix", sys.prefix)
_environment_scripts_dir = None
if _active_venv:
try:
_scripts_path = sysconfig.get_path("scripts")
if _scripts_path:
_environment_scripts_dir = Path(_scripts_path)
except (KeyError, TypeError, ValueError):
pass
if _venv_bin is None and _environment_scripts_dir is not None:
_candidate = _environment_scripts_dir / "hermes"
if _candidate.exists():
_venv_bin = _candidate
_uses_environment_entry_point = True

_source_checkout = (PROJECT_ROOT / "pyproject.toml").is_file()

# Determine the expected command link directory (mirrors install.sh logic)
_prefix = os.environ.get("PREFIX", "")
_is_termux_env = bool(os.environ.get("TERMUX_VERSION")) or "com.termux/files/usr" in _prefix
Expand All @@ -1510,18 +1534,38 @@ def run_doctor(args):
_cmd_link = _cmd_link_dir / "hermes"

if _venv_bin is None:
check_warn(
"Venv entry point not found",
"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')"
)
manual_issues.append(
f"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'"
)
if _active_venv and not _source_checkout:
_expected_dir = _environment_scripts_dir or Path(sys.prefix) / "bin"
_reinstall_cmd = (
f"{sys.executable} -m pip install --force-reinstall hermes-agent"
)
check_warn(
"Venv entry point not found",
f"(hermes not in {_expected_dir} — reinstall with {_reinstall_cmd})",
)
manual_issues.append(f"Reinstall entry point: {_reinstall_cmd}")
else:
check_warn(
"Venv entry point not found",
"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')"
)
manual_issues.append(
f"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'"
)
else:
check_ok(f"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})")

# Check the symlink at the command link location
if _cmd_link.is_symlink():
try:
_venv_bin_display = _venv_bin.relative_to(PROJECT_ROOT)
except ValueError:
_venv_bin_display = _venv_bin
check_ok(f"Venv entry point exists ({_venv_bin_display})")

# A wheel's console script is already installed in the active
# environment. Requiring a second global link would turn a
# healthy isolated venv into a false failure and make --fix leak
# that environment into the user's global command path.
if _uses_environment_entry_point and not _source_checkout:
check_ok("Active environment entry point needs no global symlink")
elif _cmd_link.is_symlink():
_target = _cmd_link.resolve()
_expected = _venv_bin.resolve()
if _target == _expected:
Expand Down
81 changes: 81 additions & 0 deletions tests/hermes_cli/test_doctor_command_install.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for the Command Installation check in hermes doctor."""

import sys
import sysconfig
import types
from argparse import Namespace
from pathlib import Path
Expand Down Expand Up @@ -167,6 +168,8 @@ def test_missing_venv_entry_point_shows_warn(self, monkeypatch, tmp_path):
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(sys, "prefix", str(tmp_path / "system-python"))
monkeypatch.setattr(sys, "base_prefix", str(tmp_path / "system-python"))

fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
Expand All @@ -189,6 +192,84 @@ def test_missing_venv_entry_point_shows_warn(self, monkeypatch, tmp_path):
assert "Command Installation" in out
assert "Venv entry point not found" in out

@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
@pytest.mark.parametrize("fix", [False, True])
def test_wheel_virtualenv_entry_point_needs_no_global_symlink(
self, monkeypatch, tmp_path, fix
):
"""A wheel venv is healthy without a global command symlink."""
_setup_doctor_env(monkeypatch, tmp_path)
venv = tmp_path / "wheel-venv"
scripts = venv / "bin"
scripts.mkdir(parents=True)
hermes_bin = scripts / "hermes"
hermes_bin.write_text("#!/usr/bin/env python\n# wheel entry point\n")
hermes_bin.chmod(0o755)

site_packages = venv / "lib" / "python3.11" / "site-packages"
site_packages.mkdir(parents=True)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", site_packages)
monkeypatch.setattr(sys, "prefix", str(venv))
monkeypatch.setattr(sys, "base_prefix", str(tmp_path / "base-python"))
real_get_path = sysconfig.get_path
monkeypatch.setattr(
sysconfig,
"get_path",
lambda name, *args, **kwargs: (
str(scripts)
if name == "scripts"
else real_get_path(name, *args, **kwargs)
),
)

monkeypatch.setattr(Path, "home", lambda: tmp_path)

out = _run_doctor(fix=fix)

assert "Venv entry point exists" in out
assert str(hermes_bin) in out
assert "Venv entry point not found" not in out
assert "Active environment entry point needs no global symlink" in out
assert "~/.local/bin/hermes not found" not in out
assert "Missing ~/.local/bin/hermes symlink" not in out
command_link = tmp_path / ".local" / "bin" / "hermes"
assert not command_link.exists()
assert not command_link.is_symlink()

@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_wheel_virtualenv_missing_entry_point_avoids_editable_fix(
self, monkeypatch, tmp_path
):
"""Wheel installs must not be repaired as source checkouts."""
_setup_doctor_env(monkeypatch, tmp_path)
venv = tmp_path / "wheel-venv"
scripts = venv / "bin"
scripts.mkdir(parents=True)
site_packages = venv / "lib" / "python3.11" / "site-packages"
site_packages.mkdir(parents=True)

monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", site_packages)
monkeypatch.setattr(sys, "prefix", str(venv))
monkeypatch.setattr(sys, "base_prefix", str(tmp_path / "base-python"))
monkeypatch.setattr(sys, "executable", str(scripts / "python"))
real_get_path = sysconfig.get_path
monkeypatch.setattr(
sysconfig,
"get_path",
lambda name, *args, **kwargs: (
str(scripts)
if name == "scripts"
else real_get_path(name, *args, **kwargs)
),
)
monkeypatch.setattr(Path, "home", lambda: tmp_path)

out = _run_doctor(fix=True)

assert "Venv entry point not found" in out
assert "pip install -e" not in out
assert "-m pip install --force-reinstall hermes-agent" in out

@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_dot_venv_dir_is_found(self, monkeypatch, tmp_path):
"""The check finds entry points in .venv/ as well as venv/."""
Expand Down