llms-py 3.0.24__py3-none-any.whl → 3.0.26__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.
@@ -9,9 +9,6 @@ import aiohttp
9
9
 
10
10
  from .parser import read_properties
11
11
 
12
- g_skills = {}
13
- g_home_skills = None
14
-
15
12
  # Example of what's returned from https://skills.sh/api/skills?limit=5000&offset=0 > ui/data/skills-top-5000.json
16
13
  # {
17
14
  # "id": "vercel-react-best-practices",
@@ -47,84 +44,27 @@ def get_skill_files(skill_dir: Path) -> list:
47
44
  return files
48
45
 
49
46
 
50
- def reload_skill(name: str, location: str, group: str):
51
- """Reload a single skill's metadata."""
52
- global g_skills
53
- skill_dir = Path(location).resolve()
54
- if not skill_dir.exists():
55
- if name in g_skills:
56
- del g_skills[name]
57
- return None
58
-
59
- props = read_properties(skill_dir)
60
- files = get_skill_files(skill_dir)
61
-
62
- skill_props = props.to_dict()
63
- skill_props.update(
64
- {
65
- "group": group,
66
- "location": str(skill_dir),
67
- "files": files,
68
- }
69
- )
70
- g_skills[props.name] = skill_props
71
- return skill_props
72
-
73
-
74
47
  def sanitize(name: str) -> str:
75
48
  return name.replace(" ", "").replace("_", "").replace("-", "").lower()
76
49
 
77
50
 
78
- def skill(name: Annotated[str, "skill name"], file: Annotated[str | None, "skill file"] = None):
79
- """Get the content of a skill or a specific file within a skill."""
80
- skill = g_skills.get(name)
81
-
82
- if not skill:
83
- sanitized_name = sanitize(name)
84
- for k, v in g_skills.items():
85
- if sanitize(k) == sanitized_name:
86
- skill = v
87
- break
51
+ def resolve_user_skills_path(ctx, user):
52
+ if not user:
53
+ raise ValueError("User is required")
54
+ user_path = ctx.get_user_path(user)
55
+ return os.path.join(user_path, "skills")
88
56
 
89
- if not skill:
90
- return f"Error: Skill {name} not found. Available skills: {', '.join(g_skills.keys())}"
91
- location = skill.get("location")
92
- if not location or not os.path.exists(location):
93
- return f"Error: Skill {name} not found at location {location}"
94
-
95
- if file:
96
- if file.startswith(location):
97
- file = file[len(location) + 1 :]
98
- if not os.path.exists(os.path.join(location, file)):
99
- return f"Error: File {file} not found in skill {name}. Available files: {', '.join(skill.get('files', []))}"
100
- with open(os.path.join(location, file)) as f:
101
- return f.read()
102
-
103
- with open(os.path.join(location, "SKILL.md")) as f:
104
- content = f.read()
105
-
106
- files = skill.get("files")
107
- if files and len(files) > 1:
108
- content += "\n\n## Skill Files:\n```\n"
109
- for file in files:
110
- content += f"{file}\n"
111
- content += "```\n"
112
- return content
113
-
114
-
115
- def install(ctx):
116
- global g_skills, g_home_skills
57
+ def resolve_skills_write_path(ctx, user=None):
58
+ if user:
59
+ user_skills_path = resolve_user_skills_path(ctx, user)
60
+ os.makedirs(user_skills_path, exist_ok=True)
61
+ return user_skills_path
117
62
  home_skills = ctx.get_home_path(os.path.join(".agent", "skills"))
118
- g_home_skills = home_skills
119
- # if not folder exists
120
- if not os.path.exists(home_skills):
121
- os.makedirs(ctx.get_home_path(os.path.join(".agent")), exist_ok=True)
122
- ctx.log(f"Creating initial skills folder: {home_skills}")
123
- # os.makedirs(home_skills)
124
- # copy ui/skills to home_skills
125
- ui_skills = os.path.join(ctx.path, "ui", "skills")
126
- shutil.copytree(ui_skills, home_skills)
63
+ os.makedirs(home_skills, exist_ok=True)
64
+ return home_skills
127
65
 
