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

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()})";
}
}