Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/Install/Agents/Agent.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Laravel\Boost\Install\Enums\McpInstallationStrategy;
use Laravel\Boost\Install\Enums\Platform;
use Laravel\Boost\Install\Mcp\FileWriter;
use Laravel\Boost\Install\Mcp\TomlFileWriter;

abstract class Agent
{
Expand Down Expand Up @@ -214,7 +215,11 @@ protected function installFileMcp(string $key, string $command, array $args = []

$normalized = $this->normalizeCommand($command, $args);

return (new FileWriter($path, $this->defaultMcpConfig()))
$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();
Expand Down
25 changes: 21 additions & 4 deletions src/Install/Agents/Codex.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function projectDetectionConfig(): array
{
return [
'paths' => ['.codex'],
'files' => ['AGENTS.md'],
'files' => ['AGENTS.md', '.codex/config.toml'],
];
}

Expand All @@ -49,12 +49,29 @@ public function guidelinesPath(): string

public function mcpInstallationStrategy(): McpInstallationStrategy
{
return McpInstallationStrategy::SHELL;
return McpInstallationStrategy::FILE;
}

public function shellMcpCommand(): string
public function mcpConfigPath(): string
{
return 'codex mcp add {key} -- {command} {args}';
return '.codex/config.toml';
}

public function mcpConfigKey(): string
{
return 'mcp_servers';
}

/** {@inheritDoc} */
public function mcpServerConfig(string $command, array $args = [], array $env = []): array
{
return collect([
'command' => $command,
'args' => $args,
'cwd' => base_path(),
'env' => $env,
])->filter(fn ($value): bool => ! in_array($value, [[], null, ''], true))
->toArray();
}

public function skillsPath(): string
Expand Down
177 changes: 177 additions & 0 deletions src/Install/Mcp/TomlFileWriter.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
4 changes: 4 additions & 0 deletions tests/Fixtures/codex-config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[mcp_servers.existing_server]
command = "npm"
args = ["start"]
cwd = "./some/path"
153 changes: 153 additions & 0 deletions tests/Unit/Install/Agents/CodexTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

declare(strict_types=1);

namespace Tests\Unit\Install\Agents;

use Illuminate\Support\Facades\File;
use Laravel\Boost\Install\Agents\Codex;
use Laravel\Boost\Install\Contracts\DetectionStrategy;
use Laravel\Boost\Install\Detection\DetectionStrategyFactory;
use Laravel\Boost\Install\Enums\McpInstallationStrategy;
use Laravel\Boost\Install\Enums\Platform;
use Mockery;

beforeEach(function (): void {
$this->strategyFactory = Mockery::mock(DetectionStrategyFactory::class);
$this->strategy = Mockery::mock(DetectionStrategy::class);
});

test('returns correct name', function (): void {
$codex = new Codex($this->strategyFactory);

expect($codex->name())->toBe('codex');
});

test('returns correct display name', function (): void {
$codex = new Codex($this->strategyFactory);

expect($codex->displayName())->toBe('Codex');
});

test('uses FILE-based MCP installation strategy', function (): void {
$codex = new Codex($this->strategyFactory);

expect($codex->mcpInstallationStrategy())->toBe(McpInstallationStrategy::FILE);
});

test('returns correct MCP config path', function (): void {
$codex = new Codex($this->strategyFactory);

expect($codex->mcpConfigPath())->toBe('.codex/config.toml');
});

test('returns correct MCP config key', function (): void {
$codex = new Codex($this->strategyFactory);

expect($codex->mcpConfigKey())->toBe('mcp_servers');
});

test('builds MCP server config with cwd field', function (): void {
$codex = new Codex($this->strategyFactory);

$config = $codex->mcpServerConfig('php', ['artisan', 'boost:mcp']);

expect($config)->toHaveKey('command', 'php')
->toHaveKey('args', ['artisan', 'boost:mcp'])
->toHaveKey('cwd');
});

test('builds MCP server config with env when provided', function (): void {
$codex = new Codex($this->strategyFactory);

$config = $codex->mcpServerConfig('php', ['artisan'], ['APP_ENV' => 'local']);

expect($config)->toHaveKey('command', 'php')
->toHaveKey('args', ['artisan'])
->toHaveKey('cwd')
->toHaveKey('env', ['APP_ENV' => 'local']);
});

test('filters empty values from server config', function (): void {
$codex = new Codex($this->strategyFactory);

$config = $codex->mcpServerConfig('php', [], []);

expect($config)->toHaveKey('command', 'php')
->toHaveKey('cwd')
->not->toHaveKey('args')
->not->toHaveKey('env');
});

test('includes config.toml in project detection', function (): void {
$codex = new Codex($this->strategyFactory);

$detection = $codex->projectDetectionConfig();

expect($detection['files'])->toContain('.codex/config.toml')
->toContain('AGENTS.md');
expect($detection['paths'])->toContain('.codex');
});

test('returns correct guidelines path', function (): void {
$codex = new Codex($this->strategyFactory);

expect($codex->guidelinesPath())->toBe('AGENTS.md');
});

test('returns correct skills path', function (): void {
$codex = new Codex($this->strategyFactory);

expect($codex->skillsPath())->toBe('.codex/skills');
});

test('system detection uses which command on Darwin', function (): void {
$codex = new Codex($this->strategyFactory);

$config = $codex->systemDetectionConfig(Platform::Darwin);

expect($config['command'])->toBe('which codex');
});

test('system detection uses which command on Linux', function (): void {
$codex = new Codex($this->strategyFactory);

$config = $codex->systemDetectionConfig(Platform::Linux);

expect($config['command'])->toBe('which codex');
});

test('system detection uses where command on Windows', function (): void {
$codex = new Codex($this->strategyFactory);

$config = $codex->systemDetectionConfig(Platform::Windows);

expect($config['command'])->toBe('where codex 2>nul');
});

test('installMcp creates TOML config file', function (): void {
$codex = new Codex($this->strategyFactory);
$capturedContent = '';

File::shouldReceive('ensureDirectoryExists')
->once()
->with('.codex');

File::shouldReceive('exists')
->once()
->with('.codex/config.toml')
->andReturn(false);

File::shouldReceive('put')
->once()
->with(Mockery::any(), Mockery::capture($capturedContent))
->andReturn(true);

$result = $codex->installMcp('laravel_boost', 'php', ['artisan', 'boost:mcp']);

expect($result)->toBeTrue()
->and($capturedContent)->toContain('[mcp_servers.laravel_boost]')
->and($capturedContent)->toContain('command = "php"')
->and($capturedContent)->toContain('args = ["artisan", "boost:mcp"]')
->and($capturedContent)->toContain('cwd = ');
});
Loading