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
10 changes: 7 additions & 3 deletions plugins/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,13 @@ 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
The block can also live in your **user-level** settings. When
`CLAUDE_CONFIG_DIR` is present, its literal value is the user configuration
directory and `settings.json` is read below it. An empty, whitespace, relative,
or tilde-prefixed value is preserved exactly, matching Claude Code. When the
variable is absent, the user settings path is `~/.claude/settings.json`. One
user-level block covers every project, with 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
Expand Down
7 changes: 5 additions & 2 deletions plugins/claude-code/skills/bm-checkpoint/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ automatic PreCompact checkpoint.
## Gather

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:
use. For the user-level base, append `settings.json` to the literal value of
`CLAUDE_CONFIG_DIR` when that environment variable is present; do not trim it,
expand `~`, or treat an empty value as unset. Use `~/.claude/settings.json` only
when the variable is absent. Then the project's `.claude/settings.json` and
`.claude/settings.local.json` override it per key:

- `primaryProject`, default omitted (Basic Memory's default project)
- `captureFolder`, default `sessions`
Expand Down
8 changes: 5 additions & 3 deletions plugins/claude-code/skills/bm-decide/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ it works whether or not that style is enabled.
## Steps

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:
hooks use. For the user-level base, append `settings.json` to the literal value
of `CLAUDE_CONFIG_DIR` when that environment variable is present; do not trim
it, expand `~`, or treat an empty value as unset. Use `~/.claude/settings.json`
only when the variable is absent. Then the project's `.claude/settings.json`
and `.claude/settings.local.json` override it per key:
- write to `primaryProject` when set (pass it as `project`, or as
`project_id` if it's an `external_id` UUID)
- follow `placementConventions` for the directory when they are specific
Expand Down
17 changes: 10 additions & 7 deletions plugins/claude-code/skills/bm-orient/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ of a session; this is the deliberate mid-session version with deeper reads.
## Steps

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. Use `primaryProject`, `secondaryProjects`, `recallTimeframe`,
`sessionProfile`, `repository`, and `placementConventions`. If no config is
present, continue against the default Basic Memory project and mention that
setup has not been run. Scope queries to `primaryProject` by passing it as
`project`, or as `project_id` if it's an `external_id` UUID.
hooks use. For the user-level base, append `settings.json` to the literal value
of `CLAUDE_CONFIG_DIR` when that environment variable is present; do not trim
it, expand `~`, or treat an empty value as unset. Use `~/.claude/settings.json`
only when the variable is absent. Then the project's `.claude/settings.json`
and `.claude/settings.local.json` override it per key. Use `primaryProject`,
`secondaryProjects`, `recallTimeframe`, `sessionProfile`, `repository`, and
`placementConventions`. If no config is present, continue against the default
Basic Memory project and mention that setup has not been run. Scope queries to
`primaryProject` by passing it as `project`, or as `project_id` if it's an
`external_id` UUID.

2. Query the primary project with `search_notes`:
- active tasks: `metadata_filters={"type": "task", "status": "active"}`
Expand Down
9 changes: 6 additions & 3 deletions plugins/claude-code/skills/bm-remember/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ Capture `$ARGUMENTS` into Basic Memory as a quick note, keeping the user's words
## Steps

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:
hooks use. For the user-level base, append `settings.json` to the literal value
of `CLAUDE_CONFIG_DIR` when that environment variable is present; do not trim
it, expand `~`, or treat an empty value as unset. Use `~/.claude/settings.json`
only when the variable is absent. 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
9 changes: 6 additions & 3 deletions plugins/claude-code/skills/bm-share/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ project — session checkpoints and `/basic-memory:bm-remember` always stay pers

## Steps

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:
1. **Resolve config.** Read the `basicMemory` block with the hooks' precedence.
For the user-level base, append `settings.json` to the literal value of
`CLAUDE_CONFIG_DIR` when that environment variable is present; do not trim it,
expand `~`, or treat an empty value as unset. Use `~/.claude/settings.json` only
when the variable is absent. 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
11 changes: 7 additions & 4 deletions plugins/claude-code/skills/bm-status/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ This is a quick diagnostic — gather the facts and lay them out; don't over-inv
unavailable and continue with MCP/config checks. The plugin hooks can still use
their uv-managed environment.

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):
2. **Configuration.** Read the `basicMemory` block with the hooks' precedence.
For the user-level base, append `settings.json` to the literal value of
`CLAUDE_CONFIG_DIR` when that environment variable is present; do not trim it,
expand `~`, or treat an empty value as unset. Use `~/.claude/settings.json` only
when the variable is absent. Then the project's `.claude/settings.json` and
`.claude/settings.local.json` override per key. Report the result, noting when
a value comes from the user-level block versus 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
14 changes: 14 additions & 0 deletions scripts/validate_claude_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@
"bm-share",
"bm-writing",
)
PROFILE_AWARE_SKILLS = (
"bm-orient",
"bm-checkpoint",
"bm-decide",
"bm-remember",
"bm-status",
"bm-share",
)
REQUIRED_SKILL_TEXT: dict[str, tuple[str, ...]] = {
"bm-setup": (
"captureEvents",
Expand Down Expand Up @@ -215,6 +223,12 @@ def validate_claude_plugin(plugin_dir: Path) -> None:
if required_text not in skill_text:
raise SystemExit(f"{skill_md}: missing plugin contract text {required_text!r}")

# These workflows resolve the same merged config as lifecycle hooks. If
# one falls back to a fixed home path, manual and automatic writes can
# silently cross Basic Memory project boundaries for Claude profiles.
if skill_dir.name in PROFILE_AWARE_SKILLS and "CLAUDE_CONFIG_DIR" not in skill_text:
raise SystemExit(f"{skill_md}: must honor CLAUDE_CONFIG_DIR")

readme = (plugin_dir / "README.md").read_text(encoding="utf-8")
for required_text in (
"lifecycle trace never becomes a graph note",
Expand Down
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 @@ -238,11 +239,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 literal full replacement for
``~/.claude``. Preserve that path exactly — including relative, empty,
whitespace, and tilde-prefixed values — so hook wiring targets the same
directory as Claude. Only an unset variable selects the default profile.
"""
override = os.environ.get("CLAUDE_CONFIG_DIR")
return Path(override) if override is not None 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 @@ -252,23 +266,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"]
Comment thread
phernandez marked this conversation as resolved.
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 @@ -1249,11 +1274,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 @@ -69,6 +69,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
Loading
Loading