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
45 changes: 31 additions & 14 deletions integrations/hermes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"],
},
Expand Down
151 changes: 151 additions & 0 deletions integrations/hermes/tests/test_bm_paths.py
Original file line number Diff line number Diff line change
@@ -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
Loading