66
+ def resolve_all_skills(ctx, user=None):
67
+ home_skills = ctx.get_home_path(os.path.join(".agent", "skills"))
128
68
  skill_roots = {}
129
69
 
130
70
  # add .claude skills first, so they can be overridden by .agent skills
@@ -142,7 +82,13 @@ def install(ctx):
142
82
  local_skills = str(Path(local_skills).resolve())
143
83
  skill_roots[LLMS_LOCAL_SKILLS] = local_skills
144
84
 
145
- g_skills = {}
85
+ user_skills_path = None
86
+ if user:
87
+ user_skills_path = resolve_user_skills_path(ctx, user)
88
+ if os.path.exists(user_skills_path):
89
+ skill_roots[f"{user}/skills"] = user_skills_path
90
+
91
+ ret = {}
146
92
  for group, root in skill_roots.items():
147
93
  if not os.path.exists(root):
148
94
  continue
@@ -164,18 +110,55 @@ def install(ctx):
164
110
  rel_path = full_path[len(str(skill_dir)) + 1 :]
165
111
  files.append(rel_path)
166
112
 
113
+ writable = False
114
+ if ctx.is_auth_enabled():
115
+ writable = user_skills_path and is_safe_path(user_skills_path, skill_dir)
116
+ else:
117
+ writable = is_safe_path(home_skills, skill_dir) or is_safe_path(local_skills, skill_dir)
118
+
167
119
  skill_props = props.to_dict()
168
120
  skill_props.update(
169
121
  {
170
122
  "group": group,
171
123
  "location": str(skill_dir),
172
124
  "files": files,
125
+ "writable": bool(writable),
173
126
  }
174
127
  )
175
- g_skills[props.name] = skill_props
128
+ ret[props.name] = skill_props
176
129
 
177
130
  except OSError:
178
131
  pass
132
+ return ret
133
+
134
+ def assert_valid_location(ctx, location, user):
135
+ if ctx.is_auth_enabled() and not user:
136
+ raise Exception("Unauthorized")
137
+
138
+ # if user is specified, only allow modifications to skills in user directory
139
+ if user:
140
+ write_skill_path = resolve_skills_write_path(ctx, user=user)
141
+ if not is_safe_path(write_skill_path, location):
142
+ raise Exception("Cannot modify skills outside of allowed user directory")
143
+ return
144
+
145
+ home_skills_path = ctx.get_home_path(os.path.join(".agent", "skills"))
146
+ local_skills_path = os.path.join(".agent", "skills")
147
+
148
+ # Otherwise only allow modifications to skills in home or local .agent directory
149
+ if not is_safe_path(home_skills_path, location) and not is_safe_path(local_skills_path, location):
150
+ raise Exception("Cannot modify skills outside of allowed directories")
151
+
152
+ def install(ctx):
153
+ home_skills = ctx.get_home_path(os.path.join(".agent", "skills"))
154
+ # if not folder exists
155
+ if not os.path.exists(home_skills):
156
+ os.makedirs(ctx.get_home_path(os.path.join(".agent")), exist_ok=True)
157
+ ctx.log(f"Creating initial skills folder: {home_skills}")
158
+ # os.makedirs(home_skills)
159
+ # copy ui/skills to home_skills
160
+ ui_skills = os.path.join(ctx.path, "ui", "skills")
161
+ shutil.copytree(ui_skills, home_skills)
179
162
 
180
163
  g_available_skills = []
181
164
  try:
@@ -186,7 +169,8 @@ def install(ctx):
186
169
  pass
187
170
 
188
171
  async def get_skills(request):
189
- return aiohttp.web.json_response(g_skills)
172
+ skills = resolve_all_skills(ctx, user=ctx.get_username(request))
173
+ return aiohttp.web.json_response(skills)
190
174
 
191
175
  ctx.add_get("", get_skills)
192
176
 
