gobby 0.2.5__py3-none-any.whl → 0.2.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gobby/adapters/claude_code.py +13 -4
- gobby/adapters/codex.py +43 -3
- gobby/agents/runner.py +8 -0
- gobby/cli/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +9 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +2 -8
- gobby/cli/installers/shared.py +71 -8
- gobby/cli/skills.py +858 -0
- gobby/cli/tasks/ai.py +0 -440
- gobby/cli/tasks/crud.py +44 -6
- gobby/cli/tasks/main.py +0 -4
- gobby/cli/tui.py +2 -2
- gobby/cli/utils.py +3 -3
- gobby/clones/__init__.py +13 -0
- gobby/clones/git.py +547 -0
- gobby/conductor/__init__.py +16 -0
- gobby/conductor/alerts.py +135 -0
- gobby/conductor/loop.py +164 -0
- gobby/conductor/monitors/__init__.py +11 -0
- gobby/conductor/monitors/agents.py +116 -0
- gobby/conductor/monitors/tasks.py +155 -0
- gobby/conductor/pricing.py +234 -0
- gobby/conductor/token_tracker.py +160 -0
- gobby/config/app.py +63 -1
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +6 -14
- gobby/hooks/event_handlers.py +145 -2
- gobby/hooks/hook_manager.py +48 -2
- gobby/hooks/skill_manager.py +130 -0
- gobby/install/claude/hooks/hook_dispatcher.py +4 -4
- gobby/install/codex/hooks/hook_dispatcher.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
- gobby/llm/claude.py +22 -34
- gobby/llm/claude_executor.py +46 -256
- gobby/llm/codex_executor.py +59 -291
- gobby/llm/executor.py +21 -0
- gobby/llm/gemini.py +134 -110
- gobby/llm/litellm_executor.py +143 -6
- gobby/llm/resolver.py +95 -33
- gobby/mcp_proxy/instructions.py +54 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -5
- gobby/mcp_proxy/server.py +33 -3
- gobby/mcp_proxy/services/tool_proxy.py +81 -1
- gobby/mcp_proxy/stdio.py +2 -1
- gobby/mcp_proxy/tools/__init__.py +0 -2
- gobby/mcp_proxy/tools/agent_messaging.py +317 -0
- gobby/mcp_proxy/tools/clones.py +903 -0
- gobby/mcp_proxy/tools/memory.py +1 -24
- gobby/mcp_proxy/tools/metrics.py +65 -1
- gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
- gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
- gobby/mcp_proxy/tools/session_messages.py +1 -2
- gobby/mcp_proxy/tools/skills/__init__.py +631 -0
- gobby/mcp_proxy/tools/task_orchestration.py +7 -0
- gobby/mcp_proxy/tools/task_readiness.py +14 -0
- gobby/mcp_proxy/tools/task_sync.py +1 -1
- gobby/mcp_proxy/tools/tasks/_context.py +0 -20
- gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
- gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +60 -29
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +5 -0
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/manager.py +11 -2
- gobby/prompts/defaults/handoff/compact.md +63 -0
- gobby/prompts/defaults/handoff/session_end.md +57 -0
- gobby/prompts/defaults/memory/extract.md +61 -0
- gobby/runner.py +37 -16
- gobby/search/__init__.py +48 -6
- gobby/search/backends/__init__.py +159 -0
- gobby/search/backends/embedding.py +225 -0
- gobby/search/embeddings.py +238 -0
- gobby/search/models.py +148 -0
- gobby/search/unified.py +496 -0
- gobby/servers/http.py +23 -8
- gobby/servers/routes/admin.py +280 -0
- gobby/servers/routes/mcp/tools.py +241 -52
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +64 -5
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +258 -0
- gobby/skills/search.py +463 -0
- gobby/skills/sync.py +119 -0
- gobby/skills/updater.py +385 -0
- gobby/skills/validator.py +368 -0
- gobby/storage/clones.py +378 -0
- gobby/storage/database.py +1 -1
- gobby/storage/memories.py +43 -13
- gobby/storage/migrations.py +180 -6
- gobby/storage/sessions.py +73 -0
- gobby/storage/skills.py +749 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +41 -6
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +39 -4
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/validation.py +24 -15
- gobby/tui/api_client.py +4 -7
- gobby/tui/app.py +5 -3
- gobby/tui/screens/orchestrator.py +1 -2
- gobby/tui/screens/tasks.py +2 -4
- gobby/tui/ws_client.py +1 -1
- gobby/utils/daemon_client.py +2 -2
- gobby/workflows/actions.py +84 -2
- gobby/workflows/context_actions.py +43 -0
- gobby/workflows/detection_helpers.py +115 -31
- gobby/workflows/engine.py +13 -2
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/loader.py +19 -6
- gobby/workflows/memory_actions.py +74 -0
- gobby/workflows/summary_actions.py +17 -0
- gobby/workflows/task_enforcement_actions.py +448 -6
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/METADATA +82 -21
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/RECORD +136 -107
- gobby/install/codex/prompts/forget.md +0 -7
- gobby/install/codex/prompts/memories.md +0 -7
- gobby/install/codex/prompts/recall.md +0 -7
- gobby/install/codex/prompts/remember.md +0 -13
- gobby/llm/gemini_executor.py +0 -339
- gobby/mcp_proxy/tools/task_expansion.py +0 -591
- gobby/tasks/context.py +0 -747
- gobby/tasks/criteria.py +0 -342
- gobby/tasks/expansion.py +0 -626
- gobby/tasks/prompts/expand.py +0 -327
- gobby/tasks/research.py +0 -421
- gobby/tasks/tdd.py +0 -352
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/WHEEL +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,631 @@
|
|
|
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 LocalSkillManager
|
|
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 storage
|
|
65
|
+
storage = LocalSkillManager(db)
|
|
66
|
+
|
|
67
|
+
# --- list_skills tool ---
|
|
68
|
+
|
|
69
|
+
@registry.tool(
|
|
70
|
+
name="list_skills",
|
|
71
|
+
description="List all skills with lightweight metadata. Supports filtering by category and enabled status.",
|
|
72
|
+
)
|
|
73
|
+
async def list_skills(
|
|
74
|
+
category: str | None = None,
|
|
75
|
+
enabled: bool | None = None,
|
|
76
|
+
limit: int = 50,
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
"""
|
|
79
|
+
List skills with lightweight metadata.
|
|
80
|
+
|
|
81
|
+
Returns ~100 tokens per skill: name, description, category, tags, enabled.
|
|
82
|
+
Does NOT include content, allowed_tools, or compatibility.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
category: Optional category filter
|
|
86
|
+
enabled: Optional enabled status filter (True/False/None for all)
|
|
87
|
+
limit: Maximum skills to return (default 50)
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Dict with success status and list of skill metadata
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
skills = storage.list_skills(
|
|
94
|
+
project_id=project_id,
|
|
95
|
+
category=category,
|
|
96
|
+
enabled=enabled,
|
|
97
|
+
limit=limit,
|
|
98
|
+
include_global=True,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Extract lightweight metadata only
|
|
102
|
+
skill_list = []
|
|
103
|
+
for skill in skills:
|
|
104
|
+
# Get category and tags from metadata
|
|
105
|
+
category_value = None
|
|
106
|
+
tags = []
|
|
107
|
+
if skill.metadata and isinstance(skill.metadata, dict):
|
|
108
|
+
skillport = skill.metadata.get("skillport", {})
|
|
109
|
+
if isinstance(skillport, dict):
|
|
110
|
+
category_value = skillport.get("category")
|
|
111
|
+
tags = skillport.get("tags", [])
|
|
112
|
+
|
|
113
|
+
skill_list.append(
|
|
114
|
+
{
|
|
115
|
+
"id": skill.id,
|
|
116
|
+
"name": skill.name,
|
|
117
|
+
"description": skill.description,
|
|
118
|
+
"category": category_value,
|
|
119
|
+
"tags": tags,
|
|
120
|
+
"enabled": skill.enabled,
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"success": True,
|
|
126
|
+
"count": len(skill_list),
|
|
127
|
+
"skills": skill_list,
|
|
128
|
+
}
|
|
129
|
+
except Exception as e:
|
|
130
|
+
return {
|
|
131
|
+
"success": False,
|
|
132
|
+
"error": str(e),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# --- get_skill tool ---
|
|
136
|
+
|
|
137
|
+
@registry.tool(
|
|
138
|
+
name="get_skill",
|
|
139
|
+
description="Get full skill content by name or ID. Returns complete skill including content, allowed_tools, etc.",
|
|
140
|
+
)
|
|
141
|
+
async def get_skill(
|
|
142
|
+
name: str | None = None,
|
|
143
|
+
skill_id: str | None = None,
|
|
144
|
+
) -> dict[str, Any]:
|
|
145
|
+
"""
|
|
146
|
+
Get a skill by name or ID with full content.
|
|
147
|
+
|
|
148
|
+
Returns all skill fields including content, allowed_tools, compatibility.
|
|
149
|
+
Use this after list_skills to get the full skill when needed.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
name: Skill name (used if skill_id not provided)
|
|
153
|
+
skill_id: Skill ID (takes precedence over name)
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Dict with success status and full skill data
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
# Validate input
|
|
160
|
+
if not skill_id and not name:
|
|
161
|
+
return {
|
|
162
|
+
"success": False,
|
|
163
|
+
"error": "Either name or skill_id is required",
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
# Get skill by ID or name
|
|
167
|
+
skill = None
|
|
168
|
+
if skill_id:
|
|
169
|
+
try:
|
|
170
|
+
skill = storage.get_skill(skill_id)
|
|
171
|
+
except ValueError:
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
if skill is None and name:
|
|
175
|
+
skill = storage.get_by_name(name, project_id=project_id)
|
|
176
|
+
|
|
177
|
+
if skill is None:
|
|
178
|
+
return {
|
|
179
|
+
"success": False,
|
|
180
|
+
"error": f"Skill not found: {skill_id or name}",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# Return full skill data
|
|
184
|
+
return {
|
|
185
|
+
"success": True,
|
|
186
|
+
"skill": {
|
|
187
|
+
"id": skill.id,
|
|
188
|
+
"name": skill.name,
|
|
189
|
+
"description": skill.description,
|
|
190
|
+
"content": skill.content,
|
|
191
|
+
"version": skill.version,
|
|
192
|
+
"license": skill.license,
|
|
193
|
+
"compatibility": skill.compatibility,
|
|
194
|
+
"allowed_tools": skill.allowed_tools,
|
|
195
|
+
"metadata": skill.metadata,
|
|
196
|
+
"enabled": skill.enabled,
|
|
197
|
+
"source_path": skill.source_path,
|
|
198
|
+
"source_type": skill.source_type,
|
|
199
|
+
"source_ref": skill.source_ref,
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
except Exception as e:
|
|
203
|
+
return {
|
|
204
|
+
"success": False,
|
|
205
|
+
"error": str(e),
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# --- search_skills tool ---
|
|
209
|
+
|
|
210
|
+
# Initialize search and index skills
|
|
211
|
+
search = SkillSearch()
|
|
212
|
+
# Expose search instance on registry for testing/manual indexing
|
|
213
|
+
registry.search = search
|
|
214
|
+
|
|
215
|
+
def _index_skills() -> None:
|
|
216
|
+
"""Index all skills for search."""
|
|
217
|
+
skills = storage.list_skills(
|
|
218
|
+
project_id=project_id,
|
|
219
|
+
limit=10000,
|
|
220
|
+
include_global=True,
|
|
221
|
+
)
|
|
222
|
+
search.index_skills(skills)
|
|
223
|
+
|
|
224
|
+
# Index on registry creation
|
|
225
|
+
_index_skills()
|
|
226
|
+
|
|
227
|
+
@registry.tool(
|
|
228
|
+
name="search_skills",
|
|
229
|
+
description="Search for skills by query. Returns ranked results with relevance scores. Supports filtering by category and tags.",
|
|
230
|
+
)
|
|
231
|
+
async def search_skills(
|
|
232
|
+
query: str,
|
|
233
|
+
category: str | None = None,
|
|
234
|
+
tags_any: list[str] | None = None,
|
|
235
|
+
tags_all: list[str] | None = None,
|
|
236
|
+
top_k: int = 10,
|
|
237
|
+
) -> dict[str, Any]:
|
|
238
|
+
"""
|
|
239
|
+
Search for skills by natural language query.
|
|
240
|
+
|
|
241
|
+
Returns ranked results with relevance scores.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
query: Search query (required, non-empty)
|
|
245
|
+
category: Optional category filter
|
|
246
|
+
tags_any: Optional tags filter - match any of these tags
|
|
247
|
+
tags_all: Optional tags filter - match all of these tags
|
|
248
|
+
top_k: Maximum results to return (default 10)
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Dict with success status and ranked search results
|
|
252
|
+
"""
|
|
253
|
+
try:
|
|
254
|
+
# Validate query
|
|
255
|
+
if not query or not query.strip():
|
|
256
|
+
return {
|
|
257
|
+
"success": False,
|
|
258
|
+
"error": "Query is required and cannot be empty",
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
# Build filters
|
|
262
|
+
filters = None
|
|
263
|
+
if category or tags_any or tags_all:
|
|
264
|
+
filters = SearchFilters(
|
|
265
|
+
category=category,
|
|
266
|
+
tags_any=tags_any,
|
|
267
|
+
tags_all=tags_all,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Perform search
|
|
271
|
+
results = await search.search_async(query=query, top_k=top_k, filters=filters)
|
|
272
|
+
|
|
273
|
+
# Format results with skill metadata
|
|
274
|
+
result_list = []
|
|
275
|
+
for r in results:
|
|
276
|
+
# Look up skill to get description, category, tags
|
|
277
|
+
skill = None
|
|
278
|
+
try:
|
|
279
|
+
skill = storage.get_skill(r.skill_id)
|
|
280
|
+
except ValueError:
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
# Get category and tags from metadata
|
|
284
|
+
category_value = None
|
|
285
|
+
tags = []
|
|
286
|
+
if skill and skill.metadata and isinstance(skill.metadata, dict):
|
|
287
|
+
skillport = skill.metadata.get("skillport", {})
|
|
288
|
+
if isinstance(skillport, dict):
|
|
289
|
+
category_value = skillport.get("category")
|
|
290
|
+
tags = skillport.get("tags", [])
|
|
291
|
+
|
|
292
|
+
result_list.append(
|
|
293
|
+
{
|
|
294
|
+
"skill_id": r.skill_id,
|
|
295
|
+
"skill_name": r.skill_name,
|
|
296
|
+
"description": skill.description if skill else None,
|
|
297
|
+
"category": category_value,
|
|
298
|
+
"tags": tags,
|
|
299
|
+
"score": r.similarity,
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
"success": True,
|
|
305
|
+
"count": len(result_list),
|
|
306
|
+
"results": result_list,
|
|
307
|
+
}
|
|
308
|
+
except Exception as e:
|
|
309
|
+
return {
|
|
310
|
+
"success": False,
|
|
311
|
+
"error": str(e),
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
# --- remove_skill tool ---
|
|
315
|
+
|
|
316
|
+
@registry.tool(
|
|
317
|
+
name="remove_skill",
|
|
318
|
+
description="Remove a skill by name or ID. Returns success status and removed skill name.",
|
|
319
|
+
)
|
|
320
|
+
async def remove_skill(
|
|
321
|
+
name: str | None = None,
|
|
322
|
+
skill_id: str | None = None,
|
|
323
|
+
) -> dict[str, Any]:
|
|
324
|
+
"""
|
|
325
|
+
Remove a skill from the database.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
name: Skill name (used if skill_id not provided)
|
|
329
|
+
skill_id: Skill ID (takes precedence over name)
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Dict with success status and removed skill info
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
# Validate input
|
|
336
|
+
if not skill_id and not name:
|
|
337
|
+
return {
|
|
338
|
+
"success": False,
|
|
339
|
+
"error": "Either name or skill_id is required",
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
# Find the skill first to get its name
|
|
343
|
+
skill = None
|
|
344
|
+
if skill_id:
|
|
345
|
+
try:
|
|
346
|
+
skill = storage.get_skill(skill_id)
|
|
347
|
+
except ValueError:
|
|
348
|
+
pass
|
|
349
|
+
|
|
350
|
+
if skill is None and name:
|
|
351
|
+
skill = storage.get_by_name(name, project_id=project_id)
|
|
352
|
+
|
|
353
|
+
if skill is None:
|
|
354
|
+
return {
|
|
355
|
+
"success": False,
|
|
356
|
+
"error": f"Skill not found: {skill_id or name}",
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
# Store the name before deletion
|
|
360
|
+
skill_name = skill.name
|
|
361
|
+
|
|
362
|
+
# Delete the skill
|
|
363
|
+
storage.delete_skill(skill.id)
|
|
364
|
+
|
|
365
|
+
# Re-index skills after deletion
|
|
366
|
+
skills = storage.list_skills(
|
|
367
|
+
project_id=project_id,
|
|
368
|
+
limit=10000,
|
|
369
|
+
include_global=True,
|
|
370
|
+
)
|
|
371
|
+
await search.index_skills_async(skills)
|
|
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
|
+
result = updater.update_skill(skill.id)
|
|
434
|
+
|
|
435
|
+
# Re-index skills if updated
|
|
436
|
+
if result.updated:
|
|
437
|
+
skills = storage.list_skills(
|
|
438
|
+
project_id=project_id,
|
|
439
|
+
limit=10000,
|
|
440
|
+
include_global=True,
|
|
441
|
+
)
|
|
442
|
+
await search.index_skills_async(skills)
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
"success": result.success,
|
|
446
|
+
"updated": result.updated,
|
|
447
|
+
"skipped": result.skipped,
|
|
448
|
+
"skip_reason": result.skip_reason,
|
|
449
|
+
"error": result.error,
|
|
450
|
+
}
|
|
451
|
+
except Exception as e:
|
|
452
|
+
return {
|
|
453
|
+
"success": False,
|
|
454
|
+
"error": str(e),
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
# --- install_skill tool ---
|
|
458
|
+
|
|
459
|
+
# Initialize loader
|
|
460
|
+
loader = SkillLoader()
|
|
461
|
+
|
|
462
|
+
@registry.tool(
|
|
463
|
+
name="install_skill",
|
|
464
|
+
description="Install a skill from a local path, GitHub URL, or ZIP archive. Auto-detects source type.",
|
|
465
|
+
)
|
|
466
|
+
async def install_skill(
|
|
467
|
+
source: str | None = None,
|
|
468
|
+
project_scoped: bool = False,
|
|
469
|
+
) -> dict[str, Any]:
|
|
470
|
+
"""
|
|
471
|
+
Install a skill from a source location.
|
|
472
|
+
|
|
473
|
+
Auto-detects source type:
|
|
474
|
+
- Local directory or SKILL.md file
|
|
475
|
+
- GitHub URL (owner/repo, github:owner/repo, https://github.com/...)
|
|
476
|
+
- ZIP archive (.zip file)
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
source: Path or URL to the skill source (required)
|
|
480
|
+
project_scoped: If True, install skill scoped to the project
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
Dict with success status, skill_id, skill_name, and source_type
|
|
484
|
+
"""
|
|
485
|
+
try:
|
|
486
|
+
# Validate input
|
|
487
|
+
if not source or not source.strip():
|
|
488
|
+
return {
|
|
489
|
+
"success": False,
|
|
490
|
+
"error": "source parameter is required",
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
source = source.strip()
|
|
494
|
+
|
|
495
|
+
# Determine source type and load skill
|
|
496
|
+
from gobby.storage.skills import SkillSourceType
|
|
497
|
+
|
|
498
|
+
parsed_skill = None
|
|
499
|
+
source_type: SkillSourceType | None = None
|
|
500
|
+
|
|
501
|
+
# Check if it's a GitHub URL/reference
|
|
502
|
+
# Pattern for owner/repo format (e.g., "anthropic/claude-code")
|
|
503
|
+
# Must match owner/repo pattern without path traversal or absolute paths
|
|
504
|
+
github_owner_repo_pattern = re.compile(
|
|
505
|
+
r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[A-Za-z0-9_./-]*)?$"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
# Explicit GitHub references (always treated as GitHub, no filesystem check)
|
|
509
|
+
is_explicit_github = (
|
|
510
|
+
source.startswith("github:")
|
|
511
|
+
or source.startswith("https://github.com/")
|
|
512
|
+
or source.startswith("http://github.com/")
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# For implicit owner/repo patterns, check local filesystem first
|
|
516
|
+
is_implicit_github_pattern = (
|
|
517
|
+
not is_explicit_github
|
|
518
|
+
and github_owner_repo_pattern.match(source)
|
|
519
|
+
and not source.startswith("/")
|
|
520
|
+
and ".." not in source # Reject path traversal
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
# Determine if this is a GitHub reference:
|
|
524
|
+
# - Explicit refs are always GitHub
|
|
525
|
+
# - Implicit patterns are GitHub only if local path doesn't exist
|
|
526
|
+
is_github_ref = is_explicit_github or (
|
|
527
|
+
is_implicit_github_pattern and not Path(source).exists()
|
|
528
|
+
)
|
|
529
|
+
if is_github_ref:
|
|
530
|
+
# GitHub URL
|
|
531
|
+
try:
|
|
532
|
+
parsed_skill = loader.load_from_github(source)
|
|
533
|
+
source_type = "github"
|
|
534
|
+
except SkillLoadError as e:
|
|
535
|
+
return {
|
|
536
|
+
"success": False,
|
|
537
|
+
"error": f"Failed to load from GitHub: {e}",
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
# Check if it's a ZIP file
|
|
541
|
+
elif source.endswith(".zip"):
|
|
542
|
+
zip_path = Path(source)
|
|
543
|
+
if not zip_path.exists():
|
|
544
|
+
return {
|
|
545
|
+
"success": False,
|
|
546
|
+
"error": f"ZIP file not found: {source}",
|
|
547
|
+
}
|
|
548
|
+
try:
|
|
549
|
+
parsed_skill = loader.load_from_zip(zip_path)
|
|
550
|
+
source_type = "zip"
|
|
551
|
+
except SkillLoadError as e:
|
|
552
|
+
return {
|
|
553
|
+
"success": False,
|
|
554
|
+
"error": f"Failed to load from ZIP: {e}",
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
# Assume it's a local path
|
|
558
|
+
else:
|
|
559
|
+
local_path = Path(source)
|
|
560
|
+
if not local_path.exists():
|
|
561
|
+
return {
|
|
562
|
+
"success": False,
|
|
563
|
+
"error": f"Path not found: {source}",
|
|
564
|
+
}
|
|
565
|
+
try:
|
|
566
|
+
parsed_skill = loader.load_skill(local_path)
|
|
567
|
+
source_type = "local"
|
|
568
|
+
except SkillLoadError as e:
|
|
569
|
+
return {
|
|
570
|
+
"success": False,
|
|
571
|
+
"error": f"Failed to load skill: {e}",
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if parsed_skill is None:
|
|
575
|
+
return {
|
|
576
|
+
"success": False,
|
|
577
|
+
"error": "Failed to load skill from source",
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
# Handle case where load_from_github/load_from_zip returns a list
|
|
581
|
+
if isinstance(parsed_skill, list):
|
|
582
|
+
if len(parsed_skill) == 0:
|
|
583
|
+
return {
|
|
584
|
+
"success": False,
|
|
585
|
+
"error": "No skills found in source",
|
|
586
|
+
}
|
|
587
|
+
# Use the first skill if multiple were found
|
|
588
|
+
parsed_skill = parsed_skill[0]
|
|
589
|
+
|
|
590
|
+
# Determine project ID for the skill
|
|
591
|
+
skill_project_id = project_id if project_scoped else None
|
|
592
|
+
|
|
593
|
+
# Store the skill
|
|
594
|
+
skill = storage.create_skill(
|
|
595
|
+
name=parsed_skill.name,
|
|
596
|
+
description=parsed_skill.description,
|
|
597
|
+
content=parsed_skill.content,
|
|
598
|
+
version=parsed_skill.version,
|
|
599
|
+
license=parsed_skill.license,
|
|
600
|
+
compatibility=parsed_skill.compatibility,
|
|
601
|
+
allowed_tools=parsed_skill.allowed_tools,
|
|
602
|
+
metadata=parsed_skill.metadata,
|
|
603
|
+
source_path=parsed_skill.source_path,
|
|
604
|
+
source_type=source_type,
|
|
605
|
+
source_ref=getattr(parsed_skill, "source_ref", None),
|
|
606
|
+
project_id=skill_project_id,
|
|
607
|
+
enabled=True,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# Re-index skills
|
|
611
|
+
skills = storage.list_skills(
|
|
612
|
+
project_id=project_id,
|
|
613
|
+
limit=10000,
|
|
614
|
+
include_global=True,
|
|
615
|
+
)
|
|
616
|
+
await search.index_skills_async(skills)
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
"success": True,
|
|
620
|
+
"installed": True,
|
|
621
|
+
"skill_id": skill.id,
|
|
622
|
+
"skill_name": skill.name,
|
|
623
|
+
"source_type": source_type,
|
|
624
|
+
}
|
|
625
|
+
except Exception as e:
|
|
626
|
+
return {
|
|
627
|
+
"success": False,
|
|
628
|
+
"error": str(e),
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return registry
|
|
@@ -11,6 +11,7 @@ from gobby.mcp_proxy.tools.orchestration.monitor import register_monitor
|
|
|
11
11
|
from gobby.mcp_proxy.tools.orchestration.orchestrate import register_orchestrator
|
|
12
12
|
from gobby.mcp_proxy.tools.orchestration.review import register_reviewer
|
|
13
13
|
from gobby.mcp_proxy.tools.orchestration.utils import get_current_project_id
|
|
14
|
+
from gobby.mcp_proxy.tools.orchestration.wait import register_wait
|
|
14
15
|
|
|
15
16
|
if TYPE_CHECKING:
|
|
16
17
|
from gobby.agents.runner import AgentRunner
|
|
@@ -74,4 +75,10 @@ def create_orchestration_registry(
|
|
|
74
75
|
default_project_id=default_project_id,
|
|
75
76
|
)
|
|
76
77
|
|
|
78
|
+
# Register wait tools
|
|
79
|
+
register_wait(
|
|
80
|
+
registry=registry,
|
|
81
|
+
task_manager=task_manager,
|
|
82
|
+
)
|
|
83
|
+
|
|
77
84
|
return registry
|
|
@@ -9,6 +9,7 @@ Provides tools for task readiness management:
|
|
|
9
9
|
Extracted from tasks.py using Strangler Fig pattern for code decomposition.
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
+
import logging
|
|
12
13
|
from collections.abc import Callable
|
|
13
14
|
from typing import TYPE_CHECKING, Any
|
|
14
15
|
|
|
@@ -20,6 +21,8 @@ from gobby.workflows.state_manager import WorkflowStateManager
|
|
|
20
21
|
if TYPE_CHECKING:
|
|
21
22
|
from gobby.storage.tasks import LocalTaskManager
|
|
22
23
|
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
23
26
|
__all__ = [
|
|
24
27
|
"create_readiness_registry",
|
|
25
28
|
"is_descendant_of",
|
|
@@ -474,6 +477,16 @@ def create_readiness_registry(
|
|
|
474
477
|
if best_proximity > 0:
|
|
475
478
|
reasons.append("same branch as current work")
|
|
476
479
|
|
|
480
|
+
# Get recommended skills based on task category
|
|
481
|
+
recommended_skills: list[str] = []
|
|
482
|
+
try:
|
|
483
|
+
from gobby.workflows.context_actions import recommend_skills_for_task
|
|
484
|
+
|
|
485
|
+
task_brief = best_task.to_brief()
|
|
486
|
+
recommended_skills = recommend_skills_for_task(task_brief)
|
|
487
|
+
except Exception as e:
|
|
488
|
+
logger.debug(f"Skill recommendation failed: {e}")
|
|
489
|
+
|
|
477
490
|
return {
|
|
478
491
|
"suggestion": best_task.to_brief(),
|
|
479
492
|
"score": best_score,
|
|
@@ -482,6 +495,7 @@ def create_readiness_registry(
|
|
|
482
495
|
{"ref": t.to_brief()["ref"], "title": t.title, "score": s}
|
|
483
496
|
for t, s, _, _ in scored[1:4] # Show top 3 alternatives
|
|
484
497
|
],
|
|
498
|
+
"recommended_skills": recommended_skills,
|
|
485
499
|
}
|
|
486
500
|
|
|
487
501
|
registry.register(
|
|
@@ -159,7 +159,7 @@ def create_sync_registry(
|
|
|
159
159
|
|
|
160
160
|
registry.register(
|
|
161
161
|
name="link_commit",
|
|
162
|
-
description="Link a git commit to a task.
|
|
162
|
+
description="Link a git commit to a task. NOTE: For closing tasks, prefer close_task(task_id, commit_sha='...') which links and closes in one call. Use link_commit only when you need to link without closing.",
|
|
163
163
|
input_schema={
|
|
164
164
|
"type": "object",
|
|
165
165
|
"properties": {
|