Skip to content

fix(hook): write Claude Code hooks in matcher-wrapped format with correct timeout#216

Merged
ccf merged 2 commits intomainfrom
fix/hook-installer-format
Apr 17, 2026
Merged

fix(hook): write Claude Code hooks in matcher-wrapped format with correct timeout#216
ccf merged 2 commits intomainfrom
fix/hook-installer-format

Conversation

@ccf
Copy link
Copy Markdown
Owner

@ccf ccf commented Apr 16, 2026

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: 10000 assuming milliseconds. Per the Claude Code schema, timeout is in seconds — so 10000 was 2.8 hours, wildly excessive for a local ingest POST. New installs use 30 seconds.

Backward compatibility

_find_hook_claude and _uninstall_claude now detect and clean up both the current matcher-wrapped format and the legacy flat format earlier installer versions wrote. Existing users can migrate with:

primer hook uninstall && primer hook install

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.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Unused function _entry_matches_command is dead code
    • Removed the _entry_matches_command function which was defined but never called anywhere in the codebase.
  • ✅ Fixed: Uninstall silently drops unrelated entries with empty hooks
    • Added an early return in _strip_command_from_matcher_entry that preserves the entry unchanged when the target command is not present in its hooks list, preventing unrelated entries from being silently dropped.

Create PR

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.

Comment thread src/primer/hook/installer.py Outdated
Comment thread src/primer/hook/installer.py
ccf and others added 2 commits April 17, 2026 01:00
…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>
@ccf ccf force-pushed the fix/hook-installer-format branch from ab01dae to ba4698e Compare April 17, 2026 05:04
@ccf ccf merged commit c82d638 into main Apr 17, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant