code-puppy 0.0.55__py3-none-any.whl → 0.0.57__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/agent.py CHANGED
@@ -1,13 +1,14 @@
1
1
  import os
2
- import pydantic
3
2
  from pathlib import Path
3
+
4
+ import pydantic
4
5
  from pydantic_ai import Agent
5
6
 
6
7
  from code_puppy.agent_prompts import get_system_prompt
7
8
  from code_puppy.model_factory import ModelFactory
8
- from code_puppy.tools.common import console
9
- from code_puppy.tools import register_all_tools
10
9
  from code_puppy.session_memory import SessionMemory
10
+ from code_puppy.tools import register_all_tools
11
+ from code_puppy.tools.common import console
11
12
 
12
13
  # Environment variables used in this module:
13
14
  # - MODELS_JSON_PATH: Optional path to a custom models.json configuration file.
@@ -1,4 +1,4 @@
1
- from code_puppy.config import get_puppy_name, get_owner_name
1
+ from code_puppy.config import get_owner_name, get_puppy_name
2
2
 
3
3
  SYSTEM_PROMPT_TEMPLATE = """
4
4
  You are {puppy_name}, the most loyal digital puppy, helping your owner {owner_name} get coding stuff done! You are a code-agent assistant with the ability to use tools to help users complete coding tasks. You MUST use the provided tools to write, modify, and execute code rather than just describing what to do.
@@ -1,6 +1,7 @@
1
- import os
2
1
  import glob
2
+ import os
3
3
  from typing import Iterable
4
+
4
5
  from prompt_toolkit.completion import Completer, Completion
5
6
  from prompt_toolkit.document import Document
6
7
 
@@ -1,5 +1,7 @@
1
1
  import os
2
+
2
3
  from rich.console import Console
4
+
3
5
  from code_puppy.command_line.model_picker_completion import (
4
6
  load_model_names,
5
7
  update_model_in_input,
@@ -1,10 +1,12 @@
1
- import os
2
1
  import json
3
- from typing import Optional, Iterable
2
+ import os
3
+ from typing import Iterable, Optional
4
+
5
+ from prompt_toolkit import PromptSession
4
6
  from prompt_toolkit.completion import Completer, Completion
5
- from prompt_toolkit.history import FileHistory
6
7
  from prompt_toolkit.document import Document
7
- from prompt_toolkit import PromptSession
8
+ from prompt_toolkit.history import FileHistory
9
+
8
10
  from code_puppy.config import get_model_name, set_model_name
9
11
 
10
12
  MODELS_JSON_PATH = os.environ.get("MODELS_JSON_PATH")
@@ -1,7 +1,3 @@
1
- import os
2
- from code_puppy.command_line.utils import list_directory
3
- from code_puppy.config import get_puppy_name, get_config_keys, get_value
4
-
5
1
  # ANSI color codes are no longer necessary because prompt_toolkit handles
6
2
  # styling via the `Style` class. We keep them here commented-out in case
7
3
  # someone needs raw ANSI later, but they are unused in the current code.
@@ -11,23 +7,25 @@ from code_puppy.config import get_puppy_name, get_config_keys, get_value
11
7
  # YELLOW = '\033[1;33m'
12
8
  # BOLD = '\033[1m'
13
9
  import asyncio
10
+ import os
14
11
  from typing import Optional
12
+
15
13
  from prompt_toolkit import PromptSession
14
+ from prompt_toolkit.completion import Completer, Completion, merge_completers
16
15
  from prompt_toolkit.formatted_text import FormattedText
17
- from prompt_toolkit.completion import merge_completers
18
16
  from prompt_toolkit.history import FileHistory
19
- from prompt_toolkit.styles import Style
20
17
  from prompt_toolkit.key_binding import KeyBindings
21
18
  from prompt_toolkit.keys import Keys
19
+ from prompt_toolkit.styles import Style
22
20
 
21
+ from code_puppy.command_line.file_path_completion import FilePathCompleter
23
22
  from code_puppy.command_line.model_picker_completion import (
24
23
  ModelNameCompleter,
25
24
  get_active_model,
26
25
  update_model_in_input,
27
26
  )
28
- from code_puppy.command_line.file_path_completion import FilePathCompleter
29
-
30
- from prompt_toolkit.completion import Completer, Completion
27
+ from code_puppy.command_line.utils import list_directory
28
+ from code_puppy.config import get_config_keys, get_puppy_name, get_value
31
29
 
32
30
 
33
31
  class SetCompleter(Completer):
@@ -1,5 +1,6 @@
1
1
  import os
2
- from typing import Tuple, List
2
+ from typing import List, Tuple
3
+
3
4
  from rich.table import Table
4
5
 
5
6
 
code_puppy/config.py CHANGED
@@ -1,5 +1,5 @@
1
- import os
2
1
  import configparser
2
+ import os
3
3
 
4
4
  CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".code_puppy")
5
5
  CONFIG_FILE = os.path.join(CONFIG_DIR, "puppy.cfg")
code_puppy/main.py CHANGED
@@ -1,25 +1,25 @@
1
- import asyncio
2
1
  import argparse
2
+ import asyncio
3
3
  import os
4
- from code_puppy.version_checker import fetch_latest_version
5
- from code_puppy import __version__
6
4
  import sys
5
+
7
6
  from dotenv import load_dotenv
8
- from code_puppy.config import ensure_config_exists
9
- from rich.console import Console
10
- from rich.markdown import Markdown
11
- from rich.console import ConsoleOptions, RenderResult
12
- from rich.markdown import CodeBlock
13
- from rich.text import Text
7
+ from rich.console import Console, ConsoleOptions, RenderResult
8
+ from rich.markdown import CodeBlock, Markdown
14
9
  from rich.syntax import Syntax
10
+ from rich.text import Text
11
+
12
+ from code_puppy import __version__
13
+ from code_puppy.agent import get_code_generation_agent, session_memory
15
14
  from code_puppy.command_line.prompt_toolkit_completion import (
16
15
  get_input_with_combined_completion,
17
16
  get_prompt_with_active_model,
18
17
  )
18
+ from code_puppy.config import ensure_config_exists
19
19
 
20
20
  # Initialize rich console for pretty output
21
21
  from code_puppy.tools.common import console
22
- from code_puppy.agent import get_code_generation_agent, session_memory
22
+ from code_puppy.version_checker import fetch_latest_version
23
23
 
24
24
  # from code_puppy.tools import * # noqa: F403
25
25
 
@@ -1,20 +1,21 @@
1
- import os
2
- import json
3
1
  import asyncio
2
+ import json
3
+ import os
4
+ import threading
4
5
  import time
5
- from typing import Dict, Any
6
+ from collections import deque
7
+ from typing import Any, Dict
8
+
9
+ import httpx
10
+ from anthropic import AsyncAnthropic
11
+ from httpx import Response
12
+ from openai import AsyncAzureOpenAI # For Azure OpenAI client
13
+ from pydantic_ai.models.anthropic import AnthropicModel
6
14
  from pydantic_ai.models.gemini import GeminiModel
7
15
  from pydantic_ai.models.openai import OpenAIModel
8
- from pydantic_ai.models.anthropic import AnthropicModel
16
+ from pydantic_ai.providers.anthropic import AnthropicProvider
9
17
  from pydantic_ai.providers.google_gla import GoogleGLAProvider
10
18
  from pydantic_ai.providers.openai import OpenAIProvider
11
- from pydantic_ai.providers.anthropic import AnthropicProvider
12
- from anthropic import AsyncAnthropic
13
- from openai import AsyncAzureOpenAI # For Azure OpenAI client
14
- import httpx
15
- from httpx import Response
16
- import threading
17
- from collections import deque
18
19
 
19
20
  # Environment variables used in this module:
20
21
  # - GEMINI_API_KEY: API key for Google's Gemini models. Required when using Gemini models.
@@ -1,7 +1,7 @@
1
1
  import json
2
- from pathlib import Path
3
2
  from datetime import datetime, timedelta
4
- from typing import Any, List, Dict, Optional
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List, Optional
5
5
 
6
6
  DEFAULT_MEMORY_PATH = Path(".puppy_session_memory.json")
7
7
 
@@ -1,6 +1,6 @@
1
- from code_puppy.tools.file_operations import register_file_operations_tools
2
- from code_puppy.tools.file_modifications import register_file_modifications_tools
3
1
  from code_puppy.tools.command_runner import register_command_runner_tools
2
+ from code_puppy.tools.file_modifications import register_file_modifications_tools
3
+ from code_puppy.tools.file_operations import register_file_operations_tools
4
4
  from code_puppy.tools.web_search import register_web_search_tools
5
5
 
6
6
 
@@ -1,8 +1,9 @@
1
- import os
2
1
  import ast
3
- from rich.tree import Tree
4
- from rich.text import Text
2
+ import os
3
+
5
4
  import pathspec
5
+ from rich.text import Text
6
+ from rich.tree import Tree
6
7
 
7
8
 
8
9
  def summarize_node(node: ast.AST) -> str:
@@ -1,11 +1,13 @@
1
1
  import subprocess
2
2
  import time
3
- from typing import Dict, Any
4
- from code_puppy.tools.common import console
3
+ from typing import Any, Dict
4
+
5
5
  from pydantic_ai import RunContext
6
6
  from rich.markdown import Markdown
7
7
  from rich.syntax import Syntax
8
8
 
9
+ from code_puppy.tools.common import console
10
+
9
11
 
10
12
  def run_shell_command(
11
13
  context: RunContext, command: str, cwd: str = None, timeout: int = 60
@@ -138,8 +140,8 @@ def run_shell_command(
138
140
  "success": False,
139
141
  "command": command,
140
142
  "error": f"Error executing command: {str(e)}",
141
- "stdout": "",
142
- "stderr": "",
143
+ "stdout": stdout[-1000:],
144
+ "stderr": stderr[-1000:],
143
145
  "exit_code": -1,
144
146
  "timeout": False,
145
147
  }
@@ -1,5 +1,41 @@
1
1
  import os
2
+
3
+ from typing import Optional, Tuple
4
+
5
+ from rapidfuzz.distance import JaroWinkler
2
6
  from rich.console import Console
3
7
 
4
8
  NO_COLOR = bool(int(os.environ.get("CODE_PUPPY_NO_COLOR", "0")))
5
9
  console = Console(no_color=NO_COLOR)
10
+
11
+ JW_THRESHOLD = 0.95
12
+
13
+
14
+ def _find_best_window(
15
+ haystack_lines: list[str],
16
+ needle: str,
17
+ ) -> Tuple[Optional[Tuple[int, int]], float]:
18
+ """
19
+ Return (start, end) indices of the window with the highest
20
+ Jaro-Winkler similarity to `needle`, along with that score.
21
+ If nothing clears JW_THRESHOLD, return (None, score).
22
+ """
23
+ needle = needle.rstrip("\n")
24
+ needle_lines = needle.splitlines()
25
+ win_size = len(needle_lines)
26
+ best_score = 0.0
27
+ best_span: Optional[Tuple[int, int]] = None
28
+ best_window = ""
29
+ # Pre-join the needle once; join windows on the fly
30
+ for i in range(len(haystack_lines) - win_size + 1):
31
+ window = "\n".join(haystack_lines[i : i + win_size])
32
+ score = JaroWinkler.normalized_similarity(window, needle)
33
+ if score > best_score:
34
+ best_score = score
35
+ best_span = (i, i + win_size)
36
+ best_window = window
37
+
38
+ console.log(f"Best span: {best_span}")
39
+ console.log(f"Best window: {best_window}")
40
+ console.log(f"Best score: {best_score}")
41
+ return (best_span, best_score)
@@ -1,4 +1,3 @@
1
- # file_modifications.py
2
1
  """Robust, always-diff-logging file-modification helpers + agent tools.
