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.
Files changed (81) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +25 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +317 -0
  5. ripperdoc/cli/commands/__init__.py +76 -0
  6. ripperdoc/cli/commands/agents_cmd.py +234 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +19 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +114 -0
  12. ripperdoc/cli/commands/cost_cmd.py +77 -0
  13. ripperdoc/cli/commands/exit_cmd.py +19 -0
  14. ripperdoc/cli/commands/help_cmd.py +20 -0
  15. ripperdoc/cli/commands/mcp_cmd.py +65 -0
  16. ripperdoc/cli/commands/models_cmd.py +327 -0
  17. ripperdoc/cli/commands/resume_cmd.py +97 -0
  18. ripperdoc/cli/commands/status_cmd.py +167 -0
  19. ripperdoc/cli/commands/tasks_cmd.py +240 -0
  20. ripperdoc/cli/commands/todos_cmd.py +69 -0
  21. ripperdoc/cli/commands/tools_cmd.py +19 -0
  22. ripperdoc/cli/ui/__init__.py +1 -0
  23. ripperdoc/cli/ui/context_display.py +297 -0
  24. ripperdoc/cli/ui/helpers.py +22 -0
  25. ripperdoc/cli/ui/rich_ui.py +1010 -0
  26. ripperdoc/cli/ui/spinner.py +50 -0
  27. ripperdoc/core/__init__.py +1 -0
  28. ripperdoc/core/agents.py +306 -0
  29. ripperdoc/core/commands.py +33 -0
  30. ripperdoc/core/config.py +382 -0
  31. ripperdoc/core/default_tools.py +57 -0
  32. ripperdoc/core/permissions.py +227 -0
  33. ripperdoc/core/query.py +682 -0
  34. ripperdoc/core/system_prompt.py +418 -0
  35. ripperdoc/core/tool.py +214 -0
  36. ripperdoc/sdk/__init__.py +9 -0
  37. ripperdoc/sdk/client.py +309 -0
  38. ripperdoc/tools/__init__.py +1 -0
  39. ripperdoc/tools/background_shell.py +291 -0
  40. ripperdoc/tools/bash_output_tool.py +98 -0
  41. ripperdoc/tools/bash_tool.py +822 -0
  42. ripperdoc/tools/file_edit_tool.py +281 -0
  43. ripperdoc/tools/file_read_tool.py +168 -0
  44. ripperdoc/tools/file_write_tool.py +141 -0
  45. ripperdoc/tools/glob_tool.py +134 -0
  46. ripperdoc/tools/grep_tool.py +232 -0
  47. ripperdoc/tools/kill_bash_tool.py +136 -0
  48. ripperdoc/tools/ls_tool.py +298 -0
  49. ripperdoc/tools/mcp_tools.py +804 -0
  50. ripperdoc/tools/multi_edit_tool.py +393 -0
  51. ripperdoc/tools/notebook_edit_tool.py +325 -0
  52. ripperdoc/tools/task_tool.py +282 -0
  53. ripperdoc/tools/todo_tool.py +362 -0
  54. ripperdoc/tools/tool_search_tool.py +366 -0
  55. ripperdoc/utils/__init__.py +1 -0
  56. ripperdoc/utils/bash_constants.py +51 -0
  57. ripperdoc/utils/bash_output_utils.py +43 -0
  58. ripperdoc/utils/exit_code_handlers.py +241 -0
  59. ripperdoc/utils/log.py +76 -0
  60. ripperdoc/utils/mcp.py +427 -0
  61. ripperdoc/utils/memory.py +239 -0
  62. ripperdoc/utils/message_compaction.py +640 -0
  63. ripperdoc/utils/messages.py +399 -0
  64. ripperdoc/utils/output_utils.py +233 -0
  65. ripperdoc/utils/path_utils.py +46 -0
  66. ripperdoc/utils/permissions/__init__.py +21 -0
  67. ripperdoc/utils/permissions/path_validation_utils.py +165 -0
  68. ripperdoc/utils/permissions/shell_command_validation.py +74 -0
  69. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  70. ripperdoc/utils/safe_get_cwd.py +24 -0
  71. ripperdoc/utils/sandbox_utils.py +38 -0
  72. ripperdoc/utils/session_history.py +223 -0
  73. ripperdoc/utils/session_usage.py +110 -0
  74. ripperdoc/utils/shell_token_utils.py +95 -0
  75. ripperdoc/utils/todo.py +199 -0
  76. ripperdoc-0.1.0.dist-info/METADATA +178 -0
  77. ripperdoc-0.1.0.dist-info/RECORD +81 -0
  78. ripperdoc-0.1.0.dist-info/WHEEL +5 -0
  79. ripperdoc-0.1.0.dist-info/entry_points.txt +3 -0
  80. ripperdoc-0.1.0.dist-info/licenses/LICENSE +53 -0
  81. 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
+ )