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,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