newcode 0.1.1__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 (289) 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 +147 -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 +630 -0
  9. code_puppy/agents/agent_golang_reviewer.py +151 -0
  10. code_puppy/agents/agent_helios.py +122 -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 +380 -0
  14. code_puppy/agents/agent_planning.py +165 -0
  15. code_puppy/agents/agent_python_programmer.py +167 -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 +2145 -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 +296 -0
  28. code_puppy/agents/pack/husky.py +307 -0
  29. code_puppy/agents/pack/retriever.py +380 -0
  30. code_puppy/agents/pack/shepherd.py +327 -0
  31. code_puppy/agents/pack/terrier.py +281 -0
  32. code_puppy/agents/pack/watchdog.py +357 -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 +674 -0
  47. code_puppy/chatgpt_codex_client.py +338 -0
  48. code_puppy/claude_cache_client.py +664 -0
  49. code_puppy/cli_runner.py +1038 -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 +526 -0
  57. code_puppy/command_line/command_handler.py +283 -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 +853 -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 +91 -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/skills_completion.py +160 -0
  97. code_puppy/command_line/uc_menu.py +893 -0
  98. code_puppy/command_line/utils.py +93 -0
  99. code_puppy/command_line/wiggum_state.py +78 -0
  100. code_puppy/config.py +1787 -0
  101. code_puppy/error_logging.py +133 -0
  102. code_puppy/gemini_code_assist.py +385 -0
  103. code_puppy/gemini_model.py +754 -0
  104. code_puppy/hook_engine/README.md +105 -0
  105. code_puppy/hook_engine/__init__.py +15 -0
  106. code_puppy/hook_engine/aliases.py +155 -0
  107. code_puppy/hook_engine/engine.py +195 -0
  108. code_puppy/hook_engine/executor.py +293 -0
  109. code_puppy/hook_engine/matcher.py +145 -0
  110. code_puppy/hook_engine/models.py +222 -0
  111. code_puppy/hook_engine/registry.py +106 -0
  112. code_puppy/hook_engine/validator.py +141 -0
  113. code_puppy/http_utils.py +361 -0
  114. code_puppy/keymap.py +128 -0
  115. code_puppy/main.py +10 -0
  116. code_puppy/mcp_/__init__.py +66 -0
  117. code_puppy/mcp_/async_lifecycle.py +286 -0
  118. code_puppy/mcp_/blocking_startup.py +469 -0
  119. code_puppy/mcp_/captured_stdio_server.py +275 -0
  120. code_puppy/mcp_/circuit_breaker.py +290 -0
  121. code_puppy/mcp_/config_wizard.py +507 -0
  122. code_puppy/mcp_/dashboard.py +308 -0
  123. code_puppy/mcp_/error_isolation.py +407 -0
  124. code_puppy/mcp_/examples/retry_example.py +226 -0
  125. code_puppy/mcp_/health_monitor.py +589 -0
  126. code_puppy/mcp_/managed_server.py +428 -0
  127. code_puppy/mcp_/manager.py +807 -0
  128. code_puppy/mcp_/mcp_logs.py +224 -0
  129. code_puppy/mcp_/registry.py +451 -0
  130. code_puppy/mcp_/retry_manager.py +337 -0
  131. code_puppy/mcp_/server_registry_catalog.py +1126 -0
  132. code_puppy/mcp_/status_tracker.py +355 -0
  133. code_puppy/mcp_/system_tools.py +209 -0
  134. code_puppy/mcp_prompts/__init__.py +1 -0
  135. code_puppy/mcp_prompts/hook_creator.py +103 -0
  136. code_puppy/messaging/__init__.py +255 -0
  137. code_puppy/messaging/bus.py +613 -0
  138. code_puppy/messaging/commands.py +167 -0
  139. code_puppy/messaging/markdown_patches.py +57 -0
  140. code_puppy/messaging/message_queue.py +361 -0
  141. code_puppy/messaging/messages.py +569 -0
  142. code_puppy/messaging/queue_console.py +271 -0
  143. code_puppy/messaging/renderers.py +311 -0
  144. code_puppy/messaging/rich_renderer.py +1153 -0
  145. code_puppy/messaging/spinner/__init__.py +83 -0
  146. code_puppy/messaging/spinner/console_spinner.py +240 -0
  147. code_puppy/messaging/spinner/spinner_base.py +96 -0
  148. code_puppy/messaging/subagent_console.py +460 -0
  149. code_puppy/model_factory.py +848 -0
  150. code_puppy/model_switching.py +63 -0
  151. code_puppy/model_utils.py +168 -0
  152. code_puppy/models.json +130 -0
  153. code_puppy/models_dev_api.json +1 -0
  154. code_puppy/models_dev_parser.py +592 -0
  155. code_puppy/plugins/__init__.py +186 -0
  156. code_puppy/plugins/agent_skills/__init__.py +22 -0
  157. code_puppy/plugins/agent_skills/config.py +175 -0
  158. code_puppy/plugins/agent_skills/discovery.py +136 -0
  159. code_puppy/plugins/agent_skills/downloader.py +392 -0
  160. code_puppy/plugins/agent_skills/installer.py +22 -0
  161. code_puppy/plugins/agent_skills/metadata.py +219 -0
  162. code_puppy/plugins/agent_skills/prompt_builder.py +100 -0
  163. code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
  164. code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
  165. code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
  166. code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
  167. code_puppy/plugins/agent_skills/skills_menu.py +781 -0
  168. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  169. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  170. code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
  171. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  172. code_puppy/plugins/antigravity_oauth/constants.py +133 -0
  173. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  174. code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
  175. code_puppy/plugins/antigravity_oauth/storage.py +288 -0
  176. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  177. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  178. code_puppy/plugins/antigravity_oauth/transport.py +863 -0
  179. code_puppy/plugins/antigravity_oauth/utils.py +168 -0
  180. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  181. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  182. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  183. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
  184. code_puppy/plugins/chatgpt_oauth/test_plugin.py +295 -0
  185. code_puppy/plugins/chatgpt_oauth/utils.py +499 -0
  186. code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
  187. code_puppy/plugins/claude_code_hooks/config.py +131 -0
  188. code_puppy/plugins/claude_code_hooks/register_callbacks.py +163 -0
  189. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  190. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  191. code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
  192. code_puppy/plugins/claude_code_oauth/config.py +52 -0
  193. code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
  194. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  195. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
  196. code_puppy/plugins/claude_code_oauth/utils.py +601 -0
  197. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  198. code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
  199. code_puppy/plugins/example_custom_command/README.md +280 -0
  200. code_puppy/plugins/example_custom_command/register_callbacks.py +48 -0
  201. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  202. code_puppy/plugins/file_permission_handler/register_callbacks.py +528 -0
  203. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  204. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  205. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  206. code_puppy/plugins/hook_creator/__init__.py +1 -0
  207. code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
  208. code_puppy/plugins/hook_manager/__init__.py +1 -0
  209. code_puppy/plugins/hook_manager/config.py +277 -0
  210. code_puppy/plugins/hook_manager/hooks_menu.py +551 -0
  211. code_puppy/plugins/hook_manager/register_callbacks.py +205 -0
  212. code_puppy/plugins/oauth_puppy_html.py +224 -0
  213. code_puppy/plugins/scheduler/__init__.py +1 -0
  214. code_puppy/plugins/scheduler/register_callbacks.py +88 -0
  215. code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
  216. code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
  217. code_puppy/plugins/shell_safety/__init__.py +6 -0
  218. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  219. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  220. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  221. code_puppy/plugins/synthetic_status/__init__.py +1 -0
  222. code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
  223. code_puppy/plugins/synthetic_status/status_api.py +147 -0
  224. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  225. code_puppy/plugins/universal_constructor/models.py +138 -0
  226. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  227. code_puppy/plugins/universal_constructor/registry.py +302 -0
  228. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  229. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  230. code_puppy/pydantic_patches.py +317 -0
  231. code_puppy/reopenable_async_client.py +232 -0
  232. code_puppy/round_robin_model.py +150 -0
  233. code_puppy/scheduler/__init__.py +41 -0
  234. code_puppy/scheduler/__main__.py +9 -0
  235. code_puppy/scheduler/cli.py +118 -0
  236. code_puppy/scheduler/config.py +126 -0
  237. code_puppy/scheduler/daemon.py +280 -0
  238. code_puppy/scheduler/executor.py +155 -0
  239. code_puppy/scheduler/platform.py +19 -0
  240. code_puppy/scheduler/platform_unix.py +22 -0
  241. code_puppy/scheduler/platform_win.py +32 -0
  242. code_puppy/session_storage.py +338 -0
  243. code_puppy/status_display.py +257 -0
  244. code_puppy/summarization_agent.py +176 -0
  245. code_puppy/terminal_utils.py +418 -0
  246. code_puppy/tools/__init__.py +470 -0
  247. code_puppy/tools/agent_tools.py +616 -0
  248. code_puppy/tools/ask_user_question/__init__.py +26 -0
  249. code_puppy/tools/ask_user_question/constants.py +73 -0
  250. code_puppy/tools/ask_user_question/demo_tui.py +55 -0
  251. code_puppy/tools/ask_user_question/handler.py +232 -0
  252. code_puppy/tools/ask_user_question/models.py +304 -0
  253. code_puppy/tools/ask_user_question/registration.py +36 -0
  254. code_puppy/tools/ask_user_question/renderers.py +309 -0
  255. code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
  256. code_puppy/tools/ask_user_question/theme.py +155 -0
  257. code_puppy/tools/ask_user_question/tui_loop.py +423 -0
  258. code_puppy/tools/browser/__init__.py +37 -0
  259. code_puppy/tools/browser/browser_control.py +289 -0
  260. code_puppy/tools/browser/browser_interactions.py +545 -0
  261. code_puppy/tools/browser/browser_locators.py +640 -0
  262. code_puppy/tools/browser/browser_manager.py +378 -0
  263. code_puppy/tools/browser/browser_navigation.py +251 -0
  264. code_puppy/tools/browser/browser_screenshot.py +179 -0
  265. code_puppy/tools/browser/browser_scripts.py +462 -0
  266. code_puppy/tools/browser/browser_workflows.py +221 -0
  267. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  268. code_puppy/tools/browser/terminal_command_tools.py +534 -0
  269. code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
  270. code_puppy/tools/browser/terminal_tools.py +525 -0
  271. code_puppy/tools/command_runner.py +1346 -0
  272. code_puppy/tools/common.py +1409 -0
  273. code_puppy/tools/display.py +84 -0
  274. code_puppy/tools/file_modifications.py +739 -0
  275. code_puppy/tools/file_operations.py +802 -0
  276. code_puppy/tools/scheduler_tools.py +412 -0
  277. code_puppy/tools/skills_tools.py +251 -0
  278. code_puppy/tools/subagent_context.py +158 -0
  279. code_puppy/tools/tools_content.py +51 -0
  280. code_puppy/tools/universal_constructor.py +889 -0
  281. code_puppy/uvx_detection.py +242 -0
  282. code_puppy/version_checker.py +82 -0
  283. newcode-0.1.1.data/data/code_puppy/models.json +130 -0
  284. newcode-0.1.1.data/data/code_puppy/models_dev_api.json +1 -0
  285. newcode-0.1.1.dist-info/METADATA +154 -0
  286. newcode-0.1.1.dist-info/RECORD +289 -0
  287. newcode-0.1.1.dist-info/WHEEL +4 -0
  288. newcode-0.1.1.dist-info/entry_points.txt +3 -0
  289. newcode-0.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,293 @@
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 .models import HookConfig, EventData, ExecutionResult
24
+ from .matcher import _extract_file_path
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
+ def _make_serializable(obj: Any) -> Any:
44
+ if isinstance(obj, dict):
45
+ return {k: _make_serializable(v) for k, v in obj.items()}
46
+ if isinstance(obj, (list, tuple)):
47
+ return [_make_serializable(v) for v in obj]
48
+ if isinstance(obj, (str, int, float, bool, type(None))):
49
+ return obj
50
+ try:
51
+ return str(obj)
52
+ except Exception:
53
+ return "<unserializable>"
54
+
55
+ payload = {
56
+ "session_id": event_data.context.get("session_id", "agent-session"),
57
+ "hook_event_name": event_data.event_type,
58
+ "tool_name": event_data.tool_name,
59
+ "tool_input": _make_serializable(event_data.tool_args),
60
+ "cwd": os.getcwd(),
61
+ "permission_mode": "default",
62
+ }
63
+ if "result" in event_data.context:
64
+ payload["tool_result"] = _make_serializable(event_data.context["result"])
65
+ if "duration_ms" in event_data.context:
66
+ payload["tool_duration_ms"] = event_data.context["duration_ms"]
67
+
68
+ return json.dumps(payload, ensure_ascii=False).encode("utf-8")
69
+
70
+
71
+ async def execute_hook(
72
+ hook: HookConfig,
73
+ event_data: EventData,
74
+ env_vars: Optional[Dict[str, str]] = None,
75
+ ) -> ExecutionResult:
76
+ """
77
+ Execute a hook command with timeout and variable substitution.
78
+
79
+ Input to the hook script:
80
+ - stdin: JSON object (Claude Code compatible format)
81
+ - env CLAUDE_TOOL_INPUT: JSON string of tool_args (legacy)
82
+ - env CLAUDE_PROJECT_DIR: current working directory
83
+
84
+ Exit code semantics:
85
+ - 0: success (stdout shown in transcript)
86
+ - 1: block operation (stderr becomes block reason)
87
+ - 2: error feedback to Claude without blocking
88
+ """
89
+ if hook.type == "prompt":
90
+ return ExecutionResult(
91
+ blocked=False,
92
+ hook_command=hook.command,
93
+ stdout=hook.command,
94
+ exit_code=0,
95
+ duration_ms=0.0,
96
+ hook_id=hook.id,
97
+ )
98
+
99
+ command = _substitute_variables(hook.command, event_data, env_vars or {})
100
+ stdin_payload = _build_stdin_payload(event_data)
101
+ start_time = time.perf_counter()
102
+
103
+ try:
104
+ env = _build_environment(event_data, env_vars)
105
+
106
+ proc = await asyncio.create_subprocess_shell(
107
+ command,
108
+ stdin=asyncio.subprocess.PIPE,
109
+ stdout=asyncio.subprocess.PIPE,
110
+ stderr=asyncio.subprocess.PIPE,
111
+ cwd=os.getcwd(),
112
+ env=env,
113
+ )
114
+
115
+ try:
116
+ stdout, stderr = await asyncio.wait_for(
117
+ proc.communicate(input=stdin_payload),
118
+ timeout=hook.timeout / 1000.0,
119
+ )
120
+ except asyncio.TimeoutError:
121
+ try:
122
+ proc.kill()
123
+ await proc.wait()
124
+ except Exception:
125
+ pass
126
+
127
+ duration_ms = (time.perf_counter() - start_time) * 1000
128
+ return ExecutionResult(
129
+ blocked=True,
130
+ hook_command=command,
131
+ stdout="",
132
+ stderr=f"Command timed out after {hook.timeout}ms",
133
+ exit_code=-1,
134
+ duration_ms=duration_ms,
135
+ error=f"Hook execution timed out after {hook.timeout}ms",
136
+ hook_id=hook.id,
137
+ )
138
+
139
+ duration_ms = (time.perf_counter() - start_time) * 1000
140
+ stdout_str = stdout.decode("utf-8", errors="replace") if stdout else ""
141
+ stderr_str = stderr.decode("utf-8", errors="replace") if stderr else ""
142
+ exit_code = proc.returncode or 0
143
+
144
+ blocked = exit_code == 1
145
+ error = stderr_str if exit_code != 0 and stderr_str else None
146
+
147
+ return ExecutionResult(
148
+ blocked=blocked,
149
+ hook_command=command,
150
+ stdout=stdout_str,
151
+ stderr=stderr_str,
152
+ exit_code=exit_code,
153
+ duration_ms=duration_ms,
154
+ error=error,
155
+ hook_id=hook.id,
156
+ )
157
+
158
+ except Exception as e:
159
+ duration_ms = (time.perf_counter() - start_time) * 1000
160
+ logger.error(f"Hook execution failed: {e}", exc_info=True)
161
+ return ExecutionResult(
162
+ blocked=False,
163
+ hook_command=command,
164
+ stdout="",
165
+ stderr=str(e),
166
+ exit_code=-1,
167
+ duration_ms=duration_ms,
168
+ error=f"Hook execution error: {e}",
169
+ hook_id=hook.id,
170
+ )
171
+
172
+
173
+ def _substitute_variables(
174
+ command: str,
175
+ event_data: EventData,
176
+ env_vars: Dict[str, str],
177
+ ) -> str:
178
+ substitutions = {
179
+ "CLAUDE_PROJECT_DIR": os.getcwd(),
180
+ "tool_name": event_data.tool_name,
181
+ "event_type": event_data.event_type,
182
+ "file": _extract_file_path(event_data.tool_args) or "",
183
+ "CLAUDE_TOOL_INPUT": json.dumps(event_data.tool_args),
184
+ }
185
+ if event_data.context:
186
+ if "result" in event_data.context:
187
+ substitutions["result"] = str(event_data.context["result"])
188
+ if "duration_ms" in event_data.context:
189
+ substitutions["duration_ms"] = str(event_data.context["duration_ms"])
190
+ substitutions.update(env_vars)
191
+
192
+ result = command
193
+ for var, value in substitutions.items():
194
+ result = result.replace(f"${{{var}}}", str(value))
195
+ result = re.sub(rf'\${re.escape(var)}(?=\W|$)', str(value), result)
196
+ return result
197
+
198
+
199
+ def _build_environment(
200
+ event_data: EventData,
201
+ env_vars: Optional[Dict[str, str]] = None,
202
+ ) -> Dict[str, str]:
203
+ env = os.environ.copy()
204
+ env["CLAUDE_PROJECT_DIR"] = os.getcwd()
205
+ env["CLAUDE_TOOL_INPUT"] = json.dumps(event_data.tool_args)
206
+ env["CLAUDE_TOOL_NAME"] = event_data.tool_name
207
+ env["CLAUDE_HOOK_EVENT"] = event_data.event_type
208
+ env["CLAUDE_CODE_HOOK"] = "1"
209
+
210
+ file_path = _extract_file_path(event_data.tool_args)
211
+ if file_path:
212
+ env["CLAUDE_FILE_PATH"] = file_path
213
+
214
+ if env_vars:
215
+ env.update(env_vars)
216
+ return env
217
+
218
+
219
+ async def execute_hooks_parallel(
220
+ hooks: List[HookConfig],
221
+ event_data: EventData,
222
+ env_vars: Optional[Dict[str, str]] = None,
223
+ ) -> List[ExecutionResult]:
224
+ if not hooks:
225
+ return []
226
+ tasks = [execute_hook(hook, event_data, env_vars) for hook in hooks]
227
+ results = await asyncio.gather(*tasks, return_exceptions=True)
228
+ final_results = []
229
+ for i, result in enumerate(results):
230
+ if isinstance(result, Exception):
231
+ final_results.append(ExecutionResult(
232
+ blocked=False,
233
+ hook_command=hooks[i].command,
234
+ stdout="",
235
+ stderr=str(result),
236
+ exit_code=-1,
237
+ duration_ms=0.0,
238
+ error=f"Hook execution failed: {result}",
239
+ hook_id=hooks[i].id,
240
+ ))
241
+ else:
242
+ final_results.append(result)
243
+ return final_results
244
+
245
+
246
+ async def execute_hooks_sequential(
247
+ hooks: List[HookConfig],
248
+ event_data: EventData,
249
+ env_vars: Optional[Dict[str, str]] = None,
250
+ stop_on_block: bool = True,
251
+ ) -> List[ExecutionResult]:
252
+ results = []
253
+ for hook in hooks:
254
+ result = await execute_hook(hook, event_data, env_vars)
255
+ results.append(result)
256
+ if stop_on_block and result.blocked:
257
+ logger.debug(f"Hook blocked operation, stopping: {hook.command}")
258
+ break
259
+ return results
260
+
261
+
262
+ def get_blocking_result(results: List[ExecutionResult]) -> Optional[ExecutionResult]:
263
+ for result in results:
264
+ if result.blocked:
265
+ return result
266
+ return None
267
+
268
+
269
+ def get_failed_results(results: List[ExecutionResult]) -> List[ExecutionResult]:
270
+ return [result for result in results if not result.success]
271
+
272
+
273
+ def format_execution_summary(results: List[ExecutionResult]) -> str:
274
+ if not results:
275
+ return "No hooks executed"
276
+ total = len(results)
277
+ successful = sum(1 for r in results if r.success)
278
+ blocked = sum(1 for r in results if r.blocked)
279
+ total_duration = sum(r.duration_ms for r in results)
280
+ summary = [
281
+ f"Executed {total} hook(s)",
282
+ f"Successful: {successful}",
283
+ f"Blocked: {blocked}",
284
+ f"Total duration: {total_duration:.2f}ms",
285
+ ]
286
+ if blocked > 0:
287
+ blocking_hooks = [r for r in results if r.blocked]
288
+ summary.append("\nBlocking hooks:")
289
+ for result in blocking_hooks:
290
+ summary.append(f" - {result.hook_command}")
291
+ if result.error:
292
+ summary.append(f" Error: {result.error}")
293
+ return "\n".join(summary)
@@ -0,0 +1,145 @@
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 = ["file_path", "file", "path", "target", "input_file",
83
+ "output_file", "source", "destination", "src", "dest", "filename"]
84
+ for key in file_keys:
85
+ if key in tool_args:
86
+ value = tool_args[key]
87
+ if isinstance(value, str):
88
+ return value
89
+ if hasattr(value, '__fspath__'):
90
+ return str(value)
91
+ for value in tool_args.values():
92
+ if isinstance(value, str) and _looks_like_file_path(value):
93
+ return value
94
+ return None
95
+
96
+
97
+ def _looks_like_file_path(value: str) -> bool:
98
+ if not value:
99
+ return False
100
+ if "." in value and not value.startswith("."):
101
+ parts = value.rsplit(".", 1)
102
+ if len(parts) == 2 and len(parts[1]) <= 10 and parts[1].isalnum():
103
+ return True
104
+ if "/" in value or "\\" in value:
105
+ return True
106
+ return False
107
+
108
+
109
+ def _is_regex_pattern(pattern: str) -> bool:
110
+ regex_chars = ['^', '$', '.', '+', '?', '[', ']', '(', ')', '{', '}', '|', '\\']
111
+ return any(char in pattern for char in regex_chars)
112
+
113
+
114
+ def extract_file_extension(file_path: str) -> Optional[str]:
115
+ if not file_path or "." not in file_path:
116
+ return None
117
+ if "/" in file_path:
118
+ file_path = file_path.rsplit("/", 1)[-1]
119
+ if "\\" in file_path:
120
+ file_path = file_path.rsplit("\\", 1)[-1]
121
+ if "." in file_path:
122
+ return "." + file_path.rsplit(".", 1)[-1]
123
+ return None
124
+
125
+
126
+ def matches_tool(tool_name: str, *names: str) -> bool:
127
+ return tool_name.lower() in [name.lower() for name in names]
128
+
129
+
130
+ def matches_file_extension(tool_args: Dict[str, Any], *extensions: str) -> bool:
131
+ file_path = _extract_file_path(tool_args)
132
+ if not file_path:
133
+ return False
134
+ ext = extract_file_extension(file_path)
135
+ return ext in extensions
136
+
137
+
138
+ def matches_file_pattern(tool_args: Dict[str, Any], pattern: str) -> bool:
139
+ file_path = _extract_file_path(tool_args)
140
+ if not file_path:
141
+ return False
142
+ try:
143
+ return bool(re.search(pattern, file_path, re.IGNORECASE))
144
+ except re.error:
145
+ return False
@@ -0,0 +1,222 @@
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
+ matcher: str
27
+ type: Literal["command", "prompt"]
28
+ command: str
29
+ timeout: int = 5000
30
+ once: bool = False
31
+ enabled: bool = True
32
+ id: Optional[str] = None
33
+
34
+ def __post_init__(self):
35
+ """Validate hook configuration after initialization."""
36
+ if not self.matcher:
37
+ raise ValueError("Hook matcher cannot be empty")
38
+
39
+ if self.type not in ("command", "prompt"):
40
+ raise ValueError(f"Hook type must be 'command' or 'prompt', got: {self.type}")
41
+
42
+ if not self.command:
43
+ raise ValueError("Hook command cannot be empty")
44
+
45
+ if self.timeout < 100:
46
+ raise ValueError(f"Hook timeout must be >= 100ms, got: {self.timeout}")
47
+
48
+ if self.id is None:
49
+ import hashlib
50
+ content = f"{self.matcher}:{self.type}:{self.command}"
51
+ self.id = hashlib.sha256(content.encode()).hexdigest()[:12]
52
+
53
+
54
+ @dataclass
55
+ class EventData:
56
+ """
57
+ Input data for hook processing.
58
+
59
+ Attributes:
60
+ event_type: Type of event (PreToolUse, PostToolUse, etc.)
61
+ tool_name: Name of the tool being called
62
+ tool_args: Arguments passed to the tool
63
+ context: Optional context metadata (result, duration, etc.)
64
+ """
65
+ event_type: str
66
+ tool_name: str
67
+ tool_args: Dict[str, Any] = field(default_factory=dict)
68
+ context: Dict[str, Any] = field(default_factory=dict)
69
+
70
+ def __post_init__(self):
71
+ if not self.event_type:
72
+ raise ValueError("Event type cannot be empty")
73
+ if not self.tool_name:
74
+ raise ValueError("Tool name cannot be empty")
75
+
76
+
77
+ @dataclass
78
+ class ExecutionResult:
79
+ """
80
+ Result from executing a hook.
81
+
82
+ Attributes:
83
+ blocked: Whether the hook blocked the operation
84
+ hook_command: The command that was executed
85
+ stdout: Standard output from command
86
+ stderr: Standard error from command
87
+ exit_code: Exit code from command execution
88
+ duration_ms: Execution duration in milliseconds
89
+ error: Error message if execution failed
90
+ hook_id: ID of the hook that was executed
91
+ """
92
+ blocked: bool
93
+ hook_command: str
94
+ stdout: str = ""
95
+ stderr: str = ""
96
+ exit_code: int = 0
97
+ duration_ms: float = 0.0
98
+ error: Optional[str] = None
99
+ hook_id: Optional[str] = None
100
+
101
+ @property
102
+ def success(self) -> bool:
103
+ """Whether the hook executed successfully (exit code 0)."""
104
+ return self.exit_code == 0 and self.error is None
105
+
106
+ @property
107
+ def output(self) -> str:
108
+ """Combined stdout and stderr."""
109
+ parts = []
110
+ if self.stdout:
111
+ parts.append(self.stdout)
112
+ if self.stderr:
113
+ parts.append(self.stderr)
114
+ return "\n".join(parts)
115
+
116
+
117
+ @dataclass
118
+ class HookGroup:
119
+ """A group of hooks that share the same matcher."""
120
+ matcher: str
121
+ hooks: List[HookConfig] = field(default_factory=list)
122
+
123
+ def __post_init__(self):
124
+ if not self.matcher:
125
+ raise ValueError("Hook group matcher cannot be empty")
126
+
127
+
128
+ @dataclass
129
+ class HookRegistry:
130
+ """Registry of all hooks organized by event type."""
131
+ pre_tool_use: List[HookConfig] = field(default_factory=list)
132
+ post_tool_use: List[HookConfig] = field(default_factory=list)
133
+ session_start: List[HookConfig] = field(default_factory=list)
134
+ session_end: List[HookConfig] = field(default_factory=list)
135
+ pre_compact: List[HookConfig] = field(default_factory=list)
136
+ user_prompt_submit: List[HookConfig] = field(default_factory=list)
137
+ notification: List[HookConfig] = field(default_factory=list)
138
+ stop: List[HookConfig] = field(default_factory=list)
139
+ subagent_stop: List[HookConfig] = field(default_factory=list)
140
+
141
+ _executed_once_hooks: set = field(default_factory=set, repr=False)
142
+
143
+ def get_hooks_for_event(self, event_type: str) -> List[HookConfig]:
144
+ attr_name = self._normalize_event_type(event_type)
145
+ if not hasattr(self, attr_name):
146
+ return []
147
+ all_hooks = getattr(self, attr_name)
148
+ enabled_hooks = []
149
+ for hook in all_hooks:
150
+ if not hook.enabled:
151
+ continue
152
+ if hook.once and hook.id in self._executed_once_hooks:
153
+ continue
154
+ enabled_hooks.append(hook)
155
+ return enabled_hooks
156
+
157
+ def mark_hook_executed(self, hook_id: str) -> None:
158
+ self._executed_once_hooks.add(hook_id)
159
+
160
+ def reset_once_hooks(self) -> None:
161
+ self._executed_once_hooks.clear()
162
+
163
+ @staticmethod
164
+ def _normalize_event_type(event_type: str) -> str:
165
+ import re
166
+ s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', event_type)
167
+ return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
168
+
169
+ def add_hook(self, event_type: str, hook: HookConfig) -> None:
170
+ attr_name = self._normalize_event_type(event_type)
171
+ if not hasattr(self, attr_name):
172
+ raise ValueError(f"Unknown event type: {event_type}")
173
+ getattr(self, attr_name).append(hook)
174
+
175
+ def remove_hook(self, event_type: str, hook_id: str) -> bool:
176
+ attr_name = self._normalize_event_type(event_type)
177
+ if not hasattr(self, attr_name):
178
+ return False
179
+ hooks_list = getattr(self, attr_name)
180
+ for i, hook in enumerate(hooks_list):
181
+ if hook.id == hook_id:
182
+ hooks_list.pop(i)
183
+ return True
184
+ return False
185
+
186
+ def count_hooks(self, event_type: Optional[str] = None) -> int:
187
+ if event_type is None:
188
+ total = 0
189
+ for attr in ['pre_tool_use', 'post_tool_use', 'session_start',
190
+ 'session_end', 'pre_compact', 'user_prompt_submit', 'notification',
191
+ 'stop', 'subagent_stop']:
192
+ total += len(getattr(self, attr))
193
+ return total
194
+ attr_name = self._normalize_event_type(event_type)
195
+ if not hasattr(self, attr_name):
196
+ return 0
197
+ return len(getattr(self, attr_name))
198
+
199
+
200
+ @dataclass
201
+ class ProcessEventResult:
202
+ """Result from processing an event through the hook engine."""
203
+ blocked: bool
204
+ executed_hooks: int
205
+ results: List[ExecutionResult]
206
+ blocking_reason: Optional[str] = None
207
+ total_duration_ms: float = 0.0
208
+
209
+ @property
210
+ def all_successful(self) -> bool:
211
+ return all(result.success for result in self.results)
212
+
213
+ @property
214
+ def failed_hooks(self) -> List[ExecutionResult]:
215
+ return [result for result in self.results if not result.success]
216
+
217
+ def get_combined_output(self) -> str:
218
+ outputs = []
219
+ for result in self.results:
220
+ if result.output:
221
+ outputs.append(f"[{result.hook_command}]\n{result.output}")
222
+ return "\n\n".join(outputs)