gobby 0.2.5__py3-none-any.whl → 0.2.7__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 (244) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +13 -4
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/agents/definitions.py +11 -1
  10. gobby/agents/isolation.py +395 -0
  11. gobby/agents/runner.py +8 -0
  12. gobby/agents/sandbox.py +261 -0
  13. gobby/agents/spawn.py +42 -287
  14. gobby/agents/spawn_executor.py +385 -0
  15. gobby/agents/spawners/__init__.py +24 -0
  16. gobby/agents/spawners/command_builder.py +189 -0
  17. gobby/agents/spawners/embedded.py +21 -2
  18. gobby/agents/spawners/headless.py +21 -2
  19. gobby/agents/spawners/prompt_manager.py +125 -0
  20. gobby/cli/__init__.py +6 -0
  21. gobby/cli/clones.py +419 -0
  22. gobby/cli/conductor.py +266 -0
  23. gobby/cli/install.py +4 -4
  24. gobby/cli/installers/antigravity.py +3 -9
  25. gobby/cli/installers/claude.py +15 -9
  26. gobby/cli/installers/codex.py +2 -8
  27. gobby/cli/installers/gemini.py +8 -8
  28. gobby/cli/installers/shared.py +175 -13
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/skills.py +858 -0
  31. gobby/cli/tasks/ai.py +0 -440
  32. gobby/cli/tasks/crud.py +44 -6
  33. gobby/cli/tasks/main.py +0 -4
  34. gobby/cli/tui.py +2 -2
  35. gobby/cli/utils.py +12 -5
  36. gobby/clones/__init__.py +13 -0
  37. gobby/clones/git.py +547 -0
  38. gobby/conductor/__init__.py +16 -0
  39. gobby/conductor/alerts.py +135 -0
  40. gobby/conductor/loop.py +164 -0
  41. gobby/conductor/monitors/__init__.py +11 -0
  42. gobby/conductor/monitors/agents.py +116 -0
  43. gobby/conductor/monitors/tasks.py +155 -0
  44. gobby/conductor/pricing.py +234 -0
  45. gobby/conductor/token_tracker.py +160 -0
  46. gobby/config/__init__.py +12 -97
  47. gobby/config/app.py +69 -91
  48. gobby/config/extensions.py +2 -2
  49. gobby/config/features.py +7 -130
  50. gobby/config/search.py +110 -0
  51. gobby/config/servers.py +1 -1
  52. gobby/config/skills.py +43 -0
  53. gobby/config/tasks.py +9 -41
  54. gobby/hooks/__init__.py +0 -13
  55. gobby/hooks/event_handlers.py +188 -2
  56. gobby/hooks/hook_manager.py +50 -4
  57. gobby/hooks/plugins.py +1 -1
  58. gobby/hooks/skill_manager.py +130 -0
  59. gobby/hooks/webhooks.py +1 -1
  60. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  61. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  62. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  63. gobby/llm/claude.py +22 -34
  64. gobby/llm/claude_executor.py +46 -256
  65. gobby/llm/codex_executor.py +59 -291
  66. gobby/llm/executor.py +21 -0
  67. gobby/llm/gemini.py +134 -110
  68. gobby/llm/litellm_executor.py +143 -6
  69. gobby/llm/resolver.py +98 -35
  70. gobby/mcp_proxy/importer.py +62 -4
  71. gobby/mcp_proxy/instructions.py +56 -0
  72. gobby/mcp_proxy/models.py +15 -0
  73. gobby/mcp_proxy/registries.py +68 -8
  74. gobby/mcp_proxy/server.py +33 -3
  75. gobby/mcp_proxy/services/recommendation.py +43 -11
  76. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  77. gobby/mcp_proxy/stdio.py +2 -1
  78. gobby/mcp_proxy/tools/__init__.py +0 -2
  79. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  80. gobby/mcp_proxy/tools/agents.py +31 -731
  81. gobby/mcp_proxy/tools/clones.py +518 -0
  82. gobby/mcp_proxy/tools/memory.py +3 -26
  83. gobby/mcp_proxy/tools/metrics.py +65 -1
  84. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  85. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  86. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  87. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  88. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  89. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  90. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  91. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  92. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  93. gobby/mcp_proxy/tools/skills/__init__.py +616 -0
  94. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  95. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  96. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  97. gobby/mcp_proxy/tools/task_sync.py +1 -1
  98. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  99. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  100. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  101. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  102. gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
  103. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  104. gobby/mcp_proxy/tools/workflows.py +1 -1
  105. gobby/mcp_proxy/tools/worktrees.py +0 -338
  106. gobby/memory/backends/__init__.py +6 -1
  107. gobby/memory/backends/mem0.py +6 -1
  108. gobby/memory/extractor.py +477 -0
  109. gobby/memory/ingestion/__init__.py +5 -0
  110. gobby/memory/ingestion/multimodal.py +221 -0
  111. gobby/memory/manager.py +73 -285
  112. gobby/memory/search/__init__.py +10 -0
  113. gobby/memory/search/coordinator.py +248 -0
  114. gobby/memory/services/__init__.py +5 -0
  115. gobby/memory/services/crossref.py +142 -0
  116. gobby/prompts/loader.py +5 -2
  117. gobby/runner.py +37 -16
  118. gobby/search/__init__.py +48 -6
  119. gobby/search/backends/__init__.py +159 -0
  120. gobby/search/backends/embedding.py +225 -0
  121. gobby/search/embeddings.py +238 -0
  122. gobby/search/models.py +148 -0
  123. gobby/search/unified.py +496 -0
  124. gobby/servers/http.py +24 -12
  125. gobby/servers/routes/admin.py +294 -0
  126. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  127. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  128. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  129. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  130. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  131. gobby/servers/routes/mcp/hooks.py +1 -1
  132. gobby/servers/routes/mcp/tools.py +48 -1317
  133. gobby/servers/websocket.py +2 -2
  134. gobby/sessions/analyzer.py +2 -0
  135. gobby/sessions/lifecycle.py +1 -1
  136. gobby/sessions/processor.py +10 -0
  137. gobby/sessions/transcripts/base.py +2 -0
  138. gobby/sessions/transcripts/claude.py +79 -10
  139. gobby/skills/__init__.py +91 -0
  140. gobby/skills/loader.py +685 -0
  141. gobby/skills/manager.py +384 -0
  142. gobby/skills/parser.py +286 -0
  143. gobby/skills/search.py +463 -0
  144. gobby/skills/sync.py +119 -0
  145. gobby/skills/updater.py +385 -0
  146. gobby/skills/validator.py +368 -0
  147. gobby/storage/clones.py +378 -0
  148. gobby/storage/database.py +1 -1
  149. gobby/storage/memories.py +43 -13
  150. gobby/storage/migrations.py +162 -201
  151. gobby/storage/sessions.py +116 -7
  152. gobby/storage/skills.py +782 -0
  153. gobby/storage/tasks/_crud.py +4 -4
  154. gobby/storage/tasks/_lifecycle.py +57 -7
  155. gobby/storage/tasks/_manager.py +14 -5
  156. gobby/storage/tasks/_models.py +8 -3
  157. gobby/sync/memories.py +40 -5
  158. gobby/sync/tasks.py +83 -6
  159. gobby/tasks/__init__.py +1 -2
  160. gobby/tasks/external_validator.py +1 -1
  161. gobby/tasks/validation.py +46 -35
  162. gobby/tools/summarizer.py +91 -10
  163. gobby/tui/api_client.py +4 -7
  164. gobby/tui/app.py +5 -3
  165. gobby/tui/screens/orchestrator.py +1 -2
  166. gobby/tui/screens/tasks.py +2 -4
  167. gobby/tui/ws_client.py +1 -1
  168. gobby/utils/daemon_client.py +2 -2
  169. gobby/utils/project_context.py +2 -3
  170. gobby/utils/status.py +13 -0
  171. gobby/workflows/actions.py +221 -1135
  172. gobby/workflows/artifact_actions.py +31 -0
  173. gobby/workflows/autonomous_actions.py +11 -0
  174. gobby/workflows/context_actions.py +93 -1
  175. gobby/workflows/detection_helpers.py +115 -31
  176. gobby/workflows/enforcement/__init__.py +47 -0
  177. gobby/workflows/enforcement/blocking.py +269 -0
  178. gobby/workflows/enforcement/commit_policy.py +283 -0
  179. gobby/workflows/enforcement/handlers.py +269 -0
  180. gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
  181. gobby/workflows/engine.py +13 -2
  182. gobby/workflows/git_utils.py +106 -0
  183. gobby/workflows/lifecycle_evaluator.py +29 -1
  184. gobby/workflows/llm_actions.py +30 -0
  185. gobby/workflows/loader.py +19 -6
  186. gobby/workflows/mcp_actions.py +20 -1
  187. gobby/workflows/memory_actions.py +154 -0
  188. gobby/workflows/safe_evaluator.py +183 -0
  189. gobby/workflows/session_actions.py +44 -0
  190. gobby/workflows/state_actions.py +60 -1
  191. gobby/workflows/stop_signal_actions.py +55 -0
  192. gobby/workflows/summary_actions.py +111 -1
  193. gobby/workflows/task_sync_actions.py +347 -0
  194. gobby/workflows/todo_actions.py +34 -1
  195. gobby/workflows/webhook_actions.py +185 -0
  196. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
  197. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
  198. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  199. gobby/adapters/codex.py +0 -1292
  200. gobby/install/claude/commands/gobby/bug.md +0 -51
  201. gobby/install/claude/commands/gobby/chore.md +0 -51
  202. gobby/install/claude/commands/gobby/epic.md +0 -52
  203. gobby/install/claude/commands/gobby/eval.md +0 -235
  204. gobby/install/claude/commands/gobby/feat.md +0 -49
  205. gobby/install/claude/commands/gobby/nit.md +0 -52
  206. gobby/install/claude/commands/gobby/ref.md +0 -52
  207. gobby/install/codex/prompts/forget.md +0 -7
  208. gobby/install/codex/prompts/memories.md +0 -7
  209. gobby/install/codex/prompts/recall.md +0 -7
  210. gobby/install/codex/prompts/remember.md +0 -13
  211. gobby/llm/gemini_executor.py +0 -339
  212. gobby/mcp_proxy/tools/session_messages.py +0 -1056
  213. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  214. gobby/prompts/defaults/expansion/system.md +0 -119
  215. gobby/prompts/defaults/expansion/user.md +0 -48
  216. gobby/prompts/defaults/external_validation/agent.md +0 -72
  217. gobby/prompts/defaults/external_validation/external.md +0 -63
  218. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  219. gobby/prompts/defaults/external_validation/system.md +0 -6
  220. gobby/prompts/defaults/features/import_mcp.md +0 -22
  221. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  222. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  223. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  224. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  225. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  226. gobby/prompts/defaults/features/server_description.md +0 -20
  227. gobby/prompts/defaults/features/server_description_system.md +0 -6
  228. gobby/prompts/defaults/features/task_description.md +0 -31
  229. gobby/prompts/defaults/features/task_description_system.md +0 -6
  230. gobby/prompts/defaults/features/tool_summary.md +0 -17
  231. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  232. gobby/prompts/defaults/research/step.md +0 -58
  233. gobby/prompts/defaults/validation/criteria.md +0 -47
  234. gobby/prompts/defaults/validation/validate.md +0 -38
  235. gobby/storage/migrations_legacy.py +0 -1359
  236. gobby/tasks/context.py +0 -747
  237. gobby/tasks/criteria.py +0 -342
  238. gobby/tasks/expansion.py +0 -626
  239. gobby/tasks/prompts/expand.py +0 -327
  240. gobby/tasks/research.py +0 -421
  241. gobby/tasks/tdd.py +0 -352
  242. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  243. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  244. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