@@ -220,35 +204,22 @@ def install(ctx):
220
204
  if not source:
221
205
  raise Exception(f"Skill '{id}' has no source repository")
222
206
 
207
+ user = ctx.assert_username(request)
208
+ write_skill_path = resolve_skills_write_path(ctx, user=user)
209
+
223
210
  # Install from GitHub
224
211
  from .installer import install_from_github
225
212
 
213
+ ctx.log(f"Installing skill '{id}' from '{source}' to '{write_skill_path}'")
226
214
  result = await install_from_github(
227
215
  repo_url=f"https://github.com/{source}.git",
228
216
  skill_names=[id],
229
- target_dir=home_skills,
217
+ target_dir=write_skill_path,
230
218
  )
231
219
 
232
220
  if not result.get("success"):
233
221
  raise Exception(result.get("error", "Installation failed"))
234
222
 
235
- # Reload the installed skills into the registry
236
- for installed in result.get("installed", []):
237
- skill_path = installed.get("path")
238
- if skill_path and os.path.exists(skill_path):
239
- skill_dir = Path(skill_path).resolve()
240
- props = read_properties(skill_dir)
241
- files = get_skill_files(skill_dir)
242
- skill_props = props.to_dict()
243
- skill_props.update(
244
- {
245
- "group": LLMS_HOME_SKILLS,
246
- "location": str(skill_dir),
247
- "files": files,
248
- }
249
- )
250
- g_skills[props.name] = skill_props
251
-
252
223
  return aiohttp.web.json_response(result)
253
224
 
254
225
  ctx.add_post("install/{id}", install_skill)
@@ -256,7 +227,8 @@ def install(ctx):
256
227
  async def get_skill(request):
257
228
  name = request.match_info.get("name")
258
229
  file = request.query.get("file")
259
- return aiohttp.web.Response(text=skill(name, file))
230
+ user = ctx.assert_username(request)
231
+ return aiohttp.web.Response(text=skill(name, file, user=user))
260
232
 
261
233
  ctx.add_get("contents/{name}", get_skill)
262
234
 
@@ -264,8 +236,10 @@ def install(ctx):
264
236
  """Get the content of a specific file in a skill."""
265
237
  name = request.match_info.get("name")
266
238
  file_path = request.match_info.get("path")
239
+ user = ctx.assert_username(request)
240
+ skills = resolve_all_skills(ctx, user=user)
267
241
 
268
- skill_info = g_skills.get(name)
242
+ skill_info = skills.get(name)
269
243
  if not skill_info:
270
244
  raise Exception(f"Skill '{name}' not found")
271
245
 
@@ -288,7 +262,7 @@ def install(ctx):
288
262
  ctx.add_get("file/{name}/{path:.*}", get_file_content)
289
263
 
290
264
  async def save_file(request):
291
- """Save/update a file in a skill. Only works for skills in home directory."""
265
+ """Save/update a file in a skill. Only works for skills in user home or local directory."""
292
266
  name = request.match_info.get("name")
293
267
 
294
268
  try:
@@ -302,15 +276,15 @@ def install(ctx):
302
276
  if not file_path or content is None:
303
277
  raise Exception("Missing 'path' or 'content' in request body")
304
278
 
305
- skill_info = g_skills.get(name)
279
+ user = ctx.assert_username(request)
280
+ skills = resolve_all_skills(ctx, user=user)
281
+ skill_info = skills.get(name)
306
282
  if not skill_info:
307
283
  raise Exception(f"Skill '{name}' not found")
308
284
 
309
285
  location = skill_info.get("location")
310
286
 
311
- # Only allow modifications to skills in home or local .agent directory
312
- if not is_safe_path(home_skills, location) and not (local_skills and is_safe_path(local_skills, location)):
313
- raise Exception("Cannot modify skills outside of allowed directories")
287
+ assert_valid_location(ctx, location, user)
314
288
 
315
289
  full_path = os.path.join(location, file_path)
316
290
 
@@ -324,10 +298,10 @@ def install(ctx):
324
298
  f.write(content)
