Skip to content

Commit 27d93f8

Browse files
committed
Add ability to manage create/edit and delete skills
1 parent bde76b9 commit 27d93f8

4 files changed

Lines changed: 707 additions & 8 deletions

File tree

llms/extensions/skills/__init__.py

Lines changed: 282 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import json
12
import os
23
import shutil
4+
import sys
35
from pathlib import Path
46
from typing import Annotated
57

@@ -8,6 +10,53 @@
810
from .parser import read_properties
911

1012
g_skills = {}
13+
g_home_skills = None
14+
15+
16+
def is_safe_path(base_path: str, requested_path: str) -> bool:
17+
"""Check if the requested path is safely within the base path."""
18+
base = Path(base_path).resolve()
19+
target = Path(requested_path).resolve()
20+
try:
21+
target.relative_to(base)
22+
return True
23+
except ValueError:
24+
return False
25+
26+
27+
def get_skill_files(skill_dir: Path) -> list:
28+
"""Get list of all files in a skill directory."""
29+
files = []
30+
for file in skill_dir.glob("**/*"):
31+
if file.is_file():
32+
full_path = str(file)
33+
rel_path = full_path[len(str(skill_dir)) + 1 :]
34+
files.append(rel_path)
35+
return files
36+
37+
38+
def reload_skill(name: str, location: str, group: str):
39+
"""Reload a single skill's metadata."""
40+
global g_skills
41+
skill_dir = Path(location).resolve()
42+
if not skill_dir.exists():
43+
if name in g_skills:
44+
del g_skills[name]
45+
return None
46+
47+
props = read_properties(skill_dir)
48+
files = get_skill_files(skill_dir)
49+
50+
skill_props = props.to_dict()
51+
skill_props.update(
52+
{
53+
"group": group,
54+
"location": str(skill_dir),
55+
"files": files,
56+
}
57+
)
58+
g_skills[props.name] = skill_props
59+
return skill_props
1160

1261

