llms-py 3.0.21__py3-none-any.whl → 3.0.23__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 (27) hide show
  1. llms/extensions/computer/__init__.py +16 -8
  2. llms/extensions/gallery/ui/index.mjs +2 -1
  3. llms/extensions/skills/README.md +275 -0
  4. llms/extensions/skills/__init__.py +362 -3
  5. llms/extensions/skills/installer.py +415 -0
  6. llms/extensions/skills/ui/data/skills-top-5000.json +1 -0
  7. llms/extensions/skills/ui/index.mjs +572 -4
  8. llms/extensions/skills/ui/skills/create-plan/SKILL.md +6 -6
  9. llms/extensions/skills/ui/skills/skill-creator/LICENSE.txt +202 -0
  10. llms/extensions/skills/ui/skills/skill-creator/SKILL.md +356 -0
  11. llms/extensions/skills/ui/skills/skill-creator/references/output-patterns.md +82 -0
  12. llms/extensions/skills/ui/skills/skill-creator/references/workflows.md +28 -0
  13. llms/extensions/skills/ui/skills/skill-creator/scripts/init_skill.py +299 -0
  14. llms/extensions/skills/ui/skills/skill-creator/scripts/package_skill.py +111 -0
  15. llms/extensions/skills/ui/skills/skill-creator/scripts/quick_validate.py +98 -0
  16. llms/llms.json +31 -19
  17. llms/main.py +20 -7
  18. llms/providers.json +1 -1
  19. llms/ui/ai.mjs +1 -1
  20. llms/ui/app.css +67 -0
  21. llms/ui/ctx.mjs +6 -7
  22. {llms_py-3.0.21.dist-info → llms_py-3.0.23.dist-info}/METADATA +1 -1
  23. {llms_py-3.0.21.dist-info → llms_py-3.0.23.dist-info}/RECORD +27 -17
  24. {llms_py-3.0.21.dist-info → llms_py-3.0.23.dist-info}/WHEEL +0 -0
  25. {llms_py-3.0.21.dist-info → llms_py-3.0.23.dist-info}/entry_points.txt +0 -0
  26. {llms_py-3.0.21.dist-info → llms_py-3.0.23.dist-info}/licenses/LICENSE +0 -0
  27. {llms_py-3.0.21.dist-info → llms_py-3.0.23.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)