codepp 0.0.437__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_puppy/__init__.py +10 -0
- code_puppy/__main__.py +10 -0
- code_puppy/agents/__init__.py +31 -0
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +117 -0
- code_puppy/agents/agent_code_reviewer.py +90 -0
- code_puppy/agents/agent_cpp_reviewer.py +132 -0
- code_puppy/agents/agent_creator_agent.py +638 -0
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_helios.py +124 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +742 -0
- code_puppy/agents/agent_pack_leader.py +385 -0
- code_puppy/agents/agent_planning.py +165 -0
- code_puppy/agents/agent_python_programmer.py +169 -0
- code_puppy/agents/agent_python_reviewer.py +90 -0
- code_puppy/agents/agent_qa_expert.py +163 -0
- code_puppy/agents/agent_qa_kitten.py +208 -0
- code_puppy/agents/agent_scheduler.py +121 -0
- code_puppy/agents/agent_security_auditor.py +181 -0
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +166 -0
- code_puppy/agents/base_agent.py +2156 -0
- code_puppy/agents/event_stream_handler.py +348 -0
- code_puppy/agents/json_agent.py +202 -0
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +327 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/prompt_reviewer.py +145 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +453 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +75 -0
- code_puppy/api/routers/sessions.py +234 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +692 -0
- code_puppy/chatgpt_codex_client.py +338 -0
- code_puppy/claude_cache_client.py +672 -0
- code_puppy/cli_runner.py +1073 -0
- code_puppy/command_line/__init__.py +1 -0
- code_puppy/command_line/add_model_menu.py +1092 -0
- code_puppy/command_line/agent_menu.py +662 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +704 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +532 -0
- code_puppy/command_line/command_handler.py +293 -0
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +719 -0
- code_puppy/command_line/core_commands.py +867 -0
- code_puppy/command_line/diff_menu.py +865 -0
- code_puppy/command_line/file_path_completion.py +73 -0
- code_puppy/command_line/load_context_completion.py +52 -0
- code_puppy/command_line/mcp/__init__.py +10 -0
- code_puppy/command_line/mcp/base.py +32 -0
- code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
- code_puppy/command_line/mcp/custom_server_form.py +688 -0
- code_puppy/command_line/mcp/custom_server_installer.py +195 -0
- code_puppy/command_line/mcp/edit_command.py +148 -0
- code_puppy/command_line/mcp/handler.py +138 -0
- code_puppy/command_line/mcp/help_command.py +147 -0
- code_puppy/command_line/mcp/install_command.py +214 -0
- code_puppy/command_line/mcp/install_menu.py +705 -0
- code_puppy/command_line/mcp/list_command.py +94 -0
- code_puppy/command_line/mcp/logs_command.py +235 -0
- code_puppy/command_line/mcp/remove_command.py +82 -0
- code_puppy/command_line/mcp/restart_command.py +100 -0
- code_puppy/command_line/mcp/search_command.py +123 -0
- code_puppy/command_line/mcp/start_all_command.py +135 -0
- code_puppy/command_line/mcp/start_command.py +117 -0
- code_puppy/command_line/mcp/status_command.py +184 -0
- code_puppy/command_line/mcp/stop_all_command.py +112 -0
- code_puppy/command_line/mcp/stop_command.py +80 -0
- code_puppy/command_line/mcp/test_command.py +107 -0
- code_puppy/command_line/mcp/utils.py +129 -0
- code_puppy/command_line/mcp/wizard_utils.py +334 -0
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +197 -0
- code_puppy/command_line/model_settings_menu.py +932 -0
- code_puppy/command_line/motd.py +96 -0
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +342 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +846 -0
- code_puppy/command_line/session_commands.py +302 -0
- code_puppy/command_line/shell_passthrough.py +145 -0
- code_puppy/command_line/skills_completion.py +160 -0
- code_puppy/command_line/uc_menu.py +893 -0
- code_puppy/command_line/utils.py +93 -0
- code_puppy/command_line/wiggum_state.py +78 -0
- code_puppy/config.py +1770 -0
- code_puppy/error_logging.py +134 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +754 -0
- code_puppy/hook_engine/README.md +105 -0
- code_puppy/hook_engine/__init__.py +21 -0
- code_puppy/hook_engine/aliases.py +155 -0
- code_puppy/hook_engine/engine.py +221 -0
- code_puppy/hook_engine/executor.py +296 -0
- code_puppy/hook_engine/matcher.py +156 -0
- code_puppy/hook_engine/models.py +240 -0
- code_puppy/hook_engine/registry.py +106 -0
- code_puppy/hook_engine/validator.py +144 -0
- code_puppy/http_utils.py +361 -0
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +10 -0
- code_puppy/mcp_/__init__.py +66 -0
- code_puppy/mcp_/async_lifecycle.py +286 -0
- code_puppy/mcp_/blocking_startup.py +469 -0
- code_puppy/mcp_/captured_stdio_server.py +275 -0
- code_puppy/mcp_/circuit_breaker.py +290 -0
- code_puppy/mcp_/config_wizard.py +507 -0
- code_puppy/mcp_/dashboard.py +308 -0
- code_puppy/mcp_/error_isolation.py +407 -0
- code_puppy/mcp_/examples/retry_example.py +226 -0
- code_puppy/mcp_/health_monitor.py +589 -0
- code_puppy/mcp_/managed_server.py +428 -0
- code_puppy/mcp_/manager.py +807 -0
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +451 -0
- code_puppy/mcp_/retry_manager.py +337 -0
- code_puppy/mcp_/server_registry_catalog.py +1126 -0
- code_puppy/mcp_/status_tracker.py +355 -0
- code_puppy/mcp_/system_tools.py +209 -0
- code_puppy/mcp_prompts/__init__.py +1 -0
- code_puppy/mcp_prompts/hook_creator.py +103 -0
- code_puppy/messaging/__init__.py +255 -0
- code_puppy/messaging/bus.py +613 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +361 -0
- code_puppy/messaging/messages.py +569 -0
- code_puppy/messaging/queue_console.py +271 -0
- code_puppy/messaging/renderers.py +311 -0
- code_puppy/messaging/rich_renderer.py +1158 -0
- code_puppy/messaging/spinner/__init__.py +83 -0
- code_puppy/messaging/spinner/console_spinner.py +240 -0
- code_puppy/messaging/spinner/spinner_base.py +95 -0
- code_puppy/messaging/subagent_console.py +460 -0
- code_puppy/model_factory.py +848 -0
- code_puppy/model_switching.py +63 -0
- code_puppy/model_utils.py +168 -0
- code_puppy/models.json +174 -0
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +186 -0
- code_puppy/plugins/agent_skills/__init__.py +22 -0
- code_puppy/plugins/agent_skills/config.py +175 -0
- code_puppy/plugins/agent_skills/discovery.py +136 -0
- code_puppy/plugins/agent_skills/downloader.py +392 -0
- code_puppy/plugins/agent_skills/installer.py +22 -0
- code_puppy/plugins/agent_skills/metadata.py +219 -0
- code_puppy/plugins/agent_skills/prompt_builder.py +60 -0
- code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
- code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
- code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
- code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
- code_puppy/plugins/agent_skills/skills_menu.py +781 -0
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +133 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
- code_puppy/plugins/antigravity_oauth/storage.py +288 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +863 -0
- code_puppy/plugins/antigravity_oauth/utils.py +168 -0
- code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
- code_puppy/plugins/chatgpt_oauth/config.py +52 -0
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +329 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
- code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
- code_puppy/plugins/claude_code_hooks/config.py +137 -0
- code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -0
- code_puppy/plugins/claude_code_oauth/README.md +167 -0
- code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
- code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
- code_puppy/plugins/claude_code_oauth/config.py +52 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
- code_puppy/plugins/claude_code_oauth/utils.py +640 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +470 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/plugins/hook_creator/__init__.py +1 -0
- code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
- code_puppy/plugins/hook_manager/__init__.py +1 -0
- code_puppy/plugins/hook_manager/config.py +290 -0
- code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
- code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
- code_puppy/plugins/oauth_puppy_html.py +228 -0
- code_puppy/plugins/scheduler/__init__.py +1 -0
- code_puppy/plugins/scheduler/register_callbacks.py +88 -0
- code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
- code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
- code_puppy/plugins/shell_safety/__init__.py +6 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
- code_puppy/plugins/synthetic_status/__init__.py +1 -0
- code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
- code_puppy/plugins/synthetic_status/status_api.py +147 -0
- code_puppy/plugins/universal_constructor/__init__.py +13 -0
- code_puppy/plugins/universal_constructor/models.py +138 -0
- code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
- code_puppy/plugins/universal_constructor/registry.py +302 -0
- code_puppy/plugins/universal_constructor/sandbox.py +584 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/pydantic_patches.py +356 -0
- code_puppy/reopenable_async_client.py +232 -0
- code_puppy/round_robin_model.py +150 -0
- code_puppy/scheduler/__init__.py +41 -0
- code_puppy/scheduler/__main__.py +9 -0
- code_puppy/scheduler/cli.py +118 -0
- code_puppy/scheduler/config.py +126 -0
- code_puppy/scheduler/daemon.py +280 -0
- code_puppy/scheduler/executor.py +155 -0
- code_puppy/scheduler/platform.py +19 -0
- code_puppy/scheduler/platform_unix.py +22 -0
- code_puppy/scheduler/platform_win.py +32 -0
- code_puppy/session_storage.py +338 -0
- code_puppy/status_display.py +257 -0
- code_puppy/summarization_agent.py +176 -0
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +501 -0
- code_puppy/tools/agent_tools.py +603 -0
- code_puppy/tools/ask_user_question/__init__.py +26 -0
- code_puppy/tools/ask_user_question/constants.py +73 -0
- code_puppy/tools/ask_user_question/demo_tui.py +55 -0
- code_puppy/tools/ask_user_question/handler.py +232 -0
- code_puppy/tools/ask_user_question/models.py +304 -0
- code_puppy/tools/ask_user_question/registration.py +26 -0
- code_puppy/tools/ask_user_question/renderers.py +309 -0
- code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
- code_puppy/tools/ask_user_question/theme.py +155 -0
- code_puppy/tools/ask_user_question/tui_loop.py +423 -0
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +289 -0
- code_puppy/tools/browser/browser_interactions.py +545 -0
- code_puppy/tools/browser/browser_locators.py +640 -0
- code_puppy/tools/browser/browser_manager.py +378 -0
- code_puppy/tools/browser/browser_navigation.py +251 -0
- code_puppy/tools/browser/browser_screenshot.py +179 -0
- code_puppy/tools/browser/browser_scripts.py +462 -0
- code_puppy/tools/browser/browser_workflows.py +221 -0
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +534 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +1346 -0
- code_puppy/tools/common.py +1409 -0
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +886 -0
- code_puppy/tools/file_operations.py +802 -0
- code_puppy/tools/scheduler_tools.py +412 -0
- code_puppy/tools/skills_tools.py +244 -0
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/tools/tools_content.py +51 -0
- code_puppy/tools/universal_constructor.py +889 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +82 -0
- codepp-0.0.437.dist-info/METADATA +766 -0
- codepp-0.0.437.dist-info/RECORD +288 -0
- codepp-0.0.437.dist-info/WHEEL +4 -0
- codepp-0.0.437.dist-info/entry_points.txt +3 -0
- codepp-0.0.437.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command execution engine for hooks.
|
|
3
|
+
|
|
4
|
+
Handles async command execution with timeout, variable substitution,
|
|
5
|
+
and comprehensive error handling.
|
|
6
|
+
|
|
7
|
+
Claude Code Hook Compatibility:
|
|
8
|
+
- Input is passed via STDIN as JSON (primary method, Claude Code standard)
|
|
9
|
+
- Input is also available via CLAUDE_TOOL_INPUT env var (legacy/convenience)
|
|
10
|
+
- Exit code 0 => success, stdout shown in transcript
|
|
11
|
+
- Exit code 1 => block the operation (stderr used as reason)
|
|
12
|
+
- Exit code 2 => error feedback to Claude (stderr fed back as tool error)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
from .matcher import _extract_file_path
|
|
24
|
+
from .models import EventData, ExecutionResult, HookConfig
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_stdin_payload(event_data: EventData) -> bytes:
|
|
30
|
+
"""
|
|
31
|
+
Build the JSON payload sent to hook scripts via stdin.
|
|
32
|
+
|
|
33
|
+
Matches the Claude Code hook input format:
|
|
34
|
+
{
|
|
35
|
+
"session_id": "...",
|
|
36
|
+
"hook_event_name": "PreToolUse",
|
|
37
|
+
"tool_name": "Bash",
|
|
38
|
+
"tool_input": { ... },
|
|
39
|
+
"cwd": "/path/to/project",
|
|
40
|
+
"permission_mode": "default"
|
|
41
|
+
}
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def _make_serializable(obj: Any) -> Any:
|
|
45
|
+
if isinstance(obj, dict):
|
|
46
|
+
return {k: _make_serializable(v) for k, v in obj.items()}
|
|
47
|
+
if isinstance(obj, (list, tuple)):
|
|
48
|
+
return [_make_serializable(v) for v in obj]
|
|
49
|
+
if isinstance(obj, (str, int, float, bool, type(None))):
|
|
50
|
+
return obj
|
|
51
|
+
try:
|
|
52
|
+
return str(obj)
|
|
53
|
+
except Exception:
|
|
54
|
+
return "<unserializable>"
|
|
55
|
+
|
|
56
|
+
payload = {
|
|
57
|
+
"session_id": event_data.context.get("session_id", "codepuppy-session"),
|
|
58
|
+
"hook_event_name": event_data.event_type,
|
|
59
|
+
"tool_name": event_data.tool_name,
|
|
60
|
+
"tool_input": _make_serializable(event_data.tool_args),
|
|
61
|
+
"cwd": os.getcwd(),
|
|
62
|
+
"permission_mode": "default",
|
|
63
|
+
}
|
|
64
|
+
if "result" in event_data.context:
|
|
65
|
+
payload["tool_result"] = _make_serializable(event_data.context["result"])
|
|
66
|
+
if "duration_ms" in event_data.context:
|
|
67
|
+
payload["tool_duration_ms"] = event_data.context["duration_ms"]
|
|
68
|
+
|
|
69
|
+
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def execute_hook(
|
|
73
|
+
hook: HookConfig,
|
|
74
|
+
event_data: EventData,
|
|
75
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
76
|
+
) -> ExecutionResult:
|
|
77
|
+
"""
|
|
78
|
+
Execute a hook command with timeout and variable substitution.
|
|
79
|
+
|
|
80
|
+
Input to the hook script:
|
|
81
|
+
- stdin: JSON object (Claude Code compatible format)
|
|
82
|
+
- env CLAUDE_TOOL_INPUT: JSON string of tool_args (legacy)
|
|
83
|
+
- env CLAUDE_PROJECT_DIR: current working directory
|
|
84
|
+
|
|
85
|
+
Exit code semantics:
|
|
86
|
+
- 0: success (stdout shown in transcript)
|
|
87
|
+
- 1: block operation (stderr becomes block reason)
|
|
88
|
+
- 2: error feedback to Claude without blocking
|
|
89
|
+
"""
|
|
90
|
+
if hook.type == "prompt":
|
|
91
|
+
return ExecutionResult(
|
|
92
|
+
blocked=False,
|
|
93
|
+
hook_command=hook.command,
|
|
94
|
+
stdout=hook.command,
|
|
95
|
+
exit_code=0,
|
|
96
|
+
duration_ms=0.0,
|
|
97
|
+
hook_id=hook.id,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
command = _substitute_variables(hook.command, event_data, env_vars or {})
|
|
101
|
+
stdin_payload = _build_stdin_payload(event_data)
|
|
102
|
+
start_time = time.perf_counter()
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
env = _build_environment(event_data, env_vars)
|
|
106
|
+
|
|
107
|
+
proc = await asyncio.create_subprocess_shell(
|
|
108
|
+
command,
|
|
109
|
+
stdin=asyncio.subprocess.PIPE,
|
|
110
|
+
stdout=asyncio.subprocess.PIPE,
|
|
111
|
+
stderr=asyncio.subprocess.PIPE,
|
|
112
|
+
cwd=os.getcwd(),
|
|
113
|
+
env=env,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
stdout, stderr = await asyncio.wait_for(
|
|
118
|
+
proc.communicate(input=stdin_payload),
|
|
119
|
+
timeout=hook.timeout / 1000.0,
|
|
120
|
+
)
|
|
121
|
+
except asyncio.TimeoutError:
|
|
122
|
+
try:
|
|
123
|
+
proc.kill()
|
|
124
|
+
await proc.wait()
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
129
|
+
return ExecutionResult(
|
|
130
|
+
blocked=True,
|
|
131
|
+
hook_command=command,
|
|
132
|
+
stdout="",
|
|
133
|
+
stderr=f"Command timed out after {hook.timeout}ms",
|
|
134
|
+
exit_code=-1,
|
|
135
|
+
duration_ms=duration_ms,
|
|
136
|
+
error=f"Hook execution timed out after {hook.timeout}ms",
|
|
137
|
+
hook_id=hook.id,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
141
|
+
stdout_str = stdout.decode("utf-8", errors="replace") if stdout else ""
|
|
142
|
+
stderr_str = stderr.decode("utf-8", errors="replace") if stderr else ""
|
|
143
|
+
exit_code = proc.returncode or 0
|
|
144
|
+
|
|
145
|
+
blocked = exit_code == 1
|
|
146
|
+
error = stderr_str if exit_code != 0 and stderr_str else None
|
|
147
|
+
|
|
148
|
+
return ExecutionResult(
|
|
149
|
+
blocked=blocked,
|
|
150
|
+
hook_command=command,
|
|
151
|
+
stdout=stdout_str,
|
|
152
|
+
stderr=stderr_str,
|
|
153
|
+
exit_code=exit_code,
|
|
154
|
+
duration_ms=duration_ms,
|
|
155
|
+
error=error,
|
|
156
|
+
hook_id=hook.id,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
except Exception as e:
|
|
160
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
161
|
+
logger.error(f"Hook execution failed: {e}", exc_info=True)
|
|
162
|
+
return ExecutionResult(
|
|
163
|
+
blocked=False,
|
|
164
|
+
hook_command=command,
|
|
165
|
+
stdout="",
|
|
166
|
+
stderr=str(e),
|
|
167
|
+
exit_code=-1,
|
|
168
|
+
duration_ms=duration_ms,
|
|
169
|
+
error=f"Hook execution error: {e}",
|
|
170
|
+
hook_id=hook.id,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _substitute_variables(
|
|
175
|
+
command: str,
|
|
176
|
+
event_data: EventData,
|
|
177
|
+
env_vars: Dict[str, str],
|
|
178
|
+
) -> str:
|
|
179
|
+
substitutions = {
|
|
180
|
+
"CLAUDE_PROJECT_DIR": os.getcwd(),
|
|
181
|
+
"tool_name": event_data.tool_name,
|
|
182
|
+
"event_type": event_data.event_type,
|
|
183
|
+
"file": _extract_file_path(event_data.tool_args) or "",
|
|
184
|
+
"CLAUDE_TOOL_INPUT": json.dumps(event_data.tool_args),
|
|
185
|
+
}
|
|
186
|
+
if event_data.context:
|
|
187
|
+
if "result" in event_data.context:
|
|
188
|
+
substitutions["result"] = str(event_data.context["result"])
|
|
189
|
+
if "duration_ms" in event_data.context:
|
|
190
|
+
substitutions["duration_ms"] = str(event_data.context["duration_ms"])
|
|
191
|
+
substitutions.update(env_vars)
|
|
192
|
+
|
|
193
|
+
result = command
|
|
194
|
+
for var, value in substitutions.items():
|
|
195
|
+
result = result.replace(f"${{{var}}}", str(value))
|
|
196
|
+
result = re.sub(rf"\${re.escape(var)}(?=\W|$)", lambda m: str(value), result)
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _build_environment(
|
|
201
|
+
event_data: EventData,
|
|
202
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
203
|
+
) -> Dict[str, str]:
|
|
204
|
+
env = os.environ.copy()
|
|
205
|
+
env["CLAUDE_PROJECT_DIR"] = os.getcwd()
|
|
206
|
+
env["CLAUDE_TOOL_INPUT"] = json.dumps(event_data.tool_args)
|
|
207
|
+
env["CLAUDE_TOOL_NAME"] = event_data.tool_name
|
|
208
|
+
env["CLAUDE_HOOK_EVENT"] = event_data.event_type
|
|
209
|
+
env["CLAUDE_CODE_HOOK"] = "1"
|
|
210
|
+
|
|
211
|
+
file_path = _extract_file_path(event_data.tool_args)
|
|
212
|
+
if file_path:
|
|
213
|
+
env["CLAUDE_FILE_PATH"] = file_path
|
|
214
|
+
|
|
215
|
+
if env_vars:
|
|
216
|
+
env.update(env_vars)
|
|
217
|
+
return env
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def execute_hooks_parallel(
|
|
221
|
+
hooks: List[HookConfig],
|
|
222
|
+
event_data: EventData,
|
|
223
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
224
|
+
) -> List[ExecutionResult]:
|
|
225
|
+
if not hooks:
|
|
226
|
+
return []
|
|
227
|
+
tasks = [execute_hook(hook, event_data, env_vars) for hook in hooks]
|
|
228
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
229
|
+
final_results = []
|
|
230
|
+
for i, result in enumerate(results):
|
|
231
|
+
if isinstance(result, Exception):
|
|
232
|
+
final_results.append(
|
|
233
|
+
ExecutionResult(
|
|
234
|
+
blocked=False,
|
|
235
|
+
hook_command=hooks[i].command,
|
|
236
|
+
stdout="",
|
|
237
|
+
stderr=str(result),
|
|
238
|
+
exit_code=-1,
|
|
239
|
+
duration_ms=0.0,
|
|
240
|
+
error=f"Hook execution failed: {result}",
|
|
241
|
+
hook_id=hooks[i].id,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
else:
|
|
245
|
+
final_results.append(result)
|
|
246
|
+
return final_results
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def execute_hooks_sequential(
|
|
250
|
+
hooks: List[HookConfig],
|
|
251
|
+
event_data: EventData,
|
|
252
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
253
|
+
stop_on_block: bool = True,
|
|
254
|
+
) -> List[ExecutionResult]:
|
|
255
|
+
results = []
|
|
256
|
+
for hook in hooks:
|
|
257
|
+
result = await execute_hook(hook, event_data, env_vars)
|
|
258
|
+
results.append(result)
|
|
259
|
+
if stop_on_block and result.blocked:
|
|
260
|
+
logger.debug(f"Hook blocked operation, stopping: {hook.command}")
|
|
261
|
+
break
|
|
262
|
+
return results
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def get_blocking_result(results: List[ExecutionResult]) -> Optional[ExecutionResult]:
|
|
266
|
+
for result in results:
|
|
267
|
+
if result.blocked:
|
|
268
|
+
return result
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_failed_results(results: List[ExecutionResult]) -> List[ExecutionResult]:
|
|
273
|
+
return [result for result in results if not result.success]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def format_execution_summary(results: List[ExecutionResult]) -> str:
|
|
277
|
+
if not results:
|
|
278
|
+
return "No hooks executed"
|
|
279
|
+
total = len(results)
|
|
280
|
+
successful = sum(1 for r in results if r.success)
|
|
281
|
+
blocked = sum(1 for r in results if r.blocked)
|
|
282
|
+
total_duration = sum(r.duration_ms for r in results)
|
|
283
|
+
summary = [
|
|
284
|
+
f"Executed {total} hook(s)",
|
|
285
|
+
f"Successful: {successful}",
|
|
286
|
+
f"Blocked: {blocked}",
|
|
287
|
+
f"Total duration: {total_duration:.2f}ms",
|
|
288
|
+
]
|
|
289
|
+
if blocked > 0:
|
|
290
|
+
blocking_hooks = [r for r in results if r.blocked]
|
|
291
|
+
summary.append("\nBlocking hooks:")
|
|
292
|
+
for result in blocking_hooks:
|
|
293
|
+
summary.append(f" - {result.hook_command}")
|
|
294
|
+
if result.error:
|
|
295
|
+
summary.append(f" Error: {result.error}")
|
|
296
|
+
return "\n".join(summary)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pattern matching engine for hook filters.
|
|
3
|
+
|
|
4
|
+
Provides flexible pattern matching to determine if a hook should execute
|
|
5
|
+
based on tool name, arguments, and other event data.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from .aliases import get_aliases
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def matches(matcher: str, tool_name: str, tool_args: Dict[str, Any]) -> bool:
|
|
15
|
+
"""
|
|
16
|
+
Evaluate if a matcher pattern matches the tool call.
|
|
17
|
+
|
|
18
|
+
Matcher Syntax:
|
|
19
|
+
- "*" - Matches all tools
|
|
20
|
+
- "ToolName" - Exact tool name match
|
|
21
|
+
- ".ext" - File extension match (e.g., ".py", ".ts")
|
|
22
|
+
- "Pattern1 && Pattern2" - AND condition (all must match)
|
|
23
|
+
- "Pattern1 || Pattern2" - OR condition (any must match)
|
|
24
|
+
"""
|
|
25
|
+
if not matcher:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
if matcher.strip() == "*":
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
if "||" in matcher:
|
|
32
|
+
parts = [p.strip() for p in matcher.split("||")]
|
|
33
|
+
return any(matches(part, tool_name, tool_args) for part in parts)
|
|
34
|
+
|
|
35
|
+
if "&&" in matcher:
|
|
36
|
+
parts = [p.strip() for p in matcher.split("&&")]
|
|
37
|
+
return all(matches(part, tool_name, tool_args) for part in parts)
|
|
38
|
+
|
|
39
|
+
return _match_single(matcher.strip(), tool_name, tool_args)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _match_single(pattern: str, tool_name: str, tool_args: Dict[str, Any]) -> bool:
|
|
43
|
+
if pattern == tool_name:
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
if pattern.lower() == tool_name.lower():
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
# Check cross-provider aliases: a hook written for "Bash" (Claude Code) should
|
|
50
|
+
# fire when code_puppy calls "agent_run_shell_command", and vice-versa.
|
|
51
|
+
tool_aliases = get_aliases(tool_name)
|
|
52
|
+
pattern_aliases = get_aliases(pattern)
|
|
53
|
+
if tool_aliases & pattern_aliases: # non-empty intersection → same logical tool
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
if pattern.startswith("."):
|
|
57
|
+
file_path = _extract_file_path(tool_args)
|
|
58
|
+
if file_path:
|
|
59
|
+
return file_path.endswith(pattern)
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
if "*" in pattern:
|
|
63
|
+
parts = pattern.split("*")
|
|
64
|
+
regex_pattern = ".*".join(re.escape(part) for part in parts)
|
|
65
|
+
if re.match(f"^{regex_pattern}$", tool_name, re.IGNORECASE):
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
if _is_regex_pattern(pattern):
|
|
69
|
+
try:
|
|
70
|
+
if re.search(pattern, tool_name, re.IGNORECASE):
|
|
71
|
+
return True
|
|
72
|
+
file_path = _extract_file_path(tool_args)
|
|
73
|
+
if file_path and re.search(pattern, file_path, re.IGNORECASE):
|
|
74
|
+
return True
|
|
75
|
+
except re.error:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _extract_file_path(tool_args: Dict[str, Any]) -> Optional[str]:
|
|
82
|
+
file_keys = [
|
|
83
|
+
"file_path",
|
|
84
|
+
"file",
|
|
85
|
+
"path",
|
|
86
|
+
"target",
|
|
87
|
+
"input_file",
|
|
88
|
+
"output_file",
|
|
89
|
+
"source",
|
|
90
|
+
"destination",
|
|
91
|
+
"src",
|
|
92
|
+
"dest",
|
|
93
|
+
"filename",
|
|
94
|
+
]
|
|
95
|
+
for key in file_keys:
|
|
96
|
+
if key in tool_args:
|
|
97
|
+
value = tool_args[key]
|
|
98
|
+
if isinstance(value, str):
|
|
99
|
+
return value
|
|
100
|
+
if hasattr(value, "__fspath__"):
|
|
101
|
+
return str(value)
|
|
102
|
+
for value in tool_args.values():
|
|
103
|
+
if isinstance(value, str) and _looks_like_file_path(value):
|
|
104
|
+
return value
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _looks_like_file_path(value: str) -> bool:
|
|
109
|
+
if not value:
|
|
110
|
+
return False
|
|
111
|
+
if "." in value and not value.startswith("."):
|
|
112
|
+
parts = value.rsplit(".", 1)
|
|
113
|
+
if len(parts) == 2 and len(parts[1]) <= 10 and parts[1].isalnum():
|
|
114
|
+
return True
|
|
115
|
+
if "/" in value or "\\" in value:
|
|
116
|
+
return True
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_regex_pattern(pattern: str) -> bool:
|
|
121
|
+
regex_chars = ["^", "$", ".", "+", "?", "[", "]", "(", ")", "{", "}", "|", "\\"]
|
|
122
|
+
return any(char in pattern for char in regex_chars)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def extract_file_extension(file_path: str) -> Optional[str]:
|
|
126
|
+
if not file_path or "." not in file_path:
|
|
127
|
+
return None
|
|
128
|
+
if "/" in file_path:
|
|
129
|
+
file_path = file_path.rsplit("/", 1)[-1]
|
|
130
|
+
if "\\" in file_path:
|
|
131
|
+
file_path = file_path.rsplit("\\", 1)[-1]
|
|
132
|
+
if "." in file_path:
|
|
133
|
+
return "." + file_path.rsplit(".", 1)[-1]
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def matches_tool(tool_name: str, *names: str) -> bool:
|
|
138
|
+
return tool_name.lower() in [name.lower() for name in names]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def matches_file_extension(tool_args: Dict[str, Any], *extensions: str) -> bool:
|
|
142
|
+
file_path = _extract_file_path(tool_args)
|
|
143
|
+
if not file_path:
|
|
144
|
+
return False
|
|
145
|
+
ext = extract_file_extension(file_path)
|
|
146
|
+
return ext in extensions
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def matches_file_pattern(tool_args: Dict[str, Any], pattern: str) -> bool:
|
|
150
|
+
file_path = _extract_file_path(tool_args)
|
|
151
|
+
if not file_path:
|
|
152
|
+
return False
|
|
153
|
+
try:
|
|
154
|
+
return bool(re.search(pattern, file_path, re.IGNORECASE))
|
|
155
|
+
except re.error:
|
|
156
|
+
return False
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for the hook engine.
|
|
3
|
+
|
|
4
|
+
Defines all data structures used throughout the hook engine with full type
|
|
5
|
+
safety and validation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class HookConfig:
|
|
14
|
+
"""
|
|
15
|
+
Configuration for a single hook.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
matcher: Pattern to match against events (e.g., "Edit && .py")
|
|
19
|
+
type: Type of hook action ("command" or "prompt")
|
|
20
|
+
command: Command or prompt text to execute
|
|
21
|
+
timeout: Maximum execution time in milliseconds (default: 5000)
|
|
22
|
+
once: Execute only once per session (default: False)
|
|
23
|
+
enabled: Whether this hook is enabled (default: True)
|
|
24
|
+
id: Optional unique identifier for this hook
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
matcher: str
|
|
28
|
+
type: Literal["command", "prompt"]
|
|
29
|
+
command: str
|
|
30
|
+
timeout: int = 5000
|
|
31
|
+
once: bool = False
|
|
32
|
+
enabled: bool = True
|
|
33
|
+
id: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
def __post_init__(self):
|
|
36
|
+
"""Validate hook configuration after initialization."""
|
|
37
|
+
if not self.matcher:
|
|
38
|
+
raise ValueError("Hook matcher cannot be empty")
|
|
39
|
+
|
|
40
|
+
if self.type not in ("command", "prompt"):
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"Hook type must be 'command' or 'prompt', got: {self.type}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if not self.command:
|
|
46
|
+
raise ValueError("Hook command cannot be empty")
|
|
47
|
+
|
|
48
|
+
if self.timeout < 100:
|
|
49
|
+
raise ValueError(f"Hook timeout must be >= 100ms, got: {self.timeout}")
|
|
50
|
+
|
|
51
|
+
if self.id is None:
|
|
52
|
+
import hashlib
|
|
53
|
+
|
|
54
|
+
content = f"{self.matcher}:{self.type}:{self.command}"
|
|
55
|
+
self.id = hashlib.sha256(content.encode()).hexdigest()[:12]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class EventData:
|
|
60
|
+
"""
|
|
61
|
+
Input data for hook processing.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
event_type: Type of event (PreToolUse, PostToolUse, etc.)
|
|
65
|
+
tool_name: Name of the tool being called
|
|
66
|
+
tool_args: Arguments passed to the tool
|
|
67
|
+
context: Optional context metadata (result, duration, etc.)
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
event_type: str
|
|
71
|
+
tool_name: str
|
|
72
|
+
tool_args: Dict[str, Any] = field(default_factory=dict)
|
|
73
|
+
context: Dict[str, Any] = field(default_factory=dict)
|
|
74
|
+
|
|
75
|
+
def __post_init__(self):
|
|
76
|
+
if not self.event_type:
|
|
77
|
+
raise ValueError("Event type cannot be empty")
|
|
78
|
+
if not self.tool_name:
|
|
79
|
+
raise ValueError("Tool name cannot be empty")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class ExecutionResult:
|
|
84
|
+
"""
|
|
85
|
+
Result from executing a hook.
|
|
86
|
+
|
|
87
|
+
Attributes:
|
|
88
|
+
blocked: Whether the hook blocked the operation
|
|
89
|
+
hook_command: The command that was executed
|
|
90
|
+
stdout: Standard output from command
|
|
91
|
+
stderr: Standard error from command
|
|
92
|
+
exit_code: Exit code from command execution
|
|
93
|
+
duration_ms: Execution duration in milliseconds
|
|
94
|
+
error: Error message if execution failed
|
|
95
|
+
hook_id: ID of the hook that was executed
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
blocked: bool
|
|
99
|
+
hook_command: str
|
|
100
|
+
stdout: str = ""
|
|
101
|
+
stderr: str = ""
|
|
102
|
+
exit_code: int = 0
|
|
103
|
+
duration_ms: float = 0.0
|
|
104
|
+
error: Optional[str] = None
|
|
105
|
+
hook_id: Optional[str] = None
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def success(self) -> bool:
|
|
109
|
+
"""Whether the hook executed successfully (exit code 0)."""
|
|
110
|
+
return self.exit_code == 0 and self.error is None
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def output(self) -> str:
|
|
114
|
+
"""Combined stdout and stderr."""
|
|
115
|
+
parts = []
|
|
116
|
+
if self.stdout:
|
|
117
|
+
parts.append(self.stdout)
|
|
118
|
+
if self.stderr:
|
|
119
|
+
parts.append(self.stderr)
|
|
120
|
+
return "\n".join(parts)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class HookGroup:
|
|
125
|
+
"""A group of hooks that share the same matcher."""
|
|
126
|
+
|
|
127
|
+
matcher: str
|
|
128
|
+
hooks: List[HookConfig] = field(default_factory=list)
|
|
129
|
+
|
|
130
|
+
def __post_init__(self):
|
|
131
|
+
if not self.matcher:
|
|
132
|
+
raise ValueError("Hook group matcher cannot be empty")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class HookRegistry:
|
|
137
|
+
"""Registry of all hooks organized by event type."""
|
|
138
|
+
|
|
139
|
+
pre_tool_use: List[HookConfig] = field(default_factory=list)
|
|
140
|
+
post_tool_use: List[HookConfig] = field(default_factory=list)
|
|
141
|
+
session_start: List[HookConfig] = field(default_factory=list)
|
|
142
|
+
session_end: List[HookConfig] = field(default_factory=list)
|
|
143
|
+
pre_compact: List[HookConfig] = field(default_factory=list)
|
|
144
|
+
user_prompt_submit: List[HookConfig] = field(default_factory=list)
|
|
145
|
+
notification: List[HookConfig] = field(default_factory=list)
|
|
146
|
+
stop: List[HookConfig] = field(default_factory=list)
|
|
147
|
+
subagent_stop: List[HookConfig] = field(default_factory=list)
|
|
148
|
+
|
|
149
|
+
_executed_once_hooks: set = field(default_factory=set, repr=False)
|
|
150
|
+
|
|
151
|
+
def get_hooks_for_event(self, event_type: str) -> List[HookConfig]:
|
|
152
|
+
attr_name = self._normalize_event_type(event_type)
|
|
153
|
+
if not hasattr(self, attr_name):
|
|
154
|
+
return []
|
|
155
|
+
all_hooks = getattr(self, attr_name)
|
|
156
|
+
enabled_hooks = []
|
|
157
|
+
for hook in all_hooks:
|
|
158
|
+
if not hook.enabled:
|
|
159
|
+
continue
|
|
160
|
+
if hook.once and hook.id in self._executed_once_hooks:
|
|
161
|
+
continue
|
|
162
|
+
enabled_hooks.append(hook)
|
|
163
|
+
return enabled_hooks
|
|
164
|
+
|
|
165
|
+
def mark_hook_executed(self, hook_id: str) -> None:
|
|
166
|
+
self._executed_once_hooks.add(hook_id)
|
|
167
|
+
|
|
168
|
+
def reset_once_hooks(self) -> None:
|
|
169
|
+
self._executed_once_hooks.clear()
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _normalize_event_type(event_type: str) -> str:
|
|
173
|
+
import re
|
|
174
|
+
|
|
175
|
+
s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", event_type)
|
|
176
|
+
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
|
|
177
|
+
|
|
178
|
+
def add_hook(self, event_type: str, hook: HookConfig) -> None:
|
|
179
|
+
attr_name = self._normalize_event_type(event_type)
|
|
180
|
+
if not hasattr(self, attr_name):
|
|
181
|
+
raise ValueError(f"Unknown event type: {event_type}")
|
|
182
|
+
getattr(self, attr_name).append(hook)
|
|
183
|
+
|
|
184
|
+
def remove_hook(self, event_type: str, hook_id: str) -> bool:
|
|
185
|
+
attr_name = self._normalize_event_type(event_type)
|
|
186
|
+
if not hasattr(self, attr_name):
|
|
187
|
+
return False
|
|
188
|
+
hooks_list = getattr(self, attr_name)
|
|
189
|
+
for i, hook in enumerate(hooks_list):
|
|
190
|
+
if hook.id == hook_id:
|
|
191
|
+
hooks_list.pop(i)
|
|
192
|
+
return True
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
def count_hooks(self, event_type: Optional[str] = None) -> int:
|
|
196
|
+
if event_type is None:
|
|
197
|
+
total = 0
|
|
198
|
+
for attr in [
|
|
199
|
+
"pre_tool_use",
|
|
200
|
+
"post_tool_use",
|
|
201
|
+
"session_start",
|
|
202
|
+
"session_end",
|
|
203
|
+
"pre_compact",
|
|
204
|
+
"user_prompt_submit",
|
|
205
|
+
"notification",
|
|
206
|
+
"stop",
|
|
207
|
+
"subagent_stop",
|
|
208
|
+
]:
|
|
209
|
+
total += len(getattr(self, attr))
|
|
210
|
+
return total
|
|
211
|
+
attr_name = self._normalize_event_type(event_type)
|
|
212
|
+
if not hasattr(self, attr_name):
|
|
213
|
+
return 0
|
|
214
|
+
return len(getattr(self, attr_name))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class ProcessEventResult:
|
|
219
|
+
"""Result from processing an event through the hook engine."""
|
|
220
|
+
|
|
221
|
+
blocked: bool
|
|
222
|
+
executed_hooks: int
|
|
223
|
+
results: List[ExecutionResult]
|
|
224
|
+
blocking_reason: Optional[str] = None
|
|
225
|
+
total_duration_ms: float = 0.0
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def all_successful(self) -> bool:
|
|
229
|
+
return all(result.success for result in self.results)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def failed_hooks(self) -> List[ExecutionResult]:
|
|
233
|
+
return [result for result in self.results if not result.success]
|
|
234
|
+
|
|
235
|
+
def get_combined_output(self) -> str:
|
|
236
|
+
outputs = []
|
|
237
|
+
for result in self.results:
|
|
238
|
+
if result.output:
|
|
239
|
+
outputs.append(f"[{result.hook_command}]\n{result.output}")
|
|
240
|
+
return "\n\n".join(outputs)
|