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.
- codemaster_cli-2.2.0.dist-info/METADATA +645 -0
- codemaster_cli-2.2.0.dist-info/RECORD +170 -0
- codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
- codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
- vibe/__init__.py +6 -0
- vibe/acp/__init__.py +0 -0
- vibe/acp/acp_agent_loop.py +746 -0
- vibe/acp/entrypoint.py +81 -0
- vibe/acp/tools/__init__.py +0 -0
- vibe/acp/tools/base.py +100 -0
- vibe/acp/tools/builtins/bash.py +134 -0
- vibe/acp/tools/builtins/read_file.py +54 -0
- vibe/acp/tools/builtins/search_replace.py +129 -0
- vibe/acp/tools/builtins/todo.py +65 -0
- vibe/acp/tools/builtins/write_file.py +98 -0
- vibe/acp/tools/session_update.py +118 -0
- vibe/acp/utils.py +213 -0
- vibe/cli/__init__.py +0 -0
- vibe/cli/autocompletion/__init__.py +0 -0
- vibe/cli/autocompletion/base.py +22 -0
- vibe/cli/autocompletion/path_completion.py +177 -0
- vibe/cli/autocompletion/slash_command.py +99 -0
- vibe/cli/cli.py +188 -0
- vibe/cli/clipboard.py +69 -0
- vibe/cli/commands.py +116 -0
- vibe/cli/entrypoint.py +163 -0
- vibe/cli/history_manager.py +91 -0
- vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
- vibe/cli/plan_offer/decide_plan_offer.py +87 -0
- vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
- vibe/cli/terminal_setup.py +323 -0
- vibe/cli/textual_ui/__init__.py +0 -0
- vibe/cli/textual_ui/ansi_markdown.py +58 -0
- vibe/cli/textual_ui/app.py +1546 -0
- vibe/cli/textual_ui/app.tcss +1020 -0
- vibe/cli/textual_ui/external_editor.py +32 -0
- vibe/cli/textual_ui/handlers/__init__.py +5 -0
- vibe/cli/textual_ui/handlers/event_handler.py +147 -0
- vibe/cli/textual_ui/widgets/__init__.py +0 -0
- vibe/cli/textual_ui/widgets/approval_app.py +192 -0
- vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
- vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
- vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
- vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
- vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
- vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
- vibe/cli/textual_ui/widgets/compact.py +41 -0
- vibe/cli/textual_ui/widgets/config_app.py +171 -0
- vibe/cli/textual_ui/widgets/context_progress.py +30 -0
- vibe/cli/textual_ui/widgets/load_more.py +43 -0
- vibe/cli/textual_ui/widgets/loading.py +201 -0
- vibe/cli/textual_ui/widgets/messages.py +277 -0
- vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
- vibe/cli/textual_ui/widgets/path_display.py +28 -0
- vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
- vibe/cli/textual_ui/widgets/question_app.py +496 -0
- vibe/cli/textual_ui/widgets/spinner.py +194 -0
- vibe/cli/textual_ui/widgets/status_message.py +76 -0
- vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
- vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
- vibe/cli/textual_ui/widgets/tools.py +201 -0
- vibe/cli/textual_ui/windowing/__init__.py +29 -0
- vibe/cli/textual_ui/windowing/history.py +105 -0
- vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
- vibe/cli/textual_ui/windowing/state.py +105 -0
- vibe/cli/update_notifier/__init__.py +47 -0
- vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
- vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
- vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
- vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
- vibe/cli/update_notifier/ports/update_gateway.py +53 -0
- vibe/cli/update_notifier/update.py +139 -0
- vibe/cli/update_notifier/whats_new.py +49 -0
- vibe/core/__init__.py +5 -0
- vibe/core/agent_loop.py +1075 -0
- vibe/core/agents/__init__.py +31 -0
- vibe/core/agents/manager.py +165 -0
- vibe/core/agents/models.py +122 -0
- vibe/core/auth/__init__.py +6 -0
- vibe/core/auth/crypto.py +137 -0
- vibe/core/auth/github.py +178 -0
- vibe/core/autocompletion/__init__.py +0 -0
- vibe/core/autocompletion/completers.py +257 -0
- vibe/core/autocompletion/file_indexer/__init__.py +10 -0
- vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
- vibe/core/autocompletion/file_indexer/indexer.py +179 -0
- vibe/core/autocompletion/file_indexer/store.py +169 -0
- vibe/core/autocompletion/file_indexer/watcher.py +71 -0
- vibe/core/autocompletion/fuzzy.py +189 -0
- vibe/core/autocompletion/path_prompt.py +108 -0
- vibe/core/autocompletion/path_prompt_adapter.py +149 -0
- vibe/core/config.py +673 -0
- vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
- vibe/core/llm/__init__.py +0 -0
- vibe/core/llm/backend/anthropic.py +630 -0
- vibe/core/llm/backend/base.py +38 -0
- vibe/core/llm/backend/factory.py +7 -0
- vibe/core/llm/backend/generic.py +425 -0
- vibe/core/llm/backend/mistral.py +381 -0
- vibe/core/llm/backend/vertex.py +115 -0
- vibe/core/llm/exceptions.py +195 -0
- vibe/core/llm/format.py +184 -0
- vibe/core/llm/message_utils.py +24 -0
- vibe/core/llm/types.py +120 -0
- vibe/core/middleware.py +209 -0
- vibe/core/output_formatters.py +85 -0
- vibe/core/paths/__init__.py +0 -0
- vibe/core/paths/config_paths.py +68 -0
- vibe/core/paths/global_paths.py +40 -0
- vibe/core/programmatic.py +56 -0
- vibe/core/prompts/__init__.py +32 -0
- vibe/core/prompts/cli.md +111 -0
- vibe/core/prompts/compact.md +48 -0
- vibe/core/prompts/dangerous_directory.md +5 -0
- vibe/core/prompts/explore.md +50 -0
- vibe/core/prompts/project_context.md +8 -0
- vibe/core/prompts/tests.md +1 -0
- vibe/core/proxy_setup.py +65 -0
- vibe/core/session/session_loader.py +222 -0
- vibe/core/session/session_logger.py +318 -0
- vibe/core/session/session_migration.py +41 -0
- vibe/core/skills/__init__.py +7 -0
- vibe/core/skills/manager.py +132 -0
- vibe/core/skills/models.py +92 -0
- vibe/core/skills/parser.py +39 -0
- vibe/core/system_prompt.py +466 -0
- vibe/core/telemetry/__init__.py +0 -0
- vibe/core/telemetry/send.py +185 -0
- vibe/core/teleport/errors.py +9 -0
- vibe/core/teleport/git.py +196 -0
- vibe/core/teleport/nuage.py +180 -0
- vibe/core/teleport/teleport.py +208 -0
- vibe/core/teleport/types.py +54 -0
- vibe/core/tools/base.py +336 -0
- vibe/core/tools/builtins/ask_user_question.py +134 -0
- vibe/core/tools/builtins/bash.py +357 -0
- vibe/core/tools/builtins/grep.py +310 -0
- vibe/core/tools/builtins/prompts/__init__.py +0 -0
- vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
- vibe/core/tools/builtins/prompts/bash.md +73 -0
- vibe/core/tools/builtins/prompts/grep.md +4 -0
- vibe/core/tools/builtins/prompts/read_file.md +13 -0
- vibe/core/tools/builtins/prompts/search_replace.md +43 -0
- vibe/core/tools/builtins/prompts/task.md +24 -0
- vibe/core/tools/builtins/prompts/todo.md +199 -0
- vibe/core/tools/builtins/prompts/write_file.md +42 -0
- vibe/core/tools/builtins/read_file.py +222 -0
- vibe/core/tools/builtins/search_replace.py +456 -0
- vibe/core/tools/builtins/task.py +154 -0
- vibe/core/tools/builtins/todo.py +134 -0
- vibe/core/tools/builtins/write_file.py +160 -0
- vibe/core/tools/manager.py +341 -0
- vibe/core/tools/mcp.py +397 -0
- vibe/core/tools/ui.py +68 -0
- vibe/core/trusted_folders.py +86 -0
- vibe/core/types.py +405 -0
- vibe/core/utils.py +396 -0
- vibe/setup/onboarding/__init__.py +39 -0
- vibe/setup/onboarding/base.py +14 -0
- vibe/setup/onboarding/onboarding.tcss +134 -0
- vibe/setup/onboarding/screens/__init__.py +5 -0
- vibe/setup/onboarding/screens/api_key.py +200 -0
- vibe/setup/onboarding/screens/provider_selection.py +87 -0
- vibe/setup/onboarding/screens/welcome.py +136 -0
- vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
- vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
- 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)
|