refactor: susun semula struktur folder — Laravel source ke src/
This commit is contained in:
284
vendor/laravel/boost/src/Install/Agents/Agent.php
vendored
Normal file
284
vendor/laravel/boost/src/Install/Agents/Agent.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
70
vendor/laravel/boost/src/Install/Agents/Amp.php
vendored
Normal file
70
vendor/laravel/boost/src/Install/Agents/Amp.php
vendored
Normal 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');
|
||||
}
|
||||
}
|
||||
64
vendor/laravel/boost/src/Install/Agents/ClaudeCode.php
vendored
Normal file
64
vendor/laravel/boost/src/Install/Agents/ClaudeCode.php
vendored
Normal 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');
|
||||
}
|
||||
}
|
||||
90
vendor/laravel/boost/src/Install/Agents/Codex.php
vendored
Normal file
90
vendor/laravel/boost/src/Install/Agents/Codex.php
vendored
Normal 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');
|
||||
}
|
||||
}
|
||||
74
vendor/laravel/boost/src/Install/Agents/Copilot.php
vendored
Normal file
74
vendor/laravel/boost/src/Install/Agents/Copilot.php
vendored
Normal 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');
|
||||
}
|
||||
}
|
||||
76
vendor/laravel/boost/src/Install/Agents/Cursor.php
vendored
Normal file
76
vendor/laravel/boost/src/Install/Agents/Cursor.php
vendored
Normal 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');
|
||||
}
|
||||
}
|
||||
76
vendor/laravel/boost/src/Install/Agents/Gemini.php
vendored
Normal file
76
vendor/laravel/boost/src/Install/Agents/Gemini.php
vendored
Normal 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');
|
||||
}
|
||||
}
|
||||
83
vendor/laravel/boost/src/Install/Agents/Junie.php
vendored
Normal file
83
vendor/laravel/boost/src/Install/Agents/Junie.php
vendored
Normal 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');
|
||||
}
|
||||
}
|
||||
74
vendor/laravel/boost/src/Install/Agents/Kiro.php
vendored
Normal file
74
vendor/laravel/boost/src/Install/Agents/Kiro.php
vendored
Normal 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');
|
||||
}
|
||||
}
|
||||
99
vendor/laravel/boost/src/Install/Agents/OpenCode.php
vendored
Normal file
99
vendor/laravel/boost/src/Install/Agents/OpenCode.php
vendored
Normal 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');
|
||||
}
|
||||
}
|
||||
60
vendor/laravel/boost/src/Install/AgentsDetector.php
vendored
Normal file
60
vendor/laravel/boost/src/Install/AgentsDetector.php
vendored
Normal 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));
|
||||
}
|
||||
}
|
||||
58
vendor/laravel/boost/src/Install/Assists/Inertia.php
vendored
Normal file
58
vendor/laravel/boost/src/Install/Assists/Inertia.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
23
vendor/laravel/boost/src/Install/Cloud.php
vendored
Normal file
23
vendor/laravel/boost/src/Install/Cloud.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
112
vendor/laravel/boost/src/Install/Concerns/DiscoverPackagePaths.php
vendored
Normal file
112
vendor/laravel/boost/src/Install/Concerns/DiscoverPackagePaths.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
17
vendor/laravel/boost/src/Install/Contracts/DetectionStrategy.php
vendored
Normal file
17
vendor/laravel/boost/src/Install/Contracts/DetectionStrategy.php
vendored
Normal 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;
|
||||
}
|
||||
21
vendor/laravel/boost/src/Install/Detection/CommandDetectionStrategy.php
vendored
Normal file
21
vendor/laravel/boost/src/Install/Detection/CommandDetectionStrategy.php
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
30
vendor/laravel/boost/src/Install/Detection/CompositeDetectionStrategy.php
vendored
Normal file
30
vendor/laravel/boost/src/Install/Detection/CompositeDetectionStrategy.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
68
vendor/laravel/boost/src/Install/Detection/DetectionStrategyFactory.php
vendored
Normal file
68
vendor/laravel/boost/src/Install/Detection/DetectionStrategyFactory.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
65
vendor/laravel/boost/src/Install/Detection/DirectoryDetectionStrategy.php
vendored
Normal file
65
vendor/laravel/boost/src/Install/Detection/DirectoryDetectionStrategy.php
vendored
Normal 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:
|
||||
}
|
||||
}
|
||||
26
vendor/laravel/boost/src/Install/Detection/FileDetectionStrategy.php
vendored
Normal file
26
vendor/laravel/boost/src/Install/Detection/FileDetectionStrategy.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
12
vendor/laravel/boost/src/Install/Enums/McpInstallationStrategy.php
vendored
Normal file
12
vendor/laravel/boost/src/Install/Enums/McpInstallationStrategy.php
vendored
Normal 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';
|
||||
}
|
||||
21
vendor/laravel/boost/src/Install/Enums/Platform.php
vendored
Normal file
21
vendor/laravel/boost/src/Install/Enums/Platform.php
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
209
vendor/laravel/boost/src/Install/GuidelineAssist.php
vendored
Normal file
209
vendor/laravel/boost/src/Install/GuidelineAssist.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
380
vendor/laravel/boost/src/Install/GuidelineComposer.php
vendored
Normal file
380
vendor/laravel/boost/src/Install/GuidelineComposer.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
27
vendor/laravel/boost/src/Install/GuidelineConfig.php
vendored
Normal file
27
vendor/laravel/boost/src/Install/GuidelineConfig.php
vendored
Normal 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;
|
||||
}
|
||||
125
vendor/laravel/boost/src/Install/GuidelineWriter.php
vendored
Normal file
125
vendor/laravel/boost/src/Install/GuidelineWriter.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
37
vendor/laravel/boost/src/Install/Herd.php
vendored
Normal file
37
vendor/laravel/boost/src/Install/Herd.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
26
vendor/laravel/boost/src/Install/MarkdownFormatter.php
vendored
Normal file
26
vendor/laravel/boost/src/Install/MarkdownFormatter.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
462
vendor/laravel/boost/src/Install/Mcp/FileWriter.php
vendored
Normal file
462
vendor/laravel/boost/src/Install/Mcp/FileWriter.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
177
vendor/laravel/boost/src/Install/Mcp/TomlFileWriter.php
vendored
Normal file
177
vendor/laravel/boost/src/Install/Mcp/TomlFileWriter.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
74
vendor/laravel/boost/src/Install/McpWriter.php
vendored
Normal file
74
vendor/laravel/boost/src/Install/McpWriter.php
vendored
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
22
vendor/laravel/boost/src/Install/Nightwatch.php
vendored
Normal file
22
vendor/laravel/boost/src/Install/Nightwatch.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
77
vendor/laravel/boost/src/Install/Sail.php
vendored
Normal file
77
vendor/laravel/boost/src/Install/Sail.php
vendored
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
34
vendor/laravel/boost/src/Install/Skill.php
vendored
Normal file
34
vendor/laravel/boost/src/Install/Skill.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
260
vendor/laravel/boost/src/Install/SkillComposer.php
vendored
Normal file
260
vendor/laravel/boost/src/Install/SkillComposer.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
341
vendor/laravel/boost/src/Install/SkillWriter.php
vendored
Normal file
341
vendor/laravel/boost/src/Install/SkillWriter.php
vendored
Normal 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) !== '';
|
||||
}
|
||||
}
|
||||
60
vendor/laravel/boost/src/Install/ThirdPartyPackage.php
vendored
Normal file
60
vendor/laravel/boost/src/Install/ThirdPartyPackage.php
vendored
Normal 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()})";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user