diff --git a/README.md b/README.md index 6b360af1..17eeca21 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,49 @@ JSON Example: } ``` +## Adding Support for Other IDEs / AI Agents + +Boost works with many popular IDEs and AI agents out of the box. If your coding tool isn't supported yet, you can create your own code environment and integrate it with Boost. To do this, create a class that extends `Laravel\Boost\Install\CodeEnvironment\CodeEnvironment` and implement one or both of the following contracts depending on what you need: + +- `Laravel\Boost\Contracts\Agent` - Adds support for AI guidelines. +- `Laravel\Boost\Contracts\McpClient` - Adds support for MCP. + +### Writing the Code Environment + +```php +> */ + private array $codeEnvironments = [ + 'phpstorm' => PhpStorm::class, + 'vscode' => VSCode::class, + 'cursor' => Cursor::class, + 'claudecode' => ClaudeCode::class, + 'codex' => Codex::class, + 'copilot' => Copilot::class, + ]; + + /** + * @param class-string $className + */ + public function registerCodeEnvironment(string $key, string $className): void + { + if (array_key_exists($key, $this->codeEnvironments)) { + throw new InvalidArgumentException("Code environment '{$key}' is already registered"); + } + + $this->codeEnvironments[$key] = $className; + } + + /** + * @return array> + */ + public function getCodeEnvironments(): array + { + return $this->codeEnvironments; + } +} diff --git a/src/BoostServiceProvider.php b/src/BoostServiceProvider.php index 4633d3ba..9ade989e 100644 --- a/src/BoostServiceProvider.php +++ b/src/BoostServiceProvider.php @@ -30,6 +30,8 @@ public function register(): void return; } + $this->app->singleton(BoostManager::class, fn (): BoostManager => new BoostManager); + $this->app->singleton(Roster::class, function () { $lockFiles = [ base_path('composer.lock'), diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index 4a08227a..84d42cf5 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -308,13 +308,13 @@ protected function selectTargetAgents(): Collection /** * Get configuration settings for contract-specific selection behavior. * - * @return array{scroll: int, required: bool, displayMethod: string} + * @return array{required: bool, displayMethod: string} */ protected function getSelectionConfig(string $contractClass): array { return match ($contractClass) { - Agent::class => ['scroll' => 5, 'required' => false, 'displayMethod' => 'agentName'], - McpClient::class => ['scroll' => 5, 'required' => true, 'displayMethod' => 'displayName'], + Agent::class => ['required' => false, 'displayMethod' => 'agentName'], + McpClient::class => ['required' => true, 'displayMethod' => 'displayName'], default => throw new InvalidArgumentException("Unsupported contract class: {$contractClass}"), }; } @@ -361,7 +361,7 @@ protected function selectCodeEnvironments(string $contractClass, string $label, label: $label, options: $options->toArray(), default: $defaults === [] ? $detectedDefaults : $defaults, - scroll: $config['scroll'], + scroll: $options->count(), required: $config['required'], hint: $defaults === [] || $detectedDefaults === [] ? '' : sprintf('Auto-detected %s for you', Arr::join(array_map(function ($className) use ($availableEnvironments, $config) { diff --git a/src/Install/CodeEnvironment/CodeEnvironment.php b/src/Install/CodeEnvironment/CodeEnvironment.php index 4082a47e..ed66ee29 100644 --- a/src/Install/CodeEnvironment/CodeEnvironment.php +++ b/src/Install/CodeEnvironment/CodeEnvironment.php @@ -4,8 +4,8 @@ namespace Laravel\Boost\Install\CodeEnvironment; -use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Support\Facades\Process; +use Laravel\Boost\BoostManager; use Laravel\Boost\Contracts\Agent; use Laravel\Boost\Contracts\McpClient; use Laravel\Boost\Install\Detection\DetectionStrategyFactory; @@ -93,19 +93,13 @@ public function mcpInstallationStrategy(): McpInstallationStrategy return McpInstallationStrategy::FILE; } - public static function fromName(string $name): ?static + public static function fromName(string $name): ?CodeEnvironment { $detectionFactory = app(DetectionStrategyFactory::class); + $boostManager = app(BoostManager::class); - foreach ([ - ClaudeCode::class, - Codex::class, - Copilot::class, - Cursor::class, - PhpStorm::class, - VSCode::class, - ] as $class) { - /** @var class-string $class */ + foreach ($boostManager->getCodeEnvironments() as $class) { + /** @var class-string $class */ $instance = new $class($detectionFactory); if ($instance->name() === $name) { return $instance; @@ -140,8 +134,6 @@ public function mcpConfigKey(): string * * @param array $args * @param array $env - * - * @throws FileNotFoundException */ public function installMcp(string $key, string $command, array $args = [], array $env = []): bool { @@ -198,8 +190,6 @@ protected function installShellMcp(string $key, string $command, array $args = [ * * @param array $args * @param array $env - * - * @throws FileNotFoundException */ protected function installFileMcp(string $key, string $command, array $args = [], array $env = []): bool { diff --git a/src/Install/CodeEnvironmentsDetector.php b/src/Install/CodeEnvironmentsDetector.php index ad446e61..c88d962c 100644 --- a/src/Install/CodeEnvironmentsDetector.php +++ b/src/Install/CodeEnvironmentsDetector.php @@ -6,29 +6,15 @@ use Illuminate\Container\Container; use Illuminate\Support\Collection; -use Laravel\Boost\Install\CodeEnvironment\ClaudeCode; +use Laravel\Boost\BoostManager; use Laravel\Boost\Install\CodeEnvironment\CodeEnvironment; -use Laravel\Boost\Install\CodeEnvironment\Codex; -use Laravel\Boost\Install\CodeEnvironment\Copilot; -use Laravel\Boost\Install\CodeEnvironment\Cursor; -use Laravel\Boost\Install\CodeEnvironment\PhpStorm; -use Laravel\Boost\Install\CodeEnvironment\VSCode; use Laravel\Boost\Install\Enums\Platform; class CodeEnvironmentsDetector { - /** @var array> */ - private array $programs = [ - 'phpstorm' => PhpStorm::class, - 'vscode' => VSCode::class, - 'cursor' => Cursor::class, - 'claudecode' => ClaudeCode::class, - 'codex' => Codex::class, - 'copilot' => Copilot::class, - ]; - public function __construct( - private readonly Container $container + private readonly Container $container, + private readonly BoostManager $boostManager ) {} /** @@ -55,8 +41,8 @@ public function discoverSystemInstalledCodeEnvironments(): array public function discoverProjectInstalledCodeEnvironments(string $basePath): array { return $this->getCodeEnvironments() - ->filter(fn ($program): bool => $program->detectInProject($basePath)) - ->map(fn ($program): string => $program->name()) + ->filter(fn (CodeEnvironment $program): bool => $program->detectInProject($basePath)) + ->map(fn (CodeEnvironment $program): string => $program->name()) ->values() ->toArray(); } @@ -68,6 +54,7 @@ public function discoverProjectInstalledCodeEnvironments(string $basePath): arra */ public function getCodeEnvironments(): Collection { - return collect($this->programs)->map(fn (string $className) => $this->container->make($className)); + return collect($this->boostManager->getCodeEnvironments()) + ->map(fn (string $className) => $this->container->make($className)); } } diff --git a/tests/Feature/BoostFacadeTest.php b/tests/Feature/BoostFacadeTest.php new file mode 100644 index 00000000..23453ed2 --- /dev/null +++ b/tests/Feature/BoostFacadeTest.php @@ -0,0 +1,34 @@ +toBeInstanceOf(BoostManager::class); +}); + +it('Boost Facade registers code environments via facade', function (): void { + Boost::registerCodeEnvironment('example1', ExampleCodeEnvironment::class); + Boost::registerCodeEnvironment('example2', ExampleCodeEnvironment::class); + $registered = Boost::getFacadeRoot()->getCodeEnvironments(); + + expect($registered)->toHaveKey('example1') + ->and($registered['example1'])->toBe(ExampleCodeEnvironment::class) + ->and($registered)->toHaveKey('example2') + ->and($registered['example2'])->toBe(ExampleCodeEnvironment::class) + ->and($registered)->toHaveKey('phpstorm'); +}); + +it('Boost Facade maintains registration state across facade calls', function (): void { + Boost::registerCodeEnvironment('persistent', 'Test\Persistent'); + + $registered = Boost::getFacadeRoot()->getCodeEnvironments(); + + expect($registered)->toHaveKey('persistent') + ->and($registered['persistent'])->toBe('Test\Persistent'); +}); diff --git a/tests/Feature/BoostServiceProviderTest.php b/tests/Feature/BoostServiceProviderTest.php index c852de12..cd2e5bb4 100644 --- a/tests/Feature/BoostServiceProviderTest.php +++ b/tests/Feature/BoostServiceProviderTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Illuminate\Support\Facades\Config; +use Laravel\Boost\Boost; +use Laravel\Boost\BoostManager; use Laravel\Boost\BoostServiceProvider; beforeEach(function (): void { @@ -74,3 +76,34 @@ }); }); }); + +describe('BoostManager registration', function (): void { + beforeEach(function (): void { + Config::set('boost.enabled', true); + app()->detectEnvironment(fn (): string => 'local'); + $provider = new BoostServiceProvider(app()); + $provider->register(); + $provider->boot(app('router')); + }); + + it('registers BoostManager in the container', function (): void { + expect(app()->bound(BoostManager::class))->toBeTrue() + ->and(app(BoostManager::class))->toBeInstanceOf(BoostManager::class); + }); + + it('registers BoostManager as a singleton', function (): void { + Config::set('boost.enabled', true); + $instance1 = app(BoostManager::class); + $instance2 = app(BoostManager::class); + + expect($instance1)->toBe($instance2); + }); + + it('binds Boost facade to the same BoostManager instance', function (): void { + Config::set('boost.enabled', true); + $containerInstance = app(BoostManager::class); + $facadeInstance = Boost::getFacadeRoot(); + + expect($facadeInstance)->toBe($containerInstance); + }); +}); diff --git a/tests/Unit/BoostManagerTest.php b/tests/Unit/BoostManagerTest.php new file mode 100644 index 00000000..6a6071ca --- /dev/null +++ b/tests/Unit/BoostManagerTest.php @@ -0,0 +1,65 @@ +getCodeEnvironments(); + + expect($registered)->toMatchArray([ + 'phpstorm' => PhpStorm::class, + 'vscode' => VSCode::class, + 'cursor' => Cursor::class, + 'claudecode' => ClaudeCode::class, + 'codex' => Codex::class, + 'copilot' => Copilot::class, + ]); +}); + +it('can register a single code environment', function (): void { + $manager = new BoostManager; + $manager->registerCodeEnvironment('example', ExampleCodeEnvironment::class); + + $registered = $manager->getCodeEnvironments(); + + expect($registered)->toHaveKey('example') + ->and($registered['example'])->toBe(ExampleCodeEnvironment::class) + ->and($registered)->toHaveKey('phpstorm'); +}); + +it('can register multiple code environments', function (): void { + $manager = new BoostManager; + $manager->registerCodeEnvironment('example1', ExampleCodeEnvironment::class); + $manager->registerCodeEnvironment('example2', ExampleCodeEnvironment::class); + + $registered = $manager->getCodeEnvironments(); + + expect($registered)->toHaveKey('example1')->toHaveKey('example2') + ->and($registered['example1'])->toBe(ExampleCodeEnvironment::class) + ->and($registered['example2'])->toBe(ExampleCodeEnvironment::class) + ->and($registered)->toHaveKey('phpstorm'); +}); + +it('throws an exception when registering a duplicate key', function (): void { + $manager = new BoostManager; + + expect(fn () => $manager->registerCodeEnvironment('phpstorm', ExampleCodeEnvironment::class)) + ->toThrow(InvalidArgumentException::class, "Code environment 'phpstorm' is already registered"); +}); + +it('throws exception when registering custom environment with a duplicate key', function (): void { + $manager = new BoostManager; + $manager->registerCodeEnvironment('custom', ExampleCodeEnvironment::class); + + expect(fn () => $manager->registerCodeEnvironment('custom', ExampleCodeEnvironment::class)) + ->toThrow(InvalidArgumentException::class, "Code environment 'custom' is already registered"); +}); diff --git a/tests/Unit/Install/CodeEnvironmentsDetectorTest.php b/tests/Unit/Install/CodeEnvironmentsDetectorTest.php index cf80d842..2e11b26e 100644 --- a/tests/Unit/Install/CodeEnvironmentsDetectorTest.php +++ b/tests/Unit/Install/CodeEnvironmentsDetectorTest.php @@ -2,259 +2,139 @@ declare(strict_types=1); +use Illuminate\Container\Container; +use Illuminate\Support\Collection; +use Laravel\Boost\BoostManager; +use Laravel\Boost\Install\CodeEnvironment\ClaudeCode; use Laravel\Boost\Install\CodeEnvironment\CodeEnvironment; +use Laravel\Boost\Install\CodeEnvironment\Codex; +use Laravel\Boost\Install\CodeEnvironment\Copilot; +use Laravel\Boost\Install\CodeEnvironment\Cursor; +use Laravel\Boost\Install\CodeEnvironment\PhpStorm; +use Laravel\Boost\Install\CodeEnvironment\VSCode; use Laravel\Boost\Install\CodeEnvironmentsDetector; use Laravel\Boost\Install\Enums\Platform; beforeEach(function (): void { - $this->container = new \Illuminate\Container\Container; - $this->detector = new CodeEnvironmentsDetector($this->container); + $this->container = new Container; + $this->boostManager = new BoostManager; + $this->detector = new CodeEnvironmentsDetector($this->container, $this->boostManager); }); -test('discoverSystemInstalledCodeEnvironments returns detected programs', function (): void { - // Create mock programs - $program1 = Mockery::mock(CodeEnvironment::class); - $program1->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(true); - $program1->shouldReceive('name')->andReturn('phpstorm'); - - $program2 = Mockery::mock(CodeEnvironment::class); - $program2->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); - $program2->shouldReceive('name')->andReturn('vscode'); - - $program3 = Mockery::mock(CodeEnvironment::class); - $program3->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(true); - $program3->shouldReceive('name')->andReturn('cursor'); - - // Mock all other programs that might be instantiated - $otherProgram = Mockery::mock(CodeEnvironment::class); - $otherProgram->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); - $otherProgram->shouldReceive('name')->andReturn('other'); - - // Bind mocked programs to container - $container = new \Illuminate\Container\Container; - $container->bind(\Laravel\Boost\Install\CodeEnvironment\PhpStorm::class, fn () => $program1); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\VSCode::class, fn () => $program2); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\Cursor::class, fn () => $program3); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\ClaudeCode::class, fn () => $otherProgram); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\Codex::class, fn () => $otherProgram); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\Copilot::class, fn () => $otherProgram); - - $detector = new CodeEnvironmentsDetector($container); - $detected = $detector->discoverSystemInstalledCodeEnvironments(); - - expect($detected)->toBe(['phpstorm', 'cursor']); -}); - -test('discoverSystemInstalledCodeEnvironments returns empty array when no programs detected', function (): void { - $program1 = Mockery::mock(CodeEnvironment::class); - $program1->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); - $program1->shouldReceive('name')->andReturn('phpstorm'); - - // Mock all other programs that might be instantiated - $otherProgram = Mockery::mock(CodeEnvironment::class); - $otherProgram->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); - $otherProgram->shouldReceive('name')->andReturn('other'); - - // Bind mocked program to container - $container = new \Illuminate\Container\Container; - $container->bind(\Laravel\Boost\Install\CodeEnvironment\PhpStorm::class, fn () => $program1); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\VSCode::class, fn () => $otherProgram); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\Cursor::class, fn () => $otherProgram); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\ClaudeCode::class, fn () => $otherProgram); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\Codex::class, fn () => $otherProgram); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\Copilot::class, fn () => $otherProgram); - - $detector = new CodeEnvironmentsDetector($container); - $detected = $detector->discoverSystemInstalledCodeEnvironments(); - - expect($detected)->toBeEmpty(); -}); - -test('discoverProjectInstalledCodeEnvironments detects programs in project', function (): void { - $basePath = '/path/to/project'; - - $program1 = Mockery::mock(CodeEnvironment::class); - $program1->shouldReceive('detectInProject')->with($basePath)->andReturn(true); - $program1->shouldReceive('name')->andReturn('vscode'); - - $program2 = Mockery::mock(CodeEnvironment::class); - $program2->shouldReceive('detectInProject')->with($basePath)->andReturn(false); - $program2->shouldReceive('name')->andReturn('phpstorm'); - - $program3 = Mockery::mock(CodeEnvironment::class); - $program3->shouldReceive('detectInProject')->with($basePath)->andReturn(true); - $program3->shouldReceive('name')->andReturn('claudecode'); - - // Bind mocked programs to container - $container = new \Illuminate\Container\Container; - $container->bind(\Laravel\Boost\Install\CodeEnvironment\VSCode::class, fn () => $program1); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\PhpStorm::class, fn () => $program2); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\ClaudeCode::class, fn () => $program3); - - $detector = new CodeEnvironmentsDetector($container); - $detected = $detector->discoverProjectInstalledCodeEnvironments($basePath); - - expect($detected)->toBe(['vscode', 'claudecode']); -}); - -test('discoverProjectInstalledCodeEnvironments returns empty array when no programs detected in project', function (): void { - $basePath = '/path/to/project'; - - $program1 = Mockery::mock(CodeEnvironment::class); - $program1->shouldReceive('detectInProject')->with($basePath)->andReturn(false); - $program1->shouldReceive('name')->andReturn('vscode'); - - // Bind mocked program to container - $container = new \Illuminate\Container\Container; - $container->bind(\Laravel\Boost\Install\CodeEnvironment\VSCode::class, fn () => $program1); - - $detector = new CodeEnvironmentsDetector($container); - $detected = $detector->discoverProjectInstalledCodeEnvironments($basePath); - - expect($detected)->toBeEmpty(); -}); - -test('discoverProjectInstalledCodeEnvironments detects applications by directory', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.vscode'); - - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); - - expect($detected)->toContain('vscode'); - - // Cleanup - rmdir($tempDir.'/.vscode'); - rmdir($tempDir); -}); - -test('discoverProjectInstalledCodeEnvironments detects applications with mixed type', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - file_put_contents($tempDir.'/CLAUDE.md', 'test'); - - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); - - expect($detected)->toContain('claude_code'); - - unlink($tempDir.'/CLAUDE.md'); - rmdir($tempDir); +afterEach(function (): void { + Mockery::close(); }); -test('discoverProjectInstalledCodeEnvironments detects copilot with nested file path', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.github'); - file_put_contents($tempDir.'/.github/copilot-instructions.md', 'test'); +it('returns collection of all registered code environments', function (): void { + $codeEnvironments = $this->detector->getCodeEnvironments(); - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + expect($codeEnvironments)->toBeInstanceOf(Collection::class) + ->and($codeEnvironments->count())->toBe(6) + ->and($codeEnvironments->keys()->toArray())->toBe([ + 'phpstorm', 'vscode', 'cursor', 'claudecode', 'codex', 'copilot', + ]); - expect($detected)->toContain('copilot'); - - // Cleanup - unlink($tempDir.'/.github/copilot-instructions.md'); - rmdir($tempDir.'/.github'); - rmdir($tempDir); + $codeEnvironments->each(function ($environment): void { + expect($environment)->toBeInstanceOf(CodeEnvironment::class); + }); }); -test('discoverProjectInstalledCodeEnvironments detects claude code with directory', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.claude'); - - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); +it('returns an array of detected environment names for system discovery', function (): void { + $mockPhpStorm = Mockery::mock(CodeEnvironment::class); + $mockPhpStorm->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(true); + $mockPhpStorm->shouldReceive('name')->andReturn('phpstorm'); - expect($detected)->toContain('claude_code'); + $mockVSCode = Mockery::mock(CodeEnvironment::class); + $mockVSCode->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); + $mockVSCode->shouldReceive('name')->andReturn('vscode'); - rmdir($tempDir.'/.claude'); - rmdir($tempDir); -}); + $mockCursor = Mockery::mock(CodeEnvironment::class); + $mockCursor->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(true); + $mockCursor->shouldReceive('name')->andReturn('cursor'); -test('discoverProjectInstalledCodeEnvironments detects phpstorm with idea directory', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.idea'); + $mockOther = Mockery::mock(CodeEnvironment::class); + $mockOther->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); + $mockOther->shouldReceive('name')->andReturn('other'); - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + $this->container->bind(PhpStorm::class, fn () => $mockPhpStorm); + $this->container->bind(VSCode::class, fn () => $mockVSCode); + $this->container->bind(Cursor::class, fn () => $mockCursor); + $this->container->bind(ClaudeCode::class, fn () => $mockOther); + $this->container->bind(Codex::class, fn () => $mockOther); + $this->container->bind(Copilot::class, fn () => $mockOther); - expect($detected)->toContain('phpstorm'); + $detector = new CodeEnvironmentsDetector($this->container, $this->boostManager); + $detected = $detector->discoverSystemInstalledCodeEnvironments(); - // Cleanup - rmdir($tempDir.'/.idea'); - rmdir($tempDir); + expect($detected)->toBe(['phpstorm', 'cursor']); }); -test('discoverProjectInstalledCodeEnvironments detects phpstorm with junie directory', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.junie'); +it('returns an empty array when no environments are detected for system discovery', function (): void { + $mockEnvironment = Mockery::mock(CodeEnvironment::class); + $mockEnvironment->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); + $mockEnvironment->shouldReceive('name')->andReturn('mock'); - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + $this->container->bind(PhpStorm::class, fn () => $mockEnvironment); + $this->container->bind(VSCode::class, fn () => $mockEnvironment); + $this->container->bind(Cursor::class, fn () => $mockEnvironment); + $this->container->bind(ClaudeCode::class, fn () => $mockEnvironment); + $this->container->bind(Codex::class, fn () => $mockEnvironment); + $this->container->bind(Copilot::class, fn () => $mockEnvironment); - expect($detected)->toContain('phpstorm'); + $detector = new CodeEnvironmentsDetector($this->container, $this->boostManager); + $detected = $detector->discoverSystemInstalledCodeEnvironments(); - // Cleanup - rmdir($tempDir.'/.junie'); - rmdir($tempDir); + expect($detected)->toBe([]); }); -test('discoverProjectInstalledCodeEnvironments detects cursor with cursor directory', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.cursor'); +it('returns an array of detected environment names for project discovery', function (): void { + $basePath = '/test/project'; - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + $mockVSCode = Mockery::mock(CodeEnvironment::class); + $mockVSCode->shouldReceive('detectInProject')->with($basePath)->andReturn(true); + $mockVSCode->shouldReceive('name')->andReturn('vscode'); - expect($detected)->toContain('cursor'); + $mockPhpStorm = Mockery::mock(CodeEnvironment::class); + $mockPhpStorm->shouldReceive('detectInProject')->with($basePath)->andReturn(false); + $mockPhpStorm->shouldReceive('name')->andReturn('phpstorm'); - // Cleanup - rmdir($tempDir.'/.cursor'); - rmdir($tempDir); -}); + $mockClaudeCode = Mockery::mock(CodeEnvironment::class); + $mockClaudeCode->shouldReceive('detectInProject')->with($basePath)->andReturn(true); + $mockClaudeCode->shouldReceive('name')->andReturn('claudecode'); -test('discoverProjectInstalledCodeEnvironments detects codex with codex directory', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.codex'); + $mockOther = Mockery::mock(CodeEnvironment::class); + $mockOther->shouldReceive('detectInProject')->with($basePath)->andReturn(false); + $mockOther->shouldReceive('name')->andReturn('other'); - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + $this->container->bind(PhpStorm::class, fn () => $mockPhpStorm); + $this->container->bind(VSCode::class, fn () => $mockVSCode); + $this->container->bind(Cursor::class, fn () => $mockOther); + $this->container->bind(ClaudeCode::class, fn () => $mockClaudeCode); + $this->container->bind(Codex::class, fn () => $mockOther); + $this->container->bind(Copilot::class, fn () => $mockOther); - expect($detected)->toContain('codex'); + $detector = new CodeEnvironmentsDetector($this->container, $this->boostManager); + $detected = $detector->discoverProjectInstalledCodeEnvironments($basePath); - rmdir($tempDir.'/.codex'); - rmdir($tempDir); + expect($detected)->toBe(['vscode', 'claudecode']); }); -test('discoverProjectInstalledCodeEnvironments detects codex with AGENTS.md file', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - file_put_contents($tempDir.'/AGENTS.md', 'test'); +it('returns an empty array when no environments are detected for project discovery', function (): void { + $basePath = '/empty/project'; - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + $mockEnvironment = Mockery::mock(CodeEnvironment::class); + $mockEnvironment->shouldReceive('detectInProject')->with($basePath)->andReturn(false); + $mockEnvironment->shouldReceive('name')->andReturn('mock'); - expect($detected)->toContain('codex'); - - unlink($tempDir.'/AGENTS.md'); - rmdir($tempDir); -}); + $this->container->bind(PhpStorm::class, fn () => $mockEnvironment); + $this->container->bind(VSCode::class, fn () => $mockEnvironment); + $this->container->bind(Cursor::class, fn () => $mockEnvironment); + $this->container->bind(ClaudeCode::class, fn () => $mockEnvironment); + $this->container->bind(Codex::class, fn () => $mockEnvironment); + $this->container->bind(Copilot::class, fn () => $mockEnvironment); -test('discoverProjectInstalledCodeEnvironments handles multiple detections', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.vscode'); - mkdir($tempDir.'/.cursor'); - file_put_contents($tempDir.'/CLAUDE.md', 'test'); - - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); - - expect($detected)->toContain('vscode') - ->and($detected)->toContain('cursor') - ->and($detected)->toContain('claude_code') - ->and(count($detected))->toBeGreaterThanOrEqual(3); + $detector = new CodeEnvironmentsDetector($this->container, $this->boostManager); + $detected = $detector->discoverProjectInstalledCodeEnvironments($basePath); - // Cleanup - rmdir($tempDir.'/.vscode'); - rmdir($tempDir.'/.cursor'); - unlink($tempDir.'/CLAUDE.md'); - rmdir($tempDir); + expect($detected)->toBe([]); }); diff --git a/tests/Unit/Install/ExampleCodeEnvironment.php b/tests/Unit/Install/ExampleCodeEnvironment.php new file mode 100644 index 00000000..34a64f06 --- /dev/null +++ b/tests/Unit/Install/ExampleCodeEnvironment.php @@ -0,0 +1,43 @@ + 'which example']; + } + + public function projectDetectionConfig(): array + { + return ['paths' => ['.example']]; + } + + public function mcpConfigPath(): string + { + return '.example/config.json'; + } + + public function guidelinesPath(): string + { + return 'EXAMPLE.md'; + } +} diff --git a/tests/Unit/Support/ComposerTest.php b/tests/Unit/Support/ComposerTest.php index ead59d86..4145496b 100644 --- a/tests/Unit/Support/ComposerTest.php +++ b/tests/Unit/Support/ComposerTest.php @@ -7,7 +7,7 @@ }); it('may store and retrieve guidelines', function (): void { - $config = new Config(__DIR__); + $config = new Config; expect($config->getGuidelines())->toBeEmpty(); @@ -22,7 +22,7 @@ }); it('may store and retrieve agents', function (): void { - $config = new Config(__DIR__); + $config = new Config; expect($config->getAgents())->toBeEmpty(); @@ -37,7 +37,7 @@ }); it('may store and retrieve editors', function (): void { - $config = new Config(__DIR__); + $config = new Config; expect($config->getEditors())->toBeEmpty(); @@ -52,7 +52,7 @@ }); it('may store and retrieve herd mcp installation status', function (): void { - $config = new Config(__DIR__); + $config = new Config; expect($config->getHerdMcp())->toBeFalse();