ripperdoc 0.2.7__py3-none-any.whl → 0.2.8__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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +5 -0
- ripperdoc/cli/commands/__init__.py +71 -6
- ripperdoc/cli/commands/help_cmd.py +11 -1
- ripperdoc/cli/commands/hooks_cmd.py +636 -0
- ripperdoc/cli/commands/permissions_cmd.py +36 -34
- ripperdoc/cli/commands/resume_cmd.py +1 -1
- ripperdoc/cli/ui/file_mention_completer.py +62 -7
- ripperdoc/cli/ui/interrupt_handler.py +1 -1
- ripperdoc/cli/ui/message_display.py +1 -1
- ripperdoc/cli/ui/panels.py +13 -10
- ripperdoc/cli/ui/rich_ui.py +92 -24
- ripperdoc/core/custom_commands.py +411 -0
- ripperdoc/core/hooks/__init__.py +99 -0
- ripperdoc/core/hooks/config.py +303 -0
- ripperdoc/core/hooks/events.py +540 -0
- ripperdoc/core/hooks/executor.py +498 -0
- ripperdoc/core/hooks/integration.py +353 -0
- ripperdoc/core/hooks/manager.py +720 -0
- ripperdoc/core/providers/anthropic.py +476 -69
- ripperdoc/core/query.py +61 -4
- ripperdoc/tools/bash_tool.py +4 -4
- ripperdoc/tools/file_read_tool.py +1 -1
- ripperdoc/utils/conversation_compaction.py +3 -3
- ripperdoc/utils/path_ignore.py +3 -4
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.8.dist-info}/METADATA +24 -3
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.8.dist-info}/RECORD +31 -23
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.8.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.8.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.8.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.8.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""Hook command executor.
|
|
2
|
+
|
|
3
|
+
This module handles the actual execution of hook commands,
|
|
4
|
+
including environment setup, input passing, and output parsing.
|
|
5
|
+
|
|
6
|
+
Supports two hook types:
|
|
7
|
+
- command: Execute a shell command
|
|
8
|
+
- prompt: Use LLM to evaluate (requires LLM callback)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable, Dict, Optional, Awaitable
|
|
18
|
+
|
|
19
|
+
from ripperdoc.core.hooks.config import HookDefinition
|
|
20
|
+
from ripperdoc.core.hooks.events import AnyHookInput, HookOutput, HookDecision, SessionStartInput
|
|
21
|
+
from ripperdoc.utils.log import get_logger
|
|
22
|
+
|
|
23
|
+
logger = get_logger()
|
|
24
|
+
|
|
25
|
+
# Type for LLM callback used by prompt hooks
|
|
26
|
+
# Takes prompt string, returns LLM response string
|
|
27
|
+
LLMCallback = Callable[[str], Awaitable[str]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HookExecutor:
|
|
31
|
+
"""Executes hook commands with proper environment and I/O handling.
|
|
32
|
+
|
|
33
|
+
Supports two hook types:
|
|
34
|
+
- command: Execute shell commands
|
|
35
|
+
- prompt: Use LLM to evaluate (requires llm_callback to be set)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
project_dir: Optional[Path] = None,
|
|
41
|
+
session_id: Optional[str] = None,
|
|
42
|
+
transcript_path: Optional[str] = None,
|
|
43
|
+
llm_callback: Optional[LLMCallback] = None,
|
|
44
|
+
):
|
|
45
|
+
"""Initialize the executor.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
project_dir: The project directory for resolving relative paths
|
|
49
|
+
and setting RIPPERDOC_PROJECT_DIR environment variable.
|
|
50
|
+
session_id: Current session ID.
|
|
51
|
+
transcript_path: Path to the conversation transcript JSON file.
|
|
52
|
+
llm_callback: Async callback for prompt-based hooks. Takes prompt string,
|
|
53
|
+
returns LLM response string. If not set, prompt hooks will
|
|
54
|
+
be skipped with a warning.
|
|
55
|
+
"""
|
|
56
|
+
self.project_dir = project_dir
|
|
57
|
+
self.session_id = session_id
|
|
58
|
+
self.transcript_path = transcript_path
|
|
59
|
+
self.llm_callback = llm_callback
|
|
60
|
+
self._env_file: Optional[Path] = None
|
|
61
|
+
|
|
62
|
+
def _get_env_file(self) -> Path:
|
|
63
|
+
"""Get or create the environment file for SessionStart hooks.
|
|
64
|
+
|
|
65
|
+
This file can be used by SessionStart hooks to persist environment
|
|
66
|
+
variables that will be loaded into the session.
|
|
67
|
+
"""
|
|
68
|
+
if self._env_file is None:
|
|
69
|
+
# Create a temporary file that persists for the session
|
|
70
|
+
fd, path = tempfile.mkstemp(prefix="ripperdoc_env_", suffix=".json")
|
|
71
|
+
os.close(fd)
|
|
72
|
+
self._env_file = Path(path)
|
|
73
|
+
# Initialize with empty JSON object
|
|
74
|
+
self._env_file.write_text("{}")
|
|
75
|
+
return self._env_file
|
|
76
|
+
|
|
77
|
+
def cleanup_env_file(self) -> None:
|
|
78
|
+
"""Clean up the environment file when session ends."""
|
|
79
|
+
if self._env_file and self._env_file.exists():
|
|
80
|
+
try:
|
|
81
|
+
self._env_file.unlink()
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
self._env_file = None
|
|
85
|
+
|
|
86
|
+
def load_env_from_file(self) -> Dict[str, str]:
|
|
87
|
+
"""Load environment variables from the env file.
|
|
88
|
+
|
|
89
|
+
This is called after SessionStart hooks run to load any
|
|
90
|
+
environment variables they may have set.
|
|
91
|
+
"""
|
|
92
|
+
if self._env_file is None or not self._env_file.exists():
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
content = self._env_file.read_text()
|
|
97
|
+
data = json.loads(content) if content.strip() else {}
|
|
98
|
+
if isinstance(data, dict):
|
|
99
|
+
return {k: str(v) for k, v in data.items() if isinstance(k, str)}
|
|
100
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
101
|
+
logger.warning(f"Failed to load env file: {e}")
|
|
102
|
+
return {}
|
|
103
|
+
|
|
104
|
+
def _build_env(self, input_data: Optional[AnyHookInput] = None) -> Dict[str, str]:
|
|
105
|
+
"""Build the environment variables for hook execution."""
|
|
106
|
+
env = os.environ.copy()
|
|
107
|
+
|
|
108
|
+
# Add RIPPERDOC_PROJECT_DIR
|
|
109
|
+
if self.project_dir:
|
|
110
|
+
env["RIPPERDOC_PROJECT_DIR"] = str(self.project_dir)
|
|
111
|
+
|
|
112
|
+
# Add session ID if available
|
|
113
|
+
if self.session_id:
|
|
114
|
+
env["RIPPERDOC_SESSION_ID"] = self.session_id
|
|
115
|
+
|
|
116
|
+
# Add transcript path if available
|
|
117
|
+
if self.transcript_path:
|
|
118
|
+
env["RIPPERDOC_TRANSCRIPT_PATH"] = self.transcript_path
|
|
119
|
+
|
|
120
|
+
# For SessionStart hooks, provide the env file path
|
|
121
|
+
if isinstance(input_data, SessionStartInput):
|
|
122
|
+
env_file = self._get_env_file()
|
|
123
|
+
env["RIPPERDOC_ENV_FILE"] = str(env_file)
|
|
124
|
+
|
|
125
|
+
return env
|
|
126
|
+
|
|
127
|
+
def _expand_command(self, command: str) -> str:
|
|
128
|
+
"""Expand environment variables in the command string."""
|
|
129
|
+
# Expand $RIPPERDOC_PROJECT_DIR
|
|
130
|
+
if self.project_dir:
|
|
131
|
+
project_dir_str = str(self.project_dir)
|
|
132
|
+
command = command.replace("$RIPPERDOC_PROJECT_DIR", project_dir_str)
|
|
133
|
+
command = command.replace("${RIPPERDOC_PROJECT_DIR}", project_dir_str)
|
|
134
|
+
return command
|
|
135
|
+
|
|
136
|
+
def _expand_prompt(self, prompt: str, input_data: AnyHookInput) -> str:
|
|
137
|
+
"""Expand variables in the prompt string.
|
|
138
|
+
|
|
139
|
+
Replaces $ARGUMENTS with the JSON-serialized input data.
|
|
140
|
+
"""
|
|
141
|
+
input_json = input_data.model_dump_json()
|
|
142
|
+
prompt = prompt.replace("$ARGUMENTS", input_json)
|
|
143
|
+
prompt = prompt.replace("${ARGUMENTS}", input_json)
|
|
144
|
+
return prompt
|
|
145
|
+
|
|
146
|
+
def _parse_prompt_response(self, response: str) -> HookOutput:
|
|
147
|
+
"""Parse LLM response from a prompt hook.
|
|
148
|
+
|
|
149
|
+
Expected response format (JSON):
|
|
150
|
+
{
|
|
151
|
+
"decision": "approve|block",
|
|
152
|
+
"reason": "explanation",
|
|
153
|
+
"continue": false, // optional
|
|
154
|
+
"stopReason": "message", // optional
|
|
155
|
+
"systemMessage": "warning" // optional
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
Or plain text (treated as additional context with no decision).
|
|
159
|
+
"""
|
|
160
|
+
response = response.strip()
|
|
161
|
+
if not response:
|
|
162
|
+
return HookOutput()
|
|
163
|
+
|
|
164
|
+
# Try to parse as JSON
|
|
165
|
+
try:
|
|
166
|
+
data = json.loads(response)
|
|
167
|
+
if isinstance(data, dict):
|
|
168
|
+
output = HookOutput()
|
|
169
|
+
|
|
170
|
+
# Parse decision
|
|
171
|
+
decision_str = data.get("decision", "").lower()
|
|
172
|
+
if decision_str == "approve":
|
|
173
|
+
output.decision = HookDecision.ALLOW
|
|
174
|
+
elif decision_str == "block":
|
|
175
|
+
output.decision = HookDecision.BLOCK
|
|
176
|
+
elif decision_str == "allow":
|
|
177
|
+
output.decision = HookDecision.ALLOW
|
|
178
|
+
elif decision_str == "deny":
|
|
179
|
+
output.decision = HookDecision.DENY
|
|
180
|
+
elif decision_str == "ask":
|
|
181
|
+
output.decision = HookDecision.ASK
|
|
182
|
+
|
|
183
|
+
output.reason = data.get("reason")
|
|
184
|
+
output.continue_execution = data.get("continue", True)
|
|
185
|
+
output.stop_reason = data.get("stopReason")
|
|
186
|
+
output.system_message = data.get("systemMessage")
|
|
187
|
+
output.additional_context = data.get("additionalContext")
|
|
188
|
+
|
|
189
|
+
return output
|
|
190
|
+
except json.JSONDecodeError:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
# Not JSON, treat as additional context
|
|
194
|
+
return HookOutput(raw_output=response, additional_context=response)
|
|
195
|
+
|
|
196
|
+
async def execute_prompt_async(
|
|
197
|
+
self,
|
|
198
|
+
hook: HookDefinition,
|
|
199
|
+
input_data: AnyHookInput,
|
|
200
|
+
) -> HookOutput:
|
|
201
|
+
"""Execute a prompt-based hook asynchronously.
|
|
202
|
+
|
|
203
|
+
Uses the LLM callback to evaluate the prompt and parse the response.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
hook: The hook definition with prompt template
|
|
207
|
+
input_data: The input data to pass to the hook
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
HookOutput containing the LLM's decision
|
|
211
|
+
"""
|
|
212
|
+
if not hook.prompt:
|
|
213
|
+
logger.warning("Prompt hook has no prompt template")
|
|
214
|
+
return HookOutput(error="Prompt hook missing prompt template")
|
|
215
|
+
|
|
216
|
+
if not self.llm_callback:
|
|
217
|
+
logger.warning(
|
|
218
|
+
"Prompt hook skipped: no LLM callback configured. "
|
|
219
|
+
"Set llm_callback on HookExecutor to enable prompt hooks."
|
|
220
|
+
)
|
|
221
|
+
return HookOutput()
|
|
222
|
+
|
|
223
|
+
# Expand the prompt template
|
|
224
|
+
prompt = self._expand_prompt(hook.prompt, input_data)
|
|
225
|
+
|
|
226
|
+
logger.debug(
|
|
227
|
+
f"Executing prompt hook",
|
|
228
|
+
extra={
|
|
229
|
+
"event": input_data.hook_event_name,
|
|
230
|
+
"timeout": hook.timeout,
|
|
231
|
+
"prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt,
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
# Call LLM with timeout
|
|
237
|
+
response = await asyncio.wait_for(
|
|
238
|
+
self.llm_callback(prompt),
|
|
239
|
+
timeout=hook.timeout,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
output = self._parse_prompt_response(response)
|
|
243
|
+
|
|
244
|
+
logger.debug(
|
|
245
|
+
"Prompt hook completed",
|
|
246
|
+
extra={
|
|
247
|
+
"decision": output.decision.value if output.decision else None,
|
|
248
|
+
},
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return output
|
|
252
|
+
|
|
253
|
+
except asyncio.TimeoutError:
|
|
254
|
+
logger.warning(f"Prompt hook timed out after {hook.timeout}s")
|
|
255
|
+
return HookOutput.from_raw("", "", 1, timed_out=True)
|
|
256
|
+
|
|
257
|
+
except Exception as e:
|
|
258
|
+
logger.error(f"Prompt hook execution failed: {e}")
|
|
259
|
+
return HookOutput(error=str(e), exit_code=1)
|
|
260
|
+
|
|
261
|
+
def execute_sync(
|
|
262
|
+
self,
|
|
263
|
+
hook: HookDefinition,
|
|
264
|
+
input_data: AnyHookInput,
|
|
265
|
+
) -> HookOutput:
|
|
266
|
+
"""Execute a hook synchronously.
|
|
267
|
+
|
|
268
|
+
Dispatches to appropriate method based on hook type.
|
|
269
|
+
Note: Prompt hooks are not supported in sync mode and will be skipped.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
hook: The hook definition to execute
|
|
273
|
+
input_data: The input data to pass to the hook
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
HookOutput containing the result or error
|
|
277
|
+
"""
|
|
278
|
+
# Prompt hooks require async - skip in sync mode
|
|
279
|
+
if hook.is_prompt_hook():
|
|
280
|
+
logger.warning(
|
|
281
|
+
"Prompt hook skipped in sync mode. Use execute_async for prompt hooks."
|
|
282
|
+
)
|
|
283
|
+
return HookOutput()
|
|
284
|
+
|
|
285
|
+
return self._execute_command_sync(hook, input_data)
|
|
286
|
+
|
|
287
|
+
def _execute_command_sync(
|
|
288
|
+
self,
|
|
289
|
+
hook: HookDefinition,
|
|
290
|
+
input_data: AnyHookInput,
|
|
291
|
+
) -> HookOutput:
|
|
292
|
+
"""Execute a command-based hook synchronously.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
hook: The hook definition to execute
|
|
296
|
+
input_data: The input data to pass to the hook (as JSON via stdin)
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
HookOutput containing the result or error
|
|
300
|
+
"""
|
|
301
|
+
if not hook.command:
|
|
302
|
+
logger.warning("Command hook has no command")
|
|
303
|
+
return HookOutput(error="Command hook missing command")
|
|
304
|
+
|
|
305
|
+
command = self._expand_command(hook.command)
|
|
306
|
+
env = self._build_env(input_data)
|
|
307
|
+
|
|
308
|
+
# Serialize input data for stdin
|
|
309
|
+
input_json = input_data.model_dump_json()
|
|
310
|
+
|
|
311
|
+
logger.debug(
|
|
312
|
+
f"Executing hook: {command}",
|
|
313
|
+
extra={
|
|
314
|
+
"event": input_data.hook_event_name,
|
|
315
|
+
"timeout": hook.timeout,
|
|
316
|
+
},
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
result = subprocess.run(
|
|
321
|
+
command,
|
|
322
|
+
shell=True,
|
|
323
|
+
input=input_json,
|
|
324
|
+
capture_output=True,
|
|
325
|
+
text=True,
|
|
326
|
+
timeout=hook.timeout,
|
|
327
|
+
env=env,
|
|
328
|
+
cwd=str(self.project_dir) if self.project_dir else None,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
output = HookOutput.from_raw(
|
|
332
|
+
stdout=result.stdout,
|
|
333
|
+
stderr=result.stderr,
|
|
334
|
+
exit_code=result.returncode,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
logger.debug(
|
|
338
|
+
f"Hook completed: {command}",
|
|
339
|
+
extra={
|
|
340
|
+
"exit_code": result.returncode,
|
|
341
|
+
"decision": output.decision.value if output.decision else None,
|
|
342
|
+
},
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
return output
|
|
346
|
+
|
|
347
|
+
except subprocess.TimeoutExpired:
|
|
348
|
+
logger.warning(f"Hook timed out after {hook.timeout}s: {command}")
|
|
349
|
+
return HookOutput.from_raw("", "", 1, timed_out=True)
|
|
350
|
+
|
|
351
|
+
except Exception as e:
|
|
352
|
+
logger.error(f"Hook execution failed: {command}: {e}")
|
|
353
|
+
return HookOutput(
|
|
354
|
+
error=str(e),
|
|
355
|
+
exit_code=1,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
async def execute_async(
|
|
359
|
+
self,
|
|
360
|
+
hook: HookDefinition,
|
|
361
|
+
input_data: AnyHookInput,
|
|
362
|
+
) -> HookOutput:
|
|
363
|
+
"""Execute a hook asynchronously.
|
|
364
|
+
|
|
365
|
+
Dispatches to appropriate method based on hook type.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
hook: The hook definition to execute
|
|
369
|
+
input_data: The input data to pass to the hook
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
HookOutput containing the result or error
|
|
373
|
+
"""
|
|
374
|
+
if hook.is_prompt_hook():
|
|
375
|
+
return await self.execute_prompt_async(hook, input_data)
|
|
376
|
+
|
|
377
|
+
return await self._execute_command_async(hook, input_data)
|
|
378
|
+
|
|
379
|
+
async def _execute_command_async(
|
|
380
|
+
self,
|
|
381
|
+
hook: HookDefinition,
|
|
382
|
+
input_data: AnyHookInput,
|
|
383
|
+
) -> HookOutput:
|
|
384
|
+
"""Execute a command-based hook asynchronously.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
hook: The hook definition to execute
|
|
388
|
+
input_data: The input data to pass to the hook (as JSON via stdin)
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
HookOutput containing the result or error
|
|
392
|
+
"""
|
|
393
|
+
if not hook.command:
|
|
394
|
+
logger.warning("Command hook has no command")
|
|
395
|
+
return HookOutput(error="Command hook missing command")
|
|
396
|
+
|
|
397
|
+
command = self._expand_command(hook.command)
|
|
398
|
+
env = self._build_env(input_data)
|
|
399
|
+
|
|
400
|
+
# Serialize input data for stdin
|
|
401
|
+
input_json = input_data.model_dump_json()
|
|
402
|
+
|
|
403
|
+
logger.debug(
|
|
404
|
+
f"Executing hook (async): {command}",
|
|
405
|
+
extra={
|
|
406
|
+
"event": input_data.hook_event_name,
|
|
407
|
+
"timeout": hook.timeout,
|
|
408
|
+
},
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
process = await asyncio.create_subprocess_shell(
|
|
413
|
+
command,
|
|
414
|
+
stdin=asyncio.subprocess.PIPE,
|
|
415
|
+
stdout=asyncio.subprocess.PIPE,
|
|
416
|
+
stderr=asyncio.subprocess.PIPE,
|
|
417
|
+
env=env,
|
|
418
|
+
cwd=str(self.project_dir) if self.project_dir else None,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
stdout, stderr = await asyncio.wait_for(
|
|
423
|
+
process.communicate(input_json.encode()),
|
|
424
|
+
timeout=hook.timeout,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
output = HookOutput.from_raw(
|
|
428
|
+
stdout=stdout.decode(),
|
|
429
|
+
stderr=stderr.decode(),
|
|
430
|
+
exit_code=process.returncode or 0,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
logger.debug(
|
|
434
|
+
f"Hook completed (async): {command}",
|
|
435
|
+
extra={
|
|
436
|
+
"exit_code": process.returncode,
|
|
437
|
+
"decision": output.decision.value if output.decision else None,
|
|
438
|
+
},
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
return output
|
|
442
|
+
|
|
443
|
+
except asyncio.TimeoutError:
|
|
444
|
+
# Kill the process on timeout
|
|
445
|
+
process.kill()
|
|
446
|
+
await process.wait()
|
|
447
|
+
logger.warning(f"Hook timed out after {hook.timeout}s: {command}")
|
|
448
|
+
return HookOutput.from_raw("", "", 1, timed_out=True)
|
|
449
|
+
|
|
450
|
+
except Exception as e:
|
|
451
|
+
logger.error(f"Hook execution failed (async): {command}: {e}")
|
|
452
|
+
return HookOutput(
|
|
453
|
+
error=str(e),
|
|
454
|
+
exit_code=1,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
async def execute_hooks_async(
|
|
458
|
+
self,
|
|
459
|
+
hooks: list[HookDefinition],
|
|
460
|
+
input_data: AnyHookInput,
|
|
461
|
+
) -> list[HookOutput]:
|
|
462
|
+
"""Execute multiple hooks in sequence.
|
|
463
|
+
|
|
464
|
+
Hooks are executed in order. If a hook returns a blocking decision,
|
|
465
|
+
subsequent hooks are still executed but the blocking result is returned.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
hooks: List of hook definitions to execute
|
|
469
|
+
input_data: The input data to pass to all hooks
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
List of HookOutput objects, one per hook
|
|
473
|
+
"""
|
|
474
|
+
results = []
|
|
475
|
+
for hook in hooks:
|
|
476
|
+
result = await self.execute_async(hook, input_data)
|
|
477
|
+
results.append(result)
|
|
478
|
+
return results
|
|
479
|
+
|
|
480
|
+
def execute_hooks_sync(
|
|
481
|
+
self,
|
|
482
|
+
hooks: list[HookDefinition],
|
|
483
|
+
input_data: AnyHookInput,
|
|
484
|
+
) -> list[HookOutput]:
|
|
485
|
+
"""Execute multiple hooks synchronously in sequence.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
hooks: List of hook definitions to execute
|
|
489
|
+
input_data: The input data to pass to all hooks
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
List of HookOutput objects, one per hook
|
|
493
|
+
"""
|
|
494
|
+
results = []
|
|
495
|
+
for hook in hooks:
|
|
496
|
+
result = self.execute_sync(hook, input_data)
|
|
497
|
+
results.append(result)
|
|
498
|
+
return results
|