code-puppy 0.0.55__tar.gz → 0.0.56__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 (29) hide show
  1. {code_puppy-0.0.55 → code_puppy-0.0.56}/.gitignore +2 -0
  2. {code_puppy-0.0.55 → code_puppy-0.0.56}/PKG-INFO +3 -1
  3. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/agent.py +4 -3
  4. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/agent_prompts.py +1 -1
  5. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/command_line/file_path_completion.py +2 -1
  6. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/command_line/meta_command_handler.py +2 -0
  7. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/command_line/model_picker_completion.py +6 -4
  8. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/command_line/prompt_toolkit_completion.py +7 -9
  9. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/command_line/utils.py +2 -1
  10. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/main.py +10 -10
  11. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/model_factory.py +12 -11
  12. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/session_memory.py +2 -2
  13. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/tools/code_map.py +4 -3
  14. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/tools/command_runner.py +6 -4
  15. code_puppy-0.0.56/code_puppy/tools/common.py +41 -0
  16. code_puppy-0.0.56/code_puppy/tools/file_modifications.py +346 -0
  17. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/tools/file_operations.py +5 -3
  18. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/tools/web_search.py +1 -0
  19. {code_puppy-0.0.55 → code_puppy-0.0.56}/pyproject.toml +3 -1
  20. code_puppy-0.0.55/code_puppy/tools/common.py +0 -5
  21. code_puppy-0.0.55/code_puppy/tools/file_modifications.py +0 -410
  22. {code_puppy-0.0.55 → code_puppy-0.0.56}/LICENSE +0 -0
  23. {code_puppy-0.0.55 → code_puppy-0.0.56}/README.md +0 -0
  24. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/__init__.py +0 -0
  25. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/command_line/__init__.py +0 -0
  26. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/config.py +1 -1
  27. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/models.json +0 -0
  28. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/tools/__init__.py +2 -2
  29. {code_puppy-0.0.55 → code_puppy-0.0.56}/code_puppy/version_checker.py +0 -0
@@ -18,3 +18,5 @@ wheels/
18
18
  .pytest_cache/
19
19
 
20
20
  dummy_path
21
+
22
+ .idea/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-puppy
3
- Version: 0.0.55
3
+ Version: 0.0.56
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
@@ -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
 
@@ -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,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
  }
