llms-py 3.0.20__py3-none-any.whl → 3.0.22__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.
- llms/extensions/computer/__init__.py +16 -8
- llms/extensions/gallery/ui/index.mjs +2 -1
- llms/extensions/providers/openrouter.py +1 -1
- llms/extensions/skills/README.md +275 -0
- llms/extensions/skills/__init__.py +362 -3
- llms/extensions/skills/installer.py +415 -0
- llms/extensions/skills/ui/data/skills-top-5000.json +1 -0
- llms/extensions/skills/ui/index.mjs +572 -4
- llms/extensions/skills/ui/skills/create-plan/SKILL.md +6 -6
- llms/extensions/skills/ui/skills/skill-creator/LICENSE.txt +202 -0
- llms/extensions/skills/ui/skills/skill-creator/SKILL.md +356 -0
- llms/extensions/skills/ui/skills/skill-creator/references/output-patterns.md +82 -0
- llms/extensions/skills/ui/skills/skill-creator/references/workflows.md +28 -0
- llms/extensions/skills/ui/skills/skill-creator/scripts/init_skill.py +299 -0
- llms/extensions/skills/ui/skills/skill-creator/scripts/package_skill.py +111 -0
- llms/extensions/skills/ui/skills/skill-creator/scripts/quick_validate.py +98 -0
- llms/llms.json +15 -15
- llms/main.py +6 -5
- llms/providers.json +1 -1
- llms/ui/ai.mjs +1 -1
- llms/ui/app.css +55 -0
- llms/ui/ctx.mjs +6 -7
- {llms_py-3.0.20.dist-info → llms_py-3.0.22.dist-info}/METADATA +1 -1
- {llms_py-3.0.20.dist-info → llms_py-3.0.22.dist-info}/RECORD +28 -18
- {llms_py-3.0.20.dist-info → llms_py-3.0.22.dist-info}/WHEEL +0 -0
- {llms_py-3.0.20.dist-info → llms_py-3.0.22.dist-info}/entry_points.txt +0 -0
- {llms_py-3.0.20.dist-info → llms_py-3.0.22.dist-info}/licenses/LICENSE +0 -0
- {llms_py-3.0.20.dist-info → llms_py-3.0.22.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Skill installer module for cloning and installing skills from GitHub repositories.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .parser import read_properties
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Skill:
|
|
18
|
+
"""Skill metadata from SKILL.md frontmatter."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
description: str
|
|
22
|
+
path: Path
|
|
23
|
+
raw_content: str | None = None
|
|
24
|
+
metadata: dict | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class InstallResult:
|
|
29
|
+
"""Result of a skill installation."""
|
|
30
|
+
|
|
31
|
+
success: bool
|
|
32
|
+
path: str
|
|
33
|
+
skill_name: str
|
|
34
|
+
error: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GitCloneError(Exception):
|
|
38
|
+
"""Error during git clone operation."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str, url: str, is_timeout: bool = False, is_auth_error: bool = False):
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.url = url
|
|
43
|
+
self.is_timeout = is_timeout
|
|
44
|
+
self.is_auth_error = is_auth_error
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
CLONE_TIMEOUT_SECONDS = 60
|
|
48
|
+
SKIP_DIRS = {"node_modules", ".git", "dist", "build", "__pycache__"}
|
|
49
|
+
EXCLUDE_FILES = {"README.md", "metadata.json"}
|
|
50
|
+
EXCLUDE_DIRS = {".git"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sanitize_name(name: str) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Sanitize a skill name for safe filesystem usage (kebab-case).
|
|
56
|
+
|
|
57
|
+
- Converts to lowercase
|
|
58
|
+
- Replaces non-alphanumeric chars (except dots/underscores) with hyphens
|
|
59
|
+
- Removes leading/trailing dots and hyphens
|
|
60
|
+
- Limits to 255 chars
|
|
61
|
+
"""
|
|
62
|
+
sanitized = name.lower()
|
|
63
|
+
# Replace any sequence of chars that are NOT lowercase letters, digits, dots, or underscores
|
|
64
|
+
sanitized = re.sub(r"[^a-z0-9._]+", "-", sanitized)
|
|
65
|
+
# Remove leading/trailing dots and hyphens
|
|
66
|
+
sanitized = re.sub(r"^[.\-]+|[.\-]+$", "", sanitized)
|
|
67
|
+
# Limit to 255 chars, fallback to 'unnamed-skill' if empty
|
|
68
|
+
return sanitized[:255] or "unnamed-skill"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_path_safe(base_path: str, target_path: str) -> bool:
|
|
72
|
+
"""Validate that a path is within an expected base directory."""
|
|
73
|
+
base = Path(base_path).resolve()
|
|
74
|
+
target = Path(target_path).resolve()
|
|
75
|
+
try:
|
|
76
|
+
target.relative_to(base)
|
|
77
|
+
return True
|
|
78
|
+
except ValueError:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def clone_repo(url: str, ref: str | None = None) -> str:
|
|
83
|
+
"""
|
|
84
|
+
Clone a git repository to a temp directory.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
url: Git repository URL
|
|
88
|
+
ref: Optional branch/tag/commit reference
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Path to the cloned repository temp directory
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
GitCloneError: If clone fails
|
|
95
|
+
"""
|
|
96
|
+
temp_dir = tempfile.mkdtemp(prefix="skills-")
|
|
97
|
+
|
|
98
|
+
clone_args = ["git", "clone", "--depth", "1"]
|
|
99
|
+
if ref:
|
|
100
|
+
clone_args.extend(["--branch", ref])
|
|
101
|
+
clone_args.extend([url, temp_dir])
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
process = await asyncio.create_subprocess_exec(
|
|
105
|
+
*clone_args,
|
|
106
|
+
stdout=asyncio.subprocess.PIPE,
|
|
107
|
+
stderr=asyncio.subprocess.PIPE,
|
|
108
|
+
)
|
|
109
|
+
_, stderr = await asyncio.wait_for(process.communicate(), timeout=CLONE_TIMEOUT_SECONDS)
|
|
110
|
+
|
|
111
|
+
if process.returncode != 0:
|
|
112
|
+
error_msg = stderr.decode() if stderr else "Unknown error"
|
|
113
|
+
await cleanup_temp_dir(temp_dir)
|
|
114
|
+
|
|
115
|
+
is_auth_error = any(
|
|
116
|
+
msg in error_msg
|
|
117
|
+
for msg in [
|
|
118
|
+
"Authentication failed",
|
|
119
|
+
"could not read Username",
|
|
120
|
+
"Permission denied",
|
|
121
|
+
"Repository not found",
|
|
122
|
+
]
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if is_auth_error:
|
|
126
|
+
raise GitCloneError(
|
|
127
|
+
f"Authentication failed for {url}.\n"
|
|
128
|
+
" - For private repos, ensure you have access\n"
|
|
129
|
+
" - For SSH: Check your keys with 'ssh -T git@github.com'\n"
|
|
130
|
+
" - For HTTPS: Run 'gh auth login' or configure git credentials",
|
|
131
|
+
url,
|
|
132
|
+
is_auth_error=True,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
raise GitCloneError(f"Failed to clone {url}: {error_msg}", url)
|
|
136
|
+
|
|
137
|
+
return temp_dir
|
|
138
|
+
|
|
139
|
+
except asyncio.TimeoutError:
|
|
140
|
+
await cleanup_temp_dir(temp_dir)
|
|
141
|
+
raise GitCloneError(
|
|
142
|
+
f"Clone timed out after {CLONE_TIMEOUT_SECONDS}s. This often happens with private repos.\n"
|
|
143
|
+
" Ensure you have access and your SSH keys or credentials are configured.",
|
|
144
|
+
url,
|
|
145
|
+
is_timeout=True,
|
|
146
|
+
) from None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def cleanup_temp_dir(dir_path: str) -> None:
|
|
150
|
+
"""Clean up a temp directory safely (only if within system tempdir)."""
|
|
151
|
+
normalized_dir = Path(dir_path).resolve()
|
|
152
|
+
normalized_tmp = Path(tempfile.gettempdir()).resolve()
|
|
153
|
+
|
|
154
|
+
if not str(normalized_dir).startswith(str(normalized_tmp)):
|
|
155
|
+
raise ValueError("Attempted to clean up directory outside of temp directory")
|
|
156
|
+
|
|
157
|
+
shutil.rmtree(dir_path, ignore_errors=True)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_skill_md(skill_md_path: Path) -> Skill | None:
|
|
161
|
+
"""Parse a SKILL.md file and return skill metadata."""
|
|
162
|
+
try:
|
|
163
|
+
props = read_properties(skill_md_path.parent)
|
|
164
|
+
if not props.name or not props.description:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
content = skill_md_path.read_text(encoding="utf-8")
|
|
168
|
+
return Skill(
|
|
169
|
+
name=props.name,
|
|
170
|
+
description=props.description,
|
|
171
|
+
path=skill_md_path.parent,
|
|
172
|
+
raw_content=content,
|
|
173
|
+
metadata=props.metadata,
|
|
174
|
+
)
|
|
175
|
+
except Exception:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def has_skill_md(dir_path: Path) -> bool:
|
|
180
|
+
"""Check if a directory contains a SKILL.md file."""
|
|
181
|
+
return (dir_path / "SKILL.md").is_file() or (dir_path / "skill.md").is_file()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def find_skill_dirs(dir_path: Path, depth: int = 0, max_depth: int = 5) -> list[Path]:
|
|
185
|
+
"""Recursively find directories containing SKILL.md files."""
|
|
186
|
+
if depth > max_depth:
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
result = []
|
|
190
|
+
try:
|
|
191
|
+
if has_skill_md(dir_path):
|
|
192
|
+
result.append(dir_path)
|
|
193
|
+
|
|
194
|
+
for entry in dir_path.iterdir():
|
|
195
|
+
if entry.is_dir() and entry.name not in SKIP_DIRS:
|
|
196
|
+
result.extend(find_skill_dirs(entry, depth + 1, max_depth))
|
|
197
|
+
except OSError:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def discover_skills(base_path: str, subpath: str | None = None) -> list[Skill]:
|
|
204
|
+
"""
|
|
205
|
+
Discover skills in a directory by finding SKILL.md files.
|
|
206
|
+
|
|
207
|
+
Searches priority directories first (common skill locations), then falls back
|
|
208
|
+
to recursive search if nothing found.
|
|
209
|
+
"""
|
|
210
|
+
skills: list[Skill] = []
|
|
211
|
+
seen_names: set[str] = set()
|
|
212
|
+
search_path = Path(base_path) / subpath if subpath else Path(base_path)
|
|
213
|
+
|
|
214
|
+
# If pointing directly at a skill, return just that
|
|
215
|
+
if has_skill_md(search_path):
|
|
216
|
+
skill = parse_skill_md(search_path / "SKILL.md")
|
|
217
|
+
if skill:
|
|
218
|
+
return [skill]
|
|
219
|
+
|
|
220
|
+
# Search common skill locations first
|
|
221
|
+
priority_dirs = [
|
|
222
|
+
search_path,
|
|
223
|
+
search_path / "skills",
|
|
224
|
+
search_path / "skills" / ".curated",
|
|
225
|
+
search_path / "skills" / ".experimental",
|
|
226
|
+
search_path / "skills" / ".system",
|
|
227
|
+
search_path / ".agent" / "skills",
|
|
228
|
+
search_path / ".agents" / "skills",
|
|
229
|
+
search_path / ".claude" / "skills",
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
for dir_path in priority_dirs:
|
|
233
|
+
if not dir_path.is_dir():
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
for entry in dir_path.iterdir():
|
|
238
|
+
if entry.is_dir() and has_skill_md(entry):
|
|
239
|
+
skill = parse_skill_md(entry / "SKILL.md")
|
|
240
|
+
if skill and skill.name not in seen_names:
|
|
241
|
+
skills.append(skill)
|
|
242
|
+
seen_names.add(skill.name)
|
|
243
|
+
except OSError:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
# Fall back to recursive search if nothing found
|
|
247
|
+
if not skills:
|
|
248
|
+
all_skill_dirs = find_skill_dirs(search_path)
|
|
249
|
+
for skill_dir in all_skill_dirs:
|
|
250
|
+
skill = parse_skill_md(skill_dir / "SKILL.md")
|
|
251
|
+
if skill and skill.name not in seen_names:
|
|
252
|
+
skills.append(skill)
|
|
253
|
+
seen_names.add(skill.name)
|
|
254
|
+
|
|
255
|
+
return skills
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def is_excluded(name: str, is_directory: bool = False) -> bool:
|
|
259
|
+
"""Check if a file/directory should be excluded from copying."""
|
|
260
|
+
if name in EXCLUDE_FILES:
|
|
261
|
+
return True
|
|
262
|
+
if name.startswith("_"):
|
|
263
|
+
return True
|
|
264
|
+
return is_directory and name in EXCLUDE_DIRS
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def copy_skill_directory(src: Path, dest: Path) -> None:
|
|
268
|
+
"""Copy a skill directory, excluding certain files."""
|
|
269
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
|
|
271
|
+
for entry in src.iterdir():
|
|
272
|
+
if is_excluded(entry.name, entry.is_dir()):
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
dest_path = dest / entry.name
|
|
276
|
+
if entry.is_dir():
|
|
277
|
+
copy_skill_directory(entry, dest_path)
|
|
278
|
+
else:
|
|
279
|
+
shutil.copy2(entry, dest_path)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def install_skill(skill: Skill, target_base: str) -> InstallResult:
|
|
283
|
+
"""
|
|
284
|
+
Install a skill to the target directory.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
skill: Skill to install
|
|
288
|
+
target_base: Base directory for skill installation (e.g., ~/.llms/.agents/skills)
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
InstallResult with success status and path
|
|
292
|
+
"""
|
|
293
|
+
skill_name = sanitize_name(skill.name)
|
|
294
|
+
target_dir = Path(target_base) / skill_name
|
|
295
|
+
|
|
296
|
+
# Validate path safety
|
|
297
|
+
if not is_path_safe(target_base, str(target_dir)):
|
|
298
|
+
return InstallResult(
|
|
299
|
+
success=False,
|
|
300
|
+
path=str(target_dir),
|
|
301
|
+
skill_name=skill_name,
|
|
302
|
+
error="Invalid skill name: potential path traversal detected",
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
# Remove existing skill directory if it exists
|
|
307
|
+
if target_dir.exists():
|
|
308
|
+
shutil.rmtree(target_dir)
|
|
309
|
+
|
|
310
|
+
# Copy skill files
|
|
311
|
+
copy_skill_directory(skill.path, target_dir)
|
|
312
|
+
|
|
313
|
+
return InstallResult(
|
|
314
|
+
success=True,
|
|
315
|
+
path=str(target_dir),
|
|
316
|
+
skill_name=skill_name,
|
|
317
|
+
)
|
|
318
|
+
except Exception as e:
|
|
319
|
+
return InstallResult(
|
|
320
|
+
success=False,
|
|
321
|
+
path=str(target_dir),
|
|
322
|
+
skill_name=skill_name,
|
|
323
|
+
error=str(e),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def filter_skills(skills: list[Skill], skill_names: list[str]) -> list[Skill]:
|
|
328
|
+
"""Filter skills by name (case-insensitive)."""
|
|
329
|
+
normalized_names = [n.lower() for n in skill_names]
|
|
330
|
+
return [s for s in skills if s.name.lower() in normalized_names or sanitize_name(s.name) in normalized_names]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def install_from_github(
|
|
334
|
+
repo_url: str,
|
|
335
|
+
ref: str | None = None,
|
|
336
|
+
subpath: str | None = None,
|
|
337
|
+
skill_names: list[str] | None = None,
|
|
338
|
+
target_dir: str | None = None,
|
|
339
|
+
) -> dict:
|
|
340
|
+
"""
|
|
341
|
+
Install skill(s) from a GitHub repository.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
repo_url: GitHub repository URL (e.g., https://github.com/owner/repo.git)
|
|
345
|
+
ref: Optional branch/tag/commit reference
|
|
346
|
+
subpath: Optional subdirectory within the repo to search for skills
|
|
347
|
+
skill_names: Optional list of skill names to install (installs all if None)
|
|
348
|
+
target_dir: Target directory for installation (defaults to ~/.llms/.agents/skills)
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Dictionary with installation results
|
|
352
|
+
"""
|
|
353
|
+
if target_dir is None:
|
|
354
|
+
target_dir = os.path.expanduser("~/.llms/.agents/skills")
|
|
355
|
+
|
|
356
|
+
# Ensure target directory exists
|
|
357
|
+
Path(target_dir).mkdir(parents=True, exist_ok=True)
|
|
358
|
+
|
|
359
|
+
temp_dir = None
|
|
360
|
+
try:
|
|
361
|
+
# Clone the repository
|
|
362
|
+
temp_dir = await clone_repo(repo_url, ref)
|
|
363
|
+
|
|
364
|
+
# Discover skills in the repo
|
|
365
|
+
skills = await discover_skills(temp_dir, subpath)
|
|
366
|
+
|
|
367
|
+
if not skills:
|
|
368
|
+
return {
|
|
369
|
+
"success": False,
|
|
370
|
+
"error": "No skills found in repository",
|
|
371
|
+
"installed": [],
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
# Filter skills if specific names requested
|
|
375
|
+
if skill_names:
|
|
376
|
+
skills = filter_skills(skills, skill_names)
|
|
377
|
+
if not skills:
|
|
378
|
+
return {
|
|
379
|
+
"success": False,
|
|
380
|
+
"error": f"No matching skills found for: {', '.join(skill_names)}",
|
|
381
|
+
"installed": [],
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
# Install each skill
|
|
385
|
+
results = []
|
|
386
|
+
for skill in skills:
|
|
387
|
+
result = await install_skill(skill, target_dir)
|
|
388
|
+
results.append(
|
|
389
|
+
{
|
|
390
|
+
"name": result.skill_name,
|
|
391
|
+
"path": result.path,
|
|
392
|
+
"success": result.success,
|
|
393
|
+
"error": result.error,
|
|
394
|
+
}
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
successful = [r for r in results if r["success"]]
|
|
398
|
+
failed = [r for r in results if not r["success"]]
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
"success": len(failed) == 0,
|
|
402
|
+
"installed": successful,
|
|
403
|
+
"failed": failed,
|
|
404
|
+
"total": len(results),
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
except GitCloneError as e:
|
|
408
|
+
return {
|
|
409
|
+
"success": False,
|
|
410
|
+
"error": str(e),
|
|
411
|
+
"installed": [],
|
|
412
|
+
}
|
|
413
|
+
finally:
|
|
414
|
+
if temp_dir:
|
|
415
|
+
await cleanup_temp_dir(temp_dir)
|