code-puppy 0.0.45__py3-none-any.whl → 0.0.47__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_puppy/agent_prompts.py +32 -33
- code_puppy/command_line/prompt_toolkit_completion.py +4 -0
- code_puppy/tools/file_modifications.py +207 -38
- code_puppy/tools/file_operations.py +77 -20
- {code_puppy-0.0.45.dist-info → code_puppy-0.0.47.dist-info}/METADATA +1 -1
- {code_puppy-0.0.45.dist-info → code_puppy-0.0.47.dist-info}/RECORD +10 -10
- {code_puppy-0.0.45.data → code_puppy-0.0.47.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.45.dist-info → code_puppy-0.0.47.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.45.dist-info → code_puppy-0.0.47.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.45.dist-info → code_puppy-0.0.47.dist-info}/licenses/LICENSE +0 -0
    
        code_puppy/agent_prompts.py
    CHANGED
    
    | @@ -27,52 +27,49 @@ YOU MUST USE THESE TOOLS to complete tasks (do not just describe what should be | |
| 27 27 | 
             
            File Operations:
         | 
| 28 28 | 
             
               - list_files(directory=".", recursive=True): ALWAYS use this to explore directories before trying to read/modify files
         | 
| 29 29 | 
             
               - read_file(file_path): ALWAYS use this to read existing files before modifying them.
         | 
| 30 | 
            -
               -  | 
| 31 | 
            -
               - replace_in_file(path, diff): Use this to make exact replacements in a file using JSON format.
         | 
| 32 | 
            -
               - delete_snippet_from_file(file_path, snippet): Use this to remove specific code snippets from files
         | 
| 30 | 
            +
               - edit_file(path, diff): Use this single tool to create new files, overwrite entire files, perform targeted replacements, or delete snippets depending on the JSON/raw payload provided.
         | 
| 33 31 | 
             
               - delete_file(file_path): Use this to remove files when needed
         | 
| 34 32 | 
             
               - grep(search_string, directory="."): Use this to recursively search for a string across files starting from the specified directory, capping results at 200 matches.
         | 
| 35 | 
            -
               - grab_json_from_url(url: str): Use this to grab JSON data from a specified URL, ensuring the response is of type application/json. It raises an error if the response type is not application/json and limits the output to 1000 lines.
         | 
| 36 33 |  | 
| 37 34 | 
             
            Tool Usage Instructions:
         | 
| 38 35 |  | 
| 39 | 
            -
            ##  | 
| 40 | 
            -
             | 
| 41 | 
            -
             | 
| 42 | 
            -
             | 
| 36 | 
            +
            ## edit_file
         | 
| 37 | 
            +
            This is an all-in-one file-modification tool. It supports the following payload shapes for the `diff` argument:
         | 
| 38 | 
            +
            1. {{ "content": "…", "overwrite": true|false }}  →  Treated as full-file content when the target file does **not** exist.
         | 
| 39 | 
            +
            2. {{ "content": "…", "overwrite": true|false }}  →  Create or overwrite a file with the provided content.
         | 
| 40 | 
            +
            3. {{ "replacements": [ {{ "old_str": "…", "new_str": "…" }}, … ] }}  →  Perform exact text replacements inside an existing file.
         | 
| 41 | 
            +
            4. {{ "delete_snippet": "…" }}  →  Remove a snippet of text from an existing file.
         | 
| 43 42 |  | 
| 44 | 
            -
             | 
| 45 | 
            -
             | 
| 46 | 
            -
             | 
| 47 | 
            -
                path="path/to/file.txt",
         | 
| 48 | 
            -
                content="Complete content of the file here..."
         | 
| 49 | 
            -
            )
         | 
| 50 | 
            -
            ```
         | 
| 43 | 
            +
            Arguments:
         | 
| 44 | 
            +
            - path (required): Target file path.
         | 
| 45 | 
            +
            - diff (required): One of the payloads above (raw string or JSON string).
         | 
| 51 46 |  | 
| 52 | 
            -
             | 
| 53 | 
            -
            Use this to make targeted replacements in an existing file. Each replacement must match exactly what's in the file.
         | 
| 54 | 
            -
            - path: The path to the file (required)
         | 
| 55 | 
            -
            - diff: JSON string with replacements (required)
         | 
| 56 | 
            -
             | 
| 57 | 
            -
            The diff parameter should be a JSON string in this format:
         | 
| 47 | 
            +
            Example (create):
         | 
| 58 48 | 
             
            ```json
         | 
| 59 | 
            -
             | 
| 60 | 
            -
              "replacements": [
         | 
| 61 | 
            -
                {{
         | 
| 62 | 
            -
                  "old_str": "exact string from file",
         | 
| 63 | 
            -
                  "new_str": "replacement string"
         | 
| 64 | 
            -
                }}
         | 
| 65 | 
            -
              ]
         | 
| 66 | 
            -
            }}
         | 
| 49 | 
            +
            edit_file("src/example.py", "print('hello')\n")
         | 
| 67 50 | 
             
            ```
         | 
| 68 51 |  | 
| 69 | 
            -
             | 
| 70 | 
            -
             | 
| 52 | 
            +
            Example (replacement):
         | 
| 53 | 
            +
            ```json
         | 
| 54 | 
            +
            edit_file(
         | 
| 55 | 
            +
              "src/example.py",
         | 
| 56 | 
            +
              "{{"replacements":[{{"old_str":"foo","new_str":"bar"}}]}}"
         | 
| 57 | 
            +
            )
         | 
