mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-07-30 13:06:57 -03:00
298 lines
9.5 KiB
Python
298 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)
|