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/utils.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine
|
|
5
|
+
import concurrent.futures
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from enum import Enum, auto
|
|
8
|
+
from fnmatch import fnmatch
|
|
9
|
+
import functools
|
|
10
|
+
import logging
|
|
11
|
+
from logging.handlers import RotatingFileHandler
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from vibe import __version__
|
|
21
|
+
from vibe.core.config import Backend
|
|
22
|
+
from vibe.core.paths.global_paths import LOG_DIR, LOG_FILE
|
|
23
|
+
from vibe.core.types import BaseEvent, ToolResultEvent
|
|
24
|
+
|
|
25
|
+
CANCELLATION_TAG = "user_cancellation"
|
|
26
|
+
TOOL_ERROR_TAG = "tool_error"
|
|
27
|
+
VIBE_STOP_EVENT_TAG = "vibe_stop_event"
|
|
28
|
+
VIBE_WARNING_TAG = "vibe_warning"
|
|
29
|
+
|
|
30
|
+
KNOWN_TAGS = [CANCELLATION_TAG, TOOL_ERROR_TAG, VIBE_STOP_EVENT_TAG, VIBE_WARNING_TAG]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TaggedText:
|
|
34
|
+
_TAG_PATTERN = re.compile(
|
|
35
|
+
rf"<({'|'.join(re.escape(tag) for tag in KNOWN_TAGS)})>(.*?)</\1>",
|
|
36
|
+
flags=re.DOTALL,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str, tag: str = "") -> None:
|
|
40
|
+
self.message = message
|
|
41
|
+
self.tag = tag
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
if not self.tag:
|
|
45
|
+
return self.message
|
|
46
|
+
return f"<{self.tag}>{self.message}</{self.tag}>"
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def from_string(text: str) -> TaggedText:
|
|
50
|
+
found_tag = ""
|
|
51
|
+
result = text
|
|
52
|
+
|
|
53
|
+
def replace_tag(match: re.Match[str]) -> str:
|
|
54
|
+
nonlocal found_tag
|
|
55
|
+
tag_name = match.group(1)
|
|
56
|
+
content = match.group(2)
|
|
57
|
+
if not found_tag:
|
|
58
|
+
found_tag = tag_name
|
|
59
|
+
return content
|
|
60
|
+
|
|
61
|
+
result = TaggedText._TAG_PATTERN.sub(replace_tag, text)
|
|
62
|
+
|
|
63
|
+
if found_tag:
|
|
64
|
+
return TaggedText(result, found_tag)
|
|
65
|
+
|
|
66
|
+
return TaggedText(text, "")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CancellationReason(Enum):
|
|
70
|
+
OPERATION_CANCELLED = auto()
|
|
71
|
+
TOOL_INTERRUPTED = auto()
|
|
72
|
+
TOOL_NO_RESPONSE = auto()
|
|
73
|
+
TOOL_SKIPPED = auto()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_user_cancellation_message(
|
|
77
|
+
cancellation_reason: CancellationReason, tool_name: str | None = None
|
|
78
|
+
) -> TaggedText:
|
|
79
|
+
match cancellation_reason:
|
|
80
|
+
case CancellationReason.OPERATION_CANCELLED:
|
|
81
|
+
return TaggedText("User cancelled the operation.", CANCELLATION_TAG)
|
|
82
|
+
case CancellationReason.TOOL_INTERRUPTED:
|
|
83
|
+
return TaggedText("Tool execution interrupted by user.", CANCELLATION_TAG)
|
|
84
|
+
case CancellationReason.TOOL_NO_RESPONSE:
|
|
85
|
+
return TaggedText(
|
|
86
|
+
"Tool execution interrupted - no response available", CANCELLATION_TAG
|
|
87
|
+
)
|
|
88
|
+
case CancellationReason.TOOL_SKIPPED:
|
|
89
|
+
return TaggedText(
|
|
90
|
+
tool_name or "Tool execution skipped by user.", CANCELLATION_TAG
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_user_cancellation_event(event: BaseEvent) -> bool:
|
|
95
|
+
return (
|
|
96
|
+
isinstance(event, ToolResultEvent)
|
|
97
|
+
and event.skipped
|
|
98
|
+
and event.skip_reason is not None
|
|
99
|
+
and f"<{CANCELLATION_TAG}>" in event.skip_reason
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:
|
|
104
|
+
"""Check if the current directory is a dangerous folder that would cause
|
|
105
|
+
issues if we were to run the tool there.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
path: Path to check (defaults to current directory)
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
tuple[bool, str]: (is_dangerous, reason) where reason explains why it's dangerous
|
|
112
|
+
"""
|
|
113
|
+
path = Path(path).resolve()
|
|
114
|
+
|
|
115
|
+
home_dir = Path.home()
|
|
116
|
+
|
|
117
|
+
dangerous_paths = {
|
|
118
|
+
home_dir: "home directory",
|
|
119
|
+
home_dir / "Documents": "Documents folder",
|
|
120
|
+
home_dir / "Desktop": "Desktop folder",
|
|
121
|
+
home_dir / "Downloads": "Downloads folder",
|
|
122
|
+
home_dir / "Pictures": "Pictures folder",
|
|
123
|
+
home_dir / "Movies": "Movies folder",
|
|
124
|
+
home_dir / "Music": "Music folder",
|
|
125
|
+
home_dir / "Library": "Library folder",
|
|
126
|
+
Path("/Applications"): "Applications folder",
|
|
127
|
+
Path("/System"): "System folder",
|
|
128
|
+
Path("/Library"): "System Library folder",
|
|
129
|
+
Path("/usr"): "System usr folder",
|
|
130
|
+
Path("/private"): "System private folder",
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for dangerous_path, description in dangerous_paths.items():
|
|
134
|
+
try:
|
|
135
|
+
if path == dangerous_path:
|
|
136
|
+
return True, f"You are in the {description}"
|
|
137
|
+
except (OSError, ValueError):
|
|
138
|
+
continue
|
|
139
|
+
return False, ""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
LOG_DIR.path.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
|
|
144
|
+
logger = logging.getLogger("vibe")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class StructuredLogFormatter(logging.Formatter):
|
|
148
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
149
|
+
timestamp = datetime.fromtimestamp(record.created, tz=UTC).isoformat()
|
|
150
|
+
ppid = os.getppid()
|
|
151
|
+
pid = os.getpid()
|
|
152
|
+
level = record.levelname
|
|
153
|
+
message = record.getMessage().replace("\\", "\\\\").replace("\n", "\\n")
|
|
154
|
+
|
|
155
|
+
line = f"{timestamp} {ppid} {pid} {level} {message}"
|
|
156
|
+
|
|
157
|
+
if record.exc_info:
|
|
158
|
+
exc_text = self.formatException(record.exc_info).replace("\n", "\\n")
|
|
159
|
+
line = f"{line} {exc_text}"
|
|
160
|
+
|
|
161
|
+
return line
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def apply_logging_config(target_logger: logging.Logger) -> None:
|
|
165
|
+
LOG_DIR.path.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
|
|
167
|
+
max_bytes = int(os.environ.get("LOG_MAX_BYTES", 10 * 1024 * 1024))
|
|
168
|
+
|
|
169
|
+
if os.environ.get("DEBUG_MODE") == "true":
|
|
170
|
+
log_level_str = "DEBUG"
|
|
171
|
+
else:
|
|
172
|
+
log_level_str = os.environ.get("LOG_LEVEL", "WARNING").upper()
|
|
173
|
+
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
174
|
+
if log_level_str not in valid_levels:
|
|
175
|
+
log_level_str = "WARNING"
|
|
176
|
+
|
|
177
|
+
handler = RotatingFileHandler(
|
|
178
|
+
LOG_FILE.path, maxBytes=max_bytes, backupCount=0, encoding="utf-8"
|
|
179
|
+
)
|
|
180
|
+
handler.setFormatter(StructuredLogFormatter())
|
|
181
|
+
log_level = getattr(logging, log_level_str, logging.WARNING)
|
|
182
|
+
handler.setLevel(log_level)
|
|
183
|
+
|
|
184
|
+
# Make sure the logger is not gating logs
|
|
185
|
+
target_logger.setLevel(logging.DEBUG)
|
|
186
|
+
|
|
187
|
+
target_logger.addHandler(handler)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
apply_logging_config(logger)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_user_agent(backend: Backend | None) -> str:
|
|
194
|
+
user_agent = f"Mistral-Vibe/{__version__}"
|
|
195
|
+
if backend == Backend.MISTRAL:
|
|
196
|
+
mistral_sdk_prefix = "mistral-client-python/"
|
|
197
|
+
user_agent = f"{mistral_sdk_prefix}{user_agent}"
|
|
198
|
+
return user_agent
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _is_retryable_http_error(e: Exception) -> bool:
|
|
202
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
203
|
+
return e.response.status_code in {408, 409, 425, 429, 500, 502, 503, 504}
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def async_retry[T, **P](
|
|
208
|
+
tries: int = 3,
|
|
209
|
+
delay_seconds: float = 0.5,
|
|
210
|
+
backoff_factor: float = 2.0,
|
|
211
|
+
is_retryable: Callable[[Exception], bool] = _is_retryable_http_error,
|
|
212
|
+
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
|
|
213
|
+
"""Args:
|
|
214
|
+
tries: Number of retry attempts
|
|
215
|
+
delay_seconds: Initial delay between retries in seconds
|
|
216
|
+
backoff_factor: Multiplier for delay on each retry
|
|
217
|
+
is_retryable: Function to determine if an exception should trigger a retry
|
|
218
|
+
(defaults to checking for retryable HTTP errors from both urllib and httpx)
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Decorated function with retry logic
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
|
|
225
|
+
@functools.wraps(func)
|
|
226
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
227
|
+
last_exc = None
|
|
228
|
+
for attempt in range(tries):
|
|
229
|
+
try:
|
|
230
|
+
return await func(*args, **kwargs)
|
|
231
|
+
except Exception as e:
|
|
232
|
+
last_exc = e
|
|
233
|
+
if attempt < tries - 1 and is_retryable(e):
|
|
234
|
+
current_delay = (delay_seconds * (backoff_factor**attempt)) + (
|
|
235
|
+
0.05 * attempt
|
|
236
|
+
)
|
|
237
|
+
await asyncio.sleep(current_delay)
|
|
238
|
+
continue
|
|
239
|
+
raise e
|
|
240
|
+
raise RuntimeError(
|
|
241
|
+
f"Retries exhausted. Last error: {last_exc}"
|
|
242
|
+
) from last_exc
|
|
243
|
+
|
|
244
|
+
return wrapper
|
|
245
|
+
|
|
246
|
+
return decorator
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def async_generator_retry[T, **P](
|
|
250
|
+
tries: int = 3,
|
|
251
|
+
delay_seconds: float = 0.5,
|
|
252
|
+
backoff_factor: float = 2.0,
|
|
253
|
+
is_retryable: Callable[[Exception], bool] = _is_retryable_http_error,
|
|
254
|
+
) -> Callable[[Callable[P, AsyncGenerator[T]]], Callable[P, AsyncGenerator[T]]]:
|
|
255
|
+
"""Retry decorator for async generators.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
tries: Number of retry attempts
|
|
259
|
+
delay_seconds: Initial delay between retries in seconds
|
|
260
|
+
backoff_factor: Multiplier for delay on each retry
|
|
261
|
+
is_retryable: Function to determine if an exception should trigger a retry
|
|
262
|
+
(defaults to checking for retryable HTTP errors from both urllib and httpx)
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Decorated async generator function with retry logic
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def decorator(
|
|
269
|
+
func: Callable[P, AsyncGenerator[T]],
|
|
270
|
+
) -> Callable[P, AsyncGenerator[T]]:
|
|
271
|
+
@functools.wraps(func)
|
|
272
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> AsyncGenerator[T]:
|
|
273
|
+
last_exc = None
|
|
274
|
+
for attempt in range(tries):
|
|
275
|
+
try:
|
|
276
|
+
async for item in func(*args, **kwargs):
|
|
277
|
+
yield item
|
|
278
|
+
return
|
|
279
|
+
except Exception as e:
|
|
280
|
+
last_exc = e
|
|
281
|
+
if attempt < tries - 1 and is_retryable(e):
|
|
282
|
+
current_delay = (delay_seconds * (backoff_factor**attempt)) + (
|
|
283
|
+
0.05 * attempt
|
|
284
|
+
)
|
|
285
|
+
await asyncio.sleep(current_delay)
|
|
286
|
+
continue
|
|
287
|
+
raise e
|
|
288
|
+
raise RuntimeError(
|
|
289
|
+
f"Retries exhausted. Last error: {last_exc}"
|
|
290
|
+
) from last_exc
|
|
291
|
+
|
|
292
|
+
return wrapper
|
|
293
|
+
|
|
294
|
+
return decorator
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class ConversationLimitException(Exception):
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def run_sync[T](coro: Coroutine[Any, Any, T]) -> T:
|
|
302
|
+
"""Run an async coroutine synchronously, handling nested event loops.
|
|
303
|
+
|
|
304
|
+
If called from within an async context (running event loop), runs the
|
|
305
|
+
coroutine in a thread pool executor. Otherwise, uses asyncio.run().
|
|
306
|
+
|
|
307
|
+
This mirrors the pattern used by ToolManager for MCP integration.
|
|
308
|
+
"""
|
|
309
|
+
try:
|
|
310
|
+
asyncio.get_running_loop()
|
|
311
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
312
|
+
future = executor.submit(asyncio.run, coro)
|
|
313
|
+
return future.result()
|
|
314
|
+
except RuntimeError:
|
|
315
|
+
return asyncio.run(coro)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def is_windows() -> bool:
|
|
319
|
+
return sys.platform == "win32"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@functools.lru_cache(maxsize=256)
|
|
323
|
+
def _compile_icase(expr: str) -> re.Pattern[str] | None:
|
|
324
|
+
try:
|
|
325
|
+
return re.compile(expr, re.IGNORECASE)
|
|
326
|
+
except re.error:
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def name_matches(name: str, patterns: list[str]) -> bool:
|
|
331
|
+
"""Check if a name matches any of the provided patterns.
|
|
332
|
+
|
|
333
|
+
Supports two forms (case-insensitive):
|
|
334
|
+
- Glob wildcards using fnmatch (e.g., 'serena_*')
|
|
335
|
+
- Regex when prefixed with 're:' (e.g., 're:serena.*')
|
|
336
|
+
"""
|
|
337
|
+
n = name.lower()
|
|
338
|
+
for raw in patterns:
|
|
339
|
+
if not (p := (raw or "").strip()):
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
if p.startswith("re:"):
|
|
343
|
+
rx = _compile_icase(p.removeprefix("re:"))
|
|
344
|
+
if rx is not None and rx.fullmatch(name) is not None:
|
|
345
|
+
return True
|
|
346
|
+
elif fnmatch(n, p.lower()):
|
|
347
|
+
return True
|
|
348
|
+
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class AsyncExecutor:
|
|
353
|
+
"""Run sync functions in a thread pool with timeout. Supports async context manager."""
|
|
354
|
+
|
|
355
|
+
def __init__(
|
|
356
|
+
self, max_workers: int = 4, timeout: float = 60.0, name: str = "async-executor"
|
|
357
|
+
) -> None:
|
|
358
|
+
self._executor = concurrent.futures.ThreadPoolExecutor(
|
|
359
|
+
max_workers=max_workers, thread_name_prefix=name
|
|
360
|
+
)
|
|
361
|
+
self._timeout = timeout
|
|
362
|
+
|
|
363
|
+
async def __aenter__(self) -> AsyncExecutor:
|
|
364
|
+
return self
|
|
365
|
+
|
|
366
|
+
async def __aexit__(self, *_: object) -> None:
|
|
367
|
+
self.shutdown(wait=False)
|
|
368
|
+
|
|
369
|
+
async def run[T](self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
|
370
|
+
loop = asyncio.get_running_loop()
|
|
371
|
+
future = loop.run_in_executor(
|
|
372
|
+
self._executor, functools.partial(fn, *args, **kwargs)
|
|
373
|
+
)
|
|
374
|
+
try:
|
|
375
|
+
return await asyncio.wait_for(future, timeout=self._timeout)
|
|
376
|
+
except TimeoutError as e:
|
|
377
|
+
raise TimeoutError(f"Operation timed out after {self._timeout}s") from e
|
|
378
|
+
|
|
379
|
+
def shutdown(self, wait: bool = True) -> None:
|
|
380
|
+
self._executor.shutdown(wait=wait)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str:
|
|
384
|
+
if old_tokens is None or new_tokens is None:
|
|
385
|
+
return "Compaction complete"
|
|
386
|
+
|
|
387
|
+
reduction = old_tokens - new_tokens
|
|
388
|
+
reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0
|
|
389
|
+
return (
|
|
390
|
+
f"Compaction complete: {old_tokens:,} → "
|
|
391
|
+
f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def utc_now() -> datetime:
|
|
396
|
+
return datetime.now(UTC)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from rich import print as rprint
|
|
6
|
+
from textual.app import App
|
|
7
|
+
|
|
8
|
+
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
|
|
9
|
+
from vibe.setup.onboarding.screens import ApiKeyScreen, ProviderSelectionScreen, WelcomeScreen
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OnboardingApp(App[str | None]):
|
|
13
|
+
CSS_PATH = "onboarding.tcss"
|
|
14
|
+
|
|
15
|
+
def on_mount(self) -> None:
|
|
16
|
+
self.theme = "textual-ansi"
|
|
17
|
+
|
|
18
|
+
self.install_screen(WelcomeScreen(), "welcome")
|
|
19
|
+
self.install_screen(ProviderSelectionScreen(), "provider_selection")
|
|
20
|
+
self.install_screen(ApiKeyScreen(), "api_key")
|
|
21
|
+
self.push_screen("welcome")
|
|
22
|
+
|
|
23
|
+
def run_onboarding(app: App | None = None) -> None:
|
|
24
|
+
result = (app or OnboardingApp()).run()
|
|
25
|
+
match result:
|
|
26
|
+
case None:
|
|
27
|
+
rprint("\n[yellow]Setup cancelled. See you next time![/]")
|
|
28
|
+
sys.exit(0)
|
|
29
|
+
case str() as s if s.startswith("save_error:"):
|
|
30
|
+
err = s.removeprefix("save_error:")
|
|
31
|
+
rprint(
|
|
32
|
+
f"\n[yellow]Warning: Could not save API key to .env file: {err}[/]"
|
|
33
|
+
"\n[dim]The API key is set for this session only. "
|
|
34
|
+
f"You may need to set it manually in {GLOBAL_ENV_FILE.path}[/]\n"
|
|
35
|
+
)
|
|
36
|
+
case "completed":
|
|
37
|
+
rprint(
|
|
38
|
+
'\nSetup complete 🎉. Run "codemaster" to start using the codeMaster CLI.\n'
|
|
39
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from textual.screen import Screen
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OnboardingScreen(Screen[str | None]):
|
|
7
|
+
NEXT_SCREEN: str | None = None
|
|
8
|
+
|
|
9
|
+
def action_next(self) -> None:
|
|
10
|
+
if self.NEXT_SCREEN:
|
|
11
|
+
self.app.switch_screen(self.NEXT_SCREEN)
|
|
12
|
+
|
|
13
|
+
def action_cancel(self) -> None:
|
|
14
|
+
self.app.exit(None)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/* =============================================================================
|
|
2
|
+
Onboarding App Styles
|
|
3
|
+
============================================================================= */
|
|
4
|
+
|
|
5
|
+
Screen {
|
|
6
|
+
align: center middle;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
OnboardingScreen {
|
|
10
|
+
align: center middle;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* =============================================================================
|
|
14
|
+
Welcome Screen
|
|
15
|
+
============================================================================= */
|
|
16
|
+
|
|
17
|
+
#welcome-container {
|
|
18
|
+
align: center middle;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#welcome-text {
|
|
22
|
+
border: round #555555;
|
|
23
|
+
padding: 1 3;
|
|
24
|
+
margin-bottom: 2;
|
|
25
|
+
text-align: center;
|
|
26
|
+
width: auto;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
WelcomeScreen #enter-hint {
|
|
30
|
+
color: ansi_bright_black;
|
|
31
|
+
min-width: 16;
|
|
32
|
+
text-align: center;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
WelcomeScreen #enter-hint.hidden {
|
|
36
|
+
visibility: hidden;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* =============================================================================
|
|
40
|
+
API Key Screen
|
|
41
|
+
============================================================================= */
|
|
42
|
+
|
|
43
|
+
#api-key-outer {
|
|
44
|
+
overflow-y: auto;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.spacer {
|
|
48
|
+
height: 1fr;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#api-key-title {
|
|
52
|
+
text-align: center;
|
|
53
|
+
margin-bottom: 2;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#api-key-content {
|
|
57
|
+
width: auto;
|
|
58
|
+
height: auto;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#api-key-content Static {
|
|
62
|
+
text-align: center;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.link-row {
|
|
66
|
+
width: auto;
|
|
67
|
+
height: auto;
|
|
68
|
+
margin-top: 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.link-chevron {
|
|
72
|
+
width: auto;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#input-box {
|
|
76
|
+
border: round #555555;
|
|
77
|
+
padding: 0 1;
|
|
78
|
+
margin-top: 2;
|
|
79
|
+
width: auto;
|
|
80
|
+
height: 3;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#input-box.valid {
|
|
84
|
+
border: round ansi_green;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#input-box.invalid {
|
|
88
|
+
border: round ansi_red;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#key {
|
|
92
|
+
border: none;
|
|
93
|
+
width: 48;
|
|
94
|
+
height: 1;
|
|
95
|
+
padding: 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#paste-hint {
|
|
99
|
+
margin-top: 3;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#feedback {
|
|
103
|
+
text-align: center;
|
|
104
|
+
height: 1;
|
|
105
|
+
margin-top: 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#feedback.error {
|
|
109
|
+
color: ansi_red;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#feedback.success {
|
|
113
|
+
color: ansi_bright_black;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#config-docs-section {
|
|
117
|
+
width: 100%;
|
|
118
|
+
height: auto;
|
|
119
|
+
align: center top;
|
|
120
|
+
padding-bottom: 2;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#config-docs-group {
|
|
124
|
+
width: auto;
|
|
125
|
+
height: auto;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#config-docs-group Static {
|
|
129
|
+
color: ansi_bright_black;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#config-docs-group .link-row {
|
|
133
|
+
margin-top: 0;
|
|
134
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
|
|
2
|
+
from vibe.setup.onboarding.screens.provider_selection import ProviderSelectionScreen
|
|
3
|
+
from vibe.setup.onboarding.screens.welcome import WelcomeScreen
|
|
4
|
+
|
|
5
|
+
__all__ = ["ApiKeyScreen", "ProviderSelectionScreen", "WelcomeScreen"]
|