codenaut 0.1.0__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.
Files changed (63) hide show
  1. agent/__init__.py +1 -0
  2. agent/agent.py +236 -0
  3. agent/events.py +145 -0
  4. agent/persistence.py +123 -0
  5. agent/session.py +99 -0
  6. client/__init__.py +1 -0
  7. client/llm_client.py +266 -0
  8. client/response.py +88 -0
  9. codenaut-0.1.0.dist-info/METADATA +199 -0
  10. codenaut-0.1.0.dist-info/RECORD +63 -0
  11. codenaut-0.1.0.dist-info/WHEEL +5 -0
  12. codenaut-0.1.0.dist-info/entry_points.txt +3 -0
  13. codenaut-0.1.0.dist-info/licenses/LICENSE +21 -0
  14. codenaut-0.1.0.dist-info/top_level.txt +12 -0
  15. config/__init__.py +1 -0
  16. config/config.py +173 -0
  17. config/loader.py +173 -0
  18. context/__init__.py +1 -0
  19. context/compaction.py +96 -0
  20. context/loop_detector.py +49 -0
  21. context/manager.py +257 -0
  22. hooks/__init__.py +1 -0
  23. hooks/hook_system.py +140 -0
  24. main.py +422 -0
  25. prompts/__init__.py +1 -0
  26. prompts/system.py +380 -0
  27. safety/__init__.py +1 -0
  28. safety/approval.py +157 -0
  29. skills/__init__.py +9 -0
  30. skills/discovery.py +97 -0
  31. skills/models.py +14 -0
  32. skills/registry.py +56 -0
  33. tools/__init__.py +1 -0
  34. tools/base.py +200 -0
  35. tools/builtin/__init__.py +67 -0
  36. tools/builtin/apply_patch.py +388 -0
  37. tools/builtin/edit_file.py +211 -0
  38. tools/builtin/glob.py +80 -0
  39. tools/builtin/grep.py +117 -0
  40. tools/builtin/list_dir.py +60 -0
  41. tools/builtin/memory.py +115 -0
  42. tools/builtin/read_file.py +133 -0
  43. tools/builtin/shell.py +160 -0
  44. tools/builtin/tasks.py +336 -0
  45. tools/builtin/web_fetch.py +55 -0
  46. tools/builtin/web_search.py +60 -0
  47. tools/builtin/write_file.py +111 -0
  48. tools/discovery.py +70 -0
  49. tools/mcp/__init__.py +1 -0
  50. tools/mcp/client.py +127 -0
  51. tools/mcp/mcp_manager.py +83 -0
  52. tools/mcp/mcp_tool.py +51 -0
  53. tools/registry.py +156 -0
  54. tools/subagents.py +148 -0
  55. ui/__init__.py +12 -0
  56. ui/completer.py +160 -0
  57. ui/prompt.py +65 -0
  58. ui/tui.py +669 -0
  59. utils/__init__.py +1 -0
  60. utils/errors.py +49 -0
  61. utils/file_tags.py +83 -0
  62. utils/paths.py +40 -0
  63. utils/text.py +79 -0
