From e083cd6c8408c984be7e731003b2cde68ff6c300 Mon Sep 17 00:00:00 2001 From: fatkobra <55045047+fatkobra@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:32:17 +0200 Subject: [PATCH 1/5] Create test_claude_plugin_hook_wrappers.py --- tests/test_claude_plugin_hook_wrappers.py | 161 ++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 tests/test_claude_plugin_hook_wrappers.py diff --git a/tests/test_claude_plugin_hook_wrappers.py b/tests/test_claude_plugin_hook_wrappers.py new file mode 100644 index 0000000..da1a5af --- /dev/null +++ b/tests/test_claude_plugin_hook_wrappers.py @@ -0,0 +1,161 @@ +"""Execution tests for Claude plugin hook wrapper scripts.""" + +import os +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +PLUGIN_HOOKS_DIR = REPO_ROOT / ".claude-plugin" / "hooks" +SCRIPT_CASES = [ + ("mempal-stop-hook.sh", "stop"), + ("mempal-precompact-hook.sh", "precompact"), +] + + +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 _run_hook(script_name: str, payload: str, bin_dir: Path) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["PATH"] = str(bin_dir) + return subprocess.run( + ["/bin/bash", str(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\' "$*" > "{args_file}"\n' + f'/bin/cat > "{stdin_file}"\n' + "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\' "$*" > "{args_file}"\n' + f'/bin/cat > "{stdin_file}"\n' + "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' > \"{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\' "$*" > "{args_file}"\n' + f'/bin/cat > "{stdin_file}"\n' + "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() From 5fe0c1c2acad6cda1a8ca184a870580873ac7dcf Mon Sep 17 00:00:00 2001 From: fatkobra <55045047+fatkobra@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:33:34 +0200 Subject: [PATCH 2/5] Update mempal-stop-hook.sh --- .claude-plugin/hooks/mempal-stop-hook.sh | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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 From be9214a19064039e2834c5b3da72c10f9b3df451 Mon Sep 17 00:00:00 2001 From: fatkobra <55045047+fatkobra@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:42:20 +0200 Subject: [PATCH 3/5] Update mempal-precompact-hook.sh --- .../hooks/mempal-precompact-hook.sh | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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 From 1dc55a791d95a1794f0e498524b771283b2bf839 Mon Sep 17 00:00:00 2001 From: fatkobra <55045047+fatkobra@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:41:53 +0200 Subject: [PATCH 4/5] test: make Claude plugin wrapper tests portable on Windows --- tests/test_claude_plugin_hook_wrappers.py | 49 ++++++++++++++++++----- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/tests/test_claude_plugin_hook_wrappers.py b/tests/test_claude_plugin_hook_wrappers.py index da1a5af..9bffa63 100644 --- a/tests/test_claude_plugin_hook_wrappers.py +++ b/tests/test_claude_plugin_hook_wrappers.py @@ -1,6 +1,7 @@ """Execution tests for Claude plugin hook wrapper scripts.""" import os +import shutil import subprocess from pathlib import Path @@ -8,12 +9,23 @@ 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) @@ -27,11 +39,28 @@ def _make_bin_dir(tmp_path: Path, executables: dict[str, str]) -> Path: return bin_dir -def _run_hook(script_name: str, payload: str, bin_dir: Path) -> subprocess.CompletedProcess[str]: +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( - ["/bin/bash", str(PLUGIN_HOOKS_DIR / script_name)], + [BASH, str(PLUGIN_HOOKS_DIR / script_name)], input=payload, text=True, capture_output=True, @@ -46,13 +75,14 @@ def test_plugin_hook_wrapper_prefers_mempalace_cli( ) -> 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\' "$*" > "{args_file}"\n' - f'/bin/cat > "{stdin_file}"\n' + f'printf \'%s\' "$*" > "{_shell_path(args_file)}"\n' + f"{_capture_stdin_to(stdin_file)}" "printf '{}\\n'\n" ), "python": "#!/bin/sh\nexit 99\n", @@ -79,13 +109,14 @@ def test_plugin_hook_wrapper_falls_back_to_importable_python( ) -> 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\' "$*" > "{args_file}"\n' - f'/bin/cat > "{stdin_file}"\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}) @@ -132,7 +163,7 @@ def test_plugin_hook_wrapper_falls_back_to_python_when_python3_cannot_import( 'if [ "$1" = "-c" ]; then\n' " exit 1\n" "fi\n" - f"printf 'used' > \"{bad_python3_used}\"\n" + f"printf 'used' > \"{_shell_path(bad_python3_used)}\"\n" "echo 'No module named mempalace' >&2\n" "exit 1\n" ), @@ -141,8 +172,8 @@ def test_plugin_hook_wrapper_falls_back_to_python_when_python3_cannot_import( 'if [ "$1" = "-c" ]; then\n' " exit 0\n" "fi\n" - f'printf \'%s\' "$*" > "{args_file}"\n' - f'/bin/cat > "{stdin_file}"\n' + f'printf \'%s\' "$*" > "{_shell_path(args_file)}"\n' + f"{_capture_stdin_to(stdin_file)}" "printf '{}\\n'\n" ), }, From 0b316d405384f157e885cbae925123e22715f2fd Mon Sep 17 00:00:00 2001 From: fatkobra <55045047+fatkobra@users.noreply.github.com> Date: Sun, 19 Apr 2026 10:34:11 +0200 Subject: [PATCH 5/5] test: normalize wrapper script path for bash on Windows --- tests/test_claude_plugin_hook_wrappers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_claude_plugin_hook_wrappers.py b/tests/test_claude_plugin_hook_wrappers.py index 9bffa63..e427e0c 100644 --- a/tests/test_claude_plugin_hook_wrappers.py +++ b/tests/test_claude_plugin_hook_wrappers.py @@ -60,7 +60,7 @@ def _run_hook( env["PATH"] = str(bin_dir) return subprocess.run( - [BASH, str(PLUGIN_HOOKS_DIR / script_name)], + [BASH, _shell_path(PLUGIN_HOOKS_DIR / script_name)], input=payload, text=True, capture_output=True,