3
2
 
4
3
  Key guarantees
@@ -11,19 +10,16 @@ Key guarantees
11
10
 
12
11
  from __future__ import annotations
13
12
 
14
- import ast
15
13
  import difflib
16
14
  import json
17
15
  import os
18
16
  import traceback
19
17
  from typing import Any, Dict, List
20
18
 
21
- from code_puppy.tools.common import console
19
+ from json_repair import repair_json
22
20
  from pydantic_ai import RunContext
23
21
 
24
- # ---------------------------------------------------------------------------
25
- # Console helpers – shared across tools
26
- # ---------------------------------------------------------------------------
22
+ from code_puppy.tools.common import _find_best_window, console
27
23
 
28
24
 
29
25
  def _print_diff(diff_text: str) -> None:
@@ -54,11 +50,6 @@ def _log_error(msg: str, exc: Exception | None = None) -> None:
54
50
  console.print(traceback.format_exc(), highlight=False)
55
51
 
56
52
 
57
- # ---------------------------------------------------------------------------
58
- # Pure helpers – no console output
59
- # ---------------------------------------------------------------------------
60
-
61
-
62
53
  def _delete_snippet_from_file(
63
54
  context: RunContext | None, file_path: str, snippet: str
64
55
  ) -> Dict[str, Any]:
