Skip to content

Commit 663a3a6

Browse files
hosmelqpushpak1300
andauthored
Add symlink install mode for skills (#499)
* feat(skills): add symlink install mode * feat(skills): always symlink skills to .ai * fix(skills): only symlink custom skills * Formatting Signed-off-by: Pushpak Chhajed <pushpak1300@gmail.com> * Refactor path handling in SkillWriter to use DIRECTORY_SEPARATOR * Add more test * Refactor --------- Signed-off-by: Pushpak Chhajed <pushpak1300@gmail.com> Co-authored-by: Pushpak Chhajed <pushpak1300@gmail.com> Co-authored-by: Pushpak Chhajed <pushpak@laravel.com>
1 parent 696d9b6 commit 663a3a6

File tree

2 files changed

+494
-8
lines changed

2 files changed

+494
-8
lines changed

src/Install/SkillWriter.php

Lines changed: 118 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)