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/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"]