Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
WIP
Signed-off-by: Pushpak Chhajed <[email protected]>
  • Loading branch information
pushpak1300 committed Dec 11, 2025
commit 8f831116b93e78ea6463fe1c520f0e1ae2c1dae5
2 changes: 0 additions & 2 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
declare(strict_types=1);

use Rector\CodingStyle\Rector\Encapsed\EncapsedStringsToSprintfRector;
use Rector\CodingStyle\Rector\FunctionLike\FunctionLikeToFirstClassCallableRector;
use Rector\Config\RectorConfig;
use Rector\Php81\Rector\Property\ReadOnlyPropertyRector;
use Rector\Strict\Rector\Empty_\DisallowedEmptyRuleFixerRector;
Expand All @@ -17,7 +16,6 @@
ReadOnlyPropertyRector::class,
EncapsedStringsToSprintfRector::class,
DisallowedEmptyRuleFixerRector::class,
FunctionLikeToFirstClassCallableRector::class,
])
->withPreparedSets(
deadCode: true,
Expand Down
46 changes: 3 additions & 43 deletions src/Install/GuidelineComposer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,6 +18,8 @@

class GuidelineComposer
{
use RendersBladeGuidelines;

protected string $userGuidelineDir = '.ai/guidelines';

/** @var Collection<string, array> */
Expand Down Expand Up @@ -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___',
'<?php' => '___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}
*/
Expand Down Expand Up @@ -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('/(?<!@)@boostsnippet\(\s*(?P<nameQuote>[\'"])(?P<name>[^\1]*?)\1(?:\s*,\s*(?P<langQuote>[\'"])(?P<lang>[^\3]*?)\3)?\s*\)(?P<content>.*?)@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] = '<code-snippet name="'.$name.'" lang="'.$lang.'">'."\n".$snippetContent."\n".'</code-snippet>'."\n\n";

return $placeholder;
}, $content);
}

