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/storage/skills.py
ADDED
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
"""Skill storage and management.
|
|
2
|
+
|
|
3
|
+
This module provides the Skill dataclass and LocalSkillManager for storing
|
|
4
|
+
and retrieving skills from SQLite, following the Agent Skills specification
|
|
5
|
+
(agentskills.io) with SkillPort feature parity plus Gobby-specific extensions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import sqlite3
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from gobby.storage.database import DatabaseProtocol
|
|
16
|
+
from gobby.utils.id import generate_prefixed_id
|
|
17
|
+
|
|
18
|
+
__all__ = ["ChangeEvent", "Skill", "SkillChangeNotifier", "LocalSkillManager"]
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Sentinel for distinguishing "not provided" from explicit None
|
|
23
|
+
_UNSET: Any = object()
|
|
24
|
+
|
|
25
|
+
# Valid source types for skills
|
|
26
|
+
SkillSourceType = Literal["local", "github", "url", "zip", "filesystem"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Skill:
|
|
31
|
+
"""A skill following the Agent Skills specification.
|
|
32
|
+
|
|
33
|
+
Skills provide structured instructions for AI agents to follow when
|
|
34
|
+
performing specific tasks. The format follows the Agent Skills spec
|
|
35
|
+
(agentskills.io) with additional Gobby-specific extensions.
|
|
36
|
+
|
|
37
|
+
Required fields per spec:
|
|
38
|
+
- id: Unique identifier (prefixed with 'skl-')
|
|
39
|
+
- name: Skill name (max 64 chars, lowercase+hyphens)
|
|
40
|
+
- description: What the skill does (max 1024 chars)
|
|
41
|
+
- content: The markdown body with instructions
|
|
42
|
+
|
|
43
|
+
Optional spec fields:
|
|
44
|
+
- version: Semantic version string
|
|
45
|
+
- license: License identifier (e.g., "MIT")
|
|
46
|
+
- compatibility: Compatibility notes (max 500 chars)
|
|
47
|
+
- allowed_tools: List of allowed tool patterns
|
|
48
|
+
- metadata: Free-form extension data (includes skillport/gobby namespaces)
|
|
49
|
+
|
|
50
|
+
Source tracking:
|
|
51
|
+
- source_path: Original file path or URL
|
|
52
|
+
- source_type: 'local', 'github', 'url', 'zip', 'filesystem'
|
|
53
|
+
- source_ref: Git ref for updates (branch/tag/commit)
|
|
54
|
+
|
|
55
|
+
Gobby-specific:
|
|
56
|
+
- enabled: Toggle skill on/off without removing
|
|
57
|
+
- project_id: NULL for global, else project-scoped
|
|
58
|
+
|
|
59
|
+
Timestamps:
|
|
60
|
+
- created_at: ISO format creation timestamp
|
|
61
|
+
- updated_at: ISO format last update timestamp
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
# Identity
|
|
65
|
+
id: str
|
|
66
|
+
name: str
|
|
67
|
+
|
|
68
|
+
# Agent Skills Spec Fields
|
|
69
|
+
description: str
|
|
70
|
+
content: str
|
|
71
|
+
version: str | None = None
|
|
72
|
+
license: str | None = None
|
|
73
|
+
compatibility: str | None = None
|
|
74
|
+
allowed_tools: list[str] | None = None
|
|
75
|
+
metadata: dict[str, Any] | None = None
|
|
76
|
+
|
|
77
|
+
# Source Tracking
|
|
78
|
+
source_path: str | None = None
|
|
79
|
+
source_type: SkillSourceType | None = None
|
|
80
|
+
source_ref: str | None = None
|
|
81
|
+
|
|
82
|
+
# Gobby-specific
|
|
83
|
+
enabled: bool = True
|
|
84
|
+
project_id: str | None = None
|
|
85
|
+
|
|
86
|
+
# Timestamps
|
|
87
|
+
created_at: str = ""
|
|
88
|
+
updated_at: str = ""
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_row(cls, row: sqlite3.Row) -> "Skill":
|
|
92
|
+
"""Create a Skill from a database row.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
row: SQLite row with skill data
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Skill instance populated from the row
|
|
99
|
+
"""
|
|
100
|
+
# Parse JSON fields
|
|
101
|
+
allowed_tools_json = row["allowed_tools"]
|
|
102
|
+
allowed_tools = json.loads(allowed_tools_json) if allowed_tools_json else None
|
|
103
|
+
|
|
104
|
+
metadata_json = row["metadata"]
|
|
105
|
+
metadata = json.loads(metadata_json) if metadata_json else None
|
|
106
|
+
|
|
107
|
+
return cls(
|
|
108
|
+
id=row["id"],
|
|
109
|
+
name=row["name"],
|
|
110
|
+
description=row["description"],
|
|
111
|
+
content=row["content"],
|
|
112
|
+
version=row["version"],
|
|
113
|
+
license=row["license"],
|
|
114
|
+
compatibility=row["compatibility"],
|
|
115
|
+
allowed_tools=allowed_tools,
|
|
116
|
+
metadata=metadata,
|
|
117
|
+
source_path=row["source_path"],
|
|
118
|
+
source_type=row["source_type"],
|
|
119
|
+
source_ref=row["source_ref"],
|
|
120
|
+
enabled=bool(row["enabled"]),
|
|
121
|
+
project_id=row["project_id"],
|
|
122
|
+
created_at=row["created_at"],
|
|
123
|
+
updated_at=row["updated_at"],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def to_dict(self) -> dict[str, Any]:
|
|
127
|
+
"""Convert skill to a dictionary representation.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Dictionary with all skill fields
|
|
131
|
+
"""
|
|
132
|
+
return {
|
|
133
|
+
"id": self.id,
|
|
134
|
+
"name": self.name,
|
|
135
|
+
"description": self.description,
|
|
136
|
+
"content": self.content,
|
|
137
|
+
"version": self.version,
|
|
138
|
+
"license": self.license,
|
|
139
|
+
"compatibility": self.compatibility,
|
|
140
|
+
"allowed_tools": self.allowed_tools,
|
|
141
|
+
"metadata": self.metadata,
|
|
142
|
+
"source_path": self.source_path,
|
|
143
|
+
"source_type": self.source_type,
|
|
144
|
+
"source_ref": self.source_ref,
|
|
145
|
+
"enabled": self.enabled,
|
|
146
|
+
"project_id": self.project_id,
|
|
147
|
+
"created_at": self.created_at,
|
|
148
|
+
"updated_at": self.updated_at,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
def get_category(self) -> str | None:
|
|
152
|
+
"""Get the skill category from metadata.skillport.category."""
|
|
153
|
+
if not self.metadata:
|
|
154
|
+
return None
|
|
155
|
+
skillport = self.metadata.get("skillport", {})
|
|
156
|
+
result = skillport.get("category")
|
|
157
|
+
return str(result) if result is not None else None
|
|
158
|
+
|
|
159
|
+
def get_tags(self) -> list[str]:
|
|
160
|
+
"""Get the skill tags from metadata.skillport.tags."""
|
|
161
|
+
if not self.metadata:
|
|
162
|
+
return []
|
|
163
|
+
skillport = self.metadata.get("skillport", {})
|
|
164
|
+
tags = skillport.get("tags", [])
|
|
165
|
+
return list(tags) if isinstance(tags, list) else []
|
|
166
|
+
|
|
167
|
+
def is_always_apply(self) -> bool:
|
|
168
|
+
"""Check if this is a core skill that should always be applied."""
|
|
169
|
+
if not self.metadata:
|
|
170
|
+
return False
|
|
171
|
+
skillport = self.metadata.get("skillport", {})
|
|
172
|
+
return bool(skillport.get("alwaysApply", False))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# Change event types
|
|
176
|
+
ChangeEventType = Literal["create", "update", "delete"]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class ChangeEvent:
|
|
181
|
+
"""A change event fired when a skill is created, updated, or deleted.
|
|
182
|
+
|
|
183
|
+
This event is passed to registered listeners when mutations occur,
|
|
184
|
+
allowing components like search indexes to stay synchronized.
|
|
185
|
+
|
|
186
|
+
Attributes:
|
|
187
|
+
event_type: Type of change ('create', 'update', 'delete')
|
|
188
|
+
skill_id: ID of the affected skill
|
|
189
|
+
skill_name: Name of the affected skill (for logging/indexing)
|
|
190
|
+
timestamp: ISO format timestamp of the event
|
|
191
|
+
metadata: Optional additional context about the change
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
event_type: ChangeEventType
|
|
195
|
+
skill_id: str
|
|
196
|
+
skill_name: str
|
|
197
|
+
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
198
|
+
metadata: dict[str, Any] | None = None
|
|
199
|
+
|
|
200
|
+
def to_dict(self) -> dict[str, Any]:
|
|
201
|
+
"""Convert event to dictionary representation."""
|
|
202
|
+
return {
|
|
203
|
+
"event_type": self.event_type,
|
|
204
|
+
"skill_id": self.skill_id,
|
|
205
|
+
"skill_name": self.skill_name,
|
|
206
|
+
"timestamp": self.timestamp,
|
|
207
|
+
"metadata": self.metadata,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# Type alias for change listeners
|
|
212
|
+
ChangeListener = Any # Callable[[ChangeEvent], None], but avoiding import issues
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class SkillChangeNotifier:
|
|
216
|
+
"""Notifies registered listeners when skills are mutated.
|
|
217
|
+
|
|
218
|
+
This implements the observer pattern to allow components like
|
|
219
|
+
search indexes to stay synchronized with skill changes.
|
|
220
|
+
|
|
221
|
+
Listeners are wrapped in try/except to prevent one failing listener
|
|
222
|
+
from blocking others or the main mutation.
|
|
223
|
+
|
|
224
|
+
Example usage:
|
|
225
|
+
```python
|
|
226
|
+
notifier = SkillChangeNotifier()
|
|
227
|
+
|
|
228
|
+
def on_skill_change(event: ChangeEvent):
|
|
229
|
+
print(f"Skill {event.skill_name} was {event.event_type}d")
|
|
230
|
+
|
|
231
|
+
notifier.add_listener(on_skill_change)
|
|
232
|
+
|
|
233
|
+
manager = LocalSkillManager(db, notifier=notifier)
|
|
234
|
+
manager.create_skill(...) # Triggers the listener
|
|
235
|
+
```
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
def __init__(self) -> None:
|
|
239
|
+
"""Initialize the notifier with an empty listener list."""
|
|
240
|
+
self._listeners: list[ChangeListener] = []
|
|
241
|
+
|
|
242
|
+
def add_listener(self, listener: ChangeListener) -> None:
|
|
243
|
+
"""Register a listener to receive change events.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
listener: Callable that accepts a ChangeEvent
|
|
247
|
+
"""
|
|
248
|
+
if listener not in self._listeners:
|
|
249
|
+
self._listeners.append(listener)
|
|
250
|
+
|
|
251
|
+
def remove_listener(self, listener: ChangeListener) -> bool:
|
|
252
|
+
"""Unregister a listener.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
listener: The listener to remove
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
True if removed, False if not found
|
|
259
|
+
"""
|
|
260
|
+
try:
|
|
261
|
+
self._listeners.remove(listener)
|
|
262
|
+
return True
|
|
263
|
+
except ValueError:
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
def fire_change(
|
|
267
|
+
self,
|
|
268
|
+
event_type: ChangeEventType,
|
|
269
|
+
skill_id: str,
|
|
270
|
+
skill_name: str,
|
|
271
|
+
metadata: dict[str, Any] | None = None,
|
|
272
|
+
) -> None:
|
|
273
|
+
"""Fire a change event to all registered listeners.
|
|
274
|
+
|
|
275
|
+
Each listener is called in a try/except block to prevent
|
|
276
|
+
one failing listener from blocking others.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
event_type: Type of change ('create', 'update', 'delete')
|
|
280
|
+
skill_id: ID of the affected skill
|
|
281
|
+
skill_name: Name of the affected skill
|
|
282
|
+
metadata: Optional additional context
|
|
283
|
+
"""
|
|
284
|
+
event = ChangeEvent(
|
|
285
|
+
event_type=event_type,
|
|
286
|
+
skill_id=skill_id,
|
|
287
|
+
skill_name=skill_name,
|
|
288
|
+
metadata=metadata,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
for listener in self._listeners:
|
|
292
|
+
try:
|
|
293
|
+
listener(event)
|
|
294
|
+
except Exception as e:
|
|
295
|
+
logger.error(
|
|
296
|
+
f"Error in skill change listener {listener}: {e}",
|
|
297
|
+
exc_info=True,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def clear_listeners(self) -> None:
|
|
301
|
+
"""Remove all registered listeners."""
|
|
302
|
+
self._listeners.clear()
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def listener_count(self) -> int:
|
|
306
|
+
"""Return the number of registered listeners."""
|
|
307
|
+
return len(self._listeners)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class LocalSkillManager:
|
|
311
|
+
"""Manages skill storage in SQLite.
|
|
312
|
+
|
|
313
|
+
Provides CRUD operations for skills with support for:
|
|
314
|
+
- Project-scoped uniqueness (UNIQUE(name, project_id))
|
|
315
|
+
- Category and tag filtering
|
|
316
|
+
- Change notifications for search reindexing
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
def __init__(
|
|
320
|
+
self,
|
|
321
|
+
db: DatabaseProtocol,
|
|
322
|
+
notifier: Any | None = None, # SkillChangeNotifier, avoid circular import
|
|
323
|
+
):
|
|
324
|
+
"""Initialize the skill manager.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
db: Database protocol implementation
|
|
328
|
+
notifier: Optional change notifier for mutations
|
|
329
|
+
"""
|
|
330
|
+
self.db = db
|
|
331
|
+
self._notifier = notifier
|
|
332
|
+
|
|
333
|
+
def _notify_change(
|
|
334
|
+
self,
|
|
335
|
+
event_type: str,
|
|
336
|
+
skill_id: str,
|
|
337
|
+
skill_name: str,
|
|
338
|
+
metadata: dict[str, Any] | None = None,
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Fire a change event if a notifier is configured.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
event_type: Type of change ('create', 'update', 'delete')
|
|
344
|
+
skill_id: ID of the affected skill
|
|
345
|
+
skill_name: Name of the affected skill
|
|
346
|
+
metadata: Optional additional metadata
|
|
347
|
+
"""
|
|
348
|
+
if self._notifier is not None:
|
|
349
|
+
try:
|
|
350
|
+
self._notifier.fire_change(
|
|
351
|
+
event_type=event_type,
|
|
352
|
+
skill_id=skill_id,
|
|
353
|
+
skill_name=skill_name,
|
|
354
|
+
metadata=metadata,
|
|
355
|
+
)
|
|
356
|
+
except Exception as e:
|
|
357
|
+
logger.error(f"Error in skill change notifier: {e}")
|
|
358
|
+
|
|
359
|
+
def create_skill(
|
|
360
|
+
self,
|
|
361
|
+
name: str,
|
|
362
|
+
description: str,
|
|
363
|
+
content: str,
|
|
364
|
+
version: str | None = None,
|
|
365
|
+
license: str | None = None,
|
|
366
|
+
compatibility: str | None = None,
|
|
367
|
+
allowed_tools: list[str] | None = None,
|
|
368
|
+
metadata: dict[str, Any] | None = None,
|
|
369
|
+
source_path: str | None = None,
|
|
370
|
+
source_type: SkillSourceType | None = None,
|
|
371
|
+
source_ref: str | None = None,
|
|
372
|
+
enabled: bool = True,
|
|
373
|
+
project_id: str | None = None,
|
|
374
|
+
) -> Skill:
|
|
375
|
+
"""Create a new skill.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
name: Skill name (max 64 chars, lowercase+hyphens)
|
|
379
|
+
description: Skill description (max 1024 chars)
|
|
380
|
+
content: Full markdown content
|
|
381
|
+
version: Optional version string
|
|
382
|
+
license: Optional license identifier
|
|
383
|
+
compatibility: Optional compatibility notes (max 500 chars)
|
|
384
|
+
allowed_tools: Optional list of allowed tool patterns
|
|
385
|
+
metadata: Optional free-form metadata
|
|
386
|
+
source_path: Original file path or URL
|
|
387
|
+
source_type: Source type ('local', 'github', 'url', 'zip', 'filesystem')
|
|
388
|
+
source_ref: Git ref for updates
|
|
389
|
+
enabled: Whether skill is active
|
|
390
|
+
project_id: Project scope (None for global)
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
The created Skill
|
|
394
|
+
|
|
395
|
+
Raises:
|
|
396
|
+
ValueError: If a skill with the same name exists in the project scope
|
|
397
|
+
"""
|
|
398
|
+
now = datetime.now(UTC).isoformat()
|
|
399
|
+
skill_id = generate_prefixed_id("skl", f"{name}:{project_id or 'global'}")
|
|
400
|
+
|
|
401
|
+
# Check if skill already exists in this project scope
|
|
402
|
+
existing = self.get_by_name(name, project_id=project_id)
|
|
403
|
+
if existing:
|
|
404
|
+
raise ValueError(
|
|
405
|
+
f"Skill '{name}' already exists"
|
|
406
|
+
+ (f" in project {project_id}" if project_id else " globally")
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
# Serialize JSON fields
|
|
410
|
+
allowed_tools_json = json.dumps(allowed_tools) if allowed_tools else None
|
|
411
|
+
metadata_json = json.dumps(metadata) if metadata else None
|
|
412
|
+
|
|
413
|
+
with self.db.transaction() as conn:
|
|
414
|
+
conn.execute(
|
|
415
|
+
"""
|
|
416
|
+
INSERT INTO skills (
|
|
417
|
+
id, name, description, content, version, license,
|
|
418
|
+
compatibility, allowed_tools, metadata, source_path,
|
|
419
|
+
source_type, source_ref, enabled, project_id,
|
|
420
|
+
created_at, updated_at
|
|
421
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
422
|
+
""",
|
|
423
|
+
(
|
|
424
|
+
skill_id,
|
|
425
|
+
name,
|
|
426
|
+
description,
|
|
427
|
+
content,
|
|
428
|
+
version,
|
|
429
|
+
license,
|
|
430
|
+
compatibility,
|
|
431
|
+
allowed_tools_json,
|
|
432
|
+
metadata_json,
|
|
433
|
+
source_path,
|
|
434
|
+
source_type,
|
|
435
|
+
source_ref,
|
|
436
|
+
enabled,
|
|
437
|
+
project_id,
|
|
438
|
+
now,
|
|
439
|
+
now,
|
|
440
|
+
),
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
skill = self.get_skill(skill_id)
|
|
444
|
+
self._notify_change("create", skill_id, name)
|
|
445
|
+
return skill
|
|
446
|
+
|
|
447
|
+
def get_skill(self, skill_id: str) -> Skill:
|
|
448
|
+
"""Get a skill by ID.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
skill_id: The skill ID
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
The Skill
|
|
455
|
+
|
|
456
|
+
Raises:
|
|
457
|
+
ValueError: If skill not found
|
|
458
|
+
"""
|
|
459
|
+
row = self.db.fetchone("SELECT * FROM skills WHERE id = ?", (skill_id,))
|
|
460
|
+
if not row:
|
|
461
|
+
raise ValueError(f"Skill {skill_id} not found")
|
|
462
|
+
return Skill.from_row(row)
|
|
463
|
+
|
|
464
|
+
def get_by_name(
|
|
465
|
+
self,
|
|
466
|
+
name: str,
|
|
467
|
+
project_id: str | None = None,
|
|
468
|
+
) -> Skill | None:
|
|
469
|
+
"""Get a skill by name within a project scope.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
name: The skill name
|
|
473
|
+
project_id: Project scope (None for global)
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
The Skill if found, None otherwise
|
|
477
|
+
"""
|
|
478
|
+
if project_id:
|
|
479
|
+
row = self.db.fetchone(
|
|
480
|
+
"SELECT * FROM skills WHERE name = ? AND project_id = ?",
|
|
481
|
+
(name, project_id),
|
|
482
|
+
)
|
|
483
|
+
else:
|
|
484
|
+
row = self.db.fetchone(
|
|
485
|
+
"SELECT * FROM skills WHERE name = ? AND project_id IS NULL",
|
|
486
|
+
(name,),
|
|
487
|
+
)
|
|
488
|
+
return Skill.from_row(row) if row else None
|
|
489
|
+
|
|
490
|
+
def update_skill(
|
|
491
|
+
self,
|
|
492
|
+
skill_id: str,
|
|
493
|
+
name: str | None = None,
|
|
494
|
+
description: str | None = None,
|
|
495
|
+
content: str | None = None,
|
|
496
|
+
version: str | None = _UNSET,
|
|
497
|
+
license: str | None = _UNSET,
|
|
498
|
+
compatibility: str | None = _UNSET,
|
|
499
|
+
allowed_tools: list[str] | None = _UNSET,
|
|
500
|
+
metadata: dict[str, Any] | None = _UNSET,
|
|
501
|
+
source_path: str | None = _UNSET,
|
|
502
|
+
source_type: SkillSourceType | None = _UNSET,
|
|
503
|
+
source_ref: str | None = _UNSET,
|
|
504
|
+
enabled: bool | None = None,
|
|
505
|
+
) -> Skill:
|
|
506
|
+
"""Update an existing skill.
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
skill_id: The skill ID to update
|
|
510
|
+
name: New name (optional)
|
|
511
|
+
description: New description (optional)
|
|
512
|
+
content: New content (optional)
|
|
513
|
+
version: New version (use _UNSET to leave unchanged, None to clear)
|
|
514
|
+
license: New license (use _UNSET to leave unchanged, None to clear)
|
|
515
|
+
compatibility: New compatibility (use _UNSET to leave unchanged, None to clear)
|
|
516
|
+
allowed_tools: New allowed tools (use _UNSET to leave unchanged, None to clear)
|
|
517
|
+
metadata: New metadata (use _UNSET to leave unchanged, None to clear)
|
|
518
|
+
source_path: New source path (use _UNSET to leave unchanged, None to clear)
|
|
519
|
+
source_type: New source type (use _UNSET to leave unchanged, None to clear)
|
|
520
|
+
source_ref: New source ref (use _UNSET to leave unchanged, None to clear)
|
|
521
|
+
enabled: New enabled state (optional)
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
The updated Skill
|
|
525
|
+
|
|
526
|
+
Raises:
|
|
527
|
+
ValueError: If skill not found
|
|
528
|
+
"""
|
|
529
|
+
updates = []
|
|
530
|
+
params: list[Any] = []
|
|
531
|
+
|
|
532
|
+
if name is not None:
|
|
533
|
+
updates.append("name = ?")
|
|
534
|
+
params.append(name)
|
|
535
|
+
if description is not None:
|
|
536
|
+
updates.append("description = ?")
|
|
537
|
+
params.append(description)
|
|
538
|
+
if content is not None:
|
|
539
|
+
updates.append("content = ?")
|
|
540
|
+
params.append(content)
|
|
541
|
+
if version is not _UNSET:
|
|
542
|
+
updates.append("version = ?")
|
|
543
|
+
params.append(version)
|
|
544
|
+
if license is not _UNSET:
|
|
545
|
+
updates.append("license = ?")
|
|
546
|
+
params.append(license)
|
|
547
|
+
if compatibility is not _UNSET:
|
|
548
|
+
updates.append("compatibility = ?")
|
|
549
|
+
params.append(compatibility)
|
|
550
|
+
if allowed_tools is not _UNSET:
|
|
551
|
+
updates.append("allowed_tools = ?")
|
|
552
|
+
params.append(json.dumps(allowed_tools) if allowed_tools else None)
|
|
553
|
+
if metadata is not _UNSET:
|
|
554
|
+
updates.append("metadata = ?")
|
|
555
|
+
params.append(json.dumps(metadata) if metadata else None)
|
|
556
|
+
if source_path is not _UNSET:
|
|
557
|
+
updates.append("source_path = ?")
|
|
558
|
+
params.append(source_path)
|
|
559
|
+
if source_type is not _UNSET:
|
|
560
|
+
updates.append("source_type = ?")
|
|
561
|
+
params.append(source_type)
|
|
562
|
+
if source_ref is not _UNSET:
|
|
563
|
+
updates.append("source_ref = ?")
|
|
564
|
+
params.append(source_ref)
|
|
565
|
+
if enabled is not None:
|
|
566
|
+
updates.append("enabled = ?")
|
|
567
|
+
params.append(enabled)
|
|
568
|
+
|
|
569
|
+
if not updates:
|
|
570
|
+
return self.get_skill(skill_id)
|
|
571
|
+
|
|
572
|
+
updates.append("updated_at = ?")
|
|
573
|
+
params.append(datetime.now(UTC).isoformat())
|
|
574
|
+
params.append(skill_id)
|
|
575
|
+
|
|
576
|
+
# nosec B608: SET clause built from hardcoded column names, values parameterized
|
|
577
|
+
sql = f"UPDATE skills SET {', '.join(updates)} WHERE id = ?" # nosec B608
|
|
578
|
+
|
|
579
|
+
with self.db.transaction() as conn:
|
|
580
|
+
cursor = conn.execute(sql, tuple(params))
|
|
581
|
+
if cursor.rowcount == 0:
|
|
582
|
+
raise ValueError(f"Skill {skill_id} not found")
|
|
583
|
+
|
|
584
|
+
skill = self.get_skill(skill_id)
|
|
585
|
+
self._notify_change("update", skill_id, skill.name)
|
|
586
|
+
return skill
|
|
587
|
+
|
|
588
|
+
def delete_skill(self, skill_id: str) -> bool:
|
|
589
|
+
"""Delete a skill by ID.
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
skill_id: The skill ID to delete
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
True if deleted, False if not found
|
|
596
|
+
"""
|
|
597
|
+
# Get skill name before deletion for notification
|
|
598
|
+
try:
|
|
599
|
+
skill = self.get_skill(skill_id)
|
|
600
|
+
skill_name = skill.name
|
|
601
|
+
except ValueError:
|
|
602
|
+
return False
|
|
603
|
+
|
|
604
|
+
with self.db.transaction() as conn:
|
|
605
|
+
cursor = conn.execute("DELETE FROM skills WHERE id = ?", (skill_id,))
|
|
606
|
+
if cursor.rowcount == 0:
|
|
607
|
+
return False
|
|
608
|
+
|
|
609
|
+
self._notify_change("delete", skill_id, skill_name)
|
|
610
|
+
return True
|
|
611
|
+
|
|
612
|
+
def list_skills(
|
|
613
|
+
self,
|
|
614
|
+
project_id: str | None = None,
|
|
615
|
+
enabled: bool | None = None,
|
|
616
|
+
category: str | None = None,
|
|
617
|
+
limit: int = 50,
|
|
618
|
+
offset: int = 0,
|
|
619
|
+
include_global: bool = True,
|
|
620
|
+
) -> list[Skill]:
|
|
621
|
+
"""List skills with optional filtering.
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
project_id: Filter by project (None for global only)
|
|
625
|
+
enabled: Filter by enabled state
|
|
626
|
+
category: Filter by category (from metadata.skillport.category)
|
|
627
|
+
limit: Maximum number of results
|
|
628
|
+
offset: Number of results to skip
|
|
629
|
+
include_global: Include global skills when project_id is set
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
List of matching Skills
|
|
633
|
+
"""
|
|
634
|
+
query = "SELECT * FROM skills WHERE 1=1"
|
|
635
|
+
params: list[Any] = []
|
|
636
|
+
|
|
637
|
+
if project_id:
|
|
638
|
+
if include_global:
|
|
639
|
+
query += " AND (project_id = ? OR project_id IS NULL)"
|
|
640
|
+
params.append(project_id)
|
|
641
|
+
else:
|
|
642
|
+
query += " AND project_id = ?"
|
|
643
|
+
params.append(project_id)
|
|
644
|
+
else:
|
|
645
|
+
query += " AND project_id IS NULL"
|
|
646
|
+
|
|
647
|
+
if enabled is not None:
|
|
648
|
+
query += " AND enabled = ?"
|
|
649
|
+
params.append(enabled)
|
|
650
|
+
|
|
651
|
+
# Filter by category using JSON extraction in SQL to avoid under-filled results
|
|
652
|
+
if category:
|
|
653
|
+
query += " AND json_extract(metadata, '$.skillport.category') = ?"
|
|
654
|
+
params.append(category)
|
|
655
|
+
|
|
656
|
+
query += " ORDER BY name ASC LIMIT ? OFFSET ?"
|
|
657
|
+
params.extend([limit, offset])
|
|
658
|
+
|
|
659
|
+
rows = self.db.fetchall(query, tuple(params))
|
|
660
|
+
return [Skill.from_row(row) for row in rows]
|
|
661
|
+
|
|
662
|
+
def search_skills(
|
|
663
|
+
self,
|
|
664
|
+
query_text: str,
|
|
665
|
+
project_id: str | None = None,
|
|
666
|
+
limit: int = 20,
|
|
667
|
+
) -> list[Skill]:
|
|
668
|
+
"""Search skills by name and description.
|
|
669
|
+
|
|
670
|
+
This is a simple text search. For advanced search with TF-IDF
|
|
671
|
+
and embeddings, use SkillSearch from the skills module.
|
|
672
|
+
|
|
673
|
+
Args:
|
|
674
|
+
query_text: Text to search for
|
|
675
|
+
project_id: Optional project scope
|
|
676
|
+
limit: Maximum number of results
|
|
677
|
+
|
|
678
|
+
Returns:
|
|
679
|
+
List of matching Skills
|
|
680
|
+
"""
|
|
681
|
+
# Escape LIKE wildcards
|
|
682
|
+
escaped_query = query_text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
683
|
+
sql = """
|
|
684
|
+
SELECT * FROM skills
|
|
685
|
+
WHERE (name LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\')
|
|
686
|
+
"""
|
|
687
|
+
params: list[Any] = [f"%{escaped_query}%", f"%{escaped_query}%"]
|
|
688
|
+
|
|
689
|
+
if project_id:
|
|
690
|
+
sql += " AND (project_id = ? OR project_id IS NULL)"
|
|
691
|
+
params.append(project_id)
|
|
692
|
+
|
|
693
|
+
sql += " ORDER BY name ASC LIMIT ?"
|
|
694
|
+
params.append(limit)
|
|
695
|
+
|
|
696
|
+
rows = self.db.fetchall(sql, tuple(params))
|
|
697
|
+
return [Skill.from_row(row) for row in rows]
|
|
698
|
+
|
|
699
|
+
def list_core_skills(self, project_id: str | None = None) -> list[Skill]:
|
|
700
|
+
"""List skills with alwaysApply=true.
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
project_id: Optional project scope
|
|
704
|
+
|
|
705
|
+
Returns:
|
|
706
|
+
List of core skills (always-apply skills)
|
|
707
|
+
"""
|
|
708
|
+
skills = self.list_skills(project_id=project_id, enabled=True, limit=1000)
|
|
709
|
+
return [s for s in skills if s.is_always_apply()]
|
|
710
|
+
|
|
711
|
+
def skill_exists(self, skill_id: str) -> bool:
|
|
712
|
+
"""Check if a skill with the given ID exists.
|
|
713
|
+
|
|
714
|
+
Args:
|
|
715
|
+
skill_id: The skill ID to check
|
|
716
|
+
|
|
717
|
+
Returns:
|
|
718
|
+
True if exists, False otherwise
|
|
719
|
+
"""
|
|
720
|
+
row = self.db.fetchone("SELECT 1 FROM skills WHERE id = ?", (skill_id,))
|
|
721
|
+
return row is not None
|
|
722
|
+
|
|
723
|
+
def count_skills(
|
|
724
|
+
self,
|
|
725
|
+
project_id: str | None = None,
|
|
726
|
+
enabled: bool | None = None,
|
|
727
|
+
) -> int:
|
|
728
|
+
"""Count skills matching criteria.
|
|
729
|
+
|
|
730
|
+
Args:
|
|
731
|
+
project_id: Filter by project
|
|
732
|
+
enabled: Filter by enabled state
|
|
733
|
+
|
|
734
|
+
Returns:
|
|
735
|
+
Number of matching skills
|
|
736
|
+
"""
|
|
737
|
+
query = "SELECT COUNT(*) as count FROM skills WHERE 1=1"
|
|
738
|
+
params: list[Any] = []
|
|
739
|
+
|
|
740
|
+
if project_id:
|
|
741
|
+
query += " AND (project_id = ? OR project_id IS NULL)"
|
|
742
|
+
params.append(project_id)
|
|
743
|
+
|
|
744
|
+
if enabled is not None:
|
|
745
|
+
query += " AND enabled = ?"
|
|
746
|
+
params.append(enabled)
|
|
747
|
+
|
|
748
|
+
row = self.db.fetchone(query, tuple(params))
|
|
749
|
+
return row["count"] if row else 0
|