mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
hermes doctor now checks whether the ~/.local/bin/hermes symlink exists and points to the correct venv entry point. With --fix, it creates or repairs the symlink automatically. Covers: - Missing symlink at ~/.local/bin/hermes (or $PREFIX/bin on Termux) - Symlink pointing to wrong target - Missing venv entry point (venv/bin/hermes or .venv/bin/hermes) - PATH warning when ~/.local/bin is not on PATH - Skipped on Windows (different mechanism) Addresses user report: 'python -m hermes_cli.main doesn't have an option to fix the local bin/install' 10 new tests covering all scenarios.
275 lines
10 KiB
Python
275 lines
10 KiB
Python
"""Tests for the Command Installation check in hermes doctor."""
|
|
|
|
import os
|
|
import sys
|
|
import types
|
|
from argparse import Namespace
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import hermes_cli.doctor as doctor_mod
|
|
|
|
|
|
def _setup_doctor_env(monkeypatch, tmp_path, venv_name="venv"):
|
|
"""Create a minimal HERMES_HOME + PROJECT_ROOT for doctor tests."""
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
|
|
|
|
project = tmp_path / "project"
|
|
project.mkdir(exist_ok=True)
|
|
|
|
# Create a fake venv entry point
|
|
venv_bin_dir = project / venv_name / "bin"
|
|
venv_bin_dir.mkdir(parents=True, exist_ok=True)
|
|
hermes_bin = venv_bin_dir / "hermes"
|
|
hermes_bin.write_text("#!/usr/bin/env python\n# entry point\n")
|
|
hermes_bin.chmod(0o755)
|
|
|
|
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
|
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
|
|
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
|
|
|
# Stub model_tools so doctor doesn't fail on import
|
|
fake_model_tools = types.SimpleNamespace(
|
|
check_tool_availability=lambda *a, **kw: ([], []),
|
|
TOOLSET_REQUIREMENTS={},
|
|
)
|
|
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
|
|
|
# Stub auth checks
|
|
try:
|
|
from hermes_cli import auth as _auth_mod
|
|
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
|
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
|
except Exception:
|
|
pass
|
|
|
|
# Stub httpx.get to avoid network calls
|
|
try:
|
|
import httpx
|
|
monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200))
|
|
except Exception:
|
|
pass
|
|
|
|
return home, project, hermes_bin
|
|
|
|
|
|
def _run_doctor(fix=False):
|
|
"""Run doctor and capture stdout."""
|
|
import io
|
|
import contextlib
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
doctor_mod.run_doctor(Namespace(fix=fix))
|
|
return buf.getvalue()
|
|
|
|
|
|
class TestDoctorCommandInstallation:
|
|
"""Tests for the ◆ Command Installation section."""
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
|
def test_correct_symlink_shows_ok(self, monkeypatch, tmp_path):
|
|
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
|
|
|
|
# Create the command link dir with correct symlink
|
|
cmd_link_dir = tmp_path / ".local" / "bin"
|
|
cmd_link_dir.mkdir(parents=True)
|
|
cmd_link = cmd_link_dir / "hermes"
|
|
cmd_link.symlink_to(hermes_bin)
|
|
|
|
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 "correct target" in out
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
|
def test_missing_symlink_shows_fail(self, monkeypatch, tmp_path):
|
|
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
|
|
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
# Don't create the symlink — it should be missing
|
|
|
|
out = _run_doctor(fix=False)
|
|
assert "Command Installation" in out
|
|
assert "Venv entry point exists" in out
|
|
assert "not found" in out
|
|
assert "hermes doctor --fix" in out
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
|
def test_fix_creates_missing_symlink(self, monkeypatch, tmp_path):
|
|
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
|
|
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
|
|
out = _run_doctor(fix=True)
|
|
assert "Command Installation" in out
|
|
assert "Created symlink" in out
|
|
|
|
# Verify the symlink was actually created
|
|
cmd_link = tmp_path / ".local" / "bin" / "hermes"
|
|
assert cmd_link.is_symlink()
|
|
assert cmd_link.resolve() == hermes_bin.resolve()
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
|
def test_wrong_target_symlink_shows_warn(self, monkeypatch, tmp_path):
|
|
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
|
|
|
|
# Create a symlink pointing to the wrong target
|
|
cmd_link_dir = tmp_path / ".local" / "bin"
|
|
cmd_link_dir.mkdir(parents=True)
|
|
cmd_link = cmd_link_dir / "hermes"
|
|
wrong_target = tmp_path / "wrong_hermes"
|
|
wrong_target.write_text("#!/usr/bin/env python\n")
|
|
cmd_link.symlink_to(wrong_target)
|
|
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
|
|
out = _run_doctor(fix=False)
|
|
assert "Command Installation" in out
|
|
assert "wrong target" in out
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
|
def test_fix_repairs_wrong_symlink(self, monkeypatch, tmp_path):
|
|
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
|
|
|
|
# Create a symlink pointing to wrong target
|
|
cmd_link_dir = tmp_path / ".local" / "bin"
|
|
cmd_link_dir.mkdir(parents=True)
|
|
cmd_link = cmd_link_dir / "hermes"
|
|
wrong_target = tmp_path / "wrong_hermes"
|
|
wrong_target.write_text("#!/usr/bin/env python\n")
|
|
cmd_link.symlink_to(wrong_target)
|
|
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
|
|
out = _run_doctor(fix=True)
|
|
assert "Fixed symlink" in out
|
|
|
|
# Verify the symlink now points to the correct target
|
|
assert cmd_link.is_symlink()
|
|
assert cmd_link.resolve() == hermes_bin.resolve()
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
|
def test_missing_venv_entry_point_shows_warn(self, monkeypatch, tmp_path):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
|
|
|
|
project = tmp_path / "project"
|
|
project.mkdir(exist_ok=True)
|
|
# Do NOT create any venv entry point
|
|
|
|
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
|
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
|
|
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
|
|
fake_model_tools = types.SimpleNamespace(
|
|
check_tool_availability=lambda *a, **kw: ([], []),
|
|
TOOLSET_REQUIREMENTS={},
|
|
)
|
|
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
|
try:
|
|
from hermes_cli import auth as _auth_mod
|
|
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
|
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
|
except Exception:
|
|
pass
|
|
try:
|
|
import httpx
|
|
monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200))
|
|
except Exception:
|
|
pass
|
|
|
|
out = _run_doctor(fix=False)
|
|
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")
|
|
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")
|
|
|
|
# Create the command link with correct symlink
|
|
hermes_bin = project / ".venv" / "bin" / "hermes"
|
|
cmd_link_dir = tmp_path / ".local" / "bin"
|
|
cmd_link_dir.mkdir(parents=True)
|
|
cmd_link = cmd_link_dir / "hermes"
|
|
cmd_link.symlink_to(hermes_bin)
|
|
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
|
|
out = _run_doctor(fix=False)
|
|
assert "Venv entry point exists" in out
|
|
assert ".venv/bin/hermes" in out
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
|
def test_non_symlink_regular_file_shows_ok(self, monkeypatch, tmp_path):
|
|
"""If ~/.local/bin/hermes is a regular file (not symlink), accept it."""
|
|
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
|
|
|
|
cmd_link_dir = tmp_path / ".local" / "bin"
|
|
cmd_link_dir.mkdir(parents=True)
|
|
cmd_link = cmd_link_dir / "hermes"
|
|
cmd_link.write_text("#!/bin/sh\nexec python -m hermes_cli.main \"$@\"\n")
|
|
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
|
|
out = _run_doctor(fix=False)
|
|
assert "non-symlink" in out
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
|
def test_termux_uses_prefix_bin(self, monkeypatch, tmp_path):
|
|
"""On Termux, the command link dir is $PREFIX/bin."""
|
|
prefix_dir = tmp_path / "termux_prefix"
|
|
prefix_bin = prefix_dir / "bin"
|
|
prefix_bin.mkdir(parents=True)
|
|
|
|
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
|
|
|
|
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
|
monkeypatch.setenv("PREFIX", str(prefix_dir))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
|
|
out = _run_doctor(fix=False)
|
|
assert "Command Installation" in out
|
|
assert "$PREFIX/bin" in out
|
|
|
|
def test_windows_skips_check(self, monkeypatch, tmp_path):
|
|
"""On Windows, the Command Installation section is skipped."""
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
|
|
|
|
project = tmp_path / "project"
|
|
project.mkdir(exist_ok=True)
|
|
|
|
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
|
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
|
|
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
|
monkeypatch.setattr(sys, "platform", "win32")
|
|
|
|
fake_model_tools = types.SimpleNamespace(
|
|
check_tool_availability=lambda *a, **kw: ([], []),
|
|
TOOLSET_REQUIREMENTS={},
|
|
)
|
|
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
|
try:
|
|
from hermes_cli import auth as _auth_mod
|
|
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
|
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
|
except Exception:
|
|
pass
|
|
try:
|
|
import httpx
|
|
monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200))
|
|
except Exception:
|
|
pass
|
|
|
|
out = _run_doctor(fix=False)
|
|
assert "Command Installation" not in out
|