diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 68338bfe89e4..6767bb88ac58 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -6,6 +6,7 @@ import os import sys +import sysconfig import subprocess import shutil from pathlib import Path @@ -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 @@ -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: diff --git a/tests/hermes_cli/test_doctor_command_install.py b/tests/hermes_cli/test_doctor_command_install.py index f394c29e92e5..f0cea7049b40 100644 --- a/tests/hermes_cli/test_doctor_command_install.py +++ b/tests/hermes_cli/test_doctor_command_install.py @@ -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 @@ -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: ([], []), @@ -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/."""