diff --git a/src/agent_comms_hooks/__init__.py b/src/agent_comms_hooks/__init__.py index 0efb22ae2c..7ee7a49d3e 100644 --- a/src/agent_comms_hooks/__init__.py +++ b/src/agent_comms_hooks/__init__.py @@ -15,6 +15,7 @@ from abc import ABC, abstractmethod from collections.abc import Mapping from dataclasses import dataclass, fields +from functools import cache from importlib import import_module from importlib.metadata import entry_points from pathlib import Path @@ -127,13 +128,30 @@ def console_script(target: str) -> str: @classmethod def owns_entry(cls, entry: Mapping[str, object]) -> bool: - """Ownership survives deletion of a program and its console script.""" - return entry.get("writer") == cls.writer + """Ownership survives deletion of a program and its console script. An untagged + entry running only this package's hooks was written before the tag existed; + install adopts it, so each hook keeps one entry.""" + match entry: + case {"writer": writer} if writer == cls.writer: + return True + case {"hooks": [_, *_] as hooks}: + return all(hook in cls.hooks() for hook in hooks) + return False + + @classmethod + @cache + def hooks(cls) -> tuple[dict[str, str], ...]: + """The hook every declared program installs.""" + return tuple(program.hook() for program in cls.members()) + + @classmethod + def hook(cls) -> dict[str, str]: + return {"type": "command", "command": cls.command()} @classmethod def entry(cls) -> dict[str, object]: """This program's entry, marked so install can replace what it wrote.""" - return {"writer": cls.writer, "hooks": [{"type": "command", "command": cls.command()}]} + return {"writer": cls.writer, "hooks": [cls.hook()]} class ClaudeToolHookProgram(ClaudeHookProgram, ABC): diff --git a/tests/test_claude_hook_install.py b/tests/test_claude_hook_install.py new file mode 100644 index 0000000000..9d5639c732 --- /dev/null +++ b/tests/test_claude_hook_install.py @@ -0,0 +1,37 @@ +"""Install owns every Claude Code hook entry that runs its programs, tagged or not.""" + +import json + +from agent_comms.harness_install import ClaudeCodeHarness +from agent_comms_hooks import ClaudeHookProgram + +FOREIGN = {"matcher": "Bash", "hooks": [{"type": "command", "command": "python3 -I guard.py"}]} + + +def commands(settings) -> dict[str, list[str]]: + hooks = json.loads(settings.read_text())["hooks"] + return { + event: sorted(hook["command"] for entry in entries for hook in entry["hooks"]) + for event, entries in hooks.items() + } + + +def test_install_adopts_untagged_copies_of_its_hooks_and_keeps_foreign_ones(tmp_path): + settings = tmp_path / ".claude/settings.json" + untagged: dict[str, list[dict[str, object]]] = {"PreToolUse": [FOREIGN]} + for program in ClaudeHookProgram.members(): + entry = {key: value for key, value in program.entry().items() if key != "writer"} + for event in program.events: + untagged.setdefault(event, []).append(entry) + settings.parent.mkdir() + settings.write_text(json.dumps({"hooks": untagged, "theme": "dark"})) + + ClaudeCodeHarness(tmp_path).configure() + ClaudeCodeHarness(tmp_path).configure() + + expected: dict[str, list[str]] = {"PreToolUse": ["python3 -I guard.py"]} + for program in ClaudeHookProgram.members(): + for event in program.events: + expected.setdefault(event, []).append(program.command()) + assert commands(settings) == {event: sorted(found) for event, found in expected.items()} + assert json.loads(settings.read_text())["theme"] == "dark"