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
vibe/core/tools/mcp.py ADDED
@@ -0,0 +1,397 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncGenerator
4
+ import contextlib
5
+ from datetime import timedelta
6
+ import hashlib
7
+ from logging import getLogger
8
+ import os
9
+ from pathlib import Path
10
+ import threading
11
+ from typing import TYPE_CHECKING, Any, ClassVar, TextIO
12
+
13
+ from mcp import ClientSession
14
+ from mcp.client.stdio import StdioServerParameters, stdio_client
15
+ from mcp.client.streamable_http import streamablehttp_client
16
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
17
+
18
+ from vibe.core.tools.base import (
19
+ BaseTool,
20
+ BaseToolConfig,
21
+ BaseToolState,
22
+ InvokeContext,
23
+ ToolError,
24
+ )
25
+ from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay
26
+ from vibe.core.types import ToolStreamEvent
27
+
28
+ if TYPE_CHECKING:
29
+ from vibe.core.types import ToolCallEvent, ToolResultEvent
30
+
31
+ logger = getLogger("vibe")
32
+
33
+
34
+ def _stderr_logger_thread(read_fd: int) -> None:
35
+ with open(read_fd, "rb") as f:
36
+ for line in iter(f.readline, b""):
37
+ decoded = line.decode("utf-8", errors="replace").rstrip()
38
+ if decoded:
39
+ logger.debug(f"[MCP stderr] {decoded}")
40
+
41
+
42
+ @contextlib.asynccontextmanager
43
+ async def _mcp_stderr_capture() -> AsyncGenerator[TextIO, None]:
44
+ r, w = os.pipe()
45
+ errlog = None
46
+ thread_started = False
47
+ try:
48
+ thread = threading.Thread(target=_stderr_logger_thread, args=(r,), daemon=True)
49
+ thread.start()
50
+ thread_started = True
51
+ errlog = os.fdopen(w, "w")
52
+ yield errlog
53
+ finally:
54
+ if errlog is not None:
55
+ errlog.close()
56
+ elif thread_started:
57
+ os.close(w)
58
+ else:
59
+ os.close(r)
60
+ os.close(w)
61
+
62
+
63
+ class _OpenArgs(BaseModel):
64
+ model_config = ConfigDict(extra="allow")
65
+
66
+
67
+ class MCPToolResult(BaseModel):
68
+ ok: bool = True
69
+ server: str
70
+ tool: str
71
+ text: str | None = None
72
+ structured: dict[str, Any] | None = None
73
+
74
+
75
+ class RemoteTool(BaseModel):
76
+ model_config = ConfigDict(from_attributes=True)
77
+
78
+ name: str
79
+ description: str | None = None
80
+ input_schema: dict[str, Any] = Field(
81
+ default_factory=lambda: {"type": "object", "properties": {}},
82
+ validation_alias="inputSchema",
83
+ )
84
+
85
+ @field_validator("name")
86
+ @classmethod
87
+ def _non_empty_name(cls, v: str) -> str:
88
+ if not isinstance(v, str) or not v.strip():
89
+ raise ValueError("MCP tool missing valid 'name'")
90
+ return v
91
+
92
+ @field_validator("input_schema", mode="before")
93
+ @classmethod
94
+ def _normalize_schema(cls, v: Any) -> dict[str, Any]:
95
+ if v is None:
96
+ return {"type": "object", "properties": {}}
97
+ if isinstance(v, dict):
98
+ return v
99
+ dump = getattr(v, "model_dump", None)
100
+ if callable(dump):
101
+ try:
102
+ v = dump()
103
+ except Exception:
104
+ raise ValueError(
105
+ "inputSchema must be a dict or have a valid model_dump method"
106
+ )
107
+ if not isinstance(v, dict):
108
+ raise ValueError("inputSchema must be a dict")
109
+ return v
110
+
111
+
112
+ class _MCPContentBlock(BaseModel):
113
+ model_config = ConfigDict(from_attributes=True)
114
+ text: str | None = None
115
+
116
+
117
+ class _MCPResultIn(BaseModel):
118
+ model_config = ConfigDict(from_attributes=True)
119
+
120
+ structuredContent: dict[str, Any] | None = None
121
+ content: list[_MCPContentBlock] | None = None
122
+
123
+ @field_validator("structuredContent", mode="before")
124
+ @classmethod
125
+ def _normalize_structured(cls, v: Any) -> dict[str, Any] | None:
126
+ if v is None:
127
+ return None
128
+ if isinstance(v, dict):
129
+ return v
130
+ dump = getattr(v, "model_dump", None)
131
+ if callable(dump):
132
+ try:
133
+ v = dump()
134
+ except Exception:
135
+ return None
136
+ return v if isinstance(v, dict) else None
137
+
138
+
139
+ def _parse_call_result(server: str, tool: str, result_obj: Any) -> MCPToolResult:
140
+ parsed = _MCPResultIn.model_validate(result_obj)
141
+ if (structured := parsed.structuredContent) is not None:
142
+ return MCPToolResult(server=server, tool=tool, text=None, structured=structured)
143
+
144
+ blocks = parsed.content or []
145
+ parts = [b.text for b in blocks if isinstance(b.text, str)]
146
+ text = "\n".join(parts) if parts else None
147
+ return MCPToolResult(server=server, tool=tool, text=text, structured=None)
148
+
149
+
150
+ async def list_tools_http(
151
+ url: str,
152
+ *,
153
+ headers: dict[str, str] | None = None,
154
+ startup_timeout_sec: float | None = None,
155
+ ) -> list[RemoteTool]:
156
+ timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
157
+ async with streamablehttp_client(url, headers=headers) as (read, write, _):
158
+ async with ClientSession(read, write, read_timeout_seconds=timeout) as session:
159
+ await session.initialize()
160
+ tools_resp = await session.list_tools()
161
+ return [RemoteTool.model_validate(t) for t in tools_resp.tools]
162
+
163
+
164
+ async def call_tool_http(
165
+ url: str,
166
+ tool_name: str,
167
+ arguments: dict[str, Any],
168
+ *,
169
+ headers: dict[str, str] | None = None,
170
+ startup_timeout_sec: float | None = None,
171
+ tool_timeout_sec: float | None = None,
172
+ ) -> MCPToolResult:
173
+ init_timeout = (
174
+ timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
175
+ )
176
+ call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
177
+ async with streamablehttp_client(url, headers=headers) as (read, write, _):
178
+ async with ClientSession(
179
+ read, write, read_timeout_seconds=init_timeout
180
+ ) as session:
181
+ await session.initialize()
182
+ result = await session.call_tool(
183
+ tool_name, arguments, read_timeout_seconds=call_timeout
184
+ )
185
+ return _parse_call_result(url, tool_name, result)
186
+
187
+
188
+ def create_mcp_http_proxy_tool_class(
189
+ *,
190
+ url: str,
191
+ remote: RemoteTool,
192
+ alias: str | None = None,
193
+ server_hint: str | None = None,
194
+ headers: dict[str, str] | None = None,
195
+ startup_timeout_sec: float | None = None,
196
+ tool_timeout_sec: float | None = None,
197
+ ) -> type[BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]]:
198
+ from urllib.parse import urlparse
199
+
200
+ def _alias_from_url(url: str) -> str:
201
+ p = urlparse(url)
202
+ host = (p.hostname or "mcp").replace(".", "_")
203
+ port = f"_{p.port}" if p.port else ""
204
+ return f"{host}{port}"
205
+
206
+ published_name = f"{(alias or _alias_from_url(url))}_{remote.name}"
207
+
208
+ class MCPHttpProxyTool(
209
+ BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]
210
+ ):
211
+ description: ClassVar[str] = (
212
+ (f"[{alias}] " if alias else "")
213
+ + (remote.description or f"MCP tool '{remote.name}' from {url}")
214
+ + (f"\nHint: {server_hint}" if server_hint else "")
215
+ )
216
+ _mcp_url: ClassVar[str] = url
217
+ _remote_name: ClassVar[str] = remote.name
218
+ _input_schema: ClassVar[dict[str, Any]] = remote.input_schema
219
+ _headers: ClassVar[dict[str, str]] = dict(headers or {})
220
+ _startup_timeout_sec: ClassVar[float | None] = startup_timeout_sec
221
+ _tool_timeout_sec: ClassVar[float | None] = tool_timeout_sec
222
+
223
+ @classmethod
224
+ def get_name(cls) -> str:
225
+ return published_name
226
+
227
+ @classmethod
228
+ def get_parameters(cls) -> dict[str, Any]:
229
+ return dict(cls._input_schema)
230
+
231
+ async def run(
232
+ self, args: _OpenArgs, ctx: InvokeContext | None = None
233
+ ) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
234
+ try:
235
+ payload = args.model_dump(exclude_none=True)
236
+ yield await call_tool_http(
237
+ self._mcp_url,
238
+ self._remote_name,
239
+ payload,
240
+ headers=self._headers,
241
+ startup_timeout_sec=self._startup_timeout_sec,
242
+ tool_timeout_sec=self._tool_timeout_sec,
243
+ )
244
+ except Exception as exc:
245
+ raise ToolError(f"MCP call failed: {exc}") from exc
246
+
247
+ @classmethod
248
+ def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
249
+ return ToolCallDisplay(summary=f"{published_name}")
250
+
251
+ @classmethod
252
+ def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
253
+ if not isinstance(event.result, MCPToolResult):
254
+ return ToolResultDisplay(
255
+ success=False,
256
+ message=event.error or event.skip_reason or "No result",
257
+ )
258
+
259
+ message = f"MCP tool {event.result.tool} completed"
260
+ return ToolResultDisplay(success=event.result.ok, message=message)
261
+
262
+ @classmethod
263
+ def get_status_text(cls) -> str:
264
+ return f"Calling MCP tool {remote.name}"
265
+
266
+ MCPHttpProxyTool.__name__ = f"MCP_{(alias or _alias_from_url(url))}__{remote.name}"
267
+ return MCPHttpProxyTool
268
+
269
+
270
+ async def list_tools_stdio(
271
+ command: list[str],
272
+ *,
273
+ env: dict[str, str] | None = None,
274
+ startup_timeout_sec: float | None = None,
275
+ ) -> list[RemoteTool]:
276
+ params = StdioServerParameters(command=command[0], args=command[1:], env=env)
277
+ timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
278
+ async with (
279
+ _mcp_stderr_capture() as errlog,
280
+ stdio_client(params, errlog=errlog) as (read, write),
281
+ ClientSession(read, write, read_timeout_seconds=timeout) as session,
282
+ ):
283
+ await session.initialize()
284
+ tools_resp = await session.list_tools()
285
+ return [RemoteTool.model_validate(t) for t in tools_resp.tools]
286
+
287
+
288
+ async def call_tool_stdio(
289
+ command: list[str],
290
+ tool_name: str,
291
+ arguments: dict[str, Any],
292
+ *,
293
+ env: dict[str, str] | None = None,
294
+ startup_timeout_sec: float | None = None,
295
+ tool_timeout_sec: float | None = None,
296
+ ) -> MCPToolResult:
297
+ params = StdioServerParameters(command=command[0], args=command[1:], env=env)
298
+ init_timeout = (
299
+ timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
300
+ )
301
+ call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
302
+ async with (
303
+ _mcp_stderr_capture() as errlog,
304
+ stdio_client(params, errlog=errlog) as (read, write),
305
+ ClientSession(read, write, read_timeout_seconds=init_timeout) as session,
306
+ ):
307
+ await session.initialize()
308
+ result = await session.call_tool(
309
+ tool_name, arguments, read_timeout_seconds=call_timeout
310
+ )
311
+ return _parse_call_result("stdio:" + " ".join(command), tool_name, result)
312
+
313
+
314
+ def create_mcp_stdio_proxy_tool_class(
315
+ *,
316
+ command: list[str],
317
+ remote: RemoteTool,
318
+ alias: str | None = None,
319
+ server_hint: str | None = None,
320
+ env: dict[str, str] | None = None,
321
+ startup_timeout_sec: float | None = None,
322
+ tool_timeout_sec: float | None = None,
323
+ ) -> type[BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]]:
324
+ def _alias_from_command(cmd: list[str]) -> str:
325
+ prog = Path(cmd[0]).name.replace(".", "_") if cmd else "mcp"
326
+ digest = hashlib.blake2s(
327
+ "\0".join(cmd).encode("utf-8"), digest_size=4
328
+ ).hexdigest()
329
+ return f"{prog}_{digest}"
330
+
331
+ computed_alias = alias or _alias_from_command(command)
332
+ published_name = f"{computed_alias}_{remote.name}"
333
+
334
+ class MCPStdioProxyTool(
335
+ BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]
336
+ ):
337
+ description: ClassVar[str] = (
338
+ (f"[{computed_alias}] " if computed_alias else "")
339
+ + (
340
+ remote.description
341
+ or f"MCP tool '{remote.name}' from stdio command: {' '.join(command)}"
342
+ )
343
+ + (f"\nHint: {server_hint}" if server_hint else "")
344
+ )
345
+ _stdio_command: ClassVar[list[str]] = command
346
+ _remote_name: ClassVar[str] = remote.name
347
+ _input_schema: ClassVar[dict[str, Any]] = remote.input_schema
348
+ _env: ClassVar[dict[str, str] | None] = env
349
+ _startup_timeout_sec: ClassVar[float | None] = startup_timeout_sec
350
+ _tool_timeout_sec: ClassVar[float | None] = tool_timeout_sec
351
+
352
+ @classmethod
353
+ def get_name(cls) -> str:
354
+ return published_name
355
+
356
+ @classmethod
357
+ def get_parameters(cls) -> dict[str, Any]:
358
+ return dict(cls._input_schema)
359
+
360
+ async def run(
361
+ self, args: _OpenArgs, ctx: InvokeContext | None = None
362
+ ) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
363
+ try:
364
+ payload = args.model_dump(exclude_none=True)
365
+ result = await call_tool_stdio(
366
+ self._stdio_command,
367
+ self._remote_name,
368
+ payload,
369
+ env=self._env,
370
+ startup_timeout_sec=self._startup_timeout_sec,
371
+ tool_timeout_sec=self._tool_timeout_sec,
372
+ )
373
+ yield result
374
+ except Exception as exc:
375
+ raise ToolError(f"MCP stdio call failed: {exc!r}") from exc
376
+
377
+ @classmethod
378
+ def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
379
+ return ToolCallDisplay(summary=f"{published_name}")
380
+
381
+ @classmethod
382
+ def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
383
+ if not isinstance(event.result, MCPToolResult):
384
+ return ToolResultDisplay(
385
+ success=False,
386
+ message=event.error or event.skip_reason or "No result",
387
+ )
388
+
389
+ message = f"MCP tool {event.result.tool} completed"
390
+ return ToolResultDisplay(success=event.result.ok, message=message)
391
+
392
+ @classmethod
393
+ def get_status_text(cls) -> str:
394
+ return f"Calling MCP tool {remote.name}"
395
+
396
+ MCPStdioProxyTool.__name__ = f"MCP_STDIO_{computed_alias}__{remote.name}"
397
+ return MCPStdioProxyTool
vibe/core/tools/ui.py ADDED
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ if TYPE_CHECKING:
8
+ from vibe.core.types import ToolCallEvent, ToolResultEvent
9
+
10
+
11
+ class ToolCallDisplay(BaseModel):
12
+ summary: str # Brief description: "Writing file.txt", "Patching code.py"
13
+ content: str | None = None # Optional content preview
14
+
15
+
16
+ class ToolResultDisplay(BaseModel):
17
+ success: bool
18
+ message: str
19
+ warnings: list[str] = Field(default_factory=list)
20
+
21
+
22
+ @runtime_checkable
23
+ class ToolUIData[TArgs: BaseModel, TResult: BaseModel](Protocol):
24
+ @classmethod
25
+ def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay: ...
26
+
27
+ @classmethod
28
+ def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: ...
29
+
30
+ @classmethod
31
+ def get_status_text(cls) -> str: ...
32
+
33
+
34
+ class ToolUIDataAdapter:
35
+ def __init__(self, tool_class: Any) -> None:
36
+ self.tool_class = tool_class
37
+ self.ui_data_class: type[ToolUIData[Any, Any]] | None = (
38
+ tool_class if issubclass(tool_class, ToolUIData) else None
39
+ )
40
+
41
+ def get_call_display(self, event: ToolCallEvent) -> ToolCallDisplay:
42
+ if self.ui_data_class:
43
+ return self.ui_data_class.get_call_display(event)
44
+
45
+ args_dict = event.args.model_dump() if hasattr(event.args, "model_dump") else {}
46
+ args_str = ", ".join(f"{k}={v!r}" for k, v in list(args_dict.items())[:3])
47
+ return ToolCallDisplay(summary=f"{event.tool_name}({args_str})")
48
+
49
+ def get_result_display(self, event: ToolResultEvent) -> ToolResultDisplay:
50
+ if event.error:
51
+ return ToolResultDisplay(success=False, message=event.error)
52
+
53
+ if event.skipped:
54
+ return ToolResultDisplay(
55
+ success=False, message=event.skip_reason or "Skipped"
56
+ )
57
+
58
+ if self.ui_data_class:
59
+ return self.ui_data_class.get_result_display(event)
60
+
61
+ return ToolResultDisplay(success=True, message="Success")
62
+
63
+ def get_status_text(self) -> str:
64
+ if self.ui_data_class:
65
+ return self.ui_data_class.get_status_text()
66
+
67
+ tool_name = getattr(self.tool_class, "get_name", lambda: "tool")()
68
+ return f"Running {tool_name}"
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import tomllib
5
+
6
+ import tomli_w
7
+
8
+ from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE
9
+
10
+ AGENTS_MD_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
11
+
12
+
13
+ def has_agents_md_file(path: Path) -> bool:
14
+ return any((path / name).exists() for name in AGENTS_MD_FILENAMES)
15
+
16
+
17
+ def has_trustable_content(path: Path) -> bool:
18
+ return (
19
+ (path / ".vibe").exists()
20
+ or (path / ".agents").exists()
21
+ or has_agents_md_file(path)
22
+ )
23
+
24
+
25
+ class TrustedFoldersManager:
26
+ def __init__(self) -> None:
27
+ self._file_path = TRUSTED_FOLDERS_FILE.path
28
+ self._trusted: list[str] = []
29
+ self._untrusted: list[str] = []
30
+ self._load()
31
+
32
+ def _normalize_path(self, path: Path) -> str:
33
+ return str(path.expanduser().resolve())
34
+
35
+ def _load(self) -> None:
36
+ if not self._file_path.is_file():
37
+ self._trusted = []
38
+ self._untrusted = []
39
+ self._save()
40
+ return
41
+
42
+ try:
43
+ with self._file_path.open("rb") as f:
44
+ data = tomllib.load(f)
45
+ self._trusted = list(data.get("trusted", []))
46
+ self._untrusted = list(data.get("untrusted", []))
47
+ except (OSError, tomllib.TOMLDecodeError):
48
+ self._trusted = []
49
+ self._untrusted = []
50
+ self._save()
51
+
52
+ def _save(self) -> None:
53
+ self._file_path.parent.mkdir(parents=True, exist_ok=True)
54
+ data = {"trusted": self._trusted, "untrusted": self._untrusted}
55
+ try:
56
+ with self._file_path.open("wb") as f:
57
+ tomli_w.dump(data, f)
58
+ except OSError:
59
+ pass
60
+
61
+ def is_trusted(self, path: Path) -> bool | None:
62
+ normalized = self._normalize_path(path)
63
+ if normalized in self._trusted:
64
+ return True
65
+ if normalized in self._untrusted:
66
+ return False
67
+ return None
68
+
69
+ def add_trusted(self, path: Path) -> None:
70
+ normalized = self._normalize_path(path)
71
+ if normalized not in self._trusted:
72
+ self._trusted.append(normalized)
73
+ if normalized in self._untrusted:
74
+ self._untrusted.remove(normalized)
75
+ self._save()
76
+
77
+ def add_untrusted(self, path: Path) -> None:
78
+ normalized = self._normalize_path(path)
79
+ if normalized not in self._untrusted:
80
+ self._untrusted.append(normalized)
81
+ if normalized in self._trusted:
82
+ self._trusted.remove(normalized)
83
+ self._save()
84
+
85
+
86
+ trusted_folders_manager = TrustedFoldersManager()