@@ -35,9 +35,25 @@ public function write(Skill $skill): int
3535 throw new RuntimeException ("Invalid skill name: {$ skill ->name }" );
3636 }
3737
38- $ targetPath = base_path ($ this ->agent ->skillsPath ().'/ ' .$ skill ->name );
38+ $ targetPath = base_path ($ this ->agent ->skillsPath ().DIRECTORY_SEPARATOR .$ skill ->name );
39+ $ canonicalPath = base_path ('.ai ' .DIRECTORY_SEPARATOR .'skills ' .DIRECTORY_SEPARATOR .$ skill ->name );
40+ $ existed = $ this ->pathExists ($ targetPath );
3941
40- $ existed = is_dir ($ targetPath );
42+ if (! $ skill ->custom ) {
43+ return $ this ->writeNonCustomSkill ($ skill , $ targetPath , $ canonicalPath , $ existed );
44+ }
45+
46+ return $ this ->writeCustomSkill ($ skill , $ targetPath , $ canonicalPath , $ existed );
47+ }
48+
49+ protected function writeNonCustomSkill (Skill $ skill , string $ targetPath , string $ canonicalPath , bool $ existed ): int
50+ {
51+ $ canonicalExists = $ this ->pathExists ($ canonicalPath );
52+ $ needsCanonicalUpdate = $ canonicalExists && ! $ this ->pathsMatch ($ skill ->path , $ canonicalPath );
53+
54+ if ($ needsCanonicalUpdate && ! $ this ->copyDirectory ($ skill ->path , $ canonicalPath )) {
55+ return self ::FAILED ;
56+ }
4157
4258 if (! $ this ->copyDirectory ($ skill ->path , $ targetPath )) {
4359 return self ::FAILED ;
@@ -46,6 +62,28 @@ public function write(Skill $skill): int
4662 return $ existed ? self ::UPDATED : self ::SUCCESS ;
4763 }
4864
65+ protected function writeCustomSkill (Skill $ skill , string $ targetPath , string $ canonicalPath , bool $ existed ): int
66+ {
67+ if (! $ this ->pathsMatch ($ skill ->path , $ canonicalPath ) && ! $ this ->copyDirectory ($ skill ->path , $ canonicalPath )) {
68+ return self ::FAILED ;
69+ }
70+
71+ if (! $ this ->ensureDirectoryExists (dirname ($ targetPath ))) {
72+ return self ::FAILED ;
73+ }
74+
75+ if (! $ this ->createSymlink ($ canonicalPath , $ targetPath ) && ! $ this ->copyDirectory ($ skill ->path , $ targetPath )) {
76+ return self ::FAILED ;
77+ }
78+
79+ return $ existed ? self ::UPDATED : self ::SUCCESS ;
80+ }
81+
82+ protected function pathExists (string $ path ): bool
83+ {
84+ return is_dir ($ path ) || is_link ($ path );
85+ }
86+
4987 /**
5088 * @param Collection<string, Skill> $skills
5189 * @return array<string, int>
@@ -81,9 +119,9 @@ public function remove(string $skillName): bool
81119 return false ;
82120 }
83121
84- $ targetPath = base_path ($ this ->agent ->skillsPath ().' / ' .$ skillName );
122+ $ targetPath = base_path ($ this ->agent ->skillsPath ().DIRECTORY_SEPARATOR .$ skillName );
85123
86- if (! is_dir ($ targetPath )) {
124+ if (! $ this -> pathExists ($ targetPath )) {
87125 return true ;
88126 }
89127
@@ -107,6 +145,24 @@ public function removeStale(array $skillNames): array
107145
108146 protected function deleteDirectory (string $ path ): bool
109147 {
148+ if (is_link ($ path )) {
149+ if (@unlink ($ path )) {
150+ return true ;
151+ }
152+
153+ // On Windows, directory symlinks can require rmdir instead of unlink,
154+ // even when the symlink target no longer exists (dangling symlinks).
155+ if (@rmdir ($ path )) {
156+ return true ;
157+ }
158+
159+ return ! file_exists ($ path ) && ! is_link ($ path );
160+ }
161+
162+ if (is_file ($ path )) {
163+ return @unlink ($ path );
164+ }
165+
110166 if (! is_dir ($ path )) {
111167 return false ;
112168 }
@@ -117,10 +173,20 @@ protected function deleteDirectory(string $path): bool
117173 );
118174
119175 foreach ($ files as $ file ) {
120- $ file ->isDir () ? @rmdir ($ file ->getRealPath ()) : @unlink ($ file ->getRealPath ());
176+ if ($ file ->isLink ()) {
177+ $ linkPath = $ file ->getPathname ();
178+
179+ if (! @unlink ($ linkPath ) && is_dir ($ linkPath )) {
180+ @rmdir ($ linkPath );
181+ }
182+
183+ continue ;
184+ }
185+
186+ $ file ->isDir () ? @rmdir ($ file ->getPathname ()) : @unlink ($ file ->getPathname ());
121187 }
122188
123- return @rmdir ($ path );
189+ return @rmdir ($ path ) || ! is_dir ( $ path ) ;
124190 }
125191
126192 protected function copyDirectory (string $ source , string $ target ): bool
@@ -152,7 +218,7 @@ protected function copyDirectory(string $source, string $target): bool
152218 protected function copyFile (SplFileInfo $ file , string $ targetDir ): bool
153219 {
154220 $ relativePath = $ file ->getRelativePathname ();
155- $ targetFile = $ targetDir .' / ' .$ relativePath ;
221+ $ targetFile = $ targetDir .DIRECTORY_SEPARATOR .$ relativePath ;
156222
157223 if (! $ this ->ensureDirectoryExists (dirname ($ targetFile ))) {
158224 return false ;
@@ -186,6 +252,51 @@ protected function ensureDirectoryExists(string $path): bool
186252 return is_dir ($ path ) || @mkdir ($ path , 0755 , true );
187253 }
188254
255+ protected function createSymlink (string $ target , string $ link ): bool
256+ {
257+ $ resolvedTarget = realpath ($ target ) ?: $ target ;
258+ $ resolvedLink = realpath ($ link ) ?: $ link ;
259+
260+ if ($ this ->pathsMatch ($ resolvedTarget , $ resolvedLink )) {
261+ return true ;
262+ }
263+
264+ if (file_exists ($ link ) || is_link ($ link )) {
265+ $ this ->deleteDirectory ($ link );
266+ }
267+
268+ if (! $ this ->ensureDirectoryExists (dirname ($ link ))) {
269+ return false ;
270+ }
271+
272+ return @symlink ($ this ->relativePath ($ resolvedTarget , dirname ($ link )), $ link );
273+ }
274+
275+ protected function pathsMatch (string $ left , string $ right ): bool
276+ {
277+ $ resolvedLeft = realpath ($ left ) ?: $ left ;
278+ $ resolvedRight = realpath ($ right ) ?: $ right ;
279+
280+ return rtrim ($ resolvedLeft , DIRECTORY_SEPARATOR ) === rtrim ($ resolvedRight , DIRECTORY_SEPARATOR );
281+ }
282+
283+ protected function relativePath (string $ target , string $ from ): string
284+ {
285+ $ base = rtrim (str_replace ('\\' , '/ ' , base_path ()), '/ ' );
286+ $ resolvedTarget = str_replace ('\\' , '/ ' , realpath ($ target ) ?: $ target );
287+ $ resolvedFrom = str_replace ('\\' , '/ ' , realpath ($ from ) ?: $ from );
288+
289+ if (! str_starts_with ($ resolvedTarget , $ base .'/ ' ) || ! str_starts_with ($ resolvedFrom , $ base .'/ ' )) {
290+ return $ resolvedTarget ;
291+ }
292+
293+ $ targetRel = ltrim (substr ($ resolvedTarget , strlen ($ base )), '/ ' );
294+ $ fromRel = ltrim (substr ($ resolvedFrom , strlen ($ base )), '/ ' );
295+ $ depth = $ fromRel === '' ? 0 : count (explode ('/ ' , $ fromRel ));
296+
297+ return str_repeat ('../ ' , $ depth ).$ targetRel ;
298+ }
299+
189300 protected function isValidSkillName (string $ name ): bool
190301 {
191302 $ hasPathTraversal = str_contains ($ name , '.. ' ) || str_contains ($ name , '/ ' ) || str_contains ($ name , '\\' );
0 commit comments