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,782 @@
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 top-level or metadata.skillport.category.
153
+
154
+ Supports both top-level category and nested metadata.skillport.category.
155
+ Top-level takes precedence.
156
+ """
157
+ if not self.metadata:
158
+ return None
159
+ # Check top-level first
160
+ result = self.metadata.get("category")
161
+ if result is not None:
162
+ return str(result)
163
+ # Fall back to nested skillport.category
164
+ skillport = self.metadata.get("skillport", {})
165
+ result = skillport.get("category")
166
+ return str(result) if result is not None else None
167
+
168
+ def get_tags(self) -> list[str]:
169
+ """Get the skill tags from metadata.skillport.tags."""
170
+ if not self.metadata:
171
+ return []
172
+ skillport = self.metadata.get("skillport", {})
173
+ tags = skillport.get("tags", [])
174
+ return list(tags) if isinstance(tags, list) else []
175
+
176
+ def is_always_apply(self) -> bool:
177
+ """Check if this is a core skill that should always be applied.
178
+
179
+ Supports both top-level alwaysApply and nested metadata.skillport.alwaysApply.
180
+ Top-level takes precedence.
181
+ """
182
+ if not self.metadata:
183
+ return False
184
+ # Check top-level first
185
+ top_level = self.metadata.get("alwaysApply")
186
+ if top_level is not None:
187
+ return bool(top_level)
188
+ # Fall back to nested skillport.alwaysApply
189
+ skillport = self.metadata.get("skillport", {})
190
+ return bool(skillport.get("alwaysApply", False))
191
+
192
+
193
+ # Change event types
194
+ ChangeEventType = Literal["create", "update", "delete"]
195
+
196
+
197
+ @dataclass
198
+ class ChangeEvent:
199
+ """A change event fired when a skill is created, updated, or deleted.
200
+
201
+ This event is passed to registered listeners when mutations occur,
202
+ allowing components like search indexes to stay synchronized.
203
+
204
+ Attributes:
205
+ event_type: Type of change ('create', 'update', 'delete')
206
+ skill_id: ID of the affected skill
207
+ skill_name: Name of the affected skill (for logging/indexing)
208
+ timestamp: ISO format timestamp of the event
209
+ metadata: Optional additional context about the change
210
+ """
211
+
212
+ event_type: ChangeEventType
213
+ skill_id: str
214
+ skill_name: str
215
+ timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
216
+ metadata: dict[str, Any] | None = None
217
+
218
+ def to_dict(self) -> dict[str, Any]:
219
+ """Convert event to dictionary representation."""
220
+ return {
221
+ "event_type": self.event_type,
222
+ "skill_id": self.skill_id,
223
+ "skill_name": self.skill_name,
224
+ "timestamp": self.timestamp,
225
+ "metadata": self.metadata,
226
+ }
227
+
228
+
229
+ # Type alias for change listeners
230
+ ChangeListener = Any # Callable[[ChangeEvent], None], but avoiding import issues
231
+
232
+
233
+ class SkillChangeNotifier:
234
+ """Notifies registered listeners when skills are mutated.
235
+
236
+ This implements the observer pattern to allow components like
237
+ search indexes to stay synchronized with skill changes.
238
+
239
+ Listeners are wrapped in try/except to prevent one failing listener
240
+ from blocking others or the main mutation.
241
+
242
+ Example usage:
243
+ ```python
244
+ notifier = SkillChangeNotifier()
245
+
246
+ def on_skill_change(event: ChangeEvent):
247
+ print(f"Skill {event.skill_name} was {event.event_type}d")
248
+
249
+ notifier.add_listener(on_skill_change)
250
+
251
+ manager = LocalSkillManager(db, notifier=notifier)
252
+ manager.create_skill(...) # Triggers the listener
253
+ ```
254
+ """
255
+
256
+ def __init__(self) -> None:
257
+ """Initialize the notifier with an empty listener list."""
258
+ self._listeners: list[ChangeListener] = []
259
+
260
+ def add_listener(self, listener: ChangeListener) -> None:
261
+ """Register a listener to receive change events.
262
+
263
+ Args:
264
+ listener: Callable that accepts a ChangeEvent
265
+ """
266
+ if listener not in self._listeners:
267
+ self._listeners.append(listener)
268
+
269
+ def remove_listener(self, listener: ChangeListener) -> bool:
270
+ """Unregister a listener.
271
+
272
+ Args:
273
+ listener: The listener to remove
274
+
275
+ Returns:
276
+ True if removed, False if not found
277
+ """
278
+ try:
279
+ self._listeners.remove(listener)
280
+ return True
281
+ except ValueError:
282
+ return False
283
+
284
+ def fire_change(
285
+ self,
286
+ event_type: ChangeEventType,
287
+ skill_id: str,
288
+ skill_name: str,
289
+ metadata: dict[str, Any] | None = None,
290
+ ) -> None:
291
+ """Fire a change event to all registered listeners.
292
+
293
+ Each listener is called in a try/except block to prevent
294
+ one failing listener from blocking others.
295
+
296
+ Args:
297
+ event_type: Type of change ('create', 'update', 'delete')
298
+ skill_id: ID of the affected skill
299
+ skill_name: Name of the affected skill
300
+ metadata: Optional additional context
301
+ """
302
+ event = ChangeEvent(
303
+ event_type=event_type,
304
+ skill_id=skill_id,
305
+ skill_name=skill_name,
306
+ metadata=metadata,
307
+ )
308
+
309
+ for listener in self._listeners:
310
+ try:
311
+ listener(event)
312
+ except Exception as e:
313
+ logger.error(
314
+ f"Error in skill change listener {listener}: {e}",
315
+ exc_info=True,
316
+ )
317
+
318
+ def clear_listeners(self) -> None:
319
+ """Remove all registered listeners."""
320
+ self._listeners.clear()
321
+
322
+ @property
323
+ def listener_count(self) -> int:
324
+ """Return the number of registered listeners."""
325
+ return len(self._listeners)
326
+
327
+
328
+ class LocalSkillManager:
329
+ """Manages skill storage in SQLite.
330
+
331
+ Provides CRUD operations for skills with support for:
332
+ - Project-scoped uniqueness (UNIQUE(name, project_id))
333
+ - Category and tag filtering
334
+ - Change notifications for search reindexing
335
+ """
336
+
337
+ def __init__(
338
+ self,
339
+ db: DatabaseProtocol,
340
+ notifier: Any | None = None, # SkillChangeNotifier, avoid circular import
341
+ ):
342
+ """Initialize the skill manager.
343
+
344
+ Args:
345
+ db: Database protocol implementation
346
+ notifier: Optional change notifier for mutations
347
+ """
348
+ self.db = db
349
+ self._notifier = notifier
350
+
351
+ def _notify_change(
352
+ self,
353
+ event_type: str,
354
+ skill_id: str,
355
+ skill_name: str,
356
+ metadata: dict[str, Any] | None = None,
357
+ ) -> None:
358
+ """Fire a change event if a notifier is configured.
359
+
360
+ Args:
361
+ event_type: Type of change ('create', 'update', 'delete')
362
+ skill_id: ID of the affected skill
363
+ skill_name: Name of the affected skill
364
+ metadata: Optional additional metadata
365
+ """
366
+ if self._notifier is not None:
367
+ try:
368
+ self._notifier.fire_change(
369
+ event_type=event_type,
370
+ skill_id=skill_id,
371
+ skill_name=skill_name,
372
+ metadata=metadata,
373
+ )
374
+ except Exception as e:
375
+ logger.error(f"Error in skill change notifier: {e}")
376
+
377
+ def create_skill(
378
+ self,
379
+ name: str,
380
+ description: str,
381
+ content: str,
382
+ version: str | None = None,
383
+ license: str | None = None,
384
+ compatibility: str | None = None,
385
+ allowed_tools: list[str] | None = None,
386
+ metadata: dict[str, Any] | None = None,
387
+ source_path: str | None = None,
388
+ source_type: SkillSourceType | None = None,
389
+ source_ref: str | None = None,
390
+ enabled: bool = True,
391
+ project_id: str | None = None,
392
+ ) -> Skill:
393
+ """Create a new skill.
394
+
395
+ Args:
396
+ name: Skill name (max 64 chars, lowercase+hyphens)
397
+ description: Skill description (max 1024 chars)
398
+ content: Full markdown content
399
+ version: Optional version string
400
+ license: Optional license identifier
401
+ compatibility: Optional compatibility notes (max 500 chars)
402
+ allowed_tools: Optional list of allowed tool patterns
403
+ metadata: Optional free-form metadata
404
+ source_path: Original file path or URL
405
+ source_type: Source type ('local', 'github', 'url', 'zip', 'filesystem')
406
+ source_ref: Git ref for updates
407
+ enabled: Whether skill is active
408
+ project_id: Project scope (None for global)
409
+
410
+ Returns:
411
+ The created Skill
412
+
413
+ Raises:
414
+ ValueError: If a skill with the same name exists in the project scope
415
+ """
416
+ now = datetime.now(UTC).isoformat()
417
+ skill_id = generate_prefixed_id("skl", f"{name}:{project_id or 'global'}")
418
+
419
+ # Check if skill already exists in this project scope
420
+ existing = self.get_by_name(name, project_id=project_id)
421
+ if existing:
422
+ raise ValueError(
423
+ f"Skill '{name}' already exists"
424
+ + (f" in project {project_id}" if project_id else " globally")
425
+ )
426
+
427
+ # Serialize JSON fields
428
+ allowed_tools_json = json.dumps(allowed_tools) if allowed_tools else None
429
+ metadata_json = json.dumps(metadata) if metadata else None
430
+
431
+ with self.db.transaction() as conn:
432
+ conn.execute(
433
+ """
434
+ INSERT INTO skills (
435
+ id, name, description, content, version, license,
436
+ compatibility, allowed_tools, metadata, source_path,
437
+ source_type, source_ref, enabled, project_id,
438
+ created_at, updated_at
439
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
440
+ """,
441
+ (
442
+ skill_id,
443
+ name,
444
+ description,
445
+ content,
446
+ version,
447
+ license,
448
+ compatibility,
449
+ allowed_tools_json,
450
+ metadata_json,
451
+ source_path,
452
+ source_type,
453
+ source_ref,
454
+ enabled,
455
+ project_id,
456
+ now,
457
+ now,
458
+ ),
459
+ )
460
+
461
+ skill = self.get_skill(skill_id)
462
+ self._notify_change("create", skill_id, name)
463
+ return skill
464
+
465
+ def get_skill(self, skill_id: str) -> Skill:
466
+ """Get a skill by ID.
467
+
468
+ Args:
469
+ skill_id: The skill ID
470
+
471
+ Returns:
472
+ The Skill
473
+
474
+ Raises:
475
+ ValueError: If skill not found
476
+ """
477
+ row = self.db.fetchone("SELECT * FROM skills WHERE id = ?", (skill_id,))
478
+ if not row:
479
+ raise ValueError(f"Skill {skill_id} not found")
480
+ return Skill.from_row(row)
481
+
482
+ def get_by_name(
483
+ self,
484
+ name: str,
485
+ project_id: str | None = None,
486
+ include_global: bool = True,
487
+ ) -> Skill | None:
488
+ """Get a skill by name within a project scope.
489
+
490
+ Args:
491
+ name: The skill name
492
+ project_id: Project scope (None for global)
493
+ include_global: Include global skills when project_id is set.
494
+ When True and project_id is set, first looks for
495
+ project-scoped skill, then falls back to global.
496
+
497
+ Returns:
498
+ The Skill if found, None otherwise
499
+ """
500
+ if project_id:
501
+ # First try project-scoped skill
502
+ row = self.db.fetchone(
503
+ "SELECT * FROM skills WHERE name = ? AND project_id = ?",
504
+ (name, project_id),
505
+ )
506
+ # If not found and include_global, try global
507
+ if row is None and include_global:
508
+ row = self.db.fetchone(
509
+ "SELECT * FROM skills WHERE name = ? AND project_id IS NULL",
510
+ (name,),
511
+ )
512
+ else:
513
+ row = self.db.fetchone(
514
+ "SELECT * FROM skills WHERE name = ? AND project_id IS NULL",
515
+ (name,),
516
+ )
517
+ return Skill.from_row(row) if row else None
518
+
519
+ def update_skill(
520
+ self,
521
+ skill_id: str,
522
+ name: str | None = None,
523
+ description: str | None = None,
524
+ content: str | None = None,
525
+ version: str | None = _UNSET,
526
+ license: str | None = _UNSET,
527
+ compatibility: str | None = _UNSET,
528
+ allowed_tools: list[str] | None = _UNSET,
529
+ metadata: dict[str, Any] | None = _UNSET,
530
+ source_path: str | None = _UNSET,
531
+ source_type: SkillSourceType | None = _UNSET,
532
+ source_ref: str | None = _UNSET,
533
+ enabled: bool | None = None,
534
+ ) -> Skill:
535
+ """Update an existing skill.
536
+
537
+ Args:
538
+ skill_id: The skill ID to update
539
+ name: New name (optional)
540
+ description: New description (optional)
541
+ content: New content (optional)
542
+ version: New version (use _UNSET to leave unchanged, None to clear)
543
+ license: New license (use _UNSET to leave unchanged, None to clear)
544
+ compatibility: New compatibility (use _UNSET to leave unchanged, None to clear)
545
+ allowed_tools: New allowed tools (use _UNSET to leave unchanged, None to clear)
546
+ metadata: New metadata (use _UNSET to leave unchanged, None to clear)
547
+ source_path: New source path (use _UNSET to leave unchanged, None to clear)
548
+ source_type: New source type (use _UNSET to leave unchanged, None to clear)
549
+ source_ref: New source ref (use _UNSET to leave unchanged, None to clear)
550
+ enabled: New enabled state (optional)
551
+
552
+ Returns:
553
+ The updated Skill
554
+
555
+ Raises:
556
+ ValueError: If skill not found
557
+ """
558
+ updates = []
559
+ params: list[Any] = []
560
+
561
+ if name is not None:
562
+ updates.append("name = ?")
563
+ params.append(name)
564
+ if description is not None:
565
+ updates.append("description = ?")
566
+ params.append(description)
567
+ if content is not None:
568
+ updates.append("content = ?")
569
+ params.append(content)
570
+ if version is not _UNSET:
571
+ updates.append("version = ?")
572
+ params.append(version)
573
+ if license is not _UNSET:
574
+ updates.append("license = ?")
575
+ params.append(license)
576
+ if compatibility is not _UNSET:
577
+ updates.append("compatibility = ?")
578
+ params.append(compatibility)
579
+ if allowed_tools is not _UNSET:
580
+ updates.append("allowed_tools = ?")
581
+ params.append(json.dumps(allowed_tools) if allowed_tools else None)
582
+ if metadata is not _UNSET:
583
+ updates.append("metadata = ?")
584
+ params.append(json.dumps(metadata) if metadata else None)
585
+ if source_path is not _UNSET:
586
+ updates.append("source_path = ?")
587
+ params.append(source_path)
588
+ if source_type is not _UNSET:
589
+ updates.append("source_type = ?")
590
+ params.append(source_type)
591
+ if source_ref is not _UNSET:
592
+ updates.append("source_ref = ?")
593
+ params.append(source_ref)
594
+ if enabled is not None:
595
+ updates.append("enabled = ?")
596
+ params.append(enabled)
597
+
598
+ if not updates:
599
+ return self.get_skill(skill_id)
600
+
601
+ updates.append("updated_at = ?")
602
+ params.append(datetime.now(UTC).isoformat())
603
+ params.append(skill_id)
604
+
605
+ # nosec B608: SET clause built from hardcoded column names, values parameterized
606
+ sql = f"UPDATE skills SET {', '.join(updates)} WHERE id = ?" # nosec B608
607
+
608
+ with self.db.transaction() as conn:
609
+ cursor = conn.execute(sql, tuple(params))
610
+ if cursor.rowcount == 0:
611
+ raise ValueError(f"Skill {skill_id} not found")
612
+
613
+ skill = self.get_skill(skill_id)
614
+ self._notify_change("update", skill_id, skill.name)
615
+ return skill
616
+
617
+ def delete_skill(self, skill_id: str) -> bool:
618
+ """Delete a skill by ID.
619
+
620
+ Args:
621
+ skill_id: The skill ID to delete
622
+
623
+ Returns:
624
+ True if deleted, False if not found
625
+ """
626
+ # Get skill name before deletion for notification
627
+ try:
628
+ skill = self.get_skill(skill_id)
629
+ skill_name = skill.name
630
+ except ValueError:
631
+ return False
632
+
633
+ with self.db.transaction() as conn:
634
+ cursor = conn.execute("DELETE FROM skills WHERE id = ?", (skill_id,))
635
+ if cursor.rowcount == 0:
636
+ return False
637
+
638
+ self._notify_change("delete", skill_id, skill_name)
639
+ return True
640
+
641
+ def list_skills(
642
+ self,
643
+ project_id: str | None = None,
644
+ enabled: bool | None = None,
645
+ category: str | None = None,
646
+ limit: int = 50,
647
+ offset: int = 0,
648
+ include_global: bool = True,
649
+ ) -> list[Skill]:
650
+ """List skills with optional filtering.
651
+
652
+ Args:
653
+ project_id: Filter by project (None for global only)
654
+ enabled: Filter by enabled state
655
+ category: Filter by category (from metadata.skillport.category)
656
+ limit: Maximum number of results
657
+ offset: Number of results to skip
658
+ include_global: Include global skills when project_id is set
659
+
660
+ Returns:
661
+ List of matching Skills
662
+ """
663
+ query = "SELECT * FROM skills WHERE 1=1"
664
+ params: list[Any] = []
665
+
666
+ if project_id:
667
+ if include_global:
668
+ query += " AND (project_id = ? OR project_id IS NULL)"
669
+ params.append(project_id)
670
+ else:
671
+ query += " AND project_id = ?"
672
+ params.append(project_id)
673
+ else:
674
+ query += " AND project_id IS NULL"
675
+
676
+ if enabled is not None:
677
+ query += " AND enabled = ?"
678
+ params.append(enabled)
679
+
680
+ # Filter by category using JSON extraction in SQL to avoid under-filled results
681
+ # Check both top-level $.category and nested $.skillport.category
682
+ if category:
683
+ query += """ AND (
684
+ json_extract(metadata, '$.category') = ?
685
+ OR json_extract(metadata, '$.skillport.category') = ?
686
+ )"""
687
+ params.extend([category, category])
688
+
689
+ query += " ORDER BY name ASC LIMIT ? OFFSET ?"
690
+ params.extend([limit, offset])
691
+
692
+ rows = self.db.fetchall(query, tuple(params))
693
+ return [Skill.from_row(row) for row in rows]
694
+
695
+ def search_skills(
696
+ self,
697
+ query_text: str,
698
+ project_id: str | None = None,
699
+ limit: int = 20,
700
+ ) -> list[Skill]:
701
+ """Search skills by name and description.
702
+
703
+ This is a simple text search. For advanced search with TF-IDF
704
+ and embeddings, use SkillSearch from the skills module.
705
+
706
+ Args:
707
+ query_text: Text to search for
708
+ project_id: Optional project scope
709
+ limit: Maximum number of results
710
+
711
+ Returns:
712
+ List of matching Skills
713
+ """
714
+ # Escape LIKE wildcards
715
+ escaped_query = query_text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
716
+ sql = """
717
+ SELECT * FROM skills
718
+ WHERE (name LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\')
719
+ """
720
+ params: list[Any] = [f"%{escaped_query}%", f"%{escaped_query}%"]
721
+
722
+ if project_id:
723
+ sql += " AND (project_id = ? OR project_id IS NULL)"
724
+ params.append(project_id)
725
+
726
+ sql += " ORDER BY name ASC LIMIT ?"
727
+ params.append(limit)
728
+
729
+ rows = self.db.fetchall(sql, tuple(params))
730
+ return [Skill.from_row(row) for row in rows]
731
+
732
+ def list_core_skills(self, project_id: str | None = None) -> list[Skill]:
733
+ """List skills with alwaysApply=true.
734
+
735
+ Args:
736
+ project_id: Optional project scope
737
+
738
+ Returns:
739
+ List of core skills (always-apply skills)
740
+ """
741
+ skills = self.list_skills(project_id=project_id, enabled=True, limit=1000)
742
+ return [s for s in skills if s.is_always_apply()]
743
+
744
+ def skill_exists(self, skill_id: str) -> bool:
745
+ """Check if a skill with the given ID exists.
746
+
747
+ Args:
748
+ skill_id: The skill ID to check
749
+
750
+ Returns:
751
+ True if exists, False otherwise
752
+ """
753
+ row = self.db.fetchone("SELECT 1 FROM skills WHERE id = ?", (skill_id,))
754
+ return row is not None
755
+
756
+ def count_skills(
757
+ self,
758
+ project_id: str | None = None,
759
+ enabled: bool | None = None,
760
+ ) -> int:
761
+ """Count skills matching criteria.
762
+
763
+ Args:
764
+ project_id: Filter by project
765
+ enabled: Filter by enabled state
766
+
767
+ Returns:
768
+ Number of matching skills
769
+ """
770
+ query = "SELECT COUNT(*) as count FROM skills WHERE 1=1"
771
+ params: list[Any] = []
772
+
773
+ if project_id:
774
+ query += " AND (project_id = ? OR project_id IS NULL)"
775
+ params.append(project_id)
776
+
777
+ if enabled is not None:
778
+ query += " AND enabled = ?"
779
+ params.append(enabled)
780
+
781
+ row = self.db.fetchone(query, tuple(params))
782
+ return row["count"] if row else 0