| |
| """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 |
| operator: str |
| pattern: str |
|
|
| @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 |
| pattern: Optional[str] = None |
| conditions: List[Condition] = field(default_factory=list) |
| action: str = "warn" |
| tool_matcher: Optional[str] = None |
| message: str = "" |
|
|
| @classmethod |
| def from_dict(cls, frontmatter: Dict[str, Any], message: str) -> 'Rule': |
| """Create Rule from frontmatter dict and message body.""" |
| |
| conditions = [] |
|
|
| |
| if 'conditions' in frontmatter: |
| cond_list = frontmatter['conditions'] |
| if isinstance(cond_list, list): |
| conditions = [Condition.from_dict(c) for c in cond_list] |
|
|
| |
| simple_pattern = frontmatter.get('pattern') |
| if simple_pattern and not conditions: |
| |
| |
| 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 |
|
|
| |
| parts = content.split('---', 2) |
| if len(parts) < 3: |
| return {}, content |
|
|
| frontmatter_text = parts[1] |
| message = parts[2].strip() |
|
|
| |
| frontmatter = {} |
| lines = frontmatter_text.split('\n') |
|
|
| current_key = None |
| current_list = [] |
| current_dict = {} |
| in_list = False |
| in_dict_item = False |
|
|
| for line in lines: |
| |
| stripped = line.strip() |
| if not stripped or stripped.startswith('#'): |
| continue |
|
|
| |
| indent = len(line) - len(line.lstrip()) |
|
|
| |
| if indent == 0 and ':' in line and not line.strip().startswith('-'): |
| |
| 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: |
| |
| current_key = key |
| in_list = True |
| current_list = [] |
| else: |
| |
| value = value.strip('"').strip("'") |
| if value.lower() == 'true': |
| value = True |
| elif value.lower() == 'false': |
| value = False |
| frontmatter[key] = value |
|
|
| |
| elif stripped.startswith('-') and in_list: |
| |
| if in_dict_item and current_dict: |
| current_list.append(current_dict) |
| current_dict = {} |
|
|
| item_text = stripped[1:].strip() |
|
|
| |
| if ':' in item_text and ',' in item_text: |
| |
| 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: |
| |
| in_dict_item = True |
| k, v = item_text.split(':', 1) |
| current_dict = {k.strip(): v.strip().strip('"').strip("'")} |
| else: |
| |
| current_list.append(item_text.strip('"').strip("'")) |
| in_dict_item = False |
|
|
| |
| elif indent > 2 and in_dict_item and ':' in line: |
| |
| k, v = stripped.split(':', 1) |
| current_dict[k.strip()] = v.strip().strip('"').strip("'") |
|
|
| |
| 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 = [] |
|
|
| |
| 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 |
|
|
| |
| if event: |
| if rule.event != 'all' and rule.event != event: |
| continue |
|
|
| |
| if rule.enabled: |
| rules.append(rule) |
|
|
| except (IOError, OSError, PermissionError) as e: |
| |
| print(f"Warning: Failed to read {file_path}: {e}", file=sys.stderr) |
| continue |
| except (ValueError, KeyError, AttributeError, TypeError) as e: |
| |
| print(f"Warning: Failed to parse {file_path}: {e}", file=sys.stderr) |
| continue |
| except Exception as e: |
| |
| 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 |
|
|
|
|
| |
| if __name__ == '__main__': |
| import sys |
|
|
| |
| 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) |
|
|