skills/registry.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from config.config import Config
6
+ from skills.models import SkillDefinition
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class SkillRegistry:
12
+ def __init__(self, config: Config):
13
+ self.config = config
14
+ self._skills: dict[str, SkillDefinition] = {}
15
+
16
+ def register(self, skill: SkillDefinition) -> None:
17
+ key = skill.name.casefold()
18
+ if key in self._skills:
19
+ logger.warning(f"Overwriting existing skill: {skill.name}")
20
+
21
+ self._skills[key] = skill
22
+
23
+ def get_skills(self) -> list[SkillDefinition]:
24
+ skills = list(self._skills.values())
25
+ if self.config.allowed_skills:
26
+ allowed = {name.casefold() for name in self.config.allowed_skills}
27
+ skills = [skill for skill in skills if skill.name.casefold() in allowed]
28
+
29
+ return skills
30
+
31
+ def get_active_skills(self, message: str | None = None) -> list[SkillDefinition]:
32
+ if not self.config.skills_enabled:
33
+ return []
34
+
35
+ normalized_message = " ".join((message or "").casefold().split())
36
+ always_loaded = {
37
+ name.casefold() for name in self.config.always_loaded_skills
38
+ }
39
+
40
+ active_skills: list[SkillDefinition] = []
41
+ for skill in self.get_skills():
42
+ skill_name = skill.name.casefold()
43
+ trigger_match = any(
44
+ trigger.casefold() in normalized_message for trigger in skill.triggers
45
+ )
46
+ name_match = skill_name in normalized_message if normalized_message else False
47
+
48
+ if (
49
+ skill.always_include
50
+ or skill_name in always_loaded
51
+ or trigger_match
52
+ or name_match
53
+ ):
54
+ active_skills.append(skill)
55
+
56
+ return active_skills
tools/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Built-in and discovered tool support."""
tools/base.py ADDED
@@ -0,0 +1,200 @@
1
+ from __future__ import annotations
2
+ import abc
3
+ from pathlib import Path
4
+ from pydantic import BaseModel, ValidationError
5
+ from enum import Enum
6
+ from typing import Any
7
+ from dataclasses import dataclass, field
8
+ from pydantic.json_schema import model_json_schema
9
+
10
+ from config.config import Config
11
+
12
+
13
+ class ToolKind(str, Enum):
14
+ READ = "read"
15
+ WRITE = "write"
16
+ SHELL = "shell"
17
+ NETWORK = "network"
18
+ MEMORY = "memory"
19
+ MCP = "mcp"
20
+
21
+
22
+ @dataclass
23
+ class FileDiff:
24
+ path: Path
25
+ old_content: str
26
+ new_content: str
27
+
28
+ is_new_file: bool = False
29
+ is_deletion: bool = False
30
+
31
+ def to_diff(self) -> str:
32
+ import difflib
33
+
34
+ old_lines = self.old_content.splitlines(keepends=True)
35
+ new_lines = self.new_content.splitlines(keepends=True)
36
+
37
+ if old_lines and not old_lines[-1].endswith("\n"):
38
+ old_lines[-1] += "\n"
39
+ if new_lines and not new_lines[-1].endswith("\n"):
40
+ new_lines[-1] += "\n"
41
+
42
+ #here /dev/null is used when one of the list is empty either old_line or new_line
43
+
44
+ old_name = "/dev/null" if self.is_new_file else str(self.path)
45
+ new_name = "/dev/null" if self.is_deletion else str(self.path)
46
+
47
+ diff = difflib.unified_diff(
48
+ old_lines,
49
+ new_lines,
50
+ fromfile=old_name,
51
+ tofile=new_name,
52
+ )
53
+
54
+ return "".join(diff)
55
+
56
+
57
+ @dataclass
58
+ class ToolResult:
59
+ success: bool
60
+ output: str
61
+ error: str | None = None
62
+ metadata: dict[str, Any] = field(default_factory=dict)
63
+
64
+ truncated: bool = False
65
+ diff: FileDiff | None = None
66
+ exit_code: int | None = None
67
+
68
+ @classmethod
69
+ def error_result(cls, error: str, output: str = "", **kwargs: Any):
70
+ return cls(
71
+ success=False,
72
+ output=output,
73
+ error=error,
74
+ **kwargs,
75
+ )
76
+
77
+ @classmethod
78
+ def success_result(cls, output: str, **kwargs: Any):
79
+ return cls(
80
+ success=True,
81
+ output=output,
82
+ error=None,
83
+ **kwargs,
84
+ )
85
+
86
+ def to_model_output(self) -> str:
87
+ if self.success:
88
+ return self.output
89
+
90
+ return f"Error: {self.error}\n\nOutput:\n{self.output}"
91
+
92
+
93
+ @dataclass
94
+ class ToolInvocation:
95
+ params: dict[str, Any]
96
+ cwd: Path
97
+
98
+
99
+ @dataclass
100
+ class ToolConfirmation:
101
+ tool_name: str
102
+ params: dict[str, Any]
103
+ description: str
104
+
105
+ diff: FileDiff | None = None
106
+ affected_paths: list[Path] = field(default_factory=list)
107
+ command: str | None = None
108
+ is_dangerous: bool = False
109
+
110
+
111
+ class Tool(abc.ABC):
112
+ name: str = "base_tool"
113
+ description: str = "Base tool"
114
+ kind: ToolKind = ToolKind.READ
115
+
116
+ def __init__(self, config: Config) -> None:
117
+ self.config = config
118
+
119
+ @property
120
+ def schema(self) -> dict[str, Any] | type["BaseModel"]:
121
+ raise NotImplementedError("Tool must define schema property or class attribute")
122
+
123
+ @abc.abstractmethod
124
+ async def execute(self, invocation: ToolInvocation) -> ToolResult:
125
+ raise NotImplementedError("Tool must implement execute method")
126
+
127
+
128
+ def validate_params(self, params: dict[str, Any]) -> list[str]:
129
+ schema = self.schema
130
+ if isinstance(schema, type) and issubclass(schema, BaseModel):
131
+ try:
132
+ schema(**params)
133
+ except ValidationError as e:
134
+ errors = []
135
+ for error in e.errors():
136
+ field = ".".join(str(x) for x in error.get("loc", []))
137
+ msg = error.get("msg", "Validation error")
138
+ errors.append(f"Parameter '{field}': {msg}")
139
+
140
+ return errors
141
+ except Exception as e:
142
+ return [str(e)]
143
+
144
+ return []
145
+
146
+ def is_mutating(self, params: dict[str, Any]) -> bool:
147
+ return self.kind in {
148
+ ToolKind.WRITE,
149
+ ToolKind.SHELL,
150
+ ToolKind.NETWORK,
151
+ ToolKind.MEMORY,
152
+ }
153
+
154
+ async def get_confirmation(
155
+ self, invocation: ToolInvocation
156
+ ) -> ToolConfirmation | None:
157
+ if not self.is_mutating(invocation.params):
158
+ return None
159
+
160
+ return ToolConfirmation(
161
+ tool_name=self.name,
162
+ params=invocation.params,
163
+ description=f"Execute {self.name} in {invocation.cwd} used for {self.description}",
164
+ )
165
+
166
+ def to_openai_schema(self) -> dict[str, Any]:
167
+ #There can be two cases - schema is pydatic type(builtin tools) - schema could be dict type(mcp tools)
168
+
169
+ schema = self.schema
170
+
171
+ if isinstance(schema, type) and issubclass(schema, BaseModel):
172
+
173
+ json_schema = model_json_schema(schema, mode="serialization")
174
+
175
+ return {
176
+ "name": self.name,
177
+ "description": self.description,
178
+ "parameters": {
179
+ "type": "object",
180
+ "properties": json_schema.get("properties", {}),
181
+ "required": json_schema.get("required", []),
182
+ },
183
+ }
184
+
185
+ if isinstance(schema, dict):
186
+ result = {
187
+ "name": self.name,
188
+ "description": self.description,
189
+ }
190
+
191
+ #either there will be parameters key
192
+
193
+ if "parameters" in schema:
194
+ result["parameters"] = schema["parameters"]
195
+ else:
196
+ result["parameters"] = schema
197
+
198
+ return result
199
+
200
+ raise ValueError(f"Invalid schema type for tool {self.name}: {type(schema)}")
@@ -0,0 +1,67 @@
1
+ from tools.builtin.apply_patch import ApplyPatchTool
2
+ from tools.builtin.edit_file import EditTool
3
+ from tools.builtin.glob import GlobTool
4
+ from tools.builtin.grep import GrepTool
5
+ from tools.builtin.list_dir import ListDirTool
6
+ from tools.builtin.memory import MemoryTool
7
+ from tools.builtin.read_file import ReadFileTool
8
+ from tools.builtin.shell import ShellTool
9
+ from tools.builtin.tasks import (
10
+ ClaimTaskTool,
11
+ CompleteTaskTool,
12
+ CreateTaskTool,
13
+ GetTaskTool,
14
+ ListTasksTool,
15
+ UpdateTaskTool,
16
+ AddTodoTool,
17
+ CompleteTodoTool,
18
+ )
19
+ from tools.builtin.web_fetch import WebFetchTool
20
+ from tools.builtin.web_search import WebSearchTool
21
+ from tools.builtin.write_file import WriteFileTool
22
+
23
+ __all__ = [
24
+ "ApplyPatchTool",
25
+ "ReadFileTool",
26
+ "WriteFileTool",
27
+ "EditTool",
28
+ "ShellTool",
29
+ "ListDirTool",
30
+ "GrepTool",
31
+ "GlobTool",
32
+ "WebSearchTool",
33
+ "WebFetchTool",
34
+ "CreateTaskTool",
35
+ "UpdateTaskTool",
36
+ "ClaimTaskTool",
37
+ "CompleteTaskTool",
38
+ "GetTaskTool",
39
+ "ListTasksTool",
40
+ "AddTodoTool",
41
+ "CompleteTodoTool",
42
+ "MemoryTool",
43
+ ]
44
+
45
+
46
+ def get_all_builtin_tools() -> list[type]:
47
+ return [
48
+ ApplyPatchTool,
49
+ ReadFileTool,
50
+ WriteFileTool,
51
+ EditTool,
52
+ ShellTool,
53
+ ListDirTool,
54
+ GrepTool,
55
+ GlobTool,
56
+ WebSearchTool,
57
+ WebFetchTool,
58
+ CreateTaskTool,
59
+ UpdateTaskTool,
60
+ ClaimTaskTool,
61
+ CompleteTaskTool,
62
+ GetTaskTool,
63
+ ListTasksTool,
64
+ AddTodoTool,
65
+ CompleteTodoTool,
66
+ MemoryTool,
67
+ ]
@@ -0,0 +1,388 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from tools.base import (
11
+ FileDiff,
12
+ ToolConfirmation,
13
+ ToolInvocation,
14
+ ToolKind,
15
+ ToolResult,
16
+ Tool,
17
+ )
18
+ from utils.paths import resolve_path, ensure_parent_directory
19
+
20
+
21
+ class PatchAction(Enum):
22
+ CREATE = "create"
23
+ UPDATE = "update"
24
+ DELETE = "delete"
25
+ RENAME = "rename"
26
+
27
+
28
+ @dataclass
29
+ class PatchOperation:
30
+ action: PatchAction
31
+ path: Path
32
+ new_path: Path | None = None # For renames
33
+ content: str | None = None # For create/update
34
+ move_path: Path | None = None # Source for renames
35
+
36
+
37
+ @dataclass
38
+ class ParsedPatch:
39
+ operations: list[PatchOperation]
40
+ errors: list[str]
41
+
42
+
43
+ class ApplyPatchParams(BaseModel):
44
+ patch: str = Field(..., description="The patch content in the specified format")
45
+ dry_run: bool = Field(
46
+ False, description="Preview changes without applying them (default: false)"
47
+ )
48
+
49
+
50
+ class ApplyPatchTool(Tool):
51
+ """
52
+ Supports a simple patch format:
53
+
54
+ ```
55
+ *** Begin Patch
56
+ *** Update File: path/to/file.py
57
+ <<<<<<< SEARCH
58
+ old content to find
59
+ =======
60
+ new content to replace with
61
+ >>>>>>> REPLACE
62
+ *** End Patch
63
+ ```
64
+
65
+ Also supports:
66
+ - *** Create File: path/to/new/file.py
67
+ - *** Delete File: path/to/file.py
68
+ - *** Rename File: old/path.py -> new/path.py
69
+ """
70
+
71
+ name = "apply_patch"
72
+ description = (
73
+ "Apply a multi-file patch. Supports creating, updating, deleting, and "
74
+ "renaming files in a single operation. **PREFERRED** when editing 2 or more files "
75
+ "instead of making multiple separate edit calls. More efficient and allows "
76
+ "batching multiple file operations atomically.\n\n"
77
+ "Format:\n"
78
+ "*** Begin Patch\n"
79
+ "*** Update File: path/to/file.py\n"
80
+ "<<<<<<< SEARCH\n"
81
+ "old content\n"
82
+ "=======\n"
83
+ "new content\n"
84
+ ">>>>>>> REPLACE\n"
85
+ "*** End Patch\n\n"
86
+ "Also supports:\n"
87
+ "*** Create File: path - creates new file with content after it\n"
88
+ "*** Delete File: path - deletes the file\n"
89
+ "*** Rename File: old -> new - renames/moves a file"
90
+ )
91
+ kind = ToolKind.WRITE
92
+ schema = ApplyPatchParams
93
+
94
+ PATCH_START = re.compile(r"^\*\*\*\s*Begin\s+Patch\s*$", re.IGNORECASE)
95
+ PATCH_END = re.compile(r"^\*\*\*\s*End\s+Patch\s*$", re.IGNORECASE)
96
+ UPDATE_FILE = re.compile(r"^\*\*\*\s*Update\s+File:\s*(.+)$", re.IGNORECASE)
97
+ CREATE_FILE = re.compile(r"^\*\*\*\s*Create\s+File:\s*(.+)$", re.IGNORECASE)
98
+ DELETE_FILE = re.compile(r"^\*\*\*\s*Delete\s+File:\s*(.+)$", re.IGNORECASE)
99
+ RENAME_FILE = re.compile(r"^\*\*\*\s*Rename\s+File:\s*(.+)\s*->\s*(.+)$", re.IGNORECASE)
100
+
101
+ SEARCH_START = re.compile(r"^<{7}\s*SEARCH\s*$")
102
+ SEPARATOR = re.compile(r"^={7}\s*$")
103
+ REPLACE_END = re.compile(r"^>{7}\s*REPLACE\s*$")
104
+
105
+ def _parse_patch(self, patch_text: str, cwd: Path) -> ParsedPatch:
106
+ operations: list[PatchOperation] = []
107
+ errors: list[str] = []
108
+
109
+ lines = patch_text.splitlines()
110
+ i = 0
111
+
112
+ while i < len(lines):
113
+ if self.PATCH_START.match(lines[i].strip()):
114
+ i += 1
115
+ break
116
+ i += 1
117
+ else:
118
+ i = 0
119
+
120
+ while i < len(lines):
121
+ line = lines[i].strip()
122
+
123
+ if self.PATCH_END.match(line):
124
+ break
125
+
126
+ if not line:
127
+ i += 1
128
+ continue
129
+
130
+ if match := self.UPDATE_FILE.match(line):
131
+ path = resolve_path(cwd, match.group(1).strip())
132
+ i += 1
133
+ op, i, err = self._parse_update(lines, i, path)
134
+ if err:
135
+ errors.append(err)
136
+ elif op:
137
+ operations.append(op)
138
+
139
+ elif match := self.CREATE_FILE.match(line):
140
+ path = resolve_path(cwd, match.group(1).strip())
141
+ i += 1
142
+ content, i = self._read_until_next_operation(lines, i)
143
+ operations.append(
144
+ PatchOperation(
145
+ action=PatchAction.CREATE,
146
+ path=path,
147
+ content=content,
148
+ )
149
+ )
150
+
151
+ elif match := self.DELETE_FILE.match(line):
152
+ path = resolve_path(cwd, match.group(1).strip())
153
+ operations.append(
154
+ PatchOperation(
155
+ action=PatchAction.DELETE,
156
+ path=path,
157
+ )
158
+ )
159
+ i += 1
160
+
161
+ elif match := self.RENAME_FILE.match(line):
162
+ old_path = resolve_path(cwd, match.group(1).strip())
163
+ new_path = resolve_path(cwd, match.group(2).strip())
164
+ operations.append(
165
+ PatchOperation(
166
+ action=PatchAction.RENAME,
167
+ path=new_path,
168
+ move_path=old_path,
169
+ )
170
+ )
171
+ i += 1
172
+
173
+ else:
174
+ i += 1
175
+
176
+ return ParsedPatch(operations=operations, errors=errors)
177
+
178
+ def _parse_update(
179
+ self,
180
+ lines: list[str],
181
+ start: int,
182
+ path: Path,
183
+ ) -> tuple[PatchOperation | None, int, str | None]:
184
+ """Parse an update operation's search/replace block."""
185
+ i = start
186
+
187
+ while i < len(lines) and not self.SEARCH_START.match(lines[i].strip()):
188
+ i += 1
189
+ if i >= len(lines):
190
+ return None, i, f"Missing <<<<<<< SEARCH for {path}"
191
+ i += 1
192
+
193
+ search_lines = []
194
+ while i < len(lines) and not self.SEPARATOR.match(lines[i].strip()):
195
+ search_lines.append(lines[i])
196
+ i += 1
197
+ if i >= len(lines):
198
+ return None, i, f"Missing ======= separator for {path}"
199
+ i += 1
200
+
201
+ replace_lines = []
202
+ while i < len(lines) and not self.REPLACE_END.match(lines[i].strip()):
203
+ replace_lines.append(lines[i])
204
+ i += 1
205
+ if i >= len(lines):
206
+ return None, i, f"Missing >>>>>>> REPLACE for {path}"
207
+ i += 1
208
+
209
+ search_content = "\n".join(search_lines)
210
+ replace_content = "\n".join(replace_lines)
211
+
212
+ return (
213
+ PatchOperation(
214
+ action=PatchAction.UPDATE,
215
+ path=path,
216
+ content=f"{search_content}\x00{replace_content}",
217
+ ),
218
+ i,
219
+ None,
220
+ )
221
+
222
+ def _read_until_next_operation(
223
+ self,
224
+ lines: list[str],
225
+ start: int,
226
+ ) -> tuple[str, int]:
227
+ """Read content until the next operation directive."""
228
+ content_lines = []
229
+ i = start
230
+
231
+ while i < len(lines):
232
+ line = lines[i]
233
+ stripped = line.strip()
234
+
235
+ if (
236
+ self.UPDATE_FILE.match(stripped)
237
+ or self.CREATE_FILE.match(stripped)
238
+ or self.DELETE_FILE.match(stripped)
239
+ or self.RENAME_FILE.match(stripped)
240
+ or self.PATCH_END.match(stripped)
241
+ ):
242
+ break
243
+
244
+ content_lines.append(line)
245
+ i += 1
246
+
247
+ while content_lines and not content_lines[-1].strip():
248
+ content_lines.pop()
249
+
250
+ return "\n".join(content_lines), i
251
+
252
+ async def get_confirmation(
253
+ self,
254
+ invocation: ToolInvocation,
255
+ ) -> ToolConfirmation | None:
256
+ try:
257
+ params = ApplyPatchParams(**invocation.params)
258
+ except Exception:
259
+ return None
260
+
261
+ parsed = self._parse_patch(params.patch, invocation.cwd)
262
+
263
+ if parsed.errors:
264
+ return None
265
+
266
+ affected_paths = []
267
+ descriptions = []
268
+
269
+ for op in parsed.operations:
270
+ affected_paths.append(op.path)
271
+ if op.move_path:
272
+ affected_paths.append(op.move_path)
273
+
274
+ if op.action == PatchAction.CREATE:
275
+ descriptions.append(f"Create: {op.path}")
276
+ elif op.action == PatchAction.UPDATE:
277
+ descriptions.append(f"Update: {op.path}")
278
+ elif op.action == PatchAction.DELETE:
279
+ descriptions.append(f"Delete: {op.path}")
280
+ elif op.action == PatchAction.RENAME:
281
+ descriptions.append(f"Rename: {op.move_path} -> {op.path}")
282
+
283
+ return ToolConfirmation(
284
+ tool_name=self.name,
285
+ params=invocation.params,
286
+ description="\n".join(descriptions) if descriptions else "Apply patch",
287
+ affected_paths=affected_paths,
288
+ is_dangerous=any(op.action == PatchAction.DELETE for op in parsed.operations),
289
+ )
290
+
291
+ async def execute(self, invocation: ToolInvocation) -> ToolResult:
292
+ try:
293
+ params = ApplyPatchParams(**invocation.params)
294
+ except Exception as e:
295
+ return ToolResult.error_result(f"Invalid parameters: {e}")
296
+
297
+ parsed = self._parse_patch(params.patch, invocation.cwd)
298
+
299
+ if parsed.errors:
300
+ return ToolResult.error_result(
301
+ "Patch parsing errors:\n" + "\n".join(f"- {e}" for e in parsed.errors)
302
+ )
303
+
304
+ if not parsed.operations:
305
+ return ToolResult.error_result("No operations found in patch")
306
+
307
+ results = []
308
+
309
+ for op in parsed.operations:
310
+ if op.action == PatchAction.CREATE:
311
+ result = await self._apply_create(op, params.dry_run)
312
+ elif op.action == PatchAction.UPDATE:
313
+ result = await self._apply_update(op, params.dry_run)
314
+ elif op.action == PatchAction.DELETE:
315
+ result = await self._apply_delete(op, params.dry_run)
316
+ elif op.action == PatchAction.RENAME:
317
+ result = await self._apply_rename(op, params.dry_run)
318
+ else:
319
+ result = f"Unknown action: {op.action}"
320
+
321
+ results.append(result)
322
+
323
+ prefix = "[DRY RUN] " if params.dry_run else ""
324
+ return ToolResult.success_result(
325
+ f"{prefix}Applied patch with {len(parsed.operations)} operation(s):\n"
326
+ + "\n".join(f"- {r}" for r in results),
327
+ metadata={
328
+ "operations": len(parsed.operations),
329
+ "dry_run": params.dry_run,
330
+ },
331
+ )
332
+
333
+ async def _apply_create(self, op: PatchOperation, dry_run: bool) -> str:
334
+ if op.path.exists():
335
+ return f"SKIP: {op.path} already exists"
336
+
337
+ if not dry_run:
338
+ ensure_parent_directory(op.path)
339
+ op.path.write_text(op.content or "", encoding="utf-8")
340
+
341
+ return f"Created: {op.path}"
342
+
343
+ async def _apply_update(self, op: PatchOperation, dry_run: bool) -> str:
344
+ if not op.path.exists():
345
+ return f"ERROR: {op.path} does not exist"
346
+
347
+ if not op.content or "\x00" not in op.content:
348
+ return f"ERROR: Invalid update content for {op.path}"
349
+
350
+ search, replace = op.content.split("\x00", 1)
351
+
352
+ try:
353
+ content = op.path.read_text(encoding="utf-8")
354
+ except Exception:
355
+ return f"ERROR: Failed to read {op.path}"
356
+
357
+ if search not in content:
358
+ return f"ERROR: Search content not found in {op.path}"
359
+
360
+ new_content = content.replace(search, replace)
361
+
362
+ if not dry_run:
363
+ op.path.write_text(new_content, encoding="utf-8")
364
+
365
+ return f"Updated: {op.path}"
366
+
367
+ async def _apply_delete(self, op: PatchOperation, dry_run: bool) -> str:
368
+ if not op.path.exists():
369
+ return f"SKIP: {op.path} does not exist"
370
+
371
+ if not dry_run:
372
+ op.path.unlink()
373
+
374
+ return f"Deleted: {op.path}"
375
+
376
+ async def _apply_rename(self, op: PatchOperation, dry_run: bool) -> str:
377
+ if not op.move_path or not op.move_path.exists():
378
+ return f"ERROR: Source file {op.move_path} does not exist"
379
+
380
+ if op.path.exists():
381
+ return f"ERROR: Target file {op.path} already exists"
382
+
383
+ if not dry_run:
384
+ ensure_parent_directory(op.path)
385
+ op.move_path.rename(op.path)
386
+
387
+ return f"Renamed: {op.move_path} -> {op.path}"
388
+