From 7362bb228e4c6deee91497586e73154fc1f11778 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Fri, 27 Feb 2026 21:31:22 +0100 Subject: [PATCH] [Mate] Add Codex wrappers and refresh agent instructions on discover --- demo/AGENTS.md | 9 +- demo/bin/codex | 19 ++ demo/bin/codex.bat | 19 ++ demo/mate/AGENT_INSTRUCTIONS.md | 53 ++++ docs/components/mate.rst | 15 +- docs/components/mate/integration.rst | 39 +++ src/mate/CHANGELOG.md | 6 + src/mate/resources/bin/codex | 19 ++ src/mate/resources/bin/codex.bat | 19 ++ src/mate/resources/mate/AGENT_INSTRUCTIONS.md | 6 + src/mate/resources/mate/config.php | 4 + .../src/Agent/AgentInstructionsAggregator.php | 35 ++- .../Agent/AgentInstructionsMaterializer.php | 236 ++++++++++++++++++ src/mate/src/Command/DiscoverCommand.php | 102 ++++---- src/mate/src/Command/InitCommand.php | 41 ++- .../Service/ExtensionConfigSynchronizer.php | 130 ++++++++++ src/mate/src/default.config.php | 4 + .../AgentInstructionsMaterializerTest.php | 136 ++++++++++ .../tests/Command/DiscoverCommandTest.php | 37 ++- src/mate/tests/Command/InitCommandTest.php | 30 ++- 20 files changed, 882 insertions(+), 77 deletions(-) create mode 100755 demo/bin/codex create mode 100644 demo/bin/codex.bat create mode 100644 demo/mate/AGENT_INSTRUCTIONS.md create mode 100644 src/mate/resources/bin/codex create mode 100644 src/mate/resources/bin/codex.bat create mode 100644 src/mate/resources/mate/AGENT_INSTRUCTIONS.md create mode 100644 src/mate/src/Agent/AgentInstructionsMaterializer.php create mode 100644 src/mate/src/Service/ExtensionConfigSynchronizer.php create mode 100644 src/mate/tests/Agent/AgentInstructionsMaterializerTest.php diff --git a/demo/AGENTS.md b/demo/AGENTS.md index b7a5f292..3a25572b 100644 --- a/demo/AGENTS.md +++ b/demo/AGENTS.md @@ -76,4 +76,11 @@ symfony console mcp:server - OpenAI GPT-4o-mini default model - ChromaDB on port 8080 - LiveComponents for real-time UI -- Symfony DI and best practices \ No newline at end of file +- Symfony DI and best practices + + +AI Mate Summary: +- Role: MCP-powered, project-aware coding guidance and tools. +- Required action: Read and follow `mate/AGENT_INSTRUCTIONS.md` before taking any action in this project, and prefer MCP tools over raw CLI commands whenever possible. +- Installed extensions: symfony/ai-mate, symfony/ai-monolog-mate-extension, symfony/ai-symfony-mate-extension. + diff --git a/demo/bin/codex b/demo/bin/codex new file mode 100755 index 00000000..cdccf5e8 --- /dev/null +++ b/demo/bin/codex @@ -0,0 +1,19 @@ +#!/usr/bin/env sh + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +PROJECT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) + +if ! command -v codex >/dev/null 2>&1; then + echo "Codex CLI is not installed or not available in PATH." >&2 + echo "Install Codex and try again." >&2 + exit 1 +fi + +cd "$PROJECT_DIR" + +exec codex \ + -c "mcp_servers.symfony_ai_mate_local.command='./vendor/bin/mate'" \ + -c "mcp_servers.symfony_ai_mate_local.args=['serve','--force-keep-alive']" \ + "$@" diff --git a/demo/bin/codex.bat b/demo/bin/codex.bat new file mode 100644 index 00000000..4f42287f --- /dev/null +++ b/demo/bin/codex.bat @@ -0,0 +1,19 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +for %%I in ("%SCRIPT_DIR%..") do set "PROJECT_DIR=%%~fI" + +where codex >nul 2>nul +if errorlevel 1 ( + echo Codex CLI is not installed or not available in PATH. 1>&2 + echo Install Codex and try again. 1>&2 + exit /b 1 +) + +pushd "%PROJECT_DIR%" >nul +codex -c "mcp_servers.symfony_ai_mate_local.command='./vendor/bin/mate'" -c "mcp_servers.symfony_ai_mate_local.args=['serve','--force-keep-alive']" %* +set "CODE=%ERRORLEVEL%" +popd >nul + +exit /b %CODE% diff --git a/demo/mate/AGENT_INSTRUCTIONS.md b/demo/mate/AGENT_INSTRUCTIONS.md new file mode 100644 index 00000000..72df17d1 --- /dev/null +++ b/demo/mate/AGENT_INSTRUCTIONS.md @@ -0,0 +1,53 @@ +## AI Mate Agent Instructions + +This MCP server provides specialized tools for PHP development. +The following extensions are installed and provide MCP tools that you should +prefer over running CLI commands directly. + +--- + +### Monolog Bridge + +Use MCP tools instead of CLI for log analysis: + +| Instead of... | Use | +|-----------------------------------|------------------------------------| +| `tail -f var/log/dev.log` | `monolog-tail` | +| `grep "error" var/log/*.log` | `monolog-search` with term "error" | +| `grep -E "pattern" var/log/*.log` | `monolog-search-regex` | + +#### Benefits + +- Structured output with parsed log entries +- Multi-file search across all logs at once +- Filter by environment, level, or channel + +--- + +### Symfony Bridge + +#### Container Introspection + +| Instead of... | Use | +|--------------------------------|---------------------| +| `bin/console debug:container` | `symfony-services` | + +- Direct access to compiled container +- Environment-aware (auto-detects dev/test/prod) + +#### Profiler Access + +When `symfony/http-kernel` is installed, profiler tools become available: + +| Tool | Description | +|-----------------------------|--------------------------------------------| +| `symfony-profiler-list` | List profiles with optional filtering | +| `symfony-profiler-latest` | Get the most recent profile | +| `symfony-profiler-search` | Search by route, method, status, date | +| `symfony-profiler-get` | Get profile by token | + +**Resources:** +- `symfony-profiler://profile/{token}` - Full profile with collector list +- `symfony-profiler://profile/{token}/{collector}` - Collector-specific data + +**Security:** Cookies, session data, auth headers, and sensitive env vars are automatically redacted. diff --git a/docs/components/mate.rst b/docs/components/mate.rst index 06fdc6ca..a03403fb 100644 --- a/docs/components/mate.rst +++ b/docs/components/mate.rst @@ -45,7 +45,9 @@ This creates: * ``mate/`` directory with configuration files * ``mate/src`` directory for custom extensions -* ``mcp.json`` for MCP client configuration +* ``mate/AGENT_INSTRUCTIONS.md`` placeholder (refreshed by ``mate discover``) +* ``mcp.json`` for MCP clients that support it (e.g. Claude Desktop) +* ``bin/codex`` and ``bin/codex.bat`` wrappers for Codex runtime MCP injection It also updates your ``composer.json`` with the following configuration: @@ -77,12 +79,23 @@ Discover available extensions: $ vendor/bin/mate discover +This command also refreshes: + +* ``mate/AGENT_INSTRUCTIONS.md`` +* Managed AI Mate instruction section in ``AGENTS.md`` + Start the MCP server: .. code-block:: terminal $ vendor/bin/mate serve +For Codex, start with the generated wrapper (``./bin/codex``); Codex does not read this project's ``mcp.json``: + +.. code-block:: terminal + + $ ./bin/codex + Add Custom Tools ---------------- diff --git a/docs/components/mate/integration.rst b/docs/components/mate/integration.rst index 0b0f58c4..4ddcf2d7 100644 --- a/docs/components/mate/integration.rst +++ b/docs/components/mate/integration.rst @@ -66,6 +66,25 @@ To add Symfony AI Mate to Claude Code (see `Claude Code MCP documentation`_ for $ claude mcp add mate $(pwd)/vendor/bin/mate serve --scope local $ claude mcp list # Verify: mate - ✓ Connected +Codex +----- + +Symfony AI Mate initializes project-local Codex wrappers: + +* ``bin/codex`` (macOS/Linux) +* ``bin/codex.bat`` (Windows) + +Use these wrappers to start Codex with runtime MCP injection: + +.. code-block:: terminal + + $ ./bin/codex + +.. note:: + + Codex does not read this project's ``mcp.json``. The wrappers pass + runtime ``-c mcp_servers...`` options so no persistent Codex config is written. + Troubleshooting --------------- @@ -127,6 +146,26 @@ Claude Code Not Connecting 3. **Check for conflicting servers** with similar names. +Codex Not Showing Mate Tools +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. **Use the wrapper**: + + .. code-block:: terminal + + $ ./bin/codex + +2. **Refresh extension and agent instructions**: + + .. code-block:: terminal + + $ vendor/bin/mate discover + +3. **Check wrapper scripts exist**: + + - macOS/Linux: ``bin/codex`` + - Windows: ``bin/codex.bat`` + For general server issues and debugging tips, see the :doc:`troubleshooting` guide. .. _`JetBrains MCP documentation`: https://www.jetbrains.com/help/idea/model-context-protocol.html diff --git a/src/mate/CHANGELOG.md b/src/mate/CHANGELOG.md index 855b5cb3..cd4bfe5a 100644 --- a/src/mate/CHANGELOG.md +++ b/src/mate/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +0.7 +--- + + * Add Codex wrapper generation (`bin/codex`, `bin/codex.bat`) to `mate init` + * Add AGENT instruction artifact materialization to `mate discover` (`mate/AGENT_INSTRUCTIONS.md` and managed `AGENTS.md` block) + 0.3 --- diff --git a/src/mate/resources/bin/codex b/src/mate/resources/bin/codex new file mode 100644 index 00000000..cdccf5e8 --- /dev/null +++ b/src/mate/resources/bin/codex @@ -0,0 +1,19 @@ +#!/usr/bin/env sh + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +PROJECT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) + +if ! command -v codex >/dev/null 2>&1; then + echo "Codex CLI is not installed or not available in PATH." >&2 + echo "Install Codex and try again." >&2 + exit 1 +fi + +cd "$PROJECT_DIR" + +exec codex \ + -c "mcp_servers.symfony_ai_mate_local.command='./vendor/bin/mate'" \ + -c "mcp_servers.symfony_ai_mate_local.args=['serve','--force-keep-alive']" \ + "$@" diff --git a/src/mate/resources/bin/codex.bat b/src/mate/resources/bin/codex.bat new file mode 100644 index 00000000..4f42287f --- /dev/null +++ b/src/mate/resources/bin/codex.bat @@ -0,0 +1,19 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +for %%I in ("%SCRIPT_DIR%..") do set "PROJECT_DIR=%%~fI" + +where codex >nul 2>nul +if errorlevel 1 ( + echo Codex CLI is not installed or not available in PATH. 1>&2 + echo Install Codex and try again. 1>&2 + exit /b 1 +) + +pushd "%PROJECT_DIR%" >nul +codex -c "mcp_servers.symfony_ai_mate_local.command='./vendor/bin/mate'" -c "mcp_servers.symfony_ai_mate_local.args=['serve','--force-keep-alive']" %* +set "CODE=%ERRORLEVEL%" +popd >nul + +exit /b %CODE% diff --git a/src/mate/resources/mate/AGENT_INSTRUCTIONS.md b/src/mate/resources/mate/AGENT_INSTRUCTIONS.md new file mode 100644 index 00000000..9f4c4798 --- /dev/null +++ b/src/mate/resources/mate/AGENT_INSTRUCTIONS.md @@ -0,0 +1,6 @@ +# AI Mate Agent Instructions + +This file is managed by `mate discover`. +Run `vendor/bin/mate discover` after installing, removing, or updating Mate extensions. + +Prefer MCP tools over equivalent shell commands when possible. diff --git a/src/mate/resources/mate/config.php b/src/mate/resources/mate/config.php index a2a0d58e..30d0e3ad 100644 --- a/src/mate/resources/mate/config.php +++ b/src/mate/resources/mate/config.php @@ -13,6 +13,10 @@ return static function (ContainerConfigurator $container): void { ; $container->services() + ->defaults() + ->autowire() + ->autoconfigure() + // Register your custom services here ; }; diff --git a/src/mate/src/Agent/AgentInstructionsAggregator.php b/src/mate/src/Agent/AgentInstructionsAggregator.php index 00540e24..a9ec43d8 100644 --- a/src/mate/src/Agent/AgentInstructionsAggregator.php +++ b/src/mate/src/Agent/AgentInstructionsAggregator.php @@ -40,11 +40,18 @@ final class AgentInstructionsAggregator ) { } - public function aggregate(): ?string + /** + * @param array|null $extensions + */ + public function aggregate(?array $extensions = null): ?string { + if (null === $extensions) { + $extensions = $this->extensions; + } + $extensionInstructions = []; - foreach ($this->extensions as $packageName => $data) { + foreach ($extensions as $packageName => $data) { if ('_custom' === $packageName) { $content = $this->loadRootProjectInstructions($data); } else { @@ -62,7 +69,7 @@ final class AgentInstructionsAggregator $sections = [$this->getGlobalHeader()]; foreach ($extensionInstructions as $content) { - $sections[] = $content; + $sections[] = $this->deepenMarkdownHeadings($content); } return implode("\n\n---\n\n", $sections); @@ -144,11 +151,31 @@ final class AgentInstructionsAggregator private function getGlobalHeader(): string { return <<<'MD' - # AI Mate Agent Instructions + ## AI Mate Agent Instructions This MCP server provides specialized tools for PHP development. The following extensions are installed and provide MCP tools that you should prefer over running CLI commands directly. MD; } + + private function deepenMarkdownHeadings(string $content): string + { + $lines = explode("\n", $content); + + foreach ($lines as $index => $line) { + if (!preg_match('/^(#{1,6})(\s+.*)$/', $line, $matches)) { + continue; + } + + $level = \strlen($matches[1]); + if ($level < 6) { + ++$level; + } + + $lines[$index] = str_repeat('#', $level).$matches[2]; + } + + return implode("\n", $lines); + } } diff --git a/src/mate/src/Agent/AgentInstructionsMaterializer.php b/src/mate/src/Agent/AgentInstructionsMaterializer.php new file mode 100644 index 00000000..49a16cb9 --- /dev/null +++ b/src/mate/src/Agent/AgentInstructionsMaterializer.php @@ -0,0 +1,236 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Agent; + +use Psr\Log\LoggerInterface; +use Symfony\AI\Mate\Discovery\ComposerExtensionDiscovery; + +/** + * Writes instruction artifacts that are consumed by coding agents. + * + * @phpstan-import-type ExtensionData from ComposerExtensionDiscovery + * + * @phpstan-type MaterializationResult array{ + * instructions_file_updated: bool, + * agents_file_updated: bool, + * } + * + * @author Johannes Wachter + */ +final class AgentInstructionsMaterializer +{ + public const AGENTS_START_MARKER = ''; + public const AGENTS_END_MARKER = ''; + + public function __construct( + private string $rootDir, + private AgentInstructionsAggregator $aggregator, + private LoggerInterface $logger, + ) { + } + + /** + * @param array $extensions + * + * @return MaterializationResult + */ + public function materializeForExtensions(array $extensions): array + { + $instructions = $this->aggregator->aggregate($extensions); + if (null === $instructions) { + $instructions = $this->getFallbackInstructions(); + } + + $instructionsFileUpdated = $this->writeInstructionsFile($instructions); + $agentsFileUpdated = $this->writeAgentsFile($extensions); + + return [ + 'instructions_file_updated' => $instructionsFileUpdated, + 'agents_file_updated' => $agentsFileUpdated, + ]; + } + + /** + * @return MaterializationResult + */ + public function synchronizeFromCurrentInstructionsFile(): array + { + $agentsFileUpdated = $this->writeAgentsFile(); + + return [ + 'instructions_file_updated' => true, + 'agents_file_updated' => $agentsFileUpdated, + ]; + } + + private function getInstructionsFilePath(): string + { + return $this->rootDir.'/mate/AGENT_INSTRUCTIONS.md'; + } + + private function getAgentsFilePath(): string + { + return $this->rootDir.'/AGENTS.md'; + } + + private function writeInstructionsFile(string $instructions): bool + { + $path = $this->getInstructionsFilePath(); + $directory = \dirname($path); + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + + $written = @file_put_contents($path, $this->normalizeContent($instructions)); + if (false === $written) { + $this->logger->warning('Failed to write AGENT_INSTRUCTIONS.md file', [ + 'path' => $path, + ]); + + return false; + } + + return true; + } + + /** + * @param array|null $extensions + */ + private function writeAgentsFile(?array $extensions = null): bool + { + $path = $this->getAgentsFilePath(); + $managedBlock = $this->buildManagedBlock($extensions); + + if (!file_exists($path)) { + $written = @file_put_contents($path, $this->normalizeContent($managedBlock)); + if (false === $written) { + $this->logger->warning('Failed to create AGENTS.md file', [ + 'path' => $path, + ]); + + return false; + } + + return true; + } + + $content = @file_get_contents($path); + if (false === $content) { + $this->logger->warning('Failed to read AGENTS.md file', [ + 'path' => $path, + ]); + + return false; + } + + $updatedContent = $this->replaceManagedBlock($content, $managedBlock); + $written = @file_put_contents($path, $this->normalizeContent($updatedContent)); + if (false === $written) { + $this->logger->warning('Failed to update AGENTS.md file', [ + 'path' => $path, + ]); + + return false; + } + + return true; + } + + private function replaceManagedBlock(string $content, string $managedBlock): string + { + $startPos = strpos($content, self::AGENTS_START_MARKER); + $endPos = strpos($content, self::AGENTS_END_MARKER); + + if (false === $startPos || false === $endPos || $endPos < $startPos) { + $trimmedContent = trim($content); + if ('' === $trimmedContent) { + return $managedBlock; + } + + return $trimmedContent."\n\n".$managedBlock; + } + + $endPos += \strlen(self::AGENTS_END_MARKER); + + $prefix = rtrim(substr($content, 0, $startPos)); + $suffix = ltrim(substr($content, $endPos)); + + $newContent = $managedBlock; + if ('' !== $prefix) { + $newContent = $prefix."\n\n".$managedBlock; + } + + if ('' !== $suffix) { + $newContent .= "\n\n".$suffix; + } + + return $newContent; + } + + /** + * @param array|null $extensions + */ + private function buildManagedBlock(?array $extensions = null): string + { + return implode("\n", [ + self::AGENTS_START_MARKER, + 'AI Mate Summary:', + '- Role: MCP-powered, project-aware coding guidance and tools.', + '- Required action: Read and follow `mate/AGENT_INSTRUCTIONS.md` before taking any action in this project, and prefer MCP tools over raw CLI commands whenever possible.', + '- Installed extensions: '.$this->buildInstalledExtensionsText($extensions), + self::AGENTS_END_MARKER, + ]); + } + + /** + * @param array|null $extensions + */ + private function buildInstalledExtensionsText(?array $extensions): string + { + if (null === $extensions) { + return 'See `mate/extensions.php`.'; + } + + $extensionNames = []; + foreach (array_keys($extensions) as $packageName) { + if ('_custom' === $packageName) { + continue; + } + + $extensionNames[] = $packageName; + } + + if ([] === $extensionNames) { + return 'Custom project tools only.'; + } + + sort($extensionNames); + + return implode(', ', $extensionNames).'.'; + } + + private function normalizeContent(string $content): string + { + return rtrim($content)."\n"; + } + + private function getFallbackInstructions(): string + { + return <<<'TEXT' +# AI Mate Agent Instructions + +No extension-specific instructions are currently available. +Run `vendor/bin/mate discover` to refresh discovered extensions and instructions. +Prefer MCP tools over equivalent shell commands when possible. +TEXT; + } +} diff --git a/src/mate/src/Command/DiscoverCommand.php b/src/mate/src/Command/DiscoverCommand.php index be160966..53d34ec0 100644 --- a/src/mate/src/Command/DiscoverCommand.php +++ b/src/mate/src/Command/DiscoverCommand.php @@ -11,7 +11,9 @@ namespace Symfony\AI\Mate\Command; +use Symfony\AI\Mate\Agent\AgentInstructionsMaterializer; use Symfony\AI\Mate\Discovery\ComposerExtensionDiscovery; +use Symfony\AI\Mate\Service\ExtensionConfigSynchronizer; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -23,6 +25,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; * * Scans for packages with extra.ai-mate configuration * and generates/updates mate/extensions.php with discovered extensions. + * Also refreshes AGENT instruction artifacts for coding agents. * * @author Johannes Wachter * @author Tobias Nyholm @@ -31,8 +34,9 @@ use Symfony\Component\Console\Style\SymfonyStyle; class DiscoverCommand extends Command { public function __construct( - private string $rootDir, private ComposerExtensionDiscovery $extensionDiscovery, + private ExtensionConfigSynchronizer $extensionConfigSynchronizer, + private AgentInstructionsMaterializer $instructionsMaterializer, ) { parent::__construct(self::getDefaultName()); } @@ -56,40 +60,27 @@ class DiscoverCommand extends Command $io->newLine(); $extensions = $this->extensionDiscovery->discover(); + $rootProjectExtension = $this->extensionDiscovery->discoverRootProject(); $count = \count($extensions); if (0 === $count) { + $materializationResult = $this->instructionsMaterializer->materializeForExtensions([ + '_custom' => $rootProjectExtension, + ]); + $io->warning([ 'No MCP extensions found.', 'Packages must have "extra.ai-mate" configuration in their composer.json.', ]); + $this->displayInstructionsStatus($io, $materializationResult); $io->note('Run "composer require vendor/package" to install MCP extensions.'); return Command::SUCCESS; } - $extensionsFile = $this->rootDir.'/mate/extensions.php'; - $existingExtensions = []; - $newPackages = []; - $removedPackages = []; - if (file_exists($extensionsFile)) { - $existingExtensions = include $extensionsFile; - if (!\is_array($existingExtensions)) { - $existingExtensions = []; - } - } - - foreach ($extensions as $packageName => $data) { - if (!isset($existingExtensions[$packageName])) { - $newPackages[] = $packageName; - } - } - - foreach ($existingExtensions as $packageName => $data) { - if (!isset($extensions[$packageName])) { - $removedPackages[] = $packageName; - } - } + $synchronizationResult = $this->extensionConfigSynchronizer->synchronize($extensions); + $newPackages = $synchronizationResult['new_packages']; + $removedPackages = $synchronizationResult['removed_packages']; $io->section(\sprintf('Discovered %d Extension%s', $count, 1 === $count ? '' : 's')); $rows = []; @@ -105,24 +96,7 @@ class DiscoverCommand extends Command } $io->table(['Status', 'Package', 'Scan Directories'], $rows); - $finalExtensions = []; - foreach ($extensions as $packageName => $data) { - $enabled = true; - if (isset($existingExtensions[$packageName]) && \is_array($existingExtensions[$packageName])) { - $enabled = $existingExtensions[$packageName]['enabled'] ?? true; - if (!\is_bool($enabled)) { - $enabled = true; - } - } - - $finalExtensions[$packageName] = [ - 'enabled' => $enabled, - ]; - } - - $this->writeExtensionsFile($extensionsFile, $finalExtensions); - - $io->success(\sprintf('Configuration written to: %s', $extensionsFile)); + $io->success(\sprintf('Configuration written to: %s', $synchronizationResult['file'])); if (\count($newPackages) > 0) { $io->note(\sprintf('Added %d new extension%s. All extensions are enabled by default.', \count($newPackages), 1 === \count($newPackages) ? '' : 's')); @@ -135,6 +109,25 @@ class DiscoverCommand extends Command ]); } + $enabledExtensionsForInstructions = [ + '_custom' => $rootProjectExtension, + ]; + + foreach ($synchronizationResult['extensions'] as $packageName => $config) { + if (!$config['enabled']) { + continue; + } + + if (!isset($extensions[$packageName])) { + continue; + } + + $enabledExtensionsForInstructions[$packageName] = $extensions[$packageName]; + } + + $materializationResult = $this->instructionsMaterializer->materializeForExtensions($enabledExtensionsForInstructions); + $this->displayInstructionsStatus($io, $materializationResult); + $io->comment([ 'Next steps:', ' • Edit mate/extensions.php to enable/disable specific extensions', @@ -145,27 +138,20 @@ class DiscoverCommand extends Command } /** - * @param array $extensions + * @param array{instructions_file_updated: bool, agents_file_updated: bool} $materializationResult */ - private function writeExtensionsFile(string $filePath, array $extensions): void + private function displayInstructionsStatus(SymfonyStyle $io, array $materializationResult): void { - $dir = \dirname($filePath); - if (!is_dir($dir)) { - mkdir($dir, 0755, true); + if ($materializationResult['instructions_file_updated']) { + $io->text('Updated mate/AGENT_INSTRUCTIONS.md.'); + } else { + $io->warning('Failed to update mate/AGENT_INSTRUCTIONS.md.'); } - $content = " $config) { - $enabled = $config['enabled'] ? 'true' : 'false'; - $content .= " '$packageName' => ['enabled' => $enabled],\n"; + if ($materializationResult['agents_file_updated']) { + $io->text('Updated AGENTS.md managed instructions block.'); + } else { + $io->warning('Failed to update AGENTS.md managed instructions block.'); } - - $content .= "];\n"; - - file_put_contents($filePath, $content); } } diff --git a/src/mate/src/Command/InitCommand.php b/src/mate/src/Command/InitCommand.php index 91787d9d..24002367 100644 --- a/src/mate/src/Command/InitCommand.php +++ b/src/mate/src/Command/InitCommand.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Mate\Command; +use Symfony\AI\Mate\Agent\AgentInstructionsMaterializer; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -18,7 +19,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; /** - * Add some config in the project root, automatically discover tools. + * Add some config in the project root and scaffold integration helpers. * Basically do every thing you need to set things up. * * @author Johannes Wachter @@ -29,6 +30,7 @@ class InitCommand extends Command { public function __construct( private string $rootDir, + private AgentInstructionsMaterializer $instructionsMaterializer, ) { parent::__construct(self::getDefaultName()); } @@ -59,15 +61,26 @@ class InitCommand extends Command $actions[] = ['✓', 'Created', 'mate/ directory']; } - $files = ['mate/extensions.php', 'mate/config.php', 'mate/.env', 'mate/.gitignore', 'mcp.json']; + $files = [ + 'mate/extensions.php', + 'mate/config.php', + 'mate/.env', + 'mate/.gitignore', + 'mate/AGENT_INSTRUCTIONS.md', + 'mcp.json', + 'bin/codex', + 'bin/codex.bat', + ]; foreach ($files as $file) { $fullPath = $this->rootDir.'/'.$file; if (!file_exists($fullPath)) { $this->copyTemplate($file, $fullPath); + $this->postCopyTemplateAction($file, $fullPath); $actions[] = ['✓', 'Created', $file]; } elseif ($io->confirm(\sprintf('%s already exists. Overwrite?', $fullPath), false)) { unlink($fullPath); $this->copyTemplate($file, $fullPath); + $this->postCopyTemplateAction($file, $fullPath); $actions[] = ['✓', 'Updated', $file]; } else { $actions[] = ['○', 'Skipped', $file.' (already exists)']; @@ -105,6 +118,13 @@ class InitCommand extends Command $composerActions = $this->updateComposerJson(); $actions = array_merge($actions, $composerActions); + $materializationResult = $this->instructionsMaterializer->synchronizeFromCurrentInstructionsFile(); + if ($materializationResult['agents_file_updated']) { + $actions[] = ['✓', 'Updated', 'AGENTS.md (AI Mate managed instructions block)']; + } else { + $actions[] = ['⚠', 'Warning', 'Could not update AGENTS.md managed instructions block']; + } + $io->section('Summary'); $io->table(['', 'Action', 'Item'], $actions); @@ -113,9 +133,10 @@ class InitCommand extends Command $io->comment([ 'Next steps:', ' 1. Run "composer dump-autoload" to update the autoloader', - ' 2. Run "vendor/bin/mate discover" to find MCP extensions', + ' 2. Run "vendor/bin/mate discover" to find MCP extensions and refresh AGENT instructions', ' 3. Add your custom MCP tools/resources/prompts to the mate/src/ directory', ' 4. Run "vendor/bin/mate serve" to start the MCP server', + ' 5. Run "./bin/codex" to start Codex with project-local Mate MCP integration', ]); $io->note([ @@ -130,9 +151,23 @@ class InitCommand extends Command private function copyTemplate(string $template, string $destination): void { + $directory = \dirname($destination); + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + copy(__DIR__.'/../../resources/'.$template, $destination); } + private function postCopyTemplateAction(string $template, string $destination): void + { + if ('bin/codex' !== $template) { + return; + } + + chmod($destination, 0755); + } + /** * @return list */ diff --git a/src/mate/src/Service/ExtensionConfigSynchronizer.php b/src/mate/src/Service/ExtensionConfigSynchronizer.php new file mode 100644 index 00000000..a9851245 --- /dev/null +++ b/src/mate/src/Service/ExtensionConfigSynchronizer.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Service; + +use Symfony\AI\Mate\Discovery\ComposerExtensionDiscovery; + +/** + * Synchronizes discovered extensions with mate/extensions.php while preserving enabled flags. + * + * @phpstan-import-type ExtensionData from ComposerExtensionDiscovery + * + * @phpstan-type ExtensionConfig array{enabled: bool} + * @phpstan-type ExtensionConfigMap array + * @phpstan-type SynchronizationResult array{ + * extensions: ExtensionConfigMap, + * new_packages: string[], + * removed_packages: string[], + * file: string, + * } + * + * @author Johannes Wachter + */ +final class ExtensionConfigSynchronizer +{ + public function __construct( + private string $rootDir, + ) { + } + + /** + * @param array $discoveredExtensions + * + * @return SynchronizationResult + */ + public function synchronize(array $discoveredExtensions): array + { + $extensionsFile = $this->rootDir.'/mate/extensions.php'; + $existingExtensions = $this->readExistingExtensions($extensionsFile); + + $newPackages = []; + foreach (array_keys($discoveredExtensions) as $packageName) { + if (!isset($existingExtensions[$packageName])) { + $newPackages[] = $packageName; + } + } + + $removedPackages = []; + foreach (array_keys($existingExtensions) as $packageName) { + if (!isset($discoveredExtensions[$packageName])) { + $removedPackages[] = $packageName; + } + } + + $finalExtensions = []; + foreach (array_keys($discoveredExtensions) as $packageName) { + $enabled = true; + if (isset($existingExtensions[$packageName]) && \is_array($existingExtensions[$packageName])) { + $enabledValue = $existingExtensions[$packageName]['enabled'] ?? true; + if (\is_bool($enabledValue)) { + $enabled = $enabledValue; + } + } + + $finalExtensions[$packageName] = [ + 'enabled' => $enabled, + ]; + } + + $this->writeExtensionsFile($extensionsFile, $finalExtensions); + + return [ + 'extensions' => $finalExtensions, + 'new_packages' => $newPackages, + 'removed_packages' => $removedPackages, + 'file' => $extensionsFile, + ]; + } + + /** + * @return array + */ + private function readExistingExtensions(string $extensionsFile): array + { + if (!file_exists($extensionsFile)) { + return []; + } + + $existingExtensions = include $extensionsFile; + if (!\is_array($existingExtensions)) { + return []; + } + + /* @var array $existingExtensions */ + return $existingExtensions; + } + + /** + * @param ExtensionConfigMap $extensions + */ + private function writeExtensionsFile(string $filePath, array $extensions): void + { + $dir = \dirname($filePath); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $content = " $config) { + $enabled = $config['enabled'] ? 'true' : 'false'; + $content .= " '$packageName' => ['enabled' => $enabled],\n"; + } + + $content .= "];\n"; + + file_put_contents($filePath, $content); + } +} diff --git a/src/mate/src/default.config.php b/src/mate/src/default.config.php index b935380e..05005da2 100644 --- a/src/mate/src/default.config.php +++ b/src/mate/src/default.config.php @@ -13,6 +13,7 @@ use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Discovery\DiscovererInterface; use Psr\Log\LoggerInterface; use Symfony\AI\Mate\Agent\AgentInstructionsAggregator; +use Symfony\AI\Mate\Agent\AgentInstructionsMaterializer; use Symfony\AI\Mate\Command\ClearCacheCommand; use Symfony\AI\Mate\Command\DebugCapabilitiesCommand; use Symfony\AI\Mate\Command\DebugExtensionsCommand; @@ -26,6 +27,7 @@ use Symfony\AI\Mate\Command\ToolsListCommand; use Symfony\AI\Mate\Discovery\CapabilityCollector; use Symfony\AI\Mate\Discovery\ComposerExtensionDiscovery; use Symfony\AI\Mate\Discovery\FilteredDiscoveryLoader; +use Symfony\AI\Mate\Service\ExtensionConfigSynchronizer; use Symfony\AI\Mate\Service\Logger; use Symfony\AI\Mate\Service\RegistryProvider; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -91,6 +93,8 @@ return static function (ContainerConfigurator $container): void { ->set(CapabilityCollector::class) ->set(AgentInstructionsAggregator::class) + ->set(AgentInstructionsMaterializer::class) + ->set(ExtensionConfigSynchronizer::class) // Register all commands ->set(InitCommand::class) diff --git a/src/mate/tests/Agent/AgentInstructionsMaterializerTest.php b/src/mate/tests/Agent/AgentInstructionsMaterializerTest.php new file mode 100644 index 00000000..ba936bef --- /dev/null +++ b/src/mate/tests/Agent/AgentInstructionsMaterializerTest.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Tests\Agent; + +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\AI\Mate\Agent\AgentInstructionsAggregator; +use Symfony\AI\Mate\Agent\AgentInstructionsMaterializer; + +/** + * @author Johannes Wachter + */ +final class AgentInstructionsMaterializerTest extends TestCase +{ + private string $tempDir; + + protected function setUp(): void + { + $this->tempDir = sys_get_temp_dir().'/mate-agent-materializer-test-'.uniqid(); + mkdir($this->tempDir, 0755, true); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + public function testMaterializeForExtensionsWritesInstructionArtifacts() + { + mkdir($this->tempDir.'/vendor/vendor/package-a', 0755, true); + file_put_contents( + $this->tempDir.'/vendor/vendor/package-a/INSTRUCTIONS.md', + "# Package A\n\nUse package A tools." + ); + + $logger = new NullLogger(); + $aggregator = new AgentInstructionsAggregator($this->tempDir, [], $logger); + $materializer = new AgentInstructionsMaterializer($this->tempDir, $aggregator, $logger); + + $result = $materializer->materializeForExtensions([ + '_custom' => ['dirs' => [], 'includes' => []], + 'vendor/package-a' => [ + 'dirs' => [], + 'includes' => [], + 'instructions' => 'INSTRUCTIONS.md', + ], + ]); + + $this->assertTrue($result['instructions_file_updated']); + $this->assertTrue($result['agents_file_updated']); + $this->assertFileExists($this->tempDir.'/mate/AGENT_INSTRUCTIONS.md'); + $this->assertFileExists($this->tempDir.'/AGENTS.md'); + + $instructions = file_get_contents($this->tempDir.'/mate/AGENT_INSTRUCTIONS.md'); + $this->assertIsString($instructions); + $this->assertStringContainsString('Use package A tools.', $instructions); + + $agents = file_get_contents($this->tempDir.'/AGENTS.md'); + $this->assertIsString($agents); + $this->assertStringContainsString(AgentInstructionsMaterializer::AGENTS_START_MARKER, $agents); + $this->assertStringContainsString(AgentInstructionsMaterializer::AGENTS_END_MARKER, $agents); + $this->assertStringContainsString('AI Mate Summary:', $agents); + $this->assertStringContainsString('- Role: MCP-powered, project-aware coding guidance and tools.', $agents); + $this->assertStringContainsString('- Required action: Read and follow `mate/AGENT_INSTRUCTIONS.md` before taking any action in this project, and prefer MCP tools over raw CLI commands whenever possible.', $agents); + $this->assertStringContainsString('- Installed extensions: vendor/package-a.', $agents); + } + + public function testSynchronizeFromCurrentInstructionsFileReplacesManagedBlock() + { + mkdir($this->tempDir.'/mate', 0755, true); + + file_put_contents( + $this->tempDir.'/mate/AGENT_INSTRUCTIONS.md', + "# Updated Instructions\n\nUse synced instructions." + ); + + file_put_contents($this->tempDir.'/AGENTS.md', <<<'MD' +# Project Rules + +Keep this line. + + +old content + + +Footer line. +MD + ); + + $logger = new NullLogger(); + $aggregator = new AgentInstructionsAggregator($this->tempDir, [], $logger); + $materializer = new AgentInstructionsMaterializer($this->tempDir, $aggregator, $logger); + + $result = $materializer->synchronizeFromCurrentInstructionsFile(); + + $this->assertTrue($result['agents_file_updated']); + + $agents = file_get_contents($this->tempDir.'/AGENTS.md'); + $this->assertIsString($agents); + $this->assertStringContainsString('Keep this line.', $agents); + $this->assertStringContainsString('Footer line.', $agents); + $this->assertStringContainsString('AI Mate Summary:', $agents); + $this->assertStringContainsString('- Required action: Read and follow `mate/AGENT_INSTRUCTIONS.md` before taking any action in this project, and prefer MCP tools over raw CLI commands whenever possible.', $agents); + $this->assertStringContainsString('- Installed extensions: See `mate/extensions.php`.', $agents); + $this->assertSame(1, substr_count($agents, AgentInstructionsMaterializer::AGENTS_START_MARKER)); + $this->assertSame(1, substr_count($agents, AgentInstructionsMaterializer::AGENTS_END_MARKER)); + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir) ?: [], ['.', '..']); + foreach ($files as $file) { + $path = $dir.'/'.$file; + if (is_dir($path)) { + $this->removeDirectory($path); + } else { + unlink($path); + } + } + + rmdir($dir); + } +} diff --git a/src/mate/tests/Command/DiscoverCommandTest.php b/src/mate/tests/Command/DiscoverCommandTest.php index d7b7059d..c7c522d9 100644 --- a/src/mate/tests/Command/DiscoverCommandTest.php +++ b/src/mate/tests/Command/DiscoverCommandTest.php @@ -13,8 +13,11 @@ namespace Symfony\AI\Mate\Tests\Command; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; +use Symfony\AI\Mate\Agent\AgentInstructionsAggregator; +use Symfony\AI\Mate\Agent\AgentInstructionsMaterializer; use Symfony\AI\Mate\Command\DiscoverCommand; use Symfony\AI\Mate\Discovery\ComposerExtensionDiscovery; +use Symfony\AI\Mate\Service\ExtensionConfigSynchronizer; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; @@ -40,7 +43,10 @@ final class DiscoverCommandTest extends TestCase $rootDir = $this->createConfiguration($this->fixturesDir.'/with-ai-mate-config', $tempDir); $logger = new NullLogger(); $discoverer = new ComposerExtensionDiscovery($rootDir, $logger); - $command = new DiscoverCommand($rootDir, $discoverer); + $synchronizer = new ExtensionConfigSynchronizer($rootDir); + $aggregator = new AgentInstructionsAggregator($rootDir, [], $logger); + $materializer = new AgentInstructionsMaterializer($rootDir, $aggregator, $logger); + $command = new DiscoverCommand($discoverer, $synchronizer, $materializer); $tester = new CommandTester($command); $tester->execute([]); @@ -56,10 +62,19 @@ final class DiscoverCommandTest extends TestCase $this->assertIsArray($extensions['vendor/package-b']); $this->assertTrue($extensions['vendor/package-a']['enabled']); $this->assertTrue($extensions['vendor/package-b']['enabled']); + $this->assertFileExists($tempDir.'/mate/AGENT_INSTRUCTIONS.md'); + $this->assertFileExists($tempDir.'/AGENTS.md'); + + $agentsContent = file_get_contents($tempDir.'/AGENTS.md'); + $this->assertIsString($agentsContent); + $this->assertStringContainsString(AgentInstructionsMaterializer::AGENTS_START_MARKER, $agentsContent); + $this->assertStringContainsString(AgentInstructionsMaterializer::AGENTS_END_MARKER, $agentsContent); + $output = $tester->getDisplay(); $this->assertStringContainsString('Discovered 2 Extension', $output); $this->assertStringContainsString('vendor/package-a', $output); $this->assertStringContainsString('vendor/package-b', $output); + $this->assertStringContainsString('Updated mate/AGENT_INSTRUCTIONS.md', $output); } finally { $this->removeDirectory($tempDir); } @@ -84,7 +99,10 @@ PHP $rootDir = $this->createConfiguration($this->fixturesDir.'/with-ai-mate-config', $tempDir); $logger = new NullLogger(); $discoverer = new ComposerExtensionDiscovery($rootDir, $logger); - $command = new DiscoverCommand($rootDir, $discoverer); + $synchronizer = new ExtensionConfigSynchronizer($rootDir); + $aggregator = new AgentInstructionsAggregator($rootDir, [], $logger); + $materializer = new AgentInstructionsMaterializer($rootDir, $aggregator, $logger); + $command = new DiscoverCommand($discoverer, $synchronizer, $materializer); $tester = new CommandTester($command); $tester->execute([]); @@ -118,7 +136,10 @@ PHP $rootDir = $this->createConfiguration($this->fixturesDir.'/with-ai-mate-config', $tempDir); $logger = new NullLogger(); $discoverer = new ComposerExtensionDiscovery($rootDir, $logger); - $command = new DiscoverCommand($rootDir, $discoverer); + $synchronizer = new ExtensionConfigSynchronizer($rootDir); + $aggregator = new AgentInstructionsAggregator($rootDir, [], $logger); + $materializer = new AgentInstructionsMaterializer($rootDir, $aggregator, $logger); + $command = new DiscoverCommand($discoverer, $synchronizer, $materializer); $tester = new CommandTester($command); $tester->execute([]); @@ -143,7 +164,10 @@ PHP $rootDir = $this->createConfiguration($this->fixturesDir.'/without-ai-mate-config', $tempDir); $logger = new NullLogger(); $discoverer = new ComposerExtensionDiscovery($rootDir, $logger); - $command = new DiscoverCommand($rootDir, $discoverer); + $synchronizer = new ExtensionConfigSynchronizer($rootDir); + $aggregator = new AgentInstructionsAggregator($rootDir, [], $logger); + $materializer = new AgentInstructionsMaterializer($rootDir, $aggregator, $logger); + $command = new DiscoverCommand($discoverer, $synchronizer, $materializer); $tester = new CommandTester($command); $tester->execute([]); @@ -152,6 +176,8 @@ PHP $output = $tester->getDisplay(); $this->assertStringContainsString('No MCP extensions found', $output); + $this->assertFileExists($tempDir.'/mate/AGENT_INSTRUCTIONS.md'); + $this->assertFileExists($tempDir.'/AGENTS.md'); } finally { $this->removeDirectory($tempDir); } @@ -161,6 +187,9 @@ PHP { // Copy fixture to temp directory for testing $this->copyDirectory($rootDir.'/vendor', $tempDir.'/vendor'); + if (file_exists($rootDir.'/composer.json')) { + copy($rootDir.'/composer.json', $tempDir.'/composer.json'); + } return $tempDir; } diff --git a/src/mate/tests/Command/InitCommandTest.php b/src/mate/tests/Command/InitCommandTest.php index 8b0d621b..6aea554e 100644 --- a/src/mate/tests/Command/InitCommandTest.php +++ b/src/mate/tests/Command/InitCommandTest.php @@ -12,6 +12,9 @@ namespace Symfony\AI\Mate\Tests\Command; use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\AI\Mate\Agent\AgentInstructionsAggregator; +use Symfony\AI\Mate\Agent\AgentInstructionsMaterializer; use Symfony\AI\Mate\Command\InitCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; @@ -37,7 +40,7 @@ final class InitCommandTest extends TestCase public function testCreatesDirectoryAndConfigFile() { - $command = new InitCommand($this->tempDir); + $command = $this->createCommand(); $tester = new CommandTester($command); $tester->execute([]); @@ -47,9 +50,14 @@ final class InitCommandTest extends TestCase $this->assertFileExists($this->tempDir.'/mate/extensions.php'); $this->assertFileExists($this->tempDir.'/mate/config.php'); $this->assertFileExists($this->tempDir.'/mate/.env'); + $this->assertFileExists($this->tempDir.'/mate/AGENT_INSTRUCTIONS.md'); $this->assertFileExists($this->tempDir.'/mcp.json'); + $this->assertFileExists($this->tempDir.'/bin/codex'); + $this->assertFileExists($this->tempDir.'/bin/codex.bat'); + $this->assertTrue(is_executable($this->tempDir.'/bin/codex')); $this->assertTrue(is_link($this->tempDir.'/.mcp.json')); $this->assertSame('mcp.json', readlink($this->tempDir.'/.mcp.json')); + $this->assertFileExists($this->tempDir.'/AGENTS.md'); $content = file_get_contents($this->tempDir.'/mate/extensions.php'); $this->assertIsString($content); @@ -59,7 +67,7 @@ final class InitCommandTest extends TestCase public function testDisplaysSuccessMessage() { - $command = new InitCommand($this->tempDir); + $command = $this->createCommand(); $tester = new CommandTester($command); $tester->execute([]); @@ -69,13 +77,14 @@ final class InitCommandTest extends TestCase $this->assertStringContainsString('extensions.php', $output); $this->assertStringContainsString('config.php', $output); $this->assertStringContainsString('vendor/bin/mate discover', $output); + $this->assertStringContainsString('./bin/codex', $output); $this->assertStringContainsString('Summary', $output); $this->assertStringContainsString('Created', $output); } public function testDoesNotOverwriteExistingFileWithoutConfirmation() { - $command = new InitCommand($this->tempDir); + $command = $this->createCommand(); $tester = new CommandTester($command); // Create existing file @@ -95,7 +104,7 @@ final class InitCommandTest extends TestCase public function testOverwritesExistingFileWithConfirmation() { - $command = new InitCommand($this->tempDir); + $command = $this->createCommand(); $tester = new CommandTester($command); // Create existing file @@ -116,7 +125,7 @@ final class InitCommandTest extends TestCase public function testCreatesDirectoryIfNotExists() { - $command = new InitCommand($this->tempDir); + $command = $this->createCommand(); $tester = new CommandTester($command); // Ensure mate directory doesn't exist @@ -135,7 +144,7 @@ final class InitCommandTest extends TestCase // Create composer.json without ai-mate config file_put_contents($this->tempDir.'/composer.json', json_encode(['name' => 'test/package'])); - $command = new InitCommand($this->tempDir); + $command = $this->createCommand(); $tester = new CommandTester($command); $tester->execute([]); @@ -158,6 +167,15 @@ final class InitCommandTest extends TestCase $this->assertStringContainsString('By default', $output); } + private function createCommand(): InitCommand + { + $logger = new NullLogger(); + $aggregator = new AgentInstructionsAggregator($this->tempDir, [], $logger); + $materializer = new AgentInstructionsMaterializer($this->tempDir, $aggregator, $logger); + + return new InitCommand($this->tempDir, $materializer); + } + private function removeDirectory(string $dir): void { if (!is_dir($dir)) {