diff --git a/src/Console/AddSkillCommand.php b/src/Console/AddSkillCommand.php index 9a3b1134..c140b38b 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; @@ -52,8 +53,10 @@ class AddSkillCommand extends Command protected string $defaultSkillsPath = '.ai/skills'; - public function __construct(private readonly Terminal $terminal) - { + public function __construct( + private readonly Terminal $terminal, + private readonly Config $config, + ) { parent::__construct(); } @@ -287,6 +290,7 @@ protected function downloadSkills(Collection $skills): array protected function addSkills(Collection $skills): array { $results = ['installedNames' => [], 'failedDetails' => []]; + $skillsToTrack = []; foreach ($skills as $skill) { $targetPath = $this->skillTargetPath($skill); @@ -298,6 +302,14 @@ protected function addSkills(Collection $skills): array try { if ($this->fetcher->downloadSkill($skill, $targetPath)) { $results['installedNames'][] = $skill->name; + $skillsToTrack[$skill->name] = [ + 'source' => Config::SKILL_SOURCE_GITHUB, + 'repo' => $skill->repo, + ]; + + if ($skill->path !== $skill->name) { + $skillsToTrack[$skill->name]['path'] = $skill->path; + } } else { $results['failedDetails'][$skill->name] = 'Download failed'; } @@ -306,6 +318,10 @@ protected function addSkills(Collection $skills): array } } + if ($skillsToTrack !== []) { + $this->config->trackSkills($skillsToTrack); + } + return $results; } diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index ceb7058b..130288c1 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -66,6 +66,9 @@ class InstallCommand extends Command /** @var array */ private array $installedSkillNames = []; + /** @var array> */ + private array $installedSkillMetadata = []; + const MIN_TEST_COUNT = 6; public function __construct( @@ -365,6 +368,13 @@ protected function installSkills(): void $skills = $skillsComposer->skills(); $this->installedSkillNames = $skills->keys()->toArray(); + $this->installedSkillMetadata = $skills + ->mapWithKeys(fn (Skill $skill): array => [ + $skill->name => [ + 'source' => $skill->custom ? Config::SKILL_SOURCE_CUSTOM : Config::SKILL_SOURCE_OFFICIAL, + ], + ]) + ->all(); /** @var Collection $skillsAgents */ $this->installFeature( @@ -433,7 +443,7 @@ protected function storeConfig(): void } if ($this->selectedBoostFeatures->contains('skills')) { - $this->config->setSkills($this->installedSkillNames); + $this->config->setSkills($this->installedSkillNames, $this->installedSkillMetadata); } $this->config->setCloud($this->selectedBoostFeatures->contains('cloud')); diff --git a/src/Support/Config.php b/src/Support/Config.php index f8613e31..374ca4e7 100644 --- a/src/Support/Config.php +++ b/src/Support/Config.php @@ -10,6 +10,14 @@ class Config { protected const FILE = 'boost.json'; + public const SKILL_SOURCE_CUSTOM = 'custom'; + + public const SKILL_SOURCE_GITHUB = 'github'; + + public const SKILL_SOURCE_OFFICIAL = 'official'; + + protected const LEGACY_OFFICIAL_SKILLS_SOURCE = 'laravel/boost'; + public function getGuidelines(): bool { return (bool) $this->get('guidelines', false); @@ -25,15 +33,31 @@ public function setGuidelines(bool $enabled): void */ public function getSkills(): array { - return $this->get('skills', []); + return array_keys($this->getSkillMetadata()); } /** * @param array $skills + * @param array> $metadata */ - public function setSkills(array $skills): void + public function setSkills(array $skills, array $metadata = []): void { - $this->set('skills', $skills); + $currentMetadata = $this->getSkillMetadata(); + $selectedSkills = []; + + foreach ($skills as $skillName) { + if ($skillName === '') { + continue; + } + + $selectedSkills[$skillName] = $this->resolveSkillMetadata( + skillName: $skillName, + currentMetadata: $currentMetadata[$skillName] ?? [], + incomingMetadata: $metadata[$skillName] ?? $currentMetadata[$skillName] ?? ['source' => self::SKILL_SOURCE_OFFICIAL], + ); + } + + $this->set('skills', $this->sortSkillsByName($selectedSkills)); } public function hasSkills(): bool @@ -41,6 +65,206 @@ public function hasSkills(): bool return $this->getSkills() !== []; } + /** + * @return array> + */ + public function getTrackedSkills(): array + { + return $this->getSkillMetadata(); + } + + /** + * @return array> + */ + public function getSkillMetadata(): array + { + return $this->extractSkillMetadata($this->getRawSkills()); + } + + /** + * @param array|string> $skills + */ + public function trackSkills(array $skills): void + { + $metadata = $this->getSkillMetadata(); + + foreach ($skills as $skillName => $skillMetadata) { + if ($skillName === '') { + continue; + } + + $metadata[$skillName] = $this->normalizeSkillMetadata($skillName, $skillMetadata); + } + + $this->set('skills', $this->sortSkillsByName($metadata)); + } + + public function trackSkill(string $skillName, string $source): void + { + $this->trackSkills([$skillName => $source]); + } + + protected function extractSkillMetadata(array $currentConfig): array + { + if (array_is_list($currentConfig)) { + $metadata = []; + + foreach ($currentConfig as $value) { + if (is_string($value) && $value !== '') { + $metadata[$value] = ['source' => self::SKILL_SOURCE_CUSTOM]; + } + } + + return $metadata; + } + + $metadata = []; + + foreach ($currentConfig as $key => $value) { + if (is_array($value) && array_is_list($value)) { + $source = is_string($key) && $key !== '' ? $key : self::SKILL_SOURCE_CUSTOM; + + foreach ($value as $skillName) { + if (is_string($skillName) && $skillName !== '') { + $metadata[$skillName] = $this->metadataFromLegacySource($skillName, $source); + } + } + + continue; + } + + if (! is_string($key) || $key === '') { + continue; + } + + if (is_bool($value)) { + $metadata[$key] = ['source' => $value ? self::SKILL_SOURCE_OFFICIAL : self::SKILL_SOURCE_CUSTOM]; + + continue; + } + + if (is_string($value)) { + $metadata[$key] = $this->metadataFromLegacySource($key, $value); + + continue; + } + + if (is_array($value)) { + $metadata[$key] = $this->normalizeSkillMetadata($key, $value); + } + } + + return $metadata; + } + + /** + * @param array|string $metadata + * @return array + */ + protected function normalizeSkillMetadata(string $skillName, array|string $metadata): array + { + if (is_string($metadata)) { + return $this->metadataFromLegacySource($skillName, $metadata); + } + + $source = $metadata['source'] ?? self::SKILL_SOURCE_CUSTOM; + + if (! is_string($source) || $source === '') { + return ['source' => self::SKILL_SOURCE_CUSTOM]; + } + + if ($source === self::SKILL_SOURCE_GITHUB) { + return $this->githubMetadata( + skillName: $skillName, + repo: $metadata['repo'] ?? null, + path: $metadata['path'] ?? null, + ); + } + + return $this->metadataFromLegacySource($skillName, $source); + } + + /** + * @return array + */ + protected function metadataFromLegacySource(string $skillName, string $source): array + { + $source = trim($source, '/'); + + if ($source === '' || $source === self::SKILL_SOURCE_CUSTOM) { + return ['source' => self::SKILL_SOURCE_CUSTOM]; + } + + if ($source === self::SKILL_SOURCE_OFFICIAL || $source === self::LEGACY_OFFICIAL_SKILLS_SOURCE) { + return ['source' => self::SKILL_SOURCE_OFFICIAL]; + } + + $parts = explode('/', $source); + + if (count($parts) < 2 || $parts[0] === '' || $parts[1] === '') { + return ['source' => self::SKILL_SOURCE_CUSTOM]; + } + + $repo = $parts[0].'/'.$parts[1]; + $basePath = implode('/', array_slice($parts, 2)); + $path = $basePath === '' ? $skillName : $basePath.'/'.$skillName; + + return $this->githubMetadata($skillName, $repo, $path); + } + + /** + * @return array + */ + protected function githubMetadata(string $skillName, mixed $repo, mixed $path = null): array + { + if (! is_string($repo) || $repo === '') { + return ['source' => self::SKILL_SOURCE_CUSTOM]; + } + + $metadata = [ + 'source' => self::SKILL_SOURCE_GITHUB, + 'repo' => $repo, + ]; + + if (is_string($path) && $path !== '' && $path !== $skillName) { + $metadata['path'] = $path; + } + + return $metadata; + } + + /** + * @param array $currentMetadata + * @param array $incomingMetadata + * @return array + */ + protected function resolveSkillMetadata(string $skillName, array $currentMetadata, array $incomingMetadata): array + { + $currentMetadata = $this->normalizeSkillMetadata($skillName, $currentMetadata); + $incomingMetadata = $this->normalizeSkillMetadata($skillName, $incomingMetadata); + + $currentSource = $currentMetadata['source'] ?? null; + $incomingSource = $incomingMetadata['source'] ?? null; + + // Preserve existing GitHub metadata when the incoming entry only carries the default custom source. + if ($currentSource === self::SKILL_SOURCE_GITHUB && $incomingSource === self::SKILL_SOURCE_CUSTOM) { + return $currentMetadata; + } + + return $incomingMetadata; + } + + /** + * @param array> $metadata + * @return array> + */ + protected function sortSkillsByName(array $metadata): array + { + ksort($metadata); + + return $metadata; + } + public function getMcp(): bool { return $this->get('mcp', false); @@ -135,6 +359,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..aff50b8a 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', @@ -93,6 +95,48 @@ $this->assertFilenameExists('.ai/skills/skill-one/SKILL.md'); $this->assertFileContains(['# SKILL Content'], '.ai/skills/skill-one/SKILL.md'); + + $config = json_decode((string) file_get_contents(base_path('boost.json')), true); + + expect($config['skills']['skill-one'])->toBe([ + 'source' => 'github', + 'repo' => 'owner/repo', + ]); +}); + +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(); + + $config = json_decode((string) file_get_contents(base_path('boost.json')), true); + + expect($config['skills'])->toBe([ + 'skill-one' => [ + 'source' => 'github', + 'repo' => 'owner/repo', + 'path' => 'path/to/skills/skill-one', + ], + ]); }); it('installs specific skills with --skill option', function (): void { diff --git a/tests/Unit/Skills/Remote/GitHubSkillProviderTest.php b/tests/Unit/Skills/Remote/GitHubSkillProviderTest.php index 850b8b83..de0eb7ea 100644 --- a/tests/Unit/Skills/Remote/GitHubSkillProviderTest.php +++ b/tests/Unit/Skills/Remote/GitHubSkillProviderTest.php @@ -11,7 +11,6 @@ beforeEach(function (): void { Http::preventStrayRequests(); }); - function fakeGitHubRepo(string $branch = 'main'): array { return ['api.github.com/repos/owner/repo' => Http::response(['default_branch' => $branch])]; diff --git a/tests/Unit/Support/ConfigTest.php b/tests/Unit/Support/ConfigTest.php index bf11844e..d4947622 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(); }); @@ -49,7 +53,11 @@ $config->setSkills($skills); expect($config->getSkills())->toEqual($skills) - ->and($config->hasSkills())->toBeTrue(); + ->and($config->hasSkills())->toBeTrue() + ->and($config->getSkillMetadata())->toBe([ + 'skill-one' => ['source' => 'official'], + 'skill-two' => ['source' => 'official'], + ]); $config->setSkills([]); @@ -101,3 +109,118 @@ expect($config->getPackages())->toEqual($packages); }); + +it('may track and query skills with source metadata', function (): void { + $config = new Config; + + expect($config->getTrackedSkills())->toBeEmpty(); + + $config->trackSkills([ + 'composition-patterns' => [ + 'source' => 'github', + 'repo' => 'vercel-labs/agent-skills', + ], + 'algorithmic-art' => [ + 'source' => 'github', + 'repo' => 'anthropics/skills', + ], + ]); + + $tracked = $config->getTrackedSkills(); + + expect($tracked)->toHaveKeys(['composition-patterns', 'algorithmic-art']) + ->and($tracked['composition-patterns'])->toBe([ + 'source' => 'github', + 'repo' => 'vercel-labs/agent-skills', + ]) + ->and($tracked['algorithmic-art'])->toBe([ + 'source' => 'github', + 'repo' => 'anthropics/skills', + ]); +}); + +it('keeps tracked source metadata when syncing skills list', function (): void { + $config = new Config; + + $config->setSkills(['algorithmic-art']); + $config->trackSkills([ + 'algorithmic-art' => [ + 'source' => 'github', + 'repo' => 'anthropics/skills', + ], + ]); + $config->setSkills(['algorithmic-art', 'pest-testing'], [ + 'algorithmic-art' => ['source' => 'custom'], + 'pest-testing' => ['source' => 'official'], + ]); + + expect($config->getSkills())->toBe(['algorithmic-art', 'pest-testing']); + + $tracked = $config->getTrackedSkills(); + + expect($tracked['algorithmic-art'])->toBe([ + 'source' => 'github', + 'repo' => 'anthropics/skills', + ])->and($tracked['pest-testing'])->toBe(['source' => 'official']); +}); + +it('normalizes legacy list-format skills into skill metadata 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([ + 'fortify-development' => ['source' => 'custom'], + 'pest-testing' => ['source' => 'custom'], + ]); +}); + +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['copilot-docs'])->toBe([ + 'source' => 'github', + 'repo' => 'anthropics/skills', + ]) + ->and($tracked['pest-testing'])->toBe(['source' => 'custom']); +}); + +it('reads legacy grouped source metadata', function (): void { + file_put_contents(base_path('boost.json'), json_encode([ + 'agents' => ['claude_code'], + 'skills' => [ + 'laravel/boost' => ['pest-testing'], + 'owner/repo/path/to/skills' => ['skill-one'], + ], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + $config = new Config; + + expect($config->getSkillMetadata())->toBe([ + 'pest-testing' => ['source' => 'official'], + 'skill-one' => [ + 'source' => 'github', + 'repo' => 'owner/repo', + 'path' => 'path/to/skills/skill-one', + ], + ]); +});