Skip to content

Commit c189352

Browse files
fzipiclaude
andcommitted
fix(cli): honour CLAUDE_CONFIG_DIR when resolving Claude hook settings
The Claude hook resolved user-level settings as a hardcoded ~/.claude and never read CLAUDE_CONFIG_DIR, which Claude Code treats as a full replacement for that directory. Users running per-account profiles hit two problems: a basicMemory block in the active profile was never read, so hook status reported "settings: not found" and capture fell back to the default project; and `hook install` wrote hook entries into ~/.claude/settings.json regardless of the active profile, editing another account's configuration. Add _claude_user_dir(), honouring CLAUDE_CONFIG_DIR and falling back to ~/.claude so single-profile setups are unchanged, and use it for both the settings base and the install/remove target. The ancestor walk in _claude_project_dir can reach $HOME and find ~/.claude/settings.json. That was previously suppressed by comparing the resolved project root to $HOME; keep that guard alongside the new profile-dir comparison, otherwise the default profile's settings re-enter as a higher-precedence project source and override the active profile. Clear CLAUDE_CONFIG_DIR in the CLI test isolation fixture so a contributor running the suite under a profile wrapper does not read their real config. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Felipe Zipitria <fzipi@fing.edu.uy>
1 parent e91efe6 commit c189352

3 files changed

Lines changed: 162 additions & 23 deletions

File tree

src/basic_memory/cli/commands/hook.py

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@
2222
(ported here; the plugin hooks are now zero-logic shims that exec these
2323
verbs): the ``basicMemory`` block of ``.claude/settings.json`` /
2424
``.claude/settings.local.json`` (nearest ancestor, over the user-level
25-
``~/.claude/settings.json``) for Claude, and the nearest project
26-
``.codex/basic-memory.json`` over ``~/.codex/basic-memory.json`` for Codex.
25+
``$CLAUDE_CONFIG_DIR/settings.json``, default ``~/.claude``) for Claude, and
26+
the nearest project ``.codex/basic-memory.json`` over
27+
``~/.codex/basic-memory.json`` for Codex.
2728
``install`` / ``remove`` wire the same verbs into the user-level
2829
harness config for standalone (non-marketplace) users, ownership-tagged so
2930
removal is surgical.
@@ -237,11 +238,24 @@ def _claude_project_dir(directory: Path) -> Path:
237238
current = current.parent
238239

239240

241+
def _claude_user_dir() -> Path:
242+
"""User-level Claude config directory.
243+
244+
Claude Code treats ``CLAUDE_CONFIG_DIR`` as a full replacement for
245+
``~/.claude``, so profile wrappers point it at a per-account directory.
246+
Honouring it keeps each profile's settings and hook wiring separate;
247+
falling back to ``~/.claude`` leaves single-profile setups unchanged.
248+
"""
249+
override = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
250+
return Path(override).expanduser() if override else Path.home() / ".claude"
251+
252+
240253
def load_claude_settings(directory: Path) -> tuple[dict[str, Any], bool]:
241254
"""Merge basicMemory blocks: user-level settings.json, then project settings.
242255
243-
Precedence (lowest to highest): ``~/.claude/settings.json``, then the
244-
nearest project ``.claude/settings.json`` and ``.claude/settings.local.json``.
256+
Precedence (lowest to highest): ``$CLAUDE_CONFIG_DIR/settings.json``
257+
(default ``~/.claude/settings.json``), then the nearest project
258+
``.claude/settings.json`` and ``.claude/settings.local.json``.
245259
A single user-level block can cover every project; any project can still
246260
pin its own mapping, which wins. ``found`` reports whether any file
247261
declared a block or was malformed — the first-run sentinel for the setup
@@ -251,23 +265,27 @@ def load_claude_settings(directory: Path) -> tuple[dict[str, Any], bool]:
251265
"""
252266
merged: dict[str, Any] = {"captureEvents": DEFAULT_CAPTURE_EVENTS}
253267
found = False
254-
home = Path.home()
255-
sources: list[tuple[Path, tuple[str, ...]]] = [(home, ("settings.json",))]
268+
user_dir = _claude_user_dir()
269+
sources: list[Path] = [user_dir / "settings.json"]
256270
project = _claude_project_dir(directory)
257-
if project != home:
258-
sources.append((project, ("settings.json", "settings.local.json")))
259-
for base, names in sources:
260-
for name in names:
261-
block, present = _read_claude_block(base / ".claude" / name)
262-
if not present:
263-
continue
264-
found = True
265-
if block is None:
266-
# Trigger: a configured source exists but cannot be trusted.
267-
# Why: its unreadable value may be an explicit capture opt-out.
268-
# Outcome: discard every route and disable capture for this event.
269-
return {"captureEvents": False}, True
270-
merged.update(block)
271+
project_dir = project / ".claude"
272+
# Trigger: the ancestor walk reaches $HOME, or the active profile dir.
273+
# Why: ``~/.claude`` is user-level config, not a project mapping — and with
274+
# CLAUDE_CONFIG_DIR set it belongs to a different profile entirely.
275+
# Outcome: never re-enter it as a higher-precedence project source.
276+
if project != Path.home() and project_dir != user_dir:
277+
sources.extend((project_dir / "settings.json", project_dir / "settings.local.json"))
278+
for path in sources:
279+
block, present = _read_claude_block(path)
280+
if not present:
281+
continue
282+
found = True
283+
if block is None:
284+
# Trigger: a configured source exists but cannot be trusted.
285+
# Why: its unreadable value may be an explicit capture opt-out.
286+
# Outcome: discard every route and disable capture for this event.
287+
return {"captureEvents": False}, True
288+
merged.update(block)
271289
return merged, found
272290

