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,357 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
from typing import ClassVar, Literal, final
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
from tree_sitter import Language, Node, Parser
|
|
13
|
+
import tree_sitter_bash as tsbash
|
|
14
|
+
|
|
15
|
+
from vibe.core.tools.base import (
|
|
16
|
+
BaseTool,
|
|
17
|
+
BaseToolConfig,
|
|
18
|
+
BaseToolState,
|
|
19
|
+
InvokeContext,
|
|
20
|
+
ToolError,
|
|
21
|
+
ToolPermission,
|
|
22
|
+
)
|
|
23
|
+
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
|
24
|
+
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
|
|
25
|
+
from vibe.core.utils import is_windows
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@lru_cache(maxsize=1)
|
|
29
|
+
def _get_parser() -> Parser:
|
|
30
|
+
return Parser(Language(tsbash.language()))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _extract_commands(command: str) -> list[str]:
|
|
34
|
+
parser = _get_parser()
|
|
35
|
+
tree = parser.parse(command.encode("utf-8"))
|
|
36
|
+
|
|
37
|
+
commands: list[str] = []
|
|
38
|
+
|
|
39
|
+
def find_commands(node: Node) -> None:
|
|
40
|
+
if node.type == "command":
|
|
41
|
+
parts = []
|
|
42
|
+
for child in node.children:
|
|
43
|
+
if (
|
|
44
|
+
child.type
|
|
45
|
+
in {"command_name", "word", "string", "raw_string", "concatenation"}
|
|
46
|
+
and child.text is not None
|
|
47
|
+
):
|
|
48
|
+
parts.append(child.text.decode("utf-8"))
|
|
49
|
+
if parts:
|
|
50
|
+
commands.append(" ".join(parts))
|
|
51
|
+
|
|
52
|
+
for child in node.children:
|
|
53
|
+
find_commands(child)
|
|
54
|
+
|
|
55
|
+
find_commands(tree.root_node)
|
|
56
|
+
return commands
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_subprocess_encoding() -> str:
|
|
60
|
+
if sys.platform == "win32":
|
|
61
|
+
# Windows console uses OEM code page (e.g., cp850, cp1252)
|
|
62
|
+
import ctypes
|
|
63
|
+
|
|
64
|
+
return f"cp{ctypes.windll.kernel32.GetOEMCP()}"
|
|
65
|
+
return "utf-8"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_shell_executable() -> str | None:
|
|
69
|
+
if is_windows():
|
|
70
|
+
return None
|
|
71
|
+
return os.environ.get("SHELL")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _get_base_env() -> dict[str, str]:
|
|
75
|
+
base_env = {
|
|
76
|
+
**os.environ,
|
|
77
|
+
"CI": "true",
|
|
78
|
+
"NONINTERACTIVE": "1",
|
|
79
|
+
"NO_TTY": "1",
|
|
80
|
+
"NO_COLOR": "1",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if is_windows():
|
|
84
|
+
base_env["GIT_PAGER"] = "more"
|
|
85
|
+
base_env["PAGER"] = "more"
|
|
86
|
+
else:
|
|
87
|
+
base_env["TERM"] = "dumb"
|
|
88
|
+
base_env["DEBIAN_FRONTEND"] = "noninteractive"
|
|
89
|
+
base_env["GIT_PAGER"] = "cat"
|
|
90
|
+
base_env["PAGER"] = "cat"
|
|
91
|
+
base_env["LESS"] = "-FX"
|
|
92
|
+
base_env["LC_ALL"] = "en_US.UTF-8"
|
|
93
|
+
|
|
94
|
+
return base_env
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _kill_process_tree(proc: asyncio.subprocess.Process) -> None:
|
|
98
|
+
if proc.returncode is not None:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
if sys.platform == "win32":
|
|
103
|
+
try:
|
|
104
|
+
subprocess_proc = await asyncio.create_subprocess_exec(
|
|
105
|
+
"taskkill",
|
|
106
|
+
"/F",
|
|
107
|
+
"/T",
|
|
108
|
+
"/PID",
|
|
109
|
+
str(proc.pid),
|
|
110
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
111
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
112
|
+
)
|
|
113
|
+
await subprocess_proc.wait()
|
|
114
|
+
except (FileNotFoundError, OSError):
|
|
115
|
+
proc.terminate()
|
|
116
|
+
else:
|
|
117
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
118
|
+
|
|
119
|
+
await proc.wait()
|
|
120
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _get_default_allowlist() -> list[str]:
|
|
125
|
+
common = ["echo", "find", "git diff", "git log", "git status", "tree", "whoami"]
|
|
126
|
+
|
|
127
|
+
if is_windows():
|
|
128
|
+
return common + ["dir", "findstr", "more", "type", "ver", "where"]
|
|
129
|
+
else:
|
|
130
|
+
return common + [
|
|
131
|
+
"cat",
|
|
132
|
+
"file",
|
|
133
|
+
"head",
|
|
134
|
+
"ls",
|
|
135
|
+
"pwd",
|
|
136
|
+
"stat",
|
|
137
|
+
"tail",
|
|
138
|
+
"uname",
|
|
139
|
+
"wc",
|
|
140
|
+
"which",
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _get_default_denylist() -> list[str]:
|
|
145
|
+
common = ["gdb", "pdb", "passwd"]
|
|
146
|
+
|
|
147
|
+
if is_windows():
|
|
148
|
+
return common + ["cmd /k", "powershell -NoExit", "pwsh -NoExit", "notepad"]
|
|
149
|
+
else:
|
|
150
|
+
return common + [
|
|
151
|
+
"nano",
|
|
152
|
+
"vim",
|
|
153
|
+
"vi",
|
|
154
|
+
"emacs",
|
|
155
|
+
"bash -i",
|
|
156
|
+
"sh -i",
|
|
157
|
+
"zsh -i",
|
|
158
|
+
"fish -i",
|
|
159
|
+
"dash -i",
|
|
160
|
+
"screen",
|
|
161
|
+
"tmux",
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _get_default_denylist_standalone() -> list[str]:
|
|
166
|
+
common = ["python", "python3", "ipython"]
|
|
167
|
+
|
|
168
|
+
if is_windows():
|
|
169
|
+
return common + ["cmd", "powershell", "pwsh", "notepad"]
|
|
170
|
+
else:
|
|
171
|
+
return common + ["bash", "sh", "nohup", "vi", "vim", "emacs", "nano", "su"]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class BashToolConfig(BaseToolConfig):
|
|
175
|
+
permission: ToolPermission = ToolPermission.ASK
|
|
176
|
+
max_output_bytes: int = Field(
|
|
177
|
+
default=16_000, description="Maximum bytes to capture from stdout and stderr."
|
|
178
|
+
)
|
|
179
|
+
default_timeout: int = Field(
|
|
180
|
+
default=300, description="Default timeout for commands in seconds."
|
|
181
|
+
)
|
|
182
|
+
allowlist: list[str] = Field(
|
|
183
|
+
default_factory=_get_default_allowlist,
|
|
184
|
+
description="Command prefixes that are automatically allowed",
|
|
185
|
+
)
|
|
186
|
+
denylist: list[str] = Field(
|
|
187
|
+
default_factory=_get_default_denylist,
|
|
188
|
+
description="Command prefixes that are automatically denied",
|
|
189
|
+
)
|
|
190
|
+
denylist_standalone: list[str] = Field(
|
|
191
|
+
default_factory=_get_default_denylist_standalone,
|
|
192
|
+
description="Commands that are denied only when run without arguments",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class BashArgs(BaseModel):
|
|
197
|
+
command: str
|
|
198
|
+
timeout: int | None = Field(
|
|
199
|
+
default=None, description="Override the default command timeout."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class BashResult(BaseModel):
|
|
204
|
+
command: str
|
|
205
|
+
stdout: str
|
|
206
|
+
stderr: str
|
|
207
|
+
returncode: int
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class Bash(
|
|
211
|
+
BaseTool[BashArgs, BashResult, BashToolConfig, BaseToolState],
|
|
212
|
+
ToolUIData[BashArgs, BashResult],
|
|
213
|
+
):
|
|
214
|
+
description: ClassVar[str] = "Run a one-off bash command and capture its output."
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
|
218
|
+
if not isinstance(event.args, BashArgs):
|
|
219
|
+
return ToolCallDisplay(summary="bash")
|
|
220
|
+
|
|
221
|
+
return ToolCallDisplay(summary=f"bash: {event.args.command}")
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
|
225
|
+
if not isinstance(event.result, BashResult):
|
|
226
|
+
return ToolResultDisplay(
|
|
227
|
+
success=False, message=event.error or event.skip_reason or "No result"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return ToolResultDisplay(success=True, message=f"Ran {event.result.command}")
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def get_status_text(cls) -> str:
|
|
234
|
+
return "Running command"
|
|
235
|
+
|
|
236
|
+
def check_allowlist_denylist(self, args: BashArgs) -> ToolPermission | None:
|
|
237
|
+
if is_windows():
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
command_parts = _extract_commands(args.command)
|
|
241
|
+
if not command_parts:
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
def is_denylisted(command: str) -> bool:
|
|
245
|
+
return any(command.startswith(pattern) for pattern in self.config.denylist)
|
|
246
|
+
|
|
247
|
+
def is_standalone_denylisted(command: str) -> bool:
|
|
248
|
+
parts = command.split()
|
|
249
|
+
if not parts:
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
base_command = parts[0]
|
|
253
|
+
has_args = len(parts) > 1
|
|
254
|
+
|
|
255
|
+
if not has_args:
|
|
256
|
+
command_name = os.path.basename(base_command)
|
|
257
|
+
if command_name in self.config.denylist_standalone:
|
|
258
|
+
return True
|
|
259
|
+
if base_command in self.config.denylist_standalone:
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
def is_allowlisted(command: str) -> bool:
|
|
265
|
+
return any(command.startswith(pattern) for pattern in self.config.allowlist)
|
|
266
|
+
|
|
267
|
+
for part in command_parts:
|
|
268
|
+
if is_denylisted(part):
|
|
269
|
+
return ToolPermission.NEVER
|
|
270
|
+
if is_standalone_denylisted(part):
|
|
271
|
+
return ToolPermission.NEVER
|
|
272
|
+
|
|
273
|
+
if all(is_allowlisted(part) for part in command_parts):
|
|
274
|
+
return ToolPermission.ALWAYS
|
|
275
|
+
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
@final
|
|
279
|
+
def _build_timeout_error(self, command: str, timeout: int) -> ToolError:
|
|
280
|
+
return ToolError(f"Command timed out after {timeout}s: {command!r}")
|
|
281
|
+
|
|
282
|
+
@final
|
|
283
|
+
def _build_result(
|
|
284
|
+
self, *, command: str, stdout: str, stderr: str, returncode: int
|
|
285
|
+
) -> BashResult:
|
|
286
|
+
if returncode != 0:
|
|
287
|
+
error_msg = f"Command failed: {command!r}\n"
|
|
288
|
+
error_msg += f"Return code: {returncode}"
|
|
289
|
+
if stderr:
|
|
290
|
+
error_msg += f"\nStderr: {stderr}"
|
|
291
|
+
if stdout:
|
|
292
|
+
error_msg += f"\nStdout: {stdout}"
|
|
293
|
+
raise ToolError(error_msg.strip())
|
|
294
|
+
|
|
295
|
+
return BashResult(
|
|
296
|
+
command=command, stdout=stdout, stderr=stderr, returncode=returncode
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
async def run(
|
|
300
|
+
self, args: BashArgs, ctx: InvokeContext | None = None
|
|
301
|
+
) -> AsyncGenerator[ToolStreamEvent | BashResult, None]:
|
|
302
|
+
timeout = args.timeout or self.config.default_timeout
|
|
303
|
+
max_bytes = self.config.max_output_bytes
|
|
304
|
+
|
|
305
|
+
proc = None
|
|
306
|
+
try:
|
|
307
|
+
# start_new_session is Unix-only, on Windows it's ignored
|
|
308
|
+
kwargs: dict[Literal["start_new_session"], bool] = (
|
|
309
|
+
{} if is_windows() else {"start_new_session": True}
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
proc = await asyncio.create_subprocess_shell(
|
|
313
|
+
args.command,
|
|
314
|
+
stdout=asyncio.subprocess.PIPE,
|
|
315
|
+
stderr=asyncio.subprocess.PIPE,
|
|
316
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
317
|
+
env=_get_base_env(),
|
|
318
|
+
executable=_get_shell_executable(),
|
|
319
|
+
**kwargs,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
324
|
+
proc.communicate(), timeout=timeout
|
|
325
|
+
)
|
|
326
|
+
except TimeoutError:
|
|
327
|
+
await _kill_process_tree(proc)
|
|
328
|
+
raise self._build_timeout_error(args.command, timeout)
|
|
329
|
+
|
|
330
|
+
encoding = _get_subprocess_encoding()
|
|
331
|
+
stdout = (
|
|
332
|
+
stdout_bytes.decode(encoding, errors="replace")[:max_bytes]
|
|
333
|
+
if stdout_bytes
|
|
334
|
+
else ""
|
|
335
|
+
)
|
|
336
|
+
stderr = (
|
|
337
|
+
stderr_bytes.decode(encoding, errors="replace")[:max_bytes]
|
|
338
|
+
if stderr_bytes
|
|
339
|
+
else ""
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
returncode = proc.returncode or 0
|
|
343
|
+
|
|
344
|
+
yield self._build_result(
|
|
345
|
+
command=args.command,
|
|
346
|
+
stdout=stdout,
|
|
347
|
+
stderr=stderr,
|
|
348
|
+
returncode=returncode,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
except (ToolError, asyncio.CancelledError):
|
|
352
|
+
raise
|
|
353
|
+
except Exception as exc:
|
|
354
|
+
raise ToolError(f"Error running command {args.command!r}: {exc}") from exc
|
|
355
|
+
finally:
|
|
356
|
+
if proc is not None:
|
|
357
|
+
await _kill_process_tree(proc)
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from enum import StrEnum, auto
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from vibe.core.tools.base import (
|
|
13
|
+
BaseTool,
|
|
14
|
+
BaseToolConfig,
|
|
15
|
+
BaseToolState,
|
|
16
|
+
InvokeContext,
|
|
17
|
+
ToolError,
|
|
18
|
+
ToolPermission,
|
|
19
|
+
)
|
|
20
|
+
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
|
21
|
+
from vibe.core.types import ToolStreamEvent
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GrepBackend(StrEnum):
|
|
28
|
+
RIPGREP = auto()
|
|
29
|
+
GNU_GREP = auto()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GrepToolConfig(BaseToolConfig):
|
|
33
|
+
permission: ToolPermission = ToolPermission.ALWAYS
|
|
34
|
+
|
|
35
|
+
max_output_bytes: int = Field(
|
|
36
|
+
default=64_000, description="Hard cap for the total size of matched lines."
|
|
37
|
+
)
|
|
38
|
+
default_max_matches: int = Field(
|
|
39
|
+
default=100, description="Default maximum number of matches to return."
|
|
40
|
+
)
|
|
41
|
+
default_timeout: int = Field(
|
|
42
|
+
default=60, description="Default timeout for the search command in seconds."
|
|
43
|
+
)
|
|
44
|
+
exclude_patterns: list[str] = Field(
|
|
45
|
+
default=[
|
|
46
|
+
".venv/",
|
|
47
|
+
"venv/",
|
|
48
|
+
".env/",
|
|
49
|
+
"env/",
|
|
50
|
+
"node_modules/",
|
|
51
|
+
".git/",
|
|
52
|
+
"__pycache__/",
|
|
53
|
+
".pytest_cache/",
|
|
54
|
+
".mypy_cache/",
|
|
55
|
+
".tox/",
|
|
56
|
+
".nox/",
|
|
57
|
+
".coverage/",
|
|
58
|
+
"htmlcov/",
|
|
59
|
+
"dist/",
|
|
60
|
+
"build/",
|
|
61
|
+
".idea/",
|
|
62
|
+
".vscode/",
|
|
63
|
+
"*.egg-info",
|
|
64
|
+
"*.pyc",
|
|
65
|
+
"*.pyo",
|
|
66
|
+
"*.pyd",
|
|
67
|
+
".DS_Store",
|
|
68
|
+
"Thumbs.db",
|
|
69
|
+
],
|
|
70
|
+
description="List of glob patterns to exclude from search (dirs should end with /).",
|
|
71
|
+
)
|
|
72
|
+
codeignore_file: str = Field(
|
|
73
|
+
default=".vibeignore",
|
|
74
|
+
description="Name of the file to read for additional exclusion patterns.",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class GrepState(BaseToolState):
|
|
79
|
+
search_history: list[str] = Field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class GrepArgs(BaseModel):
|
|
83
|
+
pattern: str
|
|
84
|
+
path: str = "."
|
|
85
|
+
max_matches: int | None = Field(
|
|
86
|
+
default=None, description="Override the default maximum number of matches."
|
|
87
|
+
)
|
|
88
|
+
use_default_ignore: bool = Field(
|
|
89
|
+
default=True, description="Whether to respect .gitignore and .ignore files."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class GrepResult(BaseModel):
|
|
94
|
+
matches: str
|
|
95
|
+
match_count: int
|
|
96
|
+
was_truncated: bool = Field(
|
|
97
|
+
description="True if output was cut short by max_matches or max_output_bytes."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Grep(
|
|
102
|
+
BaseTool[GrepArgs, GrepResult, GrepToolConfig, GrepState],
|
|
103
|
+
ToolUIData[GrepArgs, GrepResult],
|
|
104
|
+
):
|
|
105
|
+
description: ClassVar[str] = (
|
|
106
|
+
"Recursively search files for a regex pattern using ripgrep (rg) or grep. "
|
|
107
|
+
"Respects .gitignore and .codeignore files by default when using ripgrep."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def _detect_backend(self) -> GrepBackend:
|
|
111
|
+
if shutil.which("rg"):
|
|
112
|
+
return GrepBackend.RIPGREP
|
|
113
|
+
if shutil.which("grep"):
|
|
114
|
+
return GrepBackend.GNU_GREP
|
|
115
|
+
raise ToolError(
|
|
116
|
+
"Neither ripgrep (rg) nor grep is installed. "
|
|
117
|
+
"Please install ripgrep: https://github.com/BurntSushi/ripgrep#installation"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
async def run(
|
|
121
|
+
self, args: GrepArgs, ctx: InvokeContext | None = None
|
|
122
|
+
) -> AsyncGenerator[ToolStreamEvent | GrepResult, None]:
|
|
123
|
+
backend = self._detect_backend()
|
|
124
|
+
self._validate_args(args)
|
|
125
|
+
self.state.search_history.append(args.pattern)
|
|
126
|
+
|
|
127
|
+
exclude_patterns = self._collect_exclude_patterns()
|
|
128
|
+
cmd = self._build_command(args, exclude_patterns, backend)
|
|
129
|
+
stdout = await self._execute_search(cmd)
|
|
130
|
+
|
|
131
|
+
yield self._parse_output(
|
|
132
|
+
stdout, args.max_matches or self.config.default_max_matches
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _validate_args(self, args: GrepArgs) -> None:
|
|
136
|
+
if not args.pattern.strip():
|
|
137
|
+
raise ToolError("Empty search pattern provided.")
|
|
138
|
+
|
|
139
|
+
path_obj = Path(args.path).expanduser()
|
|
140
|
+
if not path_obj.is_absolute():
|
|
141
|
+
path_obj = Path.cwd() / path_obj
|
|
142
|
+
|
|
143
|
+
if not path_obj.exists():
|
|
144
|
+
raise ToolError(f"Path does not exist: {args.path}")
|
|
145
|
+
|
|
146
|
+
def _collect_exclude_patterns(self) -> list[str]:
|
|
147
|
+
patterns = list(self.config.exclude_patterns)
|
|
148
|
+
|
|
149
|
+
codeignore_path = Path.cwd() / self.config.codeignore_file
|
|
150
|
+
if codeignore_path.is_file():
|
|
151
|
+
patterns.extend(self._load_codeignore_patterns(codeignore_path))
|
|
152
|
+
|
|
153
|
+
return patterns
|
|
154
|
+
|
|
155
|
+
def _load_codeignore_patterns(self, codeignore_path: Path) -> list[str]:
|
|
156
|
+
patterns = []
|
|
157
|
+
try:
|
|
158
|
+
content = codeignore_path.read_text("utf-8")
|
|
159
|
+
for line in content.splitlines():
|
|
160
|
+
line = line.strip()
|
|
161
|
+
if line and not line.startswith("#"):
|
|
162
|
+
patterns.append(line)
|
|
163
|
+
except OSError:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
return patterns
|
|
167
|
+
|
|
168
|
+
def _build_command(
|
|
169
|
+
self, args: GrepArgs, exclude_patterns: list[str], backend: GrepBackend
|
|
170
|
+
) -> list[str]:
|
|
171
|
+
if backend == GrepBackend.RIPGREP:
|
|
172
|
+
return self._build_ripgrep_command(args, exclude_patterns)
|
|
173
|
+
return self._build_gnu_grep_command(args, exclude_patterns)
|
|
174
|
+
|
|
175
|
+
def _build_ripgrep_command(
|
|
176
|
+
self, args: GrepArgs, exclude_patterns: list[str]
|
|
177
|
+
) -> list[str]:
|
|
178
|
+
max_matches = args.max_matches or self.config.default_max_matches
|
|
179
|
+
|
|
180
|
+
cmd = [
|
|
181
|
+
"rg",
|
|
182
|
+
"--line-number",
|
|
183
|
+
"--no-heading",
|
|
184
|
+
"--smart-case",
|
|
185
|
+
"--no-binary",
|
|
186
|
+
# Request one extra to detect truncation
|
|
187
|
+
"--max-count",
|
|
188
|
+
str(max_matches + 1),
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
if not args.use_default_ignore:
|
|
192
|
+
cmd.append("--no-ignore")
|
|
193
|
+
|
|
194
|
+
for pattern in exclude_patterns:
|
|
195
|
+
cmd.extend(["--glob", f"!{pattern}"])
|
|
196
|
+
|
|
197
|
+
cmd.extend(["-e", args.pattern, args.path])
|
|
198
|
+
|
|
199
|
+
return cmd
|
|
200
|
+
|
|
201
|
+
def _build_gnu_grep_command(
|
|
202
|
+
self, args: GrepArgs, exclude_patterns: list[str]
|
|
203
|
+
) -> list[str]:
|
|
204
|
+
max_matches = args.max_matches or self.config.default_max_matches
|
|
205
|
+
|
|
206
|
+
cmd = ["grep", "-r", "-n", "-I", "-E", f"--max-count={max_matches + 1}"]
|
|
207
|
+
|
|
208
|
+
if args.pattern.islower():
|
|
209
|
+
cmd.append("-i")
|
|
210
|
+
|
|
211
|
+
for pattern in exclude_patterns:
|
|
212
|
+
if pattern.endswith("/"):
|
|
213
|
+
dir_pattern = pattern.rstrip("/")
|
|
214
|
+
cmd.append(f"--exclude-dir={dir_pattern}")
|
|
215
|
+
else:
|
|
216
|
+
cmd.append(f"--exclude={pattern}")
|
|
217
|
+
|
|
218
|
+
cmd.extend(["-e", args.pattern, args.path])
|
|
219
|
+
|
|
220
|
+
return cmd
|
|
221
|
+
|
|
222
|
+
async def _execute_search(self, cmd: list[str]) -> str:
|
|
223
|
+
try:
|
|
224
|
+
proc = await asyncio.create_subprocess_exec(
|
|
225
|
+
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
230
|
+
proc.communicate(), timeout=self.config.default_timeout
|
|
231
|
+
)
|
|
232
|
+
except TimeoutError:
|
|
233
|
+
proc.kill()
|
|
234
|
+
await proc.wait()
|
|
235
|
+
raise ToolError(
|
|
236
|
+
f"Search timed out after {self.config.default_timeout}s"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
stdout = (
|
|
240
|
+
stdout_bytes.decode("utf-8", errors="ignore") if stdout_bytes else ""
|
|
241
|
+
)
|
|
242
|
+
stderr = (
|
|
243
|
+
stderr_bytes.decode("utf-8", errors="ignore") if stderr_bytes else ""
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if proc.returncode not in {0, 1}:
|
|
247
|
+
error_msg = stderr or f"Process exited with code {proc.returncode}"
|
|
248
|
+
raise ToolError(f"grep error: {error_msg}")
|
|
249
|
+
|
|
250
|
+
return stdout
|
|
251
|
+
|
|
252
|
+
except ToolError:
|
|
253
|
+
raise
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
raise ToolError(f"Error running grep: {exc}") from exc
|
|
256
|
+
|
|
257
|
+
def _parse_output(self, stdout: str, max_matches: int) -> GrepResult:
|
|
258
|
+
output_lines = stdout.splitlines() if stdout else []
|
|
259
|
+
|
|
260
|
+
truncated_lines = output_lines[:max_matches]
|
|
261
|
+
truncated_output = "\n".join(truncated_lines)
|
|
262
|
+
|
|
263
|
+
was_truncated = (
|
|
264
|
+
len(output_lines) > max_matches
|
|
265
|
+
or len(truncated_output) > self.config.max_output_bytes
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
final_output = truncated_output[: self.config.max_output_bytes]
|
|
269
|
+
|
|
270
|
+
return GrepResult(
|
|
271
|
+
matches=final_output,
|
|
272
|
+
match_count=len(truncated_lines),
|
|
273
|
+
was_truncated=was_truncated,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
@classmethod
|
|
277
|
+
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
|
278
|
+
if not isinstance(event.args, GrepArgs):
|
|
279
|
+
return ToolCallDisplay(summary="grep")
|
|
280
|
+
|
|
281
|
+
summary = f"Grepping '{event.args.pattern}'"
|
|
282
|
+
if event.args.path != ".":
|
|
283
|
+
summary += f" in {event.args.path}"
|
|
284
|
+
if event.args.max_matches:
|
|
285
|
+
summary += f" (max {event.args.max_matches} matches)"
|
|
286
|
+
if not event.args.use_default_ignore:
|
|
287
|
+
summary += " [no-ignore]"
|
|
288
|
+
|
|
289
|
+
return ToolCallDisplay(summary=summary)
|
|
290
|
+
|
|
291
|
+
@classmethod
|
|
292
|
+
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
|
293
|
+
if not isinstance(event.result, GrepResult):
|
|
294
|
+
return ToolResultDisplay(
|
|
295
|
+
success=False, message=event.error or event.skip_reason or "No result"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
message = f"Found {event.result.match_count} matches"
|
|
299
|
+
if event.result.was_truncated:
|
|
300
|
+
message += " (truncated)"
|
|
301
|
+
|
|
302
|
+
warnings = []
|
|
303
|
+
if event.result.was_truncated:
|
|
304
|
+
warnings.append("Output was truncated due to size/match limits")
|
|
305
|
+
|
|
306
|
+
return ToolResultDisplay(success=True, message=message, warnings=warnings)
|
|
307
|
+
|
|
308
|
+
@classmethod
|
|
309
|
+
def get_status_text(cls) -> str:
|
|
310
|
+
return "Searching files"
|
|
File without changes
|