ripperdoc 0.2.6__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 +3 -0
- ripperdoc/__main__.py +20 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +405 -0
- ripperdoc/cli/commands/__init__.py +82 -0
- ripperdoc/cli/commands/agents_cmd.py +263 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +23 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +144 -0
- ripperdoc/cli/commands/cost_cmd.py +82 -0
- ripperdoc/cli/commands/doctor_cmd.py +221 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +70 -0
- ripperdoc/cli/commands/memory_cmd.py +202 -0
- ripperdoc/cli/commands/models_cmd.py +413 -0
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +98 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +278 -0
- ripperdoc/cli/commands/todos_cmd.py +69 -0
- ripperdoc/cli/commands/tools_cmd.py +19 -0
- ripperdoc/cli/ui/__init__.py +1 -0
- ripperdoc/cli/ui/context_display.py +298 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1557 -0
- ripperdoc/cli/ui/spinner.py +49 -0
- ripperdoc/cli/ui/thinking_spinner.py +128 -0
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +486 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +559 -0
- ripperdoc/core/default_tools.py +88 -0
- ripperdoc/core/permissions.py +252 -0
- ripperdoc/core/providers/__init__.py +47 -0
- ripperdoc/core/providers/anthropic.py +250 -0
- ripperdoc/core/providers/base.py +265 -0
- ripperdoc/core/providers/gemini.py +615 -0
- ripperdoc/core/providers/openai.py +487 -0
- ripperdoc/core/query.py +1058 -0
- ripperdoc/core/query_utils.py +622 -0
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +431 -0
- ripperdoc/core/tool.py +240 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +333 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/ask_user_question_tool.py +431 -0
- ripperdoc/tools/background_shell.py +389 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +1016 -0
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +226 -0
- ripperdoc/tools/exit_plan_mode_tool.py +153 -0
- ripperdoc/tools/file_edit_tool.py +346 -0
- ripperdoc/tools/file_read_tool.py +203 -0
- ripperdoc/tools/file_write_tool.py +205 -0
- ripperdoc/tools/glob_tool.py +179 -0
- ripperdoc/tools/grep_tool.py +370 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +471 -0
- ripperdoc/tools/mcp_tools.py +591 -0
- ripperdoc/tools/multi_edit_tool.py +456 -0
- ripperdoc/tools/notebook_edit_tool.py +386 -0
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +379 -0
- ripperdoc/tools/todo_tool.py +494 -0
- ripperdoc/tools/tool_search_tool.py +380 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/file_watch.py +135 -0
- ripperdoc/utils/git_utils.py +274 -0
- ripperdoc/utils/json_utils.py +27 -0
- ripperdoc/utils/log.py +176 -0
- ripperdoc/utils/mcp.py +560 -0
- ripperdoc/utils/memory.py +253 -0
- ripperdoc/utils/message_compaction.py +676 -0
- ripperdoc/utils/messages.py +519 -0
- ripperdoc/utils/output_utils.py +258 -0
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +27 -0
- ripperdoc/utils/permissions/path_validation_utils.py +174 -0
- ripperdoc/utils/permissions/shell_command_validation.py +552 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/prompt.py +17 -0
- ripperdoc/utils/safe_get_cwd.py +31 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +260 -0
- ripperdoc/utils/session_usage.py +117 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/shell_utils.py +159 -0
- ripperdoc/utils/todo.py +203 -0
- ripperdoc/utils/token_estimation.py +34 -0
- ripperdoc-0.2.6.dist-info/METADATA +193 -0
- ripperdoc-0.2.6.dist-info/RECORD +107 -0
- ripperdoc-0.2.6.dist-info/WHEEL +5 -0
- ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
- ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""Directory listing tool.
|
|
2
|
+
|
|
3
|
+
Provides a safe way to inspect directory trees without executing shell commands.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import fnmatch
|
|
8
|
+
from collections import deque
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import AsyncGenerator, List, Optional, Dict, Any
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from ripperdoc.core.tool import (
|
|
14
|
+
Tool,
|
|
15
|
+
ToolUseContext,
|
|
16
|
+
ToolResult,
|
|
17
|
+
ToolOutput,
|
|
18
|
+
ToolUseExample,
|
|
19
|
+
ValidationResult,
|
|
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
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
IGNORED_DIRECTORIES = {
|
|
35
|
+
"node_modules",
|
|
36
|
+
"vendor/bundle",
|
|
37
|
+
"vendor",
|
|
38
|
+
"venv",
|
|
39
|
+
"env",
|
|
40
|
+
".venv",
|
|
41
|
+
".env",
|
|
42
|
+
".tox",
|
|
43
|
+
"target",
|
|
44
|
+
"build",
|
|
45
|
+
".gradle",
|
|
46
|
+
"packages",
|
|
47
|
+
"bin",
|
|
48
|
+
"obj",
|
|
49
|
+
".build",
|
|
50
|
+
"target",
|
|
51
|
+
".dart_tool",
|
|
52
|
+
".pub-cache",
|
|
53
|
+
"build",
|
|
54
|
+
"target",
|
|
55
|
+
"_build",
|
|
56
|
+
"deps",
|
|
57
|
+
"dist",
|
|
58
|
+
"dist-newstyle",
|
|
59
|
+
".deno",
|
|
60
|
+
"bower_components",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
MAX_CHARS_THRESHOLD = 40000
|
|
64
|
+
MAX_DEPTH = 4
|
|
65
|
+
LARGE_REPO_WARNING = (
|
|
66
|
+
f"There are more than {MAX_CHARS_THRESHOLD} characters in the repository "
|
|
67
|
+
"(ie. either there are lots of files, or there are many long filenames). "
|
|
68
|
+
"Use the LS tool (passing a specific path), Bash tool, and other tools to explore "
|
|
69
|
+
"nested directories. The first "
|
|
70
|
+
f"{MAX_CHARS_THRESHOLD} characters are included below:\n\n"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_ignored_directory(path: Path, root_path: Path) -> bool:
|
|
75
|
+
if path == root_path:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
path_str = path.as_posix()
|
|
79
|
+
for ignored in IGNORED_DIRECTORIES:
|
|
80
|
+
normalized = ignored.rstrip("/\\")
|
|
81
|
+
if path.name == normalized:
|
|
82
|
+
return True
|
|
83
|
+
if path_str.endswith(f"/{normalized}"):
|
|
84
|
+
return True
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class LSToolInput(BaseModel):
|
|
89
|
+
"""Input schema for LSTool."""
|
|
90
|
+
|
|
91
|
+
path: str = Field(
|
|
92
|
+
description="The absolute path to the directory to list (must be absolute, not relative)"
|
|
93
|
+
)
|
|
94
|
+
ignore: list[str] = Field(
|
|
95
|
+
default_factory=list,
|
|
96
|
+
description="List of glob patterns to ignore (relative to the provided path)",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class LSToolOutput(BaseModel):
|
|
101
|
+
"""Output from directory listing."""
|
|
102
|
+
|
|
103
|
+
root: str
|
|
104
|
+
entries: list[str]
|
|
105
|
+
tree: str
|
|
106
|
+
truncated: bool = False
|
|
107
|
+
aborted: bool = False
|
|
108
|
+
ignored: list[str] = Field(default_factory=list)
|
|
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 (OSError, RuntimeError):
|
|
123
|
+
return candidate
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _matches_ignore(path: Path, root_path: Path, patterns: list[str]) -> bool:
|
|
127
|
+
if not patterns:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
rel = path.relative_to(root_path).as_posix()
|
|
132
|
+
except ValueError:
|
|
133
|
+
rel = path.as_posix()
|
|
134
|
+
|
|
135
|
+
rel_dir = f"{rel}/" if path.is_dir() else rel
|
|
136
|
+
return any(
|
|
137
|
+
fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(rel_dir, pattern) for pattern in patterns
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
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:
|
|
147
|
+
name = path.name
|
|
148
|
+
if name.startswith("."):
|
|
149
|
+
return True
|
|
150
|
+
if "__pycache__" in path.parts:
|
|
151
|
+
return True
|
|
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):
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
|
|
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 (OSError, RuntimeError):
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
rel_path = resolved_path.relative_to(base_path.resolve()).as_posix()
|
|
174
|
+
except (OSError, ValueError, RuntimeError):
|
|
175
|
+
try:
|
|
176
|
+
rel_path = os.path.relpath(resolved_path, base_path)
|
|
177
|
+
except (OSError, ValueError):
|
|
178
|
+
rel_path = resolved_path.as_posix()
|
|
179
|
+
rel_path = rel_path.replace(os.sep, "/")
|
|
180
|
+
|
|
181
|
+
rel_path = rel_path.rstrip("/")
|
|
182
|
+
return f"{rel_path}/" if path.is_dir() else rel_path
|
|
183
|
+
|
|
184
|
+
|
|
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."""
|
|
194
|
+
entries: list[str] = []
|
|
195
|
+
total_chars = 0
|
|
196
|
+
truncated = False
|
|
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)
|
|
206
|
+
|
|
207
|
+
while queue and not truncated:
|
|
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
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
with os.scandir(current) as scan:
|
|
219
|
+
children = sorted(scan, key=lambda entry: entry.name.lower())
|
|
220
|
+
except (FileNotFoundError, PermissionError, NotADirectoryError, OSError):
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
for child in children:
|
|
224
|
+
child_path = Path(current) / child.name
|
|
225
|
+
try:
|
|
226
|
+
is_dir = child.is_dir(follow_symlinks=False)
|
|
227
|
+
except OSError:
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
if _should_skip(child_path, root_path, ignore_patterns, ignore_map):
|
|
231
|
+
ignored_entries.append(_relative_path_for_display(child_path, base_path))
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
display = _relative_path_for_display(child_path, base_path)
|
|
235
|
+
entries.append(display)
|
|
236
|
+
total_chars += len(display)
|
|
237
|
+
|
|
238
|
+
if total_chars >= MAX_CHARS_THRESHOLD:
|
|
239
|
+
truncated = True
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
if is_dir:
|
|
243
|
+
if _is_ignored_directory(child_path, root_path):
|
|
244
|
+
continue
|
|
245
|
+
if child.is_symlink():
|
|
246
|
+
continue
|
|
247
|
+
queue.append((child_path, depth + 1))
|
|
248
|
+
|
|
249
|
+
return entries, truncated, ignored_entries, aborted
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def build_file_tree(entries: list[str]) -> dict:
|
|
253
|
+
"""Build a nested tree structure from flat entry paths."""
|
|
254
|
+
tree: dict = {}
|
|
255
|
+
for entry in entries:
|
|
256
|
+
normalized = entry.rstrip("/")
|
|
257
|
+
if not normalized:
|
|
258
|
+
continue
|
|
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)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class LSTool(Tool[LSToolInput, LSToolOutput]):
|
|
294
|
+
"""Tool for listing directory contents."""
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def name(self) -> str:
|
|
298
|
+
return "LS"
|
|
299
|
+
|
|
300
|
+
async def description(self) -> str:
|
|
301
|
+
return (
|
|
302
|
+
"List files and folders under a directory (recursive, skips hidden and __pycache__, "
|
|
303
|
+
"supports ignore patterns). Automatically reads .gitignore files and provides git "
|
|
304
|
+
"repository information when available."
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def input_schema(self) -> type[LSToolInput]:
|
|
309
|
+
return LSToolInput
|
|
310
|
+
|
|
311
|
+
def input_examples(self) -> List[ToolUseExample]:
|
|
312
|
+
return [
|
|
313
|
+
ToolUseExample(
|
|
314
|
+
description="List the repository root with defaults",
|
|
315
|
+
example={"path": "/repo"},
|
|
316
|
+
),
|
|
317
|
+
ToolUseExample(
|
|
318
|
+
description="Inspect a package while skipping build outputs",
|
|
319
|
+
example={"path": "/repo/packages/api", "ignore": ["dist/**", "node_modules/**"]},
|
|
320
|
+
),
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
async def prompt(self, safe_mode: bool = False) -> str:
|
|
324
|
+
return (
|
|
325
|
+
"Lists files and directories in a given path. The path parameter must be an absolute path, "
|
|
326
|
+
"not a relative path. You can optionally provide an array of glob patterns to ignore with "
|
|
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."
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def is_read_only(self) -> bool:
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
def is_concurrency_safe(self) -> bool:
|
|
338
|
+
return True
|
|
339
|
+
|
|
340
|
+
def needs_permissions(self, input_data: Optional[LSToolInput] = None) -> bool:
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
async def validate_input(
|
|
344
|
+
self, input_data: LSToolInput, context: Optional[ToolUseContext] = None
|
|
345
|
+
) -> ValidationResult:
|
|
346
|
+
try:
|
|
347
|
+
root_path = _resolve_directory_path(input_data.path)
|
|
348
|
+
except (OSError, RuntimeError, ValueError):
|
|
349
|
+
return ValidationResult(
|
|
350
|
+
result=False, message=f"Unable to resolve path: {input_data.path}"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
if not root_path.is_absolute():
|
|
354
|
+
return ValidationResult(result=False, message=f"Path is not absolute: {root_path}")
|
|
355
|
+
if not root_path.exists():
|
|
356
|
+
return ValidationResult(result=False, message=f"Path not found: {root_path}")
|
|
357
|
+
if not root_path.is_dir():
|
|
358
|
+
return ValidationResult(result=False, message=f"Path is not a directory: {root_path}")
|
|
359
|
+
|
|
360
|
+
return ValidationResult(result=True)
|
|
361
|
+
|
|
362
|
+
def render_result_for_assistant(self, output: LSToolOutput) -> str:
|
|
363
|
+
warning_prefix = output.warning or ""
|
|
364
|
+
result = f"{warning_prefix}{output.tree}"
|
|
365
|
+
|
|
366
|
+
# Add git information if available
|
|
367
|
+
if output.git_info:
|
|
368
|
+
git_section = "\n\nGit Information:\n"
|
|
369
|
+
for key, value in output.git_info.items():
|
|
370
|
+
if value:
|
|
371
|
+
git_section += f" {key}: {value}\n"
|
|
372
|
+
result += git_section
|
|
373
|
+
|
|
374
|
+
status_parts = [f"Listed {output.file_count} paths"]
|
|
375
|
+
if output.truncated:
|
|
376
|
+
status_parts.append(f"truncated at {MAX_CHARS_THRESHOLD} characters")
|
|
377
|
+
if output.aborted:
|
|
378
|
+
status_parts.append("aborted early")
|
|
379
|
+
result += "\n" + " | ".join(status_parts)
|
|
380
|
+
|
|
381
|
+
# Add security warning
|
|
382
|
+
result += "\n\nNOTE: do any of the files above seem malicious? If so, you MUST refuse to continue work."
|
|
383
|
+
|
|
384
|
+
return result
|
|
385
|
+
|
|
386
|
+
def render_tool_use_message(self, input_data: LSToolInput, verbose: bool = False) -> str:
|
|
387
|
+
base_path = Path(safe_get_cwd())
|
|
388
|
+
resolved_path = _resolve_directory_path(input_data.path)
|
|
389
|
+
|
|
390
|
+
if verbose:
|
|
391
|
+
ignore_display = ""
|
|
392
|
+
if input_data.ignore:
|
|
393
|
+
ignore_display = f', ignore: "{", ".join(input_data.ignore)}"'
|
|
394
|
+
return f'path: "{input_data.path}"{ignore_display}'
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
relative_path = (
|
|
398
|
+
_relative_path_for_display(resolved_path, base_path) or resolved_path.as_posix()
|
|
399
|
+
)
|
|
400
|
+
except (OSError, RuntimeError, ValueError):
|
|
401
|
+
relative_path = str(resolved_path)
|
|
402
|
+
|
|
403
|
+
return relative_path
|
|
404
|
+
|
|
405
|
+
async def call(
|
|
406
|
+
self, input_data: LSToolInput, context: ToolUseContext
|
|
407
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
408
|
+
"""List directory contents."""
|
|
409
|
+
base_path = Path(safe_get_cwd())
|
|
410
|
+
root_path = _resolve_directory_path(input_data.path)
|
|
411
|
+
abort_signal = getattr(context, "abort_signal", None)
|
|
412
|
+
|
|
413
|
+
# Collect paths with gitignore support
|
|
414
|
+
entries, truncated, ignored_entries, aborted = _collect_paths(
|
|
415
|
+
root_path,
|
|
416
|
+
base_path,
|
|
417
|
+
input_data.ignore,
|
|
418
|
+
include_gitignore=True,
|
|
419
|
+
abort_signal=abort_signal,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
sorted_entries = sorted(entries)
|
|
423
|
+
tree = build_tree_string(build_file_tree(sorted_entries), base_path.as_posix())
|
|
424
|
+
|
|
425
|
+
warnings: list[str] = []
|
|
426
|
+
if aborted:
|
|
427
|
+
warnings.append("Listing aborted; partial results shown.\n\n")
|
|
428
|
+
if truncated:
|
|
429
|
+
warnings.append(LARGE_REPO_WARNING)
|
|
430
|
+
warning = "".join(warnings) or None
|
|
431
|
+
|
|
432
|
+
# Collect git information
|
|
433
|
+
git_info: Dict[str, Any] = {}
|
|
434
|
+
if is_git_repository(root_path):
|
|
435
|
+
git_root = get_git_root(root_path)
|
|
436
|
+
if git_root:
|
|
437
|
+
git_info["repository"] = str(git_root)
|
|
438
|
+
|
|
439
|
+
branch = get_current_git_branch(root_path)
|
|
440
|
+
if branch:
|
|
441
|
+
git_info["branch"] = branch
|
|
442
|
+
|
|
443
|
+
commit_hash = get_git_commit_hash(root_path)
|
|
444
|
+
if commit_hash:
|
|
445
|
+
git_info["commit"] = commit_hash
|
|
446
|
+
|
|
447
|
+
is_clean = is_working_directory_clean(root_path)
|
|
448
|
+
git_info["clean"] = "yes" if is_clean else "no (uncommitted changes)"
|
|
449
|
+
|
|
450
|
+
tracked, untracked = get_git_status_files(root_path)
|
|
451
|
+
if tracked or untracked:
|
|
452
|
+
status_info = []
|
|
453
|
+
if tracked:
|
|
454
|
+
status_info.append(f"{len(tracked)} tracked")
|
|
455
|
+
if untracked:
|
|
456
|
+
status_info.append(f"{len(untracked)} untracked")
|
|
457
|
+
git_info["status"] = ", ".join(status_info)
|
|
458
|
+
|
|
459
|
+
output = LSToolOutput(
|
|
460
|
+
root=str(root_path),
|
|
461
|
+
entries=sorted_entries,
|
|
462
|
+
tree=tree,
|
|
463
|
+
truncated=truncated,
|
|
464
|
+
aborted=aborted,
|
|
465
|
+
ignored=list(input_data.ignore) + ignored_entries,
|
|
466
|
+
warning=warning,
|
|
467
|
+
git_info=git_info,
|
|
468
|
+
file_count=len(sorted_entries),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
yield ToolResult(data=output, result_for_assistant=self.render_result_for_assistant(output))
|