mirror of
https://github.com/anthropics/claude-code.git
synced 2025-11-28 16:50:27 +08:00
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>
297 lines
9.5 KiB
Python
297 lines
9.5 KiB
Python
#!/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)
|