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
|
@@ -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
|
-
|
|
70
|
-
|
|
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
|
|