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
8 changes: 8 additions & 0 deletions src/Console/AddSkillCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Laravel\Boost\Skills\Remote\GitHubSkillProvider;
use Laravel\Boost\Skills\Remote\RemoteSkill;
use Laravel\Boost\Skills\Remote\SkillAuditor;
use Laravel\Boost\Support\Config;
use Laravel\Prompts\Terminal;
use RuntimeException;

Expand Down Expand Up @@ -287,6 +288,8 @@ protected function downloadSkills(Collection $skills): array
protected function addSkills(Collection $skills): array
{
$results = ['installedNames' => [], 'failedDetails' => []];
$config = new Config;
$skillsToTrack = [];

foreach ($skills as $skill) {
$targetPath = $this->skillTargetPath($skill);
Expand All @@ -298,6 +301,7 @@ protected function addSkills(Collection $skills): array
try {
if ($this->fetcher->downloadSkill($skill, $targetPath)) {
$results['installedNames'][] = $skill->name;
$skillsToTrack[$skill->name] = $this->repository->source();
} else {
$results['failedDetails'][$skill->name] = 'Download failed';
}
Expand All @@ -306,6 +310,10 @@ protected function addSkills(Collection $skills): array
}
}

if ($skillsToTrack !== []) {
$config->trackSkills($skillsToTrack);
}

return $results;
}

Expand Down
3 changes: 2 additions & 1 deletion src/Console/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,8 @@ protected function storeConfig(): void
}

if ($this->selectedBoostFeatures->contains('skills')) {
$this->config->setSkills($this->installedSkillNames);
$skillsToPersist = $explicitMode ? $this->config->getSkills() : $this->installedSkillNames;
$this->config->setSkills($skillsToPersist);
}

