gobby 0.2.5__py3-none-any.whl → 0.2.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. gobby/adapters/claude_code.py +13 -4
  2. gobby/adapters/codex.py +43 -3
  3. gobby/agents/runner.py +8 -0
  4. gobby/cli/__init__.py +6 -0
  5. gobby/cli/clones.py +419 -0
  6. gobby/cli/conductor.py +266 -0
  7. gobby/cli/installers/antigravity.py +3 -9
  8. gobby/cli/installers/claude.py +9 -9
  9. gobby/cli/installers/codex.py +2 -8
  10. gobby/cli/installers/gemini.py +2 -8
  11. gobby/cli/installers/shared.py +71 -8
  12. gobby/cli/skills.py +858 -0
  13. gobby/cli/tasks/ai.py +0 -440
  14. gobby/cli/tasks/crud.py +44 -6
  15. gobby/cli/tasks/main.py +0 -4
  16. gobby/cli/tui.py +2 -2
  17. gobby/cli/utils.py +3 -3
  18. gobby/clones/__init__.py +13 -0
  19. gobby/clones/git.py +547 -0
  20. gobby/conductor/__init__.py +16 -0
  21. gobby/conductor/alerts.py +135 -0
  22. gobby/conductor/loop.py +164 -0
  23. gobby/conductor/monitors/__init__.py +11 -0
  24. gobby/conductor/monitors/agents.py +116 -0
  25. gobby/conductor/monitors/tasks.py +155 -0
  26. gobby/conductor/pricing.py +234 -0
  27. gobby/conductor/token_tracker.py +160 -0
  28. gobby/config/app.py +63 -1
  29. gobby/config/search.py +110 -0
  30. gobby/config/servers.py +1 -1
  31. gobby/config/skills.py +43 -0
  32. gobby/config/tasks.py +6 -14
  33. gobby/hooks/event_handlers.py +145 -2
  34. gobby/hooks/hook_manager.py +48 -2
  35. gobby/hooks/skill_manager.py +130 -0
  36. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  37. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  38. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  39. gobby/llm/claude.py +22 -34
  40. gobby/llm/claude_executor.py +46 -256
  41. gobby/llm/codex_executor.py +59 -291
  42. gobby/llm/executor.py +21 -0
  43. gobby/llm/gemini.py +134 -110
  44. gobby/llm/litellm_executor.py +143 -6
  45. gobby/llm/resolver.py +95 -33
  46. gobby/mcp_proxy/instructions.py +54 -0
  47. gobby/mcp_proxy/models.py +15 -0
  48. gobby/mcp_proxy/registries.py +68 -5
  49. gobby/mcp_proxy/server.py +33 -3
  50. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  51. gobby/mcp_proxy/stdio.py +2 -1
  52. gobby/mcp_proxy/tools/__init__.py +0 -2
  53. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  54. gobby/mcp_proxy/tools/clones.py +903 -0
  55. gobby/mcp_proxy/tools/memory.py +1 -24
  56. gobby/mcp_proxy/tools/metrics.py +65 -1
  57. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  58. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  59. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  60. gobby/mcp_proxy/tools/session_messages.py +1 -2
  61. gobby/mcp_proxy/tools/skills/__init__.py +631 -0
  62. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  63. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  64. gobby/mcp_proxy/tools/task_sync.py +1 -1
  65. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  66. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  67. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  68. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  69. gobby/mcp_proxy/tools/tasks/_lifecycle.py +60 -29
  70. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  71. gobby/mcp_proxy/tools/workflows.py +1 -1
  72. gobby/mcp_proxy/tools/worktrees.py +5 -0
  73. gobby/memory/backends/__init__.py +6 -1
  74. gobby/memory/backends/mem0.py +6 -1
  75. gobby/memory/extractor.py +477 -0
  76. gobby/memory/manager.py +11 -2
  77. gobby/prompts/defaults/handoff/compact.md +63 -0
  78. gobby/prompts/defaults/handoff/session_end.md +57 -0
  79. gobby/prompts/defaults/memory/extract.md +61 -0
  80. gobby/runner.py +37 -16
  81. gobby/search/__init__.py +48 -6
  82. gobby/search/backends/__init__.py +159 -0
  83. gobby/search/backends/embedding.py +225 -0
  84. gobby/search/embeddings.py +238 -0
  85. gobby/search/models.py +148 -0
  86. gobby/search/unified.py +496 -0
  87. gobby/servers/http.py +23 -8
  88. gobby/servers/routes/admin.py +280 -0
  89. gobby/servers/routes/mcp/tools.py +241 -52
  90. gobby/servers/websocket.py +2 -2
  91. gobby/sessions/analyzer.py +2 -0
  92. gobby/sessions/transcripts/base.py +1 -0
  93. gobby/sessions/transcripts/claude.py +64 -5
  94. gobby/skills/__init__.py +91 -0
  95. gobby/skills/loader.py +685 -0
  96. gobby/skills/manager.py +384 -0
  97. gobby/skills/parser.py +258 -0
  98. gobby/skills/search.py +463 -0
  99. gobby/skills/sync.py +119 -0
  100. gobby/skills/updater.py +385 -0
  101. gobby/skills/validator.py +368 -0
  102. gobby/storage/clones.py +378 -0
  103. gobby/storage/database.py +1 -1
  104. gobby/storage/memories.py +43 -13
  105. gobby/storage/migrations.py +180 -6
  106. gobby/storage/sessions.py +73 -0
  107. gobby/storage/skills.py +749 -0
  108. gobby/storage/tasks/_crud.py +4 -4
  109. gobby/storage/tasks/_lifecycle.py +41 -6
  110. gobby/storage/tasks/_manager.py +14 -5
  111. gobby/storage/tasks/_models.py +8 -3
  112. gobby/sync/memories.py +39 -4
  113. gobby/sync/tasks.py +83 -6
  114. gobby/tasks/__init__.py +1 -2
  115. gobby/tasks/validation.py +24 -15
  116. gobby/tui/api_client.py +4 -7
  117. gobby/tui/app.py +5 -3
  118. gobby/tui/screens/orchestrator.py +1 -2
  119. gobby/tui/screens/tasks.py +2 -4
  120. gobby/tui/ws_client.py +1 -1
  121. gobby/utils/daemon_client.py +2 -2
  122. gobby/workflows/actions.py +84 -2
  123. gobby/workflows/context_actions.py +43 -0
  124. gobby/workflows/detection_helpers.py +115 -31
  125. gobby/workflows/engine.py +13 -2
  126. gobby/workflows/lifecycle_evaluator.py +29 -1
  127. gobby/workflows/loader.py +19 -6
  128. gobby/workflows/memory_actions.py +74 -0
  129. gobby/workflows/summary_actions.py +17 -0
  130. gobby/workflows/task_enforcement_actions.py +448 -6
  131. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/METADATA +82 -21
  132. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/RECORD +136 -107
  133. gobby/install/codex/prompts/forget.md +0 -7
  134. gobby/install/codex/prompts/memories.md +0 -7
  135. gobby/install/codex/prompts/recall.md +0 -7
  136. gobby/install/codex/prompts/remember.md +0 -13
  137. gobby/llm/gemini_executor.py +0 -339
  138. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  139. gobby/tasks/context.py +0 -747
  140. gobby/tasks/criteria.py +0 -342
  141. gobby/tasks/expansion.py +0 -626
  142. gobby/tasks/prompts/expand.py +0 -327
  143. gobby/tasks/research.py +0 -421
  144. gobby/tasks/tdd.py +0 -352
  145. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/WHEEL +0 -0
  146. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/entry_points.txt +0 -0
  147. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/licenses/LICENSE.md +0 -0
  148. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,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