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
@@ -0,0 +1,466 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Generator
4
+ import fnmatch
5
+ import html
6
+ import os
7
+ from pathlib import Path
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from typing import TYPE_CHECKING
12
+
13
+ from vibe.core.prompts import UtilityPrompt
14
+ from vibe.core.trusted_folders import AGENTS_MD_FILENAMES, trusted_folders_manager
15
+ from vibe.core.utils import is_dangerous_directory, is_windows
16
+
17
+ if TYPE_CHECKING:
18
+ from vibe.core.agents import AgentManager
19
+ from vibe.core.config import ProjectContextConfig, VibeConfig
20
+ from vibe.core.skills.manager import SkillManager
21
+ from vibe.core.tools.manager import ToolManager
22
+
23
+
24
+ def _load_project_doc(workdir: Path, max_bytes: int) -> str:
25
+ if not trusted_folders_manager.is_trusted(workdir):
26
+ return ""
27
+ for name in AGENTS_MD_FILENAMES:
28
+ path = workdir / name
29
+ try:
30
+ return path.read_text("utf-8", errors="ignore")[:max_bytes]
31
+ except (FileNotFoundError, OSError):
32
+ continue
33
+ return ""
34
+
35
+
36
+ class ProjectContextProvider:
37
+ def __init__(
38
+ self, config: ProjectContextConfig, root_path: str | Path = "."
39
+ ) -> None:
40
+ self.root_path = Path(root_path).resolve()
41
+ self.config = config
42
+ self.gitignore_patterns = self._load_gitignore_patterns()
43
+ self._file_count = 0
44
+ self._start_time = 0.0
45
+
46
+ def _load_gitignore_patterns(self) -> list[str]:
47
+ gitignore_path = self.root_path / ".gitignore"
48
+ patterns = []
49
+
50
+ if gitignore_path.exists():
51
+ try:
52
+ patterns.extend(
53
+ line.strip()
54
+ for line in gitignore_path.read_text(encoding="utf-8").splitlines()
55
+ if line.strip() and not line.startswith("#")
56
+ )
57
+ except Exception as e:
58
+ print(f"Warning: Could not read .gitignore: {e}", file=sys.stderr)
59
+
60
+ default_patterns = [
61
+ ".git",
62
+ ".git/*",
63
+ "*.pyc",
64
+ "__pycache__",
65
+ "node_modules",
66
+ "node_modules/*",
67
+ ".env",
68
+ ".DS_Store",
69
+ "*.log",
70
+ ".vscode/settings.json",
71
+ ".idea/*",
72
+ "dist",
73
+ "build",
74
+ "target",
75
+ ".next",
76
+ ".nuxt",
77
+ "coverage",
78
+ ".nyc_output",
79
+ "*.egg-info",
80
+ ".pytest_cache",
81
+ ".tox",
82
+ "vendor",
83
+ "third_party",
84
+ "deps",
85
+ "*.min.js",
86
+ "*.min.css",
87
+ "*.bundle.js",
88
+ "*.chunk.js",
89
+ ".cache",
90
+ "tmp",
91
+ "temp",
92
+ "logs",
93
+ ]
94
+
95
+ return patterns + default_patterns
96
+
97
+ def _is_ignored(self, path: Path) -> bool:
98
+ try:
99
+ relative_path = path.relative_to(self.root_path)
100
+ path_str = str(relative_path)
101
+
102
+ for pattern in self.gitignore_patterns:
103
+ if pattern.endswith("/"):
104
+ if path.is_dir() and fnmatch.fnmatch(f"{path_str}/", pattern):
105
+ return True
106
+ elif fnmatch.fnmatch(path_str, pattern):
107
+ return True
108
+ elif "*" in pattern or "?" in pattern:
109
+ if fnmatch.fnmatch(path_str, pattern):
110
+ return True
111
+
112
+ return False
113
+ except (ValueError, OSError):
114
+ return True
115
+
116
+ def _should_stop(self) -> bool:
117
+ return (
118
+ self._file_count >= self.config.max_files
119
+ or (time.time() - self._start_time) > self.config.timeout_seconds
120
+ )
121
+
122
+ def _build_tree_structure_iterative(self) -> Generator[str]:
123
+ self._start_time = time.time()
124
+ self._file_count = 0
125
+
126
+ yield from self._process_directory(self.root_path, "", 0, is_root=True)
127
+
128
+ def _process_directory(
129
+ self, path: Path, prefix: str, depth: int, is_root: bool = False
130
+ ) -> Generator[str]:
131
+ if depth > self.config.max_depth or self._should_stop():
132
+ return
133
+
134
+ try:
135
+ all_items = list(path.iterdir())
136
+ items = [item for item in all_items if not self._is_ignored(item)]
137
+
138
+ items.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
139
+
140
+ show_truncation = len(items) > self.config.max_dirs_per_level
141
+ if show_truncation:
142
+ items = items[: self.config.max_dirs_per_level]
143
+
144
+ for i, item in enumerate(items):
145
+ if self._should_stop():
146
+ break
147
+
148
+ is_last = i == len(items) - 1 and not show_truncation
149
+ connector = "└── " if is_last else "├── "
150
+ name = f"{item.name}{'/' if item.is_dir() else ''}"
151
+
152
+ yield f"{prefix}{connector}{name}"
153
+ self._file_count += 1
154
+
155
+ if item.is_dir() and depth < self.config.max_depth:
156
+ child_prefix = prefix + (" " if is_last else "│ ")
157
+ yield from self._process_directory(item, child_prefix, depth + 1)
158
+
159
+ if show_truncation and not self._should_stop():
160
+ remaining = len(all_items) - len(items)
161
+ yield f"{prefix}└── ... ({remaining} more items)"
162
+
163
+ except (PermissionError, OSError):
164
+ pass
165
+
166
+ def get_directory_structure(self) -> str:
167
+ lines = []
168
+ header = f"Directory structure of {self.root_path.name} (depth≤{self.config.max_depth}, max {self.config.max_files} items):\n"
169
+
170
+ try:
171
+ for line in self._build_tree_structure_iterative():
172
+ lines.append(line)
173
+
174
+ current_text = header + "\n".join(lines)
175
+ if (
176
+ len(current_text)
177
+ > self.config.max_chars - self.config.truncation_buffer
178
+ ):
179
+ break
180
+
181
+ except Exception as e:
182
+ lines.append(f"Error building structure: {e}")
183
+
184
+ structure = header + "\n".join(lines)
185
+
186
+ if self._file_count >= self.config.max_files:
187
+ structure += f"\n... (truncated at {self.config.max_files} files limit)"
188
+ elif (time.time() - self._start_time) > self.config.timeout_seconds:
189
+ structure += (
190
+ f"\n... (truncated due to {self.config.timeout_seconds}s timeout)"
191
+ )
192
+ elif len(structure) > self.config.max_chars:
193
+ structure += f"\n... (truncated at {self.config.max_chars} characters)"
194
+
195
+ return structure
196
+
197
+ def get_git_status(self) -> str:
198
+ try:
199
+ timeout = min(self.config.timeout_seconds, 10.0)
200
+ num_commits = self.config.default_commit_count
201
+
202
+ current_branch = subprocess.run(
203
+ ["git", "branch", "--show-current"],
204
+ capture_output=True,
205
+ check=True,
206
+ cwd=self.root_path,
207
+ stdin=subprocess.DEVNULL if is_windows() else None,
208
+ text=True,
209
+ timeout=timeout,
210
+ ).stdout.strip()
211
+
212
+ main_branch = "main"
213
+ try:
214
+ branches_output = subprocess.run(
215
+ ["git", "branch", "-r"],
216
+ capture_output=True,
217
+ check=True,
218
+ cwd=self.root_path,
219
+ stdin=subprocess.DEVNULL if is_windows() else None,
220
+ text=True,
221
+ timeout=timeout,
222
+ ).stdout
223
+ if "origin/master" in branches_output:
224
+ main_branch = "master"
225
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
226
+ pass
227
+
228
+ status_output = subprocess.run(
229
+ ["git", "status", "--porcelain"],
230
+ capture_output=True,
231
+ check=True,
232
+ cwd=self.root_path,
233
+ stdin=subprocess.DEVNULL if is_windows() else None,
234
+ text=True,
235
+ timeout=timeout,
236
+ ).stdout.strip()
237
+
238
+ if status_output:
239
+ status_lines = status_output.splitlines()
240
+ MAX_GIT_STATUS_SIZE = 50
241
+ if len(status_lines) > MAX_GIT_STATUS_SIZE:
242
+ status = (
243
+ f"({len(status_lines)} changes - use 'git status' for details)"
244
+ )
245
+ else:
246
+ status = f"({len(status_lines)} changes)"
247
+ else:
248
+ status = "(clean)"
249
+
250
+ log_output = subprocess.run(
251
+ ["git", "log", "--oneline", f"-{num_commits}", "--decorate"],
252
+ capture_output=True,
253
+ check=True,
254
+ cwd=self.root_path,
255
+ stdin=subprocess.DEVNULL if is_windows() else None,
256
+ text=True,
257
+ timeout=timeout,
258
+ ).stdout.strip()
259
+
260
+ recent_commits = []
261
+ for line in log_output.split("\n"):
262
+ if not (line := line.strip()):
263
+ continue
264
+
265
+ if " " in line:
266
+ commit_hash, commit_msg = line.split(" ", 1)
267
+ if (
268
+ "(" in commit_msg
269
+ and ")" in commit_msg
270
+ and (paren_index := commit_msg.rfind("(")) > 0
271
+ ):
272
+ commit_msg = commit_msg[:paren_index].strip()
273
+ recent_commits.append(f"{commit_hash} {commit_msg}")
274
+ else:
275
+ recent_commits.append(line)
276
+
277
+ git_info_parts = [
278
+ f"Current branch: {current_branch}",
279
+ f"Main branch (you will usually use this for PRs): {main_branch}",
280
+ f"Status: {status}",
281
+ ]
282
+
283
+ if recent_commits:
284
+ git_info_parts.append("Recent commits:")
285
+ git_info_parts.extend(recent_commits)
286
+
287
+ return "\n".join(git_info_parts)
288
+
289
+ except subprocess.TimeoutExpired:
290
+ return "Git operations timed out (large repository)"
291
+ except subprocess.CalledProcessError:
292
+ return "Not a git repository or git not available"
293
+ except Exception as e:
294
+ return f"Error getting git status: {e}"
295
+
296
+ def get_full_context(self) -> str:
297
+ structure = self.get_directory_structure()
298
+ git_status = self.get_git_status()
299
+
300
+ large_repo_warning = ""
301
+ if len(structure) >= self.config.max_chars - self.config.truncation_buffer:
302
+ large_repo_warning = (
303
+ f" Large repository detected - showing summary view with depth limit {self.config.max_depth}. "
304
+ f"Use the LS tool (passing a specific path), Bash tool, and other tools to explore nested directories in detail."
305
+ )
306
+
307
+ template = UtilityPrompt.PROJECT_CONTEXT.read()
308
+ return template.format(
309
+ large_repo_warning=large_repo_warning,
310
+ structure=structure,
311
+ abs_path=self.root_path,
312
+ git_status=git_status,
313
+ )
314
+
315
+
316
+ def _get_platform_name() -> str:
317
+ platform_names = {
318
+ "win32": "Windows",
319
+ "darwin": "macOS",
320
+ "linux": "Linux",
321
+ "freebsd": "FreeBSD",
322
+ "openbsd": "OpenBSD",
323
+ "netbsd": "NetBSD",
324
+ }
325
+ return platform_names.get(sys.platform, "Unix-like")
326
+
327
+
328
+ def _get_default_shell() -> str:
329
+ """Get the default shell used by asyncio.create_subprocess_shell.
330
+
331
+ On Unix, uses $SHELL env var and default to sh.
332
+ On Windows, this is COMSPEC or cmd.exe.
333
+ """
334
+ if is_windows():
335
+ return os.environ.get("COMSPEC", "cmd.exe")
336
+ return os.environ.get("SHELL", "sh")
337
+
338
+
339
+ def _get_os_system_prompt() -> str:
340
+ shell = _get_default_shell()
341
+ platform_name = _get_platform_name()
342
+ prompt = f"The operating system is {platform_name} with shell `{shell}`"
343
+
344
+ if is_windows():
345
+ prompt += "\n" + _get_windows_system_prompt()
346
+ return prompt
347
+
348
+
349
+ def _get_windows_system_prompt() -> str:
350
+ return (
351
+ "### COMMAND COMPATIBILITY RULES (MUST FOLLOW):\n"
352
+ "- DO NOT use Unix commands like `ls`, `grep`, `cat` - they won't work on Windows\n"
353
+ "- Use: `dir` (Windows) for directory listings\n"
354
+ "- Use: backslashes (\\\\) for paths\n"
355
+ "- Check command availability with: `where command` (Windows)\n"
356
+ "- Script shebang: Not applicable on Windows\n"
357
+ "### ALWAYS verify commands work on the detected platform before suggesting them"
358
+ )
359
+
360
+
361
+ def _add_commit_signature() -> str:
362
+ return (
363
+ "When you want to commit changes, you will always use the 'git commit' bash command.\n"
364
+ "It will always be suffixed with a line telling it was generated by codeMaster with the appropriate co-authoring information.\n"
365
+ "The format you will always uses is the following heredoc.\n\n"
366
+ "```bash\n"
367
+ "git commit -m <Commit message here>\n\n"
368
+ "Generated by codeMaster.\n"
369
+ "Co-Authored-By: codeMaster <codemaster@codemaster.ai>\n"
370
+ "```"
371
+ )
372
+
373
+
374
+ def _get_available_skills_section(skill_manager: SkillManager) -> str:
375
+ skills = skill_manager.available_skills
376
+ if not skills:
377
+ return ""
378
+
379
+ lines = [
380
+ "# Available Skills",
381
+ "",
382
+ "You have access to the following skills. When a task matches a skill's description,",
383
+ "read the full SKILL.md file to load detailed instructions.",
384
+ "",
385
+ "<available_skills>",
386
+ ]
387
+
388
+ for name, info in sorted(skills.items()):
389
+ lines.append(" <skill>")
390
+ lines.append(f" <name>{html.escape(str(name))}</name>")
391
+ lines.append(
392
+ f" <description>{html.escape(str(info.description))}</description>"
393
+ )
394
+ lines.append(f" <path>{html.escape(str(info.skill_path))}</path>")
395
+ lines.append(" </skill>")
396
+
397
+ lines.append("</available_skills>")
398
+
399
+ return "\n".join(lines)
400
+
401
+
402
+ def _get_available_subagents_section(agent_manager: AgentManager) -> str:
403
+ agents = agent_manager.get_subagents()
404
+ if not agents:
405
+ return ""
406
+
407
+ lines = ["# Available Subagents", ""]
408
+ lines.append("The following subagents can be spawned via the Task tool:")
409
+ for agent in agents:
410
+ lines.append(f"- **{agent.name}**: {agent.description}")
411
+
412
+ return "\n".join(lines)
413
+
414
+
415
+ def get_universal_system_prompt(
416
+ tool_manager: ToolManager,
417
+ config: VibeConfig,
418
+ skill_manager: SkillManager,
419
+ agent_manager: AgentManager,
420
+ ) -> str:
421
+ sections = [config.system_prompt]
422
+
423
+ if config.include_commit_signature:
424
+ sections.append(_add_commit_signature())
425
+
426
+ if config.include_model_info:
427
+ sections.append(f"Your model name is: `{config.active_model}`")
428
+
429
+ if config.include_prompt_detail:
430
+ sections.append(_get_os_system_prompt())
431
+ tool_prompts = []
432
+ for tool_class in tool_manager.available_tools.values():
433
+ if prompt := tool_class.get_tool_prompt():
434
+ tool_prompts.append(prompt)
435
+ if tool_prompts:
436
+ sections.append("\n---\n".join(tool_prompts))
437
+
438
+ skills_section = _get_available_skills_section(skill_manager)
439
+ if skills_section:
440
+ sections.append(skills_section)
441
+
442
+ subagents_section = _get_available_subagents_section(agent_manager)
443
+ if subagents_section:
444
+ sections.append(subagents_section)
445
+
446
+ if config.include_project_context:
447
+ is_dangerous, reason = is_dangerous_directory()
448
+ if is_dangerous:
449
+ template = UtilityPrompt.DANGEROUS_DIRECTORY.read()
450
+ context = template.format(
451
+ reason=reason.lower(), abs_path=Path(".").resolve()
452
+ )
453
+ else:
454
+ context = ProjectContextProvider(
455
+ config=config.project_context, root_path=Path.cwd()
456
+ ).get_full_context()
457
+
458
+ sections.append(context)
459
+
460
+ project_doc = _load_project_doc(
461
+ Path.cwd(), config.project_context.max_doc_bytes
462
+ )
463
+ if project_doc.strip():
464
+ sections.append(project_doc)
465
+
466
+ return "\n\n".join(sections)
File without changes
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Callable
5
+ import os
6
+ from typing import TYPE_CHECKING, Any, Literal
7
+
8
+ import httpx
9
+
10
+ from vibe import __version__
11
+ from vibe.core.config import Backend, VibeConfig
12
+ from vibe.core.llm.format import ResolvedToolCall
13
+ from vibe.core.utils import get_user_agent
14
+
15
+ if TYPE_CHECKING:
16
+ from vibe.core.agent_loop import ToolDecision
17
+
18
+ DATALAKE_EVENTS_URL = "https://codestral.mistral.ai/v1/datalake/events"
19
+
20
+
21
+ class TelemetryClient:
22
+ def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
23
+ self._config_getter = config_getter
24
+ self._client: httpx.AsyncClient | None = None
25
+ self._pending_tasks: set[asyncio.Task[Any]] = set()
26
+
27
+ def _get_telemetry_user_agent(self) -> str:
28
+ try:
29
+ config = self._config_getter()
30
+ active_model = config.get_active_model()
31
+ provider = config.get_provider_for_model(active_model)
32
+ return get_user_agent(provider.backend)
33
+ except ValueError:
34
+ return get_user_agent(None)
35
+
36
+ def _get_mistral_api_key(self) -> str | None:
37
+ """Get the current API key from the active provider.
38
+
39
+ Only returns an API key if the provider is a Mistral provider
40
+ to avoid leaking third-party credentials to the telemetry endpoint.
41
+ """
42
+ try:
43
+ config = self._config_getter()
44
+ model = config.get_active_model()
45
+ provider = config.get_provider_for_model(model)
46
+ if provider.backend != Backend.MISTRAL:
47
+ return None
48
+ env_var = provider.api_key_env_var
49
+ return os.getenv(env_var) if env_var else None
50
+ except ValueError:
51
+ return None
52
+
53
+ def _is_enabled(self) -> bool:
54
+ """Check if telemetry is enabled in the current config."""
55
+ try:
56
+ return self._config_getter().enable_telemetry
57
+ except ValueError:
58
+ return False
59
+
60
+ @property
61
+ def client(self) -> httpx.AsyncClient:
62
+ if self._client is None:
63
+ self._client = httpx.AsyncClient(
64
+ timeout=httpx.Timeout(5.0),
65
+ limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
66
+ )
67
+ return self._client
68
+
69
+ def send_telemetry_event(self, event_name: str, properties: dict[str, Any]) -> None:
70
+ mistral_api_key = self._get_mistral_api_key()
71
+ if mistral_api_key is None or not self._is_enabled():
72
+ return
73
+ user_agent = self._get_telemetry_user_agent()
74
+
75
+ async def _send() -> None:
76
+ try:
77
+ await self.client.post(
78
+ DATALAKE_EVENTS_URL,
79
+ json={"event": event_name, "properties": properties},
80
+ headers={
81
+ "Content-Type": "application/json",
82
+ "Authorization": f"Bearer {mistral_api_key}",
83
+ "User-Agent": user_agent,
84
+ },
85
+ )
86
+ except Exception:
87
+ pass # Silently swallow all exceptions for fire-and-forget telemetry
88
+
89
+ task = asyncio.create_task(_send())
90
+ self._pending_tasks.add(task)
91
+ task.add_done_callback(self._pending_tasks.discard)
92
+
93
+ async def aclose(self) -> None:
94
+ if self._pending_tasks:
95
+ await asyncio.gather(*self._pending_tasks, return_exceptions=True)
96
+ if self._client is not None:
97
+ await self._client.aclose()
98
+ self._client = None
99
+
100
+ def _calculate_file_metrics(
101
+ self,
102
+ tool_call: ResolvedToolCall,
103
+ status: Literal["success", "failure", "skipped"],
104
+ result: dict[str, Any] | None = None,
105
+ ) -> tuple[int, int]:
106
+ nb_files_created = 0
107
+ nb_files_modified = 0
108
+ if status == "success" and result is not None:
109
+ if tool_call.tool_name == "write_file":
110
+ file_existed = result.get("file_existed", False)
111
+ if file_existed:
112
+ nb_files_modified = 1
113
+ else:
114
+ nb_files_created = 1
115
+ elif tool_call.tool_name == "search_replace":
116
+ nb_files_modified = 1 if result.get("blocks_applied", 0) > 0 else 0
117
+ return nb_files_created, nb_files_modified
118
+
119
+ def send_tool_call_finished(
120
+ self,
121
+ *,
122
+ tool_call: ResolvedToolCall,
123
+ status: Literal["success", "failure", "skipped"],
124
+ decision: ToolDecision | None,
125
+ agent_profile_name: str,
126
+ result: dict[str, Any] | None = None,
127
+ ) -> None:
128
+ verdict_value = decision.verdict.value if decision else None
129
+ approval_type_value = decision.approval_type.value if decision else None
130
+
131
+ nb_files_created, nb_files_modified = self._calculate_file_metrics(
132
+ tool_call, status, result
133
+ )
134
+
135
+ payload = {
136
+ "tool_name": tool_call.tool_name,
137
+ "status": status,
138
+ "decision": verdict_value,
139
+ "approval_type": approval_type_value,
140
+ "agent_profile_name": agent_profile_name,
141
+ "nb_files_created": nb_files_created,
142
+ "nb_files_modified": nb_files_modified,
143
+ }
144
+ self.send_telemetry_event("vibe/tool_call_finished", payload)
145
+
146
+ def send_user_copied_text(self, text: str) -> None:
147
+ payload = {"text_length": len(text)}
148
+ self.send_telemetry_event("vibe/user_copied_text", payload)
149
+
150
+ def send_user_cancelled_action(self, action: str) -> None:
151
+ payload = {"action": action}
152
+ self.send_telemetry_event("vibe/user_cancelled_action", payload)
153
+
154
+ def send_auto_compact_triggered(self) -> None:
155
+ payload = {}
156
+ self.send_telemetry_event("vibe/auto_compact_triggered", payload)
157
+
158
+ def send_slash_command_used(
159
+ self, command: str, command_type: Literal["builtin", "skill"]
160
+ ) -> None:
161
+ payload = {"command": command.lstrip("/"), "command_type": command_type}
162
+ self.send_telemetry_event("vibe/slash_command_used", payload)
163
+
164
+ def send_new_session(
165
+ self,
166
+ has_agents_md: bool,
167
+ nb_skills: int,
168
+ nb_mcp_servers: int,
169
+ nb_models: int,
170
+ entrypoint: Literal["cli", "acp", "programmatic"],
171
+ ) -> None:
172
+ payload = {
173
+ "has_agents_md": has_agents_md,
174
+ "nb_skills": nb_skills,
175
+ "nb_mcp_servers": nb_mcp_servers,
176
+ "nb_models": nb_models,
177
+ "entrypoint": entrypoint,
178
+ "version": __version__,
179
+ }
180
+ self.send_telemetry_event("vibe/new_session", payload)
181
+
182
+ def send_onboarding_api_key_added(self) -> None:
183
+ self.send_telemetry_event(
184
+ "vibe/onboarding_api_key_added", {"version": __version__}
185
+ )
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class ServiceTeleportError(Exception):
5
+ """Base exception for teleport errors."""
6
+
7
+
8
+ class ServiceTeleportNotSupportedError(ServiceTeleportError):
9
+ """Raised when teleport is not supported in current environment."""