diff --git a/integrations/hermes/__init__.py b/integrations/hermes/__init__.py index 2793e9fd3..5dfb6f8e1 100644 --- a/integrations/hermes/__init__.py +++ b/integrations/hermes/__init__.py @@ -407,16 +407,23 @@ def _clean_child_env(env: dict[str, str] | None = None) -> dict[str, str]: def _bm_binary_path() -> str | None: - """Find the bm CLI without making network calls. Used by is_available().""" - candidates = [ - os.path.expanduser("~/.local/bin/bm"), + """Find the bm CLI without spawning a process. + + ``which("bm")`` wins so a stale ``~/.local/bin/bm`` from ``uv tool install`` + does not shadow the binary on PATH. + """ + resolved = which("bm") + if resolved: + return resolved + candidates = ( "/opt/homebrew/bin/bm", "/usr/local/bin/bm", - ] - for c in candidates: - if os.path.isfile(c) and os.access(c, os.X_OK): - return c - return which("bm") + os.path.expanduser("~/.local/bin/bm"), + ) + for candidate in candidates: + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None def _uv_binary_path() -> str | None: @@ -496,8 +503,18 @@ def _config_path(hermes_home: str) -> Path: def _bm_config_path() -> Path: - """Location of bm's own project registry.""" - return Path.home() / ".basic-memory" / "config.json" + """bm project registry, same directory order as ``resolve_data_dir()``. + + Copied here because this file loads as a standalone plugin module. + A missing file stays missing. + """ + if config_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"): + directory = Path(config_dir) + elif xdg_config := os.getenv("XDG_CONFIG_HOME"): + directory = Path(xdg_config) / "basic-memory" + else: + directory = Path.home() / ".basic-memory" + return directory / "config.json" def _bm_known_projects() -> dict[str, Any] | None: @@ -1199,9 +1216,9 @@ def _server_argv(self) -> list[str]: bm = _bm_binary_path() if not bm: raise RuntimeError("bm CLI not found on PATH") - # `bm mcp` works the same in local and cloud modes — bm reads - # ~/.basic-memory/config.json to know whether the active project is local - # or cloud-routed. We pass --project on each tool call. + # `bm mcp` works the same in local and cloud modes. bm reads the + # registry at _bm_config_path() to see whether the active project is + # local or cloud-routed. We pass --project on each tool call. return [bm, "mcp"] def shutdown(self) -> None: @@ -1502,7 +1519,7 @@ def get_config_schema(self) -> list[dict[str, Any]]: return [ { "key": "mode", - "description": "Backend mode: local (writes to ~/.basic-memory/) or cloud", + "description": "Backend mode: local (in-process) or cloud (BM Cloud API)", "default": "local", "choices": ["local", "cloud"], }, diff --git a/integrations/hermes/tests/test_bm_paths.py b/integrations/hermes/tests/test_bm_paths.py new file mode 100644 index 000000000..356b09f89 --- /dev/null +++ b/integrations/hermes/tests/test_bm_paths.py @@ -0,0 +1,151 @@ +"""Registry path and bm binary lookup for the Hermes plugin.""" + +from __future__ import annotations + +import json +from pathlib import Path + +LOCAL_BM = "/home/tester/.local/bin/bm" +HOMEBREW_BM = "/opt/homebrew/bin/bm" +USR_LOCAL_BM = "/usr/local/bin/bm" + + +def _write_projects(path: Path, projects: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"projects": projects})) + + +def _patch_home(monkeypatch, bm, home: Path) -> None: + monkeypatch.setattr(bm.Path, "home", lambda: home) + + +def _patch_binary_lookup( + monkeypatch, + bm, + *, + which_result: str | None, + files: set[str], + executable: set[str], +) -> None: + monkeypatch.setattr(bm, "which", lambda name: which_result if name == "bm" else None) + monkeypatch.setattr( + bm.os.path, + "expanduser", + lambda path: LOCAL_BM if path == "~/.local/bin/bm" else path, + ) + monkeypatch.setattr(bm.os.path, "isfile", lambda path: path in files) + monkeypatch.setattr(bm.os, "access", lambda path, mode: path in executable) + + +def test_config_dir_beats_xdg_for_project_registry(bm, monkeypatch, tmp_path): + """BASIC_MEMORY_CONFIG_DIR wins even when an XDG registry also exists.""" + custom = tmp_path / "custom" + xdg = tmp_path / "xdg" + _write_projects(custom / "config.json", {"from-config-dir": {"path": "/custom"}}) + _write_projects(xdg / "basic-memory" / "config.json", {"from-xdg": {"path": "/xdg"}}) + monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + + assert bm._bm_config_path() == custom / "config.json" + assert bm._bm_known_projects() == {"from-config-dir": {"path": "/custom"}} + + +def test_xdg_registry_used_when_legacy_home_file_omits_project(bm, monkeypatch, tmp_path): + """A project that exists only under XDG is visible, and the legacy file is not.""" + home = tmp_path / "home" + xdg = tmp_path / "xdg" + _patch_home(monkeypatch, bm, home) + monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False) + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + _write_projects(home / ".basic-memory" / "config.json", {"legacy-only": {}}) + _write_projects( + xdg / "basic-memory" / "config.json", + {"hermes-memory": {"path": "/notes"}}, + ) + + assert bm._bm_config_path() == xdg / "basic-memory" / "config.json" + assert bm._bm_known_projects() == {"hermes-memory": {"path": "/notes"}} + + +def test_empty_config_dir_falls_through_to_xdg(bm, monkeypatch, tmp_path): + xdg = tmp_path / "xdg" + monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", "") + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + + assert bm._bm_config_path() == xdg / "basic-memory" / "config.json" + + +def test_config_path_defaults_to_home_and_missing_file_stays_missing(bm, monkeypatch, tmp_path): + home = tmp_path / "home" + _patch_home(monkeypatch, bm, home) + monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + path = bm._bm_config_path() + + assert path == home / ".basic-memory" / "config.json" + assert path.exists() is False + assert bm._bm_known_projects() is None + + +def test_bm_binary_path_prefers_which_over_stale_local_bin(bm, monkeypatch): + """PATH's bm wins while ~/.local/bin/bm still exists and is executable.""" + _patch_binary_lookup( + monkeypatch, + bm, + which_result="/usr/bin/bm", + files={LOCAL_BM, HOMEBREW_BM, USR_LOCAL_BM}, + executable={LOCAL_BM, HOMEBREW_BM, USR_LOCAL_BM}, + ) + + assert bm._bm_binary_path() == "/usr/bin/bm" + + +def test_bm_binary_path_falls_back_homebrew_then_usr_local_then_local(bm, monkeypatch): + """With nothing on PATH, fixed locations run homebrew, /usr/local, then ~/.local.""" + _patch_binary_lookup( + monkeypatch, + bm, + which_result=None, + files={HOMEBREW_BM, USR_LOCAL_BM, LOCAL_BM}, + executable={HOMEBREW_BM, USR_LOCAL_BM, LOCAL_BM}, + ) + assert bm._bm_binary_path() == HOMEBREW_BM + + _patch_binary_lookup( + monkeypatch, + bm, + which_result=None, + files={USR_LOCAL_BM, LOCAL_BM}, + executable={USR_LOCAL_BM, LOCAL_BM}, + ) + assert bm._bm_binary_path() == USR_LOCAL_BM + + _patch_binary_lookup( + monkeypatch, + bm, + which_result=None, + files={LOCAL_BM}, + executable={LOCAL_BM}, + ) + assert bm._bm_binary_path() == LOCAL_BM + + +def test_bm_binary_path_skips_non_executable_and_returns_none(bm, monkeypatch): + _patch_binary_lookup( + monkeypatch, + bm, + which_result=None, + files={HOMEBREW_BM, USR_LOCAL_BM}, + executable={USR_LOCAL_BM}, + ) + assert bm._bm_binary_path() == USR_LOCAL_BM + + _patch_binary_lookup( + monkeypatch, + bm, + which_result=None, + files=set(), + executable=set(), + ) + assert bm._bm_binary_path() is None