Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/Console/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, Agent> */
private Collection $selectedAgents;
Expand Down Expand Up @@ -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())
Expand Down
6 changes: 4 additions & 2 deletions src/Console/UpdateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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;
Expand All @@ -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.');
Expand Down
42 changes: 25 additions & 17 deletions src/Install/SkillWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,15 @@ public function writeAll(Collection $skills): array
* @param array<int, string> $previouslyTrackedSkills
* @return array<string, int>
*/
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();
Expand Down Expand Up @@ -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)) {
Expand All @@ -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;
}
Expand All @@ -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)) {
Expand Down
83 changes: 83 additions & 0 deletions tests/Feature/Console/UpdateCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand Down Expand Up @@ -309,13 +325,15 @@
->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()
->with(InstallCommand::class, [
'--no-interaction' => true,
'--guidelines' => false,
'--skills' => true,
'--fresh' => false,
])
->andReturn(0);
$command->setLaravel($this->app);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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']);
Expand All @@ -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);

Expand Down
Loading
Loading