code-puppy 0.0.45__tar.gz → 0.0.47__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {code_puppy-0.0.45 → code_puppy-0.0.47}/PKG-INFO +1 -1
  2. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/agent_prompts.py +32 -33
  3. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/command_line/prompt_toolkit_completion.py +4 -0
  4. code_puppy-0.0.47/code_puppy/tools/file_modifications.py +360 -0
  5. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/tools/file_operations.py +77 -20
  6. {code_puppy-0.0.45 → code_puppy-0.0.47}/pyproject.toml +1 -1
  7. code_puppy-0.0.45/code_puppy/tools/file_modifications.py +0 -191
  8. {code_puppy-0.0.45 → code_puppy-0.0.47}/.gitignore +0 -0
  9. {code_puppy-0.0.45 → code_puppy-0.0.47}/LICENSE +0 -0
  10. {code_puppy-0.0.45 → code_puppy-0.0.47}/README.md +0 -0
  11. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/__init__.py +0 -0
  12. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/agent.py +0 -0
  13. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/command_line/__init__.py +0 -0
  14. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/command_line/file_path_completion.py +0 -0
  15. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/command_line/meta_command_handler.py +0 -0
  16. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/command_line/model_picker_completion.py +0 -0
  17. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/command_line/utils.py +0 -0
  18. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/config.py +0 -0
  19. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/main.py +0 -0
  20. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/model_factory.py +0 -0
  21. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/models.json +0 -0
  22. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/session_memory.py +0 -0
  23. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/tools/__init__.py +0 -0
  24. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/tools/code_map.py +0 -0
  25. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/tools/command_runner.py +0 -0
  26. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/tools/common.py +0 -0
  27. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/tools/web_search.py +0 -0
  28. {code_puppy-0.0.45 → code_puppy-0.0.47}/code_puppy/version_checker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-puppy
3
- Version: 0.0.45
3
+ Version: 0.0.47
4
4
  Summary: Code generation agent
5
5
  Author: Michael Pfaffenberger
6
6
  License: MIT
