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,318 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime, timedelta
4
+ import getpass
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import subprocess
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from anyio import NamedTemporaryFile, Path as AsyncPath
12
+
13
+ from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
14
+ from vibe.core.utils import is_windows, utc_now
15
+
16
+ if TYPE_CHECKING:
17
+ from vibe.core.agents.models import AgentProfile
18
+ from vibe.core.config import SessionLoggingConfig, VibeConfig
19
+ from vibe.core.tools.manager import ToolManager
20
+
21
+
22
+ METADATA_FILENAME = "meta.json"
23
+ MESSAGES_FILENAME = "messages.jsonl"
24
+
25
+
26
+ class SessionLogger:
27
+ def __init__(self, session_config: SessionLoggingConfig, session_id: str) -> None:
28
+ self.session_config = session_config
29
+ self.enabled = session_config.enabled
30
+
31
+ if not self.enabled:
32
+ self.save_dir: Path | None = None
33
+ self.session_prefix: str | None = None
34
+ self.session_id: str = "disabled"
35
+ self.session_start_time: str = "N/A"
36
+ self.session_dir: Path | None = None
37
+ self.session_metadata: SessionMetadata | None = None
38
+ return
39
+
40
+ self.save_dir = Path(session_config.save_dir)
41
+ self.session_prefix = session_config.session_prefix
42
+ self.session_id = session_id
43
+ self.session_start_time = utc_now().isoformat()
44
+
45
+ self.save_dir.mkdir(parents=True, exist_ok=True)
46
+ self.session_dir = self.save_folder
47
+ self.session_metadata = self._initialize_session_metadata()
48
+
49
+ @property
50
+ def save_folder(self) -> Path:
51
+ if self.save_dir is None or self.session_prefix is None:
52
+ raise RuntimeError(
53
+ "Cannot get session save folder when logging is disabled"
54
+ )
55
+
56
+ timestamp = utc_now().strftime("%Y%m%d_%H%M%S")
57
+ folder_name = f"{self.session_prefix}_{timestamp}_{self.session_id[:8]}"
58
+ return self.save_dir / folder_name
59
+
60
+ @property
61
+ def metadata_filepath(self) -> Path:
62
+ if self.session_dir is None:
63
+ raise RuntimeError(
64
+ "Cannot get session metadata filepath when logging is disabled"
65
+ )
66
+ return self.session_dir / METADATA_FILENAME
67
+
68
+ @property
69
+ def messages_filepath(self) -> Path:
70
+ if self.session_dir is None:
71
+ raise RuntimeError(
72
+ "Cannot get session messages filepath when logging is disabled"
73
+ )
74
+ return self.session_dir / MESSAGES_FILENAME
75
+
76
+ @property
77
+ def git_commit(self) -> str | None:
78
+ try:
79
+ result = subprocess.run(
80
+ ["git", "rev-parse", "HEAD"],
81
+ capture_output=True,
82
+ stdin=subprocess.DEVNULL if is_windows() else None,
83
+ text=True,
84
+ timeout=5.0,
85
+ )
86
+ if result.returncode == 0 and result.stdout:
87
+ return result.stdout.strip()
88
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
89
+ pass
90
+ return None
91
+
92
+ @property
93
+ def git_branch(self) -> str | None:
94
+ try:
95
+ result = subprocess.run(
96
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
97
+ capture_output=True,
98
+ stdin=subprocess.DEVNULL if is_windows() else None,
99
+ text=True,
100
+ timeout=5.0,
101
+ )
102
+ if result.returncode == 0 and result.stdout:
103
+ return result.stdout.strip()
104
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
105
+ pass
106
+ return None
107
+
108
+ @property
109
+ def username(self) -> str:
110
+ try:
111
+ return getpass.getuser()
112
+ except Exception:
113
+ return "unknown"
114
+
115
+ def _initialize_session_metadata(self) -> SessionMetadata:
116
+ git_commit = self.git_commit
117
+ git_branch = self.git_branch
118
+ user_name = self.username
119
+
120
+ return SessionMetadata(
121
+ session_id=self.session_id,
122
+ start_time=self.session_start_time,
123
+ end_time=None,
124
+ git_commit=git_commit,
125
+ git_branch=git_branch,
126
+ username=user_name,
127
+ environment={"working_directory": str(Path.cwd())},
128
+ )
129
+
130
+ def _get_title(self, messages: list[LLMMessage]) -> str:
131
+ first_user_message = None
132
+ for message in messages:
133
+ if message.role == Role.user:
134
+ first_user_message = message
135
+ break
136
+
137
+ if first_user_message is None:
138
+ title = "Untitled session"
139
+ else:
140
+ MAX_TITLE_LENGTH = 50
141
+ text = str(first_user_message.content)
142
+ title = text[:MAX_TITLE_LENGTH]
143
+ if len(text) > MAX_TITLE_LENGTH:
144
+ title += "…"
145
+
146
+ return title
147
+
148
+ @staticmethod
149
+ async def persist_metadata(metadata: Any, session_dir: Path) -> None:
150
+ temp_metadata_filepath = None
151
+ metadata_filepath = session_dir / METADATA_FILENAME
152
+ try:
153
+ async with NamedTemporaryFile(
154
+ mode="w",
155
+ suffix=".json.tmp",
156
+ dir=str(session_dir),
157
+ delete=False,
158
+ encoding="utf-8",
159
+ ) as f:
160
+ temp_metadata_filepath = Path(str(f.name))
161
+ await f.write(json.dumps(metadata, indent=2, ensure_ascii=False))
162
+ await f.flush()
163
+ os.fsync(f.wrapped.fileno())
164
+
165
+ os.replace(temp_metadata_filepath, str(metadata_filepath))
166
+ except Exception as e:
167
+ raise RuntimeError(
168
+ f"Failed to persist session metadata to {metadata_filepath}: {e}"
169
+ ) from e
170
+ finally:
171
+ if (
172
+ temp_metadata_filepath
173
+ and temp_metadata_filepath.exists()
174
+ and temp_metadata_filepath.is_file()
175
+ ):
176
+ temp_metadata_filepath.unlink()
177
+
178
+ @staticmethod
179
+ async def persist_messages(messages: list[dict], session_dir: Path) -> None:
180
+ messages_filepath = session_dir / "messages.jsonl"
181
+ try:
182
+ if not messages_filepath.exists():
183
+ messages_filepath.touch()
184
+
185
+ async with await AsyncPath(messages_filepath).open(
186
+ "a", encoding="utf-8"
187
+ ) as f:
188
+ for message in messages:
189
+ await f.write(json.dumps(message, ensure_ascii=False) + "\n")
190
+ await f.flush()
191
+ os.fsync(f.wrapped.fileno())
192
+ except Exception as e:
193
+ raise RuntimeError(
194
+ f"Failed to persist session messages to {messages_filepath}: {e}"
195
+ ) from e
196
+
197
+ async def save_interaction(
198
+ self,
199
+ messages: list[LLMMessage],
200
+ stats: AgentStats,
201
+ base_config: VibeConfig,
202
+ tool_manager: ToolManager,
203
+ agent_profile: AgentProfile,
204
+ ) -> None:
205
+ if not self.enabled or self.session_dir is None:
206
+ return
207
+
208
+ if self.session_metadata is None:
209
+ return
210
+
211
+ # If the session directory does not exist, create it
212
+ try:
213
+ self.session_dir.mkdir(parents=True, exist_ok=True)
214
+ except OSError as e:
215
+ raise RuntimeError(
216
+ f"Failed to create session directory at {self.session_dir}: {type(e).__name__}: {e}"
217
+ ) from e
218
+
219
+ # Read old metadata and get total_messages
220
+ try:
221
+ if self.metadata_filepath.exists():
222
+ async with await AsyncPath(self.metadata_filepath).open(
223
+ encoding="utf-8", errors="ignore"
224
+ ) as f:
225
+ old_metadata = json.loads(await f.read())
226
+ old_total_messages = old_metadata["total_messages"]
227
+ else:
228
+ old_total_messages = 0
229
+ except Exception as e:
230
+ raise RuntimeError(
231
+ f"Failed to read session metadata at {self.metadata_filepath}: {e}"
232
+ ) from e
233
+
234
+ try:
235
+ non_system_messages = [m for m in messages if m.role != Role.system]
236
+ # Append new messages
237
+ new_messages = non_system_messages[old_total_messages:]
238
+
239
+ if len(new_messages) == 0:
240
+ return
241
+
242
+ messages_data = [m.model_dump(exclude_none=True) for m in new_messages]
243
+ await SessionLogger.persist_messages(messages_data, self.session_dir)
244
+
245
+ # If message update succeeded, write metadata
246
+ tools_available = [
247
+ {
248
+ "type": "function",
249
+ "function": {
250
+ "name": tool_class.get_name(),
251
+ "description": tool_class.description,
252
+ "parameters": tool_class.get_parameters(),
253
+ },
254
+ }
255
+ for tool_class in tool_manager.available_tools.values()
256
+ ]
257
+
258
+ title = self._get_title(messages)
259
+ system_prompt = (
260
+ messages[0].model_dump()
261
+ if len(messages) > 0 and messages[0].role == Role.system
262
+ else None
263
+ )
264
+ total_messages = len(non_system_messages)
265
+
266
+ metadata_dump = {
267
+ **self.session_metadata.model_dump(),
268
+ "end_time": utc_now().isoformat(),
269
+ "stats": stats.model_dump(),
270
+ "title": title,
271
+ "total_messages": total_messages,
272
+ "tools_available": tools_available,
273
+ "config": base_config.model_dump(mode="json"),
274
+ "agent_profile": {
275
+ "name": agent_profile.name,
276
+ "overrides": agent_profile.overrides,
277
+ },
278
+ "system_prompt": system_prompt,
279
+ }
280
+
281
+ await SessionLogger.persist_metadata(metadata_dump, self.session_dir)
282
+ except Exception as e:
283
+ raise RuntimeError(
284
+ f"Failed to save session to {self.session_dir}: {e}"
285
+ ) from e
286
+ finally:
287
+ self.cleanup_tmp_files()
288
+
289
+ def reset_session(self, session_id: str) -> None:
290
+ """Clear existing session info and setup a new session"""
291
+ if not self.enabled:
292
+ return
293
+
294
+ self.session_id = session_id
295
+ self.session_start_time = utc_now().isoformat()
296
+ self.session_dir = self.save_folder
297
+ self.session_metadata = self._initialize_session_metadata()
298
+
299
+ def cleanup_tmp_files(self) -> None:
300
+ """Delete temporary files created more than 5 minutes ago"""
301
+ if not self.enabled or not self.save_dir:
302
+ return
303
+
304
+ now = utc_now()
305
+ ago = now - timedelta(minutes=5)
306
+
307
+ tmp_files = self.save_dir.glob("**/*.json.tmp") # Recursive search
308
+
309
+ for file_path in tmp_files:
310
+ if file_path.is_file():
311
+ try:
312
+ file_mtime = datetime.fromtimestamp(
313
+ file_path.stat().st_mtime, tz=UTC
314
+ )
315
+ if file_mtime < ago:
316
+ file_path.unlink()
317
+ except Exception:
318
+ continue
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from vibe.core.config import SessionLoggingConfig
8
+ from vibe.core.session.session_logger import SessionLogger
9
+
10
+
11
+ def migrate_sessions_entrypoint(session_config: SessionLoggingConfig) -> int:
12
+ return asyncio.run(migrate_sessions(session_config))
13
+
14
+
15
+ async def migrate_sessions(session_config: SessionLoggingConfig) -> int:
16
+ """Helper for migrating session data from singular JSON files to the format introduced in Vibe 2.0 with per-session folders with split metadata and message files."""
17
+ save_dir = session_config.save_dir
18
+ if not save_dir or not session_config.enabled:
19
+ return 0
20
+
21
+ successful_migrations = 0
22
+ session_files = list(Path(save_dir).glob(f"{session_config.session_prefix}_*.json"))
23
+ for session_file in session_files:
24
+ try:
25
+ with open(session_file) as f:
26
+ session_data = f.read()
27
+ session_json = json.loads(session_data)
28
+ metadata = session_json["metadata"]
29
+ messages = session_json["messages"]
30
+
31
+ session_dir = Path(save_dir) / session_file.stem
32
+ session_dir.mkdir()
33
+
34
+ await SessionLogger.persist_metadata(metadata, session_dir)
35
+ await SessionLogger.persist_messages(messages, session_dir)
36
+ session_file.unlink()
37
+ successful_migrations += 1
38
+ except Exception:
39
+ continue
40
+
41
+ return successful_migrations
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from vibe.core.skills.manager import SkillManager
4
+ from vibe.core.skills.models import SkillInfo, SkillMetadata
5
+ from vibe.core.skills.parser import SkillParseError
6
+
7
+ __all__ = ["SkillInfo", "SkillManager", "SkillMetadata", "SkillParseError"]
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from logging import getLogger
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from vibe.core.paths.config_paths import resolve_local_skills_dirs
9
+ from vibe.core.paths.global_paths import GLOBAL_SKILLS_DIR
10
+ from vibe.core.skills.models import SkillInfo, SkillMetadata
11
+ from vibe.core.skills.parser import SkillParseError, parse_frontmatter
12
+ from vibe.core.utils import name_matches
13
+
14
+ if TYPE_CHECKING:
15
+ from vibe.core.config import VibeConfig
16
+
17
+ logger = getLogger("vibe")
18
+
19
+
20
+ class SkillManager:
21
+ def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
22
+ self._config_getter = config_getter
23
+ self._search_paths = self._compute_search_paths(self._config)
24
+ self._available: dict[str, SkillInfo] = self._discover_skills()
25
+
26
+ if self._available:
27
+ logger.info(
28
+ "Discovered %d skill(s) from %d search path(s)",
29
+ len(self._available),
30
+ len(self._search_paths),
31
+ )
32
+
33
+ @property
34
+ def _config(self) -> VibeConfig:
35
+ return self._config_getter()
36
+
37
+ @property
38
+ def available_skills(self) -> dict[str, SkillInfo]:
39
+ if self._config.enabled_skills:
40
+ return {
41
+ name: info
42
+ for name, info in self._available.items()
43
+ if name_matches(name, self._config.enabled_skills)
44
+ }
45
+ if self._config.disabled_skills:
46
+ return {
47
+ name: info
48
+ for name, info in self._available.items()
49
+ if not name_matches(name, self._config.disabled_skills)
50
+ }
51
+ return dict(self._available)
52
+
53
+ @staticmethod
54
+ def _compute_search_paths(config: VibeConfig) -> list[Path]:
55
+ paths: list[Path] = []
56
+
57
+ for path in config.skill_paths:
58
+ if path.is_dir():
59
+ paths.append(path)
60
+
61
+ paths.extend(resolve_local_skills_dirs(Path.cwd()))
62
+
63
+ if GLOBAL_SKILLS_DIR.path.is_dir():
64
+ paths.append(GLOBAL_SKILLS_DIR.path)
65
+
66
+ unique: list[Path] = []
67
+ for p in paths:
68
+ rp = p.resolve()
69
+ if rp not in unique:
70
+ unique.append(rp)
71
+
72
+ return unique
73
+
74
+ def _discover_skills(self) -> dict[str, SkillInfo]:
75
+ skills: dict[str, SkillInfo] = {}
76
+ for base in self._search_paths:
77
+ if not base.is_dir():
78
+ continue
79
+ for name, info in self._discover_skills_in_dir(base).items():
80
+ if name not in skills:
81
+ skills[name] = info
82
+ else:
83
+ logger.debug(
84
+ "Skipping duplicate skill '%s' at %s (already loaded from %s)",
85
+ name,
86
+ info.skill_path,
87
+ skills[name].skill_path,
88
+ )
89
+ return skills
90
+
91
+ def _discover_skills_in_dir(self, base: Path) -> dict[str, SkillInfo]:
92
+ skills: dict[str, SkillInfo] = {}
93
+ for skill_dir in base.iterdir():
94
+ if not skill_dir.is_dir():
95
+ continue
96
+ skill_file = skill_dir / "SKILL.md"
97
+ if not skill_file.is_file():
98
+ continue
99
+ if (skill_info := self._try_load_skill(skill_file)) is not None:
100
+ skills[skill_info.name] = skill_info
101
+ return skills
102
+
103
+ def _try_load_skill(self, skill_file: Path) -> SkillInfo | None:
104
+ try:
105
+ skill_info = self._parse_skill_file(skill_file)
106
+ except Exception as e:
107
+ logger.warning("Failed to parse skill at %s: %s", skill_file, e)
108
+ return None
109
+ return skill_info
110
+
111
+ def _parse_skill_file(self, skill_path: Path) -> SkillInfo:
112
+ try:
113
+ content = skill_path.read_text(encoding="utf-8")
114
+ except OSError as e:
115
+ raise SkillParseError(f"Cannot read file: {e}") from e
116
+
117
+ frontmatter, _ = parse_frontmatter(content)
118
+ metadata = SkillMetadata.model_validate(frontmatter)
119
+
120
+ skill_name_from_dir = skill_path.parent.name
121
+ if metadata.name != skill_name_from_dir:
122
+ logger.warning(
123
+ "Skill name '%s' doesn't match directory name '%s' at %s",
124
+ metadata.name,
125
+ skill_name_from_dir,
126
+ skill_path,
127
+ )
128
+
129
+ return SkillInfo.from_metadata(metadata, skill_path)
130
+
131
+ def get_skill(self, name: str) -> SkillInfo | None:
132
+ return self.available_skills.get(name)
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field, field_validator
7
+
8
+
9
+ class SkillMetadata(BaseModel):
10
+ model_config = {"populate_by_name": True}
11
+
12
+ name: str = Field(
13
+ ...,
14
+ min_length=1,
15
+ max_length=64,
16
+ pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$",
17
+ description="Skill identifier. Lowercase letters, numbers, and hyphens only.",
18
+ )
19
+ description: str = Field(
20
+ ...,
21
+ min_length=1,
22
+ max_length=1024,
23
+ description="What this skill does and when to use it.",
24
+ )
25
+ license: str | None = Field(
26
+ default=None, description="License name or reference to a bundled license file."
27
+ )
28
+ compatibility: str | None = Field(
29
+ default=None,
30
+ max_length=500,
31
+ description="Environment requirements (intended product, system packages, etc.).",
32
+ )
33
+ metadata: dict[str, str] = Field(
34
+ default_factory=dict,
35
+ description="Arbitrary key-value mapping for additional metadata.",
36
+ )
37
+ allowed_tools: list[str] = Field(
38
+ default_factory=list,
39
+ validation_alias="allowed-tools",
40
+ description="Space-delimited list of pre-approved tools (experimental).",
41
+ )
42
+ user_invocable: bool = Field(
43
+ default=True,
44
+ validation_alias="user-invocable",
45
+ description="Controls whether the skill appears in the slash command menu.",
46
+ )
47
+
48
+ @field_validator("allowed_tools", mode="before")
49
+ @classmethod
50
+ def parse_allowed_tools(cls, v: str | list[str] | None) -> list[str]:
51
+ if v is None:
52
+ return []
53
+ if isinstance(v, str):
54
+ return v.split()
55
+ return list(v)
56
+
57
+ @field_validator("metadata", mode="before")
58
+ @classmethod
59
+ def normalize_metadata(cls, v: dict[str, Any] | None) -> dict[str, str]:
60
+ if v is None:
61
+ return {}
62
+ return {str(k): str(val) for k, val in v.items()}
63
+
64
+
65
+ class SkillInfo(BaseModel):
66
+ name: str
67
+ description: str
68
+ license: str | None = None
69
+ compatibility: str | None = None
70
+ metadata: dict[str, str] = Field(default_factory=dict)
71
+ allowed_tools: list[str] = Field(default_factory=list)
72
+ user_invocable: bool = True
73
+ skill_path: Path
74
+
75
+ model_config = {"arbitrary_types_allowed": True}
76
+
77
+ @property
78
+ def skill_dir(self) -> Path:
79
+ return self.skill_path.parent.resolve()
80
+
81
+ @classmethod
82
+ def from_metadata(cls, meta: SkillMetadata, skill_path: Path) -> SkillInfo:
83
+ return cls(
84
+ name=meta.name,
85
+ description=meta.description,
86
+ license=meta.license,
87
+ compatibility=meta.compatibility,
88
+ metadata=meta.metadata,
89
+ allowed_tools=meta.allowed_tools,
90
+ user_invocable=meta.user_invocable,
91
+ skill_path=skill_path.resolve(),
92
+ )
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+
9
+ class SkillParseError(Exception):
10
+ def __init__(self, reason: str) -> None:
11
+ super().__init__(reason)
12
+ self.reason = reason
13
+
14
+
15
+ FM_BOUNDARY = re.compile(r"^-{3,}\s*$", re.MULTILINE)
16
+
17
+
18
+ def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
19
+ splits = FM_BOUNDARY.split(content, 2)
20
+ if len(splits) < 3 or splits[0].strip(): # noqa: PLR2004
21
+ raise SkillParseError(
22
+ "Missing or invalid YAML frontmatter (metadata section must start and end with ---)"
23
+ )
24
+
25
+ yaml_content = splits[1]
26
+ markdown_body = splits[2]
27
+
28
+ try:
29
+ frontmatter = yaml.safe_load(yaml_content)
30
+ except yaml.YAMLError as e:
31
+ raise SkillParseError(f"Invalid YAML frontmatter: {e}") from e
32
+
33
+ if frontmatter is None:
34
+ frontmatter = {}
35
+
36
+ if not isinstance(frontmatter, dict):
37
+ raise SkillParseError("YAML frontmatter must be a mapping/dictionary")
38
+
39
+ return frontmatter, markdown_body