mirror of
https://github.com/anthropics/claude-code.git
synced 2026-05-10 09:29:55 +00:00
feat: Add hookify plugin for custom hook rules via markdown
Adds the hookify plugin to public marketplace. Enables users to create custom hooks using simple markdown configuration files instead of editing JSON. Key features: - Define rules with regex patterns to warn/block operations - Create rules from explicit instructions or conversation analysis - Pattern-based matching for bash commands, file edits, prompts, stop events - Enable/disable rules dynamically without editing code - Conversation analyzer agent finds problematic behaviors Changes from internal version: - Removed non-functional SessionStart hook (not registered in hooks.json) - Removed all sessionstart documentation and examples - Fixed restart documentation to consistently state "no restart needed" - Changed license from "Internal Anthropic use only" to "MIT License" - Kept test blocks in core modules (useful for developers) Plugin provides: - 4 commands: /hookify, /hookify:list, /hookify:configure, /hookify:help - 1 agent: conversation-analyzer - 1 skill: writing-rules - 4 hook types: PreToolUse, PostToolUse, Stop, UserPromptSubmit - 4 example rules ready to use All features functional and suitable for public use. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
297
plugins/hookify/core/config_loader.py
Normal file
297
plugins/hookify/core/config_loader.py
Normal file
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Configuration loader for hookify plugin.
|
||||
|
||||
Loads and parses .claude/hookify.*.local.md files.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import glob
|
||||
import re
|
||||
from typing import List, Optional, Dict, Any
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Condition:
|
||||
"""A single condition for matching."""
|
||||
field: str # "command", "new_text", "old_text", "file_path", etc.
|
||||
operator: str # "regex_match", "contains", "equals", etc.
|
||||
pattern: str # Pattern to match
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Condition':
|
||||
"""Create Condition from dict."""
|
||||
return cls(
|
||||
field=data.get('field', ''),
|
||||
operator=data.get('operator', 'regex_match'),
|
||||
pattern=data.get('pattern', '')
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Rule:
|
||||
"""A hookify rule."""
|
||||
name: str
|
||||
enabled: bool
|
||||
event: str # "bash", "file", "stop", "all", etc.
|
||||
pattern: Optional[str] = None # Simple pattern (legacy)
|
||||
conditions: List[Condition] = field(default_factory=list)
|
||||
action: str = "warn" # "warn" or "block" (future)
|
||||
tool_matcher: Optional[str] = None # Override tool matching
|
||||
message: str = "" # Message body from markdown
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, frontmatter: Dict[str, Any], message: str) -> 'Rule':
|
||||
"""Create Rule from frontmatter dict and message body."""
|
||||
# Handle both simple pattern and complex conditions
|
||||
conditions = []
|
||||
|
||||
# New style: explicit conditions list
|
||||
if 'conditions' in frontmatter:
|
||||
cond_list = frontmatter['conditions']
|
||||
if isinstance(cond_list, list):
|
||||
conditions = [Condition.from_dict(c) for c in cond_list]
|
||||
|
||||
# Legacy style: simple pattern field
|
||||
simple_pattern = frontmatter.get('pattern')
|
||||
if simple_pattern and not conditions:
|
||||
# Convert simple pattern to condition
|
||||
# Infer field from event
|
||||
event = frontmatter.get('event', 'all')
|
||||
if event == 'bash':
|
||||
field = 'command'
|
||||
elif event == 'file':
|
||||
field = 'new_text'
|
||||
else:
|
||||
field = 'content'
|
||||
|
||||
conditions = [Condition(
|
||||
field=field,
|
||||
operator='regex_match',
|
||||
pattern=simple_pattern
|
||||
)]
|
||||
|
||||
return cls(
|
||||
name=frontmatter.get('name', 'unnamed'),
|
||||
enabled=frontmatter.get('enabled', True),
|
||||
event=frontmatter.get('event', 'all'),
|
||||
pattern=simple_pattern,
|
||||
conditions=conditions,
|
||||
action=frontmatter.get('action', 'warn'),
|
||||
tool_matcher=frontmatter.get('tool_matcher'),
|
||||
message=message.strip()
|
||||
)
|
||||
|
||||
|
||||
def extract_frontmatter(content: str) -> tuple[Dict[str, Any], str]:
|
||||
"""Extract YAML frontmatter and message body from markdown.
|
||||
|
||||
Returns (frontmatter_dict, message_body).
|
||||
|
||||
Supports multi-line dictionary items in lists by preserving indentation.
|
||||
"""
|
||||
if not content.startswith('---'):
|
||||
return {}, content
|
||||
|
||||
# Split on --- markers
|
||||
parts = content.split('---', 2)
|
||||
if len(parts) < 3:
|
||||
return {}, content
|
||||
|
||||
frontmatter_text = parts[1]
|
||||
message = parts[2].strip()
|
||||
|
||||
# Simple YAML parser that handles indented list items
|
||||
frontmatter = {}
|
||||
lines = frontmatter_text.split('\n')
|
||||
|
||||
current_key = None
|
||||
current_list = []
|
||||
current_dict = {}
|
||||
in_list = False
|
||||
in_dict_item = False
|
||||
|
||||
for line in lines:
|
||||
# Skip empty lines and comments
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith('#'):
|
||||
continue
|
||||
|
||||
# Check indentation level
|
||||
indent = len(line) - len(line.lstrip())
|
||||
|
||||
# Top-level key (no indentation or minimal)
|
||||
if indent == 0 and ':' in line and not line.strip().startswith('-'):
|
||||
# Save previous list/dict if any
|
||||
if in_list and current_key:
|
||||
if in_dict_item and current_dict:
|
||||
current_list.append(current_dict)
|
||||
current_dict = {}
|
||||
frontmatter[current_key] = current_list
|
||||
in_list = False
|
||||
in_dict_item = False
|
||||
current_list = []
|
||||
|
||||
key, value = line.split(':', 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
|
||||
if not value:
|
||||
# Empty value - list or nested structure follows
|
||||
current_key = key
|
||||
in_list = True
|
||||
current_list = []
|
||||
else:
|
||||
# Simple key-value pair
|
||||
value = value.strip('"').strip("'")
|
||||
if value.lower() == 'true':
|
||||
value = True
|
||||
elif value.lower() == 'false':
|
||||
value = False
|
||||
frontmatter[key] = value
|
||||
|
||||
# List item (starts with -)
|
||||
elif stripped.startswith('-') and in_list:
|
||||
# Save previous dict item if any
|
||||
if in_dict_item and current_dict:
|
||||
current_list.append(current_dict)
|
||||
current_dict = {}
|
||||
|
||||
item_text = stripped[1:].strip()
|
||||
|
||||
# Check if this is an inline dict (key: value on same line)
|
||||
if ':' in item_text and ',' in item_text:
|
||||
# Inline comma-separated dict: "- field: command, operator: regex_match"
|
||||
item_dict = {}
|
||||
for part in item_text.split(','):
|
||||
if ':' in part:
|
||||
k, v = part.split(':', 1)
|
||||
item_dict[k.strip()] = v.strip().strip('"').strip("'")
|
||||
current_list.append(item_dict)
|
||||
in_dict_item = False
|
||||
elif ':' in item_text:
|
||||
# Start of multi-line dict item: "- field: command"
|
||||
in_dict_item = True
|
||||
k, v = item_text.split(':', 1)
|
||||
current_dict = {k.strip(): v.strip().strip('"').strip("'")}
|
||||
else:
|
||||
# Simple list item
|
||||
current_list.append(item_text.strip('"').strip("'"))
|
||||
in_dict_item = False
|
||||
|
||||
# Continuation of dict item (indented under list item)
|
||||
elif indent > 2 and in_dict_item and ':' in line:
|
||||
# This is a field of the current dict item
|
||||
k, v = stripped.split(':', 1)
|
||||
current_dict[k.strip()] = v.strip().strip('"').strip("'")
|
||||
|
||||
# Save final list/dict if any
|
||||
if in_list and current_key:
|
||||
if in_dict_item and current_dict:
|
||||
current_list.append(current_dict)
|
||||
frontmatter[current_key] = current_list
|
||||
|
||||
return frontmatter, message
|
||||
|
||||
|
||||
def load_rules(event: Optional[str] = None) -> List[Rule]:
|
||||
"""Load all hookify rules from .claude directory.
|
||||
|
||||
Args:
|
||||
event: Optional event filter ("bash", "file", "stop", etc.)
|
||||
|
||||
Returns:
|
||||
List of enabled Rule objects matching the event.
|
||||
"""
|
||||
rules = []
|
||||
|
||||
# Find all hookify.*.local.md files
|
||||
pattern = os.path.join('.claude', 'hookify.*.local.md')
|
||||
files = glob.glob(pattern)
|
||||
|
||||
for file_path in files:
|
||||
try:
|
||||
rule = load_rule_file(file_path)
|
||||
if not rule:
|
||||
continue
|
||||
|
||||
# Filter by event if specified
|
||||
if event:
|
||||
if rule.event != 'all' and rule.event != event:
|
||||
continue
|
||||
|
||||
# Only include enabled rules
|
||||
if rule.enabled:
|
||||
rules.append(rule)
|
||||
|
||||
except (IOError, OSError, PermissionError) as e:
|
||||
# File I/O errors - log and continue
|
||||
print(f"Warning: Failed to read {file_path}: {e}", file=sys.stderr)
|
||||
continue
|
||||
except (ValueError, KeyError, AttributeError, TypeError) as e:
|
||||
# Parsing errors - log and continue
|
||||
print(f"Warning: Failed to parse {file_path}: {e}", file=sys.stderr)
|
||||
continue
|
||||
except Exception as e:
|
||||
# Unexpected errors - log with type details
|
||||
print(f"Warning: Unexpected error loading {file_path} ({type(e).__name__}): {e}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
return rules
|
||||
|
||||
|
||||
def load_rule_file(file_path: str) -> Optional[Rule]:
|
||||
"""Load a single rule file.
|
||||
|
||||
Returns:
|
||||
Rule object or None if file is invalid.
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
frontmatter, message = extract_frontmatter(content)
|
||||
|
||||
if not frontmatter:
|
||||
print(f"Warning: {file_path} missing YAML frontmatter (must start with ---)", file=sys.stderr)
|
||||
return None
|
||||
|
||||
rule = Rule.from_dict(frontmatter, message)
|
||||
return rule
|
||||
|
||||
except (IOError, OSError, PermissionError) as e:
|
||||
print(f"Error: Cannot read {file_path}: {e}", file=sys.stderr)
|
||||
return None
|
||||
except (ValueError, KeyError, AttributeError, TypeError) as e:
|
||||
print(f"Error: Malformed rule file {file_path}: {e}", file=sys.stderr)
|
||||
return None
|
||||
except UnicodeDecodeError as e:
|
||||
print(f"Error: Invalid encoding in {file_path}: {e}", file=sys.stderr)
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error: Unexpected error parsing {file_path} ({type(e).__name__}): {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
# For testing
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
|
||||
# Test frontmatter parsing
|
||||
test_content = """---
|
||||
name: test-rule
|
||||
enabled: true
|
||||
event: bash
|
||||
pattern: "rm -rf"
|
||||
---
|
||||
|
||||
⚠️ Dangerous command detected!
|
||||
"""
|
||||
|
||||
fm, msg = extract_frontmatter(test_content)
|
||||
print("Frontmatter:", fm)
|
||||
print("Message:", msg)
|
||||
|
||||
rule = Rule.from_dict(fm, msg)
|
||||
print("Rule:", rule)
|
||||
Reference in New Issue
Block a user