-
Notifications
You must be signed in to change notification settings - Fork 0
fix(install): add PostToolUse/Stop/PreCompact/UserPromptSubmit hooks to Claude Code adapter #243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| import tomllib | ||
| from pathlib import Path | ||
|
|
@@ -43,13 +44,36 @@ def test_codex_adapter_writes_valid_toml_with_quoted_brain_path(tmp_path: Path) | |
|
|
||
| assert result.action == "added" | ||
| parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) | ||
| hook = parsed["hooks"]["pre_tool"][0] | ||
| hooks = parsed["hooks"] | ||
| hook = hooks["pre_tool"][0] | ||
| assert set(hooks) >= {"pre_tool", "post_tool", "session_end"} | ||
| assert hook["id"].startswith("gradata:codex:") | ||
| # Round-trip: the brain dir must appear in the hook id (which is | ||
| # build-from-brain-dir before any shell-escaping). | ||
| # Adapter normalizes to POSIX path for cross-platform stable signature, | ||
| # so compare against as_posix() not raw str() (Windows uses backslashes). | ||
| assert brain_dir.as_posix() in hook["id"] | ||
| assert "gradata.hooks.inject_brain_rules" in hooks["pre_tool"][0]["command"] | ||
| assert "gradata.hooks.auto_correct" in hooks["post_tool"][0]["command"] | ||
| assert "gradata.hooks.session_close" in hooks["session_end"][0]["command"] | ||
|
|
||
|
|
||
| def test_opencode_adapter_writes_pre_post_and_session_hooks(tmp_path: Path) -> None: | ||
| brain_dir = tmp_path / "brain" | ||
| brain_dir.mkdir() | ||
| config_path = tmp_path / ".config" / "opencode" / "config.json" | ||
|
|
||
| adapter = get_adapter("opencode") | ||
| first = adapter.install(brain_dir, config_path) | ||
| second = adapter.install(brain_dir, config_path) | ||
|
|
||
| assert first.action == "added" | ||
| assert second.action == "already_present" | ||
| hooks = json.loads(config_path.read_text(encoding="utf-8"))["hooks"] | ||
| assert set(hooks) >= {"preTool", "postTool", "sessionEnd"} | ||
| assert "gradata.hooks.inject_brain_rules" in hooks["preTool"][0]["command"] | ||
| assert "gradata.hooks.auto_correct" in hooks["postTool"][0]["command"] | ||
| assert "gradata.hooks.session_close" in hooks["sessionEnd"][0]["command"] | ||
|
|
||
|
|
||
| def test_adapter_install_does_not_touch_real_user_config(tmp_path: Path) -> None: | ||
|
|
@@ -63,3 +87,26 @@ def test_adapter_install_does_not_touch_real_user_config(tmp_path: Path) -> None | |
| assert result.action == "added" | ||
| after = real_config.read_text(encoding="utf-8") if real_config.exists() else None | ||
| assert after == before | ||
|
|
||
|
|
||
| def test_claude_code_install_writes_pre_compact_entry(tmp_path: Path) -> None: | ||
| brain_dir = tmp_path / "brain" | ||
| brain_dir.mkdir() | ||
| config_path = tmp_path / ".claude" / "settings.json" | ||
|
|
||
| adapter = get_adapter("claude-code") | ||
| first = adapter.install(brain_dir, config_path) | ||
| second = adapter.install(brain_dir, config_path) | ||
|
|
||
| assert first.action == "added" | ||
| assert second.action == "already_present" | ||
| settings = json.loads(config_path.read_text(encoding="utf-8")) | ||
| pre_compact = settings["hooks"]["PreCompact"] | ||
| commands = [ | ||
| hook.get("command", "") | ||
| for entry in pre_compact | ||
| for hook in entry.get("hooks", []) | ||
| ] | ||
| assert len(pre_compact) == 1 | ||
| assert any("BRAIN_DIR=" in command for command in commands) | ||
| assert any("gradata.hooks.pre_compact" in command for command in commands) | ||
|
Comment on lines
+92
to
+112
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win Extend coverage to the PostToolUse phase. The test only asserts the 💚 Suggested additional assertions assert any("BRAIN_DIR=" in command for command in commands)
assert any("gradata.hooks.pre_compact" in command for command in commands)
+
+ post_tool = settings["hooks"]["PostToolUse"]
+ assert len(post_tool) == 1
+ assert "MultiEdit" in post_tool[0]["matcher"]
+ post_commands = [
+ hook.get("command", "")
+ for entry in post_tool
+ for hook in entry.get("hooks", [])
+ ]
+ assert any("gradata.hooks.auto_correct" in command for command in post_commands)🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
Claude Code hooks matcher regex tool name matching exact or substring Edit MultiEdit💡 Result:
In Claude Code, the matcher field in hook configurations determines which tool names trigger a specific hook [1][2]. Its behavior is determined by the content of the string provided [1]: 1. Exact Match or Pipe-Separated List: If the matcher string contains only letters, digits, underscores (_), or pipe characters (|), it is treated as an exact match or a list of specific tools [1][2]. For example, "Edit|MultiEdit|Write" will match any of those three tools exactly [3][4]. 2. Regular Expression: If the matcher string contains any character other than those listed above (such as a dot, caret, or asterisk), it is evaluated as a JavaScript regular expression [1][2]. For instance, "^Notebook" matches any tool name starting with "Notebook", and "mcp." matches any MCP tool [1][2]. When using tools like Edit and MultiEdit in hooks, you can group them using the pipe operator ("Edit|MultiEdit") to apply the same hook logic to both [3][4]. If you omit the matcher or set it to "" or "", the hook will fire for every occurrence of the event [1][2]. It is important to note that the matcher runs against the tool_name field provided in the JSON input to the hook [1][2]. Because non-regex matchers require exact matches, using patterns like "mcpmy_server" will fail to match "mcpmy_servertoolname"; in such cases, you must use a regex pattern like "mcpmy_server.*" to ensure a match [5].
Citations:
🏁 Script executed:
Repository: Gradata/gradata
Length of output: 102
🏁 Script executed:
Repository: Gradata/gradata
Length of output: 9335
PostToolUse matcher drops
MultiEditeditsIn
Gradata/src/gradata/hooks/adapters/claude_code.py(lines 91-103), thePostToolUsehook uses"matcher": "Edit|Write", so it triggers only for tool_name exactlyEditorWrite. The adapter explicitly supportsMultiEdit(EDIT_TOOLSincludes"MultiEdit"andextract_correction()handles it), but the hook never fires forMultiEdit.🐛 Proposed fix
🤖 Prompt for AI Agents