@@ -99,96 +90,74 @@ def _delete_snippet_from_file(
99
90
 
100
91
 
101
92
  def _replace_in_file(
102
- context: RunContext | None, path: str, diff: str
93
+ context: RunContext | None, path: str, replacements: List[Dict[str, str]]
103
94
  ) -> Dict[str, Any]:
104
95
  """Robust replacement engine with explicit edge‑case reporting."""
105
96
  file_path = os.path.abspath(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
97
 
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
- }
98
+ with open(file_path, "r", encoding="utf-8") as f:
99
+ original = f.read()
135
100
 
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
- }
101
+ modified = original
102
+ for rep in replacements:
103
+ old_snippet = rep.get("old_str", "")
104
+ new_snippet = rep.get("new_str", "")
143
105
 
144
- with open(file_path, "r", encoding="utf-8") as f:
145
- original = f.read()
106
+ if old_snippet and old_snippet in modified:
107
+ modified = modified.replace(old_snippet, new_snippet)
108
+ continue
146
109
 
147
- modified = original
148
- for rep in replacements:
149
- modified = modified.replace(rep.get("old_str", ""), rep.get("new_str", ""))
110
+ orig_lines = modified.splitlines()
111
+ loc, score = _find_best_window(orig_lines, old_snippet)
150
112
 
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
- )
113
+ if loc is None:
156
114
  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
