diff --git a/src/BoostServiceProvider.php b/src/BoostServiceProvider.php index 32df690d..0cbba269 100644 --- a/src/BoostServiceProvider.php +++ b/src/BoostServiceProvider.php @@ -12,6 +12,8 @@ use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Illuminate\View\Compilers\BladeCompiler; +use Laravel\Boost\Install\GuidelineAssist; +use Laravel\Boost\Install\GuidelineConfig; use Laravel\Boost\Mcp\Boost; use Laravel\Boost\Middleware\InjectBoost; use Laravel\Mcp\Facades\Mcp; @@ -58,6 +60,13 @@ public function register(): void return $roster; }); + + $this->app->singleton(GuidelineConfig::class, fn (): GuidelineConfig => new GuidelineConfig); + + $this->app->singleton(GuidelineAssist::class, fn ($app): GuidelineAssist => new GuidelineAssist( + $app->make(Roster::class), + $app->make(GuidelineConfig::class) + )); } public function boot(Router $router): void diff --git a/src/Install/GuidelineComposer.php b/src/Install/GuidelineComposer.php index 3f045c29..91f2ee59 100644 --- a/src/Install/GuidelineComposer.php +++ b/src/Install/GuidelineComposer.php @@ -5,8 +5,8 @@ namespace Laravel\Boost\Install; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Blade; use Illuminate\Support\Str; +use Laravel\Boost\Mcp\Prompts\Concerns\RendersBladeGuidelines; use Laravel\Boost\Support\Composer; use Laravel\Roster\Enums\Packages; use Laravel\Roster\Package; @@ -18,6 +18,8 @@ class GuidelineComposer { + use RendersBladeGuidelines; + protected string $userGuidelineDir = '.ai/guidelines'; /** @var Collection */ @@ -276,31 +278,6 @@ protected function guidelinesDir(string $dirPath, bool $thirdParty = false): arr ->all(); } - protected function renderContent(string $content, string $path): string - { - $isBladeTemplate = str_ends_with($path, '.blade.php'); - - if (! $isBladeTemplate) { - return $content; - } - - // Temporarily replace backticks and PHP opening tags with placeholders before Blade processing - // This prevents Blade from trying to execute PHP code examples and supports inline code - $placeholders = [ - '`' => '___SINGLE_BACKTICK___', - ' '___OPEN_PHP_TAG___', - '@volt' => '___VOLT_DIRECTIVE___', - '@endvolt' => '___ENDVOLT_DIRECTIVE___', - ]; - - $content = str_replace(array_keys($placeholders), array_values($placeholders), $content); - $rendered = Blade::render($content, [ - 'assist' => $this->getGuidelineAssist(), - ]); - - return str_replace(array_values($placeholders), array_keys($placeholders), $rendered); - } - /** * @return array{content: string, name: string, description: string, path: ?string, custom: bool, third_party: bool} */ @@ -346,23 +323,6 @@ protected function guideline(string $path, bool $thirdParty = false): array ]; } - private array $storedSnippets = []; - - protected function processBoostSnippets(string $content): string - { - return preg_replace_callback('/(?[\'"])(?P[^\1]*?)\1(?:\s*,\s*(?P[\'"])(?P[^\3]*?)\3)?\s*\)(?P.*?)@endboostsnippet/s', function (array $matches): string { - $name = $matches['name']; - $lang = empty($matches['lang']) ? 'html' : $matches['lang']; - $snippetContent = $matches['content']; - - $placeholder = '___BOOST_SNIPPET_'.count($this->storedSnippets).'___'; - - $this->storedSnippets[$placeholder] = ''."\n".$snippetContent."\n".''."\n\n"; - - return $placeholder; - }, $content); - } - protected function getGuidelineAssist(): GuidelineAssist { return new GuidelineAssist($this->roster, $this->config); diff --git a/src/Mcp/Boost.php b/src/Mcp/Boost.php index 46e4820e..4b48c69b 100644 --- a/src/Mcp/Boost.php +++ b/src/Mcp/Boost.php @@ -4,7 +4,10 @@ namespace Laravel\Boost\Mcp; +use InvalidArgumentException; use Laravel\Boost\Mcp\Methods\CallToolWithExecutor; +use Laravel\Boost\Mcp\Prompts\PackageGuidelinePrompt; +use Laravel\Boost\Mcp\Resources\PackageGuidelineResource; use Laravel\Boost\Mcp\Tools\ApplicationInfo; use Laravel\Boost\Mcp\Tools\BrowserLogs; use Laravel\Boost\Mcp\Tools\DatabaseConnections; @@ -21,6 +24,7 @@ use Laravel\Boost\Mcp\Tools\ReportFeedback; use Laravel\Boost\Mcp\Tools\SearchDocs; use Laravel\Boost\Mcp\Tools\Tinker; +use Laravel\Boost\Support\Composer; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Resource; @@ -79,28 +83,12 @@ protected function boot(): void $this->methods['tools/call'] = CallToolWithExecutor::class; } - /** - * @param array $availablePrimitives - * @return array - */ - private function discoverPrimitives(array $availablePrimitives, string $type): array - { - return collect($availablePrimitives) - ->diff(config("boost.mcp.{$type}.exclude", [])) - ->merge( - collect(config("boost.mcp.{$type}.include", [])) - ->filter(fn (string $class): bool => class_exists($class)) - ) - ->values() - ->all(); - } - /** * @return array> */ protected function discoverTools(): array { - return $this->discoverPrimitives([ + return $this->filterPrimitives([ ApplicationInfo::class, BrowserLogs::class, DatabaseConnections::class, @@ -125,9 +113,12 @@ protected function discoverTools(): array */ protected function discoverResources(): array { - return $this->discoverPrimitives([ + $availableResources = [ Resources\ApplicationInfo::class, - ], 'resources'); + ...$this->discoverThirdPartyPrimitives(Resource::class), + ]; + + return $this->filterPrimitives($availableResources, 'resources'); } /** @@ -135,6 +126,52 @@ protected function discoverResources(): array */ protected function discoverPrompts(): array { - return $this->discoverPrimitives([], 'prompts'); + return $this->filterPrimitives( + $this->discoverThirdPartyPrimitives(Prompt::class), + 'prompts' + ); + } + + /** + * @template T of Prompt|Resource + * + * @param class-string $primitiveType + * @return array + */ + private function discoverThirdPartyPrimitives(string $primitiveType): array + { + $primitiveClass = match ($primitiveType) { + Prompt::class => PackageGuidelinePrompt::class, + Resource::class => PackageGuidelineResource::class, + default => throw new InvalidArgumentException('Invalid Primitive Type'), + }; + + $primitives = []; + + foreach (Composer::packagesDirectoriesWithBoostGuidelines() as $package => $path) { + $corePath = $path.DIRECTORY_SEPARATOR.'core.blade.php'; + + if (file_exists($corePath)) { + $primitives[] = new $primitiveClass($package, $corePath); + } + } + + return $primitives; + } + + /** + * @param array $availablePrimitives + * @return array + */ + private function filterPrimitives(array $availablePrimitives, string $type): array + { + return collect($availablePrimitives) + ->diff(config("boost.mcp.{$type}.exclude", [])) + ->merge( + collect(config("boost.mcp.{$type}.include", [])) + ->filter(fn (string $class): bool => class_exists($class)) + ) + ->values() + ->all(); } } diff --git a/src/Mcp/Prompts/Concerns/RendersBladeGuidelines.php b/src/Mcp/Prompts/Concerns/RendersBladeGuidelines.php new file mode 100644 index 00000000..51a95bf4 --- /dev/null +++ b/src/Mcp/Prompts/Concerns/RendersBladeGuidelines.php @@ -0,0 +1,76 @@ + '___SINGLE_BACKTICK___', + ' '___OPEN_PHP_TAG___', + '@volt' => '___VOLT_DIRECTIVE___', + '@endvolt' => '___ENDVOLT_DIRECTIVE___', + ]; + + $content = str_replace(array_keys($placeholders), array_values($placeholders), $content); + $rendered = Blade::render($content, [ + 'assist' => $this->getGuidelineAssist(), + ]); + + return str_replace(array_values($placeholders), array_keys($placeholders), $rendered); + } + + protected function processBoostSnippets(string $content): string + { + return preg_replace_callback('/(?[\'"])(?P[^\1]*?)\1(?:\s*,\s*(?P[\'"])(?P[^\3]*?)\3)?\s*\)(?P.*?)@endboostsnippet/s', function (array $matches): string { + $name = $matches['name']; + $lang = empty($matches['lang']) ? 'html' : $matches['lang']; + $snippetContent = $matches['content']; + + $placeholder = '___BOOST_SNIPPET_'.count($this->storedSnippets).'___'; + + $this->storedSnippets[$placeholder] = ''."\n".$snippetContent."\n".''."\n\n"; + + return $placeholder; + }, $content); + } + + protected function renderGuidelineFile(string $bladePath): string + { + if (! file_exists($bladePath)) { + return ''; + } + + $content = file_get_contents($bladePath); + $content = $this->processBoostSnippets($content); + + $rendered = $this->renderContent($content, $bladePath); + + $rendered = str_replace(array_keys($this->storedSnippets), array_values($this->storedSnippets), $rendered); + + $this->storedSnippets = []; + + return $rendered; + } + + protected function getGuidelineAssist(): GuidelineAssist + { + return app(GuidelineAssist::class); + } +} diff --git a/src/Mcp/Prompts/PackageGuidelinePrompt.php b/src/Mcp/Prompts/PackageGuidelinePrompt.php new file mode 100644 index 00000000..b414e4af --- /dev/null +++ b/src/Mcp/Prompts/PackageGuidelinePrompt.php @@ -0,0 +1,30 @@ +name = $this->packageName; + $this->title = $this->packageName; + $this->description = "Guidelines for {$packageName}"; + } + + public function handle(): Response + { + $content = $this->renderGuidelineFile($this->bladePath); + + return Response::text($content); + } +} diff --git a/src/Mcp/Resources/PackageGuidelineResource.php b/src/Mcp/Resources/PackageGuidelineResource.php new file mode 100644 index 00000000..a025b103 --- /dev/null +++ b/src/Mcp/Resources/PackageGuidelineResource.php @@ -0,0 +1,32 @@ +name = $this->packageName; + $this->title = $this->packageName; + $this->uri = "file://instructions/{$packageName}.md"; + $this->description = "Guidelines for {$packageName}"; + $this->mimeType = 'text/markdown'; + } + + public function handle(): Response + { + $content = $this->renderGuidelineFile($this->bladePath); + + return Response::text($content); + } +} diff --git a/tests/Feature/Mcp/Prompts/BladePromptTest.php b/tests/Feature/Mcp/Prompts/BladePromptTest.php new file mode 100644 index 00000000..4d696b01 --- /dev/null +++ b/tests/Feature/Mcp/Prompts/BladePromptTest.php @@ -0,0 +1,132 @@ +testBladePath = sys_get_temp_dir().'/test-guideline.blade.php'; + file_put_contents($this->testBladePath, '# Test Guideline + + This is a test guideline for testing. + + ## Rules + - Follow best practices + - Write clean code'); +}); + +afterEach(function (): void { + if (file_exists($this->testBladePath)) { + unlink($this->testBladePath); + } +}); + +it('renders a blade file as a prompt', function (): void { + $prompt = new PackageGuidelinePrompt('acme/payments', $this->testBladePath); + + $response = $prompt->handle(); + + expect($response)->isToolResult() + ->toolHasNoError() + ->toolTextContains('Test Guideline') + ->toolTextContains('Follow best practices') + ->toolTextContains('Write clean code'); +}); + +it('generates correct metadata from the package name', function (): void { + $prompt = new PackageGuidelinePrompt('acme/payments', $this->testBladePath); + + expect($prompt->name())->toBe('acme/payments') + ->and($prompt->description())->toBe('Guidelines for acme/payments'); +}); + +it('handles a non-existent blade file gracefully', function (): void { + $prompt = new PackageGuidelinePrompt('acme/test', '/non/existent/path.blade.php'); + + $response = $prompt->handle(); + + expect($response)->isToolResult()->toolHasNoError() + ->and((string) $response->content())->toBe(''); + +}); + +it('processes backticks in blade content', function (): void { + $bladeContent = '# Guideline + +Use `Model::factory()` to create models. + +```php +User::factory()->create(); +```'; + + file_put_contents($this->testBladePath, $bladeContent); + + $prompt = new PackageGuidelinePrompt('test/package', $this->testBladePath); + $response = $prompt->handle(); + + expect($response)->isToolResult() + ->toolTextContains('`Model::factory()`') + ->toolTextContains('```php'); +}); + +it('processes php tags in blade content', function (): void { + $bladeContent = '# Guideline + +Example code: + +'; + + file_put_contents($this->testBladePath, $bladeContent); + + $prompt = new PackageGuidelinePrompt('test/package', $this->testBladePath); + $response = $prompt->handle(); + + expect($response)->isToolResult() + ->toolTextContains('toolTextContains('echo "Hello World"'); +}); + +it('processes boost snippets', function (): void { + $bladeContent = '# Guideline + +@boostsnippet(\'example\', \'php\') +function example() { + return true; +} +@endboostsnippet'; + + file_put_contents($this->testBladePath, $bladeContent); + + $prompt = new PackageGuidelinePrompt('test/package', $this->testBladePath); + $response = $prompt->handle(); + + expect($response)->isToolResult() + ->toolTextContains('') + ->toolTextContains('function example()'); +}); + +it('clears stored snippets between multiple handle calls', function (): void { + $bladeContent = '# Guideline + +@boostsnippet(\'example\', \'php\') +function example() { + return true; +} +@endboostsnippet'; + + file_put_contents($this->testBladePath, $bladeContent); + + $prompt = new PackageGuidelinePrompt('test/package', $this->testBladePath); + + $response1 = $prompt->handle(); + $content1 = (string) $response1->content(); + + $response2 = $prompt->handle(); + $content2 = (string) $response2->content(); + + expect($content1)->toBe($content2) + ->and($content1)->toContain('') + ->and($content1)->not->toContain('___BOOST_SNIPPET_'); +}); diff --git a/tests/Feature/Mcp/Resources/ThirdPartyResourceTest.php b/tests/Feature/Mcp/Resources/ThirdPartyResourceTest.php new file mode 100644 index 00000000..0716c0ea --- /dev/null +++ b/tests/Feature/Mcp/Resources/ThirdPartyResourceTest.php @@ -0,0 +1,144 @@ +testBladePath = sys_get_temp_dir().'/test-resource-guideline.blade.php'; + file_put_contents($this->testBladePath, '# Test Resource Guideline + +This is a test guideline for resource testing. + +## Rules +- Follow best practices +- Write clean code'); +}); + +afterEach(function (): void { + if (file_exists($this->testBladePath)) { + unlink($this->testBladePath); + } +}); + +test('it generates resource uri from the package name', function (): void { + $resource = new PackageGuidelineResource('acme/payments', $this->testBladePath); + + expect($resource->uri())->toBe('file://instructions/acme/payments.md'); +}); + +test('it generates a description from the package name', function (): void { + $resource = new PackageGuidelineResource('acme/payments', $this->testBladePath); + + expect($resource->description())->toBe('Guidelines for acme/payments'); +}); + +test('it has Markdown mime type', function (): void { + $resource = new PackageGuidelineResource('acme/payments', $this->testBladePath); + + expect($resource->mimeType())->toBe('text/markdown'); +}); + +test('it renders blade guideline content', function (): void { + $resource = new PackageGuidelineResource('acme/payments', $this->testBladePath); + + $response = $resource->handle(); + + expect($response)->isToolResult() + ->toolHasNoError() + ->toolTextContains('Test Resource Guideline') + ->toolTextContains('Follow best practices') + ->toolTextContains('Write clean code'); +}); + +test('it preserves inline code backticks in content', function (): void { + $bladeContent = '# Guideline + +Use `Model::factory()` to create models. + +```php +User::factory()->create(); +```'; + + file_put_contents($this->testBladePath, $bladeContent); + + $resource = new PackageGuidelineResource('test/package', $this->testBladePath); + $response = $resource->handle(); + + expect($response)->isToolResult() + ->toolTextContains('`Model::factory()`') + ->toolTextContains('```php'); +}); + +test('it preserves php tags in content', function (): void { + $bladeContent = '# Guideline + +Example code: + +'; + + file_put_contents($this->testBladePath, $bladeContent); + + $resource = new PackageGuidelineResource('test/package', $this->testBladePath); + $response = $resource->handle(); + + expect($response)->isToolResult() + ->toolTextContains('toolTextContains('echo "Hello World"'); +}); + +test('it processes boost snippet directives', function (): void { + $bladeContent = '# Guideline + +@boostsnippet(\'example\', \'php\') +function example() { + return true; +} +@endboostsnippet'; + + file_put_contents($this->testBladePath, $bladeContent); + + $resource = new PackageGuidelineResource('test/package', $this->testBladePath); + $response = $resource->handle(); + + expect($response)->isToolResult() + ->toolTextContains('') + ->toolTextContains('function example()'); +}); + +test('it clears stored snippets between multiple handle calls', function (): void { + $bladeContent = '# Guideline + +@boostsnippet(\'example\', \'php\') +function example() { + return true; +} +@endboostsnippet'; + + file_put_contents($this->testBladePath, $bladeContent); + + $resource = new PackageGuidelineResource('test/package', $this->testBladePath); + + $response1 = $resource->handle(); + $content1 = (string) $response1->content(); + + $response2 = $resource->handle(); + $content2 = (string) $response2->content(); + + expect($content1)->toBe($content2) + ->and($content1)->toContain('') + ->and($content1)->not->toContain('___BOOST_SNIPPET___'); +}); + +test('it handles non-existent blade file gracefully', function (): void { + $resource = new PackageGuidelineResource('acme/test', '/non/existent/path.blade.php'); + + $response = $resource->handle(); + + expect($response)->isToolResult() + ->toolHasNoError() + ->and((string) $response->content())->toBe(''); + +});