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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +33 -115
- ripperdoc/cli/commands/__init__.py +70 -6
- ripperdoc/cli/commands/agents_cmd.py +6 -3
- ripperdoc/cli/commands/clear_cmd.py +1 -4
- ripperdoc/cli/commands/config_cmd.py +1 -1
- ripperdoc/cli/commands/context_cmd.py +3 -2
- ripperdoc/cli/commands/doctor_cmd.py +18 -4
- ripperdoc/cli/commands/help_cmd.py +11 -1
- ripperdoc/cli/commands/hooks_cmd.py +610 -0
- ripperdoc/cli/commands/models_cmd.py +26 -9
- ripperdoc/cli/commands/permissions_cmd.py +57 -37
- ripperdoc/cli/commands/resume_cmd.py +6 -4
- ripperdoc/cli/commands/status_cmd.py +4 -4
- ripperdoc/cli/commands/tasks_cmd.py +8 -4
- ripperdoc/cli/ui/file_mention_completer.py +64 -8
- ripperdoc/cli/ui/interrupt_handler.py +3 -4
- ripperdoc/cli/ui/message_display.py +5 -3
- ripperdoc/cli/ui/panels.py +13 -10
- ripperdoc/cli/ui/provider_options.py +247 -0
- ripperdoc/cli/ui/rich_ui.py +196 -77
- ripperdoc/cli/ui/spinner.py +25 -1
- ripperdoc/cli/ui/tool_renderers.py +8 -2
- ripperdoc/cli/ui/wizard.py +215 -0
- ripperdoc/core/agents.py +9 -3
- ripperdoc/core/config.py +49 -12
- ripperdoc/core/custom_commands.py +412 -0
- ripperdoc/core/default_tools.py +11 -2
- ripperdoc/core/hooks/__init__.py +99 -0
- ripperdoc/core/hooks/config.py +301 -0
- ripperdoc/core/hooks/events.py +535 -0
- ripperdoc/core/hooks/executor.py +496 -0
- ripperdoc/core/hooks/integration.py +344 -0
- ripperdoc/core/hooks/manager.py +745 -0
- ripperdoc/core/permissions.py +40 -8
- ripperdoc/core/providers/anthropic.py +548 -68
- ripperdoc/core/providers/gemini.py +70 -5
- ripperdoc/core/providers/openai.py +60 -5
- ripperdoc/core/query.py +140 -39
- ripperdoc/core/query_utils.py +2 -0
- ripperdoc/core/skills.py +9 -3
- ripperdoc/core/system_prompt.py +4 -2
- ripperdoc/core/tool.py +9 -5
- ripperdoc/sdk/client.py +2 -2
- ripperdoc/tools/ask_user_question_tool.py +5 -3
- ripperdoc/tools/background_shell.py +2 -1
- ripperdoc/tools/bash_output_tool.py +1 -1
- ripperdoc/tools/bash_tool.py +30 -20
- ripperdoc/tools/dynamic_mcp_tool.py +29 -8
- ripperdoc/tools/enter_plan_mode_tool.py +1 -1
- ripperdoc/tools/exit_plan_mode_tool.py +1 -1
- ripperdoc/tools/file_edit_tool.py +8 -4
- ripperdoc/tools/file_read_tool.py +9 -5
- ripperdoc/tools/file_write_tool.py +9 -5
- ripperdoc/tools/glob_tool.py +3 -2
- ripperdoc/tools/grep_tool.py +3 -2
- ripperdoc/tools/kill_bash_tool.py +1 -1
- ripperdoc/tools/ls_tool.py +1 -1
- ripperdoc/tools/mcp_tools.py +13 -10
- ripperdoc/tools/multi_edit_tool.py +8 -7
- ripperdoc/tools/notebook_edit_tool.py +7 -4
- ripperdoc/tools/skill_tool.py +1 -1
- ripperdoc/tools/task_tool.py +5 -4
- ripperdoc/tools/todo_tool.py +2 -2
- ripperdoc/tools/tool_search_tool.py +3 -2
- ripperdoc/utils/conversation_compaction.py +11 -7
- ripperdoc/utils/file_watch.py +8 -2
- ripperdoc/utils/json_utils.py +2 -1
- ripperdoc/utils/mcp.py +11 -3
- ripperdoc/utils/memory.py +4 -2
- ripperdoc/utils/message_compaction.py +21 -7
- ripperdoc/utils/message_formatting.py +11 -7
- ripperdoc/utils/messages.py +105 -66
- ripperdoc/utils/path_ignore.py +38 -12
- ripperdoc/utils/permissions/path_validation_utils.py +2 -1
- ripperdoc/utils/permissions/shell_command_validation.py +427 -91
- ripperdoc/utils/safe_get_cwd.py +2 -1
- ripperdoc/utils/session_history.py +13 -6
- ripperdoc/utils/todo.py +2 -1
- ripperdoc/utils/token_estimation.py +6 -1
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/METADATA +24 -3
- ripperdoc-0.2.9.dist-info/RECORD +123 -0
- ripperdoc-0.2.7.dist-info/RECORD +0 -113
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Custom Command loading and execution for Ripperdoc.
|
|
2
|
+
|
|
3
|
+
Custom commands are defined in .md files under:
|
|
4
|
+
- `~/.ripperdoc/commands/` (user-level commands)
|
|
5
|
+
- `.ripperdoc/commands/` (project-level commands)
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
- Frontmatter support (YAML) for metadata: allowed-tools, description, argument-hint, model, thinking-mode
|
|
9
|
+
- Parameter substitution: $ARGUMENTS, $1, $2, etc.
|
|
10
|
+
- File references: @filename (resolved relative to project root)
|
|
11
|
+
- Bash command execution: !`command` syntax
|
|
12
|
+
- Nested commands via subdirectories (e.g., git/commit.md -> /git:commit)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
from ripperdoc.utils.log import get_logger
|
|
27
|
+
|
|
28
|
+
logger = get_logger()
|
|
29
|
+
|
|
30
|
+
COMMAND_DIR_NAME = "commands"
|
|
31
|
+
COMMAND_FILE_SUFFIX = ".md"
|
|
32
|
+
_COMMAND_NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
|
33
|
+
|
|
34
|
+
# Pattern for bash command execution: !`command`
|
|
35
|
+
_BASH_COMMAND_PATTERN = re.compile(r"!\`([^`]+)\`")
|
|
36
|
+
|
|
37
|
+
# Pattern for file references: @filename
|
|
38
|
+
_FILE_REFERENCE_PATTERN = re.compile(r"@([^\s\]]+)")
|
|
39
|
+
|
|
40
|
+
# Pattern for positional arguments: $1, $2, etc.
|
|
41
|
+
_POSITIONAL_ARG_PATTERN = re.compile(r"\$(\d+)")
|
|
42
|
+
|
|
43
|
+
# Pattern for all arguments: $ARGUMENTS
|
|
44
|
+
_ALL_ARGS_PATTERN = re.compile(r"\$ARGUMENTS", re.IGNORECASE)
|
|
45
|
+
|
|
46
|
+
# Thinking mode keywords
|
|
47
|
+
THINKING_MODES = ["think", "think hard", "think harder", "ultrathink"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CommandLocation(str, Enum):
|
|
51
|
+
"""Where a custom command is sourced from."""
|
|
52
|
+
|
|
53
|
+
USER = "user"
|
|
54
|
+
PROJECT = "project"
|
|
55
|
+
OTHER = "other"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class CustomCommandDefinition:
|
|
60
|
+
"""Parsed representation of a custom command."""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
description: str
|
|
64
|
+
content: str
|
|
65
|
+
path: Path
|
|
66
|
+
base_dir: Path
|
|
67
|
+
location: CommandLocation
|
|
68
|
+
allowed_tools: List[str] = field(default_factory=list)
|
|
69
|
+
argument_hint: Optional[str] = None
|
|
70
|
+
model: Optional[str] = None
|
|
71
|
+
thinking_mode: Optional[str] = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class CustomCommandLoadError:
|
|
76
|
+
"""Error encountered while loading a custom command file."""
|
|
77
|
+
|
|
78
|
+
path: Path
|
|
79
|
+
reason: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class CustomCommandLoadResult:
|
|
84
|
+
"""Aggregated result of loading custom commands."""
|
|
85
|
+
|
|
86
|
+
commands: List[CustomCommandDefinition]
|
|
87
|
+
errors: List[CustomCommandLoadError]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _split_frontmatter(raw_text: str) -> Tuple[Dict[str, Any], str]:
|
|
91
|
+
"""Extract YAML frontmatter and body content from a markdown file."""
|
|
92
|
+
lines = raw_text.splitlines()
|
|
93
|
+
if len(lines) >= 3 and lines[0].strip() == "---":
|
|
94
|
+
for idx in range(1, len(lines)):
|
|
95
|
+
if lines[idx].strip() == "---":
|
|
96
|
+
frontmatter_text = "\n".join(lines[1:idx])
|
|
97
|
+
body = "\n".join(lines[idx + 1 :])
|
|
98
|
+
try:
|
|
99
|
+
frontmatter = yaml.safe_load(frontmatter_text) or {}
|
|
100
|
+
except (yaml.YAMLError, ValueError, TypeError) as exc:
|
|
101
|
+
logger.warning(
|
|
102
|
+
"[custom_commands] Invalid frontmatter: %s: %s",
|
|
103
|
+
type(exc).__name__,
|
|
104
|
+
exc,
|
|
105
|
+
)
|
|
106
|
+
return {"__error__": f"Invalid frontmatter: {exc}"}, body
|
|
107
|
+
return frontmatter, body
|
|
108
|
+
return {}, raw_text
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _normalize_allowed_tools(value: object) -> List[str]:
|
|
112
|
+
"""Normalize allowed-tools values to a clean list of tool names."""
|
|
113
|
+
if value is None:
|
|
114
|
+
return []
|
|
115
|
+
if isinstance(value, str):
|
|
116
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
117
|
+
if isinstance(value, Iterable):
|
|
118
|
+
tools: List[str] = []
|
|
119
|
+
for item in value:
|
|
120
|
+
if isinstance(item, str) and item.strip():
|
|
121
|
+
tools.append(item.strip())
|
|
122
|
+
return tools
|
|
123
|
+
return []
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _extract_thinking_mode(content: str) -> Optional[str]:
|
|
127
|
+
"""Extract thinking mode from content if present."""
|
|
128
|
+
content_lower = content.lower()
|
|
129
|
+
for mode in reversed(THINKING_MODES): # Check longer modes first
|
|
130
|
+
if mode in content_lower:
|
|
131
|
+
return mode
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _derive_command_name(path: Path, base_commands_dir: Path) -> str:
|
|
136
|
+
"""Derive command name from file path, supporting nested commands.
|
|
137
|
+
|
|
138
|
+
Examples:
|
|
139
|
+
commands/project-info.md -> project-info
|
|
140
|
+
commands/git/commit.md -> git:commit
|
|
141
|
+
commands/git/git-commit.md -> git:git-commit
|
|
142
|
+
"""
|
|
143
|
+
relative = path.relative_to(base_commands_dir)
|
|
144
|
+
parts = list(relative.parts)
|
|
145
|
+
|
|
146
|
+
# Remove the .md suffix from the last part
|
|
147
|
+
if parts:
|
|
148
|
+
parts[-1] = parts[-1].removesuffix(COMMAND_FILE_SUFFIX)
|
|
149
|
+
|
|
150
|
+
# Join with : for nested commands
|
|
151
|
+
if len(parts) > 1:
|
|
152
|
+
return ":".join(parts)
|
|
153
|
+
return parts[0] if parts else path.stem
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _load_command_file(
|
|
157
|
+
path: Path, location: CommandLocation, base_commands_dir: Path
|
|
158
|
+
) -> Tuple[Optional[CustomCommandDefinition], Optional[CustomCommandLoadError]]:
|
|
159
|
+
"""Parse a single command .md file."""
|
|
160
|
+
try:
|
|
161
|
+
text = path.read_text(encoding="utf-8")
|
|
162
|
+
except (OSError, IOError, UnicodeDecodeError) as exc:
|
|
163
|
+
logger.warning(
|
|
164
|
+
"[custom_commands] Failed to read command file: %s: %s",
|
|
165
|
+
type(exc).__name__,
|
|
166
|
+
exc,
|
|
167
|
+
extra={"path": str(path)},
|
|
168
|
+
)
|
|
169
|
+
return None, CustomCommandLoadError(path=path, reason=f"Failed to read file: {exc}")
|
|
170
|
+
|
|
171
|
+
frontmatter, body = _split_frontmatter(text)
|
|
172
|
+
if "__error__" in frontmatter:
|
|
173
|
+
return None, CustomCommandLoadError(path=path, reason=str(frontmatter["__error__"]))
|
|
174
|
+
|
|
175
|
+
# Derive command name from path (supports nested commands)
|
|
176
|
+
command_name = _derive_command_name(path, base_commands_dir)
|
|
177
|
+
|
|
178
|
+
# Get description from frontmatter or use first line of content
|
|
179
|
+
raw_description = frontmatter.get("description")
|
|
180
|
+
if not isinstance(raw_description, str) or not raw_description.strip():
|
|
181
|
+
# Use first line of content as description (truncated)
|
|
182
|
+
first_line = body.strip().split("\n")[0] if body.strip() else ""
|
|
183
|
+
raw_description = first_line[:60] + "..." if len(first_line) > 60 else first_line
|
|
184
|
+
if not raw_description:
|
|
185
|
+
raw_description = f"Custom command: {command_name}"
|
|
186
|
+
|
|
187
|
+
allowed_tools = _normalize_allowed_tools(
|
|
188
|
+
frontmatter.get("allowed-tools") or frontmatter.get("allowed_tools")
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
argument_hint = frontmatter.get("argument-hint") or frontmatter.get("argument_hint")
|
|
192
|
+
if argument_hint and not isinstance(argument_hint, str):
|
|
193
|
+
argument_hint = str(argument_hint)
|
|
194
|
+
|
|
195
|
+
model_value = frontmatter.get("model")
|
|
196
|
+
model = model_value.strip() if isinstance(model_value, str) and model_value.strip() else None
|
|
197
|
+
|
|
198
|
+
# Get thinking mode from frontmatter or content
|
|
199
|
+
thinking_mode = frontmatter.get("thinking-mode") or frontmatter.get("thinking_mode")
|
|
200
|
+
if not thinking_mode:
|
|
201
|
+
thinking_mode = _extract_thinking_mode(body)
|
|
202
|
+
|
|
203
|
+
command = CustomCommandDefinition(
|
|
204
|
+
name=command_name,
|
|
205
|
+
description=raw_description.strip(),
|
|
206
|
+
content=body.strip(),
|
|
207
|
+
path=path,
|
|
208
|
+
base_dir=path.parent,
|
|
209
|
+
location=location,
|
|
210
|
+
allowed_tools=allowed_tools,
|
|
211
|
+
argument_hint=argument_hint.strip() if argument_hint else None,
|
|
212
|
+
model=model,
|
|
213
|
+
thinking_mode=thinking_mode.strip() if isinstance(thinking_mode, str) else None,
|
|
214
|
+
)
|
|
215
|
+
return command, None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _load_commands_from_dir(
|
|
219
|
+
commands_dir: Path, location: CommandLocation
|
|
220
|
+
) -> Tuple[List[CustomCommandDefinition], List[CustomCommandLoadError]]:
|
|
221
|
+
"""Load all custom commands from a directory, including nested subdirectories."""
|
|
222
|
+
commands: List[CustomCommandDefinition] = []
|
|
223
|
+
errors: List[CustomCommandLoadError] = []
|
|
224
|
+
|
|
225
|
+
if not commands_dir.exists() or not commands_dir.is_dir():
|
|
226
|
+
return commands, errors
|
|
227
|
+
|
|
228
|
+
# Recursively find all .md files
|
|
229
|
+
try:
|
|
230
|
+
for md_file in commands_dir.rglob(f"*{COMMAND_FILE_SUFFIX}"):
|
|
231
|
+
if not md_file.is_file():
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
command, error = _load_command_file(md_file, location, commands_dir)
|
|
235
|
+
if command:
|
|
236
|
+
commands.append(command)
|
|
237
|
+
elif error:
|
|
238
|
+
errors.append(error)
|
|
239
|
+
except OSError as exc:
|
|
240
|
+
logger.warning(
|
|
241
|
+
"[custom_commands] Failed to scan command directory: %s: %s",
|
|
242
|
+
type(exc).__name__,
|
|
243
|
+
exc,
|
|
244
|
+
extra={"path": str(commands_dir)},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return commands, errors
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def command_directories(
|
|
251
|
+
project_path: Optional[Path] = None, home: Optional[Path] = None
|
|
252
|
+
) -> List[Tuple[Path, CommandLocation]]:
|
|
253
|
+
"""Return the standard command directories for user and project scopes."""
|
|
254
|
+
home_dir = (home or Path.home()).expanduser()
|
|
255
|
+
project_dir = (project_path or Path.cwd()).resolve()
|
|
256
|
+
return [
|
|
257
|
+
(home_dir / ".ripperdoc" / COMMAND_DIR_NAME, CommandLocation.USER),
|
|
258
|
+
(project_dir / ".ripperdoc" / COMMAND_DIR_NAME, CommandLocation.PROJECT),
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def load_all_custom_commands(
|
|
263
|
+
project_path: Optional[Path] = None, home: Optional[Path] = None
|
|
264
|
+
) -> CustomCommandLoadResult:
|
|
265
|
+
"""Load custom commands from user and project directories.
|
|
266
|
+
|
|
267
|
+
Project commands override user commands with the same name.
|
|
268
|
+
"""
|
|
269
|
+
commands_by_name: Dict[str, CustomCommandDefinition] = {}
|
|
270
|
+
errors: List[CustomCommandLoadError] = []
|
|
271
|
+
|
|
272
|
+
# Load user commands first so project commands take precedence
|
|
273
|
+
for directory, location in command_directories(project_path=project_path, home=home):
|
|
274
|
+
loaded, dir_errors = _load_commands_from_dir(directory, location)
|
|
275
|
+
errors.extend(dir_errors)
|
|
276
|
+
for cmd in loaded:
|
|
277
|
+
if cmd.name in commands_by_name:
|
|
278
|
+
logger.debug(
|
|
279
|
+
"[custom_commands] Overriding command",
|
|
280
|
+
extra={
|
|
281
|
+
"command_name": cmd.name,
|
|
282
|
+
"previous_location": str(commands_by_name[cmd.name].location),
|
|
283
|
+
"new_location": str(location),
|
|
284
|
+
},
|
|
285
|
+
)
|
|
286
|
+
commands_by_name[cmd.name] = cmd
|
|
287
|
+
|
|
288
|
+
return CustomCommandLoadResult(commands=list(commands_by_name.values()), errors=errors)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def find_custom_command(
|
|
292
|
+
command_name: str, project_path: Optional[Path] = None, home: Optional[Path] = None
|
|
293
|
+
) -> Optional[CustomCommandDefinition]:
|
|
294
|
+
"""Find a custom command by name (case-sensitive match)."""
|
|
295
|
+
normalized = command_name.strip().lstrip("/")
|
|
296
|
+
if not normalized:
|
|
297
|
+
return None
|
|
298
|
+
result = load_all_custom_commands(project_path=project_path, home=home)
|
|
299
|
+
return next((cmd for cmd in result.commands if cmd.name == normalized), None)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _execute_bash_command(command: str, cwd: Optional[Path] = None) -> str:
|
|
303
|
+
"""Execute a bash command and return its output."""
|
|
304
|
+
try:
|
|
305
|
+
result = subprocess.run(
|
|
306
|
+
command,
|
|
307
|
+
shell=True,
|
|
308
|
+
capture_output=True,
|
|
309
|
+
text=True,
|
|
310
|
+
timeout=30,
|
|
311
|
+
cwd=cwd,
|
|
312
|
+
)
|
|
313
|
+
output = result.stdout.strip()
|
|
314
|
+
if result.returncode != 0 and result.stderr:
|
|
315
|
+
output = f"{output}\n{result.stderr}".strip()
|
|
316
|
+
return output if output else "(no output)"
|
|
317
|
+
except subprocess.TimeoutExpired:
|
|
318
|
+
return "(command timed out)"
|
|
319
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
320
|
+
return f"(error: {exc})"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _resolve_file_reference(filename: str, project_path: Path) -> str:
|
|
324
|
+
"""Resolve a file reference and return its content."""
|
|
325
|
+
try:
|
|
326
|
+
file_path = project_path / filename
|
|
327
|
+
if file_path.exists() and file_path.is_file():
|
|
328
|
+
content = file_path.read_text(encoding="utf-8")
|
|
329
|
+
return content.strip()
|
|
330
|
+
return f"(file not found: {filename})"
|
|
331
|
+
except (OSError, IOError) as exc:
|
|
332
|
+
return f"(error reading {filename}: {exc})"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def expand_command_content(
|
|
336
|
+
command: CustomCommandDefinition,
|
|
337
|
+
arguments: str,
|
|
338
|
+
project_path: Optional[Path] = None,
|
|
339
|
+
) -> str:
|
|
340
|
+
"""Expand a custom command's content with arguments, bash commands, and file references.
|
|
341
|
+
|
|
342
|
+
Supports:
|
|
343
|
+
- $ARGUMENTS: All arguments as a single string
|
|
344
|
+
- $1, $2, etc.: Positional arguments
|
|
345
|
+
- !`command`: Execute bash command and include output
|
|
346
|
+
- @filename: Include file content
|
|
347
|
+
"""
|
|
348
|
+
project_dir = (project_path or Path.cwd()).resolve()
|
|
349
|
+
content = command.content
|
|
350
|
+
|
|
351
|
+
# Split arguments for positional access
|
|
352
|
+
arg_parts = arguments.split() if arguments else []
|
|
353
|
+
|
|
354
|
+
# Replace $ARGUMENTS with all arguments
|
|
355
|
+
content = _ALL_ARGS_PATTERN.sub(arguments, content)
|
|
356
|
+
|
|
357
|
+
# Replace positional arguments $1, $2, etc.
|
|
358
|
+
def replace_positional(match: re.Match) -> str:
|
|
359
|
+
idx = int(match.group(1)) - 1 # $1 -> index 0
|
|
360
|
+
if 0 <= idx < len(arg_parts):
|
|
361
|
+
return arg_parts[idx]
|
|
362
|
+
return ""
|
|
363
|
+
|
|
364
|
+
content = _POSITIONAL_ARG_PATTERN.sub(replace_positional, content)
|
|
365
|
+
|
|
366
|
+
# Execute bash commands: !`command`
|
|
367
|
+
def replace_bash(match: re.Match) -> str:
|
|
368
|
+
bash_cmd = match.group(1)
|
|
369
|
+
return _execute_bash_command(bash_cmd, cwd=project_dir)
|
|
370
|
+
|
|
371
|
+
content = _BASH_COMMAND_PATTERN.sub(replace_bash, content)
|
|
372
|
+
|
|
373
|
+
# Resolve file references: @filename
|
|
374
|
+
def replace_file_ref(match: re.Match) -> str:
|
|
375
|
+
filename = match.group(1)
|
|
376
|
+
return _resolve_file_reference(filename, project_dir)
|
|
377
|
+
|
|
378
|
+
content = _FILE_REFERENCE_PATTERN.sub(replace_file_ref, content)
|
|
379
|
+
|
|
380
|
+
return content.strip()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def build_custom_command_summary(commands: Sequence[CustomCommandDefinition]) -> str:
|
|
384
|
+
"""Render a concise instruction block listing available custom commands."""
|
|
385
|
+
if not commands:
|
|
386
|
+
return ""
|
|
387
|
+
|
|
388
|
+
lines = [
|
|
389
|
+
"# Custom Commands",
|
|
390
|
+
"The following custom commands are available:",
|
|
391
|
+
]
|
|
392
|
+
|
|
393
|
+
for cmd in sorted(commands, key=lambda c: c.name):
|
|
394
|
+
location = f" ({cmd.location.value})" if cmd.location else ""
|
|
395
|
+
hint = f" {cmd.argument_hint}" if cmd.argument_hint else ""
|
|
396
|
+
lines.append(f"- /{cmd.name}{hint}{location}: {cmd.description}")
|
|
397
|
+
|
|
398
|
+
return "\n".join(lines)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
__all__ = [
|
|
402
|
+
"CustomCommandDefinition",
|
|
403
|
+
"CustomCommandLoadError",
|
|
404
|
+
"CustomCommandLoadResult",
|
|
405
|
+
"CommandLocation",
|
|
406
|
+
"COMMAND_DIR_NAME",
|
|
407
|
+
"load_all_custom_commands",
|
|
408
|
+
"find_custom_command",
|
|
409
|
+
"expand_command_content",
|
|
410
|
+
"build_custom_command_summary",
|
|
411
|
+
"command_directories",
|
|
412
|
+
]
|
ripperdoc/core/default_tools.py
CHANGED
|
@@ -68,11 +68,20 @@ def get_default_tools() -> List[Tool[Any, Any]]:
|
|
|
68
68
|
if isinstance(tool, Tool):
|
|
69
69
|
base_tools.append(tool)
|
|
70
70
|
dynamic_tools.append(tool)
|
|
71
|
-
except (
|
|
71
|
+
except (
|
|
72
|
+
ImportError,
|
|
73
|
+
ModuleNotFoundError,
|
|
74
|
+
OSError,
|
|
75
|
+
RuntimeError,
|
|
76
|
+
ConnectionError,
|
|
77
|
+
ValueError,
|
|
78
|
+
TypeError,
|
|
79
|
+
) as exc:
|
|
72
80
|
# If MCP runtime is not available, continue with base tools only.
|
|
73
81
|
logger.warning(
|
|
74
82
|
"[default_tools] Failed to load dynamic MCP tools: %s: %s",
|
|
75
|
-
type(exc).__name__,
|
|
83
|
+
type(exc).__name__,
|
|
84
|
+
exc,
|
|
76
85
|
)
|
|
77
86
|
|
|
78
87
|
task_tool = TaskTool(lambda: base_tools)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Hooks system for Ripperdoc.
|
|
2
|
+
|
|
3
|
+
This module provides a hook system similar to Claude Code CLI's hooks,
|
|
4
|
+
allowing users to execute custom scripts at various points in the workflow.
|
|
5
|
+
|
|
6
|
+
Hook events:
|
|
7
|
+
- PreToolUse: Before a tool is called (can block/allow/ask)
|
|
8
|
+
- PermissionRequest: When user is shown permission dialog (can allow/deny)
|
|
9
|
+
- PostToolUse: After a tool completes
|
|
10
|
+
- UserPromptSubmit: When user submits a prompt (can block)
|
|
11
|
+
- Notification: When a notification is sent
|
|
12
|
+
- Stop: When the agent stops responding (can block to continue)
|
|
13
|
+
- SubagentStop: When a subagent task completes (can block to continue)
|
|
14
|
+
- PreCompact: Before conversation compaction
|
|
15
|
+
- SessionStart: When a session starts or resumes
|
|
16
|
+
- SessionEnd: When a session ends
|
|
17
|
+
|
|
18
|
+
Configuration is stored in:
|
|
19
|
+
- ~/.ripperdoc/hooks.json (global)
|
|
20
|
+
- .ripperdoc/hooks.json (project)
|
|
21
|
+
- .ripperdoc/hooks.local.json (local, git-ignored)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from ripperdoc.core.hooks.events import (
|
|
25
|
+
HookEvent,
|
|
26
|
+
HookDecision,
|
|
27
|
+
HookInput,
|
|
28
|
+
HookOutput,
|
|
29
|
+
PreToolUseInput,
|
|
30
|
+
PermissionRequestInput,
|
|
31
|
+
PostToolUseInput,
|
|
32
|
+
UserPromptSubmitInput,
|
|
33
|
+
NotificationInput,
|
|
34
|
+
StopInput,
|
|
35
|
+
SubagentStopInput,
|
|
36
|
+
PreCompactInput,
|
|
37
|
+
SessionStartInput,
|
|
38
|
+
SessionEndInput,
|
|
39
|
+
PreToolUseHookOutput,
|
|
40
|
+
PermissionRequestHookOutput,
|
|
41
|
+
PermissionRequestDecision,
|
|
42
|
+
PostToolUseHookOutput,
|
|
43
|
+
UserPromptSubmitHookOutput,
|
|
44
|
+
SessionStartHookOutput,
|
|
45
|
+
)
|
|
46
|
+
from ripperdoc.core.hooks.config import (
|
|
47
|
+
HookDefinition,
|
|
48
|
+
HookMatcher,
|
|
49
|
+
HooksConfig,
|
|
50
|
+
load_hooks_config,
|
|
51
|
+
get_merged_hooks_config,
|
|
52
|
+
get_global_hooks_path,
|
|
53
|
+
get_project_hooks_path,
|
|
54
|
+
get_project_local_hooks_path,
|
|
55
|
+
)
|
|
56
|
+
from ripperdoc.core.hooks.executor import HookExecutor, LLMCallback
|
|
57
|
+
from ripperdoc.core.hooks.manager import HookManager, HookResult, hook_manager, init_hook_manager
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
# Events
|
|
61
|
+
"HookEvent",
|
|
62
|
+
"HookDecision",
|
|
63
|
+
"HookInput",
|
|
64
|
+
"HookOutput",
|
|
65
|
+
"PreToolUseInput",
|
|
66
|
+
"PermissionRequestInput",
|
|
67
|
+
"PostToolUseInput",
|
|
68
|
+
"UserPromptSubmitInput",
|
|
69
|
+
"NotificationInput",
|
|
70
|
+
"StopInput",
|
|
71
|
+
"SubagentStopInput",
|
|
72
|
+
"PreCompactInput",
|
|
73
|
+
"SessionStartInput",
|
|
74
|
+
"SessionEndInput",
|
|
75
|
+
# Hook-specific outputs
|
|
76
|
+
"PreToolUseHookOutput",
|
|
77
|
+
"PermissionRequestHookOutput",
|
|
78
|
+
"PermissionRequestDecision",
|
|
79
|
+
"PostToolUseHookOutput",
|
|
80
|
+
"UserPromptSubmitHookOutput",
|
|
81
|
+
"SessionStartHookOutput",
|
|
82
|
+
# Config
|
|
83
|
+
"HookDefinition",
|
|
84
|
+
"HookMatcher",
|
|
85
|
+
"HooksConfig",
|
|
86
|
+
"load_hooks_config",
|
|
87
|
+
"get_merged_hooks_config",
|
|
88
|
+
"get_global_hooks_path",
|
|
89
|
+
"get_project_hooks_path",
|
|
90
|
+
"get_project_local_hooks_path",
|
|
91
|
+
# Executor
|
|
92
|
+
"HookExecutor",
|
|
93
|
+
"LLMCallback",
|
|
94
|
+
# Manager
|
|
95
|
+
"HookManager",
|
|
96
|
+
"HookResult",
|
|
97
|
+
"hook_manager",
|
|
98
|
+
"init_hook_manager",
|
|
99
|
+
]
|