code-puppy 0.0.53__py3-none-any.whl → 0.0.54__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.
- code_puppy/__init__.py +1 -0
- code_puppy/agent.py +20 -8
- code_puppy/agent_prompts.py +2 -3
- code_puppy/command_line/file_path_completion.py +11 -4
- code_puppy/command_line/meta_command_handler.py +48 -28
- code_puppy/command_line/model_picker_completion.py +27 -13
- code_puppy/command_line/prompt_toolkit_completion.py +95 -51
- code_puppy/command_line/utils.py +8 -6
- code_puppy/config.py +22 -11
- code_puppy/main.py +32 -22
- code_puppy/model_factory.py +7 -7
- code_puppy/session_memory.py +31 -19
- code_puppy/tools/__init__.py +1 -0
- code_puppy/tools/code_map.py +16 -11
- code_puppy/tools/command_runner.py +160 -63
- code_puppy/tools/common.py +1 -1
- code_puppy/tools/file_modifications.py +352 -302
- code_puppy/tools/file_operations.py +109 -183
- code_puppy/tools/web_search.py +24 -8
- code_puppy/version_checker.py +4 -4
- {code_puppy-0.0.53.dist-info → code_puppy-0.0.54.dist-info}/METADATA +1 -1
- code_puppy-0.0.54.dist-info/RECORD +28 -0
- code_puppy-0.0.53.dist-info/RECORD +0 -28
- {code_puppy-0.0.53.data → code_puppy-0.0.54.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.53.dist-info → code_puppy-0.0.54.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.53.dist-info → code_puppy-0.0.54.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.53.dist-info → code_puppy-0.0.54.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,360 +1,410 @@
|
|
|
1
1
|
# file_modifications.py
|
|
2
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
21
|
-
if snippet not in
|
|
22
|
-
return {
|
|
23
|
-
|
|
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(
|
|
26
|
-
return {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
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":
|
|
176
|
+
"success": True,
|
|
40
177
|
"path": file_path,
|
|
41
|
-
"message":
|
|
42
|
-
"changed":
|
|
178
|
+
"message": "Replacements applied.",
|
|
179
|
+
"changed": True,
|
|
180
|
+
"diff": diff_text,
|
|
43
181
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def
|
|
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
|
-
|
|
58
|
-
|
|
245
|
+
preview = (diff[:400] + "…") if len(diff) > 400 else diff # for logs / errors
|
|
246
|
+
diff_text = ""
|
|
59
247
|
try:
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
except json.JSONDecodeError
|
|
253
|
+
payload = json.loads(diff)
|
|
254
|
+
except json.JSONDecodeError:
|
|
65
255
|
try:
|
|
66
|
-
|
|
67
|
-
except Exception as
|
|
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(
|
|
260
|
+
"reason": str(exc),
|
|
71
261
|
"received": preview,
|
|
262
|
+
"diff": preview,
|
|
72
263
|
}
|
|
73
|
-
|
|
74
|
-
if not isinstance(replacements_data, dict):
|
|
264
|
+
if not isinstance(payload, dict):
|
|
75
265
|
try:
|
|
76
|
-
|
|
77
|
-
except Exception as
|
|
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(
|
|
270
|
+
"reason": str(exc),
|
|
81
271
|
"received": preview,
|
|
272
|
+
"diff": preview,
|
|
82
273
|
}
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|