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
73 changes: 50 additions & 23 deletions src/basic_memory/cli/commands/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@
(ported here; the plugin hooks are now zero-logic shims that exec these
verbs): the ``basicMemory`` block of ``.claude/settings.json`` /
``.claude/settings.local.json`` (nearest ancestor, over the user-level
``~/.claude/settings.json``) for Claude, and the nearest project
``.codex/basic-memory.json`` over ``~/.codex/basic-memory.json`` for Codex.
``$CLAUDE_CONFIG_DIR/settings.json``, default ``~/.claude``) for Claude, and
the nearest project ``.codex/basic-memory.json`` over
``~/.codex/basic-memory.json`` for Codex.
``install`` / ``remove`` wire the same verbs into the user-level
harness config for standalone (non-marketplace) users, ownership-tagged so
removal is surgical.
Expand Down Expand Up @@ -237,11 +238,24 @@ def _claude_project_dir(directory: Path) -> Path:
current = current.parent


def _claude_user_dir() -> Path:
"""User-level Claude config directory.

Claude Code treats ``CLAUDE_CONFIG_DIR`` as a full replacement for
``~/.claude``, so profile wrappers point it at a per-account directory.
Honouring it keeps each profile's settings and hook wiring separate;
falling back to ``~/.claude`` leaves single-profile setups unchanged.
"""
override = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
return Path(override).expanduser() if override else Path.home() / ".claude"


def load_claude_settings(directory: Path) -> tuple[dict[str, Any], bool]:
"""Merge basicMemory blocks: user-level settings.json, then project settings.

Precedence (lowest to highest): ``~/.claude/settings.json``, then the
nearest project ``.claude/settings.json`` and ``.claude/settings.local.json``.
Precedence (lowest to highest): ``$CLAUDE_CONFIG_DIR/settings.json``
(default ``~/.claude/settings.json``), then the nearest project
``.claude/settings.json`` and ``.claude/settings.local.json``.
A single user-level block can cover every project; any project can still
pin its own mapping, which wins. ``found`` reports whether any file
declared a block or was malformed — the first-run sentinel for the setup
Expand All @@ -251,23 +265,34 @@ def load_claude_settings(directory: Path) -> tuple[dict[str, Any], bool]:
"""
merged: dict[str, Any] = {"captureEvents": DEFAULT_CAPTURE_EVENTS}
found = False
home = Path.home()
sources: list[tuple[Path, tuple[str, ...]]] = [(home, ("settings.json",))]
user_dir = _claude_user_dir()
sources: list[Path] = [user_dir / "settings.json"]
project = _claude_project_dir(directory)
if project != home:
sources.append((project, ("settings.json", "settings.local.json")))
for base, names in sources:
for name in names:
block, present = _read_claude_block(base / ".claude" / name)
if not present:
continue
found = True
if block is None:
# Trigger: a configured source exists but cannot be trusted.
# Why: its unreadable value may be an explicit capture opt-out.
# Outcome: discard every route and disable capture for this event.
return {"captureEvents": False}, True
merged.update(block)
# Trigger: the ancestor walk reaches $HOME.
# Why: ``~/.claude`` is user-level config, not a project mapping — and with
# CLAUDE_CONFIG_DIR set it belongs to a different profile entirely.
# Outcome: never re-enter it as a higher-precedence project source.
if project != Path.home():
project_dir = project / ".claude"
# A profile dir may *be* this project's .claude. Skip the file already
# read as the user-level source, but keep settings.local.json — it still
# outranks it.
seen = {path.resolve() for path in sources}
for name in ("settings.json", "settings.local.json"):
path = project_dir / name
if path.resolve() not in seen:
sources.append(path)
for path in sources:
block, present = _read_claude_block(path)
if not present:
continue
found = True
if block is None:
# Trigger: a configured source exists but cannot be trusted.
# Why: its unreadable value may be an explicit capture opt-out.
# Outcome: discard every route and disable capture for this event.
return {"captureEvents": False}, True
merged.update(block)
return merged, found


Expand Down Expand Up @@ -1248,11 +1273,13 @@ def _hook_launcher() -> str:
def _hook_config_path(harness: Harness) -> Path:
"""User-level hooks config per harness.

Claude Code reads hooks from the user settings file; Codex standalone
hooks use the same hooks.json schema the plugin ships, at the user level.
Claude Code reads hooks from the user settings file, which follows
``CLAUDE_CONFIG_DIR`` — installing must not edit another profile's
settings. Codex standalone hooks use the same hooks.json schema the
plugin ships, at the user level.
"""
if harness is Harness.claude:
return Path.home() / ".claude" / "settings.json"
return _claude_user_dir() / "settings.json"
return Path.home() / ".codex" / "hooks.json"


