diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index 05dd3223..e9513665 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -42,7 +42,8 @@ class InstallCommand extends Command protected $signature = 'boost:install {--guidelines : Install AI guidelines} {--skills : Install agent skills} - {--mcp : Install MCP server configuration}'; + {--mcp : Install MCP server configuration} + {--fresh : Delete each agent\'s generated skills directory before installing}'; /** @var Collection */ private Collection $selectedAgents; @@ -395,7 +396,7 @@ protected function installSkills(): void emptyMessage: 'No agents are selected for skill installation.', headerMessage: sprintf('Syncing %d skills for skills-capable agents', $skills->count()), nameResolver: fn (SupportsSkills&Agent $agent): string => $agent->displayName(), - processor: fn (SupportsSkills&Agent $agent): array => (new SkillWriter($agent))->sync($skills, $this->config->getSkills()), + processor: fn (SupportsSkills&Agent $agent): array => (new SkillWriter($agent))->sync($skills, $this->config->getSkills(), (bool) $this->option('fresh')), featureName: 'skills', beforeProcess: $skills->isNotEmpty() ? fn () => grid($skills->map(fn (Skill $skill): string => $skill->displayName())->sort()->values()->toArray()) diff --git a/src/Console/UpdateCommand.php b/src/Console/UpdateCommand.php index 1b1a58db..326c5f57 100644 --- a/src/Console/UpdateCommand.php +++ b/src/Console/UpdateCommand.php @@ -18,7 +18,8 @@ class UpdateCommand extends Command /** @var string */ protected $signature = 'boost:update {--discover : Discover and prompt for newly available guidelines and skills} - {--ignore-skills : Skip updating the skills directory}'; + {--ignore-skills : Skip updating the skills directory} + {--fresh : Delete each agent\'s generated skills directory and rebuild it from scratch}'; public function handle(Config $config): int { @@ -33,7 +34,7 @@ public function handle(Config $config): int } $guidelines = $config->getGuidelines(); - $hasSkills = ! $this->option('ignore-skills') && ($config->hasSkills() || is_dir(base_path('.ai/skills'))); + $hasSkills = ! $this->option('ignore-skills') && ($config->hasSkills() || is_dir(base_path('.ai/skills')) || $this->option('fresh')); if (! $guidelines && ! $hasSkills) { return self::SUCCESS; @@ -43,6 +44,7 @@ public function handle(Config $config): int '--no-interaction' => true, '--guidelines' => $guidelines, '--skills' => $hasSkills, + '--fresh' => (bool) $this->option('fresh'), ]); $this->info('Boost guidelines and skills updated successfully.'); diff --git a/src/Install/SkillWriter.php b/src/Install/SkillWriter.php index 547ac81a..7990da28 100644 --- a/src/Install/SkillWriter.php +++ b/src/Install/SkillWriter.php @@ -108,8 +108,15 @@ public function writeAll(Collection $skills): array * @param array $previouslyTrackedSkills * @return array */ - public function sync(Collection $skills, array $previouslyTrackedSkills = []): array + public function sync(Collection $skills, array $previouslyTrackedSkills = [], bool $fresh = false): array { + $targetRoot = base_path($this->agent->skillsPath()); + $canonicalRoot = base_path('.ai'.DIRECTORY_SEPARATOR.'skills'); + + if ($fresh && ! $this->pathsMatch($targetRoot, $canonicalRoot)) { + $this->deleteDirectory($targetRoot); + } + $written = $this->writeAll($skills); $newSkillNames = $skills->keys()->all(); @@ -154,17 +161,7 @@ public function removeStale(array $skillNames): array protected function deleteDirectory(string $path): bool { if (is_link($path)) { - if (@unlink($path)) { - return true; - } - - // On Windows, directory symlinks can require rmdir instead of unlink, - // even when the symlink target no longer exists (dangling symlinks). - if (@rmdir($path)) { - return true; - } - - return ! file_exists($path) && ! is_link($path); + return $this->deleteSymlink($path); } if (is_file($path)) { @@ -182,11 +179,7 @@ protected function deleteDirectory(string $path): bool foreach ($files as $file) { if ($file->isLink()) { - $linkPath = $file->getPathname(); - - if (! @unlink($linkPath) && is_dir($linkPath)) { - @rmdir($linkPath); - } + $this->deleteSymlink($file->getPathname()); continue; } @@ -197,6 +190,21 @@ protected function deleteDirectory(string $path): bool return @rmdir($path) || ! is_dir($path); } + protected function deleteSymlink(string $path): bool + { + if (@unlink($path)) { + return true; + } + + // On Windows, directory symlinks can require rmdir instead of unlink, + // even when the symlink target no longer exists (dangling symlinks). + if (@rmdir($path)) { + return true; + } + + return ! file_exists($path) && ! is_link($path); + } + protected function copyDirectory(string $source, string $target): bool { if (! is_dir($source)) { diff --git a/tests/Feature/Console/UpdateCommandTest.php b/tests/Feature/Console/UpdateCommandTest.php index 69c99ad3..d6d9f676 100644 --- a/tests/Feature/Console/UpdateCommandTest.php +++ b/tests/Feature/Console/UpdateCommandTest.php @@ -76,12 +76,14 @@ $command = Mockery::mock(UpdateCommand::class)->makePartial(); $command->shouldReceive('option')->with('discover')->andReturn(false); $command->shouldReceive('option')->with('ignore-skills')->andReturn(false); + $command->shouldReceive('option')->with('fresh')->andReturn(false); $command->shouldReceive('callSilently') ->once() ->with(InstallCommand::class, [ '--no-interaction' => true, '--guidelines' => true, '--skills' => false, + '--fresh' => false, ]) ->andReturn(0); @@ -103,12 +105,14 @@ $command = Mockery::mock(UpdateCommand::class)->makePartial(); $command->shouldReceive('option')->with('discover')->andReturn(false); $command->shouldReceive('option')->with('ignore-skills')->andReturn(false); + $command->shouldReceive('option')->with('fresh')->andReturn(false); $command->shouldReceive('callSilently') ->once() ->with(InstallCommand::class, [ '--no-interaction' => true, '--guidelines' => false, '--skills' => true, + '--fresh' => false, ]) ->andReturn(0); @@ -130,12 +134,14 @@ $command = Mockery::mock(UpdateCommand::class)->makePartial(); $command->shouldReceive('option')->with('discover')->andReturn(false); $command->shouldReceive('option')->with('ignore-skills')->andReturn(false); + $command->shouldReceive('option')->with('fresh')->andReturn(false); $command->shouldReceive('callSilently') ->once() ->with(InstallCommand::class, [ '--no-interaction' => true, '--guidelines' => true, '--skills' => true, + '--fresh' => false, ]) ->andReturn(0); @@ -157,12 +163,14 @@ $command = Mockery::mock(UpdateCommand::class)->makePartial(); $command->shouldReceive('option')->with('discover')->andReturn(false); $command->shouldReceive('option')->with('ignore-skills')->andReturn(false); + $command->shouldReceive('option')->with('fresh')->andReturn(false); $command->shouldReceive('callSilently') ->once() ->with(InstallCommand::class, [ '--no-interaction' => true, '--guidelines' => true, '--skills' => false, + '--fresh' => false, ]) ->andReturn(0); @@ -184,12 +192,14 @@ $command = Mockery::mock(UpdateCommand::class)->makePartial(); $command->shouldReceive('option')->with('discover')->andReturn(false); $command->shouldReceive('option')->with('ignore-skills')->andReturn(false); + $command->shouldReceive('option')->with('fresh')->andReturn(false); $command->shouldReceive('callSilently') ->once() ->with(InstallCommand::class, [ '--no-interaction' => true, '--guidelines' => true, '--skills' => false, + '--fresh' => false, ]) ->andReturnUsing(fn (): int => 0); @@ -212,12 +222,14 @@ $command = Mockery::mock(UpdateCommand::class)->makePartial(); $command->shouldReceive('option')->with('discover')->andReturn(false); $command->shouldReceive('option')->with('ignore-skills')->andReturn(false); + $command->shouldReceive('option')->with('fresh')->andReturn(false); $command->shouldReceive('callSilently') ->once() ->with(InstallCommand::class, [ '--no-interaction' => true, '--guidelines' => true, '--skills' => false, + '--fresh' => false, ]) ->andReturn(0); @@ -240,12 +252,14 @@ $command = Mockery::mock(UpdateCommand::class)->makePartial(); $command->shouldReceive('option')->with('discover')->andReturn(false); $command->shouldReceive('option')->with('ignore-skills')->andReturn(false); + $command->shouldReceive('option')->with('fresh')->andReturn(false); $command->shouldReceive('callSilently') ->once() ->with(InstallCommand::class, [ '--no-interaction' => true, '--guidelines' => false, '--skills' => true, + '--fresh' => false, ]) ->andReturn(0); @@ -269,12 +283,14 @@ $command = Mockery::mock(UpdateCommand::class)->makePartial(); $command->shouldReceive('option')->with('discover')->andReturn(false); $command->shouldReceive('option')->with('ignore-skills')->andReturn(false); + $command->shouldReceive('option')->with('fresh')->andReturn(false); $command->shouldReceive('callSilently') ->once() ->with(InstallCommand::class, [ '--no-interaction' => true, '--guidelines' => false, '--skills' => true, + '--fresh' => false, ]) ->andReturn(0); @@ -309,6 +325,7 @@ ->shouldAllowMockingProtectedMethods(); $command->shouldReceive('option')->with('discover')->andReturn(false); $command->shouldReceive('option')->with('ignore-skills')->andReturn(false); + $command->shouldReceive('option')->with('fresh')->andReturn(false); $command->shouldNotReceive('discoverNewContent'); $command->shouldReceive('callSilently') ->once() @@ -316,6 +333,7 @@ '--no-interaction' => true, '--guidelines' => false, '--skills' => true, + '--fresh' => false, ]) ->andReturn(0); $command->setLaravel($this->app); @@ -339,6 +357,7 @@ ->shouldAllowMockingProtectedMethods(); $command->shouldReceive('option')->with('discover')->andReturn(true); $command->shouldReceive('option')->with('ignore-skills')->andReturn(false); + $command->shouldReceive('option')->with('fresh')->andReturn(false); $command->shouldReceive('resolveNewPackages')->andReturn(collect()); $command->shouldReceive('callSilently')->once()->andReturn(0); $command->setLaravel($this->app); @@ -367,6 +386,7 @@ ->shouldAllowMockingProtectedMethods(); $command->shouldReceive('option')->with('discover')->andReturn(true); $command->shouldReceive('option')->with('ignore-skills')->andReturn(false); + $command->shouldReceive('option')->with('fresh')->andReturn(false); $command->shouldReceive('resolveNewPackages') ->andReturn(collect(['vendor/awesome-pkg' => $newPackage])); $command->shouldReceive('callSilently')->andReturn(0); @@ -390,12 +410,14 @@ $command = Mockery::mock(UpdateCommand::class)->makePartial(); $command->shouldReceive('option')->with('discover')->andReturn(false); $command->shouldReceive('option')->with('ignore-skills')->andReturn(true); + $command->shouldReceive('option')->with('fresh')->andReturn(false); $command->shouldReceive('callSilently') ->once() ->with(InstallCommand::class, [ '--no-interaction' => true, '--guidelines' => true, '--skills' => false, + '--fresh' => false, ]) ->andReturn(0); @@ -418,12 +440,14 @@ $command = Mockery::mock(UpdateCommand::class)->makePartial(); $command->shouldReceive('option')->with('discover')->andReturn(false); $command->shouldReceive('option')->with('ignore-skills')->andReturn(true); + $command->shouldReceive('option')->with('fresh')->andReturn(false); $command->shouldReceive('callSilently') ->once() ->with(InstallCommand::class, [ '--no-interaction' => true, '--guidelines' => true, '--skills' => false, + '--fresh' => false, ]) ->andReturn(0); @@ -447,6 +471,64 @@ ->assertSuccessful(); }); +it('forwards the fresh flag to the install command', function (): void { + $config = new Config; + $config->setAgents(['claude_code']); + $config->setGuidelines(true); + $config->setSkills(['test-skill']); + + $command = Mockery::mock(UpdateCommand::class)->makePartial(); + $command->shouldReceive('option')->with('discover')->andReturn(false); + $command->shouldReceive('option')->with('ignore-skills')->andReturn(false); + $command->shouldReceive('option')->with('fresh')->andReturn(true); + $command->shouldReceive('callSilently') + ->once() + ->with(InstallCommand::class, [ + '--no-interaction' => true, + '--guidelines' => true, + '--skills' => true, + '--fresh' => true, + ]) + ->andReturn(0); + + $input = new ArrayInput([]); + $output = new OutputStyle($input, new BufferedOutput); + + $command->setLaravel($this->app); + $command->setOutput($output); + + expect($command->handle($config))->toBe(0); +}); + +it('enables skills when --fresh is set even with no skills configured', function (): void { + $config = new Config; + $config->setAgents(['claude_code']); + $config->setGuidelines(false); + $config->setSkills([]); + + $command = Mockery::mock(UpdateCommand::class)->makePartial(); + $command->shouldReceive('option')->with('discover')->andReturn(false); + $command->shouldReceive('option')->with('ignore-skills')->andReturn(false); + $command->shouldReceive('option')->with('fresh')->andReturn(true); + $command->shouldReceive('callSilently') + ->once() + ->with(InstallCommand::class, [ + '--no-interaction' => true, + '--guidelines' => false, + '--skills' => true, + '--fresh' => true, + ]) + ->andReturn(0); + + $input = new ArrayInput([]); + $output = new OutputStyle($input, new BufferedOutput); + + $command->setLaravel($this->app); + $command->setOutput($output); + + expect($command->handle($config))->toBe(0); +}); + it('skips new-package discovery prompt when running in non-interactive mode', function (): void { $config = new Config; $config->setAgents(['claude_code']); @@ -462,6 +544,7 @@ ->shouldAllowMockingProtectedMethods(); $command->shouldReceive('option')->with('discover')->andReturn(true); $command->shouldReceive('option')->with('ignore-skills')->andReturn(false); + $command->shouldReceive('option')->with('fresh')->andReturn(false); $command->shouldReceive('resolveNewPackages')->andReturn(collect(['vendor/awesome-pkg' => $newPackage])); $command->shouldReceive('callSilently')->andReturn(0); diff --git a/tests/Unit/Install/SkillWriterTest.php b/tests/Unit/Install/SkillWriterTest.php index 46ab315f..d419d886 100644 --- a/tests/Unit/Install/SkillWriterTest.php +++ b/tests/Unit/Install/SkillWriterTest.php @@ -9,7 +9,7 @@ function cleanupSkillDirectory(string $path): void { if (is_link($path)) { - @unlink($path); + @unlink($path) || @rmdir($path); return; } @@ -25,7 +25,7 @@ function cleanupSkillDirectory(string $path): void foreach ($files as $file) { if ($file->isLink()) { - @unlink($file->getPathname()); + @unlink($file->getPathname()) || @rmdir($file->getPathname()); continue; } @@ -618,6 +618,46 @@ function cleanupSkillDirectory(string $path): void cleanupSkillDirectory($linkTargetDir); }); +it('removes directory containing nested dangling symlinks', function (): void { + $relativeTarget = '.boost-test-skills-'.uniqid(); + $absoluteTarget = base_path($relativeTarget); + $skillDir = $absoluteTarget.'/symlink-skill'; + $nestedDir = $skillDir.'/references'; + $linkTargetDir = base_path('.boost-link-target-'.uniqid()); + + mkdir($nestedDir, 0755, true); + mkdir($linkTargetDir, 0755, true); + file_put_contents($skillDir.'/SKILL.md', 'test'); + + $symlinkPath = $nestedDir.'/linked-dir'; + + if (! @symlink($linkTargetDir, $symlinkPath)) { + cleanupSkillDirectory($absoluteTarget); + cleanupSkillDirectory($linkTargetDir); + $this->markTestSkipped('Symlinks not supported in this environment'); + } + + cleanupSkillDirectory($linkTargetDir); + + if (! is_link($symlinkPath)) { + cleanupSkillDirectory($absoluteTarget); + $this->markTestSkipped('Dangling symlink not detectable in this environment'); + } + + $agent = Mockery::mock(SupportsSkills::class); + $agent->shouldReceive('skillsPath')->andReturn($relativeTarget); + + $writer = new SkillWriter($agent); + $result = $writer->remove('symlink-skill'); + + expect($result)->toBeTrue() + ->and($skillDir)->not->toBeDirectory() + ->and(is_link($symlinkPath))->toBeFalse() + ->and($linkTargetDir)->not->toBeDirectory(); + + cleanupSkillDirectory($absoluteTarget); +}); + it('creates canonical directory and symlinks custom skill when canonical does not exist', function (): void { $sourceDir = fixture('skills/test-skill'); $relativeTarget = '.boost-test-skills-'.uniqid(); @@ -1069,6 +1109,149 @@ function cleanupSkillDirectory(string $path): void cleanupSkillDirectory($canonicalSkillPath); }); +it('fresh sync removes untracked entries before rewriting skills', function (): void { + $sourceDir = fixture('skills/test-skill'); + $relativeTarget = '.boost-test-skills-'.uniqid(); + $absoluteTarget = base_path($relativeTarget); + + $orphanedDir = $absoluteTarget.'/orphaned-skill'; + mkdir($orphanedDir, 0755, true); + file_put_contents($orphanedDir.'/SKILL.md', 'orphaned content'); + + $agent = Mockery::mock(SupportsSkills::class); + $agent->shouldReceive('skillsPath')->andReturn($relativeTarget); + + $skills = collect([ + 'new-skill' => new Skill('new-skill', 'boost', $sourceDir, 'New skill'), + ]); + + $writer = new SkillWriter($agent); + $result = $writer->sync($skills, [], fresh: true); + + expect($result['new-skill'])->toBe(SkillWriter::SUCCESS) + ->and($absoluteTarget.'/new-skill/SKILL.md')->toBeFile() + ->and($orphanedDir)->not->toBeDirectory(); + + cleanupSkillDirectory($absoluteTarget); +}); + +it('fresh sync removes dangling symlinks', function (): void { + $sourceDir = fixture('skills/test-skill'); + $relativeTarget = '.boost-test-skills-'.uniqid(); + $absoluteTarget = base_path($relativeTarget); + $danglingTarget = base_path('.boost-dangling-'.uniqid()); + $danglingLink = $absoluteTarget.'/deleted-custom-skill'; + + mkdir($danglingTarget, 0755, true); + mkdir($absoluteTarget, 0755, true); + + if (! @symlink($danglingTarget, $danglingLink)) { + cleanupSkillDirectory($danglingTarget); + cleanupSkillDirectory($absoluteTarget); + $this->markTestSkipped('Symlinks not supported in this environment'); + } + + cleanupSkillDirectory($danglingTarget); + + $agent = Mockery::mock(SupportsSkills::class); + $agent->shouldReceive('skillsPath')->andReturn($relativeTarget); + + $skills = collect([ + 'new-skill' => new Skill('new-skill', 'boost', $sourceDir, 'New skill'), + ]); + + $writer = new SkillWriter($agent); + $result = $writer->sync($skills, [], fresh: true); + + expect($result['new-skill'])->toBe(SkillWriter::SUCCESS) + ->and(is_link($danglingLink))->toBeFalse() + ->and($absoluteTarget.'/new-skill/SKILL.md')->toBeFile(); + + cleanupSkillDirectory($absoluteTarget); +}); + +it('fresh sync preserves canonical skills behind symlinks', function (): void { + $relativeTarget = '.boost-test-skills-'.uniqid(); + $absoluteTarget = base_path($relativeTarget); + $skillName = 'test-skill-'.uniqid(); + $canonicalSkillPath = base_path('.ai/skills/'.$skillName); + + mkdir($canonicalSkillPath, 0755, true); + copy(fixture('skills/test-skill/SKILL.md'), $canonicalSkillPath.'/SKILL.md'); + + $agent = Mockery::mock(SupportsSkills::class); + $agent->shouldReceive('skillsPath')->andReturn($relativeTarget); + + $skills = collect([ + $skillName => new Skill($skillName, 'boost', $canonicalSkillPath, 'Custom skill', custom: true), + ]); + + $writer = new SkillWriter($agent); + $writer->sync($skills); + + $result = $writer->sync($skills, [], fresh: true); + + expect($result[$skillName])->toBe(SkillWriter::SUCCESS) + ->and($canonicalSkillPath)->toBeDirectory() + ->and($canonicalSkillPath.'/SKILL.md')->toBeFile(); + + $linkedPath = $absoluteTarget.'/'.$skillName; + + if (is_link($linkedPath)) { + expect(realpath($linkedPath))->toBe(realpath($canonicalSkillPath)); + } else { + expect($linkedPath)->toBeDirectory(); + } + + cleanupSkillDirectory($absoluteTarget); + cleanupSkillDirectory($canonicalSkillPath); +}); + +it('fresh sync preserves canonical custom skills when target is the canonical skills path', function (): void { + $skillName = 'test-skill-'.uniqid(); + $canonicalSkillPath = base_path('.ai/skills/'.$skillName); + + mkdir($canonicalSkillPath, 0755, true); + copy(fixture('skills/test-skill/SKILL.md'), $canonicalSkillPath.'/SKILL.md'); + + $agent = Mockery::mock(SupportsSkills::class); + $agent->shouldReceive('skillsPath')->andReturn('.ai/skills'); + + $skills = collect([ + $skillName => new Skill($skillName, 'boost', $canonicalSkillPath, 'Custom skill', custom: true), + ]); + + $writer = new SkillWriter($agent); + $result = $writer->sync($skills, [], fresh: true); + + expect($result[$skillName])->toBe(SkillWriter::UPDATED) + ->and($canonicalSkillPath)->toBeDirectory() + ->and($canonicalSkillPath.'/SKILL.md')->toBeFile(); + + cleanupSkillDirectory($canonicalSkillPath); +}); + +it('fresh sync works when the skills directory does not exist', function (): void { + $sourceDir = fixture('skills/test-skill'); + $relativeTarget = '.boost-test-skills-'.uniqid(); + $absoluteTarget = base_path($relativeTarget); + + $agent = Mockery::mock(SupportsSkills::class); + $agent->shouldReceive('skillsPath')->andReturn($relativeTarget); + + $skills = collect([ + 'new-skill' => new Skill('new-skill', 'boost', $sourceDir, 'New skill'), + ]); + + $writer = new SkillWriter($agent); + $result = $writer->sync($skills, [], fresh: true); + + expect($result['new-skill'])->toBe(SkillWriter::SUCCESS) + ->and($absoluteTarget.'/new-skill/SKILL.md')->toBeFile(); + + cleanupSkillDirectory($absoluteTarget); +}); + it('writes skill files with a trailing newline', function (): void { $sourceDir = fixture('skills/test-skill'); $relativeTarget = '.boost-test-skills-'.uniqid();