Skip to content
Merged
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
11 changes: 11 additions & 0 deletions plugins/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ your project's `.claude/settings.json`. Copy
}
```

The block can also live in your **user-level** `~/.claude/settings.json` — one
block there covers every project, no per-repo setup. Precedence, lowest to
highest: user-level `settings.json` → project `settings.json` → project
`settings.local.json`, merged per key — so a project that pins its own
`primaryProject` wins over the user-level default. (This mirrors Claude Code's
own sources: `settings.local.json` is project-scoped only, so there is no
user-level `settings.local.json`.) The hooks resolve the project settings from
the nearest ancestor directory (including the working directory) whose `.claude`
folder contains a `settings.json` or `settings.local.json`, so a mapping in the
repo root still applies when Claude starts in a subdirectory.

To enable the capture reflexes, also set `"outputStyle": "basic-memory"` in your
settings (or select it via `/config`).

Expand Down
46 changes: 37 additions & 9 deletions plugins/claude-code/hooks/pre-compact.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,45 @@ transcript_path = payload.get("transcript_path") or ""
session_id = payload.get("session_id") or ""


def _read_block(path):
try:
with open(path) as fh:
block = json.load(fh).get("basicMemory")
except Exception:
return None
return block if isinstance(block, dict) else None


def _project_dir(directory):
# Nearest ancestor (including directory) holding a .claude settings file.
d = os.path.abspath(directory)
while True:
for name in ("settings.json", "settings.local.json"):
if os.path.isfile(os.path.join(d, ".claude", name)):
return d
parent = os.path.dirname(d)
if parent == d:
return os.path.abspath(directory)
d = parent


def load_settings(directory):
# Same precedence as session-start.sh: user-level ~/.claude/settings.json is
# the base (no user-level settings.local.json — it isn't a real Claude Code
# source), then the nearest project .claude (settings.json, then
# settings.local.json) overrides it. cwd may be a repo subdirectory, so walk
# ancestors to the project root rather than reading cwd alone.
merged = {}
for name in ("settings.json", "settings.local.json"):
path = os.path.join(directory, ".claude", name)
try:
with open(path) as fh:
merged.update(json.load(fh).get("basicMemory") or {})
except FileNotFoundError:
continue
except Exception:
continue
home = os.path.expanduser("~")
sources = [(home, ("settings.json",))]
project = _project_dir(directory) # already absolute
if project != home:
sources.append((project, ("settings.json", "settings.local.json")))
for d, names in sources:
for name in names:
block = _read_block(os.path.join(d, ".claude", name))
if block is not None:
merged.update(block)
return merged


Expand Down
59 changes: 43 additions & 16 deletions plugins/claude-code/hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -76,25 +76,52 @@ cwd = payload.get("cwd") or os.getcwd()


# --- Load plugin config from .claude settings (local overrides committed) ---
# Precedence: settings.local.json (per-user) wins over settings.json (team).
# `found` is True if either file declared a basicMemory block at all — its
# presence is the first-run sentinel (setup writing it stops the nudge below).
# Precedence (lowest to highest): the user-level ~/.claude/settings.json, then
# the project's .claude/settings.json and .claude/settings.local.json. A single
# user-level basicMemory block can cover every project without running setup per
# repo; any project can still pin its own mapping, which wins. We mirror Claude
# Code's real sources: user level is settings.json only (there is no user-level
# settings.local.json), local settings are project-scoped. Because the hook cwd
# can be a repo subdirectory, we walk ancestors to the nearest .claude config so
# a project-root mapping is honoured instead of skipped. `found` is True if any
# file declared a basicMemory block — its presence is the first-run sentinel
# (setup writing it stops the nudge below).
def _read_block(path):
try:
with open(path) as fh:
block = json.load(fh).get("basicMemory")
except Exception:
return None
return block if isinstance(block, dict) else None


def _project_dir(directory):
# Nearest ancestor (including directory) holding a .claude settings file.
d = os.path.abspath(directory)
while True:
for name in ("settings.json", "settings.local.json"):
if os.path.isfile(os.path.join(d, ".claude", name)):
return d
Comment thread
phernandez marked this conversation as resolved.
Comment thread
phernandez marked this conversation as resolved.
parent = os.path.dirname(d)
if parent == d:
return os.path.abspath(directory)
d = parent


def load_settings(directory):
merged = {}
found = False
for name in ("settings.json", "settings.local.json"):
path = os.path.join(directory, ".claude", name)
try:
with open(path) as fh:
data = json.load(fh)
except FileNotFoundError:
continue
except Exception:
continue
block = data.get("basicMemory")
if isinstance(block, dict):
found = True
merged.update(block)
home = os.path.expanduser("~")
sources = [(home, ("settings.json",))]
project = _project_dir(directory) # already absolute
if project != home:
sources.append((project, ("settings.json", "settings.local.json")))
for d, names in sources:
for name in names:
block = _read_block(os.path.join(d, ".claude", name))
if block is not None:
found = True
merged.update(block)
return merged, found


Expand Down
6 changes: 4 additions & 2 deletions plugins/claude-code/skills/bm-remember/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ Capture `$ARGUMENTS` into Basic Memory as a quick note, keeping the user's words

## Steps

1. **Resolve config.** Read `.claude/settings.json` (and `.claude/settings.local.json`
if it exists) and look for the `basicMemory` block:
1. **Resolve config.** Read the `basicMemory` block with the same precedence the
hooks use: user-level `~/.claude/settings.json` as the base, then the project's
`.claude/settings.json` and `.claude/settings.local.json` override it per key. A
user-level block alone is enough; a project can still pin its own values:
- `rememberFolder` — folder for quick captures (default: `bm-remember`)
- `primaryProject` — project to write to (default: omit the `project` argument so
Basic Memory uses its default project)
Expand Down
4 changes: 3 additions & 1 deletion plugins/claude-code/skills/bm-share/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ project — session checkpoints and `/basic-memory:bm-remember` always stay pers

## Steps

1. **Resolve config.** Read `.claude/settings.json` (+ `.local`) `basicMemory`:
1. **Resolve config.** Read the `basicMemory` block with the hooks' precedence:
user-level `~/.claude/settings.json` as the base, then the project's
`.claude/settings.json` and `.claude/settings.local.json` override per key:
- `teamProjects` — a map of `<project-ref>` → `{ "promoteFolder": "shared" }`.
These are the allowed share targets. `<project-ref>` is a workspace-qualified
name (e.g. `my-team-2/notes`) or an `external_id` UUID.
Expand Down
6 changes: 4 additions & 2 deletions plugins/claude-code/skills/bm-status/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ This is a quick diagnostic — gather the facts and lay them out; don't over-inv
neither is found, report that Basic Memory isn't installed or on PATH, and stop —
nothing else will work without it.

2. **Configuration.** Read `.claude/settings.json` (and `.claude/settings.local.json`
if present) and report:
2. **Configuration.** Read the `basicMemory` block with the hooks' precedence —
user-level `~/.claude/settings.json` as the base, then the project's
`.claude/settings.json` and `.claude/settings.local.json` overriding per key —
and report (note when a value comes from the user-level block vs. the project):
- From the `basicMemory` block: `primaryProject` (or note none is pinned — the
default project is used), `secondaryProjects` (team/shared read sources),
`teamProjects` (share targets for `/basic-memory:bm-share`), `captureFolder`
Expand Down
201 changes: 201 additions & 0 deletions tests/test_claude_plugin_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import json
import os
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path

import pytest


HOOK_RUNTIME_AVAILABLE = shutil.which("bash") is not None and shutil.which("python3") is not None
pytestmark = pytest.mark.skipif(
not HOOK_RUNTIME_AVAILABLE,
reason="Claude Code hook tests require bash and python3",
)


@dataclass(frozen=True, slots=True)
class HookHarness:
repo_root: Path
home: Path
bin_dir: Path
command_log: Path

def write_settings(self, directory: Path, name: str, basic_memory: dict[str, object]) -> None:
settings_dir = directory / ".claude"
settings_dir.mkdir(parents=True, exist_ok=True)
(settings_dir / name).write_text(
json.dumps({"basicMemory": basic_memory}),
encoding="utf-8",
)

def run_hook(self, hook_name: str, payload: dict[str, str]) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env.update(
{
"BM_TEST_COMMAND_LOG": str(self.command_log),
"HOME": str(self.home),
"PATH": f"{self.bin_dir}{os.pathsep}{env['PATH']}",
}
)
return subprocess.run(
["bash", str(self.repo_root / "plugins/claude-code/hooks" / hook_name)],
input=json.dumps(payload),
capture_output=True,
check=False,
env=env,
text=True,
)

def logged_commands(self) -> list[list[str]]:
if not self.command_log.exists():
return []
return [json.loads(line) for line in self.command_log.read_text().splitlines()]


@pytest.fixture
def hook_harness(tmp_path: Path) -> HookHarness:
repo_root = Path(__file__).resolve().parents[1]
home = tmp_path / "home"
bin_dir = tmp_path / "bin"
home.mkdir()
bin_dir.mkdir()
command_log = tmp_path / "basic-memory-commands.jsonl"

fake_basic_memory = bin_dir / "basic-memory"
fake_basic_memory.write_text(
"""#!/usr/bin/env python3
import json
import os
import sys

with open(os.environ["BM_TEST_COMMAND_LOG"], "a", encoding="utf-8") as command_log:
command_log.write(json.dumps(sys.argv[1:]) + "\\n")

if sys.argv[1:3] == ["tool", "search-notes"]:
print(json.dumps({"results": []}))
""",
encoding="utf-8",
)
fake_basic_memory.chmod(0o755)

return HookHarness(
repo_root=repo_root,
home=home,
bin_dir=bin_dir,
command_log=command_log,
)


def test_session_start_uses_user_settings_without_project_config(
hook_harness: HookHarness,
) -> None:
hook_harness.write_settings(
hook_harness.home,
"settings.json",
{"primaryProject": "global-project", "captureFolder": "global-sessions"},
)
# Claude Code does not treat this as a user-level settings source. A stale
# file must not silently reroute hooks away from the visible global config.
hook_harness.write_settings(
hook_harness.home,
"settings.local.json",
{"primaryProject": "stale-project"},
)
cwd = hook_harness.home / "work/repo/src"
cwd.mkdir(parents=True)

result = hook_harness.run_hook("session-start.sh", {"cwd": str(cwd)})

assert result.returncode == 0, result.stderr
assert "**Project:** global-project" in result.stdout
assert "`global-sessions/`" in result.stdout
assert "stale-project" not in result.stdout


def test_session_start_merges_nearest_ancestor_project_settings_over_user_settings(
hook_harness: HookHarness,
) -> None:
hook_harness.write_settings(
hook_harness.home,
"settings.json",
{"primaryProject": "global-project", "captureFolder": "global-sessions"},
)
project_root = hook_harness.home / "work/repo"
hook_harness.write_settings(
project_root,
"settings.json",
{"primaryProject": "project-override"},
)
hook_harness.write_settings(
project_root,
"settings.local.json",
{"captureFolder": "local-sessions"},
)
cwd = project_root / "packages/client/src"
cwd.mkdir(parents=True)

result = hook_harness.run_hook("session-start.sh", {"cwd": str(cwd)})

assert result.returncode == 0, result.stderr
assert "**Project:** project-override" in result.stdout
assert "`local-sessions/`" in result.stdout
search_commands = [
command
for command in hook_harness.logged_commands()
if command[:2] == ["tool", "search-notes"]
]
assert len(search_commands) == 3
assert all(
command[command.index("--project") + 1] == "project-override" for command in search_commands
)


def test_pre_compact_uses_merged_project_and_capture_folder(
hook_harness: HookHarness,
tmp_path: Path,
) -> None:
hook_harness.write_settings(
hook_harness.home,
"settings.json",
{"primaryProject": "global-project", "captureFolder": "global-sessions"},
)
project_root = hook_harness.home / "work/repo"
hook_harness.write_settings(
project_root,
"settings.json",
{"primaryProject": "project-override"},
)
hook_harness.write_settings(
project_root,
"settings.local.json",
{"captureFolder": "local-sessions"},
)
cwd = project_root / "src"
cwd.mkdir(parents=True)
transcript = tmp_path / "transcript.jsonl"
transcript.write_text(
json.dumps({"message": {"role": "user", "content": "Ship the settings fallback"}}) + "\n",
encoding="utf-8",
)

result = hook_harness.run_hook(
"pre-compact.sh",
{
"cwd": str(cwd),
"session_id": "session-123",
"transcript_path": str(transcript),
},
)

assert result.returncode == 0, result.stderr
write_commands = [
command
for command in hook_harness.logged_commands()
if command[:2] == ["tool", "write-note"]
]
assert len(write_commands) == 1
write_command = write_commands[0]
assert write_command[write_command.index("--project") + 1] == "project-override"
assert write_command[write_command.index("--folder") + 1] == "local-sessions"
Loading