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.
- agent/__init__.py +1 -0
- agent/agent.py +236 -0
- agent/events.py +145 -0
- agent/persistence.py +123 -0
- agent/session.py +99 -0
- client/__init__.py +1 -0
- client/llm_client.py +266 -0
- client/response.py +88 -0
- codenaut-0.1.0.dist-info/METADATA +199 -0
- codenaut-0.1.0.dist-info/RECORD +63 -0
- codenaut-0.1.0.dist-info/WHEEL +5 -0
- codenaut-0.1.0.dist-info/entry_points.txt +3 -0
- codenaut-0.1.0.dist-info/licenses/LICENSE +21 -0
- codenaut-0.1.0.dist-info/top_level.txt +12 -0
- config/__init__.py +1 -0
- config/config.py +173 -0
- config/loader.py +173 -0
- context/__init__.py +1 -0
- context/compaction.py +96 -0
- context/loop_detector.py +49 -0
- context/manager.py +257 -0
- hooks/__init__.py +1 -0
- hooks/hook_system.py +140 -0
- main.py +422 -0
- prompts/__init__.py +1 -0
- prompts/system.py +380 -0
- safety/__init__.py +1 -0
- safety/approval.py +157 -0
- skills/__init__.py +9 -0
- skills/discovery.py +97 -0
- skills/models.py +14 -0
- skills/registry.py +56 -0
- tools/__init__.py +1 -0
- tools/base.py +200 -0
- tools/builtin/__init__.py +67 -0
- tools/builtin/apply_patch.py +388 -0
- tools/builtin/edit_file.py +211 -0
- tools/builtin/glob.py +80 -0
- tools/builtin/grep.py +117 -0
- tools/builtin/list_dir.py +60 -0
- tools/builtin/memory.py +115 -0
- tools/builtin/read_file.py +133 -0
- tools/builtin/shell.py +160 -0
- tools/builtin/tasks.py +336 -0
- tools/builtin/web_fetch.py +55 -0
- tools/builtin/web_search.py +60 -0
- tools/builtin/write_file.py +111 -0
- tools/discovery.py +70 -0
- tools/mcp/__init__.py +1 -0
- tools/mcp/client.py +127 -0
- tools/mcp/mcp_manager.py +83 -0
- tools/mcp/mcp_tool.py +51 -0
- tools/registry.py +156 -0
- tools/subagents.py +148 -0
- ui/__init__.py +12 -0
- ui/completer.py +160 -0
- ui/prompt.py +65 -0
- ui/tui.py +669 -0
- utils/__init__.py +1 -0
- utils/errors.py +49 -0
- utils/file_tags.py +83 -0
- utils/paths.py +40 -0
- 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
|
+
|