gobby 0.2.8__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.
Files changed (168) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +6 -0
  3. gobby/adapters/base.py +11 -2
  4. gobby/adapters/claude_code.py +5 -28
  5. gobby/adapters/codex_impl/adapter.py +38 -43
  6. gobby/adapters/copilot.py +324 -0
  7. gobby/adapters/cursor.py +373 -0
  8. gobby/adapters/gemini.py +2 -26
  9. gobby/adapters/windsurf.py +359 -0
  10. gobby/agents/definitions.py +162 -2
  11. gobby/agents/isolation.py +33 -1
  12. gobby/agents/pty_reader.py +192 -0
  13. gobby/agents/registry.py +10 -1
  14. gobby/agents/runner.py +24 -8
  15. gobby/agents/sandbox.py +8 -3
  16. gobby/agents/session.py +4 -0
  17. gobby/agents/spawn.py +9 -2
  18. gobby/agents/spawn_executor.py +49 -61
  19. gobby/agents/spawners/command_builder.py +4 -4
  20. gobby/app_context.py +64 -0
  21. gobby/cli/__init__.py +4 -0
  22. gobby/cli/install.py +259 -4
  23. gobby/cli/installers/__init__.py +12 -0
  24. gobby/cli/installers/copilot.py +242 -0
  25. gobby/cli/installers/cursor.py +244 -0
  26. gobby/cli/installers/shared.py +3 -0
  27. gobby/cli/installers/windsurf.py +242 -0
  28. gobby/cli/pipelines.py +639 -0
  29. gobby/cli/sessions.py +3 -1
  30. gobby/cli/skills.py +209 -0
  31. gobby/cli/tasks/crud.py +6 -5
  32. gobby/cli/tasks/search.py +1 -1
  33. gobby/cli/ui.py +116 -0
  34. gobby/cli/utils.py +5 -17
  35. gobby/cli/workflows.py +38 -17
  36. gobby/config/app.py +5 -0
  37. gobby/config/features.py +0 -20
  38. gobby/config/skills.py +23 -2
  39. gobby/config/tasks.py +4 -0
  40. gobby/hooks/broadcaster.py +9 -0
  41. gobby/hooks/event_handlers/__init__.py +155 -0
  42. gobby/hooks/event_handlers/_agent.py +175 -0
  43. gobby/hooks/event_handlers/_base.py +92 -0
  44. gobby/hooks/event_handlers/_misc.py +66 -0
  45. gobby/hooks/event_handlers/_session.py +487 -0
  46. gobby/hooks/event_handlers/_tool.py +196 -0
  47. gobby/hooks/events.py +48 -0
  48. gobby/hooks/hook_manager.py +27 -3
  49. gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
  50. gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
  51. gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
  52. gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
  53. gobby/llm/__init__.py +14 -1
  54. gobby/llm/claude.py +594 -43
  55. gobby/llm/service.py +149 -0
  56. gobby/mcp_proxy/importer.py +4 -41
  57. gobby/mcp_proxy/instructions.py +9 -27
  58. gobby/mcp_proxy/manager.py +13 -3
  59. gobby/mcp_proxy/models.py +1 -0
  60. gobby/mcp_proxy/registries.py +66 -5
  61. gobby/mcp_proxy/server.py +6 -2
  62. gobby/mcp_proxy/services/recommendation.py +2 -28
  63. gobby/mcp_proxy/services/tool_filter.py +7 -0
  64. gobby/mcp_proxy/services/tool_proxy.py +19 -1
  65. gobby/mcp_proxy/stdio.py +37 -21
  66. gobby/mcp_proxy/tools/agents.py +7 -0
  67. gobby/mcp_proxy/tools/artifacts.py +3 -3
  68. gobby/mcp_proxy/tools/hub.py +30 -1
  69. gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
  70. gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
  71. gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
  72. gobby/mcp_proxy/tools/orchestration/review.py +17 -4
  73. gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
  74. gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
  75. gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
  76. gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
  77. gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
  78. gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
  79. gobby/mcp_proxy/tools/skills/__init__.py +184 -30
  80. gobby/mcp_proxy/tools/spawn_agent.py +229 -14
  81. gobby/mcp_proxy/tools/task_readiness.py +27 -4
  82. gobby/mcp_proxy/tools/tasks/_context.py +8 -0
  83. gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
  84. gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
  85. gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
  86. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
  87. gobby/mcp_proxy/tools/tasks/_search.py +1 -1
  88. gobby/mcp_proxy/tools/workflows/__init__.py +273 -0
  89. gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
  90. gobby/mcp_proxy/tools/workflows/_import.py +112 -0
  91. gobby/mcp_proxy/tools/workflows/_lifecycle.py +332 -0
  92. gobby/mcp_proxy/tools/workflows/_query.py +226 -0
  93. gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
  94. gobby/mcp_proxy/tools/workflows/_terminal.py +175 -0
  95. gobby/mcp_proxy/tools/worktrees.py +54 -15
  96. gobby/memory/components/__init__.py +0 -0
  97. gobby/memory/components/ingestion.py +98 -0
  98. gobby/memory/components/search.py +108 -0
  99. gobby/memory/context.py +5 -5
  100. gobby/memory/manager.py +16 -25
  101. gobby/paths.py +51 -0
  102. gobby/prompts/loader.py +1 -35
  103. gobby/runner.py +131 -16
  104. gobby/servers/http.py +193 -150
  105. gobby/servers/routes/__init__.py +2 -0
  106. gobby/servers/routes/admin.py +56 -0
  107. gobby/servers/routes/mcp/endpoints/execution.py +33 -32
  108. gobby/servers/routes/mcp/endpoints/registry.py +8 -8
  109. gobby/servers/routes/mcp/hooks.py +10 -1
  110. gobby/servers/routes/pipelines.py +227 -0
  111. gobby/servers/websocket.py +314 -1
  112. gobby/sessions/analyzer.py +89 -3
  113. gobby/sessions/manager.py +5 -5
  114. gobby/sessions/transcripts/__init__.py +3 -0
  115. gobby/sessions/transcripts/claude.py +5 -0
  116. gobby/sessions/transcripts/codex.py +5 -0
  117. gobby/sessions/transcripts/gemini.py +5 -0
  118. gobby/skills/hubs/__init__.py +25 -0
  119. gobby/skills/hubs/base.py +234 -0
  120. gobby/skills/hubs/claude_plugins.py +328 -0
  121. gobby/skills/hubs/clawdhub.py +289 -0
  122. gobby/skills/hubs/github_collection.py +465 -0
  123. gobby/skills/hubs/manager.py +263 -0
  124. gobby/skills/hubs/skillhub.py +342 -0
  125. gobby/skills/parser.py +23 -0
  126. gobby/skills/sync.py +5 -4
  127. gobby/storage/artifacts.py +19 -0
  128. gobby/storage/memories.py +4 -4
  129. gobby/storage/migrations.py +118 -3
  130. gobby/storage/pipelines.py +367 -0
  131. gobby/storage/sessions.py +23 -4
  132. gobby/storage/skills.py +48 -8
  133. gobby/storage/tasks/_aggregates.py +2 -2
  134. gobby/storage/tasks/_lifecycle.py +4 -4
  135. gobby/storage/tasks/_models.py +7 -1
  136. gobby/storage/tasks/_queries.py +3 -3
  137. gobby/sync/memories.py +4 -3
  138. gobby/tasks/commits.py +48 -17
  139. gobby/tasks/external_validator.py +4 -17
  140. gobby/tasks/validation.py +13 -87
  141. gobby/tools/summarizer.py +18 -51
  142. gobby/utils/status.py +13 -0
  143. gobby/workflows/actions.py +80 -0
  144. gobby/workflows/context_actions.py +265 -27
  145. gobby/workflows/definitions.py +119 -1
  146. gobby/workflows/detection_helpers.py +23 -11
  147. gobby/workflows/enforcement/__init__.py +11 -1
  148. gobby/workflows/enforcement/blocking.py +96 -0
  149. gobby/workflows/enforcement/handlers.py +35 -1
  150. gobby/workflows/enforcement/task_policy.py +18 -0
  151. gobby/workflows/engine.py +26 -4
  152. gobby/workflows/evaluator.py +8 -5
  153. gobby/workflows/lifecycle_evaluator.py +59 -27
  154. gobby/workflows/loader.py +567 -30
  155. gobby/workflows/lobster_compat.py +147 -0
  156. gobby/workflows/pipeline_executor.py +801 -0
  157. gobby/workflows/pipeline_state.py +172 -0
  158. gobby/workflows/pipeline_webhooks.py +206 -0
  159. gobby/workflows/premature_stop.py +5 -0
  160. gobby/worktrees/git.py +135 -20
  161. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
  162. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/RECORD +166 -122
  163. gobby/hooks/event_handlers.py +0 -1008
  164. gobby/mcp_proxy/tools/workflows.py +0 -1023
  165. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
  166. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
  167. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
  168. {gobby-0.2.8.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 "codex"
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 ["claude_code", "gemini", "codex"]:
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 - claude_code, gemini, codex (optional)
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 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
- )
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
- # 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
- )
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
- # 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
- )
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
- # 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:
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
- else:
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