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,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/storage/memories.py
CHANGED
|
@@ -145,11 +145,11 @@ class LocalMemoryManager:
|
|
|
145
145
|
|
|
146
146
|
now = datetime.now(UTC).isoformat()
|
|
147
147
|
# Normalize content for consistent ID generation (avoid duplicates from
|
|
148
|
-
# whitespace differences
|
|
148
|
+
# whitespace differences)
|
|
149
149
|
normalized_content = content.strip()
|
|
150
|
-
|
|
151
|
-
#
|
|
152
|
-
memory_id = generate_prefixed_id("mm",
|
|
150
|
+
# Global dedup: ID based on content only (project_id stored but not in ID)
|
|
151
|
+
# This aligns with content_exists() which checks globally
|
|
152
|
+
memory_id = generate_prefixed_id("mm", normalized_content)
|
|
153
153
|
|
|
154
154
|
# Check if memory already exists to avoid duplicate insert errors
|
|
155
155
|
existing_row = self.db.fetchone("SELECT * FROM memories WHERE id = ?", (memory_id,))
|