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
@@ -1,5 +1,7 @@
1
+ import json
1
2
  import os
2
3
  import shutil
4
+ import sys
3
5
  from pathlib import Path
4
6
  from typing import Annotated
5
7
 
@@ -8,6 +10,62 @@ import aiohttp
8
10
  from .parser import read_properties
9
11
 
10
12
  g_skills = {}
13
+ g_home_skills = None
14
+
15
+ # Example of what's returned from https://skills.sh/api/skills?limit=5000&offset=0 > ui/data/skills-top-5000.json
16
+ # {
17
+ # "id": "vercel-react-best-practices",
18
+ # "name": "vercel-react-best-practices",
19
+ # "installs": 68580,
20
+ # "topSource": "vercel-labs/agent-skills"
21
+ # }
22
+ g_available_skills = []
23
+
24
+
25
+ def is_safe_path(base_path: str, requested_path: str) -> bool:
26
+ """Check if the requested path is safely within the base path."""
27
+ base = Path(base_path).resolve()
28
+ target = Path(requested_path).resolve()
29
+ try:
30
+ target.relative_to(base)
31
+ return True
32
+ except ValueError:
33
+ return False
34
+
35
+
36
+ def get_skill_files(skill_dir: Path) -> list:
37
+ """Get list of all files in a skill directory."""
38
+ files = []
39
+ for file in skill_dir.glob("**/*"):
40
+ if file.is_file():
41
+ full_path = str(file)
42
+ rel_path = full_path[len(str(skill_dir)) + 1 :]
43
+ files.append(rel_path)
44
+ return files
45
+
46
+
47
+ def reload_skill(name: str, location: str, group: str):
48
+ """Reload a single skill's metadata."""
49
+ global g_skills
50
+ skill_dir = Path(location).resolve()
51
+ if not skill_dir.exists():
52
+ if name in g_skills:
53
+ del g_skills[name]
54
+ return None
55
+
56
+ props = read_properties(skill_dir)
57
+ files = get_skill_files(skill_dir)
58
+
59
+ skill_props = props.to_dict()
60
+ skill_props.update(
61
+ {
62
+ "group": group,
63
+ "location": str(skill_dir),
64
+ "files": files,
65
+ }
66
+ )
67
+ g_skills[props.name] = skill_props
68
+ return skill_props
11
69
 
12
70
 
13
71
  def sanitize(name: str) -> str:
