feature #1682 [Mate] Add Codex wrappers and refresh agent instructions on discover (wachterjohannes)

This PR was squashed before being merged into the main branch.

Discussion
----------

[Mate] Add Codex wrappers and refresh agent instructions on discover

| Q             | A |
| ------------- | --- |
| Bug fix?      | yes |
| New feature?  | yes |
| Docs?         | yes |
| Issues        | n/a (no related issue) |
| License       | MIT |

## Description

This PR improves Symfony AI Mate integration for Codex and keeps agent instructions in sync with extension discovery.

### What changed

- mate discover now also refreshes agent instruction artifacts:
    - updates mate/AGENT_INSTRUCTIONS.md
    - updates a managed AI Mate section in AGENTS.md
- mate init now scaffolds all integration artifacts (without running discover):
    - keeps mcp.json + .mcp.json behavior
    - adds Codex runtime wrappers:
        - bin/codex (macOS/Linux)
        - bin/codex.bat (Windows)
    - adds mate/AGENT_INSTRUCTIONS.md template
    - updates AGENTS.md managed section from current instruction file
    - prints explicit next step to run vendor/bin/mate discover
- extracted extension sync logic to ExtensionConfigSynchronizer
- added AgentInstructionsMaterializer to centralize writing:
    - mate/AGENT_INSTRUCTIONS.md
    - managed block in AGENTS.md
- extended AgentInstructionsAggregator with aggregateForExtensions(...) to support fresh discovered maps and avoid stale container state
- updated Mate docs and changelog accordingly

## Usage

vendor/bin/mate init
vendor/bin/mate discover
./bin/codex

discover is now the command that refreshes both extension config and instruction artifacts.

Commits
-------

7362bb22 [Mate] Add Codex wrappers and refresh agent instructions on discover
This commit is contained in:
Christopher Hertel
2026-03-17 22:26:39 +01:00
20 changed files with 882 additions and 77 deletions

View File

@@ -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
- Symfony DI and best practices
<!-- BEGIN AI_MATE_INSTRUCTIONS -->
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.
<!-- END AI_MATE_INSTRUCTIONS -->

19
demo/bin/codex Executable file
View File

@@ -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']" \
"$@"

19
demo/bin/codex.bat Normal file
View File

@@ -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%

View File

@@ -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.

View File

@@ -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
----------------

View File

@@ -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

View File

@@ -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
---

View File

@@ -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']" \
"$@"

View File

@@ -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%

View File

@@ -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.

View File

@@ -13,6 +13,10 @@ return static function (ContainerConfigurator $container): void {
;
$container->services()
->defaults()
->autowire()
->autoconfigure()
// Register your custom services here
;
};

View File

@@ -40,11 +40,18 @@ final class AgentInstructionsAggregator
) {
}
public function aggregate(): ?string
/**
* @param array<string, ExtensionData>|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);
}
}

View File

@@ -0,0 +1,236 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
*/
final class AgentInstructionsMaterializer
{
public const AGENTS_START_MARKER = '<!-- BEGIN AI_MATE_INSTRUCTIONS -->';
public const AGENTS_END_MARKER = '<!-- END AI_MATE_INSTRUCTIONS -->';
public function __construct(
private string $rootDir,
private AgentInstructionsAggregator $aggregator,
private LoggerInterface $logger,
) {
}
/**
* @param array<string, ExtensionData> $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<string, ExtensionData>|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<string, ExtensionData>|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<string, ExtensionData>|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;
}
}

View File

@@ -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 <johannes@sulu.io>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
@@ -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<string, array{enabled: bool}> $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 <info>mate/AGENT_INSTRUCTIONS.md</info>.');
} else {
$io->warning('Failed to update mate/AGENT_INSTRUCTIONS.md.');
}
$content = "<?php\n\n";
$content .= "// This file is managed by 'mate discover'\n";
$content .= "// You can manually edit to enable/disable extensions\n\n";
$content .= "return [\n";
foreach ($extensions as $packageName => $config) {
$enabled = $config['enabled'] ? 'true' : 'false';
$content .= " '$packageName' => ['enabled' => $enabled],\n";
if ($materializationResult['agents_file_updated']) {
$io->text('Updated <info>AGENTS.md</info> managed instructions block.');
} else {
$io->warning('Failed to update AGENTS.md managed instructions block.');
}
$content .= "];\n";
file_put_contents($filePath, $content);
}
}

View File

@@ -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 <johannes@sulu.io>
@@ -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('<question>%s already exists. Overwrite?</question>', $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<array{string, string, string}>
*/

View File

@@ -0,0 +1,130 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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<string, ExtensionConfig>
* @phpstan-type SynchronizationResult array{
* extensions: ExtensionConfigMap,
* new_packages: string[],
* removed_packages: string[],
* file: string,
* }
*
* @author Johannes Wachter <johannes@sulu.io>
*/
final class ExtensionConfigSynchronizer
{
public function __construct(
private string $rootDir,
) {
}
/**
* @param array<string, ExtensionData> $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<string, array{enabled?: bool}>
*/
private function readExistingExtensions(string $extensionsFile): array
{
if (!file_exists($extensionsFile)) {
return [];
}
$existingExtensions = include $extensionsFile;
if (!\is_array($existingExtensions)) {
return [];
}
/* @var array<string, array{enabled?: bool}> $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 = "<?php\n\n";
$content .= "// This file is managed by 'mate discover'\n";
$content .= "// You can manually edit to enable/disable extensions\n\n";
$content .= "return [\n";
foreach ($extensions as $packageName => $config) {
$enabled = $config['enabled'] ? 'true' : 'false';
$content .= " '$packageName' => ['enabled' => $enabled],\n";
}
$content .= "];\n";
file_put_contents($filePath, $content);
}
}

View File

@@ -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)

View File

@@ -0,0 +1,136 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
*/
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.
<!-- BEGIN AI_MATE_INSTRUCTIONS -->
old content
<!-- END AI_MATE_INSTRUCTIONS -->
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);
}
}

View File

@@ -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;
}

View File

@@ -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)) {