codemaster-cli 1.0.1__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-1.0.1.dist-info/METADATA +645 -0
- codemaster_cli-1.0.1.dist-info/RECORD +174 -0
- codemaster_cli-1.0.1.dist-info/WHEEL +4 -0
- codemaster_cli-1.0.1.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 +229 -0
- vibe/cli/clipboard.py +69 -0
- vibe/cli/commands.py +116 -0
- vibe/cli/entrypoint.py +173 -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 +176 -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 +166 -0
- vibe/core/agents/models.py +143 -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 +983 -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 +33 -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/gitmaster.md +38 -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 +338 -0
- vibe/core/tools/builtins/ask_user_question.py +134 -0
- vibe/core/tools/builtins/bash.py +454 -0
- vibe/core/tools/builtins/git_clone.py +861 -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/git_clone.md +43 -0
- vibe/core/tools/builtins/prompts/gitmaster.md +38 -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 +401 -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 +184 -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,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
from enum import StrEnum, auto
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from vibe.core.tools.base import (
|
|
10
|
+
BaseTool,
|
|
11
|
+
BaseToolConfig,
|
|
12
|
+
BaseToolState,
|
|
13
|
+
InvokeContext,
|
|
14
|
+
ToolError,
|
|
15
|
+
ToolPermission,
|
|
16
|
+
)
|
|
17
|
+
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
|
18
|
+
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TodoStatus(StrEnum):
|
|
22
|
+
PENDING = auto()
|
|
23
|
+
IN_PROGRESS = auto()
|
|
24
|
+
COMPLETED = auto()
|
|
25
|
+
CANCELLED = auto()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TodoPriority(StrEnum):
|
|
29
|
+
LOW = auto()
|
|
30
|
+
MEDIUM = auto()
|
|
31
|
+
HIGH = auto()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TodoItem(BaseModel):
|
|
35
|
+
id: str
|
|
36
|
+
content: str
|
|
37
|
+
status: TodoStatus = TodoStatus.PENDING
|
|
38
|
+
priority: TodoPriority = TodoPriority.MEDIUM
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TodoArgs(BaseModel):
|
|
42
|
+
action: str = Field(description="Either 'read' or 'write'")
|
|
43
|
+
todos: list[TodoItem] | None = Field(
|
|
44
|
+
default=None, description="Complete list of todos when writing."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TodoResult(BaseModel):
|
|
49
|
+
message: str
|
|
50
|
+
todos: list[TodoItem]
|
|
51
|
+
total_count: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TodoConfig(BaseToolConfig):
|
|
55
|
+
permission: ToolPermission = ToolPermission.ALWAYS
|
|
56
|
+
max_todos: int = 100
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TodoState(BaseToolState):
|
|
60
|
+
todos: list[TodoItem] = Field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Todo(
|
|
64
|
+
BaseTool[TodoArgs, TodoResult, TodoConfig, TodoState],
|
|
65
|
+
ToolUIData[TodoArgs, TodoResult],
|
|
66
|
+
):
|
|
67
|
+
description: ClassVar[str] = (
|
|
68
|
+
"Manage todos. Use action='read' to view, action='write' with complete list to update."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
|
73
|
+
if not isinstance(event.args, TodoArgs):
|
|
74
|
+
return ToolCallDisplay(summary="Invalid arguments")
|
|
75
|
+
|
|
76
|
+
args = event.args
|
|
77
|
+
|
|
78
|
+
match args.action:
|
|
79
|
+
case "read":
|
|
80
|
+
return ToolCallDisplay(summary="Reading todos")
|
|
81
|
+
case "write":
|
|
82
|
+
count = len(args.todos) if args.todos else 0
|
|
83
|
+
return ToolCallDisplay(summary=f"Writing {count} todos")
|
|
84
|
+
case _:
|
|
85
|
+
return ToolCallDisplay(summary=f"Unknown action: {args.action}")
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
|
89
|
+
if not isinstance(event.result, TodoResult):
|
|
90
|
+
return ToolResultDisplay(success=True, message="Success")
|
|
91
|
+
|
|
92
|
+
result = event.result
|
|
93
|
+
|
|
94
|
+
return ToolResultDisplay(success=True, message=result.message)
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def get_status_text(cls) -> str:
|
|
98
|
+
return "Managing todos"
|
|
99
|
+
|
|
100
|
+
async def run(
|
|
101
|
+
self, args: TodoArgs, ctx: InvokeContext | None = None
|
|
102
|
+
) -> AsyncGenerator[ToolStreamEvent | TodoResult, None]:
|
|
103
|
+
match args.action:
|
|
104
|
+
case "read":
|
|
105
|
+
yield self._read_todos()
|
|
106
|
+
case "write":
|
|
107
|
+
yield self._write_todos(args.todos or [])
|
|
108
|
+
case _:
|
|
109
|
+
raise ToolError(
|
|
110
|
+
f"Invalid action '{args.action}'. Use 'read' or 'write'."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _read_todos(self) -> TodoResult:
|
|
114
|
+
return TodoResult(
|
|
115
|
+
message=f"Retrieved {len(self.state.todos)} todos",
|
|
116
|
+
todos=self.state.todos,
|
|
117
|
+
total_count=len(self.state.todos),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _write_todos(self, todos: list[TodoItem]) -> TodoResult:
|
|
121
|
+
if len(todos) > self.config.max_todos:
|
|
122
|
+
raise ToolError(f"Cannot store more than {self.config.max_todos} todos")
|
|
123
|
+
|
|
124
|
+
ids = [todo.id for todo in todos]
|
|
125
|
+
if len(ids) != len(set(ids)):
|
|
126
|
+
raise ToolError("Todo IDs must be unique")
|
|
127
|
+
|
|
128
|
+
self.state.todos = todos
|
|
129
|
+
|
|
130
|
+
return TodoResult(
|
|
131
|
+
message=f"Updated {len(todos)} todos",
|
|
132
|
+
todos=self.state.todos,
|
|
133
|
+
total_count=len(self.state.todos),
|
|
134
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import ClassVar, final
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from vibe.core.tools.base import (
|
|
11
|
+
BaseTool,
|
|
12
|
+
BaseToolConfig,
|
|
13
|
+
BaseToolState,
|
|
14
|
+
InvokeContext,
|
|
15
|
+
ToolError,
|
|
16
|
+
ToolPermission,
|
|
17
|
+
)
|
|
18
|
+
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
|
19
|
+
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WriteFileArgs(BaseModel):
|
|
23
|
+
path: str
|
|
24
|
+
content: str
|
|
25
|
+
overwrite: bool = Field(
|
|
26
|
+
default=False, description="Must be set to true to overwrite an existing file."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WriteFileResult(BaseModel):
|
|
31
|
+
path: str
|
|
32
|
+
bytes_written: int
|
|
33
|
+
file_existed: bool
|
|
34
|
+
content: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WriteFileConfig(BaseToolConfig):
|
|
38
|
+
permission: ToolPermission = ToolPermission.ASK
|
|
39
|
+
max_write_bytes: int = 64_000
|
|
40
|
+
create_parent_dirs: bool = True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class WriteFileState(BaseToolState):
|
|
44
|
+
recently_written_files: list[str] = Field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class WriteFile(
|
|
48
|
+
BaseTool[WriteFileArgs, WriteFileResult, WriteFileConfig, WriteFileState],
|
|
49
|
+
ToolUIData[WriteFileArgs, WriteFileResult],
|
|
50
|
+
):
|
|
51
|
+
description: ClassVar[str] = (
|
|
52
|
+
"Create or overwrite a UTF-8 file. Fails if file exists unless 'overwrite=True'."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
|
57
|
+
if not isinstance(event.args, WriteFileArgs):
|
|
58
|
+
return ToolCallDisplay(summary="Invalid arguments")
|
|
59
|
+
|
|
60
|
+
args = event.args
|
|
61
|
+
|
|
62
|
+
return ToolCallDisplay(
|
|
63
|
+
summary=f"Writing {args.path}{' (overwrite)' if args.overwrite else ''}",
|
|
64
|
+
content=args.content,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
|
69
|
+
if isinstance(event.result, WriteFileResult):
|
|
70
|
+
action = "Overwritten" if event.result.file_existed else "Created"
|
|
71
|
+
return ToolResultDisplay(
|
|
72
|
+
success=True, message=f"{action} {Path(event.result.path).name}"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return ToolResultDisplay(success=True, message="File written")
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def get_status_text(cls) -> str:
|
|
79
|
+
return "Writing file"
|
|
80
|
+
|
|
81
|
+
def check_allowlist_denylist(self, args: WriteFileArgs) -> ToolPermission | None:
|
|
82
|
+
import fnmatch
|
|
83
|
+
|
|
84
|
+
file_path = Path(args.path).expanduser()
|
|
85
|
+
if not file_path.is_absolute():
|
|
86
|
+
file_path = Path.cwd() / file_path
|
|
87
|
+
file_str = str(file_path)
|
|
88
|
+
|
|
89
|
+
for pattern in self.config.denylist:
|
|
90
|
+
if fnmatch.fnmatch(file_str, pattern):
|
|
91
|
+
return ToolPermission.NEVER
|
|
92
|
+
|
|
93
|
+
for pattern in self.config.allowlist:
|
|
94
|
+
if fnmatch.fnmatch(file_str, pattern):
|
|
95
|
+
return ToolPermission.ALWAYS
|
|
96
|
+
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
@final
|
|
100
|
+
async def run(
|
|
101
|
+
self, args: WriteFileArgs, ctx: InvokeContext | None = None
|
|
102
|
+
) -> AsyncGenerator[ToolStreamEvent | WriteFileResult, None]:
|
|
103
|
+
file_path, file_existed, content_bytes = self._prepare_and_validate_path(args)
|
|
104
|
+
|
|
105
|
+
await self._write_file(args, file_path)
|
|
106
|
+
|
|
107
|
+
BUFFER_SIZE = 10
|
|
108
|
+
self.state.recently_written_files.append(str(file_path))
|
|
109
|
+
if len(self.state.recently_written_files) > BUFFER_SIZE:
|
|
110
|
+
self.state.recently_written_files.pop(0)
|
|
111
|
+
|
|
112
|
+
yield WriteFileResult(
|
|
113
|
+
path=str(file_path),
|
|
114
|
+
bytes_written=content_bytes,
|
|
115
|
+
file_existed=file_existed,
|
|
116
|
+
content=args.content,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def _prepare_and_validate_path(self, args: WriteFileArgs) -> tuple[Path, bool, int]:
|
|
120
|
+
if not args.path.strip():
|
|
121
|
+
raise ToolError("Path cannot be empty")
|
|
122
|
+
|
|
123
|
+
content_bytes = len(args.content.encode("utf-8"))
|
|
124
|
+
if content_bytes > self.config.max_write_bytes:
|
|
125
|
+
raise ToolError(
|
|
126
|
+
f"Content exceeds {self.config.max_write_bytes} bytes limit"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
file_path = Path(args.path).expanduser()
|
|
130
|
+
if not file_path.is_absolute():
|
|
131
|
+
file_path = Path.cwd() / file_path
|
|
132
|
+
file_path = file_path.resolve()
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
file_path.relative_to(Path.cwd().resolve())
|
|
136
|
+
except ValueError:
|
|
137
|
+
raise ToolError(f"Cannot write outside project directory: {file_path}")
|
|
138
|
+
|
|
139
|
+
file_existed = file_path.exists()
|
|
140
|
+
|
|
141
|
+
if file_existed and not args.overwrite:
|
|
142
|
+
raise ToolError(
|
|
143
|
+
f"File '{file_path}' exists. Set overwrite=True to replace."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if self.config.create_parent_dirs:
|
|
147
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
elif not file_path.parent.exists():
|
|
149
|
+
raise ToolError(f"Parent directory does not exist: {file_path.parent}")
|
|
150
|
+
|
|
151
|
+
return file_path, file_existed, content_bytes
|
|
152
|
+
|
|
153
|
+
async def _write_file(self, args: WriteFileArgs, file_path: Path) -> None:
|
|
154
|
+
try:
|
|
155
|
+
async with await anyio.Path(file_path).open(
|
|
156
|
+
mode="w", encoding="utf-8"
|
|
157
|
+
) as f:
|
|
158
|
+
await f.write(args.content)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
raise ToolError(f"Error writing {file_path}: {e}") from e
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterator
|
|
4
|
+
import hashlib
|
|
5
|
+
import importlib.util
|
|
6
|
+
import inspect
|
|
7
|
+
from logging import getLogger
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from vibe.core.paths.config_paths import resolve_local_tools_dir
|
|
14
|
+
from vibe.core.paths.global_paths import DEFAULT_TOOL_DIR, GLOBAL_TOOLS_DIR
|
|
15
|
+
from vibe.core.tools.base import BaseTool, BaseToolConfig
|
|
16
|
+
from vibe.core.tools.mcp import (
|
|
17
|
+
RemoteTool,
|
|
18
|
+
create_mcp_http_proxy_tool_class,
|
|
19
|
+
create_mcp_stdio_proxy_tool_class,
|
|
20
|
+
list_tools_http,
|
|
21
|
+
list_tools_stdio,
|
|
22
|
+
)
|
|
23
|
+
from vibe.core.utils import name_matches, run_sync
|
|
24
|
+
|
|
25
|
+
logger = getLogger("vibe")
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp, VibeConfig
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _try_canonical_module_name(path: Path) -> str | None:
|
|
32
|
+
"""Extract canonical module name for vibe package files.
|
|
33
|
+
|
|
34
|
+
Prevents Pydantic class identity mismatches when the same module
|
|
35
|
+
is imported via dynamic discovery and regular imports.
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
parts = path.resolve().parts
|
|
39
|
+
except (OSError, ValueError):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
vibe_idx = parts.index("vibe")
|
|
44
|
+
except ValueError:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
if vibe_idx + 1 >= len(parts):
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
module_parts = [p.removesuffix(".py") for p in parts[vibe_idx:]]
|
|
51
|
+
return ".".join(module_parts)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _compute_module_name(path: Path) -> str:
|
|
55
|
+
"""Return canonical module name for vibe files, hash-based synthetic name otherwise."""
|
|
56
|
+
if canonical := _try_canonical_module_name(path):
|
|
57
|
+
return canonical
|
|
58
|
+
|
|
59
|
+
resolved = path.resolve()
|
|
60
|
+
path_hash = hashlib.md5(str(resolved).encode()).hexdigest()[:8]
|
|
61
|
+
stem = re.sub(r"[^0-9A-Za-z_]", "_", path.stem) or "mod"
|
|
62
|
+
return f"vibe_tools_discovered_{stem}_{path_hash}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class NoSuchToolError(Exception):
|
|
66
|
+
"""Exception raised when a tool is not found."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ToolManager:
|
|
70
|
+
"""Manages tool discovery and instantiation for an Agent.
|
|
71
|
+
|
|
72
|
+
Discovers available tools from the provided search paths. Each Agent
|
|
73
|
+
should have its own ToolManager instance.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
|
|
77
|
+
self._config_getter = config_getter
|
|
78
|
+
self._instances: dict[str, BaseTool] = {}
|
|
79
|
+
self._search_paths: list[Path] = self._compute_search_paths(self._config)
|
|
80
|
+
|
|
81
|
+
self._available: dict[str, type[BaseTool]] = {
|
|
82
|
+
cls.get_name(): cls for cls in self._iter_tool_classes(self._search_paths)
|
|
83
|
+
}
|
|
84
|
+
self._integrate_mcp()
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def _config(self) -> VibeConfig:
|
|
88
|
+
return self._config_getter()
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _compute_search_paths(config: VibeConfig) -> list[Path]:
|
|
92
|
+
paths: list[Path] = [DEFAULT_TOOL_DIR.path]
|
|
93
|
+
|
|
94
|
+
paths.extend(config.tool_paths)
|
|
95
|
+
|
|
96
|
+
if (tools_dir := resolve_local_tools_dir(Path.cwd())) is not None:
|
|
97
|
+
paths.append(tools_dir)
|
|
98
|
+
|
|
99
|
+
paths.append(GLOBAL_TOOLS_DIR.path)
|
|
100
|
+
|
|
101
|
+
unique: list[Path] = []
|
|
102
|
+
seen: set[Path] = set()
|
|
103
|
+
for p in paths:
|
|
104
|
+
rp = p.resolve()
|
|
105
|
+
if rp not in seen:
|
|
106
|
+
seen.add(rp)
|
|
107
|
+
unique.append(rp)
|
|
108
|
+
return unique
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _iter_tool_classes(search_paths: list[Path]) -> Iterator[type[BaseTool]]:
|
|
112
|
+
"""Iterate over all search_paths to find tool classes.
|
|
113
|
+
|
|
114
|
+
Note: if a search path is not a directory, it is treated as a single tool file.
|
|
115
|
+
"""
|
|
116
|
+
for base in search_paths:
|
|
117
|
+
if not base.is_dir() and base.name.endswith(".py"):
|
|
118
|
+
if tools := ToolManager._load_tools_from_file(base):
|
|
119
|
+
for tool in tools:
|
|
120
|
+
yield tool
|
|
121
|
+
|
|
122
|
+
for path in base.rglob("*.py"):
|
|
123
|
+
if tools := ToolManager._load_tools_from_file(path):
|
|
124
|
+
for tool in tools:
|
|
125
|
+
yield tool
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _load_tools_from_file(file_path: Path) -> list[type[BaseTool]] | None:
|
|
129
|
+
if not file_path.is_file():
|
|
130
|
+
return
|
|
131
|
+
name = file_path.name
|
|
132
|
+
if name.startswith("_"):
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
module_name = _compute_module_name(file_path)
|
|
136
|
+
|
|
137
|
+
if module_name in sys.modules:
|
|
138
|
+
module = sys.modules[module_name]
|
|
139
|
+
else:
|
|
140
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
141
|
+
if spec is None or spec.loader is None:
|
|
142
|
+
return
|
|
143
|
+
module = importlib.util.module_from_spec(spec)
|
|
144
|
+
sys.modules[module_name] = module
|
|
145
|
+
try:
|
|
146
|
+
spec.loader.exec_module(module)
|
|
147
|
+
except Exception:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
tools = []
|
|
151
|
+
for tool_obj in vars(module).values():
|
|
152
|
+
if not inspect.isclass(tool_obj):
|
|
153
|
+
continue
|
|
154
|
+
if not issubclass(tool_obj, BaseTool) or tool_obj is BaseTool:
|
|
155
|
+
continue
|
|
156
|
+
if inspect.isabstract(tool_obj):
|
|
157
|
+
continue
|
|
158
|
+
tools.append(tool_obj)
|
|
159
|
+
return tools
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def discover_tool_defaults(
|
|
163
|
+
search_paths: list[Path] | None = None,
|
|
164
|
+
) -> dict[str, dict[str, Any]]:
|
|
165
|
+
if search_paths is None:
|
|
166
|
+
search_paths = [DEFAULT_TOOL_DIR.path]
|
|
167
|
+
|
|
168
|
+
defaults: dict[str, dict[str, Any]] = {}
|
|
169
|
+
for cls in ToolManager._iter_tool_classes(search_paths):
|
|
170
|
+
try:
|
|
171
|
+
tool_name = cls.get_name()
|
|
172
|
+
config_class = cls._get_tool_config_class()
|
|
173
|
+
defaults[tool_name] = config_class().model_dump(exclude_none=True)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
logger.warning(
|
|
176
|
+
"Failed to get defaults for tool %s: %s", cls.__name__, e
|
|
177
|
+
)
|
|
178
|
+
continue
|
|
179
|
+
return defaults
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def available_tools(self) -> dict[str, type[BaseTool]]:
|
|
183
|
+
if self._config.enabled_tools:
|
|
184
|
+
return {
|
|
185
|
+
name: cls
|
|
186
|
+
for name, cls in self._available.items()
|
|
187
|
+
if name_matches(name, self._config.enabled_tools)
|
|
188
|
+
}
|
|
189
|
+
if self._config.disabled_tools:
|
|
190
|
+
return {
|
|
191
|
+
name: cls
|
|
192
|
+
for name, cls in self._available.items()
|
|
193
|
+
if not name_matches(name, self._config.disabled_tools)
|
|
194
|
+
}
|
|
195
|
+
return dict(self._available)
|
|
196
|
+
|
|
197
|
+
def _integrate_mcp(self) -> None:
|
|
198
|
+
if not self._config.mcp_servers:
|
|
199
|
+
return
|
|
200
|
+
run_sync(self._integrate_mcp_async())
|
|
201
|
+
|
|
202
|
+
async def _integrate_mcp_async(self) -> None:
|
|
203
|
+
try:
|
|
204
|
+
http_count = 0
|
|
205
|
+
stdio_count = 0
|
|
206
|
+
|
|
207
|
+
for srv in self._config.mcp_servers:
|
|
208
|
+
match srv.transport:
|
|
209
|
+
case "http" | "streamable-http":
|
|
210
|
+
http_count += await self._register_http_server(srv)
|
|
211
|
+
case "stdio":
|
|
212
|
+
stdio_count += await self._register_stdio_server(srv)
|
|
213
|
+
case _:
|
|
214
|
+
logger.warning("Unsupported MCP transport: %r", srv.transport)
|
|
215
|
+
|
|
216
|
+
logger.info(
|
|
217
|
+
"MCP integration registered %d tools (http=%d, stdio=%d)",
|
|
218
|
+
http_count + stdio_count,
|
|
219
|
+
http_count,
|
|
220
|
+
stdio_count,
|
|
221
|
+
)
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
logger.warning("Failed to integrate MCP tools: %s", exc)
|
|
224
|
+
|
|
225
|
+
async def _register_http_server(self, srv: MCPHttp | MCPStreamableHttp) -> int:
|
|
226
|
+
url = (srv.url or "").strip()
|
|
227
|
+
if not url:
|
|
228
|
+
logger.warning("MCP server '%s' missing url for http transport", srv.name)
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
headers = srv.http_headers()
|
|
232
|
+
try:
|
|
233
|
+
tools: list[RemoteTool] = await list_tools_http(
|
|
234
|
+
url, headers=headers, startup_timeout_sec=srv.startup_timeout_sec
|
|
235
|
+
)
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
logger.warning("MCP HTTP discovery failed for %s: %s", url, exc)
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
added = 0
|
|
241
|
+
for remote in tools:
|
|
242
|
+
try:
|
|
243
|
+
proxy_cls = create_mcp_http_proxy_tool_class(
|
|
244
|
+
url=url,
|
|
245
|
+
remote=remote,
|
|
246
|
+
alias=srv.name,
|
|
247
|
+
server_hint=srv.prompt,
|
|
248
|
+
headers=headers,
|
|
249
|
+
startup_timeout_sec=srv.startup_timeout_sec,
|
|
250
|
+
tool_timeout_sec=srv.tool_timeout_sec,
|
|
251
|
+
)
|
|
252
|
+
self._available[proxy_cls.get_name()] = proxy_cls
|
|
253
|
+
added += 1
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
logger.warning(
|
|
256
|
+
"Failed to register MCP HTTP tool '%s' from %s: %r",
|
|
257
|
+
getattr(remote, "name", "<unknown>"),
|
|
258
|
+
url,
|
|
259
|
+
exc,
|
|
260
|
+
)
|
|
261
|
+
return added
|
|
262
|
+
|
|
263
|
+
async def _register_stdio_server(self, srv: MCPStdio) -> int:
|
|
264
|
+
cmd = srv.argv()
|
|
265
|
+
if not cmd:
|
|
266
|
+
logger.warning("MCP stdio server '%s' has invalid/empty command", srv.name)
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
tools: list[RemoteTool] = await list_tools_stdio(
|
|
271
|
+
cmd, env=srv.env or None, startup_timeout_sec=srv.startup_timeout_sec
|
|
272
|
+
)
|
|
273
|
+
except Exception as exc:
|
|
274
|
+
logger.warning("MCP stdio discovery failed for %r: %s", cmd, exc)
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
added = 0
|
|
278
|
+
for remote in tools:
|
|
279
|
+
try:
|
|
280
|
+
proxy_cls = create_mcp_stdio_proxy_tool_class(
|
|
281
|
+
command=cmd,
|
|
282
|
+
remote=remote,
|
|
283
|
+
alias=srv.name,
|
|
284
|
+
server_hint=srv.prompt,
|
|
285
|
+
env=srv.env or None,
|
|
286
|
+
startup_timeout_sec=srv.startup_timeout_sec,
|
|
287
|
+
tool_timeout_sec=srv.tool_timeout_sec,
|
|
288
|
+
)
|
|
289
|
+
self._available[proxy_cls.get_name()] = proxy_cls
|
|
290
|
+
added += 1
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
logger.warning(
|
|
293
|
+
"Failed to register MCP stdio tool '%s' from %r: %r",
|
|
294
|
+
getattr(remote, "name", "<unknown>"),
|
|
295
|
+
cmd,
|
|
296
|
+
exc,
|
|
297
|
+
)
|
|
298
|
+
return added
|
|
299
|
+
|
|
300
|
+
def get_tool_config(self, tool_name: str) -> BaseToolConfig:
|
|
301
|
+
tool_class = self._available.get(tool_name)
|
|
302
|
+
|
|
303
|
+
if tool_class:
|
|
304
|
+
config_class = tool_class._get_tool_config_class()
|
|
305
|
+
default_config = config_class()
|
|
306
|
+
else:
|
|
307
|
+
config_class = BaseToolConfig
|
|
308
|
+
default_config = BaseToolConfig()
|
|
309
|
+
|
|
310
|
+
user_overrides = self._config.tools.get(tool_name)
|
|
311
|
+
if user_overrides is None:
|
|
312
|
+
merged_dict = default_config.model_dump()
|
|
313
|
+
else:
|
|
314
|
+
merged_dict = {**default_config.model_dump(), **user_overrides.model_dump()}
|
|
315
|
+
|
|
316
|
+
return config_class.model_validate(merged_dict)
|
|
317
|
+
|
|
318
|
+
def get(self, tool_name: str) -> BaseTool:
|
|
319
|
+
"""Get a tool instance, creating it lazily on first call.
|
|
320
|
+
|
|
321
|
+
Raises:
|
|
322
|
+
NoSuchToolError: If the requested tool is not available.
|
|
323
|
+
"""
|
|
324
|
+
if tool_name in self._instances:
|
|
325
|
+
return self._instances[tool_name]
|
|
326
|
+
|
|
327
|
+
if tool_name not in self._available:
|
|
328
|
+
raise NoSuchToolError(
|
|
329
|
+
f"Unknown tool: {tool_name}. Available: {list(self._available.keys())}"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
tool_class = self._available[tool_name]
|
|
333
|
+
tool_config = self.get_tool_config(tool_name)
|
|
334
|
+
self._instances[tool_name] = tool_class.from_config(tool_config)
|
|
335
|
+
return self._instances[tool_name]
|
|
336
|
+
|
|
337
|
+
def reset_all(self) -> None:
|
|
338
|
+
self._instances.clear()
|
|
339
|
+
|
|
340
|
+
def invalidate_tool(self, tool_name: str) -> None:
|
|
341
|
+
self._instances.pop(tool_name, None)
|