| 58 | 
            +
            ```
         | 
| 71 59 |  | 
| 72 | 
            -
            NEVER output an entire file | 
| 60 | 
            +
            NEVER output an entire file – this is very expensive.
         | 
| 73 61 | 
             
            You may not edit file extensions: [.ipynb]
         | 
| 74 62 | 
             
            You should specify the following arguments before the others: [TargetFile]
         | 
| 75 63 |  | 
| 64 | 
            +
            Remember: ONE argument = ONE JSON string.
         | 
| 65 | 
            +
             | 
| 66 | 
            +
            Best-practice guidelines for `edit_file`:
         | 
| 67 | 
            +
            • Keep each diff small – ideally between 100-300 lines.  
         | 
| 68 | 
            +
            • Apply multiple sequential `edit_file` calls when you need to refactor large files instead of sending one massive diff.  
         | 
| 69 | 
            +
            • Never paste an entire file inside `old_str`; target only the minimal snippet you want changed.  
         | 
| 70 | 
            +
            • If the resulting file would grow beyond 600 lines, split logic into additional files and create them with separate `edit_file` calls.
         | 
| 71 | 
            +
             | 
| 72 | 
            +
             | 
| 76 73 | 
             
            System Operations:
         | 
| 77 74 | 
             
               - run_shell_command(command, cwd=None, timeout=60): Use this to execute commands, run tests, or start services
         | 
| 78 75 |  | 
| @@ -88,6 +85,8 @@ In the event that you want to see the entire output for the test, run a single t | |
| 88 85 |  | 
| 89 86 | 
             
            npm test -- ./path/to/test/file.tsx # or something like this.
         | 
| 90 87 |  | 
| 88 | 
            +
            DONT USE THE TERMINAL TOOL TO RUN THE CODE WE WROTE UNLESS THE USER ASKS YOU TO.
         | 
| 89 | 
            +
             | 
| 91 90 | 
             
            Reasoning & Explanation:
         | 
| 92 91 | 
             
               - share_your_reasoning(reasoning, next_steps=None): Use this to explicitly share your thought process and planned next steps
         | 
| 93 92 |  | 
| @@ -95,7 +94,7 @@ Important rules: | |
| 95 94 | 
             
            - You MUST use tools to accomplish tasks - DO NOT just output code or descriptions
         | 
| 96 95 | 
             
            - Before every other tool use, you must use "share_your_reasoning" to explain your thought process and planned next steps
         | 
| 97 96 | 
             
            - Check if files exist before trying to modify or delete them
         | 
| 98 | 
            -
            - Whenever possible, prefer to MODIFY existing files first (use ` | 
| 97 | 
            +
            - Whenever possible, prefer to MODIFY existing files first (use `edit_file`) before creating brand-new files or deleting existing ones.
         | 
| 99 98 | 
             
            - After using system operations tools, always explain the results
         | 
| 100 99 | 
             
            - You're encouraged to loop between share_your_reasoning, file tools, and run_shell_command to test output in order to write programs
         | 
| 101 100 | 
             
            - Aim to continue operations independently unless user input is definitively required.
         | 
