code-puppy 0.0.54__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.
- {code_puppy-0.0.54 → code_puppy-0.0.56}/.gitignore +2 -0
- {code_puppy-0.0.54 → code_puppy-0.0.56}/PKG-INFO +3 -1
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/agent.py +4 -3
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/agent_prompts.py +1 -1
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/command_line/file_path_completion.py +2 -1
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/command_line/meta_command_handler.py +2 -0
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/command_line/model_picker_completion.py +6 -4
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/command_line/prompt_toolkit_completion.py +7 -9
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/command_line/utils.py +2 -1
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/main.py +10 -10
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/model_factory.py +12 -11
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/session_memory.py +2 -2
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/tools/code_map.py +4 -3
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/tools/command_runner.py +6 -4
- code_puppy-0.0.56/code_puppy/tools/common.py +41 -0
- code_puppy-0.0.56/code_puppy/tools/file_modifications.py +346 -0
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/tools/file_operations.py +139 -18
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/tools/web_search.py +1 -0
- {code_puppy-0.0.54 → code_puppy-0.0.56}/pyproject.toml +3 -1
- code_puppy-0.0.54/code_puppy/tools/common.py +0 -5
- code_puppy-0.0.54/code_puppy/tools/file_modifications.py +0 -410
- {code_puppy-0.0.54 → code_puppy-0.0.56}/LICENSE +0 -0
- {code_puppy-0.0.54 → code_puppy-0.0.56}/README.md +0 -0
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/__init__.py +0 -0
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/command_line/__init__.py +0 -0
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/config.py +1 -1
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/models.json +0 -0
- {code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/tools/__init__.py +2 -2
- {code_puppy-0.0.54 → code_puppy-0.0.56}/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.
|
|
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
|
|
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,10 +1,12 @@
|
|
|
1
|
-
import os
|
|
2
1
|
import json
|
|
3
|
-
|
|
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
|
|
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")
|
{code_puppy-0.0.54 → code_puppy-0.0.56}/code_puppy/command_line/prompt_toolkit_completion.py
RENAMED
|
@@ -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.
|
|
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,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
|
|
9
|
-
from rich.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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,11 +1,13 @@
|
|
|
1
1
|
import subprocess
|
|
2
2
|
import time
|
|
3
|
-
from typing import
|
|
4
|
-
|
|
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
|
-
|
|
5
|
-
from
|
|
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
|
# ---------------------------------------------------------------------------
|
|
@@ -40,29 +42,148 @@ def should_ignore_path(path: str) -> bool:
|
|
|
40
42
|
def _list_files(
|
|
41
43
|
context: RunContext, directory: str = ".", recursive: bool = True
|
|
42
44
|
) -> List[Dict[str, Any]]:
|
|
43
|
-
|
|
45
|
+
results = []
|
|
46
|
+
directory = os.path.abspath(directory)
|
|
47
|
+
console.print("\n[bold white on blue] DIRECTORY LISTING [/bold white on blue]")
|
|
44
48
|
console.print(
|
|
45
|
-
f"\
|
|
49
|
+
f"\U0001f4c2 [bold cyan]{directory}[/bold cyan] [dim](recursive={recursive})[/dim]"
|
|
46
50
|
)
|
|
47
51
|
console.print("[dim]" + "-" * 60 + "[/dim]")
|
|
48
|
-
|
|
49
|
-
results: List[Dict[str, Any]] = []
|
|
50
|
-
if not os.path.exists(directory) or not os.path.isdir(directory):
|
|
52
|
+
if not os.path.exists(directory):
|
|
51
53
|
console.print(
|
|
52
|
-
f"[bold red]Directory '{directory}' does not exist
|
|
54
|
+
f"[bold red]Error:[/bold red] Directory '{directory}' does not exist"
|
|
53
55
|
)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
console.print("[dim]" + "-" * 60 + "[/dim]\n")
|
|
57
|
+
return [{"error": f"Directory '{directory}' does not exist"}]
|
|
58
|
+
if not os.path.isdir(directory):
|
|
59
|
+
console.print(f"[bold red]Error:[/bold red] '{directory}' is not a directory")
|
|
60
|
+
console.print("[dim]" + "-" * 60 + "[/dim]\n")
|
|
61
|
+
return [{"error": f"'{directory}' is not a directory"}]
|
|
62
|
+
folder_structure = {}
|
|
63
|
+
file_list = []
|
|
57
64
|
for root, dirs, files in os.walk(directory):
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
dirs[:] = [d for d in dirs if not should_ignore_path(os.path.join(root, d))]
|
|
66
|
+
rel_path = os.path.relpath(root, directory)
|
|
67
|
+
depth = 0 if rel_path == "." else rel_path.count(os.sep) + 1
|
|
68
|
+
if rel_path == ".":
|
|
69
|
+
rel_path = ""
|
|
70
|
+
if rel_path:
|
|
71
|
+
dir_path = os.path.join(directory, rel_path)
|
|
72
|
+
results.append(
|
|
73
|
+
{
|
|
74
|
+
"path": rel_path,
|
|
75
|
+
"type": "directory",
|
|
76
|
+
"size": 0,
|
|
77
|
+
"full_path": dir_path,
|
|
78
|
+
"depth": depth,
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
folder_structure[rel_path] = {
|
|
82
|
+
"path": rel_path,
|
|
83
|
+
"depth": depth,
|
|
84
|
+
"full_path": dir_path,
|
|
85
|
+
}
|
|
86
|
+
for file in files:
|
|
87
|
+
file_path = os.path.join(root, file)
|
|
88
|
+
if should_ignore_path(file_path):
|
|
89
|
+
continue
|
|
90
|
+
rel_file_path = os.path.join(rel_path, file) if rel_path else file
|
|
91
|
+
try:
|
|
92
|
+
size = os.path.getsize(file_path)
|
|
93
|
+
file_info = {
|
|
94
|
+
"path": rel_file_path,
|
|
95
|
+
"type": "file",
|
|
96
|
+
"size": size,
|
|
97
|
+
"full_path": file_path,
|
|
98
|
+
"depth": depth,
|
|
99
|
+
}
|
|
100
|
+
results.append(file_info)
|
|
101
|
+
file_list.append(file_info)
|
|
102
|
+
except (FileNotFoundError, PermissionError):
|
|
103
|
+
continue
|
|
64
104
|
if not recursive:
|
|
65
105
|
break
|
|
106
|
+
|
|
107
|
+
def format_size(size_bytes):
|
|
108
|
+
if size_bytes < 1024:
|
|
109
|
+
return f"{size_bytes} B"
|
|
110
|
+
elif size_bytes < 1024 * 1024:
|
|
111
|
+
return f"{size_bytes / 1024:.1f} KB"
|
|
112
|
+
elif size_bytes < 1024 * 1024 * 1024:
|
|
113
|
+
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
114
|
+
else:
|
|
115
|
+
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
|
|
116
|
+
|
|
117
|
+
def get_file_icon(file_path):
|
|
118
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
119
|
+
if ext in [".py", ".pyw"]:
|
|
120
|
+
return "\U0001f40d"
|
|
121
|
+
elif ext in [".js", ".jsx", ".ts", ".tsx"]:
|
|
122
|
+
return "\U0001f4dc"
|
|
123
|
+
elif ext in [".html", ".htm", ".xml"]:
|
|
124
|
+
return "\U0001f310"
|
|
125
|
+
elif ext in [".css", ".scss", ".sass"]:
|
|
126
|
+
return "\U0001f3a8"
|
|
127
|
+
elif ext in [".md", ".markdown", ".rst"]:
|
|
128
|
+
return "\U0001f4dd"
|
|
129
|
+
elif ext in [".json", ".yaml", ".yml", ".toml"]:
|
|
130
|
+
return "\u2699\ufe0f"
|
|
131
|
+
elif ext in [".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp"]:
|
|
132
|
+
return "\U0001f5bc\ufe0f"
|
|
133
|
+
elif ext in [".mp3", ".wav", ".ogg", ".flac"]:
|
|
134
|
+
return "\U0001f3b5"
|
|
135
|
+
elif ext in [".mp4", ".avi", ".mov", ".webm"]:
|
|
136
|
+
return "\U0001f3ac"
|
|
137
|
+
elif ext in [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"]:
|
|
138
|
+
return "\U0001f4c4"
|
|
139
|
+
elif ext in [".zip", ".tar", ".gz", ".rar", ".7z"]:
|
|
140
|
+
return "\U0001f4e6"
|
|
141
|
+
elif ext in [".exe", ".dll", ".so", ".dylib"]:
|
|
142
|
+
return "\u26a1"
|
|
143
|
+
else:
|
|
144
|
+
return "\U0001f4c4"
|
|
145
|
+
|
|
146
|
+
if results:
|
|
147
|
+
files = sorted(
|
|
148
|
+
[f for f in results if f["type"] == "file"], key=lambda x: x["path"]
|
|
149
|
+
)
|
|
150
|
+
console.print(
|
|
151
|
+
f"\U0001f4c1 [bold blue]{os.path.basename(directory) or directory}[/bold blue]"
|
|
152
|
+
)
|
|
153
|
+
all_items = sorted(results, key=lambda x: x["path"])
|
|
154
|
+
parent_dirs_with_content = set()
|
|
155
|
+
for i, item in enumerate(all_items):
|
|
156
|
+
if item["type"] == "directory" and not item["path"]:
|
|
157
|
+
continue
|
|
158
|
+
if os.sep in item["path"]:
|
|
159
|
+
parent_path = os.path.dirname(item["path"])
|
|
160
|
+
parent_dirs_with_content.add(parent_path)
|
|
161
|
+
depth = item["path"].count(os.sep) + 1 if item["path"] else 0
|
|
162
|
+
prefix = ""
|
|
163
|
+
for d in range(depth):
|
|
164
|
+
if d == depth - 1:
|
|
165
|
+
prefix += "\u2514\u2500\u2500 "
|
|
166
|
+
else:
|
|
167
|
+
prefix += " "
|
|
168
|
+
name = os.path.basename(item["path"]) or item["path"]
|
|
169
|
+
if item["type"] == "directory":
|
|
170
|
+
console.print(f"{prefix}\U0001f4c1 [bold blue]{name}/[/bold blue]")
|
|
171
|
+
else:
|
|
172
|
+
icon = get_file_icon(item["path"])
|
|
173
|
+
size_str = format_size(item["size"])
|
|
174
|
+
console.print(
|
|
175
|
+
f"{prefix}{icon} [green]{name}[/green] [dim]({size_str})[/dim]"
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
console.print("[yellow]Directory is empty[/yellow]")
|
|
179
|
+
dir_count = sum(1 for item in results if item["type"] == "directory")
|
|
180
|
+
file_count = sum(1 for item in results if item["type"] == "file")
|
|
181
|
+
total_size = sum(item["size"] for item in results if item["type"] == "file")
|
|
182
|
+
console.print("\n[bold cyan]Summary:[/bold cyan]")
|
|
183
|
+
console.print(
|
|
184
|
+
f"\U0001f4c1 [blue]{dir_count} directories[/blue], \U0001f4c4 [green]{file_count} files[/green] [dim]({format_size(total_size)} total)[/dim]"
|
|
185
|
+
)
|
|
186
|
+
console.print("[dim]" + "-" * 60 + "[/dim]\n")
|
|
66
187
|
return results
|
|
67
188
|
|
|
68
189
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "code-puppy"
|
|
7
|
-
version = "0.0.
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -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
|
|
|
File without changes
|