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.
Files changed (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. 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"]