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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/mate/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ CHANGELOG

* Add an `Arguments` column to `tools:list`'s table output, and stop truncating tool descriptions to 50 characters, so a tool's parameters and full description are visible without a separate `tools:inspect` call
* Add a `tools:inspect <tool-name>` hint to `tools:call`'s error output when a parameter name is unknown or a required one is missing
* Add a per-skill status table to `skills:install` output (same columns as `skills:list`, plus an `action` column showing installed/rebuilt/skipped/unchanged), and `--format=json`/`--format=toon` support, matching `skills:list`

0.13
----
Expand Down
182 changes: 180 additions & 2 deletions src/mate/src/Command/SkillsInstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@

namespace Symfony\AI\Mate\Command;

use HelgeSverre\Toon\Toon;
use Symfony\AI\Mate\Command\Trait\EnsuresToonFormatAvailabilityTrait;
use Symfony\AI\Mate\Skill\Model\SkillInstallResult;
use Symfony\AI\Mate\Skill\Model\SkillStatus;
use Symfony\AI\Mate\Skill\SkillManager;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
Expand All @@ -28,11 +32,25 @@
* extensions, and records what it did back into mate/extensions.php. It never writes into
* mate/skills/ (user-owned overrides).
*
* @phpstan-type SkillRow array{
* installed_name: string,
* original_name: string,
* package: string,
* enabled: bool,
* mode: string,
* state: string,
* source: string,
* status: string,
* action: string,
* }
*
* @author Johannes Wachter <johannes@sulu.io>
*/
#[AsCommand('skills:install', 'Install and reconcile Mate skills into the generated agent folders')]
class SkillsInstallCommand extends Command
{
use EnsuresToonFormatAvailabilityTrait;

public function __construct(
private SkillManager $manager,
) {
Expand All @@ -52,6 +70,7 @@ public static function getDefaultDescription(): string
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report what would change without writing anything');
$this->addOption('format', null, InputOption::VALUE_REQUIRED, 'Output format (table, json, toon)', 'table');
$this->setHelp(
<<<'HELP'
The <info>%command.name%</info> command rebuilds the generated skill folders
Expand All @@ -75,7 +94,30 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$io = new SymfonyStyle($input, $output);
$dryRun = true === $input->getOption('dry-run');

$this->render($io, $this->manager->reinstall($dryRun), $dryRun);
$format = $input->getOption('format');
\assert(\is_string($format));

if (!$this->ensureFormatSupported($io, $format, ['table', 'json', 'toon'])) {
return Command::FAILURE;
}

$result = $this->manager->reinstall($dryRun);

if ('table' === $format) {
$this->render($io, $result, $dryRun);

return Command::SUCCESS;
}

$data = $this->getArrayResult($result, $dryRun);

if ('json' === $format) {
$output->writeln(json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES));

return Command::SUCCESS;
}

$output->writeln(Toon::encode($data));

return Command::SUCCESS;
}
Expand Down Expand Up @@ -109,16 +151,152 @@ private function render(SymfonyStyle $io, SkillInstallResult $result, bool $dryR
$io->note($notice);
}

$rows = $this->buildRows($result, $dryRun);

if ([] === $result->active) {
$io->text($dryRun ? 'No skills would be installed.' : 'No skills are currently installed.');

if ([] !== $rows) {
$io->newLine();
$this->outputTable($rows, $io);
}

return;
}

$io->text(\sprintf('%d skill%s %s: %s', \count($result->active), 1 === \count($result->active) ? '' : 's', $dryRun ? 'would be installed' : 'installed', implode(', ', $result->active)));
// "active", not "installed": a no-op rerun would otherwise contradict the table below,
// which correctly says "unchanged" for the same skills.
$io->text(\sprintf('%d skill%s active: %s', \count($result->active), 1 === \count($result->active) ? '' : 's', implode(', ', $result->active)));

if ([] !== $rows) {
$io->newLine();
$this->outputTable($rows, $io);
}

if ($dryRun && [] === $result->installed && [] === $result->updated && [] === $result->removed) {
$io->success('Nothing to do, the generated folders are up to date.');
}
}

