spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

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