Spaces:
Running
Running
feat(agent): add Claude Code-style agent, skills, slash-commands, hooks, todos, sandboxed workspace, and full-stack scaffolding
81aa0b5 verified | """Hooks system — pre/post tool execution rules. | |
| Inspired by Claude Code's hookify plugin. Rules are markdown files | |
| with YAML frontmatter that define: | |
| - event: bash | file | prompt | stop | all | |
| - pattern: regex to match | |
| - action: warn | block | |
| - message: shown to the user/agent when triggered | |
| Rules are discovered from: | |
| - code/hooks/builtins/ (built-in rules) | |
| - workspace/.sonicoder/hooks/ (user rules) | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| import re | |
| from typing import Any | |
| from code.skills import _parse_frontmatter | |
| logger = logging.getLogger(__name__) | |
| _BUILTIN_HOOKS_DIR = os.path.join(os.path.dirname(__file__), "builtins") | |
| _USER_HOOKS_DIRNAME = ".sonicoder/hooks" | |
| def _hook_dirs() -> list[str]: | |
| dirs = [_BUILTIN_HOOKS_DIR] | |
| try: | |
| from code.tools.fs import get_workspace_root | |
| user_dir = os.path.join(get_workspace_root(), _USER_HOOKS_DIRNAME) | |
| if os.path.isdir(user_dir): | |
| dirs.append(user_dir) | |
| except Exception: | |
| pass | |
| return dirs | |
| def _load_hook(filepath: str) -> dict[str, Any] | None: | |
| try: | |
| with open(filepath, "r", encoding="utf-8") as f: | |
| content = f.read() | |
| except Exception: | |
| return None | |
| meta, body = _parse_frontmatter(content) | |
| name = meta.get("name", os.path.splitext(os.path.basename(filepath))[0]) | |
| enabled = meta.get("enabled", "true").lower() == "true" | |
| event = meta.get("event", "all").lower() | |
| action = meta.get("action", "warn").lower() | |
| pattern = meta.get("pattern", "") | |
| conditions_raw = meta.get("conditions", "") | |
| # Parse conditions (simplified — actual hookify uses YAML lists) | |
| conditions: list[dict[str, str]] = [] | |
| if conditions_raw: | |
| # Very rough parse: each "- field: x\n operator: y\n pattern: z" | |
| for block in re.split(r"(?=\n-\s+field:)", "\n" + conditions_raw): | |
| field_m = re.search(r"field:\s*(\S+)", block) | |
| op_m = re.search(r"operator:\s*(\S+)", block) | |
| pat_m = re.search(r"pattern:\s*(.+?)(?=\n\s*$|\n\s*-|\Z)", block, re.DOTALL) | |
| if field_m and op_m and pat_m: | |
| conditions.append({ | |
| "field": field_m.group(1), | |
| "operator": op_m.group(1), | |
| "pattern": pat_m.group(1).strip(), | |
| }) | |
| return { | |
| "name": name, | |
| "enabled": enabled, | |
| "event": event, | |
| "action": action, | |
| "pattern": pattern, | |
| "conditions": conditions, | |
| "message": body.strip(), | |
| "path": filepath, | |
| } | |
| def list_hooks() -> list[dict[str, Any]]: | |
| """List all hooks (metadata only).""" | |
| hooks: list[dict[str, Any]] = [] | |
| seen: set[str] = set() | |
| for hooks_dir in _hook_dirs(): | |
| if not os.path.isdir(hooks_dir): | |
| continue | |
| for entry in sorted(os.listdir(hooks_dir)): | |
| if not entry.endswith(".md"): | |
| continue | |
| filepath = os.path.join(hooks_dir, entry) | |
| hook = _load_hook(filepath) | |
| if hook and hook["name"] not in seen: | |
| seen.add(hook["name"]) | |
| hooks.append({ | |
| "name": hook["name"], | |
| "enabled": hook["enabled"], | |
| "event": hook["event"], | |
| "action": hook["action"], | |
| "pattern": hook["pattern"], | |
| }) | |
| return hooks | |
| def _match_condition(condition: dict[str, str], context: dict[str, Any]) -> bool: | |
| """Check if a single condition matches.""" | |
| field = condition.get("field", "") | |
| operator = condition.get("operator", "regex_match") | |
| pattern = condition.get("pattern", "") | |
| value = str(context.get(field, "")) | |
| if operator == "regex_match": | |
| return bool(re.search(pattern, value)) | |
| elif operator == "contains": | |
| return pattern in value | |
| elif operator == "equals": | |
| return value == pattern | |
| elif operator == "not_contains": | |
| return pattern not in value | |
| elif operator == "starts_with": | |
| return value.startswith(pattern) | |
| elif operator == "ends_with": | |
| return value.endswith(pattern) | |
| return False | |
| def _match_hook(hook: dict[str, Any], event: str, context: dict[str, Any]) -> bool: | |
| """Check if a hook matches the given event and context.""" | |
| if not hook["enabled"]: | |
| return False | |
| if hook["event"] != "all" and hook["event"] != event: | |
| return False | |
| # Simple pattern match (single pattern) | |
| if hook["pattern"]: | |
| # For bash event, match against command | |
| target = str(context.get("command", context.get("file_path", context.get("user_prompt", "")))) | |
| if not re.search(hook["pattern"], target): | |
| return False | |
| # Multi-condition match (all conditions must match) | |
| if hook["conditions"]: | |
| for cond in hook["conditions"]: | |
| if not _match_condition(cond, context): | |
| return False | |
| return True | |
| def check_hook(event: str, context: dict[str, Any]) -> dict[str, Any]: | |
| """Check all hooks for an event. | |
| Args: | |
| event: One of 'bash', 'file', 'prompt', 'stop', 'all' | |
| context: Dict with relevant fields (command, file_path, new_text, user_prompt, etc.) | |
| Returns: | |
| dict with: | |
| - blocked: bool — whether the action should be blocked | |
| - warnings: list of warning messages to show | |
| - matched_hooks: list of hook names that matched | |
| """ | |
| warnings: list[str] = [] | |
| matched: list[str] = [] | |
| blocked = False | |
| for hooks_dir in _hook_dirs(): | |
| if not os.path.isdir(hooks_dir): | |
| continue | |
| for entry in sorted(os.listdir(hooks_dir)): | |
| if not entry.endswith(".md"): | |
| continue | |
| filepath = os.path.join(hooks_dir, entry) | |
| hook = _load_hook(filepath) | |
| if not hook: | |
| continue | |
| if _match_hook(hook, event, context): | |
| matched.append(hook["name"]) | |
| if hook["action"] == "block": | |
| blocked = True | |
| warnings.append(f"🛑 BLOCKED by rule '{hook['name']}':\n\n{hook['message']}") | |
| else: | |
| warnings.append(f"⚠️ Warning from rule '{hook['name']}':\n\n{hook['message']}") | |
| return { | |
| "blocked": blocked, | |
| "warnings": warnings, | |
| "matched_hooks": matched, | |
| } | |
| def create_hook( | |
| name: str, | |
| event: str, | |
| pattern: str, | |
| action: str = "warn", | |
| message: str = "", | |
| enabled: bool = True, | |
| ) -> dict[str, Any]: | |
| """Create a new user hook (saved to workspace/.sonicoder/hooks/).""" | |
| try: | |
| from code.tools.fs import get_workspace_root | |
| hooks_dir = os.path.join(get_workspace_root(), _USER_HOOKS_DIRNAME) | |
| os.makedirs(hooks_dir, exist_ok=True) | |
| filepath = os.path.join(hooks_dir, f"{name}.local.md") | |
| content = f"""--- | |
| name: {name} | |
| enabled: {str(enabled).lower()} | |
| event: {event} | |
| pattern: {pattern} | |
| action: {action} | |
| --- | |
| {message} | |
| """ | |
| with open(filepath, "w", encoding="utf-8") as f: | |
| f.write(content) | |
| return {"success": True, "name": name, "path": filepath} | |
| except Exception as exc: | |
| return {"success": False, "error": str(exc)} | |
| def delete_hook(name: str) -> dict[str, Any]: | |
| """Delete a user hook by name.""" | |
| try: | |
| from code.tools.fs import get_workspace_root | |
| hooks_dir = os.path.join(get_workspace_root(), _USER_HOOKS_DIRNAME) | |
| filepath = os.path.join(hooks_dir, f"{name}.local.md") | |
| if os.path.exists(filepath): | |
| os.remove(filepath) | |
| return {"success": True, "name": name} | |
| return {"success": False, "error": f"Hook not found: {name}"} | |
| except Exception as exc: | |
| return {"success": False, "error": str(exc)} | |