code-puppy 0.0.53__py3-none-any.whl → 0.0.55__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.
@@ -1,360 +1,410 @@
1
1
  # file_modifications.py
2
- import os
2
+ """Robust, always-diff-logging file-modification helpers + agent tools.
3
+
4
+ Key guarantees
5
+ --------------
6
+ 1. **A diff is printed _inline_ on every path** (success, no-op, or error) – no decorator magic.
7
+ 2. **Full traceback logging** for unexpected errors via `_log_error`.
8
+ 3. Helper functions stay print-free and return a `diff` key, while agent-tool wrappers handle
9
+ all console output.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
3
15
  import difflib
4
16
  import json
17
+ import os
18
+ import traceback
19
+ from typing import Any, Dict, List
20
+
5
21
  from code_puppy.tools.common import console
6
- from typing import Dict, Any, List
7
22
  from pydantic_ai import RunContext
8
23
 
9
24
  # ---------------------------------------------------------------------------
10
- # Module-level helper functions (exposed for unit tests; *not* registered)
25
+ # Console helpers shared across tools
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ def _print_diff(diff_text: str) -> None:
30
+ """Pretty-print *diff_text* with colour-coding (always runs)."""
31
+ console.print(
32
+ "[bold cyan]\n── DIFF ────────────────────────────────────────────────[/bold cyan]"
33
+ )
34
+ if diff_text and diff_text.strip():
35
+ for line in diff_text.splitlines():
36
+ if line.startswith("+") and not line.startswith("+++"):
37
+ console.print(f"[bold green]{line}[/bold green]", highlight=False)
38
+ elif line.startswith("-") and not line.startswith("---"):
39
+ console.print(f"[bold red]{line}[/bold red]", highlight=False)
40
+ elif line.startswith("@"):
41
+ console.print(f"[bold cyan]{line}[/bold cyan]", highlight=False)
42
+ else:
43
+ console.print(line, highlight=False)
44
+ else:
45
+ console.print("[dim]-- no diff available --[/dim]")
46
+ console.print(
47
+ "[bold cyan]───────────────────────────────────────────────────────[/bold cyan]"
48
+ )
49
+
50
+
51
+ def _log_error(msg: str, exc: Exception | None = None) -> None:
52
+ console.print(f"[bold red]Error:[/bold red] {msg}")
53
+ if exc is not None:
54
+ console.print(traceback.format_exc(), highlight=False)
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Pure helpers – no console output
11
59
  # ---------------------------------------------------------------------------
12
60
 
13
- def delete_snippet_from_file(context: RunContext | None, file_path: str, snippet: str) -> Dict[str, Any]:
14
- """Remove *snippet* from *file_path* if present, returning a diff summary."""
61
+
62
+ def _delete_snippet_from_file(
63
+ context: RunContext | None, file_path: str, snippet: str
64
+ ) -> Dict[str, Any]:
15
65
  file_path = os.path.abspath(file_path)
66
+ diff_text = ""
16
67
  try:
17
68
  if not os.path.exists(file_path) or not os.path.isfile(file_path):
18
- return {"error": f"File '{file_path}' does not exist."}
69
+ return {"error": f"File '{file_path}' does not exist.", "diff": diff_text}
19
70
  with open(file_path, "r", encoding="utf-8") as f:
20
- content = f.read()
21
- if snippet not in content:
22
- return {"error": f"Snippet not found in file '{file_path}'."}
23
- modified_content = content.replace(snippet, "")
71
+ original = f.read()
72
+ if snippet not in original:
73
+ return {
74
+ "error": f"Snippet not found in file '{file_path}'.",
75
+ "diff": diff_text,
76
+ }
77
+ modified = original.replace(snippet, "")
78
+ diff_text = "".join(
79
+ difflib.unified_diff(
80
+ original.splitlines(keepends=True),
81
+ modified.splitlines(keepends=True),
82
+ fromfile=f"a/{os.path.basename(file_path)}",
83
+ tofile=f"b/{os.path.basename(file_path)}",
84
+ n=3,
85
+ )
86
+ )
24
87
  with open(file_path, "w", encoding="utf-8") as f:
25
- f.write(modified_content)
26
- return {"success": True, "path": file_path, "message": "Snippet deleted from file."}
27
- except PermissionError:
28
- return {"error": f"Permission denied to modify '{file_path}'."}
29
- except FileNotFoundError:
30
- return {"error": f"File '{file_path}' does not exist."}
31
- except Exception as exc:
32
- return {"error": str(exc)}
88
+ f.write(modified)
89
+ return {
90
+ "success": True,
91
+ "path": file_path,
92
+ "message": "Snippet deleted from file.",
93
+ "changed": True,
94
+ "diff": diff_text,
95
+ }
96
+ except Exception as exc: # noqa: BLE001
97
+ _log_error("Unhandled exception in delete_snippet_from_file", exc)
98
+ return {"error": str(exc), "diff": diff_text}
33
99
 
34
100
 
35
- def write_to_file(context: RunContext | None, path: str, content: str) -> Dict[str, Any]:
101
+ def _replace_in_file(
102
+ context: RunContext | None, path: str, diff: str
103
+ ) -> Dict[str, Any]:
104
+ """Robust replacement engine with explicit edge‑case reporting."""
36
105
  file_path = os.path.abspath(path)
37
- if os.path.exists(file_path):
106
+ preview = (diff[:400] + "…") if len(diff) > 400 else diff # for logs / errors
107
+ diff_text = ""
108
+ try:
109
+ if not os.path.exists(file_path):
110
+ return {"error": f"File '{file_path}' does not exist", "diff": preview}
111
+
112
+ # ── Parse diff payload (tolerate single quotes) ──────────────────
113
+ try:
114
+ payload = json.loads(diff)
115
+ except json.JSONDecodeError:
116
+ try:
117
+ payload = json.loads(diff.replace("'", '"'))
118
+ except Exception as exc:
119
+ return {
120
+ "error": "Could not parse diff as JSON.",
121
+ "reason": str(exc),
122
+ "received": preview,
123
+ "diff": preview,
124
+ }
125
+ if not isinstance(payload, dict):
126
+ try:
127
+ payload = ast.literal_eval(diff)
128
+ except Exception as exc:
129
+ return {
130
+ "error": "Diff is neither valid JSON nor Python literal.",
131
+ "reason": str(exc),
132
+ "received": preview,
133
+ "diff": preview,
134
+ }
135
+
136
+ replacements: List[Dict[str, str]] = payload.get("replacements", [])
137
+ if not replacements:
138
+ return {
139
+ "error": "No valid replacements found in diff.",
140
+ "received": preview,
141
+ "diff": preview,
142
+ }
143
+
144
+ with open(file_path, "r", encoding="utf-8") as f:
145
+ original = f.read()
146
+
147
+ modified = original
148
+ for rep in replacements:
149
+ modified = modified.replace(rep.get("old_str", ""), rep.get("new_str", ""))
150
+
151
+ if modified == original:
152
+ # ── Explicit no‑op edge case ────────────────────────────────
153
+ console.print(
154
+ "[bold yellow]No changes to apply – proposed content is identical.[/bold yellow]"
155
+ )
156
+ return {
157
+ "success": False,
158
+ "path": file_path,
159
+ "message": "No changes to apply.",
160
+ "changed": False,
161
+ "diff": "", # empty so _print_diff prints placeholder
162
+ }
163
+
164
+ diff_text = "".join(
165
+ difflib.unified_diff(
166
+ original.splitlines(keepends=True),
167
+ modified.splitlines(keepends=True),
168
+ fromfile=f"a/{os.path.basename(file_path)}",
169
+ tofile=f"b/{os.path.basename(file_path)}",
170
+ n=3,
171
+ )
172
+ )
173
+ with open(file_path, "w", encoding="utf-8") as f:
174
+ f.write(modified)
38
175
  return {
39
- "success": False,
176
+ "success": True,
40
177
  "path": file_path,
41
- "message": f"Cowardly refusing to overwrite existing file: {file_path}",
42
- "changed": False,
178
+ "message": "Replacements applied.",
179
+ "changed": True,
180
+ "diff": diff_text,
43
181
  }
44
- os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
45
- with open(file_path, "w", encoding="utf-8") as f:
46
- f.write(content)
47
- return {
48
- "success": True,
49
- "path": file_path,
50
- "message": f"File '{file_path}' created successfully.",
51
- "changed": True,
52
- }
53
-
54
-
55
- def replace_in_file(context: RunContext | None, path: str, diff: str) -> Dict[str, Any]:
182
+
183
+ except Exception as exc: # noqa: BLE001
184
+ # ── Explicit error edge case ────────────────────────────────────
185
+ _log_error("Unhandled exception in replace_in_file", exc)
186
+ return {
187
+ "error": str(exc),
188
+ "path": file_path,
189
+ "diff": preview, # show the exact diff input that blew up
190
+ }
191
+
192
+
193
+ def _write_to_file(
194
+ context: RunContext | None,
195
+ path: str,
196
+ content: str,
197
+ overwrite: bool = False,
198
+ ) -> Dict[str, Any]:
199
+ file_path = os.path.abspath(path)
200
+
201
+ try:
202
+ exists = os.path.exists(file_path)
203
+ if exists and not overwrite:
204
+ return {
205
+ "success": False,
206
+ "path": file_path,
207
+ "message": f"Cowardly refusing to overwrite existing file: {file_path}",
208
+ "changed": False,
209
+ "diff": "",
210
+ }
211
+
212
+ # --- NEW: build diff before writing ---
213
+ diff_lines = difflib.unified_diff(
214
+ [] if not exists else [""], # empty “old” file
215
+ content.splitlines(keepends=True), # new file lines
216
+ fromfile="/dev/null" if not exists else f"a/{os.path.basename(file_path)}",
217
+ tofile=f"b/{os.path.basename(file_path)}",
218
+ n=3,
219
+ )
220
+ diff_text = "".join(diff_lines)
221
+
222
+ os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
223
+ with open(file_path, "w", encoding="utf-8") as f:
224
+ f.write(content)
225
+
226
+ action = "overwritten" if exists else "created"
227
+ return {
228
+ "success": True,
229
+ "path": file_path,
230
+ "message": f"File '{file_path}' {action} successfully.",
231
+ "changed": True,
232
+ "diff": diff_text,
233
+ }
234
+
235
+ except Exception as exc: # noqa: BLE001
236
+ _log_error("Unhandled exception in write_to_file", exc)
237
+ return {"error": str(exc), "diff": ""}
238
+
239
+
240
+ def _replace_in_file(
241
+ context: RunContext | None, path: str, diff: str
242
+ ) -> Dict[str, Any]:
243
+ """Robust replacement engine with explicit edge‑case reporting."""
56
244
  file_path = os.path.abspath(path)
57
- if not os.path.exists(file_path):
58
- return {"error": f"File '{file_path}' does not exist"}
245
+ preview = (diff[:400] + "…") if len(diff) > 400 else diff # for logs / errors
246
+ diff_text = ""
59
247
  try:
60
- import json, ast, difflib
61
- preview = (diff[:200] + '...') if len(diff) > 200 else diff
248
+ if not os.path.exists(file_path):
249
+ return {"error": f"File '{file_path}' does not exist", "diff": preview}
250
+
251
+ # ── Parse diff payload (tolerate single quotes) ──────────────────
62
252
  try:
63
- replacements_data = json.loads(diff)
64
- except json.JSONDecodeError as e1:
253
+ payload = json.loads(diff)
254
+ except json.JSONDecodeError:
65
255
  try:
66
- replacements_data = json.loads(diff.replace("'", '"'))
67
- except Exception as e2:
256
+ payload = json.loads(diff.replace("'", '"'))
257
+ except Exception as exc:
68
258
  return {
69
259
  "error": "Could not parse diff as JSON.",
70
- "reason": str(e2),
260
+ "reason": str(exc),
71
261
  "received": preview,
262
+ "diff": preview,
72
263
  }
73
- # If still not a dict -> maybe python literal
74
- if not isinstance(replacements_data, dict):
264
+ if not isinstance(payload, dict):
75
265
  try:
76
- replacements_data = ast.literal_eval(diff)
77
- except Exception as e3:
266
+ payload = ast.literal_eval(diff)
267
+ except Exception as exc:
78
268
  return {
79
269
  "error": "Diff is neither valid JSON nor Python literal.",
80
- "reason": str(e3),
270
+ "reason": str(exc),
81
271
  "received": preview,
272
+ "diff": preview,
82
273
  }
83
- replacements = replacements_data.get("replacements", []) if isinstance(replacements_data, dict) else []
274
+
275
+ replacements: List[Dict[str, str]] = payload.get("replacements", [])
84
276
  if not replacements:
85
277
  return {
86
278
  "error": "No valid replacements found in diff.",
87
279
  "received": preview,
280
+ "diff": preview,
88
281
  }
282
+
89
283
  with open(file_path, "r", encoding="utf-8") as f:
90
284
  original = f.read()
285
+
91
286
  modified = original
92
287
  for rep in replacements:
93
288
  modified = modified.replace(rep.get("old_str", ""), rep.get("new_str", ""))
289
+
94
290
  if modified == original:
95
- return {"success": False, "path": file_path, "message": "No changes to apply.", "changed": False}
291
+ # ── Explicit no‑op edge case ────────────────────────────────
292
+ console.print(
293
+ "[bold yellow]No changes to apply – proposed content is identical.[/bold yellow]"
294
+ )
295
+ return {
296
+ "success": False,
297
+ "path": file_path,
298
+ "message": "No changes to apply.",
299
+ "changed": False,
300
+ "diff": "", # empty so _print_diff prints placeholder
301
+ }
302
+
303
+ diff_text = "".join(
304
+ difflib.unified_diff(
305
+ original.splitlines(keepends=True),
306
+ modified.splitlines(keepends=True),
307
+ fromfile=f"a/{os.path.basename(file_path)}",
308
+ tofile=f"b/{os.path.basename(file_path)}",
309
+ n=3,
310
+ )
311
+ )
96
312
  with open(file_path, "w", encoding="utf-8") as f:
97
313
  f.write(modified)
98
- diff_text = "".join(difflib.unified_diff(original.splitlines(keepends=True), modified.splitlines(keepends=True)))
99
- return {"success": True, "path": file_path, "message": "Replacements applied.", "diff": diff_text, "changed": True}
100
- except Exception as exc:
101
- return {"error": str(exc)}
314
+ return {
315
+ "success": True,
316
+ "path": file_path,
317
+ "message": "Replacements applied.",
318
+ "changed": True,
319
+ "diff": diff_text,
320
+ }
321
+
322
+ except Exception as exc: # noqa: BLE001
323
+ # ── Explicit error edge case ────────────────────────────────────
324
+ _log_error("Unhandled exception in replace_in_file", exc)
325
+ return {
326
+ "error": str(exc),
327
+ "path": file_path,
328
+ "diff": preview, # show the exact diff input that blew up
329
+ }
330
+
102
331
 
103
332
  # ---------------------------------------------------------------------------
333
+ # Agent-tool registration
334
+ # ---------------------------------------------------------------------------
335
+
104
336
 
105
- def register_file_modifications_tools(agent):
106
- # @agent.tool
107
- def delete_snippet_from_file(context: RunContext, file_path: str, snippet: str) -> Dict[str, Any]:
337
+ def register_file_modifications_tools(agent): # noqa: C901 – a bit long but clear
338
+ """Attach file-editing tools to *agent* with mandatory diff rendering."""
339
+
340
+ # ------------------------------------------------------------------
341
+ # Delete snippet
342
+ # ------------------------------------------------------------------
343
+ @agent.tool
344
+ def delete_snippet_from_file(
345
+ context: RunContext, file_path: str, snippet: str
346
+ ) -> Dict[str, Any]:
108
347
  console.log(f"🗑️ Deleting snippet from file [bold red]{file_path}[/bold red]")
109
- file_path = os.path.abspath(file_path)
110
- console.print("\n[bold white on red] SNIPPET DELETION [/bold white on red]")
111
- console.print(f"[bold yellow]From file:[/bold yellow] {file_path}")
112
- try:
113
- if not os.path.exists(file_path):
114
- console.print(f"[bold red]Error:[/bold red] File '{file_path}' does not exist")
115
- return {"error": f"File '{file_path}' does not exist."}
116
- if not os.path.isfile(file_path):
117
- return {"error": f"'{file_path}' is not a file. Use rmdir for directories."}
118
- with open(file_path, "r", encoding="utf-8") as f:
119
- content = f.read()
120
- if snippet not in content:
121
- console.print(f"[bold red]Error:[/bold red] Snippet not found in file '{file_path}'")
122
- return {"error": f"Snippet not found in file '{file_path}'."}
123
- modified_content = content.replace(snippet, "")
124
- diff_lines = list(difflib.unified_diff(content.splitlines(keepends=True), modified_content.splitlines(keepends=True), fromfile=f"a/{os.path.basename(file_path)}", tofile=f"b/{os.path.basename(file_path)}", n=3))
125
- diff_text = "".join(diff_lines)
126
- console.print("[bold cyan]Changes to be applied:[/bold cyan]")
127
- if diff_text.strip():
128
- formatted_diff = ""
129
- for line in diff_lines:
130
- if line.startswith("+") and not line.startswith("+++"):
131
- formatted_diff += f"[bold green]{line}[/bold green]"
132
- elif line.startswith("-") and not line.startswith("---"):
133
- formatted_diff += f"[bold red]{line}[/bold red]"
134
- elif line.startswith("@"):
135
- formatted_diff += f"[bold cyan]{line}[/bold cyan]"
136
- else:
137
- formatted_diff += line
138
- console.print(formatted_diff)
139
- else:
140
- console.print("[dim]No changes detected[/dim]")
141
- return {"success": False, "path": file_path, "message": "No changes needed.", "diff": ""}
142
- with open(file_path, "w", encoding="utf-8") as f:
143
- f.write(modified_content)
144
- return {"success": True, "path": file_path, "message": f"Snippet deleted from file '{file_path}'.", "diff": diff_text}
145
- except PermissionError:
146
- return {"error": f"Permission denied to delete '{file_path}'."}
147
- except FileNotFoundError:
148
- return {"error": f"File '{file_path}' does not exist."}
149
- except Exception as e:
150
- return {"error": f"Error deleting file '{file_path}': {str(e)}"}
151
-
152
- # @agent.tool
348
+ res = _delete_snippet_from_file(context, file_path, snippet)
349
+ _print_diff(res.get("diff", ""))
350
+ return res
351
+
352
+ # ------------------------------------------------------------------
353
+ # Write / create file
354
+ # ------------------------------------------------------------------
355
+ @agent.tool
153
356
  def write_to_file(context: RunContext, path: str, content: str) -> Dict[str, Any]:
154
- try:
155
- file_path = os.path.abspath(path)
156
- os.makedirs(os.path.dirname(file_path), exist_ok=True)
157
- console.print("\n[bold white on blue] FILE WRITE [/bold white on blue]")
158
- console.print(f"[bold yellow]Writing to:[/bold yellow] {file_path}")
159
- file_exists = os.path.exists(file_path)
160
- if file_exists:
161
- console.print(f'[bold red]Refusing to overwrite existing file:[/bold red] {file_path}')
162
- return {'success': False,'path': file_path,'message': f'Cowardly refusing to overwrite existing file: {file_path}','changed': False,}
163
- trimmed_content = content
164
- max_preview = 1000
165
- if len(content) > max_preview:
166
- trimmed_content = content[:max_preview] + '... [truncated]'
167
- console.print('[bold magenta]Content to be written:[/bold magenta]')
168
- console.print(trimmed_content, highlight=False)
169
- with open(file_path, 'w', encoding='utf-8') as f:
170
- f.write(content)
171
- action = "updated" if file_exists else "created"
172
- return {"success": True,"path": file_path,"message": f"File '{file_path}' {action} successfully.","diff": trimmed_content,"changed": True,}
173
- except Exception as e:
174
- console.print(f"[bold red]Error:[/bold red] {str(e)}")
175
- return {"error": f"Error writing to file '{path}': {str(e)}"}
176
-
177
- # @agent.tool(retries=5)
357
+ console.log(f"✏️ Writing file [bold blue]{path}[/bold blue]")
358
+ res = _write_to_file(context, path, content, overwrite=False)
359
+ _print_diff(res.get("diff", content))
360
+ return res
361
+
362
+ # ------------------------------------------------------------------
363
+ # Replace text in file
364
+ # ------------------------------------------------------------------
365
+ @agent.tool
178
366
  def replace_in_file(context: RunContext, path: str, diff: str) -> Dict[str, Any]:
179
- try:
180
- file_path = os.path.abspath(path)
181
- console.print("\n[bold white on yellow] FILE REPLACEMENTS [/bold white on yellow]")
182
- console.print(f"[bold yellow]Modifying:[/bold yellow] {file_path}")
183
- if not os.path.exists(file_path):
184
- console.print(f"[bold red]Error:[/bold red] File '{file_path}' does not exist")
185
- return {"error": f"File '{file_path}' does not exist"}
186
- if not os.path.isfile(file_path):
187
- return {"error": f"'{file_path}' is not a file."}
188
- # ------------------------------------------------------------------
189
- # Robust parsing of the diff argument
190
- # The agent sometimes sends single-quoted or otherwise invalid JSON.
191
- # Attempt to recover by trying several strategies before giving up.
192
- # ------------------------------------------------------------------
193
- preview = (diff[:200] + '...') if len(diff) > 200 else diff
194
- try:
195
- replacements_data = json.loads(diff)
196
- except json.JSONDecodeError as e1:
197
- try:
198
- replacements_data = json.loads(diff.replace("'", '"'))
199
- except Exception as e2:
200
- return {
201
- "error": "Could not parse diff as JSON.",
202
- "reason": str(e2),
203
- "received": preview,
204
- }
205
- # If still not a dict -> maybe python literal
206
- if not isinstance(replacements_data, dict):
207
- try:
208
- replacements_data = ast.literal_eval(diff)
209
- except Exception as e3:
210
- return {
211
- "error": "Diff is neither valid JSON nor Python literal.",
212
- "reason": str(e3),
213
- "received": preview,
214
- }
215
- replacements = replacements_data.get("replacements", []) if isinstance(replacements_data, dict) else []
216
- if not replacements:
217
- return {
218
- "error": "No valid replacements found in diff.",
219
- "received": preview,
220
- }
221
- with open(file_path, "r", encoding="utf-8") as f:
222
- current_content = f.read()
223
- modified_content = current_content
224
- applied_replacements = []
225
- for i, replacement in enumerate(replacements, 1):
226
- old_str = replacement.get("old_str", "")
227
- new_str = replacement.get("new_str", "")
228
- if not old_str:
229
- console.print(f"[bold yellow]Warning:[/bold yellow] Replacement #{i} has empty old_str")
230
- continue
231
- if old_str not in modified_content:
232
- console.print(f"[bold red]Error:[/bold red] Text not found in file: {old_str[:50]}...")
233
- return {"error": f"Text to replace not found in file (replacement #{i})"}
234
- modified_content = modified_content.replace(old_str, new_str)
235
- applied_replacements.append({"old_str": old_str, "new_str": new_str})
236
- diff_lines = list(difflib.unified_diff(current_content.splitlines(keepends=True), modified_content.splitlines(keepends=True), fromfile=f"a/{os.path.basename(file_path)}", tofile=f"b/{os.path.basename(file_path)}", n=3))
237
- diff_text = "".join(diff_lines)
238
- console.print("[bold cyan]Changes to be applied:[/bold cyan]")
239
- if diff_text.strip():
240
- formatted_diff = ""
241
- for line in diff_lines:
242
- if line.startswith("+") and not line.startswith("+++"):
243
- formatted_diff += f"[bold green]{line}[/bold green]"
244
- elif line.startswith("-") and not line.startswith("---"):
245
- formatted_diff += f"[bold red]{line}[/bold red]"
246
- elif line.startswith("@"):
247
- formatted_diff += f"[bold cyan]{line}[/bold cyan]"
248
- else:
249
- formatted_diff += line
250
- console.print(formatted_diff)
251
- else:
252
- console.print("[dim]No changes detected - file content is identical[/dim]")
253
- return {"success": False,"path": file_path,"message": "No changes to apply.","diff": "","changed": False,}
254
- with open(file_path, "w", encoding="utf-8") as f:
255
- f.write(modified_content)
256
- return {"success": True,"path": file_path,"message": f"Applied {len(applied_replacements)} replacements to '{file_path}'","diff": diff_text,"changed": True,"replacements_applied": len(applied_replacements)}
257
- except Exception as e:
258
- console.print(f"[bold red]Error:[/bold red] {str(e)}")
259
- return {"error": f"Error replacing in file '{path}': {str(e)}"}
367
+ console.log(f"♻️ Replacing text in [bold yellow]{path}[/bold yellow]")
368
+ res = _replace_in_file(context, path, diff)
369
+ _print_diff(res.get("diff", diff))
370
+ return res
260
371
 
372
+ # ------------------------------------------------------------------
373
+ # Delete entire file
374
+ # ------------------------------------------------------------------
375
+ # ------------------------------------------------------------------
376
+ # Delete entire file (with full diff)
377
+ # ------------------------------------------------------------------
261
378
  @agent.tool
262
379
  def delete_file(context: RunContext, file_path: str) -> Dict[str, Any]:
263
380
  console.log(f"🗑️ Deleting file [bold red]{file_path}[/bold red]")
264
381
  file_path = os.path.abspath(file_path)
265
382
  try:
266
- if not os.path.exists(file_path):
267
- return {"error": f"File '{file_path}' does not exist."}
268
- if not os.path.isfile(file_path):
269
- return {"error": f"'{file_path}' is not a file. Use rmdir for directories."}
270
- os.remove(file_path)
271
- return {"success": True,"path": file_path,"message": f"File '{file_path}' deleted successfully."}
272
- except PermissionError:
273
- return {"error": f"Permission denied to delete '{file_path}'."}
274
- except FileNotFoundError:
275
- return {"error": f"File '{file_path}' does not exist."}
276
- except Exception as e:
277
- return {"error": f"Error deleting file '{file_path}': {str(e)}"}
278
-
279
- @agent.tool(retries=5)
280
- def edit_file(context: RunContext, path: str, diff: str) -> Dict[str, Any]:
281
- """
282
- Unified file editing tool that can:
283
- - Create/write a new file when the target does not exist (using raw content or a JSON payload with a "content" key)
284
- - Replace text within an existing file via a JSON payload with "replacements" (delegates to internal replace logic)
285
- - Delete a snippet from an existing file via a JSON payload with "delete_snippet"
286
-
287
- Parameters
288
- ----------
289
- path : str
290
- Path to the target file (relative or absolute)
291
- diff : str
292
- Either:
293
- * Raw file content (for file creation)
294
- * A JSON string with one of the following shapes:
295
- {"content": "full file contents", "overwrite": true}
296
- {"replacements": [ {"old_str": "foo", "new_str": "bar"}, ... ] }
297
- {"delete_snippet": "text to remove"}
298
-
299
- The function auto-detects the payload type and routes to the appropriate internal helper.
300
- """
301
- file_path = os.path.abspath(path)
302
-
303
- # 1. Attempt to parse the incoming `diff` as JSON (robustly, allowing single quotes)
304
- parsed_payload: Dict[str, Any] | None = None
305
- try:
306
- parsed_payload = json.loads(diff)
307
- except json.JSONDecodeError:
308
- # Fallback: try to sanitise single quotes
309
- try:
310
- parsed_payload = json.loads(diff.replace("'", '"'))
311
- except Exception:
312
- parsed_payload = None
313
-
314
- # ------------------------------------------------------------------
315
- # Case A: JSON payload recognised
316
- # ------------------------------------------------------------------
317
- if isinstance(parsed_payload, dict):
318
- # Delete-snippet mode
319
- if "delete_snippet" in parsed_payload:
320
- snippet = parsed_payload["delete_snippet"]
321
- return delete_snippet_from_file(context, file_path, snippet)
322
-
323
- # Replacement mode
324
- if "replacements" in parsed_payload:
325
- # Forward the ORIGINAL diff string (not parsed) so that the existing logic
326
- # which handles various JSON quirks can run unchanged.
327
- return replace_in_file(context, file_path, diff)
328
-
329
- # Write / create mode via content field
330
- if "content" in parsed_payload:
331
- content = parsed_payload["content"]
332
- overwrite = bool(parsed_payload.get("overwrite", False))
333
- file_exists = os.path.exists(file_path)
334
- if file_exists and not overwrite:
335
- return {"success": False, "path": file_path, "message": f"File '{file_path}' exists. Set 'overwrite': true to replace.", "changed": False}
336
- if file_exists and overwrite:
337
- # Overwrite directly
338
- try:
339
- with open(file_path, "w", encoding="utf-8") as f:
340
- f.write(content)
341
- return {"success": True, "path": file_path, "message": f"File '{file_path}' overwritten successfully.", "changed": True}
342
- except Exception as e:
343
- return {"error": f"Error overwriting file '{file_path}': {str(e)}"}
344
- # File does not exist -> create
345
- return write_to_file(context, file_path, content)
346
-
347
- # ------------------------------------------------------------------
348
- # Case B: Not JSON or unrecognised structure.
349
- # Treat `diff` as raw content for file creation OR as replacement diff.
350
- # ------------------------------------------------------------------
351
- if not os.path.exists(file_path):
352
- # Create new file with provided raw content
353
- return write_to_file(context, file_path, diff)
354
-
355
- # If file exists, attempt to treat the raw input as a replacement diff spec.
356
- replacement_result = replace_in_file(context, file_path, diff)
357
- if replacement_result.get("error"):
358
- # Fallback: refuse to overwrite blindly
359
- return {"success": False, "path": file_path, "message": "Unrecognised payload and cannot derive edit instructions.", "changed": False}
360
- return replacement_result
383
+ if not os.path.exists(file_path) or not os.path.isfile(file_path):
384
+ res = {"error": f"File '{file_path}' does not exist.", "diff": ""}
385
+ else:
386
+ with open(file_path, "r", encoding="utf-8") as f:
387
+ original = f.read()
388
+ # Diff: original lines empty file
389
+ diff_text = "".join(
390
+ difflib.unified_diff(
391
+ original.splitlines(keepends=True),
392
+ [],
393
+ fromfile=f"a/{os.path.basename(file_path)}",
394
+ tofile=f"b/{os.path.basename(file_path)}",
395
+ n=3,
396
+ )
397
+ )
398
+ os.remove(file_path)
399
+ res = {
400
+ "success": True,
401
+ "path": file_path,
402
+ "message": f"File '{file_path}' deleted successfully.",
403
+ "changed": True,
404
+ "diff": diff_text,
405
+ }
406
+ except Exception as exc: # noqa: BLE001
407
+ _log_error("Unhandled exception in delete_file", exc)
408
+ res = {"error": str(exc), "diff": ""}
409
+ _print_diff(res.get("diff", ""))
410
+ return res