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,393 @@
1
+ """Multi-edit tool.
2
+
3
+ Allows performing multiple exact string replacements in a single file atomically.
4
+ """
5
+
6
+ import difflib
7
+ from pathlib import Path
8
+ from typing import AsyncGenerator, Optional, List
9
+ from textwrap import dedent
10
+ from pydantic import BaseModel, Field
11
+
12
+ from ripperdoc.core.tool import (
13
+ Tool,
14
+ ToolUseContext,
15
+ ToolResult,
16
+ ToolOutput,
17
+ ToolUseExample,
18
+ ValidationResult,
19
+ )
20
+
21
+
22
+ DEFAULT_ACTION = "Edit"
23
+ TOOL_NAME_READ = "View"
24
+ NOTEBOOK_EDIT_TOOL_NAME = "NotebookEdit"
25
+
26
+ MULTI_EDIT_DESCRIPTION = dedent(
27
+ f"""\
28
+ This is a tool for making multiple edits to a single file in one operation. It is built on top of the {DEFAULT_ACTION} tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the {DEFAULT_ACTION} tool when you need to make multiple edits to the same file.
29
+
30
+ Before using this tool:
31
+
32
+ 1. Use the {TOOL_NAME_READ} tool to understand the file's contents and context
33
+ 2. Verify the directory path is correct
34
+
35
+ To make multiple file edits, provide the following:
36
+ 1. file_path: The absolute path to the file to modify (must be absolute, not relative)
37
+ 2. edits: An array of edit operations to perform, where each edit contains:
38
+ - old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
39
+ - new_string: The edited text to replace the old_string
40
+ - replace_all: Replace all occurences of old_string. This parameter is optional and defaults to false.
41
+
42
+ IMPORTANT:
43
+ - All edits are applied in sequence, in the order they are provided
44
+ - Each edit operates on the result of the previous edit
45
+ - All edits must be valid for the operation to succeed - if any edit fails, none will be applied
46
+ - This tool is ideal when you need to make several changes to different parts of the same file
47
+ - For Jupyter notebooks (.ipynb files), use the {NOTEBOOK_EDIT_TOOL_NAME} instead
48
+
49
+ CRITICAL REQUIREMENTS:
50
+ 1. All edits follow the same requirements as the single Edit tool
51
+ 2. The edits are atomic - either all succeed or none are applied
52
+ 3. Plan your edits carefully to avoid conflicts between sequential operations
53
+
54
+ WARNING:
55
+ - The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace)
56
+ - The tool will fail if edits.old_string and edits.new_string are the same
57
+ - Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find
58
+
59
+ When making edits:
60
+ - Ensure all edits result in idiomatic, correct code
61
+ - Do not leave the code in a broken state
62
+ - Always use absolute file paths (starting with /)
63
+ - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
64
+ - Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
65
+
66
+ If you want to create a new file, use:
67
+ - A new file path, including dir name if needed
68
+ - First edit: empty old_string and the new file's contents as new_string
69
+ - Subsequent edits: normal edit operations on the created content"""
70
+ )
71
+
72
+
73
+ class EditOperation(BaseModel):
74
+ """Single edit operation."""
75
+
76
+ old_string: str = Field(description="The text to replace (must match exactly)")
77
+ new_string: str = Field(description="The text to replace it with")
78
+ replace_all: bool = Field(
79
+ default=False,
80
+ description="Replace all occurrences of old_string (default: false, only first)",
81
+ )
82
+
83
+
84
+ class MultiEditToolInput(BaseModel):
85
+ """Input schema for MultiEditTool."""
86
+
87
+ file_path: str = Field(description="Absolute path to the file to edit")
88
+ edits: List[EditOperation] = Field(
89
+ description="Array of edit operations to apply sequentially",
90
+ min_length=1,
91
+ )
92
+
93
+
94
+ class MultiEditToolOutput(BaseModel):
95
+ """Output from multi-edit."""
96
+
97
+ file_path: str
98
+ replacements_made: int
99
+ success: bool
100
+ message: str
101
+ additions: int = 0
102
+ deletions: int = 0
103
+ diff_lines: list[str] = []
104
+ diff_with_line_numbers: list[str] = []
105
+ applied_edits: List[EditOperation] = []
106
+ created: bool = False
107
+
108
+
109
+ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
110
+ """Tool for applying multiple edits to a file atomically."""
111
+
112
+ @property
113
+ def name(self) -> str:
114
+ return "MultiEdit"
115
+
116
+ async def description(self) -> str:
117
+ return MULTI_EDIT_DESCRIPTION
118
+
119
+ @property
120
+ def input_schema(self) -> type[MultiEditToolInput]:
121
+ return MultiEditToolInput
122
+
123
+ def input_examples(self) -> List[ToolUseExample]:
124
+ return [
125
+ ToolUseExample(
126
+ description="Apply multiple replacements in one pass",
127
+ input={
128
+ "file_path": "/repo/src/app.py",
129
+ "edits": [
130
+ {"old_string": "DEBUG = True", "new_string": "DEBUG = False"},
131
+ {"old_string": "old_fn(", "new_string": "new_fn("},
132
+ ],
133
+ },
134
+ ),
135
+ ToolUseExample(
136
+ description="Create a new file then adjust content",
137
+ input={
138
+ "file_path": "/repo/docs/notes.txt",
139
+ "edits": [
140
+ {"old_string": "", "new_string": "Line one\nLine two\n"},
141
+ {"old_string": "Line two", "new_string": "Second line"},
142
+ ],
143
+ },
144
+ ),
145
+ ]
146
+
147
+ async def prompt(self, safe_mode: bool = False) -> str:
148
+ return MULTI_EDIT_DESCRIPTION
149
+
150
+ def is_read_only(self) -> bool:
151
+ return False
152
+
153
+ def is_concurrency_safe(self) -> bool:
154
+ return False
155
+
156
+ def needs_permissions(self, input_data: Optional[MultiEditToolInput] = None) -> bool:
157
+ return True
158
+
159
+ async def validate_input(
160
+ self,
161
+ input_data: MultiEditToolInput,
162
+ context: Optional[ToolUseContext] = None,
163
+ ) -> ValidationResult:
164
+ path = Path(input_data.file_path).expanduser()
165
+ if not path.is_absolute():
166
+ path = Path.cwd() / path
167
+
168
+ # Ensure edits differ.
169
+ for edit in input_data.edits:
170
+ if edit.old_string == edit.new_string:
171
+ return ValidationResult(
172
+ result=False,
173
+ message="old_string and new_string must be different",
174
+ )
175
+
176
+ # If the file exists, ensure it is not a directory.
177
+ if path.exists() and path.is_dir():
178
+ return ValidationResult(
179
+ result=False,
180
+ message=f"Path is a directory, not a file: {path}",
181
+ )
182
+
183
+ return ValidationResult(result=True)
184
+
185
+ def render_result_for_assistant(self, output: MultiEditToolOutput) -> str:
186
+ return output.message
187
+
188
+ def render_tool_use_message(
189
+ self,
190
+ input_data: MultiEditToolInput,
191
+ verbose: bool = False,
192
+ ) -> str:
193
+ return f"Multi-editing: {input_data.file_path} ({len(input_data.edits)} edits)"
194
+
195
+ def _apply_edits(self, content: str, edits: List[EditOperation]) -> tuple[str, int]:
196
+ """Apply edits in-memory. Raises ValueError on failure."""
197
+ current = content
198
+ total_replacements = 0
199
+
200
+ for edit in edits:
201
+ # Creation workflow: old_string empty means write provided content when file is empty.
202
+ if edit.old_string == "":
203
+ if current != "":
204
+ raise ValueError(
205
+ "old_string was empty but the file already has content; "
206
+ "use replace_all=true with an explicit old_string instead."
207
+ )
208
+ current = edit.new_string
209
+ total_replacements += 1 if edit.new_string else 0
210
+ continue
211
+
212
+ occurrences = current.count(edit.old_string)
213
+ if occurrences == 0:
214
+ raise ValueError(f"String not found: {edit.old_string!r}")
215
+
216
+ if not edit.replace_all and occurrences > 1:
217
+ raise ValueError(
218
+ f"String appears {occurrences} times. "
219
+ "Provide a unique string or set replace_all=true."
220
+ )
221
+
222
+ if edit.replace_all:
223
+ current = current.replace(edit.old_string, edit.new_string)
224
+ total_replacements += occurrences
225
+ else:
226
+ current = current.replace(edit.old_string, edit.new_string, 1)
227
+ total_replacements += 1
228
+
229
+ return current, total_replacements
230
+
231
+ def _build_diff(
232
+ self, original: str, updated: str, file_path: str
233
+ ) -> tuple[list[str], list[str], int, int]:
234
+ old_lines = original.splitlines(keepends=True)
235
+ new_lines = updated.splitlines(keepends=True)
236
+
237
+ diff = list(
238
+ difflib.unified_diff(
239
+ old_lines,
240
+ new_lines,
241
+ fromfile=file_path,
242
+ tofile=file_path,
243
+ lineterm="",
244
+ )
245
+ )
246
+
247
+ additions = sum(1 for line in diff if line.startswith("+") and not line.startswith("+++"))
248
+ deletions = sum(1 for line in diff if line.startswith("-") and not line.startswith("---"))
249
+
250
+ diff_lines = [line for line in diff[3:]] # skip headers
251
+
252
+ diff_with_line_numbers: list[str] = []
253
+ old_line_num: Optional[int] = None
254
+ new_line_num: Optional[int] = None
255
+
256
+ for line in diff[3:]:
257
+ if line.startswith("@@"):
258
+ import re
259
+
260
+ match = re.search(r"@@ -(\d+),(\d+) \+(\d+),(\d+) @@", line)
261
+ if match:
262
+ old_line_num = int(match.group(1))
263
+ new_line_num = int(match.group(3))
264
+ diff_with_line_numbers.append(f" [dim]{line}[/dim]")
265
+ elif line.startswith("+") and not line.startswith("+++"):
266
+ if new_line_num is not None:
267
+ diff_with_line_numbers.append(
268
+ f" [green]{new_line_num:6d} + {line[1:]}[/green]"
269
+ )
270
+ new_line_num += 1
271
+ else:
272
+ diff_with_line_numbers.append(f" [green]{line}[/green]")
273
+ elif line.startswith("-") and not line.startswith("---"):
274
+ if old_line_num is not None:
275
+ diff_with_line_numbers.append(
276
+ f" [red]{old_line_num:6d} - {line[1:]}[/red]"
277
+ )
278
+ old_line_num += 1
279
+ else:
280
+ diff_with_line_numbers.append(f" [red]{line}[/red]")
281
+ elif line.strip():
282
+ if old_line_num is not None and new_line_num is not None:
283
+ diff_with_line_numbers.append(
284
+ f" {old_line_num:6d} {new_line_num:6d} {line}"
285
+ )
286
+ old_line_num += 1
287
+ new_line_num += 1
288
+ else:
289
+ diff_with_line_numbers.append(f" {line}")
290
+
291
+ return diff_lines, diff_with_line_numbers, additions, deletions
292
+
293
+ async def call(
294
+ self,
295
+ input_data: MultiEditToolInput,
296
+ context: ToolUseContext,
297
+ ) -> AsyncGenerator[ToolOutput, None]:
298
+ """Apply multiple edits atomically."""
299
+ file_path = Path(input_data.file_path).expanduser()
300
+ if not file_path.is_absolute():
301
+ file_path = Path.cwd() / file_path
302
+ file_path = file_path.resolve()
303
+
304
+ existing = file_path.exists()
305
+ original_content = ""
306
+ try:
307
+ if existing:
308
+ original_content = file_path.read_text(encoding="utf-8")
309
+ except Exception as exc: # pragma: no cover - unlikely permission issue
310
+ output = MultiEditToolOutput(
311
+ file_path=str(file_path),
312
+ replacements_made=0,
313
+ success=False,
314
+ message=f"Error reading file: {exc}",
315
+ )
316
+ yield ToolResult(
317
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
318
+ )
319
+ return
320
+
321
+ applied = input_data.edits
322
+ try:
323
+ updated_content, total_replacements = self._apply_edits(original_content, applied)
324
+ except ValueError as exc:
325
+ output = MultiEditToolOutput(
326
+ file_path=str(file_path),
327
+ replacements_made=0,
328
+ success=False,
329
+ message=str(exc),
330
+ applied_edits=applied,
331
+ created=not existing and original_content == "",
332
+ )
333
+ yield ToolResult(
334
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
335
+ )
336
+ return
337
+
338
+ if updated_content == original_content:
339
+ output = MultiEditToolOutput(
340
+ file_path=str(file_path),
341
+ replacements_made=0,
342
+ success=False,
343
+ message="Edits produced no changes.",
344
+ applied_edits=applied,
345
+ created=not existing and original_content == "",
346
+ )
347
+ yield ToolResult(
348
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
349
+ )
350
+ return
351
+
352
+ # Ensure parent exists (validated earlier) and write the file.
353
+ file_path.parent.mkdir(parents=True, exist_ok=True)
354
+ try:
355
+ file_path.write_text(updated_content, encoding="utf-8")
356
+ except Exception as exc:
357
+ output = MultiEditToolOutput(
358
+ file_path=str(file_path),
359
+ replacements_made=0,
360
+ success=False,
361
+ message=f"Error writing file: {exc}",
362
+ applied_edits=applied,
363
+ created=not existing and original_content == "",
364
+ )
365
+ yield ToolResult(
366
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
367
+ )
368
+ return
369
+
370
+ diff_lines, diff_with_line_numbers, additions, deletions = self._build_diff(
371
+ original_content, updated_content, str(file_path)
372
+ )
373
+
374
+ output = MultiEditToolOutput(
375
+ file_path=str(file_path),
376
+ replacements_made=total_replacements,
377
+ success=True,
378
+ message=(
379
+ f"Applied {len(applied)} edit(s) with {total_replacements} replacement(s) "
380
+ f"to {file_path}"
381
+ ),
382
+ additions=additions,
383
+ deletions=deletions,
384
+ diff_lines=diff_lines,
385
+ diff_with_line_numbers=diff_with_line_numbers,
386
+ applied_edits=applied,
387
+ created=not existing and original_content == "",
388
+ )
389
+
390
+ yield ToolResult(
391
+ data=output,
392
+ result_for_assistant=self.render_result_for_assistant(output),
393
+ )