diff --git a/src/mate/CHANGELOG.md b/src/mate/CHANGELOG.md index db9cdeb819..3c317c4a10 100644 --- a/src/mate/CHANGELOG.md +++ b/src/mate/CHANGELOG.md @@ -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 ` 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 ---- diff --git a/src/mate/src/Command/SkillsInstallCommand.php b/src/mate/src/Command/SkillsInstallCommand.php index fa272b639d..f3cd17ed8e 100644 --- a/src/mate/src/Command/SkillsInstallCommand.php +++ b/src/mate/src/Command/SkillsInstallCommand.php @@ -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; @@ -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 */ #[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, ) { @@ -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 %command.name% command rebuilds the generated skill folders @@ -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; } @@ -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 + */ + 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 $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, + * updated: list, + * removed: list, + * skipped: array, + * notices: list, + * skills: list, + * 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), + ], + ]; + } } diff --git a/src/mate/tests/Command/SkillsInstallCommandTest.php b/src/mate/tests/Command/SkillsInstallCommandTest.php index c6548087d8..4e91f7f05f 100644 --- a/src/mate/tests/Command/SkillsInstallCommandTest.php +++ b/src/mate/tests/Command/SkillsInstallCommandTest.php @@ -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; @@ -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(); @@ -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() @@ -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() @@ -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') ?: ''); }