/**
* @return list<SkillRow>
*/
private function buildRows(SkillInstallResult $result, bool $dryRun): array
{
$names = array_unique(array_merge($result->active, array_keys($result->skipped)));
sort($names);

if ([] === $names) {
return [];
}

$statuses = [];
foreach ($this->manager->status() as $status) {
$statuses[$status->installedName] = $status;
}

$rows = [];
foreach ($names as $name) {
$status = $statuses[$name] ?? null;
if (null === $status) {
continue;
}

$rows[] = $this->toRow($status, $this->resolveAction($result, $name, $dryRun));
}

return $rows;
}

private function resolveAction(SkillInstallResult $result, string $name, bool $dryRun): string
{
if (\in_array($name, $result->installed, true)) {
return $dryRun ? 'would install' : 'installed';
}

if (\in_array($name, $result->updated, true)) {
return $dryRun ? 'would rebuild' : 'rebuilt';
}

if (isset($result->skipped[$name])) {
return $dryRun ? 'would skip' : 'skipped';
}

return 'unchanged';
}

/**
* @return SkillRow
*/
private function toRow(SkillStatus $status, string $action): array
{
return [
'installed_name' => $status->installedName,
'original_name' => $status->originalName,
'package' => $status->package,
'enabled' => $status->enabled,
'mode' => $status->mode,
'state' => $status->state,
// Same field skills:list carries, so both commands' JSON/toon output line up.
'source' => $status->source,
'status' => $status->status,
'action' => $action,
];
}

/**
* @param list<SkillRow> $rows
*/
private function outputTable(array $rows, SymfonyStyle $io): void
{
$table = new Table($io);
$table->setHeaders(['Installed Name', 'Original', 'Package', 'Enabled', 'Mode', 'State', 'Status', 'Action']);

foreach ($rows as $row) {
$table->addRow([
$row['installed_name'],
$row['original_name'],
$row['package'],
$row['enabled'] ? 'yes' : 'no',
$row['mode'],
$row['state'],
$row['status'],
$row['action'],
]);
}

$table->render();
}

/**
* @return array{
* dry_run: bool,
* installed: list<string>,
* updated: list<string>,
* removed: list<string>,
* skipped: array<string, string>,
* notices: list<string>,
* skills: list<SkillRow>,
* summary: array{total: int, installed: int, updated: int, removed: int, skipped: int},
* }
*/
private function getArrayResult(SkillInstallResult $result, bool $dryRun): array
{
return [
'dry_run' => $dryRun,
'installed' => $result->installed,
'updated' => $result->updated,
'removed' => $result->removed,
'skipped' => $result->skipped,
'notices' => $result->notices,
'skills' => $this->buildRows($result, $dryRun),
'summary' => [
'total' => \count($result->active),
'installed' => \count($result->installed),
'updated' => \count($result->updated),
'removed' => \count($result->removed),
'skipped' => \count($result->skipped),
],
];
}
}
109 changes: 107 additions & 2 deletions src/mate/tests/Command/SkillsInstallCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use PHPUnit\Framework\TestCase;
use Symfony\AI\Mate\Command\SkillsInstallCommand;
use Symfony\AI\Mate\Command\SkillsListCommand;
use Symfony\AI\Mate\Skill\SkillStateRepository;
use Symfony\AI\Mate\Tests\Skill\SkillFixtureTrait;
use Symfony\AI\Mate\Tests\Skill\SkillServicesTrait;
Expand Down Expand Up @@ -59,6 +60,23 @@ public function testInstallsDeclaredSkills()
$this->assertStringContainsString('mate-system-information', $output);
}

public function testInstallsDeclaredSkillsRendersPerSkillTableWithInstalledAction()
{
$this->createPackageWithSkill();

$tester = new CommandTester($this->command());
$tester->execute([]);

$output = $tester->getDisplay();
$this->assertStringContainsString('Installed Name', $output);
$this->assertStringContainsString('Original', $output);
$this->assertStringContainsString('Package', $output);
$this->assertStringContainsString('Action', $output);
$this->assertStringContainsString('mate-system-information', $output);
$this->assertStringContainsString('vendor/pkg-a', $output);
$this->assertStringContainsString('installed', $output);
}

public function testSecondRunIsIdempotent()
{
$this->createPackageWithSkill();
Expand All @@ -70,7 +88,91 @@ public function testSecondRunIsIdempotent()

$output = $tester->getDisplay();
$this->assertStringNotContainsString('Installed 1 new skill', $output);
$this->assertStringContainsString('1 skill installed', $output);
$this->assertStringNotContainsString('1 skill installed', $output);
$this->assertStringContainsString('1 skill active', $output);
}