gobby/skills/loader.py ADDED
@@ -0,0 +1,685 @@
1
+ """SkillLoader - Load skills from filesystem, GitHub, and ZIP archives.
2
+
3
+ This module provides the SkillLoader class for loading skills from:
4
+ - Single SKILL.md files
5
+ - Directories containing SKILL.md files
6
+ - Recursively from a root directory
7
+ - GitHub repositories
8
+ - ZIP archives
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import re
15
+ import shutil
16
+ import subprocess # nosec B404 - required for git clone/pull operations with validated input
17
+ import tempfile
18
+ import zipfile
19
+ from collections.abc import Generator
20
+ from contextlib import contextmanager
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ from gobby.skills.parser import ParsedSkill, SkillParseError, parse_skill_file
25
+ from gobby.skills.validator import SkillValidator
26
+ from gobby.storage.skills import SkillSourceType
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Default cache directory for cloned GitHub repos
31
+ DEFAULT_CACHE_DIR = Path.home() / ".gobby" / "skill-cache"
32
+
33
+
34
+ @dataclass
35
+ class GitHubRef:
36
+ """Parsed GitHub repository reference.
37
+
38
+ Attributes:
39
+ owner: Repository owner (user or org)
40
+ repo: Repository name
41
+ branch: Branch or tag name (None for default branch)
42
+ path: Path within the repository to skill directory
43
+ """
44
+
45
+ owner: str
46
+ repo: str
47
+ branch: str | None = None
48
+ path: str | None = None
49
+
50
+ @property
51
+ def clone_url(self) -> str:
52
+ """Get the HTTPS clone URL."""
53
+ return f"https://github.com/{self.owner}/{self.repo}.git"
54
+
55
+ @property
56
+ def cache_key(self) -> str:
57
+ """Get a unique key for caching this repo/branch combo."""
58
+ branch_part = self.branch or "HEAD"
59
+ return f"{self.owner}/{self.repo}/{branch_part}"
60
+
61
+
62
+ def parse_github_url(url: str) -> GitHubRef:
63
+ """Parse a GitHub URL into its components.
64
+
65
+ Supports formats:
66
+ - owner/repo
67
+ - owner/repo#branch
68
+ - github:owner/repo
69
+ - github:owner/repo#branch
70
+ - https://github.com/owner/repo
71
+ - https://github.com/owner/repo.git
72
+ - https://github.com/owner/repo/tree/branch
73
+ - https://github.com/owner/repo/tree/branch/path/to/skill
74
+
75
+ Args:
76
+ url: GitHub URL in any supported format
77
+
78
+ Returns:
79
+ GitHubRef with parsed components
80
+
81
+ Raises:
82
+ ValueError: If URL cannot be parsed
83
+ """
84
+ if not url or not url.strip():
85
+ raise ValueError("Invalid GitHub URL: empty string")
86
+
87
+ url = url.strip()
88
+
89
+ # Format: github:owner/repo#branch
90
+ if url.startswith("github:"):
91
+ url = url[7:] # Remove "github:" prefix
92
+ return _parse_owner_repo_format(url)
93
+
94
+ # Format: https://github.com/owner/repo...
95
+ if url.startswith("https://github.com/") or url.startswith("http://github.com/"):
96
+ return _parse_full_github_url(url)
97
+
98
+ # Format: owner/repo#branch
99
+ if "/" in url and not url.startswith("http"):
100
+ return _parse_owner_repo_format(url)
101
+
102
+ raise ValueError(f"Invalid GitHub URL: {url}")
103
+
104
+
105
+ def _parse_owner_repo_format(url: str) -> GitHubRef:
106
+ """Parse owner/repo#branch format."""
107
+ branch = None
108
+
109
+ # Check for branch suffix
110
+ if "#" in url:
111
+ url, branch = url.rsplit("#", 1)
112
+
113
+ # Split owner/repo
114
+ parts = url.split("/")
115
+ if len(parts) < 2:
116
+ raise ValueError(f"Invalid GitHub URL: {url}")
117
+
118
+ owner = parts[0]
119
+ repo = parts[1].removesuffix(".git")
120
+
121
+ return GitHubRef(owner=owner, repo=repo, branch=branch)
122
+
123
+
124
+ def _parse_full_github_url(url: str) -> GitHubRef:
125
+ """Parse full https://github.com/... URL."""
126
+ # Remove protocol
127
+ url = re.sub(r"^https?://github\.com/", "", url)
128
+
129
+ # Remove trailing slash
130
+ url = url.rstrip("/")
131
+
132
+ # Remove .git suffix
133
+ url = url.removesuffix(".git")
134
+
135
+ parts = url.split("/")
136
+ if len(parts) < 2:
137
+ raise ValueError(f"Invalid GitHub URL: {url}")
138
+
139
+ owner = parts[0]
140
+ repo = parts[1]
141
+ branch = None
142
+ path = None
143
+
144
+ # Check for /tree/branch/path format
145
+ if len(parts) > 2 and parts[2] == "tree":
146
+ if len(parts) > 3:
147
+ branch = parts[3]
148
+ if len(parts) > 4:
149
+ path = "/".join(parts[4:])
150
+
151
+ return GitHubRef(owner=owner, repo=repo, branch=branch, path=path)
152
+
153
+
154
+ def _validate_github_ref(ref: GitHubRef) -> None:
155
+ """Validate GitHub reference components for safety.
156
+
157
+ Args:
158
+ ref: GitHubRef to validate
159
+
160
+ Raises:
161
+ SkillLoadError: If validation fails
162
+ """
163
+ # Safe characters for owner/repo: alphanumeric, hyphen, underscore, dot
164
+ safe_name_pattern = re.compile(r"^[A-Za-z0-9_.-]+$")
165
+ # Safe branch name: alphanumeric, hyphen, underscore, dot, forward slash
166
+ # No leading hyphen (could be interpreted as command flag)
167
+ safe_branch_pattern = re.compile(r"^[A-Za-z0-9_./][A-Za-z0-9_./-]*$")
168
+
169
+ # Validate owner
170
+ if not ref.owner or len(ref.owner) > 100:
171
+ raise SkillLoadError(f"Invalid GitHub owner: {ref.owner}")
172
+ if not safe_name_pattern.match(ref.owner):
173
+ raise SkillLoadError(f"Invalid characters in GitHub owner: {ref.owner}")
174
+
175
+ # Validate repo
176
+ if not ref.repo or len(ref.repo) > 100:
177
+ raise SkillLoadError(f"Invalid GitHub repo: {ref.repo}")
178
+ if not safe_name_pattern.match(ref.repo):
179
+ raise SkillLoadError(f"Invalid characters in GitHub repo: {ref.repo}")
180
+
181
+ # Validate branch if present
182
+ if ref.branch:
183
+ if len(ref.branch) > 200:
184
+ raise SkillLoadError(f"Branch name too long: {ref.branch}")
185
+ if not safe_branch_pattern.match(ref.branch):
186
+ raise SkillLoadError(f"Invalid characters in branch name: {ref.branch}")
187
+ # Reject shell metacharacters and path traversal
188
+ if ".." in ref.branch or any(
189
+ c in ref.branch for c in ("$", "`", ";", "&", "|", "<", ">", "\\", "\n", "\r")
190
+ ):
191
+ raise SkillLoadError(f"Invalid branch name: {ref.branch}")
192
+
193
+
194
+ def clone_skill_repo(
195
+ ref: GitHubRef,
196
+ cache_dir: Path | None = None,
197
+ ) -> Path:
198
+ """Clone or update a GitHub repository.
199
+
200
+ Args:
201
+ ref: Parsed GitHub reference
202
+ cache_dir: Directory to cache cloned repos (default: ~/.gobby/skill-cache)
203
+
204
+ Returns:
205
+ Path to the cloned repository
206
+
207
+ Raises:
208
+ SkillLoadError: If clone/pull fails or validation fails
209
+ """
210
+ # Validate input before any filesystem or subprocess operations
211
+ _validate_github_ref(ref)
212
+
213
+ cache_dir = cache_dir or DEFAULT_CACHE_DIR
214
+ cache_dir.mkdir(parents=True, exist_ok=True)
215
+
216
+ repo_path = cache_dir / ref.owner / ref.repo
217
+ is_existing = repo_path.exists() and (repo_path / ".git").exists()
218
+
219
+ if is_existing:
220
+ # Update existing repo
221
+ if ref.branch:
222
+ # Checkout the specific branch first (git command with validated ref)
223
+ checkout_cmd = ["git", "-C", str(repo_path), "checkout", ref.branch]
224
+ result = subprocess.run( # nosec B603 - hardcoded git command, input validated
225
+ checkout_cmd, capture_output=True, text=True, timeout=60
226
+ )
227
+ if result.returncode != 0:
228
+ raise SkillLoadError(
229
+ f"Failed to checkout branch {ref.branch}: {result.stderr}",
230
+ ref.clone_url,
231
+ )
232
+ # Pull latest changes (hardcoded git command)
233
+ pull_cmd = ["git", "-C", str(repo_path), "pull", "--ff-only"]
234
+ result = subprocess.run( # nosec B603 - hardcoded git command
235
+ pull_cmd, capture_output=True, text=True, timeout=120
236
+ )
237
+ if result.returncode != 0:
238
+ raise SkillLoadError(
239
+ f"Failed to pull repository updates: {result.stderr}",
240
+ ref.clone_url,
241
+ )
242
+ return repo_path
243
+ else:
244
+ # Clone new repo
245
+ repo_path.parent.mkdir(parents=True, exist_ok=True)
246
+ cmd = ["git", "clone", "--depth", "1"]
247
+ if ref.branch:
248
+ cmd.extend(["--branch", ref.branch])
249
+ cmd.extend([ref.clone_url, str(repo_path)])
250
+
251
+ result = subprocess.run( # nosec B603 - hardcoded git clone, input validated
252
+ cmd, capture_output=True, text=True, timeout=120
253
+ )
254
+
255
+ if result.returncode != 0:
256
+ raise SkillLoadError(
257
+ f"Failed to clone repository: {result.stderr}",
258
+ ref.clone_url,
259
+ )
260
+
261
+ return repo_path
262
+
263
+
264
+ @contextmanager
265
+ def extract_zip(zip_path: str | Path) -> Generator[Path]:
266
+ """Extract a ZIP archive to a temporary directory.
267
+
268
+ This context manager extracts the contents of a ZIP file to a temporary
269
+ directory, yields the path to the extracted contents, and cleans up
270
+ the temporary directory on exit (even if an exception occurs).
271
+
272
+ Args:
273
+ zip_path: Path to the ZIP file
274
+
275
+ Yields:
276
+ Path to the temporary directory containing extracted contents
277
+
278
+ Raises:
279
+ SkillLoadError: If ZIP file not found or invalid
280
+
281
+ Example:
282
+ ```python
283
+ with extract_zip("skills.zip") as temp_path:
284
+ skill = loader.load_skill(temp_path / "my-skill")
285
+ # temp_path is automatically deleted here
286
+ ```
287
+ """
288
+ zip_path = Path(zip_path)
289
+
290
+ if not zip_path.exists():
291
+ raise SkillLoadError("ZIP file not found", zip_path)
292
+
293
+ if not zipfile.is_zipfile(zip_path):
294
+ raise SkillLoadError("Invalid ZIP file", zip_path)
295
+
296
+ temp_dir = tempfile.mkdtemp(prefix="gobby-skill-")
297
+ temp_path = Path(temp_dir)
298
+
299
+ try:
300
+ with zipfile.ZipFile(zip_path, "r") as zf:
301
+ # Safe extraction to prevent zip-slip attacks
302
+ for member in zf.infolist():
303
+ # Build and normalize the target path
304
+ target_path = (temp_path / member.filename).resolve()
305
+
306
+ # Verify the target is inside temp_path (prevent zip-slip)
307
+ try:
308
+ target_path.relative_to(temp_path.resolve())
309
+ except ValueError:
310
+ raise SkillLoadError(
311
+ f"Zip entry would extract outside target: {member.filename}",
312
+ zip_path,
313
+ ) from None
314
+
315
+ if member.is_dir():
316
+ target_path.mkdir(parents=True, exist_ok=True)
317
+ else:
318
+ # Ensure parent directory exists
319
+ target_path.parent.mkdir(parents=True, exist_ok=True)
320
+ # Extract file content
321
+ with zf.open(member) as source, open(target_path, "wb") as dest:
322
+ shutil.copyfileobj(source, dest)
323
+
324
+ yield temp_path
325
+ finally:
326
+ # Clean up temp directory
327
+ if temp_path.exists():
328
+ shutil.rmtree(temp_path, ignore_errors=True)
329
+
330
+
331
+ class SkillLoadError(Exception):
332
+ """Error loading a skill from the filesystem."""
333
+
334
+ def __init__(self, message: str, path: str | Path | None = None):
335
+ self.path = str(path) if path else None
336
+ super().__init__(f"{message}" + (f": {path}" if path else ""))
337
+
338
+
339
+ class SkillLoader:
340
+ """Load skills from the filesystem.
341
+
342
+ This class handles loading skills from:
343
+ - Single SKILL.md files
344
+ - Directories containing SKILL.md
345
+ - Recursively from a skills root directory
346
+
347
+ Example usage:
348
+ ```python
349
+ from gobby.skills.loader import SkillLoader
350
+
351
+ loader = SkillLoader()
352
+
353
+ # Load a single skill
354
+ skill = loader.load_skill("path/to/SKILL.md")
355
+
356
+ # Load from a skill directory
357
+ skill = loader.load_skill("path/to/skill-name/")
358
+
359
+ # Load all skills from a directory
360
+ skills = loader.load_directory("path/to/skills/")
361
+ ```
362
+ """
363
+
364
+ def __init__(
365
+ self,
366
+ default_source_type: SkillSourceType = "local",
367
+ ):
368
+ """Initialize the loader.
369
+
370
+ Args:
371
+ default_source_type: Default source type for loaded skills
372
+ """
373
+ self._default_source_type = default_source_type
374
+ self._validator = SkillValidator()
375
+
376
+ def load_skill(
377
+ self,
378
+ path: str | Path,
379
+ validate: bool = True,
380
+ check_dir_name: bool = True,
381
+ ) -> ParsedSkill:
382
+ """Load a skill from a file or directory.
383
+
384
+ Args:
385
+ path: Path to SKILL.md file or directory containing SKILL.md
386
+ validate: Whether to validate the skill
387
+ check_dir_name: Whether to check that directory name matches skill name
388
+
389
+ Returns:
390
+ ParsedSkill loaded from the path
391
+
392
+ Raises:
393
+ SkillLoadError: If skill cannot be loaded
394
+ """
395
+ path = Path(path)
396
+
397
+ if not path.exists():
398
+ raise SkillLoadError("Path not found", path)
399
+
400
+ # Determine the actual SKILL.md path
401
+ if path.is_file():
402
+ skill_file = path
403
+ is_directory_load = False
404
+ else:
405
+ skill_file = path / "SKILL.md"
406
+ if not skill_file.exists():
407
+ raise SkillLoadError("SKILL.md not found in directory", path)
408
+ is_directory_load = True
409
+
410
+ # Parse the skill file
411
+ try:
412
+ skill = parse_skill_file(skill_file)
413
+ except SkillParseError as e:
414
+ raise SkillLoadError(f"Failed to parse skill: {e}", skill_file) from e
415
+
416
+ # Check directory name matches skill name (when loading from directory)
417
+ if is_directory_load and check_dir_name:
418
+ dir_name = path.name
419
+ if skill.name != dir_name:
420
+ raise SkillLoadError(
421
+ f"Directory name mismatch: directory '{dir_name}' "
422
+ f"does not match skill name '{skill.name}'",
423
+ path,
424
+ )
425
+
426
+ # Validate the skill
427
+ if validate:
428
+ result = self._validator.validate(skill)
429
+ if not result.valid:
430
+ errors = "; ".join(result.errors)
431
+ raise SkillLoadError(
432
+ f"Skill validation failed: {errors}",
433
+ skill_file,
434
+ )
435
+
436
+ # Detect directory structure (scripts/, references/, assets/)
437
+ if is_directory_load:
438
+ skill.scripts = self._scan_subdirectory(path, "scripts")
439
+ skill.references = self._scan_subdirectory(path, "references")
440
+ skill.assets = self._scan_subdirectory(path, "assets")
441
+
442
+ # Set source tracking
443
+ skill.source_path = str(skill_file)
444
+ skill.source_type = self._default_source_type
445
+
446
+ return skill
447
+
448
+ def _scan_subdirectory(self, skill_dir: Path, subdir_name: str) -> list[str] | None:
449
+ """Scan a subdirectory for files and return relative paths.
450
+
451
+ Args:
452
+ skill_dir: Path to the skill directory
453
+ subdir_name: Name of the subdirectory (scripts, references, assets)
454
+
455
+ Returns:
456
+ List of relative file paths, or None if directory doesn't exist or is empty
457
+ """
458
+ subdir = skill_dir / subdir_name
459
+ if not subdir.exists() or not subdir.is_dir():
460
+ return None
461
+
462
+ # Resolve skill_dir once for security checks
463
+ skill_dir_resolved = skill_dir.resolve()
464
+
465
+ files: list[str] = []
466
+ for file_path in subdir.rglob("*"):
467
+ # Skip symlinks to prevent traversal attacks
468
+ if file_path.is_symlink():
469
+ continue
470
+
471
+ if file_path.is_file():
472
+ # Verify resolved path is within skill directory
473
+ try:
474
+ resolved = file_path.resolve()
475
+ # Check that resolved path is under skill_dir
476
+ resolved.relative_to(skill_dir_resolved)
477
+ except (OSError, ValueError):
478
+ # Skip files that can't be resolved or are outside skill_dir
479
+ continue
480
+
481
+ # Get path relative to skill directory
482
+ rel_path = file_path.relative_to(skill_dir)
483
+ files.append(str(rel_path))
484
+
485
+ return sorted(files) if files else None
486
+
487
+ def load_directory(
488
+ self,
489
+ path: str | Path,
490
+ validate: bool = True,
491
+ ) -> list[ParsedSkill]:
492
+ """Load all skills from a directory.
493
+
494
+ Scans for subdirectories containing SKILL.md files and loads them.
495
+ Non-skill directories and files are ignored.
496
+
497
+ Args:
498
+ path: Path to directory containing skill subdirectories
499
+ validate: Whether to validate loaded skills
500
+
501
+ Returns:
502
+ List of ParsedSkill objects
503
+
504
+ Raises:
505
+ SkillLoadError: If directory not found
506
+ """
507
+ path = Path(path)
508
+
509
+ if not path.exists():
510
+ raise SkillLoadError("Directory not found", path)
511
+
512
+ if not path.is_dir():
513
+ raise SkillLoadError("Path is not a directory", path)
514
+
515
+ skills: list[ParsedSkill] = []
516
+
517
+ for item in path.iterdir():
518
+ if not item.is_dir():
519
+ continue
520
+
521
+ skill_file = item / "SKILL.md"
522
+ if not skill_file.exists():
523
+ continue
524
+
525
+ try:
526
+ skill = self.load_skill(item, validate=validate)
527
+ skills.append(skill)
528
+ except SkillLoadError as e:
529
+ logger.warning(f"Skipping invalid skill: {e}")
530
+ continue
531
+
532
+ return skills
533
+
534
+ def scan_skills(
535
+ self,
536
+ path: str | Path,
537
+ ) -> list[Path]:
538
+ """Scan a directory for skill directories.
539
+
540
+ Finds all subdirectories containing SKILL.md without loading them.
541
+
542
+ Args:
543
+ path: Path to scan
544
+
545
+ Returns:
546
+ List of paths to skill directories
547
+ """
548
+ path = Path(path)
549
+
550
+ if not path.exists() or not path.is_dir():
551
+ return []
552
+
553
+ skill_dirs: list[Path] = []
554
+
555
+ for item in path.iterdir():
556
+ if item.is_dir() and (item / "SKILL.md").exists():
557
+ skill_dirs.append(item)
558
+
559
+ return skill_dirs
560
+
561
+ def load_from_github(
562
+ self,
563
+ url: str,
564
+ validate: bool = True,
565
+ load_all: bool = False,
566
+ cache_dir: Path | None = None,
567
+ ) -> ParsedSkill | list[ParsedSkill]:
568
+ """Load skill(s) from a GitHub repository.
569
+
570
+ Supports formats:
571
+ - owner/repo - Single skill repo
572
+ - owner/repo#branch - With specific branch
573
+ - github:owner/repo - With github: prefix
574
+ - https://github.com/owner/repo - Full URL
575
+ - https://github.com/owner/repo/tree/branch/path - With path to skill
576
+
577
+ Args:
578
+ url: GitHub URL in any supported format
579
+ validate: Whether to validate loaded skills
580
+ load_all: If True, load all skills from repo (returns list)
581
+ cache_dir: Optional cache directory override
582
+
583
+ Returns:
584
+ ParsedSkill if load_all=False, list[ParsedSkill] if load_all=True
585
+
586
+ Raises:
587
+ SkillLoadError: If skill cannot be loaded
588
+ """
589
+ ref = parse_github_url(url)
590
+ repo_path = clone_skill_repo(ref, cache_dir=cache_dir)
591
+
592
+ # Determine the skill path within the repo
593
+ if ref.path:
594
+ skill_path = repo_path / ref.path
595
+ else:
596
+ skill_path = repo_path
597
+
598
+ if load_all:
599
+ # Load all skills from the repo
600
+ skills = self.load_directory(skill_path, validate=validate)
601
+ for skill in skills:
602
+ skill.source_type = "github"
603
+ skill.source_path = f"github:{ref.owner}/{ref.repo}"
604
+ skill.source_ref = ref.branch
605
+ return skills
606
+ else:
607
+ # Load single skill
608
+ skill = self.load_skill(
609
+ skill_path,
610
+ validate=validate,
611
+ check_dir_name=False, # Don't check dir name for GitHub imports
612
+ )
613
+ skill.source_type = "github"
614
+ skill.source_path = f"github:{ref.owner}/{ref.repo}"
615
+ skill.source_ref = ref.branch
616
+ return skill
617
+
618
+ def load_from_zip(
619
+ self,
620
+ zip_path: str | Path,
621
+ validate: bool = True,
622
+ load_all: bool = False,
623
+ internal_path: str | None = None,
624
+ ) -> ParsedSkill | list[ParsedSkill]:
625
+ """Load skill(s) from a ZIP archive.
626
+
627
+ The ZIP can contain:
628
+ - A single skill directory with SKILL.md
629
+ - A SKILL.md at the root
630
+ - Multiple skill directories (use load_all=True)
631
+
632
+ Args:
633
+ zip_path: Path to the ZIP file
634
+ validate: Whether to validate loaded skills
635
+ load_all: If True, load all skills from ZIP (returns list)
636
+ internal_path: Path within the ZIP to the skill directory
637
+
638
+ Returns:
639
+ ParsedSkill if load_all=False, list[ParsedSkill] if load_all=True
640
+
641
+ Raises:
642
+ SkillLoadError: If skill cannot be loaded
643
+ """
644
+ zip_path = Path(zip_path)
645
+
646
+ if not zip_path.exists():
647
+ raise SkillLoadError("ZIP file not found", zip_path)
648
+
649
+ with extract_zip(zip_path) as temp_path:
650
+ # Determine the skill path within the extracted contents
651
+ if internal_path:
652
+ skill_path = temp_path / internal_path
653
+ else:
654
+ # Check for SKILL.md at root
655
+ if (temp_path / "SKILL.md").exists():
656
+ skill_path = temp_path
657
+ else:
658
+ # Look for a skill directory
659
+ skill_dirs = self.scan_skills(temp_path)
660
+ if skill_dirs:
661
+ if load_all:
662
+ skill_path = temp_path
663
+ else:
664
+ skill_path = skill_dirs[0]
665
+ else:
666
+ # Try the temp path itself
667
+ skill_path = temp_path
668
+
669
+ if load_all:
670
+ # Load all skills from the ZIP
671
+ skills = self.load_directory(skill_path, validate=validate)
672
+ for skill in skills:
673
+ skill.source_type = "zip"
674
+ skill.source_path = f"zip:{zip_path}"
675
+ return skills
676
+ else:
677
+ # Load single skill
678
+ skill = self.load_skill(
679
+ skill_path,
680
+ validate=validate,
681
+ check_dir_name=False, # Don't check dir name for ZIP imports
682
+ )
683
+ skill.source_type = "zip"
684
+ skill.source_path = f"zip:{zip_path}"
685
+ return skill