ripperdoc 0.3.1__py3-none-any.whl → 0.3.2__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 +9 -1
- ripperdoc/cli/commands/agents_cmd.py +93 -53
- ripperdoc/cli/commands/mcp_cmd.py +3 -0
- ripperdoc/cli/commands/models_cmd.py +768 -283
- ripperdoc/cli/commands/permissions_cmd.py +107 -52
- ripperdoc/cli/commands/resume_cmd.py +61 -51
- ripperdoc/cli/commands/themes_cmd.py +31 -1
- ripperdoc/cli/ui/agents_tui/__init__.py +3 -0
- ripperdoc/cli/ui/agents_tui/textual_app.py +1138 -0
- ripperdoc/cli/ui/choice.py +376 -0
- ripperdoc/cli/ui/models_tui/__init__.py +5 -0
- ripperdoc/cli/ui/models_tui/textual_app.py +698 -0
- ripperdoc/cli/ui/panels.py +19 -4
- ripperdoc/cli/ui/permissions_tui/__init__.py +3 -0
- ripperdoc/cli/ui/permissions_tui/textual_app.py +526 -0
- ripperdoc/cli/ui/provider_options.py +220 -80
- ripperdoc/cli/ui/rich_ui.py +9 -11
- ripperdoc/cli/ui/tips.py +89 -0
- ripperdoc/cli/ui/wizard.py +98 -45
- ripperdoc/core/config.py +3 -0
- ripperdoc/core/permissions.py +25 -70
- ripperdoc/core/providers/anthropic.py +11 -0
- ripperdoc/protocol/stdio.py +3 -1
- ripperdoc/tools/bash_tool.py +2 -0
- ripperdoc/tools/file_edit_tool.py +100 -181
- ripperdoc/tools/file_read_tool.py +101 -25
- ripperdoc/tools/multi_edit_tool.py +239 -91
- ripperdoc/tools/notebook_edit_tool.py +11 -29
- ripperdoc/utils/file_editing.py +164 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +11 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +37 -28
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/top_level.txt +0 -0
|
@@ -5,7 +5,6 @@ Allows performing multiple exact string replacements in a single file atomically
|
|
|
5
5
|
|
|
6
6
|
import difflib
|
|
7
7
|
import os
|
|
8
|
-
from pathlib import Path
|
|
9
8
|
from typing import AsyncGenerator, Optional, List
|
|
10
9
|
from textwrap import dedent
|
|
11
10
|
from pydantic import BaseModel, Field
|
|
@@ -19,7 +18,13 @@ from ripperdoc.core.tool import (
|
|
|
19
18
|
ValidationResult,
|
|
20
19
|
)
|
|
21
20
|
from ripperdoc.utils.log import get_logger
|
|
22
|
-
from ripperdoc.utils.
|
|
21
|
+
from ripperdoc.utils.file_editing import (
|
|
22
|
+
atomic_write_with_fallback,
|
|
23
|
+
open_locked_file,
|
|
24
|
+
resolve_input_path,
|
|
25
|
+
safe_record_snapshot,
|
|
26
|
+
select_write_encoding,
|
|
27
|
+
)
|
|
23
28
|
from ripperdoc.tools.file_read_tool import detect_file_encoding
|
|
24
29
|
|
|
25
30
|
logger = get_logger()
|
|
@@ -167,10 +172,7 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
|
|
|
167
172
|
input_data: MultiEditToolInput,
|
|
168
173
|
context: Optional[ToolUseContext] = None,
|
|
169
174
|
) -> ValidationResult:
|
|
170
|
-
path =
|
|
171
|
-
if not path.is_absolute():
|
|
172
|
-
path = Path.cwd() / path
|
|
173
|
-
resolved_path = str(path.resolve())
|
|
175
|
+
path, cache_key = resolve_input_path(input_data.file_path)
|
|
174
176
|
|
|
175
177
|
# Ensure edits differ.
|
|
176
178
|
for edit in input_data.edits:
|
|
@@ -197,7 +199,7 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
|
|
|
197
199
|
# If file exists, check if it has been read before editing
|
|
198
200
|
if path.exists() and not is_creation:
|
|
199
201
|
file_state_cache = getattr(context, "file_state_cache", {}) if context else {}
|
|
200
|
-
file_snapshot = file_state_cache.get(
|
|
202
|
+
file_snapshot = file_state_cache.get(cache_key)
|
|
201
203
|
|
|
202
204
|
if not file_snapshot:
|
|
203
205
|
return ValidationResult(
|
|
@@ -208,7 +210,7 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
|
|
|
208
210
|
|
|
209
211
|
# Check if file has been modified since it was read
|
|
210
212
|
try:
|
|
211
|
-
current_mtime = os.path.getmtime(
|
|
213
|
+
current_mtime = os.path.getmtime(cache_key)
|
|
212
214
|
if current_mtime > file_snapshot.timestamp:
|
|
213
215
|
return ValidationResult(
|
|
214
216
|
result=False,
|
|
@@ -335,143 +337,289 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
|
|
|
335
337
|
context: ToolUseContext,
|
|
336
338
|
) -> AsyncGenerator[ToolOutput, None]:
|
|
337
339
|
"""Apply multiple edits atomically."""
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
file_path = file_path.resolve()
|
|
340
|
+
resolved_path, cache_key = resolve_input_path(input_data.file_path)
|
|
341
|
+
file_state_cache = getattr(context, "file_state_cache", {})
|
|
342
|
+
file_snapshot = file_state_cache.get(cache_key)
|
|
342
343
|
|
|
343
|
-
existing =
|
|
344
|
+
existing = resolved_path.exists()
|
|
345
|
+
created = not existing
|
|
344
346
|
original_content = ""
|
|
345
347
|
file_encoding = "utf-8"
|
|
346
348
|
|
|
347
|
-
# Detect file encoding if file exists
|
|
348
349
|
if existing:
|
|
349
|
-
detected_encoding, _ = detect_file_encoding(str(
|
|
350
|
+
detected_encoding, _ = detect_file_encoding(str(resolved_path))
|
|
350
351
|
if detected_encoding:
|
|
351
352
|
file_encoding = detected_encoding
|
|
352
353
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
354
|
+
applied = input_data.edits
|
|
355
|
+
|
|
356
|
+
if not existing:
|
|
357
|
+
try:
|
|
358
|
+
updated_content, total_replacements = self._apply_edits(original_content, applied)
|
|
359
|
+
except ValueError as exc:
|
|
360
|
+
output = MultiEditToolOutput(
|
|
361
|
+
file_path=str(resolved_path),
|
|
362
|
+
replacements_made=0,
|
|
363
|
+
success=False,
|
|
364
|
+
message=str(exc),
|
|
365
|
+
applied_edits=applied,
|
|
366
|
+
created=True,
|
|
367
|
+
)
|
|
368
|
+
yield ToolResult(
|
|
369
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
370
|
+
)
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
if updated_content == original_content:
|
|
374
|
+
output = MultiEditToolOutput(
|
|
375
|
+
file_path=str(resolved_path),
|
|
376
|
+
replacements_made=0,
|
|
377
|
+
success=False,
|
|
378
|
+
message="Edits produced no changes.",
|
|
379
|
+
applied_edits=applied,
|
|
380
|
+
created=True,
|
|
381
|
+
)
|
|
382
|
+
yield ToolResult(
|
|
383
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
384
|
+
)
|
|
385
|
+
return
|
|
386
|
+
|
|
387
|
+
# Ensure parent exists (validated earlier) and write the file.
|
|
388
|
+
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
|
389
|
+
|
|
390
|
+
write_encoding = select_write_encoding(
|
|
391
|
+
file_encoding,
|
|
392
|
+
updated_content,
|
|
393
|
+
resolved_path,
|
|
394
|
+
log_prefix="[multi_edit_tool]",
|
|
372
395
|
)
|
|
373
|
-
return
|
|
374
396
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
397
|
+
try:
|
|
398
|
+
with open(resolved_path, "x", encoding=write_encoding) as handle:
|
|
399
|
+
handle.write(updated_content)
|
|
400
|
+
except FileExistsError:
|
|
401
|
+
output = MultiEditToolOutput(
|
|
402
|
+
file_path=str(resolved_path),
|
|
403
|
+
replacements_made=0,
|
|
404
|
+
success=False,
|
|
405
|
+
message=(
|
|
406
|
+
"File was created while preparing edits. "
|
|
407
|
+
"Read it first before attempting to edit it."
|
|
408
|
+
),
|
|
409
|
+
applied_edits=applied,
|
|
410
|
+
created=False,
|
|
411
|
+
)
|
|
412
|
+
yield ToolResult(
|
|
413
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
414
|
+
)
|
|
415
|
+
return
|
|
416
|
+
except (OSError, IOError, PermissionError, UnicodeDecodeError) as exc:
|
|
417
|
+
logger.warning(
|
|
418
|
+
"[multi_edit_tool] Error writing edited file: %s: %s",
|
|
419
|
+
type(exc).__name__,
|
|
420
|
+
exc,
|
|
421
|
+
extra={"file_path": str(resolved_path)},
|
|
422
|
+
)
|
|
423
|
+
output = MultiEditToolOutput(
|
|
424
|
+
file_path=str(resolved_path),
|
|
425
|
+
replacements_made=0,
|
|
426
|
+
success=False,
|
|
427
|
+
message=f"Error writing file: {exc}",
|
|
428
|
+
applied_edits=applied,
|
|
429
|
+
created=True,
|
|
430
|
+
)
|
|
431
|
+
yield ToolResult(
|
|
432
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
433
|
+
)
|
|
434
|
+
return
|
|
435
|
+
|
|
436
|
+
safe_record_snapshot(
|
|
437
|
+
cache_key,
|
|
438
|
+
updated_content,
|
|
439
|
+
file_state_cache,
|
|
440
|
+
encoding=write_encoding,
|
|
441
|
+
log_prefix="[multi_edit_tool]",
|
|
386
442
|
)
|
|
387
|
-
|
|
388
|
-
|
|
443
|
+
|
|
444
|
+
diff_lines, diff_with_line_numbers, additions, deletions = self._build_diff(
|
|
445
|
+
original_content, updated_content, str(resolved_path)
|
|
389
446
|
)
|
|
390
|
-
return
|
|
391
447
|
|
|
392
|
-
if updated_content == original_content:
|
|
393
448
|
output = MultiEditToolOutput(
|
|
394
|
-
file_path=str(
|
|
395
|
-
replacements_made=
|
|
396
|
-
success=
|
|
397
|
-
message=
|
|
449
|
+
file_path=str(resolved_path),
|
|
450
|
+
replacements_made=total_replacements,
|
|
451
|
+
success=True,
|
|
452
|
+
message=(
|
|
453
|
+
f"Applied {len(applied)} edit(s) with {total_replacements} replacement(s) "
|
|
454
|
+
f"to {resolved_path}"
|
|
455
|
+
),
|
|
456
|
+
additions=additions,
|
|
457
|
+
deletions=deletions,
|
|
458
|
+
diff_lines=diff_lines,
|
|
459
|
+
diff_with_line_numbers=diff_with_line_numbers,
|
|
398
460
|
applied_edits=applied,
|
|
399
|
-
created=
|
|
461
|
+
created=True,
|
|
400
462
|
)
|
|
463
|
+
|
|
401
464
|
yield ToolResult(
|
|
402
|
-
data=output,
|
|
465
|
+
data=output,
|
|
466
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
403
467
|
)
|
|
404
468
|
return
|
|
405
469
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
# Verify content can be encoded, fall back to UTF-8 if needed
|
|
470
|
+
updated_content = ""
|
|
471
|
+
total_replacements = 0
|
|
410
472
|
write_encoding = file_encoding
|
|
411
|
-
try:
|
|
412
|
-
updated_content.encode(file_encoding)
|
|
413
|
-
except (UnicodeEncodeError, LookupError):
|
|
414
|
-
logger.info(
|
|
415
|
-
"New content cannot be encoded with %s, using UTF-8 for %s",
|
|
416
|
-
file_encoding,
|
|
417
|
-
str(file_path),
|
|
418
|
-
)
|
|
419
|
-
write_encoding = "utf-8"
|
|
420
473
|
|
|
421
474
|
try:
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
475
|
+
with open_locked_file(resolved_path, file_encoding) as (
|
|
476
|
+
handle,
|
|
477
|
+
pre_lock_mtime,
|
|
478
|
+
post_lock_mtime,
|
|
479
|
+
):
|
|
480
|
+
if pre_lock_mtime is not None and post_lock_mtime is not None:
|
|
481
|
+
if post_lock_mtime > pre_lock_mtime:
|
|
482
|
+
output = MultiEditToolOutput(
|
|
483
|
+
file_path=str(resolved_path),
|
|
484
|
+
replacements_made=0,
|
|
485
|
+
success=False,
|
|
486
|
+
message="File was modified while acquiring lock. Please retry.",
|
|
487
|
+
applied_edits=applied,
|
|
488
|
+
created=False,
|
|
489
|
+
)
|
|
490
|
+
yield ToolResult(
|
|
491
|
+
data=output,
|
|
492
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
493
|
+
)
|
|
494
|
+
return
|
|
495
|
+
|
|
496
|
+
if file_snapshot and post_lock_mtime is not None:
|
|
497
|
+
if post_lock_mtime > file_snapshot.timestamp:
|
|
498
|
+
output = MultiEditToolOutput(
|
|
499
|
+
file_path=str(resolved_path),
|
|
500
|
+
replacements_made=0,
|
|
501
|
+
success=False,
|
|
502
|
+
message=(
|
|
503
|
+
"File has been modified since read, either by the user "
|
|
504
|
+
"or by a linter. Read it again before attempting to edit it."
|
|
505
|
+
),
|
|
506
|
+
applied_edits=applied,
|
|
507
|
+
created=False,
|
|
508
|
+
)
|
|
509
|
+
yield ToolResult(
|
|
510
|
+
data=output,
|
|
511
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
512
|
+
)
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
original_content = handle.read()
|
|
516
|
+
|
|
517
|
+
try:
|
|
518
|
+
updated_content, total_replacements = self._apply_edits(
|
|
519
|
+
original_content, applied
|
|
520
|
+
)
|
|
521
|
+
except ValueError as exc:
|
|
522
|
+
output = MultiEditToolOutput(
|
|
523
|
+
file_path=str(resolved_path),
|
|
524
|
+
replacements_made=0,
|
|
525
|
+
success=False,
|
|
526
|
+
message=str(exc),
|
|
527
|
+
applied_edits=applied,
|
|
528
|
+
created=False,
|
|
529
|
+
)
|
|
530
|
+
yield ToolResult(
|
|
531
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
532
|
+
)
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
if updated_content == original_content:
|
|
536
|
+
output = MultiEditToolOutput(
|
|
537
|
+
file_path=str(resolved_path),
|
|
538
|
+
replacements_made=0,
|
|
539
|
+
success=False,
|
|
540
|
+
message="Edits produced no changes.",
|
|
541
|
+
applied_edits=applied,
|
|
542
|
+
created=False,
|
|
543
|
+
)
|
|
544
|
+
yield ToolResult(
|
|
545
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
546
|
+
)
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
write_encoding = select_write_encoding(
|
|
550
|
+
file_encoding,
|
|
426
551
|
updated_content,
|
|
427
|
-
|
|
428
|
-
|
|
552
|
+
resolved_path,
|
|
553
|
+
log_prefix="[multi_edit_tool]",
|
|
429
554
|
)
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
555
|
+
write_error = atomic_write_with_fallback(
|
|
556
|
+
handle,
|
|
557
|
+
resolved_path,
|
|
558
|
+
updated_content,
|
|
559
|
+
write_encoding,
|
|
560
|
+
original_content,
|
|
561
|
+
temp_prefix=".ripperdoc_multi_edit_",
|
|
562
|
+
log_prefix="[multi_edit_tool]",
|
|
563
|
+
conflict_message="File was modified during atomic write fallback. Please retry.",
|
|
436
564
|
)
|
|
565
|
+
if write_error:
|
|
566
|
+
output = MultiEditToolOutput(
|
|
567
|
+
file_path=str(resolved_path),
|
|
568
|
+
replacements_made=0,
|
|
569
|
+
success=False,
|
|
570
|
+
message=write_error,
|
|
571
|
+
applied_edits=applied,
|
|
572
|
+
created=False,
|
|
573
|
+
)
|
|
574
|
+
yield ToolResult(
|
|
575
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
576
|
+
)
|
|
577
|
+
return
|
|
437
578
|
except (OSError, IOError, PermissionError, UnicodeDecodeError) as exc:
|
|
579
|
+
# pragma: no cover - unlikely permission issue
|
|
438
580
|
logger.warning(
|
|
439
|
-
"[multi_edit_tool] Error
|
|
581
|
+
"[multi_edit_tool] Error reading file before edits: %s: %s",
|
|
440
582
|
type(exc).__name__,
|
|
441
583
|
exc,
|
|
442
|
-
extra={"file_path": str(
|
|
584
|
+
extra={"file_path": str(resolved_path)},
|
|
443
585
|
)
|
|
444
586
|
output = MultiEditToolOutput(
|
|
445
|
-
file_path=str(
|
|
587
|
+
file_path=str(resolved_path),
|
|
446
588
|
replacements_made=0,
|
|
447
589
|
success=False,
|
|
448
|
-
message=f"Error
|
|
449
|
-
applied_edits=applied,
|
|
450
|
-
created=not existing and original_content == "",
|
|
590
|
+
message=f"Error reading file: {exc}",
|
|
451
591
|
)
|
|
452
592
|
yield ToolResult(
|
|
453
593
|
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
454
594
|
)
|
|
455
595
|
return
|
|
456
596
|
|
|
597
|
+
safe_record_snapshot(
|
|
598
|
+
cache_key,
|
|
599
|
+
updated_content,
|
|
600
|
+
file_state_cache,
|
|
601
|
+
encoding=write_encoding,
|
|
602
|
+
log_prefix="[multi_edit_tool]",
|
|
603
|
+
)
|
|
604
|
+
|
|
457
605
|
diff_lines, diff_with_line_numbers, additions, deletions = self._build_diff(
|
|
458
|
-
original_content, updated_content, str(
|
|
606
|
+
original_content, updated_content, str(resolved_path)
|
|
459
607
|
)
|
|
460
608
|
|
|
461
609
|
output = MultiEditToolOutput(
|
|
462
|
-
file_path=str(
|
|
610
|
+
file_path=str(resolved_path),
|
|
463
611
|
replacements_made=total_replacements,
|
|
464
612
|
success=True,
|
|
465
613
|
message=(
|
|
466
614
|
f"Applied {len(applied)} edit(s) with {total_replacements} replacement(s) "
|
|
467
|
-
f"to {
|
|
615
|
+
f"to {resolved_path}"
|
|
468
616
|
),
|
|
469
617
|
additions=additions,
|
|
470
618
|
deletions=deletions,
|
|
471
619
|
diff_lines=diff_lines,
|
|
472
620
|
diff_with_line_numbers=diff_with_line_numbers,
|
|
473
621
|
applied_edits=applied,
|
|
474
|
-
created=
|
|
622
|
+
created=created,
|
|
475
623
|
)
|
|
476
624
|
|
|
477
625
|
yield ToolResult(
|
|
@@ -4,10 +4,8 @@ Allows performing insert/replace/delete operations on Jupyter notebook cells.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
|
-
import os
|
|
8
7
|
import random
|
|
9
8
|
import string
|
|
10
|
-
from pathlib import Path
|
|
11
9
|
from textwrap import dedent
|
|
12
10
|
from typing import AsyncGenerator, List, Optional
|
|
13
11
|
from pydantic import BaseModel, Field
|
|
@@ -21,18 +19,12 @@ from ripperdoc.core.tool import (
|
|
|
21
19
|
ValidationResult,
|
|
22
20
|
)
|
|
23
21
|
from ripperdoc.utils.log import get_logger
|
|
24
|
-
from ripperdoc.utils.
|
|
22
|
+
from ripperdoc.utils.file_editing import resolve_input_path, safe_record_snapshot
|
|
25
23
|
|
|
26
24
|
|
|
27
25
|
logger = get_logger()
|
|
28
26
|
|
|
29
27
|
|
|
30
|
-
def _resolve_path(path_str: str) -> Path:
|
|
31
|
-
"""Return an absolute Path, interpreting relative paths from CWD."""
|
|
32
|
-
path = Path(path_str).expanduser()
|
|
33
|
-
return path if path.is_absolute() else Path.cwd() / path
|
|
34
|
-
|
|
35
|
-
|
|
36
28
|
def _generate_cell_id() -> str:
|
|
37
29
|
"""Generate a short random cell id."""
|
|
38
30
|
return "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
|
|
@@ -137,8 +129,7 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
|
|
|
137
129
|
async def validate_input(
|
|
138
130
|
self, input_data: NotebookEditInput, context: Optional[ToolUseContext] = None
|
|
139
131
|
) -> ValidationResult:
|
|
140
|
-
path =
|
|
141
|
-
resolved_path = str(path.resolve())
|
|
132
|
+
path, cache_key = resolve_input_path(input_data.notebook_path)
|
|
142
133
|
|
|
143
134
|
if not path.exists():
|
|
144
135
|
return ValidationResult(
|
|
@@ -175,7 +166,7 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
|
|
|
175
166
|
|
|
176
167
|
# Check if file has been read before editing
|
|
177
168
|
file_state_cache = getattr(context, "file_state_cache", {}) if context else {}
|
|
178
|
-
file_snapshot = file_state_cache.get(
|
|
169
|
+
file_snapshot = file_state_cache.get(cache_key)
|
|
179
170
|
|
|
180
171
|
if not file_snapshot:
|
|
181
172
|
return ValidationResult(
|
|
@@ -186,7 +177,7 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
|
|
|
186
177
|
|
|
187
178
|
# Check if file has been modified since it was read
|
|
188
179
|
try:
|
|
189
|
-
current_mtime = os.path.getmtime(
|
|
180
|
+
current_mtime = os.path.getmtime(cache_key)
|
|
190
181
|
if current_mtime > file_snapshot.timestamp:
|
|
191
182
|
return ValidationResult(
|
|
192
183
|
result=False,
|
|
@@ -249,7 +240,7 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
|
|
|
249
240
|
async def call(
|
|
250
241
|
self, input_data: NotebookEditInput, context: ToolUseContext
|
|
251
242
|
) -> AsyncGenerator[ToolOutput, None]:
|
|
252
|
-
path =
|
|
243
|
+
path, cache_key = resolve_input_path(input_data.notebook_path)
|
|
253
244
|
mode = (input_data.edit_mode or "replace").lower()
|
|
254
245
|
cell_type = (input_data.cell_type or "").lower() or None
|
|
255
246
|
new_source = input_data.new_source
|
|
@@ -315,21 +306,12 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
|
|
|
315
306
|
)
|
|
316
307
|
|
|
317
308
|
path.write_text(json.dumps(nb_json, indent=1), encoding="utf-8")
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
getattr(context, "file_state_cache", {}),
|
|
325
|
-
)
|
|
326
|
-
except (OSError, IOError, RuntimeError) as exc:
|
|
327
|
-
logger.warning(
|
|
328
|
-
"[notebook_edit_tool] Failed to record file snapshot: %s: %s",
|
|
329
|
-
type(exc).__name__,
|
|
330
|
-
exc,
|
|
331
|
-
extra={"file_path": abs_notebook_path},
|
|
332
|
-
)
|
|
309
|
+
safe_record_snapshot(
|
|
310
|
+
cache_key,
|
|
311
|
+
json.dumps(nb_json, indent=1),
|
|
312
|
+
getattr(context, "file_state_cache", {}),
|
|
313
|
+
log_prefix="[notebook_edit_tool]",
|
|
314
|
+
)
|
|
333
315
|
|
|
334
316
|
output = NotebookEditOutput(
|
|
335
317
|
new_source=new_source,
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Shared helpers for safe file editing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Generator, Optional, TextIO, Union
|
|
10
|
+
|
|
11
|
+
from ripperdoc.utils.log import get_logger
|
|
12
|
+
from ripperdoc.utils.file_watch import record_snapshot
|
|
13
|
+
from ripperdoc.utils.platform import HAS_FCNTL
|
|
14
|
+
|
|
15
|
+
logger = get_logger()
|
|
16
|
+
|
|
17
|
+
PathLike = Union[str, Path]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_input_path(input_path: str) -> tuple[Path, str]:
|
|
21
|
+
"""Return resolved path plus cache key (abspath preserves symlink usage)."""
|
|
22
|
+
path = Path(input_path).expanduser()
|
|
23
|
+
if not path.is_absolute():
|
|
24
|
+
path = Path.cwd() / path
|
|
25
|
+
return path.resolve(), os.path.abspath(input_path)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def safe_record_snapshot(
|
|
29
|
+
file_path: str,
|
|
30
|
+
content: str,
|
|
31
|
+
cache: dict,
|
|
32
|
+
*,
|
|
33
|
+
encoding: str = "utf-8",
|
|
34
|
+
log_prefix: str = "",
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Record file snapshot with shared error handling."""
|
|
37
|
+
try:
|
|
38
|
+
record_snapshot(
|
|
39
|
+
file_path,
|
|
40
|
+
content,
|
|
41
|
+
cache,
|
|
42
|
+
encoding=encoding,
|
|
43
|
+
)
|
|
44
|
+
except (OSError, IOError, RuntimeError) as exc:
|
|
45
|
+
prefix = f"{log_prefix} " if log_prefix else ""
|
|
46
|
+
logger.warning(
|
|
47
|
+
"%sFailed to record file snapshot: %s: %s",
|
|
48
|
+
prefix,
|
|
49
|
+
type(exc).__name__,
|
|
50
|
+
exc,
|
|
51
|
+
extra={"file_path": file_path},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@contextlib.contextmanager
|
|
56
|
+
def file_lock(file_handle: TextIO, exclusive: bool = True) -> Generator[None, None, None]:
|
|
57
|
+
"""Acquire a file lock, with fallback for systems without fcntl."""
|
|
58
|
+
if not HAS_FCNTL:
|
|
59
|
+
yield
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
import fcntl
|
|
63
|
+
|
|
64
|
+
lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
|
|
65
|
+
try:
|
|
66
|
+
fcntl.flock(file_handle.fileno(), lock_type)
|
|
67
|
+
yield
|
|
68
|
+
finally:
|
|
69
|
+
with contextlib.suppress(OSError):
|
|
70
|
+
fcntl.flock(file_handle.fileno(), fcntl.LOCK_UN)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@contextlib.contextmanager
|
|
74
|
+
def open_locked_file(
|
|
75
|
+
file_path: PathLike, encoding: str
|
|
76
|
+
) -> Generator[tuple[TextIO, Optional[float], Optional[float]], None, None]:
|
|
77
|
+
"""Open a file for read/write and acquire an exclusive lock."""
|
|
78
|
+
with open(str(file_path), "r+", encoding=encoding) as handle:
|
|
79
|
+
try:
|
|
80
|
+
pre_lock_mtime = os.fstat(handle.fileno()).st_mtime
|
|
81
|
+
except OSError:
|
|
82
|
+
pre_lock_mtime = None
|
|
83
|
+
|
|
84
|
+
with file_lock(handle, exclusive=True):
|
|
85
|
+
try:
|
|
86
|
+
post_lock_mtime = os.fstat(handle.fileno()).st_mtime
|
|
87
|
+
except OSError:
|
|
88
|
+
post_lock_mtime = None
|
|
89
|
+
yield handle, pre_lock_mtime, post_lock_mtime
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def select_write_encoding(
|
|
93
|
+
file_encoding: str,
|
|
94
|
+
updated_content: str,
|
|
95
|
+
file_path: PathLike,
|
|
96
|
+
*,
|
|
97
|
+
log_prefix: str = "",
|
|
98
|
+
) -> str:
|
|
99
|
+
"""Return a safe encoding for writing updated content."""
|
|
100
|
+
try:
|
|
101
|
+
updated_content.encode(file_encoding)
|
|
102
|
+
return file_encoding
|
|
103
|
+
except (UnicodeEncodeError, LookupError):
|
|
104
|
+
prefix = f"{log_prefix} " if log_prefix else ""
|
|
105
|
+
logger.info(
|
|
106
|
+
"%sNew content cannot be encoded with %s, using UTF-8 for %s",
|
|
107
|
+
prefix,
|
|
108
|
+
file_encoding,
|
|
109
|
+
str(file_path),
|
|
110
|
+
)
|
|
111
|
+
return "utf-8"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def atomic_write_with_fallback(
|
|
115
|
+
handle: TextIO,
|
|
116
|
+
file_path: PathLike,
|
|
117
|
+
updated_content: str,
|
|
118
|
+
write_encoding: str,
|
|
119
|
+
original_content: str,
|
|
120
|
+
*,
|
|
121
|
+
temp_prefix: str,
|
|
122
|
+
log_prefix: str,
|
|
123
|
+
conflict_message: str,
|
|
124
|
+
) -> Optional[str]:
|
|
125
|
+
"""Atomically write content, falling back to in-place write if needed."""
|
|
126
|
+
try:
|
|
127
|
+
file_dir = os.path.dirname(str(file_path))
|
|
128
|
+
try:
|
|
129
|
+
fd, temp_path = tempfile.mkstemp(
|
|
130
|
+
dir=file_dir,
|
|
131
|
+
prefix=temp_prefix,
|
|
132
|
+
suffix=".tmp",
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
with os.fdopen(fd, "w", encoding=write_encoding) as temp_f:
|
|
136
|
+
temp_f.write(updated_content)
|
|
137
|
+
original_stat = os.fstat(handle.fileno())
|
|
138
|
+
os.chmod(temp_path, original_stat.st_mode)
|
|
139
|
+
os.replace(temp_path, str(file_path))
|
|
140
|
+
except Exception:
|
|
141
|
+
with contextlib.suppress(OSError):
|
|
142
|
+
os.unlink(temp_path)
|
|
143
|
+
raise
|
|
144
|
+
except OSError as atomic_error:
|
|
145
|
+
handle.seek(0)
|
|
146
|
+
current_content = handle.read()
|
|
147
|
+
if current_content != original_content:
|
|
148
|
+
return conflict_message
|
|
149
|
+
handle.seek(0)
|
|
150
|
+
handle.truncate()
|
|
151
|
+
handle.write(updated_content)
|
|
152
|
+
prefix = f"{log_prefix} " if log_prefix else ""
|
|
153
|
+
logger.debug("%sAtomic write failed, used fallback: %s", prefix, atomic_error)
|
|
154
|
+
except (OSError, IOError, PermissionError, UnicodeDecodeError) as exc:
|
|
155
|
+
prefix = f"{log_prefix} " if log_prefix else ""
|
|
156
|
+
logger.warning(
|
|
157
|
+
"%sError writing edited file: %s: %s",
|
|
158
|
+
prefix,
|
|
159
|
+
type(exc).__name__,
|
|
160
|
+
exc,
|
|
161
|
+
extra={"file_path": str(file_path)},
|
|
162
|
+
)
|
|
163
|
+
return f"Error writing file: {exc}"
|
|
164
|
+
return None
|