@@ -52,8 +110,9 @@ def skill(name: Annotated[str, "skill name"], file: Annotated[str | None, "skill
52
110
 
53
111
 
54
112
  def install(ctx):
55
- global g_skills
113
+ global g_skills, g_home_skills
56
114
  home_skills = ctx.get_home_path(os.path.join(".agent", "skills"))
115
+ g_home_skills = home_skills
57
116
  # if not folder exists
58
117
  if not os.path.exists(home_skills):
59
118
  os.makedirs(ctx.get_home_path(os.path.join(".agent")), exist_ok=True)
@@ -66,8 +125,9 @@ def install(ctx):
66
125
  skill_roots = {}
67
126
 
68
127
  # add .claude skills first, so they can be overridden by .agent skills
69
- if os.path.exists("~/.claude/skills"):
70
- skill_roots["~/.claude/skills"] = "~/.claude/skills"
128
+ claude_skills = os.path.expanduser("~/.claude/skills")
129
+ if os.path.exists(claude_skills):
130
+ skill_roots["~/.claude/skills"] = claude_skills
71
131
 
72
132
  if os.path.exists(os.path.join(".claude", "skills")):
73
133
  skill_roots[".claude/skills"] = os.path.join(".claude", "skills")
@@ -112,11 +172,82 @@ def install(ctx):
112
172
  except OSError:
113
173
  pass
114
174
 
175
+ g_available_skills = []
176
+ try:
177
+ with open(os.path.join(ctx.path, "ui", "data", "skills-top-5000.json")) as f:
178
+ top_skills = json.load(f)
179
+ g_available_skills = top_skills["skills"]
180
+ except Exception:
181
+ pass
182
+
115
183
  async def get_skills(request):
116
184
  return aiohttp.web.json_response(g_skills)
117
185
 
118
186
  ctx.add_get("", get_skills)
119
187
 
188
+ async def search_available_skills(request):
189
+ q = request.query.get("q", "")
190
+ limit = int(request.query.get("limit", 50))
191
+ offset = int(request.query.get("offset", 0))
192
+ q_lower = q.lower()
193
+ filtered_results = [
194
+ s for s in g_available_skills if q_lower in s.get("name", "") or q_lower in s.get("topSource", "")
195
+ ]
196
+ sorted_by_installs = sorted(filtered_results, key=lambda x: x.get("installs", 0), reverse=True)
197
+ results = sorted_by_installs[offset : offset + limit]
198
+ return aiohttp.web.json_response(
199
+ {
200
+ "results": results,
201
+ "total": len(sorted_by_installs),
202
+ }
203
+ )
204
+
205
+ ctx.add_get("search", search_available_skills)
206
+
207
+ async def install_skill(request):
208
+ id = request.match_info.get("id")
209
+ skill = next((s for s in g_available_skills if s.get("id") == id), None)
210
+ if not skill:
211
+ raise Exception(f"Skill '{id}' not found")
212
+
213
+ # Get the source repo (e.g., "vercel-labs/agent-skills")
214
+ source = skill.get("topSource")
215
+ if not source:
216
+ raise Exception(f"Skill '{id}' has no source repository")
217
+
218
+ # Install from GitHub
219
+ from .installer import install_from_github
220
+
221
+ result = await install_from_github(
222
+ repo_url=f"https://github.com/{source}.git",
223
+ skill_names=[id],
224
+ target_dir=home_skills,
225
+ )
226
+
227
+ if not result.get("success"):
228
+ raise Exception(result.get("error", "Installation failed"))
229
+
230
+ # Reload the installed skills into the registry
231
+ for installed in result.get("installed", []):
232
+ skill_path = installed.get("path")
233
+ if skill_path and os.path.exists(skill_path):
234
+ skill_dir = Path(skill_path).resolve()
235
+ props = read_properties(skill_dir)
236
+ files = get_skill_files(skill_dir)
237
+ skill_props = props.to_dict()
238
+ skill_props.update(
239
+ {
240
+ "group": "~/.llms/.agents",
241
+ "location": str(skill_dir),
242
+ "files": files,
243
+ }
244
+ )
245
+ g_skills[props.name] = skill_props
246
+
247
+ return aiohttp.web.json_response(result)
248
+
249
+ ctx.add_post("install/{id}", install_skill)
250
+
120
251
  async def get_skill(request):
121
252
  name = request.match_info.get("name")
122
253
  file = request.query.get("file")
@@ -124,6 +255,234 @@ def install(ctx):
124
255
 
125
256
  ctx.add_get("contents/{name}", get_skill)
126
257
 
258
+ async def get_file_content(request):
259
+ """Get the content of a specific file in a skill."""
260
+ name = request.match_info.get("name")
261
+ file_path = request.match_info.get("path")
262
+
263
+ skill_info = g_skills.get(name)
264
+ if not skill_info:
265
+ raise Exception(f"Skill '{name}' not found")
266
+
267
+ location = skill_info.get("location")
268
+ full_path = os.path.join(location, file_path)
269
+
270
+ if not is_safe_path(location, full_path):
271
+ raise Exception("Invalid file path")
272
+
273
+ if not os.path.exists(full_path):
274
+ raise Exception(f"File '{file_path}' not found")
275
+
276
+ try:
277
+ with open(full_path, encoding="utf-8") as f:
278
+ content = f.read()
279
+ return aiohttp.web.json_response({"content": content, "path": file_path})
280
+ except Exception as e:
281
+ raise Exception(str(e)) from e
282
+
283
+ ctx.add_get("file/{name}/{path:.*}", get_file_content)
284
+
285
+ async def save_file(request):
286
+ """Save/update a file in a skill. Only works for skills in home directory."""
287
+ name = request.match_info.get("name")
288
+
289
+ try:
290
+ data = await request.json()
291
+ except json.JSONDecodeError:
292
+ raise Exception("Invalid JSON body") from None
293
+
294
+ file_path = data.get("path")
295
+ content = data.get("content")
296
+
297
+ if not file_path or content is None:
298
+ raise Exception("Missing 'path' or 'content' in request body")
299
+
300
+ skill_info = g_skills.get(name)
301
+ if not skill_info:
302
+ raise Exception(f"Skill '{name}' not found")
303
+
304
+ location = skill_info.get("location")
305
+
306
+ # Only allow modifications to skills in home directory
307
+ if not is_safe_path(home_skills, location):
308
+ raise Exception("Cannot modify skills outside of home directory")
309
+
310
+ full_path = os.path.join(location, file_path)
311
+
312
+ if not is_safe_path(location, full_path):
313
+ raise Exception("Invalid file path")
314
+
315
+ try:
316
+ # Create parent directories if they don't exist
317
+ os.makedirs(os.path.dirname(full_path), exist_ok=True)
318
+ with open(full_path, "w", encoding="utf-8") as f:
319
+ f.write(content)
320
+
321
+ # Reload skill metadata
322
+ group = skill_info.get("group", "~/.llms/.agents")
323
+ updated_skill = reload_skill(name, location, group)
324
+
325
+ return aiohttp.web.json_response({"path": file_path, "skill": updated_skill})
326
+ except Exception as e:
327
+ raise Exception(str(e)) from e
328
+
329
+ ctx.add_post("file/{name}", save_file)
330
+
331
+ async def delete_file(request):
332
+ """Delete a file from a skill. Only works for skills in home directory."""
333
+ name = request.match_info.get("name")
334
+ file_path = request.query.get("path")
335
+
336
+ if not file_path:
337
+ raise Exception("Missing 'path' query parameter")
338
+
339
+ skill_info = g_skills.get(name)
340
+ if not skill_info:
341
+ raise Exception(f"Skill '{name}' not found")
342
+
343
+ location = skill_info.get("location")
344
+
345
+ # Only allow modifications to skills in home directory
346
+ if not is_safe_path(home_skills, location):
347
+ raise Exception("Cannot modify skills outside of home directory")
348
+
349
+ full_path = os.path.join(location, file_path)
350
+
351
+ if not is_safe_path(location, full_path):
352
+ raise Exception("Invalid file path")
353
+
354
+ # Prevent deleting SKILL.md
355
+ if file_path.lower() == "skill.md":
356
+ raise Exception("Cannot delete SKILL.md - delete the entire skill instead")
357
+
358
+ if not os.path.exists(full_path):
359
+ raise Exception(f"File '{file_path}' not found")
360
+
361
+ try:
362
+ os.remove(full_path)
363
+
364
+ # Clean up empty parent directories
365
+ parent = os.path.dirname(full_path)
366
+ while parent != location:
367
+ if os.path.isdir(parent) and not os.listdir(parent):
368
+ os.rmdir(parent)
369
+ parent = os.path.dirname(parent)
370
+ else:
371
+ break
372
+
373
+ # Reload skill metadata
374
+ group = skill_info.get("group", "~/.llms/.agents")
375
+ updated_skill = reload_skill(name, location, group)
376
+
377
+ return aiohttp.web.json_response({"path": file_path, "skill": updated_skill})
378
+ except Exception as e:
379
+ raise Exception(str(e)) from e
380
+
381
+ ctx.add_delete("file/{name}", delete_file)
382
+
383
+ async def create_skill(request):
384
+ """Create a new skill using the skill-creator template."""
385
+ try:
386
+ data = await request.json()
387
+ except json.JSONDecodeError:
388
+ raise Exception("Invalid JSON body") from None
389
+
390
+ skill_name = data.get("name")
391
+ if not skill_name:
392
+ raise Exception("Missing 'name' in request body")
393
+
394
+ # Validate skill name format
395
+ import re
396
+
397
+ if not re.match(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$", skill_name):
398
+ raise Exception("Skill name must be lowercase, use hyphens, start/end with alphanumeric")
399
+
400
+ if len(skill_name) > 40:
401
+ raise Exception("Skill name must be 40 characters or less")
402
+
403
+ skill_dir = os.path.join(home_skills, skill_name)
404
+
405
+ if os.path.exists(skill_dir):
406
+ raise Exception(f"Skill '{skill_name}' already exists")
407
+
408
+ # Use init_skill.py from skill-creator
409
+ init_script = os.path.join(ctx.path, "ui", "skills", "skill-creator", "scripts", "init_skill.py")
410
+
411
+ if not os.path.exists(init_script):
412
+ raise Exception("skill-creator not found")
413
+
414
+ try:
415
+ import subprocess
416
+
417
+ result = subprocess.run(
418
+ [sys.executable, init_script, skill_name, "--path", home_skills],
419
+ capture_output=True,
420
+ text=True,
421
+ timeout=30,
422
+ )
423
+
424
+ if result.returncode != 0:
425
+ raise Exception(f"Failed to create skill: {result.stderr}")
426
+
427
+ # Load the new skill
428
+ if os.path.exists(skill_dir):
429
+ skill_dir_path = Path(skill_dir).resolve()
430
+ props = read_properties(skill_dir_path)
431
+ files = get_skill_files(skill_dir_path)
432
+
433
+ skill_props = props.to_dict()
434
+ skill_props.update(
435
+ {
436
+ "group": "~/.llms/.agents",
437
+ "location": str(skill_dir_path),
438
+ "files": files,
439
+ }
440
+ )
441
+ g_skills[props.name] = skill_props
442
+
443
+ return aiohttp.web.json_response({"skill": skill_props, "output": result.stdout})
444
+
445
+ raise Exception("Skill directory not created")
446
+
447
+ except subprocess.TimeoutExpired:
448
+ raise Exception("Skill creation timed out") from None
449
+ except Exception as e:
450
+ raise Exception(str(e)) from e
451
+
452
+ ctx.add_post("create", create_skill)
453
+
454
+ async def delete_skill(request):
455
+ """Delete an entire skill. Only works for skills in home directory."""
456
+ name = request.match_info.get("name")
457
+
458
+ skill_info = g_skills.get(name)
459
+
460
+ if skill_info:
461
+ location = skill_info.get("location")
462
+ else:
463
+ # Check if orphaned directory exists on disk (not loaded in g_skills)
464
+ potential_location = os.path.join(home_skills, name)
465
+ if os.path.exists(potential_location) and is_safe_path(home_skills, potential_location):
466
+ location = potential_location
467
+ else:
468
+ raise Exception(f"Skill '{name}' not found")
469
+
470
+ # Only allow deletion of skills in home directory
471
+ if not is_safe_path(home_skills, location):
472
+ raise Exception("Cannot delete skills outside of home directory")
473
+
474
+ try:
475
+ if os.path.exists(location):
476
+ shutil.rmtree(location)
477
+ if name in g_skills:
478
+ del g_skills[name]
479
+
480
+ return aiohttp.web.json_response({"deleted": name})
481
+ except Exception as e:
482
+ raise Exception(str(e)) from e
483
+
484
+ ctx.add_delete("skill/{name}", delete_skill)
485
+
127
486
  ctx.register_tool(skill, group="core_tools")
128
487
 
129
488