ripperdoc 0.2.9__py3-none-any.whl → 0.2.10__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 +235 -14
- ripperdoc/cli/commands/__init__.py +2 -0
- ripperdoc/cli/commands/agents_cmd.py +132 -5
- ripperdoc/cli/commands/clear_cmd.py +8 -0
- ripperdoc/cli/commands/exit_cmd.py +1 -0
- ripperdoc/cli/commands/models_cmd.py +3 -3
- ripperdoc/cli/commands/resume_cmd.py +4 -0
- ripperdoc/cli/commands/stats_cmd.py +244 -0
- ripperdoc/cli/ui/panels.py +1 -0
- ripperdoc/cli/ui/rich_ui.py +295 -24
- ripperdoc/cli/ui/spinner.py +30 -18
- ripperdoc/cli/ui/thinking_spinner.py +1 -2
- ripperdoc/cli/ui/wizard.py +6 -8
- ripperdoc/core/agents.py +10 -3
- ripperdoc/core/config.py +3 -6
- ripperdoc/core/default_tools.py +90 -10
- ripperdoc/core/hooks/events.py +4 -0
- ripperdoc/core/hooks/llm_callback.py +59 -0
- ripperdoc/core/permissions.py +78 -4
- ripperdoc/core/providers/openai.py +29 -19
- ripperdoc/core/query.py +192 -31
- ripperdoc/core/tool.py +9 -4
- ripperdoc/sdk/client.py +77 -2
- ripperdoc/tools/background_shell.py +305 -134
- ripperdoc/tools/bash_tool.py +42 -13
- ripperdoc/tools/file_edit_tool.py +159 -50
- ripperdoc/tools/file_read_tool.py +20 -0
- ripperdoc/tools/file_write_tool.py +7 -8
- ripperdoc/tools/lsp_tool.py +615 -0
- ripperdoc/tools/task_tool.py +514 -65
- ripperdoc/utils/conversation_compaction.py +1 -1
- ripperdoc/utils/file_watch.py +206 -3
- ripperdoc/utils/lsp.py +806 -0
- ripperdoc/utils/message_formatting.py +5 -2
- ripperdoc/utils/messages.py +21 -1
- ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
- ripperdoc/utils/session_heatmap.py +244 -0
- ripperdoc/utils/session_stats.py +293 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/RECORD +45 -39
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
Allows the AI to edit files by replacing text.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import contextlib
|
|
6
7
|
import os
|
|
8
|
+
import tempfile
|
|
7
9
|
from pathlib import Path
|
|
8
|
-
from typing import AsyncGenerator, List, Optional
|
|
10
|
+
from typing import AsyncGenerator, Generator, List, Optional, TextIO
|
|
9
11
|
from pydantic import BaseModel, Field
|
|
10
12
|
|
|
11
13
|
from ripperdoc.core.tool import (
|
|
@@ -20,9 +22,42 @@ from ripperdoc.utils.log import get_logger
|
|
|
20
22
|
from ripperdoc.utils.file_watch import record_snapshot
|
|
21
23
|
from ripperdoc.utils.path_ignore import check_path_for_tool
|
|
22
24
|
|
|
25
|
+
# Import fcntl for file locking on Unix systems
|
|
26
|
+
try:
|
|
27
|
+
import fcntl
|
|
28
|
+
|
|
29
|
+
HAS_FCNTL = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
HAS_FCNTL = False
|
|
32
|
+
|
|
23
33
|
logger = get_logger()
|
|
24
34
|
|
|
25
35
|
|
|
36
|
+
@contextlib.contextmanager
|
|
37
|
+
def _file_lock(file_handle: TextIO, exclusive: bool = True) -> Generator[None, None, None]:
|
|
38
|
+
"""Acquire a file lock, with fallback for systems without fcntl.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
file_handle: An open file handle to lock
|
|
42
|
+
exclusive: If True, acquire exclusive lock; otherwise shared lock
|
|
43
|
+
|
|
44
|
+
Yields:
|
|
45
|
+
None
|
|
46
|
+
"""
|
|
47
|
+
if not HAS_FCNTL:
|
|
48
|
+
# On Windows or systems without fcntl, skip locking
|
|
49
|
+
yield
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
|
|
53
|
+
try:
|
|
54
|
+
fcntl.flock(file_handle.fileno(), lock_type)
|
|
55
|
+
yield
|
|
56
|
+
finally:
|
|
57
|
+
with contextlib.suppress(OSError):
|
|
58
|
+
fcntl.flock(file_handle.fileno(), fcntl.LOCK_UN)
|
|
59
|
+
|
|
60
|
+
|
|
26
61
|
class FileEditToolInput(BaseModel):
|
|
27
62
|
"""Input schema for FileEditTool."""
|
|
28
63
|
|
|
@@ -180,57 +215,131 @@ match exactly (including whitespace and indentation)."""
|
|
|
180
215
|
async def call(
|
|
181
216
|
self, input_data: FileEditToolInput, context: ToolUseContext
|
|
182
217
|
) -> AsyncGenerator[ToolOutput, None]:
|
|
183
|
-
"""Edit the file."""
|
|
218
|
+
"""Edit the file with TOCTOU protection."""
|
|
219
|
+
|
|
220
|
+
abs_file_path = os.path.abspath(input_data.file_path)
|
|
221
|
+
file_state_cache = getattr(context, "file_state_cache", {})
|
|
222
|
+
file_snapshot = file_state_cache.get(abs_file_path)
|
|
184
223
|
|
|
185
224
|
try:
|
|
186
|
-
#
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
225
|
+
# Open file with exclusive lock to prevent concurrent modifications
|
|
226
|
+
# Use r+ mode to get a file handle we can lock before reading
|
|
227
|
+
with open(abs_file_path, "r+", encoding="utf-8") as f:
|
|
228
|
+
with _file_lock(f, exclusive=True):
|
|
229
|
+
# Re-check mtime AFTER acquiring lock to close TOCTOU window
|
|
230
|
+
# This is the key fix: validate mtime while holding the lock
|
|
231
|
+
if file_snapshot:
|
|
232
|
+
try:
|
|
233
|
+
current_mtime = os.fstat(f.fileno()).st_mtime
|
|
234
|
+
if current_mtime > file_snapshot.timestamp:
|
|
235
|
+
output = FileEditToolOutput(
|
|
236
|
+
file_path=input_data.file_path,
|
|
237
|
+
replacements_made=0,
|
|
238
|
+
success=False,
|
|
239
|
+
message="File has been modified since read, either by the user "
|
|
240
|
+
"or by a linter. Read it again before attempting to edit it.",
|
|
241
|
+
)
|
|
242
|
+
yield ToolResult(
|
|
243
|
+
data=output,
|
|
244
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
245
|
+
)
|
|
246
|
+
return
|
|
247
|
+
except OSError:
|
|
248
|
+
pass # fstat failed, proceed anyway
|
|
249
|
+
|
|
250
|
+
# Read content while holding the lock
|
|
251
|
+
content = f.read()
|
|
252
|
+
|
|
253
|
+
# Check if old_string exists
|
|
254
|
+
if input_data.old_string not in content:
|
|
255
|
+
output = FileEditToolOutput(
|
|
256
|
+
file_path=input_data.file_path,
|
|
257
|
+
replacements_made=0,
|
|
258
|
+
success=False,
|
|
259
|
+
message=f"String not found in file: {input_data.file_path}",
|
|
260
|
+
)
|
|
261
|
+
yield ToolResult(
|
|
262
|
+
data=output,
|
|
263
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
264
|
+
)
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
# Count occurrences
|
|
268
|
+
occurrence_count = content.count(input_data.old_string)
|
|
269
|
+
|
|
270
|
+
# Check for ambiguity if not replace_all
|
|
271
|
+
if not input_data.replace_all and occurrence_count > 1:
|
|
272
|
+
output = FileEditToolOutput(
|
|
273
|
+
file_path=input_data.file_path,
|
|
274
|
+
replacements_made=0,
|
|
275
|
+
success=False,
|
|
276
|
+
message=f"String appears {occurrence_count} times in file. "
|
|
277
|
+
f"Either provide a unique string or use replace_all=true",
|
|
278
|
+
)
|
|
279
|
+
yield ToolResult(
|
|
280
|
+
data=output,
|
|
281
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
282
|
+
)
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
# Perform replacement
|
|
286
|
+
if input_data.replace_all:
|
|
287
|
+
new_content = content.replace(input_data.old_string, input_data.new_string)
|
|
288
|
+
replacements = occurrence_count
|
|
289
|
+
else:
|
|
290
|
+
new_content = content.replace(
|
|
291
|
+
input_data.old_string, input_data.new_string, 1
|
|
292
|
+
)
|
|
293
|
+
replacements = 1
|
|
294
|
+
|
|
295
|
+
# Atomic write: write to temp file then rename
|
|
296
|
+
# This ensures the file is either fully written or not at all
|
|
297
|
+
file_dir = os.path.dirname(abs_file_path)
|
|
298
|
+
try:
|
|
299
|
+
# Create temp file in same directory to ensure same filesystem
|
|
300
|
+
fd, temp_path = tempfile.mkstemp(
|
|
301
|
+
dir=file_dir, prefix=".ripperdoc_edit_", suffix=".tmp"
|
|
302
|
+
)
|
|
303
|
+
try:
|
|
304
|
+
with os.fdopen(fd, "w", encoding="utf-8") as temp_f:
|
|
305
|
+
temp_f.write(new_content)
|
|
306
|
+
# Preserve original file permissions
|
|
307
|
+
original_stat = os.fstat(f.fileno())
|
|
308
|
+
os.chmod(temp_path, original_stat.st_mode)
|
|
309
|
+
# Atomic replace (works on Unix, best-effort on Windows)
|
|
310
|
+
os.replace(temp_path, abs_file_path)
|
|
311
|
+
except Exception:
|
|
312
|
+
# Clean up temp file on failure
|
|
313
|
+
with contextlib.suppress(OSError):
|
|
314
|
+
os.unlink(temp_path)
|
|
315
|
+
raise
|
|
316
|
+
except OSError as atomic_error:
|
|
317
|
+
# Fallback to in-place write if atomic write fails
|
|
318
|
+
# (e.g., cross-filesystem issues)
|
|
319
|
+
# Re-verify file hasn't changed before fallback write (TOCTOU protection)
|
|
320
|
+
f.seek(0)
|
|
321
|
+
current_content = f.read()
|
|
322
|
+
if current_content != content:
|
|
323
|
+
output = FileEditToolOutput(
|
|
324
|
+
file_path=input_data.file_path,
|
|
325
|
+
replacements_made=0,
|
|
326
|
+
success=False,
|
|
327
|
+
message="File was modified during atomic write fallback. Please retry.",
|
|
328
|
+
)
|
|
329
|
+
yield ToolResult(
|
|
330
|
+
data=output,
|
|
331
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
332
|
+
)
|
|
333
|
+
return
|
|
334
|
+
f.seek(0)
|
|
335
|
+
f.truncate()
|
|
336
|
+
f.write(new_content)
|
|
337
|
+
logger.debug(
|
|
338
|
+
"[file_edit_tool] Atomic write failed, used fallback: %s",
|
|
339
|
+
atomic_error,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Record the new snapshot after successful edit
|
|
234
343
|
try:
|
|
235
344
|
record_snapshot(
|
|
236
345
|
abs_file_path,
|
|
@@ -22,6 +22,10 @@ from ripperdoc.utils.path_ignore import check_path_for_tool
|
|
|
22
22
|
|
|
23
23
|
logger = get_logger()
|
|
24
24
|
|
|
25
|
+
# Maximum file size to read (default 50MB, configurable via env)
|
|
26
|
+
MAX_FILE_SIZE_MB = float(os.getenv("RIPPERDOC_MAX_READ_FILE_SIZE_MB", "50"))
|
|
27
|
+
MAX_FILE_SIZE_BYTES = int(MAX_FILE_SIZE_MB * 1024 * 1024)
|
|
28
|
+
|
|
25
29
|
|
|
26
30
|
class FileReadToolInput(BaseModel):
|
|
27
31
|
"""Input schema for FileReadTool."""
|
|
@@ -140,6 +144,22 @@ and limit to read only a portion of the file."""
|
|
|
140
144
|
"""Read the file."""
|
|
141
145
|
|
|
142
146
|
try:
|
|
147
|
+
# Check file size before reading to prevent memory exhaustion
|
|
148
|
+
file_size = os.path.getsize(input_data.file_path)
|
|
149
|
+
if file_size > MAX_FILE_SIZE_BYTES:
|
|
150
|
+
error_output = FileReadToolOutput(
|
|
151
|
+
content=f"File too large to read: {file_size / (1024*1024):.1f}MB exceeds limit of {MAX_FILE_SIZE_MB}MB. Use offset and limit parameters to read portions.",
|
|
152
|
+
file_path=input_data.file_path,
|
|
153
|
+
line_count=0,
|
|
154
|
+
offset=0,
|
|
155
|
+
limit=None,
|
|
156
|
+
)
|
|
157
|
+
yield ToolResult(
|
|
158
|
+
data=error_output,
|
|
159
|
+
result_for_assistant=f"Error: File {input_data.file_path} is too large ({file_size / (1024*1024):.1f}MB). Maximum size is {MAX_FILE_SIZE_MB}MB. Use offset and limit to read portions.",
|
|
160
|
+
)
|
|
161
|
+
return
|
|
162
|
+
|
|
143
163
|
with open(input_data.file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
144
164
|
lines = f.readlines()
|
|
145
165
|
|
|
@@ -104,6 +104,13 @@ NEVER write new files unless explicitly required by the user."""
|
|
|
104
104
|
|
|
105
105
|
file_path = os.path.abspath(input_data.file_path)
|
|
106
106
|
|
|
107
|
+
file_path_obj = Path(file_path)
|
|
108
|
+
should_proceed, warning_msg = check_path_for_tool(
|
|
109
|
+
file_path_obj, tool_name="Write", warn_only=True
|
|
110
|
+
)
|
|
111
|
+
if warning_msg:
|
|
112
|
+
logger.warning("[file_write_tool] %s", warning_msg)
|
|
113
|
+
|
|
107
114
|
# If file doesn't exist, it's a new file - allow without reading first
|
|
108
115
|
if not os.path.exists(file_path):
|
|
109
116
|
return ValidationResult(result=True)
|
|
@@ -132,14 +139,6 @@ NEVER write new files unless explicitly required by the user."""
|
|
|
132
139
|
except OSError:
|
|
133
140
|
pass # File mtime check failed, proceed anyway
|
|
134
141
|
|
|
135
|
-
# Check if path is ignored (warning for write operations)
|
|
136
|
-
file_path_obj = Path(file_path)
|
|
137
|
-
should_proceed, warning_msg = check_path_for_tool(
|
|
138
|
-
file_path_obj, tool_name="Write", warn_only=True
|
|
139
|
-
)
|
|
140
|
-
if warning_msg:
|
|
141
|
-
logger.warning("[file_write_tool] %s", warning_msg)
|
|
142
|
-
|
|
143
142
|
return ValidationResult(result=True)
|
|
144
143
|
|
|
145
144
|
def render_result_for_assistant(self, output: FileWriteToolOutput) -> str:
|