1+ import json
12import os
23import shutil
4+ import sys
35from pathlib import Path
46from typing import Annotated
57
810from .parser import read_properties
911
1012g_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
1362def sanitize (name : str ) -> str :
@@ -52,8 +101,9 @@ def skill(name: Annotated[str, "skill name"], file: Annotated[str | None, "skill
52101
53102
54103def 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