ripperdoc 0.1.0__py3-none-any.whl → 0.2.2__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 +75 -15
- ripperdoc/cli/commands/__init__.py +4 -0
- ripperdoc/cli/commands/agents_cmd.py +23 -1
- ripperdoc/cli/commands/context_cmd.py +13 -3
- ripperdoc/cli/commands/cost_cmd.py +1 -1
- ripperdoc/cli/commands/doctor_cmd.py +200 -0
- ripperdoc/cli/commands/memory_cmd.py +209 -0
- ripperdoc/cli/commands/models_cmd.py +25 -0
- ripperdoc/cli/commands/resume_cmd.py +3 -3
- ripperdoc/cli/commands/status_cmd.py +5 -5
- ripperdoc/cli/commands/tasks_cmd.py +32 -5
- ripperdoc/cli/ui/context_display.py +4 -3
- ripperdoc/cli/ui/rich_ui.py +205 -43
- ripperdoc/cli/ui/spinner.py +3 -4
- ripperdoc/core/agents.py +10 -6
- ripperdoc/core/config.py +48 -3
- ripperdoc/core/default_tools.py +26 -6
- ripperdoc/core/permissions.py +19 -0
- ripperdoc/core/query.py +238 -302
- ripperdoc/core/query_utils.py +537 -0
- ripperdoc/core/system_prompt.py +2 -1
- ripperdoc/core/tool.py +14 -1
- ripperdoc/sdk/client.py +1 -1
- ripperdoc/tools/background_shell.py +9 -3
- ripperdoc/tools/bash_tool.py +19 -4
- ripperdoc/tools/file_edit_tool.py +9 -2
- ripperdoc/tools/file_read_tool.py +9 -2
- ripperdoc/tools/file_write_tool.py +15 -2
- ripperdoc/tools/glob_tool.py +57 -17
- ripperdoc/tools/grep_tool.py +9 -2
- ripperdoc/tools/ls_tool.py +244 -75
- ripperdoc/tools/mcp_tools.py +47 -19
- ripperdoc/tools/multi_edit_tool.py +13 -2
- ripperdoc/tools/notebook_edit_tool.py +9 -6
- ripperdoc/tools/task_tool.py +20 -5
- ripperdoc/tools/todo_tool.py +163 -29
- ripperdoc/tools/tool_search_tool.py +15 -4
- ripperdoc/utils/git_utils.py +276 -0
- ripperdoc/utils/json_utils.py +28 -0
- ripperdoc/utils/log.py +130 -29
- ripperdoc/utils/mcp.py +83 -10
- ripperdoc/utils/memory.py +14 -1
- ripperdoc/utils/message_compaction.py +51 -14
- ripperdoc/utils/messages.py +63 -4
- ripperdoc/utils/output_utils.py +36 -9
- ripperdoc/utils/permissions/path_validation_utils.py +6 -0
- ripperdoc/utils/safe_get_cwd.py +4 -0
- ripperdoc/utils/session_history.py +27 -9
- ripperdoc/utils/todo.py +2 -2
- {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/METADATA +4 -2
- ripperdoc-0.2.2.dist-info/RECORD +86 -0
- ripperdoc-0.1.0.dist-info/RECORD +0 -81
- {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/WHEEL +0 -0
- {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/top_level.txt +0 -0
ripperdoc/tools/ls_tool.py
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
Provides a safe way to inspect directory trees without executing shell commands.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import os
|
|
6
7
|
import fnmatch
|
|
7
8
|
from collections import deque
|
|
8
9
|
from pathlib import Path
|
|
9
|
-
from typing import AsyncGenerator, List, Optional
|
|
10
|
+
from typing import AsyncGenerator, List, Optional, Dict, Any
|
|
10
11
|
from pydantic import BaseModel, Field
|
|
11
12
|
|
|
12
13
|
from ripperdoc.core.tool import (
|
|
@@ -17,10 +18,22 @@ from ripperdoc.core.tool import (
|
|
|
17
18
|
ToolUseExample,
|
|
18
19
|
ValidationResult,
|
|
19
20
|
)
|
|
21
|
+
from ripperdoc.utils.safe_get_cwd import safe_get_cwd
|
|
22
|
+
from ripperdoc.utils.git_utils import (
|
|
23
|
+
build_ignore_patterns_map,
|
|
24
|
+
should_ignore_path,
|
|
25
|
+
is_git_repository,
|
|
26
|
+
get_git_root,
|
|
27
|
+
get_current_git_branch,
|
|
28
|
+
get_git_commit_hash,
|
|
29
|
+
is_working_directory_clean,
|
|
30
|
+
get_git_status_files,
|
|
31
|
+
)
|
|
20
32
|
|
|
21
33
|
|
|
22
34
|
IGNORED_DIRECTORIES = {
|
|
23
35
|
"node_modules",
|
|
36
|
+
"vendor/bundle",
|
|
24
37
|
"vendor",
|
|
25
38
|
"venv",
|
|
26
39
|
"env",
|
|
@@ -34,18 +47,21 @@ IGNORED_DIRECTORIES = {
|
|
|
34
47
|
"bin",
|
|
35
48
|
"obj",
|
|
36
49
|
".build",
|
|
50
|
+
"target",
|
|
51
|
+
".dart_tool",
|
|
52
|
+
".pub-cache",
|
|
53
|
+
"build",
|
|
54
|
+
"target",
|
|
37
55
|
"_build",
|
|
38
56
|
"deps",
|
|
39
57
|
"dist",
|
|
40
58
|
"dist-newstyle",
|
|
41
59
|
".deno",
|
|
42
60
|
"bower_components",
|
|
43
|
-
"vendor/bundle",
|
|
44
|
-
".dart_tool",
|
|
45
|
-
".pub-cache",
|
|
46
61
|
}
|
|
47
62
|
|
|
48
63
|
MAX_CHARS_THRESHOLD = 40000
|
|
64
|
+
MAX_DEPTH = 4
|
|
49
65
|
LARGE_REPO_WARNING = (
|
|
50
66
|
f"There are more than {MAX_CHARS_THRESHOLD} characters in the repository "
|
|
51
67
|
"(ie. either there are lots of files, or there are many long filenames). "
|
|
@@ -88,8 +104,23 @@ class LSToolOutput(BaseModel):
|
|
|
88
104
|
entries: list[str]
|
|
89
105
|
tree: str
|
|
90
106
|
truncated: bool = False
|
|
107
|
+
aborted: bool = False
|
|
91
108
|
ignored: list[str] = Field(default_factory=list)
|
|
92
109
|
warning: Optional[str] = None
|
|
110
|
+
git_info: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
|
111
|
+
file_count: int = 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _resolve_directory_path(raw_path: str) -> Path:
|
|
115
|
+
"""Resolve a user-provided path against the current working directory."""
|
|
116
|
+
base_path = Path(safe_get_cwd())
|
|
117
|
+
candidate = Path(raw_path).expanduser()
|
|
118
|
+
if not candidate.is_absolute():
|
|
119
|
+
candidate = base_path / candidate
|
|
120
|
+
try:
|
|
121
|
+
return candidate.resolve()
|
|
122
|
+
except Exception:
|
|
123
|
+
return candidate
|
|
93
124
|
|
|
94
125
|
|
|
95
126
|
def _matches_ignore(path: Path, root_path: Path, patterns: list[str]) -> bool:
|
|
@@ -107,100 +138,156 @@ def _matches_ignore(path: Path, root_path: Path, patterns: list[str]) -> bool:
|
|
|
107
138
|
)
|
|
108
139
|
|
|
109
140
|
|
|
110
|
-
def _should_skip(
|
|
141
|
+
def _should_skip(
|
|
142
|
+
path: Path,
|
|
143
|
+
root_path: Path,
|
|
144
|
+
patterns: list[str],
|
|
145
|
+
ignore_map: Optional[Dict[Optional[Path], List[str]]] = None
|
|
146
|
+
) -> bool:
|
|
111
147
|
name = path.name
|
|
112
148
|
if name.startswith("."):
|
|
113
149
|
return True
|
|
114
150
|
if "__pycache__" in path.parts:
|
|
115
151
|
return True
|
|
116
|
-
|
|
152
|
+
|
|
153
|
+
# Check against ignore patterns
|
|
154
|
+
if ignore_map and should_ignore_path(path, root_path, ignore_map):
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
# Also check against direct patterns for backward compatibility
|
|
158
|
+
if patterns and _matches_ignore(path, root_path, patterns):
|
|
117
159
|
return True
|
|
160
|
+
|
|
118
161
|
return False
|
|
119
162
|
|
|
120
163
|
|
|
121
|
-
def
|
|
122
|
-
|
|
164
|
+
def _relative_path_for_display(path: Path, base_path: Path) -> str:
|
|
165
|
+
"""Convert a path to a display-friendly path relative to base_path."""
|
|
166
|
+
resolved_path = path
|
|
167
|
+
try:
|
|
168
|
+
resolved_path = path.resolve()
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
rel_path = resolved_path.relative_to(base_path.resolve()).as_posix()
|
|
174
|
+
except Exception:
|
|
175
|
+
try:
|
|
176
|
+
rel_path = os.path.relpath(resolved_path, base_path)
|
|
177
|
+
except Exception:
|
|
178
|
+
rel_path = resolved_path.as_posix()
|
|
179
|
+
rel_path = rel_path.replace(os.sep, "/")
|
|
180
|
+
|
|
181
|
+
rel_path = rel_path.rstrip("/")
|
|
123
182
|
return f"{rel_path}/" if path.is_dir() else rel_path
|
|
124
183
|
|
|
125
184
|
|
|
126
|
-
def _collect_paths(
|
|
185
|
+
def _collect_paths(
|
|
186
|
+
root_path: Path,
|
|
187
|
+
base_path: Path,
|
|
188
|
+
ignore_patterns: list[str],
|
|
189
|
+
include_gitignore: bool = True,
|
|
190
|
+
abort_signal: Optional[Any] = None,
|
|
191
|
+
max_depth: Optional[int] = MAX_DEPTH,
|
|
192
|
+
) -> tuple[list[str], bool, List[str], bool]:
|
|
193
|
+
"""Collect paths under root_path relative to base_path with early-exit controls."""
|
|
127
194
|
entries: list[str] = []
|
|
128
195
|
total_chars = 0
|
|
129
196
|
truncated = False
|
|
130
|
-
|
|
197
|
+
aborted = False
|
|
198
|
+
ignored_entries: List[str] = []
|
|
199
|
+
ignore_map = build_ignore_patterns_map(
|
|
200
|
+
root_path,
|
|
201
|
+
user_ignore_patterns=ignore_patterns,
|
|
202
|
+
include_gitignore=include_gitignore,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
queue = deque([(root_path, 0)]) # (path, depth)
|
|
131
206
|
|
|
132
207
|
while queue and not truncated:
|
|
133
|
-
|
|
208
|
+
if abort_signal is not None and getattr(abort_signal, "is_set", lambda: False)():
|
|
209
|
+
aborted = True
|
|
210
|
+
break
|
|
211
|
+
|
|
212
|
+
current, depth = queue.popleft()
|
|
213
|
+
|
|
214
|
+
if max_depth is not None and depth > max_depth:
|
|
215
|
+
continue
|
|
134
216
|
|
|
135
217
|
try:
|
|
136
|
-
|
|
137
|
-
|
|
218
|
+
with os.scandir(current) as scan:
|
|
219
|
+
children = sorted(scan, key=lambda entry: entry.name.lower())
|
|
220
|
+
except (FileNotFoundError, PermissionError, NotADirectoryError, OSError):
|
|
138
221
|
continue
|
|
139
222
|
|
|
140
223
|
for child in children:
|
|
224
|
+
child_path = Path(current) / child.name
|
|
141
225
|
try:
|
|
142
|
-
is_dir = child.is_dir()
|
|
226
|
+
is_dir = child.is_dir(follow_symlinks=False)
|
|
143
227
|
except OSError:
|
|
144
228
|
continue
|
|
145
229
|
|
|
146
|
-
if _should_skip(
|
|
230
|
+
if _should_skip(child_path, root_path, ignore_patterns, ignore_map):
|
|
231
|
+
ignored_entries.append(_relative_path_for_display(child_path, base_path))
|
|
147
232
|
continue
|
|
148
233
|
|
|
149
|
-
display =
|
|
234
|
+
display = _relative_path_for_display(child_path, base_path)
|
|
150
235
|
entries.append(display)
|
|
151
236
|
total_chars += len(display)
|
|
152
237
|
|
|
153
|
-
if total_chars
|
|
238
|
+
if total_chars >= MAX_CHARS_THRESHOLD:
|
|
154
239
|
truncated = True
|
|
155
240
|
break
|
|
156
241
|
|
|
157
242
|
if is_dir:
|
|
158
|
-
if _is_ignored_directory(
|
|
243
|
+
if _is_ignored_directory(child_path, root_path):
|
|
159
244
|
continue
|
|
160
245
|
if child.is_symlink():
|
|
161
246
|
continue
|
|
162
|
-
queue.append(
|
|
163
|
-
|
|
164
|
-
return entries, truncated
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def _add_to_tree(tree: dict, parts: list[str], is_dir: bool) -> None:
|
|
168
|
-
node = tree
|
|
169
|
-
for idx, part in enumerate(parts):
|
|
170
|
-
node = node.setdefault(part, {"children": {}, "is_dir": False})
|
|
171
|
-
if idx == len(parts) - 1:
|
|
172
|
-
node["is_dir"] = is_dir
|
|
173
|
-
node = node["children"]
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def _render_tree(tree: dict, indent: str = " ", current_indent: str = " ") -> str:
|
|
177
|
-
lines: list[str] = []
|
|
178
|
-
for name in sorted(tree):
|
|
179
|
-
node = tree[name]
|
|
180
|
-
suffix = "/" if node.get("is_dir") else ""
|
|
181
|
-
lines.append(f"{current_indent}- {name}{suffix}")
|
|
182
|
-
children = node.get("children") or {}
|
|
183
|
-
if children:
|
|
184
|
-
lines.append(_render_tree(children, indent, current_indent + indent))
|
|
185
|
-
return "\n".join(lines)
|
|
186
|
-
|
|
247
|
+
queue.append((child_path, depth + 1))
|
|
187
248
|
|
|
188
|
-
|
|
189
|
-
root_line = f"- {root_path.resolve().as_posix()}/"
|
|
249
|
+
return entries, truncated, ignored_entries, aborted
|
|
190
250
|
|
|
191
|
-
if not entries:
|
|
192
|
-
return f"{root_line}\n (empty directory)"
|
|
193
251
|
|
|
252
|
+
def build_file_tree(entries: list[str]) -> dict:
|
|
253
|
+
"""Build a nested tree structure from flat entry paths."""
|
|
194
254
|
tree: dict = {}
|
|
195
255
|
for entry in entries:
|
|
196
|
-
normalized = entry
|
|
256
|
+
normalized = entry.rstrip("/")
|
|
197
257
|
if not normalized:
|
|
198
258
|
continue
|
|
199
|
-
parts = normalized.split("/")
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
259
|
+
parts = [part for part in normalized.split("/") if part]
|
|
260
|
+
node = tree
|
|
261
|
+
for idx, part in enumerate(parts):
|
|
262
|
+
node = node.setdefault(part, {"children": {}, "is_dir": False})
|
|
263
|
+
if idx == len(parts) - 1:
|
|
264
|
+
node["is_dir"] = node.get("is_dir", False) or entry.endswith("/")
|
|
265
|
+
else:
|
|
266
|
+
node["is_dir"] = True
|
|
267
|
+
node = node["children"]
|
|
268
|
+
return tree
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def build_tree_string(tree: dict, root_label: str, indent: str = " ") -> str:
|
|
272
|
+
"""Render a file tree into a readable string."""
|
|
273
|
+
root_line = f"- {root_label.rstrip('/')}/"
|
|
274
|
+
|
|
275
|
+
if not tree:
|
|
276
|
+
return f"{root_line}\n{indent}(empty directory)"
|
|
277
|
+
|
|
278
|
+
lines: list[str] = [root_line]
|
|
279
|
+
|
|
280
|
+
def _render(node: dict, current_indent: str) -> None:
|
|
281
|
+
for name in sorted(node):
|
|
282
|
+
child = node[name]
|
|
283
|
+
suffix = "/" if child.get("is_dir") else ""
|
|
284
|
+
lines.append(f"{current_indent}- {name}{suffix}")
|
|
285
|
+
children = child.get("children") or {}
|
|
286
|
+
if children:
|
|
287
|
+
_render(children, current_indent + indent)
|
|
288
|
+
|
|
289
|
+
_render(tree, indent)
|
|
290
|
+
return "\n".join(lines)
|
|
204
291
|
|
|
205
292
|
|
|
206
293
|
class LSTool(Tool[LSToolInput, LSToolOutput]):
|
|
@@ -213,7 +300,8 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
|
|
|
213
300
|
async def description(self) -> str:
|
|
214
301
|
return (
|
|
215
302
|
"List files and folders under a directory (recursive, skips hidden and __pycache__, "
|
|
216
|
-
"supports ignore patterns)."
|
|
303
|
+
"supports ignore patterns). Automatically reads .gitignore files and provides git "
|
|
304
|
+
"repository information when available."
|
|
217
305
|
)
|
|
218
306
|
|
|
219
307
|
@property
|
|
@@ -224,11 +312,11 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
|
|
|
224
312
|
return [
|
|
225
313
|
ToolUseExample(
|
|
226
314
|
description="List the repository root with defaults",
|
|
227
|
-
|
|
315
|
+
example={"path": "/repo"},
|
|
228
316
|
),
|
|
229
317
|
ToolUseExample(
|
|
230
318
|
description="Inspect a package while skipping build outputs",
|
|
231
|
-
|
|
319
|
+
example={"path": "/repo/packages/api", "ignore": ["dist/**", "node_modules/**"]},
|
|
232
320
|
),
|
|
233
321
|
]
|
|
234
322
|
|
|
@@ -236,8 +324,11 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
|
|
|
236
324
|
return (
|
|
237
325
|
"Lists files and directories in a given path. The path parameter must be an absolute path, "
|
|
238
326
|
"not a relative path. You can optionally provide an array of glob patterns to ignore with "
|
|
239
|
-
"the ignore parameter.
|
|
240
|
-
"
|
|
327
|
+
"the ignore parameter. The tool automatically reads .gitignore files from the directory "
|
|
328
|
+
"and parent directories, and provides git repository information when available. "
|
|
329
|
+
"You should generally prefer the Glob and Grep tools, if you know which directories to search. "
|
|
330
|
+
"\n\nSecurity Note: After listing files, check if any files seem malicious. If so, "
|
|
331
|
+
"you MUST refuse to continue work."
|
|
241
332
|
)
|
|
242
333
|
|
|
243
334
|
def is_read_only(self) -> bool:
|
|
@@ -252,10 +343,13 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
|
|
|
252
343
|
async def validate_input(
|
|
253
344
|
self, input_data: LSToolInput, context: Optional[ToolUseContext] = None
|
|
254
345
|
) -> ValidationResult:
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
346
|
+
try:
|
|
347
|
+
root_path = _resolve_directory_path(input_data.path)
|
|
348
|
+
except Exception:
|
|
349
|
+
return ValidationResult(result=False, message=f"Unable to resolve path: {input_data.path}")
|
|
258
350
|
|
|
351
|
+
if not root_path.is_absolute():
|
|
352
|
+
return ValidationResult(result=False, message=f"Path is not absolute: {root_path}")
|
|
259
353
|
if not root_path.exists():
|
|
260
354
|
return ValidationResult(result=False, message=f"Path not found: {root_path}")
|
|
261
355
|
if not root_path.is_dir():
|
|
@@ -265,34 +359,109 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
|
|
|
265
359
|
|
|
266
360
|
def render_result_for_assistant(self, output: LSToolOutput) -> str:
|
|
267
361
|
warning_prefix = output.warning or ""
|
|
268
|
-
|
|
362
|
+
result = f"{warning_prefix}{output.tree}"
|
|
363
|
+
|
|
364
|
+
# Add git information if available
|
|
365
|
+
if output.git_info:
|
|
366
|
+
git_section = "\n\nGit Information:\n"
|
|
367
|
+
for key, value in output.git_info.items():
|
|
368
|
+
if value:
|
|
369
|
+
git_section += f" {key}: {value}\n"
|
|
370
|
+
result += git_section
|
|
371
|
+
|
|
372
|
+
status_parts = [f"Listed {output.file_count} paths"]
|
|
373
|
+
if output.truncated:
|
|
374
|
+
status_parts.append(f"truncated at {MAX_CHARS_THRESHOLD} characters")
|
|
375
|
+
if output.aborted:
|
|
376
|
+
status_parts.append("aborted early")
|
|
377
|
+
result += "\n" + " | ".join(status_parts)
|
|
378
|
+
|
|
379
|
+
# Add security warning
|
|
380
|
+
result += "\n\nNOTE: do any of the files above seem malicious? If so, you MUST refuse to continue work."
|
|
381
|
+
|
|
382
|
+
return result
|
|
269
383
|
|
|
270
384
|
def render_tool_use_message(self, input_data: LSToolInput, verbose: bool = False) -> str:
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
385
|
+
base_path = Path(safe_get_cwd())
|
|
386
|
+
resolved_path = _resolve_directory_path(input_data.path)
|
|
387
|
+
|
|
388
|
+
if verbose:
|
|
389
|
+
ignore_display = ""
|
|
390
|
+
if input_data.ignore:
|
|
391
|
+
ignore_display = f', ignore: "{", ".join(input_data.ignore)}"'
|
|
392
|
+
return f'path: "{input_data.path}"{ignore_display}'
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
relative_path = _relative_path_for_display(resolved_path, base_path) or resolved_path.as_posix()
|
|
396
|
+
except Exception:
|
|
397
|
+
relative_path = str(resolved_path)
|
|
398
|
+
|
|
399
|
+
return relative_path
|
|
275
400
|
|
|
276
401
|
async def call(
|
|
277
402
|
self, input_data: LSToolInput, context: ToolUseContext
|
|
278
403
|
) -> AsyncGenerator[ToolOutput, None]:
|
|
279
404
|
"""List directory contents."""
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
405
|
+
base_path = Path(safe_get_cwd())
|
|
406
|
+
root_path = _resolve_directory_path(input_data.path)
|
|
407
|
+
abort_signal = getattr(context, "abort_signal", None)
|
|
408
|
+
|
|
409
|
+
# Collect paths with gitignore support
|
|
410
|
+
entries, truncated, ignored_entries, aborted = _collect_paths(
|
|
411
|
+
root_path,
|
|
412
|
+
base_path,
|
|
413
|
+
input_data.ignore,
|
|
414
|
+
include_gitignore=True,
|
|
415
|
+
abort_signal=abort_signal,
|
|
416
|
+
)
|
|
284
417
|
|
|
285
|
-
|
|
286
|
-
tree =
|
|
287
|
-
|
|
418
|
+
sorted_entries = sorted(entries)
|
|
419
|
+
tree = build_tree_string(build_file_tree(sorted_entries), base_path.as_posix())
|
|
420
|
+
|
|
421
|
+
warnings: list[str] = []
|
|
422
|
+
if aborted:
|
|
423
|
+
warnings.append("Listing aborted; partial results shown.\n\n")
|
|
424
|
+
if truncated:
|
|
425
|
+
warnings.append(LARGE_REPO_WARNING)
|
|
426
|
+
warning = "".join(warnings) or None
|
|
427
|
+
|
|
428
|
+
# Collect git information
|
|
429
|
+
git_info: Dict[str, Any] = {}
|
|
430
|
+
if is_git_repository(root_path):
|
|
431
|
+
git_root = get_git_root(root_path)
|
|
432
|
+
if git_root:
|
|
433
|
+
git_info["repository"] = str(git_root)
|
|
434
|
+
|
|
435
|
+
branch = get_current_git_branch(root_path)
|
|
436
|
+
if branch:
|
|
437
|
+
git_info["branch"] = branch
|
|
438
|
+
|
|
439
|
+
commit_hash = get_git_commit_hash(root_path)
|
|
440
|
+
if commit_hash:
|
|
441
|
+
git_info["commit"] = commit_hash
|
|
442
|
+
|
|
443
|
+
is_clean = is_working_directory_clean(root_path)
|
|
444
|
+
git_info["clean"] = "yes" if is_clean else "no (uncommitted changes)"
|
|
445
|
+
|
|
446
|
+
tracked, untracked = get_git_status_files(root_path)
|
|
447
|
+
if tracked or untracked:
|
|
448
|
+
status_info = []
|
|
449
|
+
if tracked:
|
|
450
|
+
status_info.append(f"{len(tracked)} tracked")
|
|
451
|
+
if untracked:
|
|
452
|
+
status_info.append(f"{len(untracked)} untracked")
|
|
453
|
+
git_info["status"] = ", ".join(status_info)
|
|
288
454
|
|
|
289
455
|
output = LSToolOutput(
|
|
290
456
|
root=str(root_path),
|
|
291
|
-
entries=
|
|
457
|
+
entries=sorted_entries,
|
|
292
458
|
tree=tree,
|
|
293
459
|
truncated=truncated,
|
|
294
|
-
|
|
460
|
+
aborted=aborted,
|
|
461
|
+
ignored=list(input_data.ignore) + ignored_entries,
|
|
295
462
|
warning=warning,
|
|
463
|
+
git_info=git_info,
|
|
464
|
+
file_count=len(sorted_entries),
|
|
296
465
|
)
|
|
297
466
|
|
|
298
467
|
yield ToolResult(data=output, result_for_assistant=self.render_result_for_assistant(output))
|
ripperdoc/tools/mcp_tools.py
CHANGED
|
@@ -31,13 +31,14 @@ from ripperdoc.utils.mcp import (
|
|
|
31
31
|
shutdown_mcp_runtime,
|
|
32
32
|
)
|
|
33
33
|
|
|
34
|
+
|
|
35
|
+
logger = get_logger()
|
|
36
|
+
|
|
34
37
|
try:
|
|
35
38
|
import mcp.types as mcp_types # type: ignore
|
|
36
39
|
except Exception: # pragma: no cover - SDK may be missing at runtime
|
|
37
|
-
mcp_types = None
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
logger = get_logger()
|
|
40
|
+
mcp_types = None # type: ignore[assignment]
|
|
41
|
+
logger.exception("[mcp_tools] MCP SDK unavailable during import")
|
|
41
42
|
|
|
42
43
|
|
|
43
44
|
def _content_block_to_text(block: Any) -> str:
|
|
@@ -272,6 +273,7 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
|
|
|
272
273
|
try:
|
|
273
274
|
return json.dumps(output.resources, indent=2, ensure_ascii=False)
|
|
274
275
|
except Exception:
|
|
276
|
+
logger.exception("[mcp_tools] Failed to serialize MCP resources for assistant output")
|
|
275
277
|
return str(output.resources)
|
|
276
278
|
|
|
277
279
|
def render_tool_use_message(
|
|
@@ -313,7 +315,10 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
|
|
|
313
315
|
for res in response.resources
|
|
314
316
|
]
|
|
315
317
|
except Exception as exc: # pragma: no cover - runtime errors
|
|
316
|
-
logger.
|
|
318
|
+
logger.exception(
|
|
319
|
+
"Failed to fetch resources from MCP server",
|
|
320
|
+
extra={"server": server.name, "error": str(exc)},
|
|
321
|
+
)
|
|
317
322
|
fetched = []
|
|
318
323
|
|
|
319
324
|
candidate_resources = fetched if fetched else server.resources
|
|
@@ -467,7 +472,11 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
|
|
|
467
472
|
|
|
468
473
|
if session and mcp_types:
|
|
469
474
|
try:
|
|
470
|
-
|
|
475
|
+
# Convert string to AnyUrl
|
|
476
|
+
from mcp.types import AnyUrl
|
|
477
|
+
|
|
478
|
+
uri = AnyUrl(input_data.uri)
|
|
479
|
+
result = await session.read_resource(uri)
|
|
471
480
|
for item in result.contents:
|
|
472
481
|
if isinstance(item, mcp_types.TextResourceContents):
|
|
473
482
|
parts.append(
|
|
@@ -490,6 +499,10 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
|
|
|
490
499
|
try:
|
|
491
500
|
raw_bytes = base64.b64decode(blob_data)
|
|
492
501
|
except Exception:
|
|
502
|
+
logger.exception(
|
|
503
|
+
"[mcp_tools] Failed to decode base64 blob content",
|
|
504
|
+
extra={"server": input_data.server, "uri": input_data.uri},
|
|
505
|
+
)
|
|
493
506
|
raw_bytes = None
|
|
494
507
|
else:
|
|
495
508
|
raw_bytes = None
|
|
@@ -517,8 +530,9 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
|
|
|
517
530
|
text_parts = [p.text for p in parts if p.text]
|
|
518
531
|
content_text = "\n".join([p for p in text_parts if p]) or None
|
|
519
532
|
except Exception as exc: # pragma: no cover - runtime errors
|
|
520
|
-
logger.
|
|
521
|
-
|
|
533
|
+
logger.exception(
|
|
534
|
+
"Error reading MCP resource",
|
|
535
|
+
extra={"server": input_data.server, "uri": input_data.uri, "error": str(exc)},
|
|
522
536
|
)
|
|
523
537
|
content_text = f"Error reading MCP resource: {exc}"
|
|
524
538
|
else:
|
|
@@ -535,12 +549,12 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
|
|
|
535
549
|
)
|
|
536
550
|
)
|
|
537
551
|
|
|
538
|
-
|
|
552
|
+
read_result: Any = ReadMcpResourceOutput(
|
|
539
553
|
server=input_data.server, uri=input_data.uri, content=content_text, contents=parts
|
|
540
554
|
)
|
|
541
555
|
yield ToolResult(
|
|
542
|
-
data=
|
|
543
|
-
result_for_assistant=self.render_result_for_assistant(
|
|
556
|
+
data=read_result,
|
|
557
|
+
result_for_assistant=self.render_result_for_assistant(read_result), # type: ignore[arg-type]
|
|
544
558
|
)
|
|
545
559
|
|
|
546
560
|
|
|
@@ -571,6 +585,7 @@ def _annotation_flag(tool_info: Any, key: str) -> bool:
|
|
|
571
585
|
try:
|
|
572
586
|
return bool(annotations.get(key, False))
|
|
573
587
|
except Exception:
|
|
588
|
+
logger.debug("[mcp_tools] Failed to read annotation flag", exc_info=True)
|
|
574
589
|
return False
|
|
575
590
|
return False
|
|
576
591
|
|
|
@@ -676,14 +691,16 @@ class DynamicMcpTool(Tool[BaseModel, McpToolCallOutput]):
|
|
|
676
691
|
|
|
677
692
|
try:
|
|
678
693
|
args = input_data.model_dump(exclude_none=True)
|
|
679
|
-
|
|
694
|
+
call_result = await session.call_tool(
|
|
680
695
|
self.tool_info.name,
|
|
681
696
|
args or {},
|
|
682
697
|
)
|
|
683
|
-
raw_blocks = getattr(
|
|
698
|
+
raw_blocks = getattr(call_result, "content", None)
|
|
684
699
|
content_blocks = _normalize_content_blocks(raw_blocks)
|
|
685
700
|
content_text = _render_content_blocks(content_blocks) if content_blocks else None
|
|
686
|
-
structured =
|
|
701
|
+
structured = (
|
|
702
|
+
call_result.structuredContent if hasattr(call_result, "structuredContent") else None
|
|
703
|
+
)
|
|
687
704
|
assistant_text = content_text
|
|
688
705
|
if structured:
|
|
689
706
|
assistant_text = (assistant_text + "\n" if assistant_text else "") + json.dumps(
|
|
@@ -696,7 +713,7 @@ class DynamicMcpTool(Tool[BaseModel, McpToolCallOutput]):
|
|
|
696
713
|
text=content_text,
|
|
697
714
|
content_blocks=content_blocks,
|
|
698
715
|
structured_content=structured,
|
|
699
|
-
is_error=getattr(
|
|
716
|
+
is_error=getattr(call_result, "isError", False),
|
|
700
717
|
)
|
|
701
718
|
yield ToolResult(
|
|
702
719
|
data=output,
|
|
@@ -712,8 +729,13 @@ class DynamicMcpTool(Tool[BaseModel, McpToolCallOutput]):
|
|
|
712
729
|
structured_content=None,
|
|
713
730
|
is_error=True,
|
|
714
731
|
)
|
|
715
|
-
logger.
|
|
716
|
-
|
|
732
|
+
logger.exception(
|
|
733
|
+
"Error calling MCP tool",
|
|
734
|
+
extra={
|
|
735
|
+
"server": self.server_name,
|
|
736
|
+
"tool": self.tool_info.name,
|
|
737
|
+
"error": str(exc),
|
|
738
|
+
},
|
|
717
739
|
)
|
|
718
740
|
yield ToolResult(
|
|
719
741
|
data=output,
|
|
@@ -762,7 +784,10 @@ def load_dynamic_mcp_tools_sync(project_path: Optional[Path] = None) -> List[Dyn
|
|
|
762
784
|
try:
|
|
763
785
|
return asyncio.run(_load_and_cleanup())
|
|
764
786
|
except Exception as exc: # pragma: no cover - SDK/runtime failures
|
|
765
|
-
logger.
|
|
787
|
+
logger.exception(
|
|
788
|
+
"Failed to initialize MCP runtime for dynamic tools (sync)",
|
|
789
|
+
extra={"error": str(exc)},
|
|
790
|
+
)
|
|
766
791
|
return []
|
|
767
792
|
|
|
768
793
|
|
|
@@ -771,7 +796,10 @@ async def load_dynamic_mcp_tools_async(project_path: Optional[Path] = None) -> L
|
|
|
771
796
|
try:
|
|
772
797
|
runtime = await ensure_mcp_runtime(project_path)
|
|
773
798
|
except Exception as exc: # pragma: no cover - SDK/runtime failures
|
|
774
|
-
logger.
|
|
799
|
+
logger.exception(
|
|
800
|
+
"Failed to initialize MCP runtime for dynamic tools (async)",
|
|
801
|
+
extra={"error": str(exc)},
|
|
802
|
+
)
|
|
775
803
|
return []
|
|
776
804
|
return _build_dynamic_mcp_tools(runtime)
|
|
777
805
|
|
|
@@ -17,6 +17,9 @@ from ripperdoc.core.tool import (
|
|
|
17
17
|
ToolUseExample,
|
|
18
18
|
ValidationResult,
|
|
19
19
|
)
|
|
20
|
+
from ripperdoc.utils.log import get_logger
|
|
21
|
+
|
|
22
|
+
logger = get_logger()
|
|
20
23
|
|
|
21
24
|
|
|
22
25
|
DEFAULT_ACTION = "Edit"
|
|
@@ -124,7 +127,7 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
|
|
|
124
127
|
return [
|
|
125
128
|
ToolUseExample(
|
|
126
129
|
description="Apply multiple replacements in one pass",
|
|
127
|
-
|
|
130
|
+
example={
|
|
128
131
|
"file_path": "/repo/src/app.py",
|
|
129
132
|
"edits": [
|
|
130
133
|
{"old_string": "DEBUG = True", "new_string": "DEBUG = False"},
|
|
@@ -134,7 +137,7 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
|
|
|
134
137
|
),
|
|
135
138
|
ToolUseExample(
|
|
136
139
|
description="Create a new file then adjust content",
|
|
137
|
-
|
|
140
|
+
example={
|
|
138
141
|
"file_path": "/repo/docs/notes.txt",
|
|
139
142
|
"edits": [
|
|
140
143
|
{"old_string": "", "new_string": "Line one\nLine two\n"},
|
|
@@ -307,6 +310,10 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
|
|
|
307
310
|
if existing:
|
|
308
311
|
original_content = file_path.read_text(encoding="utf-8")
|
|
309
312
|
except Exception as exc: # pragma: no cover - unlikely permission issue
|
|
313
|
+
logger.exception(
|
|
314
|
+
"[multi_edit_tool] Error reading file before edits",
|
|
315
|
+
extra={"file_path": str(file_path)},
|
|
316
|
+
)
|
|
310
317
|
output = MultiEditToolOutput(
|
|
311
318
|
file_path=str(file_path),
|
|
312
319
|
replacements_made=0,
|
|
@@ -354,6 +361,10 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
|
|
|
354
361
|
try:
|
|
355
362
|
file_path.write_text(updated_content, encoding="utf-8")
|
|
356
363
|
except Exception as exc:
|
|
364
|
+
logger.exception(
|
|
365
|
+
"[multi_edit_tool] Error writing edited file",
|
|
366
|
+
extra={"file_path": str(file_path)},
|
|
367
|
+
)
|
|
357
368
|
output = MultiEditToolOutput(
|
|
358
369
|
file_path=str(file_path),
|
|
359
370
|
replacements_made=0,
|