#!/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)