325
299
 
326
300
  # Reload skill metadata
327
- group = skill_info.get("group", LLMS_HOME_SKILLS)
328
- updated_skill = reload_skill(name, location, group)
301
+ skills = resolve_all_skills(ctx, user=user)
302
+ skill_info = skills.get(name)
329
303
 
330
- return aiohttp.web.json_response({"path": file_path, "skill": updated_skill})
304
+ return aiohttp.web.json_response({"path": file_path, "skill": skill_info})
331
305
  except Exception as e:
332
306
  raise Exception(str(e)) from e
333
307
 
@@ -341,15 +315,14 @@ def install(ctx):
341
315
  if not file_path:
342
316
  raise Exception("Missing 'path' query parameter")
343
317
 
344
- skill_info = g_skills.get(name)
318
+ user = ctx.assert_username(request)
319
+ skills = resolve_all_skills(ctx, user=user)
320
+ skill_info = skills.get(name)
345
321
  if not skill_info:
346
322
  raise Exception(f"Skill '{name}' not found")
347
323
 
348
324
  location = skill_info.get("location")
349
-
350
- # Only allow modifications to skills in home or local .agent directory
351
- if not is_safe_path(home_skills, location) and not (local_skills and is_safe_path(local_skills, location)):
352
- raise Exception("Cannot modify skills outside of allowed directories")
325
+ assert_valid_location(ctx, location, user)
353
326
 
354
327
  full_path = os.path.join(location, file_path)
355
328
 
@@ -376,10 +349,10 @@ def install(ctx):
376
349
  break
377
350
 
378
351
  # Reload skill metadata
379
- group = skill_info.get("group", LLMS_HOME_SKILLS)
380
- updated_skill = reload_skill(name, location, group)
352
+ skills = resolve_all_skills(ctx, user=user)
353
+ skill_info = skills.get(name)
381
354
 
382
- return aiohttp.web.json_response({"path": file_path, "skill": updated_skill})
355
+ return aiohttp.web.json_response({"path": file_path, "skill": skill_info})
383
356
  except Exception as e:
384
357
  raise Exception(str(e)) from e
385
358
 
@@ -405,7 +378,9 @@ def install(ctx):
405
378
  if len(skill_name) > 40:
406
379
  raise Exception("Skill name must be 40 characters or less")
407
380
 
408
- skill_dir = os.path.join(home_skills, skill_name)
381
+ user = ctx.assert_username(request)
382
+ write_skill_path = resolve_skills_write_path(ctx, user=user)
383
+ skill_dir = os.path.join(write_skill_path, skill_name)
409
384
 
410
385
  if os.path.exists(skill_dir):
411
386
  raise Exception(f"Skill '{skill_name}' already exists")
@@ -419,8 +394,9 @@ def install(ctx):
419
394
  try:
420
395
  import subprocess
421
396
 
