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
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()
|