diff --git a/src/basic_memory/cli/commands/hook.py b/src/basic_memory/cli/commands/hook.py index 679c4b636..81251f90d 100644 --- a/src/basic_memory/cli/commands/hook.py +++ b/src/basic_memory/cli/commands/hook.py @@ -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. @@ -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 @@ -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 @@ -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" diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 6f8bce19c..083fe8c4d 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -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)) diff --git a/tests/cli/test_hook_command.py b/tests/cli/test_hook_command.py index bb3c632ef..cf24635ec 100644 --- a/tests/cli/test_hook_command.py +++ b/tests/cli/test_hook_command.py @@ -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"