ripperdoc 0.2.4__py3-none-any.whl → 0.2.5__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/__main__.py +0 -5
- ripperdoc/cli/cli.py +37 -16
- ripperdoc/cli/commands/__init__.py +2 -0
- ripperdoc/cli/commands/agents_cmd.py +12 -9
- ripperdoc/cli/commands/compact_cmd.py +7 -3
- ripperdoc/cli/commands/context_cmd.py +33 -13
- ripperdoc/cli/commands/doctor_cmd.py +27 -14
- ripperdoc/cli/commands/exit_cmd.py +1 -1
- ripperdoc/cli/commands/mcp_cmd.py +13 -8
- ripperdoc/cli/commands/memory_cmd.py +5 -5
- ripperdoc/cli/commands/models_cmd.py +47 -16
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +1 -2
- ripperdoc/cli/commands/tasks_cmd.py +24 -13
- ripperdoc/cli/ui/rich_ui.py +500 -406
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/agents.py +17 -9
- ripperdoc/core/config.py +130 -6
- ripperdoc/core/default_tools.py +7 -2
- ripperdoc/core/permissions.py +20 -14
- ripperdoc/core/providers/anthropic.py +107 -4
- ripperdoc/core/providers/base.py +33 -4
- ripperdoc/core/providers/gemini.py +169 -50
- ripperdoc/core/providers/openai.py +257 -23
- ripperdoc/core/query.py +294 -61
- ripperdoc/core/query_utils.py +50 -6
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +13 -7
- ripperdoc/core/tool.py +8 -6
- ripperdoc/sdk/client.py +14 -1
- ripperdoc/tools/ask_user_question_tool.py +20 -22
- ripperdoc/tools/background_shell.py +19 -13
- ripperdoc/tools/bash_tool.py +356 -209
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +5 -2
- ripperdoc/tools/exit_plan_mode_tool.py +6 -3
- ripperdoc/tools/file_edit_tool.py +53 -10
- ripperdoc/tools/file_read_tool.py +17 -7
- ripperdoc/tools/file_write_tool.py +49 -13
- ripperdoc/tools/glob_tool.py +10 -9
- ripperdoc/tools/grep_tool.py +182 -51
- ripperdoc/tools/ls_tool.py +6 -6
- ripperdoc/tools/mcp_tools.py +106 -456
- ripperdoc/tools/multi_edit_tool.py +49 -9
- ripperdoc/tools/notebook_edit_tool.py +57 -13
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +7 -8
- ripperdoc/tools/todo_tool.py +12 -12
- ripperdoc/tools/tool_search_tool.py +5 -6
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/file_watch.py +5 -4
- ripperdoc/utils/json_utils.py +4 -4
- ripperdoc/utils/log.py +3 -3
- ripperdoc/utils/mcp.py +36 -15
- ripperdoc/utils/memory.py +9 -6
- ripperdoc/utils/message_compaction.py +16 -11
- ripperdoc/utils/messages.py +73 -8
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/permissions/__init__.py +7 -1
- ripperdoc/utils/permissions/path_validation_utils.py +5 -3
- ripperdoc/utils/permissions/shell_command_validation.py +496 -18
- ripperdoc/utils/prompt.py +1 -1
- ripperdoc/utils/safe_get_cwd.py +5 -2
- ripperdoc/utils/session_history.py +38 -19
- ripperdoc/utils/todo.py +6 -2
- ripperdoc/utils/token_estimation.py +4 -3
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +12 -1
- ripperdoc-0.2.5.dist-info/RECORD +107 -0
- ripperdoc-0.2.4.dist-info/RECORD +0 -99
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Tool result renderers for CLI display.
|
|
2
|
+
|
|
3
|
+
This module provides a strategy pattern implementation for rendering different
|
|
4
|
+
tool results in the Rich CLI interface.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Callable, List, Optional
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.markup import escape
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ToolResultRenderer:
|
|
14
|
+
"""Base class for rendering tool results to console."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, console: Console, verbose: bool = False):
|
|
17
|
+
self.console = console
|
|
18
|
+
self.verbose = verbose
|
|
19
|
+
|
|
20
|
+
def can_handle(self, _sender: str) -> bool:
|
|
21
|
+
"""Return True if this renderer handles the given tool name."""
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
|
|
24
|
+
def render(self, _content: str, _tool_data: Any) -> None:
|
|
25
|
+
"""Render the tool result to console."""
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
|
|
28
|
+
def _get_field(self, data: Any, key: str, default: Any = None) -> Any:
|
|
29
|
+
"""Safely fetch a field from either an object or a dict."""
|
|
30
|
+
if isinstance(data, dict):
|
|
31
|
+
return data.get(key, default)
|
|
32
|
+
return getattr(data, key, default)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TodoResultRenderer(ToolResultRenderer):
|
|
36
|
+
"""Render Todo tool results."""
|
|
37
|
+
|
|
38
|
+
def can_handle(self, sender: str) -> bool:
|
|
39
|
+
return "Todo" in sender
|
|
40
|
+
|
|
41
|
+
def render(self, content: str, _tool_data: Any) -> None:
|
|
42
|
+
lines = content.splitlines()
|
|
43
|
+
if lines:
|
|
44
|
+
self.console.print(f" ⎿ [dim]{escape(lines[0])}[/]")
|
|
45
|
+
for line in lines[1:]:
|
|
46
|
+
self.console.print(f" {line}", markup=False)
|
|
47
|
+
else:
|
|
48
|
+
self.console.print(" ⎿ [dim]Todo update[/]")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ReadResultRenderer(ToolResultRenderer):
|
|
52
|
+
"""Render Read/View tool results."""
|
|
53
|
+
|
|
54
|
+
def can_handle(self, sender: str) -> bool:
|
|
55
|
+
return "Read" in sender or "View" in sender
|
|
56
|
+
|
|
57
|
+
def render(self, content: str, _tool_data: Any) -> None:
|
|
58
|
+
lines = content.split("\n")
|
|
59
|
+
line_count = len(lines)
|
|
60
|
+
self.console.print(f" ⎿ [dim]Read {line_count} lines[/]")
|
|
61
|
+
if self.verbose:
|
|
62
|
+
preview = lines[:30]
|
|
63
|
+
for line in preview:
|
|
64
|
+
self.console.print(line, markup=False)
|
|
65
|
+
if len(lines) > len(preview):
|
|
66
|
+
self.console.print(f"[dim]... ({len(lines) - len(preview)} more lines)[/]")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class EditResultRenderer(ToolResultRenderer):
|
|
70
|
+
"""Render Write/Edit/MultiEdit tool results."""
|
|
71
|
+
|
|
72
|
+
def can_handle(self, sender: str) -> bool:
|
|
73
|
+
return "Write" in sender or "Edit" in sender or "MultiEdit" in sender
|
|
74
|
+
|
|
75
|
+
def render(self, _content: str, tool_data: Any) -> None:
|
|
76
|
+
if tool_data and (hasattr(tool_data, "file_path") or isinstance(tool_data, dict)):
|
|
77
|
+
file_path = self._get_field(tool_data, "file_path")
|
|
78
|
+
additions = self._get_field(tool_data, "additions", 0)
|
|
79
|
+
deletions = self._get_field(tool_data, "deletions", 0)
|
|
80
|
+
diff_with_line_numbers = self._get_field(tool_data, "diff_with_line_numbers", [])
|
|
81
|
+
|
|
82
|
+
if not file_path:
|
|
83
|
+
self.console.print(" ⎿ [dim]File updated successfully[/]")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
self.console.print(
|
|
87
|
+
f" ⎿ [dim]Updated {escape(str(file_path))} with {additions} additions and {deletions} removals[/]"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if self.verbose:
|
|
91
|
+
for line in diff_with_line_numbers:
|
|
92
|
+
self.console.print(line, markup=False)
|
|
93
|
+
else:
|
|
94
|
+
self.console.print(" ⎿ [dim]File updated successfully[/]")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class GlobResultRenderer(ToolResultRenderer):
|
|
98
|
+
"""Render Glob tool results."""
|
|
99
|
+
|
|
100
|
+
def can_handle(self, sender: str) -> bool:
|
|
101
|
+
return "Glob" in sender
|
|
102
|
+
|
|
103
|
+
def render(self, content: str, _tool_data: Any) -> None:
|
|
104
|
+
files = content.split("\n")
|
|
105
|
+
file_count = len([f for f in files if f.strip()])
|
|
106
|
+
self.console.print(f" ⎿ [dim]Found {file_count} files[/]")
|
|
107
|
+
if self.verbose:
|
|
108
|
+
for line in files[:30]:
|
|
109
|
+
if line.strip():
|
|
110
|
+
self.console.print(f" {line}", markup=False)
|
|
111
|
+
if file_count > 30:
|
|
112
|
+
self.console.print(f"[dim]... ({file_count - 30} more)[/]")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class GrepResultRenderer(ToolResultRenderer):
|
|
116
|
+
"""Render Grep tool results."""
|
|
117
|
+
|
|
118
|
+
def can_handle(self, sender: str) -> bool:
|
|
119
|
+
return "Grep" in sender
|
|
120
|
+
|
|
121
|
+
def render(self, content: str, _tool_data: Any) -> None:
|
|
122
|
+
matches = content.split("\n")
|
|
123
|
+
match_count = len([m for m in matches if m.strip()])
|
|
124
|
+
self.console.print(f" ⎿ [dim]Found {match_count} matches[/]")
|
|
125
|
+
if self.verbose:
|
|
126
|
+
for line in matches[:30]:
|
|
127
|
+
if line.strip():
|
|
128
|
+
self.console.print(f" {line}", markup=False)
|
|
129
|
+
if match_count > 30:
|
|
130
|
+
self.console.print(f"[dim]... ({match_count - 30} more)[/]")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class LSResultRenderer(ToolResultRenderer):
|
|
134
|
+
"""Render LS tool results."""
|
|
135
|
+
|
|
136
|
+
def can_handle(self, sender: str) -> bool:
|
|
137
|
+
return "LS" in sender
|
|
138
|
+
|
|
139
|
+
def render(self, content: str, _tool_data: Any) -> None:
|
|
140
|
+
tree_lines = content.splitlines()
|
|
141
|
+
self.console.print(f" ⎿ [dim]Directory tree ({len(tree_lines)} lines)[/]")
|
|
142
|
+
if self.verbose:
|
|
143
|
+
preview = tree_lines[:40]
|
|
144
|
+
for line in preview:
|
|
145
|
+
self.console.print(f" {line}", markup=False)
|
|
146
|
+
if len(tree_lines) > len(preview):
|
|
147
|
+
self.console.print(f"[dim]... ({len(tree_lines) - len(preview)} more)[/]")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# Type alias for bash output parser callback
|
|
151
|
+
BashOutputParser = Callable[[str], tuple[List[str], List[str]]]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class BashResultRenderer(ToolResultRenderer):
|
|
155
|
+
"""Render Bash tool results."""
|
|
156
|
+
|
|
157
|
+
def __init__(
|
|
158
|
+
self, console: Console, verbose: bool = False, parse_fallback: Optional[BashOutputParser] = None
|
|
159
|
+
):
|
|
160
|
+
super().__init__(console, verbose)
|
|
161
|
+
self._parse_fallback = parse_fallback
|
|
162
|
+
|
|
163
|
+
def can_handle(self, sender: str) -> bool:
|
|
164
|
+
return "Bash" in sender
|
|
165
|
+
|
|
166
|
+
def render(self, content: str, tool_data: Any) -> None:
|
|
167
|
+
stdout_lines: List[str] = []
|
|
168
|
+
stderr_lines: List[str] = []
|
|
169
|
+
exit_code = 0
|
|
170
|
+
duration_ms = 0
|
|
171
|
+
timeout_ms = 0
|
|
172
|
+
|
|
173
|
+
if tool_data:
|
|
174
|
+
exit_code = self._get_field(tool_data, "exit_code", 0)
|
|
175
|
+
stdout = self._get_field(tool_data, "stdout", "") or ""
|
|
176
|
+
stderr = self._get_field(tool_data, "stderr", "") or ""
|
|
177
|
+
duration_ms = self._get_field(tool_data, "duration_ms", 0) or 0
|
|
178
|
+
timeout_ms = self._get_field(tool_data, "timeout_ms", 0) or 0
|
|
179
|
+
stdout_lines = stdout.splitlines() if stdout else []
|
|
180
|
+
stderr_lines = stderr.splitlines() if stderr else []
|
|
181
|
+
|
|
182
|
+
if not stdout_lines and not stderr_lines and content and self._parse_fallback:
|
|
183
|
+
stdout_lines, stderr_lines = self._parse_fallback(content)
|
|
184
|
+
|
|
185
|
+
show_inline_stdout = (
|
|
186
|
+
stdout_lines and not stderr_lines and exit_code == 0 and not self.verbose
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if show_inline_stdout:
|
|
190
|
+
preview = stdout_lines if self.verbose else stdout_lines[:5]
|
|
191
|
+
self.console.print(f" ⎿ {preview[0]}", markup=False)
|
|
192
|
+
for line in preview[1:]:
|
|
193
|
+
self.console.print(f" {line}", markup=False)
|
|
194
|
+
if not self.verbose and len(stdout_lines) > len(preview):
|
|
195
|
+
self.console.print(f"[dim]... ({len(stdout_lines) - len(preview)} more lines)[/]")
|
|
196
|
+
else:
|
|
197
|
+
self._render_detailed_output(
|
|
198
|
+
tool_data, exit_code, duration_ms, timeout_ms, stdout_lines, stderr_lines
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _render_detailed_output(
|
|
202
|
+
self,
|
|
203
|
+
tool_data: Any,
|
|
204
|
+
exit_code: int,
|
|
205
|
+
duration_ms: float,
|
|
206
|
+
timeout_ms: int,
|
|
207
|
+
stdout_lines: List[str],
|
|
208
|
+
stderr_lines: List[str],
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Render detailed Bash output with exit code, stdout, stderr."""
|
|
211
|
+
if tool_data:
|
|
212
|
+
timing = ""
|
|
213
|
+
if duration_ms:
|
|
214
|
+
timing = f" ({duration_ms / 1000:.2f}s"
|
|
215
|
+
if timeout_ms:
|
|
216
|
+
timing += f" / timeout {timeout_ms / 1000:.0f}s"
|
|
217
|
+
timing += ")"
|
|
218
|
+
elif timeout_ms:
|
|
219
|
+
timing = f" (timeout {timeout_ms / 1000:.0f}s)"
|
|
220
|
+
self.console.print(f" ⎿ [dim]Exit code {exit_code}{timing}[/]")
|
|
221
|
+
else:
|
|
222
|
+
self.console.print(" ⎿ [dim]Command executed[/]")
|
|
223
|
+
|
|
224
|
+
# Render stdout
|
|
225
|
+
if stdout_lines:
|
|
226
|
+
preview = stdout_lines if self.verbose else stdout_lines[:5]
|
|
227
|
+
self.console.print("[dim]stdout:[/]")
|
|
228
|
+
for line in preview:
|
|
229
|
+
self.console.print(f" {line}", markup=False)
|
|
230
|
+
if not self.verbose and len(stdout_lines) > len(preview):
|
|
231
|
+
self.console.print(
|
|
232
|
+
f"[dim]... ({len(stdout_lines) - len(preview)} more stdout lines)[/]"
|
|
233
|
+
)
|
|
234
|
+
else:
|
|
235
|
+
self.console.print("[dim]stdout:[/]")
|
|
236
|
+
self.console.print(" [dim](no stdout)[/]")
|
|
237
|
+
|
|
238
|
+
# Render stderr
|
|
239
|
+
if stderr_lines:
|
|
240
|
+
preview = stderr_lines if self.verbose else stderr_lines[:5]
|
|
241
|
+
self.console.print("[dim]stderr:[/]")
|
|
242
|
+
for line in preview:
|
|
243
|
+
self.console.print(f" {line}", markup=False)
|
|
244
|
+
if not self.verbose and len(stderr_lines) > len(preview):
|
|
245
|
+
self.console.print(
|
|
246
|
+
f"[dim]... ({len(stderr_lines) - len(preview)} more stderr lines)[/]"
|
|
247
|
+
)
|
|
248
|
+
else:
|
|
249
|
+
self.console.print("[dim]stderr:[/]")
|
|
250
|
+
self.console.print(" [dim](no stderr)[/]")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class ToolResultRendererRegistry:
|
|
254
|
+
"""Registry that selects the appropriate renderer for a tool result."""
|
|
255
|
+
|
|
256
|
+
def __init__(
|
|
257
|
+
self, console: Console, verbose: bool = False, parse_bash_fallback: Optional[BashOutputParser] = None
|
|
258
|
+
):
|
|
259
|
+
self.console = console
|
|
260
|
+
self.verbose = verbose
|
|
261
|
+
self._renderers: List[ToolResultRenderer] = [
|
|
262
|
+
TodoResultRenderer(console, verbose),
|
|
263
|
+
ReadResultRenderer(console, verbose),
|
|
264
|
+
EditResultRenderer(console, verbose),
|
|
265
|
+
GlobResultRenderer(console, verbose),
|
|
266
|
+
GrepResultRenderer(console, verbose),
|
|
267
|
+
LSResultRenderer(console, verbose),
|
|
268
|
+
BashResultRenderer(console, verbose, parse_bash_fallback),
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
def get_renderer(self, sender: str) -> Optional[ToolResultRenderer]:
|
|
272
|
+
"""Get the appropriate renderer for the given tool name."""
|
|
273
|
+
for renderer in self._renderers:
|
|
274
|
+
if renderer.can_handle(sender):
|
|
275
|
+
return renderer
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
def render(self, sender: str, content: str, tool_data: Any) -> bool:
|
|
279
|
+
"""Render the tool result. Returns True if rendered, False otherwise."""
|
|
280
|
+
renderer = self.get_renderer(sender)
|
|
281
|
+
if renderer:
|
|
282
|
+
renderer.render(content, tool_data)
|
|
283
|
+
return True
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
__all__ = [
|
|
288
|
+
"ToolResultRenderer",
|
|
289
|
+
"ToolResultRendererRegistry",
|
|
290
|
+
"TodoResultRenderer",
|
|
291
|
+
"ReadResultRenderer",
|
|
292
|
+
"EditResultRenderer",
|
|
293
|
+
"GlobResultRenderer",
|
|
294
|
+
"GrepResultRenderer",
|
|
295
|
+
"LSResultRenderer",
|
|
296
|
+
"BashResultRenderer",
|
|
297
|
+
"BashOutputParser",
|
|
298
|
+
]
|
ripperdoc/core/agents.py
CHANGED
|
@@ -41,7 +41,7 @@ def _safe_tool_name(factory: Any, fallback: str) -> str:
|
|
|
41
41
|
try:
|
|
42
42
|
name = getattr(factory(), "name", None)
|
|
43
43
|
return str(name) if name else fallback
|
|
44
|
-
except
|
|
44
|
+
except (TypeError, ValueError, RuntimeError, AttributeError):
|
|
45
45
|
return fallback
|
|
46
46
|
|
|
47
47
|
|
|
@@ -189,9 +189,9 @@ PLAN_AGENT_PROMPT = (
|
|
|
189
189
|
"End your response with:\n\n"
|
|
190
190
|
"### Critical Files for Implementation\n"
|
|
191
191
|
"List 3-5 files most critical for implementing this plan:\n"
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
192
|
+
'- path/to/file1.ts - [Brief reason: e.g., "Core logic to modify"]\n'
|
|
193
|
+
'- path/to/file2.ts - [Brief reason: e.g., "Interfaces to implement"]\n'
|
|
194
|
+
'- path/to/file3.ts - [Brief reason: e.g., "Pattern to follow"]\n\n'
|
|
195
195
|
"REMEMBER: You can ONLY explore and plan. You CANNOT and MUST NOT write, edit, or "
|
|
196
196
|
"modify any files. You do NOT have access to file editing tools."
|
|
197
197
|
)
|
|
@@ -224,7 +224,7 @@ def _built_in_agents() -> List[AgentDefinition]:
|
|
|
224
224
|
AgentDefinition(
|
|
225
225
|
agent_type="explore",
|
|
226
226
|
when_to_use=(
|
|
227
|
-
|
|
227
|
+
"Fast agent specialized for exploring codebases. Use this when you need to quickly find "
|
|
228
228
|
'files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), '
|
|
229
229
|
'or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, '
|
|
230
230
|
'specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, '
|
|
@@ -278,8 +278,12 @@ def _split_frontmatter(raw_text: str) -> Tuple[Dict[str, Any], str]:
|
|
|
278
278
|
body = "\n".join(lines[idx + 1 :])
|
|
279
279
|
try:
|
|
280
280
|
frontmatter = yaml.safe_load(frontmatter_text) or {}
|
|
281
|
-
except
|
|
282
|
-
logger.
|
|
281
|
+
except (yaml.YAMLError, ValueError, TypeError) as exc: # pragma: no cover - defensive
|
|
282
|
+
logger.warning(
|
|
283
|
+
"Invalid frontmatter in agent file: %s: %s",
|
|
284
|
+
type(exc).__name__, exc,
|
|
285
|
+
extra={"error": str(exc)},
|
|
286
|
+
)
|
|
283
287
|
return {"__error__": f"Invalid frontmatter: {exc}"}, body
|
|
284
288
|
return frontmatter, body
|
|
285
289
|
return {}, raw_text
|
|
@@ -305,8 +309,12 @@ def _parse_agent_file(
|
|
|
305
309
|
"""Parse a single agent file."""
|
|
306
310
|
try:
|
|
307
311
|
text = path.read_text(encoding="utf-8")
|
|
308
|
-
except
|
|
309
|
-
logger.
|
|
312
|
+
except (OSError, IOError, UnicodeDecodeError) as exc:
|
|
313
|
+
logger.warning(
|
|
314
|
+
"Failed to read agent file: %s: %s",
|
|
315
|
+
type(exc).__name__, exc,
|
|
316
|
+
extra={"error": str(exc), "path": str(path)},
|
|
317
|
+
)
|
|
310
318
|
return None, f"Failed to read agent file {path}: {exc}"
|
|
311
319
|
|
|
312
320
|
frontmatter, body = _split_frontmatter(text)
|
ripperdoc/core/config.py
CHANGED
|
@@ -110,6 +110,9 @@ class ModelProfile(BaseModel):
|
|
|
110
110
|
# Tool handling for OpenAI-compatible providers. "native" uses tool_calls, "text" flattens tool
|
|
111
111
|
# interactions into plain text to support providers that reject tool roles.
|
|
112
112
|
openai_tool_mode: Literal["native", "text"] = "native"
|
|
113
|
+
# Optional override for thinking protocol handling (e.g., "deepseek", "openrouter",
|
|
114
|
+
# "qwen", "gemini_openai", "openai_reasoning"). When unset, provider heuristics are used.
|
|
115
|
+
thinking_mode: Optional[str] = None
|
|
113
116
|
# Pricing (USD per 1M tokens). Leave as 0 to skip cost calculation.
|
|
114
117
|
input_cost_per_million_tokens: float = 0.0
|
|
115
118
|
output_cost_per_million_tokens: float = 0.0
|
|
@@ -140,6 +143,10 @@ class GlobalConfig(BaseModel):
|
|
|
140
143
|
auto_compact_enabled: bool = True
|
|
141
144
|
context_token_limit: Optional[int] = None
|
|
142
145
|
|
|
146
|
+
# User-level permission rules (applied globally)
|
|
147
|
+
user_allow_rules: list[str] = Field(default_factory=list)
|
|
148
|
+
user_deny_rules: list[str] = Field(default_factory=list)
|
|
149
|
+
|
|
143
150
|
# Onboarding
|
|
144
151
|
has_completed_onboarding: bool = False
|
|
145
152
|
last_onboarding_version: Optional[str] = None
|
|
@@ -151,12 +158,18 @@ class GlobalConfig(BaseModel):
|
|
|
151
158
|
class ProjectConfig(BaseModel):
|
|
152
159
|
"""Project-specific configuration stored in .ripperdoc/config.json"""
|
|
153
160
|
|
|
154
|
-
# Tool permissions
|
|
161
|
+
# Tool permissions (project level - checked into git)
|
|
155
162
|
allowed_tools: list[str] = Field(default_factory=list)
|
|
156
163
|
bash_allow_rules: list[str] = Field(default_factory=list)
|
|
157
164
|
bash_deny_rules: list[str] = Field(default_factory=list)
|
|
158
165
|
working_directories: list[str] = Field(default_factory=list)
|
|
159
166
|
|
|
167
|
+
# Path ignore patterns (gitignore-style)
|
|
168
|
+
ignore_patterns: list[str] = Field(
|
|
169
|
+
default_factory=list,
|
|
170
|
+
description="Gitignore-style patterns for paths to ignore in file operations"
|
|
171
|
+
)
|
|
172
|
+
|
|
160
173
|
# Context
|
|
161
174
|
context: Dict[str, str] = Field(default_factory=dict)
|
|
162
175
|
context_files: list[str] = Field(default_factory=list)
|
|
@@ -177,6 +190,14 @@ class ProjectConfig(BaseModel):
|
|
|
177
190
|
last_session_id: Optional[str] = None
|
|
178
191
|
|
|
179
192
|
|
|
193
|
+
class ProjectLocalConfig(BaseModel):
|
|
194
|
+
"""Project-local configuration stored in .ripperdoc/config.local.json (not checked into git)"""
|
|
195
|
+
|
|
196
|
+
# Local permission rules (project-specific but not shared)
|
|
197
|
+
local_allow_rules: list[str] = Field(default_factory=list)
|
|
198
|
+
local_deny_rules: list[str] = Field(default_factory=list)
|
|
199
|
+
|
|
200
|
+
|
|
180
201
|
class ConfigManager:
|
|
181
202
|
"""Manages global and project-specific configuration."""
|
|
182
203
|
|
|
@@ -185,6 +206,7 @@ class ConfigManager:
|
|
|
185
206
|
self.current_project_path: Optional[Path] = None
|
|
186
207
|
self._global_config: Optional[GlobalConfig] = None
|
|
187
208
|
self._project_config: Optional[ProjectConfig] = None
|
|
209
|
+
self._project_local_config: Optional[ProjectLocalConfig] = None
|
|
188
210
|
|
|
189
211
|
def get_global_config(self) -> GlobalConfig:
|
|
190
212
|
"""Load and return global configuration."""
|
|
@@ -200,8 +222,12 @@ class ConfigManager:
|
|
|
200
222
|
"profile_count": len(self._global_config.model_profiles),
|
|
201
223
|
},
|
|
202
224
|
)
|
|
203
|
-
except
|
|
204
|
-
logger.
|
|
225
|
+
except (json.JSONDecodeError, OSError, IOError, UnicodeDecodeError, ValueError, TypeError) as e:
|
|
226
|
+
logger.warning(
|
|
227
|
+
"Error loading global config: %s: %s",
|
|
228
|
+
type(e).__name__, e,
|
|
229
|
+
extra={"error": str(e)},
|
|
230
|
+
)
|
|
205
231
|
self._global_config = GlobalConfig()
|
|
206
232
|
else:
|
|
207
233
|
self._global_config = GlobalConfig()
|
|
@@ -250,9 +276,10 @@ class ConfigManager:
|
|
|
250
276
|
"allowed_tools": len(self._project_config.allowed_tools),
|
|
251
277
|
},
|
|
252
278
|
)
|
|
253
|
-
except
|
|
254
|
-
logger.
|
|
255
|
-
"Error loading project config",
|
|
279
|
+
except (json.JSONDecodeError, OSError, IOError, UnicodeDecodeError, ValueError, TypeError) as e:
|
|
280
|
+
logger.warning(
|
|
281
|
+
"Error loading project config: %s: %s",
|
|
282
|
+
type(e).__name__, e,
|
|
256
283
|
extra={"error": str(e), "path": str(config_path)},
|
|
257
284
|
)
|
|
258
285
|
self._project_config = ProjectConfig()
|
|
@@ -293,6 +320,91 @@ class ConfigManager:
|
|
|
293
320
|
},
|
|
294
321
|
)
|
|
295
322
|
|
|
323
|
+
def get_project_local_config(self, project_path: Optional[Path] = None) -> ProjectLocalConfig:
|
|
324
|
+
"""Load and return project-local configuration (not checked into git)."""
|
|
325
|
+
if project_path is not None:
|
|
326
|
+
if self.current_project_path != project_path:
|
|
327
|
+
self._project_local_config = None
|
|
328
|
+
self.current_project_path = project_path
|
|
329
|
+
|
|
330
|
+
if self.current_project_path is None:
|
|
331
|
+
return ProjectLocalConfig()
|
|
332
|
+
|
|
333
|
+
config_path = self.current_project_path / ".ripperdoc" / "config.local.json"
|
|
334
|
+
|
|
335
|
+
if self._project_local_config is None:
|
|
336
|
+
if config_path.exists():
|
|
337
|
+
try:
|
|
338
|
+
data = json.loads(config_path.read_text())
|
|
339
|
+
self._project_local_config = ProjectLocalConfig(**data)
|
|
340
|
+
logger.debug(
|
|
341
|
+
"[config] Loaded project-local config",
|
|
342
|
+
extra={
|
|
343
|
+
"path": str(config_path),
|
|
344
|
+
"project_path": str(self.current_project_path),
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
except (json.JSONDecodeError, OSError, IOError, UnicodeDecodeError, ValueError, TypeError) as e:
|
|
348
|
+
logger.warning(
|
|
349
|
+
"Error loading project-local config: %s: %s",
|
|
350
|
+
type(e).__name__, e,
|
|
351
|
+
extra={"error": str(e), "path": str(config_path)},
|
|
352
|
+
)
|
|
353
|
+
self._project_local_config = ProjectLocalConfig()
|
|
354
|
+
else:
|
|
355
|
+
self._project_local_config = ProjectLocalConfig()
|
|
356
|
+
|
|
357
|
+
return self._project_local_config
|
|
358
|
+
|
|
359
|
+
def save_project_local_config(
|
|
360
|
+
self, config: ProjectLocalConfig, project_path: Optional[Path] = None
|
|
361
|
+
) -> None:
|
|
362
|
+
"""Save project-local configuration."""
|
|
363
|
+
if project_path is not None:
|
|
364
|
+
self.current_project_path = project_path
|
|
365
|
+
|
|
366
|
+
if self.current_project_path is None:
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
config_dir = self.current_project_path / ".ripperdoc"
|
|
370
|
+
config_dir.mkdir(exist_ok=True)
|
|
371
|
+
|
|
372
|
+
config_path = config_dir / "config.local.json"
|
|
373
|
+
self._project_local_config = config
|
|
374
|
+
config_path.write_text(config.model_dump_json(indent=2))
|
|
375
|
+
|
|
376
|
+
# Ensure config.local.json is in .gitignore
|
|
377
|
+
self._ensure_gitignore_entry("config.local.json")
|
|
378
|
+
|
|
379
|
+
logger.debug(
|
|
380
|
+
"[config] Saved project-local config",
|
|
381
|
+
extra={
|
|
382
|
+
"path": str(config_path),
|
|
383
|
+
"project_path": str(self.current_project_path),
|
|
384
|
+
},
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def _ensure_gitignore_entry(self, entry: str) -> bool:
|
|
388
|
+
"""Ensure an entry exists in .ripperdoc/.gitignore. Returns True if added."""
|
|
389
|
+
if self.current_project_path is None:
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
gitignore_path = self.current_project_path / ".ripperdoc" / ".gitignore"
|
|
393
|
+
try:
|
|
394
|
+
text = ""
|
|
395
|
+
if gitignore_path.exists():
|
|
396
|
+
text = gitignore_path.read_text(encoding="utf-8", errors="ignore")
|
|
397
|
+
existing_lines = text.splitlines()
|
|
398
|
+
if entry in existing_lines:
|
|
399
|
+
return False
|
|
400
|
+
with gitignore_path.open("a", encoding="utf-8") as f:
|
|
401
|
+
if text and not text.endswith("\n"):
|
|
402
|
+
f.write("\n")
|
|
403
|
+
f.write(f"{entry}\n")
|
|
404
|
+
return True
|
|
405
|
+
except (OSError, IOError):
|
|
406
|
+
return False
|
|
407
|
+
|
|
296
408
|
def get_api_key(self, provider: ProviderType) -> Optional[str]:
|
|
297
409
|
"""Get API key for a provider."""
|
|
298
410
|
# First check environment variables
|
|
@@ -433,3 +545,15 @@ def set_model_pointer(pointer: str, profile_name: str) -> GlobalConfig:
|
|
|
433
545
|
def get_current_model_profile(pointer: str = "main") -> Optional[ModelProfile]:
|
|
434
546
|
"""Convenience wrapper to fetch the active profile for a pointer."""
|
|
435
547
|
return config_manager.get_current_model_profile(pointer)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def get_project_local_config(project_path: Optional[Path] = None) -> ProjectLocalConfig:
|
|
551
|
+
"""Get project-local configuration (not checked into git)."""
|
|
552
|
+
return config_manager.get_project_local_config(project_path)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def save_project_local_config(
|
|
556
|
+
config: ProjectLocalConfig, project_path: Optional[Path] = None
|
|
557
|
+
) -> None:
|
|
558
|
+
"""Save project-local configuration."""
|
|
559
|
+
config_manager.save_project_local_config(config, project_path)
|
ripperdoc/core/default_tools.py
CHANGED
|
@@ -17,6 +17,7 @@ from ripperdoc.tools.file_write_tool import FileWriteTool
|
|
|
17
17
|
from ripperdoc.tools.glob_tool import GlobTool
|
|
18
18
|
from ripperdoc.tools.ls_tool import LSTool
|
|
19
19
|
from ripperdoc.tools.grep_tool import GrepTool
|
|
20
|
+
from ripperdoc.tools.skill_tool import SkillTool
|
|
20
21
|
from ripperdoc.tools.todo_tool import TodoReadTool, TodoWriteTool
|
|
21
22
|
from ripperdoc.tools.ask_user_question_tool import AskUserQuestionTool
|
|
22
23
|
from ripperdoc.tools.enter_plan_mode_tool import EnterPlanModeTool
|
|
@@ -48,6 +49,7 @@ def get_default_tools() -> List[Tool[Any, Any]]:
|
|
|
48
49
|
GlobTool(),
|
|
49
50
|
LSTool(),
|
|
50
51
|
GrepTool(),
|
|
52
|
+
SkillTool(),
|
|
51
53
|
TodoReadTool(),
|
|
52
54
|
TodoWriteTool(),
|
|
53
55
|
AskUserQuestionTool(),
|
|
@@ -66,9 +68,12 @@ def get_default_tools() -> List[Tool[Any, Any]]:
|
|
|
66
68
|
if isinstance(tool, Tool):
|
|
67
69
|
base_tools.append(tool)
|
|
68
70
|
dynamic_tools.append(tool)
|
|
69
|
-
except
|
|
71
|
+
except (ImportError, ModuleNotFoundError, OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc:
|
|
70
72
|
# If MCP runtime is not available, continue with base tools only.
|
|
71
|
-
logger.
|
|
73
|
+
logger.warning(
|
|
74
|
+
"[default_tools] Failed to load dynamic MCP tools: %s: %s",
|
|
75
|
+
type(exc).__name__, exc,
|
|
76
|
+
)
|
|
72
77
|
|
|
73
78
|
task_tool = TaskTool(lambda: base_tools)
|
|
74
79
|
all_tools = base_tools + [task_tool]
|
ripperdoc/core/permissions.py
CHANGED
|
@@ -48,19 +48,19 @@ def permission_key(tool: Tool[Any, Any], parsed_input: Any) -> str:
|
|
|
48
48
|
if hasattr(parsed_input, "file_path"):
|
|
49
49
|
try:
|
|
50
50
|
return f"{tool.name}::path::{Path(getattr(parsed_input, 'file_path')).resolve()}"
|
|
51
|
-
except
|
|
52
|
-
logger.
|
|
51
|
+
except (OSError, RuntimeError) as exc:
|
|
52
|
+
logger.warning(
|
|
53
53
|
"[permissions] Failed to resolve file_path for permission key",
|
|
54
|
-
extra={"tool": getattr(tool, "name", None)},
|
|
54
|
+
extra={"tool": getattr(tool, "name", None), "error": str(exc)},
|
|
55
55
|
)
|
|
56
56
|
return f"{tool.name}::path::{getattr(parsed_input, 'file_path')}"
|
|
57
57
|
if hasattr(parsed_input, "path"):
|
|
58
58
|
try:
|
|
59
59
|
return f"{tool.name}::path::{Path(getattr(parsed_input, 'path')).resolve()}"
|
|
60
|
-
except
|
|
61
|
-
logger.
|
|
60
|
+
except (OSError, RuntimeError) as exc:
|
|
61
|
+
logger.warning(
|
|
62
62
|
"[permissions] Failed to resolve path for permission key",
|
|
63
|
-
extra={"tool": getattr(tool, "name", None)},
|
|
63
|
+
extra={"tool": getattr(tool, "name", None), "error": str(exc)},
|
|
64
64
|
)
|
|
65
65
|
return f"{tool.name}::path::{getattr(parsed_input, 'path')}"
|
|
66
66
|
return tool.name
|
|
@@ -126,14 +126,15 @@ def make_permission_checker(
|
|
|
126
126
|
try:
|
|
127
127
|
if hasattr(tool, "needs_permissions") and not tool.needs_permissions(parsed_input):
|
|
128
128
|
return PermissionResult(result=True)
|
|
129
|
-
except
|
|
130
|
-
|
|
129
|
+
except (TypeError, AttributeError, ValueError) as exc:
|
|
130
|
+
# Tool implementation error - log and deny for safety
|
|
131
|
+
logger.warning(
|
|
131
132
|
"[permissions] Tool needs_permissions check failed",
|
|
132
|
-
extra={"tool": getattr(tool, "name", None)},
|
|
133
|
+
extra={"tool": getattr(tool, "name", None), "error": str(exc), "error_type": type(exc).__name__},
|
|
133
134
|
)
|
|
134
135
|
return PermissionResult(
|
|
135
136
|
result=False,
|
|
136
|
-
message="Permission check failed
|
|
137
|
+
message=f"Permission check failed: {type(exc).__name__}: {exc}",
|
|
137
138
|
)
|
|
138
139
|
|
|
139
140
|
allowed_tools = set(config.allowed_tools or [])
|
|
@@ -167,14 +168,15 @@ def make_permission_checker(
|
|
|
167
168
|
# Allow tools to return a plain dict shaped like PermissionDecision.
|
|
168
169
|
if isinstance(decision, dict) and "behavior" in decision:
|
|
169
170
|
decision = PermissionDecision(**decision)
|
|
170
|
-
except
|
|
171
|
-
|
|
171
|
+
except (TypeError, AttributeError, ValueError, KeyError) as exc:
|
|
172
|
+
# Tool implementation error - fall back to asking user
|
|
173
|
+
logger.warning(
|
|
172
174
|
"[permissions] Tool check_permissions failed",
|
|
173
|
-
extra={"tool": getattr(tool, "name", None)},
|
|
175
|
+
extra={"tool": getattr(tool, "name", None), "error": str(exc), "error_type": type(exc).__name__},
|
|
174
176
|
)
|
|
175
177
|
decision = PermissionDecision(
|
|
176
178
|
behavior="ask",
|
|
177
|
-
message="Error checking permissions
|
|
179
|
+
message=f"Error checking permissions: {type(exc).__name__}",
|
|
178
180
|
rule_suggestions=None,
|
|
179
181
|
)
|
|
180
182
|
|
|
@@ -219,6 +221,10 @@ def make_permission_checker(
|
|
|
219
221
|
]
|
|
220
222
|
|
|
221
223
|
answer = (await _prompt_user(prompt, options=options)).strip().lower()
|
|
224
|
+
logger.debug(
|
|
225
|
+
"[permissions] User answer for permission prompt",
|
|
226
|
+
extra={"answer": answer, "tool": getattr(tool, "name", None)},
|
|
227
|
+
)
|
|
222
228
|
rule_suggestions = _rule_strings(decision.rule_suggestions) or [
|
|
223
229
|
permission_key(tool, parsed_input)
|
|
224
230
|
]
|