Expand Down
5 changes: 5 additions & 0 deletions tests/cli/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ def isolated_home(tmp_path, monkeypatch) -> Path:
monkeypatch.setenv("HOME", str(tmp_path))
if os.name == "nt":
monkeypatch.setenv("USERPROFILE", str(tmp_path))
# Trigger: a contributor runs the suite under a Claude profile wrapper.
# Why: CLAUDE_CONFIG_DIR redirects the user-level settings the hook reads,
# so an ambient value would point tests at their real config.
# Outcome: unset it; tests that exercise it set it explicitly.
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
# Set to tmp_path directly (not tmp_path/basic-memory) so default project
# home is tmp_path - tests expect to find imported files there
monkeypatch.setenv("BASIC_MEMORY_HOME", str(tmp_path))
Expand Down
132 changes: 132 additions & 0 deletions tests/cli/test_hook_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -2111,3 +2111,135 @@ def test_mapping_dir_fallback_order(tmp_path: Path) -> None:
assert hook_module._mapping_dir(explicit, "/payload/cwd") == explicit
assert hook_module._mapping_dir(None, "/payload/cwd") == Path("/payload/cwd")
assert hook_module._mapping_dir(None, "") == Path.cwd()


# --- CLAUDE_CONFIG_DIR (profile-scoped user settings) ---


def _write_user_block(config_dir: Path, block: dict[str, Any]) -> None:
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / "settings.json").write_text(json.dumps({"basicMemory": block}), encoding="utf-8")


def test_claude_config_dir_supplies_user_settings(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
profile = tmp_path / ".claude-profile"
_write_user_block(profile, {"primaryProject": "profile-wide"})
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(profile))
project = tmp_path / "proj"
project.mkdir()

merged, found = hook_module.load_claude_settings(project)

assert found is True
assert merged["primaryProject"] == "profile-wide"


def test_claude_config_dir_ignores_default_home_settings(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_write_user_block(Path.home() / ".claude", {"primaryProject": "other-profile"})
profile = tmp_path / ".claude-profile"
_write_user_block(profile, {"primaryProject": "active-profile"})
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(profile))
project = tmp_path / "proj"
project.mkdir()

merged, _ = hook_module.load_claude_settings(project)

# The other profile's routing must not leak into this one.
assert merged["primaryProject"] == "active-profile"


def test_claude_config_dir_still_loses_to_project_settings(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
profile = tmp_path / ".claude-profile"
_write_user_block(profile, {"primaryProject": "profile-wide", "recallTimeframe": "9d"})
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(profile))
project = tmp_path / "proj"
(project / ".claude").mkdir(parents=True)
_write_claude_settings(project, {"primaryProject": "project-level"})

merged, found = hook_module.load_claude_settings(project)

assert found is True
assert merged["primaryProject"] == "project-level"
assert merged["recallTimeframe"] == "9d"


@pytest.mark.parametrize("value", ["", " "])
def test_claude_config_dir_blank_falls_back_to_home(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, value: str
) -> None:
_write_user_block(Path.home() / ".claude", {"primaryProject": "home-default"})
monkeypatch.setenv("CLAUDE_CONFIG_DIR", value)
project = tmp_path / "proj"
project.mkdir()

merged, found = hook_module.load_claude_settings(project)

assert found is True
assert merged["primaryProject"] == "home-default"


def test_claude_config_dir_expands_user(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_write_user_block(Path.home() / ".claude-profile", {"primaryProject": "expanded"})
monkeypatch.setenv("CLAUDE_CONFIG_DIR", "~/.claude-profile")
project = tmp_path / "proj"
project.mkdir()

merged, _ = hook_module.load_claude_settings(project)

assert merged["primaryProject"] == "expanded"


def test_install_claude_writes_hooks_into_config_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
profile = tmp_path / ".claude-profile"
profile.mkdir()
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(profile))

result = runner.invoke(cli_app, ["hook", "install"])

assert result.exit_code == 0
data = _read_json(profile / "settings.json")
assert data["hooks"]["SessionStart"][0]["hooks"][0]["command"] == (
"basic-memory hook session-start --harness claude"
)
# The default profile's settings must be left alone.
assert not (Path.home() / ".claude" / "settings.json").exists()


def test_remove_claude_hooks_from_config_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
profile = tmp_path / ".claude-profile"
profile.mkdir()
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(profile))
runner.invoke(cli_app, ["hook", "install"])

result = runner.invoke(cli_app, ["hook", "remove"])

assert result.exit_code == 0
assert _read_json(profile / "settings.json").get("hooks", {}) == {}


def test_claude_config_dir_pointing_at_project_keeps_local_override(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
project = tmp_path / "proj"
(project / ".claude").mkdir(parents=True)
_write_claude_settings(project, {"primaryProject": "profile-wide", "recallTimeframe": "9d"})
(project / ".claude" / "settings.local.json").write_text(
json.dumps({"basicMemory": {"primaryProject": "local-override"}}), encoding="utf-8"
)
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(project / ".claude"))

merged, found = hook_module.load_claude_settings(project)

assert found is True
assert merged["primaryProject"] == "local-override"
assert merged["recallTimeframe"] == "9d"