ripperdoc 0.2.6__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.
Files changed (44) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +5 -0
  3. ripperdoc/cli/commands/__init__.py +71 -6
  4. ripperdoc/cli/commands/clear_cmd.py +1 -0
  5. ripperdoc/cli/commands/exit_cmd.py +1 -1
  6. ripperdoc/cli/commands/help_cmd.py +11 -1
  7. ripperdoc/cli/commands/hooks_cmd.py +636 -0
  8. ripperdoc/cli/commands/permissions_cmd.py +36 -34
  9. ripperdoc/cli/commands/resume_cmd.py +71 -37
  10. ripperdoc/cli/ui/file_mention_completer.py +276 -0
  11. ripperdoc/cli/ui/helpers.py +100 -3
  12. ripperdoc/cli/ui/interrupt_handler.py +175 -0
  13. ripperdoc/cli/ui/message_display.py +249 -0
  14. ripperdoc/cli/ui/panels.py +63 -0
  15. ripperdoc/cli/ui/rich_ui.py +233 -648
  16. ripperdoc/cli/ui/tool_renderers.py +2 -2
  17. ripperdoc/core/agents.py +4 -4
  18. ripperdoc/core/custom_commands.py +411 -0
  19. ripperdoc/core/hooks/__init__.py +99 -0
  20. ripperdoc/core/hooks/config.py +303 -0
  21. ripperdoc/core/hooks/events.py +540 -0
  22. ripperdoc/core/hooks/executor.py +498 -0
  23. ripperdoc/core/hooks/integration.py +353 -0
  24. ripperdoc/core/hooks/manager.py +720 -0
  25. ripperdoc/core/providers/anthropic.py +476 -69
  26. ripperdoc/core/query.py +61 -4
  27. ripperdoc/core/query_utils.py +1 -1
  28. ripperdoc/core/tool.py +1 -1
  29. ripperdoc/tools/bash_tool.py +5 -5
  30. ripperdoc/tools/file_edit_tool.py +2 -2
  31. ripperdoc/tools/file_read_tool.py +2 -2
  32. ripperdoc/tools/multi_edit_tool.py +1 -1
  33. ripperdoc/utils/conversation_compaction.py +476 -0
  34. ripperdoc/utils/message_compaction.py +109 -154
  35. ripperdoc/utils/message_formatting.py +216 -0
  36. ripperdoc/utils/messages.py +31 -9
  37. ripperdoc/utils/path_ignore.py +3 -4
  38. ripperdoc/utils/session_history.py +19 -7
  39. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/METADATA +24 -3
  40. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/RECORD +44 -30
  41. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/WHEEL +0 -0
  42. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/entry_points.txt +0 -0
  43. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/licenses/LICENSE +0 -0
  44. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,303 @@
