From d100e72c2944ade85511925833dad2364f5786ee Mon Sep 17 00:00:00 2001 From: SagorIslamOfficial Date: Thu, 9 Apr 2026 00:14:46 +0600 Subject: [PATCH 1/2] feat: add skill tracking in `boost.json` with hashes for GitHub skills --- src/Console/AddSkillCommand.php | 9 ++++ src/Skills/Remote/GitHubSkillProvider.php | 14 ++++++ src/Support/Config.php | 44 +++++++++++++++++++ tests/Feature/Console/AddSkillCommandTest.php | 34 ++++++++++++++ .../Skills/Remote/GitHubSkillProviderTest.php | 17 +++++++ tests/Unit/Support/ConfigTest.php | 26 +++++++++++ 6 files changed, 144 insertions(+) diff --git a/src/Console/AddSkillCommand.php b/src/Console/AddSkillCommand.php index 9a3b1134..ae54f675 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,7 @@ protected function downloadSkills(Collection $skills): array protected function addSkills(Collection $skills): array { $results = ['installedNames' => [], 'failedDetails' => []]; + $config = new Config; foreach ($skills as $skill) { $targetPath = $this->skillTargetPath($skill); @@ -298,6 +300,13 @@ protected function addSkills(Collection $skills): array try { if ($this->fetcher->downloadSkill($skill, $targetPath)) { $results['installedNames'][] = $skill->name; + + $config->trackSkill( + repository: $this->repository->source(), + skillName: $skill->name, + sourceType: 'github', + computedHash: $this->fetcher->getSkillHash($skill), + ); } else { $results['failedDetails'][$skill->name] = 'Download failed'; } diff --git a/src/Skills/Remote/GitHubSkillProvider.php b/src/Skills/Remote/GitHubSkillProvider.php index c17c2a92..3bcb7787 100644 --- a/src/Skills/Remote/GitHubSkillProvider.php +++ b/src/Skills/Remote/GitHubSkillProvider.php @@ -58,6 +58,20 @@ public function discoverSkills(): Collection ->keyBy(fn (RemoteSkill $skill): string => $skill->name); } + public function getSkillHash(RemoteSkill $skill): ?string + { + $tree = $this->fetchRepositoryTree(); + + if ($tree === null) { + return null; + } + + $skillItem = collect($tree['tree']) + ->first(fn (array $item): bool => $item['path'] === $skill->path && $item['type'] === 'tree'); + + return is_array($skillItem) ? ($skillItem['sha'] ?? null) : null; + } + public function downloadSkill(RemoteSkill $skill, string $targetPath): bool { $tree = $this->fetchRepositoryTree(); diff --git a/src/Support/Config.php b/src/Support/Config.php index e7db62b1..afb2a822 100644 --- a/src/Support/Config.php +++ b/src/Support/Config.php @@ -5,6 +5,7 @@ namespace Laravel\Boost\Support; use Illuminate\Support\Str; +use stdClass; class Config { @@ -41,6 +42,36 @@ public function hasSkills(): bool return $this->getSkills() !== []; } + /** + * @return array}> + */ + public function getTrackedSkills(): array + { + return $this->get('repositories', []); + } + + public function trackSkill(string $repository, string $skillName, string $sourceType = 'github', ?string $computedHash = null): void + { + $repositories = $this->get('repositories', []); + + if (! isset($repositories[$repository])) { + $repositories[$repository] = [ + 'sourceType' => $sourceType, + 'skills' => [], + ]; + } + + $skillData = []; + + if ($computedHash !== null) { + $skillData['computedHash'] = $computedHash; + } + + $repositories[$repository]['skills'][$skillName] = $skillData; + + $this->set('repositories', $repositories); + } + public function getMcp(): bool { return $this->get('mcp', false); @@ -138,6 +169,19 @@ protected function set(string $key, mixed $value): void data_set($config, $key, $value); + if (isset($config['repositories'])) { + foreach ($config['repositories'] as $repo => $data) { + if (isset($data['skills']) && is_array($data['skills'])) { + $skillObj = new stdClass; + + foreach ($data['skills'] as $skillName => $skillData) { + $skillObj->$skillName = is_array($skillData) ? (object) $skillData : (object) []; + } + $config['repositories'][$repo]['skills'] = $skillObj; + } + } + } + ksort($config); $path = base_path(self::FILE); diff --git a/tests/Feature/Console/AddSkillCommandTest.php b/tests/Feature/Console/AddSkillCommandTest.php index cb60e859..5bc2e830 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,38 @@ $this->assertFileContains(['# SKILL Content'], '.ai/skills/skill-one/SKILL.md'); }); +it('tracks installed skills in boost json with full source and skill hash', 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('owner/repo/path/to/skills') + ->and($tracked['owner/repo/path/to/skills']['sourceType'])->toBe('github') + ->and($tracked['owner/repo/path/to/skills']['skills'])->toHaveKey('skill-one') + ->and($tracked['owner/repo/path/to/skills']['skills']['skill-one']['computedHash'])->toBe('skill-one-tree-sha'); +}); + 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..e26f573f 100644 --- a/tests/Unit/Skills/Remote/GitHubSkillProviderTest.php +++ b/tests/Unit/Skills/Remote/GitHubSkillProviderTest.php @@ -462,3 +462,20 @@ 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'); }); + +it('returns skill hash for the requested skill directory', function (): void { + Http::fake([ + ...fakeGitHubRepo(), + ...fakeTreeResponse([ + ['path' => 'skill-one', 'type' => 'tree', 'sha' => 'skill-one-tree-sha'], + ['path' => 'skill-one/SKILL.md', 'type' => 'blob', 'sha' => 'skill-one-file-sha', 'size' => 123], + ['path' => 'skill-two', 'type' => 'tree', 'sha' => 'skill-two-tree-sha'], + ['path' => 'skill-two/SKILL.md', 'type' => 'blob', 'sha' => 'skill-two-file-sha', 'size' => 456], + ]), + ]); + + $fetcher = new GitHubSkillProvider(new GitHubRepository('owner', 'repo')); + $hash = $fetcher->getSkillHash(new RemoteSkill('skill-two', 'owner/repo', 'skill-two')); + + expect($hash)->toBe('skill-two-tree-sha'); +}); diff --git a/tests/Unit/Support/ConfigTest.php b/tests/Unit/Support/ConfigTest.php index 5baf5dd2..dfd4847c 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,25 @@ expect($config->getPackages())->toEqual($packages); }); + +it('may track and query skills across repositories', function (): void { + $config = new Config; + + expect($config->getTrackedSkills())->toBeEmpty(); + + $config->trackSkill('vercel-labs/agent-skills', 'composition-patterns', 'github'); + $config->trackSkill( + repository: 'vercel-labs/agent-skills', + skillName: 'deploy-to-vercel', + sourceType: 'github', + computedHash: 'bf90d0a4', + ); + $config->trackSkill('anthropics/skills', 'algorithmic-art', 'github'); + + $tracked = $config->getTrackedSkills(); + + expect($tracked)->toHaveKeys(['vercel-labs/agent-skills', 'anthropics/skills']) + ->and($tracked['vercel-labs/agent-skills']['sourceType'])->toBe('github') + ->and($tracked['vercel-labs/agent-skills']['skills'])->toHaveKeys(['composition-patterns', 'deploy-to-vercel']) + ->and($tracked['vercel-labs/agent-skills']['skills']['deploy-to-vercel']['computedHash'])->toBe('bf90d0a4'); +}); From 397dc312e602e828caccb709e6877b05ed39c092 Mon Sep 17 00:00:00 2001 From: SagorIslamOfficial Date: Thu, 16 Apr 2026 23:09:03 +0600 Subject: [PATCH 2/2] refactor: group skill entries by repository source in boost.json --- src/Console/AddSkillCommand.php | 13 +- src/Console/InstallCommand.php | 3 +- src/Skills/Remote/GitHubSkillProvider.php | 14 -- src/Support/Config.php | 137 ++++++++++++++---- tests/Feature/Console/AddSkillCommandTest.php | 8 +- .../Skills/Remote/GitHubSkillProviderTest.php | 16 -- tests/Unit/Support/ConfigTest.php | 72 +++++++-- 7 files changed, 175 insertions(+), 88 deletions(-) diff --git a/src/Console/AddSkillCommand.php b/src/Console/AddSkillCommand.php index ae54f675..249ca351 100644 --- a/src/Console/AddSkillCommand.php +++ b/src/Console/AddSkillCommand.php @@ -289,6 +289,7 @@ protected function addSkills(Collection $skills): array { $results = ['installedNames' => [], 'failedDetails' => []]; $config = new Config; + $skillsToTrack = []; foreach ($skills as $skill) { $targetPath = $this->skillTargetPath($skill); @@ -300,13 +301,7 @@ protected function addSkills(Collection $skills): array try { if ($this->fetcher->downloadSkill($skill, $targetPath)) { $results['installedNames'][] = $skill->name; - - $config->trackSkill( - repository: $this->repository->source(), - skillName: $skill->name, - sourceType: 'github', - computedHash: $this->fetcher->getSkillHash($skill), - ); + $skillsToTrack[$skill->name] = $this->repository->source(); } else { $results['failedDetails'][$skill->name] = 'Download failed'; } @@ -315,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/Skills/Remote/GitHubSkillProvider.php b/src/Skills/Remote/GitHubSkillProvider.php index 3bcb7787..c17c2a92 100644 --- a/src/Skills/Remote/GitHubSkillProvider.php +++ b/src/Skills/Remote/GitHubSkillProvider.php @@ -58,20 +58,6 @@ public function discoverSkills(): Collection ->keyBy(fn (RemoteSkill $skill): string => $skill->name); } - public function getSkillHash(RemoteSkill $skill): ?string - { - $tree = $this->fetchRepositoryTree(); - - if ($tree === null) { - return null; - } - - $skillItem = collect($tree['tree']) - ->first(fn (array $item): bool => $item['path'] === $skill->path && $item['type'] === 'tree'); - - return is_array($skillItem) ? ($skillItem['sha'] ?? null) : null; - } - public function downloadSkill(RemoteSkill $skill, string $targetPath): bool { $tree = $this->fetchRepositoryTree(); diff --git a/src/Support/Config.php b/src/Support/Config.php index afb2a822..516bfaa1 100644 --- a/src/Support/Config.php +++ b/src/Support/Config.php @@ -5,12 +5,13 @@ namespace Laravel\Boost\Support; use Illuminate\Support\Str; -use stdClass; class Config { protected const FILE = 'boost.json'; + protected const DEFAULT_SKILLS_SOURCE = 'laravel/boost'; + public function getGuidelines(): bool { return (bool) $this->get('guidelines', false); @@ -26,7 +27,7 @@ public function setGuidelines(bool $enabled): void */ public function getSkills(): array { - return $this->get('skills', []); + return array_keys($this->extractSourceMap($this->getRawSkills())); } /** @@ -34,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 @@ -42,34 +54,101 @@ public function hasSkills(): bool return $this->getSkills() !== []; } - /** - * @return array}> - */ public function getTrackedSkills(): array { - return $this->get('repositories', []); + $tracked = []; + + foreach ($this->extractSourceMap($this->getRawSkills()) as $skillName => $source) { + if ($source !== self::DEFAULT_SKILLS_SOURCE) { + $tracked[$skillName] = ['source' => $source]; + } + } + + return $tracked; } - public function trackSkill(string $repository, string $skillName, string $sourceType = 'github', ?string $computedHash = null): void + public function trackSkills(array $skillsWithSource): void { - $repositories = $this->get('repositories', []); + $sourceMap = $this->extractSourceMap($this->getRawSkills()); - if (! isset($repositories[$repository])) { - $repositories[$repository] = [ - 'sourceType' => $sourceType, - 'skills' => [], - ]; + foreach ($skillsWithSource as $skillName => $source) { + if (is_string($skillName) && $skillName !== '' && is_string($source) && $source !== '') { + $sourceMap[$skillName] = $source; + } } - $skillData = []; + $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; - if ($computedHash !== null) { - $skillData['computedHash'] = $computedHash; + 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; + } } - $repositories[$repository]['skills'][$skillName] = $skillData; + 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; + } - $this->set('repositories', $repositories); + foreach ($grouped as &$skillList) { + $skillList = array_values($skillList); + sort($skillList); + } + + ksort($grouped); + + return $grouped; } public function getMcp(): bool @@ -156,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(); @@ -169,19 +255,6 @@ protected function set(string $key, mixed $value): void data_set($config, $key, $value); - if (isset($config['repositories'])) { - foreach ($config['repositories'] as $repo => $data) { - if (isset($data['skills']) && is_array($data['skills'])) { - $skillObj = new stdClass; - - foreach ($data['skills'] as $skillName => $skillData) { - $skillObj->$skillName = is_array($skillData) ? (object) $skillData : (object) []; - } - $config['repositories'][$repo]['skills'] = $skillObj; - } - } - } - ksort($config); $path = base_path(self::FILE); diff --git a/tests/Feature/Console/AddSkillCommandTest.php b/tests/Feature/Console/AddSkillCommandTest.php index 5bc2e830..4c51f1ec 100644 --- a/tests/Feature/Console/AddSkillCommandTest.php +++ b/tests/Feature/Console/AddSkillCommandTest.php @@ -97,7 +97,7 @@ $this->assertFileContains(['# SKILL Content'], '.ai/skills/skill-one/SKILL.md'); }); -it('tracks installed skills in boost json with full source and skill hash', function (): void { +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', @@ -123,10 +123,8 @@ $tracked = (new Config)->getTrackedSkills(); - expect($tracked)->toHaveKey('owner/repo/path/to/skills') - ->and($tracked['owner/repo/path/to/skills']['sourceType'])->toBe('github') - ->and($tracked['owner/repo/path/to/skills']['skills'])->toHaveKey('skill-one') - ->and($tracked['owner/repo/path/to/skills']['skills']['skill-one']['computedHash'])->toBe('skill-one-tree-sha'); + 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 { diff --git a/tests/Unit/Skills/Remote/GitHubSkillProviderTest.php b/tests/Unit/Skills/Remote/GitHubSkillProviderTest.php index e26f573f..f3be5f28 100644 --- a/tests/Unit/Skills/Remote/GitHubSkillProviderTest.php +++ b/tests/Unit/Skills/Remote/GitHubSkillProviderTest.php @@ -463,19 +463,3 @@ function fakeTreeResponse(array $tree, string $branch = 'main'): array ->and($skills->get('my-skill')->path)->toBe('.ai/claude/skills/my-skill'); }); -it('returns skill hash for the requested skill directory', function (): void { - Http::fake([ - ...fakeGitHubRepo(), - ...fakeTreeResponse([ - ['path' => 'skill-one', 'type' => 'tree', 'sha' => 'skill-one-tree-sha'], - ['path' => 'skill-one/SKILL.md', 'type' => 'blob', 'sha' => 'skill-one-file-sha', 'size' => 123], - ['path' => 'skill-two', 'type' => 'tree', 'sha' => 'skill-two-tree-sha'], - ['path' => 'skill-two/SKILL.md', 'type' => 'blob', 'sha' => 'skill-two-file-sha', 'size' => 456], - ]), - ]); - - $fetcher = new GitHubSkillProvider(new GitHubRepository('owner', 'repo')); - $hash = $fetcher->getSkillHash(new RemoteSkill('skill-two', 'owner/repo', 'skill-two')); - - expect($hash)->toBe('skill-two-tree-sha'); -}); diff --git a/tests/Unit/Support/ConfigTest.php b/tests/Unit/Support/ConfigTest.php index dfd4847c..91fbd11b 100644 --- a/tests/Unit/Support/ConfigTest.php +++ b/tests/Unit/Support/ConfigTest.php @@ -90,24 +90,70 @@ expect($config->getPackages())->toEqual($packages); }); -it('may track and query skills across repositories', function (): void { +it('may track and query skills with source metadata', function (): void { $config = new Config; expect($config->getTrackedSkills())->toBeEmpty(); - $config->trackSkill('vercel-labs/agent-skills', 'composition-patterns', 'github'); - $config->trackSkill( - repository: 'vercel-labs/agent-skills', - skillName: 'deploy-to-vercel', - sourceType: 'github', - computedHash: 'bf90d0a4', - ); - $config->trackSkill('anthropics/skills', 'algorithmic-art', 'github'); + $config->trackSkill('composition-patterns', 'vercel-labs/agent-skills'); + $config->trackSkill('algorithmic-art', 'anthropics/skills'); $tracked = $config->getTrackedSkills(); - expect($tracked)->toHaveKeys(['vercel-labs/agent-skills', 'anthropics/skills']) - ->and($tracked['vercel-labs/agent-skills']['sourceType'])->toBe('github') - ->and($tracked['vercel-labs/agent-skills']['skills'])->toHaveKeys(['composition-patterns', 'deploy-to-vercel']) - ->and($tracked['vercel-labs/agent-skills']['skills']['deploy-to-vercel']['computedHash'])->toBe('bf90d0a4'); + 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'); });