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,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
+ )