Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
24 changes: 21 additions & 3 deletions src/agent_comms_hooks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
37 changes: 37 additions & 0 deletions tests/test_claude_hook_install.py
Original file line number Diff line number Diff line change
@@ -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"
Loading