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.
- gobby/adapters/claude_code.py +13 -4
- gobby/adapters/codex.py +43 -3
- gobby/agents/runner.py +8 -0
- gobby/cli/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +9 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +2 -8
- gobby/cli/installers/shared.py +71 -8
- gobby/cli/skills.py +858 -0
- gobby/cli/tasks/ai.py +0 -440
- gobby/cli/tasks/crud.py +44 -6
- gobby/cli/tasks/main.py +0 -4
- gobby/cli/tui.py +2 -2
- gobby/cli/utils.py +3 -3
- gobby/clones/__init__.py +13 -0
- gobby/clones/git.py +547 -0
- gobby/conductor/__init__.py +16 -0
- gobby/conductor/alerts.py +135 -0
- gobby/conductor/loop.py +164 -0
- gobby/conductor/monitors/__init__.py +11 -0
- gobby/conductor/monitors/agents.py +116 -0
- gobby/conductor/monitors/tasks.py +155 -0
- gobby/conductor/pricing.py +234 -0
- gobby/conductor/token_tracker.py +160 -0
- gobby/config/app.py +63 -1
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +6 -14
- gobby/hooks/event_handlers.py +145 -2
- gobby/hooks/hook_manager.py +48 -2
- gobby/hooks/skill_manager.py +130 -0
- gobby/install/claude/hooks/hook_dispatcher.py +4 -4
- gobby/install/codex/hooks/hook_dispatcher.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
- gobby/llm/claude.py +22 -34
- gobby/llm/claude_executor.py +46 -256
- gobby/llm/codex_executor.py +59 -291
- gobby/llm/executor.py +21 -0
- gobby/llm/gemini.py +134 -110
- gobby/llm/litellm_executor.py +143 -6
- gobby/llm/resolver.py +95 -33
- gobby/mcp_proxy/instructions.py +54 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -5
- gobby/mcp_proxy/server.py +33 -3
- gobby/mcp_proxy/services/tool_proxy.py +81 -1
- gobby/mcp_proxy/stdio.py +2 -1
- gobby/mcp_proxy/tools/__init__.py +0 -2
- gobby/mcp_proxy/tools/agent_messaging.py +317 -0
- gobby/mcp_proxy/tools/clones.py +903 -0
- gobby/mcp_proxy/tools/memory.py +1 -24
- gobby/mcp_proxy/tools/metrics.py +65 -1
- gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
- gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
- gobby/mcp_proxy/tools/session_messages.py +1 -2
- gobby/mcp_proxy/tools/skills/__init__.py +631 -0
- gobby/mcp_proxy/tools/task_orchestration.py +7 -0
- gobby/mcp_proxy/tools/task_readiness.py +14 -0
- gobby/mcp_proxy/tools/task_sync.py +1 -1
- gobby/mcp_proxy/tools/tasks/_context.py +0 -20
- gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
- gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +60 -29
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +5 -0
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/manager.py +11 -2
- gobby/prompts/defaults/handoff/compact.md +63 -0
- gobby/prompts/defaults/handoff/session_end.md +57 -0
- gobby/prompts/defaults/memory/extract.md +61 -0
- gobby/runner.py +37 -16
- gobby/search/__init__.py +48 -6
- gobby/search/backends/__init__.py +159 -0
- gobby/search/backends/embedding.py +225 -0
- gobby/search/embeddings.py +238 -0
- gobby/search/models.py +148 -0
- gobby/search/unified.py +496 -0
- gobby/servers/http.py +23 -8
- gobby/servers/routes/admin.py +280 -0
- gobby/servers/routes/mcp/tools.py +241 -52
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +64 -5
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +258 -0
- gobby/skills/search.py +463 -0
- gobby/skills/sync.py +119 -0
- gobby/skills/updater.py +385 -0
- gobby/skills/validator.py +368 -0
- gobby/storage/clones.py +378 -0
- gobby/storage/database.py +1 -1
- gobby/storage/memories.py +43 -13
- gobby/storage/migrations.py +180 -6
- gobby/storage/sessions.py +73 -0
- gobby/storage/skills.py +749 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +41 -6
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +39 -4
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/validation.py +24 -15
- gobby/tui/api_client.py +4 -7
- gobby/tui/app.py +5 -3
- gobby/tui/screens/orchestrator.py +1 -2
- gobby/tui/screens/tasks.py +2 -4
- gobby/tui/ws_client.py +1 -1
- gobby/utils/daemon_client.py +2 -2
- gobby/workflows/actions.py +84 -2
- gobby/workflows/context_actions.py +43 -0
- gobby/workflows/detection_helpers.py +115 -31
- gobby/workflows/engine.py +13 -2
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/loader.py +19 -6
- gobby/workflows/memory_actions.py +74 -0
- gobby/workflows/summary_actions.py +17 -0
- gobby/workflows/task_enforcement_actions.py +448 -6
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/METADATA +82 -21
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/RECORD +136 -107
- gobby/install/codex/prompts/forget.md +0 -7
- gobby/install/codex/prompts/memories.md +0 -7
- gobby/install/codex/prompts/recall.md +0 -7
- gobby/install/codex/prompts/remember.md +0 -13
- gobby/llm/gemini_executor.py +0 -339
- gobby/mcp_proxy/tools/task_expansion.py +0 -591
- gobby/tasks/context.py +0 -747
- gobby/tasks/criteria.py +0 -342
- gobby/tasks/expansion.py +0 -626
- gobby/tasks/prompts/expand.py +0 -327
- gobby/tasks/research.py +0 -421
- gobby/tasks/tdd.py +0 -352
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/WHEEL +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/top_level.txt +0 -0
gobby/skills/manager.py
ADDED
|
@@ -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))
|