diff --git a/.claude-plugin/hooks/mempal-precompact-hook.sh b/.claude-plugin/hooks/mempal-precompact-hook.sh index 0ac46dd..19bb6b0 100644 --- a/.claude-plugin/hooks/mempal-precompact-hook.sh +++ b/.claude-plugin/hooks/mempal-precompact-hook.sh @@ -1,5 +1,24 @@ #!/bin/bash # MemPalace PreCompact Hook — thin wrapper calling Python CLI # All logic lives in mempalace.hooks_cli for cross-harness extensibility -INPUT=$(cat) -echo "$INPUT" | python3 -m mempalace hook run --hook precompact --harness claude-code +run_mempalace_hook() { + if command -v mempalace >/dev/null 2>&1; then + mempalace hook run "$@" + return $? + fi + + if command -v python3 >/dev/null 2>&1 && python3 -c "import mempalace" >/dev/null 2>&1; then + python3 -m mempalace hook run "$@" + return $? + fi + + if command -v python >/dev/null 2>&1 && python -c "import mempalace" >/dev/null 2>&1; then + python -m mempalace hook run "$@" + return $? + fi + + echo "MemPalace hook error: could not find a runnable mempalace command or module" >&2 + return 1 +} + +run_mempalace_hook --hook precompact --harness claude-code diff --git a/.claude-plugin/hooks/mempal-stop-hook.sh b/.claude-plugin/hooks/mempal-stop-hook.sh index cba3284..5c860b4 100644 --- a/.claude-plugin/hooks/mempal-stop-hook.sh +++ b/.claude-plugin/hooks/mempal-stop-hook.sh @@ -1,5 +1,24 @@ #!/bin/bash # MemPalace Stop Hook — thin wrapper calling Python CLI # All logic lives in mempalace.hooks_cli for cross-harness extensibility -INPUT=$(cat) -echo "$INPUT" | python3 -m mempalace hook run --hook stop --harness claude-code +run_mempalace_hook() { + if command -v mempalace >/dev/null 2>&1; then + mempalace hook run "$@" + return $? + fi + + if command -v python3 >/dev/null 2>&1 && python3 -c "import mempalace" >/dev/null 2>&1; then + python3 -m mempalace hook run "$@" + return $? + fi + + if command -v python >/dev/null 2>&1 && python -c "import mempalace" >/dev/null 2>&1; then + python -m mempalace hook run "$@" + return $? + fi + + echo "MemPalace hook error: could not find a runnable mempalace command or module" >&2 + return 1 +} + +run_mempalace_hook --hook stop --harness claude-code diff --git a/tests/test_claude_plugin_hook_wrappers.py b/tests/test_claude_plugin_hook_wrappers.py new file mode 100644 index 0000000..e427e0c --- /dev/null +++ b/tests/test_claude_plugin_hook_wrappers.py @@ -0,0 +1,192 @@ +"""Execution tests for Claude plugin hook wrapper scripts.""" + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +PLUGIN_HOOKS_DIR = REPO_ROOT / ".claude-plugin" / "hooks" +BASH = shutil.which("bash") + +pytestmark = pytest.mark.skipif( + BASH is None, + reason="bash required for Claude plugin hook wrapper tests", +) + +SCRIPT_CASES = [ + ("mempal-stop-hook.sh", "stop"), + ("mempal-precompact-hook.sh", "precompact"), +] + + +def _shell_path(path: Path) -> str: + return path.as_posix() + + +def _write_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def _make_bin_dir(tmp_path: Path, executables: dict[str, str]) -> Path: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + for name, content in executables.items(): + _write_executable(bin_dir / name, content) + return bin_dir + + +def _capture_stdin_to(output_path: Path) -> str: + return ( + 'stdin_payload=""\n' + 'while IFS= read -r line || [ -n "$line" ]; do\n' + ' stdin_payload="${stdin_payload}${line}"\n' + "done\n" + f'printf \'%s\' "$stdin_payload" > "{_shell_path(output_path)}"\n' + ) + + +def _run_hook( + script_name: str, + payload: str, + bin_dir: Path, +) -> subprocess.CompletedProcess[str]: + assert BASH is not None + + env = os.environ.copy() + env["PATH"] = str(bin_dir) + + return subprocess.run( + [BASH, _shell_path(PLUGIN_HOOKS_DIR / script_name)], + input=payload, + text=True, + capture_output=True, + cwd=REPO_ROOT, + env=env, + ) + + +@pytest.mark.parametrize(("script_name", "hook_name"), SCRIPT_CASES) +def test_plugin_hook_wrapper_prefers_mempalace_cli( + tmp_path: Path, script_name: str, hook_name: str +) -> None: + args_file = tmp_path / "args.txt" + stdin_file = tmp_path / "stdin.json" + + bin_dir = _make_bin_dir( + tmp_path, + { + "mempalace": ( + "#!/bin/sh\n" + f'printf \'%s\' "$*" > "{_shell_path(args_file)}"\n' + f"{_capture_stdin_to(stdin_file)}" + "printf '{}\\n'\n" + ), + "python": "#!/bin/sh\nexit 99\n", + "python3": "#!/bin/sh\nexit 99\n", + }, + ) + + payload = '{"session_id":"abc123"}' + result = _run_hook(script_name, payload, bin_dir) + + assert result.returncode == 0 + assert result.stdout == "{}\n" + assert ( + args_file.read_text(encoding="utf-8") + == f"hook run --hook {hook_name} --harness claude-code" + ) + assert stdin_file.read_text(encoding="utf-8") == payload + + +@pytest.mark.parametrize(("script_name", "hook_name"), SCRIPT_CASES) +@pytest.mark.parametrize("python_name", ["python3", "python"]) +def test_plugin_hook_wrapper_falls_back_to_importable_python( + tmp_path: Path, script_name: str, hook_name: str, python_name: str +) -> None: + args_file = tmp_path / "args.txt" + stdin_file = tmp_path / "stdin.json" + + python_stub = ( + "#!/bin/sh\n" + 'if [ "$1" = "-c" ]; then\n' + " exit 0\n" + "fi\n" + f'printf \'%s\' "$*" > "{_shell_path(args_file)}"\n' + f"{_capture_stdin_to(stdin_file)}" + "printf '{}\\n'\n" + ) + bin_dir = _make_bin_dir(tmp_path, {python_name: python_stub}) + + payload = '{"session_id":"xyz789"}' + result = _run_hook(script_name, payload, bin_dir) + + assert result.returncode == 0 + assert result.stdout == "{}\n" + assert ( + args_file.read_text(encoding="utf-8") + == f"-m mempalace hook run --hook {hook_name} --harness claude-code" + ) + assert stdin_file.read_text(encoding="utf-8") == payload + + +@pytest.mark.parametrize(("script_name", "hook_name"), SCRIPT_CASES) +def test_plugin_hook_wrapper_errors_cleanly_when_no_runner_exists( + tmp_path: Path, script_name: str, hook_name: str +) -> None: + bin_dir = _make_bin_dir(tmp_path, {}) + + payload = '{"session_id":"no-runner"}' + result = _run_hook(script_name, payload, bin_dir) + + assert result.returncode != 0 + assert result.stdout == "" + assert "could not find a runnable mempalace command or module" in result.stderr + + +@pytest.mark.parametrize(("script_name", "hook_name"), SCRIPT_CASES) +def test_plugin_hook_wrapper_falls_back_to_python_when_python3_cannot_import( + tmp_path: Path, script_name: str, hook_name: str +) -> None: + args_file = tmp_path / "args.txt" + stdin_file = tmp_path / "stdin.json" + bad_python3_used = tmp_path / "bad_python3_used.txt" + + bin_dir = _make_bin_dir( + tmp_path, + { + "python3": ( + "#!/bin/sh\n" + 'if [ "$1" = "-c" ]; then\n' + " exit 1\n" + "fi\n" + f"printf 'used' > \"{_shell_path(bad_python3_used)}\"\n" + "echo 'No module named mempalace' >&2\n" + "exit 1\n" + ), + "python": ( + "#!/bin/sh\n" + 'if [ "$1" = "-c" ]; then\n' + " exit 0\n" + "fi\n" + f'printf \'%s\' "$*" > "{_shell_path(args_file)}"\n' + f"{_capture_stdin_to(stdin_file)}" + "printf '{}\\n'\n" + ), + }, + ) + + payload = '{"session_id":"fallback"}' + result = _run_hook(script_name, payload, bin_dir) + + assert result.returncode == 0 + assert result.stdout == "{}\n" + assert ( + args_file.read_text(encoding="utf-8") + == f"-m mempalace hook run --hook {hook_name} --harness claude-code" + ) + assert stdin_file.read_text(encoding="utf-8") == payload + assert not bad_python3_used.exists()