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.
Files changed (288) hide show
  1. code_puppy/__init__.py +10 -0
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agents/__init__.py +31 -0
  4. code_puppy/agents/agent_c_reviewer.py +155 -0
  5. code_puppy/agents/agent_code_puppy.py +117 -0
  6. code_puppy/agents/agent_code_reviewer.py +90 -0
  7. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  8. code_puppy/agents/agent_creator_agent.py +638 -0
  9. code_puppy/agents/agent_golang_reviewer.py +151 -0
  10. code_puppy/agents/agent_helios.py +124 -0
  11. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  12. code_puppy/agents/agent_manager.py +742 -0
  13. code_puppy/agents/agent_pack_leader.py +385 -0
  14. code_puppy/agents/agent_planning.py +165 -0
  15. code_puppy/agents/agent_python_programmer.py +169 -0
  16. code_puppy/agents/agent_python_reviewer.py +90 -0
  17. code_puppy/agents/agent_qa_expert.py +163 -0
  18. code_puppy/agents/agent_qa_kitten.py +208 -0
  19. code_puppy/agents/agent_scheduler.py +121 -0
  20. code_puppy/agents/agent_security_auditor.py +181 -0
  21. code_puppy/agents/agent_terminal_qa.py +323 -0
  22. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  23. code_puppy/agents/base_agent.py +2156 -0
  24. code_puppy/agents/event_stream_handler.py +348 -0
  25. code_puppy/agents/json_agent.py +202 -0
  26. code_puppy/agents/pack/__init__.py +34 -0
  27. code_puppy/agents/pack/bloodhound.py +304 -0
  28. code_puppy/agents/pack/husky.py +327 -0
  29. code_puppy/agents/pack/retriever.py +393 -0
  30. code_puppy/agents/pack/shepherd.py +348 -0
  31. code_puppy/agents/pack/terrier.py +287 -0
  32. code_puppy/agents/pack/watchdog.py +367 -0
  33. code_puppy/agents/prompt_reviewer.py +145 -0
  34. code_puppy/agents/subagent_stream_handler.py +276 -0
  35. code_puppy/api/__init__.py +13 -0
  36. code_puppy/api/app.py +169 -0
  37. code_puppy/api/main.py +21 -0
  38. code_puppy/api/pty_manager.py +453 -0
  39. code_puppy/api/routers/__init__.py +12 -0
  40. code_puppy/api/routers/agents.py +36 -0
  41. code_puppy/api/routers/commands.py +217 -0
  42. code_puppy/api/routers/config.py +75 -0
  43. code_puppy/api/routers/sessions.py +234 -0
  44. code_puppy/api/templates/terminal.html +361 -0
  45. code_puppy/api/websocket.py +154 -0
  46. code_puppy/callbacks.py +692 -0
  47. code_puppy/chatgpt_codex_client.py +338 -0
  48. code_puppy/claude_cache_client.py +672 -0
  49. code_puppy/cli_runner.py +1073 -0
  50. code_puppy/command_line/__init__.py +1 -0
  51. code_puppy/command_line/add_model_menu.py +1092 -0
  52. code_puppy/command_line/agent_menu.py +662 -0
  53. code_puppy/command_line/attachments.py +395 -0
  54. code_puppy/command_line/autosave_menu.py +704 -0
  55. code_puppy/command_line/clipboard.py +527 -0
  56. code_puppy/command_line/colors_menu.py +532 -0
  57. code_puppy/command_line/command_handler.py +293 -0
  58. code_puppy/command_line/command_registry.py +150 -0
  59. code_puppy/command_line/config_commands.py +719 -0
  60. code_puppy/command_line/core_commands.py +867 -0
  61. code_puppy/command_line/diff_menu.py +865 -0
  62. code_puppy/command_line/file_path_completion.py +73 -0
  63. code_puppy/command_line/load_context_completion.py +52 -0
  64. code_puppy/command_line/mcp/__init__.py +10 -0
  65. code_puppy/command_line/mcp/base.py +32 -0
  66. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  67. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  68. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  69. code_puppy/command_line/mcp/edit_command.py +148 -0
  70. code_puppy/command_line/mcp/handler.py +138 -0
  71. code_puppy/command_line/mcp/help_command.py +147 -0
  72. code_puppy/command_line/mcp/install_command.py +214 -0
  73. code_puppy/command_line/mcp/install_menu.py +705 -0
  74. code_puppy/command_line/mcp/list_command.py +94 -0
  75. code_puppy/command_line/mcp/logs_command.py +235 -0
  76. code_puppy/command_line/mcp/remove_command.py +82 -0
  77. code_puppy/command_line/mcp/restart_command.py +100 -0
  78. code_puppy/command_line/mcp/search_command.py +123 -0
  79. code_puppy/command_line/mcp/start_all_command.py +135 -0
  80. code_puppy/command_line/mcp/start_command.py +117 -0
  81. code_puppy/command_line/mcp/status_command.py +184 -0
  82. code_puppy/command_line/mcp/stop_all_command.py +112 -0
  83. code_puppy/command_line/mcp/stop_command.py +80 -0
  84. code_puppy/command_line/mcp/test_command.py +107 -0
  85. code_puppy/command_line/mcp/utils.py +129 -0
  86. code_puppy/command_line/mcp/wizard_utils.py +334 -0
  87. code_puppy/command_line/mcp_completion.py +174 -0
  88. code_puppy/command_line/model_picker_completion.py +197 -0
  89. code_puppy/command_line/model_settings_menu.py +932 -0
  90. code_puppy/command_line/motd.py +96 -0
  91. code_puppy/command_line/onboarding_slides.py +179 -0
  92. code_puppy/command_line/onboarding_wizard.py +342 -0
  93. code_puppy/command_line/pin_command_completion.py +329 -0
  94. code_puppy/command_line/prompt_toolkit_completion.py +846 -0
  95. code_puppy/command_line/session_commands.py +302 -0
  96. code_puppy/command_line/shell_passthrough.py +145 -0
  97. code_puppy/command_line/skills_completion.py +160 -0
  98. code_puppy/command_line/uc_menu.py +893 -0
  99. code_puppy/command_line/utils.py +93 -0
  100. code_puppy/command_line/wiggum_state.py +78 -0
  101. code_puppy/config.py +1770 -0
  102. code_puppy/error_logging.py +134 -0
  103. code_puppy/gemini_code_assist.py +385 -0
  104. code_puppy/gemini_model.py +754 -0
  105. code_puppy/hook_engine/README.md +105 -0
  106. code_puppy/hook_engine/__init__.py +21 -0
  107. code_puppy/hook_engine/aliases.py +155 -0
  108. code_puppy/hook_engine/engine.py +221 -0
  109. code_puppy/hook_engine/executor.py +296 -0
  110. code_puppy/hook_engine/matcher.py +156 -0
  111. code_puppy/hook_engine/models.py +240 -0
  112. code_puppy/hook_engine/registry.py +106 -0
  113. code_puppy/hook_engine/validator.py +144 -0
  114. code_puppy/http_utils.py +361 -0
  115. code_puppy/keymap.py +128 -0
  116. code_puppy/main.py +10 -0
  117. code_puppy/mcp_/__init__.py +66 -0
  118. code_puppy/mcp_/async_lifecycle.py +286 -0
  119. code_puppy/mcp_/blocking_startup.py +469 -0
  120. code_puppy/mcp_/captured_stdio_server.py +275 -0
  121. code_puppy/mcp_/circuit_breaker.py +290 -0
  122. code_puppy/mcp_/config_wizard.py +507 -0
  123. code_puppy/mcp_/dashboard.py +308 -0
  124. code_puppy/mcp_/error_isolation.py +407 -0
  125. code_puppy/mcp_/examples/retry_example.py +226 -0
  126. code_puppy/mcp_/health_monitor.py +589 -0
  127. code_puppy/mcp_/managed_server.py +428 -0
  128. code_puppy/mcp_/manager.py +807 -0
  129. code_puppy/mcp_/mcp_logs.py +224 -0
  130. code_puppy/mcp_/registry.py +451 -0
  131. code_puppy/mcp_/retry_manager.py +337 -0
  132. code_puppy/mcp_/server_registry_catalog.py +1126 -0
  133. code_puppy/mcp_/status_tracker.py +355 -0
  134. code_puppy/mcp_/system_tools.py +209 -0
  135. code_puppy/mcp_prompts/__init__.py +1 -0
  136. code_puppy/mcp_prompts/hook_creator.py +103 -0
  137. code_puppy/messaging/__init__.py +255 -0
  138. code_puppy/messaging/bus.py +613 -0
  139. code_puppy/messaging/commands.py +167 -0
  140. code_puppy/messaging/markdown_patches.py +57 -0
  141. code_puppy/messaging/message_queue.py +361 -0
  142. code_puppy/messaging/messages.py +569 -0
  143. code_puppy/messaging/queue_console.py +271 -0
  144. code_puppy/messaging/renderers.py +311 -0
  145. code_puppy/messaging/rich_renderer.py +1158 -0
  146. code_puppy/messaging/spinner/__init__.py +83 -0
  147. code_puppy/messaging/spinner/console_spinner.py +240 -0
  148. code_puppy/messaging/spinner/spinner_base.py +95 -0
  149. code_puppy/messaging/subagent_console.py +460 -0
  150. code_puppy/model_factory.py +848 -0
  151. code_puppy/model_switching.py +63 -0
  152. code_puppy/model_utils.py +168 -0
  153. code_puppy/models.json +174 -0
  154. code_puppy/models_dev_api.json +1 -0
  155. code_puppy/models_dev_parser.py +592 -0
  156. code_puppy/plugins/__init__.py +186 -0
  157. code_puppy/plugins/agent_skills/__init__.py +22 -0
  158. code_puppy/plugins/agent_skills/config.py +175 -0
  159. code_puppy/plugins/agent_skills/discovery.py +136 -0
  160. code_puppy/plugins/agent_skills/downloader.py +392 -0
  161. code_puppy/plugins/agent_skills/installer.py +22 -0
  162. code_puppy/plugins/agent_skills/metadata.py +219 -0
  163. code_puppy/plugins/agent_skills/prompt_builder.py +60 -0
  164. code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
  165. code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
  166. code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
  167. code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
  168. code_puppy/plugins/agent_skills/skills_menu.py +781 -0
  169. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  170. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  171. code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
  172. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  173. code_puppy/plugins/antigravity_oauth/constants.py +133 -0
  174. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  175. code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
  176. code_puppy/plugins/antigravity_oauth/storage.py +288 -0
  177. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  178. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  179. code_puppy/plugins/antigravity_oauth/transport.py +863 -0
  180. code_puppy/plugins/antigravity_oauth/utils.py +168 -0
  181. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  182. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  183. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +329 -0
  184. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
  185. code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
  186. code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
  187. code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
  188. code_puppy/plugins/claude_code_hooks/config.py +137 -0
  189. code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -0
  190. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  191. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  192. code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
  193. code_puppy/plugins/claude_code_oauth/config.py +52 -0
  194. code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
  195. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  196. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
  197. code_puppy/plugins/claude_code_oauth/utils.py +640 -0
  198. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  199. code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
  200. code_puppy/plugins/example_custom_command/README.md +280 -0
  201. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  202. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  203. code_puppy/plugins/file_permission_handler/register_callbacks.py +470 -0
  204. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  205. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  206. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  207. code_puppy/plugins/hook_creator/__init__.py +1 -0
  208. code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
  209. code_puppy/plugins/hook_manager/__init__.py +1 -0
  210. code_puppy/plugins/hook_manager/config.py +290 -0
  211. code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
  212. code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
  213. code_puppy/plugins/oauth_puppy_html.py +228 -0
  214. code_puppy/plugins/scheduler/__init__.py +1 -0
  215. code_puppy/plugins/scheduler/register_callbacks.py +88 -0
  216. code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
  217. code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
  218. code_puppy/plugins/shell_safety/__init__.py +6 -0
  219. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  220. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  221. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  222. code_puppy/plugins/synthetic_status/__init__.py +1 -0
  223. code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
  224. code_puppy/plugins/synthetic_status/status_api.py +147 -0
  225. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  226. code_puppy/plugins/universal_constructor/models.py +138 -0
  227. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  228. code_puppy/plugins/universal_constructor/registry.py +302 -0
  229. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  230. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  231. code_puppy/pydantic_patches.py +356 -0
  232. code_puppy/reopenable_async_client.py +232 -0
  233. code_puppy/round_robin_model.py +150 -0
  234. code_puppy/scheduler/__init__.py +41 -0
  235. code_puppy/scheduler/__main__.py +9 -0
  236. code_puppy/scheduler/cli.py +118 -0
  237. code_puppy/scheduler/config.py +126 -0
  238. code_puppy/scheduler/daemon.py +280 -0
  239. code_puppy/scheduler/executor.py +155 -0
  240. code_puppy/scheduler/platform.py +19 -0
  241. code_puppy/scheduler/platform_unix.py +22 -0
  242. code_puppy/scheduler/platform_win.py +32 -0
  243. code_puppy/session_storage.py +338 -0
  244. code_puppy/status_display.py +257 -0
  245. code_puppy/summarization_agent.py +176 -0
  246. code_puppy/terminal_utils.py +418 -0
  247. code_puppy/tools/__init__.py +501 -0
  248. code_puppy/tools/agent_tools.py +603 -0
  249. code_puppy/tools/ask_user_question/__init__.py +26 -0
  250. code_puppy/tools/ask_user_question/constants.py +73 -0
  251. code_puppy/tools/ask_user_question/demo_tui.py +55 -0
  252. code_puppy/tools/ask_user_question/handler.py +232 -0
  253. code_puppy/tools/ask_user_question/models.py +304 -0
  254. code_puppy/tools/ask_user_question/registration.py +26 -0
  255. code_puppy/tools/ask_user_question/renderers.py +309 -0
  256. code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
  257. code_puppy/tools/ask_user_question/theme.py +155 -0
  258. code_puppy/tools/ask_user_question/tui_loop.py +423 -0
  259. code_puppy/tools/browser/__init__.py +37 -0
  260. code_puppy/tools/browser/browser_control.py +289 -0
  261. code_puppy/tools/browser/browser_interactions.py +545 -0
  262. code_puppy/tools/browser/browser_locators.py +640 -0
  263. code_puppy/tools/browser/browser_manager.py +378 -0
  264. code_puppy/tools/browser/browser_navigation.py +251 -0
  265. code_puppy/tools/browser/browser_screenshot.py +179 -0
  266. code_puppy/tools/browser/browser_scripts.py +462 -0
  267. code_puppy/tools/browser/browser_workflows.py +221 -0
  268. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  269. code_puppy/tools/browser/terminal_command_tools.py +534 -0
  270. code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
  271. code_puppy/tools/browser/terminal_tools.py +525 -0
  272. code_puppy/tools/command_runner.py +1346 -0
  273. code_puppy/tools/common.py +1409 -0
  274. code_puppy/tools/display.py +84 -0
  275. code_puppy/tools/file_modifications.py +886 -0
  276. code_puppy/tools/file_operations.py +802 -0
  277. code_puppy/tools/scheduler_tools.py +412 -0
  278. code_puppy/tools/skills_tools.py +244 -0
  279. code_puppy/tools/subagent_context.py +158 -0
  280. code_puppy/tools/tools_content.py +51 -0
  281. code_puppy/tools/universal_constructor.py +889 -0
  282. code_puppy/uvx_detection.py +242 -0
  283. code_puppy/version_checker.py +82 -0
  284. codepp-0.0.437.dist-info/METADATA +766 -0
  285. codepp-0.0.437.dist-info/RECORD +288 -0
  286. codepp-0.0.437.dist-info/WHEEL +4 -0
  287. codepp-0.0.437.dist-info/entry_points.txt +3 -0
  288. 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)