Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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: 7 additions & 4 deletions plugins/claude-code/hooks/pre-compact.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ set -u

input="$(cat 2>/dev/null || true)"

# Resolve how to invoke the Basic Memory CLI — prefer a binary on PATH, fall back to
# uvx / uv so checkpointing still works when BM was connected only as an ephemeral
# `uvx basic-memory mcp` server (no persistent CLI). Silent no-op if none available.
if command -v basic-memory >/dev/null 2>&1; then
# Resolve how to invoke the Basic Memory CLI — prefer an explicit command when the
# host configured one, then a binary on PATH. Fall back to uvx / uv so checkpointing
# still works when BM was connected only as an ephemeral `uvx basic-memory mcp`
# server (no persistent CLI). Silent no-op if none available.
if [[ -n "${BM_BIN:-}" ]]; then
BM="$BM_BIN"
elif command -v basic-memory >/dev/null 2>&1; then
BM="basic-memory"
elif command -v bm >/dev/null 2>&1; then
BM="bm"
Expand Down
7 changes: 5 additions & 2 deletions plugins/claude-code/hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@ set -u
input="$(cat 2>/dev/null || true)"

# --- Resolve how to invoke the Basic Memory CLI ---
# Prefer a binary on PATH (fast — no per-call env resolution). Fall back to uvx / uv
# Prefer an explicit command when the host configured one, then a binary on PATH
# (fast — no per-call env resolution). Fall back to uvx / uv
# so the hook still works when Basic Memory was connected only as an ephemeral
# `uvx basic-memory mcp` server (the MCP setup our README recommends) with no
# persistent CLI installed — the uv cache is already warm from running the server.
# Trigger: none of basic-memory / bm / uvx / uv on PATH → BM isn't usable here.
# Outcome: silent no-op (the plugin must be invisible to non-BM users).
if command -v basic-memory >/dev/null 2>&1; then
if [[ -n "${BM_BIN:-}" ]]; then
BM="$BM_BIN"
Comment thread
phernandez marked this conversation as resolved.
elif command -v basic-memory >/dev/null 2>&1; then
BM="basic-memory"
elif command -v bm >/dev/null 2>&1; then
BM="bm"
Expand Down
45 changes: 43 additions & 2 deletions tests/test_claude_plugin_hooks.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import json
import os
import shlex
import shutil
import subprocess
import sys
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
def _resolve_bash_executable(*, platform_name: str = os.name) -> str | None:
"""Prefer Git Bash over Windows' WSL launcher for hook execution."""
if platform_name == "nt":
git_executable = shutil.which("git")
if git_executable:
git_bash = Path(git_executable).resolve().parent.parent / "bin" / "bash.exe"
if git_bash.is_file():
return str(git_bash)
return shutil.which("bash")


BASH_EXECUTABLE = _resolve_bash_executable()
HOOK_RUNTIME_AVAILABLE = BASH_EXECUTABLE 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",
Expand All @@ -31,16 +45,19 @@ def write_settings(self, directory: Path, name: str, basic_memory: dict[str, obj
)

def run_hook(self, hook_name: str, payload: dict[str, str]) -> subprocess.CompletedProcess[str]:
assert BASH_EXECUTABLE is not None
env = os.environ.copy()
env.update(
{
"BM_BIN": shlex.join([sys.executable, str(self.bin_dir / "basic-memory")]),
"BM_TEST_COMMAND_LOG": str(self.command_log),
"HOME": str(self.home),
"PATH": f"{self.bin_dir}{os.pathsep}{env['PATH']}",
"USERPROFILE": str(self.home),
}
)
return subprocess.run(
["bash", str(self.repo_root / "plugins/claude-code/hooks" / hook_name)],
[BASH_EXECUTABLE, str(self.repo_root / "plugins/claude-code/hooks" / hook_name)],
input=json.dumps(payload),
capture_output=True,
check=False,
Expand Down Expand Up @@ -88,6 +105,30 @@ def hook_harness(tmp_path: Path) -> HookHarness:
)


def test_resolve_bash_executable_prefers_git_bash_on_windows(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
git_root = tmp_path / "Git"
git_executable = git_root / "cmd/git.exe"
git_bash = git_root / "bin/bash.exe"
git_executable.parent.mkdir(parents=True)
git_executable.touch()
git_bash.parent.mkdir(parents=True)
git_bash.touch()

def fake_which(command: str) -> str | None:
if command == "git":
return str(git_executable)
if command == "bash":
return "C:/Windows/System32/bash.exe"
return None

monkeypatch.setattr(shutil, "which", fake_which)

assert _resolve_bash_executable(platform_name="nt") == str(git_bash)


def test_session_start_uses_user_settings_without_project_config(
hook_harness: HookHarness,
) -> None:
Expand Down
Loading