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,465 @@
|
|
|
1
|
+
"""GitHub Collection provider implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the GitHubCollectionProvider class which provides
|
|
4
|
+
access to skill collections hosted in GitHub repositories.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import shutil
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from gobby.skills.hubs.base import DownloadResult, HubProvider, HubSkillDetails, HubSkillInfo
|
|
18
|
+
from gobby.skills.loader import GitHubRef, clone_skill_repo
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from gobby.llm.service import LLMService
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GitHubCollectionProvider(HubProvider):
|
|
27
|
+
"""Provider for GitHub-hosted skill collections.
|
|
28
|
+
|
|
29
|
+
This provider accesses skills stored in a GitHub repository,
|
|
30
|
+
typically organized as a collection of skill directories.
|
|
31
|
+
|
|
32
|
+
The repository structure is expected to be:
|
|
33
|
+
```
|
|
34
|
+
repo/
|
|
35
|
+
├── skill-1/
|
|
36
|
+
│ └── SKILL.md
|
|
37
|
+
├── skill-2/
|
|
38
|
+
│ └── SKILL.md
|
|
39
|
+
└── ...
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Example usage:
|
|
43
|
+
```python
|
|
44
|
+
provider = GitHubCollectionProvider(
|
|
45
|
+
hub_name="my-collection",
|
|
46
|
+
base_url="", # Not used for GitHub
|
|
47
|
+
repo="user/my-skills",
|
|
48
|
+
branch="main",
|
|
49
|
+
auth_token="ghp_your_token", # Optional, for private repos
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
skills = await provider.list_skills()
|
|
53
|
+
for skill in skills:
|
|
54
|
+
print(f"{skill.slug}: {skill.description}")
|
|
55
|
+
```
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
# Cache TTL for synthesized descriptions (1 hour)
|
|
59
|
+
CACHE_TTL = 3600
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
hub_name: str,
|
|
64
|
+
base_url: str,
|
|
65
|
+
repo: str | None = None,
|
|
66
|
+
branch: str = "main",
|
|
67
|
+
path: str | None = None,
|
|
68
|
+
auth_token: str | None = None,
|
|
69
|
+
llm_service: LLMService | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Initialize the GitHub Collection provider.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
hub_name: The configured name for this hub instance
|
|
75
|
+
base_url: Not used for GitHub (kept for interface compatibility)
|
|
76
|
+
repo: GitHub repository in 'owner/repo' format
|
|
77
|
+
branch: Git branch to use (default: 'main')
|
|
78
|
+
path: Subdirectory path within repo where skills are located
|
|
79
|
+
auth_token: Optional GitHub token for private repos
|
|
80
|
+
llm_service: Optional LLM service for synthesizing descriptions
|
|
81
|
+
"""
|
|
82
|
+
super().__init__(hub_name=hub_name, base_url=base_url, auth_token=auth_token)
|
|
83
|
+
self._repo = repo or ""
|
|
84
|
+
self._branch = branch
|
|
85
|
+
self._path = path
|
|
86
|
+
self._llm_service = llm_service
|
|
87
|
+
self._description_cache: dict[str, tuple[str, float]] = {}
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def provider_type(self) -> str:
|
|
91
|
+
"""Return the provider type identifier."""
|
|
92
|
+
return "github-collection"
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def repo(self) -> str:
|
|
96
|
+
"""GitHub repository in 'owner/repo' format."""
|
|
97
|
+
return self._repo
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def branch(self) -> str:
|
|
101
|
+
"""Git branch to use."""
|
|
102
|
+
return self._branch
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def path(self) -> str | None:
|
|
106
|
+
"""Subdirectory path within repo where skills are located."""
|
|
107
|
+
return self._path
|
|
108
|
+
|
|
109
|
+
async def _fetch_skill_list(self) -> list[dict[str, Any]]:
|
|
110
|
+
"""Fetch the list of skills from the repository.
|
|
111
|
+
|
|
112
|
+
Uses the GitHub API to list contents of the repository root,
|
|
113
|
+
filtering for directories which represent skills.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
List of skill metadata dictionaries with 'slug', 'name' keys
|
|
117
|
+
"""
|
|
118
|
+
if not self._repo or "/" not in self._repo:
|
|
119
|
+
logger.warning(f"Invalid repo format: {self._repo}")
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
owner, repo = self._repo.split("/", 1)
|
|
123
|
+
url = f"https://api.github.com/repos/{owner}/{repo}/contents"
|
|
124
|
+
if self._path:
|
|
125
|
+
url = f"{url}/{self._path.strip('/')}"
|
|
126
|
+
|
|
127
|
+
headers: dict[str, str] = {
|
|
128
|
+
"Accept": "application/vnd.github.v3+json",
|
|
129
|
+
}
|
|
130
|
+
if self.auth_token:
|
|
131
|
+
headers["Authorization"] = f"Bearer {self.auth_token}"
|
|
132
|
+
|
|
133
|
+
params: dict[str, str] = {}
|
|
134
|
+
if self._branch:
|
|
135
|
+
params["ref"] = self._branch
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
async with httpx.AsyncClient() as client:
|
|
139
|
+
response = await client.get(
|
|
140
|
+
url,
|
|
141
|
+
headers=headers,
|
|
142
|
+
params=params,
|
|
143
|
+
timeout=30.0,
|
|
144
|
+
)
|
|
145
|
+
response.raise_for_status()
|
|
146
|
+
contents: list[dict[str, Any]] = response.json()
|
|
147
|
+
|
|
148
|
+
# Filter for directories only (skills are directories)
|
|
149
|
+
skills = []
|
|
150
|
+
for item in contents:
|
|
151
|
+
if item.get("type") == "dir":
|
|
152
|
+
name = item.get("name", "")
|
|
153
|
+
# Skip hidden directories
|
|
154
|
+
if name.startswith("."):
|
|
155
|
+
continue
|
|
156
|
+
skills.append(
|
|
157
|
+
{
|
|
158
|
+
"slug": name,
|
|
159
|
+
"name": name,
|
|
160
|
+
"description": "",
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return skills
|
|
165
|
+
|
|
166
|
+
except httpx.HTTPStatusError as e:
|
|
167
|
+
logger.error(f"GitHub API error: {e.response.status_code} for {url}")
|
|
168
|
+
return []
|
|
169
|
+
except httpx.RequestError as e:
|
|
170
|
+
logger.error(f"GitHub API request failed: {e}")
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
async def _fetch_skill_content(self, slug: str) -> str | None:
|
|
174
|
+
"""Fetch SKILL.md content for a skill.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
slug: The skill's directory name
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
SKILL.md content as string, or None if not found
|
|
181
|
+
"""
|
|
182
|
+
if not self._repo or "/" not in self._repo:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
owner, repo = self._repo.split("/", 1)
|
|
186
|
+
skill_path = f"{self._path.strip('/')}/{slug}" if self._path else slug
|
|
187
|
+
url = f"https://api.github.com/repos/{owner}/{repo}/contents/{skill_path}/SKILL.md"
|
|
188
|
+
|
|
189
|
+
headers: dict[str, str] = {
|
|
190
|
+
"Accept": "application/vnd.github.v3.raw", # Get raw content
|
|
191
|
+
}
|
|
192
|
+
if self.auth_token:
|
|
193
|
+
headers["Authorization"] = f"Bearer {self.auth_token}"
|
|
194
|
+
|
|
195
|
+
params: dict[str, str] = {}
|
|
196
|
+
if self._branch:
|
|
197
|
+
params["ref"] = self._branch
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
async with httpx.AsyncClient() as client:
|
|
201
|
+
response = await client.get(
|
|
202
|
+
url,
|
|
203
|
+
headers=headers,
|
|
204
|
+
params=params,
|
|
205
|
+
timeout=30.0,
|
|
206
|
+
)
|
|
207
|
+
response.raise_for_status()
|
|
208
|
+
return response.text
|
|
209
|
+
except httpx.HTTPStatusError as e:
|
|
210
|
+
logger.debug(f"Could not fetch SKILL.md for {slug}: {e.response.status_code}")
|
|
211
|
+
return None
|
|
212
|
+
except httpx.RequestError as e:
|
|
213
|
+
logger.debug(f"Request failed fetching SKILL.md for {slug}: {e}")
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
async def _synthesize_description(self, slug: str, content: str) -> str:
|
|
217
|
+
"""Use LLM to synthesize a concise description from SKILL.md content.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
slug: The skill's directory name (for context)
|
|
221
|
+
content: SKILL.md content
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Synthesized description string (empty if LLM unavailable)
|
|
225
|
+
"""
|
|
226
|
+
if not self._llm_service:
|
|
227
|
+
return ""
|
|
228
|
+
|
|
229
|
+
# Truncate content to fit in prompt
|
|
230
|
+
snippet = content[:1500]
|
|
231
|
+
|
|
232
|
+
prompt = f"""Generate a concise 1-sentence description (max 100 chars) for this skill.
|
|
233
|
+
|
|
234
|
+
Skill name: {slug}
|
|
235
|
+
|
|
236
|
+
SKILL.md content:
|
|
237
|
+
{snippet}
|
|
238
|
+
|
|
239
|
+
Output ONLY the description text, no quotes, no explanation, no preamble."""
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
provider = self._llm_service.get_default_provider()
|
|
243
|
+
description = await provider.generate_text(prompt)
|
|
244
|
+
# Clean up LLM output
|
|
245
|
+
return description.strip().strip('"').strip("'")[:100]
|
|
246
|
+
except Exception as e:
|
|
247
|
+
logger.warning(f"Failed to synthesize description for {slug}: {e}")
|
|
248
|
+
return ""
|
|
249
|
+
|
|
250
|
+
async def _clone_skill(
|
|
251
|
+
self,
|
|
252
|
+
slug: str,
|
|
253
|
+
target_dir: str | None = None,
|
|
254
|
+
version: str | None = None,
|
|
255
|
+
) -> str:
|
|
256
|
+
"""Clone a specific skill from the repository.
|
|
257
|
+
|
|
258
|
+
Uses clone_skill_repo to clone the entire repository, then
|
|
259
|
+
returns the path to the specific skill directory within it.
|
|
260
|
+
If target_dir is specified, copies the skill directory there.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
slug: The skill's directory name
|
|
264
|
+
target_dir: Optional target directory to copy skill to
|
|
265
|
+
version: Optional version/branch to checkout (overrides default branch)
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
Path to the skill directory
|
|
269
|
+
"""
|
|
270
|
+
# Parse repo into owner/repo
|
|
271
|
+
if "/" not in self._repo:
|
|
272
|
+
raise ValueError(f"Invalid repo format: {self._repo}, expected owner/repo")
|
|
273
|
+
|
|
274
|
+
owner, repo = self._repo.split("/", 1)
|
|
275
|
+
|
|
276
|
+
# Build skill path within repo (accounting for subdirectory)
|
|
277
|
+
skill_subpath = f"{self._path.strip('/')}/{slug}" if self._path else slug
|
|
278
|
+
|
|
279
|
+
# Build GitHubRef with optional version override
|
|
280
|
+
ref = GitHubRef(
|
|
281
|
+
owner=owner,
|
|
282
|
+
repo=repo,
|
|
283
|
+
branch=version or self._branch,
|
|
284
|
+
path=skill_subpath,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Clone/update the repository
|
|
288
|
+
repo_path = clone_skill_repo(ref)
|
|
289
|
+
|
|
290
|
+
# Path to the skill within the repo
|
|
291
|
+
skill_path = repo_path / skill_subpath
|
|
292
|
+
|
|
293
|
+
# If target_dir specified, copy skill there
|
|
294
|
+
if target_dir:
|
|
295
|
+
target = Path(target_dir)
|
|
296
|
+
if skill_path.exists():
|
|
297
|
+
# Copy skill directory contents to target
|
|
298
|
+
if target.exists():
|
|
299
|
+
shutil.rmtree(target)
|
|
300
|
+
shutil.copytree(skill_path, target)
|
|
301
|
+
return target_dir
|
|
302
|
+
|
|
303
|
+
return str(skill_path)
|
|
304
|
+
|
|
305
|
+
async def discover(self) -> dict[str, Any]:
|
|
306
|
+
"""Discover hub capabilities.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
Dictionary with hub info
|
|
310
|
+
"""
|
|
311
|
+
return {
|
|
312
|
+
"hub_name": self.hub_name,
|
|
313
|
+
"provider_type": self.provider_type,
|
|
314
|
+
"repo": self.repo,
|
|
315
|
+
"branch": self.branch,
|
|
316
|
+
"path": self.path,
|
|
317
|
+
"authenticated": self.auth_token is not None,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async def search(
|
|
321
|
+
self,
|
|
322
|
+
query: str,
|
|
323
|
+
limit: int = 20,
|
|
324
|
+
) -> list[HubSkillInfo]:
|
|
325
|
+
"""Search for skills matching a query.
|
|
326
|
+
|
|
327
|
+
This performs client-side filtering of the skill list.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
query: Search query string
|
|
331
|
+
limit: Maximum number of results
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
List of matching skills with basic info
|
|
335
|
+
"""
|
|
336
|
+
# Get all skills and filter locally
|
|
337
|
+
all_skills = await self.list_skills(limit=1000)
|
|
338
|
+
|
|
339
|
+
query_lower = query.lower()
|
|
340
|
+
matching = [
|
|
341
|
+
skill
|
|
342
|
+
for skill in all_skills
|
|
343
|
+
if query_lower in skill.slug.lower()
|
|
344
|
+
or query_lower in skill.display_name.lower()
|
|
345
|
+
or query_lower in skill.description.lower()
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
return matching[:limit]
|
|
349
|
+
|
|
350
|
+
async def list_skills(
|
|
351
|
+
self,
|
|
352
|
+
limit: int = 50,
|
|
353
|
+
offset: int = 0,
|
|
354
|
+
) -> list[HubSkillInfo]:
|
|
355
|
+
"""List available skills from the repository.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
limit: Maximum number of results
|
|
359
|
+
offset: Number of results to skip
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
List of skills with basic info
|
|
363
|
+
"""
|
|
364
|
+
skills_data = await self._fetch_skill_list()
|
|
365
|
+
|
|
366
|
+
skills = [
|
|
367
|
+
HubSkillInfo(
|
|
368
|
+
slug=skill.get("slug", skill.get("name", "")),
|
|
369
|
+
display_name=skill.get("name", skill.get("slug", "")),
|
|
370
|
+
description=skill.get("description", ""),
|
|
371
|
+
hub_name=self.hub_name,
|
|
372
|
+
version=skill.get("version"),
|
|
373
|
+
)
|
|
374
|
+
for skill in skills_data
|
|
375
|
+
]
|
|
376
|
+
|
|
377
|
+
return skills[offset : offset + limit]
|
|
378
|
+
|
|
379
|
+
async def get_skill_details(
|
|
380
|
+
self,
|
|
381
|
+
slug: str,
|
|
382
|
+
) -> HubSkillDetails | None:
|
|
383
|
+
"""Get detailed information about a specific skill.
|
|
384
|
+
|
|
385
|
+
Fetches SKILL.md content and uses LLM to synthesize a concise
|
|
386
|
+
description. Results are cached for CACHE_TTL seconds.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
slug: The skill's unique identifier
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Detailed skill info with synthesized description, or None if not found
|
|
393
|
+
"""
|
|
394
|
+
# Verify skill exists in the list
|
|
395
|
+
all_skills = await self.list_skills(limit=1000)
|
|
396
|
+
skill_exists = any(skill.slug == slug for skill in all_skills)
|
|
397
|
+
if not skill_exists:
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
# Check cache first
|
|
401
|
+
cache_key = f"{self._repo}:{slug}"
|
|
402
|
+
if cache_key in self._description_cache:
|
|
403
|
+
cached_desc, cached_at = self._description_cache[cache_key]
|
|
404
|
+
if time.time() - cached_at < self.CACHE_TTL:
|
|
405
|
+
return HubSkillDetails(
|
|
406
|
+
slug=slug,
|
|
407
|
+
display_name=slug,
|
|
408
|
+
description=cached_desc,
|
|
409
|
+
hub_name=self.hub_name,
|
|
410
|
+
version=self._branch,
|
|
411
|
+
latest_version=self._branch,
|
|
412
|
+
versions=[self._branch],
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Fetch SKILL.md content
|
|
416
|
+
content = await self._fetch_skill_content(slug)
|
|
417
|
+
description = ""
|
|
418
|
+
|
|
419
|
+
if content:
|
|
420
|
+
# Synthesize description using LLM
|
|
421
|
+
description = await self._synthesize_description(slug, content)
|
|
422
|
+
# Cache the result
|
|
423
|
+
self._description_cache[cache_key] = (description, time.time())
|
|
424
|
+
|
|
425
|
+
return HubSkillDetails(
|
|
426
|
+
slug=slug,
|
|
427
|
+
display_name=slug,
|
|
428
|
+
description=description,
|
|
429
|
+
hub_name=self.hub_name,
|
|
430
|
+
version=self._branch,
|
|
431
|
+
latest_version=self._branch,
|
|
432
|
+
versions=[self._branch],
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
async def download_skill(
|
|
436
|
+
self,
|
|
437
|
+
slug: str,
|
|
438
|
+
version: str | None = None,
|
|
439
|
+
target_dir: str | None = None,
|
|
440
|
+
) -> DownloadResult:
|
|
441
|
+
"""Download and extract a skill from the repository.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
slug: The skill's unique identifier
|
|
445
|
+
version: Specific version (branch/tag) to download
|
|
446
|
+
target_dir: Directory to extract to
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
DownloadResult with success status, path, version, or error
|
|
450
|
+
"""
|
|
451
|
+
try:
|
|
452
|
+
path = await self._clone_skill(slug, target_dir, version)
|
|
453
|
+
return DownloadResult(
|
|
454
|
+
success=True,
|
|
455
|
+
slug=slug,
|
|
456
|
+
path=path,
|
|
457
|
+
version=version or self.branch,
|
|
458
|
+
)
|
|
459
|
+
except Exception as e:
|
|
460
|
+
logger.error(f"Failed to download skill {slug}: {e}")
|
|
461
|
+
return DownloadResult(
|
|
462
|
+
success=False,
|
|
463
|
+
slug=slug,
|
|
464
|
+
error=str(e),
|
|
465
|
+
)
|