273291

@@ -1248,11 +1266,13 @@ def _hook_launcher() -> str:
12481266
def _hook_config_path(harness: Harness) -> Path:
12491267
"""User-level hooks config per harness.
12501268
1251-
Claude Code reads hooks from the user settings file; Codex standalone
1252-
hooks use the same hooks.json schema the plugin ships, at the user level.
1269+
Claude Code reads hooks from the user settings file, which follows
1270+
``CLAUDE_CONFIG_DIR`` — installing must not edit another profile's
1271+
settings. Codex standalone hooks use the same hooks.json schema the
1272+
plugin ships, at the user level.
12531273
"""
12541274
if harness is Harness.claude:
1255-
return Path.home() / ".claude" / "settings.json"
1275+
return _claude_user_dir() / "settings.json"
12561276
return Path.home() / ".codex" / "hooks.json"
12571277

12581278

tests/cli/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ def isolated_home(tmp_path, monkeypatch) -> Path:
3131
monkeypatch.setenv("HOME", str(tmp_path))
3232
if os.name == "nt":
3333
monkeypatch.setenv("USERPROFILE", str(tmp_path))
34+
# Trigger: a contributor runs the suite under a Claude profile wrapper.
35+
# Why: CLAUDE_CONFIG_DIR redirects the user-level settings the hook reads,
36+
# so an ambient value would point tests at their real config.
37+
# Outcome: unset it; tests that exercise it set it explicitly.
38+
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
3439
# Set to tmp_path directly (not tmp_path/basic-memory) so default project
3540
# home is tmp_path - tests expect to find imported files there
3641
monkeypatch.setenv("BASIC_MEMORY_HOME", str(tmp_path))

