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,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
- # Navigate from this file to install/shared/skills/
27
- # This file: src/gobby/skills/sync.py
28
- # Target: src/gobby/install/shared/skills/
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}")