refactor: susun semula struktur folder — Laravel source ke src/

This commit is contained in:
Saufi
2026-05-19 15:58:35 +08:00
parent f052251b94
commit bf53c71b45
10806 changed files with 1385379 additions and 121 deletions

21
vendor/laravel/boost/src/Boost.php vendored Normal file
View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost;
use Illuminate\Support\Facades\Facade;
/**
* @method static void registerAgent(string $key, string $className)
* @method static array getAgents()
*
* @see BoostManager
*/
class Boost extends Facade
{
protected static function getFacadeAccessor(): string
{
return BoostManager::class;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost;
use InvalidArgumentException;
use Laravel\Boost\Install\Agents\Agent;
use Laravel\Boost\Install\Agents\Amp;
use Laravel\Boost\Install\Agents\ClaudeCode;
use Laravel\Boost\Install\Agents\Codex;
use Laravel\Boost\Install\Agents\Copilot;
use Laravel\Boost\Install\Agents\Cursor;
use Laravel\Boost\Install\Agents\Gemini;
use Laravel\Boost\Install\Agents\Junie;
use Laravel\Boost\Install\Agents\Kiro;
use Laravel\Boost\Install\Agents\OpenCode;
class BoostManager
{
/** @var array<string, class-string<Agent>> */
private array $agents = [
'amp' => Amp::class,
'junie' => Junie::class,
'cursor' => Cursor::class,
'claude_code' => ClaudeCode::class,
'codex' => Codex::class,
'copilot' => Copilot::class,
'kiro' => Kiro::class,
'opencode' => OpenCode::class,
'gemini' => Gemini::class,
];
/**
* @param class-string<Agent> $className
*/
public function registerAgent(string $key, string $className): void
{
if (array_key_exists($key, $this->agents)) {
throw new InvalidArgumentException("Agent '{$key}' is already registered");
}
$this->agents[$key] = $className;
}
/**
* @return array<string, class-string<Agent>>
*/
public function getAgents(): array
{
return $this->agents;
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Http\Request;
use Illuminate\Log\Logger;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\Log;
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\Boost\Services\BrowserLogger;
use Laravel\Mcp\Facades\Mcp;
use Laravel\Roster\Roster;
class BoostServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(
__DIR__.'/../config/boost.php',
'boost'
);
if (! $this->shouldRun()) {
return;
}
$this->app->singleton(BoostManager::class, fn (): BoostManager => new BoostManager);
$this->app->singleton(Roster::class, fn (): Roster => Roster::scan(base_path()));
$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
{
if (! $this->shouldRun()) {
return;
}
Mcp::local('laravel-boost', Boost::class);
$this->registerPublishing();
$this->registerCommands();
$this->registerRoutes();
if (config('boost.browser_logs_watcher', true)) {
$this->registerBrowserLogger();
$this->callAfterResolving('blade.compiler', $this->registerBladeDirectives(...));
$this->hookIntoResponses($router);
}
}
protected function registerPublishing(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/../config/boost.php' => config_path('boost.php'),
], 'boost-config');
}
}
protected function registerCommands(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
Console\StartCommand::class,
Console\InstallCommand::class,
Console\UpdateCommand::class,
Console\ExecuteToolCommand::class,
Console\AddSkillCommand::class,
Console\ListSkillCommand::class,
]);
}
}
protected function registerRoutes(): void
{
Route::post('/_boost/browser-logs', function (Request $request) {
$logs = $request->input('logs', []);
// Handle sendBeacon's text/plain content type.
if (empty($logs) && ! $request->isJson()) {
$decoded = json_decode($request->getContent(), true);
$logs = $decoded['logs'] ?? [];
}
/** @var Logger $logger */
$logger = Log::channel('browser');
/**
* @var array{
* type: 'error'|'warn'|'info'|'log'|'table'|'window_error'|'uncaught_error'|'unhandled_rejection',
* timestamp: string,
* data: array,
* url:string,
* userAgent:string
* } $log */
foreach ($logs as $log) {
$logger->write(
level: match ($log['type']) {
'warn' => 'warning',
'log', 'table' => 'debug',
'window_error', 'uncaught_error', 'unhandled_rejection' => 'error',
default => $log['type']
},
message: self::buildLogMessageFromData($log['data']),
context: [
'url' => $log['url'],
'user_agent' => $log['userAgent'] ?: null,
'timestamp' => $log['timestamp'] ?: now()->toIso8601String(),
]
);
}
return response()->json(['status' => 'logged']);
})
->name('boost.browser-logs')
->withoutMiddleware(VerifyCsrfToken::class);
}
/**
* Build a string message for the log based on various input types. Single-dimensional, and multi:
* "data": {"message":"Unhandled Promise Rejection","reason":{"name":"TypeError","message":"NetworkError when attempting to fetch resource.","stack":""}}]
*/
private static function buildLogMessageFromData(array $data): string
{
$messages = [];
foreach ($data as $value) {
$messages[] = match (true) {
is_array($value) => self::buildLogMessageFromData($value),
is_string($value), is_numeric($value) => (string) $value,
is_bool($value) => $value ? 'true' : 'false',
is_null($value) => 'null',
is_object($value) => json_encode($value),
default => $value,
};
}
return implode(' ', $messages);
}
protected function registerBrowserLogger(): void
{
if (config('logging.channels.browser') !== null) {
return;
}
config([
'logging.channels.browser' => [
'driver' => 'single',
'path' => storage_path('logs'.DIRECTORY_SEPARATOR.'browser.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 14,
],
]);
}
protected function registerBladeDirectives(BladeCompiler $bladeCompiler): void
{
$bladeCompiler->directive('boostJs', fn (): string => '<?php echo '.BrowserLogger::class.'::getScript(); ?>');
}
protected function hookIntoResponses(Router $router): void
{
$this->app->booted(function () use ($router): void {
$router->pushMiddlewareToGroup('web', InjectBoost::class);
});
}
protected function shouldRun(): bool
{
if (! config('boost.enabled', true)) {
return false;
}
if (app()->runningUnitTests()) {
return false;
}
// Only enable Boost on local environments or when debug is true
if (! app()->environment('local') && config('app.debug', false) !== true) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Concerns;
use Laravel\Boost\Console\Enums\Theme;
use Laravel\Prompts\Concerns\Colors;
use function Laravel\Prompts\note;
trait DisplayHelper
{
use Colors;
protected ?Theme $theme = null;
protected function initTheme(?Theme $theme = null): void
{
$this->theme = $theme ?? Theme::random();
}
protected function displayBoostHeader(string $featureName, string $projectName, ?Theme $theme = null): void
{
$this->initTheme($theme);
$this->displayGradientLogo();
$this->displayTagline($featureName);
$this->displayNote($projectName);
}
protected function displayGradientLogo(): void
{
$lines = [
' ██████╗ ██████╗ ██████╗ ███████╗ ████████╗',
' ██╔══██╗ ██╔═══██╗ ██╔═══██╗ ██╔════╝ ╚══██╔══╝',
' ██████╔╝ ██║ ██║ ██║ ██║ ███████╗ ██║ ',
' ██╔══██╗ ██║ ██║ ██║ ██║ ╚════██║ ██║ ',
' ██████╔╝ ╚██████╔╝ ╚██████╔╝ ███████║ ██║ ',
' ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚═╝ ',
];
$gradient = $this->theme->gradient();
$this->newLine();
foreach ($lines as $index => $line) {
$this->output->writeln($this->ansi256Fg($gradient[$index], $line));
}
$this->newLine();
}
protected function displayTagline(string $featureName): void
{
$tagline = " ✦ Laravel Boost :: {$featureName} :: We Must Ship ✦ ";
$this->output->writeln(' '.$this->displayBadge($tagline));
}
protected function displayNote(string $projectName): void
{
note(" Let's give {$this->displayBadge($projectName)} a Boost");
}
protected function displayOutro(string $text, string $link = '', int $terminalWidth = 80): void
{
$visibleText = preg_replace('/\x1b\[[0-9;]*m|\x1b\]8;;[^\x07]*\x07|\x1b\]8;;\x1b\\\\/', '', $text.$link) ?? '';
$visualWidth = mb_strwidth($visibleText);
$paddingLength = (int) (floor(($terminalWidth - $visualWidth) / 2)) - 2;
$padding = str_repeat(' ', max(0, $paddingLength));
$this->output->writeln(
"\e[48;5;{$this->theme->primary()}m\033[2K{$padding}\e[30m\e[1m{$text}{$link}\e[0m"
);
$this->newLine();
}
protected function ansi256Fg(int $color, string $text): string
{
return "\e[38;5;{$color}m{$text}\e[0m";
}
protected function displayBadge(string $text): string
{
return "\e[48;5;{$this->theme->primary()}m\e[30m\e[1m{$text}\e[0m";
}
protected function hyperlink(string $label, string $url): string
{
return "\033]8;;{$url}\007{$label}\033]8;;\033\\";
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Concerns;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
trait MakesHttpRequests
{
public function client(): PendingRequest
{
$client = Http::withHeaders([
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0 Laravel Boost',
]);
// Disable SSL verification for local development URLs and testing
if (app()->environment(['local', 'testing']) || str_contains((string) config('boost.hosted.api_url', ''), '.test')) {
return $client->withoutVerifying();
}
return $client;
}
public function get(string $url): Response
{
return $this->client()->get($url);
}
/**
* @param array<string, mixed> $json
*/
public function json(string $url, array $json): Response
{
return $this->client()->asJson()->post($url, $json);
}
}

View File

@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Concerns;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
trait ReadsLogs
{
/**
* Regular expression fragments and default chunk-window sizes used when
* scanning log files. Declaring them once keeps every consumer in sync.
*/
protected function getTimestampRegex(): string
{
return '\\[\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\]';
}
protected function getEntrySplitRegex(): string
{
return '/(?='.$this->getTimestampRegex().')/';
}
protected function getErrorEntryRegex(): string
{
return '/^'.$this->getTimestampRegex().'.*\\.ERROR:/';
}
protected function getChunkSizeStart(): int
{
return 64 * 1024; // 64 kB
}
protected function getChunkSizeMax(): int
{
return 1024 * 1024; // 1 MB
}
/**
* Resolve the current log file path based on Laravel's logging configuration.
*/
protected function resolveLogFilePath(): string
{
$channel = Config::get('logging.default');
$channelConfig = Config::get("logging.channels.{$channel}");
$channelConfig = $this->resolveChannelWithPath($channelConfig);
$baseLogPath = Arr::get($channelConfig, 'path', storage_path('logs/laravel.log'));
if (Arr::get($channelConfig, 'driver') === 'daily') {
return $this->resolveDailyLogFilePath($baseLogPath);
}
return $baseLogPath;
}
/**
* @param array<string, mixed>|null $channelConfig
* @return array<string, mixed>|null
*/
protected function resolveChannelWithPath(?array $channelConfig, int $depth = 0): ?array
{
if ($channelConfig === null || $depth > 2) {
return $channelConfig;
}
if (isset($channelConfig['path'])) {
return $channelConfig;
}
if (($channelConfig['driver'] ?? null) !== 'stack') {
return $channelConfig;
}
$firstValidLoggerConfig = collect($channelConfig['channels'] ?? [])
->map(fn (string $name) => Config::get("logging.channels.{$name}"))
->filter(fn ($config): bool => is_array($config))
->map(fn (array $config) => $this->resolveChannelWithPath($config, $depth + 1))
->first(fn (?array $config): bool => isset($config['path']));
return $firstValidLoggerConfig ?? $channelConfig;
}
protected function resolveDailyLogFilePath(string $basePath): string
{
$pathInfo = pathinfo($basePath);
$directory = $pathInfo['dirname'];
$filename = $pathInfo['filename'];
$extension = isset($pathInfo['extension']) ? '.'.$pathInfo['extension'] : '';
$todayLogFile = $directory.DIRECTORY_SEPARATOR.$filename.'-'.date('Y-m-d').$extension;
if (file_exists($todayLogFile)) {
return $todayLogFile;
}
$pattern = $directory.DIRECTORY_SEPARATOR.$filename.'-*'.$extension;
$files = glob($pattern) ?: [];
$datePattern = '/^'.preg_quote($filename, '/').'-\d{4}-\d{2}-\d{2}'.preg_quote($extension, '/').'$/';
$latestFile = collect($files)
->filter(fn ($file): int|false => preg_match($datePattern, basename($file)))
->sortDesc()
->first();
return $latestFile ?? $todayLogFile;
}
/**
* Determine if the given line (or entry) is an ERROR log entry.
*/
protected function isErrorEntry(string $line): bool
{
if (str_starts_with(trim($line), '{')) {
return $this->isJsonErrorEntry($line);
}
return preg_match($this->getErrorEntryRegex(), $line) === 1;
}
/**
* Retrieve the last $count complete PSR-3 log entries from the log file using
* chunked reading instead of character-by-character reverse scanning.
*
* @return string[]
*/
protected function readLastLogEntries(string $logFile, int $count): array
{
$chunkSize = $this->getChunkSizeStart();
do {
$entries = $this->scanLogChunkForEntries($logFile, $chunkSize);
if (count($entries) >= $count || $chunkSize >= $this->getChunkSizeMax()) {
break;
}
$chunkSize *= 2;
} while (true);
return array_slice($entries, -$count);
}
/**
* Return the most recent ERROR log entry, or null if none exists within the
* inspected window.
*/
protected function readLastErrorEntry(string $logFile): ?string
{
$chunkSize = $this->getChunkSizeStart();
do {
$entries = $this->scanLogChunkForEntries($logFile, $chunkSize);
for ($i = count($entries) - 1; $i >= 0; $i--) {
if ($this->isErrorEntry($entries[$i])) {
return trim((string) $entries[$i]);
}
}
if ($chunkSize >= $this->getChunkSizeMax()) {
return null;
}
$chunkSize *= 2;
} while (true);
}
/**
* Determine if the log content uses JSON format (one JSON object per line).
*/
protected function isJsonLogFormat(string $content): bool
{
$firstLine = strtok($content, "\n");
if ($firstLine === false || trim($firstLine) === '') {
return false;
}
$trimmed = trim($firstLine);
if (! str_starts_with($trimmed, '{')) {
return false;
}
json_decode($trimmed);
return json_last_error() === JSON_ERROR_NONE;
}
/**
* Determine if the given entry is a JSON-formatted ERROR log entry.
*/
protected function isJsonErrorEntry(string $entry): bool
{
$decoded = json_decode(trim($entry), true);
if (! is_array($decoded)) {
return false;
}
$level = $decoded['level'] ?? $decoded['level_name'] ?? '';
return strtoupper((string) $level) === 'ERROR' || (int) ($decoded['level'] ?? 0) >= 400;
}
/**
* Scan the last $chunkSize bytes of the log file and return an array of
* complete log entries (oldest ➜ newest).
*
* @return string[]
*/
protected function scanLogChunkForEntries(string $logFile, int $chunkSize): array
{
$fileSize = filesize($logFile);
if ($fileSize === false) {
return [];
}
$handle = fopen($logFile, 'r');
if (! $handle) {
return [];
}
try {
$offset = max($fileSize - $chunkSize, 0);
fseek($handle, $offset);
// If we started mid-line, discard the partial line to align to the next newline.
if ($offset > 0) {
fgets($handle);
}
$content = stream_get_contents($handle);
if ($this->isJsonLogFormat($content)) {
return array_values(array_filter(
explode("\n", $content),
fn (string $line): bool => trim($line) !== '',
));
}
// Split by beginning-of-entry look-ahead (PSR-3 timestamp pattern).
$entries = preg_split($this->getEntrySplitRegex(), $content, -1, PREG_SPLIT_NO_EMPTY);
if (! $entries) {
return [];
}
return $entries; // already in chronological order relative to chunk
} finally {
fclose($handle);
}
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Concerns;
use Illuminate\Support\Facades\Blade;
use Laravel\Boost\Install\GuidelineAssist;
trait RendersBladeGuidelines
{
private array $storedSnippets = [];
protected function renderContent(string $content, string $path, array $data = []): string
{
$isBladeTemplate = str_ends_with($path, '.blade.php');
if (! $isBladeTemplate) {
return $content;
}
// Temporarily replace backticks, PHP opening tags, component tags, and Volt directives
// with placeholders before Blade processing. This prevents Blade from trying to execute
// PHP code examples, compile component references, and supports inline code.
$placeholders = [
'`' => '___SINGLE_BACKTICK___',
'<?php' => '___OPEN_PHP_TAG___',
'@volt' => '___VOLT_DIRECTIVE___',
'@endvolt' => '___ENDVOLT_DIRECTIVE___',
'</x-' => '___BLADE_COMPONENT_CLOSE___',
'<x-' => '___BLADE_COMPONENT_OPEN___',
];
$content = str_replace(array_keys($placeholders), array_values($placeholders), $content);
$rendered = Blade::render($content, [
'assist' => $this->getGuidelineAssist(),
...$data,
]);
$rendered = html_entity_decode($rendered, ENT_QUOTES | ENT_HTML5);
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 = trim($matches['content']);
$placeholder = '___BOOST_SNIPPET_'.count($this->storedSnippets).'___';
$this->storedSnippets[$placeholder] = '<!-- '.$name.' -->'."\n".'```'.$lang."\n".$snippetContent."\n".'```'."\n\n";
return $placeholder;
}, $content);
}
protected function renderBladeFile(string $bladePath, array $data = []): string
{
if (! file_exists($bladePath)) {
return '';
}
$content = file_get_contents($bladePath);
if ($content === false) {
return '';
}
$content = $this->processBoostSnippets($content);
$rendered = $this->renderContent($content, $bladePath, $data);
$rendered = str_replace(array_keys($this->storedSnippets), array_values($this->storedSnippets), $rendered);
$this->storedSnippets = [];
return $rendered;
}
protected function getGuidelineAssist(): GuidelineAssist
{
return app(GuidelineAssist::class);
}
}

View File

@@ -0,0 +1,409 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Console;
use const DIRECTORY_SEPARATOR;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use InvalidArgumentException;
use Laravel\Boost\Concerns\DisplayHelper;
use Laravel\Boost\Skills\Remote\AuditResult;
use Laravel\Boost\Skills\Remote\GitHubRepository;
use Laravel\Boost\Skills\Remote\GitHubSkillProvider;
use Laravel\Boost\Skills\Remote\RemoteSkill;
use Laravel\Boost\Skills\Remote\SkillAuditor;
use Laravel\Prompts\Terminal;
use RuntimeException;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\grid;
use function Laravel\Prompts\multiselect;
use function Laravel\Prompts\note;
use function Laravel\Prompts\spin;
use function Laravel\Prompts\table;
use function Laravel\Prompts\text;
class AddSkillCommand extends Command
{
use DisplayHelper;
/** @var string */
protected $signature = 'boost:add-skill
{repo? : GitHub repository (owner/repo or full URL)}
{--list : List available skills}
{--all : Install all skills}
{--skill=* : Specific skills to install}
{--force : Overwrite existing skills}
{--skip-audit : Skip security audit}';
/** @var string */
protected $description = 'Add skills from a remote GitHub repository';
protected GitHubRepository $repository;
protected GitHubSkillProvider $fetcher;
/** @var Collection<string, RemoteSkill> */
protected Collection $availableSkills;
protected string $defaultSkillsPath = '.ai/skills';
public function __construct(private readonly Terminal $terminal)
{
parent::__construct();
}
public function handle(): int
{
$this->displayHeader();
if (! $this->initializeRepository()) {
return self::FAILURE;
}
if (! $this->discoverAvailableSkills()) {
return self::FAILURE;
}
return $this->handleAction();
}
protected function initializeRepository(): bool
{
$repository = $this->parseRepository();
if (! $repository instanceof GitHubRepository) {
return false;
}
$this->repository = $repository;
$this->fetcher = new GitHubSkillProvider($this->repository);
return true;
}
protected function discoverAvailableSkills(): bool
{
try {
$this->availableSkills = spin(
callback: fn (): Collection => $this->fetcher->discoverSkills(),
message: "Fetching skills from {$this->repository->source()}..."
);
} catch (RuntimeException $runtimeException) {
$this->error($runtimeException->getMessage());
return false;
}
if ($this->availableSkills->isEmpty()) {
$this->error('No valid skills are found in the repository.');
return false;
}
return true;
}
protected function handleAction(): int
{
if ($this->option('list')) {
return $this->displaySkillsTable();
}
return $this->installSkills();
}
protected function parseRepository(): ?GitHubRepository
{
$input = $this->argument('repo') ??
text(
label: 'Which GitHub repository would you like to fetch skills from?',
placeholder: 'owner/repo or GitHub URL',
required: true,
validate: function (string $value): ?string {
try {
GitHubRepository::fromInput($value);
return null;
} catch (InvalidArgumentException $invalidArgumentException) {
return $invalidArgumentException->getMessage();
}
},
hint: 'e.g., vercel-labs/agent-skills or https://github.com/owner/repo'
);
return GitHubRepository::fromInput($input);
}
protected function displayHeader(): void
{
$this->terminal->initDimensions();
$this->displayBoostHeader('Skill', config('app.name'));
}
protected function displaySkillsTable(): int
{
note("Found {$this->availableSkills->count()} available skills");
grid($this->availableSkills->keys()->sort()->values()->toArray());
return self::SUCCESS;
}
protected function installSkills(): int
{
$selectedSkills = $this->selectSkills();
if ($selectedSkills->isEmpty()) {
$this->warn('No skills are selected.');
return self::SUCCESS;
}
$skillsToInstall = $this->skillsToInstall($selectedSkills);
if ($skillsToInstall->isEmpty()) {
return self::SUCCESS;
}
if (! $this->runAuditBeforeInstall($skillsToInstall)) {
return self::SUCCESS;
}
$results = $this->downloadSkills($skillsToInstall);
if ($results['installedNames'] !== []) {
$this->info('Skills installed:');
grid($results['installedNames']);
$this->runBoostUpdate();
$this->showOutro();
}
if ($results['failedDetails'] !== []) {
$this->error('Some skills failed to install:');
grid(array_keys($results['failedDetails']));
}
return self::SUCCESS;
}
/**
* @return Collection<string, RemoteSkill>
*/
protected function selectSkills(): Collection
{
if ($this->option('all')) {
return $this->availableSkills;
}
/** @var array<int, string> $skillOptions */
$skillOptions = $this->option('skill');
if ($skillOptions !== []) {
return $this->availableSkills->only($skillOptions);
}
/** @var array<int, string> $selected */
$selected = multiselect(
label: 'Which skills would you like to install?',
options: $this->availableSkills
->mapWithKeys(fn (RemoteSkill $skill): array => [$skill->name => $skill->name])
->toArray(),
scroll: 10,
required: true,
hint: 'Use --all to install all skills at once',
);
return $this->availableSkills->only($selected);
}
/**
* @param Collection<string, RemoteSkill> $skills
*/
protected function skillsToInstall(Collection $skills): Collection
{
[$existingSkills, $newSkills] = $skills->partition(
fn (RemoteSkill $skill): bool => $this->skillExists($skill)
);
if ($existingSkills->isEmpty() || $this->shouldUpdateExisting($existingSkills)) {
return $skills;
}
return $newSkills;
}
/**
* @param Collection<string, RemoteSkill> $existingSkills
*/
protected function shouldUpdateExisting(Collection $existingSkills): bool
{
if ($this->option('force')) {
return true;
}
if (! stream_isatty(STDIN)) {
return false;
}
return confirm(
label: "Update {$existingSkills->count()} existing skill(s)?",
);
}
protected function skillExists(RemoteSkill $skill): bool
{
return is_dir($this->skillTargetPath($skill));
}
protected function skillTargetPath(RemoteSkill $skill): string
{
return base_path($this->defaultSkillsPath.DIRECTORY_SEPARATOR.$skill->name);
}
/**
* @param Collection<string, RemoteSkill> $skills
* @return array{installedNames: array<int, string>, failedDetails: array<string, string>}
*/
protected function downloadSkills(Collection $skills): array
{
return spin(
callback: fn (): array => $this->addSkills($skills),
message: 'Downloading skills...'
);
}
/**
* @param Collection<string, RemoteSkill> $skills
* @return array{installedNames: array<int, string>, failedDetails: array<string, string>}
*/
protected function addSkills(Collection $skills): array
{
$results = ['installedNames' => [], 'failedDetails' => []];
foreach ($skills as $skill) {
$targetPath = $this->skillTargetPath($skill);
if ($this->skillExists($skill)) {
File::deleteDirectory($targetPath);
}
try {
if ($this->fetcher->downloadSkill($skill, $targetPath)) {
$results['installedNames'][] = $skill->name;
} else {
$results['failedDetails'][$skill->name] = 'Download failed';
}
} catch (RuntimeException $e) {
$results['failedDetails'][$skill->name] = $e->getMessage();
}
}
return $results;
}
/**
* @param Collection<string, RemoteSkill> $selectedSkills
*/
protected function runAuditBeforeInstall(Collection $selectedSkills): bool
{
if ($this->option('skip-audit')) {
return true;
}
$skillNames = $selectedSkills->keys()->values()->all();
/** @var array<string, array<int, AuditResult>> $auditResults */
$auditResults = spin(
callback: fn (): array => (new SkillAuditor)->audit(
$this->repository->source(),
$skillNames,
),
message: 'Running security audit...',
);
if (! $this->hasRiskySkills($auditResults)) {
return true;
}
$this->displayAuditResults($auditResults, $skillNames);
if (! stream_isatty(STDIN)) {
return true;
}
return confirm('Do you want to install these skills?');
}
/**
* @param array<string, array<int, AuditResult>> $auditResults
* @param array<int, string> $skillNames
*/
protected function displayAuditResults(array $auditResults, array $skillNames): void
{
$partnerKeys = collect($auditResults)
->flatMap(fn (array $results): array => array_map(fn (AuditResult $auditResult): string => $auditResult->partner, $results))
->unique()
->values()
->all();
$headers = array_merge(['Skill'], array_map(ucfirst(...), $partnerKeys));
$rows = [];
foreach ($skillNames as $skillName) {
$partnerResults = $auditResults[$skillName] ?? [];
$partnerMap = collect($partnerResults)->keyBy(fn (AuditResult $auditResult): string => $auditResult->partner);
$row = [$skillName];
foreach ($partnerKeys as $partnerKey) {
$row[] = $partnerMap->has($partnerKey)
? $this->colorizeRisk($partnerMap->get($partnerKey))
: '—';
}
$rows[] = $row;
}
note('Security Audit');
table($headers, $rows);
}
/**
* @param array<string, array<int, AuditResult>> $auditResults
*/
protected function hasRiskySkills(array $auditResults): bool
{
return collect($auditResults)
->flatten()
->contains(fn (AuditResult $result): bool => $result->risk->weight() >= 3);
}
protected function colorizeRisk(AuditResult $result): string
{
return match ($result->risk->color()) {
'red' => $this->red($result->risk->label()),
'yellow' => $this->yellow($result->risk->label()),
'green' => $this->green($result->risk->label()),
default => $this->dim($result->risk->label()),
};
}
protected function runBoostUpdate(): void
{
$this->callSilently(UpdateCommand::class);
}
protected function showOutro(): void
{
$this->displayOutro('Enjoy the boost 🚀', terminalWidth: $this->terminal->cols());
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Console\Enums;
enum Theme: string
{
case LaravelRed = 'laravel_red';
case Gray = 'gray';
case Ocean = 'ocean';
case Vaporwave = 'vaporwave';
case Sunset = 'sunset';
/**
* @return array<int, int>
*/
public function gradient(): array
{
return match ($this) {
self::LaravelRed => [196, 160, 124, 88, 52, 88],
self::Gray => [250, 248, 245, 243, 240, 238],
self::Ocean => [81, 75, 69, 63, 57, 21],
self::Vaporwave => [213, 177, 141, 105, 69, 39],
self::Sunset => [214, 208, 202, 196, 160, 124],
};
}
public function primary(): int
{
return $this->gradient()[0];
}
public function accent(): int
{
return $this->gradient()[2];
}
public static function random(): self
{
$cases = self::cases();
return $cases[array_rand($cases)];
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Console;
use Illuminate\Console\Command;
use Laravel\Boost\Mcp\ToolRegistry;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Throwable;
class ExecuteToolCommand extends Command
{
protected $signature = 'boost:execute-tool {tool} {arguments}';
protected $description = 'Execute a Boost MCP tool in isolation (internal command)';
protected $hidden = true;
public function handle(): int
{
$toolClass = $this->argument('tool');
$argumentsEncoded = $this->argument('arguments');
// Validate the tool is registered
if (! ToolRegistry::isToolAllowed($toolClass)) {
$this->error("Tool not registered or not allowed: {$toolClass}");
return 1;
}
// Decode arguments
$arguments = json_decode(base64_decode($argumentsEncoded, true), true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->error('Invalid arguments format: '.json_last_error_msg());
return 1;
}
/** @var Tool $tool */
$tool = app($toolClass);
$request = new Request($arguments ?? []);
ob_start();
try {
/** @var Response $response */
$response = $tool->handle($request); // @phpstan-ignore-line
} catch (Throwable $throwable) {
ob_end_clean();
$errorResult = Response::error("Tool execution failed (E_THROWABLE): {$throwable->getMessage()}");
$this->error(json_encode([
'isError' => true,
'content' => [
$errorResult->content()->toTool($tool),
],
]));
return static::FAILURE;
}
ob_end_clean();
echo json_encode([
'isError' => $response->isError(),
'content' => [
$response->content()->toTool($tool),
],
]);
return static::SUCCESS;
}
}

View File

@@ -0,0 +1,560 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Console;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Laravel\Boost\Concerns\DisplayHelper;
use Laravel\Boost\Contracts\SupportsGuidelines;
use Laravel\Boost\Contracts\SupportsMcp;
use Laravel\Boost\Contracts\SupportsSkills;
use Laravel\Boost\Install\Agents\Agent;
use Laravel\Boost\Install\AgentsDetector;
use Laravel\Boost\Install\Cloud;
use Laravel\Boost\Install\GuidelineComposer;
use Laravel\Boost\Install\GuidelineConfig;
use Laravel\Boost\Install\GuidelineWriter;
use Laravel\Boost\Install\McpWriter;
use Laravel\Boost\Install\Nightwatch;
use Laravel\Boost\Install\Sail;
use Laravel\Boost\Install\Skill;
use Laravel\Boost\Install\SkillComposer;
use Laravel\Boost\Install\SkillWriter;
use Laravel\Boost\Install\ThirdPartyPackage;
use Laravel\Boost\Skills\Remote\GitHubRepository;
use Laravel\Boost\Skills\Remote\GitHubSkillProvider;
use Laravel\Boost\Skills\Remote\RemoteSkill;
use Laravel\Boost\Support\Config;
use Laravel\Prompts\Terminal;
use Symfony\Component\Process\Process;
use function Laravel\Prompts\grid;
use function Laravel\Prompts\multiselect;
class InstallCommand extends Command
{
use DisplayHelper;
protected $signature = 'boost:install
{--guidelines : Install AI guidelines}
{--skills : Install agent skills}
{--mcp : Install MCP server configuration}';
/** @var Collection<int, Agent> */
private Collection $selectedAgents;
/** @var Collection<int, string> */
private Collection $selectedBoostFeatures;
/** @var Collection<int, string> */
private Collection $selectedThirdPartyPackages;
private string $projectName;
/** @var array<non-empty-string> */
private array $systemInstalledAgents = [];
/** @var array<non-empty-string> */
private array $projectInstalledAgents = [];
private bool $enforceTests = true;
/** @var array<int, string> */
private array $installedSkillNames = [];
const MIN_TEST_COUNT = 6;
public function __construct(
private readonly AgentsDetector $agentsDetector,
private readonly Cloud $cloud,
private readonly Config $config,
private readonly Nightwatch $nightwatch,
private readonly Sail $sail,
private readonly Terminal $terminal
) {
parent::__construct();
}
public function handle(): int
{
$this->terminal->initDimensions();
$this->projectName = config('app.name');
$this->displayBoostHeader('Install', $this->projectName);
$this->discoverEnvironment();
$this->collectInstallationPreferences();
$this->performInstallation();
$this->outro();
return self::SUCCESS;
}
protected function discoverEnvironment(): void
{
if ($this->config->getAgents() !== []) {
return;
}
$this->systemInstalledAgents = $this->agentsDetector->discoverSystemInstalledAgents();
$this->projectInstalledAgents = $this->agentsDetector->discoverProjectInstalledAgents(base_path());
}
protected function collectInstallationPreferences(): void
{
$this->selectedBoostFeatures = $this->selectBoostFeatures();
$this->selectedThirdPartyPackages = $this->selectedBoostFeatures->contains('guidelines') || $this->selectedBoostFeatures->contains('skills')
? $this->selectThirdPartyPackages()
: collect();
$this->selectIntegrations();
$this->selectedAgents = $this->selectAgents();
$this->enforceTests = $this->selectedBoostFeatures->contains('guidelines') && $this->determineTestEnforcement();
}
protected function performInstallation(): void
{
app()->instance(GuidelineConfig::class, $this->buildGuidelineConfig());
if ($this->selectedBoostFeatures->contains('guidelines')) {
$this->installGuidelines();
}
if ($this->shouldInstallCloudSkill()) {
$this->downloadCloudSkill();
}
if ($this->selectedBoostFeatures->contains('skills')) {
$this->installSkills();
}
if ($this->selectedBoostFeatures->contains('mcp')) {
$this->installMcpServerConfig();
}
$this->storeConfig();
}
protected function outro(): void
{
$url = 'https://laravel.com/docs/boost';
$link = $this->hyperlink($url, $url);
$text = 'Enjoy the boost 🚀 Next steps: ';
$this->displayOutro($text, $link, $this->terminal->cols());
}
/**
* We shouldn't add an AI guideline enforcing test if they don't have a basic test setup.
* This would likely just create headaches for them or be a waste of time as they
* won't have the CI setup to make use of them anyway, so we're just wasting their
* tokens/money by enforcing them.
*/
protected function determineTestEnforcement(): bool
{
if (config('boost.enforce_tests') !== null) {
return (bool) config('boost.enforce_tests');
}
if (! file_exists(base_path('vendor/bin/phpunit'))) {
return false;
}
$process = new Process([PHP_BINARY, 'artisan', 'test', '--list-tests'], base_path());
$process->run();
return Str::of($process->getOutput())
->trim()
->explode("\n")
->filter(fn ($line): bool => str_contains($line, '::'))
->count() >= self::MIN_TEST_COUNT;
}
/**
* @return Collection<int, string>
*/
protected function selectBoostFeatures(): Collection
{
$featureLabels = collect([
'guidelines' => 'AI Guidelines',
'skills' => 'Agent Skills',
'mcp' => 'Boost MCP Server Configuration',
]);
$explicit = $featureLabels->keys()->filter(fn ($feature) => $this->option($feature));
if ($explicit->isNotEmpty()) {
return $explicit->values();
}
$configValues = collect([
'guidelines' => $this->config->getGuidelines(),
'skills' => $this->config->hasSkills(),
'mcp' => $this->config->getMcp(),
]);
$defaults = $configValues->filter()->keys()->whenEmpty(fn () => $featureLabels->keys());
return collect(multiselect(
label: 'Which Boost features would you like to configure?',
options: $featureLabels->all(),
default: $defaults->all(),
required: true,
hint: 'This will override the current guidelines, skills, and MCP configuration',
));
}
/**
* @return Collection<int, string>
*/
protected function selectThirdPartyPackages(): Collection
{
$packages = ThirdPartyPackage::discover();
if ($packages->isEmpty()) {
return collect();
}
return collect(multiselect(
label: 'Which third-party AI guidelines/skills would you like to install?',
options: $packages->mapWithKeys(fn (ThirdPartyPackage $pkg, string $name): array => [
$name => $pkg->displayLabel(),
])->toArray(),
default: collect($this->config->getPackages())
->filter(fn (string $name) => $packages->has($name))
->values(),
scroll: 10,
hint: 'You can add or remove them later by running this command again',
));
}
protected function selectIntegrations(): void
{
$integrations = collect([
'cloud' => [
'label' => 'Laravel Cloud',
'available' => true,
'default' => $this->config->getCloud(),
],
'nightwatch' => [
'label' => 'Laravel Nightwatch',
'available' => $this->nightwatch->isInstalled(),
'default' => $this->config->getNightwatch(),
],
'sail' => [
'label' => 'Laravel Sail',
'available' => $this->sail->isInstalled(),
'default' => $this->sail->isActive() || $this->config->getSail(),
],
])->filter(fn (array $integration): bool => $integration['available']);
$selected = multiselect(
label: 'Which integrations would you like to configure for Boost?',
options: $integrations->map(fn (array $integration): string => $integration['label'])->all(),
default: $integrations->filter(fn (array $integration): bool => $integration['default'])->keys()->all(),
hint: 'Selected integrations will have their MCP servers or skills automatically configured',
);
$this->selectedBoostFeatures->push(...$selected);
}
/**
* @return Collection<int, Agent>
*/
protected function selectAgents(): Collection
{
$allAgents = $this->agentsDetector->getAgents();
if ($allAgents->isEmpty()) {
return collect();
}
$featureInterfaces = [
'guidelines' => SupportsGuidelines::class,
'skills' => SupportsSkills::class,
'mcp' => SupportsMcp::class,
];
$filteredAgents = $allAgents->filter(
fn (Agent $agent): bool => $this->selectedBoostFeatures->contains(
fn ($feature): bool => isset($featureInterfaces[$feature]) && $agent instanceof $featureInterfaces[$feature])
)->keyBy(fn (Agent $agent): string => $agent->name());
if ($filteredAgents->isEmpty()) {
return collect();
}
$options = $filteredAgents
->mapWithKeys(fn (Agent $agent): array => [$agent->name() => $agent->displayName()])
->sort();
$defaults = collect($this->config->getAgents())
->filter(fn (string $name) => $filteredAgents->has($name))
->whenEmpty(fn () => collect([...$this->projectInstalledAgents, ...$this->systemInstalledAgents])
->unique()
->filter(fn (string $name) => $filteredAgents->has($name))
)
->values();
$selected = multiselect(
label: 'Which AI agents would you like to configure?',
options: $options->all(),
default: $defaults->all(),
scroll: $options->count(),
required: true,
);
return collect($selected)
->map(fn (string $name) => $filteredAgents->get($name))
->filter()
->values();
}
/**
* @return Collection<int, Agent&SupportsMcp>
*/
protected function agentsWithMcp(): Collection
{
return $this->selectedAgents->filter(fn (Agent $a): bool => $a instanceof SupportsMcp);
}
/**
* @return Collection<int, Agent&SupportsGuidelines>
*/
protected function agentsWithGuidelines(): Collection
{
return $this->selectedAgents->filter(fn (Agent $a): bool => $a instanceof SupportsGuidelines);
}
/**
* @return Collection<int, Agent&SupportsSkills>
*/
protected function agentsWithSkills(): Collection
{
return $this->selectedAgents->filter(fn (Agent $a): bool => $a instanceof SupportsSkills);
}
protected function installGuidelines(): void
{
$guidelinesAgents = $this->agentsWithGuidelines();
$composer = app(GuidelineComposer::class)->config($this->buildGuidelineConfig());
$guidelines = $composer->guidelines();
$composedAiGuidelines = $composer->compose();
$this->installFeature(
agents: $guidelinesAgents,
emptyMessage: 'No agents are selected for guideline installation.',
headerMessage: sprintf('Adding %d guidelines to your selected agents', $guidelines->count()),
nameResolver: fn (Agent $agent): string => $agent->displayName(),
processor: fn (Agent&SupportsGuidelines $agent): int => (new GuidelineWriter($agent))->write($composedAiGuidelines),
featureName: 'guidelines',
beforeProcess: fn () => grid($guidelines->map(fn ($guideline, string $key): string => $key.($guideline['custom'] ? '*' : ''))->sort()->values()->toArray()),
withDelay: true,
);
}
protected function installSkills(): void
{
$skillsAgents = $this->agentsWithSkills();
$skillsComposer = app(SkillComposer::class)->config($this->buildGuidelineConfig());
$skills = $skillsComposer->skills();
$this->installedSkillNames = $skills->keys()->toArray();
/** @var Collection<int, SupportsSkills&Agent> $skillsAgents */
$this->installFeature(
agents: $skillsAgents,
emptyMessage: 'No agents are selected for skill installation.',
headerMessage: sprintf('Syncing %d skills for skills-capable agents', $skills->count()),
nameResolver: fn (SupportsSkills&Agent $agent): string => $agent->displayName(),
processor: fn (SupportsSkills&Agent $agent): array => (new SkillWriter($agent))->sync($skills, $this->config->getSkills()),
featureName: 'skills',
beforeProcess: $skills->isNotEmpty()
? fn () => grid($skills->map(fn (Skill $skill): string => $skill->displayName())->sort()->values()->toArray())
: null,
);
}
protected function buildGuidelineConfig(): GuidelineConfig
{
$guidelineConfig = new GuidelineConfig;
$guidelineConfig->enforceTests = $this->enforceTests;
$guidelineConfig->hasAnApi = false;
$guidelineConfig->aiGuidelines = $this->selectedThirdPartyPackages->values()->toArray();
$guidelineConfig->usesSail = $this->shouldUseSail();
$guidelineConfig->hasSkills = $this->selectedBoostFeatures->contains('skills');
$guidelineConfig->hasMcp = $this->selectedBoostFeatures->contains('mcp') || ($this->isExplicitFlagMode() && $this->config->getMcp());
return $guidelineConfig;
}
protected function shouldInstallCloudSkill(): bool
{
return $this->selectedBoostFeatures->contains('cloud');
}
protected function downloadCloudSkill(): void
{
try {
$repository = GitHubRepository::fromInput($this->cloud->skillRepo().'/'.$this->cloud->skillPath());
$provider = new GitHubSkillProvider($repository);
$skill = $provider->discoverSkills()->get($this->cloud->skillName());
if (! $skill instanceof RemoteSkill) {
return;
}
$provider->downloadSkill($skill, base_path('.ai/skills/'.$this->cloud->skillName()));
} catch (Exception $exception) {
$this->warn('Failed to download Cloud skill: '.$exception->getMessage());
$this->line('You can install it later with: php artisan boost:add-skill '.$this->cloud->skillRepo());
}
}
protected function storeConfig(): void
{
$explicitMode = $this->isExplicitFlagMode();
if (! $explicitMode) {
$this->config->flush();
$this->config->setAgents($this->selectedAgents->map(fn (Agent $agent): string => $agent->name())->values()->toArray());
$this->config->setPackages($this->selectedThirdPartyPackages->values()->toArray());
} elseif ($this->selectedBoostFeatures->contains('guidelines') || $this->selectedBoostFeatures->contains('skills')) {
$this->config->setPackages($this->selectedThirdPartyPackages->values()->toArray());
}
if ($this->selectedBoostFeatures->contains('guidelines')) {
$this->config->setGuidelines(true);
}
if ($this->selectedBoostFeatures->contains('skills')) {
$this->config->setSkills($this->installedSkillNames);
}
$this->config->setCloud($this->selectedBoostFeatures->contains('cloud'));
if ($this->selectedBoostFeatures->contains('mcp')) {
$this->config->setMcp(true);
$this->config->setSail($this->shouldUseSail());
$this->config->setNightwatch($this->shouldInstallNightwatchMcp());
}
}
protected function shouldInstallNightwatchMcp(): bool
{
return $this->selectedBoostFeatures->contains('nightwatch');
}
protected function shouldUseSail(): bool
{
if ($this->selectedBoostFeatures->contains('mcp')) {
return $this->selectedBoostFeatures->contains('sail');
}
return $this->config->getSail();
}
protected function isExplicitFlagMode(): bool
{
if ($this->option('guidelines')) {
return true;
}
if ($this->option('skills')) {
return true;
}
return (bool) $this->option('mcp');
}
protected function installMcpServerConfig(): void
{
$this->installFeature(
agents: $this->agentsWithMcp(),
emptyMessage: 'No agents are selected for MCP installation.',
headerMessage: 'Installing MCP servers to your selected Agents',
nameResolver: fn (Agent $agent): string => $agent->displayName(),
processor: fn (Agent&SupportsMcp $agent): int => (new McpWriter($agent))->write(
$this->shouldUseSail() ? $this->sail : null,
$this->shouldInstallNightwatchMcp() ? $this->nightwatch : null
),
featureName: 'MCP servers',
withDelay: true,
);
}
/**
* @template T
*
* @param Collection<int, T> $agents
* @param callable(T): string $nameResolver
* @param callable(T): mixed $processor
* @param ?callable(): void $beforeProcess
*/
protected function installFeature(
Collection $agents,
string $emptyMessage,
string $headerMessage,
callable $nameResolver,
callable $processor,
string $featureName,
?callable $beforeProcess = null,
bool $withDelay = false,
): void {
if ($agents->isEmpty()) {
$this->info($emptyMessage);
return;
}
$this->newLine();
$this->info($headerMessage);
if ($beforeProcess !== null) {
$beforeProcess();
}
$this->newLine();
if ($withDelay) {
usleep(750000);
}
$failed = [];
$nameMap = $agents->map(fn ($agent): string => $nameResolver($agent));
$longestName = $nameMap->map(fn (string $name) => Str::length($name))->max() ?? 0;
foreach ($agents as $index => $agent) {
$name = $nameMap[$index];
$this->output->write(' '.str_pad($name, $longestName).'... ');
try {
$processor($agent);
$this->line($this->green('✓'));
} catch (Exception $e) {
$failed[$name] = $e->getMessage();
$this->line($this->red('✗'));
}
}
if ($failed !== []) {
$this->newLine();
$this->error(sprintf('✗ Failed to install %s to %d agent%s:',
$featureName,
count($failed),
count($failed) === 1 ? '' : 's'
));
foreach ($failed as $agentName => $error) {
$this->line(" - {$agentName}: {$error}");
}
}
$this->newLine();
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Laravel\Boost\Concerns\DisplayHelper;
use Laravel\Boost\Install\SkillComposer;
use function Laravel\Prompts\note;
use function Laravel\Prompts\table;
class ListSkillCommand extends Command
{
use DisplayHelper;
protected $signature = 'boost:list-skills';
protected $description = 'List all available skills in the current project';
public function handle(SkillComposer $skillComposer): int
{
$skills = $skillComposer->skills();
if ($skills->isEmpty()) {
$this->info('No skills available in this project.');
return self::SUCCESS;
}
$this->displayBoostHeader('Skills', config('app.name'));
$count = $skills->count();
note("Found {$count} skill".($count === 1 ? '' : 's'));
$this->displaySkillsTable($skills);
return self::SUCCESS;
}
protected function displaySkillsTable(Collection $skills): void
{
$rows = $skills
->sortBy(fn ($skill) => $skill->name)
->map(fn ($skill): array => $skill->custom
? [$this->dim($skill->name.'*'), $this->yellow('local')]
: [$skill->name, $this->dim($skill->package)]
)
->values()
->toArray();
table(
headers: ['Skill', 'Source'],
rows: $rows
);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand('boost:mcp', 'Starts Laravel Boost (usually from mcp.json)')]
class StartCommand extends Command
{
public function handle(): int
{
return Artisan::call('mcp:start laravel-boost');
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Laravel\Boost\Install\ThirdPartyPackage;
use Laravel\Boost\Support\Config;
use Symfony\Component\Console\Attribute\AsCommand;
use function Laravel\Prompts\multiselect;
#[AsCommand('boost:update', 'Update the Laravel Boost guidelines & skills to the latest guidance')]
class UpdateCommand extends Command
{
/** @var string */
protected $signature = 'boost:update
{--discover : Discover and prompt for newly available guidelines and skills}
{--ignore-skills : Skip updating the skills directory}';
public function handle(Config $config): int
{
if (! $config->isValid() || empty($config->getAgents())) {
$this->error('Please set up Boost with [php artisan boost:install] first.');
return self::FAILURE;
}
if ($this->option('discover')) {
$this->discoverNewContent($config);
}
$guidelines = $config->getGuidelines();
$hasSkills = ! $this->option('ignore-skills') && ($config->hasSkills() || is_dir(base_path('.ai/skills')));
if (! $guidelines && ! $hasSkills) {
return self::SUCCESS;
}
$this->callSilently(InstallCommand::class, [
'--no-interaction' => true,
'--guidelines' => $guidelines,
'--skills' => $hasSkills,
]);
$this->info('Boost guidelines and skills updated successfully.');
return self::SUCCESS;
}
protected function discoverNewContent(Config $config): void
{
$newPackages = $this->resolveNewPackages($config);
if ($newPackages->isNotEmpty()) {
/** @var array<int, string> $selectedPackages */
$selectedPackages = multiselect(
label: 'New packages with guidelines/skills discovered! Which would you like to add?',
options: $newPackages
->mapWithKeys(fn (ThirdPartyPackage $pkg, string $name): array => [$name => $pkg->displayLabel()])
->toArray(),
scroll: 10,
required: false,
hint: 'Select packages to include their guidelines and skills',
);
if ($selectedPackages !== []) {
$config->setPackages(array_merge($config->getPackages(), $selectedPackages));
}
}
}
/**
* @return Collection<string, ThirdPartyPackage>
*/
protected function resolveNewPackages(Config $config): Collection
{
$configuredPackages = $config->getPackages();
return ThirdPartyPackage::discover()
->filter(fn (ThirdPartyPackage $pkg, string $name): bool => ! in_array($name, $configuredPackages, true));
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Contracts;
/**
* Contract for AI coding assistants that receive guidelines.
*/
interface SupportsGuidelines
{
/**
* Get the file path where AI guidelines should be written.
*/
public function guidelinesPath(): string;
/**
* Determine if the guideline file requires frontmatter.
*/
public function frontmatter(): bool;
/**
* Transform the generated guidelines' Markdown.
*/
public function transformGuidelines(string $markdown): string;
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Contracts;
/**
* Contract for agents that support MCP (Model Context Protocol).
*/
interface SupportsMcp
{
/**
* Whether to use absolute paths for MCP commands.
*/
public function useAbsolutePathForMcp(): bool;
/**
* Get the PHP executable path for this MCP client.
*/
public function getPhpPath(bool $forceAbsolutePath = false): string;
/**
* Get the artisan path for this MCP client.
*/
public function getArtisanPath(bool $forceAbsolutePath = false): string;
/**
* Install an MCP server configuration in this IDE.
*
* @param array<int, string> $args
* @param array<string, string> $env
*/
public function installMcp(string $key, string $command, array $args = [], array $env = []): bool;
/**
* Install an HTTP MCP server configuration in this IDE.
*/
public function installHttpMcp(string $key, string $url): bool;
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Contracts;
/**
* Contract for agents that support Agent Skills
*/
interface SupportsSkills
{
/**
* Get the file path where agent skills should be written.
*/
public function skillsPath(): string;
}

View File

@@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Agents;
use Illuminate\Support\Facades\Process;
use Laravel\Boost\BoostManager;
use Laravel\Boost\Install\Detection\DetectionStrategyFactory;
use Laravel\Boost\Install\Enums\McpInstallationStrategy;
use Laravel\Boost\Install\Enums\Platform;
use Laravel\Boost\Install\Mcp\FileWriter;
use Laravel\Boost\Install\Mcp\TomlFileWriter;
use Laravel\Boost\Support\CommandNormalizer;
abstract class Agent
{
public function __construct(protected readonly DetectionStrategyFactory $strategyFactory)
{
//
}
abstract public function name(): string;
abstract public function displayName(): string;
public function useAbsolutePathForMcp(): bool
{
return false;
}
public function getPhpPath(bool $forceAbsolutePath = false): string
{
$phpBinaryPath = config('boost.executable_paths.php') ?? 'php';
if ($phpBinaryPath === 'php' && ($this->useAbsolutePathForMcp() || $forceAbsolutePath)) {
return PHP_BINARY;
}
return $phpBinaryPath;
}
public function getArtisanPath(bool $forceAbsolutePath = false): string
{
return ($this->useAbsolutePathForMcp() || $forceAbsolutePath) ? base_path('artisan') : 'artisan';
}
/**
* Get the detection configuration for system-wide installation detection.
*
* @return array{paths?: string[], command?: string, files?: string[]}
*/
abstract public function systemDetectionConfig(Platform $platform): array;
/**
* Get the detection configuration for project-specific detection.
*
* @return array{paths?: string[], files?: string[]}
*/
abstract public function projectDetectionConfig(): array;
public function detectOnSystem(Platform $platform): bool
{
$config = $this->systemDetectionConfig($platform);
$strategy = $this->strategyFactory->makeFromConfig($config);
return $strategy->detect($config, $platform);
}
public function detectInProject(string $basePath): bool
{
$config = array_merge($this->projectDetectionConfig(), ['basePath' => $basePath]);
$strategy = $this->strategyFactory->makeFromConfig($config);
return $strategy->detect($config);
}
public function mcpInstallationStrategy(): McpInstallationStrategy
{
return McpInstallationStrategy::FILE;
}
public static function fromName(string $name): ?Agent
{
$detectionFactory = app(DetectionStrategyFactory::class);
$boostManager = app(BoostManager::class);
foreach ($boostManager->getAgents() as $class) {
/** @var class-string<Agent> $class */
$instance = new $class($detectionFactory);
if ($instance->name() === $name) {
return $instance;
}
}
return null;
}
public function shellMcpCommand(): ?string
{
return null;
}
public function mcpConfigPath(): ?string
{
return null;
}
public function frontmatter(): bool
{
return false;
}
public function mcpConfigKey(): string
{
return 'mcpServers';
}
/** @return array<string, mixed> */
public function defaultMcpConfig(): array
{
return [];
}
/**
* Install MCP server using the appropriate strategy.
*
* @param array<int, string> $args
* @param array<string, string> $env
*/
public function installMcp(string $key, string $command, array $args = [], array $env = []): bool
{
return match ($this->mcpInstallationStrategy()) {
McpInstallationStrategy::SHELL => $this->installShellMcp($key, $command, $args, $env),
McpInstallationStrategy::FILE => $this->installFileMcp($key, $command, $args, $env),
McpInstallationStrategy::NONE => false
};
}
/**
* Build the HTTP MCP server configuration payload for file-based installation.
*
* @return array<string, mixed>
*/
public function httpMcpServerConfig(string $url): array
{
return [
'type' => 'http',
'url' => $url,
];
}
/**
* Install an HTTP MCP server using the file-based strategy.
*/
public function installHttpMcp(string $key, string $url): bool
{
$path = $this->mcpConfigPath();
if (! $path) {
return false;
}
$writer = str_ends_with($path, '.toml')
? new TomlFileWriter($path, $this->defaultMcpConfig())
: new FileWriter($path, $this->defaultMcpConfig());
return $writer
->configKey($this->mcpConfigKey())
->addServerConfig($key, $this->httpMcpServerConfig($url))
->save();
}
/**
* Build the MCP server configuration payload for file-based installation.
*
* @param array<int, string> $args
* @param array<string, string> $env
* @return array<string, mixed>
*/
public function mcpServerConfig(string $command, array $args = [], array $env = []): array
{
return [
'command' => $command,
'args' => $args,
'env' => $env,
];
}
/**
* Install MCP server using a shell command strategy.
*
* @param array<int, string> $args
* @param array<string, string> $env
*/
protected function installShellMcp(string $key, string $command, array $args = [], array $env = []): bool
{
$shellCommand = $this->shellMcpCommand();
if ($shellCommand === null) {
return false;
}
$normalized = $this->normalizeCommand($command, $args);
// Build environment string
$envString = '';
foreach ($env as $envKey => $value) {
$envKey = strtoupper($envKey);
$envString .= "-e {$envKey}=\"{$value}\" ";
}
// Replace placeholders in shell command
$command = str_replace([
'{key}',
'{command}',
'{args}',
'{env}',
], [
$key,
$normalized['command'],
implode(' ', array_map(fn (string $arg): string => '"'.$arg.'"', $normalized['args'])),
trim($envString),
], $shellCommand);
$result = Process::run($command);
if ($result->successful()) {
return true;
}
return str_contains($result->errorOutput(), 'already exists');
}
/**
* Install MCP server using a file-based configuration strategy.
*
* @param array<int, string> $args
* @param array<string, string> $env
*/
protected function installFileMcp(string $key, string $command, array $args = [], array $env = []): bool
{
$path = $this->mcpConfigPath();
if (! $path) {
return false;
}
$normalized = $this->normalizeCommand($command, $args);
$writer = str_ends_with($path, '.toml')
? new TomlFileWriter($path, $this->defaultMcpConfig())
: new FileWriter($path, $this->defaultMcpConfig());
return $writer
->configKey($this->mcpConfigKey())
->addServerConfig($key, $this->mcpServerConfig($normalized['command'], $normalized['args'], $env))
->save();
}
/**
* Normalize command by splitting space-separated commands into command + args.
*
* Absolute paths (starting with / on Unix or a drive letter on Windows)
* are never split, as they may contain spaces (e.g. macOS "Application Support").
*
* @param array<int, string> $args
* @return array{command: string, args: array<int, string>}
*/
protected function normalizeCommand(string $command, array $args = []): array
{
return CommandNormalizer::normalize($command, $args);
}
/**
* Post-process the generated guidelines' Markdown.
*/
public function transformGuidelines(string $markdown): string
{
return $markdown;
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Agents;
use Laravel\Boost\Contracts\SupportsGuidelines;
use Laravel\Boost\Contracts\SupportsMcp;
use Laravel\Boost\Contracts\SupportsSkills;
use Laravel\Boost\Install\Enums\Platform;
class Amp extends Agent implements SupportsGuidelines, SupportsMcp, SupportsSkills
{
public function name(): string
{
return 'amp';
}
public function displayName(): string
{
return 'Amp';
}
public function systemDetectionConfig(Platform $platform): array
{
return match ($platform) {
Platform::Darwin, Platform::Linux => [
'command' => 'command -v amp',
'paths' => ['~/.amp', '~/.config/amp'],
],
Platform::Windows => [
'command' => 'cmd /c where amp 2>nul',
'paths' => ['%USERPROFILE%\\.amp', '%USERPROFILE%\\.config\\amp'],
],
};
}
public function projectDetectionConfig(): array
{
return [
'paths' => ['.amp'],
];
}
public function mcpConfigPath(): string
{
return config('boost.agents.amp.mcp_config_path', base_path('.amp/settings.json'));
}
public function mcpConfigKey(): string
{
return 'amp.mcpServers';
}
/** {@inheritDoc} */
public function httpMcpServerConfig(string $url): array
{
return ['url' => $url];
}
public function guidelinesPath(): string
{
return config('boost.agents.amp.guidelines_path', 'AGENTS.md');
}
public function skillsPath(): string
{
return config('boost.agents.amp.skills_path', '.agents/skills');
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Agents;
use Laravel\Boost\Contracts\SupportsGuidelines;
use Laravel\Boost\Contracts\SupportsMcp;
use Laravel\Boost\Contracts\SupportsSkills;
use Laravel\Boost\Install\Enums\McpInstallationStrategy;
use Laravel\Boost\Install\Enums\Platform;
class ClaudeCode extends Agent implements SupportsGuidelines, SupportsMcp, SupportsSkills
{
public function name(): string
{
return 'claude_code';
}
public function displayName(): string
{
return 'Claude Code';
}
public function systemDetectionConfig(Platform $platform): array
{
return match ($platform) {
Platform::Darwin, Platform::Linux => [
'command' => 'command -v claude',
],
Platform::Windows => [
'command' => 'cmd /c where claude 2>nul',
],
};
}
public function projectDetectionConfig(): array
{
return [
'paths' => ['.claude'],
'files' => ['CLAUDE.md'],
];
}
public function mcpInstallationStrategy(): McpInstallationStrategy
{
return McpInstallationStrategy::FILE;
}
public function mcpConfigPath(): string
{
return config('boost.agents.claude_code.mcp_config_path', '.mcp.json');
}
public function guidelinesPath(): string
{
return config('boost.agents.claude_code.guidelines_path', 'CLAUDE.md');
}
public function skillsPath(): string
{
return config('boost.agents.claude_code.skills_path', '.claude/skills');
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Agents;
use Laravel\Boost\Contracts\SupportsGuidelines;
use Laravel\Boost\Contracts\SupportsMcp;
use Laravel\Boost\Contracts\SupportsSkills;
use Laravel\Boost\Install\Enums\McpInstallationStrategy;
use Laravel\Boost\Install\Enums\Platform;
class Codex extends Agent implements SupportsGuidelines, SupportsMcp, SupportsSkills
{
public function name(): string
{
return 'codex';
}
public function displayName(): string
{
return 'Codex';
}
public function systemDetectionConfig(Platform $platform): array
{
return match ($platform) {
Platform::Darwin, Platform::Linux => [
'command' => 'which codex',
],
Platform::Windows => [
'command' => 'cmd /c where codex 2>nul',
],
};
}
public function projectDetectionConfig(): array
{
return [
'paths' => ['.codex'],
'files' => ['AGENTS.md', '.codex/config.toml'],
];
}
public function guidelinesPath(): string
{
return config('boost.agents.codex.guidelines_path', 'AGENTS.md');
}
public function mcpInstallationStrategy(): McpInstallationStrategy
{
return McpInstallationStrategy::FILE;
}
public function mcpConfigPath(): string
{
return config('boost.agents.codex.mcp_config_path', '.codex/config.toml');
}
public function mcpConfigKey(): string
{
return 'mcp_servers';
}
/** {@inheritDoc} */
public function httpMcpServerConfig(string $url): array
{
return [
'command' => 'npx',
'args' => ['-y', 'mcp-remote', $url],
];
}
/** {@inheritDoc} */
public function mcpServerConfig(string $command, array $args = [], array $env = []): array
{
return collect([
'command' => $command,
'args' => $args,
'cwd' => config('boost.executable_paths.current_directory', base_path()),
'env' => $env,
])->filter(fn ($value): bool => ! in_array($value, [[], null, ''], true))
->toArray();
}
public function skillsPath(): string
{
return config('boost.agents.codex.skills_path', '.agents/skills');
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Agents;
use Laravel\Boost\Contracts\SupportsGuidelines;
use Laravel\Boost\Contracts\SupportsMcp;
use Laravel\Boost\Contracts\SupportsSkills;
use Laravel\Boost\Install\Enums\Platform;
class Copilot extends Agent implements SupportsGuidelines, SupportsMcp, SupportsSkills
{
public function name(): string
{
return 'copilot';
}
public function displayName(): string
{
return 'GitHub Copilot';
}
public function detectOnSystem(Platform $platform): bool
{
return false;
}
public function systemDetectionConfig(Platform $platform): array
{
return match ($platform) {
Platform::Darwin => [
'paths' => ['/Applications/Visual Studio Code.app'],
],
Platform::Linux => [
'command' => 'command -v code',
],
Platform::Windows => [
'paths' => [
'%ProgramFiles%\\Microsoft VS Code',
'%LOCALAPPDATA%\\Programs\\Microsoft VS Code',
],
],
};
}
public function projectDetectionConfig(): array
{
return [
'paths' => ['.vscode'],
'files' => ['.github/copilot-instructions.md'],
];
}
public function mcpConfigPath(): string
{
return config('boost.agents.copilot.mcp_config_path', '.vscode/mcp.json');
}
public function mcpConfigKey(): string
{
return 'servers';
}
public function guidelinesPath(): string
{
return config('boost.agents.copilot.guidelines_path', 'AGENTS.md');
}
public function skillsPath(): string
{
return config('boost.agents.copilot.skills_path', '.github/skills');
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Agents;
use Laravel\Boost\Contracts\SupportsGuidelines;
use Laravel\Boost\Contracts\SupportsMcp;
use Laravel\Boost\Contracts\SupportsSkills;
use Laravel\Boost\Install\Enums\Platform;
class Cursor extends Agent implements SupportsGuidelines, SupportsMcp, SupportsSkills
{
public function name(): string
{
return 'cursor';
}
public function displayName(): string
{
return 'Cursor';
}
public function systemDetectionConfig(Platform $platform): array
{
return match ($platform) {
Platform::Darwin => [
'paths' => ['/Applications/Cursor.app'],
],
Platform::Linux => [
'paths' => [
'/opt/cursor',
'/usr/local/bin/cursor',
'~/.local/bin/cursor',
],
],
Platform::Windows => [
'paths' => [
'%ProgramFiles%\\Cursor',
'%LOCALAPPDATA%\\Programs\\Cursor',
],
],
};
}
public function projectDetectionConfig(): array
{
return [
'paths' => ['.cursor'],
];
}
public function mcpConfigPath(): string
{
return config('boost.agents.cursor.mcp_config_path', '.cursor/mcp.json');
}
/** {@inheritDoc} */
public function httpMcpServerConfig(string $url): array
{
return [
'command' => 'npx',
'args' => ['-y', 'mcp-remote', $url],
];
}
public function guidelinesPath(): string
{
return config('boost.agents.cursor.guidelines_path', 'AGENTS.md');
}
public function skillsPath(): string
{
return config('boost.agents.cursor.skills_path', '.cursor/skills');
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Agents;
use Laravel\Boost\Contracts\SupportsGuidelines;
use Laravel\Boost\Contracts\SupportsMcp;
use Laravel\Boost\Contracts\SupportsSkills;
use Laravel\Boost\Install\Enums\Platform;
class Gemini extends Agent implements SupportsGuidelines, SupportsMcp, SupportsSkills
{
public function name(): string
{
return 'gemini';
}
public function displayName(): string
{
return 'Gemini CLI';
}
public function transformGuidelines(string $markdown): string
{
return preg_replace_callback(
'/## Foundational Context.*?(?=\n## |$)/s',
fn (array $matches) => preg_replace('/(?<!\\\\)@([a-z0-9-]+\/[a-z0-9-]+)/i', '\\\\@$1', $matches[0]),
$markdown
);
}
public function systemDetectionConfig(Platform $platform): array
{
return match ($platform) {
Platform::Darwin, Platform::Linux => [
'command' => 'command -v gemini',
],
Platform::Windows => [
'command' => 'cmd /c where gemini 2>nul',
],
};
}
public function projectDetectionConfig(): array
{
return [
'paths' => ['.gemini'],
'files' => ['GEMINI.md'],
];
}
public function mcpConfigPath(): string
{
return config('boost.agents.gemini.mcp_config_path', '.gemini/settings.json');
}
/** {@inheritDoc} */
public function httpMcpServerConfig(string $url): array
{
return [
'command' => 'npx',
'args' => ['-y', 'mcp-remote', $url],
];
}
public function guidelinesPath(): string
{
return config('boost.agents.gemini.guidelines_path', 'GEMINI.md');
}
public function skillsPath(): string
{
return config('boost.agents.gemini.skills_path', '.agents/skills');
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Agents;
use Laravel\Boost\Contracts\SupportsGuidelines;
use Laravel\Boost\Contracts\SupportsMcp;
use Laravel\Boost\Contracts\SupportsSkills;
use Laravel\Boost\Install\Enums\Platform;
class Junie extends Agent implements SupportsGuidelines, SupportsMcp, SupportsSkills
{
public function name(): string
{
return 'junie';
}
public function displayName(): string
{
return 'Junie';
}
public function useAbsolutePathForMcp(): bool
{
return true;
}
public function systemDetectionConfig(Platform $platform): array
{
return match ($platform) {
Platform::Darwin => [
'paths' => ['/Applications/PhpStorm.app'],
],
Platform::Linux => [
'paths' => [
'/opt/phpstorm',
'/opt/PhpStorm*',
'/usr/local/bin/phpstorm',
'~/.local/share/JetBrains/Toolbox/apps/PhpStorm/ch-*',
],
],
Platform::Windows => [
'paths' => [
'%ProgramFiles%\\JetBrains\\PhpStorm*',
'%LOCALAPPDATA%\\JetBrains\\Toolbox\\apps\\PhpStorm\\ch-*',
'%LOCALAPPDATA%\\Programs\\PhpStorm',
],
],
};
}
public function projectDetectionConfig(): array
{
return [
'paths' => ['.idea', '.junie'],
];
}
public function mcpConfigPath(): string
{
return config('boost.agents.junie.mcp_path', '.junie/mcp/mcp.json');
}
/** {@inheritDoc} */
public function httpMcpServerConfig(string $url): array
{
return [
'command' => 'npx',
'args' => ['-y', 'mcp-remote', $url],
];
}
public function guidelinesPath(): string
{
return config('boost.agents.junie.guidelines_path', 'AGENTS.md');
}
public function skillsPath(): string
{
return config('boost.agents.junie.skills_path', '.junie/skills');
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Agents;
use Laravel\Boost\Contracts\SupportsGuidelines;
use Laravel\Boost\Contracts\SupportsMcp;
use Laravel\Boost\Contracts\SupportsSkills;
use Laravel\Boost\Install\Enums\Platform;
class Kiro extends Agent implements SupportsGuidelines, SupportsMcp, SupportsSkills
{
public function name(): string
{
return 'kiro';
}
public function displayName(): string
{
return 'Kiro';
}
public function systemDetectionConfig(Platform $platform): array
{
return match ($platform) {
Platform::Darwin => [
'paths' => ['/Applications/Kiro.app'],
],
Platform::Linux => [
'paths' => [
'/opt/kiro',
'/usr/local/bin/kiro',
'~/.local/bin/kiro',
],
],
Platform::Windows => [
'paths' => [
'%ProgramFiles%\\Kiro',
'%LOCALAPPDATA%\\Programs\\Kiro',
],
],
};
}
public function projectDetectionConfig(): array
{
return [
'paths' => ['.kiro'],
];
}
public function httpMcpServerConfig(string $url): array
{
return [
'url' => $url,
];
}
public function mcpConfigPath(): string
{
return config('boost.agents.kiro.mcp_config_path', '.kiro/settings/mcp.json');
}
public function guidelinesPath(): string
{
return config('boost.agents.kiro.guidelines_path', 'AGENTS.md');
}
public function skillsPath(): string
{
return config('boost.agents.kiro.skills_path', '.kiro/skills');
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Agents;
use Laravel\Boost\Contracts\SupportsGuidelines;
use Laravel\Boost\Contracts\SupportsMcp;
use Laravel\Boost\Contracts\SupportsSkills;
use Laravel\Boost\Install\Enums\McpInstallationStrategy;
use Laravel\Boost\Install\Enums\Platform;
use stdClass;
class OpenCode extends Agent implements SupportsGuidelines, SupportsMcp, SupportsSkills
{
public function name(): string
{
return 'opencode';
}
public function displayName(): string
{
return 'OpenCode';
}
public function systemDetectionConfig(Platform $platform): array
{
return match ($platform) {
Platform::Darwin, Platform::Linux => [
'command' => 'command -v opencode',
],
Platform::Windows => [
'command' => 'cmd /c where opencode 2>nul',
],
};
}
public function projectDetectionConfig(): array
{
return [
'files' => ['AGENTS.md', 'opencode.json'],
];
}
public function mcpInstallationStrategy(): McpInstallationStrategy
{
return McpInstallationStrategy::FILE;
}
public function mcpConfigPath(): string
{
return config('boost.agents.opencode.mcp_config_path', 'opencode.json');
}
public function guidelinesPath(): string
{
return config('boost.agents.opencode.guidelines_path', 'AGENTS.md');
}
public function mcpConfigKey(): string
{
return 'mcp';
}
/** {@inheritDoc} */
public function defaultMcpConfig(): array
{
return [
'$schema' => 'https://opencode.ai/config.json',
];
}
/** {@inheritDoc} */
public function httpMcpServerConfig(string $url): array
{
return [
'type' => 'remote',
'enabled' => true,
'url' => $url,
'oauth' => new stdClass,
];
}
/** {@inheritDoc} */
public function mcpServerConfig(string $command, array $args = [], array $env = []): array
{
return [
'type' => 'local',
'enabled' => true,
'command' => [$command, ...$args],
'environment' => $env,
];
}
public function skillsPath(): string
{
return config('boost.agents.opencode.skills_path', '.agents/skills');
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install;
use Illuminate\Container\Container;
use Illuminate\Support\Collection;
use Laravel\Boost\BoostManager;
use Laravel\Boost\Install\Agents\Agent;
use Laravel\Boost\Install\Enums\Platform;
class AgentsDetector
{
public function __construct(
private readonly Container $container,
private readonly BoostManager $boostManager
) {}
/**
* Detect installed agents on the current platform.
*
* @return array<string>
*/
public function discoverSystemInstalledAgents(): array
{
$platform = Platform::current();
return $this->getAgents()
->filter(fn (Agent $program): bool => $program->detectOnSystem($platform))
->map(fn (Agent $program): string => $program->name())
->values()
->toArray();
}
/**
* Detect agents used in the current project.
*
* @return array<string>
*/
public function discoverProjectInstalledAgents(string $basePath): array
{
return $this->getAgents()
->filter(fn (Agent $program): bool => $program->detectInProject($basePath))
->map(fn (Agent $program): string => $program->name())
->values()
->toArray();
}
/**
* Get all registered agents.
*
* @return Collection<string, Agent>
*/
public function getAgents(): Collection
{
return collect($this->boostManager->getAgents())
->map(fn (string $className) => $this->container->make($className));
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Assists;
use Laravel\Roster\Enums\Packages;
use Laravel\Roster\Roster;
class Inertia
{
public function __construct(private Roster $roster)
{
//
}
public function gte(string $version): bool
{
if ($this->roster->usesVersion(Packages::INERTIA_LARAVEL, $version, '>=')) {
return true;
}
if ($this->roster->usesVersion(Packages::INERTIA_REACT, $version, '>=')) {
return true;
}
if ($this->roster->usesVersion(Packages::INERTIA_SVELTE, $version, '>=')) {
return true;
}
return $this->roster->usesVersion(Packages::INERTIA_VUE, $version, '>=');
}
public function hasFormComponent(): bool
{
return $this->gte('2.1.0');
}
public function hasFormComponentResets(): bool
{
return $this->gte('2.1.2');
}
public function pagesDirectory(): string
{
$jsPath = base_path('resources/js');
if (is_dir($jsPath)) {
$entries = @scandir($jsPath);
if ($entries !== false && in_array('pages', $entries, true)) {
return 'resources/js/pages';
}
}
return 'resources/js/Pages';
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install;
class Cloud
{
public function skillRepo(): string
{
return 'laravel/cloud-cli';
}
public function skillPath(): string
{
return 'skills';
}
public function skillName(): string
{
return 'deploying-laravel-cloud';
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Concerns;
use Illuminate\Support\Collection;
use Laravel\Boost\Support\Composer;
use Laravel\Boost\Support\Npm;
use Laravel\Roster\Enums\Packages;
use Laravel\Roster\Package;
use Laravel\Roster\Roster;
trait DiscoverPackagePaths
{
/**
* Only include guidelines for these package names if they're a direct requirement.
* This fixes every Boost user getting the MCP guidelines due to indirect import.
*
* @var array<int, Packages>
* */
protected array $mustBeDirect = [
Packages::MCP,
Packages::LIVEWIRE,
];
/**
* Packages excluded from Roster-based guideline discovery.
* Boost is already loaded by getCoreGuidelines(); Sail requires explicit opt-in.
*
* @var array<int, Packages>
*/
protected array $excludedPackages = [
Packages::BOOST,
Packages::SAIL,
];
abstract protected function getRoster(): Roster;
/**
* Package priority system to handle conflicts between packages.
* When a higher-priority package is present, lower-priority packages are excluded from guidelines.
*/
protected function getPackagePriorities(): array
{
return [
Packages::PEST->value => [Packages::PHPUNIT->value],
Packages::FLUXUI_PRO->value => [Packages::FLUXUI_FREE->value],
];
}
protected function shouldExcludePackage(Package $package): bool
{
if (in_array($package->package(), $this->excludedPackages, true)) {
return true;
}
foreach ($this->getPackagePriorities() as $priorityPackage => $excludedPackages) {
if (in_array($package->package()->value, $excludedPackages, true)
&& $this->getRoster()->uses(Packages::from($priorityPackage))) {
return true;
}
}
return $package->indirect() && in_array($package->package(), $this->mustBeDirect, true);
}
/**
* @return Collection<int, array{path: string, name: string, version: string}>
*/
protected function discoverPackagePaths(string $basePath): Collection
{
$packages = $this->getRoster()->packages()
->reject(fn (Package $package): bool => $this->shouldExcludePackage($package));
/** @var Collection<int, array{path: string, name: string, version: string}> $result */
$result = $packages
->map(function (Package $package) use ($basePath): array {
$name = $this->normalizePackageName($package->name());
return [
'path' => $basePath.DIRECTORY_SEPARATOR.$name,
'name' => $name,
'version' => $package->majorVersion(),
];
})
->collect();
return $result->filter(fn (array $package): bool => is_dir($package['path']));
}
protected function normalizePackageName(string $name): string
{
return str_replace('_', '-', strtolower($name));
}
protected function getBoostAiPath(): string
{
return __DIR__.'/../../../.ai';
}
protected function resolveFirstPartyBoostPath(Package $package, string $subpath): ?string
{
if (! Composer::isFirstPartyPackage($package->rawName()) && ! Npm::isFirstPartyPackage($package->rawName())) {
return null;
}
$path = implode(DIRECTORY_SEPARATOR, [$package->path(), 'resources', 'boost', $subpath]);
return is_dir($path) ? $path : null;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Contracts;
use Laravel\Boost\Install\Enums\Platform;
interface DetectionStrategy
{
/**
* Detect if the application is installed on the machine.
*
* @param array{command?:string, basePath?:string, files?:array<string>, paths?:array<string>} $config
*/
public function detect(array $config, ?Platform $platform = null): bool;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Detection;
use Illuminate\Support\Facades\Process;
use Laravel\Boost\Install\Contracts\DetectionStrategy;
use Laravel\Boost\Install\Enums\Platform;
class CommandDetectionStrategy implements DetectionStrategy
{
public function detect(array $config, ?Platform $platform = null): bool
{
if (! isset($config['command'])) {
return false;
}
return Process::run($config['command'])->successful();
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Detection;
use Laravel\Boost\Install\Contracts\DetectionStrategy;
use Laravel\Boost\Install\Enums\Platform;
class CompositeDetectionStrategy implements DetectionStrategy
{
/**
* @param DetectionStrategy[] $strategies
*/
public function __construct(private readonly array $strategies)
{
//
}
public function detect(array $config, ?Platform $platform = null): bool
{
foreach ($this->strategies as $strategy) {
if ($strategy->detect($config, $platform)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Detection;
use Illuminate\Container\Container;
use InvalidArgumentException;
use Laravel\Boost\Install\Contracts\DetectionStrategy;
class DetectionStrategyFactory
{
private const TYPE_DIRECTORY = 'directory';
private const TYPE_COMMAND = 'command';
private const TYPE_FILE = 'file';
public function __construct(private readonly Container $container)
{
//
}
public function make(string|array $type, array $config = []): DetectionStrategy
{
if (is_array($type)) {
return new CompositeDetectionStrategy(
array_map(fn (string|array $singleType): DetectionStrategy => $this->make($singleType, $config), $type)
);
}
return match ($type) {
self::TYPE_DIRECTORY => $this->container->make(DirectoryDetectionStrategy::class),
self::TYPE_COMMAND => $this->container->make(CommandDetectionStrategy::class),
self::TYPE_FILE => $this->container->make(FileDetectionStrategy::class),
default => throw new InvalidArgumentException("Unknown detection type: {$type}"),
};
}
public function makeFromConfig(array $config): DetectionStrategy
{
$type = $this->inferTypeFromConfig($config);
return $this->make($type, $config);
}
protected function inferTypeFromConfig(array $config): string|array
{
$typeMap = [
'files' => self::TYPE_FILE,
'paths' => self::TYPE_DIRECTORY,
'command' => self::TYPE_COMMAND,
];
$types = collect($typeMap)
->only(array_keys($config))
->values()
->all();
if (empty($types)) {
throw new InvalidArgumentException(
'Cannot infer detection type from config keys. Expected one of: '.collect($typeMap)->keys()->join(', ')
);
}
return count($types) > 1 ? $types : reset($types);
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Detection;
use Laravel\Boost\Install\Contracts\DetectionStrategy;
use Laravel\Boost\Install\Enums\Platform;
class DirectoryDetectionStrategy implements DetectionStrategy
{
public function detect(array $config, ?Platform $platform = null): bool
{
if (! isset($config['paths'])) {
return false;
}
$basePath = $config['basePath'] ?? '';
foreach ($config['paths'] as $path) {
$expandedPath = $this->expandPath($path, $platform);
// If basePath is provided, prepend it to relative paths
if ($basePath && ! $this->isAbsolutePath($expandedPath)) {
$expandedPath = $basePath.DIRECTORY_SEPARATOR.$expandedPath;
}
if (str_contains($expandedPath, '*')) {
$matches = glob($expandedPath, GLOB_ONLYDIR);
if (! empty($matches)) {
return true;
}
} elseif (is_dir($expandedPath)) {
return true;
}
}
return false;
}
protected function expandPath(string $path, ?Platform $platform = null): string
{
if ($platform === Platform::Windows) {
return preg_replace_callback('/%([^%]+)%/', fn (array $matches) => getenv($matches[1]) ?: $matches[0], $path);
}
if (str_starts_with($path, '~')) {
$home = getenv('HOME');
if ($home) {
return str_replace('~', $home, $path);
}
}
return $path;
}
protected function isAbsolutePath(string $path): bool
{
return str_starts_with($path, '/') ||
str_starts_with($path, '\\') ||
(strlen($path) > 1 && $path[1] === ':'); // Windows C:
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Detection;
use Laravel\Boost\Install\Contracts\DetectionStrategy;
use Laravel\Boost\Install\Enums\Platform;
class FileDetectionStrategy implements DetectionStrategy
{
public function detect(array $config, ?Platform $platform = null): bool
{
$basePath = $config['basePath'] ?? getcwd();
if (isset($config['files'])) {
foreach ($config['files'] as $file) {
if (file_exists($basePath.DIRECTORY_SEPARATOR.$file)) {
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Enums;
enum McpInstallationStrategy: string
{
case SHELL = 'shell';
case FILE = 'file';
case NONE = 'none';
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Enums;
enum Platform: string
{
case Darwin = 'darwin';
case Linux = 'linux';
case Windows = 'windows';
public static function current(): self
{
return match (PHP_OS_FAMILY) {
'Windows' => self::Windows,
'Darwin' => self::Darwin,
default => self::Linux,
};
}
}

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Laravel\Boost\Install\Assists\Inertia;
use Laravel\Roster\Enums\NodePackageManager;
use Laravel\Roster\Enums\Packages;
use Laravel\Roster\Roster;
use Symfony\Component\Finder\Finder;
class GuidelineAssist
{
/** @var array<string, string> */
protected array $enumPaths = [];
public function __construct(public Roster $roster, public GuidelineConfig $config, public ?Collection $skills = null)
{
$this->skills ??= collect();
$this->enumPaths = $this->discover();
}
/**
* @return array<string, string> - className, absolutePath
*/
public function enums(): array
{
return $this->enumPaths;
}
/**
* Discover all enum files in the application directory.
*
* @return array<string, string>
*/
protected function discover(): array
{
$appPath = app_path();
if (! is_dir($appPath)) {
return [];
}
$enums = [];
$finder = Finder::create()
->in($appPath)
->files()
->name('/[A-Z].*\.php$/');
foreach ($finder as $file) {
$path = $file->getRealPath();
$code = file_get_contents($path);
if ($code === false) {
continue;
}
if (stripos($code, 'enum') === false) {
continue;
}
$tokens = token_get_all($code);
foreach ($tokens as $token) {
if (is_array($token) && $token[0] === T_ENUM) {
$className = app()->getNamespace().str_replace(
['/', '.php'],
['\\', ''],
$file->getRelativePathname()
);
$enums[$className] = $path;
break;
}
}
}
return $enums;
}
public function enumContents(): string
{
if ($this->enumPaths === []) {
return '';
}
$path = current($this->enumPaths);
if (! is_file($path)) {
return '';
}
return file_get_contents($path) ?: '';
}
public function inertia(): Inertia
{
return new Inertia($this->roster);
}
public function supportsPintAgentFormatter(): bool
{
return $this->roster->usesVersion(Packages::PINT, '1.27.0', '>=');
}
public function hasPackage(Packages $package): bool
{
return $this->roster->packages()->contains(
fn ($pkg): bool => $pkg->package() === $package
);
}
public function nodePackageManager(): string
{
return ($this->roster->nodePackageManager() ?? NodePackageManager::NPM)->value;
}
protected function detectedNodePackageManager(): string
{
return $this->nodePackageManager();
}
public function nodePackageManagerCommand(string $command): string
{
$npmExecutable = config('boost.executable_paths.npm');
if ($npmExecutable !== null) {
return "{$npmExecutable} {$command}";
}
if ($this->config->usesSail) {
return Sail::nodePackageManagerCommand($this->detectedNodePackageManager())." {$command}";
}
return "{$this->detectedNodePackageManager()} {$command}";
}
public function artisanCommand(string $command): string
{
return "{$this->artisan()} {$command}";
}
public function composerCommand(string $command): string
{
$composerExecutable = config('boost.executable_paths.composer');
if ($composerExecutable !== null) {
return "{$composerExecutable} {$command}";
}
if ($this->config->usesSail) {
return Sail::composerCommand()." {$command}";
}
return "composer {$command}";
}
public function binCommand(string $command): string
{
$vendorBinPrefix = config('boost.executable_paths.vendor_bin');
if ($vendorBinPrefix !== null) {
return "{$vendorBinPrefix}{$command}";
}
if ($this->config->usesSail) {
return Sail::binCommand().$command;
}
return "vendor/bin/{$command}";
}
public function artisan(): string
{
$phpExecutable = config('boost.executable_paths.php');
if ($phpExecutable !== null) {
return "{$phpExecutable} artisan";
}
return $this->config->usesSail
? Sail::artisanCommand()
: 'php artisan';
}
public function sailBinaryPath(): string
{
return Sail::binaryPath();
}
public function appPath(string $path = ''): string
{
return ltrim(Str::after(app_path($path), base_path()), DIRECTORY_SEPARATOR);
}
public function hasSkillsEnabled(): bool
{
return $this->config->hasSkills;
}
public function hasMcpEnabled(): bool
{
return $this->config->hasMcp;
}
}

View File

@@ -0,0 +1,380 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Laravel\Boost\Concerns\RendersBladeGuidelines;
use Laravel\Boost\Install\Concerns\DiscoverPackagePaths;
use Laravel\Boost\Support\Composer;
use Laravel\Roster\Package;
use Laravel\Roster\Roster;
use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
class GuidelineComposer
{
use DiscoverPackagePaths;
use RendersBladeGuidelines;
protected string $userGuidelineDir = '.ai/guidelines';
/** @var Collection<string, array>|null */
protected ?Collection $guidelines = null;
protected GuidelineConfig $config;
public function __construct(protected Roster $roster, protected Herd $herd)
{
$this->config = new GuidelineConfig;
}
protected function getRoster(): Roster
{
return $this->roster;
}
public function config(GuidelineConfig $config): self
{
$this->config = $config;
return $this;
}
/**
* Auto discovers the guideline files and composes them into one string.
*/
public function compose(): string
{
return self::composeGuidelines($this->guidelines());
}
public function customGuidelinePath(string $path = ''): string
{
return base_path($this->userGuidelineDir.'/'.ltrim($path, '/'));
}
protected function isCustomGuideline(string $path): bool
{
$resolvedBase = realpath($this->customGuidelinePath());
return $resolvedBase !== false && str_contains($path, $resolvedBase);
}
/**
* Static method to compose guidelines from a collection.
* Can be used without Laravel dependencies.
*
* @param Collection<string, array{content: string, name: string, path: ?string, custom: bool}> $guidelines
*/
public static function composeGuidelines(Collection $guidelines): string
{
$composed = trim($guidelines
->filter(fn ($guideline): bool => ! empty(trim($guideline['content'])))
->map(fn ($guideline, $key): string => "\n=== {$key} rules ===\n\n".trim($guideline['content']))
->join("\n\n")
);
return MarkdownFormatter::format($composed);
}
/**
* @return string[]
*/
public function used(): array
{
return $this->guidelines()->keys()->toArray();
}
/**
* @return Collection<string, array>
*/
public function guidelines(): Collection
{
if ($this->guidelines instanceof Collection) {
return $this->guidelines;
}
$excluded = config('boost.guidelines.exclude', []);
$base = collect()
->merge($this->getCoreGuidelines())
->merge($this->getConditionalGuidelines())
->merge($this->getPackageGuidelines())
->merge($this->getThirdPartyGuidelines())
->reject(fn (array $guideline, string $key): bool => in_array($key, $excluded, true));
$basePaths = $base->pluck('path')->filter()->values();
$customGuidelines = $this->getUserGuidelines()
->reject(fn ($guideline): bool => $basePaths->contains($guideline['path']));
return $this->guidelines = $customGuidelines
->merge($base)
->filter(fn ($guideline): bool => filled($guideline['content']));
}
/**
* @return Collection<string, array>
*/
protected function getUserGuidelines(): Collection
{
return collect($this->guidelinesDir($this->customGuidelinePath()))
->mapWithKeys(fn ($guideline): array => ['.ai/'.$guideline['name'] => $guideline]);
}
/**
* @return Collection<string, array>
*/
protected function getCoreGuidelines(): Collection
{
return collect([
'foundation' => $this->guideline('foundation'),
'boost' => $this->guideline('boost/core'),
'php' => $this->guideline('php/core'),
'deployments' => $this->guideline('deployments/core'),
]);
}
/**
* @return Collection<string, array>
*/
protected function getConditionalGuidelines(): Collection
{
return collect([
'herd' => [
'condition' => str_contains((string) config('app.url'), '.test') && $this->herd->isInstalled() && ! $this->config->usesSail,
'path' => 'herd/core',
],
'sail' => [
'condition' => $this->config->usesSail,
'path' => 'sail/core',
],
'laravel/style' => [
'condition' => $this->config->laravelStyle,
'path' => 'laravel/style',
],
'laravel/api' => [
'condition' => $this->config->hasAnApi,
'path' => 'laravel/api',
],
'laravel/localization' => [
'condition' => $this->config->caresAboutLocalization,
'path' => 'laravel/localization',
],
'tests' => [
'condition' => $this->config->enforceTests,
'path' => 'enforce-tests',
],
])
->filter(fn ($config): bool => $config['condition'])
->mapWithKeys(fn ($config, $key): array => [$key => $this->guideline($config['path'])]);
}
protected function getPackageGuidelines(): Collection
{
return $this->roster->packages()
->reject(fn (Package $package): bool => $this->shouldExcludePackage($package))
->flatMap(function (Package $package): Collection {
$guidelineDir = $this->normalizePackageName($package->name());
$vendorPath = $this->resolveFirstPartyBoostPath($package, 'guidelines');
$vendorCorePath = $vendorPath !== null
? implode(DIRECTORY_SEPARATOR, [$vendorPath, 'core'])
: null;
$guidelines = collect([
$guidelineDir.'/core' => $this->resolveGuideline($vendorCorePath, $guidelineDir.'/core'),
]);
$packageGuidelines = $this->guidelinesDir($guidelineDir.'/'.$package->majorVersion());
foreach ($packageGuidelines as $guideline) {
$suffix = $guideline['name'] === 'core' ? '' : '/'.$guideline['name'];
$guidelines->put(
$guidelineDir.'/v'.$package->majorVersion().$suffix,
$guideline
);
}
return $guidelines;
});
}
/**
* @return array{content: string, name: string, description: string, path: ?string, custom: bool, third_party: bool}
*/
private function resolveGuideline(?string $vendorPath, string $guidelineKey): array
{
if ($vendorPath !== null) {
foreach (['.blade.php', '.md'] as $ext) {
if (file_exists($vendorPath.$ext)) {
return $this->guideline($vendorPath.$ext, false, $guidelineKey);
}
}
}
return $this->guideline($guidelineKey);
}
/**
* @return Collection<string, array>
*/
protected function getThirdPartyGuidelines(): Collection
{
$guidelines = collect();
foreach (Composer::packagesDirectoriesWithBoostGuidelines() as $package => $path) {
if (Composer::isFirstPartyPackage($package)) {
continue;
}
foreach ($this->guidelinesDir($path, true) as $guideline) {
$guidelines->put($package, $guideline);
}
}
if (! isset($this->config->aiGuidelines)) {
return $guidelines;
}
return $guidelines->filter(
fn (mixed $guideline, string $name): bool => in_array($name, $this->config->aiGuidelines, true),
);
}
/**
* @return array<array{content: string, name: string, description: string, path: ?string, custom: bool, third_party: bool}>
*/
protected function guidelinesDir(string $dirPath, bool $thirdParty = false): array
{
if (! is_dir($dirPath)) {
$dirPath = str_replace('/', DIRECTORY_SEPARATOR, $this->getBoostAiPath().'/'.$dirPath);
}
try {
$finder = Finder::create()
->files()
->in($dirPath)
->exclude('skill')
->name('*.blade.php')
->name('*.md')
->sortByName();
} catch (DirectoryNotFoundException) {
return [];
}
return collect($finder)
->map(fn (SplFileInfo $file): array => $this->guideline($file->getRealPath(), $thirdParty))
->all();
}
/**
* @return array{content: string, name: string, description: string, path: ?string, custom: bool, third_party: bool}
*/
protected function guideline(string $path, bool $thirdParty = false, ?string $overrideKey = null): array
{
$path = $this->guidelinePath($path, $overrideKey);
if ($path === null) {
return [
'content' => '',
'description' => '',
'name' => '',
'path' => null,
'custom' => false,
'third_party' => $thirdParty,
];
}
$rendered = $this->renderBladeFile($path);
$description = Str::of($rendered)
->after('# ')
->before("\n")
->trim()
->limit(50)
->whenEmpty(fn () => Str::of('No description provided'))
->value();
return [
'content' => trim($rendered),
'name' => str_replace(['.blade.php', '.md'], '', basename($path)),
'description' => $description,
'path' => $path,
'custom' => $this->isCustomGuideline($path),
'third_party' => $thirdParty,
'tokens' => round(str_word_count($rendered) * 1.3),
];
}
protected function getGuidelineAssist(): GuidelineAssist
{
return new GuidelineAssist($this->roster, $this->config);
}
protected function prependPackageGuidelinePath(string $path): string
{
return $this->prependGuidelinePath($path, $this->getBoostAiPath().'/');
}
protected function prependUserGuidelinePath(string $path): string
{
return $this->prependGuidelinePath($path, $this->customGuidelinePath());
}
private function prependGuidelinePath(string $path, string $basePath): string
{
if (! str_ends_with($path, '.md') && ! str_ends_with($path, '.blade.php')) {
$path .= '.blade.php';
}
return str_replace('/', DIRECTORY_SEPARATOR, $basePath.$path);
}
protected function guidelinePath(string $path, ?string $overrideKey = null): ?string
{
// Relative path, prepend our package path to it
if (! file_exists($path)) {
$path = $this->prependPackageGuidelinePath($path);
if (! file_exists($path)) {
return null;
}
}
$path = realpath($path);
if ($this->isCustomGuideline($path)) {
return $path;
}
if ($overrideKey !== null) {
foreach (['.blade.php', '.md'] as $ext) {
$customPath = $this->prependUserGuidelinePath($overrideKey.$ext);
if (file_exists($customPath)) {
return realpath($customPath);
}
}
return $path;
}
// The path is not a custom guideline, check if the user has an override for this
$basePath = realpath(__DIR__.'/../../');
$relativePath = Str::of($path)
->replace([$basePath, '.ai'.DIRECTORY_SEPARATOR, '.ai/'], '')
->ltrim('/\\')
->toString();
$customPath = $this->prependUserGuidelinePath($relativePath);
return file_exists($customPath) ? realpath($customPath) : $path;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install;
class GuidelineConfig
{
public bool $enforceTests = false;
public bool $laravelStyle = false;
public bool $usesSail = false;
public bool $caresAboutLocalization = false;
public bool $hasAnApi = false;
public bool $hasSkills = false;
public bool $hasMcp = false;
/**
* @var array<int, string>
*/
public array $aiGuidelines;
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install;
use Laravel\Boost\Contracts\SupportsGuidelines;
use RuntimeException;
class GuidelineWriter
{
public const NEW = 0;
public const REPLACED = 1;
public const FAILED = 2;
public const NOOP = 3;
public function __construct(protected SupportsGuidelines $agent)
{
//
}
/**
* @return GuidelineWriter::NEW|GuidelineWriter::REPLACED|GuidelineWriter::FAILED|GuidelineWriter::NOOP
*/
public function write(string $guidelines): int
{
if (empty($guidelines)) {
return self::NOOP;
}
$guidelines = $this->agent->transformGuidelines($guidelines);
$filePath = $this->agent->guidelinesPath();
$directory = dirname($filePath);
if (! is_dir($directory) && ! @mkdir($directory, 0755, true)) {
throw new RuntimeException("Failed to create directory: {$directory}");
}
$handle = @fopen($filePath, 'c+');
if (! $handle) {
throw new RuntimeException("Failed to open file: {$filePath}");
}
try {
$this->acquireLockWithRetry($handle, $filePath);
$content = stream_get_contents($handle);
// Check if guidelines already exist
$pattern = '/<laravel-boost-guidelines>.*?<\/laravel-boost-guidelines>/s';
$replacement = "<laravel-boost-guidelines>\n".$guidelines."\n\n</laravel-boost-guidelines>";
$replaced = false;
if (preg_match($pattern, $content)) {
// Replace ALL existing boost guidelines blocks in-place
// If the user added guidelines after ours then let's
// make sure we keep the flow.
$newContent = preg_replace($pattern, $replacement, $content, 1);
$replaced = true;
} else {
// No existing Boost guidelines found, append to end of existing file
$frontMatter = '';
if ($this->agent->frontmatter() && ! str_contains($content, "\n---\n")) {
$frontMatter = "---\nalwaysApply: true\n---\n";
}
$existingContent = rtrim($content);
$separatingNewlines = empty($existingContent) ? '' : "\n\n===\n\n";
$newContent = $frontMatter.$existingContent.$separatingNewlines.$replacement;
}
// Normalize multiple blank lines to single blank lines
$newContent = preg_replace("/\n{3,}/", "\n\n", (string) $newContent);
// Ensure file content ends with a newline
if (! str_ends_with((string) $newContent, "\n")) {
$newContent .= "\n";
}
if (ftruncate($handle, 0) === false || fseek($handle, 0) === -1) {
throw new RuntimeException("Failed to reset file pointer: {$filePath}");
}
if (fwrite($handle, (string) $newContent) === false) {
throw new RuntimeException("Failed to write to file: {$filePath}");
}
flock($handle, LOCK_UN);
} finally {
fclose($handle);
}
return $replaced ? self::REPLACED : self::NEW;
}
protected function acquireLockWithRetry(mixed $handle, string $filePath, int $maxRetries = 3): void
{
$attempts = 0;
$delay = 100000; // Start with 100ms in microseconds
while ($attempts < $maxRetries) {
if (flock($handle, LOCK_EX | LOCK_NB)) {
return;
}
$attempts++;
if ($attempts >= $maxRetries) {
throw new RuntimeException("Failed to acquire lock on file after {$maxRetries} attempts: {$filePath}");
}
// Exponential backoff with jitter
$jitter = random_int(0, (int) ($delay * 0.1));
usleep($delay + $jitter);
$delay *= 2;
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install;
use Laravel\Boost\Install\Enums\Platform;
class Herd
{
public function isInstalled(): bool
{
if (! $this->isWindowsPlatform()) {
return file_exists('/Applications/Herd.app/Contents/MacOS/Herd');
}
return is_dir($this->getHomePath().'/.config/herd');
}
public function getHomePath(): string
{
if ($this->isWindowsPlatform()) {
if (! isset($_SERVER['HOME'])) {
$_SERVER['HOME'] = $_SERVER['USERPROFILE'];
}
$_SERVER['HOME'] = str_replace('\\', '/', $_SERVER['HOME']);
}
return $_SERVER['HOME'];
}
public function isWindowsPlatform(): bool
{
return Platform::current() === Platform::Windows;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install;
class MarkdownFormatter
{
/**
* Apply consistent formatting to markdown content.
*/
public static function format(string $content): string
{
// Normalize line endings (CRLF → LF, CR → LF)
$content = str_replace(["\r\n", "\r"], "\n", $content);
// Ensure blank line before and after markdown headings
$content = preg_replace('/(?<!\n)\n(#{1,4} )/m', "\n\n$1", $content);
$content = preg_replace('/(#{1,4} .+)\n(?!\n)/m', "$1\n\n", (string) $content);
// Collapse multiple consecutive empty lines into a single empty line
$content = preg_replace('/\n{3,}/', "\n\n", (string) $content);
return $content;
}
}

View File

@@ -0,0 +1,462 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Mcp;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class FileWriter
{
protected string $configKey = 'mcpServers';
protected array $serversToAdd = [];
protected int $defaultIndentation = 8;
public function __construct(protected string $filePath, protected array $baseConfig = [])
{
//
}
public function configKey(string $key): self
{
$this->configKey = $key;
return $this;
}
/**
* @deprecated Use addServerConfig() for array-based configuration.
*
* @param array<int, string> $args
* @param array<string, string> $env
*/
public function addServer(string $key, string $command, array $args = [], array $env = []): self
{
return $this->addServerConfig($key, collect([
'command' => $command,
'args' => $args,
'env' => $env,
])->filter(fn ($value): bool => ! in_array($value, [[], null, ''], true))->toArray());
}
/**
* @param array<string, mixed> $config
*/
public function addServerConfig(string $key, array $config): self
{
$this->serversToAdd[$key] = collect($config)
->filter(fn ($value): bool => ! in_array($value, [[], null, ''], true))
->toArray();
return $this;
}
public function save(): bool
{
$this->ensureDirectoryExists();
if ($this->shouldWriteNew()) {
return $this->createNewFile();
}
$content = $this->readFile();
if ($this->isPlainJson($content)) {
return $this->updatePlainJsonFile($content);
}
if (! $this->hasJson5Features($content)) {
return false;
}
return $this->updateJson5File($content);
}
protected function updatePlainJsonFile(string $content): bool
{
$config = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return false;
}
$this->addServersToConfig($config);
return $this->writeJsonConfig($config);
}
protected function updateJson5File(string $content): bool
{
$configKeyPattern = '/["\']'.preg_quote($this->configKey, '/').'["\']\\s*:\\s*\\{/';
if (preg_match($configKeyPattern, $content, $matches, PREG_OFFSET_CAPTURE)) {
return $this->injectIntoExistingConfigKey($content, $matches);
}
return $this->injectNewConfigKey($content);
}
protected function injectIntoExistingConfigKey(string $content, array $matches): bool
{
// $matches[0][1] contains the position of the configKey pattern match
$configKeyStart = $matches[0][1];
// Find the opening brace of the configKey object
$openBracePos = strpos($content, '{', $configKeyStart);
if ($openBracePos === false) {
return false;
}
// Find the matching closing brace for this configKey object
$closeBracePos = $this->findMatchingClosingBrace($content, $openBracePos);
if ($closeBracePos === false) {
return false;
}
// Filter out servers that already exist
$serversToAdd = $this->filterExistingServers($content, $openBracePos, $closeBracePos);
if ($serversToAdd === []) {
return true;
}
// Detect indentation from surrounding content
$indentLength = $this->detectIndentation($content, $closeBracePos);
$serverJsonParts = [];
foreach ($serversToAdd as $key => $serverConfig) {
$serverJsonParts[] = $this->generateServerJson($key, $serverConfig, $indentLength);
}
$serversJson = implode(','."\n", $serverJsonParts);
// Check if we need a comma and add it to the preceding content
$needsComma = $this->needsCommaBeforeClosingBrace($content, $openBracePos, $closeBracePos);
if (! $needsComma) {
$newContent = substr_replace($content, $serversJson, $closeBracePos, 0);
return $this->writeFile($newContent);
}
// Find the position to add comma (after the last meaningful character)
$commaPosition = $this->findCommaInsertionPoint($content, $openBracePos, $closeBracePos);
if ($commaPosition !== -1) {
$newContent = substr_replace($content, ',', $commaPosition, 0);
$newContent = substr_replace($newContent, $serversJson, $commaPosition + 1, 0);
} else {
$newContent = substr_replace($content, $serversJson, $closeBracePos, 0);
}
return $this->writeFile($newContent);
}
protected function filterExistingServers(string $content, int $openBracePos, int $closeBracePos): array
{
$configContent = substr($content, $openBracePos + 1, $closeBracePos - $openBracePos - 1);
$filteredServers = [];
foreach ($this->serversToAdd as $key => $serverConfig) {
if (! $this->serverExistsInContent($configContent, $key)) {
$filteredServers[$key] = $serverConfig;
}
}
return $filteredServers;
}
protected function serverExistsInContent(string $content, string $serverKey): bool
{
$quotedPattern = '/["\']'.preg_quote($serverKey, '/').'["\']\\s*:/';
$unquotedPattern = '/(?<=^|\\s|,|{)'.preg_quote($serverKey, '/').'\\s*:/m';
return preg_match($quotedPattern, $content) || preg_match($unquotedPattern, $content);
}
protected function injectNewConfigKey(string $content): bool
{
$openBracePos = strpos($content, '{');
if ($openBracePos === false) {
return false;
}
$serverJsonParts = [];
foreach ($this->serversToAdd as $key => $serverConfig) {
$serverJsonParts[] = $this->generateServerJson($key, $serverConfig);
}
$serversJson = implode(',', $serverJsonParts);
$configKeySection = '"'.$this->configKey.'": {'.$serversJson.'}';
$needsComma = $this->needsCommaAfterBrace($content, $openBracePos);
$injection = $configKeySection.($needsComma ? ',' : '');
$newContent = substr_replace($content, $injection, $openBracePos + 1, 0);
return $this->writeFile($newContent);
}
protected function generateServerJson(string $key, array $serverConfig, int $baseIndent = 0): string
{
$json = json_encode($serverConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
// Normalize line endings to Unix style
$json = str_replace("\r\n", "\n", $json);
// If no indentation needed, return as-is
if (empty($baseIndent)) {
return '"'.$key.'": '.$json;
}
// Apply indentation to each line of the JSON
$baseIndent = str_repeat(' ', $baseIndent);
$lines = explode("\n", $json);
$firstLine = array_shift($lines);
$indentedLines = [
"{$baseIndent}\"{$key}\": {$firstLine}",
...array_map(fn (string $line): string => $baseIndent.$line, $lines),
];
return "\n".implode("\n", $indentedLines);
}
protected function needsCommaAfterBrace(string $content, int $bracePosition): bool
{
$afterBrace = substr($content, $bracePosition + 1);
$trimmed = preg_replace('/^\s*(?:\/\/.*$)?/m', '', $afterBrace);
return filled($trimmed) && ! Str::startsWith($trimmed, '}');
}
protected function findMatchingClosingBrace(string $content, int $openBracePos): int|false
{
$braceCount = 1;
$length = strlen($content);
$stringQuote = null;
$escaped = false;
for ($i = $openBracePos + 1; $i < $length; $i++) {
$char = $content[$i];
if ($stringQuote === null) {
if ($char === '{') {
$braceCount++;
} elseif ($char === '}') {
$braceCount--;
if ($braceCount === 0) {
return $i;
}
} elseif (($char === '"' || $char === "'") && ! $escaped) {
$stringQuote = $char;
}
} elseif ($char === $stringQuote && ! $escaped) {
$stringQuote = null;
}
$escaped = ($char === '\\' && ! $escaped);
}
return false;
}
protected function needsCommaBeforeClosingBrace(string $content, int $openBracePos, int $closeBracePos): bool
{
// Get content between opening and closing braces
$innerContent = substr($content, $openBracePos + 1, $closeBracePos - $openBracePos - 1);
// Skip whitespace and comments to find last meaningful character
$trimmed = preg_replace('/\s+|\/\/.*$/m', '', $innerContent);
// If empty or ends with opening brace, no comma needed
if (blank($trimmed) || Str::endsWith($trimmed, '{')) {
return false;
}
// If ends with comma, no additional comma needed
return ! Str::endsWith($trimmed, ',');
}
protected function findCommaInsertionPoint(string $content, int $openBracePos, int $closeBracePos): int
{
// Work backwards from closing brace to find last meaningful character
for ($i = $closeBracePos - 1; $i > $openBracePos; $i--) {
$char = $content[$i];
// Skip whitespace and newlines
if (in_array($char, [' ', "\t", "\n", "\r"], true)) {
continue;
}
// Skip comments (simple approach - if we hit //, skip to start of line)
if ($i > 0 && $content[$i - 1] === '/' && $char === '/') {
// Find start of this line
$lineStart = strrpos($content, "\n", $i - strlen($content)) ?: 0;
$i = $lineStart;
continue;
}
// Found last meaningful character, comma goes after it
if ($char !== ',') {
return $i + 1;
}
// Already has comma, no insertion needed
return -1;
}
// Fallback - insert right after opening brace
return $openBracePos + 1;
}
public function detectIndentation(string $content, int $nearPosition): int
{
// Look backwards from the position to find server-level indentation
// We want to find lines that look like: "server-name": {
$lines = explode("\n", substr($content, 0, $nearPosition));
// Look for the most recent server definition to match its indentation
for ($i = count($lines) - 1; $i >= 0; $i--) {
$line = $lines[$i];
// Match server definitions: any amount of whitespace + "key": {
if (preg_match('/^(\s*)"[^"]+"\s*:\s*\{/', $line, $matches)) {
return strlen($matches[1]);
}
}
// Fallback: assume 8 spaces (2 levels of 4-space indentation typical for JSON)
return $this->defaultIndentation;
}
/**
* Is the file content plain JSON, without JSON5 features?
*/
protected function isPlainJson(string $content): bool
{
if ($this->hasJson5Features($content)) {
return false;
}
json_decode($content);
return json_last_error() === JSON_ERROR_NONE;
}
protected function hasJson5Features(string $content): bool
{
if ($this->hasUnquotedComments($content)) {
return true;
}
if (preg_match('/,\s*[\]}]/', $content)) {
return true;
}
if (preg_match('/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:/m', $content)) {
return true;
}
return $this->hasSingleQuotedStrings($content);
}
protected function hasUnquotedComments(string $content): bool
{
// Match double-quoted strings (skip), line comments (//), or block comments (/* */)
$pattern = '/"(?:\\\\.|[^"\\\\])*"|(\/\/.*)|(\\/\\*[\\s\\S]*?\\*\\/)/';
if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
if (! empty($match[1]) || ! empty($match[2])) {
return true;
}
}
}
return false;
}
protected function hasSingleQuotedStrings(string $content): bool
{
// Match double-quoted strings (skip) or single-quoted strings (detect)
$pattern = '/"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'/';
if (preg_match_all($pattern, $content, $matches)) {
foreach ($matches[0] as $match) {
if ($match[0] === "'") {
return true;
}
}
}
return false;
}
protected function createNewFile(): bool
{
$config = $this->baseConfig;
$this->addServersToConfig($config);
return $this->writeJsonConfig($config);
}
protected function addServersToConfig(array &$config): void
{
foreach ($this->serversToAdd as $key => $serverConfig) {
$config[$this->configKey][$key] = $serverConfig;
}
}
protected function writeJsonConfig(array $config): bool
{
$json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
// Normalize line endings to Unix style
if ($json) {
$json = str_replace("\r\n", "\n", $json);
}
return $json && $this->writeFile($json);
}
protected function ensureDirectoryExists(): void
{
File::ensureDirectoryExists(dirname($this->filePath));
}
protected function fileExists(): bool
{
return File::exists($this->filePath);
}
protected function shouldWriteNew(): bool
{
if (! $this->fileExists()) {
return true;
}
return File::size($this->filePath) < 3;
// To account for files that are just `{}`
}
protected function readFile(): string
{
return File::get($this->filePath);
}
protected function writeFile(string $content): bool
{
return File::put($this->filePath, $content) !== false;
}
}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install\Mcp;
use Illuminate\Support\Facades\File;
class TomlFileWriter
{
protected string $configKey = 'mcp_servers';
/** @var array<string, array<string, mixed>> */
protected array $serversToAdd = [];
/** @param array<string, mixed> $baseConfig */
public function __construct(protected string $filePath, protected array $baseConfig = [])
{
//
}
public function configKey(string $key): self
{
$this->configKey = $key;
return $this;
}
/** @param array<string, mixed> $config */
public function addServerConfig(string $key, array $config): self
{
$this->serversToAdd[$key] = collect($config)
->filter(fn ($value): bool => ! in_array($value, [[], null, ''], true))
->toArray();
return $this;
}
public function save(): bool
{
File::ensureDirectoryExists(dirname($this->filePath));
if ($this->shouldWriteNew()) {
return $this->createNewFile();
}
return $this->updateExistingFile();
}
protected function createNewFile(): bool
{
$lines = [];
foreach ($this->baseConfig as $key => $value) {
if (! is_array($value)) {
$lines[] = "{$key} = ".$this->formatValue($value);
}
}
foreach ($this->serversToAdd as $key => $config) {
if ($lines !== []) {
$lines[] = '';
}
$lines[] = $this->buildServerToml($key, $config);
}
return $this->writeFile(implode(PHP_EOL, $lines).PHP_EOL);
}
protected function updateExistingFile(): bool
{
$content = File::get($this->filePath);
foreach ($this->serversToAdd as $key => $config) {
if ($this->serverExists($content, $key)) {
$content = $this->removeExistingServer($content, $key);
}
$trimmed = rtrim($content);
$separator = $trimmed === '' ? '' : PHP_EOL.PHP_EOL;
$content = $trimmed.$separator.$this->buildServerToml($key, $config).PHP_EOL;
}
return $this->writeFile($content);
}
/** @param array<string, mixed> $config */
protected function buildServerToml(string $key, array $config): string
{
$lines = [];
$lines[] = "[{$this->configKey}.{$key}]";
foreach ($config as $field => $value) {
if ($field === 'env' && is_array($value)) {
continue;
}
$lines[] = "{$field} = ".$this->formatValue($value);
}
if (isset($config['env']) && is_array($config['env']) && $config['env'] !== []) {
$lines[] = '';
$lines[] = "[{$this->configKey}.{$key}.env]";
foreach ($config['env'] as $envKey => $envValue) {
$lines[] = "{$envKey} = ".$this->formatValue($envValue);
}
}
return implode(PHP_EOL, $lines);
}
protected function formatValue(mixed $value): string
{
if (is_string($value)) {
return '"'.$this->escapeTomlString($value).'"';
}
if (is_array($value)) {
$items = array_map($this->formatValue(...), $value);
return '['.implode(', ', $items).']';
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
return (string) $value;
}
protected function escapeTomlString(string $value): string
{
return strtr($value, [
'\\' => '\\\\',
'"' => '\\"',
"\n" => '\\n',
"\r" => '\\r',
"\t" => '\\t',
]);
}
protected function serverExists(string $content, string $key): bool
{
$pattern = '/^\['.preg_quote($this->configKey, '/').'\.'.preg_quote($key, '/').'\]/m';
return (bool) preg_match($pattern, $content);
}
protected function removeExistingServer(string $content, string $key): string
{
$escapedConfigKey = preg_quote($this->configKey, '/');
$escapedKey = preg_quote($key, '/');
$envPattern = '/(\r?\n)*\['.$escapedConfigKey.'\.'.$escapedKey.'\.env\].*?(?=\r?\n\[|$)/s';
$content = preg_replace($envPattern, '', $content) ?? $content;
$mainPattern = '/(\r?\n)*\['.$escapedConfigKey.'\.'.$escapedKey.'\].*?(?=\r?\n\[|$)/s';
return preg_replace($mainPattern, '', $content) ?? $content;
}
protected function shouldWriteNew(): bool
{
if (! File::exists($this->filePath)) {
return true;
}
return File::size($this->filePath) < 3;
}
protected function writeFile(string $content): bool
{
return File::put($this->filePath, $content) !== false;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install;
use Laravel\Boost\Contracts\SupportsMcp;
use RuntimeException;
class McpWriter
{
public const SUCCESS = 0;
public function __construct(protected SupportsMcp $agent)
{
//
}
public function write(?Sail $sail = null, ?Nightwatch $nightwatch = null): int
{
$this->installBoostMcp($sail);
if ($nightwatch instanceof Nightwatch) {
$this->installNightwatchMcp($nightwatch);
}
return self::SUCCESS;
}
protected function installBoostMcp(?Sail $sail): void
{
$mcp = $this->buildBoostMcpCommand($sail);
if (! $this->agent->installMcp($mcp['key'], $mcp['command'], $mcp['args'])) {
throw new RuntimeException('Failed to install Boost MCP: could not write configuration');
}
}
/**
* @return array{key: string, command: string, args: array<int, string>}
*/
protected function buildBoostMcpCommand(?Sail $sail): array
{
if ($sail instanceof Sail) {
return $sail->buildMcpCommand('laravel-boost');
}
if ($this->isRunningInsideWsl()) {
return [
'key' => 'laravel-boost',
'command' => 'wsl.exe',
'args' => [$this->agent->getPhpPath(true), $this->agent->getArtisanPath(true), 'boost:mcp'],
];
}
return [
'key' => 'laravel-boost',
'command' => $this->agent->getPhpPath(),
'args' => [$this->agent->getArtisanPath(), 'boost:mcp'],
];
}
private function isRunningInsideWsl(): bool
{
return ! empty(getenv('WSL_DISTRO_NAME')) || ! empty(getenv('IS_WSL'));
}
protected function installNightwatchMcp(Nightwatch $nightwatch): void
{
if (! $this->agent->installHttpMcp('nightwatch', $nightwatch->mcpUrl())) {
throw new RuntimeException('Failed to install Nightwatch MCP: could not write configuration');
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install;
use Laravel\Boost\Support\Composer;
class Nightwatch
{
const MCP_URL = 'https://nightwatch.laravel.com/mcp';
public function isInstalled(): bool
{
return array_key_exists('laravel/nightwatch', Composer::packages());
}
public function mcpUrl(): string
{
return self::MCP_URL;
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install;
use const DIRECTORY_SEPARATOR;
class Sail
{
public const DEFAULT_BINARY_PATH = 'vendor'.DIRECTORY_SEPARATOR.'bin'.DIRECTORY_SEPARATOR.'sail';
public static function artisanCommand(): string
{
return self::command('artisan');
}
public static function binCommand(): string
{
return self::command('bin ');
}
public static function composerCommand(): string
{
return self::command('composer');
}
public static function nodePackageManagerCommand(string $manager): string
{
return self::command($manager);
}
public static function command(string $command): string
{
return self::binaryPath().' '.$command;
}
public static function binaryPath(): string
{
return config('boost.executable_paths.sail') ?? self::DEFAULT_BINARY_PATH;
}
public function isInstalled(): bool
{
$binaryPath = self::binaryPath();
$resolvedPath = str_starts_with($binaryPath, DIRECTORY_SEPARATOR) ? $binaryPath : base_path($binaryPath);
return file_exists($resolvedPath) &&
(file_exists(base_path('docker-compose.yml')) || file_exists(base_path('compose.yaml')));
}
public function isActive(): bool
{
if ($this->isRunningInDevcontainer()) {
return false;
}
return get_current_user() === 'sail' || getenv('LARAVEL_SAIL') === '1';
}
public function isRunningInDevcontainer(): bool
{
return getenv('REMOTE_CONTAINERS') === 'true';
}
/**
* @return array{key: string, command: string, args: array<int, string>}
*/
public function buildMcpCommand(string $serverName): array
{
return [
'key' => $serverName,
'command' => self::binaryPath(),
'args' => ['artisan', 'boost:mcp'],
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install;
class Skill
{
public function __construct(
public string $name,
public string $package,
public string $path,
public string $description,
public bool $custom = false,
) {}
public function withCustom(bool $custom): self
{
return new self(
name: $this->name,
package: $this->package,
path: $this->path,
description: $this->description,
custom: $custom,
);
}
public function displayName(): string
{
return $this->custom
? '.ai/'.$this->name.'*'
: $this->name;
}
}

View File

@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install;
use Exception;
use Illuminate\Support\Collection;
use Laravel\Boost\Concerns\RendersBladeGuidelines;
use Laravel\Boost\Install\Concerns\DiscoverPackagePaths;
use Laravel\Boost\Support\Composer;
use Laravel\Roster\Package;
use Laravel\Roster\Roster;
use Symfony\Component\Yaml\Yaml;
class SkillComposer
{
use DiscoverPackagePaths;
use RendersBladeGuidelines;
/** @var Collection<string, Skill>|null */
protected ?Collection $skills = null;
public function __construct(protected Roster $roster, protected GuidelineConfig $config = new GuidelineConfig)
{
//
}
protected function getRoster(): Roster
{
return $this->roster;
}
public function config(GuidelineConfig $config): self
{
$this->config = $config;
$this->skills = null;
return $this;
}
/**
* Get all discovered skills (Boost built-in, third-party, and user).
*
* @return Collection<string, Skill>
*/
public function skills(): Collection
{
if ($this->skills instanceof Collection) {
return $this->skills;
}
$excluded = config('boost.skills.exclude', []);
return $this->skills = collect()
->merge($this->getBoostSkills())
->merge($this->getThirdPartySkills())
->reject(fn (Skill $skill, string $key): bool => in_array($key, $excluded, true))
->merge($this->getUserSkills());
}
/**
* @return Collection<string, Skill>
*/
protected function getBoostSkills(): Collection
{
/** @var Collection<string, Skill> $skills */
$skills = $this->getRoster()->packages()
->reject(fn (Package $package): bool => $this->shouldExcludePackage($package))
->collect()
->flatMap(function (Package $package): Collection {
$name = $this->normalizePackageName($package->name());
$vendorSkillPath = $this->resolveFirstPartyBoostPath($package, 'skills');
$vendorSkills = $vendorSkillPath !== null
? $this->discoverSkillsFromDirectory($vendorSkillPath, $name)
: collect();
$aiPath = $this->getBoostAiPath().DIRECTORY_SEPARATOR.$name;
$aiSkills = is_dir($aiPath)
? $this->discoverSkillsFromPath($aiPath, $name, $package->majorVersion())
: collect();
return $aiSkills->merge($vendorSkills);
});
return $skills;
}
/**
* @return Collection<string, Skill>
*/
protected function getThirdPartySkills(): Collection
{
$skills = collect(Composer::packagesDirectoriesWithBoostSkills())
->reject(fn (string $path, string $package): bool => Composer::isFirstPartyPackage($package))
->flatMap(fn (string $path, string $package): Collection => $this->discoverSkillsFromDirectory($path, $package));
if (! isset($this->config->aiGuidelines)) {
return $skills;
}
return $skills->filter(
fn (Skill $skill): bool => in_array($skill->package, $this->config->aiGuidelines, true)
);
}
/**
* @return Collection<string, Skill>
*/
protected function getUserSkills(): Collection
{
return $this->discoverPackageSpecificUserSkills()
->merge($this->discoverExplicitUserSkills());
}
/**
* @return Collection<string, Skill>
*/
protected function discoverExplicitUserSkills(): Collection
{
$path = base_path('.ai/skills');
if (! is_dir($path)) {
return collect();
}
return collect(glob($path.DIRECTORY_SEPARATOR.'*', GLOB_ONLYDIR))
->map(fn (string $skillPath): ?Skill => $this->parseSkill($skillPath, 'user', custom: true))
->filter()
->keyBy(fn (Skill $skill): string => $skill->name);
}
/**
* @return Collection<string, Skill>
*/
protected function discoverPackageSpecificUserSkills(): Collection
{
$userAiPath = base_path('.ai');
if (! is_dir($userAiPath)) {
return collect();
}
return $this->discoverPackagePaths($userAiPath)
->flatMap(fn (array $package): Collection => $this->discoverSkillsFromPath(
$package['path'],
$package['name'],
$package['version']
)->map(fn (Skill $skill): Skill => $skill->withCustom(true)));
}
/**
* @return Collection<string, Skill>
*/
protected function discoverSkillsFromPath(string $packagePath, string $packageName, ?string $version): Collection
{
$rootSkills = $this->discoverSkillsFromDirectory($packagePath.DIRECTORY_SEPARATOR.'skill', $packageName);
if ($version === null) {
return $rootSkills;
}
$versionSkills = $this->discoverSkillsFromDirectory($packagePath.DIRECTORY_SEPARATOR.$version.DIRECTORY_SEPARATOR.'skill', $packageName);
return $rootSkills->merge($versionSkills);
}
/**
* @return Collection<string, Skill>
*/
protected function discoverSkillsFromDirectory(string $skillPath, string $packageName): Collection
{
if (! is_dir($skillPath)) {
return collect();
}
return collect(glob($skillPath.DIRECTORY_SEPARATOR.'*', GLOB_ONLYDIR))
->map(fn (string $skillDir): ?Skill => $this->parseSkill($skillDir, $packageName))
->filter()
->keyBy(fn (Skill $skill): string => $skill->name);
}
protected function parseSkill(string $skillPath, string $package = '', bool $custom = false): ?Skill
{
$skillFile = $this->findSkillFile($skillPath);
if ($skillFile === null) {
return null;
}
$content = str_ends_with($skillFile, '.blade.php')
? $this->renderBladeFile($skillFile)
: file_get_contents($skillFile);
if ($content === false || $content === '') {
return null;
}
$frontmatter = $this->parseSkillFrontmatter($content);
if (empty($frontmatter['name']) || empty($frontmatter['description'])) {
return null;
}
return new Skill(
name: $frontmatter['name'],
package: $package ?: $this->determinePackageFromPath($skillPath),
path: $skillPath,
description: $frontmatter['description'],
custom: $custom,
);
}
protected function findSkillFile(string $skillPath): ?string
{
foreach (['SKILL.blade.php', 'SKILL.md'] as $filename) {
$path = $skillPath.DIRECTORY_SEPARATOR.$filename;
if (file_exists($path)) {
return $path;
}
}
return null;
}
/**
* @return array<string, mixed>
*/
protected function parseSkillFrontmatter(string $content): array
{
$content = preg_replace('/^(\s*<!--.*?-->\s*)+/s', '', $content);
if (! preg_match('/^\s*---\s*\n(.*?)\n---\s*\n/s', (string) $content, $matches)) {
return [];
}
try {
return Yaml::parse($matches[1]) ?? [];
} catch (Exception) {
return [];
}
}
protected function determinePackageFromPath(string $skillPath): string
{
$parentDir = basename(dirname($skillPath));
return preg_match('/^\d+(\.\d+)?$/', $parentDir) === 1
? basename(dirname($skillPath, 2))
: $parentDir;
}
protected function getGuidelineAssist(): GuidelineAssist
{
return new GuidelineAssist($this->roster, $this->config);
}
}

View File

@@ -0,0 +1,341 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install;
use FilesystemIterator;
use Illuminate\Support\Collection;
use Laravel\Boost\Concerns\RendersBladeGuidelines;
use Laravel\Boost\Contracts\SupportsSkills;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
class SkillWriter
{
use RendersBladeGuidelines;
public const SUCCESS = 0;
public const UPDATED = 1;
public const FAILED = 2;
public function __construct(protected SupportsSkills $agent)
{
//
}
public function write(Skill $skill): int
{
if (! $this->isValidSkillName($skill->name)) {
throw new RuntimeException("Invalid skill name: {$skill->name}");
}
$targetPath = base_path($this->agent->skillsPath().DIRECTORY_SEPARATOR.$skill->name);
$canonicalPath = base_path('.ai'.DIRECTORY_SEPARATOR.'skills'.DIRECTORY_SEPARATOR.$skill->name);
$existed = $this->pathExists($targetPath);
if (! $skill->custom) {
return $this->writeNonCustomSkill($skill, $targetPath, $canonicalPath, $existed);
}
return $this->writeCustomSkill($skill, $targetPath, $canonicalPath, $existed);
}
protected function writeNonCustomSkill(Skill $skill, string $targetPath, string $canonicalPath, bool $existed): int
{
$canonicalExists = $this->pathExists($canonicalPath);
$needsCanonicalUpdate = $canonicalExists && ! $this->pathsMatch($skill->path, $canonicalPath);
if ($needsCanonicalUpdate && ! $this->copyDirectory($skill->path, $canonicalPath)) {
return self::FAILED;
}
if (! $this->copyDirectory($skill->path, $targetPath)) {
return self::FAILED;
}
return $existed ? self::UPDATED : self::SUCCESS;
}
protected function writeCustomSkill(Skill $skill, string $targetPath, string $canonicalPath, bool $existed): int
{
if (! $this->pathsMatch($skill->path, $canonicalPath) && ! $this->copyDirectory($skill->path, $canonicalPath)) {
return self::FAILED;
}
if (! $this->ensureDirectoryExists(dirname($targetPath))) {
return self::FAILED;
}
if ($this->directoryContainsBladeFiles($canonicalPath)) {
if (! $this->copyDirectory($canonicalPath, $targetPath)) {
return self::FAILED;
}
return $existed ? self::UPDATED : self::SUCCESS;
}
if (! $this->createSymlink($canonicalPath, $targetPath) && ! $this->copyDirectory($skill->path, $targetPath)) {
return self::FAILED;
}
return $existed ? self::UPDATED : self::SUCCESS;
}
protected function pathExists(string $path): bool
{
return is_dir($path) || is_link($path);
}
/**
* @param Collection<string, Skill> $skills
* @return array<string, int>
*/
public function writeAll(Collection $skills): array
{
return $skills
->mapWithKeys(fn (Skill $skill): array => [$skill->name => $this->write($skill)])
->all();
}
/**
* @param Collection<string, Skill> $skills
* @param array<int, string> $previouslyTrackedSkills
* @return array<string, int>
*/
public function sync(Collection $skills, array $previouslyTrackedSkills = []): array
{
$written = $this->writeAll($skills);
$newSkillNames = $skills->keys()->all();
$staleSkillNames = array_values(array_diff($previouslyTrackedSkills, $newSkillNames));
$this->removeStale($staleSkillNames);
return $written;
}
public function remove(string $skillName): bool
{
if (! $this->isValidSkillName($skillName)) {
return false;
}
$targetPath = base_path($this->agent->skillsPath().DIRECTORY_SEPARATOR.$skillName);
if (! $this->pathExists($targetPath)) {
return true;
}
return $this->deleteDirectory($targetPath);
}
/**
* @param array<int, string> $skillNames
* @return array<string, bool>
*/
public function removeStale(array $skillNames): array
{
$results = [];
foreach ($skillNames as $name) {
$results[$name] = $this->remove($name);
}
return $results;
}
protected function deleteDirectory(string $path): bool
{
if (is_link($path)) {
if (@unlink($path)) {
return true;
}
// On Windows, directory symlinks can require rmdir instead of unlink,
// even when the symlink target no longer exists (dangling symlinks).
if (@rmdir($path)) {
return true;
}
return ! file_exists($path) && ! is_link($path);
}
if (is_file($path)) {
return @unlink($path);
}
if (! is_dir($path)) {
return false;
}
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $file) {
if ($file->isLink()) {
$linkPath = $file->getPathname();
if (! @unlink($linkPath) && is_dir($linkPath)) {
@rmdir($linkPath);
}
continue;
}
$file->isDir() ? @rmdir($file->getPathname()) : @unlink($file->getPathname());
}
return @rmdir($path) || ! is_dir($path);
}
protected function copyDirectory(string $source, string $target): bool
{
if (! is_dir($source)) {
return false;
}
$this->deleteDirectory($target);
if (! $this->ensureDirectoryExists($target)) {
throw new RuntimeException("Failed to create directory: {$target}");
}
$finder = Finder::create()
->files()
->in($source)
->ignoreDotFiles(false);
foreach ($finder as $file) {
if (! $this->copyFile($file, $target)) {
return false;
}
}
return true;
}
protected function copyFile(SplFileInfo $file, string $targetDir): bool
{
$relativePath = $file->getRelativePathname();
$targetFile = $targetDir.DIRECTORY_SEPARATOR.$relativePath;
if (! $this->ensureDirectoryExists(dirname($targetFile))) {
return false;
}
$isBladeFile = str_ends_with($relativePath, '.blade.php');
$isMarkdownFile = str_ends_with($relativePath, '.md');
if ($isBladeFile) {
$content = MarkdownFormatter::format(trim($this->renderBladeFile($file->getRealPath())));
$replacedTargetFile = preg_replace('/\.blade\.php$/', '.md', $targetFile);
if ($replacedTargetFile === null) {
$replacedTargetFile = substr($targetFile, 0, -10).'.md';
}
return file_put_contents($replacedTargetFile, $content) !== false;
}
if ($isMarkdownFile) {
$content = MarkdownFormatter::format(trim(file_get_contents($file->getRealPath())));
return file_put_contents($targetFile, $content) !== false;
}
return @copy($file->getRealPath(), $targetFile);
}
protected function ensureDirectoryExists(string $path): bool
{
return is_dir($path) || @mkdir($path, 0755, true);
}
protected function createSymlink(string $target, string $link): bool
{
$resolvedTarget = realpath($target) ?: $target;
$resolvedLink = realpath($link) ?: $link;
if ($this->pathsMatch($resolvedTarget, $resolvedLink)) {
return true;
}
if (file_exists($link) || is_link($link)) {
$this->deleteDirectory($link);
}
if (! $this->ensureDirectoryExists(dirname($link))) {
return false;
}
return @symlink($this->relativePath($resolvedTarget, dirname($link)), $link);
}
protected function pathsMatch(string $left, string $right): bool
{
$resolvedLeft = realpath($left) ?: $left;
$resolvedRight = realpath($right) ?: $right;
return rtrim($resolvedLeft, DIRECTORY_SEPARATOR) === rtrim($resolvedRight, DIRECTORY_SEPARATOR);
}
protected function relativePath(string $target, string $from): string
{
$resolvedTarget = str_replace('\\', '/', realpath($target) ?: $target);
$resolvedFrom = str_replace('\\', '/', realpath($from) ?: $from);
$targetSegments = explode('/', $resolvedTarget);
$fromSegments = explode('/', $resolvedFrom);
$commonDepth = 0;
$maxSharedDepth = min(count($targetSegments), count($fromSegments));
while ($commonDepth < $maxSharedDepth && $targetSegments[$commonDepth] === $fromSegments[$commonDepth]) {
$commonDepth++;
}
if ($commonDepth === 0) {
return $resolvedTarget;
}
$traversalsUp = count($fromSegments) - $commonDepth;
$remainingTarget = array_slice($targetSegments, $commonDepth);
return str_repeat('../', $traversalsUp).implode('/', $remainingTarget);
}
protected function directoryContainsBladeFiles(string $path): bool
{
if (! is_dir($path)) {
return false;
}
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS)
);
foreach ($files as $file) {
if ($file->isFile() && str_ends_with((string) $file->getFilename(), '.blade.php')) {
return true;
}
}
return false;
}
protected function isValidSkillName(string $name): bool
{
$hasPathTraversal = str_contains($name, '..') || str_contains($name, '/') || str_contains($name, '\\');
return ! $hasPathTraversal && trim($name) !== '';
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Install;
use Illuminate\Support\Collection;
use Laravel\Boost\Support\Composer;
class ThirdPartyPackage
{
public function __construct(
public readonly string $name,
public readonly bool $hasGuidelines,
public readonly bool $hasSkills,
) {
//
}
/**
* Discover all third-party packages with boost features.
*
* @return Collection<string, ThirdPartyPackage>
*/
public static function discover(): Collection
{
$withGuidelines = Composer::packagesDirectoriesWithBoostGuidelines();
$withSkills = Composer::packagesDirectoriesWithBoostSkills();
$allPackageNames = array_unique(array_merge(
array_keys($withGuidelines),
array_keys($withSkills)
));
return collect($allPackageNames)
->reject(fn (string $name): bool => Composer::isFirstPartyPackage($name))
->mapWithKeys(fn (string $name): array => [
$name => new self(
name: $name,
hasGuidelines: isset($withGuidelines[$name]),
hasSkills: isset($withSkills[$name]),
),
]);
}
public function featureLabel(): string
{
return match (true) {
$this->hasGuidelines && $this->hasSkills => 'guidelines, skills',
$this->hasGuidelines => 'guideline',
$this->hasSkills => 'skills',
default => '',
};
}
public function displayLabel(): string
{
return "{$this->name} ({$this->featureLabel()})";
}
}

143
vendor/laravel/boost/src/Mcp/Boost.php vendored Normal file
View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp;
use Laravel\Boost\Mcp\Methods\CallToolWithExecutor;
use Laravel\Boost\Mcp\Prompts\LaravelCodeSimplifier\LaravelCodeSimplifier;
use Laravel\Boost\Mcp\Prompts\UpgradeInertiav3\UpgradeInertiaV3;
use Laravel\Boost\Mcp\Prompts\UpgradeLaravelv13\UpgradeLaravelV13;
use Laravel\Boost\Mcp\Prompts\UpgradeLivewirev4\UpgradeLivewireV4;
use Laravel\Boost\Mcp\Tools\ApplicationInfo;
use Laravel\Boost\Mcp\Tools\BrowserLogs;
use Laravel\Boost\Mcp\Tools\DatabaseConnections;
use Laravel\Boost\Mcp\Tools\DatabaseQuery;
use Laravel\Boost\Mcp\Tools\DatabaseSchema;
use Laravel\Boost\Mcp\Tools\GetAbsoluteUrl;
use Laravel\Boost\Mcp\Tools\LastError;
use Laravel\Boost\Mcp\Tools\ReadLogEntries;
use Laravel\Boost\Mcp\Tools\SearchDocs;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\Tool;
class Boost extends Server
{
/**
* The MCP server's name.
*/
protected string $name = 'Laravel Boost';
/**
* The MCP server's version.
*/
protected string $version = '0.0.1';
/**
* The MCP server's instructions for the LLM.
*/
protected string $instructions = 'Laravel ecosystem MCP server offering database schema access, error logs, semantic documentation search and more. Boost helps with code generation.';
/**
* The default pagination length for resources that support pagination.
*/
public int $defaultPaginationLength = 50;
/**
* The tools registered with this MCP server.
*
* @var array<int, class-string<Tool>>
*/
protected array $tools = [];
/**
* The resources registered with this MCP server.
*
* @var array<int, class-string<Resource>>
*/
protected array $resources = [];
/**
* The prompts registered with this MCP server.
*
* @var array<int, class-string<Prompt>>
*/
protected array $prompts = [];
protected function boot(): void
{
$this->tools = $this->discoverTools();
$this->resources = $this->discoverResources();
$this->prompts = $this->discoverPrompts();
// Override the tools/call method to use our ToolExecutor
$this->methods['tools/call'] = CallToolWithExecutor::class;
}
/**
* @return array<int, class-string<Tool>>
*/
protected function discoverTools(): array
{
return $this->filterPrimitives([
ApplicationInfo::class,
BrowserLogs::class,
DatabaseConnections::class,
DatabaseQuery::class,
DatabaseSchema::class,
GetAbsoluteUrl::class,
LastError::class,
ReadLogEntries::class,
SearchDocs::class,
], 'tools');
}
/**
* @return array<int, class-string<Resource>>
*/
protected function discoverResources(): array
{
return $this->filterPrimitives([
Resources\ApplicationInfo::class,
], 'resources');
}
/**
* @return array<int, class-string<Prompt>>
*/
protected function discoverPrompts(): array
{
return $this->filterPrimitives([
LaravelCodeSimplifier::class,
UpgradeInertiaV3::class,
UpgradeLaravelV13::class,
UpgradeLivewireV4::class,
], 'prompts');
}
/**
* @param array<int, Tool|Resource|Prompt|class-string> $availablePrimitives
* @return array<int, Tool|Resource|Prompt|class-string>
*/
private function filterPrimitives(array $availablePrimitives, string $type): array
{
$excludeList = config("boost.mcp.{$type}.exclude", []);
$includeList = config("boost.mcp.{$type}.include", []);
$filtered = collect($availablePrimitives)->reject(function (string|object $item) use ($excludeList): bool {
$className = is_string($item) ? $item : $item::class;
return in_array($className, $excludeList, true);
});
$explicitlyIncluded = collect($includeList)
->filter(fn (string $class): bool => class_exists($class));
return $filtered
->merge($explicitlyIncluded)
->values()
->all();
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Methods;
use Laravel\Boost\Mcp\ToolExecutor;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Contracts\Errable;
use Laravel\Mcp\Server\Contracts\Method;
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
use Laravel\Mcp\Server\Methods\Concerns\InteractsWithResponses;
use Laravel\Mcp\Server\ServerContext;
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
use Throwable;
class CallToolWithExecutor implements Errable, Method
{
use InteractsWithResponses;
public function __construct(protected ToolExecutor $executor)
{
//
}
/**
* Handle the JSON-RPC tool/call request with process isolation.
*/
public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse
{
if (is_null($request->get('name'))) {
throw new JsonRpcException(
'Missing [name] parameter.',
-32602,
$request->id,
);
}
$tool = $context
->tools()
->first(
fn ($tool): bool => $tool->name() === $request->params['name'],
fn () => throw new JsonRpcException(
"Tool [{$request->params['name']}] not found.",
-32602,
$request->id,
));
$arguments = [];
if (isset($request->params['arguments']) && is_array($request->params['arguments'])) {
$arguments = $request->params['arguments'];
}
try {
$response = $this->executor->execute($tool::class, $arguments);
} catch (Throwable $throwable) {
$response = Response::error('Tool execution error: '.$throwable->getMessage());
}
return $this->toJsonRpcResponse($request, $response, fn ($responseFactory): array => [
'content' => $responseFactory->responses()->map(fn ($response) => $response->content()->toTool($tool))->all(),
'isError' => $responseFactory->responses()->contains(fn ($response) => $response->isError()),
]);
}
}

View File

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Prompts\LaravelCodeSimplifier;
use Laravel\Boost\Concerns\RendersBladeGuidelines;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Prompt;
class LaravelCodeSimplifier extends Prompt
{
use RendersBladeGuidelines;
protected string $name = 'laravel-code-simplifier';
protected string $title = 'laravel_code_simplifier';
protected string $description = 'Simplifies and refines PHP/Laravel code for clarity, consistency, and maintainability while preserving all functionality. Focuses on recently modified code unless instructed otherwise.';
public function handle(): Response
{
$content = $this->renderBladeFile(__DIR__.'/laravel-code-simplifier.blade.php');
return Response::text($content);
}
}

View File

@@ -0,0 +1,66 @@
# Laravel Code Simplifier
You are an expert PHP/Laravel code simplification specialist focused on enhancing code clarity, consistency, and maintainability while preserving exact functionality. Your expertise lies in applying Laravel best practices and standards to simplify and improve code without altering its behavior. You prioritize readable, explicit code over overly compact solutions. This is a balance that you have mastered as a result of your years as an expert PHP developer.
You will analyze recently modified code using git and apply refinements that:
## 1. Preserve Functionality
Never change what the code does - only how it does it. All original features, outputs, and behaviors must remain intact.
## 2. Apply Project Standards
Follow established project coding standards including:
- Use proper namespace declarations and organize imports logically
- Prefer explicit return type declarations on methods
- Follow Laravel conventions for controllers, models, and services
- Use proper error handling patterns (exceptions, custom exception classes)
- Maintain consistent naming conventions (PSR-12, Laravel standards)
## 3. Enhance Clarity
Simplify the code structure by:
- Reducing unnecessary complexity and nesting
- Eliminating redundant code and abstractions
- Improving readability through clear variable and function names
- Consolidating related logic
- Removing unnecessary comments that describe obvious code
- **IMPORTANT**: Avoid nested ternary operators - prefer match expressions, switch statements, or if/else chains for multiple conditions
- Choose clarity over brevity - explicit code is often better than overly compact code
## 4. Maintain Balance
Avoid oversimplification that could:
- Reduce code clarity or maintainability
- Create overly clever solutions that are hard to understand
- Combine too many concerns into single methods or classes
- Remove helpful abstractions that improve code organization
- Prioritize "fewer lines" over readability (e.g., nested ternaries, dense one-liners)
- Make the code harder to debug or extend
## 5. Focus Scope
Only refine code that has been recently modified or touched in the current session, unless explicitly instructed to review a broader scope.
## Refinement Process
1. Identify the recently modified code sections
2. Analyze for opportunities to improve elegance and consistency
3. Apply project-specific best practices and coding standards
4. Ensure all functionality remains unchanged
5. Verify the refined code is simpler and more maintainable
6. Document only significant changes that affect understanding
## Execution Strategy
When multiple files need refinement, maximize efficiency by:
- **Spin up background agents in parallel** to process independent files simultaneously
- Each agent should handle a separate file or logical unit of work
- Coordinate results to ensure consistency across related files
- Use as many concurrent agents as possible when files don't have dependencies on each other
You operate autonomously and proactively, refining code immediately after it's written or modified without requiring explicit requests. Your goal is to ensure all code meets the highest standards of elegance and maintainability while preserving its complete functionality.

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Prompts\UpgradeInertiav3;
use Laravel\Boost\Concerns\RendersBladeGuidelines;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Prompt;
use Laravel\Roster\Enums\Packages;
use Laravel\Roster\Roster;
class UpgradeInertiaV3 extends Prompt
{
use RendersBladeGuidelines;
protected string $name = 'upgrade-inertia-v3';
protected string $title = 'upgrade_inertia_v3';
protected string $description = 'Provides step-by-step guidance for upgrading from Inertia v2 to v3.';
public function shouldRegister(Roster $roster): bool
{
if ($roster->uses(Packages::INERTIA_LARAVEL)) {
return true;
}
if ($roster->uses(Packages::INERTIA_REACT)) {
return true;
}
if ($roster->uses(Packages::INERTIA_VUE)) {
return true;
}
return $roster->uses(Packages::INERTIA_SVELTE);
}
public function handle(): Response
{
$roster = $this->getGuidelineAssist()->roster;
$content = $this->renderBladeFile(__DIR__.'/upgrade-inertia-v3.blade.php', [
'usesReact' => $roster->uses(Packages::INERTIA_REACT),
'usesVue' => $roster->uses(Packages::INERTIA_VUE),
'usesSvelte' => $roster->uses(Packages::INERTIA_SVELTE),
]);
return Response::text($content);
}
}

View File

@@ -0,0 +1,408 @@
# Inertia v2 to v3 Upgrade Specialist
You are an expert Inertia upgrade specialist with deep knowledge of both Inertia v2 and v3. Your task is to systematically upgrade the application from Inertia v2 to v3 while ensuring all functionality remains intact. You understand the nuances of breaking changes and can identify affected code patterns with precision.
## Core Principle: Documentation-First Approach
**IMPORTANT:** Always use the `search-docs` tool whenever you need:
- Specific code examples for implementing Inertia v3 features
- Clarification on breaking changes or new syntax
- Verification of upgrade patterns before applying them
- Examples of correct usage for new directives or methods
The official Inertia documentation is your primary source of truth. Consult it before making assumptions or implementing changes.
## Upgrade Process
Follow this systematic process to upgrade the application:
### 1. Assess Current State
Before making any changes:
- Check `composer.json` for the current `inertiajs/inertia-laravel` version constraint
- Check `package.json` for the current `@inertiajs/*` adapter version
- Run `{{ $assist->composerCommand('show inertiajs/inertia-laravel') }}` to confirm installed server version
- Identify all Inertia pages in `{{ $assist->inertia()->pagesDirectory() }}`
- Review `config/inertia.php` for current configuration
- Review your Vite and SSR setup if the application server-renders Inertia pages
### 2. Create Safety Net
- Ensure you're working on a dedicated branch
- Run the existing test suite to establish baseline
- Note any components with complex JavaScript interactions
### 3. Analyze Codebase for Breaking Changes
Search the codebase for patterns affected by v3 changes:
**High Priority Searches:**
- `router.on('invalid'` or `inertia:invalid` - Rename to `httpException`
- `router.on('exception'` or `inertia:exception` - Rename to `networkError`
- `router.cancel(` - Renamed to `router.cancelAll()`
- `defaults: { future` or `future: {` - The `future` namespace has been removed
- `hideProgress(` or `revealProgress(` - Use the `progress` object instead
- `Inertia::lazy(` or `LazyProp` - Replace with `Inertia::optional()`
- `config/inertia.php` - Configuration structure has changed
**Medium Priority Searches:**
- `qs` imports - Install `qs` directly if the application uses it
- `lodash-es` imports - Install `lodash-es` directly if the application uses it
- `axios` imports or interceptors - Decide whether the app should keep Axios or rely on Inertia's built-in HTTP client
- `Inertia\\Testing\\Concerns\\Has`, `Matching`, or `Debugging` - Deprecated traits removed in v3
- `require(` in frontend code - Inertia packages are now ESM-only
@if($usesReact)
- `import { Deferred }` - React deferred partial reload behavior changed
@endif
@if($usesSvelte)
- Non-runes Svelte components - Update to Svelte 5 runes syntax (`$props()`, `$state()`, `$effect()`, etc.)
@endif
**Low Priority Searches:**
- `vite build --ssr` or `inertia:start-ssr` in development scripts - Dev SSR flow changed when using `@inertiajs/vite`
- `only`, `except`, `Deferred`, or `WhenVisible` with nested props - Dot notation support improved
- `clearHistory` or `encryptHistory` - These page object keys are now omitted unless `true`
### 4. Apply Changes Systematically
For each category of changes:
1. **Search** for affected patterns using grep/search tools
2. **Consult documentation** - Use `search-docs` tool to verify correct upgrade patterns and examples
3. **List** all files that need modification
4. **Apply** the fix consistently across all occurrences
5. **Verify** each change doesn't break functionality
### 5. Update Dependencies
After code changes are complete:
- `{{ $assist->composerCommand('require inertiajs/inertia-laravel:^3.0') }}`
@if($usesReact)
- `{{ $assist->nodePackageManagerCommand('install @inertiajs/react@^3.0') }}`
@endif
@if($usesVue)
- `{{ $assist->nodePackageManagerCommand('install @inertiajs/vue3@^3.0') }}`
@endif
@if($usesSvelte)
- `{{ $assist->nodePackageManagerCommand('install @inertiajs/svelte@^3.0') }}`
@endif
- `{{ $assist->nodePackageManagerCommand('install @inertiajs/vite@^3.0') }}`
- `{{ $assist->artisanCommand('vendor:publish --provider="Inertia\\\\ServiceProvider" --force') }}`
- `{{ $assist->artisanCommand('view:clear') }}`
### 6. Test and Verify
- Run the full test suite
- Manually test critical user flows
- Check browser console for JavaScript errors
- Verify error handling, deferred props, and form submission flows still behave correctly
## Execution Strategy
When upgrading, maximize efficiency by:
- **Batch similar changes** - Group all config updates, then all routing updates, etc.
- **Use parallel agents** for independent file modifications
- **Prioritize high-impact changes** that could cause immediate failures
- **Test incrementally** - Verify after each category of changes
## Important Notes
- Inertia v3 requires PHP 8.2+, Laravel 11+, and Node 20+
@if($usesReact)
- React users must upgrade to React 19+
@endif
@if($usesSvelte)
- Svelte users must upgrade to Svelte 5+ and update components to Svelte 5 runes syntax
@endif
- Axios removal usually does not require code changes
- If the application imports `qs`, install it directly instead of rewriting query handling blindly
- After upgrading, republish the config file and clear cached views because the `@inertia` Blade directive output changed
---
# Upgrading from v2 to v3
Inertia v3 introduces significant improvements including removal of legacy dependencies, streamlined configuration, and better developer experience. This guide covers all breaking changes and migration steps.
## Requirements
Before upgrading, ensure your environment meets these minimum requirements:
- PHP 8.2+
- Laravel 11+
- Node 20+
@if($usesReact)
- React 19+
@endif
@if($usesSvelte)
- Svelte 5+ with Svelte 5 runes syntax (`$props()`, `$state()`, `$effect()`, etc.)
@endif
## Installation
Update your server-side adapter by running `{{ $assist->composerCommand('require inertiajs/inertia-laravel:^3.0') }}`.
Update your client-side adapter:
@if($usesReact)
- `{{ $assist->nodePackageManagerCommand('install @inertiajs/react@^3.0') }}`
@endif
@if($usesVue)
- `{{ $assist->nodePackageManagerCommand('install @inertiajs/vue3@^3.0') }}`
@endif
@if($usesSvelte)
- `{{ $assist->nodePackageManagerCommand('install @inertiajs/svelte@^3.0') }}`
@endif
You may also install the optional Vite plugin, which simplifies page resolution and SSR configuration:
- `{{ $assist->nodePackageManagerCommand('install @inertiajs/vite@^3.0') }}`
After updating, republish the config and clear caches:
- `{{ $assist->artisanCommand('vendor:publish --provider="Inertia\\\\ServiceProvider" --force') }}`
- `{{ $assist->artisanCommand('view:clear') }}`
## High-impact changes
These changes are most likely to affect your application and should be reviewed carefully.
### Axios removed
Inertia v3 no longer ships with or requires Axios. For most applications, this requires no changes. The built-in HTTP client still supports interceptors, and applications that use Axios directly may keep Axios by installing it themselves or by using the Axios adapter.
- `{{ $assist->nodePackageManagerCommand('install axios') }}`
### `qs` dependency removed
The `qs` package is no longer bundled with `@inertiajs/core`. Inertia still handles its own query strings internally, but you should install `qs` directly if your application imports it.
- `{{ $assist->nodePackageManagerCommand('install qs') }}`
### `lodash-es` dependency removed
The `lodash-es` package has been replaced with `es-toolkit` and is no longer included as a dependency of `@inertiajs/core`. You should install `lodash-es` directly if your application imports it.
- `{{ $assist->nodePackageManagerCommand('install lodash-es') }}`
### Event renames
Two global events have been renamed for clarity:
@boostsnippet('Global Event Renames', 'js')
// Before (v2)
router.on('invalid', (event) => {})
router.on('exception', (event) => {})
// After (v3)
router.on('httpException', (event) => {})
router.on('networkError', (event) => {})
@endboostsnippet
If you use document-level event listeners, update the event names accordingly (e.g. `document.addEventListener('inertia:httpException', ...)`).
You may also handle these events per-visit using the new `onHttpException` and `onNetworkError` callbacks:
@boostsnippet('Per-Visit Event Callbacks', 'js')
router.post('/users', data, {
onHttpException: (response) => {
return false
},
onNetworkError: (error) => {},
})
@endboostsnippet
Returning `false` from `onHttpException` or calling `event.preventDefault()` on the global `httpException` event keeps Inertia from navigating away to its error page.
### `router.cancel()` renamed to `router.cancelAll()`
@boostsnippet('Cancel Rename', 'js')
// Before (v2)
router.cancel()
// After (v3)
router.cancelAll()
router.cancelAll({ async: false, prefetch: false })
@endboostsnippet
### Future options removed
The `future` configuration namespace has been removed. The four v2 future options are now always enabled and can no longer be configured:
@boostsnippet('Future Options Removed', 'js')
// Before (v2)
createInertiaApp({
defaults: {
future: {
preserveEqualProps: true,
useDataInertiaHeadAttribute: true,
useDialogForErrorModal: true,
useScriptElementForInitialPage: true,
},
},
})
// After (v3)
createInertiaApp({
// ...
})
@endboostsnippet
Initial page data is now always passed through a `<script type="application/json">` element. The old `data-page` attribute approach is no longer supported.
### Progress exports removed
The named exports `hideProgress()` and `revealProgress()` have been removed. If you need programmatic control, use the adapter's exported `progress` object instead.
@if($usesReact)
@boostsnippet('Progress Exports React', 'js')
import { progress } from '@inertiajs/react'
progress.hide()
progress.reveal()
@endboostsnippet
@endif
@if($usesVue)
@boostsnippet('Progress Exports Vue', 'js')
import { progress } from '@inertiajs/vue3'
progress.hide()
progress.reveal()
@endboostsnippet
@endif
@if($usesSvelte)
@boostsnippet('Progress Exports Svelte', 'js')
import { progress } from '@inertiajs/svelte'
progress.hide()
progress.reveal()
@endboostsnippet
@endif
### `LazyProp` removed
The deprecated `Inertia::lazy()` method and `LazyProp` class have been removed. Use `Inertia::optional()` instead:
@boostsnippet('LazyProp Migration', 'php')
// Before (v2)
return Inertia::render('Users/Index', [
'users' => Inertia::lazy(fn () => User::all()),
]);
// After (v3)
return Inertia::render('Users/Index', [
'users' => Inertia::optional(fn () => User::all()),
]);
@endboostsnippet
## Medium-impact changes
### Config restructuring
The `config/inertia.php` file structure has changed. After upgrading, republish it with `{{ $assist->artisanCommand('vendor:publish --provider="Inertia\\\\ServiceProvider" --force') }}` and then re-apply any customizations on top of the new structure.
@boostsnippet('Config Restructuring', 'php')
// Before (v2) - config/inertia.php
'testing' => [
'ensure_pages_exist' => true,
'page_paths' => [resource_path('js/Pages')],
'page_extensions' => ['js', 'jsx', 'svelte', 'ts', 'tsx', 'vue'],
],
// After (v3) - config/inertia.php
'pages' => [
'ensure_pages_exist' => false,
'paths' => [resource_path('js/Pages')],
'extensions' => ['js', 'jsx', 'svelte', 'ts', 'tsx', 'vue'],
],
'testing' => [
'ensure_pages_exist' => true,
],
@endboostsnippet
@if($usesReact)
### `Deferred` component behavior (React)
The React `<Deferred>` component no longer resets to its fallback during partial reloads. Existing content now stays visible while new data loads, which matches the Vue and Svelte behavior. A `reloading` slot prop is available when you want to show loading state during those partial reloads.
@endif
### Form `processing` reset timing
The `useForm` helper now resets `processing` and `progress` inside `onFinish`, not immediately when a response arrives. If you depend on the exact timing of `form.processing`, re-test those flows after upgrading.
### Testing concerns removed
The deprecated `Inertia\Testing\Concerns\Has`, `Matching`, and `Debugging` traits have been removed. They were replaced long ago by `AssertableInertia`, so no action is required unless your application still references those traits directly.
## Other changes
### Blade components
Inertia now provides `<x-inertia::head>` and `<x-inertia::app>` Blade components as an alternative to the `@inertiaHead` and `@inertia` directives. The head component accepts fallback content via its slot that only renders when SSR is not active, solving the long-standing issue of duplicate `<title>` tags in SSR applications. The existing directives continue to work and require no changes.
### ES2022 build target
Inertia packages now target ES2022, up from ES2020 in v2. You may use the `@vitejs/plugin-legacy` Vite plugin if your application needs to support older browsers.
### Optional Vite plugin
The new `@inertiajs/vite` plugin can simplify component resolution and SSR configuration. If you adopt it, review the official examples before changing your `createInertiaApp()` bootstrap.
### SSR in development
When using `@inertiajs/vite`, SSR now works in development by simply running your normal Vite dev server. You no longer need `vite build --ssr` or `php artisan inertia:start-ssr` during development.
### Middleware priority
The Inertia middleware is now automatically registered at the correct priority, so no manual middleware-priority customization is required.
### Nested prop types
Nested `Inertia::optional()`, `Inertia::defer()`, and `Inertia::merge()` values now resolve correctly inside closures and nested arrays. On the client side, `only`, `except`, `Deferred`, and `WhenVisible` support dot-notation paths for nested props.
@boostsnippet('Nested Prop Types', 'php')
return Inertia::render('Dashboard', [
'auth' => fn () => [
'user' => Auth::user(),
'notifications' => Inertia::defer(fn () => Auth::user()->unreadNotifications),
'invoices' => Inertia::optional(fn () => Auth::user()->invoices),
],
]);
@endboostsnippet
### ESM-only
All Inertia packages are now ESM-only. Replace any CommonJS `require()` imports with `import` statements.
### Page object changes
The `clearHistory` and `encryptHistory` keys are now omitted from the page object unless they are `true`. If you inspect raw page payloads in custom integrations or tests, update those expectations.
## Next steps: New features in v3
After completing the upgrade, the following new features are available. Do **not** refactor existing code to adopt these features as part of the upgrade. Just complete the breaking changes above. These are listed as next steps so you can explore them separately.
- **Standalone HTTP requests (`useHttp`)** - Make HTTP requests without triggering page visits. Supports reactive state, error handling, file upload progress, request cancellation, optimistic updates, and precognition.
- **Optimistic updates** - Chain `router.optimistic()` before a visit to apply changes instantly on the client. Props revert automatically on failure. Works with router visits, `<Form>`, `useForm`, and `useHttp`.
- **Instant visits** - Swap to the target page component immediately via `<Link href="/dashboard" component="Dashboard">` while the server request fires in the background.
- **Layout props (`useLayoutProps`)** - Persistent layouts can declare defaults that pages override via `setLayoutProps()`. Supports named layouts, nested layouts, and static props.
- **Exception handling (`handleExceptionsUsing`)** - Full control over error page rendering with access to shared data via `withSharedData()`.
- **Default layout** - Set a default layout in `createInertiaApp()` instead of on every page.
- **Form component generics** - TypeScript generics for type-safe errors and slot props.
- **Enum support** - Use PHP enums directly in `Inertia::render()` responses.
- **`preserveErrors` option** - Preserve validation errors during partial reloads.
- **Deferred `reloading` prop** - Show loading indicators during partial reloads across all adapters.
Consult the `search-docs` tool for implementation details when you're ready to adopt any of these features.
## Getting help
If you encounter issues during the upgrade:
- Check the [upgrade guide](https://inertiajs.com/docs/v3/getting-started/upgrade-guide) for the latest details
- Visit the [GitHub discussions](https://github.com/inertiajs/inertia/discussions) for community support

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Prompts\UpgradeLaravelv13;
use Laravel\Boost\Concerns\RendersBladeGuidelines;
use Laravel\Boost\Install\Herd;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Prompt;
use Laravel\Roster\Enums\Packages;
use Laravel\Roster\Roster;
class UpgradeLaravelV13 extends Prompt
{
use RendersBladeGuidelines;
protected string $name = 'upgrade-laravel-v13';
protected string $title = 'upgrade_laravel_v13';
protected string $description = 'Provides step-by-step guidance for upgrading from Laravel 12.x to 13.0.';
public function shouldRegister(Roster $roster): bool
{
return $roster->usesVersion(Packages::LARAVEL, '12.0.0', '>=')
&& $roster->usesVersion(Packages::LARAVEL, '13.0.0', '<');
}
public function handle(): Response
{
$content = $this->renderBladeFile(__DIR__.'/upgrade-laravel-v13.blade.php', [
'usesHerd' => app(Herd::class)->isInstalled(),
]);
return Response::text($content);
}
}

View File

@@ -0,0 +1,462 @@
# Laravel 12 to 13 Upgrade Specialist
You are an expert Laravel upgrade specialist with deep knowledge of both Laravel 12.x and 13.0. Your task is to systematically upgrade the application from Laravel 12 to 13 while ensuring all functionality remains intact. You understand the nuances of breaking changes and can identify affected code patterns with precision.
## Core Principle: Documentation-First Approach
**IMPORTANT:** Always use the `search-docs` tool whenever you need:
- Specific code examples for implementing Laravel 13 features
- Clarification on breaking changes or new behavior
- Verification of upgrade patterns before applying them
- Examples of correct usage for renamed classes or methods
The official Laravel documentation is your primary source of truth. Consult it before making assumptions or implementing changes.
## Upgrade Process
Follow this systematic process to upgrade the application:
### 1. Assess Current State
Before making any changes:
- Check `composer.json` for the current Laravel version constraint
- Run `{{ $assist->composerCommand('show laravel/framework') }}` to confirm installed version
- Identify middleware references to `VerifyCsrfToken` or `ValidateCsrfToken`
- Review `config/cache.php` for serialization settings
- Review `config/session.php` for cookie name configuration
### 2. Create Safety Net
- Ensure you're working on a dedicated branch
- Run the existing test suite to establish baseline
- Note any custom cache store implementations or queue driver implementations
### 3. Analyze Codebase for Breaking Changes
Search the codebase for patterns affected by v13 changes:
**High Priority Searches:**
- `VerifyCsrfToken` or `ValidateCsrfToken` — Must rename to `PreventRequestForgery`
- `composer.json` — Dependency version constraints to update
- `phpunit.xml` or `pest` config — Test framework version compatibility
**Medium Priority Searches:**
- `config/cache.php` — Check for `serializable_classes` configuration
- Code that stores PHP objects in cache — May need explicit class allow-lists
- `upsert` calls with empty `uniqueBy` — Now throws `InvalidArgumentException`
**Low Priority Searches:**
- `$event->exceptionOccurred` — Renamed to `$event->exception` in `JobAttempted`
- `$event->connection` on `QueueBusy` — Renamed to `$connectionName`
- `pagination::default` or `pagination::simple-default` — View names changed
- `Container::call` with nullable class defaults — Behavior changed
- Manager `extend` callbacks using `$this` — Binding changed
- Custom `Str` factories in tests — Now reset between tests
### 4. Apply Changes Systematically
For each category of changes:
1. **Search** for affected patterns using grep/search tools
2. **Consult documentation** — Use `search-docs` tool to verify correct upgrade patterns and examples
3. **List** all files that need modification
4. **Apply** the fix consistently across all occurrences
5. **Verify** each change doesn't break functionality
### 5. Update Dependencies
After code changes are complete:
```bash
{{ $assist->composerCommand('require laravel/framework:^13.0 --with-all-dependencies') }}
```
### 6. Test and Verify
- Run the full test suite
- Verify CSRF protection still works correctly
- Check cache read/write operations
- Test any queue listeners that reference event properties
## Execution Strategy
When upgrading, maximize efficiency by:
- **Batch similar changes** Group all CSRF middleware renames, then all config updates, etc.
- **Use parallel agents** for independent file modifications
- **Prioritize high-impact changes** that could cause immediate failures
- **Test incrementally** Verify after each category of changes
# Upgrading from Laravel 12.x to 13.0
> [!NOTE]
> We attempt to document every possible breaking change. Since some of these breaking changes are in obscure parts of the framework only a portion of these changes may actually affect your application.
## Updating Dependencies
**Likelihood Of Impact: High**
Update the following dependencies in your application's `composer.json` file:
@boostsnippet('Dependency Updates', 'json')
{
"require": {
"laravel/framework": "^13.0"
},
"require-dev": {
"laravel/tinker": "^3.0",
"phpunit/phpunit": "^12.0",
"pestphp/pest": "^4.0"
}
}
@endboostsnippet
Run the update:
```bash
{{ $assist->composerCommand('update') }}
```
## Updating the Laravel Installer
If you use the Laravel installer CLI tool, update it for Laravel 13.x compatibility:
@if($usesHerd)
```bash
herd laravel:update
```
@else
```bash
{{ $assist->composerCommand('global update laravel/installer') }}
```
@endif
## Cache
### Cache Prefixes and Session Cookie Names
**Likelihood Of Impact: Low**
Laravel's default cache and Redis key prefixes now use hyphenated suffixes. In addition, the default session cookie name now uses `Str::snake(...)` for the application name.
In most applications, this change will not apply because application-level configuration files already define these values. This primarily affects applications that rely on framework-level fallback configuration when corresponding application config values are not present.
If your application relies on these generated defaults, cache keys and session cookie names may change after upgrading:
@boostsnippet('Cache Prefix Changes', 'php')
// Laravel <= 12.x
Str::slug((string) env('APP_NAME', 'laravel'), '_').'_cache_';
Str::slug((string) env('APP_NAME', 'laravel'), '_').'_database_';
Str::slug((string) env('APP_NAME', 'laravel'), '_').'_session';
// Laravel >= 13.x
Str::slug((string) env('APP_NAME', 'laravel')).'-cache-';
Str::slug((string) env('APP_NAME', 'laravel')).'-database-';
Str::snake((string) env('APP_NAME', 'laravel')).'_session';
@endboostsnippet
> [!IMPORTANT]
> To retain previous behavior, explicitly configure `CACHE_PREFIX`, `REDIS_PREFIX`, and `SESSION_COOKIE` in your environment.
### `Store` and `Repository` Contracts: `touch`
**Likelihood Of Impact: Very Low**
The cache contracts now include a `touch` method for extending item TTLs. If you maintain custom cache store implementations, you should add this method:
@boostsnippet('Cache Store Touch', 'php')
// Illuminate\Contracts\Cache\Store
public function touch($key, $seconds);
@endboostsnippet
### Cache `serializable_classes` Configuration
**Likelihood Of Impact: Medium**
The default application `cache` configuration now includes a `serializable_classes` option set to `false`. This hardens cache unserialization behavior to help prevent PHP deserialization gadget chain attacks if your application's `APP_KEY` is leaked. If your application intentionally stores PHP objects in cache, you should explicitly list the classes that may be unserialized:
@boostsnippet('Cache Serializable Classes', 'php')
'serializable_classes' => [
App\Data\CachedDashboardStats::class,
App\Support\CachedPricingSnapshot::class,
],
@endboostsnippet
If your application previously relied on unserializing arbitrary cached objects, you will need to migrate that usage to explicit class allow-lists or to non-object cache payloads (such as arrays).
## Container
### `Container::call` and Nullable Class Defaults
**Likelihood Of Impact: Low**
`Container::call` now respects nullable class parameter defaults when no binding exists, matching constructor injection behavior introduced in Laravel 12:
@boostsnippet('Container Call Nullable', 'php')
$container->call(function (?Carbon $date = null) {
return $date;
});
// Laravel <= 12.x: Carbon instance
// Laravel >= 13.x: null
@endboostsnippet
If your method-call injection logic depended on the previous behavior, you may need to update it.
## Contracts
### `Dispatcher` Contract: `dispatchAfterResponse`
**Likelihood Of Impact: Very Low**
The `Illuminate\Contracts\Bus\Dispatcher` contract now includes the `dispatchAfterResponse($command, $handler = null)` method.
If you maintain a custom dispatcher implementation, add this method to your class.
### `ResponseFactory` Contract: `eventStream`
**Likelihood Of Impact: Very Low**
The `Illuminate\Contracts\Routing\ResponseFactory` contract now includes an `eventStream` signature.
If you maintain a custom implementation of this contract, you should add this method.
### `MustVerifyEmail` Contract: `markEmailAsUnverified`
**Likelihood Of Impact: Very Low**
The `Illuminate\Contracts\Auth\MustVerifyEmail` contract now includes `markEmailAsUnverified()`.
If you provide a custom implementation of this contract, add this method to remain compatible.
## Database
### Database `upsert` With MySQL or MariaDB
**Likelihood Of Impact: Medium**
Laravel now validates that the caller provides a non-empty value for `uniqueBy`, and will throw an `InvalidArgumentException` instead of generating invalid SQL.
Although the MariaDB and MySQL database drivers ignore the `uniqueBy` value and always use the table's primary and unique indexes to detect existing records, the validation still applies. An `InvalidArgumentException` will be thrown if `uniqueBy` is empty.
### MySQL `DELETE` Queries With `JOIN`, `ORDER BY`, and `LIMIT`
**Likelihood Of Impact: Low**
Laravel now compiles full `DELETE ... JOIN` queries including `ORDER BY` and `LIMIT` for MySQL grammar.
In previous versions, `ORDER BY` / `LIMIT` clauses could be silently ignored on joined deletes. In Laravel 13, these clauses are included in the generated SQL. As a result, database engines that do not support this syntax (such as standard MySQL / MariaDB variants) may now throw a `QueryException` instead of executing an unbounded delete.
## Eloquent
### Model Booting and Nested Instantiation
**Likelihood Of Impact: Very Low**
Creating a new model instance while that model is still booting is now disallowed and throws a `LogicException`.
This affects code that instantiates models from inside model `boot` methods or trait `boot*` methods:
@boostsnippet('Model Booting', 'php')
protected static function boot()
{
parent::boot();
// No longer allowed during booting...
(new static())->getTable();
}
@endboostsnippet
Move this logic outside the boot cycle to avoid nested booting.
### Polymorphic Pivot Table Name Generation
**Likelihood Of Impact: Low**
When table names are inferred for polymorphic pivot models using custom pivot model classes, Laravel now generates pluralized names.
If your application depended on the previous singular inferred names for morph pivot tables and used custom pivot classes, you should explicitly define the table name on your pivot model.
### Collection Model Serialization Restores Eager-Loaded Relations
**Likelihood Of Impact: Low**
When Eloquent model collections are serialized and restored (such as in queued jobs), eager-loaded relations are now restored for the collection's models.
If your code depended on relations not being present after deserialization, you may need to adjust that logic.
## HTTP Client
### HTTP Client `Response::throw` and `throwIf` Signatures
**Likelihood Of Impact: Very Low**
The HTTP client response methods now declare their callback parameters in the method signatures:
@boostsnippet('HTTP Client Throw Signatures', 'php')
public function throw($callback = null);
public function throwIf($condition, $callback = null);
@endboostsnippet
If you override these methods in custom response classes, ensure your method signatures are compatible.
## Notifications
### Default Password Reset Subject
**Likelihood Of Impact: Very Low**
Laravel's default password reset mail subject has changed:
@boostsnippet('Password Reset Subject', 'text')
// Laravel <= 12.x
Reset Password Notification
// Laravel >= 13.x
Reset your password
@endboostsnippet
If your tests, assertions, or translation overrides depend on the previous default string, update them accordingly.
### Queued Notifications and Missing Models
**Likelihood Of Impact: Very Low**
Queued notifications now respect the `#[DeleteWhenMissingModels]` attribute and `$deleteWhenMissingModels` property defined on the notification class.
In previous versions, missing models could still cause queued notification jobs to fail in cases where you expected them to be deleted.
## Queue
### `JobAttempted` Event Exception Payload
**Likelihood Of Impact: Low**
The `Illuminate\Queue\Events\JobAttempted` event now exposes the exception object (or `null`) via `$exception`, replacing the previous boolean `$exceptionOccurred` property:
@boostsnippet('JobAttempted Event', 'php')
// Laravel <= 12.x
$event->exceptionOccurred;
// Laravel >= 13.x
$event->exception;
@endboostsnippet
If you listen for this event, update your listener code accordingly.
### `QueueBusy` Event Property Rename
**Likelihood Of Impact: Low**
The `Illuminate\Queue\Events\QueueBusy` event property `$connection` has been renamed to `$connectionName` for consistency with other queue events.
If your listeners reference `$connection`, update them to `$connectionName`.
### `Queue` Contract Method Additions
**Likelihood Of Impact: Very Low**
The `Illuminate\Contracts\Queue\Queue` contract now includes queue size inspection methods that were previously only declared in docblocks.
If you maintain custom queue driver implementations of this contract, add implementations for:
- `pendingSize`
- `delayedSize`
- `reservedSize`
- `creationTimeOfOldestPendingJob`
## Routing
### Domain Route Registration Precedence
**Likelihood Of Impact: Low**
Routes with an explicit domain are now prioritized before non-domain routes in route matching.
This allows catch-all subdomain routes to behave consistently even when non-domain routes are registered earlier. If your application relied on previous registration precedence between domain and non-domain routes, review route matching behavior.
## Scheduling
### `withScheduling` Registration Timing
**Likelihood Of Impact: Very Low**
Schedules registered via `ApplicationBuilder::withScheduling()` are now deferred until `Schedule` is resolved.
If your application relied on immediate schedule registration timing during bootstrap, you may need to adjust that logic.
## Security
### Request Forgery Protection
**Likelihood Of Impact: High**
Laravel's CSRF middleware has been renamed from `VerifyCsrfToken` to `PreventRequestForgery`, and now includes request-origin verification using the `Sec-Fetch-Site` header.
`VerifyCsrfToken` and `ValidateCsrfToken` remain as deprecated aliases, but direct references should be updated to `PreventRequestForgery`, especially when excluding middleware in tests or route definitions:
@boostsnippet('CSRF Middleware Rename', 'php')
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
// Laravel <= 12.x
->withoutMiddleware([VerifyCsrfToken::class]);
// Laravel >= 13.x
->withoutMiddleware([PreventRequestForgery::class]);
@endboostsnippet
The middleware configuration API now also provides `preventRequestForgery(...)`.
## Support
### Manager `extend` Callback Binding
**Likelihood Of Impact: Low**
Custom driver closures registered via manager `extend` methods are now bound to the manager instance.
If you previously relied on another bound object (such as a service provider instance) as `$this` inside these callbacks, you should move those values into closure captures using `use (...)`.
### `Str` Factories Reset Between Tests
**Likelihood Of Impact: Low**
Laravel now resets custom `Str` factories during test teardown.
If your tests depended on custom UUID / ULID / random string factories persisting between test methods, you should set them in each relevant test or setup hook.
### `Js::from` Uses Unescaped Unicode By Default
**Likelihood Of Impact: Very Low**
`Illuminate\Support\Js::from` now uses `JSON_UNESCAPED_UNICODE` by default.
If your tests or frontend output comparisons depended on escaped Unicode sequences (for example `\u00e8`), update your expectations.
## Views
### Pagination Bootstrap View Names
**Likelihood Of Impact: Low**
The internal pagination view names for Bootstrap 3 defaults are now explicit:
@boostsnippet('Pagination Views', 'text')
// Laravel <= 12.x
pagination::default
pagination::simple-default
// Laravel >= 13.x
pagination::bootstrap-3
pagination::simple-bootstrap-3
@endboostsnippet
## Getting help
If you encounter issues during the upgrade:
- Check the [upgrade guide](https://laravel.com/docs/13.x/upgrade) for the latest details
- Review the [GitHub comparison](https://github.com/laravel/laravel/compare/12.x...13.x) for skeleton changes

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Prompts\UpgradeLivewirev4;
use Laravel\Boost\Concerns\RendersBladeGuidelines;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Prompt;
use Laravel\Roster\Enums\Packages;
use Laravel\Roster\Roster;
class UpgradeLivewireV4 extends Prompt
{
use RendersBladeGuidelines;
protected string $name = 'upgrade-livewire-v4';
protected string $title = 'upgrade_livewire_v4';
protected string $description = 'Provides step-by-step guidance for upgrading from Livewire v3 to v4.';
public function shouldRegister(Roster $roster): bool
{
return $roster->uses(Packages::LIVEWIRE);
}
public function handle(): Response
{
$content = $this->renderBladeFile(__DIR__.'/upgrade-livewire-v4.blade.php');
return Response::text($content);
}
}

View File

@@ -0,0 +1,825 @@
# Livewire v3 to v4 Upgrade Specialist
You are an expert Livewire upgrade specialist with deep knowledge of both Livewire v3 and v4. Your task is to systematically upgrade the application from Livewire v3 to v4 while ensuring all functionality remains intact. You understand the nuances of breaking changes and can identify affected code patterns with precision.
## Core Principle: Documentation-First Approach
**IMPORTANT:** Always use the `search-docs` tool whenever you need:
- Specific code examples for implementing Livewire v4 features
- Clarification on breaking changes or new syntax
- Verification of upgrade patterns before applying them
- Examples of correct usage for new directives or methods
The official Livewire documentation is your primary source of truth. Consult it before making assumptions or implementing changes.
## Upgrade Process
Follow this systematic process to upgrade the application:
### 1. Assess Current State
Before making any changes:
- Check `composer.json` for the current Livewire version constraint
- Run `{{ $assist->composerCommand('show livewire/livewire') }}` to confirm installed version
- Identify all Livewire components in the application (search for `extends Component`)
- Review `config/livewire.php` for current configuration
### 2. Create Safety Net
- Ensure you're working on a dedicated branch
- Run the existing test suite to establish baseline
- Note any components with complex JavaScript interactions
### 3. Analyze Codebase for Breaking Changes
Search the codebase for patterns affected by v4 changes:
**High Priority Searches:**
- `config/livewire.php` - Configuration key renames needed
- `Route::get` with Livewire components - May need `Route::livewire()`
- `wire:model` on container elements (divs, modals) - Check for bubbling behavior
- `wire:scroll` - Needs rename to `wire:navigate:scroll`
- `<livewire:` tags - Must be properly closed (self-closing or with closing tag)
**Medium Priority Searches:**
- `wire:transition` with modifiers (`.opacity`, `.scale`, `.duration`) - Modifiers removed
- `$this->stream(` - Parameter order changed
- Array property replacements from JavaScript - Hook behavior changed
**Low Priority Searches:**
- `$wire.$js(` or `$js(` - Deprecated syntax
- `Livewire.hook('commit'` or `Livewire.hook('request'` - Deprecated hooks
### 4. Apply Changes Systematically
For each category of changes:
1. **Search** for affected patterns using grep/search tools
2. **Consult documentation** - Use `search-docs` tool to verify correct upgrade patterns and examples
3. **List** all files that need modification
4. **Apply** the fix consistently across all occurrences
5. **Verify** each change doesn't break functionality
### 5. Update Dependencies
After code changes are complete:
```bash
{{ $assist->composerCommand('require livewire/livewire:^4.0') }}
{{ $assist->artisanCommand('optimize:clear') }}
```
### 6. Test and Verify
- Run the full test suite
- Manually test critical user flows
- Check browser console for JavaScript errors
- Verify all components render correctly
## Execution Strategy
When upgrading, maximize efficiency by:
- **Batch similar changes** - Group all config updates, then all routing updates, etc.
- **Use parallel agents** for independent file modifications
- **Prioritize high-impact changes** that could cause immediate failures
- **Test incrementally** - Verify after each category of changes
## Important Notes
- Most applications can upgrade with minimal changes
- The old syntax for deprecated features still works but should be migrated
---
# Upgrading from v3 to v4
Livewire v4 introduces several improvements and optimizations while maintaining backward compatibility wherever possible. This guide will help you upgrade from Livewire v3 to v4.
> [!tip] Smooth upgrade path
> Most applications can upgrade to v4 with minimal changes. The breaking changes are primarily configuration updates and method signature changes that only affect advanced usage.
## Installation
Update your `composer.json` to require Livewire v4:
@boostsnippet('Installation', 'bash')
composer require livewire/livewire:^4.0
@endboostsnippet
After updating, clear your application's cache:
@boostsnippet('Clear Cache', 'bash')
php artisan optimize:clear
@endboostsnippet
> [!info] View all changes on GitHub
> For a complete overview of all code changes between v3 and v4, you can review the full diff on GitHub: [Compare 3.x to main →](https://github.com/livewire/livewire/compare/3.x...main)
## High-impact changes
These changes are most likely to affect your application and should be reviewed carefully.
### Config file updates
Several configuration keys have been renamed, reorganized, or have new defaults. Update your `config/livewire.php` file:
> [!tip] View the full config file
> For reference, you can view the complete v4 config file on GitHub: [livewire.php →](https://github.com/livewire/livewire/blob/main/config/livewire.php)
#### Renamed configuration keys
**Layout configuration:**
@boostsnippet('Layout Configuration', 'php')
// Before (v3)
'layout' => 'components.layouts.app',
// After (v4)
'component_layout' => 'layouts::app',
@endboostsnippet
The layout now uses the `layouts::` namespace by default, pointing to `resources/views/layouts/app.blade.php`.
**Placeholder configuration:**
@boostsnippet('Placeholder Configuration', 'php')
// Before (v3)
'lazy_placeholder' => 'livewire.placeholder',
// After (v4)
'component_placeholder' => 'livewire.placeholder',
@endboostsnippet
#### Changed defaults
**Smart wire:key behavior:**
@boostsnippet('Smart Wire Key', 'php')
// Now defaults to true (was false in v3)
'smart_wire_keys' => true,
@endboostsnippet
This helps prevent wire:key issues on deeply nested components. Note: You still need to add `wire:key` manually in loops—this setting doesn't eliminate that requirement.
[Learn more about wire:key ](/docs/4.x/nesting#rendering-children-in-a-loop)
#### New configuration options
**Component locations:**
@boostsnippet('Component Locations', 'php')
'component_locations' => [
resource_path('views/components'),
resource_path('views/livewire'),
],
@endboostsnippet
Defines where Livewire looks for single-file and multi-file (view-based) components.
**Component namespaces:**
@boostsnippet('Component Namespaces', 'php')
'component_namespaces' => [
'layouts' => resource_path('views/layouts'),
'pages' => resource_path('views/pages'),
],
@endboostsnippet
Creates custom namespaces for organizing view-based components (e.g., `<livewire:pages::dashboard />`).
**Make command defaults:**
@boostsnippet('Make Command Defaults', 'php')
'make_command' => [
'type' => 'sfc', // Options: 'sfc', 'mfc', or 'class'
'emoji' => true, // Whether to use ⚡ emoji prefix
],
@endboostsnippet
Configure default component format and emoji usage. Set `type` to `'class'` to match v3 behavior.
**CSP-safe mode:**
@boostsnippet('CSP Safe Mode', 'php')
'csp_safe' => false,
@endboostsnippet
Enable Content Security Policy mode to avoid `unsafe-eval` violations. When enabled, Livewire uses the [Alpine CSP build](https://alpinejs.dev/advanced/csp). Note: This mode restricts complex JavaScript expressions in directives like `wire:click="addToCart($event.detail.productId)"` or global references like `window.location`.
### Routing changes
For full-page components, the recommended routing approach has changed:
@boostsnippet('Routing Changes', 'php')
// Before (v3) - still works but not recommended
Route::get('/dashboard', Dashboard::class);
// After (v4) - recommended for all component types
Route::livewire('/dashboard', Dashboard::class);
// For view-based components, you can use the component name
Route::livewire('/dashboard', 'pages::dashboard');
@endboostsnippet
Using `Route::livewire()` is now the preferred method and is required for single-file and multi-file components to work correctly as full-page components.
[Learn more about routing ](/docs/4.x/components#page-components)
### `wire:model` now ignores child events by default
In v3, `wire:model` would respond to input/change events that bubbled up from child elements. This caused unexpected behavior when using `wire:model` on container elements (like modals or accordions) that contained form inputs—clearing an input inside would bubble up and potentially close the modal.
In v4, `wire:model` now only listens for events originating directly on the element itself (equivalent to the `.self` modifier behavior).
If you have code that relies on capturing events from child elements, add the `.deep` modifier:
@boostsnippet('Wire Model Deep', 'blade')
<!-- Before (v3) - listened to child events by default -->
<div wire:model="value">
<input type="text">
</div>
<!-- After (v4) - add .deep to restore old behavior -->
<div wire:model.deep="value">
<input type="text">
</div>
@endboostsnippet
> [!tip] Most apps won't need changes
> This change primarily affects non-standard uses of `wire:model` on container elements. Standard form input bindings (inputs, selects, textareas) are unaffected.
### Use `wire:navigate:scroll`
When using `wire:scroll` to preserve scroll in a scrollable container across `wire:navigate` requests in v3, you will need to instead use `wire:navigate:scroll` in v4:
@boostsnippet('Wire Navigate Scroll', 'blade')
@@persist('sidebar')
<div class="overflow-y-scroll" wire:scroll> <!-- [tl! remove] -->
<div class="overflow-y-scroll" wire:navigate:scroll> <!-- [tl! add] -->
<!-- ... -->
</div>
@@endpersist
@endboostsnippet
### Component tags must be closed
In v3, Livewire component tags would render even without being properly closed. In v4, with the addition of slot support, component tags must be properly closed—otherwise Livewire interprets subsequent content as slot content and the component won't render:
@boostsnippet('Component Tags Closed', 'blade')
<!-- Before (v3) - unclosed tag -->
<livewire:component-name>
<!-- After (v4) - Self-closing tag -->
<livewire:component-name />
@endboostsnippet
[Learn more about rendering components ](/docs/4.x/components#rendering-components)
[Learn more about slots ](/docs/4.x/nesting#slots)
## Medium-impact changes
These changes may affect certain parts of your application depending on which features you use.
### `wire:transition` now uses View Transitions API
In v3, `wire:transition` was a wrapper around Alpine's `x-transition` directive, supporting modifiers like `.opacity`, `.scale`, `.duration.200ms`, and `.origin.top`.
In v4, `wire:transition` uses the browser's native [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) instead. Basic usage still works—elements will fade in and out smoothly—but all modifiers have been removed.
@boostsnippet('Wire Transition', 'blade')
<!-- This still works in v4 -->
<div wire:transition>...</div>
<!-- These modifiers are no longer supported -->
<div wire:transition.opacity>...</div> <!-- [tl! remove] -->
<div wire:transition.scale.origin.top>...</div> <!-- [tl! remove] -->
<div wire:transition.duration.500ms>...</div> <!-- [tl! remove] -->
@endboostsnippet
[Learn more about wire:transition ](/docs/4.x/wire-transition)
### Performance improvements
Livewire v4 includes significant performance improvements to the request handling system:
- **Non-blocking polling**: `wire:poll` no longer blocks other requests or is blocked by them
- **Parallel live updates**: `wire:model.live` requests now run in parallel, allowing faster typing and quicker results
These improvements happen automatically—no changes needed to your code.
### Update hooks consolidate array/object changes
When replacing an entire array or object from the frontend (e.g., `$wire.items = ['new', 'values']`), Livewire now sends a single consolidated update instead of granular updates for each index.
**Before:** Setting `$wire.items = ['a', 'b']` on an array of 4 items would fire `updatingItems`/`updatedItems` hooks multiple times—once for each index change plus `__rm__` removals.
**After:** The same operation fires the hooks once with the full new array value, matching v2 behavior.
If your code relies on individual index hooks firing when replacing entire arrays, you may need to adjust. Single-item changes (like `wire:model="items.0"`) still fire granular hooks as expected.
### Method signature changes
If you're extending Livewire's core functionality or using these methods directly, note these signature changes:
**Streaming:**
The `stream()` method parameter order has changed:
@boostsnippet('Stream Method Signature', 'php')
// Before (v3)
$this->stream(to: '#container', content: 'Hello', replace: true);
// After (v4)
$this->stream(content: 'Hello', replace: true, el: '#container');
@endboostsnippet
If you're using named parameters (as shown above), note that `to:` has been renamed to `el:`. If you're using positional parameters, you'll need to update to the following:
@boostsnippet('Stream Positional Parameters', 'php')
// Before (v3) - positional parameters
$this->stream('#container', 'Hello');
// After (v4) - positional/named parameters
$this->stream('Hello', el: '#container');
@endboostsnippet
[Learn more about streaming ](/docs/4.x/wire-stream)
**Component mounting (internal):**
If you're extending `LivewireManager` or calling the `mount()` method directly:
@boostsnippet('Mount Method Signature', 'php')
// Before (v3)
public function mount($name, $params = [], $key = null)
// After (v4)
public function mount($name, $params = [], $key = null, $slots = [])
@endboostsnippet
This change adds support for passing slots when mounting components and generally won't affect most applications.
## Low-impact changes
These changes only affect applications using advanced features or customization.
### JavaScript deprecations
#### Deprecated: `$wire.$js()` method
The `$wire.$js()` method for defining JavaScript actions has been deprecated:
@boostsnippet('Wire JS Deprecation', 'js')
// Deprecated (v3)
$wire.$js('bookmark', () => {
// Toggle bookmark...
})
// New (v4)
$wire.$js.bookmark = () => {
// Toggle bookmark...
}
@endboostsnippet
The new syntax is cleaner and more intuitive.
#### Deprecated: `$js` without prefix
The use of `$js` in scripts without `$wire.$js` or `this.$js` prefix has been deprecated:
@boostsnippet('JS Without Prefix Deprecation', 'js')
// Deprecated (v3)
$js('bookmark', () => {
// Toggle bookmark...
})
// New (v4)
$wire.$js.bookmark = () => {
// Toggle bookmark...
}
// Or
this.$js.bookmark = () => {
// Toggle bookmark...
}
@endboostsnippet
> [!tip] Old syntax still works
> Both `$wire.$js('bookmark', ...)` and `$js('bookmark', ...)` will continue to work in v4 for backward compatibility, but you should migrate to the new syntax when convenient.
#### Deprecated: `commit` and `request` hooks
The `commit` and `request` hooks have been deprecated in favor of a new interceptor system that provides more granular control and better performance.
> [!tip] Old hooks still work
> The deprecated hooks will continue to work in v4 for backward compatibility, but you should migrate to the new system when convenient.
#### Migrating from `commit` hook
The old `commit` hook:
@boostsnippet('Old Commit Hook', 'js')
// OLD - Deprecated
Livewire.hook('commit', ({ component, commit, respond, succeed, fail }) => {
respond(() => {
// Runs after response received but before processing
})
succeed(({ snapshot, effects }) => {
// Runs after successful response
})
fail(() => {
// Runs if request failed
})
})
@endboostsnippet
Should be replaced with the new `interceptMessage`:
@boostsnippet('New Intercept Message', 'js')
// NEW - Recommended
Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError, onFailure }) => {
onFinish(() => {
// Equivalent to respond()
})
onSuccess(({ payload }) => {
// Equivalent to succeed()
// Access snapshot via payload.snapshot
// Access effects via payload.effects
})
onError(() => {
// Equivalent to fail() for server errors
})
onFailure(() => {
// Equivalent to fail() for network errors
})
})
@endboostsnippet
#### Migrating from `request` hook
The old `request` hook:
@boostsnippet('Old Request Hook', 'js')
// OLD - Deprecated
Livewire.hook('request', ({ url, options, payload, respond, succeed, fail }) => {
respond(({ status, response }) => {
// Runs when response received
})
succeed(({ status, json }) => {
// Runs on successful response
})
fail(({ status, content, preventDefault }) => {
// Runs on failed response
})
})
@endboostsnippet
Should be replaced with the new `interceptRequest`:
@boostsnippet('New Intercept Request', 'js')
// NEW - Recommended
Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => {
// Access url via request.uri
// Access options via request.options
// Access payload via request.payload
onResponse(({ response }) => {
// Equivalent to respond()
// Access status via response.status
})
onSuccess(({ response, responseJson }) => {
// Equivalent to succeed()
// Access status via response.status
// Access json via responseJson
})
onError(({ response, responseBody, preventDefault }) => {
// Equivalent to fail() for server errors
// Access status via response.status
// Access content via responseBody
})
onFailure(({ error }) => {
// Equivalent to fail() for network errors
})
})
@endboostsnippet
#### Key differences
1. **More granular error handling**: The new system separates network failures (`onFailure`) from server errors (`onError`)
2. **Better lifecycle hooks**: Message interceptors provide additional hooks like `onSync`, `onMorph`, and `onRender`
3. **Cancellation support**: Both messages and requests can be cancelled/aborted
4. **Component scoping**: Message interceptors can be scoped to specific components using `$wire.intercept(...)`
For complete documentation on the new interceptor system, see the [JavaScript Interceptors documentation](/docs/4.x/javascript#interceptors).
## Upgrading Volt
Livewire v4 now supports single-file components, which use the same syntax as Volt class-based components. This means you can migrate from Volt to Livewire's built-in single-file components.
### Update component imports
Replace all instances of `Livewire\Volt\Component` with `Livewire\Component`:
@boostsnippet('Volt Import Update', 'php')
// Before (Volt)
use Livewire\Volt\Component;
new class extends Component { ... }
// After (Livewire v4)
use Livewire\Component;
new class extends Component { ... }
@endboostsnippet
### Remove Volt service provider
Delete the Volt service provider file:
@boostsnippet('Remove Volt Provider', 'bash')
rm app/Providers/VoltServiceProvider.php
@endboostsnippet
Then remove it from the providers array in `bootstrap/providers.php`:
@boostsnippet('Update Providers Array', 'php')
// Before
return [
App\Providers\AppServiceProvider::class,
App\Providers\VoltServiceProvider::class,
];
// After
return [
App\Providers\AppServiceProvider::class,
];
@endboostsnippet
### Remove Volt package
Uninstall the Volt package:
@boostsnippet('Uninstall Volt', 'bash')
composer remove livewire/volt
@endboostsnippet
### Install Livewire v4
After completing the above changes, install Livewire v4. Your existing Volt class-based components will work without modification since they use the same syntax as Livewire's single-file components.
## New features in v4
Livewire v4 introduces several powerful new features you can start using immediately:
### Component features
**Single-file and multi-file components**
v4 introduces new component formats alongside the traditional class-based approach. Single-file components combine PHP and Blade in one file, while multi-file components organize PHP, Blade, JavaScript, and tests in a directory.
By default, view-based component files are prefixed with a emoji to distinguish them from regular Blade files in your editor and searches. This can be disabled via the `make_command.emoji` config.
@boostsnippet('Make Livewire Commands', 'bash')
php artisan make:livewire create-post # Single-file (default)
php artisan make:livewire create-post --mfc # Multi-file
php artisan livewire:convert create-post # Convert between formats
@endboostsnippet
[Learn more about component formats ](/docs/4.x/components)
**Slots and attribute forwarding**
Components now support slots and automatic attribute bag forwarding using `@{{ $attributes }}`, making component composition more flexible.
[Learn more about nesting components ](/docs/4.x/nesting)
**JavaScript in view-based components**
View-based components can now include `<script>` tags without the `@@script` wrapper. These scripts are served as separate cached files for better performance and automatic `$wire` binding:
@boostsnippet('JavaScript in Components', 'blade')
<div>
<!-- Your component template -->
</div>
<script>
// $wire is automatically bound as 'this'
this.count++ // Same as $wire.count++
// $wire is still available if preferred
$wire.save()
</script>
@endboostsnippet
[Learn more about JavaScript in components ](/docs/4.x/javascript)
### Islands
Islands allow you to create isolated regions within a component that update independently, dramatically improving performance without creating separate child components.
@boostsnippet('Islands Example', 'blade')
@@island(name: 'stats', lazy: true)
<div>@{{ $this->expensiveStats }}</div>
@@endisland
@endboostsnippet
[Learn more about islands ](/docs/4.x/islands)
### Loading improvements
**Deferred loading**
In addition to lazy loading (viewport-based), components can now be deferred to load immediately after the initial page load:
@boostsnippet('Deferred Loading Blade', 'blade')
<livewire:revenue defer />
@endboostsnippet
@boostsnippet('Deferred Loading PHP', 'php')
#[Defer]
class Revenue extends Component { ... }
@endboostsnippet
**Bundled loading**
Control whether multiple lazy/deferred components load in parallel or bundled together:
@boostsnippet('Bundled Loading Blade', 'blade')
<livewire:revenue lazy.bundle />
<livewire:expenses defer.bundle />
@endboostsnippet
@boostsnippet('Bundled Loading PHP', 'php')
#[Lazy(bundle: true)]
class Revenue extends Component { ... }
@endboostsnippet
[Learn more about lazy and deferred loading ](/docs/4.x/lazy)
### Async actions
Run actions in parallel without blocking other requests using the `.async` modifier or `#[Async]` attribute:
@boostsnippet('Async Actions Blade', 'blade')
<button wire:click.async="logActivity">Track</button>
@endboostsnippet
@boostsnippet('Async Actions PHP', 'php')
#[Async]
public function logActivity() { ... }
@endboostsnippet
[Learn more about async actions ](/docs/4.x/actions#parallel-execution-with-async)
### New directives and modifiers
**`wire:sort` - Drag-and-drop sorting**
Built-in support for sortable lists with drag-and-drop:
@boostsnippet('Wire Sort', 'blade')
<ul wire:sort="updateOrder">
@@foreach ($items as $item)
<li wire:sort:item="@{{ $item->id }}" wire:key="@{{ $item->id }}">@{{ $item->name }}</li>
@@endforeach
</ul>
@endboostsnippet
[Learn more about wire:sort ](/docs/4.x/wire-sort)
**`wire:intersect` - Viewport intersection**
Run actions when elements enter or leave the viewport, similar to Alpine's [`x-intersect`](https://alpinejs.dev/plugins/intersect):
@boostsnippet('Wire Intersect', 'blade')
<!-- Basic usage -->
<div wire:intersect="loadMore">...</div>
<!-- With modifiers -->
<div wire:intersect.once="trackView">...</div>
<div wire:intersect:leave="pauseVideo">...</div>
<div wire:intersect.half="loadMore">...</div>
<div wire:intersect.full="startAnimation">...</div>
<!-- With options -->
<div wire:intersect.margin.200px="loadMore">...</div>
<div wire:intersect.threshold.50="trackScroll">...</div>
@endboostsnippet
Available modifiers:
- `.once` - Fire only once
- `.half` - Wait until half is visible
- `.full` - Wait until fully visible
- `.threshold.X` - Custom visibility percentage (0-100)
- `.margin.Xpx` or `.margin.X%` - Intersection margin
[Learn more about wire:intersect →](/docs/4.x/wire-intersect)
**`wire:ref` - Element references**
Easily reference and interact with elements in your template:
@boostsnippet('Wire Ref', 'blade')
<div wire:ref="modal">
<!-- Modal content -->
</div>
<button wire:click="$js.scrollToModal">Scroll to modal</button>
<script>
this.$js.scrollToModal = () => {
this.$refs.modal.scrollIntoView()
}
</script>
@endboostsnippet
[Learn more about wire:ref →](/docs/4.x/wire-ref)
**`.renderless` modifier**
Skip component re-rendering directly from the template:
@boostsnippet('Renderless Modifier', 'blade')
<button wire:click.renderless="trackClick">Track</button>
@endboostsnippet
This is an alternative to the `#[Renderless]` attribute for actions that don't need to update the UI.
**`.preserve-scroll` modifier**
Preserve scroll position during updates to prevent layout jumps:
@boostsnippet('Preserve Scroll', 'blade')
<button wire:click.preserve-scroll="loadMore">Load More</button>
@endboostsnippet
**`data-loading` attribute**
Every element that triggers a network request automatically receives a `data-loading` attribute, making it easy to style loading states with Tailwind:
@boostsnippet('Data Loading', 'blade')
<button wire:click="save" class="data-loading:opacity-50 data-loading:pointer-events-none">
Save Changes
</button>
@endboostsnippet
[Learn more about loading states ](/docs/4.x/loading-states)
### JavaScript improvements
**`$errors` magic property**
Access your component's error bag from JavaScript:
@boostsnippet('Errors Magic Property', 'blade')
<div wire:show="$errors.has('email')">
<span wire:text="$errors.first('email')"></span>
</div>
@endboostsnippet
[Learn more about validation →](/docs/4.x/validation)
**`$intercept` magic**
Intercept and modify Livewire requests from JavaScript:
@boostsnippet('Intercept Magic', 'blade')
<script>
this.$intercept('save', ({ ... }) => {
// ...
})
</script>
@endboostsnippet
[Learn more about JavaScript interceptors →](/docs/4.x/javascript#interceptors)
**Island targeting from JavaScript**
Trigger island renders directly from the template:
@boostsnippet('Island Targeting', 'blade')
<button wire:click="loadMore" wire:island.append="stats">
Load more
</button>
@endboostsnippet
[Learn more about islands ](/docs/4.x/islands)
## Getting help
If you encounter issues during the upgrade:
- Check the [documentation](https://livewire.laravel.com) for detailed feature guides
- Visit the [GitHub discussions](https://github.com/livewire/livewire/discussions) for community support

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Resources;
use Laravel\Boost\Mcp\ToolExecutor;
use Laravel\Boost\Mcp\Tools\ApplicationInfo as ApplicationInfoTool;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Resource;
class ApplicationInfo extends Resource
{
public function __construct(protected ToolExecutor $toolExecutor)
{
//
}
/**
* The resource's description.
*/
protected string $description = 'Comprehensive application information including PHP version, Laravel version, database engine, all installed packages with their versions in the application.';
/**
* The resource's URI.
*/
protected string $uri = 'file://instructions/application-info.md';
/**
* The resource's MIME type.
*/
protected string $mimeType = 'text/markdown';
/**
* Handle the resource request.
*/
public function handle(): Response
{
$response = $this->toolExecutor->execute(ApplicationInfoTool::class);
if ($response->isError()) {
return $response; // Return the error response directly
}
$data = json_decode((string) $response->content(), true);
if (! $data) {
return Response::error('Error parsing application information');
}
return Response::json($data);
}
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp;
use Dotenv\Dotenv;
use Illuminate\Support\Env;
use Laravel\Boost\Support\CommandNormalizer;
use Laravel\Mcp\Response;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;
class ToolExecutor
{
public function execute(string $toolClass, array $arguments = []): Response
{
if (! ToolRegistry::isToolAllowed($toolClass)) {
return Response::error("Tool not registered or not allowed: {$toolClass}");
}
return $this->executeInSubprocess($toolClass, $arguments);
}
protected function executeInSubprocess(string $toolClass, array $arguments): Response
{
$command = $this->buildCommand($toolClass, $arguments);
// We need to 'unset' env vars that will be passed from the parent process to the child process, stopping the child process from reading .env and getting updated values
$env = (Dotenv::create(
Env::getRepository(),
app()->environmentPath(),
app()->environmentFile()
))->safeLoad();
$cleanEnv = array_fill_keys(array_keys($env), false);
$process = new Process(
command: $command,
env: $cleanEnv,
timeout: $this->getTimeout($arguments)
);
try {
$process->mustRun();
$output = $process->getOutput();
$decoded = json_decode($output, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return Response::error('Invalid JSON output from tool process: '.json_last_error_msg());
}
return $this->reconstructResponse($decoded);
} catch (ProcessTimedOutException) {
$process->stop();
return Response::error("Tool execution timed out after {$this->getTimeout($arguments)} seconds");
} catch (ProcessFailedException) {
$errorOutput = $process->getErrorOutput().$process->getOutput();
return Response::error("Process tool execution failed: {$errorOutput}");
}
}
protected function getTimeout(array $arguments): int
{
$timeout = (int) ($arguments['timeout'] ?? 180);
return max(1, min(600, $timeout));
}
/**
* Reconstruct a Response from JSON data.
*
* @param array<string, mixed> $data
*/
protected function reconstructResponse(array $data): Response
{
if (! isset($data['isError']) || ! isset($data['content'])) {
return Response::error('Invalid tool response format.');
}
if ($data['isError']) {
$errorText = 'Unknown error';
if (is_array($data['content']) && ! empty($data['content'])) {
$firstContent = $data['content'][0] ?? [];
if (is_array($firstContent)) {
$errorText = $firstContent['text'] ?? $errorText;
}
}
return Response::error($errorText);
}
// Handle array format - extract text content
if (is_array($data['content']) && ! empty($data['content'])) {
$firstContent = $data['content'][0] ?? [];
if (is_array($firstContent)) {
$text = $firstContent['text'] ?? '';
$decoded = json_decode((string) $text, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
return Response::json($decoded);
}
return Response::text($text);
}
}
return Response::text('');
}
/**
* Build the command array for executing a tool in a subprocess.
*
* @param array<string, mixed> $arguments
* @return array<string>
*/
protected function buildCommand(string $toolClass, array $arguments): array
{
$phpBinary = config('boost.executable_paths.php') ?? PHP_BINARY;
$normalized = CommandNormalizer::normalize($phpBinary);
return [
$normalized['command'],
...$normalized['args'],
base_path('artisan'),
'boost:execute-tool',
$toolClass,
base64_encode(json_encode($arguments)),
];
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp;
use DirectoryIterator;
class ToolRegistry
{
/** @var array<int, class-string>|null */
private static ?array $cachedTools = null;
/**
* Get all available tools based on the discovery logic from Boost server.
*
* @return array<int, class-string>
*/
public static function getAvailableTools(): array
{
if (self::$cachedTools !== null) {
return self::$cachedTools;
}
$tools = [];
// Discover tools from the Tools directory
$excludedTools = config('boost.mcp.tools.exclude', []);
$toolDir = new DirectoryIterator(__DIR__.DIRECTORY_SEPARATOR.'Tools');
foreach ($toolDir as $toolFile) {
if ($toolFile->isFile() && $toolFile->getExtension() === 'php') {
$fqdn = 'Laravel\\Boost\\Mcp\\Tools\\'.$toolFile->getBasename('.php');
if (class_exists($fqdn) && ! in_array($fqdn, $excludedTools, true)) {
$tools[] = $fqdn;
}
}
}
// Add extra tools from configuration
$extraTools = config('boost.mcp.tools.include', []);
foreach ($extraTools as $toolClass) {
if (class_exists($toolClass) && ! in_array($toolClass, $tools, true)) {
$tools[] = $toolClass;
}
}
self::$cachedTools = $tools;
return $tools;
}
/**
* Check if a tool class is allowed to be executed.
*/
public static function isToolAllowed(string $toolClass): bool
{
return in_array($toolClass, self::getAvailableTools(), true);
}
/**
* Clear the cached tools (useful for testing or when configuration changes).
*/
public static function clearCache(): void
{
self::$cachedTools = null;
}
/**
* Get tool names (class basenames) mapped to their full class names.
*
* @return array<string, class-string>
*/
public static function getToolNames(): array
{
$tools = self::getAvailableTools();
$names = [];
foreach ($tools as $toolClass) {
$name = class_basename($toolClass);
$names[$name] = $toolClass;
}
return $names;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Tools;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Roster\Package;
use Laravel\Roster\Roster;
#[IsReadOnly]
class ApplicationInfo extends Tool
{
public function __construct(protected Roster $roster)
{
//
}
/**
* The tool's description.
*/
protected string $description = 'Get comprehensive application information including PHP version, Laravel version, database engine, and all installed packages with their versions. You should use this tool on each new chat, and use the package & version data to write version specific code for the packages that exist.';
/**
* Handle the tool request.
*/
public function handle(Request $request): Response
{
return Response::json([
'php_version' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
'laravel_version' => app()->version(),
'database_engine' => config('database.default'),
'packages' => $this->roster->packages()->map(fn (Package $package): array => ['roster_name' => $package->name(), 'version' => $package->version(), 'package_name' => $package->rawName()]),
]);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Boost\Concerns\ReadsLogs;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
class BrowserLogs extends Tool
{
use ReadsLogs;
/**
* The tool's description.
*/
protected string $description = 'Read the last N log entries from the BROWSER log. Very helpful for debugging the frontend and JS/Javascript';
/**
* Get the tool's input schema.
*
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'entries' => $schema->integer()
->description('Number of log entries to return.')
->required(),
];
}
/**
* Handle the tool request.
*/
public function handle(Request $request): Response
{
$maxEntries = $request->integer('entries');
if ($maxEntries <= 0) {
return Response::error('The "entries" argument must be greater than 0.');
}
// Locate the correct log file using the shared helper.
$logFile = storage_path('logs'.DIRECTORY_SEPARATOR.'browser.log');
if (! file_exists($logFile)) {
return Response::error('No log file found, probably means no logs yet.');
}
$entries = $this->readLastLogEntries($logFile, $maxEntries);
if ($entries === []) {
return Response::text('Unable to retrieve log entries, or no logs');
}
$logs = implode("\n\n", $entries);
if (empty(trim($logs))) {
return Response::text('No log entries yet.');
}
return Response::text($logs);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Tools;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
class DatabaseConnections extends Tool
{
/**
* The tool's description.
*/
protected string $description = 'List the configured database connection names for this application.';
/**
* Handle the tool request.
*/
public function handle(Request $request): Response
{
$connections = array_keys(config('database.connections', []));
return Response::json([
'default_connection' => config('database.default'),
'connections' => $connections,
]);
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Facades\DB;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Throwable;
#[IsReadOnly]
class DatabaseQuery extends Tool
{
/**
* The tool's description.
*/
protected string $description = 'Execute a read-only SQL query against the configured database.';
/**
* Get the tool's input schema.
*
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'query' => $schema->string()
->description('The SQL query to execute. Only read-only queries are allowed (i.e. SELECT, SHOW, EXPLAIN, DESCRIBE).')
->required(),
'database' => $schema->string()
->description("Optional database connection name to use. Defaults to the application's default connection."),
];
}
/**
* Handle the tool request.
*/
public function handle(Request $request): Response
{
$query = trim((string) $request->string('query'));
$token = strtok(ltrim($query), " \t\n\r");
if (! $token) {
return Response::error('Please pass a valid query');
}
$firstWord = strtoupper($token);
// Allowed read-only commands.
$allowList = [
'SELECT',
'SHOW',
'EXPLAIN',
'DESCRIBE',
'DESC',
'WITH', // SELECT must follow Common-table expressions
'VALUES', // Returns literal values
'TABLE', // PostgresSQL shorthand for SELECT *
];
$isReadOnly = in_array($firstWord, $allowList, true);
// Additional validation for WITH … SELECT.
if ($firstWord === 'WITH') {
if (! preg_match('/\)\s*SELECT\b/i', $query)) {
$isReadOnly = false;
}
if (preg_match('/\)\s*(DELETE|UPDATE|INSERT|DROP|ALTER|TRUNCATE|REPLACE|RENAME|CREATE)\b/i', $query)) {
$isReadOnly = false;
}
}
if (! $isReadOnly) {
return Response::error('Only read-only queries are allowed (SELECT, SHOW, EXPLAIN, DESCRIBE, DESC, WITH … SELECT).');
}
$connectionName = $request->get('database');
try {
$connection = DB::connection($connectionName);
$prefix = $connection->getTablePrefix();
if ($prefix) {
$query = $this->addPrefixToQuery($query, $prefix);
}
return Response::json(
$connection->select($query)
);
} catch (Throwable $throwable) {
return Response::error('Query failed: '.$throwable->getMessage());
}
}
protected function addPrefixToQuery(string $query, string $prefix): string
{
$cteNames = $this->extractCteNames($query);
$pattern = '/\b(FROM|JOIN|INTO|UPDATE|TABLE|DESCRIBE|DESC)\s+([`"\']?)(\w+)\2/i';
return preg_replace_callback($pattern, function (array $matches) use ($prefix, $cteNames): string {
$keyword = $matches[1];
$quote = $matches[2];
$tableName = $matches[3];
if (str_starts_with($tableName, $prefix) || in_array($tableName, $cteNames, true)) {
return $matches[0];
}
return "{$keyword} {$quote}{$prefix}{$tableName}{$quote}";
}, $query) ?? $query;
}
/**
* Extract CTE (Common Table Expression) names from a query.
*
* @return array<int, string>
*/
protected function extractCteNames(string $query): array
{
if (preg_match_all('/\b(\w+)\s*(?:\([^)]*\))?\s*AS\s*\(/i', $query, $matches)) {
return $matches[1];
}
return [];
}
}

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Tools;
use Exception;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Laravel\Boost\Mcp\Tools\DatabaseSchema\SchemaDriverFactory;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
class DatabaseSchema extends Tool
{
/**
* The tool's description.
*/
protected string $description = 'Read the database schema for this application. Returns table names, columns, indexes, and foreign keys. Use "summary" mode first to get an overview (table names with column types only), then call again without "summary" and with a "filter" to get full details for specific tables. Params: "summary" (default false) - returns only table names and column types; "database" - connection name; "filter" - substring match on table names; "include_column_details" (default false) - adds nullable, default, auto_increment, comments; "include_views" (default false); "include_routines" (default false) - stored procedures, functions, sequences.';
/**
* Get the tool's input schema.
*
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'summary' => $schema->boolean()
->description('Return only table names and their column types. Use this first to understand the database structure, then request full details for specific tables using "filter". Defaults to false.'),
'database' => $schema->string()
->description("Name of the database connection to dump (defaults to app's default connection, often not needed)"),
'filter' => $schema->string()
->description('Filter tables by name (substring match).'),
'include_views' => $schema->boolean()
->description('Include database views. Defaults to false.'),
'include_routines' => $schema->boolean()
->description('Include stored procedures, functions, and sequences. Defaults to false.'),
'include_column_details' => $schema->boolean()
->description('Include full column metadata (nullable, default, auto_increment, comments, generation). Defaults to false.'),
];
}
/**
* Handle the tool request.
*/
public function handle(Request $request): Response
{
$summary = $request->get('summary', false);
$connection = $request->get('database') ?? config('database.default');
$filter = $request->get('filter') ?? '';
$includeViews = $request->get('include_views', false);
$includeRoutines = $request->get('include_routines', false);
$includeColumnDetails = $request->get('include_column_details', false);
$cacheKey = sprintf(
'boost:mcp:database-schema:%s:%s:%d:%d:%d:%d',
$connection,
$filter,
(int) $summary,
(int) $includeViews,
(int) $includeRoutines,
(int) $includeColumnDetails
);
$schema = rescue(
fn () => Cache::remember($cacheKey, 20, fn (): array => $this->getDatabaseStructure(
$connection,
$filter,
$summary,
$includeViews,
$includeRoutines,
$includeColumnDetails
)),
fn (): array => $this->getDatabaseStructure($connection, $filter, $summary, $includeViews, $includeRoutines, $includeColumnDetails),
report: false
);
return Response::json($schema);
}
/**
* @return array{engine: string, tables: array<string, mixed>, views?: array<mixed>, routines?: array{stored_procedures: array<mixed>, functions: array<mixed>, sequences: array<mixed>}}
*/
protected function getDatabaseStructure(
?string $connection,
string $filter = '',
bool $summary = false,
bool $includeViews = false,
bool $includeRoutines = false,
bool $includeColumnDetails = false
): array {
$result = [
'engine' => DB::connection($connection)->getDriverName(),
'tables' => $summary
? $this->getAllTableColumnTypes($connection, $filter)
: $this->getAllTablesStructure($connection, $filter, $includeColumnDetails),
];
if ($summary) {
return $result;
}
$driver = SchemaDriverFactory::make($connection);
if ($includeViews) {
$result['views'] = $driver->getViews();
}
if ($includeRoutines) {
$result['routines'] = [
'stored_procedures' => $driver->getStoredProcedures(),
'functions' => $driver->getFunctions(),
'sequences' => $driver->getSequences(),
];
}
return $result;
}
/**
* @return array<string, array<string, mixed>>
*/
protected function getAllTablesStructure(?string $connection, string $filter = '', bool $includeColumnDetails = false): array
{
$structures = [];
foreach ($this->getAllTables($connection) as $table) {
$tableName = is_object($table) ? $table->name : ($table['name'] ?? '');
if ($filter !== '' && ! str_contains(strtolower($tableName), strtolower($filter))) {
continue;
}
$structures[$tableName] = $this->getTableStructure($connection, $tableName, $includeColumnDetails);
}
return $structures;
}
/**
* @return array<string, array<string, string>>
*/
protected function getAllTableColumnTypes(?string $connection, string $filter = ''): array
{
$tables = [];
foreach ($this->getAllTables($connection) as $table) {
$tableName = is_object($table) ? $table->name : ($table['name'] ?? '');
if ($filter !== '' && ! str_contains(strtolower($tableName), strtolower($filter))) {
continue;
}
$tables[$tableName] = collect(Schema::connection($connection)->getColumns($tableName))
->pluck('type', 'name')
->all();
}
return $tables;
}
/**
* @return array<int, object|array<string, mixed>>
*/
protected function getAllTables(?string $connection): array
{
return SchemaDriverFactory::make($connection)->getTables();
}
protected function getTableStructure(?string $connection, string $tableName, bool $includeColumnDetails = false): array
{
$driver = SchemaDriverFactory::make($connection);
try {
$columns = $this->getTableColumns($connection, $tableName, $includeColumnDetails);
$indexes = $this->getTableIndexes($connection, $tableName);
$foreignKeys = $this->getTableForeignKeys($connection, $tableName);
$triggers = $driver->getTriggers($tableName);
$checkConstraints = $driver->getCheckConstraints($tableName);
return [
'columns' => $columns,
'indexes' => $indexes,
'foreign_keys' => $foreignKeys,
'triggers' => $triggers,
'check_constraints' => $checkConstraints,
];
} catch (Exception $exception) {
Log::error('Failed to get table structure for: '.$tableName, [
'error' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
return [
'error' => 'Failed to get structure: '.$exception->getMessage(),
];
}
}
/**
* @return array<string, array{type: string, nullable?: bool, default?: mixed, auto_increment?: bool, comment?: string, generation?: array<string, mixed>}>
*/
protected function getTableColumns(?string $connection, string $tableName, bool $includeColumnDetails = false): array
{
$schema = Schema::connection($connection);
$columnDetails = [];
foreach ($schema->getColumns($tableName) as $column) {
$detail = ['type' => $column['type']];
if ($includeColumnDetails) {
$detail['nullable'] = $column['nullable'];
$detail['default'] = $column['default'];
$detail['auto_increment'] = $column['auto_increment'];
if ($column['comment'] !== null && $column['comment'] !== '') {
$detail['comment'] = $column['comment'];
}
if ($column['generation'] !== null) {
$detail['generation'] = $column['generation'];
}
}
$columnDetails[$column['name']] = $detail;
}
return $columnDetails;
}
/**
* @return array<string, array{columns: mixed, type: mixed, is_unique: bool, is_primary: bool}>
*/
protected function getTableIndexes(?string $connection, string $tableName): array
{
try {
$indexDetails = [];
foreach (Schema::connection($connection)->getIndexes($tableName) as $index) {
$indexDetails[$index['name']] = [
'columns' => Arr::get($index, 'columns'),
'type' => Arr::get($index, 'type'),
'is_unique' => Arr::get($index, 'unique', false),
'is_primary' => Arr::get($index, 'primary', false),
];
}
return $indexDetails;
} catch (Exception) {
return [];
}
}
protected function getTableForeignKeys(?string $connection, string $tableName): array
{
try {
return Schema::connection($connection)->getForeignKeys($tableName);
} catch (Exception) {
return [];
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Tools\DatabaseSchema;
abstract class DatabaseSchemaDriver
{
public function __construct(protected ?string $connection = null)
{
//
}
protected function hasTable(?string $table): bool
{
return $table !== null && $table !== '';
}
abstract public function getViews(): array;
abstract public function getStoredProcedures(): array;
abstract public function getFunctions(): array;
abstract public function getTriggers(?string $table = null): array;
abstract public function getCheckConstraints(string $table): array;
abstract public function getSequences(): array;
abstract public function getTables(): array;
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Tools\DatabaseSchema;
use Exception;
use Illuminate\Support\Facades\DB;
class MySQLSchemaDriver extends DatabaseSchemaDriver
{
public function getViews(): array
{
try {
return DB::connection($this->connection)->select('
SELECT TABLE_NAME as name, VIEW_DEFINITION as definition
FROM information_schema.VIEWS
WHERE TABLE_SCHEMA = DATABASE()
');
} catch (Exception) {
return [];
}
}
public function getStoredProcedures(): array
{
try {
return DB::connection($this->connection)->select('SHOW PROCEDURE STATUS WHERE Db = DATABASE()');
} catch (Exception) {
return [];
}
}
public function getFunctions(): array
{
try {
return DB::connection($this->connection)->select('SHOW FUNCTION STATUS WHERE Db = DATABASE()');
} catch (Exception) {
return [];
}
}
public function getTriggers(?string $table = null): array
{
try {
if ($this->hasTable($table)) {
return DB::connection($this->connection)->select('SHOW TRIGGERS WHERE `Table` = ?', [$table]);
}
return DB::connection($this->connection)->select('SHOW TRIGGERS');
} catch (Exception) {
return [];
}
}
public function getCheckConstraints(string $table): array
{
try {
return DB::connection($this->connection)->select('
SELECT CONSTRAINT_NAME, CHECK_CLAUSE
FROM information_schema.CHECK_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = ?
', [$table]);
} catch (Exception) {
return [];
}
}
public function getSequences(): array
{
return [];
}
public function getTables(): array
{
try {
return DB::connection($this->connection)->select('
SELECT TABLE_NAME as name
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_TYPE = "BASE TABLE"
ORDER BY TABLE_NAME
');
} catch (Exception) {
return [];
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Tools\DatabaseSchema;
class NullSchemaDriver extends DatabaseSchemaDriver
{
public function getViews(): array
{
return [];
}
public function getStoredProcedures(): array
{
return [];
}
public function getFunctions(): array
{
return [];
}
public function getTriggers(?string $table = null): array
{
return [];
}
public function getCheckConstraints(string $table): array
{
return [];
}
public function getSequences(): array
{
return [];
}
public function getTables(): array
{
return [];
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Tools\DatabaseSchema;
use Exception;
use Illuminate\Support\Facades\DB;
class PostgreSQLSchemaDriver extends DatabaseSchemaDriver
{
public function getViews(): array
{
try {
return DB::connection($this->connection)->select("
SELECT schemaname, viewname, definition
FROM pg_views
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
");
} catch (Exception) {
return [];
}
}
public function getStoredProcedures(): array
{
try {
return DB::connection($this->connection)->select("
SELECT proname, prosrc, proargnames, prorettype
FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND prokind = 'p'
");
} catch (Exception) {
return [];
}
}
public function getFunctions(): array
{
try {
return DB::connection($this->connection)->select("
SELECT proname, prosrc, proargnames, prorettype
FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND prokind = 'f'
");
} catch (Exception) {
return [];
}
}
public function getTriggers(?string $table = null): array
{
try {
$sql = '
SELECT trigger_name, event_manipulation, event_object_table, action_statement
FROM information_schema.triggers
WHERE trigger_schema = current_schema()
';
if ($this->hasTable($table)) {
$sql .= ' AND event_object_table = ?';
return DB::connection($this->connection)->select($sql, [$table]);
}
return DB::connection($this->connection)->select($sql);
} catch (Exception) {
return [];
}
}
public function getCheckConstraints(string $table): array
{
try {
return DB::connection($this->connection)->select("
SELECT conname, pg_get_constraintdef(oid) as definition
FROM pg_constraint
WHERE contype = 'c'
AND conrelid = ?::regclass
", [$table]);
} catch (Exception) {
return [];
}
}
public function getSequences(): array
{
try {
return DB::connection($this->connection)->select('
SELECT sequence_name, start_value, minimum_value, maximum_value, increment
FROM information_schema.sequences
WHERE sequence_schema = current_schema()
');
} catch (Exception) {
return [];
}
}
public function getTables(): array
{
try {
return DB::connection($this->connection)->select('
SELECT tablename as name
FROM pg_tables
WHERE schemaname = current_schema()
ORDER BY tablename
');
} catch (Exception) {
return [];
}
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Tools\DatabaseSchema;
use Exception;
use Illuminate\Support\Facades\DB;
class SQLiteSchemaDriver extends DatabaseSchemaDriver
{
public function getViews(): array
{
try {
return DB::connection($this->connection)->select("
SELECT name, sql
FROM sqlite_master
WHERE type = 'view'
");
} catch (Exception) {
return [];
}
}
public function getStoredProcedures(): array
{
return [];
}
public function getFunctions(): array
{
return [];
}
public function getTriggers(?string $table = null): array
{
try {
$sql = "SELECT name, sql FROM sqlite_master WHERE type = 'trigger'";
if ($this->hasTable($table)) {
$sql .= ' AND tbl_name = ?';
return DB::connection($this->connection)->select($sql, [$table]);
}
return DB::connection($this->connection)->select($sql);
} catch (Exception) {
return [];
}
}
public function getCheckConstraints(string $table): array
{
return [];
}
public function getSequences(): array
{
return [];
}
public function getTables(): array
{
try {
return DB::connection($this->connection)->select("
SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name NOT LIKE 'sqlite_%'
ORDER BY name
");
} catch (Exception) {
return [];
}
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Tools\DatabaseSchema;
use Illuminate\Support\Facades\DB;
class SchemaDriverFactory
{
public static function make(?string $connection = null): DatabaseSchemaDriver
{
$connectionName = $connection ?? config('database.default');
$driverName = config("database.connections.{$connectionName}.driver");
if (! is_string($driverName) || $driverName === '') {
$driverName = DB::connection($connectionName)->getDriverName();
}
return match ($driverName) {
'mysql', 'mariadb' => new MySQLSchemaDriver($connection),
'pgsql' => new PostgreSQLSchemaDriver($connection),
'sqlite' => new SQLiteSchemaDriver($connection),
default => new NullSchemaDriver($connection),
};
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
class GetAbsoluteUrl extends Tool
{
/**
* The tool's description.
*/
protected string $description = 'Get the absolute URL for a given relative path or named route. If no arguments are provided, you will get the absolute URL for "/"';
/**
* Get the tool's input schema.
*
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'path' => $schema->string()
->description('The relative URL/path (e.g. "/dashboard") to convert to an absolute URL.'),
'route' => $schema->string()
->description('The named route to generate an absolute URL for (e.g. "home").'),
];
}
/**
* Handle the tool request.
*/
public function handle(Request $request): Response
{
$path = $request->get('path');
$routeName = $request->get('route');
if ($path) {
return Response::text(url($path));
}
if ($routeName) {
return Response::text(route($routeName));
}
return Response::text(url('/'));
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Tools;
use Illuminate\Log\Events\MessageLogged;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Boost\Concerns\ReadsLogs;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
class LastError extends Tool
{
use ReadsLogs;
/**
* Indicates whether the Log listener has been registered for this process.
*/
private static bool $listenerRegistered = false;
public function __construct()
{
// Register the listener only once per PHP process.
if (! self::$listenerRegistered) {
Log::listen(function (MessageLogged $event): void {
if ($event->level === 'error') {
rescue(fn () => Cache::forever('boost:last_error', [
'timestamp' => now()->toDateTimeString(),
'level' => $event->level,
'message' => $event->message,
'context' => [], // $event->context,
]), report: false);
}
});
self::$listenerRegistered = true;
}
}
/**
* The tool's description.
*/
protected string $description = 'Get details of the last error/exception created in this application on the backend. Use browser-log tool for browser errors.';
/**
* Handle the tool request.
*/
public function handle(Request $request): Response
{
// First, attempt to retrieve the cached last error captured during runtime.
// This works even if the log driver isn't a file driver, so is the preferred approach
$cached = rescue(fn () => Cache::get('boost:last_error'), report: false);
if ($cached) {
$entry = "[{$cached['timestamp']}] {$cached['level']}: {$cached['message']}";
if (! empty($cached['context'])) {
$entry .= ' '.json_encode($cached['context']);
}
return Response::text($entry);
}
// Locate the correct log file using the shared helper.
$logFile = $this->resolveLogFilePath();
if (! file_exists($logFile)) {
return Response::error("Log file not found at {$logFile}");
}
$entry = $this->readLastErrorEntry($logFile);
if ($entry !== null) {
return Response::text(Str::limit($entry, 500, '... more logs', true));
}
return Response::error('Unable to find an ERROR entry in the inspected portion of the log file.');
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Boost\Concerns\ReadsLogs;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
class ReadLogEntries extends Tool
{
use ReadsLogs;
/**
* The tool's description.
*/
protected string $description = 'Read the last N log entries from the application log, correctly handling multi-line PSR-3 formatted logs and JSON-formatted logs. Only works for log files.';
/**
* Get the tool's input schema.
*
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'entries' => $schema->integer()
->description('Number of log entries to return.')
->required(),
];
}
/**
* Handle the tool request.
*/
public function handle(Request $request): Response
{
$maxEntries = (int) $request->get('entries');
if ($maxEntries <= 0) {
return Response::error('The "entries" argument must be greater than 0.');
}
// Determine log file path via helper.
$logFile = $this->resolveLogFilePath();
if (! file_exists($logFile)) {
return Response::error("Log file not found at {$logFile}");
}
$entries = $this->readLastLogEntries($logFile, $maxEntries);
if ($entries === []) {
return Response::text('Unable to retrieve log entries, or no entries yet.');
}
$logs = implode("\n\n", $entries);
if (empty(trim($logs))) {
return Response::text('No log entries yet.');
}
return Response::text($logs);
}
// The isNewLogEntry and readLinesReverse helper methods are now provided by the ReadsLogs trait.
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Mcp\Tools;
use Generator;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Boost\Concerns\MakesHttpRequests;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Laravel\Roster\Package;
use Laravel\Roster\Roster;
use Throwable;
class SearchDocs extends Tool
{
use MakesHttpRequests;
public function __construct(protected Roster $roster)
{
//
}
/**
* The tool's description.
*/
protected string $description = "Search for up-to-date version-specific documentation related to this project and its packages. This tool will search Laravel hosted documentation based on the packages installed and is perfect for all Laravel ecosystem packages. Laravel, Inertia, Pest, Livewire, Filament, Nova, Tailwind, and more. You must use this tool to search for Laravel-ecosystem docs before using other approaches. The results provided are for this project's package version and does not cover all versions of the package.";
/**
* Get the tool's input schema.
*
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'queries' => $schema->array()
->items($schema->string()->description('Search query'))
->description('List of queries to perform, pass multiple if you aren\'t sure if it is "toggle" or "switch", for example')
->required(),
'packages' => $schema->array()
->items($schema->string()->description("The composer package name (e.g., 'symfony/console')"))
->description('Package names to limit searching to from application-info. Useful if you know the package(s) you need. i.e. laravel/framework, inertiajs/inertia-laravel, @inertiajs/react'),
'token_limit' => $schema->integer()
->description('Maximum number of tokens to return in the response. Defaults to 3,000 tokens, maximum 1,000,000 tokens. If results are truncated, or you need more complete documentation, increase this value (e.g.5000, 10000)'),
];
}
/**
* Handle the tool request.
*/
public function handle(Request $request): Response|Generator
{
$apiUrl = config('boost.hosted.api_url', 'https://boost.laravel.com').'/api/docs';
$packagesFilter = $this->resolveArrayParam($request->get('packages'));
if ($packagesFilter instanceof Response) {
return $packagesFilter;
}
$rawQueries = $this->resolveArrayParam($request->get('queries'));
if ($rawQueries instanceof Response) {
return $rawQueries;
}
$queries = array_filter(
array_map(trim(...), $rawQueries),
fn (string $query): bool => $query !== '' && $query !== '*'
);
try {
$packagesCollection = $this->roster->packages();
// Only search in specific packages
if ($packagesFilter) {
$packagesCollection = $packagesCollection->filter(fn (Package $package): bool => in_array($package->rawName(), $packagesFilter, true));
}
$packages = $packagesCollection->map(function (Package $package): array {
$name = $package->rawName();
$version = $package->majorVersion().'.x';
return [
'name' => $name,
'version' => $version,
];
});
$packages = $packages->values()->toArray();
} catch (Throwable $throwable) {
return Response::error('Failed to get packages: '.$throwable->getMessage());
}
$tokenLimit = $request->get('token_limit') ?? 3000;
$tokenLimit = min($tokenLimit, 1000000); // Cap at 1M tokens
$payload = [
'queries' => $queries,
'packages' => $packages,
'token_limit' => $tokenLimit,
'format' => 'markdown',
];
try {
$response = $this->client()->asJson()->post($apiUrl, $payload);
if (! $response->successful()) {
return Response::error('Failed to search documentation: '.$response->body());
}
} catch (Throwable $throwable) {
return Response::error('HTTP request failed: '.$throwable->getMessage());
}
return Response::text($response->body());
}
/**
* @return array<int, mixed>|null|Response
*/
private function resolveArrayParam(mixed $value): array|null|Response
{
if (! is_string($value)) {
return $value;
}
$decoded = json_decode($value, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return Response::error('Invalid parameter: '.json_last_error_msg());
}
if (! is_array($decoded) || ! array_is_list($decoded)) {
return Response::error('Invalid parameter: expected a JSON array.');
}
return $decoded;
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Middleware;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Laravel\Boost\Services\BrowserLogger;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class InjectBoost
{
public function handle(Request $request, Closure $next): Response
{
/** @var Response $response */
$response = $next($request);
if ($this->shouldInject($request, $response)) {
$originalView = $response->original ?? null;
$injectedContent = $this->injectScript($response->getContent());
$response->setContent($injectedContent);
if ($originalView instanceof View && property_exists($response, 'original')) {
$response->original = $originalView;
}
}
return $response;
}
protected function shouldInject(Request $request, Response $response): bool
{
if ($request->headers->get('x-livewire-navigate') === '1') {
return false;
}
$responseTypes = [
StreamedResponse::class,
BinaryFileResponse::class,
JsonResponse::class,
RedirectResponse::class,
];
foreach ($responseTypes as $type) {
if ($response instanceof $type) {
return false;
}
}
if (! str_contains((string) $response->headers->get('content-type', ''), 'html')) {
return false;
}
$content = $response->getContent();
// Check if it's HTML
if (! str_contains($content, '<html') && ! str_contains($content, '<head')) {
return false;
}
// Check if already injected
return ! str_contains($content, 'browser-logger-active');
}
protected function injectScript(string $content): string
{
$script = BrowserLogger::getScript();
// Try to inject before closing </head>
if (str_contains($content, '</head>')) {
return str_replace('</head>', $script."\n</head>", $content);
}
// Fallback: inject before closing </body>
if (str_contains($content, '</body>')) {
return str_replace('</body>', $script."\n</body>", $content);
}
return $content.$script;
}
}

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Services;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Vite;
use Illuminate\View\ComponentAttributeBag;
class BrowserLogger
{
public static function getScript(): string
{
$endpoint = Route::has('boost.browser-logs')
? route('boost.browser-logs')
: '/_boost/browser-logs';
$attributes = new ComponentAttributeBag([
'id' => 'browser-logger-active',
]);
if ($nonce = Vite::cspNonce()) {
$attributes = $attributes->merge(['nonce' => $nonce]);
}
return <<<HTML
<script {$attributes->toHtml()}>
(function() {
const ENDPOINT = '{$endpoint}';
const logQueue = [];
let flushTimeout = null;
console.log('🔍 Browser logger active (MCP server detected). Posting to: ' + ENDPOINT);
// Store original console methods
const originalConsole = {
log: console.log,
info: console.info,
error: console.error,
warn: console.warn,
table: console.table
};
// Helper to safely stringify values
function safeStringify(obj) {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[Circular]';
seen.add(value);
}
if (value instanceof Error) {
return {
name: value.name,
message: value.message,
stack: value.stack
};
}
return value;
});
}
// Batch and send logs
function flushLogs() {
if (logQueue.length === 0) return;
const batch = logQueue.splice(0, logQueue.length);
fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ logs: batch })
}).catch(err => {
// Silently fail to avoid infinite loops
originalConsole.error('Failed to send logs:', err);
});
}
// Debounced flush (100ms)
function scheduleFlush() {
if (flushTimeout) clearTimeout(flushTimeout);
flushTimeout = setTimeout(flushLogs, 100);
}
// Intercept console methods
['log', 'info', 'error', 'warn', 'table'].forEach(method => {
console[method] = function(...args) {
// Call original method
originalConsole[method].apply(console, args);
// Capture log data
try {
logQueue.push({
type: method,
timestamp: new Date().toISOString(),
data: args.map(arg => {
try {
return typeof arg === 'object' ? JSON.parse(safeStringify(arg)) : arg;
} catch (e) {
return String(arg);
}
}),
url: window.location.href,
userAgent: navigator.userAgent
});
scheduleFlush();
} catch (e) {
// Fail silently
}
};
});
// Global error handlers for uncaught errors
const originalOnError = window.onerror;
window.onerror = function boostErrorHandler(errorMsg, url, lineNumber, colNumber, error) {
try {
logQueue.push({
type: 'uncaught_error',
timestamp: new Date().toISOString(),
data: [{
message: errorMsg,
filename: url,
lineno: lineNumber,
colno: colNumber,
error: error ? {
name: error.name,
message: error.message,
stack: error.stack
} : null
}],
url: window.location.href,
userAgent: navigator.userAgent
});
scheduleFlush();
} catch (e) {
// Fail silently
}
// Call original handler if it exists
if (originalOnError && typeof originalOnError === 'function') {
return originalOnError(errorMsg, url, lineNumber, colNumber, error);
}
// Let the error continue to propagate
return false;
}
window.addEventListener('error', (event) => {
try {
logQueue.push({
type: 'window_error',
timestamp: new Date().toISOString(),
data: [{
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error: event.error ? {
name: event.error.name,
message: event.error.message,
stack: event.error.stack
} : null
}],
url: window.location.href,
userAgent: navigator.userAgent
});
scheduleFlush();
} catch (e) {
// Fail silently
}
// Let the error continue to propagate
return false;
});
window.addEventListener('unhandledrejection', (event) => {
try {
logQueue.push({
type: 'error',
timestamp: new Date().toISOString(),
data: [{
message: 'Unhandled Promise Rejection',
reason: event.reason instanceof Error ? {
name: event.reason.name,
message: event.reason.message,
stack: event.reason.stack
} : event.reason
}],
url: window.location.href,
userAgent: navigator.userAgent
});
scheduleFlush();
} catch (e) {
// Fail silently
}
// Let the rejection continue to propagate
return false;
});
// Flush on page unload
window.addEventListener('beforeunload', () => {
if (logQueue.length > 0) {
navigator.sendBeacon(ENDPOINT, JSON.stringify({ logs: logQueue }));
}
});
})();
</script>
HTML;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Skills\Remote;
class AuditResult
{
public function __construct(
public string $partner,
public Risk $risk,
public ?int $alerts = null,
public ?string $analyzedAt = null,
) {
//
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Skills\Remote;
use Illuminate\Support\Str;
use InvalidArgumentException;
class GitHubRepository
{
public function __construct(public string $owner, public string $repo, public string $path = '')
{
//
}
/**
* @throws InvalidArgumentException
*/
public static function fromInput(string $input): self
{
$input = self::normalizeUrl($input);
return self::parseOwnerRepoPath($input);
}
public function fullName(): string
{
return $this->owner.'/'.$this->repo;
}
public function source(): string
{
return $this->path === ''
? $this->fullName()
: $this->fullName().'/'.$this->path;
}
/**
* @throws InvalidArgumentException
*/
private static function normalizeUrl(string $input): string
{
$isUrl = Str::startsWith($input, ['http://', 'https://']);
if (! $isUrl) {
return $input;
}
$parsed = parse_url($input);
$host = $parsed['host'] ?? '';
$isGitHubUrl = $host === 'github.com' || Str::endsWith($host, '.github.com');
if (! $isGitHubUrl) {
throw new InvalidArgumentException('Only GitHub URLs are supported.');
}
$path = Str::of($parsed['path'] ?? '')->trim('/')->toString();
if (Str::contains($path, '/tree/')) {
return Str::of($path)->replaceMatches('#/tree/[^/]+#', '')->toString();
}
return $path;
}
/**
* @throws InvalidArgumentException
*/
private static function parseOwnerRepoPath(string $input): self
{
$parts = explode('/', $input);
if (count($parts) < 2 || $parts[0] === '' || $parts[1] === '') {
throw new InvalidArgumentException('Invalid repository format. Expected: owner/repo, owner/repo/path, or GitHub URL');
}
return new self(
owner: $parts[0],
repo: $parts[1],
path: implode('/', array_slice($parts, 2)),
);
}
}

View File

@@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Skills\Remote;
use GuzzleHttp\Promise\EachPromise;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
class GitHubSkillProvider
{
protected ?string $defaultBranch = null;
/** @var array<string, mixed>|null */
protected ?array $cachedTree = null;
public function __construct(protected GitHubRepository $repository)
{
//
}
/**
* @return Collection<string, RemoteSkill>
*/
public function discoverSkills(): Collection
{
$tree = $this->fetchRepositoryTree();
if ($tree === null) {
return collect();
}
$basePath = $this->repository->path;
$skillMarkers = collect($tree['tree'])
->filter(fn (array $item): bool => $item['type'] === 'blob' && in_array(basename((string) $item['path']), ['SKILL.md', 'SKILL.blade.php'], true));
if ($basePath !== '') {
$prefix = $basePath.'/';
$skillMarkers = $skillMarkers->filter(function (array $item) use ($prefix): bool {
$skillDir = dirname((string) $item['path']);
return str_starts_with($skillDir, $prefix) && ! str_contains(substr($skillDir, strlen($prefix)), '/');
});
}
return $skillMarkers
->map(fn (array $item): RemoteSkill => new RemoteSkill(
name: basename(dirname((string) $item['path'])),
repo: $this->repository->fullName(),
path: dirname((string) $item['path']),
))
->keyBy(fn (RemoteSkill $skill): string => $skill->name);
}
public function downloadSkill(RemoteSkill $skill, string $targetPath): bool
{
$tree = $this->fetchRepositoryTree();
if ($tree === null) {
return false;
}
$skillFiles = $this->extractSkillFilesFromTree($tree['tree'], $skill->path);
if ($skillFiles->isEmpty()) {
return false;
}
if (! $this->ensureDirectoryExists($targetPath)) {
return false;
}
$files = $skillFiles->filter(fn (array $item): bool => $item['type'] === 'blob');
$directories = $skillFiles->filter(fn (array $item): bool => $item['type'] === 'tree');
foreach ($directories as $dir) {
$relativePath = $this->getRelativePath($dir['path'], $skill->path);
$localPath = $targetPath.'/'.$relativePath;
if (! $this->ensureDirectoryExists($localPath)) {
return false;
}
}
return $this->downloadFiles($files->toArray(), $targetPath, $skill->path);
}
/**
* @return array{tree: array<int, array<string, mixed>>, sha: string, url: string, truncated: bool}|null
*
* @throws RuntimeException
*/
protected function fetchRepositoryTree(): ?array
{
if ($this->cachedTree !== null) {
return $this->cachedTree;
}
$url = sprintf(
'https://api.github.com/repos/%s/%s/git/trees/%s?recursive=1',
$this->repository->owner,
$this->repository->repo,
urlencode($this->resolveDefaultBranch())
);
$response = $this->client()->get($url);
if ($response->status() === 403) {
$rateLimitRemaining = $response->header('X-RateLimit-Remaining');
$rateLimitReset = $response->header('X-RateLimit-Reset');
if ($rateLimitRemaining === '0') {
$resetTime = $rateLimitReset
? date('Y-m-d H:i:s', (int) $rateLimitReset)
: 'unknown';
throw new RuntimeException(
"GitHub API rate limit exceeded. Rate limit will reset at {$resetTime}. ".
'Configure a GitHub token via boost.github.token or services.github.token for higher limits (5000 req/hr vs 60 req/hr).'
);
}
}
if ($response->failed()) {
$errorMessage = $response->json('message') ?? 'Unknown error';
throw new RuntimeException(
"Failed to fetch repository tree from GitHub: {$errorMessage} (HTTP {$response->status()})"
);
}
$tree = $response->json();
if (! is_array($tree) || ! isset($tree['tree']) || ! is_array($tree['tree'])) {
throw new RuntimeException('Invalid response structure from GitHub Tree API');
}
/** @var array<string, mixed> $tree */
if (($tree['truncated'] ?? false) === true) {
Log::warning('GitHub tree response truncated (>100K entries). Some files may not be visible.', [
'repo' => $this->repository->fullName(),
'entries' => count($tree['tree']),
]);
}
/** @var array{tree: array<int, array<string, mixed>>, sha: string, url: string, truncated: bool} $tree */
$this->cachedTree = $tree;
return $tree;
}
/**
* @param array<int, array<string, mixed>> $tree
* @return Collection<int, array<string, mixed>>
*/
protected function extractSkillFilesFromTree(array $tree, string $skillPath): Collection
{
$prefix = $skillPath.'/';
return collect($tree)
->filter(fn (array $item): bool => str_starts_with((string) $item['path'], $prefix))
->values();
}
/**
* @param array<int, array<string, mixed>> $files
*/
protected function downloadFiles(array $files, string $targetPath, string $basePath): bool
{
$fileUrls = collect($files)->mapWithKeys(fn (array $item): array => [
$item['path'] => $this->buildRawFileUrl($item['path']),
]);
$responses = [];
$generator = (function () use ($fileUrls) {
foreach ($fileUrls as $path => $url) {
yield $path => $this->client(60)->async()->get($url);
}
})();
(new EachPromise($generator, [
'concurrency' => 25,
'fulfilled' => static function ($response, $path) use (&$responses): void {
$responses[$path] = $response;
},
'rejected' => static function ($reason, $path) use (&$responses): void {
$responses[$path] = $reason;
},
]))->promise()->wait();
foreach ($files as $item) {
$response = $responses[$item['path']] ?? null;
if ($response instanceof Throwable || $response === null || $response->failed()) {
return false;
}
$relativePath = $this->getRelativePath($item['path'], $basePath);
$localPath = $targetPath.'/'.$relativePath;
if (! $this->ensureDirectoryExists(dirname($localPath))) {
return false;
}
if (file_put_contents($localPath, $response->body()) === false) {
return false;
}
}
return true;
}
protected function buildRawFileUrl(string $path): string
{
return sprintf(
'https://raw.githubusercontent.com/%s/%s/%s/%s',
$this->repository->owner,
$this->repository->repo,
$this->resolveDefaultBranch(),
ltrim($path, '/')
);
}
protected function getRelativePath(string $fullPath, string $basePath): string
{
if (str_starts_with($fullPath, $basePath.'/')) {
return substr($fullPath, strlen($basePath.'/'));
}
return basename($fullPath);
}
protected function ensureDirectoryExists(string $path): bool
{
return is_dir($path) || @mkdir($path, 0755, true);
}
protected function client(int $timeout = 30): PendingRequest
{
$headers = [
'Accept' => 'application/vnd.github.v3+json',
'User-Agent' => 'Laravel-Boost',
];
$token = $this->getGitHubToken();
if ($token !== null) {
$headers['Authorization'] = "Bearer {$token}";
}
return Http::withHeaders($headers)->timeout($timeout);
}
protected function resolveDefaultBranch(): string
{
if ($this->defaultBranch !== null) {
return $this->defaultBranch;
}
$url = sprintf(
'https://api.github.com/repos/%s/%s',
$this->repository->owner,
$this->repository->repo
);
$response = $this->client(timeout: 15)->get($url);
$branch = $response->successful()
? $response->json('default_branch')
: null;
$this->defaultBranch = is_string($branch) ? $branch : 'main';
return $this->defaultBranch;
}
protected function getGitHubToken(): ?string
{
return config('boost.github.token') ?? config('services.github.token');
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Skills\Remote;
class RemoteSkill
{
public function __construct(public string $name, public string $repo, public string $path)
{
//
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Skills\Remote;
enum Risk: string
{
case Critical = 'critical';
case High = 'high';
case Medium = 'medium';
case Low = 'low';
case Safe = 'safe';
public function weight(): int
{
return match ($this) {
self::Critical => 5,
self::High => 4,
self::Medium => 3,
self::Low => 2,
self::Safe => 1,
};
}
public function label(): string
{
return match ($this) {
self::Critical => 'Critical Risk',
self::High => 'High Risk',
self::Medium => 'Med Risk',
self::Low => 'Low Risk',
self::Safe => 'Safe',
};
}
public function color(): string
{
return match ($this) {
self::Critical, self::High => 'red',
self::Medium => 'yellow',
self::Low, self::Safe => 'green',
};
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Skills\Remote;
use Illuminate\Support\Facades\Http;
class SkillAuditor
{
protected string $auditUrl = 'https://skills.laravel.cloud/api/v1/skills/audit';
protected int $timeoutSeconds = 3;
/**
* @param array<int, string> $skillSlugs
* @return array<string, array<int, AuditResult>>
*/
public function audit(string $source, array $skillSlugs): array
{
return rescue(function () use ($source, $skillSlugs): array {
$response = Http::timeout($this->timeoutSeconds)
->get($this->auditUrl, [
'source' => $source,
'skills' => implode(',', $skillSlugs),
]);
if ($response->failed()) {
return [];
}
$data = $response->json();
if (! is_array($data)) {
return [];
}
/** @var array<string, mixed> $data */
return $this->parseResponse($data);
}, [], report: false);
}
/**
* @param array<string, mixed> $data
* @return array<string, array<int, AuditResult>>
*/
protected function parseResponse(array $data): array
{
$results = [];
foreach ($data as $skill => $partners) {
if (! is_array($partners)) {
continue;
}
$skillResults = [];
foreach ($partners as $partner => $audit) {
if (! is_array($audit)) {
continue;
}
$risk = Risk::tryFrom((string) ($audit['risk'] ?? ''));
if ($risk === null) {
continue;
}
$skillResults[] = new AuditResult(
partner: (string) $partner,
risk: $risk,
alerts: isset($audit['alerts']) ? (int) $audit['alerts'] : null,
analyzedAt: isset($audit['analyzedAt']) ? (string) $audit['analyzedAt'] : null,
);
}
if ($skillResults !== []) {
$results[$skill] = $skillResults;
}
}
return $results;
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Support;
class CommandNormalizer
{
/**
* @param array<int, string> $args
* @return array{command: string, args: array<int, string>}
*/
public static function normalize(string $command, array $args = []): array
{
if (str_starts_with($command, '/') || preg_match('#^[a-zA-Z]:[/\\\\]#', $command)) {
return [
'command' => $command,
'args' => $args,
];
}
$parts = str($command)->explode(' ');
return [
'command' => $parts->first(),
'args' => $parts->skip(1)->values()->merge($args)->all(),
];
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Support;
class Composer
{
/** @var array<int, string> */
public const FIRST_PARTY_SCOPES = [
'laravel',
];
/** @var array<int, string> */
public const FIRST_PARTY_PACKAGES = [
'livewire/livewire',
'livewire/flux',
'livewire/flux-pro',
'livewire/volt',
'inertiajs/inertia-laravel',
'pestphp/pest',
'phpunit/phpunit',
];
public static function isFirstPartyPackage(string $composerName): bool
{
if (collect(self::FIRST_PARTY_SCOPES)->contains(fn (string $scope): bool => str_starts_with($composerName, $scope.'/'))) {
return true;
}
return in_array($composerName, self::FIRST_PARTY_PACKAGES, true);
}
public static function packagesDirectories(): array
{
return collect(static::packages())
->mapWithKeys(fn (string $key, string $package): array => [$package => implode(DIRECTORY_SEPARATOR, [
base_path('vendor'),
str_replace('/', DIRECTORY_SEPARATOR, $package),
])])
->filter(fn (string $path): bool => is_dir($path))
->toArray();
}
public static function packages(): array
{
$composerJsonPath = base_path('composer.json');
if (! file_exists($composerJsonPath)) {
return [];
}
$composerData = json_decode(file_get_contents($composerJsonPath), true);
if (json_last_error() !== JSON_ERROR_NONE) {
return [];
}
return collect($composerData['require'] ?? [])
->merge($composerData['require-dev'] ?? [])
->mapWithKeys(fn (string $key, string $package): array => [$package => $key])
->toArray();
}
public static function packagesDirectoriesWithBoostGuidelines(): array
{
return self::packagesDirectoriesWithBoostSubpath('guidelines');
}
public static function packagesDirectoriesWithBoostSkills(): array
{
return self::packagesDirectoriesWithBoostSubpath('skills');
}
/**
* @return array<string, string>
*/
private static function packagesDirectoriesWithBoostSubpath(string $subpath): array
{
return collect(self::packagesDirectories())
->map(fn (string $path): string => implode(DIRECTORY_SEPARATOR, array_filter([
$path,
'resources',
'boost',
$subpath,
])))
->filter(fn (string $path): bool => is_dir($path))
->toArray();
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Support;
use Illuminate\Support\Str;
class Config
{
protected const FILE = 'boost.json';
public function getGuidelines(): bool
{
return (bool) $this->get('guidelines', false);
}
public function setGuidelines(bool $enabled): void
{
$this->set('guidelines', $enabled);
}
/**
* @return array<int, string>
*/
public function getSkills(): array
{
return $this->get('skills', []);
}
/**
* @param array<int, string> $skills
*/
public function setSkills(array $skills): void
{
$this->set('skills', $skills);
}
public function hasSkills(): bool
{
return $this->getSkills() !== [];
}
public function getMcp(): bool
{
return $this->get('mcp', false);
}
public function setMcp(bool $enabled): void
{
$this->set('mcp', $enabled);
}
/**
* @return array<int, string>
*/
public function getPackages(): array
{
return $this->get('packages', []);
}
/**
* @param array<int, string> $packages
*/
public function setPackages(array $packages): void
{
$this->set('packages', $packages);
}
/**
* @param array<int, string> $agents
*/
public function setAgents(array $agents): void
{
$this->set('agents', $agents);
}
/**
* @return array<int, string>
*/
public function getAgents(): array
{
return $this->get('agents', []);
}
public function setNightwatch(bool $installed): void
{
$this->set('nightwatch', $installed);
}
public function getNightwatch(): bool
{
return (bool) $this->get('nightwatch', $this->get('nightwatch_mcp', false));
}
public function setCloud(bool $installed): void
{
$this->set('cloud', $installed);
}
public function getCloud(): bool
{
return $this->get('cloud', false);
}
public function setSail(bool $useSail): void
{
$this->set('sail', $useSail);
}
public function getSail(): bool
{
return $this->get('sail', false);
}
public function isValid(): bool
{
$path = base_path(self::FILE);
if (! file_exists($path)) {
return false;
}
json_decode(file_get_contents($path), true);
return json_last_error() === JSON_ERROR_NONE;
}
public function flush(): void
{
$path = base_path(self::FILE);
if (file_exists($path)) {
unlink($path);
}
}
protected function get(string $key, mixed $default = null): mixed
{
$config = $this->all();
return data_get($config, $key, $default);
}
protected function set(string $key, mixed $value): void
{
$config = array_filter($this->all(), fn ($value): bool => $value !== null && $value !== []);
data_set($config, $key, $value);
ksort($config);
$path = base_path(self::FILE);
file_put_contents($path, Str::of(json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))->append(PHP_EOL));
}
protected function all(): array
{
$path = base_path(self::FILE);
if (! file_exists($path)) {
return [];
}
$config = json_decode(file_get_contents($path), true);
if (json_last_error() !== JSON_ERROR_NONE) {
return [];
}
return $config ?? [];
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Laravel\Boost\Support;
class Npm
{
/** @var array<int, string> */
public const FIRST_PARTY_SCOPES = [
'@inertiajs',
'@laravel',
];
/** @var array<int, string> */
public const FIRST_PARTY_PACKAGES = [
'laravel-echo',
'laravel-precognition',
'laravel-vite-plugin',
];
public static function isFirstPartyPackage(string $npmName): bool
{
if (collect(self::FIRST_PARTY_SCOPES)->contains(fn (string $scope): bool => str_starts_with($npmName, $scope.'/'))) {
return true;
}
return in_array($npmName, self::FIRST_PARTY_PACKAGES, true);
}
/**
* @return array<string, string>
*/
public static function packagesDirectories(): array
{
return collect(static::packages())
->mapWithKeys(fn (string $key, string $package): array => [$package => implode(DIRECTORY_SEPARATOR, [
base_path('node_modules'),
str_replace('/', DIRECTORY_SEPARATOR, $package),
])])
->filter(fn (string $path): bool => is_dir($path))
->toArray();
}
/**
* @return array<string, string>
*/
public static function packages(): array
{
$packageJsonPath = base_path('package.json');
if (! file_exists($packageJsonPath)) {
return [];
}
$packageData = json_decode(file_get_contents($packageJsonPath), true);
if (json_last_error() !== JSON_ERROR_NONE) {
return [];
}
return collect($packageData['dependencies'] ?? [])
->merge($packageData['devDependencies'] ?? [])
->mapWithKeys(fn (string $key, string $package): array => [$package => $key])
->toArray();
}
/**
* @return array<string, string>
*/
public static function packagesDirectoriesWithBoostGuidelines(): array
{
return self::packagesDirectoriesWithBoostSubpath('guidelines');
}
/**
* @return array<string, string>
*/
public static function packagesDirectoriesWithBoostSkills(): array
{
return self::packagesDirectoriesWithBoostSubpath('skills');
}
/**
* @return array<string, string>
*/
private static function packagesDirectoriesWithBoostSubpath(string $subpath): array
{
return collect(self::packagesDirectories())
->map(fn (string $path): string => implode(DIRECTORY_SEPARATOR, array_filter([
$path,
'resources',
'boost',
$subpath,
])))
->filter(fn (string $path): bool => is_dir($path))
->toArray();
}
}