115
+ "error": "No suitable match in file (JW < 0.95)",
116
+ "jw_score": score,
117
+ "received": old_snippet,
118
+ "diff": "",
162
119
  }
163
120
 
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
- )
121
+ start, end = loc
122
+ modified = (
123
+ "\n".join(orig_lines[:start])
124
+ + "\n"
125
+ + new_snippet.rstrip("\n")
126
+ + "\n"
127
+ + "\n".join(orig_lines[end:])
172
128
  )
173
- with open(file_path, "w", encoding="utf-8") as f:
174
- f.write(modified)
175
- return {
176
- "success": True,
177
- "path": file_path,
178
- "message": "Replacements applied.",
179
- "changed": True,
180
- "diff": diff_text,
181
- }
182
129
 
183
- except Exception as exc: # noqa: BLE001
184
- # ── Explicit error edge case ────────────────────────────────────
185
- _log_error("Unhandled exception in replace_in_file", exc)
130
+ if modified == original:
131
+ console.print(
132
+ "[bold yellow]No changes to apply – proposed content is identical.[/bold yellow]"
133
+ )
186
134
  return {
187
- "error": str(exc),
135
+ "success": False,
188
136
  "path": file_path,
189
- "diff": preview, # show the exact diff input that blew up
137
+ "message": "No changes to apply.",
138
+ "changed": False,
139
+ "diff": "",
190
140
  }
191
141
 
142
+ diff_text = "".join(
143
+ difflib.unified_diff(
144
+ original.splitlines(keepends=True),
145
+ modified.splitlines(keepends=True),
146
+ fromfile=f"a/{os.path.basename(file_path)}",
147
+ tofile=f"b/{os.path.basename(file_path)}",
148
+ n=3,
149
+ )
150
+ )
151
+ with open(file_path, "w", encoding="utf-8") as f:
152
+ f.write(modified)
153
+ return {
154
+ "success": True,
155
+ "path": file_path,
156
+ "message": "Replacements applied.",
157
+ "changed": True,
158
+ "diff": diff_text,
159
+ }
160
+
192
161
 