1362
def sanitize(name: str) -> str:
@@ -52,8 +101,9 @@ def skill(name: Annotated[str, "skill name"], file: Annotated[str | None, "skill
52101

53102

54103
def install(ctx):
55-
global g_skills
104+
global g_skills, g_home_skills
56105
home_skills = ctx.get_home_path(os.path.join(".agent", "skills"))
106+
g_home_skills = home_skills
57107
# if not folder exists
58108
if not os.path.exists(home_skills):
59109
os.makedirs(ctx.get_home_path(os.path.join(".agent")), exist_ok=True)
@@ -66,8 +116,9 @@ def install(ctx):
66116
skill_roots = {}
67117

68118
# 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"
119+
claude_skills = os.path.expanduser("~/.claude/skills")
120+
if os.path.exists(claude_skills):
121+
skill_roots["~/.claude/skills"] = claude_skills
71122

72123
if os.path.exists(os.path.join(".claude", "skills")):
73124
skill_roots[".claude/skills"] = os.path.join(".claude", "skills")
@@ -124,6 +175,234 @@ async def get_skill(request):
124175

125176
ctx.add_get("contents/{name}", get_skill)
126177

178+
async def get_file_content(request):
179+
"""Get the content of a specific file in a skill."""
180+
name = request.match_info.get("name")
181+
file_path = request.match_info.get("path")
182+
183+
skill_info = g_skills.get(name)
184+
if not skill_info:
185+
raise Exception(f"Skill '{name}' not found")
186+
187+
location = skill_info.get("location")
188+
full_path = os.path.join(location, file_path)
189+
190+
if not is_safe_path(location, full_path):
191+
raise Exception("Invalid file path")
192+
193+
if not os.path.exists(full_path):
194+
raise Exception(f"File '{file_path}' not found")
195+
196+
try:
197+
with open(full_path, encoding="utf-8") as f:
198+
content = f.read()
199+
return aiohttp.web.json_response({"content": content, "path": file_path})
200+
except Exception as e:
201+
raise Exception(str(e)) from e
202+
203+
ctx.add_get("file/{name}/{path:.*}", get_file_content)
204+
205+
async def save_file(request):
206+
"""Save/update a file in a skill. Only works for skills in home directory."""
207+
name = request.match_info.get("name")
208+
209+
try:
210+
data = await request.json()
211+
except json.JSONDecodeError:
212+
raise Exception("Invalid JSON body") from None
213+
214+
file_path = data.get("path")
215+
content = data.get("content")
216+
217+
if not file_path or content is None:
218+
raise Exception("Missing 'path' or 'content' in request body")
219+
220+
skill_info = g_skills.get(name)
221+
if not skill_info:
222+
raise Exception(f"Skill '{name}' not found")
223+
224+
location = skill_info.get("location")
225+
226+
# Only allow modifications to skills in home directory
227+
if not is_safe_path(home_skills, location):
228+
raise Exception("Cannot modify skills outside of home directory")
229+
230+
full_path = os.path.join(location, file_path)
231+
232+
if not is_safe_path(location, full_path):
233+
raise Exception("Invalid file path")
234+
235+
try:
236+
# Create parent directories if they don't exist
237+
os.makedirs(os.path.dirname(full_path), exist_ok=True)
238+
with open(full_path, "w", encoding="utf-8") as f:
239+
f.write(content)
240+
241+
# Reload skill metadata
242+
group = skill_info.get("group", "~/.llms/.agents")
243+
updated_skill = reload_skill(name, location, group)
244+
245+
return aiohttp.web.json_response({"path": file_path, "skill": updated_skill})
246+
except Exception as e:
247+
raise Exception(str(e)) from e
248+
249+
ctx.add_post("file/{name}", save_file)
250+
251+
async def delete_file(request):
252+
"""Delete a file from a skill. Only works for skills in home directory."""
253+
name = request.match_info.get("name")
254+
file_path = request.query.get("path")
255+
256+
if not file_path:
257+
raise Exception("Missing 'path' query parameter")
258+
259+
skill_info = g_skills.get(name)
260+
if not skill_info:
261+
raise Exception(f"Skill '{name}' not found")
262+
263+
location = skill_info.get("location")
264+
265+
# Only allow modifications to skills in home directory
266+
if not is_safe_path(home_skills, location):
267+
raise Exception("Cannot modify skills outside of home directory")
268+
269+
full_path = os.path.join(location, file_path)
270+
271+
if not is_safe_path(location, full_path):
272+
raise Exception("Invalid file path")
273+
274+
# Prevent deleting SKILL.md
275+
if file_path.lower() == "skill.md":
276+
raise Exception("Cannot delete SKILL.md - delete the entire skill instead")
277+
278+
if not os.path.exists(full_path):
279+
raise Exception(f"File '{file_path}' not found")
280+
281+
try:
282+
os.remove(full_path)
283+
284+
# Clean up empty parent directories
285+
parent = os.path.dirname(full_path)
286+
while parent != location:
287+
if os.path.isdir(parent) and not os.listdir(parent):
288+
os.rmdir(parent)
289+
parent = os.path.dirname(parent)
290+
else:
291+
break
292+
293+
# Reload skill metadata
294+
group = skill_info.get("group", "~/.llms/.agents")
295+
updated_skill = reload_skill(name, location, group)
296+
297+
return aiohttp.web.json_response({"path": file_path, "skill": updated_skill})
298+
except Exception as e:
299+
raise Exception(str(e)) from e
300+
301+
ctx.add_delete("file/{name}", delete_file)
302+
303+
async def create_skill(request):
304+
"""Create a new skill using the skill-creator template."""
305+
try:
306+
data = await request.json()
307+
except json.JSONDecodeError:
308+
raise Exception("Invalid JSON body") from None
309+
310+
skill_name = data.get("name")
311+
if not skill_name:
312+
raise Exception("Missing 'name' in request body")
313+
314+
# Validate skill name format
315+
import re
316+
317+
if not re.match(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$", skill_name):
318+
raise Exception("Skill name must be lowercase, use hyphens, start/end with alphanumeric")
319+
320+
if len(skill_name) > 40:
321+
raise Exception("Skill name must be 40 characters or less")
322+
323+
skill_dir = os.path.join(home_skills, skill_name)
324+
325+
if os.path.exists(skill_dir):
326+
raise Exception(f"Skill '{skill_name}' already exists")
327+
328+
# Use init_skill.py from skill-creator
329+
init_script = os.path.join(ctx.path, "ui", "skills", "skill-creator", "scripts", "init_skill.py")
330+
331+
if not os.path.exists(init_script):
332+
raise Exception("skill-creator not found")
333+
334+
try:
335+
import subprocess
336+
337+
result = subprocess.run(
338+
[sys.executable, init_script, skill_name, "--path", home_skills],
339+
capture_output=True,
340+
text=True,
341+
timeout=30,
342+
)
343+
344+
if result.returncode != 0:
345+
raise Exception(f"Failed to create skill: {result.stderr}")
346+
347+
# Load the new skill
348+
if os.path.exists(skill_dir):
349+
skill_dir_path = Path(skill_dir).resolve()
350+
props = read_properties(skill_dir_path)
351+
files = get_skill_files(skill_dir_path)
352+
353+
skill_props = props.to_dict()
354+
skill_props.update(
355+
{
356+
"group": "~/.llms/.agents",
357+
"location": str(skill_dir_path),
358+
"files": files,
359+
}
360+
)
361+
g_skills[props.name] = skill_props
362+
363+
return aiohttp.web.json_response({"skill": skill_props, "output": result.stdout})
364+
365+
raise Exception("Skill directory not created")
366+
367+
except subprocess.TimeoutExpired:
368+
raise Exception("Skill creation timed out") from None
369+
except Exception as e:
370+
raise Exception(str(e)) from e
371+
372+
ctx.add_post("create", create_skill)
373+
374+
async def delete_skill(request):
375+
"""Delete an entire skill. Only works for skills in home directory."""
376+
name = request.match_info.get("name")
377+
378+
skill_info = g_skills.get(name)
379+
380+
if skill_info:
381+
location = skill_info.get("location")
382+
else:
383+
# Check if orphaned directory exists on disk (not loaded in g_skills)
384+
potential_location = os.path.join(home_skills, name)
385+
if os.path.exists(potential_location) and is_safe_path(home_skills, potential_location):
386+
location = potential_location
387+
else:
388+
raise Exception(f"Skill '{name}' not found")
389+
390+
# Only allow deletion of skills in home directory
391+
if not is_safe_path(home_skills, location):
392+
raise Exception("Cannot delete skills outside of home directory")
393+
394+
try:
395+
if os.path.exists(location):
396+
shutil.rmtree(location)
397+
if name in g_skills:
398+
del g_skills[name]
399+
400+
return aiohttp.web.json_response({"deleted": name})
401+
except Exception as e:
402+
raise Exception(str(e)) from e
403+
404+
ctx.add_delete("skill/{name}", delete_skill)
405+
127406
ctx.register_tool(skill, group="core_tools")
128407

129408

0 commit comments

Comments
 (0)