| @@ -90,6 +90,10 @@ async def get_input_with_combined_completion(prompt_str = '>>> ', history_file: | |
| 90 90 | 
             
                @bindings.add(Keys.Escape, 'm')  # Alt+M
         | 
| 91 91 | 
             
                def _(event):
         | 
| 92 92 | 
             
                    event.app.current_buffer.insert_text('\n')
         | 
| 93 | 
            +
                @bindings.add(Keys.Escape)
         | 
| 94 | 
            +
                def _(event):
         | 
| 95 | 
            +
                    """Cancel the current prompt when the user presses the ESC key alone."""
         | 
| 96 | 
            +
                    event.app.exit(exception=KeyboardInterrupt)
         | 
| 93 97 |  | 
| 94 98 | 
             
                session = PromptSession(
         | 
| 95 99 | 
             
                    completer=completer,
         | 
| @@ -6,8 +6,104 @@ from code_puppy.tools.common import console | |
| 6 6 | 
             
            from typing import Dict, Any, List
         | 
| 7 7 | 
             
            from pydantic_ai import RunContext
         | 
| 8 8 |  | 
| 9 | 
            +
            # ---------------------------------------------------------------------------
         | 
| 10 | 
            +
            # Module-level helper functions (exposed for unit tests; *not* registered)
         | 
| 11 | 
            +
            # ---------------------------------------------------------------------------
         | 
| 12 | 
            +
             | 
| 13 | 
            +
            def delete_snippet_from_file(context: RunContext | None, file_path: str, snippet: str) -> Dict[str, Any]:
         | 
| 14 | 
            +
                """Remove *snippet* from *file_path* if present, returning a diff summary."""
         | 
| 15 | 
            +
                file_path = os.path.abspath(file_path)
         | 
| 16 | 
            +
                try:
         | 
| 17 | 
            +
                    if not os.path.exists(file_path) or not os.path.isfile(file_path):
         | 
| 18 | 
            +
                        return {"error": f"File '{file_path}' does not exist."}
         | 
| 19 | 
            +
                    with open(file_path, "r", encoding="utf-8") as f:
         | 
| 20 | 
            +
                        content = f.read()
         | 
| 21 | 
            +
                    if snippet not in content:
         | 
| 22 | 
            +
                        return {"error": f"Snippet not found in file '{file_path}'."}
         | 
| 23 | 
            +
                    modified_content = content.replace(snippet, "")
         | 
| 24 | 
            +
                    with open(file_path, "w", encoding="utf-8") as f:
         | 
| 25 | 
            +
                        f.write(modified_content)
         | 
| 26 | 
            +
                    return {"success": True, "path": file_path, "message": "Snippet deleted from file."}
         | 
| 27 | 
            +
                except PermissionError:
         | 
| 28 | 
            +
                    return {"error": f"Permission denied to modify '{file_path}'."}
         | 
| 29 | 
            +
                except FileNotFoundError:
         | 
| 30 | 
            +
                    return {"error": f"File '{file_path}' does not exist."}
         | 
| 31 | 
            +
                except Exception as exc:
         | 
| 32 | 
            +
                    return {"error": str(exc)}
         | 
| 33 | 
            +
             | 
| 34 | 
            +
             | 
| 35 | 
            +
            def write_to_file(context: RunContext | None, path: str, content: str) -> Dict[str, Any]:
         | 
| 36 | 
            +
                file_path = os.path.abspath(path)
         | 
| 37 | 
            +
                if os.path.exists(file_path):
         | 
| 38 | 
            +
                    return {
         | 
| 39 | 
            +
                        "success": False,
         | 
| 40 | 
            +
                        "path": file_path,
         | 
| 41 | 
            +
                        "message": f"Cowardly refusing to overwrite existing file: {file_path}",
         | 
| 42 | 
            +
                        "changed": False,
         | 
| 43 | 
            +
                    }
         | 
| 44 | 
            +
                os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
         | 
| 45 | 
            +
                with open(file_path, "w", encoding="utf-8") as f:
         | 
| 46 | 
            +
                    f.write(content)
         | 
| 47 | 
            +
                return {
         | 
| 48 | 
            +
                    "success": True,
         | 
| 49 | 
            +
                    "path": file_path,
         | 
| 50 | 
            +
                    "message": f"File '{file_path}' created successfully.",
         | 
| 51 | 
            +
                    "changed": True,
         | 
| 52 | 
            +
                }
         | 
| 53 | 
            +
             | 
| 54 | 
            +
             | 
| 55 | 
            +
            def replace_in_file(context: RunContext | None, path: str, diff: str) -> Dict[str, Any]:
         | 
| 56 | 
            +
                file_path = os.path.abspath(path)
         | 
| 57 | 
            +
                if not os.path.exists(file_path):
         | 
| 58 | 
            +
                    return {"error": f"File '{file_path}' does not exist"}
         | 
| 59 | 
            +
                try:
         | 
| 60 | 
            +
                    import json, ast, difflib
         | 
| 61 | 
            +
                    preview = (diff[:200] + '...') if len(diff) > 200 else diff
         | 
| 62 | 
            +
                    try:
         | 
| 63 | 
            +
                        replacements_data = json.loads(diff)
         | 
| 64 | 
            +
                    except json.JSONDecodeError as e1:
         | 
| 65 | 
            +
                        try:
         | 
| 66 | 
            +
                            replacements_data = json.loads(diff.replace("'", '"'))
         | 
| 67 | 
            +
                        except Exception as e2:
         | 
| 68 | 
            +
                            return {
         | 
| 69 | 
            +
                                "error": "Could not parse diff as JSON.",
         | 
| 70 | 
            +
                                "reason": str(e2),
         | 
| 71 | 
            +
                                "received": preview,
         | 
| 72 | 
            +
                            }
         | 
| 73 | 
            +
                    # If still not a dict -> maybe python literal
         | 
| 74 | 
            +
                    if not isinstance(replacements_data, dict):
         | 
| 75 | 
            +
                        try:
         | 
| 76 | 
            +
                            replacements_data = ast.literal_eval(diff)
         | 
| 77 | 
            +
                        except Exception as e3:
         | 
| 78 | 
            +
                            return {
         | 
| 79 | 
            +
                                "error": "Diff is neither valid JSON nor Python literal.",
         | 
| 80 | 
            +
                                "reason": str(e3),
         | 
| 81 | 
            +
                                "received": preview,
         | 
| 82 | 
            +
                            }
         | 
| 83 | 
            +
                    replacements = replacements_data.get("replacements", []) if isinstance(replacements_data, dict) else []
         | 
| 84 | 
            +
                    if not replacements:
         | 
| 85 | 
            +
                        return {
         | 
| 86 | 
            +
                            "error": "No valid replacements found in diff.",
         | 
| 87 | 
            +
                            "received": preview,
         | 
| 88 | 
            +
                        }
         | 
| 89 | 
            +
                    with open(file_path, "r", encoding="utf-8") as f:
         | 
| 90 | 
            +
                        original = f.read()
         | 
| 91 | 
            +
                    modified = original
         | 
| 92 | 
            +
                    for rep in replacements:
         | 
| 93 | 
            +
                        modified = modified.replace(rep.get("old_str", ""), rep.get("new_str", ""))
         | 
| 94 | 
            +
                    if modified == original:
         | 
| 95 | 
            +
                        return {"success": False, "path": file_path, "message": "No changes to apply.", "changed": False}
         | 
| 96 | 
            +
                    with open(file_path, "w", encoding="utf-8") as f:
         | 
| 97 | 
            +
                        f.write(modified)
         | 
| 98 | 
            +
                    diff_text = "".join(difflib.unified_diff(original.splitlines(keepends=True), modified.splitlines(keepends=True)))
         | 
| 99 | 
            +
                    return {"success": True, "path": file_path, "message": "Replacements applied.", "diff": diff_text, "changed": True}
         | 
| 100 | 
            +
                except Exception as exc:
         | 
| 101 | 
            +
                    return {"error": str(exc)}
         | 
| 102 | 
            +
             | 
| 103 | 
            +
            # ---------------------------------------------------------------------------
         | 
| 104 | 
            +
             | 
| 9 105 | 
             
            def register_file_modifications_tools(agent):
         | 
| 10 | 
            -
                @agent.tool
         | 
| 106 | 
            +
                # @agent.tool
         | 
| 11 107 | 
             
                def delete_snippet_from_file(context: RunContext, file_path: str, snippet: str) -> Dict[str, Any]:
         | 
| 12 108 | 
             
                    console.log(f"🗑️ Deleting snippet from file [bold red]{file_path}[/bold red]")
         | 
| 13 109 | 
             
                    file_path = os.path.abspath(file_path)
         | 
| @@ -53,7 +149,7 @@ def register_file_modifications_tools(agent): | |
| 53 149 | 
             
                    except Exception as e:
         | 
| 54 150 | 
             
                        return {"error": f"Error deleting file '{file_path}': {str(e)}"}
         | 
| 55 151 |  | 
| 56 | 
            -
                @agent.tool
         | 
| 152 | 
            +
                # @agent.tool
         | 
| 57 153 | 
             
                def write_to_file(context: RunContext, path: str, content: str) -> Dict[str, Any]:
         | 
| 58 154 | 
             
                    try:
         | 
| 59 155 | 
             
                        file_path = os.path.abspath(path)
         | 
| @@ -78,7 +174,7 @@ def register_file_modifications_tools(agent): | |
| 78 174 | 
             
                        console.print(f"[bold red]Error:[/bold red] {str(e)}")
         | 
| 79 175 | 
             
                        return {"error": f"Error writing to file '{path}': {str(e)}"}
         | 
| 80 176 |  | 
| 81 | 
            -
                @agent.tool(retries=5)
         | 
| 177 | 
            +
                # @agent.tool(retries=5)
         | 
| 82 178 | 
             
                def replace_in_file(context: RunContext, path: str, diff: str) -> Dict[str, Any]:
         | 
| 83 179 | 
             
                    try:
         | 
| 84 180 | 
             
                        file_path = os.path.abspath(path)
         | 
| @@ -94,44 +190,34 @@ def register_file_modifications_tools(agent): | |
| 94 190 | 
             
                        # The agent sometimes sends single-quoted or otherwise invalid JSON.
         | 
| 95 191 | 
             
                        # Attempt to recover by trying several strategies before giving up.
         | 
| 96 192 | 
             
                        # ------------------------------------------------------------------
         | 
| 97 | 
            -
                         | 
| 98 | 
            -
                        replacements: List[Dict[str, str]] = []
         | 
| 193 | 
            +
                        preview = (diff[:200] + '...') if len(diff) > 200 else diff
         | 
| 99 194 | 
             
                        try:
         | 
| 100 195 | 
             
                            replacements_data = json.loads(diff)
         | 
| 101 | 
            -
             | 
| 102 | 
            -
                            parsed_successfully = True
         | 
| 103 | 
            -
                        except json.JSONDecodeError:
         | 
| 104 | 
            -
                            # Fallback 1: convert single quotes to double quotes and retry
         | 
| 196 | 
            +
                        except json.JSONDecodeError as e1:
         | 
| 105 197 | 
             
                            try:
         | 
| 106 | 
            -
                                 | 
| 107 | 
            -
             | 
| 108 | 
            -
                                 | 
| 109 | 
            -
             | 
| 110 | 
            -
             | 
| 111 | 
            -
             | 
| 112 | 
            -
                                 | 
| 113 | 
            -
             | 
| 114 | 
            -
             | 
| 115 | 
            -
             | 
| 116 | 
            -
             | 
| 117 | 
            -
             | 
| 118 | 
            -
             | 
| 119 | 
            -
             | 
| 120 | 
            -
             | 
| 121 | 
            -
             | 
| 122 | 
            -
             | 
| 123 | 
            -
             | 
| 124 | 
            -
             | 
| 125 | 
            -
             | 
| 126 | 
            -
             | 
| 127 | 
            -
             | 
| 128 | 
            -
             | 
| 129 | 
            -
                                    console.print(
         | 
| 130 | 
            -
                                        f"[bold red]Error:[/bold red] Could not parse diff as JSON or Python literal. Reason: {e2}"
         | 
| 131 | 
            -
                                    )
         | 
| 132 | 
            -
                        if not parsed_successfully or not replacements:
         | 
| 133 | 
            -
                            console.print("[bold red]Error:[/bold red] No valid replacements found in the diff after all parsing attempts")
         | 
| 134 | 
            -
                            return {"error": "No valid replacements found in the diff"}
         | 
| 198 | 
            +
                                replacements_data = json.loads(diff.replace("'", '"'))
         | 
| 199 | 
            +
                            except Exception as e2:
         | 
| 200 | 
            +
                                return {
         | 
| 201 | 
            +
                                    "error": "Could not parse diff as JSON.",
         | 
| 202 | 
            +
                                    "reason": str(e2),
         | 
| 203 | 
            +
                                    "received": preview,
         | 
| 204 | 
            +
                                }
         | 
| 205 | 
            +
                        # If still not a dict -> maybe python literal
         | 
| 206 | 
            +
                        if not isinstance(replacements_data, dict):
         | 
| 207 | 
            +
                            try:
         | 
| 208 | 
            +
                                replacements_data = ast.literal_eval(diff)
         | 
| 209 | 
            +
                            except Exception as e3:
         | 
| 210 | 
            +
                                return {
         | 
| 211 | 
            +
                                    "error": "Diff is neither valid JSON nor Python literal.",
         | 
| 212 | 
            +
                                    "reason": str(e3),
         | 
| 213 | 
            +
                                    "received": preview,
         | 
| 214 | 
            +
                                }
         | 
| 215 | 
            +
                        replacements = replacements_data.get("replacements", []) if isinstance(replacements_data, dict) else []
         | 
| 216 | 
            +
                        if not replacements:
         | 
| 217 | 
            +
                            return {
         | 
| 218 | 
            +
                                "error": "No valid replacements found in diff.",
         | 
| 219 | 
            +
                                "received": preview,
         | 
| 220 | 
            +
                            }
         | 
| 135 221 | 
             
                        with open(file_path, "r", encoding="utf-8") as f:
         | 
| 136 222 | 
             
                            current_content = f.read()
         | 
| 137 223 | 
             
                        modified_content = current_content
         | 
| @@ -189,3 +275,86 @@ def register_file_modifications_tools(agent): | |
| 189 275 | 
             
                        return {"error": f"File '{file_path}' does not exist."}
         | 
| 190 276 | 
             
                    except Exception as e:
         | 
| 191 277 | 
             
                        return {"error": f"Error deleting file '{file_path}': {str(e)}"}
         | 
| 278 | 
            +
             | 
| 279 | 
            +
                @agent.tool(retries=5)
         | 
| 280 | 
            +
                def edit_file(context: RunContext, path: str, diff: str) -> Dict[str, Any]:
         | 
| 281 | 
            +
                    """
         | 
| 282 | 
            +
                    Unified file editing tool that can:
         | 
| 283 | 
            +
                    - Create/write a new file when the target does not exist (using raw content or a JSON payload with a "content" key)
         | 
| 284 | 
            +
                    - Replace text within an existing file via a JSON payload with "replacements" (delegates to internal replace logic)
         | 
| 285 | 
            +
                    - Delete a snippet from an existing file via a JSON payload with "delete_snippet"
         | 
| 286 | 
            +
             | 
| 287 | 
            +
                    Parameters
         | 
| 288 | 
            +
                    ----------
         | 
| 289 | 
            +
                    path : str
         | 
| 290 | 
            +
                        Path to the target file (relative or absolute)
         | 
| 291 | 
            +
                    diff : str
         | 
| 292 | 
            +
                        Either:
         | 
| 293 | 
            +
                          * Raw file content (for file creation)
         | 
| 294 | 
            +
                          * A JSON string with one of the following shapes:
         | 
| 295 | 
            +
                              {"content": "full file contents", "overwrite": true}
         | 
| 296 | 
            +
                              {"replacements": [ {"old_str": "foo", "new_str": "bar"}, ... ] }
         | 
| 297 | 
            +
                              {"delete_snippet": "text to remove"}
         | 
| 298 | 
            +
             | 
| 299 | 
            +
                    The function auto-detects the payload type and routes to the appropriate internal helper.
         | 
| 300 | 
            +
                    """
         | 
| 301 | 
            +
                    file_path = os.path.abspath(path)
         | 
| 302 | 
            +
             | 
| 303 | 
            +
                    # 1. Attempt to parse the incoming `diff` as JSON (robustly, allowing single quotes)
         | 
| 304 | 
            +
                    parsed_payload: Dict[str, Any] | None = None
         | 
| 305 | 
            +
                    try:
         | 
| 306 | 
            +
                        parsed_payload = json.loads(diff)
         | 
| 307 | 
            +
                    except json.JSONDecodeError:
         | 
| 308 | 
            +
                        # Fallback: try to sanitise single quotes
         | 
| 309 | 
            +
                        try:
         | 
| 310 | 
            +
                            parsed_payload = json.loads(diff.replace("'", '"'))
         | 
| 311 | 
            +
                        except Exception:
         | 
| 312 | 
            +
                            parsed_payload = None
         | 
| 313 | 
            +
             | 
| 314 | 
            +
                    # ------------------------------------------------------------------
         | 
| 315 | 
            +
                    # Case A: JSON payload recognised
         | 
| 316 | 
            +
                    # ------------------------------------------------------------------
         | 
| 317 | 
            +
                    if isinstance(parsed_payload, dict):
         | 
| 318 | 
            +
                        # Delete-snippet mode
         | 
| 319 | 
            +
                        if "delete_snippet" in parsed_payload:
         | 
| 320 | 
            +
                            snippet = parsed_payload["delete_snippet"]
         | 
| 321 | 
            +
                            return delete_snippet_from_file(context, file_path, snippet)
         | 
| 322 | 
            +
             | 
| 323 | 
            +
                        # Replacement mode
         | 
| 324 | 
            +
                        if "replacements" in parsed_payload:
         | 
| 325 | 
            +
                            # Forward the ORIGINAL diff string (not parsed) so that the existing logic
         | 
| 326 | 
            +
                            # which handles various JSON quirks can run unchanged.
         | 
| 327 | 
            +
                            return replace_in_file(context, file_path, diff)
         | 
| 328 | 
            +
             | 
| 329 | 
            +
                        # Write / create mode via content field
         | 
| 330 | 
            +
                        if "content" in parsed_payload:
         | 
| 331 | 
            +
                            content = parsed_payload["content"]
         | 
| 332 | 
            +
                            overwrite = bool(parsed_payload.get("overwrite", False))
         | 
| 333 | 
            +
                            file_exists = os.path.exists(file_path)
         | 
| 334 | 
            +
                            if file_exists and not overwrite:
         | 
| 335 | 
            +
                                return {"success": False, "path": file_path, "message": f"File '{file_path}' exists. Set 'overwrite': true to replace.", "changed": False}
         | 
| 336 | 
            +
                            if file_exists and overwrite:
         | 
| 337 | 
            +
                                # Overwrite directly
         | 
| 338 | 
            +
                                try:
         | 
| 339 | 
            +
                                    with open(file_path, "w", encoding="utf-8") as f:
         | 
| 340 | 
            +
                                        f.write(content)
         | 
| 341 | 
            +
                                    return {"success": True, "path": file_path, "message": f"File '{file_path}' overwritten successfully.", "changed": True}
         | 
| 342 | 
            +
                                except Exception as e:
         | 
| 343 | 
            +
                                    return {"error": f"Error overwriting file '{file_path}': {str(e)}"}
         | 
| 344 | 
            +
                            # File does not exist -> create
         | 
| 345 | 
            +
                            return write_to_file(context, file_path, content)
         | 
| 346 | 
            +
             | 
| 347 | 
            +
                    # ------------------------------------------------------------------
         | 
| 348 | 
            +
                    # Case B: Not JSON or unrecognised structure.
         | 
| 349 | 
            +
                    # Treat `diff` as raw content for file creation OR as replacement diff.
         | 
| 350 | 
            +
                    # ------------------------------------------------------------------
         | 
| 351 | 
            +
                    if not os.path.exists(file_path):
         | 
| 352 | 
            +
                        # Create new file with provided raw content
         | 
| 353 | 
            +
                        return write_to_file(context, file_path, diff)
         | 
| 354 | 
            +
             | 
| 355 | 
            +
                    # If file exists, attempt to treat the raw input as a replacement diff spec.
         | 
| 356 | 
            +
                    replacement_result = replace_in_file(context, file_path, diff)
         | 
| 357 | 
            +
                    if replacement_result.get("error"):
         | 
| 358 | 
            +
                        # Fallback: refuse to overwrite blindly
         | 
| 359 | 
            +
                        return {"success": False, "path": file_path, "message": "Unrecognised payload and cannot derive edit instructions.", "changed": False}
         | 
| 360 | 
            +
                    return replacement_result
         | 
| @@ -5,6 +5,83 @@ from typing import List, Dict, Any | |
| 5 5 | 
             
            from code_puppy.tools.common import console
         | 
| 6 6 | 
             
            from pydantic_ai import RunContext
         | 
| 7 7 |  | 
| 8 | 
            +
            # ---------------------------------------------------------------------------
         | 
| 9 | 
            +
            # Module-level helper functions (exposed for unit tests _and_ used as tools)
         | 
| 10 | 
            +
            # ---------------------------------------------------------------------------
         | 
| 11 | 
            +
            IGNORE_PATTERNS = [
         | 
| 12 | 
            +
                "**/node_modules/**",
         | 
| 13 | 
            +
                "**/.git/**",
         | 
| 14 | 
            +
                "**/__pycache__/**",
         | 
| 15 | 
            +
                "**/.DS_Store",
         | 
| 16 | 
            +
                "**/.env",
         | 
| 17 | 
            +
                "**/.venv/**",
         | 
| 18 | 
            +
                "**/venv/**",
         | 
| 19 | 
            +
                "**/.idea/**",
         | 
| 20 | 
            +
                "**/.vscode/**",
         | 
| 21 | 
            +
                "**/dist/**",
         | 
| 22 | 
            +
                "**/build/**",
         | 
| 23 | 
            +
                "**/*.pyc",
         | 
| 24 | 
            +
                "**/*.pyo",
         | 
| 25 | 
            +
                "**/*.pyd",
         | 
| 26 | 
            +
                "**/*.so",
         | 
| 27 | 
            +
                "**/*.dll",
         | 
| 28 | 
            +
                "**/*.exe",
         | 
| 29 | 
            +
            ]
         | 
| 30 | 
            +
             | 
| 31 | 
            +
            def should_ignore_path(path: str) -> bool:
         | 
| 32 | 
            +
                """Return True if *path* matches any pattern in IGNORE_PATTERNS."""
         | 
| 33 | 
            +
                for pattern in IGNORE_PATTERNS:
         | 
| 34 | 
            +
                    if fnmatch.fnmatch(path, pattern):
         | 
| 35 | 
            +
                        return True
         | 
| 36 | 
            +
                return False
         | 
| 37 | 
            +
             | 
| 38 | 
            +
            def list_files(context: RunContext | None, directory: str = ".", recursive: bool = True) -> List[Dict[str, Any]]:
         | 
| 39 | 
            +
                """Light-weight `list_files` implementation sufficient for unit-tests and agent tooling."""
         | 
| 40 | 
            +
                directory = os.path.abspath(directory)
         | 
| 41 | 
            +
                results: List[Dict[str, Any]] = []
         | 
| 42 | 
            +
                if not os.path.exists(directory) or not os.path.isdir(directory):
         | 
| 43 | 
            +
                    return [{"error": f"Directory '{directory}' does not exist or is not a directory"}]
         | 
| 44 | 
            +
                for root, dirs, files in os.walk(directory):
         | 
| 45 | 
            +
                    rel_root = os.path.relpath(root, directory)
         | 
| 46 | 
            +
                    if rel_root == ".":
         | 
| 47 | 
            +
                        rel_root = ""
         | 
| 48 | 
            +
                    for f in files:
         | 
| 49 | 
            +
                        fp = os.path.join(rel_root, f) if rel_root else f
         | 
| 50 | 
            +
                        results.append({"path": fp, "type": "file"})
         | 
| 51 | 
            +
                    if not recursive:
         | 
| 52 | 
            +
                        break
         | 
| 53 | 
            +
                return results
         | 
| 54 | 
            +
             | 
| 55 | 
            +
            def read_file(context: RunContext | None, file_path: str) -> Dict[str, Any]:
         | 
| 56 | 
            +
                file_path = os.path.abspath(file_path)
         | 
| 57 | 
            +
                if not os.path.exists(file_path):
         | 
| 58 | 
            +
                    return {"error": f"File '{file_path}' does not exist"}
         | 
| 59 | 
            +
                if not os.path.isfile(file_path):
         | 
| 60 | 
            +
                    return {"error": f"'{file_path}' is not a file"}
         | 
| 61 | 
            +
                try:
         | 
| 62 | 
            +
                    with open(file_path, "r", encoding="utf-8") as f:
         | 
| 63 | 
            +
                        content = f.read()
         | 
| 64 | 
            +
                    return {"content": content, "path": file_path, "total_lines": len(content.splitlines())}
         | 
| 65 | 
            +
                except Exception as exc:
         | 
| 66 | 
            +
                    return {"error": str(exc)}
         | 
| 67 | 
            +
             | 
| 68 | 
            +
            def grep(context: RunContext | None, search_string: str, directory: str = ".") -> List[Dict[str, Any]]:
         | 
| 69 | 
            +
                matches: List[Dict[str, Any]] = []
         | 
| 70 | 
            +
                directory = os.path.abspath(directory)
         | 
| 71 | 
            +
                for root, dirs, files in os.walk(directory):
         | 
| 72 | 
            +
                    for f in files:
         | 
| 73 | 
            +
                        file_path = os.path.join(root, f)
         | 
| 74 | 
            +
                        try:
         | 
| 75 | 
            +
                            with open(file_path, "r", encoding="utf-8") as fh:
         | 
| 76 | 
            +
                                for ln, line in enumerate(fh, 1):
         | 
| 77 | 
            +
                                    if search_string in line:
         | 
| 78 | 
            +
                                        matches.append({"file_path": file_path, "line_number": ln})
         | 
| 79 | 
            +
                                        if len(matches) >= 200:
         | 
| 80 | 
            +
                                            return matches
         | 
| 81 | 
            +
                        except Exception:
         | 
| 82 | 
            +
                            continue
         | 
| 83 | 
            +
                return matches
         | 
| 84 | 
            +
             | 
| 8 85 | 
             
            def register_file_operations_tools(agent):
         | 
| 9 86 | 
             
                # Constants for file operations
         | 
| 10 87 | 
             
                IGNORE_PATTERNS = [
         | 
| @@ -145,26 +222,6 @@ def register_file_operations_tools(agent): | |
| 145 222 | 
             
                    console.print("[dim]" + "-" * 60 + "[/dim]\n")
         | 
| 146 223 | 
             
                    return results
         | 
| 147 224 |  | 
| 148 | 
            -
                @agent.tool
         | 
| 149 | 
            -
                def create_file(context: RunContext, file_path: str, content: str = "") -> Dict[str, Any]:
         | 
| 150 | 
            -
                    file_path = os.path.abspath(file_path)
         | 
| 151 | 
            -
                    if os.path.exists(file_path):
         | 
| 152 | 
            -
                        return {"error": f"File '{file_path}' already exists. Use replace_in_file or write_to_file to edit it."}
         | 
| 153 | 
            -
                    directory = os.path.dirname(file_path)
         | 
| 154 | 
            -
                    if directory and not os.path.exists(directory):
         | 
| 155 | 
            -
                        try:
         | 
| 156 | 
            -
                            os.makedirs(directory)
         | 
| 157 | 
            -
                        except Exception as e:
         | 
| 158 | 
            -
                            return {"error": f"Error creating directory '{directory}': {str(e)}"}
         | 
| 159 | 
            -
                    try:
         | 
| 160 | 
            -
                        with open(file_path, "w", encoding="utf-8") as f:
         | 
| 161 | 
            -
                            console.print("[yellow]Writing to file:[/yellow]")
         | 
| 162 | 
            -
                            console.print(content)
         | 
| 163 | 
            -
                            f.write(content)
         | 
| 164 | 
            -
                        return {"success": True, "path": file_path, "message": f"File created at '{file_path}'", "content_length": len(content)}
         | 
| 165 | 
            -
                    except Exception as e:
         | 
| 166 | 
            -
                        return {"error": f"Error creating file '{file_path}': {str(e)}"}
         | 
| 167 | 
            -
             | 
| 168 225 | 
             
                @agent.tool
         | 
| 169 226 | 
             
                def read_file(context: RunContext, file_path: str) -> Dict[str, Any]:
         | 
| 170 227 | 
             
                    file_path = os.path.abspath(file_path)
         | 
| @@ -1,6 +1,6 @@ | |
| 1 1 | 
             
            code_puppy/__init__.py,sha256=orgffM-uGp8g1XCqXGKWaFB4tCCz8TVgsLMPKCOGNx0,81
         | 
| 2 2 | 
             
            code_puppy/agent.py,sha256=avoOEorAYUyQYnYVVCezLz1QKDm0Qgx3i_C-BqgDiQQ,3198
         | 
| 3 | 
            -
            code_puppy/agent_prompts.py,sha256= | 
| 3 | 
            +
            code_puppy/agent_prompts.py,sha256=A6ADydqbHozIAtwOy_UY-fYZ2XDE9_5oV3oxOMtVCFA,6782
         | 
| 4 4 | 
             
            code_puppy/config.py,sha256=vBC4JFKNNXUSJ8l4yHF-Vp4lPwMlHNRafb1yxjIpxAE,2202
         | 
| 5 5 | 
             
            code_puppy/main.py,sha256=bc27bk6rFge95H2BumTTzRLtOx43z5FnsmjIjQx_RpU,10052
         | 
| 6 6 | 
             
            code_puppy/model_factory.py,sha256=Pi46jRr7JaBtJfNpSFk-fK2DlBy0Dv6tG2RcXLf4ZVI,11560
         | 
| @@ -11,18 +11,18 @@ code_puppy/command_line/__init__.py,sha256=y7WeRemfYppk8KVbCGeAIiTuiOszIURCDjOMZ | |
| 11 11 | 
             
            code_puppy/command_line/file_path_completion.py,sha256=HAlOu9XVYgJ7FbjdrhKBL0rFmCVFxSGGewdcfKTUsPw,2865
         | 
| 12 12 | 
             
            code_puppy/command_line/meta_command_handler.py,sha256=w9aZoMZVhPQj2Vix3nqW_K9Psou6nU_Jb2dFwr37yKs,3453
         | 
| 13 13 | 
             
            code_puppy/command_line/model_picker_completion.py,sha256=5VEa6OE9shsF0EafJXzBLpFXhFIkqevJi9RRD-eUvZA,3718
         | 
| 14 | 
            -
            code_puppy/command_line/prompt_toolkit_completion.py,sha256= | 
| 14 | 
            +
            code_puppy/command_line/prompt_toolkit_completion.py,sha256=QtXqqEzc6C8ODHTfvIDD1sY0fLaX40S7Sfbol43A9i8,5338
         | 
| 15 15 | 
             
            code_puppy/command_line/utils.py,sha256=L1PnV9tNupEW1zeziyb5aGAq8DYP8sMiuQbFYLO5Nus,1236
         | 
| 16 16 | 
             
            code_puppy/tools/__init__.py,sha256=48BVpMt0HAMtz8G_z9SQhX6LnRqR83_AVfMQMf7bY0g,557
         | 
| 17 17 | 
             
            code_puppy/tools/code_map.py,sha256=BghDHaebhGDfDGvA34gwO_5r92Py4O0Q3J4RV-WtnWs,3155
         | 
| 18 18 | 
             
            code_puppy/tools/command_runner.py,sha256=XDUdVo-9lIegYcYirdbI3qSuJgijBgLnujoYQq5MSe4,4755
         | 
| 19 19 | 
             
            code_puppy/tools/common.py,sha256=dbmyZTrTBQh_0WWpaYN6jEync62W2mMrzNS8UFK0co4,146
         | 
| 20 | 
            -
            code_puppy/tools/file_modifications.py,sha256= | 
| 21 | 
            -
            code_puppy/tools/file_operations.py,sha256= | 
| 20 | 
            +
            code_puppy/tools/file_modifications.py,sha256=nT87uAoY14RTAapnFCgkLTmW9g9P9bymxts2MpSpoo0,19297
         | 
| 21 | 
            +
            code_puppy/tools/file_operations.py,sha256=LJU_1b3WCXTAHa2B5VAbckrn1VVWb-HhcI3TF3BxYWs,11625
         | 
| 22 22 | 
             
            code_puppy/tools/web_search.py,sha256=HhcwX0MMvMDPFO8gr8gzgesD5wPXOypjkxyLZeNwL5g,589
         | 
| 23 | 
            -
            code_puppy-0.0. | 
| 24 | 
            -
            code_puppy-0.0. | 
| 25 | 
            -
            code_puppy-0.0. | 
| 26 | 
            -
            code_puppy-0.0. | 
| 27 | 
            -
            code_puppy-0.0. | 
| 28 | 
            -
            code_puppy-0.0. | 
| 23 | 
            +
            code_puppy-0.0.47.data/data/code_puppy/models.json,sha256=7H-y97YK9BXhag5wJU19rtg24JtZWYx60RsBLBW3WiI,2162
         | 
| 24 | 
            +
            code_puppy-0.0.47.dist-info/METADATA,sha256=X_fhgMeSahe4T6IeiwYvUIhWpaDTXqy14Oubz0I5Lsg,4716
         | 
| 25 | 
            +
            code_puppy-0.0.47.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
         | 
| 26 | 
            +
            code_puppy-0.0.47.dist-info/entry_points.txt,sha256=d8YkBvIUxF-dHNJAj-x4fPEqizbY5d_TwvYpc01U5kw,58
         | 
| 27 | 
            +
            code_puppy-0.0.47.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
         | 
| 28 | 
            +
            code_puppy-0.0.47.dist-info/RECORD,,
         | 
| 
            File without changes
         | 
| 
            File without changes
         | 
| 
            File without changes
         | 
| 
            File without changes
         |