codemaster-cli 2.2.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 (170) hide show
  1. codemaster_cli-2.2.0.dist-info/METADATA +645 -0
  2. codemaster_cli-2.2.0.dist-info/RECORD +170 -0
  3. codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
  4. codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
  5. vibe/__init__.py +6 -0
  6. vibe/acp/__init__.py +0 -0
  7. vibe/acp/acp_agent_loop.py +746 -0
  8. vibe/acp/entrypoint.py +81 -0
  9. vibe/acp/tools/__init__.py +0 -0
  10. vibe/acp/tools/base.py +100 -0
  11. vibe/acp/tools/builtins/bash.py +134 -0
  12. vibe/acp/tools/builtins/read_file.py +54 -0
  13. vibe/acp/tools/builtins/search_replace.py +129 -0
  14. vibe/acp/tools/builtins/todo.py +65 -0
  15. vibe/acp/tools/builtins/write_file.py +98 -0
  16. vibe/acp/tools/session_update.py +118 -0
  17. vibe/acp/utils.py +213 -0
  18. vibe/cli/__init__.py +0 -0
  19. vibe/cli/autocompletion/__init__.py +0 -0
  20. vibe/cli/autocompletion/base.py +22 -0
  21. vibe/cli/autocompletion/path_completion.py +177 -0
  22. vibe/cli/autocompletion/slash_command.py +99 -0
  23. vibe/cli/cli.py +188 -0
  24. vibe/cli/clipboard.py +69 -0
  25. vibe/cli/commands.py +116 -0
  26. vibe/cli/entrypoint.py +163 -0
  27. vibe/cli/history_manager.py +91 -0
  28. vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
  29. vibe/cli/plan_offer/decide_plan_offer.py +87 -0
  30. vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
  31. vibe/cli/terminal_setup.py +323 -0
  32. vibe/cli/textual_ui/__init__.py +0 -0
  33. vibe/cli/textual_ui/ansi_markdown.py +58 -0
  34. vibe/cli/textual_ui/app.py +1546 -0
  35. vibe/cli/textual_ui/app.tcss +1020 -0
  36. vibe/cli/textual_ui/external_editor.py +32 -0
  37. vibe/cli/textual_ui/handlers/__init__.py +5 -0
  38. vibe/cli/textual_ui/handlers/event_handler.py +147 -0
  39. vibe/cli/textual_ui/widgets/__init__.py +0 -0
  40. vibe/cli/textual_ui/widgets/approval_app.py +192 -0
  41. vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
  42. vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
  43. vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
  44. vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
  45. vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
  46. vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
  47. vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
  48. vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
  49. vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
  50. vibe/cli/textual_ui/widgets/compact.py +41 -0
  51. vibe/cli/textual_ui/widgets/config_app.py +171 -0
  52. vibe/cli/textual_ui/widgets/context_progress.py +30 -0
  53. vibe/cli/textual_ui/widgets/load_more.py +43 -0
  54. vibe/cli/textual_ui/widgets/loading.py +201 -0
  55. vibe/cli/textual_ui/widgets/messages.py +277 -0
  56. vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
  57. vibe/cli/textual_ui/widgets/path_display.py +28 -0
  58. vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
  59. vibe/cli/textual_ui/widgets/question_app.py +496 -0
  60. vibe/cli/textual_ui/widgets/spinner.py +194 -0
  61. vibe/cli/textual_ui/widgets/status_message.py +76 -0
  62. vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
  63. vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
  64. vibe/cli/textual_ui/widgets/tools.py +201 -0
  65. vibe/cli/textual_ui/windowing/__init__.py +29 -0
  66. vibe/cli/textual_ui/windowing/history.py +105 -0
  67. vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
  68. vibe/cli/textual_ui/windowing/state.py +105 -0
  69. vibe/cli/update_notifier/__init__.py +47 -0
  70. vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
  71. vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
  72. vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
  73. vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
  74. vibe/cli/update_notifier/ports/update_gateway.py +53 -0
  75. vibe/cli/update_notifier/update.py +139 -0
  76. vibe/cli/update_notifier/whats_new.py +49 -0
  77. vibe/core/__init__.py +5 -0
  78. vibe/core/agent_loop.py +1075 -0
  79. vibe/core/agents/__init__.py +31 -0
  80. vibe/core/agents/manager.py +165 -0
  81. vibe/core/agents/models.py +122 -0
  82. vibe/core/auth/__init__.py +6 -0
  83. vibe/core/auth/crypto.py +137 -0
  84. vibe/core/auth/github.py +178 -0
  85. vibe/core/autocompletion/__init__.py +0 -0
  86. vibe/core/autocompletion/completers.py +257 -0
  87. vibe/core/autocompletion/file_indexer/__init__.py +10 -0
  88. vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
  89. vibe/core/autocompletion/file_indexer/indexer.py +179 -0
  90. vibe/core/autocompletion/file_indexer/store.py +169 -0
  91. vibe/core/autocompletion/file_indexer/watcher.py +71 -0
  92. vibe/core/autocompletion/fuzzy.py +189 -0
  93. vibe/core/autocompletion/path_prompt.py +108 -0
  94. vibe/core/autocompletion/path_prompt_adapter.py +149 -0
  95. vibe/core/config.py +673 -0
  96. vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
  97. vibe/core/llm/__init__.py +0 -0
  98. vibe/core/llm/backend/anthropic.py +630 -0
  99. vibe/core/llm/backend/base.py +38 -0
  100. vibe/core/llm/backend/factory.py +7 -0
  101. vibe/core/llm/backend/generic.py +425 -0
  102. vibe/core/llm/backend/mistral.py +381 -0
  103. vibe/core/llm/backend/vertex.py +115 -0
  104. vibe/core/llm/exceptions.py +195 -0
  105. vibe/core/llm/format.py +184 -0
  106. vibe/core/llm/message_utils.py +24 -0
  107. vibe/core/llm/types.py +120 -0
  108. vibe/core/middleware.py +209 -0
  109. vibe/core/output_formatters.py +85 -0
  110. vibe/core/paths/__init__.py +0 -0
  111. vibe/core/paths/config_paths.py +68 -0
  112. vibe/core/paths/global_paths.py +40 -0
  113. vibe/core/programmatic.py +56 -0
  114. vibe/core/prompts/__init__.py +32 -0
  115. vibe/core/prompts/cli.md +111 -0
  116. vibe/core/prompts/compact.md +48 -0
  117. vibe/core/prompts/dangerous_directory.md +5 -0
  118. vibe/core/prompts/explore.md +50 -0
  119. vibe/core/prompts/project_context.md +8 -0
  120. vibe/core/prompts/tests.md +1 -0
  121. vibe/core/proxy_setup.py +65 -0
  122. vibe/core/session/session_loader.py +222 -0
  123. vibe/core/session/session_logger.py +318 -0
  124. vibe/core/session/session_migration.py +41 -0
  125. vibe/core/skills/__init__.py +7 -0
  126. vibe/core/skills/manager.py +132 -0
  127. vibe/core/skills/models.py +92 -0
  128. vibe/core/skills/parser.py +39 -0
  129. vibe/core/system_prompt.py +466 -0
  130. vibe/core/telemetry/__init__.py +0 -0
  131. vibe/core/telemetry/send.py +185 -0
  132. vibe/core/teleport/errors.py +9 -0
  133. vibe/core/teleport/git.py +196 -0
  134. vibe/core/teleport/nuage.py +180 -0
  135. vibe/core/teleport/teleport.py +208 -0
  136. vibe/core/teleport/types.py +54 -0
  137. vibe/core/tools/base.py +336 -0
  138. vibe/core/tools/builtins/ask_user_question.py +134 -0
  139. vibe/core/tools/builtins/bash.py +357 -0
  140. vibe/core/tools/builtins/grep.py +310 -0
  141. vibe/core/tools/builtins/prompts/__init__.py +0 -0
  142. vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
  143. vibe/core/tools/builtins/prompts/bash.md +73 -0
  144. vibe/core/tools/builtins/prompts/grep.md +4 -0
  145. vibe/core/tools/builtins/prompts/read_file.md +13 -0
  146. vibe/core/tools/builtins/prompts/search_replace.md +43 -0
  147. vibe/core/tools/builtins/prompts/task.md +24 -0
  148. vibe/core/tools/builtins/prompts/todo.md +199 -0
  149. vibe/core/tools/builtins/prompts/write_file.md +42 -0
  150. vibe/core/tools/builtins/read_file.py +222 -0
  151. vibe/core/tools/builtins/search_replace.py +456 -0
  152. vibe/core/tools/builtins/task.py +154 -0
  153. vibe/core/tools/builtins/todo.py +134 -0
  154. vibe/core/tools/builtins/write_file.py +160 -0
  155. vibe/core/tools/manager.py +341 -0
  156. vibe/core/tools/mcp.py +397 -0
  157. vibe/core/tools/ui.py +68 -0
  158. vibe/core/trusted_folders.py +86 -0
  159. vibe/core/types.py +405 -0
  160. vibe/core/utils.py +396 -0
  161. vibe/setup/onboarding/__init__.py +39 -0
  162. vibe/setup/onboarding/base.py +14 -0
  163. vibe/setup/onboarding/onboarding.tcss +134 -0
  164. vibe/setup/onboarding/screens/__init__.py +5 -0
  165. vibe/setup/onboarding/screens/api_key.py +200 -0
  166. vibe/setup/onboarding/screens/provider_selection.py +87 -0
  167. vibe/setup/onboarding/screens/welcome.py +136 -0
  168. vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
  169. vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
  170. vibe/whats_new.md +5 -0
