diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index 5d19609..02b556b 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -9,9 +9,11 @@ use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use InvalidArgumentException; use Laravel\Boost\Contracts\Agent; -use Laravel\Boost\Contracts\Ide; +use Laravel\Boost\Contracts\McpClient; use Laravel\Boost\Install\Cli\DisplayHelper; +use Laravel\Boost\Install\CodeEnvironment\CodeEnvironment; use Laravel\Boost\Install\CodeEnvironmentsDetector; use Laravel\Boost\Install\GuidelineComposer; use Laravel\Boost\Install\GuidelineConfig; @@ -19,7 +21,6 @@ use Laravel\Boost\Install\Herd; use Laravel\Prompts\Concerns\Colors; use Laravel\Prompts\Terminal; -use ReflectionClass; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Finder\Finder; @@ -42,8 +43,8 @@ class InstallCommand extends Command /** @var Collection */ private Collection $selectedTargetAgents; - /** @var Collection */ - private Collection $selectedTargetIdes; + /** @var Collection */ + private Collection $selectedTargetMcpClient; /** @var Collection */ private Collection $selectedBoostFeatures; @@ -57,8 +58,6 @@ class InstallCommand extends Command private bool $enforceTests = true; - private array $projectInstalledAgents = []; - private string $greenTick; private string $redCross; @@ -70,7 +69,7 @@ public function handle(CodeEnvironmentsDetector $codeEnvironmentsDetector, Herd $this->displayBoostHeader(); $this->discoverEnvironment(); $this->collectInstallationPreferences(); - $this->enact(); + $this->performInstallation(); $this->outro(); } @@ -85,7 +84,7 @@ private function bootstrap(CodeEnvironmentsDetector $codeEnvironmentsDetector, H $this->redCross = $this->red('✗'); $this->selectedTargetAgents = collect(); - $this->selectedTargetIdes = collect(); + $this->selectedTargetMcpClient = collect(); $this->projectName = basename(base_path()); } @@ -114,27 +113,24 @@ private function discoverEnvironment(): void { $this->systemInstalledCodeEnvironments = $this->codeEnvironmentsDetector->discoverSystemInstalledCodeEnvironments(); $this->projectInstalledCodeEnvironments = $this->codeEnvironmentsDetector->discoverProjectInstalledCodeEnvironments(base_path()); - $this->projectInstalledAgents = $this->discoverProjectAgents(); } private function collectInstallationPreferences(): void { $this->selectedBoostFeatures = $this->selectBoostFeatures(); $this->enforceTests = $this->determineTestEnforcement(ask: false); - $this->selectedTargetIdes = $this->selectTargetIdes(); + $this->selectedTargetMcpClient = $this->selectTargetMcpClients(); $this->selectedTargetAgents = $this->selectTargetAgents(); } - private function enact(): void + private function performInstallation(): void { - if ($this->shouldInstallAiGuidelines() && ! empty($this->selectedTargetAgents)) { - $this->enactGuidelines(); - } + $this->installGuidelines(); usleep(750000); - if (($this->shouldInstallMcp() || $this->shouldInstallHerdMcp()) && $this->selectedTargetIdes->isNotEmpty()) { - $this->enactMcpServers(); + if (($this->shouldInstallMcp() || $this->shouldInstallHerdMcp()) && $this->selectedTargetMcpClient->isNotEmpty()) { + $this->installMcpServerConfig(); } } @@ -148,9 +144,9 @@ private function discoverTools(): array ->name('*.php'); foreach ($finder as $toolFile) { - $fqdn = 'Laravel\\Boost\\Mcp\\Tools\\'.$toolFile->getBasename('.php'); - if (class_exists($fqdn)) { - $tools[$fqdn] = Str::headline($toolFile->getBasename('.php')); + $fullyClassifiedClassName = 'Laravel\\Boost\\Mcp\\Tools\\'.$toolFile->getBasename('.php'); + if (class_exists($fullyClassifiedClassName)) { + $tools[$fullyClassifiedClassName] = Str::headline($toolFile->getBasename('.php')); } } @@ -163,11 +159,11 @@ private function outro(): void { $label = 'https://boost.laravel.com/installed'; - $ideNames = $this->selectedTargetIdes->map(fn ($ide) => 'i:'.class_basename($ide))->toArray(); - $agentNames = $this->selectedTargetAgents->map(fn ($agent) => 'a:'.class_basename($agent))->toArray(); + $ideNames = $this->selectedTargetMcpClient->map(fn (McpClient $mcpClient) => 'i:'.$mcpClient->mcpClientName()) + ->toArray(); + $agentNames = $this->selectedTargetAgents->map(fn (Agent $agent) => 'a:'.$agent->agentName())->toArray(); $boostFeatures = $this->selectedBoostFeatures->map(fn ($feature) => 'b:'.$feature)->toArray(); - // Guidelines installed (prefix: g) $guidelines = []; if ($this->shouldInstallAiGuidelines()) { $guidelines[] = 'g:ai'; @@ -177,10 +173,7 @@ private function outro(): void $guidelines[] = 'g:style'; } - // Combine all data $allData = array_merge($ideNames, $agentNames, $boostFeatures, $guidelines); - - // Create a compact CSV string and base64 encode $installData = base64_encode(implode(',', $allData)); $link = $this->hyperlink($label, 'https://boost.laravel.com/installed/?d='.$installData); @@ -229,20 +222,22 @@ private function selectBoostFeatures(): Collection { $defaultInstallOptions = ['mcp_server', 'ai_guidelines']; $installOptions = [ - 'mcp_server' => 'Boost MCP Server', - 'ai_guidelines' => 'Package AI Guidelines (i.e. Framework, Inertia, Pest)', + 'mcp_server' => 'Boost MCP Server (with 15+ tools)', + 'ai_guidelines' => 'Boost AI Guidelines (for Laravel, Inertia, and more)', ]; if ($this->herd->isMcpAvailable()) { $installOptions['herd_mcp'] = 'Herd MCP Server'; + + return collect(multiselect( + label: 'What shall we install?', + options: $installOptions, + default: $defaultInstallOptions, + required: true, + )); } - return collect(multiselect( - label: 'What shall we install?', - options: $installOptions, - default: $defaultInstallOptions, - required: true, - )); + return collect(['mcp_server', 'ai_guidelines']); } /** @@ -261,147 +256,123 @@ protected function boostToolsToDisable(): array /** * @return array */ - private function discoverProjectAgents(): array - { - $agents = []; - $projectAgents = $this->codeEnvironmentsDetector->discoverProjectInstalledCodeEnvironments(base_path()); - - // Map IDE detections to their corresponding agents - $ideToAgentMap = [ - 'phpstorm' => 'junie', - 'claudecode' => 'claudecode', - 'cursor' => 'cursor', - 'copilot' => 'copilot', - ]; - - foreach ($projectAgents as $app) { - if (isset($ideToAgentMap[$app])) { - $agents[] = $ideToAgentMap[$app]; - } - } - - foreach ($this->systemInstalledCodeEnvironments as $ide) { - if (isset($ideToAgentMap[$ide]) && ! in_array($ideToAgentMap[$ide], $agents)) { - $agents[] = $ideToAgentMap[$ide]; - } - } - - return array_unique($agents); - } /** - * @return Collection + * @return Collection */ - private function selectTargetIdes(): Collection + private function selectTargetMcpClients(): Collection { - $ides = []; if (! $this->shouldInstallMcp() && ! $this->shouldInstallHerdMcp()) { return collect(); } - $agentDir = implode(DIRECTORY_SEPARATOR, [__DIR__, '..', 'Install', 'Agents']); - - $finder = Finder::create() - ->in($agentDir) - ->files() - ->name('*.php'); - - foreach ($finder as $ideFile) { - $className = 'Laravel\\Boost\\Install\\Agents\\'.$ideFile->getBasename('.php'); - - if (class_exists($className)) { - $reflection = new ReflectionClass($className); - - if ($reflection->implementsInterface(Ide::class) && ! $reflection->isAbstract()) { - $ides[$className] = Str::headline($ideFile->getBasename('.php')); - } - } - } - - ksort($ides); - - $detectedClasses = []; - foreach ($this->projectInstalledCodeEnvironments as $ideKey) { - foreach ($ides as $className => $displayName) { - if (strtolower($ideKey) === strtolower(class_basename($className))) { - $detectedClasses[] = $className; - break; - } - } - } - - $selectedIdeClasses = collect(multiselect( - label: sprintf('Which code editors do you use in %s?', $this->projectName), - options: $ides, - default: $detectedClasses, - scroll: 5, - required: true, - hint: sprintf('Auto-detected %s for you', Arr::join(array_map(fn ($c) => class_basename($c), $detectedClasses), ', ', ' & ')) - ))->sort(); - - return $selectedIdeClasses->map(fn ($ideClass) => new $ideClass); + return $this->selectCodeEnvironments( + McpClient::class, + sprintf('Which code editors do you use in %s?', $this->projectName) + ); } /** - * @return Collection + * @return Collection */ private function selectTargetAgents(): Collection { - $agents = []; if (! $this->shouldInstallAiGuidelines()) { return collect(); } - $agentDir = implode(DIRECTORY_SEPARATOR, [__DIR__, '..', 'Install', 'Agents']); + return $this->selectCodeEnvironments( + Agent::class, + sprintf('Which agents need AI guidelines for %s?', $this->projectName) + ); + } - $finder = Finder::create() - ->in($agentDir) - ->files() - ->name('*.php'); + /** + * Get configuration settings for contract-specific selection behavior. + * + * @return array{scroll: int, required: bool, displayMethod: string} + */ + private function getSelectionConfig(string $contractClass): array + { + return match ($contractClass) { + Agent::class => ['scroll' => 4, 'required' => false, 'displayMethod' => 'agentName'], + McpClient::class => ['scroll' => 5, 'required' => true, 'displayMethod' => 'displayName'], + default => throw new InvalidArgumentException("Unsupported contract class: {$contractClass}"), + }; + } - foreach ($finder as $agentFile) { - $className = 'Laravel\\Boost\\Install\\Agents\\'.$agentFile->getBasename('.php'); + /** + * @return Collection + */ + private function selectCodeEnvironments(string $contractClass, string $label): Collection + { + $allEnvironments = $this->codeEnvironmentsDetector->getCodeEnvironments(); + $config = $this->getSelectionConfig($contractClass); - if (class_exists($className)) { - $reflection = new ReflectionClass($className); + $availableEnvironments = $allEnvironments->filter(function (CodeEnvironment $environment) use ($contractClass) { + return $environment instanceof $contractClass; + }); - if ($reflection->implementsInterface(Agent::class)) { - $agents[$className] = Str::headline($agentFile->getBasename('.php')); - } - } + if ($availableEnvironments->isEmpty()) { + return collect(); } - ksort($agents); + $options = $availableEnvironments + ->filter(function (CodeEnvironment $environment) { + // We only show Zed if it's actually installed + if ($environment->name() === 'zed' && ! in_array('zed', $this->systemInstalledCodeEnvironments)) { + return false; + } + + return true; + }) + ->mapWithKeys(function (CodeEnvironment $environment) use ($config) { + $displayMethod = $config['displayMethod']; + $displayText = $environment->{$displayMethod}(); + + return [get_class($environment) => $displayText]; + })->sort(); - // Map detected agent keys to class names $detectedClasses = []; - foreach ($this->projectInstalledAgents as $agentKey) { - foreach ($agents as $className => $displayName) { - if (strtolower($agentKey) === strtolower(class_basename($className))) { - $detectedClasses[] = $className; - break; - } + $installedEnvNames = array_unique(array_merge( + $this->projectInstalledCodeEnvironments, + $this->systemInstalledCodeEnvironments + )); + + foreach ($installedEnvNames as $envKey) { + $matchingEnv = $availableEnvironments->first(fn (CodeEnvironment $env) => strtolower($envKey) === strtolower($env->name())); + if ($matchingEnv && ($options->contains($matchingEnv->displayName() || $options->contains($matchingEnv->agentName())))) { + $detectedClasses[] = get_class($matchingEnv); } } - $selectedAgentClasses = collect(multiselect( - label: sprintf('Which agents need AI guidelines for %s?', $this->projectName), - options: $agents, - default: $detectedClasses, - scroll: 4, + $selectedClasses = collect(multiselect( + label: $label, + options: $options->toArray(), + default: array_unique($detectedClasses), + scroll: $config['scroll'], + required: $config['required'], + hint: empty($detectedClasses) ? '' : sprintf('Auto-detected %s for you', + Arr::join(array_map(function ($className) use ($availableEnvironments, $config) { + $env = $availableEnvironments->first(fn ($env) => get_class($env) === $className); + $displayMethod = $config['displayMethod']; + + return $env->{$displayMethod}(); + }, $detectedClasses), ', ', ' & ') + ) ))->sort(); - return $selectedAgentClasses->map(fn ($agentClass) => new $agentClass); + return $selectedClasses->map(fn ($className) => $availableEnvironments->first(fn ($env) => get_class($env) === $className)); } - protected function enactGuidelines(): void + private function installGuidelines(): void { if (! $this->shouldInstallAiGuidelines()) { return; } if ($this->selectedTargetAgents->isEmpty()) { - $this->info('No agents selected for guideline installation.'); + $this->info(' No agents selected for guideline installation.'); return; } @@ -424,12 +395,13 @@ protected function enactGuidelines(): void $failed = []; $composedAiGuidelines = $composer->compose(); - $longestAgentName = max(1, ...$this->selectedTargetAgents->map(fn ($agent) => Str::length(class_basename($agent)))->toArray()); + $longestAgentName = max(1, ...$this->selectedTargetAgents->map(fn ($agent) => Str::length($agent->agentName()))->toArray()); + /** @var CodeEnvironment $agent */ foreach ($this->selectedTargetAgents as $agent) { - $agentName = class_basename($agent); + $agentName = $agent->agentName(); $displayAgentName = str_pad($agentName, $longestAgentName); $this->output->write(" {$displayAgentName}... "); - + /** @var Agent $agent */ try { (new GuidelineWriter($agent)) ->write($composedAiGuidelines); @@ -474,8 +446,17 @@ private function shouldInstallHerdMcp(): bool return $this->selectedBoostFeatures->contains('herd_mcp'); } - private function enactMcpServers(): void + private function installMcpServerConfig(): void { + if (! $this->shouldInstallMcp() && ! $this->shouldInstallHerdMcp()) { + return; + } + + if ($this->selectedTargetMcpClient->isEmpty()) { + $this->info('No agents selected for guideline installation.'); + + return; + } $this->newLine(); $this->info(' Installing MCP servers to your selected IDEs'); $this->newLine(); @@ -483,18 +464,22 @@ private function enactMcpServers(): void usleep(750000); $failed = []; - $longestIdeName = max(1, ...$this->selectedTargetIdes->map(fn ($ide) => Str::length(class_basename($ide)))->toArray()); + $longestIdeName = max( + 1, + ...$this->selectedTargetMcpClient->map( + fn (McpClient $mcpClient) => Str::length($mcpClient->mcpClientName()) + )->toArray() + ); - foreach ($this->selectedTargetIdes as $ide) { - $ideName = class_basename($ide); + foreach ($this->selectedTargetMcpClient as $mcpClient) { + $ideName = $mcpClient->mcpClientName(); $ideDisplay = str_pad($ideName, $longestIdeName); $this->output->write(" {$ideDisplay}... "); $results = []; - // Install Laravel Boost MCP if enabled if ($this->shouldInstallMcp()) { try { - $result = $ide->installMcp('laravel-boost', 'php', ['./artisan', 'boost:mcp']); + $result = $mcpClient->installMcp('laravel-boost', 'php', ['./artisan', 'boost:mcp']); if ($result) { $results[] = $this->greenTick.' Boost'; @@ -511,7 +496,7 @@ private function enactMcpServers(): void // Install Herd MCP if enabled if ($this->shouldInstallHerdMcp()) { try { - $result = $ide->installMcp( + $result = $mcpClient->installMcp( key: 'herd', command: 'php', args: [$this->herd->mcpPath()], diff --git a/src/Contracts/Agent.php b/src/Contracts/Agent.php index 59bdc10..75a55b0 100644 --- a/src/Contracts/Agent.php +++ b/src/Contracts/Agent.php @@ -4,10 +4,29 @@ namespace Laravel\Boost\Contracts; -// We give Agents AI Rules +/** + * Agent contract for AI coding assistants that receive guidelines. + */ interface Agent { + /** + * Get the display name of the Agent. + * + * @return string|null + */ + public function agentName(): ?string; + + /** + * Get the file path where AI guidelines should be written. + * + * @return string The relative or absolute path to the guideline file + */ public function guidelinesPath(): string; + /** + * Determine if the guideline file requires frontmatter. + * + * @return bool + */ public function frontmatter(): bool; } diff --git a/src/Contracts/Ide.php b/src/Contracts/Ide.php deleted file mode 100644 index 2186d13..0000000 --- a/src/Contracts/Ide.php +++ /dev/null @@ -1,17 +0,0 @@ - $args - * @param array $env - */ - public function installMcp(string $key, string $command, array $args = [], array $env = []): bool; -} diff --git a/src/Contracts/McpClient.php b/src/Contracts/McpClient.php new file mode 100644 index 0000000..74629d3 --- /dev/null +++ b/src/Contracts/McpClient.php @@ -0,0 +1,29 @@ + $args Command line arguments + * @param array $env Environment variables + * @return bool True if installation succeeded, false otherwise + */ + public function installMcp(string $key, string $command, array $args = [], array $env = []): bool; +} diff --git a/src/Install/Agents/ClaudeCode.php b/src/Install/Agents/ClaudeCode.php deleted file mode 100644 index 00e480f..0000000 --- a/src/Install/Agents/ClaudeCode.php +++ /dev/null @@ -1,22 +0,0 @@ - $args - * @param array $env - */ - public function installMcp(string $key, string $command, array $args = [], array $env = []): bool - { - $path = $this->mcpPath(); - - // Ensure directory exists - $directory = dirname($path); - if (! is_dir($directory)) { - if (! mkdir($directory, 0755, true)) { - return false; - } - } - - // Read existing configuration or create new one - $config = []; - if (file_exists($path)) { - $content = file_get_contents($path); - if ($content !== false) { - $decoded = json_decode($content, true); - if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { - $config = $decoded; - } - } - } - - // Ensure mcpServers key exists - if (! isset($config[$this->jsonMcpKey])) { - $config[$this->jsonMcpKey] = []; - } - - // Add or update laravel-boost server configuration - $config[$this->jsonMcpKey][$key] = array_filter([ - 'command' => $command, - 'args' => $args, - 'env' => $env, - ]); - - // Write configuration back to file - $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - if ($json === false) { - return false; - } - - $result = file_put_contents($path, $json); - - return $result !== false; - } -} diff --git a/src/Install/Agents/Junie.php b/src/Install/Agents/Junie.php deleted file mode 100644 index 4d8d99e..0000000 --- a/src/Install/Agents/Junie.php +++ /dev/null @@ -1,20 +0,0 @@ - $args - * @param array $env - */ - public function installMcp(string $key, string $command, array $args = [], array $env = []): bool - { - // -e, --env Set environment variables (e.g. -e KEY=value) - $envString = ''; - foreach ($env as $envKey => $value) { - $envKey = strtoupper($envKey); - $envString .= "-e {$envKey}=\"{$value}\" "; - } - - $command = str_replace([ - '{key}', - '{command}', - '{args}', - '{env}', - ], [ - $key, - $command, - implode(' ', array_map(fn ($arg) => '"'.$arg.'"', $args)), - trim($envString), - ], $this->shellCommand); - - $result = Process::run($command); - - return $result->successful() || $result->seeInErrorOutput('already exists'); - } -} diff --git a/src/Install/Agents/VsCode.php b/src/Install/Agents/VsCode.php deleted file mode 100644 index b580568..0000000 --- a/src/Install/Agents/VsCode.php +++ /dev/null @@ -1,15 +0,0 @@ - ['CLAUDE.md'], ]; } + + public function mcpInstallationStrategy(): McpInstallationStrategy + { + return McpInstallationStrategy::SHELL; + } + + public function shellMcpCommand(): string + { + return 'claude mcp add -s local -t stdio {key} "{command}" {args} {env}'; + } + + public function guidelinesPath(): string + { + return 'CLAUDE.md'; + } } diff --git a/src/Install/CodeEnvironment/CodeEnvironment.php b/src/Install/CodeEnvironment/CodeEnvironment.php index 95c6299..579daa7 100644 --- a/src/Install/CodeEnvironment/CodeEnvironment.php +++ b/src/Install/CodeEnvironment/CodeEnvironment.php @@ -4,51 +4,50 @@ namespace Laravel\Boost\Install\CodeEnvironment; +use Illuminate\Contracts\Filesystem\FileNotFoundException; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Process; +use Laravel\Boost\Contracts\Agent; +use Laravel\Boost\Contracts\McpClient; use Laravel\Boost\Install\Detection\DetectionStrategyFactory; +use Laravel\Boost\Install\Enums\McpInstallationStrategy; use Laravel\Boost\Install\Enums\Platform; abstract class CodeEnvironment { - public function __construct( - protected readonly DetectionStrategyFactory $strategyFactory - ) { + public function __construct(protected readonly DetectionStrategyFactory $strategyFactory) + { } - /** - * Get the internal identifier name for this code environment. - * - * @return string - */ abstract public function name(): string; - /** - * Get the human-readable display name for this code environment. - * - * @return string - */ abstract public function displayName(): string; + public function agentName(): ?string + { + return $this->displayName(); + } + + public function mcpClientName(): ?string + { + return $this->displayName(); + } + /** * Get the detection configuration for system-wide installation detection. * - * @param \Laravel\Boost\Install\Enums\Platform $platform - * @return array + * @param Platform $platform + * @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 + * @return array{paths?: string[], files?: string[]} */ abstract public function projectDetectionConfig(): array; - /** - * Determine if this code environment is installed on the system. - * - * @param \Laravel\Boost\Install\Enums\Platform $platform - * @return bool - */ public function detectOnSystem(Platform $platform): bool { $config = $this->systemDetectionConfig($platform); @@ -57,12 +56,6 @@ public function detectOnSystem(Platform $platform): bool return $strategy->detect($config, $platform); } - /** - * Determine if this code environment is being used in a specific project. - * - * @param string $basePath - * @return bool - */ public function detectInProject(string $basePath): bool { $config = array_merge($this->projectDetectionConfig(), ['basePath' => $basePath]); @@ -70,4 +63,136 @@ public function detectInProject(string $basePath): bool return $strategy->detect($config); } + + public function IsAgent(): bool + { + return $this->agentName() && $this instanceof Agent; + } + + public function isMcpClient(): bool + { + return $this->mcpClientName() && $this instanceof McpClient; + } + + public function mcpInstallationStrategy(): McpInstallationStrategy + { + return McpInstallationStrategy::FILE; + } + + public function shellMcpCommand(): ?string + { + return null; + } + + public function mcpConfigPath(): ?string + { + return null; + } + + public function frontmatter(): bool + { + return false; + } + + public function mcpConfigKey(): string + { + return 'mcpServers'; + } + + /** + * Install MCP server using the appropriate strategy. + * + * @param string $key + * @param string $command + * @param array $args + * @param array $env + * @return bool + * + * @throws FileNotFoundException + */ + 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 + }; + } + + /** + * Install MCP server using a shell command strategy. + * + * @param string $key + * @param string $command + * @param array $args + * @param array $env + * @return bool + */ + protected function installShellMcp(string $key, string $command, array $args = [], array $env = []): bool + { + $shellCommand = $this->shellMcpCommand(); + if ($shellCommand === null) { + return false; + } + + // 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, + $command, + implode(' ', array_map(fn ($arg) => '"'.$arg.'"', $args)), + trim($envString), + ], $shellCommand); + + $result = Process::run($command); + + return $result->successful() || str_contains($result->errorOutput(), 'already exists'); + } + + /** + * Install MCP server using a file-based configuration strategy. + * + * @param string $key + * @param string $command + * @param array $args + * @param array $env + * @return bool + * + * @throws FileNotFoundException + */ + protected function installFileMcp(string $key, string $command, array $args = [], array $env = []): bool + { + $path = $this->mcpConfigPath(); + if (! $path) { + return false; + } + + File::ensureDirectoryExists(dirname($path)); + + $config = File::exists($path) + ? json_decode(File::get($path), true) ?: [] + : []; + + $mcpKey = $this->mcpConfigKey(); + data_set($config, "{$mcpKey}.{$key}", collect([ + 'command' => $command, + 'args' => $args, + 'env' => $env, + ])->filter()->toArray()); + + $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + return $json && File::put($path, $json); + } } diff --git a/src/Install/CodeEnvironment/Copilot.php b/src/Install/CodeEnvironment/Copilot.php index a5a03f5..2219539 100644 --- a/src/Install/CodeEnvironment/Copilot.php +++ b/src/Install/CodeEnvironment/Copilot.php @@ -4,9 +4,10 @@ namespace Laravel\Boost\Install\CodeEnvironment; +use Laravel\Boost\Contracts\Agent; use Laravel\Boost\Install\Enums\Platform; -class Copilot extends CodeEnvironment +class Copilot extends CodeEnvironment implements Agent { public function name(): string { @@ -38,4 +39,13 @@ public function detectOnSystem(Platform $platform): bool return false; } + public function mcpClientName(): ?string + { + return null; + } + + public function guidelinesPath(): string + { + return '.github/copilot-instructions.md'; + } } diff --git a/src/Install/CodeEnvironment/Cursor.php b/src/Install/CodeEnvironment/Cursor.php index 17d36d7..8449a5f 100644 --- a/src/Install/CodeEnvironment/Cursor.php +++ b/src/Install/CodeEnvironment/Cursor.php @@ -4,9 +4,11 @@ namespace Laravel\Boost\Install\CodeEnvironment; +use Laravel\Boost\Contracts\Agent; +use Laravel\Boost\Contracts\McpClient; use Laravel\Boost\Install\Enums\Platform; -class Cursor extends CodeEnvironment +class Cursor extends CodeEnvironment implements Agent, McpClient { public function name(): string { @@ -47,4 +49,18 @@ public function projectDetectionConfig(): array ]; } + public function mcpConfigPath(): string + { + return '.cursor/mcp.json'; + } + + public function guidelinesPath(): string + { + return '.cursor/rules/laravel-boost.mdc'; + } + + public function frontmatter(): bool + { + return true; + } } diff --git a/src/Install/CodeEnvironment/PhpStorm.php b/src/Install/CodeEnvironment/PhpStorm.php index 4bc7d8e..cd7e1a9 100644 --- a/src/Install/CodeEnvironment/PhpStorm.php +++ b/src/Install/CodeEnvironment/PhpStorm.php @@ -4,9 +4,11 @@ namespace Laravel\Boost\Install\CodeEnvironment; +use Laravel\Boost\Contracts\Agent; +use Laravel\Boost\Contracts\McpClient; use Laravel\Boost\Install\Enums\Platform; -class PhpStorm extends CodeEnvironment +class PhpStorm extends CodeEnvironment implements Agent, McpClient { public function name(): string { @@ -48,4 +50,18 @@ public function projectDetectionConfig(): array ]; } + public function agentName(): string + { + return 'Junie'; + } + + public function mcpConfigPath(): string + { + return '.junie/mcp/mcp.json'; + } + + public function guidelinesPath(): string + { + return '.junie/guidelines.md'; + } } diff --git a/src/Install/CodeEnvironment/VSCode.php b/src/Install/CodeEnvironment/VSCode.php index 49fd9e6..97f891a 100644 --- a/src/Install/CodeEnvironment/VSCode.php +++ b/src/Install/CodeEnvironment/VSCode.php @@ -4,9 +4,10 @@ namespace Laravel\Boost\Install\CodeEnvironment; +use Laravel\Boost\Contracts\McpClient; use Laravel\Boost\Install\Enums\Platform; -class VSCode extends CodeEnvironment +class VSCode extends CodeEnvironment implements McpClient { public function name(): string { @@ -15,7 +16,7 @@ public function name(): string public function displayName(): string { - return 'Visual Studio Code'; + return 'VS Code'; } public function systemDetectionConfig(Platform $platform): array @@ -43,4 +44,13 @@ public function projectDetectionConfig(): array ]; } + public function mcpConfigPath(): string + { + return '.vscode/mcp.json'; + } + + public function mcpConfigKey(): string + { + return 'servers'; + } } diff --git a/src/Install/CodeEnvironment/Zed.php b/src/Install/CodeEnvironment/Zed.php index 3717b22..975fe0d 100644 --- a/src/Install/CodeEnvironment/Zed.php +++ b/src/Install/CodeEnvironment/Zed.php @@ -4,9 +4,10 @@ namespace Laravel\Boost\Install\CodeEnvironment; +use Laravel\Boost\Contracts\McpClient; use Laravel\Boost\Install\Enums\Platform; -class Zed extends CodeEnvironment +class Zed extends CodeEnvironment implements McpClient { public function name(): string { @@ -47,4 +48,13 @@ public function projectDetectionConfig(): array ]; } + public function agentName(): ?string + { + return null; + } + + public function mcpConfigPath(): string + { + return '.zed/mcp.json'; + } } diff --git a/src/Install/CodeEnvironmentsDetector.php b/src/Install/CodeEnvironmentsDetector.php index 4f243a1..5f74f4e 100644 --- a/src/Install/CodeEnvironmentsDetector.php +++ b/src/Install/CodeEnvironmentsDetector.php @@ -41,7 +41,7 @@ public function discoverSystemInstalledCodeEnvironments(): array { $platform = Platform::current(); - return $this->getAllPrograms() + return $this->getCodeEnvironments() ->filter(fn (CodeEnvironment $program) => $program->detectOnSystem($platform)) ->map(fn (CodeEnvironment $program) => $program->name()) ->values() @@ -55,7 +55,7 @@ public function discoverSystemInstalledCodeEnvironments(): array */ public function discoverProjectInstalledCodeEnvironments(string $basePath): array { - return $this->getAllPrograms() + return $this->getCodeEnvironments() ->filter(fn ($program) => $program->detectInProject($basePath)) ->map(fn ($program) => $program->name()) ->values() @@ -63,14 +63,12 @@ public function discoverProjectInstalledCodeEnvironments(string $basePath): arra } /** - * Get all registered programs. + * Get all registered code environments. * * @return Collection */ - private function getAllPrograms(): Collection + public function getCodeEnvironments(): Collection { - return collect($this->programs)->map( - fn (string $className) => $this->container->make($className) - ); + return collect($this->programs)->map(fn (string $className) => $this->container->make($className)); } } diff --git a/src/Install/Enums/McpInstallationStrategy.php b/src/Install/Enums/McpInstallationStrategy.php new file mode 100644 index 0000000..38383e6 --- /dev/null +++ b/src/Install/Enums/McpInstallationStrategy.php @@ -0,0 +1,12 @@ +put('core', $this->guideline('core')); - $guidelines->put('boost/core', $this->guideline('boost/core')); + $guidelines->put('foundation', $this->guideline('foundation')); + $guidelines->put('boost', $this->guideline('boost/core')); - $guidelines->put('php/core', $this->guideline('php/core')); + $guidelines->put('php', $this->guideline('php/base')); // TODO: AI-48: Use composer target version, not PHP version. Production could be 8.1, but local is 8.4 // $phpMajorMinor = PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION; // $guidelines->put('php/v'.$phpMajorMinor, $this->guidelinesDir('php/'.$phpMajorMinor)); if (str_contains(config('app.url'), '.test') && $this->herd->isInstalled()) { - $guidelines->put('herd/core', $this->guideline('herd/core')); + $guidelines->put('herd', $this->guideline('herd/core')); } if ($this->config->laravelStyle) { diff --git a/src/Install/GuidelineWriter.php b/src/Install/GuidelineWriter.php index 0821808..13f828f 100644 --- a/src/Install/GuidelineWriter.php +++ b/src/Install/GuidelineWriter.php @@ -5,6 +5,7 @@ namespace Laravel\Boost\Install; use Laravel\Boost\Contracts\Agent; +use RuntimeException; class GuidelineWriter { @@ -34,13 +35,13 @@ public function write(string $guidelines): int $directory = dirname($filePath); if (! is_dir($directory)) { if (! mkdir($directory, 0755, true)) { - throw new \RuntimeException("Failed to create directory: {$directory}"); + throw new RuntimeException("Failed to create directory: {$directory}"); } } $handle = fopen($filePath, 'c+'); if (! $handle) { - throw new \RuntimeException("Failed to open file: {$filePath}"); + throw new RuntimeException("Failed to open file: {$filePath}"); } try { @@ -72,11 +73,11 @@ public function write(string $guidelines): int } if (ftruncate($handle, 0) === false || fseek($handle, 0) === -1) { - throw new \RuntimeException("Failed to reset file pointer: {$filePath}"); + throw new RuntimeException("Failed to reset file pointer: {$filePath}"); } if (fwrite($handle, $newContent) === false) { - throw new \RuntimeException("Failed to write to file: {$filePath}"); + throw new RuntimeException("Failed to write to file: {$filePath}"); } flock($handle, LOCK_UN); @@ -100,7 +101,7 @@ private function acquireLockWithRetry(mixed $handle, string $filePath, int $maxR $attempts++; if ($attempts >= $maxRetries) { - throw new \RuntimeException("Failed to acquire lock on file after {$maxRetries} attempts: {$filePath}"); + throw new RuntimeException("Failed to acquire lock on file after {$maxRetries} attempts: {$filePath}"); } // Exponential backoff with jitter diff --git a/tests/Unit/Install/CodeEnvironment/CodeEnvironmentTest.php b/tests/Unit/Install/CodeEnvironment/CodeEnvironmentTest.php new file mode 100644 index 0000000..3dc56db --- /dev/null +++ b/tests/Unit/Install/CodeEnvironment/CodeEnvironmentTest.php @@ -0,0 +1,365 @@ +strategyFactory = Mockery::mock(DetectionStrategyFactory::class); + $this->strategy = Mockery::mock(DetectionStrategy::class); +}); + +afterEach(function () { + Mockery::close(); +}); + +// Create a concrete test implementation for testing abstract methods +class TestCodeEnvironment extends CodeEnvironment +{ + public function name(): string + { + return 'test'; + } + + public function displayName(): string + { + return 'Test Environment'; + } + + public function systemDetectionConfig(Platform $platform): array + { + return ['paths' => ['/test/path']]; + } + + public function projectDetectionConfig(): array + { + return ['files' => ['test.config']]; + } +} + +class TestAgent extends TestCodeEnvironment implements Agent +{ + public function guidelinesPath(): string + { + return 'test-guidelines.md'; + } +} + +class TestMcpClient extends TestCodeEnvironment implements McpClient +{ + public function mcpConfigPath(): string + { + return '.test/mcp.json'; + } +} + +class TestAgentAndMcpClient extends TestCodeEnvironment implements Agent, McpClient +{ + public function guidelinesPath(): string + { + return 'test-guidelines.md'; + } + + public function mcpConfigPath(): string + { + return '.test/mcp.json'; + } +} + +test('detectOnSystem delegates to strategy factory and detection strategy', function () { + $platform = Platform::Darwin; + $config = ['paths' => ['/test/path']]; + + $this->strategyFactory + ->shouldReceive('makeFromConfig') + ->once() + ->with($config) + ->andReturn($this->strategy); + + $this->strategy + ->shouldReceive('detect') + ->once() + ->with($config, $platform) + ->andReturn(true); + + $environment = new TestCodeEnvironment($this->strategyFactory); + $result = $environment->detectOnSystem($platform); + + expect($result)->toBe(true); +}); + +test('detectInProject merges config with basePath and delegates to strategy', function () { + $basePath = '/project/path'; + $projectConfig = ['files' => ['test.config']]; + $mergedConfig = ['files' => ['test.config'], 'basePath' => $basePath]; + + $this->strategyFactory + ->shouldReceive('makeFromConfig') + ->once() + ->with($mergedConfig) + ->andReturn($this->strategy); + + $this->strategy + ->shouldReceive('detect') + ->once() + ->with($mergedConfig) + ->andReturn(false); + + $environment = new TestCodeEnvironment($this->strategyFactory); + $result = $environment->detectInProject($basePath); + + expect($result)->toBe(false); +}); + +test('agentName returns displayName by default', function () { + $environment = new TestCodeEnvironment($this->strategyFactory); + + expect($environment->agentName())->toBe('Test Environment'); +}); + +test('mcpClientName returns displayName by default', function () { + $environment = new TestCodeEnvironment($this->strategyFactory); + + expect($environment->mcpClientName())->toBe('Test Environment'); +}); + +test('IsAgent returns true when implements Agent interface and has agentName', function () { + $agent = new TestAgent($this->strategyFactory); + + expect($agent->IsAgent())->toBe(true); +}); + +test('IsAgent returns false when does not implement Agent interface', function () { + $environment = new TestCodeEnvironment($this->strategyFactory); + + expect($environment->IsAgent())->toBe(false); +}); + +test('isMcpClient returns true when implements McpClient interface and has mcpClientName', function () { + $mcpClient = new TestMcpClient($this->strategyFactory); + + expect($mcpClient->isMcpClient())->toBe(true); +}); + +test('isMcpClient returns false when does not implement McpClient interface', function () { + $environment = new TestCodeEnvironment($this->strategyFactory); + + expect($environment->isMcpClient())->toBe(false); +}); + +test('mcpInstallationStrategy returns File by default', function () { + $environment = new TestCodeEnvironment($this->strategyFactory); + + expect($environment->mcpInstallationStrategy())->toBe(McpInstallationStrategy::FILE); +}); + +test('shellMcpCommand returns null by default', function () { + $environment = new TestCodeEnvironment($this->strategyFactory); + + expect($environment->shellMcpCommand())->toBe(null); +}); + +test('mcpConfigPath returns null by default', function () { + $environment = new TestCodeEnvironment($this->strategyFactory); + + expect($environment->mcpConfigPath())->toBe(null); +}); + +test('frontmatter returns false by default', function () { + $environment = new TestCodeEnvironment($this->strategyFactory); + + expect($environment->frontmatter())->toBe(false); +}); + +test('mcpConfigKey returns mcpServers by default', function () { + $environment = new TestCodeEnvironment($this->strategyFactory); + + expect($environment->mcpConfigKey())->toBe('mcpServers'); +}); + +test('installMcp uses Shell strategy when configured', function () { + $environment = Mockery::mock(TestCodeEnvironment::class)->makePartial(); + $environment->shouldAllowMockingProtectedMethods(); + + $environment->shouldReceive('mcpInstallationStrategy') + ->andReturn(McpInstallationStrategy::SHELL); + + $environment->shouldReceive('installShellMcp') + ->once() + ->with('test-key', 'test-command', ['arg1'], ['ENV' => 'value']) + ->andReturn(true); + + $result = $environment->installMcp('test-key', 'test-command', ['arg1'], ['ENV' => 'value']); + + expect($result)->toBe(true); +}); + +test('installMcp uses File strategy when configured', function () { + $environment = Mockery::mock(TestCodeEnvironment::class)->makePartial(); + $environment->shouldAllowMockingProtectedMethods(); + + $environment->shouldReceive('mcpInstallationStrategy') + ->andReturn(McpInstallationStrategy::FILE); + + $environment->shouldReceive('installFileMcp') + ->once() + ->with('test-key', 'test-command', ['arg1'], ['ENV' => 'value']) + ->andReturn(true); + + $result = $environment->installMcp('test-key', 'test-command', ['arg1'], ['ENV' => 'value']); + + expect($result)->toBe(true); +}); + +test('installMcp returns false for None strategy', function () { + $environment = Mockery::mock(TestCodeEnvironment::class)->makePartial(); + + $environment->shouldReceive('mcpInstallationStrategy') + ->andReturn(McpInstallationStrategy::NONE); + + $result = $environment->installMcp('test-key', 'test-command'); + + expect($result)->toBe(false); +}); + +test('installShellMcp returns false when shellMcpCommand is null', function () { + $environment = new TestCodeEnvironment($this->strategyFactory); + + $result = $environment->installMcp('test-key', 'test-command'); + + expect($result)->toBe(false); +}); + +test('installShellMcp executes command with placeholders replaced', function () { + $environment = Mockery::mock(TestCodeEnvironment::class)->makePartial(); + $environment->shouldAllowMockingProtectedMethods(); + + $environment->shouldReceive('shellMcpCommand') + ->andReturn('install {key} {command} {args} {env}'); + + $environment->shouldReceive('mcpInstallationStrategy') + ->andReturn(McpInstallationStrategy::SHELL); + + $mockResult = Mockery::mock(); + $mockResult->shouldReceive('successful')->andReturn(true); + $mockResult->shouldReceive('errorOutput')->andReturn(''); + + Process::shouldReceive('run') + ->once() + ->with(Mockery::on(function ($command) { + return str_contains($command, 'install test-key test-command "arg1" "arg2"') && + str_contains($command, '-e ENV1="value1"') && + str_contains($command, '-e ENV2="value2"'); + })) + ->andReturn($mockResult); + + $result = $environment->installMcp('test-key', 'test-command', ['arg1', 'arg2'], ['env1' => 'value1', 'env2' => 'value2']); + + expect($result)->toBe(true); +}); + +test('installShellMcp returns true when process fails but has already exists error', function () { + $environment = Mockery::mock(TestCodeEnvironment::class)->makePartial(); + $environment->shouldAllowMockingProtectedMethods(); + + $environment->shouldReceive('shellMcpCommand') + ->andReturn('install {key}'); + + $environment->shouldReceive('mcpInstallationStrategy') + ->andReturn(McpInstallationStrategy::SHELL); + + $mockResult = Mockery::mock(); + $mockResult->shouldReceive('successful')->andReturn(false); + $mockResult->shouldReceive('errorOutput')->andReturn('Error: already exists'); + + Process::shouldReceive('run') + ->once() + ->andReturn($mockResult); + + $result = $environment->installMcp('test-key', 'test-command'); + + expect($result)->toBe(true); +}); + +test('installFileMcp returns false when mcpConfigPath is null', function () { + $environment = new TestCodeEnvironment($this->strategyFactory); + + $result = $environment->installMcp('test-key', 'test-command'); + + expect($result)->toBe(false); +}); + +test('installFileMcp creates new config file when none exists', function () { + $environment = Mockery::mock(TestMcpClient::class)->makePartial(); + $environment->shouldAllowMockingProtectedMethods(); + + $environment->shouldReceive('mcpInstallationStrategy') + ->andReturn(McpInstallationStrategy::FILE); + + File::shouldReceive('ensureDirectoryExists') + ->once() + ->with('.test'); + + File::shouldReceive('exists') + ->once() + ->with('.test/mcp.json') + ->andReturn(false); + + File::shouldReceive('put') + ->once() + ->with('.test/mcp.json', Mockery::type('string')) + ->andReturn(true); + + $result = $environment->installMcp('test-key', 'test-command', ['arg1'], ['ENV' => 'value']); + + expect($result)->toBe(true); +}); + +test('installFileMcp updates existing config file', function () { + $environment = Mockery::mock(TestMcpClient::class)->makePartial(); + $environment->shouldAllowMockingProtectedMethods(); + + $environment->shouldReceive('mcpInstallationStrategy') + ->andReturn(McpInstallationStrategy::FILE); + + $existingConfig = json_encode(['mcpServers' => ['existing' => ['command' => 'existing-cmd']]]); + + File::shouldReceive('ensureDirectoryExists') + ->once() + ->with('.test'); + + File::shouldReceive('exists') + ->once() + ->with('.test/mcp.json') + ->andReturn(true); + + File::shouldReceive('get') + ->once() + ->with('.test/mcp.json') + ->andReturn($existingConfig); + + File::shouldReceive('put') + ->once() + ->with('.test/mcp.json', Mockery::on(function ($json) { + $config = json_decode($json, true); + + return isset($config['mcpServers']['test-key']) && + isset($config['mcpServers']['existing']); + })) + ->andReturn(true); + + $result = $environment->installMcp('test-key', 'test-command', ['arg1'], ['ENV' => 'value']); + + expect($result)->toBe(true); +});