1
+ """Hook configuration loading and management.
2
+
3
+ This module handles loading hooks configuration from:
4
+ - ~/.ripperdoc/hooks.json (global/user-level)
5
+ - .ripperdoc/hooks.json (project-level, checked into git)
6
+ - .ripperdoc/hooks.local.json (local, git-ignored)
7
+
8
+ Configuration format:
9
+ {
10
+ "hooks": {
11
+ "EventName": [
12
+ {
13
+ "matcher": "ToolPattern", // Only for PreToolUse/PermissionRequest/PostToolUse
14
+ "hooks": [
15
+ {
16
+ "type": "command",
17
+ "command": "your-command-here",
18
+ "timeout": 100
19
+ },
20
+ {
21
+ "type": "prompt",
22
+ "prompt": "Evaluate if this should proceed: $ARGUMENTS",
23
+ "timeout": 30
24
+ }
25
+ ]
26
+ }
27
+ ]
28
+ }
29
+ }
30
+ """
31
+
32
+ import json
33
+ import re
34
+ from pathlib import Path
35
+ from typing import Any, Dict, List, Literal, Optional
36
+ from pydantic import BaseModel, Field
37
+
38
+ from ripperdoc.core.hooks.events import HookEvent
39
+ from ripperdoc.utils.log import get_logger
40
+
41
+ logger = get_logger()
42
+
43
+ # Default timeout for hook commands (in seconds)
44
+ DEFAULT_HOOK_TIMEOUT = 60
45
+
46
+ # Hook events that support prompt-based hooks
47
+ PROMPT_SUPPORTED_EVENTS = {
48
+ "Stop",
49
+ "SubagentStop",
50
+ "UserPromptSubmit",
51
+ "PreToolUse",
52
+ "PermissionRequest",
53
+ }
54
+
55
+
56
+ class HookDefinition(BaseModel):
57
+ """Definition of a single hook.
58
+
59
+ Supports two types:
60
+ - command: Execute a shell command
61
+ - prompt: Use LLM to evaluate (for supported events only)
62
+ """
63
+
64
+ type: Literal["command", "prompt"] = "command"
65
+ command: Optional[str] = None # Shell command (for type="command")
66
+ prompt: Optional[str] = None # LLM prompt (for type="prompt"), use $ARGUMENTS for input JSON
67
+ timeout: int = DEFAULT_HOOK_TIMEOUT # Timeout in seconds
68
+
69
+ def is_command_hook(self) -> bool:
70
+ """Check if this is a command-based hook."""
71
+ return self.type == "command" and self.command is not None
72
+
73
+ def is_prompt_hook(self) -> bool:
74
+ """Check if this is a prompt-based hook."""
75
+ return self.type == "prompt" and self.prompt is not None
76
+
77
+
78
+ class HookMatcher(BaseModel):
79
+ """A matcher that groups hooks for a specific pattern.
80
+
81
+ For PreToolUse/PostToolUse events, the matcher can be:
82
+ - A specific tool name (e.g., "Bash", "Write", "Edit")
83
+ - A regex pattern (e.g., "Edit|Write", "mcp__.*__write.*")
84
+ - "*" or "" to match all tools
85
+ """
86
+
87
+ matcher: Optional[str] = None # None or empty means match all
88
+ hooks: List[HookDefinition] = Field(default_factory=list)
89
+
90
+ def matches(self, tool_name: Optional[str] = None) -> bool:
91
+ """Check if this matcher matches the given tool name.
92
+
93
+ For events that don't use tool names, always returns True if matcher is empty.
94
+ """
95
+ if not self.matcher or self.matcher == "*":
96
+ return True
97
+
98
+ if tool_name is None:
99
+ return True
100
+
101
+ # Try exact match first (case-sensitive)
102
+ if self.matcher == tool_name:
103
+ return True
104
+
105
+ # Try regex match
106
+ try:
107
+ pattern = re.compile(self.matcher)
108
+ return bool(pattern.match(tool_name))
109
+ except re.error:
110
+ # Invalid regex, fall back to simple string comparison
111
+ return False
112
+
113
+
114
+ class HooksConfig(BaseModel):
115
+ """Configuration for all hooks."""
116
+
117
+ hooks: Dict[str, List[HookMatcher]] = Field(default_factory=dict)
118
+
119
+ def get_hooks_for_event(
120
+ self, event: HookEvent, tool_name: Optional[str] = None
121
+ ) -> List[HookDefinition]:
122
+ """Get all hooks that should run for a given event and optional tool name."""
123
+ event_name = event.value
124
+ if event_name not in self.hooks:
125
+ return []
126
+
127
+ result = []
128
+ for matcher in self.hooks[event_name]:
129
+ if matcher.matches(tool_name):
130
+ result.extend(matcher.hooks)
131
+ return result
132
+
133
+ def merge_with(self, other: "HooksConfig") -> "HooksConfig":
134
+ """Merge this config with another, with 'other' taking precedence for additions."""
135
+ merged_hooks: Dict[str, List[HookMatcher]] = {}
136
+
137
+ # Copy all events from this config
138
+ for event_name, matchers in self.hooks.items():
139
+ merged_hooks[event_name] = list(matchers)
140
+
141
+ # Add/extend with hooks from other config
142
+ for event_name, matchers in other.hooks.items():
143
+ if event_name not in merged_hooks:
144
+ merged_hooks[event_name] = []
145
+ merged_hooks[event_name].extend(matchers)
146
+
147
+ return HooksConfig(hooks=merged_hooks)
148
+
149
+
150
+ def _parse_hooks_file(data: Dict[str, Any]) -> HooksConfig:
151
+ """Parse a hooks configuration from a dictionary.
152
+
153
+ Supports two formats:
154
+ 1. With 'hooks' wrapper: {"hooks": {"PreToolUse": [...]}}
155
+ 2. Without wrapper: {"PreToolUse": [...]}
156
+ """
157
+ # Support both formats: with or without "hooks" wrapper
158
+ hooks_data = data.get("hooks", {})
159
+ if not hooks_data:
160
+ # Try treating the entire data as hooks config (no wrapper)
161
+ # Check if any top-level key looks like an event name
162
+ for key in data.keys():
163
+ try:
164
+ HookEvent(key)
165
+ hooks_data = data
166
+ break
167
+ except ValueError:
168
+ continue
169
+
170
+ parsed_hooks: Dict[str, List[HookMatcher]] = {}
171
+
172
+ for event_name, matchers_list in hooks_data.items():
173
+ # Validate event name
174
+ try:
175
+ HookEvent(event_name)
176
+ except ValueError:
177
+ logger.warning(f"Unknown hook event: {event_name}")
178
+ continue
179
+
180
+ if not isinstance(matchers_list, list):
181
+ logger.warning(f"Invalid hooks config for {event_name}: expected list")
182
+ continue
183
+
184
+ parsed_matchers: List[HookMatcher] = []
185
+ for matcher_data in matchers_list:
186
+ if not isinstance(matcher_data, dict):
187
+ continue
188
+
189
+ matcher_pattern = matcher_data.get("matcher")
190
+ hooks_list = matcher_data.get("hooks", [])
191
+
192
+ if not isinstance(hooks_list, list):
193
+ continue
194
+
195
+ hook_definitions = []
196
+ for hook_data in hooks_list:
197
+ if not isinstance(hook_data, dict):
198
+ continue
199
+
200
+ hook_type = hook_data.get("type", "command")
201
+
202
+ # Validate hook type
203
+ if hook_type not in ("command", "prompt"):
204
+ logger.warning(f"Unknown hook type: {hook_type}")
205
+ continue
206
+
207
+ # For command hooks, require command field
208
+ if hook_type == "command":
209
+ if "command" not in hook_data:
210
+ continue
211
+ hook_def = HookDefinition(
212
+ type="command",
213
+ command=hook_data["command"],
214
+ timeout=hook_data.get("timeout", DEFAULT_HOOK_TIMEOUT),
215
+ )
216
+ # For prompt hooks, require prompt field and validate event
217
+ elif hook_type == "prompt":
218
+ if "prompt" not in hook_data:
219
+ continue
220
+ # Warn if prompt hooks used on unsupported events
221
+ if event_name not in PROMPT_SUPPORTED_EVENTS:
222
+ logger.warning(
223
+ f"Prompt hooks not supported for {event_name} event, skipping"
224
+ )
225
+ continue
226
+ hook_def = HookDefinition(
227
+ type="prompt",
228
+ prompt=hook_data["prompt"],
229
+ timeout=hook_data.get("timeout", DEFAULT_HOOK_TIMEOUT),
230
+ )
231
+ else:
232
+ continue
233
+
234
+ hook_definitions.append(hook_def)
235
+
236
+ if hook_definitions:
237
+ parsed_matchers.append(
238
+ HookMatcher(matcher=matcher_pattern, hooks=hook_definitions)
239
+ )
240
+
241
+ if parsed_matchers:
242
+ parsed_hooks[event_name] = parsed_matchers
243
+
244
+ return HooksConfig(hooks=parsed_hooks)
245
+
246
+
247
+ def load_hooks_config(config_path: Path) -> HooksConfig:
248
+ """Load hooks configuration from a file."""
249
+ if not config_path.exists():
250
+ return HooksConfig()
251
+
252
+ try:
253
+ data = json.loads(config_path.read_text(encoding="utf-8"))
254
+ config = _parse_hooks_file(data)
255
+ logger.debug(
256
+ f"Loaded hooks config from {config_path}",
257
+ extra={"event_count": len(config.hooks)},
258
+ )
259
+ return config
260
+ except json.JSONDecodeError as e:
261
+ logger.warning(f"Invalid JSON in hooks config {config_path}: {e}")
262
+ return HooksConfig()
263
+ except (OSError, IOError) as e:
264
+ logger.warning(f"Error reading hooks config {config_path}: {e}")
265
+ return HooksConfig()
266
+
267
+
268
+ def get_global_hooks_path() -> Path:
269
+ """Get the path to the global hooks configuration."""
270
+ return Path.home() / ".ripperdoc" / "hooks.json"
271
+
272
+
273
+ def get_project_hooks_path(project_path: Path) -> Path:
274
+ """Get the path to the project hooks configuration."""
275
+ return project_path / ".ripperdoc" / "hooks.json"
276
+
277
+
278
+ def get_project_local_hooks_path(project_path: Path) -> Path:
279
+ """Get the path to the local (git-ignored) project hooks configuration."""
280
+ return project_path / ".ripperdoc" / "hooks.local.json"
281
+
282
+
283
+ def get_merged_hooks_config(project_path: Optional[Path] = None) -> HooksConfig:
284
+ """Get the merged hooks configuration from all sources.
285
+
286
+ Order of precedence (later overrides earlier):
287
+ 1. Global config (~/.ripperdoc/hooks.json)
288
+ 2. Project config (.ripperdoc/hooks.json)
289
+ 3. Local project config (.ripperdoc/hooks.local.json)
290
+ """
291
+ # Start with global config
292
+ config = load_hooks_config(get_global_hooks_path())
293
+
294
+ # Merge project config if available
295
+ if project_path:
296
+ project_config = load_hooks_config(get_project_hooks_path(project_path))
297
+ config = config.merge_with(project_config)
298
+
299
+ # Merge local config
300
+ local_config = load_hooks_config(get_project_local_hooks_path(project_path))
301
+ config = config.merge_with(local_config)
302
+
303
+ return config