diff --git a/src/Console/AddSkillCommand.php b/src/Console/AddSkillCommand.php index 9a3b1134..249ca351 100644 --- a/src/Console/AddSkillCommand.php +++ b/src/Console/AddSkillCommand.php @@ -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; @@ -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); @@ -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'; } @@ -306,6 +310,10 @@ protected function addSkills(Collection $skills): array } } + if ($skillsToTrack !== []) { + $config->trackSkills($skillsToTrack); + } + return $results; } diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index ea9aa277..e32d42fc 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -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')) { diff --git a/src/Support/Config.php b/src/Support/Config.php index e7db62b1..516bfaa1 100644 --- a/src/Support/Config.php +++ b/src/Support/Config.php @@ -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); @@ -25,7 +27,7 @@ public function setGuidelines(bool $enabled): void */ public function getSkills(): array { - return $this->get('skills', []); + return array_keys($this->extractSourceMap($this->getRawSkills())); } /** @@ -33,7 +35,18 @@ public function getSkills(): array */ 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 @@ -41,6 +54,103 @@ 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); @@ -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(); diff --git a/tests/Feature/Console/AddSkillCommandTest.php b/tests/Feature/Console/AddSkillCommandTest.php index cb60e859..4c51f1ec 100644 --- a/tests/Feature/Console/AddSkillCommandTest.php +++ b/tests/Feature/Console/AddSkillCommandTest.php @@ -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', @@ -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([ diff --git a/tests/Unit/Skills/Remote/GitHubSkillProviderTest.php b/tests/Unit/Skills/Remote/GitHubSkillProviderTest.php index 850b8b83..f3be5f28 100644 --- a/tests/Unit/Skills/Remote/GitHubSkillProviderTest.php +++ b/tests/Unit/Skills/Remote/GitHubSkillProviderTest.php @@ -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'); }); + diff --git a/tests/Unit/Support/ConfigTest.php b/tests/Unit/Support/ConfigTest.php index 5baf5dd2..91fbd11b 100644 --- a/tests/Unit/Support/ConfigTest.php +++ b/tests/Unit/Support/ConfigTest.php @@ -2,6 +2,10 @@ use Laravel\Boost\Support\Config; +beforeEach(function (): void { + (new Config)->flush(); +}); + afterEach(function (): void { (new Config)->flush(); }); @@ -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'); +});