fix(hook): write Claude Code hooks in matcher-wrapped format with correct timeout#216
Merged
fix(hook): write Claude Code hooks in matcher-wrapped format with correct timeout#216
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Unused function
_entry_matches_commandis dead code- Removed the
_entry_matches_commandfunction which was defined but never called anywhere in the codebase.
- Removed the
- ✅ Fixed: Uninstall silently drops unrelated entries with empty hooks
- Added an early return in
_strip_command_from_matcher_entrythat preserves the entry unchanged when the target command is not present in its hooks list, preventing unrelated entries from being silently dropped.
- Added an early return in
Or push these changes by commenting:
@cursor push 9ae2838295
Preview (9ae2838295)
diff --git a/src/primer/hook/installer.py b/src/primer/hook/installer.py
--- a/src/primer/hook/installer.py
+++ b/src/primer/hook/installer.py
@@ -180,24 +180,6 @@
return already_end and already_compact
-def _entry_matches_command(entry: object, command: str) -> bool:
- """True if a hook list entry (any supported format) references `command`."""
- if isinstance(entry, str):
- return entry == command
- if not isinstance(entry, dict):
- return False
- # Legacy flat format
- if entry.get("command") == command:
- return True
- # Matcher-wrapped format — entry matches if any inner hook targets the command
- inner_hooks = entry.get("hooks", [])
- if isinstance(inner_hooks, list):
- for inner in inner_hooks:
- if isinstance(inner, dict) and inner.get("command") == command:
- return True
- return False
-
-
def _strip_command_from_matcher_entry(entry: dict, command: str) -> dict | None:
"""Remove the command from a matcher-wrapped entry, returning a cleaned
entry or None if no inner hooks remain."""
@@ -207,6 +189,8 @@
remaining = [
h for h in inner_hooks if not (isinstance(h, dict) and h.get("command") == command)
]
+ if len(remaining) == len(inner_hooks):
+ return entry
if not remaining:
return None
cleaned = dict(entry)You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit ab01dae. Configure here.
…rect timeout
The installer was writing hooks in a flat format that newer Claude Code
versions reject:
{"command": "...", "timeout": 10000}
Claude Code requires the matcher-wrapped schema:
{
"matcher": "",
"hooks": [{"type": "command", "command": "...", "timeout": 30}]
}
Additionally, timeouts are specified in SECONDS per the schema, not
milliseconds. The old 10000 value would have been 2.8 hours, which is
wildly excessive for a local ingest POST. New installs use 30 seconds.
- _install_claude writes the matcher-wrapped format
- _find_hook_claude detects both the current format and the legacy flat
format so uninstall can migrate existing users
- _uninstall_claude strips either format cleanly, removing empty
matcher entries when the last inner hook is removed
- New tests cover legacy-format detection, uninstall, and install
idempotency against a legacy-format settings.json
Existing users will see their legacy hooks honored by status/uninstall.
To migrate to the correct format, they can run:
primer hook uninstall && primer hook install
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Remove _entry_matches_command dead code — never called.
- Stop silently dropping unrelated third-party matcher entries whose
hooks array is already empty. The old _strip_command_from_matcher_
entry returned None for any empty remaining list, so a pre-existing
{"matcher": "Bash", "hooks": []} would disappear on uninstall. Now
we only strip entries that actually contain the primer command.
- Add regression tests for:
* preserve third-party entry with empty hooks array
* preserve third-party sibling hooks inside a shared matcher entry
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ab01dae to
ba4698e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Summary
The Primer hook installer was writing Claude Code hooks in a flat format that newer Claude Code versions reject with a settings-load error:
{"command": "...", "timeout": 10000}Claude Code requires the matcher-wrapped schema:
{ "matcher": "", "hooks": [{"type": "command", "command": "...", "timeout": 30}] }This PR switches the installer to write the correct format and fixes two related issues:
Timeout unit
The old installer used
timeout: 10000assuming milliseconds. Per the Claude Code schema,timeoutis in seconds — so 10000 was 2.8 hours, wildly excessive for a local ingest POST. New installs use 30 seconds.Backward compatibility
_find_hook_claudeand_uninstall_claudenow detect and clean up both the current matcher-wrapped format and the legacy flat format earlier installer versions wrote. Existing users can migrate with:Test plan
pytest tests/test_hook_installer.py— 23 passed (3 new legacy-format tests)jq -e '.hooks.SessionEnd[] | .hooks[] | select(.type == "command") | .command'validates against a fresh install🤖 Generated with Claude Code
Note
Medium Risk
Changes how Claude hook entries are written and removed in user settings files, so mistakes could break hook detection or accidentally drop/retain third-party hooks. Coverage is improved with new tests for legacy and mixed hook lists.
Overview
Updates the Claude Code hook installer to write hooks in Claude’s matcher-wrapped schema (including
type: "command") and switches the timeout to 30 seconds (seconds, not ms).Improves Claude hook detection and uninstall to handle both the new matcher-wrapped format and the legacy flat
{command, timeout}entries, while preserving unrelated/third-party matcher entries and sibling hooks. Tests are updated and expanded to cover the new schema, legacy compatibility, and uninstall preservation cases.Reviewed by Cursor Bugbot for commit ba4698e. Bugbot is set up for automated code reviews on this repo. Configure here.