tests/cli/test_hook_command.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2111,3 +2111,117 @@ def test_mapping_dir_fallback_order(tmp_path: Path) -> None:
21112111
assert hook_module._mapping_dir(explicit, "/payload/cwd") == explicit
21122112
assert hook_module._mapping_dir(None, "/payload/cwd") == Path("/payload/cwd")
21132113
assert hook_module._mapping_dir(None, "") == Path.cwd()
2114+
2115+
2116+
# --- CLAUDE_CONFIG_DIR (profile-scoped user settings) ---
2117+
2118+
2119+
def _write_user_block(config_dir: Path, block: dict[str, Any]) -> None:
2120+
config_dir.mkdir(parents=True, exist_ok=True)
2121+
(config_dir / "settings.json").write_text(json.dumps({"basicMemory": block}), encoding="utf-8")
2122+
2123+
2124+
def test_claude_config_dir_supplies_user_settings(
2125+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2126+
) -> None:
2127+
profile = tmp_path / ".claude-profile"
2128+
_write_user_block(profile, {"primaryProject": "profile-wide"})
2129+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(profile))
2130+
project = tmp_path / "proj"
2131+
project.mkdir()
2132+
2133+
merged, found = hook_module.load_claude_settings(project)
2134+
2135+
assert found is True
2136+
assert merged["primaryProject"] == "profile-wide"
2137+
2138+
2139+
def test_claude_config_dir_ignores_default_home_settings(
2140+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2141+
) -> None:
2142+
_write_user_block(Path.home() / ".claude", {"primaryProject": "other-profile"})
2143+
profile = tmp_path / ".claude-profile"
2144+
_write_user_block(profile, {"primaryProject": "active-profile"})
2145+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(profile))
2146+
project = tmp_path / "proj"
2147+
project.mkdir()
2148+
2149+
merged, _ = hook_module.load_claude_settings(project)
2150+
2151+
# The other profile's routing must not leak into this one.
2152+
assert merged["primaryProject"] == "active-profile"
2153+
2154+
2155+
def test_claude_config_dir_still_loses_to_project_settings(
2156+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2157+
) -> None:
2158+
profile = tmp_path / ".claude-profile"
2159+
_write_user_block(profile, {"primaryProject": "profile-wide", "recallTimeframe": "9d"})
2160+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(profile))
2161+
project = tmp_path / "proj"
2162+
(project / ".claude").mkdir(parents=True)
2163+
_write_claude_settings(project, {"primaryProject": "project-level"})
2164+
2165+
merged, found = hook_module.load_claude_settings(project)
2166+
2167+
assert found is True
2168+
assert merged["primaryProject"] == "project-level"
2169+
assert merged["recallTimeframe"] == "9d"
2170+
2171+
2172+
@pytest.mark.parametrize("value", ["", " "])
2173+
def test_claude_config_dir_blank_falls_back_to_home(
2174+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, value: str
2175+
) -> None:
2176+
_write_user_block(Path.home() / ".claude", {"primaryProject": "home-default"})
2177+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", value)
2178+
project = tmp_path / "proj"
2179+
project.mkdir()
2180+
2181+
merged, found = hook_module.load_claude_settings(project)
2182+
2183+
assert found is True
2184+
assert merged["primaryProject"] == "home-default"
2185+
2186+
2187+
def test_claude_config_dir_expands_user(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
2188+
_write_user_block(Path.home() / ".claude-profile", {"primaryProject": "expanded"})
2189+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", "~/.claude-profile")
2190+
project = tmp_path / "proj"
2191+
project.mkdir()
2192+
2193+
merged, _ = hook_module.load_claude_settings(project)
2194+
2195+
assert merged["primaryProject"] == "expanded"
2196+
2197+
2198+
def test_install_claude_writes_hooks_into_config_dir(
2199+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2200+
) -> None:
2201+
profile = tmp_path / ".claude-profile"
2202+
profile.mkdir()
2203+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(profile))
2204+
2205+
result = runner.invoke(cli_app, ["hook", "install"])
2206+
2207+
assert result.exit_code == 0
2208+
data = _read_json(profile / "settings.json")
2209+
assert data["hooks"]["SessionStart"][0]["hooks"][0]["command"] == (
2210+
"basic-memory hook session-start --harness claude"
2211+
)
2212+
# The default profile's settings must be left alone.
2213+
assert not (Path.home() / ".claude" / "settings.json").exists()
2214+
2215+
2216+
def test_remove_claude_hooks_from_config_dir(
2217+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2218+
) -> None:
2219+
profile = tmp_path / ".claude-profile"
2220+
profile.mkdir()
2221+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(profile))
2222+
runner.invoke(cli_app, ["hook", "install"])
2223+
2224+
result = runner.invoke(cli_app, ["hook", "remove"])
2225+
2226+
assert result.exit_code == 0
2227+
assert _read_json(profile / "settings.json").get("hooks", {}) == {}

0 commit comments

Comments
 (0)