@@ -0,0 +1,222 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncGenerator
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, ClassVar, NamedTuple, final
6
+
7
+ import anyio
8
+ from pydantic import BaseModel, Field
9
+
10
+ from vibe.core.tools.base import (
11
+ BaseTool,
12
+ BaseToolConfig,
13
+ BaseToolState,
14
+ InvokeContext,
15
+ ToolError,
16
+ ToolPermission,
17
+ )
18
+ from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
19
+ from vibe.core.types import ToolStreamEvent
20
+
21
+ if TYPE_CHECKING:
22
+ from vibe.core.types import ToolCallEvent, ToolResultEvent
23
+
24
+
25
+ class _ReadResult(NamedTuple):
26
+ lines: list[str]
27
+ bytes_read: int
28
+ was_truncated: bool
29
+
30
+
31
+ class ReadFileArgs(BaseModel):
32
+ path: str
33
+ offset: int = Field(
34
+ default=0,
35
+ description="Line number to start reading from (0-indexed, inclusive).",
36
+ )
37
+ limit: int | None = Field(
38
+ default=None, description="Maximum number of lines to read."
39
+ )
40
+
41
+
42
+ class ReadFileResult(BaseModel):
43
+ path: str
44
+ content: str
45
+ lines_read: int
46
+ was_truncated: bool = Field(
47
+ description="True if the reading was stopped due to the max_read_bytes limit."
48
+ )
49
+
50
+
51
+ class ReadFileToolConfig(BaseToolConfig):
52
+ permission: ToolPermission = ToolPermission.ALWAYS
53
+
54
+ max_read_bytes: int = Field(
55
+ default=64_000, description="Maximum total bytes to read from a file in one go."
56
+ )
57
+ max_state_history: int = Field(
58
+ default=10, description="Number of recently read files to remember in state."
59
+ )
60
+
61
+
62
+ class ReadFileState(BaseToolState):
63
+ recently_read_files: list[str] = Field(default_factory=list)
64
+
65
+
66
+ class ReadFile(
67
+ BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, ReadFileState],
68
+ ToolUIData[ReadFileArgs, ReadFileResult],
69
+ ):
70
+ description: ClassVar[str] = (
71
+ "Read a UTF-8 file, returning content from a specific line range. "
72
+ "Reading is capped by a byte limit for safety."
73
+ )
74
+
75
+ @final
76
+ async def run(
77
+ self, args: ReadFileArgs, ctx: InvokeContext | None = None
78
+ ) -> AsyncGenerator[ToolStreamEvent | ReadFileResult, None]:
79
+ file_path = self._prepare_and_validate_path(args)
80
+
81
+ read_result = await self._read_file(args, file_path)
82
+
83
+ self._update_state_history(file_path)
84
+
85
+ yield ReadFileResult(
86
+ path=str(file_path),
87
+ content="".join(read_result.lines),
88
+ lines_read=len(read_result.lines),
89
+ was_truncated=read_result.was_truncated,
90
+ )
91
+
92
+ def check_allowlist_denylist(self, args: ReadFileArgs) -> ToolPermission | None:
93
+ import fnmatch
94
+
95
+ file_path = Path(args.path).expanduser()
96
+ if not file_path.is_absolute():
97
+ file_path = Path.cwd() / file_path
98
+ file_str = str(file_path)
99
+
100
+ for pattern in self.config.denylist:
101
+ if fnmatch.fnmatch(file_str, pattern):
102
+ return ToolPermission.NEVER
103
+
104
+ for pattern in self.config.allowlist:
105
+ if fnmatch.fnmatch(file_str, pattern):
106
+ return ToolPermission.ALWAYS
107
+
108
+ return None
109
+
110
+ def _prepare_and_validate_path(self, args: ReadFileArgs) -> Path:
111
+ self._validate_inputs(args)
112
+
113
+ file_path = Path(args.path).expanduser()
114
+ if not file_path.is_absolute():
115
+ file_path = Path.cwd() / file_path
116
+
117
+ self._validate_path(file_path)
118
+ return file_path
119
+
120
+ async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
121
+ try:
122
+ lines_to_return: list[str] = []
123
+ bytes_read = 0
124
+ was_truncated = False
125
+
126
+ async with await anyio.Path(file_path).open(
127
+ encoding="utf-8", errors="ignore"
128
+ ) as f:
129
+ line_index = 0
130
+ async for line in f:
131
+ if line_index < args.offset:
132
+ line_index += 1
133
+ continue
134
+
135
+ if args.limit is not None and len(lines_to_return) >= args.limit:
136
+ break
137
+
138
+ line_bytes = len(line.encode("utf-8"))
139
+ if bytes_read + line_bytes > self.config.max_read_bytes:
140
+ was_truncated = True
141
+ break
142
+
143
+ lines_to_return.append(line)
144
+ bytes_read += line_bytes
145
+ line_index += 1
146
+
147
+ return _ReadResult(
148
+ lines=lines_to_return,
149
+ bytes_read=bytes_read,
150
+ was_truncated=was_truncated,
151
+ )
152
+
153
+ except OSError as exc:
154
+ raise ToolError(f"Error reading {file_path}: {exc}") from exc
155
+
156
+ def _validate_inputs(self, args: ReadFileArgs) -> None:
157
+ if not args.path.strip():
158
+ raise ToolError("Path cannot be empty")
159
+ if args.offset < 0:
160
+ raise ToolError("Offset cannot be negative")
161
+ if args.limit is not None and args.limit <= 0:
162
+ raise ToolError("Limit, if provided, must be a positive number")
163
+
164
+ def _validate_path(self, file_path: Path) -> None:
165
+ try:
166
+ resolved_path = file_path.resolve()
167
+ except ValueError:
168
+ raise ToolError(
169
+ f"Security error: Cannot read path '{file_path}' outside of the project directory '{Path.cwd()}'."
170
+ )
171
+ except FileNotFoundError:
172
+ raise ToolError(f"File not found at: {file_path}")
173
+
174
+ if not resolved_path.exists():
175
+ raise ToolError(f"File not found at: {file_path}")
176
+ if resolved_path.is_dir():
177
+ raise ToolError(f"Path is a directory, not a file: {file_path}")
178
+
179
+ def _update_state_history(self, file_path: Path) -> None:
180
+ self.state.recently_read_files.append(str(file_path.resolve()))
181
+ if len(self.state.recently_read_files) > self.config.max_state_history:
182
+ self.state.recently_read_files.pop(0)
183
+
184
+ @classmethod
185
+ def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
186
+ if not isinstance(event.args, ReadFileArgs):
187
+ return ToolCallDisplay(summary="read_file")
188
+
189
+ summary = f"Reading {event.args.path}"
190
+ if event.args.offset > 0 or event.args.limit is not None:
191
+ parts = []
192
+ if event.args.offset > 0:
193
+ parts.append(f"from line {event.args.offset}")
194
+ if event.args.limit is not None:
195
+ parts.append(f"limit {event.args.limit} lines")
196
+ summary += f" ({', '.join(parts)})"
197
+
198
+ return ToolCallDisplay(summary=summary)
199
+
200
+ @classmethod
201
+ def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
202
+ if not isinstance(event.result, ReadFileResult):
203
+ return ToolResultDisplay(
204
+ success=False, message=event.error or event.skip_reason or "No result"
205
+ )
206
+
207
+ path_obj = Path(event.result.path)
208
+ message = f"Read {event.result.lines_read} line{'' if event.result.lines_read <= 1 else 's'} from {path_obj.name}"
209
+ if event.result.was_truncated:
210
+ message += " (truncated)"
211
+
212
+ return ToolResultDisplay(
213
+ success=True,
214
+ message=message,
215
+ warnings=["File was truncated due to size limit"]
216
+ if event.result.was_truncated
217
+ else [],
218
+ )
219
+
220
+ @classmethod
221
+ def get_status_text(cls) -> str:
222
+ return "Reading file"
@@ -0,0 +1,456 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncGenerator
4
+ import difflib
5
+ from pathlib import Path
6
+ import re
7
+ import shutil
8
+ from typing import ClassVar, NamedTuple, final
9
+
10
+ import anyio
11
+ from pydantic import BaseModel, Field
12
+
13
+ from vibe.core.tools.base import (
14
+ BaseTool,
15
+ BaseToolConfig,
16
+ BaseToolState,
17
+ InvokeContext,
18
+ ToolError,
19
+ )
20
+ from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
21
+ from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
22
+
23
+ SEARCH_REPLACE_BLOCK_RE = re.compile(
24
+ r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL
25
+ )
26
+
27
+ SEARCH_REPLACE_BLOCK_WITH_FENCE_RE = re.compile(
28
+ r"```[\s\S]*?\n<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE\s*\n```",
29
+ flags=re.DOTALL,
30
+ )
31
+
32
+
33
+ class SearchReplaceBlock(NamedTuple):
34
+ search: str
35
+ replace: str
36
+
37
+
38
+ class FuzzyMatch(NamedTuple):
39
+ similarity: float
40
+ start_line: int
41
+ end_line: int
42
+ text: str
43
+
44
+
45
+ class BlockApplyResult(NamedTuple):
46
+ content: str
47
+ applied: int
48
+ errors: list[str]
49
+ warnings: list[str]
50
+
51
+
52
+ class SearchReplaceArgs(BaseModel):
53
+ file_path: str
54
+ content: str
55
+
56
+
57
+ class SearchReplaceResult(BaseModel):
58
+ file: str
59
+ blocks_applied: int
60
+ lines_changed: int
61
+ content: str
62
+ warnings: list[str] = Field(default_factory=list)
63
+
64
+
65
+ class SearchReplaceConfig(BaseToolConfig):
66
+ max_content_size: int = 100_000
67
+ create_backup: bool = False
68
+ fuzzy_threshold: float = 0.9
69
+
70
+
71
+ class SearchReplaceState(BaseToolState):
72
+ pass
73
+
74
+
75
+ class SearchReplace(
76
+ BaseTool[
77
+ SearchReplaceArgs, SearchReplaceResult, SearchReplaceConfig, SearchReplaceState
78
+ ],
79
+ ToolUIData[SearchReplaceArgs, SearchReplaceResult],
80
+ ):
81
+ description: ClassVar[str] = (
82
+ "Replace sections of files using SEARCH/REPLACE blocks. "
83
+ "Supports fuzzy matching and detailed error reporting. "
84
+ "Format: <<<<<<< SEARCH\\n[text]\\n=======\\n[replacement]\\n>>>>>>> REPLACE"
85
+ )
86
+
87
+ @classmethod
88
+ def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
89
+ if not isinstance(event.args, SearchReplaceArgs):
90
+ return ToolCallDisplay(summary="Invalid arguments")
91
+
92
+ args = event.args
93
+ blocks = cls._parse_search_replace_blocks(args.content)
94
+
95
+ return ToolCallDisplay(
96
+ summary=f"Patching {args.file_path} ({len(blocks)} blocks)",
97
+ content=args.content,
98
+ )
99
+
100
+ @classmethod
101
+ def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
102
+ if isinstance(event.result, SearchReplaceResult):
103
+ return ToolResultDisplay(
104
+ success=True,
105
+ message=f"Applied {event.result.blocks_applied} block{'' if event.result.blocks_applied == 1 else 's'}",
106
+ warnings=event.result.warnings,
107
+ )
108
+
109
+ return ToolResultDisplay(success=True, message="Patch applied")
110
+
111
+ @classmethod
112
+ def get_status_text(cls) -> str:
113
+ return "Editing files"
114
+
115
+ @final
116
+ async def run(
117
+ self, args: SearchReplaceArgs, ctx: InvokeContext | None = None
118
+ ) -> AsyncGenerator[ToolStreamEvent | SearchReplaceResult, None]:
119
+ file_path, search_replace_blocks = self._prepare_and_validate_args(args)
120
+
121
+ original_content = await self._read_file(file_path)
122
+
123
+ block_result = self._apply_blocks(
124
+ original_content,
125
+ search_replace_blocks,
126
+ file_path,
127
+ self.config.fuzzy_threshold,
128
+ )
129
+
130
+ if block_result.errors:
131
+ error_message = "SEARCH/REPLACE blocks failed:\n" + "\n\n".join(
132
+ block_result.errors
133
+ )
134
+ if block_result.warnings:
135
+ error_message += "\n\nWarnings encountered:\n" + "\n".join(
136
+ block_result.warnings
137
+ )
138
+ raise ToolError(error_message)
139
+
140
+ modified_content = block_result.content
141
+
142
+ # Calculate line changes
143
+ if modified_content == original_content:
144
+ lines_changed = 0
145
+ else:
146
+ original_lines = len(original_content.splitlines())
147
+ new_lines = len(modified_content.splitlines())
148
+ lines_changed = new_lines - original_lines
149
+
150
+ try:
151
+ if self.config.create_backup:
152
+ await self._backup_file(file_path)
153
+ except Exception:
154
+ pass
155
+
156
+ await self._write_file(file_path, modified_content)
157
+
158
+ yield SearchReplaceResult(
159
+ file=str(file_path),
160
+ blocks_applied=block_result.applied,
161
+ lines_changed=lines_changed,
162
+ warnings=block_result.warnings,
163
+ content=args.content,
164
+ )
165
+
166
+ @final
167
+ def _prepare_and_validate_args(
168
+ self, args: SearchReplaceArgs
169
+ ) -> tuple[Path, list[SearchReplaceBlock]]:
170
+ file_path_str = args.file_path.strip()
171
+ content = args.content.strip()
172
+
173
+ if not file_path_str:
174
+ raise ToolError("File path cannot be empty")
175
+
176
+ if len(content) > self.config.max_content_size:
177
+ raise ToolError(
178
+ f"Content size ({len(content)} bytes) exceeds max_content_size "
179
+ f"({self.config.max_content_size} bytes)"
180
+ )
181
+
182
+ if not content:
183
+ raise ToolError("Empty content provided")
184
+
185
+ project_root = Path.cwd()
186
+ file_path = Path(file_path_str).expanduser()
187
+ if not file_path.is_absolute():
188
+ file_path = project_root / file_path
189
+ file_path = file_path.resolve()
190
+
191
+ if not file_path.exists():
192
+ raise ToolError(f"File does not exist: {file_path}")
193
+
194
+ if not file_path.is_file():
195
+ raise ToolError(f"Path is not a file: {file_path}")
196
+
197
+ search_replace_blocks = self._parse_search_replace_blocks(content)
198
+ if not search_replace_blocks:
199
+ raise ToolError(
200
+ "No valid SEARCH/REPLACE blocks found in content.\n"
201
+ "Expected format:\n"
202
+ "<<<<<<< SEARCH\n"
203
+ "[exact content to find]\n"
204
+ "=======\n"
205
+ "[new content to replace with]\n"
206
+ ">>>>>>> REPLACE"
207
+ )
208
+
209
+ return file_path, search_replace_blocks
210
+
211
+ async def _read_file(self, file_path: Path) -> str:
212
+ try:
213
+ async with await anyio.Path(file_path).open(encoding="utf-8") as f:
214
+ return await f.read()
215
+ except UnicodeDecodeError as e:
216
+ raise ToolError(f"Unicode decode error reading {file_path}: {e}") from e
217
+ except PermissionError:
218
+ raise ToolError(f"Permission denied reading file: {file_path}")
219
+ except Exception as e:
220
+ raise ToolError(f"Unexpected error reading {file_path}: {e}") from e
221
+
222
+ async def _backup_file(self, file_path: Path) -> None:
223
+ shutil.copy2(file_path, file_path.with_suffix(file_path.suffix + ".bak"))
224
+
225
+ async def _write_file(self, file_path: Path, content: str) -> None:
226
+ try:
227
+ async with await anyio.Path(file_path).open(
228
+ mode="w", encoding="utf-8"
229
+ ) as f:
230
+ await f.write(content)
231
+ except PermissionError:
232
+ raise ToolError(f"Permission denied writing to file: {file_path}")
233
+ except OSError as e:
234
+ raise ToolError(f"OS error writing to {file_path}: {e}") from e
235
+ except Exception as e:
236
+ raise ToolError(f"Unexpected error writing to {file_path}: {e}") from e
237
+
238
+ @final
239
+ @staticmethod
240
+ def _apply_blocks(
241
+ content: str,
242
+ blocks: list[SearchReplaceBlock],
243
+ filepath: Path,
244
+ fuzzy_threshold: float = 0.9,
245
+ ) -> BlockApplyResult:
246
+ applied = 0
247
+ errors: list[str] = []
248
+ warnings: list[str] = []
249
+ current_content = content
250
+
251
+ for i, (search, replace) in enumerate(blocks, 1):
252
+ if search not in current_content:
253
+ context = SearchReplace._find_search_context(current_content, search)
254
+ fuzzy_context = SearchReplace._find_fuzzy_match_context(
255
+ current_content, search, fuzzy_threshold
256
+ )
257
+
258
+ error_msg = (
259
+ f"SEARCH/REPLACE block {i} failed: Search text not found in {filepath}\n"
260
+ f"Search text was:\n{search!r}\n"
261
+ f"Context analysis:\n{context}"
262
+ )
263
+
264
+ if fuzzy_context:
265
+ error_msg += f"\n{fuzzy_context}"
266
+
267
+ error_msg += (
268
+ "\nDebugging tips:\n"
269
+ "1. Check for exact whitespace/indentation match\n"
270
+ "2. Verify line endings match the file exactly (\\r\\n vs \\n)\n"
271
+ "3. Ensure the search text hasn't been modified by previous blocks or user edits\n"
272
+ "4. Check for typos or case sensitivity issues"
273
+ )
274
+
275
+ errors.append(error_msg)
276
+ continue
277
+
278
+ occurrences = current_content.count(search)
279
+ if occurrences > 1:
280
+ warning_msg = (
281
+ f"Search text in block {i} appears {occurrences} times in the file. "
282
+ f"Only the first occurrence will be replaced. Consider making your "
283
+ f"search pattern more specific to avoid unintended changes."
284
+ )
285
+ warnings.append(warning_msg)
286
+
287
+ current_content = current_content.replace(search, replace, 1)
288
+ applied += 1
289
+
290
+ return BlockApplyResult(
291
+ content=current_content, applied=applied, errors=errors, warnings=warnings
292
+ )
293
+
294
+ @final
295
+ @staticmethod
296
+ def _find_fuzzy_match_context(
297
+ content: str, search_text: str, threshold: float = 0.9
298
+ ) -> str | None:
299
+ best_match = SearchReplace._find_best_fuzzy_match(
300
+ content, search_text, threshold
301
+ )
302
+
303
+ if not best_match:
304
+ return None
305
+
306
+ diff = SearchReplace._create_unified_diff(
307
+ search_text, best_match.text, "SEARCH", "CLOSEST MATCH"
308
+ )
309
+
310
+ similarity_pct = best_match.similarity * 100
311
+
312
+ return (
313
+ f"Closest fuzzy match (similarity {similarity_pct:.1f}%) "
314
+ f"at lines {best_match.start_line}–{best_match.end_line}:\n"
315
+ f"```diff\n{diff}\n```"
316
+ )
317
+
318
+ @final
319
+ @staticmethod
320
+ def _find_best_fuzzy_match( # noqa: PLR0914
321
+ content: str, search_text: str, threshold: float = 0.9
322
+ ) -> FuzzyMatch | None:
323
+ content_lines = content.split("\n")
324
+ search_lines = search_text.split("\n")
325
+ window_size = len(search_lines)
326
+
327
+ if window_size == 0:
328
+ return None
329
+
330
+ non_empty_search = [line for line in search_lines if line.strip()]
331
+ if not non_empty_search:
332
+ return None
333
+
334
+ first_anchor = non_empty_search[0]
335
+ last_anchor = (
336
+ non_empty_search[-1] if len(non_empty_search) > 1 else first_anchor
337
+ )
338
+
339
+ candidate_starts = set()
340
+ spread = 5
341
+
342
+ for i, line in enumerate(content_lines):
343
+ if first_anchor in line or last_anchor in line:
344
+ start_min = max(0, i - spread)
345
+ start_max = min(len(content_lines) - window_size + 1, i + spread + 1)
346
+ for s in range(start_min, start_max):
347
+ candidate_starts.add(s)
348
+
349
+ if not candidate_starts:
350
+ max_positions = min(len(content_lines) - window_size + 1, 100)
351
+ candidate_starts = set(range(0, max_positions))
352
+
353
+ best_match = None
354
+ best_similarity = 0.0
355
+
356
+ for start in candidate_starts:
357
+ end = start + window_size
358
+ window_text = "\n".join(content_lines[start:end])
359
+
360
+ matcher = difflib.SequenceMatcher(None, search_text, window_text)
361
+ similarity = matcher.ratio()
362
+
363
+ if similarity >= threshold and similarity > best_similarity:
364
+ best_similarity = similarity
365
+ best_match = FuzzyMatch(
366
+ similarity=similarity,
367
+ start_line=start + 1, # 1-based line numbers
368
+ end_line=end,
369
+ text=window_text,
370
+ )
371
+
372
+ return best_match
373
+
374
+ @final
375
+ @staticmethod
376
+ def _create_unified_diff(
377
+ text1: str, text2: str, label1: str = "SEARCH", label2: str = "CLOSEST MATCH"
378
+ ) -> str:
379
+ lines1 = text1.splitlines(keepends=True)
380
+ lines2 = text2.splitlines(keepends=True)
381
+
382
+ lines1 = [line if line.endswith("\n") else line + "\n" for line in lines1]
383
+ lines2 = [line if line.endswith("\n") else line + "\n" for line in lines2]
384
+
385
+ diff = difflib.unified_diff(
386
+ lines1, lines2, fromfile=label1, tofile=label2, lineterm="", n=3
387
+ )
388
+
389
+ diff_lines = list(diff)
390
+
391
+ if diff_lines and not diff_lines[0].startswith("==="):
392
+ diff_lines.insert(2, "=" * 67 + "\n")
393
+
394
+ result = "".join(diff_lines)
395
+
396
+ max_chars = 2000
397
+ if len(result) > max_chars:
398
+ result = result[:max_chars] + "\n...(diff truncated)"
399
+
400
+ return result.rstrip()
401
+
402
+ @final
403
+ @staticmethod
404
+ def _parse_search_replace_blocks(content: str) -> list[SearchReplaceBlock]:
405
+ """Parse SEARCH/REPLACE blocks from content.
406
+
407
+ Supports two formats:
408
+ 1. With code block fences (```...```)
409
+ 2. Without code block fences
410
+ """
411
+ matches = SEARCH_REPLACE_BLOCK_WITH_FENCE_RE.findall(content)
412
+
413
+ if not matches:
414
+ matches = SEARCH_REPLACE_BLOCK_RE.findall(content)
415
+
416
+ return [
417
+ SearchReplaceBlock(
418
+ search=search.rstrip("\r\n"), replace=replace.rstrip("\r\n")
419
+ )
420
+ for search, replace in matches
421
+ ]
422
+
423
+ @final
424
+ @staticmethod
425
+ def _find_search_context(
426
+ content: str, search_text: str, max_context: int = 5
427
+ ) -> str:
428
+ lines = content.split("\n")
429
+ search_lines = search_text.split("\n")
430
+
431
+ if not search_lines:
432
+ return "Search text is empty"
433
+
434
+ first_search_line = search_lines[0].strip()
435
+ if not first_search_line:
436
+ return "First line of search text is empty or whitespace only"
437
+
438
+ matches = []
439
+ for i, line in enumerate(lines):
440
+ if first_search_line in line:
441
+ matches.append(i)
442
+
443
+ if not matches:
444
+ return f"First search line '{first_search_line}' not found anywhere in file"
445
+
446
+ context_lines = []
447
+ for match_idx in matches[:3]:
448
+ start = max(0, match_idx - max_context)
449
+ end = min(len(lines), match_idx + max_context + 1)
450
+
451
+ context_lines.append(f"\nPotential match area around line {match_idx + 1}:")
452
+ for i in range(start, end):
453
+ marker = ">>>" if i == match_idx else " "
454
+ context_lines.append(f"{marker} {i + 1:3d}: {lines[i]}")
455
+
456
+ return "\n".join(context_lines)