@@ -0,0 +1,41 @@
1
+ import os
2
+
3
+ from typing import Optional, Tuple
4
+
5
+ from rapidfuzz.distance import JaroWinkler
6
+ from rich.console import Console
7
+
8
+ NO_COLOR = bool(int(os.environ.get("CODE_PUPPY_NO_COLOR", "0")))
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)
@@ -0,0 +1,346 @@
1
+ """Robust, always-diff-logging file-modification helpers + agent tools.
2
+
3
+ Key guarantees
4
+ --------------
5
+ 1. **A diff is printed _inline_ on every path** (success, no-op, or error) – no decorator magic.
6
+ 2. **Full traceback logging** for unexpected errors via `_log_error`.
7
+ 3. Helper functions stay print-free and return a `diff` key, while agent-tool wrappers handle
8
+ all console output.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import difflib
14
+ import json
15
+ import os
16
+ import traceback
17
+ from typing import Any, Dict, List
18
+
19
+ from json_repair import repair_json
20
+ from pydantic_ai import RunContext
21
+
22
+ from code_puppy.tools.common import _find_best_window, console
23
+
24
+
25
+ def _print_diff(diff_text: str) -> None:
26
+ """Pretty-print *diff_text* with colour-coding (always runs)."""
27
+ console.print(
28
+ "[bold cyan]\n── DIFF ────────────────────────────────────────────────[/bold cyan]"
29
+ )
30
+ if diff_text and diff_text.strip():
31
+ for line in diff_text.splitlines():
32
+ if line.startswith("+") and not line.startswith("+++"):
33
+ console.print(f"[bold green]{line}[/bold green]", highlight=False)
34
+ elif line.startswith("-") and not line.startswith("---"):
35
+ console.print(f"[bold red]{line}[/bold red]", highlight=False)
36
+ elif line.startswith("@"):
37
+ console.print(f"[bold cyan]{line}[/bold cyan]", highlight=False)
38
+ else:
39
+ console.print(line, highlight=False)
40
+ else:
41
+ console.print("[dim]-- no diff available --[/dim]")
42
+ console.print(
43
+ "[bold cyan]───────────────────────────────────────────────────────[/bold cyan]"
44
+ )
45
+
46
+
47
+ def _log_error(msg: str, exc: Exception | None = None) -> None:
48
+ console.print(f"[bold red]Error:[/bold red] {msg}")
49
+ if exc is not None:
50
+ console.print(traceback.format_exc(), highlight=False)
51
+
52
+
53
+ def _delete_snippet_from_file(
54
+ context: RunContext | None, file_path: str, snippet: str
55
+ ) -> Dict[str, Any]:
56
+ file_path = os.path.abspath(file_path)
57
+ diff_text = ""
58
+ try:
59
+ if not os.path.exists(file_path) or not os.path.isfile(file_path):
60
+ return {"error": f"File '{file_path}' does not exist.", "diff": diff_text}
61
+ with open(file_path, "r", encoding="utf-8") as f:
62
+ original = f.read()
63
+ if snippet not in original:
64
+ return {
65
+ "error": f"Snippet not found in file '{file_path}'.",
66
+ "diff": diff_text,
67
+ }
68
+ modified = original.replace(snippet, "")
69
+ diff_text = "".join(
70
+ difflib.unified_diff(
71
+ original.splitlines(keepends=True),
72
+ modified.splitlines(keepends=True),
73
+ fromfile=f"a/{os.path.basename(file_path)}",
74
+ tofile=f"b/{os.path.basename(file_path)}",
75
+ n=3,
76
+ )
77
+ )
78
+ with open(file_path, "w", encoding="utf-8") as f:
79
+ f.write(modified)
80
+ return {
81
+ "success": True,
82
+ "path": file_path,
83
+ "message": "Snippet deleted from file.",
84
+ "changed": True,
85
+ "diff": diff_text,
86
+ }
87
+ except Exception as exc: # noqa: BLE001
88
+ _log_error("Unhandled exception in delete_snippet_from_file", exc)
89
+ return {"error": str(exc), "diff": diff_text}
90
+
91
+
92
+ def _replace_in_file(
93
+ context: RunContext | None, path: str, replacements: List[Dict[str, str]]
94
+ ) -> Dict[str, Any]:
95
+ """Robust replacement engine with explicit edge‑case reporting."""
96
+ file_path = os.path.abspath(path)
97
+
98
+ with open(file_path, "r", encoding="utf-8") as f:
99
+ original = f.read()
100
+
101
+ modified = original
102
+ for rep in replacements:
103
+ old_snippet = rep.get("old_str", "")
104
+ new_snippet = rep.get("new_str", "")
105
+
106
+ if old_snippet and old_snippet in modified:
107
+ modified = modified.replace(old_snippet, new_snippet)
108
+ continue
109
+
110
+ orig_lines = modified.splitlines()
111
+ loc, score = _find_best_window(orig_lines, old_snippet)
112
+
113
+ if loc is None:
114
+ return {
115
+ "error": "No suitable match in file (JW < 0.95)",
116
+ "jw_score": score,
117
+ "received": old_snippet,
118
+ "diff": "",
119
+ }
120
+
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:])
128
+ )
129
+
130
+ if modified == original:
131
+ console.print(
132
+ "[bold yellow]No changes to apply – proposed content is identical.[/bold yellow]"
133
+ )
134
+ return {
135
+ "success": False,
136
+ "path": file_path,
137
+ "message": "No changes to apply.",
138
+ "changed": False,
139
+ "diff": "",
140
+ }
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
+
161
+
162
+ def _write_to_file(
163
+ context: RunContext | None,
164
+ path: str,
165
+ content: str,
166
+ overwrite: bool = False,
167
+ ) -> Dict[str, Any]:
168
+ file_path = os.path.abspath(path)
169
+
170
+ try:
171
+ exists = os.path.exists(file_path)
172
+ if exists and not overwrite:
173
+ return {
174
+ "success": False,
175
+ "path": file_path,
176
+ "message": f"Cowardly refusing to overwrite existing file: {file_path}",
177
+ "changed": False,
178
+ "diff": "",
179
+ }
180
+
181
+ diff_lines = difflib.unified_diff(
182
+ [] if not exists else [""],
183
+ content.splitlines(keepends=True),
184
+ fromfile="/dev/null" if not exists else f"a/{os.path.basename(file_path)}",
185
+ tofile=f"b/{os.path.basename(file_path)}",
186
+ n=3,
187
+ )
188
+ diff_text = "".join(diff_lines)
189
+
190
+ os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
191
+ with open(file_path, "w", encoding="utf-8") as f:
192
+ f.write(content)
193
+
194
+ action = "overwritten" if exists else "created"
195
+ return {
196
+ "success": True,
197
+ "path": file_path,
198
+ "message": f"File '{file_path}' {action} successfully.",
199
+ "changed": True,
200
+ "diff": diff_text,
201
+ }
202
+
203
+ except Exception as exc:
204
+ _log_error("Unhandled exception in write_to_file", exc)
205
+ return {"error": str(exc), "diff": ""}
206
+
207
+
208
+ def register_file_modifications_tools(agent):
209
+ """Attach file-editing tools to *agent* with mandatory diff rendering."""
210
+
211
+ def delete_snippet_from_file(
212
+ context: RunContext, file_path: str, snippet: str
213
+ ) -> Dict[str, Any]:
214
+ console.log(f"🗑️ Deleting snippet from file [bold red]{file_path}[/bold red]")
215
+ res = _delete_snippet_from_file(context, file_path, snippet)
216
+ diff = res.get("diff", "")
217
+ if diff:
218
+ _print_diff(diff)
219
+ return res
220
+
221
+ def write_to_file(
222
+ context: RunContext, path: str, content: str, overwrite: bool
223
+ ) -> Dict[str, Any]:
224
+ console.log(f"✏️ Writing file [bold blue]{path}[/bold blue]")
225
+ res = _write_to_file(context, path, content, overwrite=overwrite)
226
+ diff = res.get("diff", "")
227
+ if diff:
228
+ _print_diff(diff)
229
+ return res
230
+
231
+ def replace_in_file(
232
+ context: RunContext, path: str, replacements: List[Dict[str, str]]
233
+ ) -> Dict[str, Any]:
234
+ console.log(f"♻️ Replacing text in [bold yellow]{path}[/bold yellow]")
235
+ res = _replace_in_file(context, path, replacements)
236
+ diff = res.get("diff", "")
237
+ if diff:
238
+ _print_diff(diff)
239
+ return res
240
+
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
+
315
+ @agent.tool
316
+ def delete_file(context: RunContext, file_path: str) -> Dict[str, Any]:
317
+ console.log(f"🗑️ Deleting file [bold red]{file_path}[/bold red]")
318
+ file_path = os.path.abspath(file_path)
319
+ try:
320
+ if not os.path.exists(file_path) or not os.path.isfile(file_path):
321
+ res = {"error": f"File '{file_path}' does not exist.", "diff": ""}
322
+ else:
323
+ with open(file_path, "r", encoding="utf-8") as f:
324
+ original = f.read()
325
+ diff_text = "".join(
326
+ difflib.unified_diff(
327
+ original.splitlines(keepends=True),
328
+ [],
329
+ fromfile=f"a/{os.path.basename(file_path)}",
330
+ tofile=f"b/{os.path.basename(file_path)}",
331
+ n=3,
332
+ )
333
+ )
334
+ os.remove(file_path)
335
+ res = {
336
+ "success": True,
337
+ "path": file_path,
338
+ "message": f"File '{file_path}' deleted successfully.",
339
+ "changed": True,
340
+ "diff": diff_text,
341
+ }
342
+ except Exception as exc:
343
+ _log_error("Unhandled exception in delete_file", exc)
344
+ res = {"error": str(exc), "diff": ""}
345
+ _print_diff(res.get("diff", ""))
346
+ return res
@@ -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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "code-puppy"
7
- version = "0.0.55"
7
+ version = "0.0.56"
8
8
  description = "Code generation agent"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -21,6 +21,8 @@ dependencies = [
21
21
  "httpx-limiter>=0.3.0",
22
22
  "prompt-toolkit>=3.0.38",
23
23
  "pathspec>=0.11.0",
24
+ "rapidfuzz>=3.13.0",
25
+ "json-repair>=0.46.2",
24
26
  ]
25
27
  authors = [
26
28
  {name = "Michael Pfaffenberger"}
@@ -1,5 +0,0 @@
1
- import os
2
- from rich.console import Console
3
-
4
- NO_COLOR = bool(int(os.environ.get("CODE_PUPPY_NO_COLOR", "0")))
5
- console = Console(no_color=NO_COLOR)
@@ -1,410 +0,0 @@
1
- # file_modifications.py
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
15
- import difflib
16
- import json
17
- import os
18
- import traceback
19
- from typing import Any, Dict, List
20
-
21
- from code_puppy.tools.common import console
22
- from pydantic_ai import RunContext
23
-
24
- # ---------------------------------------------------------------------------
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
59
- # ---------------------------------------------------------------------------
60
-
61
-
62
- def _delete_snippet_from_file(
63
- context: RunContext | None, file_path: str, snippet: str
64
- ) -> Dict[str, Any]:
65
- file_path = os.path.abspath(file_path)
66
- diff_text = ""
67
- try:
68
- if not os.path.exists(file_path) or not os.path.isfile(file_path):
69
- return {"error": f"File '{file_path}' does not exist.", "diff": diff_text}
70
- with open(file_path, "r", encoding="utf-8") as f:
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
- )
87
- with open(file_path, "w", encoding="utf-8") as f:
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}
99
-
100
-
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."""
105
- 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
-
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)
175
- return {
176
- "success": True,
177
- "path": file_path,
178
- "message": "Replacements applied.",
179
- "changed": True,
180
- "diff": diff_text,
181
- }
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."""
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
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]:
347
- console.log(f"🗑️ Deleting snippet from file [bold red]{file_path}[/bold red]")
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
356
- def write_to_file(context: RunContext, path: str, content: str) -> Dict[str, Any]:
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
366
- def replace_in_file(context: RunContext, path: str, diff: str) -> Dict[str, Any]:
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
371
-
372
- # ------------------------------------------------------------------
373
- # Delete entire file
374
- # ------------------------------------------------------------------
375
- # ------------------------------------------------------------------
376
- # Delete entire file (with full diff)
377
- # ------------------------------------------------------------------
378
- @agent.tool
379
- def delete_file(context: RunContext, file_path: str) -> Dict[str, Any]:
380
- console.log(f"🗑️ Deleting file [bold red]{file_path}[/bold red]")
381
- file_path = os.path.abspath(file_path)
382
- try:
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
File without changes
File without changes
@@ -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")
@@ -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