gobby 0.2.5__py3-none-any.whl → 0.2.7__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 (244) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +13 -4
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/agents/definitions.py +11 -1
  10. gobby/agents/isolation.py +395 -0
  11. gobby/agents/runner.py +8 -0
  12. gobby/agents/sandbox.py +261 -0
  13. gobby/agents/spawn.py +42 -287
  14. gobby/agents/spawn_executor.py +385 -0
  15. gobby/agents/spawners/__init__.py +24 -0
  16. gobby/agents/spawners/command_builder.py +189 -0
  17. gobby/agents/spawners/embedded.py +21 -2
  18. gobby/agents/spawners/headless.py +21 -2
  19. gobby/agents/spawners/prompt_manager.py +125 -0
  20. gobby/cli/__init__.py +6 -0
  21. gobby/cli/clones.py +419 -0
  22. gobby/cli/conductor.py +266 -0
  23. gobby/cli/install.py +4 -4
  24. gobby/cli/installers/antigravity.py +3 -9
  25. gobby/cli/installers/claude.py +15 -9
  26. gobby/cli/installers/codex.py +2 -8
  27. gobby/cli/installers/gemini.py +8 -8
  28. gobby/cli/installers/shared.py +175 -13
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/skills.py +858 -0
  31. gobby/cli/tasks/ai.py +0 -440
  32. gobby/cli/tasks/crud.py +44 -6
  33. gobby/cli/tasks/main.py +0 -4
  34. gobby/cli/tui.py +2 -2
  35. gobby/cli/utils.py +12 -5
  36. gobby/clones/__init__.py +13 -0
  37. gobby/clones/git.py +547 -0
  38. gobby/conductor/__init__.py +16 -0
  39. gobby/conductor/alerts.py +135 -0
  40. gobby/conductor/loop.py +164 -0
  41. gobby/conductor/monitors/__init__.py +11 -0
  42. gobby/conductor/monitors/agents.py +116 -0
  43. gobby/conductor/monitors/tasks.py +155 -0
  44. gobby/conductor/pricing.py +234 -0
  45. gobby/conductor/token_tracker.py +160 -0
  46. gobby/config/__init__.py +12 -97
  47. gobby/config/app.py +69 -91
  48. gobby/config/extensions.py +2 -2
  49. gobby/config/features.py +7 -130
  50. gobby/config/search.py +110 -0
  51. gobby/config/servers.py +1 -1
  52. gobby/config/skills.py +43 -0
  53. gobby/config/tasks.py +9 -41
  54. gobby/hooks/__init__.py +0 -13
  55. gobby/hooks/event_handlers.py +188 -2
  56. gobby/hooks/hook_manager.py +50 -4
  57. gobby/hooks/plugins.py +1 -1
  58. gobby/hooks/skill_manager.py +130 -0
  59. gobby/hooks/webhooks.py +1 -1
  60. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  61. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  62. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  63. gobby/llm/claude.py +22 -34
  64. gobby/llm/claude_executor.py +46 -256
  65. gobby/llm/codex_executor.py +59 -291
  66. gobby/llm/executor.py +21 -0
  67. gobby/llm/gemini.py +134 -110
  68. gobby/llm/litellm_executor.py +143 -6
  69. gobby/llm/resolver.py +98 -35
  70. gobby/mcp_proxy/importer.py +62 -4
  71. gobby/mcp_proxy/instructions.py +56 -0
  72. gobby/mcp_proxy/models.py +15 -0
  73. gobby/mcp_proxy/registries.py +68 -8
  74. gobby/mcp_proxy/server.py +33 -3
  75. gobby/mcp_proxy/services/recommendation.py +43 -11
  76. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  77. gobby/mcp_proxy/stdio.py +2 -1
  78. gobby/mcp_proxy/tools/__init__.py +0 -2
  79. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  80. gobby/mcp_proxy/tools/agents.py +31 -731
  81. gobby/mcp_proxy/tools/clones.py +518 -0
  82. gobby/mcp_proxy/tools/memory.py +3 -26
  83. gobby/mcp_proxy/tools/metrics.py +65 -1
  84. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  85. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  86. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  87. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  88. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  89. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  90. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  91. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  92. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  93. gobby/mcp_proxy/tools/skills/__init__.py +616 -0
  94. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  95. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  96. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  97. gobby/mcp_proxy/tools/task_sync.py +1 -1
  98. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  99. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  100. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  101. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  102. gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
  103. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  104. gobby/mcp_proxy/tools/workflows.py +1 -1
  105. gobby/mcp_proxy/tools/worktrees.py +0 -338
  106. gobby/memory/backends/__init__.py +6 -1
  107. gobby/memory/backends/mem0.py +6 -1
  108. gobby/memory/extractor.py +477 -0
  109. gobby/memory/ingestion/__init__.py +5 -0
  110. gobby/memory/ingestion/multimodal.py +221 -0
  111. gobby/memory/manager.py +73 -285
  112. gobby/memory/search/__init__.py +10 -0
  113. gobby/memory/search/coordinator.py +248 -0
  114. gobby/memory/services/__init__.py +5 -0
  115. gobby/memory/services/crossref.py +142 -0
  116. gobby/prompts/loader.py +5 -2
  117. gobby/runner.py +37 -16
  118. gobby/search/__init__.py +48 -6
  119. gobby/search/backends/__init__.py +159 -0
  120. gobby/search/backends/embedding.py +225 -0
  121. gobby/search/embeddings.py +238 -0
  122. gobby/search/models.py +148 -0
  123. gobby/search/unified.py +496 -0
  124. gobby/servers/http.py +24 -12
  125. gobby/servers/routes/admin.py +294 -0
  126. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  127. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  128. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  129. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  130. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  131. gobby/servers/routes/mcp/hooks.py +1 -1
  132. gobby/servers/routes/mcp/tools.py +48 -1317
  133. gobby/servers/websocket.py +2 -2
  134. gobby/sessions/analyzer.py +2 -0
  135. gobby/sessions/lifecycle.py +1 -1
  136. gobby/sessions/processor.py +10 -0
  137. gobby/sessions/transcripts/base.py +2 -0
  138. gobby/sessions/transcripts/claude.py +79 -10
  139. gobby/skills/__init__.py +91 -0
  140. gobby/skills/loader.py +685 -0
  141. gobby/skills/manager.py +384 -0
  142. gobby/skills/parser.py +286 -0
  143. gobby/skills/search.py +463 -0
  144. gobby/skills/sync.py +119 -0
  145. gobby/skills/updater.py +385 -0
  146. gobby/skills/validator.py +368 -0
  147. gobby/storage/clones.py +378 -0
  148. gobby/storage/database.py +1 -1
  149. gobby/storage/memories.py +43 -13
  150. gobby/storage/migrations.py +162 -201
  151. gobby/storage/sessions.py +116 -7
  152. gobby/storage/skills.py +782 -0
  153. gobby/storage/tasks/_crud.py +4 -4
  154. gobby/storage/tasks/_lifecycle.py +57 -7
  155. gobby/storage/tasks/_manager.py +14 -5
  156. gobby/storage/tasks/_models.py +8 -3
  157. gobby/sync/memories.py +40 -5
  158. gobby/sync/tasks.py +83 -6
  159. gobby/tasks/__init__.py +1 -2
  160. gobby/tasks/external_validator.py +1 -1
  161. gobby/tasks/validation.py +46 -35
  162. gobby/tools/summarizer.py +91 -10
  163. gobby/tui/api_client.py +4 -7
  164. gobby/tui/app.py +5 -3
  165. gobby/tui/screens/orchestrator.py +1 -2
  166. gobby/tui/screens/tasks.py +2 -4
  167. gobby/tui/ws_client.py +1 -1
  168. gobby/utils/daemon_client.py +2 -2
  169. gobby/utils/project_context.py +2 -3
  170. gobby/utils/status.py +13 -0
  171. gobby/workflows/actions.py +221 -1135
  172. gobby/workflows/artifact_actions.py +31 -0
  173. gobby/workflows/autonomous_actions.py +11 -0
  174. gobby/workflows/context_actions.py +93 -1
  175. gobby/workflows/detection_helpers.py +115 -31
  176. gobby/workflows/enforcement/__init__.py +47 -0
  177. gobby/workflows/enforcement/blocking.py +269 -0
  178. gobby/workflows/enforcement/commit_policy.py +283 -0
  179. gobby/workflows/enforcement/handlers.py +269 -0
  180. gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
  181. gobby/workflows/engine.py +13 -2
  182. gobby/workflows/git_utils.py +106 -0
  183. gobby/workflows/lifecycle_evaluator.py +29 -1
  184. gobby/workflows/llm_actions.py +30 -0
  185. gobby/workflows/loader.py +19 -6
  186. gobby/workflows/mcp_actions.py +20 -1
  187. gobby/workflows/memory_actions.py +154 -0
  188. gobby/workflows/safe_evaluator.py +183 -0
  189. gobby/workflows/session_actions.py +44 -0
  190. gobby/workflows/state_actions.py +60 -1
  191. gobby/workflows/stop_signal_actions.py +55 -0
  192. gobby/workflows/summary_actions.py +111 -1
  193. gobby/workflows/task_sync_actions.py +347 -0
  194. gobby/workflows/todo_actions.py +34 -1
  195. gobby/workflows/webhook_actions.py +185 -0
  196. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
  197. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
  198. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  199. gobby/adapters/codex.py +0 -1292
  200. gobby/install/claude/commands/gobby/bug.md +0 -51
  201. gobby/install/claude/commands/gobby/chore.md +0 -51
  202. gobby/install/claude/commands/gobby/epic.md +0 -52
  203. gobby/install/claude/commands/gobby/eval.md +0 -235
  204. gobby/install/claude/commands/gobby/feat.md +0 -49
  205. gobby/install/claude/commands/gobby/nit.md +0 -52
  206. gobby/install/claude/commands/gobby/ref.md +0 -52
  207. gobby/install/codex/prompts/forget.md +0 -7
  208. gobby/install/codex/prompts/memories.md +0 -7
  209. gobby/install/codex/prompts/recall.md +0 -7
  210. gobby/install/codex/prompts/remember.md +0 -13
  211. gobby/llm/gemini_executor.py +0 -339
  212. gobby/mcp_proxy/tools/session_messages.py +0 -1056
  213. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  214. gobby/prompts/defaults/expansion/system.md +0 -119
  215. gobby/prompts/defaults/expansion/user.md +0 -48
  216. gobby/prompts/defaults/external_validation/agent.md +0 -72
  217. gobby/prompts/defaults/external_validation/external.md +0 -63
  218. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  219. gobby/prompts/defaults/external_validation/system.md +0 -6
  220. gobby/prompts/defaults/features/import_mcp.md +0 -22
  221. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  222. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  223. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  224. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  225. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  226. gobby/prompts/defaults/features/server_description.md +0 -20
  227. gobby/prompts/defaults/features/server_description_system.md +0 -6
  228. gobby/prompts/defaults/features/task_description.md +0 -31
  229. gobby/prompts/defaults/features/task_description_system.md +0 -6
  230. gobby/prompts/defaults/features/tool_summary.md +0 -17
  231. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  232. gobby/prompts/defaults/research/step.md +0 -58
  233. gobby/prompts/defaults/validation/criteria.md +0 -47
  234. gobby/prompts/defaults/validation/validate.md +0 -38
  235. gobby/storage/migrations_legacy.py +0 -1359
  236. gobby/tasks/context.py +0 -747
  237. gobby/tasks/criteria.py +0 -342
  238. gobby/tasks/expansion.py +0 -626
  239. gobby/tasks/prompts/expand.py +0 -327
  240. gobby/tasks/research.py +0 -421
  241. gobby/tasks/tdd.py +0 -352
  242. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  243. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  244. {gobby-0.2.5.dist-info → gobby-0.2.7.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,286 @@
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 top-level or metadata.skillport.category."""
86
+ if not self.metadata:
87
+ return None
88
+ # Check top-level first (from frontmatter)
89
+ result = self.metadata.get("category")
90
+ if result is not None:
91
+ return str(result)
92
+ # Fall back to nested skillport.category
93
+ skillport = self.metadata.get("skillport", {})
94
+ result = skillport.get("category")
95
+ return str(result) if result is not None else None
96
+
97
+ def get_tags(self) -> list[str]:
98
+ """Get tags from metadata.skillport.tags."""
99
+ if not self.metadata:
100
+ return []
101
+ skillport = self.metadata.get("skillport", {})
102
+ tags = skillport.get("tags", [])
103
+ if isinstance(tags, list):
104
+ return tags
105
+ if isinstance(tags, str):
106
+ return [tags]
107
+ return []
108
+
109
+ def is_always_apply(self) -> bool:
110
+ """Check if this is a core skill (alwaysApply=true).
111
+
112
+ Supports both top-level alwaysApply and nested metadata.skillport.alwaysApply.
113
+ Top-level takes precedence.
114
+ """
115
+ if not self.metadata:
116
+ return False
117
+ # Check top-level first (from frontmatter)
118
+ top_level = self.metadata.get("alwaysApply")
119
+ if top_level is not None:
120
+ return bool(top_level)
121
+ # Fall back to nested skillport.alwaysApply
122
+ skillport = self.metadata.get("skillport", {})
123
+ return bool(skillport.get("alwaysApply", False))
124
+
125
+ def to_dict(self) -> dict[str, Any]:
126
+ """Convert to dictionary representation."""
127
+ return {
128
+ "name": self.name,
129
+ "description": self.description,
130
+ "content": self.content,
131
+ "version": self.version,
132
+ "license": self.license,
133
+ "compatibility": self.compatibility,
134
+ "allowed_tools": self.allowed_tools,
135
+ "metadata": self.metadata,
136
+ "source_path": self.source_path,
137
+ "source_type": self.source_type,
138
+ "source_ref": self.source_ref,
139
+ "scripts": self.scripts,
140
+ "references": self.references,
141
+ "assets": self.assets,
142
+ }
143
+
144
+
145
+ class SkillParseError(Exception):
146
+ """Error parsing a skill file."""
147
+
148
+ def __init__(self, message: str, path: str | None = None):
149
+ self.path = path
150
+ super().__init__(f"{message}" + (f" in {path}" if path else ""))
151
+
152
+
153
+ def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
154
+ """Parse YAML frontmatter from text.
155
+
156
+ Args:
157
+ text: Full text content with frontmatter
158
+
159
+ Returns:
160
+ Tuple of (frontmatter dict, content body)
161
+
162
+ Raises:
163
+ SkillParseError: If frontmatter is missing or invalid
164
+ """
165
+ match = FRONTMATTER_PATTERN.match(text)
166
+ if not match:
167
+ raise SkillParseError("Missing or invalid YAML frontmatter (must start with ---)")
168
+
169
+ frontmatter_yaml = match.group(1)
170
+ content = text[match.end() :].strip()
171
+
172
+ try:
173
+ frontmatter = yaml.safe_load(frontmatter_yaml)
174
+ except yaml.YAMLError as e:
175
+ raise SkillParseError(f"Invalid YAML in frontmatter: {e}") from e
176
+
177
+ if frontmatter is None:
178
+ frontmatter = {}
179
+
180
+ if not isinstance(frontmatter, dict):
181
+ raise SkillParseError("Frontmatter must be a YAML mapping")
182
+
183
+ return frontmatter, content
184
+
185
+
186
+ def parse_skill_text(text: str, source_path: str | None = None) -> ParsedSkill:
187
+ """Parse a skill from text content.
188
+
189
+ Args:
190
+ text: Full skill file content (frontmatter + markdown)
191
+ source_path: Optional path for error messages
192
+
193
+ Returns:
194
+ ParsedSkill with extracted data
195
+
196
+ Raises:
197
+ SkillParseError: If parsing fails or required fields missing
198
+ """
199
+ try:
200
+ frontmatter, content = parse_frontmatter(text)
201
+ except SkillParseError as e:
202
+ # Create a new exception with the path included in the message
203
+ raise SkillParseError(str(e), path=source_path) from e
204
+
205
+ # Extract required fields
206
+ name = frontmatter.get("name")
207
+ if not name:
208
+ raise SkillParseError("Missing required field: name", source_path)
209
+
210
+ description = frontmatter.get("description")
211
+ if not description:
212
+ raise SkillParseError("Missing required field: description", source_path)
213
+
214
+ # Extract optional fields
215
+ license_str = frontmatter.get("license")
216
+ compatibility = frontmatter.get("compatibility")
217
+
218
+ # Handle allowed-tools (can be string or list)
219
+ allowed_tools_raw = frontmatter.get("allowed-tools") or frontmatter.get("allowed_tools")
220
+ allowed_tools: list[str] | None = None
221
+ if allowed_tools_raw:
222
+ if isinstance(allowed_tools_raw, str):
223
+ # Single tool or comma-separated
224
+ allowed_tools = [t.strip() for t in allowed_tools_raw.split(",")]
225
+ elif isinstance(allowed_tools_raw, list):
226
+ allowed_tools = [str(t) for t in allowed_tools_raw]
227
+
228
+ # Extract metadata (may contain version, skillport, gobby namespaces)
229
+ metadata = frontmatter.get("metadata")
230
+
231
+ # Handle top-level alwaysApply and category by including them in metadata
232
+ # This allows both top-level and nested formats to work
233
+ top_level_always_apply = frontmatter.get("alwaysApply")
234
+ top_level_category = frontmatter.get("category")
235
+
236
+ if top_level_always_apply is not None or top_level_category is not None:
237
+ if metadata is None:
238
+ metadata = {}
239
+ # Store at top level of metadata (not nested in skillport)
240
+ if top_level_always_apply is not None:
241
+ metadata["alwaysApply"] = top_level_always_apply
242
+ if top_level_category is not None:
243
+ metadata["category"] = top_level_category
244
+
245
+ # Version can be at top level or in metadata
246
+ version = frontmatter.get("version")
247
+ if version is None and metadata and isinstance(metadata, dict):
248
+ version = metadata.get("version")
249
+
250
+ # Convert version to string if it's a number (e.g., 1.0 parsed as float)
251
+ if version is not None:
252
+ version = str(version)
253
+
254
+ return ParsedSkill(
255
+ name=name,
256
+ description=description,
257
+ content=content,
258
+ version=version,
259
+ license=license_str,
260
+ compatibility=compatibility,
261
+ allowed_tools=allowed_tools,
262
+ metadata=metadata,
263
+ source_path=source_path,
264
+ )
265
+
266
+
267
+ def parse_skill_file(path: str | Path) -> ParsedSkill:
268
+ """Parse a skill from a file path.
269
+
270
+ Args:
271
+ path: Path to SKILL.md file
272
+
273
+ Returns:
274
+ ParsedSkill with extracted data
275
+
276
+ Raises:
277
+ FileNotFoundError: If file doesn't exist
278
+ SkillParseError: If parsing fails (propagated from parse_skill_text)
279
+ """
280
+ path = Path(path)
281
+
282
+ if not path.exists():
283
+ raise FileNotFoundError(f"Skill file not found: {path}")
284
+
285
+ text = path.read_text(encoding="utf-8")
286
+ return parse_skill_text(text, source_path=str(path))