gobby 0.2.5__py3-none-any.whl → 0.2.6__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 (148) hide show
  1. gobby/adapters/claude_code.py +13 -4
  2. gobby/adapters/codex.py +43 -3
  3. gobby/agents/runner.py +8 -0
  4. gobby/cli/__init__.py +6 -0
  5. gobby/cli/clones.py +419 -0
  6. gobby/cli/conductor.py +266 -0
  7. gobby/cli/installers/antigravity.py +3 -9
  8. gobby/cli/installers/claude.py +9 -9
  9. gobby/cli/installers/codex.py +2 -8
  10. gobby/cli/installers/gemini.py +2 -8
  11. gobby/cli/installers/shared.py +71 -8
  12. gobby/cli/skills.py +858 -0
  13. gobby/cli/tasks/ai.py +0 -440
  14. gobby/cli/tasks/crud.py +44 -6
  15. gobby/cli/tasks/main.py +0 -4
  16. gobby/cli/tui.py +2 -2
  17. gobby/cli/utils.py +3 -3
  18. gobby/clones/__init__.py +13 -0
  19. gobby/clones/git.py +547 -0
  20. gobby/conductor/__init__.py +16 -0
  21. gobby/conductor/alerts.py +135 -0
  22. gobby/conductor/loop.py +164 -0
  23. gobby/conductor/monitors/__init__.py +11 -0
  24. gobby/conductor/monitors/agents.py +116 -0
  25. gobby/conductor/monitors/tasks.py +155 -0
  26. gobby/conductor/pricing.py +234 -0
  27. gobby/conductor/token_tracker.py +160 -0
  28. gobby/config/app.py +63 -1
  29. gobby/config/search.py +110 -0
  30. gobby/config/servers.py +1 -1
  31. gobby/config/skills.py +43 -0
  32. gobby/config/tasks.py +6 -14
  33. gobby/hooks/event_handlers.py +145 -2
  34. gobby/hooks/hook_manager.py +48 -2
  35. gobby/hooks/skill_manager.py +130 -0
  36. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  37. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  38. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  39. gobby/llm/claude.py +22 -34
  40. gobby/llm/claude_executor.py +46 -256
  41. gobby/llm/codex_executor.py +59 -291
  42. gobby/llm/executor.py +21 -0
  43. gobby/llm/gemini.py +134 -110
  44. gobby/llm/litellm_executor.py +143 -6
  45. gobby/llm/resolver.py +95 -33
  46. gobby/mcp_proxy/instructions.py +54 -0
  47. gobby/mcp_proxy/models.py +15 -0
  48. gobby/mcp_proxy/registries.py +68 -5
  49. gobby/mcp_proxy/server.py +33 -3
  50. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  51. gobby/mcp_proxy/stdio.py +2 -1
  52. gobby/mcp_proxy/tools/__init__.py +0 -2
  53. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  54. gobby/mcp_proxy/tools/clones.py +903 -0
  55. gobby/mcp_proxy/tools/memory.py +1 -24
  56. gobby/mcp_proxy/tools/metrics.py +65 -1
  57. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  58. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  59. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  60. gobby/mcp_proxy/tools/session_messages.py +1 -2
  61. gobby/mcp_proxy/tools/skills/__init__.py +631 -0
  62. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  63. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  64. gobby/mcp_proxy/tools/task_sync.py +1 -1
  65. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  66. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  67. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  68. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  69. gobby/mcp_proxy/tools/tasks/_lifecycle.py +60 -29
  70. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  71. gobby/mcp_proxy/tools/workflows.py +1 -1
  72. gobby/mcp_proxy/tools/worktrees.py +5 -0
  73. gobby/memory/backends/__init__.py +6 -1
  74. gobby/memory/backends/mem0.py +6 -1
  75. gobby/memory/extractor.py +477 -0
  76. gobby/memory/manager.py +11 -2
  77. gobby/prompts/defaults/handoff/compact.md +63 -0
  78. gobby/prompts/defaults/handoff/session_end.md +57 -0
  79. gobby/prompts/defaults/memory/extract.md +61 -0
  80. gobby/runner.py +37 -16
  81. gobby/search/__init__.py +48 -6
  82. gobby/search/backends/__init__.py +159 -0
  83. gobby/search/backends/embedding.py +225 -0
  84. gobby/search/embeddings.py +238 -0
  85. gobby/search/models.py +148 -0
  86. gobby/search/unified.py +496 -0
  87. gobby/servers/http.py +23 -8
  88. gobby/servers/routes/admin.py +280 -0
  89. gobby/servers/routes/mcp/tools.py +241 -52
  90. gobby/servers/websocket.py +2 -2
  91. gobby/sessions/analyzer.py +2 -0
  92. gobby/sessions/transcripts/base.py +1 -0
  93. gobby/sessions/transcripts/claude.py +64 -5
  94. gobby/skills/__init__.py +91 -0
  95. gobby/skills/loader.py +685 -0
  96. gobby/skills/manager.py +384 -0
  97. gobby/skills/parser.py +258 -0
  98. gobby/skills/search.py +463 -0
  99. gobby/skills/sync.py +119 -0
  100. gobby/skills/updater.py +385 -0
  101. gobby/skills/validator.py +368 -0
  102. gobby/storage/clones.py +378 -0
  103. gobby/storage/database.py +1 -1
  104. gobby/storage/memories.py +43 -13
  105. gobby/storage/migrations.py +180 -6
  106. gobby/storage/sessions.py +73 -0
  107. gobby/storage/skills.py +749 -0
  108. gobby/storage/tasks/_crud.py +4 -4
  109. gobby/storage/tasks/_lifecycle.py +41 -6
  110. gobby/storage/tasks/_manager.py +14 -5
  111. gobby/storage/tasks/_models.py +8 -3
  112. gobby/sync/memories.py +39 -4
  113. gobby/sync/tasks.py +83 -6
  114. gobby/tasks/__init__.py +1 -2
  115. gobby/tasks/validation.py +24 -15
  116. gobby/tui/api_client.py +4 -7
  117. gobby/tui/app.py +5 -3
  118. gobby/tui/screens/orchestrator.py +1 -2
  119. gobby/tui/screens/tasks.py +2 -4
  120. gobby/tui/ws_client.py +1 -1
  121. gobby/utils/daemon_client.py +2 -2
  122. gobby/workflows/actions.py +84 -2
  123. gobby/workflows/context_actions.py +43 -0
  124. gobby/workflows/detection_helpers.py +115 -31
  125. gobby/workflows/engine.py +13 -2
  126. gobby/workflows/lifecycle_evaluator.py +29 -1
  127. gobby/workflows/loader.py +19 -6
  128. gobby/workflows/memory_actions.py +74 -0
  129. gobby/workflows/summary_actions.py +17 -0
  130. gobby/workflows/task_enforcement_actions.py +448 -6
  131. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/METADATA +82 -21
  132. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/RECORD +136 -107
  133. gobby/install/codex/prompts/forget.md +0 -7
  134. gobby/install/codex/prompts/memories.md +0 -7
  135. gobby/install/codex/prompts/recall.md +0 -7
  136. gobby/install/codex/prompts/remember.md +0 -13
  137. gobby/llm/gemini_executor.py +0 -339
  138. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  139. gobby/tasks/context.py +0 -747
  140. gobby/tasks/criteria.py +0 -342
  141. gobby/tasks/expansion.py +0 -626
  142. gobby/tasks/prompts/expand.py +0 -327
  143. gobby/tasks/research.py +0 -421
  144. gobby/tasks/tdd.py +0 -352
  145. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/WHEEL +0 -0
  146. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/entry_points.txt +0 -0
  147. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/licenses/LICENSE.md +0 -0
  148. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,384 @@