397
+ ctx.log(f"Creating skill '{skill_name}' in '{write_skill_path}'")
422
398
  result = subprocess.run(
423
- [sys.executable, init_script, skill_name, "--path", home_skills],
399
+ [sys.executable, init_script, skill_name, "--path", write_skill_path],
424
400
  capture_output=True,
425
401
  text=True,
426
402
  timeout=30,
@@ -431,21 +407,9 @@ def install(ctx):
431
407
 
432
408
  # Load the new skill
433
409
  if os.path.exists(skill_dir):
434
- skill_dir_path = Path(skill_dir).resolve()
435
- props = read_properties(skill_dir_path)
436
- files = get_skill_files(skill_dir_path)
437
-
438
- skill_props = props.to_dict()
439
- skill_props.update(
440
- {
441
- "group": LLMS_HOME_SKILLS,
442
- "location": str(skill_dir_path),
443
- "files": files,
444
- }
445
- )
446
- g_skills[props.name] = skill_props
447
-
448
- return aiohttp.web.json_response({"skill": skill_props, "output": result.stdout})
410
+ skills = resolve_all_skills(ctx, user=user)
411
+ skill_info = skills.get(skill_name)
412
+ return aiohttp.web.json_response({"skill": skill_info, "output": result.stdout})
449
413
 
450
414
  raise Exception("Skill directory not created")
451
415
 
@@ -460,27 +424,26 @@ def install(ctx):
460
424
  """Delete an entire skill. Only works for skills in home directory."""
461
425
  name = request.match_info.get("name")
462
426
 
463
- skill_info = g_skills.get(name)
427
+ user = ctx.assert_username(request)
428
+ skills = resolve_all_skills(ctx, user=user)
429
+ skill_info = skills.get(name)
464
430
 
465
431
  if skill_info:
466
432
  location = skill_info.get("location")
467
433
  else:
468
- # Check if orphaned directory exists on disk (not loaded in g_skills)
434
+ # Check if orphaned directory exists on disk (not loaded in skills)
469
435
  potential_location = os.path.join(home_skills, name)
470
- if os.path.exists(potential_location) and is_safe_path(home_skills, potential_location):
436
+ if os.path.exists(potential_location):
471
437
  location = potential_location
472
438
  else:
473
439
  raise Exception(f"Skill '{name}' not found")
474
440
 
475
- # Only allow deletion of skills in home or local .agent directory
476
- if not is_safe_path(home_skills, location) and not (local_skills and is_safe_path(local_skills, location)):
477
- raise Exception("Cannot delete skills outside of allowed directories")
441
+ # Only allow deletion of skills in allowed directories
442
+ assert_valid_location(ctx, location, user)
478
443
 
479
444
  try:
480
445
  if os.path.exists(location):
481
446
  shutil.rmtree(location)
482
- if name in g_skills:
483
- del g_skills[name]
484
447
 
485
448
  return aiohttp.web.json_response({"deleted": name})
486
449
  except Exception as e:
@@ -488,6 +451,42 @@ def install(ctx):
488
451
 
489
452
  ctx.add_delete("skill/{name}", delete_skill)
490
453
 
454
+ def skill(name: Annotated[str, "skill name"], file: Annotated[str | None, "skill file"] = None, user=None):
455
+ """Get the content of a skill or a specific file within a skill."""
456
+ ctx.log(f"skill tool '{name}', file='{file}', user='{user}'")
457
+
458
+ skills = resolve_all_skills(ctx, user=user)
459
+ skill = skills.get(name)
460
+
461
+ if not skill:
462
+ sanitized_name = sanitize(name)
463
+ for k, v in skills.items():
464
+ if sanitize(k) == sanitized_name:
465
+ skill = v
466
+ break
467
+
468
+ if not skill:
469
+ return f"Error: Skill {name} not found. Available skills: {', '.join(skills.keys())}"
470
+ location = skill.get("location")
471
+ if not location or not os.path.exists(location):
472
+ return f"Error: Skill {name} not found at location {location}"
473
+
474
+ if file:
475
+ if file.startswith(location):
476
+ file = file[len(location) + 1 :]
477
+ if not os.path.exists(os.path.join(location, file)):
478
+ return f"Error: File {file} not found in skill {name}. Available files: {', '.join(skill.get('files', []))}"
479
+ with open(os.path.join(location, file)) as f:
480
+ return f.read()
481
+
482
+ with open(os.path.join(location, "SKILL.md")) as f:
483
+ content = f.read()
484
+
485
+ files = skill.get("files")
486
+ if files and len(files) > 1:
487
+ content += "\n\n## Skill Files:\n```\n"
488
+ return content
489
+
491
490
  ctx.register_tool(skill, group="core_tools")
492
491
 
493
492
 
@@ -3,9 +3,6 @@ import { leftPart } from "@servicestack/client"
3
3
 
4
4
  let ext
5
5
 
6
- const LLMS_HOME_SKILLS = "~/.llms/.agent/skills"
7
- const LLMS_LOCAL_SKILLS = ".agent/skills"
8
-
9
6
  const SkillSelector = {
10
7
  template: `
11
8
  <div class="px-4 py-4 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 max-h-[80vh] overflow-y-auto">
@@ -122,10 +119,10 @@ const SkillSelector = {
122
119
  skills
123
120
  }))
124
121
 
125
- // Sort groups: writable (~/.llms/.agent/skills,.agent/skills) first, then alphabetically
122
+ // Sort groups: writable first, then alphabetically
126
123
  definedGroups.sort((a, b) => {
127
- const aEditable = a.name === LLMS_HOME_SKILLS || a.name === LLMS_LOCAL_SKILLS
128
- const bEditable = b.name === LLMS_HOME_SKILLS || b.name === LLMS_LOCAL_SKILLS
124
+ const aEditable = a.skills.some(s => s.writable)
125
+ const bEditable = b.skills.some(s => s.writable)
129
126
  if (aEditable !== bEditable) return aEditable ? -1 : 1
130
127
  return a.name.localeCompare(b.name)
131
128
  })
@@ -392,6 +389,7 @@ const SkillPage = {
392
389
  const editorRef = ref(null)
393
390
  const expandedSkills = ref({})
394
391
  const skills = computed(() => ctx.state.skills || {})
392
+
395
393
  const skillGroups = computed(() => {
396
394
  const grouped = {}
397
395
  const query = searchQuery.value.toLowerCase()
@@ -402,8 +400,8 @@ const SkillPage = {
402
400
  grouped[group].push(skill)
403
401
  })
404
402
  return Object.entries(grouped).sort((a, b) => {
405
- const aEditable = a[0] === LLMS_HOME_SKILLS || a[0] === LLMS_LOCAL_SKILLS
406
- const bEditable = b[0] === LLMS_HOME_SKILLS || b[0] === LLMS_LOCAL_SKILLS
403
+ const aEditable = a[1].some(s => s.writable)
404
+ const bEditable = b[1].some(s => s.writable)
407
405
  if (aEditable !== bEditable) return aEditable ? -1 : 1
408
406
  return a[0].localeCompare(b[0])
409
407
  }).map(([name, skills]) => ({ name, skills: skills.sort((a, b) => a.name.localeCompare(b.name)) }))
@@ -426,8 +424,8 @@ const SkillPage = {
426
424
  return tree.sort((a, b) => { if (a.isFile !== b.isFile) return a.isFile ? 1 : -1; return a.name.localeCompare(b.name) })
427
425
  }
428
426
  const hasUnsavedChanges = computed(() => isEditing.value && editContent.value !== fileContent.value)
429
- function isGroupEditable(groupName) { return groupName === LLMS_HOME_SKILLS || groupName === LLMS_LOCAL_SKILLS }
430
- function isEditable(skill) { return skill?.group === LLMS_HOME_SKILLS || skill?.group === LLMS_LOCAL_SKILLS }
427
+ function isGroupEditable(groupName) { return Object.values(skills.value).some(s => s.group === groupName && s.writable) }
428
+ function isEditable(skill) { return skill?.writable }
431
429
  function isSkillExpanded(name) { return !!expandedSkills.value[name] }
432
430
  function toggleSkillExpand(skill) {
433
431
  expandedSkills.value[skill.name] = !expandedSkills.value[skill.name]
llms/llms.json CHANGED
@@ -1,14 +1,5 @@
1
1
  {
2
2
  "version": 3,
3
- "auth": {
4
- "enabled": false,
5
- "github": {
6
- "client_id": "GITHUB_CLIENT_ID",
7
- "client_secret": "GITHUB_CLIENT_SECRET",
8
- "redirect_uri": "http://localhost:8000/auth/github/callback",
9
- "restrict_to": "GITHUB_USERS"
10
- }
11
- },
12
3
  "disable_extensions": [],
13
4
  "defaults": {
14
5
  "headers": {
@@ -133,6 +124,7 @@
133
124
  }
134
125
  },
135
126
  "limits": {
127
+ "client_timeout": 120,
136
128
  "client_max_size": 20971520
137
129
  },
138
130
  "convert": {