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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +6 -0
- gobby/adapters/base.py +11 -2
- gobby/adapters/claude_code.py +5 -28
- 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 +64 -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/utils.py +5 -17
- gobby/cli/workflows.py +38 -17
- gobby/config/app.py +5 -0
- gobby/config/features.py +0 -20
- gobby/config/skills.py +23 -2
- gobby/config/tasks.py +4 -0
- gobby/hooks/broadcaster.py +9 -0
- gobby/hooks/event_handlers/__init__.py +155 -0
- gobby/hooks/event_handlers/_agent.py +175 -0
- gobby/hooks/event_handlers/_base.py +92 -0
- gobby/hooks/event_handlers/_misc.py +66 -0
- gobby/hooks/event_handlers/_session.py +487 -0
- gobby/hooks/event_handlers/_tool.py +196 -0
- gobby/hooks/events.py +48 -0
- gobby/hooks/hook_manager.py +27 -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 +594 -43
- gobby/llm/service.py +149 -0
- gobby/mcp_proxy/importer.py +4 -41
- gobby/mcp_proxy/instructions.py +9 -27
- gobby/mcp_proxy/manager.py +13 -3
- gobby/mcp_proxy/models.py +1 -0
- gobby/mcp_proxy/registries.py +66 -5
- gobby/mcp_proxy/server.py +6 -2
- gobby/mcp_proxy/services/recommendation.py +2 -28
- 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/artifacts.py +3 -3
- 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/task_readiness.py +27 -4
- 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 +273 -0
- gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
- gobby/mcp_proxy/tools/workflows/_import.py +112 -0
- gobby/mcp_proxy/tools/workflows/_lifecycle.py +332 -0
- gobby/mcp_proxy/tools/workflows/_query.py +226 -0
- gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
- gobby/mcp_proxy/tools/workflows/_terminal.py +175 -0
- gobby/mcp_proxy/tools/worktrees.py +54 -15
- gobby/memory/components/__init__.py +0 -0
- gobby/memory/components/ingestion.py +98 -0
- gobby/memory/components/search.py +108 -0
- gobby/memory/context.py +5 -5
- gobby/memory/manager.py +16 -25
- gobby/paths.py +51 -0
- gobby/prompts/loader.py +1 -35
- gobby/runner.py +131 -16
- gobby/servers/http.py +193 -150
- gobby/servers/routes/__init__.py +2 -0
- gobby/servers/routes/admin.py +56 -0
- gobby/servers/routes/mcp/endpoints/execution.py +33 -32
- gobby/servers/routes/mcp/endpoints/registry.py +8 -8
- 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 +89 -3
- 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/skills/parser.py +23 -0
- gobby/skills/sync.py +5 -4
- gobby/storage/artifacts.py +19 -0
- gobby/storage/memories.py +4 -4
- gobby/storage/migrations.py +118 -3
- gobby/storage/pipelines.py +367 -0
- gobby/storage/sessions.py +23 -4
- gobby/storage/skills.py +48 -8
- 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/tasks/external_validator.py +4 -17
- gobby/tasks/validation.py +13 -87
- gobby/tools/summarizer.py +18 -51
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +80 -0
- gobby/workflows/context_actions.py +265 -27
- gobby/workflows/definitions.py +119 -1
- gobby/workflows/detection_helpers.py +23 -11
- gobby/workflows/enforcement/__init__.py +11 -1
- gobby/workflows/enforcement/blocking.py +96 -0
- gobby/workflows/enforcement/handlers.py +35 -1
- gobby/workflows/enforcement/task_policy.py +18 -0
- gobby/workflows/engine.py +26 -4
- gobby/workflows/evaluator.py +8 -5
- gobby/workflows/lifecycle_evaluator.py +59 -27
- 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.8.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/RECORD +166 -122
- gobby/hooks/event_handlers.py +0 -1008
- gobby/mcp_proxy/tools/workflows.py +0 -1023
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Hub manager for coordinating skill hub providers.
|
|
2
|
+
|
|
3
|
+
This module provides the HubManager class which manages hub configurations,
|
|
4
|
+
creates and caches provider instances, and coordinates operations across
|
|
5
|
+
multiple skill hubs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from gobby.skills.hubs.base import HubProvider
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from gobby.config.skills import HubConfig
|
|
18
|
+
from gobby.llm.service import LLMService
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Type alias for provider factory functions
|
|
23
|
+
ProviderFactory = type[HubProvider]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HubManager:
|
|
27
|
+
"""Manages skill hub providers and configurations.
|
|
28
|
+
|
|
29
|
+
The HubManager is responsible for:
|
|
30
|
+
- Storing hub configurations
|
|
31
|
+
- Creating and caching provider instances
|
|
32
|
+
- Resolving API keys for authenticated hubs
|
|
33
|
+
- Providing a unified interface for hub operations
|
|
34
|
+
|
|
35
|
+
Example usage:
|
|
36
|
+
```python
|
|
37
|
+
configs = {
|
|
38
|
+
"clawdhub": HubConfig(type="clawdhub", base_url="https://clawdhub.com"),
|
|
39
|
+
"skillhub": HubConfig(type="skillhub", auth_key_name="SKILLHUB_KEY"),
|
|
40
|
+
}
|
|
41
|
+
api_keys = {"SKILLHUB_KEY": "secret123"}
|
|
42
|
+
|
|
43
|
+
manager = HubManager(configs=configs, api_keys=api_keys)
|
|
44
|
+
|
|
45
|
+
# List available hubs
|
|
46
|
+
for hub_name in manager.list_hubs():
|
|
47
|
+
print(hub_name)
|
|
48
|
+
|
|
49
|
+
# Get a provider
|
|
50
|
+
provider = manager.get_provider("clawdhub")
|
|
51
|
+
results = await provider.search("commit message")
|
|
52
|
+
```
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
configs: dict[str, HubConfig] | None = None,
|
|
58
|
+
api_keys: dict[str, str] | None = None,
|
|
59
|
+
llm_service: LLMService | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Initialize the hub manager.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
configs: Dictionary of hub configurations keyed by hub name
|
|
65
|
+
api_keys: Dictionary of API keys keyed by key name
|
|
66
|
+
llm_service: Optional LLM service for providers that need it
|
|
67
|
+
"""
|
|
68
|
+
self._configs: dict[str, HubConfig] = configs or {}
|
|
69
|
+
self._api_keys: dict[str, str] = api_keys or {}
|
|
70
|
+
self._llm_service = llm_service
|
|
71
|
+
self._providers: dict[str, HubProvider] = {}
|
|
72
|
+
self._factories: dict[str, ProviderFactory] = {}
|
|
73
|
+
|
|
74
|
+
def register_provider_factory(
|
|
75
|
+
self,
|
|
76
|
+
hub_type: str,
|
|
77
|
+
factory: ProviderFactory,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Register a provider factory for a hub type.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
hub_type: The hub type identifier (e.g., 'clawdhub', 'skillhub')
|
|
83
|
+
factory: The provider class to instantiate for this type
|
|
84
|
+
"""
|
|
85
|
+
self._factories[hub_type] = factory
|
|
86
|
+
logger.debug(f"Registered provider factory for hub type: {hub_type}")
|
|
87
|
+
|
|
88
|
+
def list_hubs(self) -> list[str]:
|
|
89
|
+
"""List all configured hub names.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
List of hub names
|
|
93
|
+
"""
|
|
94
|
+
return list(self._configs.keys())
|
|
95
|
+
|
|
96
|
+
def has_hub(self, hub_name: str) -> bool:
|
|
97
|
+
"""Check if a hub is configured.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
hub_name: Name of the hub to check
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
True if the hub exists, False otherwise
|
|
104
|
+
"""
|
|
105
|
+
return hub_name in self._configs
|
|
106
|
+
|
|
107
|
+
def get_config(self, hub_name: str) -> HubConfig:
|
|
108
|
+
"""Get the configuration for a hub.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
hub_name: Name of the hub
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The hub configuration
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
KeyError: If hub is not configured
|
|
118
|
+
"""
|
|
119
|
+
if hub_name not in self._configs:
|
|
120
|
+
raise KeyError(f"Unknown hub: {hub_name}")
|
|
121
|
+
return self._configs[hub_name]
|
|
122
|
+
|
|
123
|
+
def _create_provider(self, hub_name: str) -> HubProvider:
|
|
124
|
+
"""Create a provider instance for a hub.
|
|
125
|
+
|
|
126
|
+
This is the factory method that instantiates the correct provider
|
|
127
|
+
type based on the hub's configuration. It resolves auth tokens
|
|
128
|
+
and passes all necessary configuration to the provider.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
hub_name: Name of the hub
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
A new provider instance (not cached)
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
KeyError: If hub is not configured
|
|
138
|
+
ValueError: If no factory is registered for the hub type
|
|
139
|
+
"""
|
|
140
|
+
if hub_name not in self._configs:
|
|
141
|
+
raise KeyError(f"Unknown hub: {hub_name}")
|
|
142
|
+
|
|
143
|
+
config = self._configs[hub_name]
|
|
144
|
+
factory = self._factories.get(config.type)
|
|
145
|
+
|
|
146
|
+
if factory is None:
|
|
147
|
+
raise ValueError(f"No provider factory registered for hub type: {config.type}")
|
|
148
|
+
|
|
149
|
+
# Resolve auth token from api_keys if auth_key_name is set
|
|
150
|
+
auth_token: str | None = None
|
|
151
|
+
if config.auth_key_name:
|
|
152
|
+
auth_token = self._api_keys.get(config.auth_key_name)
|
|
153
|
+
if auth_token is None:
|
|
154
|
+
logger.warning(
|
|
155
|
+
f"Auth key '{config.auth_key_name}' not found in api_keys for hub '{hub_name}'"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Determine base_url - use config value or derive from hub type
|
|
159
|
+
base_url = config.base_url or ""
|
|
160
|
+
|
|
161
|
+
# Build provider kwargs based on hub type
|
|
162
|
+
kwargs: dict[str, Any] = {
|
|
163
|
+
"hub_name": hub_name,
|
|
164
|
+
"base_url": base_url,
|
|
165
|
+
"auth_token": auth_token,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# Add type-specific config
|
|
169
|
+
if config.type == "github-collection":
|
|
170
|
+
kwargs["repo"] = config.repo
|
|
171
|
+
kwargs["branch"] = config.branch or "main"
|
|
172
|
+
kwargs["path"] = config.path
|
|
173
|
+
kwargs["llm_service"] = self._llm_service
|
|
174
|
+
|
|
175
|
+
# Create the provider
|
|
176
|
+
provider = factory(**kwargs)
|
|
177
|
+
logger.debug(f"Created provider for hub: {hub_name} (type: {config.type})")
|
|
178
|
+
|
|
179
|
+
return provider
|
|
180
|
+
|
|
181
|
+
def get_provider(self, hub_name: str) -> HubProvider:
|
|
182
|
+
"""Get or create a provider for a hub.
|
|
183
|
+
|
|
184
|
+
Providers are cached after creation, so subsequent calls
|
|
185
|
+
return the same instance.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
hub_name: Name of the hub
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
The provider instance for this hub
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
KeyError: If hub is not configured
|
|
195
|
+
ValueError: If no factory is registered for the hub type
|
|
196
|
+
"""
|
|
197
|
+
if hub_name not in self._configs:
|
|
198
|
+
raise KeyError(f"Unknown hub: {hub_name}")
|
|
199
|
+
|
|
200
|
+
# Return cached provider if available
|
|
201
|
+
if hub_name in self._providers:
|
|
202
|
+
return self._providers[hub_name]
|
|
203
|
+
|
|
204
|
+
# Create and cache the provider
|
|
205
|
+
provider = self._create_provider(hub_name)
|
|
206
|
+
self._providers[hub_name] = provider
|
|
207
|
+
|
|
208
|
+
return provider
|
|
209
|
+
|
|
210
|
+
async def search_all(
|
|
211
|
+
self,
|
|
212
|
+
query: str,
|
|
213
|
+
limit: int = 20,
|
|
214
|
+
hub_names: list[str] | None = None,
|
|
215
|
+
) -> list[dict[str, Any]]:
|
|
216
|
+
"""Search across multiple hubs in parallel.
|
|
217
|
+
|
|
218
|
+
Uses asyncio.gather for concurrent searches across all providers,
|
|
219
|
+
improving performance when searching multiple hubs.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
query: Search query string
|
|
223
|
+
limit: Maximum results per hub
|
|
224
|
+
hub_names: Specific hubs to search (None for all)
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Combined list of results from all hubs
|
|
228
|
+
"""
|
|
229
|
+
hubs_to_search = hub_names or self.list_hubs()
|
|
230
|
+
|
|
231
|
+
# Filter to valid hubs only
|
|
232
|
+
valid_hubs = []
|
|
233
|
+
for hub_name in hubs_to_search:
|
|
234
|
+
if not self.has_hub(hub_name):
|
|
235
|
+
logger.warning(f"Skipping unknown hub: {hub_name}")
|
|
236
|
+
else:
|
|
237
|
+
valid_hubs.append(hub_name)
|
|
238
|
+
|
|
239
|
+
if not valid_hubs:
|
|
240
|
+
return []
|
|
241
|
+
|
|
242
|
+
async def search_hub(hub_name: str) -> list[dict[str, Any]]:
|
|
243
|
+
"""Search a single hub and return results as dicts."""
|
|
244
|
+
try:
|
|
245
|
+
provider = self.get_provider(hub_name)
|
|
246
|
+
hub_results = await provider.search(query, limit=limit)
|
|
247
|
+
return [r.to_dict() for r in hub_results]
|
|
248
|
+
except Exception as e:
|
|
249
|
+
logger.error(f"Error searching hub {hub_name}: {e}")
|
|
250
|
+
return []
|
|
251
|
+
|
|
252
|
+
# Execute all searches in parallel
|
|
253
|
+
all_results = await asyncio.gather(
|
|
254
|
+
*[search_hub(hub_name) for hub_name in valid_hubs],
|
|
255
|
+
return_exceptions=False,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Flatten results from all hubs
|
|
259
|
+
results: list[dict[str, Any]] = []
|
|
260
|
+
for hub_results in all_results:
|
|
261
|
+
results.extend(hub_results)
|
|
262
|
+
|
|
263
|
+
return results
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""SkillHub provider implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the SkillHubProvider class which connects to the
|
|
4
|
+
SkillHub REST API for skill search, listing, and download functionality.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import tempfile
|
|
11
|
+
import zipfile
|
|
12
|
+
from io import BytesIO
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from gobby.skills.hubs.base import DownloadResult, HubProvider, HubSkillDetails, HubSkillInfo
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SkillHubProvider(HubProvider):
|
|
24
|
+
"""Provider for SkillHub skill registry using REST API.
|
|
25
|
+
|
|
26
|
+
This provider connects to the SkillHub API to provide access to
|
|
27
|
+
skills in the SkillHub registry.
|
|
28
|
+
|
|
29
|
+
Authentication is via Bearer token in the Authorization header.
|
|
30
|
+
|
|
31
|
+
Example usage:
|
|
32
|
+
```python
|
|
33
|
+
provider = SkillHubProvider(
|
|
34
|
+
hub_name="skillhub",
|
|
35
|
+
base_url="https://skillhub.dev",
|
|
36
|
+
auth_token="sk-your-api-key",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
results = await provider.search("commit message")
|
|
40
|
+
for skill in results:
|
|
41
|
+
print(f"{skill.slug}: {skill.description}")
|
|
42
|
+
```
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
hub_name: str,
|
|
48
|
+
base_url: str,
|
|
49
|
+
auth_token: str | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Initialize the SkillHub provider.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
hub_name: The configured name for this hub instance
|
|
55
|
+
base_url: Base URL for the SkillHub API
|
|
56
|
+
auth_token: Optional API key for authentication
|
|
57
|
+
"""
|
|
58
|
+
super().__init__(hub_name=hub_name, base_url=base_url, auth_token=auth_token)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def provider_type(self) -> str:
|
|
62
|
+
"""Return the provider type identifier."""
|
|
63
|
+
return "skillhub"
|
|
64
|
+
|
|
65
|
+
def _get_headers(self) -> dict[str, str]:
|
|
66
|
+
"""Get HTTP headers for API requests.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Dictionary of headers including Authorization if auth_token is set
|
|
70
|
+
"""
|
|
71
|
+
headers: dict[str, str] = {
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
"Accept": "application/json",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if self.auth_token:
|
|
77
|
+
headers["Authorization"] = f"Bearer {self.auth_token}"
|
|
78
|
+
|
|
79
|
+
return headers
|
|
80
|
+
|
|
81
|
+
async def _make_request(
|
|
82
|
+
self,
|
|
83
|
+
method: str,
|
|
84
|
+
endpoint: str,
|
|
85
|
+
params: dict[str, Any] | None = None,
|
|
86
|
+
json_data: dict[str, Any] | None = None,
|
|
87
|
+
) -> dict[str, Any]:
|
|
88
|
+
"""Make an HTTP request to the SkillHub API.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
method: HTTP method (GET, POST, etc.)
|
|
92
|
+
endpoint: API endpoint path
|
|
93
|
+
params: Query parameters
|
|
94
|
+
json_data: JSON body data for POST requests
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Parsed JSON response
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
RuntimeError: If the request fails
|
|
101
|
+
"""
|
|
102
|
+
url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
|
103
|
+
|
|
104
|
+
async with httpx.AsyncClient() as client:
|
|
105
|
+
try:
|
|
106
|
+
response = await client.request(
|
|
107
|
+
method=method,
|
|
108
|
+
url=url,
|
|
109
|
+
headers=self._get_headers(),
|
|
110
|
+
params=params,
|
|
111
|
+
json=json_data,
|
|
112
|
+
timeout=30.0,
|
|
113
|
+
)
|
|
114
|
+
response.raise_for_status()
|
|
115
|
+
result: dict[str, Any] = response.json()
|
|
116
|
+
return result
|
|
117
|
+
except httpx.HTTPStatusError as e:
|
|
118
|
+
logger.error(f"SkillHub API error: {e.response.status_code}")
|
|
119
|
+
raise RuntimeError(f"SkillHub API error: {e.response.status_code}") from e
|
|
120
|
+
except httpx.RequestError as e:
|
|
121
|
+
logger.error(f"SkillHub request failed: {e}")
|
|
122
|
+
raise RuntimeError(f"SkillHub request failed: {e}") from e
|
|
123
|
+
|
|
124
|
+
async def _download_and_extract(
|
|
125
|
+
self,
|
|
126
|
+
download_url: str,
|
|
127
|
+
target_dir: str | None = None,
|
|
128
|
+
) -> str:
|
|
129
|
+
"""Download and extract a skill from a URL.
|
|
130
|
+
|
|
131
|
+
Downloads a ZIP file from the given URL and extracts it safely,
|
|
132
|
+
preventing path traversal attacks.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
download_url: URL to download the skill ZIP from
|
|
136
|
+
target_dir: Optional target directory to extract to
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Path to the extracted skill directory
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
RuntimeError: If download or extraction fails
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
async with httpx.AsyncClient() as client:
|
|
146
|
+
response = await client.get(
|
|
147
|
+
download_url,
|
|
148
|
+
headers=self._get_headers(),
|
|
149
|
+
timeout=60.0,
|
|
150
|
+
follow_redirects=True,
|
|
151
|
+
)
|
|
152
|
+
response.raise_for_status()
|
|
153
|
+
zip_content = response.content
|
|
154
|
+
|
|
155
|
+
except httpx.HTTPStatusError as e:
|
|
156
|
+
logger.error(f"Failed to download skill: {e.response.status_code}")
|
|
157
|
+
raise RuntimeError(f"Failed to download skill: {e.response.status_code}") from e
|
|
158
|
+
except httpx.RequestError as e:
|
|
159
|
+
logger.error(f"Download request failed: {e}")
|
|
160
|
+
raise RuntimeError(f"Download request failed: {e}") from e
|
|
161
|
+
|
|
162
|
+
# Determine extraction directory
|
|
163
|
+
if target_dir:
|
|
164
|
+
extract_path = Path(target_dir)
|
|
165
|
+
extract_path.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
else:
|
|
167
|
+
extract_path = Path(tempfile.mkdtemp(prefix="skillhub_"))
|
|
168
|
+
|
|
169
|
+
# Extract ZIP safely
|
|
170
|
+
try:
|
|
171
|
+
zip_buffer = BytesIO(zip_content)
|
|
172
|
+
with zipfile.ZipFile(zip_buffer, "r") as zf:
|
|
173
|
+
# Check for path traversal attacks
|
|
174
|
+
for member in zf.namelist():
|
|
175
|
+
member_path = Path(member)
|
|
176
|
+
# Reject absolute paths or paths with ..
|
|
177
|
+
if member_path.is_absolute() or ".." in member_path.parts:
|
|
178
|
+
raise RuntimeError(f"Unsafe path in ZIP: {member}")
|
|
179
|
+
|
|
180
|
+
# Extract all files
|
|
181
|
+
zf.extractall(extract_path)
|
|
182
|
+
|
|
183
|
+
except zipfile.BadZipFile as e:
|
|
184
|
+
logger.error(f"Invalid ZIP file: {e}")
|
|
185
|
+
raise RuntimeError(f"Invalid ZIP file: {e}") from e
|
|
186
|
+
|
|
187
|
+
return str(extract_path)
|
|
188
|
+
|
|
189
|
+
async def discover(self) -> dict[str, Any]:
|
|
190
|
+
"""Discover hub capabilities.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Dictionary with hub info
|
|
194
|
+
"""
|
|
195
|
+
return {
|
|
196
|
+
"hub_name": self.hub_name,
|
|
197
|
+
"provider_type": self.provider_type,
|
|
198
|
+
"base_url": self.base_url,
|
|
199
|
+
"authenticated": self.auth_token is not None,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async def search(
|
|
203
|
+
self,
|
|
204
|
+
query: str,
|
|
205
|
+
limit: int = 20,
|
|
206
|
+
) -> list[HubSkillInfo]:
|
|
207
|
+
"""Search for skills matching a query.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
query: Search query string
|
|
211
|
+
limit: Maximum number of results
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
List of matching skills with basic info
|
|
215
|
+
"""
|
|
216
|
+
result = await self._make_request(
|
|
217
|
+
method="POST",
|
|
218
|
+
endpoint="/skills/search",
|
|
219
|
+
json_data={"query": query, "limit": limit},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
skills = result.get("skills", [])
|
|
223
|
+
return [
|
|
224
|
+
HubSkillInfo(
|
|
225
|
+
slug=skill.get("slug", skill.get("name", "")),
|
|
226
|
+
display_name=skill.get("name", skill.get("slug", "")),
|
|
227
|
+
description=skill.get("description", ""),
|
|
228
|
+
hub_name=self.hub_name,
|
|
229
|
+
version=skill.get("version"),
|
|
230
|
+
score=skill.get("score"),
|
|
231
|
+
)
|
|
232
|
+
for skill in skills
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
async def list_skills(
|
|
236
|
+
self,
|
|
237
|
+
limit: int = 50,
|
|
238
|
+
offset: int = 0,
|
|
239
|
+
) -> list[HubSkillInfo]:
|
|
240
|
+
"""List available skills from the hub.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
limit: Maximum number of results
|
|
244
|
+
offset: Number of results to skip
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
List of skills with basic info
|
|
248
|
+
"""
|
|
249
|
+
result = await self._make_request(
|
|
250
|
+
method="GET",
|
|
251
|
+
endpoint="/skills/catalog",
|
|
252
|
+
params={"limit": limit, "offset": offset},
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
skills = result.get("skills", [])
|
|
256
|
+
return [
|
|
257
|
+
HubSkillInfo(
|
|
258
|
+
slug=skill.get("slug", skill.get("name", "")),
|
|
259
|
+
display_name=skill.get("name", skill.get("slug", "")),
|
|
260
|
+
description=skill.get("description", ""),
|
|
261
|
+
hub_name=self.hub_name,
|
|
262
|
+
version=skill.get("version"),
|
|
263
|
+
)
|
|
264
|
+
for skill in skills
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
async def get_skill_details(
|
|
268
|
+
self,
|
|
269
|
+
slug: str,
|
|
270
|
+
) -> HubSkillDetails | None:
|
|
271
|
+
"""Get detailed information about a specific skill.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
slug: The skill's unique identifier
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Detailed skill info, or None if not found
|
|
278
|
+
"""
|
|
279
|
+
try:
|
|
280
|
+
result = await self._make_request(
|
|
281
|
+
method="GET",
|
|
282
|
+
endpoint=f"/skills/{slug}",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if not result:
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
return HubSkillDetails(
|
|
289
|
+
slug=result.get("slug", slug),
|
|
290
|
+
display_name=result.get("name", slug),
|
|
291
|
+
description=result.get("description", ""),
|
|
292
|
+
hub_name=self.hub_name,
|
|
293
|
+
version=result.get("version"),
|
|
294
|
+
latest_version=result.get("latest_version", result.get("version")),
|
|
295
|
+
versions=result.get("versions", []),
|
|
296
|
+
)
|
|
297
|
+
except RuntimeError:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
async def download_skill(
|
|
301
|
+
self,
|
|
302
|
+
slug: str,
|
|
303
|
+
version: str | None = None,
|
|
304
|
+
target_dir: str | None = None,
|
|
305
|
+
) -> DownloadResult:
|
|
306
|
+
"""Download and extract a skill from the hub.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
slug: The skill's unique identifier
|
|
310
|
+
version: Specific version to download (None for latest)
|
|
311
|
+
target_dir: Directory to extract to
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
DownloadResult with success status, path, version, or error
|
|
315
|
+
"""
|
|
316
|
+
# Get download URL from API
|
|
317
|
+
params: dict[str, Any] = {}
|
|
318
|
+
if version:
|
|
319
|
+
params["version"] = version
|
|
320
|
+
|
|
321
|
+
result = await self._make_request(
|
|
322
|
+
method="GET",
|
|
323
|
+
endpoint=f"/skills/{slug}/download",
|
|
324
|
+
params=params if params else None,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
download_url = result.get("download_url", "")
|
|
328
|
+
|
|
329
|
+
if download_url:
|
|
330
|
+
path = await self._download_and_extract(download_url, target_dir)
|
|
331
|
+
return DownloadResult(
|
|
332
|
+
success=True,
|
|
333
|
+
slug=slug,
|
|
334
|
+
path=path,
|
|
335
|
+
version=result.get("version", version),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return DownloadResult(
|
|
339
|
+
success=False,
|
|
340
|
+
slug=slug,
|
|
341
|
+
error="No download URL provided",
|
|
342
|
+
)
|
gobby/skills/parser.py
CHANGED
|
@@ -64,6 +64,8 @@ class ParsedSkill:
|
|
|
64
64
|
scripts: List of script file paths (relative to skill dir)
|
|
65
65
|
references: List of reference file paths (relative to skill dir)
|
|
66
66
|
assets: List of asset file paths (relative to skill dir)
|
|
67
|
+
always_apply: Whether skill should always be injected at session start
|
|
68
|
+
injection_format: How to inject skill (summary, full, content)
|
|
67
69
|
"""
|
|
68
70
|
|
|
69
71
|
name: str
|
|
@@ -80,6 +82,8 @@ class ParsedSkill:
|
|
|
80
82
|
scripts: list[str] | None = None
|
|
81
83
|
references: list[str] | None = None
|
|
82
84
|
assets: list[str] | None = None
|
|
85
|
+
always_apply: bool = False
|
|
86
|
+
injection_format: str = "summary"
|
|
83
87
|
|
|
84
88
|
def get_category(self) -> str | None:
|
|
85
89
|
"""Get category from top-level or metadata.skillport.category."""
|
|
@@ -139,6 +143,8 @@ class ParsedSkill:
|
|
|
139
143
|
"scripts": self.scripts,
|
|
140
144
|
"references": self.references,
|
|
141
145
|
"assets": self.assets,
|
|
146
|
+
"always_apply": self.always_apply,
|
|
147
|
+
"injection_format": self.injection_format,
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
|
|
@@ -232,6 +238,7 @@ def parse_skill_text(text: str, source_path: str | None = None) -> ParsedSkill:
|
|
|
232
238
|
# This allows both top-level and nested formats to work
|
|
233
239
|
top_level_always_apply = frontmatter.get("alwaysApply")
|
|
234
240
|
top_level_category = frontmatter.get("category")
|
|
241
|
+
top_level_injection_format = frontmatter.get("injectionFormat")
|
|
235
242
|
|
|
236
243
|
if top_level_always_apply is not None or top_level_category is not None:
|
|
237
244
|
if metadata is None:
|
|
@@ -251,6 +258,20 @@ def parse_skill_text(text: str, source_path: str | None = None) -> ParsedSkill:
|
|
|
251
258
|
if version is not None:
|
|
252
259
|
version = str(version)
|
|
253
260
|
|
|
261
|
+
# Extract always_apply: check top-level first, then metadata.skillport.alwaysApply
|
|
262
|
+
always_apply = False
|
|
263
|
+
if top_level_always_apply is not None:
|
|
264
|
+
always_apply = bool(top_level_always_apply)
|
|
265
|
+
elif metadata and isinstance(metadata, dict):
|
|
266
|
+
skillport = metadata.get("skillport", {})
|
|
267
|
+
if isinstance(skillport, dict) and skillport.get("alwaysApply"):
|
|
268
|
+
always_apply = bool(skillport["alwaysApply"])
|
|
269
|
+
|
|
270
|
+
# Extract injection_format: check top-level first, default to "summary"
|
|
271
|
+
injection_format = "summary"
|
|
272
|
+
if top_level_injection_format is not None:
|
|
273
|
+
injection_format = str(top_level_injection_format)
|
|
274
|
+
|
|
254
275
|
return ParsedSkill(
|
|
255
276
|
name=name,
|
|
256
277
|
description=description,
|
|
@@ -261,6 +282,8 @@ def parse_skill_text(text: str, source_path: str | None = None) -> ParsedSkill:
|
|
|
261
282
|
allowed_tools=allowed_tools,
|
|
262
283
|
metadata=metadata,
|
|
263
284
|
source_path=source_path,
|
|
285
|
+
always_apply=always_apply,
|
|
286
|
+
injection_format=injection_format,
|
|
264
287
|
)
|
|
265
288
|
|
|
266
289
|
|
gobby/skills/sync.py
CHANGED
|
@@ -23,10 +23,9 @@ def get_bundled_skills_path() -> Path:
|
|
|
23
23
|
Returns:
|
|
24
24
|
Path to src/gobby/install/shared/skills/
|
|
25
25
|
"""
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return Path(__file__).parent.parent / "install" / "shared" / "skills"
|
|
26
|
+
from gobby.paths import get_install_dir
|
|
27
|
+
|
|
28
|
+
return get_install_dir() / "shared" / "skills"
|
|
30
29
|
|
|
31
30
|
|
|
32
31
|
def sync_bundled_skills(db: DatabaseProtocol) -> dict[str, Any]:
|
|
@@ -101,6 +100,8 @@ def sync_bundled_skills(db: DatabaseProtocol) -> dict[str, Any]:
|
|
|
101
100
|
source_ref=None,
|
|
102
101
|
project_id=None, # Global scope
|
|
103
102
|
enabled=True,
|
|
103
|
+
always_apply=parsed.always_apply,
|
|
104
|
+
injection_format=parsed.injection_format,
|
|
104
105
|
)
|
|
105
106
|
|
|
106
107
|
logger.info(f"Synced bundled skill: {parsed.name}")
|