protected function getGuidelineAssist(): GuidelineAssist
{
return new GuidelineAssist($this->roster, $this->config);
Expand Down
38 changes: 16 additions & 22 deletions src/Mcp/Boost.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
namespace Laravel\Boost\Mcp;

use DirectoryIterator;
use Laravel\Boost\Install\GuidelineAssist;
use Laravel\Boost\Mcp\Methods\CallToolWithExecutor;
use Laravel\Boost\Mcp\Prompts\BladePrompt;
use Laravel\Boost\Mcp\Resources\ApplicationInfo;
use Laravel\Boost\Support\Composer;
use Laravel\Mcp\Server;

class Boost extends Server
Expand Down Expand Up @@ -58,7 +61,8 @@ protected function boot(): void
{
collect($this->discoverTools())->each(fn (string $tool): string => $this->tools[] = $tool);
collect($this->discoverResources())->each(fn (string $resource): string => $this->resources[] = $resource);
collect($this->discoverPrompts())->each(fn (string $prompt): string => $this->prompts[] = $prompt);

$this->discoverThirdPartyPrompts();

// Override the tools/call method to use our ToolExecutor
$this->methods['tools/call'] = CallToolWithExecutor::class;
Expand Down Expand Up @@ -122,32 +126,22 @@ protected function discoverResources(): array
return $resources;
}

/**
* @return array<int, class-string<\Laravel\Mcp\Server\Prompt>>
*/
protected function discoverPrompts(): array
protected function discoverThirdPartyPrompts(): void
{
$prompts = [];
$guidelineAssist = app(GuidelineAssist::class);

$excludedPrompts = config('boost.mcp.prompts.exclude', []);
$promptDir = new DirectoryIterator(__DIR__.DIRECTORY_SEPARATOR.'Prompts');
foreach (Composer::packagesDirectoriesWithBoostGuidelines() as $package => $path) {
$corePath = $path.DIRECTORY_SEPARATOR.'core.blade.php';

foreach ($promptDir as $promptFile) {
if ($promptFile->isFile() && $promptFile->getExtension() === 'php') {
$fqdn = 'Laravel\\Boost\\Mcp\\Prompts\\'.$promptFile->getBasename('.php');
if (class_exists($fqdn) && ! in_array($fqdn, $excludedPrompts, true)) {
$prompts[] = $fqdn;
}
}
}

$extraPrompts = config('boost.mcp.prompts.include', []);
foreach ($extraPrompts as $promptClass) {
if (class_exists($promptClass)) {
$prompts[] = $promptClass;
if (file_exists($corePath)) {
$this->registerThirdPartyPrompt($package, $corePath, $guidelineAssist);
}
}
}

return $prompts;
protected function registerThirdPartyPrompt(string $package, string $bladePath, GuidelineAssist $guidelineAssist): void
{
$prompt = new BladePrompt($guidelineAssist, $package, $bladePath);
$this->prompts[] = $prompt;
}
}
38 changes: 38 additions & 0 deletions src/Mcp/Prompts/BladePrompt.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Laravel\Boost\Mcp\Prompts;

use Illuminate\Support\Str;
use Laravel\Boost\Install\GuidelineAssist;
use Laravel\Boost\Mcp\Prompts\Concerns\RendersBladeGuidelines;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Prompt;

class BladePrompt extends Prompt
{
use RendersBladeGuidelines;

public function __construct(
protected GuidelineAssist $guidelineAssist,
protected string $packageName,
protected string $bladePath,
) {

$this->name = Str::slug(str_replace('/', '-', $packageName)).'-task';
$this->description = "Guidelines for {$packageName}";
}

public function handle(): Response
{
$content = $this->renderBlade($this->bladePath);

return Response::text($content);
}

protected function getGuidelineAssist(): GuidelineAssist
{
return $this->guidelineAssist;
}
}
69 changes: 69 additions & 0 deletions src/Mcp/Prompts/Concerns/RendersBladeGuidelines.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace Laravel\Boost\Mcp\Prompts\Concerns;

use Illuminate\Support\Facades\Blade;
use Laravel\Boost\Install\GuidelineAssist;

trait RendersBladeGuidelines
{
private array $storedSnippets = [];

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___',
'<?php' => '___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('/(?<!@)@boostsnippet\(\s*(?P<nameQuote>[\'"])(?P<name>[^\1]*?)\1(?:\s*,\s*(?P<langQuote>[\'"])(?P<lang>[^\3]*?)\3)?\s*\)(?P<content>.*?)@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] = '<code-snippet name="'.$name.'" lang="'.$lang.'">'."\n".$snippetContent."\n".'</code-snippet>'."\n\n";

return $placeholder;
}, $content);
}

protected function renderBlade(string $bladePath): string
{
if (! file_exists($bladePath)) {
return '';
}

$content = file_get_contents($bladePath);
$content = $this->processBoostSnippets($content);

$rendered = $this->renderContent($content, $bladePath);

return str_replace(array_keys($this->storedSnippets), array_values($this->storedSnippets), $rendered);
}

abstract protected function getGuidelineAssist(): GuidelineAssist;
}
123 changes: 123 additions & 0 deletions tests/Feature/Mcp/Prompts/BladePromptTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

declare(strict_types=1);

use Laravel\Boost\Install\GuidelineAssist;
use Laravel\Boost\Install\GuidelineConfig;
use Laravel\Boost\Install\Herd;
use Laravel\Boost\Mcp\Prompts\BladePrompt;
use Laravel\Roster\Roster;

beforeEach(function (): void {
// Create a test blade file
$this->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');

$roster = app(Roster::class);
$herd = app(Herd::class);
$this->guidelineAssist = new GuidelineAssist($roster, new GuidelineConfig, $herd);
});

afterEach(function (): void {
if (file_exists($this->testBladePath)) {
unlink($this->testBladePath);
}
});

test('it renders blade file as prompt', function (): void {
$prompt = new BladePrompt('acme/payments', $this->testBladePath, $this->guidelineAssist);

$response = $prompt->handle();

expect($response)->isToolResult()
->toolHasNoError()
->toolTextContains('Test Guideline')
->toolTextContains('Follow best practices')
->toolTextContains('Write clean code');
});

test('it generates correct prompt name from package', function (): void {
$prompt = new BladePrompt('acme/payments', $this->testBladePath, $this->guidelineAssist);

expect($prompt->name())->toBe('acme-payments-task');
});

test('it generates correct description from package', function (): void {
$prompt = new BladePrompt('acme/payments', $this->testBladePath, $this->guidelineAssist);

expect($prompt->description())->toBe('Guidelines for acme/payments');
});

test('it handles non-existent blade file gracefully', function (): void {
$prompt = new BladePrompt('acme/test', '/non/existent/path.blade.php', $this->guidelineAssist);

$response = $prompt->handle();

expect($response)->isToolResult()
->toolHasNoError();

expect((string) $response->content())->toBe('');
});

test('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 BladePrompt('test/package', $this->testBladePath, $this->guidelineAssist);
$response = $prompt->handle();

expect($response)->isToolResult()
->toolTextContains('`Model::factory()`')
->toolTextContains('```php');
});

test('it processes php tags in blade content', function (): void {
$bladeContent = '# Guideline

Example code:

<?php
echo "Hello World";
?>';

file_put_contents($this->testBladePath, $bladeContent);

$prompt = new BladePrompt('test/package', $this->testBladePath, $this->guidelineAssist);
$response = $prompt->handle();

expect($response)->isToolResult()
->toolTextContains('<?php')
->toolTextContains('echo "Hello World"');
});

test('it processes boost snippets', function (): void {
$bladeContent = '# Guideline

@boostsnippet(\'example\', \'php\')
function example() {
return true;
}
@endboostsnippet';

file_put_contents($this->testBladePath, $bladeContent);

$prompt = new BladePrompt('test/package', $this->testBladePath, $this->guidelineAssist);
$response = $prompt->handle();

expect($response)->isToolResult()
->toolTextContains('<code-snippet name="example" lang="php">')
->toolTextContains('function example()');
});
Loading