if ($this->selectedBoostFeatures->contains('mcp')) {
Expand Down
121 changes: 119 additions & 2 deletions src/Support/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ class Config
{
protected const FILE = 'boost.json';

protected const DEFAULT_SKILLS_SOURCE = 'laravel/boost';

public function getGuidelines(): bool
{
return (bool) $this->get('guidelines', false);
Expand All @@ -25,22 +27,130 @@ public function setGuidelines(bool $enabled): void
*/
public function getSkills(): array
{
return $this->get('skills', []);
return array_keys($this->extractSourceMap($this->getRawSkills()));
}

/**
* @param array<int, string> $skills
*/
public function setSkills(array $skills): void
{
$this->set('skills', $skills);
$sourceMap = $this->extractSourceMap($this->getRawSkills());
$selectedSkills = [];

foreach ($skills as $skillName) {
if ($skillName === '') {
continue;
}

$selectedSkills[$skillName] = $sourceMap[$skillName] ?? self::DEFAULT_SKILLS_SOURCE;
}

$this->set('skills', $this->groupSkillsBySource($selectedSkills));
}

public function hasSkills(): bool
{
return $this->getSkills() !== [];
}

public function getTrackedSkills(): array
{
$tracked = [];

foreach ($this->extractSourceMap($this->getRawSkills()) as $skillName => $source) {
if ($source !== self::DEFAULT_SKILLS_SOURCE) {
$tracked[$skillName] = ['source' => $source];
}
}

return $tracked;
}

public function trackSkills(array $skillsWithSource): void
{
$sourceMap = $this->extractSourceMap($this->getRawSkills());

foreach ($skillsWithSource as $skillName => $source) {
if (is_string($skillName) && $skillName !== '' && is_string($source) && $source !== '') {
$sourceMap[$skillName] = $source;
}
}

$this->set('skills', $this->groupSkillsBySource($sourceMap));
}

public function trackSkill(string $skillName, string $source): void
{
$this->trackSkills([$skillName => $source]);
}

protected function extractSourceMap(array $currentConfig): array
{
$sourceMap = [];
$isList = array_is_list($currentConfig);

foreach ($currentConfig as $key => $value) {
if ($isList) {
if (is_string($value) && $value !== '') {
$sourceMap[$value] = self::DEFAULT_SKILLS_SOURCE;
}

continue;
}

if (is_array($value) && array_is_list($value)) {
$source = is_string($key) && $key !== '' ? $key : self::DEFAULT_SKILLS_SOURCE;

foreach ($value as $skillName) {
if (is_string($skillName) && $skillName !== '') {
$sourceMap[$skillName] = $source;
}
}

continue;
}

if (! is_string($key) || ! is_array($value)) {
continue;
}

$source = $value['source'] ?? self::DEFAULT_SKILLS_SOURCE;

if (is_string($source)) {
$sourceMap[$key] = $source !== '' ? $source : self::DEFAULT_SKILLS_SOURCE;
}
}

return $sourceMap;
}

protected function groupSkillsBySource(array $sourceMap): array
{
$grouped = [];

foreach ($sourceMap as $skillName => $source) {
if (! is_string($skillName) || $skillName === '' || ! is_string($source) || $source === '') {
continue;
}

if (! isset($grouped[$source])) {
$grouped[$source] = [];
}

$grouped[$source][$skillName] = $skillName;
}

foreach ($grouped as &$skillList) {
$skillList = array_values($skillList);
sort($skillList);
}

ksort($grouped);

return $grouped;
}

public function getMcp(): bool
{
return $this->get('mcp', false);
Expand Down Expand Up @@ -125,6 +235,13 @@ public function flush(): void
}
}

protected function getRawSkills(): array
{
$skills = $this->get('skills', []);

return is_array($skills) ? $skills : [];
}

protected function get(string $key, mixed $default = null): mixed
{
$config = $this->all();
Expand Down
32 changes: 32 additions & 0 deletions tests/Feature/Console/AddSkillCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Laravel\Boost\Support\Config;
use Orchestra\Testbench\Concerns\InteractsWithPublishedFiles;

uses(InteractsWithPublishedFiles::class);

beforeEach(function (): void {
File::deleteDirectory(base_path('.ai/skills'));
(new Config)->flush();

$this->files = [
'.ai/skills/skill-one/SKILL.md',
Expand Down Expand Up @@ -95,6 +97,36 @@
$this->assertFileContains(['# SKILL Content'], '.ai/skills/skill-one/SKILL.md');
});

it('tracks installed skills in boost json using source metadata', function (): void {
Http::fake([
'api.github.com/repos/owner/repo/git/trees/main?recursive=1' => Http::response([
'sha' => 'repo-tree-sha',
'tree' => [
['path' => 'path/to/skills/skill-one', 'type' => 'tree', 'sha' => 'skill-one-tree-sha'],
['path' => 'path/to/skills/skill-one/SKILL.md', 'type' => 'blob', 'sha' => 'skill-file-sha', 'size' => 123],
],
'truncated' => false,
]),
'raw.githubusercontent.com/*' => Http::response(<<<'YAML'
---
name: skill-one
description: First skill
---
# SKILL Content
YAML),
]);

$this->artisan('boost:add-skill', [
'repo' => 'owner/repo/path/to/skills',
'--all' => true,
])->assertSuccessful();

$tracked = (new Config)->getTrackedSkills();

expect($tracked)->toHaveKey('skill-one')
->and($tracked['skill-one']['source'])->toBe('owner/repo/path/to/skills');
});

it('installs specific skills with --skill option', function (): void {
Http::fake([
'api.github.com/repos/owner/repo/git/trees/main?recursive=1' => Http::response([
Expand Down
1 change: 1 addition & 0 deletions tests/Unit/Skills/Remote/GitHubSkillProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -462,3 +462,4 @@ function fakeTreeResponse(array $tree, string $branch = 'main'): array
->and($skills->has('my-skill'))->toBeTrue()
->and($skills->get('my-skill')->path)->toBe('.ai/claude/skills/my-skill');
});

72 changes: 72 additions & 0 deletions tests/Unit/Support/ConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

use Laravel\Boost\Support\Config;

beforeEach(function (): void {
(new Config)->flush();
});

afterEach(function (): void {
(new Config)->flush();
});
Expand Down Expand Up @@ -85,3 +89,71 @@

expect($config->getPackages())->toEqual($packages);
});

it('may track and query skills with source metadata', function (): void {
$config = new Config;

expect($config->getTrackedSkills())->toBeEmpty();

$config->trackSkill('composition-patterns', 'vercel-labs/agent-skills');
$config->trackSkill('algorithmic-art', 'anthropics/skills');

$tracked = $config->getTrackedSkills();

expect($tracked)->toHaveKeys(['composition-patterns', 'algorithmic-art'])
->and($tracked['composition-patterns']['source'])->toBe('vercel-labs/agent-skills')
->and($tracked['algorithmic-art']['source'])->toBe('anthropics/skills');
});

it('keeps tracked source metadata when syncing skills list', function (): void {
$config = new Config;

$config->setSkills(['algorithmic-art']);
$config->trackSkill('algorithmic-art', 'anthropics/skills');
$config->setSkills(['algorithmic-art', 'pest-testing']);

expect($config->getSkills())->toBe(['algorithmic-art', 'pest-testing']);

$tracked = $config->getTrackedSkills();

expect($tracked)->toHaveKey('algorithmic-art')
->and($tracked['algorithmic-art']['source'])->toBe('anthropics/skills')
->and($tracked)->not->toHaveKey('pest-testing');
});

it('normalizes legacy list-format skills into grouped format', function (): void {
file_put_contents(base_path('boost.json'), json_encode([
'agents' => ['claude_code'],
'skills' => ['pest-testing', 'fortify-development'],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));

$config = new Config;

expect($config->getSkills())->toBe(['pest-testing', 'fortify-development']);

$config->setSkills($config->getSkills());

$normalized = json_decode((string) file_get_contents(base_path('boost.json')), true);

expect($normalized['skills'])->toBe([
'laravel/boost' => ['fortify-development', 'pest-testing'],
]);
});

it('reads legacy flat-format skill source metadata', function (): void {
file_put_contents(base_path('boost.json'), json_encode([
'agents' => ['claude_code'],
'skills' => [
'copilot-docs' => ['source' => 'anthropics/skills'],
'pest-testing' => ['source' => ''],
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));

$config = new Config;
$tracked = $config->getTrackedSkills();

expect($config->getSkills())->toBe(['copilot-docs', 'pest-testing'])
->and($tracked)->toHaveKey('copilot-docs')
->and($tracked['copilot-docs']['source'])->toBe('anthropics/skills')
->and($tracked)->not->toHaveKey('pest-testing');
});
Loading