gobby 0.2.9__py3-none-any.whl → 0.2.11__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/__init__.py +1 -1
- gobby/adapters/__init__.py +6 -0
- gobby/adapters/base.py +11 -2
- gobby/adapters/claude_code.py +2 -2
- gobby/adapters/codex_impl/adapter.py +38 -43
- gobby/adapters/copilot.py +324 -0
- gobby/adapters/cursor.py +373 -0
- gobby/adapters/gemini.py +2 -26
- gobby/adapters/windsurf.py +359 -0
- gobby/agents/definitions.py +162 -2
- gobby/agents/isolation.py +33 -1
- gobby/agents/pty_reader.py +192 -0
- gobby/agents/registry.py +10 -1
- gobby/agents/runner.py +24 -8
- gobby/agents/sandbox.py +8 -3
- gobby/agents/session.py +4 -0
- gobby/agents/spawn.py +9 -2
- gobby/agents/spawn_executor.py +49 -61
- gobby/agents/spawners/command_builder.py +4 -4
- gobby/app_context.py +5 -0
- gobby/cli/__init__.py +4 -0
- gobby/cli/install.py +259 -4
- gobby/cli/installers/__init__.py +12 -0
- gobby/cli/installers/copilot.py +242 -0
- gobby/cli/installers/cursor.py +244 -0
- gobby/cli/installers/shared.py +3 -0
- gobby/cli/installers/windsurf.py +242 -0
- gobby/cli/pipelines.py +639 -0
- gobby/cli/sessions.py +3 -1
- gobby/cli/skills.py +209 -0
- gobby/cli/tasks/crud.py +6 -5
- gobby/cli/tasks/search.py +1 -1
- gobby/cli/ui.py +116 -0
- gobby/cli/workflows.py +38 -17
- gobby/config/app.py +5 -0
- gobby/config/skills.py +23 -2
- gobby/hooks/broadcaster.py +9 -0
- gobby/hooks/event_handlers/_base.py +6 -1
- gobby/hooks/event_handlers/_session.py +44 -130
- gobby/hooks/events.py +48 -0
- gobby/hooks/hook_manager.py +25 -3
- gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
- gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
- gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
- gobby/llm/__init__.py +14 -1
- gobby/llm/claude.py +217 -1
- gobby/llm/service.py +149 -0
- gobby/mcp_proxy/instructions.py +9 -27
- gobby/mcp_proxy/models.py +1 -0
- gobby/mcp_proxy/registries.py +56 -9
- gobby/mcp_proxy/server.py +6 -2
- gobby/mcp_proxy/services/tool_filter.py +7 -0
- gobby/mcp_proxy/services/tool_proxy.py +19 -1
- gobby/mcp_proxy/stdio.py +37 -21
- gobby/mcp_proxy/tools/agents.py +7 -0
- gobby/mcp_proxy/tools/hub.py +30 -1
- gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
- gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
- gobby/mcp_proxy/tools/orchestration/review.py +17 -4
- gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
- gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
- gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
- gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
- gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
- gobby/mcp_proxy/tools/skills/__init__.py +184 -30
- gobby/mcp_proxy/tools/spawn_agent.py +229 -14
- gobby/mcp_proxy/tools/tasks/_context.py +8 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
- gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
- gobby/mcp_proxy/tools/tasks/_search.py +1 -1
- gobby/mcp_proxy/tools/workflows/__init__.py +9 -2
- gobby/mcp_proxy/tools/workflows/_lifecycle.py +12 -1
- gobby/mcp_proxy/tools/workflows/_query.py +45 -26
- gobby/mcp_proxy/tools/workflows/_terminal.py +39 -3
- gobby/mcp_proxy/tools/worktrees.py +54 -15
- gobby/memory/context.py +5 -5
- gobby/runner.py +108 -6
- gobby/servers/http.py +7 -1
- gobby/servers/routes/__init__.py +2 -0
- gobby/servers/routes/admin.py +44 -0
- gobby/servers/routes/mcp/endpoints/execution.py +18 -25
- gobby/servers/routes/mcp/hooks.py +10 -1
- gobby/servers/routes/pipelines.py +227 -0
- gobby/servers/websocket.py +314 -1
- gobby/sessions/analyzer.py +87 -1
- gobby/sessions/manager.py +5 -5
- gobby/sessions/transcripts/__init__.py +3 -0
- gobby/sessions/transcripts/claude.py +5 -0
- gobby/sessions/transcripts/codex.py +5 -0
- gobby/sessions/transcripts/gemini.py +5 -0
- gobby/skills/hubs/__init__.py +25 -0
- gobby/skills/hubs/base.py +234 -0
- gobby/skills/hubs/claude_plugins.py +328 -0
- gobby/skills/hubs/clawdhub.py +289 -0
- gobby/skills/hubs/github_collection.py +465 -0
- gobby/skills/hubs/manager.py +263 -0
- gobby/skills/hubs/skillhub.py +342 -0
- gobby/storage/memories.py +4 -4
- gobby/storage/migrations.py +95 -3
- gobby/storage/pipelines.py +367 -0
- gobby/storage/sessions.py +23 -4
- gobby/storage/skills.py +1 -1
- gobby/storage/tasks/_aggregates.py +2 -2
- gobby/storage/tasks/_lifecycle.py +4 -4
- gobby/storage/tasks/_models.py +7 -1
- gobby/storage/tasks/_queries.py +3 -3
- gobby/sync/memories.py +4 -3
- gobby/tasks/commits.py +48 -17
- gobby/workflows/actions.py +75 -0
- gobby/workflows/context_actions.py +246 -5
- gobby/workflows/definitions.py +119 -1
- gobby/workflows/detection_helpers.py +23 -11
- gobby/workflows/enforcement/task_policy.py +18 -0
- gobby/workflows/engine.py +20 -1
- gobby/workflows/evaluator.py +8 -5
- gobby/workflows/lifecycle_evaluator.py +57 -26
- gobby/workflows/loader.py +567 -30
- gobby/workflows/lobster_compat.py +147 -0
- gobby/workflows/pipeline_executor.py +801 -0
- gobby/workflows/pipeline_state.py +172 -0
- gobby/workflows/pipeline_webhooks.py +206 -0
- gobby/workflows/premature_stop.py +5 -0
- gobby/worktrees/git.py +135 -20
- {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
- {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/RECORD +134 -106
- {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
- {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Pipeline execution tools."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from gobby.workflows.pipeline_state import ApprovalRequired
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def run_pipeline(
|
|
10
|
+
loader: Any,
|
|
11
|
+
executor: Any,
|
|
12
|
+
name: str,
|
|
13
|
+
inputs: dict[str, Any],
|
|
14
|
+
project_id: str,
|
|
15
|
+
) -> dict[str, Any]:
|
|
16
|
+
"""
|
|
17
|
+
Run a pipeline by name.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
loader: WorkflowLoader instance
|
|
21
|
+
executor: PipelineExecutor instance
|
|
22
|
+
name: Pipeline name to run
|
|
23
|
+
inputs: Input values for the pipeline
|
|
24
|
+
project_id: Project context for the execution
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Dict with success status, execution status, and outputs or approval info
|
|
28
|
+
"""
|
|
29
|
+
if not executor:
|
|
30
|
+
return {
|
|
31
|
+
"success": False,
|
|
32
|
+
"error": "No executor configured",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if not loader:
|
|
36
|
+
return {
|
|
37
|
+
"success": False,
|
|
38
|
+
"error": "No loader configured",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Load the pipeline definition
|
|
42
|
+
pipeline = loader.load_pipeline(name)
|
|
43
|
+
if not pipeline:
|
|
44
|
+
return {
|
|
45
|
+
"success": False,
|
|
46
|
+
"error": f"Pipeline '{name}' not found",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
# Execute the pipeline
|
|
51
|
+
execution = await executor.execute(
|
|
52
|
+
pipeline=pipeline,
|
|
53
|
+
inputs=inputs,
|
|
54
|
+
project_id=project_id,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Parse outputs if available
|
|
58
|
+
outputs = None
|
|
59
|
+
if execution.outputs_json:
|
|
60
|
+
try:
|
|
61
|
+
outputs = json.loads(execution.outputs_json)
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
outputs = execution.outputs_json
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
"success": True,
|
|
67
|
+
"status": execution.status.value,
|
|
68
|
+
"execution_id": execution.id,
|
|
69
|
+
"outputs": outputs,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
except ApprovalRequired as e:
|
|
73
|
+
# Pipeline paused waiting for approval
|
|
74
|
+
return {
|
|
75
|
+
"success": True,
|
|
76
|
+
"status": "waiting_approval",
|
|
77
|
+
"execution_id": e.execution_id,
|
|
78
|
+
"step_id": e.step_id,
|
|
79
|
+
"token": e.token,
|
|
80
|
+
"message": e.message,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
except Exception as e:
|
|
84
|
+
return {
|
|
85
|
+
"success": False,
|
|
86
|
+
"error": f"Execution failed: {e}",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def approve_pipeline(
|
|
91
|
+
executor: Any,
|
|
92
|
+
token: str,
|
|
93
|
+
approved_by: str | None = None,
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
"""
|
|
96
|
+
Approve a pipeline execution waiting for approval.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
executor: PipelineExecutor instance
|
|
100
|
+
token: Approval token from the waiting execution
|
|
101
|
+
approved_by: Identifier of who approved (email, user ID, etc.)
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Dict with success status and execution status
|
|
105
|
+
"""
|
|
106
|
+
if not executor:
|
|
107
|
+
return {
|
|
108
|
+
"success": False,
|
|
109
|
+
"error": "No executor configured",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
execution = await executor.approve(
|
|
114
|
+
token=token,
|
|
115
|
+
approved_by=approved_by,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"success": True,
|
|
120
|
+
"status": execution.status.value,
|
|
121
|
+
"execution_id": execution.id,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
except ValueError as e:
|
|
125
|
+
return {
|
|
126
|
+
"success": False,
|
|
127
|
+
"error": str(e),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
except Exception as e:
|
|
131
|
+
return {
|
|
132
|
+
"success": False,
|
|
133
|
+
"error": f"Approval failed: {e}",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def reject_pipeline(
|
|
138
|
+
executor: Any,
|
|
139
|
+
token: str,
|
|
140
|
+
rejected_by: str | None = None,
|
|
141
|
+
) -> dict[str, Any]:
|
|
142
|
+
"""
|
|
143
|
+
Reject a pipeline execution waiting for approval.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
executor: PipelineExecutor instance
|
|
147
|
+
token: Approval token from the waiting execution
|
|
148
|
+
rejected_by: Identifier of who rejected (email, user ID, etc.)
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Dict with success status and execution status (cancelled)
|
|
152
|
+
"""
|
|
153
|
+
if not executor:
|
|
154
|
+
return {
|
|
155
|
+
"success": False,
|
|
156
|
+
"error": "No executor configured",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
execution = await executor.reject(
|
|
161
|
+
token=token,
|
|
162
|
+
rejected_by=rejected_by,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
"success": True,
|
|
167
|
+
"status": execution.status.value,
|
|
168
|
+
"execution_id": execution.id,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
except ValueError as e:
|
|
172
|
+
return {
|
|
173
|
+
"success": False,
|
|
174
|
+
"error": str(e),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
except Exception as e:
|
|
178
|
+
return {
|
|
179
|
+
"success": False,
|
|
180
|
+
"error": f"Rejection failed: {e}",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def get_pipeline_status(
|
|
185
|
+
execution_manager: Any,
|
|
186
|
+
execution_id: str,
|
|
187
|
+
) -> dict[str, Any]:
|
|
188
|
+
"""
|
|
189
|
+
Get the status of a pipeline execution.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
execution_manager: LocalPipelineExecutionManager instance
|
|
193
|
+
execution_id: Execution ID to query
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Dict with execution details and step statuses
|
|
197
|
+
"""
|
|
198
|
+
if not execution_manager:
|
|
199
|
+
return {
|
|
200
|
+
"success": False,
|
|
201
|
+
"error": "No execution manager configured",
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
execution = execution_manager.get_execution(execution_id)
|
|
206
|
+
if not execution:
|
|
207
|
+
return {
|
|
208
|
+
"success": False,
|
|
209
|
+
"error": f"Execution '{execution_id}' not found",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# Get step executions
|
|
213
|
+
steps = execution_manager.get_steps_for_execution(execution_id)
|
|
214
|
+
|
|
215
|
+
# Parse inputs if available
|
|
216
|
+
inputs = None
|
|
217
|
+
if execution.inputs_json:
|
|
218
|
+
try:
|
|
219
|
+
inputs = json.loads(execution.inputs_json)
|
|
220
|
+
except json.JSONDecodeError:
|
|
221
|
+
inputs = execution.inputs_json
|
|
222
|
+
|
|
223
|
+
# Parse outputs if available
|
|
224
|
+
outputs = None
|
|
225
|
+
if execution.outputs_json:
|
|
226
|
+
try:
|
|
227
|
+
outputs = json.loads(execution.outputs_json)
|
|
228
|
+
except json.JSONDecodeError:
|
|
229
|
+
outputs = execution.outputs_json
|
|
230
|
+
|
|
231
|
+
# Build execution dict
|
|
232
|
+
execution_dict = {
|
|
233
|
+
"id": execution.id,
|
|
234
|
+
"pipeline_name": execution.pipeline_name,
|
|
235
|
+
"project_id": execution.project_id,
|
|
236
|
+
"status": execution.status.value,
|
|
237
|
+
"inputs": inputs,
|
|
238
|
+
"outputs": outputs,
|
|
239
|
+
"created_at": execution.created_at,
|
|
240
|
+
"updated_at": execution.updated_at,
|
|
241
|
+
"completed_at": execution.completed_at,
|
|
242
|
+
"resume_token": execution.resume_token,
|
|
243
|
+
"session_id": execution.session_id,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
# Build steps list
|
|
247
|
+
steps_list = []
|
|
248
|
+
for step in steps:
|
|
249
|
+
step_output = None
|
|
250
|
+
if step.output_json:
|
|
251
|
+
try:
|
|
252
|
+
step_output = json.loads(step.output_json)
|
|
253
|
+
except json.JSONDecodeError:
|
|
254
|
+
step_output = step.output_json
|
|
255
|
+
|
|
256
|
+
steps_list.append(
|
|
257
|
+
{
|
|
258
|
+
"id": step.id,
|
|
259
|
+
"step_id": step.step_id,
|
|
260
|
+
"status": step.status.value,
|
|
261
|
+
"started_at": step.started_at,
|
|
262
|
+
"completed_at": step.completed_at,
|
|
263
|
+
"output": step_output,
|
|
264
|
+
"error": step.error,
|
|
265
|
+
"approval_token": step.approval_token,
|
|
266
|
+
"approved_by": step.approved_by,
|
|
267
|
+
"approved_at": step.approved_at,
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
"success": True,
|
|
273
|
+
"execution": execution_dict,
|
|
274
|
+
"steps": steps_list,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
except Exception as e:
|
|
278
|
+
return {
|
|
279
|
+
"success": False,
|
|
280
|
+
"error": f"Failed to get status: {e}",
|
|
281
|
+
}
|
|
@@ -75,7 +75,7 @@ def register_crud_tools(
|
|
|
75
75
|
description="""Get YOUR current session ID - the CORRECT way to look up your session.
|
|
76
76
|
|
|
77
77
|
Use this when session_id wasn't in your injected context. Pass your external_id
|
|
78
|
-
(from transcript path or GOBBY_SESSION_ID env) and source (claude, gemini, codex).
|
|
78
|
+
(from transcript path or GOBBY_SESSION_ID env) and source (claude, gemini, codex, cursor, windsurf, copilot).
|
|
79
79
|
|
|
80
80
|
DO NOT use list_sessions to find your session - it won't work with multiple active sessions.""",
|
|
81
81
|
)
|
|
@@ -87,12 +87,12 @@ DO NOT use list_sessions to find your session - it won't work with multiple acti
|
|
|
87
87
|
Look up your internal session_id from external_id and source.
|
|
88
88
|
|
|
89
89
|
The agent passes external_id (from injected context or GOBBY_SESSION_ID env var)
|
|
90
|
-
and source (claude, gemini, codex). project_id and machine_id are
|
|
90
|
+
and source (claude, gemini, codex, cursor, windsurf, copilot). project_id and machine_id are
|
|
91
91
|
auto-resolved from config files.
|
|
92
92
|
|
|
93
93
|
Args:
|
|
94
94
|
external_id: Your CLI's session ID (from context or GOBBY_SESSION_ID env)
|
|
95
|
-
source: CLI source - "claude", "gemini", or "
|
|
95
|
+
source: CLI source - "claude", "gemini", "codex", "cursor", "windsurf", or "copilot"
|
|
96
96
|
|
|
97
97
|
Returns:
|
|
98
98
|
session_id: Internal Gobby session ID (use for parent_session_id, etc.)
|
|
@@ -240,7 +240,7 @@ This tool is for browsing/listing sessions, not for self-identification.""",
|
|
|
240
240
|
|
|
241
241
|
# Count by source
|
|
242
242
|
by_source: dict[str, int] = {}
|
|
243
|
-
for src in ["
|
|
243
|
+
for src in ["claude", "gemini", "codex", "cursor", "windsurf", "copilot"]:
|
|
244
244
|
count = session_manager.count(project_id=project_id, source=src)
|
|
245
245
|
if count > 0:
|
|
246
246
|
by_source[src] = count
|
|
@@ -414,7 +414,7 @@ Args:
|
|
|
414
414
|
Args:
|
|
415
415
|
session_id: Session reference - supports #N, N (seq_num), UUID, or prefix (optional)
|
|
416
416
|
project_id: Project ID to find parent session in (optional)
|
|
417
|
-
source: Filter by CLI source -
|
|
417
|
+
source: Filter by CLI source - claude, gemini, codex, cursor, windsurf, copilot (optional)
|
|
418
418
|
link_child_session_id: Session to link as child - supports #N, N, UUID, or prefix (optional)
|
|
419
419
|
|
|
420
420
|
Returns:
|
|
@@ -20,6 +20,7 @@ from pathlib import Path
|
|
|
20
20
|
from typing import TYPE_CHECKING, Any
|
|
21
21
|
|
|
22
22
|
from gobby.mcp_proxy.tools.internal import InternalToolRegistry
|
|
23
|
+
from gobby.skills.hubs.manager import HubManager
|
|
23
24
|
from gobby.skills.loader import SkillLoader, SkillLoadError
|
|
24
25
|
from gobby.skills.search import SearchFilters, SkillSearch
|
|
25
26
|
from gobby.skills.updater import SkillUpdater
|
|
@@ -45,6 +46,7 @@ class SkillsToolRegistry(InternalToolRegistry):
|
|
|
45
46
|
def create_skills_registry(
|
|
46
47
|
db: DatabaseProtocol,
|
|
47
48
|
project_id: str | None = None,
|
|
49
|
+
hub_manager: HubManager | None = None,
|
|
48
50
|
) -> SkillsToolRegistry:
|
|
49
51
|
"""
|
|
50
52
|
Create a skills management tool registry.
|
|
@@ -52,13 +54,14 @@ def create_skills_registry(
|
|
|
52
54
|
Args:
|
|
53
55
|
db: Database connection for storage
|
|
54
56
|
project_id: Optional default project scope for skill operations
|
|
57
|
+
hub_manager: Optional HubManager for hub operations (list_hubs, search_hub)
|
|
55
58
|
|
|
56
59
|
Returns:
|
|
57
60
|
SkillsToolRegistry with skill management tools registered
|
|
58
61
|
"""
|
|
59
62
|
registry = SkillsToolRegistry(
|
|
60
63
|
name="gobby-skills",
|
|
61
|
-
description="Skill management - list_skills, get_skill, search_skills, install_skill, update_skill, remove_skill",
|
|
64
|
+
description="Skill management - list_skills, get_skill, search_skills, install_skill, update_skill, remove_skill, list_hubs, search_hub",
|
|
62
65
|
)
|
|
63
66
|
|
|
64
67
|
# Initialize change notifier and storage
|
|
@@ -485,40 +488,87 @@ def create_skills_registry(
|
|
|
485
488
|
source = source.strip()
|
|
486
489
|
|
|
487
490
|
# Determine source type and load skill
|
|
491
|
+
from gobby.skills.parser import ParsedSkill
|
|
488
492
|
from gobby.storage.skills import SkillSourceType
|
|
489
493
|
|
|
490
|
-
parsed_skill = None
|
|
494
|
+
parsed_skill: ParsedSkill | list[ParsedSkill] | None = None
|
|
491
495
|
source_type: SkillSourceType | None = None
|
|
492
496
|
|
|
493
|
-
# Check
|
|
494
|
-
#
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[A-Za-z0-9_./-]*)?$"
|
|
498
|
-
)
|
|
497
|
+
# Check for hub:slug syntax (e.g., "clawdhub:commit-message")
|
|
498
|
+
# Must have exactly one colon, not be a URL, and the hub part must be alphanumeric
|
|
499
|
+
hub_pattern = re.compile(r"^([A-Za-z0-9_-]+):([A-Za-z0-9_-]+)$")
|
|
500
|
+
hub_match = hub_pattern.match(source)
|
|
499
501
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
or source.startswith("https://github.com/")
|
|
504
|
-
or source.startswith("http://github.com/")
|
|
505
|
-
)
|
|
502
|
+
if hub_match and not source.startswith("http"):
|
|
503
|
+
# Hub reference: hub_name:skill_slug
|
|
504
|
+
hub_name, skill_slug = hub_match.groups()
|
|
506
505
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
and ".." not in source # Reject path traversal
|
|
513
|
-
)
|
|
506
|
+
if hub_manager is None:
|
|
507
|
+
return {
|
|
508
|
+
"success": False,
|
|
509
|
+
"error": "No hub manager configured. Add hubs to config to enable hub installs.",
|
|
510
|
+
}
|
|
514
511
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
512
|
+
if not hub_manager.has_hub(hub_name):
|
|
513
|
+
return {
|
|
514
|
+
"success": False,
|
|
515
|
+
"error": f"Unknown hub: {hub_name}. Use list_hubs to see available hubs.",
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
# Get the provider and download the skill
|
|
520
|
+
provider = hub_manager.get_provider(hub_name)
|
|
521
|
+
download_result = await provider.download_skill(skill_slug)
|
|
522
|
+
|
|
523
|
+
if not download_result.success or not download_result.path:
|
|
524
|
+
return {
|
|
525
|
+
"success": False,
|
|
526
|
+
"error": f"Failed to download from hub: {download_result.error or 'Unknown error'}",
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
# Load the skill from the downloaded path
|
|
530
|
+
skill_path = Path(download_result.path)
|
|
531
|
+
parsed_skill = loader.load_skill(skill_path)
|
|
532
|
+
source_type = "hub"
|
|
533
|
+
|
|
534
|
+
except Exception as e:
|
|
535
|
+
return {
|
|
536
|
+
"success": False,
|
|
537
|
+
"error": f"Failed to install from hub {hub_name}: {e}",
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
# Check if it's a GitHub URL/reference (only if not already parsed from hub)
|
|
541
|
+
is_github_ref = False
|
|
542
|
+
if parsed_skill is None:
|
|
543
|
+
# Pattern for owner/repo format (e.g., "anthropic/claude-code")
|
|
544
|
+
# Must match owner/repo pattern without path traversal or absolute paths
|
|
545
|
+
github_owner_repo_pattern = re.compile(
|
|
546
|
+
r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[A-Za-z0-9_./-]*)?$"
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
# Explicit GitHub references (always treated as GitHub, no filesystem check)
|
|
550
|
+
is_explicit_github = (
|
|
551
|
+
source.startswith("github:")
|
|
552
|
+
or source.startswith("https://github.com/")
|
|
553
|
+
or source.startswith("http://github.com/")
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# For implicit owner/repo patterns, check local filesystem first
|
|
557
|
+
is_implicit_github_pattern = (
|
|
558
|
+
not is_explicit_github
|
|
559
|
+
and github_owner_repo_pattern.match(source)
|
|
560
|
+
and not source.startswith("/")
|
|
561
|
+
and ".." not in source # Reject path traversal
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# Determine if this is a GitHub reference:
|
|
565
|
+
# - Explicit refs are always GitHub
|
|
566
|
+
# - Implicit patterns are GitHub only if local path doesn't exist
|
|
567
|
+
is_github_ref = is_explicit_github or bool(
|
|
568
|
+
is_implicit_github_pattern and not Path(source).exists()
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
if parsed_skill is None and is_github_ref:
|
|
522
572
|
# GitHub URL
|
|
523
573
|
try:
|
|
524
574
|
parsed_skill = loader.load_from_github(source)
|
|
@@ -530,7 +580,7 @@ def create_skills_registry(
|
|
|
530
580
|
}
|
|
531
581
|
|
|
532
582
|
# Check if it's a ZIP file
|
|
533
|
-
elif source.endswith(".zip"):
|
|
583
|
+
elif parsed_skill is None and source.endswith(".zip"):
|
|
534
584
|
zip_path = Path(source)
|
|
535
585
|
if not zip_path.exists():
|
|
536
586
|
return {
|
|
@@ -547,7 +597,7 @@ def create_skills_registry(
|
|
|
547
597
|
}
|
|
548
598
|
|
|
549
599
|
# Assume it's a local path
|
|
550
|
-
|
|
600
|
+
elif parsed_skill is None:
|
|
551
601
|
local_path = Path(source)
|
|
552
602
|
if not local_path.exists():
|
|
553
603
|
return {
|
|
@@ -613,4 +663,108 @@ def create_skills_registry(
|
|
|
613
663
|
"error": str(e),
|
|
614
664
|
}
|
|
615
665
|
|
|
666
|
+
# --- list_hubs tool ---
|
|
667
|
+
|
|
668
|
+
@registry.tool(
|
|
669
|
+
name="list_hubs",
|
|
670
|
+
description="List all configured skill hubs. Returns hub names and types.",
|
|
671
|
+
)
|
|
672
|
+
async def list_hubs() -> dict[str, Any]:
|
|
673
|
+
"""
|
|
674
|
+
List all configured skill hubs.
|
|
675
|
+
|
|
676
|
+
Returns hub name, type, and base_url for each configured hub.
|
|
677
|
+
|
|
678
|
+
Returns:
|
|
679
|
+
Dict with success status and list of hub info
|
|
680
|
+
"""
|
|
681
|
+
try:
|
|
682
|
+
if hub_manager is None:
|
|
683
|
+
return {
|
|
684
|
+
"success": True,
|
|
685
|
+
"count": 0,
|
|
686
|
+
"hubs": [],
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
hub_names = hub_manager.list_hubs()
|
|
690
|
+
hubs_list = []
|
|
691
|
+
|
|
692
|
+
for name in hub_names:
|
|
693
|
+
config = hub_manager.get_config(name)
|
|
694
|
+
hubs_list.append(
|
|
695
|
+
{
|
|
696
|
+
"name": name,
|
|
697
|
+
"type": config.type,
|
|
698
|
+
"base_url": config.base_url,
|
|
699
|
+
}
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
return {
|
|
703
|
+
"success": True,
|
|
704
|
+
"count": len(hubs_list),
|
|
705
|
+
"hubs": hubs_list,
|
|
706
|
+
}
|
|
707
|
+
except Exception as e:
|
|
708
|
+
return {
|
|
709
|
+
"success": False,
|
|
710
|
+
"error": str(e),
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
# --- search_hub tool ---
|
|
714
|
+
|
|
715
|
+
@registry.tool(
|
|
716
|
+
name="search_hub",
|
|
717
|
+
description="Search for skills across configured hubs. Returns ranked results from all or specific hubs.",
|
|
718
|
+
)
|
|
719
|
+
async def search_hub(
|
|
720
|
+
query: str,
|
|
721
|
+
hub_name: str | None = None,
|
|
722
|
+
limit: int = 20,
|
|
723
|
+
) -> dict[str, Any]:
|
|
724
|
+
"""
|
|
725
|
+
Search for skills across configured hubs.
|
|
726
|
+
|
|
727
|
+
Args:
|
|
728
|
+
query: Search query (required, non-empty)
|
|
729
|
+
hub_name: Optional specific hub to search (None for all hubs)
|
|
730
|
+
limit: Maximum results per hub (default 20)
|
|
731
|
+
|
|
732
|
+
Returns:
|
|
733
|
+
Dict with success status and search results
|
|
734
|
+
"""
|
|
735
|
+
try:
|
|
736
|
+
# Validate query
|
|
737
|
+
if not query or not query.strip():
|
|
738
|
+
return {
|
|
739
|
+
"success": False,
|
|
740
|
+
"error": "Query is required and cannot be empty",
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if hub_manager is None:
|
|
744
|
+
return {
|
|
745
|
+
"success": False,
|
|
746
|
+
"error": "No hub manager configured. Add hubs to config to enable hub search.",
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
# Build hub filter
|
|
750
|
+
hub_names_filter = [hub_name] if hub_name else None
|
|
751
|
+
|
|
752
|
+
# Perform search
|
|
753
|
+
results = await hub_manager.search_all(
|
|
754
|
+
query=query.strip(),
|
|
755
|
+
limit=limit,
|
|
756
|
+
hub_names=hub_names_filter,
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
"success": True,
|
|
761
|
+
"count": len(results),
|
|
762
|
+
"results": results,
|
|
763
|
+
}
|
|
764
|
+
except Exception as e:
|
|
765
|
+
return {
|
|
766
|
+
"success": False,
|
|
767
|
+
"error": str(e),
|
|
768
|
+
}
|
|
769
|
+
|
|
616
770
|
return registry
|