diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index c3315f8d0..b156c75e3 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -371,13 +371,37 @@ def print_systemd_linger_guidance() -> None: def get_launchd_plist_path() -> Path: return Path.home() / "Library" / "LaunchAgents" / "ai.hermes.gateway.plist" +def _detect_venv_dir() -> Path | None: + """Detect the active virtualenv directory. + + Checks ``sys.prefix`` first (works regardless of the directory name), + then falls back to probing common directory names under PROJECT_ROOT. + Returns ``None`` when no virtualenv can be found. + """ + # If we're running inside a virtualenv, sys.prefix points to it. + if sys.prefix != sys.base_prefix: + venv = Path(sys.prefix) + if venv.is_dir(): + return venv + + # Fallback: check common virtualenv directory names under the project root. + for candidate in (".venv", "venv"): + venv = PROJECT_ROOT / candidate + if venv.is_dir(): + return venv + + return None + + def get_python_path() -> str: - if is_windows(): - venv_python = PROJECT_ROOT / "venv" / "Scripts" / "python.exe" - else: - venv_python = PROJECT_ROOT / "venv" / "bin" / "python" - if venv_python.exists(): - return str(venv_python) + venv = _detect_venv_dir() + if venv is not None: + if is_windows(): + venv_python = venv / "Scripts" / "python.exe" + else: + venv_python = venv / "bin" / "python" + if venv_python.exists(): + return str(venv_python) return sys.executable def get_hermes_cli_path() -> str: @@ -399,8 +423,9 @@ def get_hermes_cli_path() -> str: def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) -> str: python_path = get_python_path() working_dir = str(PROJECT_ROOT) - venv_dir = str(PROJECT_ROOT / "venv") - venv_bin = str(PROJECT_ROOT / "venv" / "bin") + detected_venv = _detect_venv_dir() + venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv") + venv_bin = str(detected_venv / "bin") if detected_venv else str(PROJECT_ROOT / "venv" / "bin") node_bin = str(PROJECT_ROOT / "node_modules" / ".bin") path_entries = [venv_bin, node_bin] diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 0bfe1a98a..0189f036b 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -282,6 +282,78 @@ class TestGatewaySystemServiceRouting: assert run_calls == [] +class TestDetectVenvDir: + """Tests for _detect_venv_dir() virtualenv detection.""" + + def test_detects_active_virtualenv_via_sys_prefix(self, tmp_path, monkeypatch): + venv_path = tmp_path / "my-custom-venv" + venv_path.mkdir() + monkeypatch.setattr("sys.prefix", str(venv_path)) + monkeypatch.setattr("sys.base_prefix", "/usr") + + result = gateway_cli._detect_venv_dir() + assert result == venv_path + + def test_falls_back_to_dot_venv_directory(self, tmp_path, monkeypatch): + # Not inside a virtualenv + monkeypatch.setattr("sys.prefix", "/usr") + monkeypatch.setattr("sys.base_prefix", "/usr") + monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) + + dot_venv = tmp_path / ".venv" + dot_venv.mkdir() + + result = gateway_cli._detect_venv_dir() + assert result == dot_venv + + def test_falls_back_to_venv_directory(self, tmp_path, monkeypatch): + monkeypatch.setattr("sys.prefix", "/usr") + monkeypatch.setattr("sys.base_prefix", "/usr") + monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) + + venv = tmp_path / "venv" + venv.mkdir() + + result = gateway_cli._detect_venv_dir() + assert result == venv + + def test_prefers_dot_venv_over_venv(self, tmp_path, monkeypatch): + monkeypatch.setattr("sys.prefix", "/usr") + monkeypatch.setattr("sys.base_prefix", "/usr") + monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) + + (tmp_path / ".venv").mkdir() + (tmp_path / "venv").mkdir() + + result = gateway_cli._detect_venv_dir() + assert result == tmp_path / ".venv" + + def test_returns_none_when_no_virtualenv(self, tmp_path, monkeypatch): + monkeypatch.setattr("sys.prefix", "/usr") + monkeypatch.setattr("sys.base_prefix", "/usr") + monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) + + result = gateway_cli._detect_venv_dir() + assert result is None + + +class TestGeneratedUnitUsesDetectedVenv: + def test_systemd_unit_uses_dot_venv_when_detected(self, tmp_path, monkeypatch): + dot_venv = tmp_path / ".venv" + dot_venv.mkdir() + (dot_venv / "bin").mkdir() + + monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: dot_venv) + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(dot_venv / "bin" / "python")) + + unit = gateway_cli.generate_systemd_unit(system=False) + + assert f"VIRTUAL_ENV={dot_venv}" in unit + assert f"{dot_venv}/bin" in unit + # Must NOT contain a hardcoded /venv/ path + assert "/venv/" not in unit or "/.venv/" in unit + + class TestEnsureUserSystemdEnv: """Tests for _ensure_user_systemd_env() D-Bus session bus auto-detection."""