Skip to content
Open
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
96 changes: 78 additions & 18 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
import importlib.util
Expand Down Expand Up @@ -1623,16 +1624,86 @@ def run_doctor(args):
_check_gateway_service_linger(issues)
_check_s6_supervision(issues)

if sys.platform != "win32":
_section("Command Installation")
# Determine the venv entry point location
_venv_bin = None
for _venv_name in ("venv", ".venv"):
_candidate = PROJECT_ROOT / _venv_name / "bin" / "hermes"
# Entry-point detection runs on ALL platforms. The environment's scripts
# directory is platform-aware via sysconfig: on Windows it is
# venv\Scripts (console scripts installed as hermes.exe/.cmd/.bat), on
# POSIX venv/bin (bare "hermes"). Only the *symlink* management below is
# POSIX-specific, so that part stays guarded.
_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:
# Windows console scripts are installed as <name>.exe (plus .cmd/.bat
# shims); POSIX installs a bare <name>. Check all platform variants so
# an environment-installed entry point is not reported missing on
# Windows (verified on Win11: venv\Scripts\hermes.exe, bare "hermes"
# absent) or in cross-layout venvs.
for _script_name in ("hermes", "hermes.exe", "hermes.cmd", "hermes.bat"):
_candidate = _environment_scripts_dir / _script_name
if _candidate.exists():
_venv_bin = _candidate
_uses_environment_entry_point = True
break

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

if _venv_bin is None:
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:
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")

# POSIX-only: the ~/.local/bin symlink management does not apply on
# Windows (command resolution goes through the console script + PATH).
if sys.platform != "win32":
# 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 @@ -1644,18 +1715,7 @@ def run_doctor(args):
_cmd_link_display = "~/.local/bin"
_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]'"
)
else:
check_ok(f"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})")

# Check the symlink at the command link location
if _venv_bin is not None and not (_uses_environment_entry_point and not _source_checkout):
if _cmd_link.is_symlink():
_target = _cmd_link.resolve()
_expected = _venv_bin.resolve()
Expand Down
178 changes: 178 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 @@ -66,6 +67,26 @@ def _run_doctor(fix=False):
return buf.getvalue()


def _stub_posix_stdlib(monkeypatch):
"""Provide minimal POSIX-only stdlib stubs (fcntl) so a win32 host can
exercise the POSIX-guarded doctor section after sys.platform is mocked."""
import types as _types
try:
import fcntl # noqa: F401
except ImportError:
monkeypatch.setitem(
sys.modules,
"fcntl",
_types.SimpleNamespace(
flock=lambda *a, **k: None,
LOCK_EX=2,
LOCK_UN=8,
LOCK_SH=1,
LOCK_NB=4,
),
)


class TestDoctorCommandInstallation:
"""Tests for the ◆ Command Installation section."""

Expand Down Expand Up @@ -108,6 +129,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 @@ -130,7 +153,88 @@ 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/."""
home, project, _ = _setup_doctor_env(monkeypatch, tmp_path, venv_name=".venv")

@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_termux_uses_prefix_bin(self, monkeypatch, tmp_path):
Expand All @@ -149,3 +253,77 @@ def test_termux_uses_prefix_bin(self, monkeypatch, tmp_path):
assert "Command Installation" in out
assert "$PREFIX/bin" in out

@pytest.mark.skipif(sys.platform != "win32", reason="Tests the native Windows console-script layout")
def test_wheel_virtualenv_windows_console_script_entry_point(
self, monkeypatch, tmp_path
):
"""Windows console-script layout (hermes.exe in the env Scripts dir)
must be detected on a real win32 host — no platform fake. The
entry-point detection runs on all platforms; only symlink
management is POSIX-guarded (#49529, verified on Win11:
venv\\Scripts\\hermes.exe, bare \"hermes\" absent)."""
_setup_doctor_env(monkeypatch, tmp_path)
venv = tmp_path / "wheel-venv"
scripts = venv / "Scripts"
scripts.mkdir(parents=True)
hermes_exe = scripts / "hermes.exe"
hermes_exe.write_bytes(b"MZ\x90\x00") # minimal PE header stub

site_packages = venv / "Lib" / "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=False)

assert "Command Installation" in out
assert "Venv entry point exists" in out
assert "hermes.exe" in out
assert "Venv entry point not found" not in out
assert "Active environment entry point needs no global symlink" in out

@pytest.mark.skipif(sys.platform != "win32", reason="Tests the native Windows console-script layout")
def test_wheel_virtualenv_windows_console_script_missing_still_warns(
self, monkeypatch, tmp_path
):
"""Windows layout with no console script at all must still warn (no
false all-clear from the .exe/.cmd/.bat name scan)."""
_setup_doctor_env(monkeypatch, tmp_path)
venv = tmp_path / "wheel-venv"
scripts = venv / "Scripts"
scripts.mkdir(parents=True)

site_packages = venv / "Lib" / "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=False)

assert "Venv entry point not found" in out
assert "Active environment entry point needs no global symlink" not in out

Loading