Skip to content

Commit 142b62d

Browse files
committed
fix(plugin): copy lib/ directory alongside hook file in session-start
session-start.py only copied the mode-detect hook to ~/.claude/hooks/ but not the lib/ directory containing hud_state.py. This caused the hook's HUD state update to silently fail (ImportError caught by bare except), leaving the TUI sidebar stuck on "idle" for all modes. - Extract _install_hook_with_lib() to handle hook + lib/ copy together - Use shutil.copytree with dirs_exist_ok=True for lib/ updates - Exclude __pycache__/*.pyc from copytree to avoid stale bytecode - Add 3 tests (normal copy, update existing, no lib/ graceful) Closes #1102
1 parent 834ebe1 commit 142b62d

2 files changed

Lines changed: 114 additions & 3 deletions

File tree

packages/claude-code-plugin/hooks/session-start.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,35 @@ def register_hook_in_settings(settings_file: Path) -> bool:
344344
return True
345345

346346

347+
def _install_hook_with_lib(
348+
source_file: Path, hooks_dir: Path, target_file: Path
349+
) -> None:
350+
"""Copy hook file AND its lib/ dependencies to the target hooks directory.
351+
352+
Copies the hook script and, if present, the sibling lib/ directory
353+
so that runtime imports (e.g. hud_state) work from ~/.claude/hooks/.
354+
355+
Args:
356+
source_file: Path to the source hook script.
357+
hooks_dir: Target directory (e.g. ~/.claude/hooks/).
358+
target_file: Full target path for the hook script.
359+
"""
360+
hooks_dir.mkdir(parents=True, exist_ok=True)
361+
shutil.copy(source_file, target_file)
362+
target_file.chmod(0o755)
363+
364+
# Copy lib/ directory alongside the hook (#1102)
365+
source_lib = source_file.parent / "lib"
366+
if source_lib.is_dir():
367+
target_lib = hooks_dir / "lib"
368+
shutil.copytree(
369+
source_lib,
370+
target_lib,
371+
dirs_exist_ok=True,
372+
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
373+
)
374+
375+
347376
HUD_FILENAME = "codingbuddy-hud.py"
348377

349378
# tmux suggestion messages (i18n)
@@ -648,9 +677,7 @@ def main():
648677
source_file = find_plugin_source()
649678

650679
if source_file:
651-
hooks_dir.mkdir(parents=True, exist_ok=True)
652-
shutil.copy(source_file, target_file)
653-
target_file.chmod(0o755)
680+
_install_hook_with_lib(source_file, hooks_dir, target_file)
654681
installed_hook = True
655682
else:
656683
# Source not found - provide manual installation guide

packages/claude-code-plugin/hooks/test_session_start.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,90 @@ def test_finds_agents_from_relative_path(self):
309309
assert len(json_files) > 0
310310

311311

312+
class TestHookLibCopy:
313+
"""Tests for lib/ directory copying alongside hook file (#1102)."""
314+
315+
def test_copies_lib_dir_when_installing_hook(self):
316+
"""Test that lib/ directory is copied alongside the hook file."""
317+
with tempfile.TemporaryDirectory() as tmpdir:
318+
home = Path(tmpdir) / "home"
319+
home.mkdir()
320+
321+
# Create mock plugin source with lib/
322+
plugin_dir = Path(tmpdir) / "plugin" / "hooks"
323+
plugin_dir.mkdir(parents=True)
324+
source_file = plugin_dir / "user-prompt-submit.py"
325+
source_file.write_text("# mock hook")
326+
327+
lib_dir = plugin_dir / "lib"
328+
lib_dir.mkdir()
329+
(lib_dir / "hud_state.py").write_text("# mock hud_state")
330+
(lib_dir / "__init__.py").write_text("")
331+
332+
hooks_dir = home / ".claude" / "hooks"
333+
target_file = hooks_dir / session_hook.HOOK_FILENAME
334+
335+
# Simulate main() behavior: find source and install
336+
with patch.object(session_hook, "find_plugin_source", return_value=source_file):
337+
with patch.object(Path, "home", return_value=home):
338+
session_hook._install_hook_with_lib(source_file, hooks_dir, target_file)
339+
340+
# Verify hook file was copied
341+
assert target_file.exists()
342+
343+
# Verify lib/ was copied
344+
target_lib = hooks_dir / "lib"
345+
assert target_lib.is_dir()
346+
assert (target_lib / "hud_state.py").exists()
347+
assert (target_lib / "__init__.py").exists()
348+
349+
def test_updates_lib_dir_when_already_exists(self):
350+
"""Test that lib/ directory is updated when it already exists."""
351+
with tempfile.TemporaryDirectory() as tmpdir:
352+
# Create source with new file
353+
plugin_dir = Path(tmpdir) / "plugin" / "hooks"
354+
plugin_dir.mkdir(parents=True)
355+
source_file = plugin_dir / "user-prompt-submit.py"
356+
source_file.write_text("# mock hook")
357+
source_lib = plugin_dir / "lib"
358+
source_lib.mkdir()
359+
(source_lib / "hud_state.py").write_text("# updated version")
360+
(source_lib / "new_module.py").write_text("# new")
361+
362+
# Create existing target with old lib
363+
hooks_dir = Path(tmpdir) / "target_hooks"
364+
hooks_dir.mkdir(parents=True)
365+
target_file = hooks_dir / session_hook.HOOK_FILENAME
366+
target_lib = hooks_dir / "lib"
367+
target_lib.mkdir()
368+
(target_lib / "hud_state.py").write_text("# old version")
369+
370+
session_hook._install_hook_with_lib(source_file, hooks_dir, target_file)
371+
372+
# Verify updated
373+
assert (target_lib / "hud_state.py").read_text() == "# updated version"
374+
assert (target_lib / "new_module.py").exists()
375+
376+
def test_works_when_source_has_no_lib(self):
377+
"""Test graceful handling when source has no lib/ directory."""
378+
with tempfile.TemporaryDirectory() as tmpdir:
379+
plugin_dir = Path(tmpdir) / "plugin" / "hooks"
380+
plugin_dir.mkdir(parents=True)
381+
source_file = plugin_dir / "user-prompt-submit.py"
382+
source_file.write_text("# mock hook")
383+
# No lib/ directory
384+
385+
hooks_dir = Path(tmpdir) / "target_hooks"
386+
hooks_dir.mkdir(parents=True)
387+
target_file = hooks_dir / session_hook.HOOK_FILENAME
388+
389+
# Should not raise
390+
session_hook._install_hook_with_lib(source_file, hooks_dir, target_file)
391+
392+
assert target_file.exists()
393+
assert not (hooks_dir / "lib").exists()
394+
395+
312396
if __name__ == "__main__":
313397
import pytest
314398
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)