ripperdoc 0.1.0__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 +25 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +317 -0
- ripperdoc/cli/commands/__init__.py +76 -0
- ripperdoc/cli/commands/agents_cmd.py +234 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +19 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +114 -0
- ripperdoc/cli/commands/cost_cmd.py +77 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +65 -0
- ripperdoc/cli/commands/models_cmd.py +327 -0
- ripperdoc/cli/commands/resume_cmd.py +97 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +240 -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 +297 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1010 -0
- ripperdoc/cli/ui/spinner.py +50 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +306 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +382 -0
- ripperdoc/core/default_tools.py +57 -0
- ripperdoc/core/permissions.py +227 -0
- ripperdoc/core/query.py +682 -0
- ripperdoc/core/system_prompt.py +418 -0
- ripperdoc/core/tool.py +214 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +309 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/background_shell.py +291 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +822 -0
- ripperdoc/tools/file_edit_tool.py +281 -0
- ripperdoc/tools/file_read_tool.py +168 -0
- ripperdoc/tools/file_write_tool.py +141 -0
- ripperdoc/tools/glob_tool.py +134 -0
- ripperdoc/tools/grep_tool.py +232 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +298 -0
- ripperdoc/tools/mcp_tools.py +804 -0
- ripperdoc/tools/multi_edit_tool.py +393 -0
- ripperdoc/tools/notebook_edit_tool.py +325 -0
- ripperdoc/tools/task_tool.py +282 -0
- ripperdoc/tools/todo_tool.py +362 -0
- ripperdoc/tools/tool_search_tool.py +366 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/log.py +76 -0
- ripperdoc/utils/mcp.py +427 -0
- ripperdoc/utils/memory.py +239 -0
- ripperdoc/utils/message_compaction.py +640 -0
- ripperdoc/utils/messages.py +399 -0
- ripperdoc/utils/output_utils.py +233 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +21 -0
- ripperdoc/utils/permissions/path_validation_utils.py +165 -0
- ripperdoc/utils/permissions/shell_command_validation.py +74 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/safe_get_cwd.py +24 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +223 -0
- ripperdoc/utils/session_usage.py +110 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/todo.py +199 -0
- ripperdoc-0.1.0.dist-info/METADATA +178 -0
- ripperdoc-0.1.0.dist-info/RECORD +81 -0
- ripperdoc-0.1.0.dist-info/WHEEL +5 -0
- ripperdoc-0.1.0.dist-info/entry_points.txt +3 -0
- ripperdoc-0.1.0.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""File editing tool.
|
|
2
|
+
|
|
3
|
+
Allows the AI to edit files by replacing text.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from typing import AsyncGenerator, List, Optional
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from ripperdoc.core.tool import (
|
|
11
|
+
Tool,
|
|
12
|
+
ToolUseContext,
|
|
13
|
+
ToolResult,
|
|
14
|
+
ToolOutput,
|
|
15
|
+
ToolUseExample,
|
|
16
|
+
ValidationResult,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FileEditToolInput(BaseModel):
|
|
21
|
+
"""Input schema for FileEditTool."""
|
|
22
|
+
|
|
23
|
+
file_path: str = Field(description="Absolute path to the file to edit")
|
|
24
|
+
old_string: str = Field(description="The text to replace (must match exactly)")
|
|
25
|
+
new_string: str = Field(description="The text to replace it with")
|
|
26
|
+
replace_all: bool = Field(
|
|
27
|
+
default=False, description="Replace all occurrences (default: false, only first)"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FileEditToolOutput(BaseModel):
|
|
32
|
+
"""Output from file editing."""
|
|
33
|
+
|
|
34
|
+
file_path: str
|
|
35
|
+
replacements_made: int
|
|
36
|
+
success: bool
|
|
37
|
+
message: str
|
|
38
|
+
additions: int = 0
|
|
39
|
+
deletions: int = 0
|
|
40
|
+
diff_lines: list[str] = []
|
|
41
|
+
diff_with_line_numbers: list[str] = []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FileEditTool(Tool[FileEditToolInput, FileEditToolOutput]):
|
|
45
|
+
"""Tool for editing files by replacing text."""
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def name(self) -> str:
|
|
49
|
+
return "Edit"
|
|
50
|
+
|
|
51
|
+
async def description(self) -> str:
|
|
52
|
+
return """Edit a file by replacing exact string matches. The old_string must
|
|
53
|
+
match exactly (including whitespace and indentation)."""
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def input_schema(self) -> type[FileEditToolInput]:
|
|
57
|
+
return FileEditToolInput
|
|
58
|
+
|
|
59
|
+
def input_examples(self) -> List[ToolUseExample]:
|
|
60
|
+
return [
|
|
61
|
+
ToolUseExample(
|
|
62
|
+
description="Rename a function definition once",
|
|
63
|
+
input={
|
|
64
|
+
"file_path": "/repo/src/app.py",
|
|
65
|
+
"old_string": "def old_name(",
|
|
66
|
+
"new_string": "def new_name(",
|
|
67
|
+
"replace_all": False,
|
|
68
|
+
},
|
|
69
|
+
),
|
|
70
|
+
ToolUseExample(
|
|
71
|
+
description="Replace every occurrence of a constant across a file",
|
|
72
|
+
input={
|
|
73
|
+
"file_path": "/repo/src/config.ts",
|
|
74
|
+
"old_string": 'API_BASE = "http://localhost"',
|
|
75
|
+
"new_string": 'API_BASE = "https://api.example.com"',
|
|
76
|
+
"replace_all": True,
|
|
77
|
+
},
|
|
78
|
+
),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
async def prompt(self, safe_mode: bool = False) -> str:
|
|
82
|
+
return (
|
|
83
|
+
"Performs exact string replacements in files.\n\n"
|
|
84
|
+
"Usage:\n"
|
|
85
|
+
"- You must use your `View` tool at least once in the conversation to read the file before editing; edits will fail if you skip reading.\n"
|
|
86
|
+
"- When editing text from View output, preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix is formatted as spaces + line number + tab. Never include any part of the prefix in old_string or new_string.\n"
|
|
87
|
+
"- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n"
|
|
88
|
+
"- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.\n"
|
|
89
|
+
"- The edit will FAIL if `old_string` is not unique in the file. Provide more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.\n"
|
|
90
|
+
"- Use `replace_all` when replacing or renaming strings across the file (e.g., renaming a variable)."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def is_read_only(self) -> bool:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def is_concurrency_safe(self) -> bool:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
def needs_permissions(self, input_data: Optional[FileEditToolInput] = None) -> bool:
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
async def validate_input(
|
|
103
|
+
self, input_data: FileEditToolInput, context: Optional[ToolUseContext] = None
|
|
104
|
+
) -> ValidationResult:
|
|
105
|
+
# Check if file exists
|
|
106
|
+
if not os.path.exists(input_data.file_path):
|
|
107
|
+
return ValidationResult(result=False, message=f"File not found: {input_data.file_path}")
|
|
108
|
+
|
|
109
|
+
# Check if it's a file
|
|
110
|
+
if not os.path.isfile(input_data.file_path):
|
|
111
|
+
return ValidationResult(
|
|
112
|
+
result=False, message=f"Path is not a file: {input_data.file_path}"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Check that old_string and new_string are different
|
|
116
|
+
if input_data.old_string == input_data.new_string:
|
|
117
|
+
return ValidationResult(
|
|
118
|
+
result=False, message="old_string and new_string must be different"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return ValidationResult(result=True)
|
|
122
|
+
|
|
123
|
+
def render_result_for_assistant(self, output: FileEditToolOutput) -> str:
|
|
124
|
+
"""Format output for the AI."""
|
|
125
|
+
# Return simple message for AI, but include structured data for UI
|
|
126
|
+
# The UI will extract the structured data from the output object
|
|
127
|
+
return output.message
|
|
128
|
+
|
|
129
|
+
def render_tool_use_message(self, input_data: FileEditToolInput, verbose: bool = False) -> str:
|
|
130
|
+
"""Format the tool use for display."""
|
|
131
|
+
return f"Editing: {input_data.file_path}"
|
|
132
|
+
|
|
133
|
+
async def call(
|
|
134
|
+
self, input_data: FileEditToolInput, context: ToolUseContext
|
|
135
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
136
|
+
"""Edit the file."""
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
# Read the file
|
|
140
|
+
with open(input_data.file_path, "r", encoding="utf-8") as f:
|
|
141
|
+
content = f.read()
|
|
142
|
+
|
|
143
|
+
# Check if old_string exists
|
|
144
|
+
if input_data.old_string not in content:
|
|
145
|
+
output = FileEditToolOutput(
|
|
146
|
+
file_path=input_data.file_path,
|
|
147
|
+
replacements_made=0,
|
|
148
|
+
success=False,
|
|
149
|
+
message=f"String not found in file: {input_data.file_path}",
|
|
150
|
+
)
|
|
151
|
+
yield ToolResult(
|
|
152
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
153
|
+
)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
# Count occurrences
|
|
157
|
+
occurrence_count = content.count(input_data.old_string)
|
|
158
|
+
|
|
159
|
+
# Check for ambiguity if not replace_all
|
|
160
|
+
if not input_data.replace_all and occurrence_count > 1:
|
|
161
|
+
output = FileEditToolOutput(
|
|
162
|
+
file_path=input_data.file_path,
|
|
163
|
+
replacements_made=0,
|
|
164
|
+
success=False,
|
|
165
|
+
message=f"String appears {occurrence_count} times in file. "
|
|
166
|
+
f"Either provide a unique string or use replace_all=true",
|
|
167
|
+
)
|
|
168
|
+
yield ToolResult(
|
|
169
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
170
|
+
)
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
# Perform replacement
|
|
174
|
+
if input_data.replace_all:
|
|
175
|
+
new_content = content.replace(input_data.old_string, input_data.new_string)
|
|
176
|
+
replacements = occurrence_count
|
|
177
|
+
else:
|
|
178
|
+
new_content = content.replace(input_data.old_string, input_data.new_string, 1)
|
|
179
|
+
replacements = 1
|
|
180
|
+
|
|
181
|
+
# Write the file
|
|
182
|
+
with open(input_data.file_path, "w", encoding="utf-8") as f:
|
|
183
|
+
f.write(new_content)
|
|
184
|
+
|
|
185
|
+
# Generate diff for display
|
|
186
|
+
import difflib
|
|
187
|
+
|
|
188
|
+
old_lines = content.splitlines(keepends=True)
|
|
189
|
+
new_lines = new_content.splitlines(keepends=True)
|
|
190
|
+
|
|
191
|
+
diff = list(
|
|
192
|
+
difflib.unified_diff(
|
|
193
|
+
old_lines,
|
|
194
|
+
new_lines,
|
|
195
|
+
fromfile=input_data.file_path,
|
|
196
|
+
tofile=input_data.file_path,
|
|
197
|
+
lineterm="",
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Count additions and deletions from diff
|
|
202
|
+
additions = sum(
|
|
203
|
+
1 for line in diff if line.startswith("+") and not line.startswith("+++")
|
|
204
|
+
)
|
|
205
|
+
deletions = sum(
|
|
206
|
+
1 for line in diff if line.startswith("-") and not line.startswith("---")
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Store diff lines for display
|
|
210
|
+
diff_lines = []
|
|
211
|
+
for line in diff[3:]: # Skip header lines
|
|
212
|
+
diff_lines.append(line)
|
|
213
|
+
|
|
214
|
+
# Generate diff with line numbers for better display
|
|
215
|
+
diff_with_line_numbers = []
|
|
216
|
+
old_line_num = None
|
|
217
|
+
new_line_num = None
|
|
218
|
+
|
|
219
|
+
for line in diff[3:]: # Skip header lines
|
|
220
|
+
if line.startswith("@@"):
|
|
221
|
+
# Parse line numbers from diff header
|
|
222
|
+
import re
|
|
223
|
+
|
|
224
|
+
match = re.search(r"@@ -(\d+),(\d+) \+(\d+),(\d+) @@", line)
|
|
225
|
+
if match:
|
|
226
|
+
old_line_num = int(match.group(1))
|
|
227
|
+
new_line_num = int(match.group(3))
|
|
228
|
+
diff_with_line_numbers.append(f" [dim]{line}[/dim]")
|
|
229
|
+
elif line.startswith("+") and not line.startswith("+++"):
|
|
230
|
+
if new_line_num is not None:
|
|
231
|
+
diff_with_line_numbers.append(
|
|
232
|
+
f" [green]{new_line_num:6d} + {line[1:]}[/green]"
|
|
233
|
+
)
|
|
234
|
+
new_line_num += 1
|
|
235
|
+
else:
|
|
236
|
+
diff_with_line_numbers.append(f" [green]{line}[/green]")
|
|
237
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
238
|
+
if old_line_num is not None:
|
|
239
|
+
diff_with_line_numbers.append(
|
|
240
|
+
f" [red]{old_line_num:6d} - {line[1:]}[/red]"
|
|
241
|
+
)
|
|
242
|
+
old_line_num += 1
|
|
243
|
+
else:
|
|
244
|
+
diff_with_line_numbers.append(f" [red]{line}[/red]")
|
|
245
|
+
elif line.strip():
|
|
246
|
+
if old_line_num is not None and new_line_num is not None:
|
|
247
|
+
diff_with_line_numbers.append(
|
|
248
|
+
f" {old_line_num:6d} {new_line_num:6d} {line}"
|
|
249
|
+
)
|
|
250
|
+
old_line_num += 1
|
|
251
|
+
new_line_num += 1
|
|
252
|
+
else:
|
|
253
|
+
diff_with_line_numbers.append(f" {line}")
|
|
254
|
+
|
|
255
|
+
output = FileEditToolOutput(
|
|
256
|
+
file_path=input_data.file_path,
|
|
257
|
+
replacements_made=replacements,
|
|
258
|
+
success=True,
|
|
259
|
+
message=f"Successfully made {replacements} replacement(s) in {input_data.file_path}",
|
|
260
|
+
additions=additions,
|
|
261
|
+
deletions=deletions,
|
|
262
|
+
diff_lines=diff_lines,
|
|
263
|
+
diff_with_line_numbers=diff_with_line_numbers,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
yield ToolResult(
|
|
267
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
except Exception as e:
|
|
271
|
+
error_output = FileEditToolOutput(
|
|
272
|
+
file_path=input_data.file_path,
|
|
273
|
+
replacements_made=0,
|
|
274
|
+
success=False,
|
|
275
|
+
message=f"Error editing file: {str(e)}",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
yield ToolResult(
|
|
279
|
+
data=error_output,
|
|
280
|
+
result_for_assistant=self.render_result_for_assistant(error_output),
|
|
281
|
+
)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""File reading tool.
|
|
2
|
+
|
|
3
|
+
Allows the AI to read file contents.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from typing import AsyncGenerator, List, Optional
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from ripperdoc.core.tool import (
|
|
11
|
+
Tool,
|
|
12
|
+
ToolUseContext,
|
|
13
|
+
ToolResult,
|
|
14
|
+
ToolOutput,
|
|
15
|
+
ToolUseExample,
|
|
16
|
+
ValidationResult,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FileReadToolInput(BaseModel):
|
|
21
|
+
"""Input schema for FileReadTool."""
|
|
22
|
+
|
|
23
|
+
file_path: str = Field(description="Absolute path to the file to read")
|
|
24
|
+
offset: Optional[int] = Field(
|
|
25
|
+
default=None, description="Line number to start reading from (optional)"
|
|
26
|
+
)
|
|
27
|
+
limit: Optional[int] = Field(default=None, description="Number of lines to read (optional)")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FileReadToolOutput(BaseModel):
|
|
31
|
+
"""Output from file reading."""
|
|
32
|
+
|
|
33
|
+
content: str
|
|
34
|
+
file_path: str
|
|
35
|
+
line_count: int
|
|
36
|
+
offset: int
|
|
37
|
+
limit: Optional[int]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FileReadTool(Tool[FileReadToolInput, FileReadToolOutput]):
|
|
41
|
+
"""Tool for reading file contents."""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def name(self) -> str:
|
|
45
|
+
return "View"
|
|
46
|
+
|
|
47
|
+
async def description(self) -> str:
|
|
48
|
+
return """Read the contents of a file. You can optionally specify an offset
|
|
49
|
+
and limit to read only a portion of the file."""
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def input_schema(self) -> type[FileReadToolInput]:
|
|
53
|
+
return FileReadToolInput
|
|
54
|
+
|
|
55
|
+
def input_examples(self) -> List[ToolUseExample]:
|
|
56
|
+
return [
|
|
57
|
+
ToolUseExample(
|
|
58
|
+
description="Read the top of a file to understand structure",
|
|
59
|
+
input={"file_path": "/repo/src/main.py", "limit": 50},
|
|
60
|
+
),
|
|
61
|
+
ToolUseExample(
|
|
62
|
+
description="Inspect a slice of a large log without loading everything",
|
|
63
|
+
input={"file_path": "/repo/logs/server.log", "offset": 200, "limit": 40},
|
|
64
|
+
),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
async def prompt(self, safe_mode: bool = False) -> str:
|
|
68
|
+
return (
|
|
69
|
+
"Read a file from the local filesystem.\n\n"
|
|
70
|
+
"Usage:\n"
|
|
71
|
+
"- The file_path parameter must be an absolute path (not relative).\n"
|
|
72
|
+
"- By default, the entire file is read. You can optionally specify a line offset and limit (handy for long files); offset is zero-based and output line numbers start at 1.\n"
|
|
73
|
+
"- Lines longer than 2000 characters are truncated in the output.\n"
|
|
74
|
+
"- Results are returned with cat -n style numbering: spaces + line number + tab, then the file content.\n"
|
|
75
|
+
"- You can call multiple tools in a single response—speculatively read multiple potentially useful files together.\n"
|
|
76
|
+
"- It is okay to attempt reading a non-existent file; an error will be returned if the file is missing."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def is_read_only(self) -> bool:
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
def is_concurrency_safe(self) -> bool:
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
def needs_permissions(self, input_data: Optional[FileReadToolInput] = None) -> bool:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
async def validate_input(
|
|
89
|
+
self, input_data: FileReadToolInput, context: Optional[ToolUseContext] = None
|
|
90
|
+
) -> ValidationResult:
|
|
91
|
+
# Check if file exists
|
|
92
|
+
if not os.path.exists(input_data.file_path):
|
|
93
|
+
return ValidationResult(result=False, message=f"File not found: {input_data.file_path}")
|
|
94
|
+
|
|
95
|
+
# Check if it's a file (not a directory)
|
|
96
|
+
if not os.path.isfile(input_data.file_path):
|
|
97
|
+
return ValidationResult(
|
|
98
|
+
result=False, message=f"Path is not a file: {input_data.file_path}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return ValidationResult(result=True)
|
|
102
|
+
|
|
103
|
+
def render_result_for_assistant(self, output: FileReadToolOutput) -> str:
|
|
104
|
+
"""Format output for the AI."""
|
|
105
|
+
lines = output.content.split("\n")
|
|
106
|
+
numbered_lines = []
|
|
107
|
+
|
|
108
|
+
for i, line in enumerate(lines, start=output.offset + 1):
|
|
109
|
+
# Truncate very long lines
|
|
110
|
+
if len(line) > 2000:
|
|
111
|
+
line = line[:2000] + "... [truncated]"
|
|
112
|
+
numbered_lines.append(f"{i:6d}\t{line}")
|
|
113
|
+
|
|
114
|
+
return "\n".join(numbered_lines)
|
|
115
|
+
|
|
116
|
+
def render_tool_use_message(self, input_data: FileReadToolInput, verbose: bool = False) -> str:
|
|
117
|
+
"""Format the tool use for display."""
|
|
118
|
+
msg = f"Reading: {input_data.file_path}"
|
|
119
|
+
if input_data.offset or input_data.limit:
|
|
120
|
+
msg += f" (offset: {input_data.offset}, limit: {input_data.limit})"
|
|
121
|
+
return msg
|
|
122
|
+
|
|
123
|
+
async def call(
|
|
124
|
+
self, input_data: FileReadToolInput, context: ToolUseContext
|
|
125
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
126
|
+
"""Read the file."""
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
with open(input_data.file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
130
|
+
lines = f.readlines()
|
|
131
|
+
|
|
132
|
+
offset = input_data.offset or 0
|
|
133
|
+
limit = input_data.limit
|
|
134
|
+
|
|
135
|
+
# Apply offset and limit
|
|
136
|
+
if limit is not None:
|
|
137
|
+
selected_lines = lines[offset : offset + limit]
|
|
138
|
+
else:
|
|
139
|
+
selected_lines = lines[offset:]
|
|
140
|
+
|
|
141
|
+
content = "".join(selected_lines)
|
|
142
|
+
|
|
143
|
+
output = FileReadToolOutput(
|
|
144
|
+
content=content,
|
|
145
|
+
file_path=input_data.file_path,
|
|
146
|
+
line_count=len(selected_lines),
|
|
147
|
+
offset=offset,
|
|
148
|
+
limit=limit,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
yield ToolResult(
|
|
152
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
except Exception as e:
|
|
156
|
+
# Create an error output
|
|
157
|
+
error_output = FileReadToolOutput(
|
|
158
|
+
content=f"Error reading file: {str(e)}",
|
|
159
|
+
file_path=input_data.file_path,
|
|
160
|
+
line_count=0,
|
|
161
|
+
offset=0,
|
|
162
|
+
limit=None,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
yield ToolResult(
|
|
166
|
+
data=error_output,
|
|
167
|
+
result_for_assistant=f"Error reading file {input_data.file_path}: {str(e)}",
|
|
168
|
+
)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""File writing tool.
|
|
2
|
+
|
|
3
|
+
Allows the AI to create new files.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import AsyncGenerator, List, Optional
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from ripperdoc.core.tool import (
|
|
12
|
+
Tool,
|
|
13
|
+
ToolUseContext,
|
|
14
|
+
ToolResult,
|
|
15
|
+
ToolOutput,
|
|
16
|
+
ToolUseExample,
|
|
17
|
+
ValidationResult,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FileWriteToolInput(BaseModel):
|
|
22
|
+
"""Input schema for FileWriteTool."""
|
|
23
|
+
|
|
24
|
+
file_path: str = Field(description="Absolute path to the file to create")
|
|
25
|
+
content: str = Field(description="Content to write to the file")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FileWriteToolOutput(BaseModel):
|
|
29
|
+
"""Output from file writing."""
|
|
30
|
+
|
|
31
|
+
file_path: str
|
|
32
|
+
bytes_written: int
|
|
33
|
+
success: bool
|
|
34
|
+
message: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FileWriteTool(Tool[FileWriteToolInput, FileWriteToolOutput]):
|
|
38
|
+
"""Tool for creating new files."""
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def name(self) -> str:
|
|
42
|
+
return "Write"
|
|
43
|
+
|
|
44
|
+
async def description(self) -> str:
|
|
45
|
+
return """Create a new file with the specified content. This will overwrite
|
|
46
|
+
the file if it already exists."""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def input_schema(self) -> type[FileWriteToolInput]:
|
|
50
|
+
return FileWriteToolInput
|
|
51
|
+
|
|
52
|
+
def input_examples(self) -> List[ToolUseExample]:
|
|
53
|
+
return [
|
|
54
|
+
ToolUseExample(
|
|
55
|
+
description="Create a JSON fixture file",
|
|
56
|
+
input={"file_path": "/repo/tests/fixtures/sample.json", "content": '{\n "items": []\n}\n'},
|
|
57
|
+
),
|
|
58
|
+
ToolUseExample(
|
|
59
|
+
description="Write a short markdown note",
|
|
60
|
+
input={"file_path": "/repo/docs/USAGE.md", "content": "# Usage\n\nRun `make test`.\n"},
|
|
61
|
+
),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
async def prompt(self, safe_mode: bool = False) -> str:
|
|
65
|
+
prompt = """Use the Write tool to create new files. """
|
|
66
|
+
|
|
67
|
+
if safe_mode:
|
|
68
|
+
prompt += """IMPORTANT: You must ALWAYS prefer editing existing files.
|
|
69
|
+
NEVER write new files unless explicitly required by the user."""
|
|
70
|
+
|
|
71
|
+
return prompt
|
|
72
|
+
|
|
73
|
+
def is_read_only(self) -> bool:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
def is_concurrency_safe(self) -> bool:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
def needs_permissions(self, input_data: Optional[FileWriteToolInput] = None) -> bool:
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
async def validate_input(
|
|
83
|
+
self, input_data: FileWriteToolInput, context: Optional[ToolUseContext] = None
|
|
84
|
+
) -> ValidationResult:
|
|
85
|
+
# Check if file already exists (warning)
|
|
86
|
+
if os.path.exists(input_data.file_path):
|
|
87
|
+
# In safe mode, this should be handled by permissions
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
# Check if parent directory exists
|
|
91
|
+
parent = Path(input_data.file_path).parent
|
|
92
|
+
if not parent.exists():
|
|
93
|
+
return ValidationResult(
|
|
94
|
+
result=False, message=f"Parent directory does not exist: {parent}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return ValidationResult(result=True)
|
|
98
|
+
|
|
99
|
+
def render_result_for_assistant(self, output: FileWriteToolOutput) -> str:
|
|
100
|
+
"""Format output for the AI."""
|
|
101
|
+
return output.message
|
|
102
|
+
|
|
103
|
+
def render_tool_use_message(self, input_data: FileWriteToolInput, verbose: bool = False) -> str:
|
|
104
|
+
"""Format the tool use for display."""
|
|
105
|
+
return f"Writing: {input_data.file_path} ({len(input_data.content)} bytes)"
|
|
106
|
+
|
|
107
|
+
async def call(
|
|
108
|
+
self, input_data: FileWriteToolInput, context: ToolUseContext
|
|
109
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
110
|
+
"""Write the file."""
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
# Write the file
|
|
114
|
+
with open(input_data.file_path, "w", encoding="utf-8") as f:
|
|
115
|
+
f.write(input_data.content)
|
|
116
|
+
|
|
117
|
+
bytes_written = len(input_data.content.encode("utf-8"))
|
|
118
|
+
|
|
119
|
+
output = FileWriteToolOutput(
|
|
120
|
+
file_path=input_data.file_path,
|
|
121
|
+
bytes_written=bytes_written,
|
|
122
|
+
success=True,
|
|
123
|
+
message=f"Successfully wrote {bytes_written} bytes to {input_data.file_path}",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
yield ToolResult(
|
|
127
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
except Exception as e:
|
|
131
|
+
error_output = FileWriteToolOutput(
|
|
132
|
+
file_path=input_data.file_path,
|
|
133
|
+
bytes_written=0,
|
|
134
|
+
success=False,
|
|
135
|
+
message=f"Error writing file: {str(e)}",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
yield ToolResult(
|
|
139
|
+
data=error_output,
|
|
140
|
+
result_for_assistant=self.render_result_for_assistant(error_output),
|
|
141
|
+
)
|