config_loader.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. #!/usr/bin/env python3
  2. """Configuration loader for hookify plugin.
  3. Loads and parses .claude/hookify.*.local.md files.
  4. """
  5. import os
  6. import sys
  7. import glob
  8. import re
  9. from typing import List, Optional, Dict, Any
  10. from dataclasses import dataclass, field
  11. @dataclass
  12. class Condition:
  13. """A single condition for matching."""
  14. field: str # "command", "new_text", "old_text", "file_path", etc.
  15. operator: str # "regex_match", "contains", "equals", etc.
  16. pattern: str # Pattern to match
  17. @classmethod
  18. def from_dict(cls, data: Dict[str, Any]) -> 'Condition':
  19. """Create Condition from dict."""
  20. return cls(
  21. field=data.get('field', ''),
  22. operator=data.get('operator', 'regex_match'),
  23. pattern=data.get('pattern', '')
  24. )
  25. @dataclass
  26. class Rule:
  27. """A hookify rule."""
  28. name: str
  29. enabled: bool
  30. event: str # "bash", "file", "stop", "all", etc.
  31. pattern: Optional[str] = None # Simple pattern (legacy)
  32. conditions: List[Condition] = field(default_factory=list)
  33. action: str = "warn" # "warn" or "block" (future)
  34. tool_matcher: Optional[str] = None # Override tool matching
  35. message: str = "" # Message body from markdown
  36. @classmethod
  37. def from_dict(cls, frontmatter: Dict[str, Any], message: str) -> 'Rule':
  38. """Create Rule from frontmatter dict and message body."""
  39. # Handle both simple pattern and complex conditions
  40. conditions = []
  41. # New style: explicit conditions list
  42. if 'conditions' in frontmatter:
  43. cond_list = frontmatter['conditions']
  44. if isinstance(cond_list, list):
  45. conditions = [Condition.from_dict(c) for c in cond_list]
  46. # Legacy style: simple pattern field
  47. simple_pattern = frontmatter.get('pattern')
  48. if simple_pattern and not conditions:
  49. # Convert simple pattern to condition
  50. # Infer field from event
  51. event = frontmatter.get('event', 'all')
  52. if event == 'bash':
  53. field = 'command'
  54. elif event == 'file':
  55. field = 'new_text'
  56. else:
  57. field = 'content'
  58. conditions = [Condition(
  59. field=field,
  60. operator='regex_match',
  61. pattern=simple_pattern
  62. )]
  63. return cls(
  64. name=frontmatter.get('name', 'unnamed'),
  65. enabled=frontmatter.get('enabled', True),
  66. event=frontmatter.get('event', 'all'),
  67. pattern=simple_pattern,
  68. conditions=conditions,
  69. action=frontmatter.get('action', 'warn'),
  70. tool_matcher=frontmatter.get('tool_matcher'),
  71. message=message.strip()
  72. )
  73. def extract_frontmatter(content: str) -> tuple[Dict[str, Any], str]:
  74. """Extract YAML frontmatter and message body from markdown.
  75. Returns (frontmatter_dict, message_body).
  76. Supports multi-line dictionary items in lists by preserving indentation.
  77. """
  78. if not content.startswith('---'):
  79. return {}, content
  80. # Split on --- markers
  81. parts = content.split('---', 2)
  82. if len(parts) < 3:
  83. return {}, content
  84. frontmatter_text = parts[1]
  85. message = parts[2].strip()
  86. # Simple YAML parser that handles indented list items
  87. frontmatter = {}
  88. lines = frontmatter_text.split('\n')
  89. current_key = None
  90. current_list = []
  91. current_dict = {}
  92. in_list = False
  93. in_dict_item = False
  94. for line in lines:
  95. # Skip empty lines and comments
  96. stripped = line.strip()
  97. if not stripped or stripped.startswith('#'):
  98. continue
  99. # Check indentation level
  100. indent = len(line) - len(line.lstrip())
  101. # Top-level key (no indentation or minimal)
  102. if indent == 0 and ':' in line and not line.strip().startswith('-'):
  103. # Save previous list/dict if any
  104. if in_list and current_key:
  105. if in_dict_item and current_dict:
  106. current_list.append(current_dict)
  107. current_dict = {}
  108. frontmatter[current_key] = current_list
  109. in_list = False
  110. in_dict_item = False
  111. current_list = []
  112. key, value = line.split(':', 1)
  113. key = key.strip()
  114. value = value.strip()
  115. if not value:
  116. # Empty value - list or nested structure follows
  117. current_key = key
  118. in_list = True
  119. current_list = []
  120. else:
  121. # Simple key-value pair
  122. value = value.strip('"').strip("'")
  123. if value.lower() == 'true':
  124. value = True
  125. elif value.lower() == 'false':
  126. value = False
  127. frontmatter[key] = value
  128. # List item (starts with -)
  129. elif stripped.startswith('-') and in_list:
  130. # Save previous dict item if any
  131. if in_dict_item and current_dict:
  132. current_list.append(current_dict)
  133. current_dict = {}
  134. item_text = stripped[1:].strip()
  135. # Check if this is an inline dict (key: value on same line)
  136. if ':' in item_text and ',' in item_text:
  137. # Inline comma-separated dict: "- field: command, operator: regex_match"
  138. item_dict = {}
  139. for part in item_text.split(','):
  140. if ':' in part:
  141. k, v = part.split(':', 1)
  142. item_dict[k.strip()] = v.strip().strip('"').strip("'")
  143. current_list.append(item_dict)
  144. in_dict_item = False
  145. elif ':' in item_text:
  146. # Start of multi-line dict item: "- field: command"
  147. in_dict_item = True
  148. k, v = item_text.split(':', 1)
  149. current_dict = {k.strip(): v.strip().strip('"').strip("'")}
  150. else:
  151. # Simple list item
  152. current_list.append(item_text.strip('"').strip("'"))
  153. in_dict_item = False
  154. # Continuation of dict item (indented under list item)
  155. elif indent > 2 and in_dict_item and ':' in line:
  156. # This is a field of the current dict item
  157. k, v = stripped.split(':', 1)
  158. current_dict[k.strip()] = v.strip().strip('"').strip("'")
  159. # Save final list/dict if any
  160. if in_list and current_key:
  161. if in_dict_item and current_dict:
  162. current_list.append(current_dict)
  163. frontmatter[current_key] = current_list
  164. return frontmatter, message
  165. def load_rules(event: Optional[str] = None) -> List[Rule]:
  166. """Load all hookify rules from .claude directory.
  167. Args:
  168. event: Optional event filter ("bash", "file", "stop", etc.)
  169. Returns:
  170. List of enabled Rule objects matching the event.
  171. """
  172. rules = []
  173. # Find all hookify.*.local.md files
  174. pattern = os.path.join('.claude', 'hookify.*.local.md')
  175. files = glob.glob(pattern)
  176. for file_path in files:
  177. try:
  178. rule = load_rule_file(file_path)
  179. if not rule:
  180. continue
  181. # Filter by event if specified
  182. if event:
  183. if rule.event != 'all' and rule.event != event:
  184. continue
  185. # Only include enabled rules
  186. if rule.enabled:
  187. rules.append(rule)
  188. except (IOError, OSError, PermissionError) as e:
  189. # File I/O errors - log and continue
  190. print(f"Warning: Failed to read {file_path}: {e}", file=sys.stderr)
  191. continue
  192. except (ValueError, KeyError, AttributeError, TypeError) as e:
  193. # Parsing errors - log and continue
  194. print(f"Warning: Failed to parse {file_path}: {e}", file=sys.stderr)
  195. continue
  196. except Exception as e:
  197. # Unexpected errors - log with type details
  198. print(f"Warning: Unexpected error loading {file_path} ({type(e).__name__}): {e}", file=sys.stderr)
  199. continue
  200. return rules
  201. def load_rule_file(file_path: str) -> Optional[Rule]:
  202. """Load a single rule file.
  203. Returns:
  204. Rule object or None if file is invalid.
  205. """
  206. try:
  207. with open(file_path, 'r') as f:
  208. content = f.read()
  209. frontmatter, message = extract_frontmatter(content)
  210. if not frontmatter:
  211. print(f"Warning: {file_path} missing YAML frontmatter (must start with ---)", file=sys.stderr)
  212. return None
  213. rule = Rule.from_dict(frontmatter, message)
  214. return rule
  215. except (IOError, OSError, PermissionError) as e:
  216. print(f"Error: Cannot read {file_path}: {e}", file=sys.stderr)
  217. return None
  218. except (ValueError, KeyError, AttributeError, TypeError) as e:
  219. print(f"Error: Malformed rule file {file_path}: {e}", file=sys.stderr)
  220. return None
  221. except UnicodeDecodeError as e:
  222. print(f"Error: Invalid encoding in {file_path}: {e}", file=sys.stderr)
  223. return None
  224. except Exception as e:
  225. print(f"Error: Unexpected error parsing {file_path} ({type(e).__name__}): {e}", file=sys.stderr)
  226. return None
  227. # For testing
  228. if __name__ == '__main__':
  229. import sys
  230. # Test frontmatter parsing
  231. test_content = """---
  232. name: test-rule
  233. enabled: true
  234. event: bash
  235. pattern: "rm -rf"
  236. ---
  237. ⚠️ Dangerous command detected!
  238. """
  239. fm, msg = extract_frontmatter(test_content)
  240. print("Frontmatter:", fm)
  241. print("Message:", msg)
  242. rule = Rule.from_dict(fm, msg)
  243. print("Rule:", rule)