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