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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +5 -0
- ripperdoc/cli/commands/__init__.py +71 -6
- ripperdoc/cli/commands/clear_cmd.py +1 -0
- ripperdoc/cli/commands/exit_cmd.py +1 -1
- 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 +71 -37
- ripperdoc/cli/ui/file_mention_completer.py +276 -0
- ripperdoc/cli/ui/helpers.py +100 -3
- ripperdoc/cli/ui/interrupt_handler.py +175 -0
- ripperdoc/cli/ui/message_display.py +249 -0
- ripperdoc/cli/ui/panels.py +63 -0
- ripperdoc/cli/ui/rich_ui.py +233 -648
- ripperdoc/cli/ui/tool_renderers.py +2 -2
- ripperdoc/core/agents.py +4 -4
- 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/core/query_utils.py +1 -1
- ripperdoc/core/tool.py +1 -1
- ripperdoc/tools/bash_tool.py +5 -5
- ripperdoc/tools/file_edit_tool.py +2 -2
- ripperdoc/tools/file_read_tool.py +2 -2
- ripperdoc/tools/multi_edit_tool.py +1 -1
- ripperdoc/utils/conversation_compaction.py +476 -0
- ripperdoc/utils/message_compaction.py +109 -154
- ripperdoc/utils/message_formatting.py +216 -0
- ripperdoc/utils/messages.py +31 -9
- ripperdoc/utils/path_ignore.py +3 -4
- ripperdoc/utils/session_history.py +19 -7
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/METADATA +24 -3
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/RECORD +44 -30
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/licenses/LICENSE +0 -0
- {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
|