193
162
  def _write_to_file(
194
163
  context: RunContext | None,
@@ -209,10 +178,9 @@ def _write_to_file(
209
178
  "diff": "",
210
179
  }
211
180
 
212
- # --- NEW: build diff before writing ---
213
181
  diff_lines = difflib.unified_diff(
214
- [] if not exists else [""], # empty “old” file
215
- content.splitlines(keepends=True), # new file lines
182
+ [] if not exists else [""],
183
+ content.splitlines(keepends=True),
216
184
  fromfile="/dev/null" if not exists else f"a/{os.path.basename(file_path)}",
217
185
  tofile=f"b/{os.path.basename(file_path)}",
218
186
  n=3,
@@ -232,149 +200,118 @@ def _write_to_file(
232
200
  "diff": diff_text,
233
201
  }
234
202
 
235
- except Exception as exc: # noqa: BLE001
203
+ except Exception as exc:
236
204
  _log_error("Unhandled exception in write_to_file", exc)
237
205
  return {"error": str(exc), "diff": ""}
238
206
 
239
207
 
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."""
244
- file_path = os.path.abspath(path)
245
- preview = (diff[:400] + "…") if len(diff) > 400 else diff # for logs / errors
246
- diff_text = ""
247
- try:
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) ──────────────────
252
- try:
253
- payload = json.loads(diff)
254
- except json.JSONDecodeError:
255
- try:
256
- payload = json.loads(diff.replace("'", '"'))
257
- except Exception as exc:
258
- return {
259
- "error": "Could not parse diff as JSON.",
260
- "reason": str(exc),
261
- "received": preview,
262
- "diff": preview,
263
- }
264
- if not isinstance(payload, dict):
265
- try:
266
- payload = ast.literal_eval(diff)
267
- except Exception as exc:
268
- return {
269
- "error": "Diff is neither valid JSON nor Python literal.",
270
- "reason": str(exc),
271
- "received": preview,
272
- "diff": preview,
273
- }
274
-
275
- replacements: List[Dict[str, str]] = payload.get("replacements", [])
276
- if not replacements:
277
- return {
278
- "error": "No valid replacements found in diff.",
279
- "received": preview,
280
- "diff": preview,
281
- }
282
-
283
- with open(file_path, "r", encoding="utf-8") as f:
284
- original = f.read()
285
-
286
- modified = original
287
- for rep in replacements:
288
- modified = modified.replace(rep.get("old_str", ""), rep.get("new_str", ""))
289
-
290
- if modified == original:
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
- )
312
- with open(file_path, "w", encoding="utf-8") as f:
313
- f.write(modified)
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
-
331
-
332
- # ---------------------------------------------------------------------------
333
- # Agent-tool registration
334
- # ---------------------------------------------------------------------------
335
-
336
-
337
- def register_file_modifications_tools(agent): # noqa: C901 – a bit long but clear
208
+ def register_file_modifications_tools(agent):
338
209
  """Attach file-editing tools to *agent* with mandatory diff rendering."""
339
210
 
340
- # ------------------------------------------------------------------
341
- # Delete snippet
342
- # ------------------------------------------------------------------
343
- @agent.tool
344
211
  def delete_snippet_from_file(
345
212
  context: RunContext, file_path: str, snippet: str
346
213
  ) -> Dict[str, Any]:
347
214
  console.log(f"🗑️ Deleting snippet from file [bold red]{file_path}[/bold red]")
348
215
  res = _delete_snippet_from_file(context, file_path, snippet)
349
- _print_diff(res.get("diff", ""))
216
+ diff = res.get("diff", "")
217
+ if diff:
218
+ _print_diff(diff)
350
219
  return res
351
220
 
352
- # ------------------------------------------------------------------
353
- # Write / create file
354
- # ------------------------------------------------------------------
355
- @agent.tool
356
- def write_to_file(context: RunContext, path: str, content: str) -> Dict[str, Any]:
221
+ def write_to_file(
222
+ context: RunContext, path: str, content: str, overwrite: bool
223
+ ) -> Dict[str, Any]:
357
224
  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))
225
+ res = _write_to_file(context, path, content, overwrite=overwrite)
226
+ diff = res.get("diff", "")
227
+ if diff:
228
+ _print_diff(diff)
360
229
  return res
361
230
 
362
- # ------------------------------------------------------------------
363
- # Replace text in file
364
- # ------------------------------------------------------------------
365
- @agent.tool
366
- def replace_in_file(context: RunContext, path: str, diff: str) -> Dict[str, Any]:
231
+ def replace_in_file(
232
+ context: RunContext, path: str, replacements: List[Dict[str, str]]
233
+ ) -> Dict[str, Any]:
367
234
  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))
235
+ res = _replace_in_file(context, path, replacements)
236
+ diff = res.get("diff", "")
237
+ if diff:
238
+ _print_diff(diff)
370
239
  return res
371
240
 
372
- # ------------------------------------------------------------------
373
- # Delete entire file
374
- # ------------------------------------------------------------------
375
- # ------------------------------------------------------------------
376
- # Delete entire file (with full diff)
377
- # ------------------------------------------------------------------
241
+ @agent.tool(retries=5)
242
+ def edit_file(context: RunContext, path: str, diff: str) -> Dict[str, Any]:
243
+ """
244
+ Unified file editing tool that can:
245
+ - Create/write a new file when the target does not exist (using raw content or a JSON payload with a "content" key)
246
+ - Replace text within an existing file via a JSON payload with "replacements" (delegates to internal replace logic)
247
+ - Delete a snippet from an existing file via a JSON payload with "delete_snippet"
248
+ Parameters
249
+ ----------
250
+ path : str
251
+ Path to the target file (relative or absolute)
252
+ diff : str
253
+ Either:
254
+ * Raw file content (for file creation)
255
+ * A JSON string with one of the following shapes:
256
+ {"content": "full file contents", "overwrite": true}
257
+ {"replacements": [ {"old_str": "foo", "new_str": "bar"}, ... ] }
258
+ {"delete_snippet": "text to remove"}
259
+ The function auto-detects the payload type and routes to the appropriate internal helper.
260
+ """
261
+ console.print("\n[bold white on blue] EDIT FILE [/bold white on blue]")
262
+ file_path = os.path.abspath(path)
263
+ try:
264
+ parsed_payload = json.loads(diff)
265
+ except json.JSONDecodeError:
266
+ try:
267
+ console.print(
268
+ "[bold yellow] JSON Parsing Failed! TRYING TO REPAIR! [/bold yellow]"
269
+ )
270
+ parsed_payload = json.loads(repair_json(diff))
271
+ console.print(
272
+ "[bold green on cyan] SUCCESS - WOOF! [/bold green on cyan]"
273
+ )
274
+ except Exception as e:
275
+ console.print(
276
+ f"[bold red] Unable to parse diff [/bold red] -- {str(e)}"
277
+ )
278
+ return {
279
+ "success": False,
280
+ "path": file_path,
281
+ "message": f"Unable to parse diff JSON -- {str(e)}",
282
+ "changed": False,
283
+ "diff": "",
284
+ }
285
+ if isinstance(parsed_payload, dict):
286
+ if "delete_snippet" in parsed_payload:
287
+ snippet = parsed_payload["delete_snippet"]
288
+ return delete_snippet_from_file(context, file_path, snippet)
289
+ if "replacements" in parsed_payload:
290
+ replacements = parsed_payload["replacements"]
291
+ return replace_in_file(context, file_path, replacements)
292
+ if "content" in parsed_payload:
293
+ content = parsed_payload["content"]
294
+ overwrite = bool(parsed_payload.get("overwrite", False))
295
+ file_exists = os.path.exists(file_path)
296
+ if file_exists and not overwrite:
297
+ return {
298
+ "success": False,
299
+ "path": file_path,
300
+ "message": f"File '{file_path}' exists. Set 'overwrite': true to replace.",
301
+ "changed": False,
302
+ }
303
+ return write_to_file(context, file_path, content, overwrite)
304
+ console.print(
305
+ "[bold red] Unable to route file modification tool call to sub-tool [/bold red]"
306
+ )
307
+ console.print("Inputs: ", path, diff)
308
+ return {
309
+ "success": False,
310
+ "path": file_path,
311
+ "message": "Wasn't able to route file modification to the right sub-tool!",
312
+ "changed": False,
313
+ }
314
+
378
315
  @agent.tool
379
316
  def delete_file(context: RunContext, file_path: str) -> Dict[str, Any]:
380
317
  console.log(f"🗑️ Deleting file [bold red]{file_path}[/bold red]")
@@ -385,7 +322,6 @@ def register_file_modifications_tools(agent): # noqa: C901 – a bit long but c
385
322
  else:
386
323
  with open(file_path, "r", encoding="utf-8") as f:
387
324
  original = f.read()
388
- # Diff: original lines → empty file
389
325
  diff_text = "".join(
390
326
  difflib.unified_diff(
391
327
  original.splitlines(keepends=True),
@@ -403,7 +339,7 @@ def register_file_modifications_tools(agent): # noqa: C901 – a bit long but c
403
339
  "changed": True,
404
340
  "diff": diff_text,
405
341
  }
406
- except Exception as exc: # noqa: BLE001
342
+ except Exception as exc:
407
343
  _log_error("Unhandled exception in delete_file", exc)
408
344
  res = {"error": str(exc), "diff": ""}
409
345
  _print_diff(res.get("diff", ""))
@@ -1,10 +1,12 @@
1
1
  # file_operations.py
2
- import os
3
2
  import fnmatch
4
- from typing import List, Dict, Any
5
- from code_puppy.tools.common import console
3
+ import os
4
+ from typing import Any, Dict, List
5
+
6
6
  from pydantic_ai import RunContext
7
7
 
8
+ from code_puppy.tools.common import console
9
+
8
10
  # ---------------------------------------------------------------------------
9
11
  # Module-level helper functions (exposed for unit tests _and_ used as tools)
10
12
  # ---------------------------------------------------------------------------
@@ -1,4 +1,5 @@
1
1
  from typing import Dict
2
+
2
3
  import requests
3
4
  from pydantic_ai import RunContext
4
5
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-puppy
3
- Version: 0.0.55
3
+ Version: 0.0.57
4
4
  Summary: Code generation agent
5
5
  Author: Michael Pfaffenberger
6
6
  License: MIT
@@ -16,6 +16,7 @@ Requires-Python: >=3.10
16
16
  Requires-Dist: bs4>=0.0.2
17
17
  Requires-Dist: httpx-limiter>=0.3.0
18
18
  Requires-Dist: httpx>=0.24.1
19
+ Requires-Dist: json-repair>=0.46.2
19
20
  Requires-Dist: logfire>=0.7.1
20
21
  Requires-Dist: pathspec>=0.11.0
21
22
  Requires-Dist: prompt-toolkit>=3.0.38
@@ -23,6 +24,7 @@ Requires-Dist: pydantic-ai>=0.1.0
23
24
  Requires-Dist: pydantic>=2.4.0
24
25
  Requires-Dist: pytest-cov>=6.1.1
25
26
  Requires-Dist: python-dotenv>=1.0.0
27
+ Requires-Dist: rapidfuzz>=3.13.0
26
28
  Requires-Dist: rich>=13.4.2
27
29
  Requires-Dist: ruff>=0.11.11
28
30
  Description-Content-Type: text/markdown
@@ -0,0 +1,28 @@
1
+ code_puppy/__init__.py,sha256=-ANvE6Xe5NlWDIRCIfL1x-rgtCZ6zM2Ye9NphFoULSY,82
2
+ code_puppy/agent.py,sha256=e3DBLW6dq30VuQdm5CVfLO81cU4ilyLJIz6rBj5U7pw,3239
3
+ code_puppy/agent_prompts.py,sha256=bT8m7UCFU56yYdMtnB9xeiPtMwU3WA63d265PclCzb4,6774
4
+ code_puppy/config.py,sha256=LTcZe5eHTT0zq-YJzSeNg8LCwhHb1HGN4tOkITtb7Eo,3941
5
+ code_puppy/main.py,sha256=qDbDB123MAv5r_kH91x1LxUROI9I28y2tvuYo2YxEbQ,10323
6
+ code_puppy/model_factory.py,sha256=SE4osZ2_BEzKdIlBT4nNroWlTWNc5byOFGMO9erN5N4,11646
7
+ code_puppy/models.json,sha256=7H-y97YK9BXhag5wJU19rtg24JtZWYx60RsBLBW3WiI,2162
8
+ code_puppy/session_memory.py,sha256=4sgAAjbXdLSi8hETpd56tgtrG6hqMUuZWDlJOu6BQjA,2735
9
+ code_puppy/version_checker.py,sha256=aRGulzuY4C4CdFvU1rITduyL-1xTFsn4GiD1uSfOl_Y,396
10
+ code_puppy/command_line/__init__.py,sha256=y7WeRemfYppk8KVbCGeAIiTuiOszIURCDjOMZv_YRmU,45
11
+ code_puppy/command_line/file_path_completion.py,sha256=gw8NpIxa6GOpczUJRyh7VNZwoXKKn-yvCqit7h2y6Gg,2931
12
+ code_puppy/command_line/meta_command_handler.py,sha256=5jORzgSEmJBP8ixGIz8ET5Su9taPJZpQibe732LmOsg,5571
13
+ code_puppy/command_line/model_picker_completion.py,sha256=NkyZZG7IhcVWSJ3ADytwCA5f8DpNeVs759Qtqs4fQtY,3733
14
+ code_puppy/command_line/prompt_toolkit_completion.py,sha256=wxz8xCBge__EAxJRXYUCt9FoDpHog1QZ6RqpvoQP7O4,7904
15
+ code_puppy/command_line/utils.py,sha256=7eyxDHjPjPB9wGDJQQcXV_zOsGdYsFgI0SGCetVmTqE,1251
16
+ code_puppy/tools/__init__.py,sha256=J_HCbkivdr1rP5vfucxttXhGmTBx0S2LNoDMrbaE-Fc,558
17
+ code_puppy/tools/code_map.py,sha256=5vzKBUddY0z9kMfHZmLiewUMJofDOONJIaXCWVhbE5E,3201
18
+ code_puppy/tools/command_runner.py,sha256=6Ej2axH-b4ZGPLxOWg9kfW75qbftAecCFqWEtw3wEB8,6540
19
+ code_puppy/tools/common.py,sha256=v-qT0_OEPkH_m4bT70BaMszpssc4wYkPlf7uMooH3s8,1309
20
+ code_puppy/tools/file_modifications.py,sha256=vf5I-X_i7Q75ShKalo9zZyKD6nEWSy_PIJKHpOwT93I,13161
21
+ code_puppy/tools/file_operations.py,sha256=-mo07EB6Rp_eDEFWqdJblj1Unz6flAsSY2ZNIxYzBiM,11595
22
+ code_puppy/tools/web_search.py,sha256=sA2ierjuuYA517-uhb5s53SgeVsyOe1nExoZsrU1Fps,1284
23
+ code_puppy-0.0.57.data/data/code_puppy/models.json,sha256=7H-y97YK9BXhag5wJU19rtg24JtZWYx60RsBLBW3WiI,2162
24
+ code_puppy-0.0.57.dist-info/METADATA,sha256=ot-l6Xp2m1QHXn2tPSDUcjlErdIeuoU9wzYOh1TqGqk,4784
25
+ code_puppy-0.0.57.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
26
+ code_puppy-0.0.57.dist-info/entry_points.txt,sha256=d8YkBvIUxF-dHNJAj-x4fPEqizbY5d_TwvYpc01U5kw,58
27
+ code_puppy-0.0.57.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
28
+ code_puppy-0.0.57.dist-info/RECORD,,
@@ -1,28 +0,0 @@
1
- code_puppy/__init__.py,sha256=-ANvE6Xe5NlWDIRCIfL1x-rgtCZ6zM2Ye9NphFoULSY,82
2
- code_puppy/agent.py,sha256=yA2247YbmBdff37joH8lRDKFk-iYreCugjY4qrdmsgA,3238
3
- code_puppy/agent_prompts.py,sha256=mUt9a430x0aYbnCw4L6t-Wst1_0FcqKYka_RHtnRog0,6774
4
- code_puppy/config.py,sha256=Mn9VWj8Ux-qnl636BH0jE1tuM-HQ6bYmRaozMg-vbg8,3941
5
- code_puppy/main.py,sha256=mLnECoA5b20Jb_9PUIznsMKOmChIjvX0lEpT69Dx7y4,10370
6
- code_puppy/model_factory.py,sha256=AtCBAWEK6OmPHL79HG3J-MxvlMbXJ9s4SBNblcZKemM,11645
7
- code_puppy/models.json,sha256=7H-y97YK9BXhag5wJU19rtg24JtZWYx60RsBLBW3WiI,2162
8
- code_puppy/session_memory.py,sha256=CODAMmSsrxh8N9x_dXLryOSW3GnBXJ70auJnHm4m5Z8,2735
9
- code_puppy/version_checker.py,sha256=aRGulzuY4C4CdFvU1rITduyL-1xTFsn4GiD1uSfOl_Y,396
10
- code_puppy/command_line/__init__.py,sha256=y7WeRemfYppk8KVbCGeAIiTuiOszIURCDjOMZv_YRmU,45
11
- code_puppy/command_line/file_path_completion.py,sha256=WSGpUO5PwpqxxJAhdjzUEWjJCxGWPUl5GU95KTg_Y40,2930
12
- code_puppy/command_line/meta_command_handler.py,sha256=XqidNJYaoYMaHcFI85RwwKYElEhPXgedkXwauw_4C60,5569
13
- code_puppy/command_line/model_picker_completion.py,sha256=QHxzhgTvbGFw0EYFJAukcWB3JTuK4Z1ZWaYvygMqfgM,3731
14
- code_puppy/command_line/prompt_toolkit_completion.py,sha256=PyPo3H4CInBCbtddI91Owgwi1tKzWu9ryADOWWWMfHI,7942
15
- code_puppy/command_line/utils.py,sha256=_3wEvtJbey4E4qdg4pFX29sDPucRKGTyuodKI__NVrQ,1250
16
- code_puppy/tools/__init__.py,sha256=B1sHgH9mONPmWiGb6vucCIKzoRBlhM7UBxtfSRU1AEY,558
17
- code_puppy/tools/code_map.py,sha256=eAIT6IKEpq4OE6gE64HOjGMgEcPeJVEGb1eBHmFwaxY,3200
18
- code_puppy/tools/command_runner.py,sha256=m-0emolt81BvKuiEUBW8VPzrA1wgA_sXRJFmySPtxqA,6514
19
- code_puppy/tools/common.py,sha256=qX6wWsZPU9mwHX0AS2RIGIEQtQJ_AeWZ799LUvOCbfs,146
20
- code_puppy/tools/file_modifications.py,sha256=onhmNwlMlOD0BrnMH_1QGiwZQu0qAAiiN2dHRilzBHA,16124
21
- code_puppy/tools/file_operations.py,sha256=CAHFqlSmbjgbaAPENxoDajAHzWPSD2FjsuLpmdt5H0A,11593
22
- code_puppy/tools/web_search.py,sha256=GvUJJUDQ_5VHkd_YJkRUWJdqr0Y-XSZIyzmUHHiVcus,1283
23
- code_puppy-0.0.55.data/data/code_puppy/models.json,sha256=7H-y97YK9BXhag5wJU19rtg24JtZWYx60RsBLBW3WiI,2162
24
- code_puppy-0.0.55.dist-info/METADATA,sha256=NlrFhezFmkkXX0O08-KeDU1I_plHv1kFG00vus-MiIk,4716
25
- code_puppy-0.0.55.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
26
- code_puppy-0.0.55.dist-info/entry_points.txt,sha256=d8YkBvIUxF-dHNJAj-x4fPEqizbY5d_TwvYpc01U5kw,58
27
- code_puppy-0.0.55.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
28
- code_puppy-0.0.55.dist-info/RECORD,,