public function testSecondRunShowsUnchangedActionInTable()
{
$this->createPackageWithSkill();

(new CommandTester($this->command()))->execute([]);

$tester = new CommandTester($this->command());
$tester->execute([]);

$output = $tester->getDisplay();
$this->assertStringContainsString('mate-system-information', $output);
$this->assertStringContainsString('unchanged', $output);
}

public function testUpdatedSourceShowsRebuiltActionInTable()
{
$this->createPackageWithSkill();
(new CommandTester($this->command()))->execute([]);

$this->createSkill($this->rootDir.'/vendor/vendor/pkg-a/skills', 'system-information', 'Inspect the runtime environment when diagnosing a version-specific problem.', 'UPDATED UPSTREAM');

$tester = new CommandTester($this->command());
$tester->execute([]);

$output = $tester->getDisplay();
$this->assertStringContainsString('Rebuilt 1 skill', $output);
$this->assertStringContainsString('mate-system-information', $output);
$this->assertStringContainsString('rebuilt', $output);
}

public function testSkippedSkillAppearsInTableWithSkippedAction()
{
$this->createPackageWithSkill();
(new SkillStateRepository($this->rootDir))->setMode('vendor/pkg-a', 'system-information', 'override');

$tester = new CommandTester($this->command());
$tester->execute([]);

$output = $tester->getDisplay();
$this->assertStringContainsString('Skipped mate-system-information', $output);
$this->assertStringContainsString('skipped', $output);
}

public function testRemovedSkillDoesNotAppearInPerSkillTable()
{
$this->createPackageWithSkill();
(new CommandTester($this->command()))->execute([]);

(new SkillStateRepository($this->rootDir))->setEnabled('vendor/pkg-a', 'system-information', false);

$tester = new CommandTester($this->command());
$tester->execute([]);

$output = $tester->getDisplay();
$this->assertStringContainsString('Removed 1 skill', $output);
$this->assertStringNotContainsString('Installed Name', $output);
}

public function testJsonFormatIncludesSkillsAndSummary()
{
$this->createPackageWithSkill();

$tester = new CommandTester($this->command());
$tester->execute(['--format' => 'json']);

$decoded = json_decode($tester->getDisplay(), true);
$this->assertIsArray($decoded);
$this->assertFalse($decoded['dry_run']);
$this->assertSame(['mate-system-information'], $decoded['installed']);
$this->assertSame(1, $decoded['summary']['total']);
$this->assertSame(1, $decoded['summary']['installed']);
$this->assertSame('mate-system-information', $decoded['skills'][0]['installed_name']);
$this->assertSame('installed', $decoded['skills'][0]['action']);

// Cross-check against skills:list, which carries the same field for the same skill.
$listTester = new CommandTester(new SkillsListCommand($this->createManager($this->rootDir)));
$listTester->execute(['--format' => 'json']);
$listDecoded = json_decode($listTester->getDisplay(), true);
$this->assertIsArray($listDecoded);
$this->assertArrayHasKey('source', $decoded['skills'][0]);
$this->assertSame($listDecoded['skills'][0]['source'], $decoded['skills'][0]['source']);
}

public function testDryRunReportsTheNewSkillWithoutWritingAnything()
Expand All @@ -88,6 +190,7 @@ public function testDryRunReportsTheNewSkillWithoutWritingAnything()
$this->assertStringContainsString('dry run', $output);
$this->assertStringContainsString('Would install 1 new skill', $output);
$this->assertStringContainsString('mate-system-information', $output);
$this->assertStringContainsString('would install', $output);
}

public function testDryRunReportsAChangedSourceAsARebuild()
Expand All @@ -100,7 +203,9 @@ public function testDryRunReportsAChangedSourceAsARebuild()
$tester = new CommandTester($this->command());
$tester->execute(['--dry-run' => true]);

$this->assertStringContainsString('Would rebuild 1 skill', $tester->getDisplay());
$output = $tester->getDisplay();
$this->assertStringContainsString('Would rebuild 1 skill', $output);
$this->assertStringContainsString('would rebuild', $output);
$this->assertStringNotContainsString('UPDATED UPSTREAM', file_get_contents($this->rootDir.'/.agents/skills/mate-system-information/SKILL.md') ?: '');
}

Expand Down
Loading