gobby 0.2.5__py3-none-any.whl → 0.2.6__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/adapters/claude_code.py +13 -4
- gobby/adapters/codex.py +43 -3
- gobby/agents/runner.py +8 -0
- gobby/cli/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +9 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +2 -8
- gobby/cli/installers/shared.py +71 -8
- gobby/cli/skills.py +858 -0
- gobby/cli/tasks/ai.py +0 -440
- gobby/cli/tasks/crud.py +44 -6
- gobby/cli/tasks/main.py +0 -4
- gobby/cli/tui.py +2 -2
- gobby/cli/utils.py +3 -3
- gobby/clones/__init__.py +13 -0
- gobby/clones/git.py +547 -0
- gobby/conductor/__init__.py +16 -0
- gobby/conductor/alerts.py +135 -0
- gobby/conductor/loop.py +164 -0
- gobby/conductor/monitors/__init__.py +11 -0
- gobby/conductor/monitors/agents.py +116 -0
- gobby/conductor/monitors/tasks.py +155 -0
- gobby/conductor/pricing.py +234 -0
- gobby/conductor/token_tracker.py +160 -0
- gobby/config/app.py +63 -1
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +6 -14
- gobby/hooks/event_handlers.py +145 -2
- gobby/hooks/hook_manager.py +48 -2
- gobby/hooks/skill_manager.py +130 -0
- gobby/install/claude/hooks/hook_dispatcher.py +4 -4
- gobby/install/codex/hooks/hook_dispatcher.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
- gobby/llm/claude.py +22 -34
- gobby/llm/claude_executor.py +46 -256
- gobby/llm/codex_executor.py +59 -291
- gobby/llm/executor.py +21 -0
- gobby/llm/gemini.py +134 -110
- gobby/llm/litellm_executor.py +143 -6
- gobby/llm/resolver.py +95 -33
- gobby/mcp_proxy/instructions.py +54 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -5
- gobby/mcp_proxy/server.py +33 -3
- gobby/mcp_proxy/services/tool_proxy.py +81 -1
- gobby/mcp_proxy/stdio.py +2 -1
- gobby/mcp_proxy/tools/__init__.py +0 -2
- gobby/mcp_proxy/tools/agent_messaging.py +317 -0
- gobby/mcp_proxy/tools/clones.py +903 -0
- gobby/mcp_proxy/tools/memory.py +1 -24
- gobby/mcp_proxy/tools/metrics.py +65 -1
- gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
- gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
- gobby/mcp_proxy/tools/session_messages.py +1 -2
- gobby/mcp_proxy/tools/skills/__init__.py +631 -0
- gobby/mcp_proxy/tools/task_orchestration.py +7 -0
- gobby/mcp_proxy/tools/task_readiness.py +14 -0
- gobby/mcp_proxy/tools/task_sync.py +1 -1
- gobby/mcp_proxy/tools/tasks/_context.py +0 -20
- gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
- gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +60 -29
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +5 -0
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/manager.py +11 -2
- gobby/prompts/defaults/handoff/compact.md +63 -0
- gobby/prompts/defaults/handoff/session_end.md +57 -0
- gobby/prompts/defaults/memory/extract.md +61 -0
- gobby/runner.py +37 -16
- gobby/search/__init__.py +48 -6
- gobby/search/backends/__init__.py +159 -0
- gobby/search/backends/embedding.py +225 -0
- gobby/search/embeddings.py +238 -0
- gobby/search/models.py +148 -0
- gobby/search/unified.py +496 -0
- gobby/servers/http.py +23 -8
- gobby/servers/routes/admin.py +280 -0
- gobby/servers/routes/mcp/tools.py +241 -52
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +64 -5
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +258 -0
- gobby/skills/search.py +463 -0
- gobby/skills/sync.py +119 -0
- gobby/skills/updater.py +385 -0
- gobby/skills/validator.py +368 -0
- gobby/storage/clones.py +378 -0
- gobby/storage/database.py +1 -1
- gobby/storage/memories.py +43 -13
- gobby/storage/migrations.py +180 -6
- gobby/storage/sessions.py +73 -0
- gobby/storage/skills.py +749 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +41 -6
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +39 -4
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/validation.py +24 -15
- gobby/tui/api_client.py +4 -7
- gobby/tui/app.py +5 -3
- gobby/tui/screens/orchestrator.py +1 -2
- gobby/tui/screens/tasks.py +2 -4
- gobby/tui/ws_client.py +1 -1
- gobby/utils/daemon_client.py +2 -2
- gobby/workflows/actions.py +84 -2
- gobby/workflows/context_actions.py +43 -0
- gobby/workflows/detection_helpers.py +115 -31
- gobby/workflows/engine.py +13 -2
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/loader.py +19 -6
- gobby/workflows/memory_actions.py +74 -0
- gobby/workflows/summary_actions.py +17 -0
- gobby/workflows/task_enforcement_actions.py +448 -6
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/METADATA +82 -21
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/RECORD +136 -107
- gobby/install/codex/prompts/forget.md +0 -7
- gobby/install/codex/prompts/memories.md +0 -7
- gobby/install/codex/prompts/recall.md +0 -7
- gobby/install/codex/prompts/remember.md +0 -13
- gobby/llm/gemini_executor.py +0 -339
- gobby/mcp_proxy/tools/task_expansion.py +0 -591
- gobby/tasks/context.py +0 -747
- gobby/tasks/criteria.py +0 -342
- gobby/tasks/expansion.py +0 -626
- gobby/tasks/prompts/expand.py +0 -327
- gobby/tasks/research.py +0 -421
- gobby/tasks/tdd.py +0 -352
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/WHEEL +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.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
|