diff --git a/src/BoostManager.php b/src/BoostManager.php index bcf70773..1250f501 100644 --- a/src/BoostManager.php +++ b/src/BoostManager.php @@ -10,6 +10,7 @@ use Laravel\Boost\Install\CodeEnvironment\Codex; use Laravel\Boost\Install\CodeEnvironment\Copilot; use Laravel\Boost\Install\CodeEnvironment\Cursor; +use Laravel\Boost\Install\CodeEnvironment\OpenCode; use Laravel\Boost\Install\CodeEnvironment\PhpStorm; use Laravel\Boost\Install\CodeEnvironment\VSCode; @@ -23,6 +24,7 @@ class BoostManager 'claudecode' => ClaudeCode::class, 'codex' => Codex::class, 'copilot' => Copilot::class, + 'opencode' => OpenCode::class, ]; /** diff --git a/src/Install/CodeEnvironment/ClaudeCode.php b/src/Install/CodeEnvironment/ClaudeCode.php index 83687f2e..ed7d58dd 100644 --- a/src/Install/CodeEnvironment/ClaudeCode.php +++ b/src/Install/CodeEnvironment/ClaudeCode.php @@ -25,7 +25,7 @@ public function systemDetectionConfig(Platform $platform): array { return match ($platform) { Platform::Darwin, Platform::Linux => [ - 'command' => 'which claude', + 'command' => 'command -v claude', ], Platform::Windows => [ 'command' => 'where claude 2>nul', diff --git a/src/Install/CodeEnvironment/CodeEnvironment.php b/src/Install/CodeEnvironment/CodeEnvironment.php index ed66ee29..9c48f147 100644 --- a/src/Install/CodeEnvironment/CodeEnvironment.php +++ b/src/Install/CodeEnvironment/CodeEnvironment.php @@ -129,6 +129,12 @@ public function mcpConfigKey(): string return 'mcpServers'; } + /** @return array */ + public function defaultMcpConfig(): array + { + return []; + } + /** * Install MCP server using the appropriate strategy. * @@ -144,6 +150,22 @@ public function installMcp(string $key, string $command, array $args = [], array }; } + /** + * Build the MCP server configuration payload for file-based installation. + * + * @param array $args + * @param array $env + * @return array + */ + 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. * @@ -198,9 +220,9 @@ protected function installFileMcp(string $key, string $command, array $args = [] return false; } - return (new FileWriter($path)) + return (new FileWriter($path, $this->defaultMcpConfig())) ->configKey($this->mcpConfigKey()) - ->addServer($key, $command, $args, $env) + ->addServerConfig($key, $this->mcpServerConfig($command, $args, $env)) ->save(); } } diff --git a/src/Install/CodeEnvironment/OpenCode.php b/src/Install/CodeEnvironment/OpenCode.php new file mode 100644 index 00000000..1609614b --- /dev/null +++ b/src/Install/CodeEnvironment/OpenCode.php @@ -0,0 +1,81 @@ + [ + 'command' => 'command -v opencode', + ], + Platform::Windows => [ + 'command' => '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 'opencode.json'; + } + + public function guidelinesPath(): string + { + return 'AGENTS.md'; + } + + public function mcpConfigKey(): string + { + return 'mcp'; + } + + /** {@inheritDoc} */ + public function defaultMcpConfig(): array + { + return [ + '$schema' => 'https://opencode.ai/config.json', + ]; + } + + /** {@inheritDoc} */ + public function mcpServerConfig(string $command, array $args = [], array $env = []): array + { + return [ + 'type' => 'local', + 'enabled' => true, + 'command' => [$command, ...$args], + 'environment' => $env, + ]; + } +} diff --git a/src/Install/CodeEnvironment/VSCode.php b/src/Install/CodeEnvironment/VSCode.php index 97f891af..4a1d2ae6 100644 --- a/src/Install/CodeEnvironment/VSCode.php +++ b/src/Install/CodeEnvironment/VSCode.php @@ -26,7 +26,7 @@ public function systemDetectionConfig(Platform $platform): array 'paths' => ['/Applications/Visual Studio Code.app'], ], Platform::Linux => [ - 'command' => 'which code', + 'command' => 'command -v code', ], Platform::Windows => [ 'paths' => [ diff --git a/src/Install/Mcp/FileWriter.php b/src/Install/Mcp/FileWriter.php index c538afff..80cb7244 100644 --- a/src/Install/Mcp/FileWriter.php +++ b/src/Install/Mcp/FileWriter.php @@ -15,7 +15,7 @@ class FileWriter protected int $defaultIndentation = 8; - public function __construct(protected string $filePath) {} + public function __construct(protected string $filePath, protected array $baseConfig = []) {} public function configKey(string $key): self { @@ -25,17 +25,28 @@ public function configKey(string $key): self } /** - * @param string $key MCP Server Name + * @deprecated Use addServerConfig() for array-based configuration. + * * @param array $args * @param array $env */ public function addServer(string $key, string $command, array $args = [], array $env = []): self { - $this->serversToAdd[$key] = collect([ + return $this->addServerConfig($key, collect([ 'command' => $command, 'args' => $args, 'env' => $env, - ])->filter()->toArray(); + ])->filter(fn ($value): bool => ! in_array($value, [[], null, ''], true))->toArray()); + } + + /** + * @param array $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; } @@ -358,7 +369,7 @@ protected function hasUnquotedComments(string $content): bool protected function createNewFile(): bool { - $config = []; + $config = $this->baseConfig; $this->addServersToConfig($config); return $this->writeJsonConfig($config); diff --git a/tests/Unit/Install/CodeEnvironmentsDetectorTest.php b/tests/Unit/Install/CodeEnvironmentsDetectorTest.php index 2e11b26e..e189cb36 100644 --- a/tests/Unit/Install/CodeEnvironmentsDetectorTest.php +++ b/tests/Unit/Install/CodeEnvironmentsDetectorTest.php @@ -10,6 +10,7 @@ use Laravel\Boost\Install\CodeEnvironment\Codex; use Laravel\Boost\Install\CodeEnvironment\Copilot; use Laravel\Boost\Install\CodeEnvironment\Cursor; +use Laravel\Boost\Install\CodeEnvironment\OpenCode; use Laravel\Boost\Install\CodeEnvironment\PhpStorm; use Laravel\Boost\Install\CodeEnvironment\VSCode; use Laravel\Boost\Install\CodeEnvironmentsDetector; @@ -29,9 +30,9 @@ $codeEnvironments = $this->detector->getCodeEnvironments(); expect($codeEnvironments)->toBeInstanceOf(Collection::class) - ->and($codeEnvironments->count())->toBe(6) + ->and($codeEnvironments->count())->toBe(7) ->and($codeEnvironments->keys()->toArray())->toBe([ - 'phpstorm', 'vscode', 'cursor', 'claudecode', 'codex', 'copilot', + 'phpstorm', 'vscode', 'cursor', 'claudecode', 'codex', 'copilot', 'opencode', ]); $codeEnvironments->each(function ($environment): void { @@ -62,6 +63,7 @@ $this->container->bind(ClaudeCode::class, fn () => $mockOther); $this->container->bind(Codex::class, fn () => $mockOther); $this->container->bind(Copilot::class, fn () => $mockOther); + $this->container->bind(OpenCode::class, fn () => $mockOther); $detector = new CodeEnvironmentsDetector($this->container, $this->boostManager); $detected = $detector->discoverSystemInstalledCodeEnvironments(); @@ -80,6 +82,7 @@ $this->container->bind(ClaudeCode::class, fn () => $mockEnvironment); $this->container->bind(Codex::class, fn () => $mockEnvironment); $this->container->bind(Copilot::class, fn () => $mockEnvironment); + $this->container->bind(OpenCode::class, fn () => $mockEnvironment); $detector = new CodeEnvironmentsDetector($this->container, $this->boostManager); $detected = $detector->discoverSystemInstalledCodeEnvironments(); @@ -112,6 +115,7 @@ $this->container->bind(ClaudeCode::class, fn () => $mockClaudeCode); $this->container->bind(Codex::class, fn () => $mockOther); $this->container->bind(Copilot::class, fn () => $mockOther); + $this->container->bind(OpenCode::class, fn () => $mockOther); $detector = new CodeEnvironmentsDetector($this->container, $this->boostManager); $detected = $detector->discoverProjectInstalledCodeEnvironments($basePath); @@ -132,6 +136,7 @@ $this->container->bind(ClaudeCode::class, fn () => $mockEnvironment); $this->container->bind(Codex::class, fn () => $mockEnvironment); $this->container->bind(Copilot::class, fn () => $mockEnvironment); + $this->container->bind(OpenCode::class, fn () => $mockEnvironment); $detector = new CodeEnvironmentsDetector($this->container, $this->boostManager); $detected = $detector->discoverProjectInstalledCodeEnvironments($basePath); diff --git a/tests/Unit/Install/Mcp/FileWriterTest.php b/tests/Unit/Install/Mcp/FileWriterTest.php index 177a1c2c..17405ef2 100644 --- a/tests/Unit/Install/Mcp/FileWriterTest.php +++ b/tests/Unit/Install/Mcp/FileWriterTest.php @@ -27,7 +27,11 @@ $writer = new FileWriter('/path/to/mcp.json'); $result = $writer ->configKey('servers') - ->addServer('test', 'php', ['artisan'], ['ENV' => 'value']); + ->addServerConfig('test', [ + 'command' => 'php', + 'args' => 'artisan', + 'env' => 'value', + ]); expect($result)->toBe($writer); }); @@ -49,12 +53,7 @@ ->configKey($configKey); foreach ($servers as $serverKey => $serverConfig) { - $writer->addServer( - $serverKey, - $serverConfig['command'], - $serverConfig['args'] ?? [], - $serverConfig['env'] ?? [] - ); + $writer->addServerConfig($serverKey, $serverConfig); } $result = $writer->save(); @@ -80,7 +79,10 @@ $result = (new FileWriter('/path/to/mcp.json')) ->configKey('servers') - ->addServer('new-server', 'npm', ['start']) + ->addServerConfig('new-server', [ + 'command' => 'npm', + 'args' => ['start'], + ]) ->save(); expect($result)->toBeTrue(); @@ -107,7 +109,10 @@ File::shouldReceive('size')->andReturn(200); $result = (new FileWriter('/path/to/mcp.json')) - ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->addServerConfig('boost', [ + 'command' => 'php', + 'args' => ['artisan', 'boost:mcp'], + ]) ->save(); expect($result)->toBeTrue(); @@ -132,7 +137,7 @@ $result = (new FileWriter('/path/to/mcp.json')) ->configKey('servers') // mcp.json5 uses "servers", not "mcpServers" - ->addServer('test', 'cmd') + ->addServerConfig('test', ['command' => 'cmd']) ->save(); expect($result)->toBeTrue(); @@ -155,7 +160,7 @@ File::shouldReceive('size')->andReturn(200); $result = (new FileWriter('/path/to/mcp.json')) - ->addServer('new-server', 'test-cmd') + ->addServerConfig('new-server', ['command' => 'test-cmd']) ->save(); expect($result)->toBeTrue(); @@ -243,7 +248,10 @@ File::shouldReceive('size')->andReturn(200); $result = (new FileWriter('/path/to/mcp.json')) - ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->addServerConfig('boost', [ + 'command' => 'php', + 'args' => ['artisan', 'boost:mcp'], + ]) ->save(); expect($result)->toBeTrue(); @@ -266,7 +274,10 @@ $result = (new FileWriter('/path/to/mcp.json')) ->configKey('servers') // mcp.json5 uses "servers" not "mcpServers" - ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->addServerConfig('boost', [ + 'command' => 'php', + 'args' => ['artisan', 'boost:mcp'], + ]) ->save(); expect($result)->toBeTrue(); @@ -295,7 +306,10 @@ $result = (new FileWriter('/path/to/mcp.json')) ->configKey('servers') // mcp.json5 uses "servers" not "mcpServers" - ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->addServerConfig('boost', [ + 'command' => 'php', + 'args' => ['artisan', 'boost:mcp'], + ]) ->save(); $boostCounts = substr_count($capturedContent, '"boost"'); @@ -318,7 +332,10 @@ $result = (new FileWriter('/path/to/mcp.json')) ->configKey('servers') - ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->addServerConfig('boost', [ + 'command' => 'php', + 'args' => ['artisan', 'boost:mcp'], + ]) ->save(); // Second call should return true but not modify the file since boost already exists @@ -341,7 +358,10 @@ File::shouldReceive('size')->andReturn(200); $result = (new FileWriter('/path/to/mcp.json')) - ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->addServerConfig('boost', [ + 'command' => 'php', + 'args' => ['artisan', 'boost:mcp'], + ]) ->save(); expect($result)->toBeTrue(); @@ -362,7 +382,10 @@ File::shouldReceive('size')->andReturn(200); $result = (new FileWriter('/path/to/mcp.json')) - ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->addServerConfig('boost', [ + 'command' => 'php', + 'args' => ['artisan', 'boost:mcp'], + ]) ->save(); expect($result)->toBeTrue()