ripperdoc 0.2.7__py3-none-any.whl → 0.2.9__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 (87) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +33 -115
  3. ripperdoc/cli/commands/__init__.py +70 -6
  4. ripperdoc/cli/commands/agents_cmd.py +6 -3
  5. ripperdoc/cli/commands/clear_cmd.py +1 -4
  6. ripperdoc/cli/commands/config_cmd.py +1 -1
  7. ripperdoc/cli/commands/context_cmd.py +3 -2
  8. ripperdoc/cli/commands/doctor_cmd.py +18 -4
  9. ripperdoc/cli/commands/help_cmd.py +11 -1
  10. ripperdoc/cli/commands/hooks_cmd.py +610 -0
  11. ripperdoc/cli/commands/models_cmd.py +26 -9
  12. ripperdoc/cli/commands/permissions_cmd.py +57 -37
  13. ripperdoc/cli/commands/resume_cmd.py +6 -4
  14. ripperdoc/cli/commands/status_cmd.py +4 -4
  15. ripperdoc/cli/commands/tasks_cmd.py +8 -4
  16. ripperdoc/cli/ui/file_mention_completer.py +64 -8
  17. ripperdoc/cli/ui/interrupt_handler.py +3 -4
  18. ripperdoc/cli/ui/message_display.py +5 -3
  19. ripperdoc/cli/ui/panels.py +13 -10
  20. ripperdoc/cli/ui/provider_options.py +247 -0
  21. ripperdoc/cli/ui/rich_ui.py +196 -77
  22. ripperdoc/cli/ui/spinner.py +25 -1
  23. ripperdoc/cli/ui/tool_renderers.py +8 -2
  24. ripperdoc/cli/ui/wizard.py +215 -0
  25. ripperdoc/core/agents.py +9 -3
  26. ripperdoc/core/config.py +49 -12
  27. ripperdoc/core/custom_commands.py +412 -0
  28. ripperdoc/core/default_tools.py +11 -2
  29. ripperdoc/core/hooks/__init__.py +99 -0
  30. ripperdoc/core/hooks/config.py +301 -0
  31. ripperdoc/core/hooks/events.py +535 -0
  32. ripperdoc/core/hooks/executor.py +496 -0
  33. ripperdoc/core/hooks/integration.py +344 -0
  34. ripperdoc/core/hooks/manager.py +745 -0
  35. ripperdoc/core/permissions.py +40 -8
  36. ripperdoc/core/providers/anthropic.py +548 -68
  37. ripperdoc/core/providers/gemini.py +70 -5
  38. ripperdoc/core/providers/openai.py +60 -5
  39. ripperdoc/core/query.py +140 -39
  40. ripperdoc/core/query_utils.py +2 -0
  41. ripperdoc/core/skills.py +9 -3
  42. ripperdoc/core/system_prompt.py +4 -2
  43. ripperdoc/core/tool.py +9 -5
  44. ripperdoc/sdk/client.py +2 -2
  45. ripperdoc/tools/ask_user_question_tool.py +5 -3
  46. ripperdoc/tools/background_shell.py +2 -1
  47. ripperdoc/tools/bash_output_tool.py +1 -1
  48. ripperdoc/tools/bash_tool.py +30 -20
  49. ripperdoc/tools/dynamic_mcp_tool.py +29 -8
  50. ripperdoc/tools/enter_plan_mode_tool.py +1 -1
  51. ripperdoc/tools/exit_plan_mode_tool.py +1 -1
  52. ripperdoc/tools/file_edit_tool.py +8 -4
  53. ripperdoc/tools/file_read_tool.py +9 -5
  54. ripperdoc/tools/file_write_tool.py +9 -5
  55. ripperdoc/tools/glob_tool.py +3 -2
  56. ripperdoc/tools/grep_tool.py +3 -2
  57. ripperdoc/tools/kill_bash_tool.py +1 -1
  58. ripperdoc/tools/ls_tool.py +1 -1
  59. ripperdoc/tools/mcp_tools.py +13 -10
  60. ripperdoc/tools/multi_edit_tool.py +8 -7
  61. ripperdoc/tools/notebook_edit_tool.py +7 -4
  62. ripperdoc/tools/skill_tool.py +1 -1
  63. ripperdoc/tools/task_tool.py +5 -4
  64. ripperdoc/tools/todo_tool.py +2 -2
  65. ripperdoc/tools/tool_search_tool.py +3 -2
  66. ripperdoc/utils/conversation_compaction.py +11 -7
  67. ripperdoc/utils/file_watch.py +8 -2
  68. ripperdoc/utils/json_utils.py +2 -1
  69. ripperdoc/utils/mcp.py +11 -3
  70. ripperdoc/utils/memory.py +4 -2
  71. ripperdoc/utils/message_compaction.py +21 -7
  72. ripperdoc/utils/message_formatting.py +11 -7
  73. ripperdoc/utils/messages.py +105 -66
  74. ripperdoc/utils/path_ignore.py +38 -12
  75. ripperdoc/utils/permissions/path_validation_utils.py +2 -1
  76. ripperdoc/utils/permissions/shell_command_validation.py +427 -91
  77. ripperdoc/utils/safe_get_cwd.py +2 -1
  78. ripperdoc/utils/session_history.py +13 -6
  79. ripperdoc/utils/todo.py +2 -1
  80. ripperdoc/utils/token_estimation.py +6 -1
  81. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/METADATA +24 -3
  82. ripperdoc-0.2.9.dist-info/RECORD +123 -0
  83. ripperdoc-0.2.7.dist-info/RECORD +0 -113
  84. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/WHEEL +0 -0
  85. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/entry_points.txt +0 -0
  86. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/licenses/LICENSE +0 -0
  87. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,496 @@
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 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, additionalContext=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
+ "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("Prompt hook skipped in sync mode. Use execute_async for prompt hooks.")
281
+ return HookOutput()
282
+
283
+ return self._execute_command_sync(hook, input_data)
284
+
285
+ def _execute_command_sync(
286
+ self,
287
+ hook: HookDefinition,
288
+ input_data: AnyHookInput,
289
+ ) -> HookOutput:
290
+ """Execute a command-based hook synchronously.
291
+
292
+ Args:
293
+ hook: The hook definition to execute
294
+ input_data: The input data to pass to the hook (as JSON via stdin)
295
+
296
+ Returns:
297
+ HookOutput containing the result or error
298
+ """
299
+ if not hook.command:
300
+ logger.warning("Command hook has no command")
301
+ return HookOutput(error="Command hook missing command")
302
+
303
+ command = self._expand_command(hook.command)
304
+ env = self._build_env(input_data)
305
+
306
+ # Serialize input data for stdin
307
+ input_json = input_data.model_dump_json()
308
+
309
+ logger.debug(
310
+ f"Executing hook: {command}",
311
+ extra={
312
+ "event": input_data.hook_event_name,
313
+ "timeout": hook.timeout,
314
+ },
315
+ )
316
+
317
+ try:
318
+ result = subprocess.run(
319
+ command,
320
+ shell=True,
321
+ input=input_json,
322
+ capture_output=True,
323
+ text=True,
324
+ timeout=hook.timeout,
325
+ env=env,
326
+ cwd=str(self.project_dir) if self.project_dir else None,
327
+ )
328
+
329
+ output = HookOutput.from_raw(
330
+ stdout=result.stdout,
331
+ stderr=result.stderr,
332
+ exit_code=result.returncode,
333
+ )
334
+
335
+ logger.debug(
336
+ f"Hook completed: {command}",
337
+ extra={
338
+ "exit_code": result.returncode,
339
+ "decision": output.decision.value if output.decision else None,
340
+ },
341
+ )
342
+
343
+ return output
344
+
345
+ except subprocess.TimeoutExpired:
346
+ logger.warning(f"Hook timed out after {hook.timeout}s: {command}")
347
+ return HookOutput.from_raw("", "", 1, timed_out=True)
348
+
349
+ except Exception as e:
350
+ logger.error(f"Hook execution failed: {command}: {e}")
351
+ return HookOutput(
352
+ error=str(e),
353
+ exit_code=1,
354
+ )
355
+
356
+ async def execute_async(
357
+ self,
358
+ hook: HookDefinition,
359
+ input_data: AnyHookInput,
360
+ ) -> HookOutput:
361
+ """Execute a hook asynchronously.
362
+
363
+ Dispatches to appropriate method based on hook type.
364
+
365
+ Args:
366
+ hook: The hook definition to execute
367
+ input_data: The input data to pass to the hook
368
+
369
+ Returns:
370
+ HookOutput containing the result or error
371
+ """
372
+ if hook.is_prompt_hook():
373
+ return await self.execute_prompt_async(hook, input_data)
374
+
375
+ return await self._execute_command_async(hook, input_data)
376
+
377
+ async def _execute_command_async(
378
+ self,
379
+ hook: HookDefinition,
380
+ input_data: AnyHookInput,
381
+ ) -> HookOutput:
382
+ """Execute a command-based hook asynchronously.
383
+
384
+ Args:
385
+ hook: The hook definition to execute
386
+ input_data: The input data to pass to the hook (as JSON via stdin)
387
+
388
+ Returns:
389
+ HookOutput containing the result or error
390
+ """
391
+ if not hook.command:
392
+ logger.warning("Command hook has no command")
393
+ return HookOutput(error="Command hook missing command")
394
+
395
+ command = self._expand_command(hook.command)
396
+ env = self._build_env(input_data)
397
+
398
+ # Serialize input data for stdin
399
+ input_json = input_data.model_dump_json()
400
+
401
+ logger.debug(
402
+ f"Executing hook (async): {command}",
403
+ extra={
404
+ "event": input_data.hook_event_name,
405
+ "timeout": hook.timeout,
406
+ },
407
+ )
408
+
409
+ try:
410
+ process = await asyncio.create_subprocess_shell(
411
+ command,
412
+ stdin=asyncio.subprocess.PIPE,
413
+ stdout=asyncio.subprocess.PIPE,
414
+ stderr=asyncio.subprocess.PIPE,
415
+ env=env,
416
+ cwd=str(self.project_dir) if self.project_dir else None,
417
+ )
418
+
419
+ try:
420
+ stdout, stderr = await asyncio.wait_for(
421
+ process.communicate(input_json.encode()),
422
+ timeout=hook.timeout,
423
+ )
424
+
425
+ output = HookOutput.from_raw(
426
+ stdout=stdout.decode(),
427
+ stderr=stderr.decode(),
428
+ exit_code=process.returncode or 0,
429
+ )
430
+
431
+ logger.debug(
432
+ f"Hook completed (async): {command}",
433
+ extra={
434
+ "exit_code": process.returncode,
435
+ "decision": output.decision.value if output.decision else None,
436
+ },
437
+ )
438
+
439
+ return output
440
+
441
+ except asyncio.TimeoutError:
442
+ # Kill the process on timeout
443
+ process.kill()
444
+ await process.wait()
445
+ logger.warning(f"Hook timed out after {hook.timeout}s: {command}")
446
+ return HookOutput.from_raw("", "", 1, timed_out=True)
447
+
448
+ except Exception as e:
449
+ logger.error(f"Hook execution failed (async): {command}: {e}")
450
+ return HookOutput(
451
+ error=str(e),
452
+ exit_code=1,
453
+ )
454
+
455
+ async def execute_hooks_async(
456
+ self,
457
+ hooks: list[HookDefinition],
458
+ input_data: AnyHookInput,
459
+ ) -> list[HookOutput]:
460
+ """Execute multiple hooks in sequence.
461
+
462
+ Hooks are executed in order. If a hook returns a blocking decision,
463
+ subsequent hooks are still executed but the blocking result is returned.
464
+
465
+ Args:
466
+ hooks: List of hook definitions to execute
467
+ input_data: The input data to pass to all hooks
468
+
469
+ Returns:
470
+ List of HookOutput objects, one per hook
471
+ """
472
+ results = []
473
+ for hook in hooks:
474
+ result = await self.execute_async(hook, input_data)
475
+ results.append(result)
476
+ return results
477
+
478
+ def execute_hooks_sync(
479
+ self,
480
+ hooks: list[HookDefinition],
481
+ input_data: AnyHookInput,
482
+ ) -> list[HookOutput]:
483
+ """Execute multiple hooks synchronously in sequence.
484
+
485
+ Args:
486
+ hooks: List of hook definitions to execute
487
+ input_data: The input data to pass to all hooks
488
+
489
+ Returns:
490
+ List of HookOutput objects, one per hook
491
+ """
492
+ results = []
493
+ for hook in hooks:
494
+ result = self.execute_sync(hook, input_data)
495
+ results.append(result)
496
+ return results