@@ -27,52 +27,49 @@ YOU MUST USE THESE TOOLS to complete tasks (do not just describe what should be
27
27
  File Operations:
28
28
  - list_files(directory=".", recursive=True): ALWAYS use this to explore directories before trying to read/modify files
29
29
  - read_file(file_path): ALWAYS use this to read existing files before modifying them.
30
- - write_to_file(path, content): Use this to write or overwrite files with complete content.
31
- - replace_in_file(path, diff): Use this to make exact replacements in a file using JSON format.
32
- - delete_snippet_from_file(file_path, snippet): Use this to remove specific code snippets from files
30
+ - edit_file(path, diff): Use this single tool to create new files, overwrite entire files, perform targeted replacements, or delete snippets depending on the JSON/raw payload provided.
33
31
  - delete_file(file_path): Use this to remove files when needed
34
32
  - grep(search_string, directory="."): Use this to recursively search for a string across files starting from the specified directory, capping results at 200 matches.
35
- - grab_json_from_url(url: str): Use this to grab JSON data from a specified URL, ensuring the response is of type application/json. It raises an error if the response type is not application/json and limits the output to 1000 lines.
36
33
 
37
34
  Tool Usage Instructions:
38
35
 
39
- ## write_to_file
40
- Use this when you need to create a new file or completely replace an existing file's contents.
41
- - path: The path to the file (required)
42
- - content: The COMPLETE content of the file (required)
36
+ ## edit_file
37
+ This is an all-in-one file-modification tool. It supports the following payload shapes for the `diff` argument:
38
+ 1. {{ "content": "…", "overwrite": true|false }} → Treated as full-file content when the target file does **not** exist.
39
+ 2. {{ "content": "…", "overwrite": true|false }} → Create or overwrite a file with the provided content.
40
+ 3. {{ "replacements": [ {{ "old_str": "…", "new_str": "…" }}, … ] }} → Perform exact text replacements inside an existing file.
41
+ 4. {{ "delete_snippet": "…" }} → Remove a snippet of text from an existing file.
43
42
 
44
- Example:
45
- ```
46
- write_to_file(
47
- path="path/to/file.txt",
48
- content="Complete content of the file here..."
49
- )
50
- ```
43
+ Arguments:
44
+ - path (required): Target file path.
45
+ - diff (required): One of the payloads above (raw string or JSON string).
51
46
 
52
- ## replace_in_file
53
- Use this to make targeted replacements in an existing file. Each replacement must match exactly what's in the file.
54
- - path: The path to the file (required)
55
- - diff: JSON string with replacements (required)
56
-
57
- The diff parameter should be a JSON string in this format:
47
+ Example (create):
58
48
  ```json
59
- {{
60
- "replacements": [
61
- {{
62
- "old_str": "exact string from file",
63
- "new_str": "replacement string"
64
- }}
65
- ]
66
- }}
49
+ edit_file("src/example.py", "print('hello')\n")
67
50
  ```
68
51
 
69
- For grab_json_from_url, this is super useful for hitting a swagger doc or openapi doc. That will allow you to
70
- write correct code to hit the API.
52
+ Example (replacement):
53
+ ```json
54
+ edit_file(
55
+ "src/example.py",
56
+ "{{"replacements":[{{"old_str":"foo","new_str":"bar"}}]}}"
57
+ )
58
+ ```
71
59
 
72
- NEVER output an entire file, this is very expensive.
60
+ NEVER output an entire file this is very expensive.
73
61
  You may not edit file extensions: [.ipynb]
74
62
  You should specify the following arguments before the others: [TargetFile]
75
63
 
64
+ Remember: ONE argument = ONE JSON string.
65
+
66
+ Best-practice guidelines for `edit_file`:
67
+ • Keep each diff small – ideally between 100-300 lines.
68
+ • Apply multiple sequential `edit_file` calls when you need to refactor large files instead of sending one massive diff.
69
+ • Never paste an entire file inside `old_str`; target only the minimal snippet you want changed.
70
+ • If the resulting file would grow beyond 600 lines, split logic into additional files and create them with separate `edit_file` calls.
71
+
72
+
76
73
  System Operations:
77
74
  - run_shell_command(command, cwd=None, timeout=60): Use this to execute commands, run tests, or start services
78
75
 
@@ -88,6 +85,8 @@ In the event that you want to see the entire output for the test, run a single t
88
85
 
89
86
  npm test -- ./path/to/test/file.tsx # or something like this.
90
87
 
88
+ DONT USE THE TERMINAL TOOL TO RUN THE CODE WE WROTE UNLESS THE USER ASKS YOU TO.
89
+
91
90
  Reasoning & Explanation:
92
91
  - share_your_reasoning(reasoning, next_steps=None): Use this to explicitly share your thought process and planned next steps
93
92
 
@@ -95,7 +94,7 @@ Important rules:
95
94
  - You MUST use tools to accomplish tasks - DO NOT just output code or descriptions
96
95
  - Before every other tool use, you must use "share_your_reasoning" to explain your thought process and planned next steps
97
96
  - Check if files exist before trying to modify or delete them
98
- - Whenever possible, prefer to MODIFY existing files first (use `replace_in_file`, `delete_snippet_from_file`, or `write_to_file`) before creating brand-new files or deleting existing ones.
97
+ - Whenever possible, prefer to MODIFY existing files first (use `edit_file`) before creating brand-new files or deleting existing ones.
99
98
  - After using system operations tools, always explain the results
100
99
  - You're encouraged to loop between share_your_reasoning, file tools, and run_shell_command to test output in order to write programs
101
100
  - Aim to continue operations independently unless user input is definitively required.
@@ -90,6 +90,10 @@ async def get_input_with_combined_completion(prompt_str = '>>> ', history_file:
90
90
  @bindings.add(Keys.Escape, 'm') # Alt+M
91
91
  def _(event):
92
92
  event.app.current_buffer.insert_text('\n')
93
+ @bindings.add(Keys.Escape)
94
+ def _(event):
95
+ """Cancel the current prompt when the user presses the ESC key alone."""
96
+ event.app.exit(exception=KeyboardInterrupt)
93
97
 
94
98
  session = PromptSession(
95
99
  completer=completer,
@@ -0,0 +1,360 @@
1
+ # file_modifications.py
2
+ import os
3
+ import difflib
4
+ import json
5
+ from code_puppy.tools.common import console
6
+ from typing import Dict, Any, List
7
+ from pydantic_ai import RunContext
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Module-level helper functions (exposed for unit tests; *not* registered)
11
+ # ---------------------------------------------------------------------------
12
+
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."""
15
+ file_path = os.path.abspath(file_path)
16
+ try:
17
+ if not os.path.exists(file_path) or not os.path.isfile(file_path):
18
+ return {"error": f"File '{file_path}' does not exist."}
19
+ 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, "")
24
+ 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)}
33
+
34
+
35
+ def write_to_file(context: RunContext | None, path: str, content: str) -> Dict[str, Any]:
36
+ file_path = os.path.abspath(path)
37
+ if os.path.exists(file_path):
38
+ return {
39
+ "success": False,
40
+ "path": file_path,
41
+ "message": f"Cowardly refusing to overwrite existing file: {file_path}",
42
+ "changed": False,
43
+ }
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]:
56
+ file_path = os.path.abspath(path)
57
+ if not os.path.exists(file_path):
58
+ return {"error": f"File '{file_path}' does not exist"}
59
+ try:
60
+ import json, ast, difflib
61
+ preview = (diff[:200] + '...') if len(diff) > 200 else diff
62
+ try:
63
+ replacements_data = json.loads(diff)
64
+ except json.JSONDecodeError as e1:
65
+ try:
66
+ replacements_data = json.loads(diff.replace("'", '"'))
67
+ except Exception as e2:
68
+ return {
69
+ "error": "Could not parse diff as JSON.",
70
+ "reason": str(e2),
71
+ "received": preview,
72
+ }
73
+ # If still not a dict -> maybe python literal
74
+ if not isinstance(replacements_data, dict):
75
+ try:
76
+ replacements_data = ast.literal_eval(diff)
77
+ except Exception as e3:
78
+ return {
79
+ "error": "Diff is neither valid JSON nor Python literal.",
80
+ "reason": str(e3),
81
+ "received": preview,
82
+ }
83
+ replacements = replacements_data.get("replacements", []) if isinstance(replacements_data, dict) else []
84
+ if not replacements:
85
+ return {
86
+ "error": "No valid replacements found in diff.",
87
+ "received": preview,
88
+ }
89
+ with open(file_path, "r", encoding="utf-8") as f:
90
+ original = f.read()
91
+ modified = original
92
+ for rep in replacements:
93
+ modified = modified.replace(rep.get("old_str", ""), rep.get("new_str", ""))
94
+ if modified == original:
95
+ return {"success": False, "path": file_path, "message": "No changes to apply.", "changed": False}
96
+ with open(file_path, "w", encoding="utf-8") as f:
97
+ 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)}
102
+
103
+ # ---------------------------------------------------------------------------
104
+
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]:
108
+ 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
153
+ 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)
178
+ 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)}"}
260
+
261
+ @agent.tool
262
+ def delete_file(context: RunContext, file_path: str) -> Dict[str, Any]:
263
+ console.log(f"🗑️ Deleting file [bold red]{file_path}[/bold red]")
264
+ file_path = os.path.abspath(file_path)
265
+ 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
@@ -5,6 +5,83 @@ from typing import List, Dict, Any
5
5
  from code_puppy.tools.common import console
6
6
  from pydantic_ai import RunContext
7
7
 
8
+ # ---------------------------------------------------------------------------
9
+ # Module-level helper functions (exposed for unit tests _and_ used as tools)
10
+ # ---------------------------------------------------------------------------
11
+ IGNORE_PATTERNS = [
12
+ "**/node_modules/**",
13
+ "**/.git/**",
14
+ "**/__pycache__/**",
15
+ "**/.DS_Store",
16
+ "**/.env",
17
+ "**/.venv/**",
18
+ "**/venv/**",
19
+ "**/.idea/**",
20
+ "**/.vscode/**",
21
+ "**/dist/**",
22
+ "**/build/**",
23
+ "**/*.pyc",
24
+ "**/*.pyo",
25
+ "**/*.pyd",
26
+ "**/*.so",
27
+ "**/*.dll",
28
+ "**/*.exe",
29
+ ]
30
+
31
+ def should_ignore_path(path: str) -> bool:
32
+ """Return True if *path* matches any pattern in IGNORE_PATTERNS."""
33
+ for pattern in IGNORE_PATTERNS:
34
+ if fnmatch.fnmatch(path, pattern):
35
+ return True
36
+ return False
37
+
38
+ def list_files(context: RunContext | None, directory: str = ".", recursive: bool = True) -> List[Dict[str, Any]]:
39
+ """Light-weight `list_files` implementation sufficient for unit-tests and agent tooling."""
40
+ directory = os.path.abspath(directory)
41
+ results: List[Dict[str, Any]] = []
42
+ if not os.path.exists(directory) or not os.path.isdir(directory):
43
+ return [{"error": f"Directory '{directory}' does not exist or is not a directory"}]
44
+ for root, dirs, files in os.walk(directory):
45
+ rel_root = os.path.relpath(root, directory)
46
+ if rel_root == ".":
47
+ rel_root = ""
48
+ for f in files:
49
+ fp = os.path.join(rel_root, f) if rel_root else f
50
+ results.append({"path": fp, "type": "file"})
51
+ if not recursive:
52
+ break
53
+ return results
54
+
55
+ def read_file(context: RunContext | None, file_path: str) -> Dict[str, Any]:
56
+ file_path = os.path.abspath(file_path)
57
+ if not os.path.exists(file_path):
58
+ return {"error": f"File '{file_path}' does not exist"}
59
+ if not os.path.isfile(file_path):
60
+ return {"error": f"'{file_path}' is not a file"}
61
+ try:
62
+ with open(file_path, "r", encoding="utf-8") as f:
63
+ content = f.read()
64
+ return {"content": content, "path": file_path, "total_lines": len(content.splitlines())}
65
+ except Exception as exc:
66
+ return {"error": str(exc)}
67
+
68
+ def grep(context: RunContext | None, search_string: str, directory: str = ".") -> List[Dict[str, Any]]:
69
+ matches: List[Dict[str, Any]] = []
70
+ directory = os.path.abspath(directory)
71
+ for root, dirs, files in os.walk(directory):
72
+ for f in files:
73
+ file_path = os.path.join(root, f)
74
+ try:
75
+ with open(file_path, "r", encoding="utf-8") as fh:
76
+ for ln, line in enumerate(fh, 1):
77
+ if search_string in line:
78
+ matches.append({"file_path": file_path, "line_number": ln})
79
+ if len(matches) >= 200:
80
+ return matches
81
+ except Exception:
82
+ continue
83
+ return matches
84
+
8
85
  def register_file_operations_tools(agent):
9
86
  # Constants for file operations
10
87
  IGNORE_PATTERNS = [
@@ -145,26 +222,6 @@ def register_file_operations_tools(agent):
145
222
  console.print("[dim]" + "-" * 60 + "[/dim]\n")
146
223
  return results
147
224
 
148
- @agent.tool
149
- def create_file(context: RunContext, file_path: str, content: str = "") -> Dict[str, Any]:
150
- file_path = os.path.abspath(file_path)
151
- if os.path.exists(file_path):
152
- return {"error": f"File '{file_path}' already exists. Use replace_in_file or write_to_file to edit it."}
153
- directory = os.path.dirname(file_path)
154
- if directory and not os.path.exists(directory):
155
- try:
156
- os.makedirs(directory)
157
- except Exception as e:
158
- return {"error": f"Error creating directory '{directory}': {str(e)}"}
159
- try:
160
- with open(file_path, "w", encoding="utf-8") as f:
161
- console.print("[yellow]Writing to file:[/yellow]")
162
- console.print(content)
163
- f.write(content)
164
- return {"success": True, "path": file_path, "message": f"File created at '{file_path}'", "content_length": len(content)}
165
- except Exception as e:
166
- return {"error": f"Error creating file '{file_path}': {str(e)}"}
167
-
168
225
  @agent.tool
169
226
  def read_file(context: RunContext, file_path: str) -> Dict[str, Any]:
170
227
  file_path = os.path.abspath(file_path)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "code-puppy"
7
- version = "0.0.45"
7
+ version = "0.0.47"
8
8
  description = "Code generation agent"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,191 +0,0 @@
1
- # file_modifications.py
2
- import os
3
- import difflib
4
- import json
5
- from code_puppy.tools.common import console
6
- from typing import Dict, Any, List
7
- from pydantic_ai import RunContext
8
-
9
- def register_file_modifications_tools(agent):
10
- @agent.tool
11
- def delete_snippet_from_file(context: RunContext, file_path: str, snippet: str) -> Dict[str, Any]:
12
- console.log(f"🗑️ Deleting snippet from file [bold red]{file_path}[/bold red]")
13
- file_path = os.path.abspath(file_path)
14
- console.print("\n[bold white on red] SNIPPET DELETION [/bold white on red]")
15
- console.print(f"[bold yellow]From file:[/bold yellow] {file_path}")
16
- try:
17
- if not os.path.exists(file_path):
18
- console.print(f"[bold red]Error:[/bold red] File '{file_path}' does not exist")
19
- return {"error": f"File '{file_path}' does not exist."}
20
- if not os.path.isfile(file_path):
21
- return {"error": f"'{file_path}' is not a file. Use rmdir for directories."}
22
- with open(file_path, "r", encoding="utf-8") as f:
23
- content = f.read()
24
- if snippet not in content:
25
- console.print(f"[bold red]Error:[/bold red] Snippet not found in file '{file_path}'")
26
- return {"error": f"Snippet not found in file '{file_path}'."}
27
- modified_content = content.replace(snippet, "")
28
- 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))
29
- diff_text = "".join(diff_lines)
30
- console.print("[bold cyan]Changes to be applied:[/bold cyan]")
31
- if diff_text.strip():
32
- formatted_diff = ""
33
- for line in diff_lines:
34
- if line.startswith("+") and not line.startswith("+++"):
35
- formatted_diff += f"[bold green]{line}[/bold green]"
36
- elif line.startswith("-") and not line.startswith("---"):
37
- formatted_diff += f"[bold red]{line}[/bold red]"
38
- elif line.startswith("@"):
39
- formatted_diff += f"[bold cyan]{line}[/bold cyan]"
40
- else:
41
- formatted_diff += line
42
- console.print(formatted_diff)
43
- else:
44
- console.print("[dim]No changes detected[/dim]")
45
- return {"success": False, "path": file_path, "message": "No changes needed.", "diff": ""}
46
- with open(file_path, "w", encoding="utf-8") as f:
47
- f.write(modified_content)
48
- return {"success": True, "path": file_path, "message": f"Snippet deleted from file '{file_path}'.", "diff": diff_text}
49
- except PermissionError:
50
- return {"error": f"Permission denied to delete '{file_path}'."}
51
- except FileNotFoundError:
52
- return {"error": f"File '{file_path}' does not exist."}
53
- except Exception as e:
54
- return {"error": f"Error deleting file '{file_path}': {str(e)}"}
55
-
56
- @agent.tool
57
- def write_to_file(context: RunContext, path: str, content: str) -> Dict[str, Any]:
58
- try:
59
- file_path = os.path.abspath(path)
60
- os.makedirs(os.path.dirname(file_path), exist_ok=True)
61
- console.print("\n[bold white on blue] FILE WRITE [/bold white on blue]")
62
- console.print(f"[bold yellow]Writing to:[/bold yellow] {file_path}")
63
- file_exists = os.path.exists(file_path)
64
- if file_exists:
65
- console.print(f'[bold red]Refusing to overwrite existing file:[/bold red] {file_path}')
66
- return {'success': False,'path': file_path,'message': f'Cowardly refusing to overwrite existing file: {file_path}','changed': False,}
67
- trimmed_content = content
68
- max_preview = 1000
69
- if len(content) > max_preview:
70
- trimmed_content = content[:max_preview] + '... [truncated]'
71
- console.print('[bold magenta]Content to be written:[/bold magenta]')
72
- console.print(trimmed_content, highlight=False)
73
- with open(file_path, 'w', encoding='utf-8') as f:
74
- f.write(content)
75
- action = "updated" if file_exists else "created"
76
- return {"success": True,"path": file_path,"message": f"File '{file_path}' {action} successfully.","diff": trimmed_content,"changed": True,}
77
- except Exception as e:
78
- console.print(f"[bold red]Error:[/bold red] {str(e)}")
79
- return {"error": f"Error writing to file '{path}': {str(e)}"}
80
-
81
- @agent.tool(retries=5)
82
- def replace_in_file(context: RunContext, path: str, diff: str) -> Dict[str, Any]:
83
- try:
84
- file_path = os.path.abspath(path)
85
- console.print("\n[bold white on yellow] FILE REPLACEMENTS [/bold white on yellow]")
86
- console.print(f"[bold yellow]Modifying:[/bold yellow] {file_path}")
87
- if not os.path.exists(file_path):
88
- console.print(f"[bold red]Error:[/bold red] File '{file_path}' does not exist")
89
- return {"error": f"File '{file_path}' does not exist"}
90
- if not os.path.isfile(file_path):
91
- return {"error": f"'{file_path}' is not a file."}
92
- # ------------------------------------------------------------------
93
- # Robust parsing of the diff argument
94
- # The agent sometimes sends single-quoted or otherwise invalid JSON.
95
- # Attempt to recover by trying several strategies before giving up.
96
- # ------------------------------------------------------------------
97
- parsed_successfully = False
98
- replacements: List[Dict[str, str]] = []
99
- try:
100
- replacements_data = json.loads(diff)
101
- replacements = replacements_data.get("replacements", [])
102
- parsed_successfully = True
103
- except json.JSONDecodeError:
104
- # Fallback 1: convert single quotes to double quotes and retry
105
- try:
106
- sanitized = diff.replace("'", '"')
107
- replacements_data = json.loads(sanitized)
108
- replacements = replacements_data.get("replacements", [])
109
- parsed_successfully = True
110
- except json.JSONDecodeError:
111
- # Fallback 2: attempt Python literal eval
112
- try:
113
- import ast
114
- replacements_data = ast.literal_eval(diff)
115
- if isinstance(replacements_data, dict):
116
- replacements = replacements_data.get("replacements", []) if "replacements" in replacements_data else []
117
- # If dict keys look like a single replacement, wrap it
118
- if not replacements:
119
- # maybe it's already {"old_str": ..., "new_str": ...}
120
- if all(k in replacements_data for k in ("old_str", "new_str")):
121
- replacements = [
122
- {
123
- "old_str": replacements_data["old_str"],
124
- "new_str": replacements_data["new_str"],
125
- }
126
- ]
127
- parsed_successfully = True
128
- except Exception as e2:
129
- console.print(
130
- f"[bold red]Error:[/bold red] Could not parse diff as JSON or Python literal. Reason: {e2}"
131
- )
132
- if not parsed_successfully or not replacements:
133
- console.print("[bold red]Error:[/bold red] No valid replacements found in the diff after all parsing attempts")
134
- return {"error": "No valid replacements found in the diff"}
135
- with open(file_path, "r", encoding="utf-8") as f:
136
- current_content = f.read()
137
- modified_content = current_content
138
- applied_replacements = []
139
- for i, replacement in enumerate(replacements, 1):
140
- old_str = replacement.get("old_str", "")
141
- new_str = replacement.get("new_str", "")
142
- if not old_str:
143
- console.print(f"[bold yellow]Warning:[/bold yellow] Replacement #{i} has empty old_str")
144
- continue
145
- if old_str not in modified_content:
146
- console.print(f"[bold red]Error:[/bold red] Text not found in file: {old_str[:50]}...")
147
- return {"error": f"Text to replace not found in file (replacement #{i})"}
148
- modified_content = modified_content.replace(old_str, new_str)
149
- applied_replacements.append({"old_str": old_str, "new_str": new_str})
150
- 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))
151
- diff_text = "".join(diff_lines)
152
- console.print("[bold cyan]Changes to be applied:[/bold cyan]")
153
- if diff_text.strip():
154
- formatted_diff = ""
155
- for line in diff_lines:
156
- if line.startswith("+") and not line.startswith("+++"):
157
- formatted_diff += f"[bold green]{line}[/bold green]"
158
- elif line.startswith("-") and not line.startswith("---"):
159
- formatted_diff += f"[bold red]{line}[/bold red]"
160
- elif line.startswith("@"):
161
- formatted_diff += f"[bold cyan]{line}[/bold cyan]"
162
- else:
163
- formatted_diff += line
164
- console.print(formatted_diff)
165
- else:
166
- console.print("[dim]No changes detected - file content is identical[/dim]")
167
- return {"success": False,"path": file_path,"message": "No changes to apply.","diff": "","changed": False,}
168
- with open(file_path, "w", encoding="utf-8") as f:
169
- f.write(modified_content)
170
- 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)}
171
- except Exception as e:
172
- console.print(f"[bold red]Error:[/bold red] {str(e)}")
173
- return {"error": f"Error replacing in file '{path}': {str(e)}"}
174
-
175
- @agent.tool
176
- def delete_file(context: RunContext, file_path: str) -> Dict[str, Any]:
177
- console.log(f"🗑️ Deleting file [bold red]{file_path}[/bold red]")
178
- file_path = os.path.abspath(file_path)
179
- try:
180
- if not os.path.exists(file_path):
181
- return {"error": f"File '{file_path}' does not exist."}
182
- if not os.path.isfile(file_path):
183
- return {"error": f"'{file_path}' is not a file. Use rmdir for directories."}
184
- os.remove(file_path)
185
- return {"success": True,"path": file_path,"message": f"File '{file_path}' deleted successfully."}
186
- except PermissionError:
187
- return {"error": f"Permission denied to delete '{file_path}'."}
188
- except FileNotFoundError:
189
- return {"error": f"File '{file_path}' does not exist."}
190
- except Exception as e:
191
- return {"error": f"Error deleting file '{file_path}': {str(e)}"}
File without changes
File without changes
File without changes