crabcode 0.1.6__py3-none-win_amd64.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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""ApplyPatchTool — apply structured, multi-file patches safely."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from crabcode_core.logging_utils import get_logger
|
|
13
|
+
from crabcode_core.text_io import normalize_newlines, read_utf8_text, write_utf8_text
|
|
14
|
+
from crabcode_core.tools.diff_utils import compute_diff
|
|
15
|
+
from crabcode_core.types.tool import (
|
|
16
|
+
PermissionBehavior,
|
|
17
|
+
PermissionResult,
|
|
18
|
+
Tool,
|
|
19
|
+
ToolContext,
|
|
20
|
+
ToolResult,
|
|
21
|
+
)
|
|
22
|
+
from crabcode_core.io_worker import run_file_tool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
logger = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
_BEGIN = "*** Begin Patch"
|
|
28
|
+
_END = "*** End Patch"
|
|
29
|
+
_BEGIN_RE = re.compile(r"^\s*\*\*\* Begin Patch(?:\s+\*\*\*)?\s*$")
|
|
30
|
+
_END_RE = re.compile(r"^\s*\*\*\* End Patch(?:\s+\*\*\*)?\s*$")
|
|
31
|
+
_END_OF_FILE_RE = re.compile(r"^\s*\*\*\* End of File(?:\s+\*\*\*)?\s*$")
|
|
32
|
+
_ACTION_RE = re.compile(r"^\*\*\* (Add|Update|Delete) File: (.+)$")
|
|
33
|
+
_MOVE_RE = re.compile(r"^\*\*\* Move to: (.+)$")
|
|
34
|
+
_HUNK_RE = re.compile(r"^@@(?:\s(.*?))?\s*$")
|
|
35
|
+
_RANGE_HINT_RE = re.compile(r"^-?\d+(?:,\d+)?\s+\+?\d+(?:,\d+)?(?:\s+@@)?$")
|
|
36
|
+
_MAX_PATCH_CHARS = 2_000_000
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PatchError(ValueError):
|
|
40
|
+
"""Raised when a patch cannot be parsed or applied safely."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class PatchLine:
|
|
45
|
+
kind: Literal[" ", "+", "-"]
|
|
46
|
+
text: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class PatchHunk:
|
|
51
|
+
hint: str | None
|
|
52
|
+
lines: tuple[PatchLine, ...]
|
|
53
|
+
end_of_file: bool = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class PatchAction:
|
|
58
|
+
kind: Literal["add", "update", "delete"]
|
|
59
|
+
path: str
|
|
60
|
+
move_to: str | None = None
|
|
61
|
+
content: str | None = None
|
|
62
|
+
hunks: tuple[PatchHunk, ...] = ()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class _FileState:
|
|
67
|
+
text: str
|
|
68
|
+
newline: str | None
|
|
69
|
+
has_bom: bool
|
|
70
|
+
mode: int | None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_patch(patch: str) -> list[PatchAction]:
|
|
74
|
+
"""Parse the Codex apply_patch envelope into validated actions."""
|
|
75
|
+
if not isinstance(patch, str) or not patch.strip():
|
|
76
|
+
raise PatchError("patch must be a non-empty string")
|
|
77
|
+
if len(patch) > _MAX_PATCH_CHARS:
|
|
78
|
+
raise PatchError(f"patch exceeds the {_MAX_PATCH_CHARS} character limit")
|
|
79
|
+
|
|
80
|
+
lines = patch.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
81
|
+
start = 0
|
|
82
|
+
while start < len(lines) and not lines[start].strip():
|
|
83
|
+
start += 1
|
|
84
|
+
end = len(lines)
|
|
85
|
+
while end > start and not lines[end - 1].strip():
|
|
86
|
+
end -= 1
|
|
87
|
+
lines = lines[start:end]
|
|
88
|
+
if not lines or not _BEGIN_RE.fullmatch(lines[0]):
|
|
89
|
+
raise PatchError(f"patch must start with {_BEGIN!r}")
|
|
90
|
+
if (
|
|
91
|
+
len(lines) >= 3
|
|
92
|
+
and _END_OF_FILE_RE.fullmatch(lines[-1])
|
|
93
|
+
and _END_RE.fullmatch(lines[-2])
|
|
94
|
+
):
|
|
95
|
+
lines.pop()
|
|
96
|
+
if len(lines) < 2 or not _END_RE.fullmatch(lines[-1]):
|
|
97
|
+
raise PatchError(f"patch must end with {_END!r}")
|
|
98
|
+
|
|
99
|
+
actions: list[PatchAction] = []
|
|
100
|
+
index = 1
|
|
101
|
+
final = len(lines) - 1
|
|
102
|
+
while index < final:
|
|
103
|
+
match = _ACTION_RE.match(lines[index])
|
|
104
|
+
if not match:
|
|
105
|
+
raise PatchError(f"expected a file action at patch line {index + 1}: {lines[index]!r}")
|
|
106
|
+
verb, raw_path = match.groups()
|
|
107
|
+
path = raw_path.strip()
|
|
108
|
+
if not path:
|
|
109
|
+
raise PatchError(f"empty file path at patch line {index + 1}")
|
|
110
|
+
index += 1
|
|
111
|
+
|
|
112
|
+
if verb == "Add":
|
|
113
|
+
content_lines: list[str] = []
|
|
114
|
+
while index < final and not _ACTION_RE.match(lines[index]):
|
|
115
|
+
line = lines[index]
|
|
116
|
+
if not line.startswith("+"):
|
|
117
|
+
raise PatchError(
|
|
118
|
+
f"added file content must start with '+' at patch line {index + 1}"
|
|
119
|
+
)
|
|
120
|
+
content_lines.append(line[1:])
|
|
121
|
+
index += 1
|
|
122
|
+
content = "\n".join(content_lines)
|
|
123
|
+
if content_lines:
|
|
124
|
+
content += "\n"
|
|
125
|
+
actions.append(PatchAction(kind="add", path=path, content=content))
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
if verb == "Delete":
|
|
129
|
+
actions.append(PatchAction(kind="delete", path=path))
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
move_to: str | None = None
|
|
133
|
+
if index < final:
|
|
134
|
+
move_match = _MOVE_RE.match(lines[index])
|
|
135
|
+
if move_match:
|
|
136
|
+
move_to = move_match.group(1).strip()
|
|
137
|
+
if not move_to:
|
|
138
|
+
raise PatchError(f"empty move destination at patch line {index + 1}")
|
|
139
|
+
index += 1
|
|
140
|
+
|
|
141
|
+
hunks: list[PatchHunk] = []
|
|
142
|
+
while index < final and not _ACTION_RE.match(lines[index]):
|
|
143
|
+
hunk_match = _HUNK_RE.match(lines[index])
|
|
144
|
+
if not hunk_match:
|
|
145
|
+
raise PatchError(f"expected a hunk header at patch line {index + 1}: {lines[index]!r}")
|
|
146
|
+
raw_hint = (hunk_match.group(1) or "").strip()
|
|
147
|
+
hint = raw_hint if raw_hint and not _RANGE_HINT_RE.match(raw_hint) else None
|
|
148
|
+
index += 1
|
|
149
|
+
hunk_lines: list[PatchLine] = []
|
|
150
|
+
end_of_file = False
|
|
151
|
+
while index < final:
|
|
152
|
+
line = lines[index]
|
|
153
|
+
if _END_OF_FILE_RE.fullmatch(line):
|
|
154
|
+
end_of_file = True
|
|
155
|
+
index += 1
|
|
156
|
+
break
|
|
157
|
+
if _ACTION_RE.match(line) or _HUNK_RE.match(line):
|
|
158
|
+
break
|
|
159
|
+
if not line or line[0] not in {" ", "+", "-"}:
|
|
160
|
+
raise PatchError(
|
|
161
|
+
f"hunk lines must start with space, '+' or '-' at patch line {index + 1}"
|
|
162
|
+
)
|
|
163
|
+
hunk_lines.append(PatchLine(line[0], line[1:]))
|
|
164
|
+
index += 1
|
|
165
|
+
if not hunk_lines:
|
|
166
|
+
raise PatchError("empty update hunk")
|
|
167
|
+
hunks.append(PatchHunk(hint=hint, lines=tuple(hunk_lines), end_of_file=end_of_file))
|
|
168
|
+
|
|
169
|
+
if not hunks and move_to is None:
|
|
170
|
+
raise PatchError(f"update for {path!r} has no hunks")
|
|
171
|
+
actions.append(
|
|
172
|
+
PatchAction(kind="update", path=path, move_to=move_to, hunks=tuple(hunks))
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if not actions:
|
|
176
|
+
raise PatchError("patch contains no file actions")
|
|
177
|
+
return actions
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def patch_paths(patch: str) -> list[str]:
|
|
181
|
+
"""Return source and destination paths in stable order."""
|
|
182
|
+
paths: list[str] = []
|
|
183
|
+
for action in parse_patch(patch):
|
|
184
|
+
for path in (action.path, action.move_to):
|
|
185
|
+
if path and path not in paths:
|
|
186
|
+
paths.append(path)
|
|
187
|
+
return paths
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _resolve_path(root: Path, raw_path: str) -> Path:
|
|
191
|
+
if "\x00" in raw_path:
|
|
192
|
+
raise PatchError("file paths must not contain NUL bytes")
|
|
193
|
+
candidate = Path(raw_path)
|
|
194
|
+
if candidate.is_absolute():
|
|
195
|
+
resolved = candidate.resolve(strict=False)
|
|
196
|
+
else:
|
|
197
|
+
resolved = (root / candidate).resolve(strict=False)
|
|
198
|
+
try:
|
|
199
|
+
resolved.relative_to(root)
|
|
200
|
+
except ValueError as exc:
|
|
201
|
+
raise PatchError(f"path escapes the workspace: {raw_path}") from exc
|
|
202
|
+
return resolved
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _find_hint(lines: list[str], hint: str, start: int) -> int | None:
|
|
206
|
+
for index in range(start, len(lines)):
|
|
207
|
+
if lines[index] == hint or lines[index].strip() == hint.strip():
|
|
208
|
+
return index
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _find_sequence(
|
|
213
|
+
lines: list[str],
|
|
214
|
+
expected: list[str],
|
|
215
|
+
*,
|
|
216
|
+
start: int,
|
|
217
|
+
hint: str | None,
|
|
218
|
+
end_of_file: bool,
|
|
219
|
+
) -> int | None:
|
|
220
|
+
if end_of_file:
|
|
221
|
+
position = len(lines) - len(expected)
|
|
222
|
+
return position if position >= start and lines[position:] == expected else None
|
|
223
|
+
|
|
224
|
+
search_start = start
|
|
225
|
+
if hint:
|
|
226
|
+
hinted = _find_hint(lines, hint, start)
|
|
227
|
+
if hinted is None:
|
|
228
|
+
return None
|
|
229
|
+
search_start = hinted
|
|
230
|
+
|
|
231
|
+
if not expected:
|
|
232
|
+
return search_start
|
|
233
|
+
last = len(lines) - len(expected)
|
|
234
|
+
for position in range(search_start, last + 1):
|
|
235
|
+
if lines[position : position + len(expected)] == expected:
|
|
236
|
+
return position
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _apply_hunks(text: str, hunks: tuple[PatchHunk, ...], path: str) -> str:
|
|
241
|
+
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
242
|
+
had_final_newline = normalized.endswith("\n")
|
|
243
|
+
lines = normalized.split("\n")
|
|
244
|
+
if had_final_newline:
|
|
245
|
+
lines.pop()
|
|
246
|
+
|
|
247
|
+
cursor = 0
|
|
248
|
+
for hunk_number, hunk in enumerate(hunks, start=1):
|
|
249
|
+
expected = [line.text for line in hunk.lines if line.kind != "+"]
|
|
250
|
+
replacement = [line.text for line in hunk.lines if line.kind != "-"]
|
|
251
|
+
position = _find_sequence(
|
|
252
|
+
lines,
|
|
253
|
+
expected,
|
|
254
|
+
start=cursor,
|
|
255
|
+
hint=hunk.hint,
|
|
256
|
+
end_of_file=hunk.end_of_file,
|
|
257
|
+
)
|
|
258
|
+
if position is None:
|
|
259
|
+
preview = "\\n".join(expected[:3])
|
|
260
|
+
raise PatchError(
|
|
261
|
+
f"hunk {hunk_number} for {path!r} did not match the current file"
|
|
262
|
+
+ (f": {preview!r}" if preview else "")
|
|
263
|
+
)
|
|
264
|
+
lines[position : position + len(expected)] = replacement
|
|
265
|
+
cursor = position + len(replacement)
|
|
266
|
+
|
|
267
|
+
result = "\n".join(lines)
|
|
268
|
+
if had_final_newline:
|
|
269
|
+
result += "\n"
|
|
270
|
+
return result
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _read_state(path: Path) -> _FileState | None:
|
|
274
|
+
if not path.exists():
|
|
275
|
+
return None
|
|
276
|
+
if not path.is_file():
|
|
277
|
+
raise PatchError(f"path is not a regular file: {path}")
|
|
278
|
+
source = read_utf8_text(path)
|
|
279
|
+
return _FileState(
|
|
280
|
+
text=source.text,
|
|
281
|
+
newline=source.newline,
|
|
282
|
+
has_bom=source.has_bom,
|
|
283
|
+
mode=path.stat().st_mode,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _relative(path: Path, root: Path) -> str:
|
|
288
|
+
return path.relative_to(root).as_posix()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _render_diff(
|
|
292
|
+
root: Path,
|
|
293
|
+
initial: dict[Path, _FileState | None],
|
|
294
|
+
final: dict[Path, _FileState | None],
|
|
295
|
+
) -> tuple[str, list[dict[str, Any]], dict[str, int]]:
|
|
296
|
+
diffs: list[str] = []
|
|
297
|
+
files: list[dict[str, Any]] = []
|
|
298
|
+
totals = {"added": 0, "removed": 0}
|
|
299
|
+
for path in initial:
|
|
300
|
+
before = initial[path]
|
|
301
|
+
after = final[path]
|
|
302
|
+
if before == after:
|
|
303
|
+
continue
|
|
304
|
+
display_path = _relative(path, root)
|
|
305
|
+
info = compute_diff(
|
|
306
|
+
before.text if before else "",
|
|
307
|
+
after.text if after else "",
|
|
308
|
+
display_path,
|
|
309
|
+
)
|
|
310
|
+
if before is None:
|
|
311
|
+
action = "create"
|
|
312
|
+
elif after is None:
|
|
313
|
+
action = "delete"
|
|
314
|
+
else:
|
|
315
|
+
action = "modify"
|
|
316
|
+
stats = dict(info["stats"])
|
|
317
|
+
totals["added"] += stats["added"]
|
|
318
|
+
totals["removed"] += stats["removed"]
|
|
319
|
+
files.append({"path": display_path, "action": action, "stats": stats})
|
|
320
|
+
if info["diff_text"]:
|
|
321
|
+
diffs.append(info["diff_text"])
|
|
322
|
+
display = "\n".join(diffs)
|
|
323
|
+
if len(display) > 50_000:
|
|
324
|
+
display = display[:50_000] + "\n... (diff truncated)"
|
|
325
|
+
return display, files, totals
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _write_transaction(
|
|
329
|
+
initial: dict[Path, _FileState | None],
|
|
330
|
+
final: dict[Path, _FileState | None],
|
|
331
|
+
) -> None:
|
|
332
|
+
staged: dict[Path, Path] = {}
|
|
333
|
+
try:
|
|
334
|
+
for path, state in final.items():
|
|
335
|
+
if state is None or state == initial[path]:
|
|
336
|
+
continue
|
|
337
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
338
|
+
temporary = path.with_name(f".{path.name}.crabcode-{uuid.uuid4().hex}.tmp")
|
|
339
|
+
write_utf8_text(
|
|
340
|
+
temporary,
|
|
341
|
+
state.text,
|
|
342
|
+
newline=state.newline,
|
|
343
|
+
has_bom=state.has_bom,
|
|
344
|
+
)
|
|
345
|
+
if state.mode is not None:
|
|
346
|
+
os.chmod(temporary, state.mode)
|
|
347
|
+
staged[path] = temporary
|
|
348
|
+
|
|
349
|
+
for path, temporary in staged.items():
|
|
350
|
+
os.replace(temporary, path)
|
|
351
|
+
for path, state in final.items():
|
|
352
|
+
if state is None and initial[path] is not None:
|
|
353
|
+
path.unlink()
|
|
354
|
+
except Exception as exc:
|
|
355
|
+
rollback_errors: list[str] = []
|
|
356
|
+
for path, state in initial.items():
|
|
357
|
+
try:
|
|
358
|
+
if state is None:
|
|
359
|
+
path.unlink(missing_ok=True)
|
|
360
|
+
else:
|
|
361
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
362
|
+
write_utf8_text(
|
|
363
|
+
path,
|
|
364
|
+
state.text,
|
|
365
|
+
newline=state.newline,
|
|
366
|
+
has_bom=state.has_bom,
|
|
367
|
+
)
|
|
368
|
+
if state.mode is not None:
|
|
369
|
+
os.chmod(path, state.mode)
|
|
370
|
+
except Exception as rollback_exc:
|
|
371
|
+
rollback_errors.append(f"{path}: {rollback_exc}")
|
|
372
|
+
detail = f"; rollback failures: {'; '.join(rollback_errors)}" if rollback_errors else ""
|
|
373
|
+
raise OSError(f"failed to commit patch: {exc}{detail}") from exc
|
|
374
|
+
finally:
|
|
375
|
+
for temporary in staged.values():
|
|
376
|
+
try:
|
|
377
|
+
temporary.unlink(missing_ok=True)
|
|
378
|
+
except OSError:
|
|
379
|
+
pass
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class ApplyPatchTool(Tool):
|
|
383
|
+
name = "apply_patch"
|
|
384
|
+
description = "Apply a structured patch to one or more workspace files."
|
|
385
|
+
is_read_only = False
|
|
386
|
+
is_concurrency_safe = False
|
|
387
|
+
input_schema = {
|
|
388
|
+
"type": "object",
|
|
389
|
+
"properties": {
|
|
390
|
+
"patch": {
|
|
391
|
+
"type": "string",
|
|
392
|
+
"minLength": 1,
|
|
393
|
+
"description": "Patch text using the *** Begin Patch / *** End Patch format.",
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
"required": ["patch"],
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
400
|
+
return (
|
|
401
|
+
"Apply a patch to one or more files. Prefer this tool for code edits, especially "
|
|
402
|
+
"multi-file changes. The patch must use this format:\n\n"
|
|
403
|
+
"*** Begin Patch\n"
|
|
404
|
+
"*** Update File: path/to/file.py\n"
|
|
405
|
+
"@@\n"
|
|
406
|
+
" unchanged context\n"
|
|
407
|
+
"-old line\n"
|
|
408
|
+
"+new line\n"
|
|
409
|
+
"*** End Patch\n\n"
|
|
410
|
+
"Supported actions are Add File, Update File, Delete File, and an optional "
|
|
411
|
+
"*** Move to: new/path immediately after Update File. Every added-file line "
|
|
412
|
+
"must start with '+'. Hunk lines must start with a space, '+' or '-'. Paths "
|
|
413
|
+
"may be workspace-relative or absolute, and must stay inside the workspace. "
|
|
414
|
+
"The entire patch is validated before any file "
|
|
415
|
+
"is changed, and a failed hunk applies no changes."
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
async def validate_input(self, tool_input: dict[str, Any]) -> str | None:
|
|
419
|
+
patch = tool_input.get("patch")
|
|
420
|
+
if not isinstance(patch, str):
|
|
421
|
+
return "patch must be a string"
|
|
422
|
+
try:
|
|
423
|
+
parse_patch(patch)
|
|
424
|
+
except PatchError as exc:
|
|
425
|
+
return str(exc)
|
|
426
|
+
return None
|
|
427
|
+
|
|
428
|
+
async def check_permissions(
|
|
429
|
+
self,
|
|
430
|
+
tool_input: dict[str, Any],
|
|
431
|
+
context: ToolContext,
|
|
432
|
+
) -> PermissionResult:
|
|
433
|
+
patch = tool_input.get("patch")
|
|
434
|
+
if not isinstance(patch, str):
|
|
435
|
+
return PermissionResult()
|
|
436
|
+
try:
|
|
437
|
+
root = Path(context.cwd).resolve()
|
|
438
|
+
affected = [
|
|
439
|
+
_relative(_resolve_path(root, raw_path), root)
|
|
440
|
+
for raw_path in patch_paths(patch)
|
|
441
|
+
]
|
|
442
|
+
except PatchError as exc:
|
|
443
|
+
return PermissionResult(
|
|
444
|
+
behavior=PermissionBehavior.DENY,
|
|
445
|
+
reason=f"Invalid patch path: {exc}",
|
|
446
|
+
)
|
|
447
|
+
return PermissionResult(
|
|
448
|
+
updated_input={**tool_input, "affected_paths": affected},
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
async def call(
|
|
452
|
+
self,
|
|
453
|
+
tool_input: dict[str, Any],
|
|
454
|
+
context: ToolContext,
|
|
455
|
+
) -> ToolResult:
|
|
456
|
+
result = await run_file_tool(self.name, tool_input, context)
|
|
457
|
+
if result.is_error or not result.data or context.lsp_manager is None:
|
|
458
|
+
return result
|
|
459
|
+
from crabcode_core.lsp.diagnostics import collect_and_format_diagnostics
|
|
460
|
+
|
|
461
|
+
diagnostics: list[str] = []
|
|
462
|
+
for file_info in result.data.get("files", []):
|
|
463
|
+
if file_info.get("action") == "delete":
|
|
464
|
+
continue
|
|
465
|
+
path = Path(context.cwd) / str(file_info.get("path", ""))
|
|
466
|
+
diagnostics.append(
|
|
467
|
+
await collect_and_format_diagnostics(context.lsp_manager, str(path))
|
|
468
|
+
)
|
|
469
|
+
result.result_for_model += "".join(diagnostics)
|
|
470
|
+
return result
|
|
471
|
+
|
|
472
|
+
async def _call_local(
|
|
473
|
+
self,
|
|
474
|
+
tool_input: dict[str, Any],
|
|
475
|
+
context: ToolContext,
|
|
476
|
+
) -> ToolResult:
|
|
477
|
+
patch = tool_input.get("patch", "")
|
|
478
|
+
try:
|
|
479
|
+
actions = parse_patch(patch)
|
|
480
|
+
root = Path(context.cwd).resolve()
|
|
481
|
+
initial: dict[Path, _FileState | None] = {}
|
|
482
|
+
current: dict[Path, _FileState | None] = {}
|
|
483
|
+
|
|
484
|
+
def load(path: Path) -> _FileState | None:
|
|
485
|
+
if path not in current:
|
|
486
|
+
state = _read_state(path)
|
|
487
|
+
initial[path] = state
|
|
488
|
+
current[path] = state
|
|
489
|
+
return current[path]
|
|
490
|
+
|
|
491
|
+
for action in actions:
|
|
492
|
+
path = _resolve_path(root, action.path)
|
|
493
|
+
state = load(path)
|
|
494
|
+
if action.kind == "add":
|
|
495
|
+
if state is not None:
|
|
496
|
+
raise PatchError(f"cannot add an existing file: {action.path}")
|
|
497
|
+
current[path] = _FileState(
|
|
498
|
+
text=action.content or "",
|
|
499
|
+
newline="\n",
|
|
500
|
+
has_bom=False,
|
|
501
|
+
mode=None,
|
|
502
|
+
)
|
|
503
|
+
continue
|
|
504
|
+
if state is None:
|
|
505
|
+
raise PatchError(f"file does not exist: {action.path}")
|
|
506
|
+
if action.kind == "delete":
|
|
507
|
+
current[path] = None
|
|
508
|
+
continue
|
|
509
|
+
|
|
510
|
+
updated = _apply_hunks(state.text, action.hunks, action.path)
|
|
511
|
+
updated_state = replace(state, text=updated)
|
|
512
|
+
if action.move_to:
|
|
513
|
+
destination = _resolve_path(root, action.move_to)
|
|
514
|
+
if destination == path:
|
|
515
|
+
raise PatchError("move destination must differ from the source path")
|
|
516
|
+
if load(destination) is not None:
|
|
517
|
+
raise PatchError(f"move destination already exists: {action.move_to}")
|
|
518
|
+
current[path] = None
|
|
519
|
+
current[destination] = updated_state
|
|
520
|
+
else:
|
|
521
|
+
current[path] = updated_state
|
|
522
|
+
|
|
523
|
+
final = {path: current[path] for path in initial}
|
|
524
|
+
display, files, totals = _render_diff(root, initial, final)
|
|
525
|
+
if not files:
|
|
526
|
+
raise PatchError("patch would not change any files")
|
|
527
|
+
|
|
528
|
+
if context.session_id:
|
|
529
|
+
try:
|
|
530
|
+
from crabcode_core.snapshot.tracker import (
|
|
531
|
+
pre_bash_snapshot,
|
|
532
|
+
track_snapshot_for_file,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
snapshot_id = pre_bash_snapshot(
|
|
536
|
+
context.cwd,
|
|
537
|
+
context.session_id,
|
|
538
|
+
enabled=context.snapshot_enabled,
|
|
539
|
+
max_size_mb=context.snapshot_max_size_mb,
|
|
540
|
+
tool_name=self.name,
|
|
541
|
+
action="patch",
|
|
542
|
+
)
|
|
543
|
+
if snapshot_id is None:
|
|
544
|
+
for path, before in initial.items():
|
|
545
|
+
after = final[path]
|
|
546
|
+
if before == after:
|
|
547
|
+
continue
|
|
548
|
+
if before is None:
|
|
549
|
+
action_name = "create"
|
|
550
|
+
elif after is None:
|
|
551
|
+
action_name = "delete"
|
|
552
|
+
else:
|
|
553
|
+
action_name = "modify"
|
|
554
|
+
track_snapshot_for_file(
|
|
555
|
+
cwd=context.cwd,
|
|
556
|
+
session_id=context.session_id,
|
|
557
|
+
file_path=str(path),
|
|
558
|
+
old_content=before.text if before is not None else None,
|
|
559
|
+
action=action_name,
|
|
560
|
+
)
|
|
561
|
+
except Exception:
|
|
562
|
+
logger.debug("Failed to track snapshots for ApplyPatch", exc_info=True)
|
|
563
|
+
|
|
564
|
+
_write_transaction(initial, final)
|
|
565
|
+
changed = len(files)
|
|
566
|
+
summary = (
|
|
567
|
+
f"Applied patch to {changed} file{'s' if changed != 1 else ''} "
|
|
568
|
+
f"[+{totals['added']}/-{totals['removed']}]"
|
|
569
|
+
)
|
|
570
|
+
return ToolResult(
|
|
571
|
+
data={
|
|
572
|
+
"files": files,
|
|
573
|
+
"affected_paths": [
|
|
574
|
+
_relative(path, root)
|
|
575
|
+
for path in initial
|
|
576
|
+
if initial[path] != final[path]
|
|
577
|
+
],
|
|
578
|
+
"stats": totals,
|
|
579
|
+
},
|
|
580
|
+
result_for_model=summary,
|
|
581
|
+
result_for_display=f"{summary}\n{display}" if display else summary,
|
|
582
|
+
)
|
|
583
|
+
except (OSError, UnicodeError, PatchError) as exc:
|
|
584
|
+
return ToolResult(result_for_model=f"ApplyPatch error: {exc}", is_error=True)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
__all__ = ["ApplyPatchTool", "PatchError", "parse_patch", "patch_paths"]
|