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,616 @@
1
+ """
2
+ Internal MCP tools for Skill management.
3
+
4
+ Exposes functionality for:
5
+ - list_skills(): List all skills with lightweight metadata
6
+ - get_skill(): Get skill by ID or name with full content
7
+ - search_skills(): Search skills by query with relevance ranking
8
+ - update_skill(): Update an existing skill by refreshing from source
9
+ - install_skill(): Install skill from local path, GitHub URL, or ZIP archive
10
+ - remove_skill(): Remove a skill by name or ID
11
+
12
+ These tools use LocalSkillManager for storage and SkillSearch for search.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections.abc import Callable
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ from gobby.mcp_proxy.tools.internal import InternalToolRegistry
23
+ from gobby.skills.loader import SkillLoader, SkillLoadError
24
+ from gobby.skills.search import SearchFilters, SkillSearch
25
+ from gobby.skills.updater import SkillUpdater
26
+ from gobby.storage.skills import ChangeEvent, LocalSkillManager, SkillChangeNotifier
27
+
28
+ if TYPE_CHECKING:
29
+ from gobby.storage.database import DatabaseProtocol
30
+
31
+ __all__ = ["create_skills_registry", "SkillsToolRegistry"]
32
+
33
+
34
+ class SkillsToolRegistry(InternalToolRegistry):
35
+ """Registry for skill management tools with test-friendly get_tool method."""
36
+
37
+ search: SkillSearch # Assigned dynamically in create_skills_registry
38
+
39
+ def get_tool(self, name: str) -> Callable[..., Any] | None:
40
+ """Get a tool function by name (for testing)."""
41
+ tool = self._tools.get(name)
42
+ return tool.func if tool else None
43
+
44
+
45
+ def create_skills_registry(
46
+ db: DatabaseProtocol,
47
+ project_id: str | None = None,
48
+ ) -> SkillsToolRegistry:
49
+ """
50
+ Create a skills management tool registry.
51
+
52
+ Args:
53
+ db: Database connection for storage
54
+ project_id: Optional default project scope for skill operations
55
+
56
+ Returns:
57
+ SkillsToolRegistry with skill management tools registered
58
+ """
59
+ registry = SkillsToolRegistry(
60
+ name="gobby-skills",
61
+ description="Skill management - list_skills, get_skill, search_skills, install_skill, update_skill, remove_skill",
62
+ )
63
+
64
+ # Initialize change notifier and storage
65
+ notifier = SkillChangeNotifier()
66
+ storage = LocalSkillManager(db, notifier=notifier)
67
+
68
+ # --- list_skills tool ---
69
+
70
+ @registry.tool(
71
+ name="list_skills",
72
+ description="List all skills with lightweight metadata. Supports filtering by category and enabled status.",
73
+ )
74
+ async def list_skills(
75
+ category: str | None = None,
76
+ enabled: bool | None = None,
77
+ limit: int = 50,
78
+ ) -> dict[str, Any]:
79
+ """
80
+ List skills with lightweight metadata.
81
+
82
+ Returns ~100 tokens per skill: name, description, category, tags, enabled.
83
+ Does NOT include content, allowed_tools, or compatibility.
84
+
85
+ Args:
86
+ category: Optional category filter
87
+ enabled: Optional enabled status filter (True/False/None for all)
88
+ limit: Maximum skills to return (default 50)
89
+
90
+ Returns:
91
+ Dict with success status and list of skill metadata
92
+ """
93
+ try:
94
+ skills = storage.list_skills(
95
+ project_id=project_id,
96
+ category=category,
97
+ enabled=enabled,
98
+ limit=limit,
99
+ include_global=True,
100
+ )
101
+
102
+ # Extract lightweight metadata only
103
+ skill_list = []
104
+ for skill in skills:
105
+ # Get category and tags from metadata
106
+ category_value = None
107
+ tags = []
108
+ if skill.metadata and isinstance(skill.metadata, dict):
109
+ skillport = skill.metadata.get("skillport", {})
110
+ if isinstance(skillport, dict):
111
+ category_value = skillport.get("category")
112
+ tags = skillport.get("tags", [])
113
+
114
+ skill_list.append(
115
+ {
116
+ "id": skill.id,
117
+ "name": skill.name,
118
+ "description": skill.description,
119
+ "category": category_value,
120
+ "tags": tags,
121
+ "enabled": skill.enabled,
122
+ }
123
+ )
124
+
125
+ return {
126
+ "success": True,
127
+ "count": len(skill_list),
128
+ "skills": skill_list,
129
+ }
130
+ except Exception as e:
131
+ return {
132
+ "success": False,
133
+ "error": str(e),
134
+ }
135
+
136
+ # --- get_skill tool ---
137
+
138
+ @registry.tool(
139
+ name="get_skill",
140
+ description="Get full skill content by name or ID. Returns complete skill including content, allowed_tools, etc.",
141
+ )
142
+ async def get_skill(
143
+ name: str | None = None,
144
+ skill_id: str | None = None,
145
+ ) -> dict[str, Any]:
146
+ """
147
+ Get a skill by name or ID with full content.
148
+
149
+ Returns all skill fields including content, allowed_tools, compatibility.
150
+ Use this after list_skills to get the full skill when needed.
151
+
152
+ Args:
153
+ name: Skill name (used if skill_id not provided)
154
+ skill_id: Skill ID (takes precedence over name)
155
+
156
+ Returns:
157
+ Dict with success status and full skill data
158
+ """
159
+ try:
160
+ # Validate input
161
+ if not skill_id and not name:
162
+ return {
163
+ "success": False,
164
+ "error": "Either name or skill_id is required",
165
+ }
166
+
167
+ # Get skill by ID or name
168
+ skill = None
169
+ if skill_id:
170
+ try:
171
+ skill = storage.get_skill(skill_id)
172
+ except ValueError:
173
+ pass
174
+
175
+ if skill is None and name:
176
+ skill = storage.get_by_name(name, project_id=project_id)
177
+
178
+ if skill is None:
179
+ return {
180
+ "success": False,
181
+ "error": f"Skill not found: {skill_id or name}",
182
+ }
183
+
184
+ # Return full skill data
185
+ return {
186
+ "success": True,
187
+ "skill": {
188
+ "id": skill.id,
189
+ "name": skill.name,
190
+ "description": skill.description,
191
+ "content": skill.content,
192
+ "version": skill.version,
193
+ "license": skill.license,
194
+ "compatibility": skill.compatibility,
195
+ "allowed_tools": skill.allowed_tools,
196
+ "metadata": skill.metadata,
197
+ "enabled": skill.enabled,
198
+ "source_path": skill.source_path,
199
+ "source_type": skill.source_type,
200
+ "source_ref": skill.source_ref,
201
+ },
202
+ }
203
+ except Exception as e:
204
+ return {
205
+ "success": False,
206
+ "error": str(e),
207
+ }
208
+
209
+ # --- search_skills tool ---
210
+
211
+ # Initialize search and index skills
212
+ search = SkillSearch()
213
+ # Expose search instance on registry for testing/manual indexing
214
+ registry.search = search
215
+
216
+ def _index_skills() -> None:
217
+ """Index all skills for search."""
218
+ skills = storage.list_skills(
219
+ project_id=project_id,
220
+ limit=10000,
221
+ include_global=True,
222
+ )
223
+ search.index_skills(skills)
224
+
225
+ # Index on registry creation
226
+ _index_skills()
227
+
228
+ # Wire up change notifier to re-index on any skill mutation
229
+ def _on_skill_change(event: ChangeEvent) -> None:
230
+ """Re-index skills when any skill is created, updated, or deleted."""
231
+ _index_skills()
232
+
233
+ notifier.add_listener(_on_skill_change)
234
+
235
+ @registry.tool(
236
+ name="search_skills",
237
+ description="Search for skills by query. Returns ranked results with relevance scores. Supports filtering by category and tags.",
238
+ )
239
+ async def search_skills(
240
+ query: str,
241
+ category: str | None = None,
242
+ tags_any: list[str] | None = None,
243
+ tags_all: list[str] | None = None,
244
+ top_k: int = 10,
245
+ ) -> dict[str, Any]:
246
+ """
247
+ Search for skills by natural language query.
248
+
249
+ Returns ranked results with relevance scores.
250
+
251
+ Args:
252
+ query: Search query (required, non-empty)
253
+ category: Optional category filter
254
+ tags_any: Optional tags filter - match any of these tags
255
+ tags_all: Optional tags filter - match all of these tags
256
+ top_k: Maximum results to return (default 10)
257
+
258
+ Returns:
259
+ Dict with success status and ranked search results
260
+ """
261
+ try:
262
+ # Validate query
263
+ if not query or not query.strip():
264
+ return {
265
+ "success": False,
266
+ "error": "Query is required and cannot be empty",
267
+ }
268
+
269
+ # Build filters
270
+ filters = None
271
+ if category or tags_any or tags_all:
272
+ filters = SearchFilters(
273
+ category=category,
274
+ tags_any=tags_any,
275
+ tags_all=tags_all,
276
+ )
277
+
278
+ # Perform search
279
+ results = await search.search_async(query=query, top_k=top_k, filters=filters)
280
+
281
+ # Format results with skill metadata
282
+ result_list = []
283
+ for r in results:
284
+ # Look up skill to get description, category, tags
285
+ skill = None
286
+ try:
287
+ skill = storage.get_skill(r.skill_id)
288
+ except ValueError:
289
+ pass
290
+
291
+ # Get category and tags from metadata
292
+ category_value = None
293
+ tags = []
294
+ if skill and skill.metadata and isinstance(skill.metadata, dict):
295
+ skillport = skill.metadata.get("skillport", {})
296
+ if isinstance(skillport, dict):
297
+ category_value = skillport.get("category")
298
+ tags = skillport.get("tags", [])
299
+
300
+ result_list.append(
301
+ {
302
+ "skill_id": r.skill_id,
303
+ "skill_name": r.skill_name,
304
+ "description": skill.description if skill else None,
305
+ "category": category_value,
306
+ "tags": tags,
307
+ "score": r.similarity,
308
+ }
309
+ )
310
+
311
+ return {
312
+ "success": True,
313
+ "count": len(result_list),
314
+ "results": result_list,
315
+ }
316
+ except Exception as e:
317
+ return {
318
+ "success": False,
319
+ "error": str(e),
320
+ }
321
+
322
+ # --- remove_skill tool ---
323
+
324
+ @registry.tool(
325
+ name="remove_skill",
326
+ description="Remove a skill by name or ID. Returns success status and removed skill name.",
327
+ )
328
+ async def remove_skill(
329
+ name: str | None = None,
330
+ skill_id: str | None = None,
331
+ ) -> dict[str, Any]:
332
+ """
333
+ Remove a skill from the database.
334
+
335
+ Args:
336
+ name: Skill name (used if skill_id not provided)
337
+ skill_id: Skill ID (takes precedence over name)
338
+
339
+ Returns:
340
+ Dict with success status and removed skill info
341
+ """
342
+ try:
343
+ # Validate input
344
+ if not skill_id and not name:
345
+ return {
346
+ "success": False,
347
+ "error": "Either name or skill_id is required",
348
+ }
349
+
350
+ # Find the skill first to get its name
351
+ skill = None
352
+ if skill_id:
353
+ try:
354
+ skill = storage.get_skill(skill_id)
355
+ except ValueError:
356
+ pass
357
+
358
+ if skill is None and name:
359
+ skill = storage.get_by_name(name, project_id=project_id)
360
+
361
+ if skill is None:
362
+ return {
363
+ "success": False,
364
+ "error": f"Skill not found: {skill_id or name}",
365
+ }
366
+
367
+ # Store the name before deletion
368
+ skill_name = skill.name
369
+
370
+ # Delete the skill (notifier triggers re-indexing automatically)
371
+ storage.delete_skill(skill.id)
372
+
373
+ return {
374
+ "success": True,
375
+ "removed": True,
376
+ "skill_name": skill_name,
377
+ }
378
+ except Exception as e:
379
+ return {
380
+ "success": False,
381
+ "error": str(e),
382
+ }
383
+
384
+ # --- update_skill tool ---
385
+
386
+ # Initialize updater
387
+ updater = SkillUpdater(storage)
388
+
389
+ @registry.tool(
390
+ name="update_skill",
391
+ description="Update a skill by refreshing from its source. Returns whether the skill was updated.",
392
+ )
393
+ async def update_skill(
394
+ name: str | None = None,
395
+ skill_id: str | None = None,
396
+ ) -> dict[str, Any]:
397
+ """
398
+ Update a skill by refreshing from its source path.
399
+
400
+ Args:
401
+ name: Skill name (used if skill_id not provided)
402
+ skill_id: Skill ID (takes precedence over name)
403
+
404
+ Returns:
405
+ Dict with success status and update info
406
+ """
407
+ try:
408
+ # Validate input
409
+ if not skill_id and not name:
410
+ return {
411
+ "success": False,
412
+ "error": "Either name or skill_id is required",
413
+ }
414
+
415
+ # Find the skill first
416
+ skill = None
417
+ if skill_id:
418
+ try:
419
+ skill = storage.get_skill(skill_id)
420
+ except ValueError:
421
+ pass
422
+
423
+ if skill is None and name:
424
+ skill = storage.get_by_name(name, project_id=project_id)
425
+
426
+ if skill is None:
427
+ return {
428
+ "success": False,
429
+ "error": f"Skill not found: {skill_id or name}",
430
+ }
431
+
432
+ # Use SkillUpdater to refresh from source
433
+ # (notifier triggers re-indexing automatically if updated)
434
+ result = updater.update_skill(skill.id)
435
+
436
+ return {
437
+ "success": result.success,
438
+ "updated": result.updated,
439
+ "skipped": result.skipped,
440
+ "skip_reason": result.skip_reason,
441
+ "error": result.error,
442
+ }
443
+ except Exception as e:
444
+ return {
445
+ "success": False,
446
+ "error": str(e),
447
+ }
448
+
449
+ # --- install_skill tool ---
450
+
451
+ # Initialize loader
452
+ loader = SkillLoader()
453
+
454
+ @registry.tool(
455
+ name="install_skill",
456
+ description="Install a skill from a local path, GitHub URL, or ZIP archive. Auto-detects source type.",
457
+ )
458
+ async def install_skill(
459
+ source: str | None = None,
460
+ project_scoped: bool = False,
461
+ ) -> dict[str, Any]:
462
+ """
463
+ Install a skill from a source location.
464
+
465
+ Auto-detects source type:
466
+ - Local directory or SKILL.md file
467
+ - GitHub URL (owner/repo, github:owner/repo, https://github.com/...)
468
+ - ZIP archive (.zip file)
469
+
470
+ Args:
471
+ source: Path or URL to the skill source (required)
472
+ project_scoped: If True, install skill scoped to the project
473
+
474
+ Returns:
475
+ Dict with success status, skill_id, skill_name, and source_type
476
+ """
477
+ try:
478
+ # Validate input
479
+ if not source or not source.strip():
480
+ return {
481
+ "success": False,
482
+ "error": "source parameter is required",
483
+ }
484
+
485
+ source = source.strip()
486
+
487
+ # Determine source type and load skill
488
+ from gobby.storage.skills import SkillSourceType
489
+
490
+ parsed_skill = None
491
+ source_type: SkillSourceType | None = None
492
+
493
+ # Check if it's a GitHub URL/reference
494
+ # Pattern for owner/repo format (e.g., "anthropic/claude-code")
495
+ # Must match owner/repo pattern without path traversal or absolute paths
496
+ github_owner_repo_pattern = re.compile(
497
+ r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[A-Za-z0-9_./-]*)?$"
498
+ )
499
+
500
+ # Explicit GitHub references (always treated as GitHub, no filesystem check)
501
+ is_explicit_github = (
502
+ source.startswith("github:")
503
+ or source.startswith("https://github.com/")
504
+ or source.startswith("http://github.com/")
505
+ )
506
+
507
+ # For implicit owner/repo patterns, check local filesystem first
508
+ is_implicit_github_pattern = (
509
+ not is_explicit_github
510
+ and github_owner_repo_pattern.match(source)
511
+ and not source.startswith("/")
512
+ and ".." not in source # Reject path traversal
513
+ )
514
+
515
+ # Determine if this is a GitHub reference:
516
+ # - Explicit refs are always GitHub
517
+ # - Implicit patterns are GitHub only if local path doesn't exist
518
+ is_github_ref = is_explicit_github or (
519
+ is_implicit_github_pattern and not Path(source).exists()
520
+ )
521
+ if is_github_ref:
522
+ # GitHub URL
523
+ try:
524
+ parsed_skill = loader.load_from_github(source)
525
+ source_type = "github"
526
+ except SkillLoadError as e:
527
+ return {
528
+ "success": False,
529
+ "error": f"Failed to load from GitHub: {e}",
530
+ }
531
+
532
+ # Check if it's a ZIP file
533
+ elif source.endswith(".zip"):
534
+ zip_path = Path(source)
535
+ if not zip_path.exists():
536
+ return {
537
+ "success": False,
538
+ "error": f"ZIP file not found: {source}",
539
+ }
540
+ try:
541
+ parsed_skill = loader.load_from_zip(zip_path)
542
+ source_type = "zip"
543
+ except SkillLoadError as e:
544
+ return {
545
+ "success": False,
546
+ "error": f"Failed to load from ZIP: {e}",
547
+ }
548
+
549
+ # Assume it's a local path
550
+ else:
551
+ local_path = Path(source)
552
+ if not local_path.exists():
553
+ return {
554
+ "success": False,
555
+ "error": f"Path not found: {source}",
556
+ }
557
+ try:
558
+ parsed_skill = loader.load_skill(local_path)
559
+ source_type = "local"
560
+ except SkillLoadError as e:
561
+ return {
562
+ "success": False,
563
+ "error": f"Failed to load skill: {e}",
564
+ }
565
+
566
+ if parsed_skill is None:
567
+ return {
568
+ "success": False,
569
+ "error": "Failed to load skill from source",
570
+ }
571
+
572
+ # Handle case where load_from_github/load_from_zip returns a list
573
+ if isinstance(parsed_skill, list):
574
+ if len(parsed_skill) == 0:
575
+ return {
576
+ "success": False,
577
+ "error": "No skills found in source",
578
+ }
579
+ # Use the first skill if multiple were found
580
+ parsed_skill = parsed_skill[0]
581
+
582
+ # Determine project ID for the skill
583
+ skill_project_id = project_id if project_scoped else None
584
+
585
+ # Store the skill
586
+ skill = storage.create_skill(
587
+ name=parsed_skill.name,
588
+ description=parsed_skill.description,
589
+ content=parsed_skill.content,
590
+ version=parsed_skill.version,
591
+ license=parsed_skill.license,
592
+ compatibility=parsed_skill.compatibility,
593
+ allowed_tools=parsed_skill.allowed_tools,
594
+ metadata=parsed_skill.metadata,
595
+ source_path=parsed_skill.source_path,
596
+ source_type=source_type,
597
+ source_ref=getattr(parsed_skill, "source_ref", None),
598
+ project_id=skill_project_id,
599
+ enabled=True,
600
+ )
601
+ # Notifier triggers re-indexing automatically via create_skill
602
+
603
+ return {
604
+ "success": True,
605
+ "installed": True,
606
+ "skill_id": skill.id,
607
+ "skill_name": skill.name,
608
+ "source_type": source_type,
609
+ }
610
+ except Exception as e:
611
+ return {
612
+ "success": False,
613
+ "error": str(e),
614
+ }
615
+
616
+ return registry