1
+ """SkillManager - Coordinator for skill storage and search.
2
+
3
+ This module provides the SkillManager class which coordinates:
4
+ - Storage operations (LocalSkillManager)
5
+ - Search functionality (SkillSearch)
6
+ - Change notifications for automatic reindexing
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from gobby.skills.search import SearchFilters, SkillSearch, SkillSearchResult
15
+ from gobby.storage.skills import (
16
+ ChangeEvent,
17
+ LocalSkillManager,
18
+ Skill,
19
+ SkillChangeNotifier,
20
+ SkillSourceType,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from gobby.storage.database import DatabaseProtocol
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class SkillManager:
30
+ """Coordinates skill storage and search operations.
31
+
32
+ This class provides a unified interface for skill management,
33
+ wiring together:
34
+ - LocalSkillManager for persistent storage
35
+ - SkillSearch for TF-IDF based search
36
+ - SkillChangeNotifier for automatic reindex tracking
37
+
38
+ Example usage:
39
+ ```python
40
+ from gobby.skills.manager import SkillManager
41
+ from gobby.storage.database import LocalDatabase
42
+
43
+ db = LocalDatabase("gobby-hub.db")
44
+ manager = SkillManager(db)
45
+
46
+ # Create a skill
47
+ skill = manager.create_skill(
48
+ name="commit-message",
49
+ description="Generate commit messages",
50
+ content="# Instructions...",
51
+ )
52
+
53
+ # Search for skills
54
+ manager.reindex()
55
+ results = manager.search("git commit")
56
+ ```
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ db: DatabaseProtocol,
62
+ project_id: str | None = None,
63
+ ):
64
+ """Initialize the skill manager.
65
+
66
+ Args:
67
+ db: Database connection for storage
68
+ project_id: Optional default project scope
69
+ """
70
+ self._project_id = project_id
71
+
72
+ # Set up change notifier
73
+ self._notifier = SkillChangeNotifier()
74
+ self._notifier.add_listener(self._on_skill_change)
75
+
76
+ # Initialize storage with notifier
77
+ self._storage = LocalSkillManager(db, notifier=self._notifier)
78
+
79
+ # Initialize search
80
+ self._search = SkillSearch()
81
+
82
+ @property
83
+ def storage(self) -> LocalSkillManager:
84
+ """Get the storage manager."""
85
+ return self._storage
86
+
87
+ @property
88
+ def search(self) -> SkillSearch:
89
+ """Get the search instance."""
90
+ return self._search
91
+
92
+ def _on_skill_change(self, event: ChangeEvent) -> None:
93
+ """Handle skill change events for search tracking.
94
+
95
+ Args:
96
+ event: The change event
97
+ """
98
+ if event.event_type == "create":
99
+ # Get the full skill for search
100
+ try:
101
+ skill = self._storage.get_skill(event.skill_id)
102
+ try:
103
+ self._search.add_skill(skill)
104
+ except Exception as e:
105
+ logger.error(
106
+ f"Failed to add skill to search index "
107
+ f"(event={event.event_type}, skill_id={event.skill_id}): {e}"
108
+ )
109
+ except ValueError as e:
110
+ logger.debug(
111
+ f"Failed to get skill for {event.event_type} event "
112
+ f"(skill_id={event.skill_id}): {e}"
113
+ )
114
+ elif event.event_type == "update":
115
+ try:
116
+ skill = self._storage.get_skill(event.skill_id)
117
+ try:
118
+ self._search.update_skill(skill)
119
+ except Exception as e:
120
+ logger.error(
121
+ f"Failed to update skill in search index "
122
+ f"(event={event.event_type}, skill_id={event.skill_id}): {e}"
123
+ )
124
+ except ValueError as e:
125
+ logger.debug(
126
+ f"Failed to get skill for {event.event_type} event "
127
+ f"(skill_id={event.skill_id}): {e}"
128
+ )
129
+ elif event.event_type == "delete":
130
+ try:
131
+ self._search.remove_skill(event.skill_id)
132
+ except Exception as e:
133
+ logger.error(
134
+ f"Failed to remove skill from search index "
135
+ f"(event={event.event_type}, skill_id={event.skill_id}): {e}"
136
+ )
137
+
138
+ # --- CRUD Operations ---
139
+
140
+ def create_skill(
141
+ self,
142
+ name: str,
143
+ description: str,
144
+ content: str,
145
+ version: str | None = None,
146
+ license: str | None = None,
147
+ compatibility: str | None = None,
148
+ allowed_tools: list[str] | None = None,
149
+ metadata: dict[str, Any] | None = None,
150
+ source_path: str | None = None,
151
+ source_type: SkillSourceType | None = None,
152
+ source_ref: str | None = None,
153
+ enabled: bool = True,
154
+ project_id: str | None = None,
155
+ ) -> Skill:
156
+ """Create a new skill.
157
+
158
+ Args:
159
+ name: Skill name
160
+ description: Skill description
161
+ content: Markdown content
162
+ version: Optional version string
163
+ license: Optional license identifier
164
+ compatibility: Optional compatibility notes
165
+ allowed_tools: Optional allowed tool patterns
166
+ metadata: Optional metadata (includes skillport/gobby namespaces)
167
+ source_path: Original source path
168
+ source_type: Source type
169
+ source_ref: Git ref for updates
170
+ enabled: Whether skill is active
171
+ project_id: Project scope (uses default if not specified)
172
+
173
+ Returns:
174
+ The created Skill
175
+ """
176
+ return self._storage.create_skill(
177
+ name=name,
178
+ description=description,
179
+ content=content,
180
+ version=version,
181
+ license=license,
182
+ compatibility=compatibility,
183
+ allowed_tools=allowed_tools,
184
+ metadata=metadata,
185
+ source_path=source_path,
186
+ source_type=source_type,
187
+ source_ref=source_ref,
188
+ enabled=enabled,
189
+ project_id=project_id or self._project_id,
190
+ )
191
+
192
+ def get_skill(self, skill_id: str) -> Skill:
193
+ """Get a skill by ID.
194
+
195
+ Args:
196
+ skill_id: The skill ID
197
+
198
+ Returns:
199
+ The Skill
200
+
201
+ Raises:
202
+ ValueError: If not found
203
+ """
204
+ return self._storage.get_skill(skill_id)
205
+
206
+ def get_by_name(
207
+ self,
208
+ name: str,
209
+ project_id: str | None = None,
210
+ ) -> Skill | None:
211
+ """Get a skill by name.
212
+
213
+ Args:
214
+ name: Skill name
215
+ project_id: Project scope (uses default if not specified)
216
+
217
+ Returns:
218
+ The Skill if found, None otherwise
219
+ """
220
+ return self._storage.get_by_name(
221
+ name,
222
+ project_id=project_id or self._project_id,
223
+ )
224
+
225
+ def update_skill(
226
+ self,
227
+ skill_id: str,
228
+ name: str | None = None,
229
+ description: str | None = None,
230
+ content: str | None = None,
231
+ **kwargs: Any,
232
+ ) -> Skill:
233
+ """Update a skill.
234
+
235
+ Args:
236
+ skill_id: ID of the skill to update
237
+ name: New name (optional)
238
+ description: New description (optional)
239
+ content: New content (optional)
240
+ **kwargs: Additional fields to update
241
+
242
+ Returns:
243
+ The updated Skill
244
+ """
245
+ return self._storage.update_skill(
246
+ skill_id=skill_id,
247
+ name=name,
248
+ description=description,
249
+ content=content,
250
+ **kwargs,
251
+ )
252
+
253
+ def delete_skill(self, skill_id: str) -> bool:
254
+ """Delete a skill.
255
+
256
+ Args:
257
+ skill_id: ID of the skill to delete
258
+
259
+ Returns:
260
+ True if deleted, False if not found
261
+ """
262
+ return self._storage.delete_skill(skill_id)
263
+
264
+ def list_skills(
265
+ self,
266
+ project_id: str | None = None,
267
+ enabled: bool | None = None,
268
+ category: str | None = None,
269
+ limit: int = 50,
270
+ offset: int = 0,
271
+ include_global: bool = True,
272
+ ) -> list[Skill]:
273
+ """List skills with optional filtering.
274
+
275
+ Args:
276
+ project_id: Filter by project (uses default if not specified)
277
+ enabled: Filter by enabled state
278
+ category: Filter by category
279
+ limit: Maximum results
280
+ offset: Results to skip
281
+ include_global: Include global skills
282
+
283
+ Returns:
284
+ List of matching Skills
285
+ """
286
+ return self._storage.list_skills(
287
+ project_id=project_id or self._project_id,
288
+ enabled=enabled,
289
+ category=category,
290
+ limit=limit,
291
+ offset=offset,
292
+ include_global=include_global,
293
+ )
294
+
295
+ # --- Search Operations ---
296
+
297
+ def search_skills(
298
+ self,
299
+ query: str,
300
+ top_k: int = 10,
301
+ filters: SearchFilters | None = None,
302
+ ) -> list[SkillSearchResult]:
303
+ """Search for skills.
304
+
305
+ Args:
306
+ query: Search query
307
+ top_k: Maximum results
308
+ filters: Optional filters
309
+
310
+ Returns:
311
+ List of search results
312
+ """
313
+ return self._search.search(query, top_k=top_k, filters=filters)
314
+
315
+ def reindex(self, batch_size: int = 1000) -> None:
316
+ """Rebuild the search index from storage.
317
+
318
+ Args:
319
+ batch_size: Number of skills to fetch per batch (default: 1000)
320
+ """
321
+ all_skills: list[Skill] = []
322
+ offset = 0
323
+
324
+ # Paginate through all skills to avoid truncation
325
+ while True:
326
+ batch = self._storage.list_skills(
327
+ project_id=self._project_id,
328
+ include_global=True,
329
+ limit=batch_size,
330
+ offset=offset,
331
+ )
332
+ if not batch:
333
+ break
334
+ all_skills.extend(batch)
335
+ if len(batch) < batch_size:
336
+ # Last batch, no more results
337
+ break
338
+ offset += batch_size
339
+
340
+ self._search.index_skills(all_skills)
341
+
342
+ def needs_reindex(self) -> bool:
343
+ """Check if search index needs rebuilding.
344
+
345
+ Returns:
346
+ True if reindex() should be called
347
+ """
348
+ return self._search.needs_reindex()
349
+
350
+ # --- Core Skills ---
351
+
352
+ def list_core_skills(self, project_id: str | None = None) -> list[Skill]:
353
+ """List skills with alwaysApply=true.
354
+
355
+ Core skills are automatically included in agent prompts.
356
+
357
+ Args:
358
+ project_id: Project scope (uses default if not specified)
359
+
360
+ Returns:
361
+ List of core skills
362
+ """
363
+ return self._storage.list_core_skills(project_id=project_id or self._project_id)
364
+
365
+ # --- Utility ---
366
+
367
+ def count_skills(
368
+ self,
369
+ project_id: str | None = None,
370
+ enabled: bool | None = None,
371
+ ) -> int:
372
+ """Count skills matching criteria.
373
+
374
+ Args:
375
+ project_id: Project scope
376
+ enabled: Filter by enabled state
377
+
378
+ Returns:
379
+ Number of matching skills
380
+ """
381
+ return self._storage.count_skills(
382
+ project_id=project_id or self._project_id,
383
+ enabled=enabled,
384
+ )
gobby/skills/parser.py ADDED
@@ -0,0 +1,258 @@
1
+ """YAML frontmatter parser for SKILL.md files.
2
+
3
+ This module parses skill files following the Agent Skills specification format:
4
+ - YAML frontmatter delimited by ---
5
+ - Markdown content body
6
+
7
+ Example SKILL.md format:
8
+ ```markdown
9
+ ---
10
+ name: commit-message
11
+ description: Generate conventional commit messages
12
+ license: MIT
13
+ compatibility: Requires git CLI
14
+ metadata:
15
+ author: anthropic
16
+ version: "1.0"
17
+ skillport:
18
+ category: git
19
+ tags: [git, commits]
20
+ alwaysApply: false
21
+ gobby:
22
+ triggers: ["/commit"]
23
+ allowed-tools: Bash(git:*)
24
+ ---
25
+
26
+ # Commit Message Generator
27
+
28
+ Instructions for the skill...
29
+ ```
30
+ """
31
+
32
+ import re
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ import yaml
38
+
39
+ # Pattern to extract YAML frontmatter (content between --- delimiters)
40
+ # Allows empty frontmatter (---\n---) or content between delimiters
41
+ # Supports both LF (\n) and CRLF (\r\n) line endings
42
+ FRONTMATTER_PATTERN = re.compile(
43
+ r"^---[ \t]*\r?\n(.*?)^---[ \t]*\r?\n?",
44
+ re.DOTALL | re.MULTILINE,
45
+ )
46
+
47
+
48
+ @dataclass
49
+ class ParsedSkill:
50
+ """Parsed skill data from a SKILL.md file.
51
+
52
+ Attributes:
53
+ name: Skill name (required)
54
+ description: Skill description (required)
55
+ content: Markdown body content
56
+ version: Version string (from metadata.version or top-level)
57
+ license: License identifier
58
+ compatibility: Compatibility notes
59
+ allowed_tools: List of allowed tool patterns
60
+ metadata: Full metadata dict (includes skillport/gobby namespaces)
61
+ source_path: Path the skill was loaded from
62
+ source_type: Source type (local, github, zip, etc.)
63
+ source_ref: Git ref for GitHub imports
64
+ scripts: List of script file paths (relative to skill dir)
65
+ references: List of reference file paths (relative to skill dir)
66
+ assets: List of asset file paths (relative to skill dir)
67
+ """
68
+
69
+ name: str
70
+ description: str
71
+ content: str
72
+ version: str | None = None
73
+ license: str | None = None
74
+ compatibility: str | None = None
75
+ allowed_tools: list[str] | None = None
76
+ metadata: dict[str, Any] | None = None
77
+ source_path: str | None = None
78
+ source_type: str | None = None
79
+ source_ref: str | None = None
80
+ scripts: list[str] | None = None
81
+ references: list[str] | None = None
82
+ assets: list[str] | None = None
83
+
84
+ def get_category(self) -> str | None:
85
+ """Get category from metadata.skillport.category."""
86
+ if not self.metadata:
87
+ return None
88
+ skillport = self.metadata.get("skillport", {})
89
+ result = skillport.get("category")
90
+ return str(result) if result is not None else None
91
+
92
+ def get_tags(self) -> list[str]:
93
+ """Get tags from metadata.skillport.tags."""
94
+ if not self.metadata:
95
+ return []
96
+ skillport = self.metadata.get("skillport", {})
97
+ tags = skillport.get("tags", [])
98
+ if isinstance(tags, list):
99
+ return tags
100
+ if isinstance(tags, str):
101
+ return [tags]
102
+ return []
103
+
104
+ def is_always_apply(self) -> bool:
105
+ """Check if this is a core skill (alwaysApply=true)."""
106
+ if not self.metadata:
107
+ return False
108
+ skillport = self.metadata.get("skillport", {})
109
+ return bool(skillport.get("alwaysApply", False))
110
+
111
+ def to_dict(self) -> dict[str, Any]:
112
+ """Convert to dictionary representation."""
113
+ return {
114
+ "name": self.name,
115
+ "description": self.description,
116
+ "content": self.content,
117
+ "version": self.version,
118
+ "license": self.license,
119
+ "compatibility": self.compatibility,
120
+ "allowed_tools": self.allowed_tools,
121
+ "metadata": self.metadata,
122
+ "source_path": self.source_path,
123
+ "source_type": self.source_type,
124
+ "source_ref": self.source_ref,
125
+ "scripts": self.scripts,
126
+ "references": self.references,
127
+ "assets": self.assets,
128
+ }
129
+
130
+
131
+ class SkillParseError(Exception):
132
+ """Error parsing a skill file."""
133
+
134
+ def __init__(self, message: str, path: str | None = None):
135
+ self.path = path
136
+ super().__init__(f"{message}" + (f" in {path}" if path else ""))
137
+
138
+
139
+ def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
140
+ """Parse YAML frontmatter from text.
141
+
142
+ Args:
143
+ text: Full text content with frontmatter
144
+
145
+ Returns:
146
+ Tuple of (frontmatter dict, content body)
147
+
148
+ Raises:
149
+ SkillParseError: If frontmatter is missing or invalid
150
+ """
151
+ match = FRONTMATTER_PATTERN.match(text)
152
+ if not match:
153
+ raise SkillParseError("Missing or invalid YAML frontmatter (must start with ---)")
154
+
155
+ frontmatter_yaml = match.group(1)
156
+ content = text[match.end() :].strip()
157
+
158
+ try:
159
+ frontmatter = yaml.safe_load(frontmatter_yaml)
160
+ except yaml.YAMLError as e:
161
+ raise SkillParseError(f"Invalid YAML in frontmatter: {e}") from e
162
+
163
+ if frontmatter is None:
164
+ frontmatter = {}
165
+
166
+ if not isinstance(frontmatter, dict):
167
+ raise SkillParseError("Frontmatter must be a YAML mapping")
168
+
169
+ return frontmatter, content
170
+
171
+
172
+ def parse_skill_text(text: str, source_path: str | None = None) -> ParsedSkill:
173
+ """Parse a skill from text content.
174
+
175
+ Args:
176
+ text: Full skill file content (frontmatter + markdown)
177
+ source_path: Optional path for error messages
178
+
179
+ Returns:
180
+ ParsedSkill with extracted data
181
+
182
+ Raises:
183
+ SkillParseError: If parsing fails or required fields missing
184
+ """
185
+ try:
186
+ frontmatter, content = parse_frontmatter(text)
187
+ except SkillParseError as e:
188
+ # Create a new exception with the path included in the message
189
+ raise SkillParseError(str(e), path=source_path) from e
190
+
191
+ # Extract required fields
192
+ name = frontmatter.get("name")
193
+ if not name:
194
+ raise SkillParseError("Missing required field: name", source_path)
195
+
196
+ description = frontmatter.get("description")
197
+ if not description:
198
+ raise SkillParseError("Missing required field: description", source_path)
199
+
200
+ # Extract optional fields
201
+ license_str = frontmatter.get("license")
202
+ compatibility = frontmatter.get("compatibility")
203
+
204
+ # Handle allowed-tools (can be string or list)
205
+ allowed_tools_raw = frontmatter.get("allowed-tools") or frontmatter.get("allowed_tools")
206
+ allowed_tools: list[str] | None = None
207
+ if allowed_tools_raw:
208
+ if isinstance(allowed_tools_raw, str):
209
+ # Single tool or comma-separated
210
+ allowed_tools = [t.strip() for t in allowed_tools_raw.split(",")]
211
+ elif isinstance(allowed_tools_raw, list):
212
+ allowed_tools = [str(t) for t in allowed_tools_raw]
213
+
214
+ # Extract metadata (may contain version, skillport, gobby namespaces)
215
+ metadata = frontmatter.get("metadata")
216
+
217
+ # Version can be at top level or in metadata
218
+ version = frontmatter.get("version")
219
+ if version is None and metadata and isinstance(metadata, dict):
220
+ version = metadata.get("version")
221
+
222
+ # Convert version to string if it's a number (e.g., 1.0 parsed as float)
223
+ if version is not None:
224
+ version = str(version)
225
+
226
+ return ParsedSkill(
227
+ name=name,
228
+ description=description,
229
+ content=content,
230
+ version=version,
231
+ license=license_str,
232
+ compatibility=compatibility,
233
+ allowed_tools=allowed_tools,
234
+ metadata=metadata,
235
+ source_path=source_path,
236
+ )
237
+
238
+
239
+ def parse_skill_file(path: str | Path) -> ParsedSkill:
240
+ """Parse a skill from a file path.
241
+
242
+ Args:
243
+ path: Path to SKILL.md file
244
+
245
+ Returns:
246
+ ParsedSkill with extracted data
247
+
248
+ Raises:
249
+ FileNotFoundError: If file doesn't exist
250
+ SkillParseError: If parsing fails (propagated from parse_skill_text)
251
+ """
252
+ path = Path(path)
253
+
254
+ if not path.exists():
255
+ raise FileNotFoundError(f"Skill file not found: {path}")
256
+
257
+ text = path.read_text(encoding="utf-8")
258
+ return parse_skill_text(text, source_path=str(path))