mirror of
https://github.com/symfony/ai.git
synced 2026-03-23 23:42:18 +01:00
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:
@@ -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
19
demo/bin/codex
Executable 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
19
demo/bin/codex.bat
Normal 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%
|
||||
53
demo/mate/AGENT_INSTRUCTIONS.md
Normal file
53
demo/mate/AGENT_INSTRUCTIONS.md
Normal 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.
|
||||
@@ -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
|
||||
----------------
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
|
||||
19
src/mate/resources/bin/codex
Normal file
19
src/mate/resources/bin/codex
Normal 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
src/mate/resources/bin/codex.bat
Normal file
19
src/mate/resources/bin/codex.bat
Normal 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%
|
||||
6
src/mate/resources/mate/AGENT_INSTRUCTIONS.md
Normal file
6
src/mate/resources/mate/AGENT_INSTRUCTIONS.md
Normal 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.
|
||||
@@ -13,6 +13,10 @@ return static function (ContainerConfigurator $container): void {
|
||||
;
|
||||
|
||||
$container->services()
|
||||
->defaults()
|
||||
->autowire()
|
||||
->autoconfigure()
|
||||
|
||||
// Register your custom services here
|
||||
;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
236
src/mate/src/Agent/AgentInstructionsMaterializer.php
Normal file
236
src/mate/src/Agent/AgentInstructionsMaterializer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
*/
|
||||
|
||||
130
src/mate/src/Service/ExtensionConfigSynchronizer.php
Normal file
130
src/mate/src/Service/ExtensionConfigSynchronizer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
136
src/mate/tests/Agent/AgentInstructionsMaterializerTest.php
Normal file
136
src/mate/tests/Agent/AgentInstructionsMaterializerTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user