From adf1408ce9a60255b1a0def125ca3a838ff75e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 9 Apr 2026 17:40:48 -0300 Subject: [PATCH 1/6] Add SkillsCommand to synchronize packaged skills into consumer repositories --- src/Command/Skills/SkillsSynchronizer.php | 177 ++++++++++++++++++ src/Command/Skills/SynchronizeResult.php | 91 +++++++++ src/Command/SkillsCommand.php | 110 +++++++++++ src/Command/SyncCommand.php | 1 + .../Capability/DevToolsCommandProvider.php | 2 + .../DevToolsCommandProviderTest.php | 5 + 6 files changed, 386 insertions(+) create mode 100644 src/Command/Skills/SkillsSynchronizer.php create mode 100644 src/Command/Skills/SynchronizeResult.php create mode 100644 src/Command/SkillsCommand.php diff --git a/src/Command/Skills/SkillsSynchronizer.php b/src/Command/Skills/SkillsSynchronizer.php new file mode 100644 index 0000000..37d4af9 --- /dev/null +++ b/src/Command/Skills/SkillsSynchronizer.php @@ -0,0 +1,177 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Command\Skills; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Filesystem\Path; + +/** + * Synchronizes Fast Forward skills into consumer repositories. + */ +final class SkillsSynchronizer +{ + private readonly Filesystem $filesystem; + + public function __construct(?Filesystem $filesystem = null) + { + $this->filesystem = $filesystem ?? new Filesystem(); + } + + /** + * Synchronizes skills from the package to the consumer repository. + * + * @param string $rootPath The consumer repository root path + * @param string $skillsDir The target .agents/skills directory + * @param string $packageSkillsPath The source skills directory in the package + * @param callable(string): void $logger Callback for logging messages + * + * @return SynchronizeResult The result of the synchronization + */ + public function synchronize( + string $rootPath, + string $skillsDir, + string $packageSkillsPath, + callable $logger, + ): SynchronizeResult { + $result = new SynchronizeResult(); + + if (! $this->filesystem->exists($packageSkillsPath)) { + $logger('No packaged skills found at: ' . $packageSkillsPath . ''); + $result->markFailed(); + + return $result; + } + + if (! $this->filesystem->exists($skillsDir)) { + $this->filesystem->mkdir($skillsDir); + $logger('Created .agents/skills directory.'); + } + + $this->syncPackageSkills($rootPath, $skillsDir, $packageSkillsPath, $logger, $result); + $this->cleanupBrokenLinks($skillsDir, $logger, $result); + + return $result; + } + + /** + * Syncs skills from the package to the consumer repository. + * + * @param string $rootPath + * @param string $skillsDir + * @param string $packageSkillsPath + * @param callable $logger + * @param SynchronizeResult $result + */ + private function syncPackageSkills( + string $rootPath, + string $skillsDir, + string $packageSkillsPath, + callable $logger, + SynchronizeResult $result, + ): void { + $finder = Finder::create() + ->directories() + ->in($packageSkillsPath) + ->depth('== 0'); + + foreach ($finder as $skillDir) { + $skillName = $skillDir->getFilename(); + $targetLink = Path::makeAbsolute($skillName, $skillsDir); + $sourcePath = $skillDir->getRealPath(); + + if ($this->filesystem->exists($targetLink)) { + // Check if existing target is a valid symlink pointing to source + if ($this->isSymlink($targetLink)) { + $existingTarget = readlink($targetLink); + + if ($existingTarget === $sourcePath) { + $logger('Preserved existing link: ' . $skillName . ''); + $result->addPreservedLink($skillName); + + continue; + } + + // Broken or wrong symlink - remove and recreate + $this->filesystem->remove($targetLink); + } else { + // Non-symlink exists - check if it's the same content + // For development mode in dev-tools repo, we might have actual directories + // In that case, offer to convert to symlink + $logger('Found existing directory: ' . $skillName . ' (converting to symlink)'); + $this->filesystem->remove($targetLink); + } + } + + $this->filesystem->symlink($sourcePath, $targetLink); + $logger('Created link: ' . $skillName . ' -> ' . $sourcePath . ''); + $result->addCreatedLink($skillName); + } + } + + /** + * Cleans up broken symlinks in the skills directory. + * + * @param string $skillsDir + * @param callable $logger + * @param SynchronizeResult $result + */ + private function cleanupBrokenLinks(string $skillsDir, callable $logger, SynchronizeResult $result): void + { + if (! $this->filesystem->exists($skillsDir)) { + return; + } + + $items = scandir($skillsDir); + + foreach ($items as $item) { + if ('.' === $item || '..' === $item) { + continue; + } + + $itemPath = Path::makeAbsolute($item, $skillsDir); + + if (! is_link($itemPath)) { + continue; + } + + $target = readlink($itemPath); + + if (false === $target) { + continue; + } + + if (! file_exists($target)) { + $this->filesystem->remove($itemPath); + $logger('Removed broken link: ' . $item . ''); + $result->addRemovedBrokenLink($item); + } + } + } + + /** + * Checks if a path is a symbolic link. + * + * @param string $path + */ + private function isSymlink(string $path): bool + { + return is_link($path); + } +} diff --git a/src/Command/Skills/SynchronizeResult.php b/src/Command/Skills/SynchronizeResult.php new file mode 100644 index 0000000..71f5d71 --- /dev/null +++ b/src/Command/Skills/SynchronizeResult.php @@ -0,0 +1,91 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Command\Skills; + +/** + * Result of skill synchronization operation. + */ +final class SynchronizeResult +{ + /** + * @var list + */ + private array $createdLinks = []; + + /** + * @var list + */ + private array $preservedLinks = []; + + /** + * @var list + */ + private array $removedBrokenLinks = []; + + private bool $failed = false; + + public function addCreatedLink(string $link): void + { + $this->createdLinks[] = $link; + } + + public function addPreservedLink(string $link): void + { + $this->preservedLinks[] = $link; + } + + public function addRemovedBrokenLink(string $link): void + { + $this->removedBrokenLinks[] = $link; + } + + public function markFailed(): void + { + $this->failed = true; + } + + /** + * @return list + */ + public function getCreatedLinks(): array + { + return $this->createdLinks; + } + + /** + * @return list + */ + public function getPreservedLinks(): array + { + return $this->preservedLinks; + } + + /** + * @return list + */ + public function getRemovedBrokenLinks(): array + { + return $this->removedBrokenLinks; + } + + public function failed(): bool + { + return $this->failed; + } +} diff --git a/src/Command/SkillsCommand.php b/src/Command/SkillsCommand.php new file mode 100644 index 0000000..8d5e8ec --- /dev/null +++ b/src/Command/SkillsCommand.php @@ -0,0 +1,110 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Command; + +use FastForward\DevTools\Command\Skills\SkillsSynchronizer; +use FastForward\DevTools\Command\Skills\SynchronizeResult; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Filesystem\Path; + +/** + * Synchronizes Fast Forward skills into the consumer repository by managing `.agents/skills` links. + */ +final class SkillsCommand extends AbstractCommand +{ + private readonly SkillsSynchronizer $synchronizer; + + public function __construct(?SkillsSynchronizer $synchronizer = null) + { + $this->synchronizer = $synchronizer ?? new SkillsSynchronizer(); + + parent::__construct(); + } + + protected function configure(): void + { + $this + ->setName('dev-tools:skills') + ->setDescription('Synchronizes Fast Forward skills into .agents/skills directory.') + ->setHelp( + 'This command ensures the consumer repository contains linked Fast Forward skills ' + . 'by creating symlinks to the packaged skills and removing broken links.' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $output->writeln('Starting skills synchronization...'); + + $rootPath = $this->getCurrentWorkingDirectory(); + $skillsDir = Path::makeAbsolute('.agents/skills', $rootPath); + + // Use __DIR__ to get the package path + $packagePath = Path::makeAbsolute('..', __DIR__); + while (! file_exists($packagePath . '/composer.json')) { + $parent = \dirname($packagePath); + if ($parent === $packagePath) { + break; + } + $packagePath = $parent; + } + $packageSkillsPath = Path::makeAbsolute('.agents/skills', $packagePath); + + // If package path equals root path, we're in the dev-tools repo itself + // and skills are already present as regular files (tracked in git) + if ($packagePath === $rootPath && $this->filesystem->exists($skillsDir)) { + $output->writeln('Skills already available in development repository (tracked in git).'); + + return self::SUCCESS; + } + + // Normal consumer repository flow + if (! $this->filesystem->exists($packageSkillsPath)) { + $output->writeln('No packaged skills found at: ' . $packageSkillsPath . ''); + + return self::FAILURE; + } + + if (! $this->filesystem->exists($skillsDir)) { + $this->filesystem->mkdir($skillsDir); + $output->writeln('Created .agents/skills directory.'); + } + + /** @var SynchronizeResult $result */ + $result = $this->synchronizer->synchronize( + $rootPath, + $skillsDir, + $packageSkillsPath, + static function (string $message) use ($output): void { + $output->writeln($message); + }, + ); + + if ($result->failed()) { + $output->writeln('Skills synchronization failed.'); + + return self::FAILURE; + } + + $output->writeln('Skills synchronization completed successfully.'); + + return self::SUCCESS; + } +} diff --git a/src/Command/SyncCommand.php b/src/Command/SyncCommand.php index 1ad7980..f5d0d4b 100644 --- a/src/Command/SyncCommand.php +++ b/src/Command/SyncCommand.php @@ -75,6 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->copyDependabotConfig(); $this->addRepositoryWikiGitSubmodule(); $this->runCommand('gitignore', $output); + $this->runCommand('dev-tools:skills', $output); return self::SUCCESS; } diff --git a/src/Composer/Capability/DevToolsCommandProvider.php b/src/Composer/Capability/DevToolsCommandProvider.php index 8857259..2172e61 100644 --- a/src/Composer/Capability/DevToolsCommandProvider.php +++ b/src/Composer/Capability/DevToolsCommandProvider.php @@ -30,6 +30,7 @@ use FastForward\DevTools\Command\TestsCommand; use FastForward\DevTools\Command\WikiCommand; use FastForward\DevTools\Command\SyncCommand; +use FastForward\DevTools\Command\SkillsCommand; /** * Provides a registry of custom dev-tools commands mapped for Composer integration. @@ -58,6 +59,7 @@ public function getCommands() new WikiCommand(), new SyncCommand(), new GitIgnoreCommand(), + new SkillsCommand(), ]; } } diff --git a/tests/Composer/Capability/DevToolsCommandProviderTest.php b/tests/Composer/Capability/DevToolsCommandProviderTest.php index 94bdedb..ba885a7 100644 --- a/tests/Composer/Capability/DevToolsCommandProviderTest.php +++ b/tests/Composer/Capability/DevToolsCommandProviderTest.php @@ -23,6 +23,8 @@ use FastForward\DevTools\Command\DocsCommand; use FastForward\DevTools\Command\GitIgnoreCommand; use FastForward\DevTools\Command\SyncCommand; +use FastForward\DevTools\Command\SkillsCommand; +use FastForward\DevTools\Command\Skills\SkillsSynchronizer; use FastForward\DevTools\Command\PhpDocCommand; use FastForward\DevTools\Command\RefactorCommand; use FastForward\DevTools\Command\ReportsCommand; @@ -48,6 +50,8 @@ #[UsesClass(WikiCommand::class)] #[UsesClass(SyncCommand::class)] #[UsesClass(GitIgnoreCommand::class)] +#[UsesClass(SkillsCommand::class)] +#[UsesClass(SkillsSynchronizer::class)] #[UsesClass(Merger::class)] #[UsesClass(Writer::class)] final class DevToolsCommandProviderTest extends TestCase @@ -80,6 +84,7 @@ public function getCommandsWillReturnAllSupportedCommandsInExpectedOrder(): void new WikiCommand(), new SyncCommand(), new GitIgnoreCommand(), + new SkillsCommand(), ], $this->commandProvider->getCommands(), ); From 17e4006804e5b09f8d19be3993d982d95d09f41d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 9 Apr 2026 17:42:36 -0300 Subject: [PATCH 2/6] refactor: update branching pattern and PR title guidance in documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- .../github-pull-request/references/implementation-loop.md | 2 +- .../skills/github-pull-request/references/pr-drafting.md | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/skills/github-pull-request/references/implementation-loop.md b/.agents/skills/github-pull-request/references/implementation-loop.md index b642ca7..291a399 100644 --- a/.agents/skills/github-pull-request/references/implementation-loop.md +++ b/.agents/skills/github-pull-request/references/implementation-loop.md @@ -7,7 +7,7 @@ Use this loop for each selected issue. - Update `main` or the repository's integration branch before branching. - Keep branch names stable and descriptive. - Prefer the repository convention when one exists. -- If no repository convention is defined, use a stable pattern such as `issue-123-short-slug`. +- If no repository convention is defined, use a stable pattern such as `type-of-issue/123-short-slug`. ## Implementation Boundaries diff --git a/.agents/skills/github-pull-request/references/pr-drafting.md b/.agents/skills/github-pull-request/references/pr-drafting.md index 1b5b1b6..53d82b1 100644 --- a/.agents/skills/github-pull-request/references/pr-drafting.md +++ b/.agents/skills/github-pull-request/references/pr-drafting.md @@ -41,15 +41,15 @@ Closes #123 ## Title Guidance - Follow repository title rules when they exist. -- For this repository, prefer `[area] Brief description (#123)`. +- For this repository, prefer `[area] Brief description`. - Use the issue number that the PR closes in the title when it is known. - Derive `area` from the touched subsystem, command, or package rather than using a generic label. Examples: -- `[tests] Add command coverage for sync workflow (#42)` -- `[docs] Document wiki generation flow (#57)` -- `[command] Add dependency analysis command (#91)` +- `[tests] Add command coverage for sync workflow` +- `[docs] Document wiki generation flow` +- `[command] Add dependency analysis command` ## Draft vs Ready From ff69fc8222964e93ed5760447fa4df3c3c170ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 9 Apr 2026 18:16:35 -0300 Subject: [PATCH 3/6] Add descriptive PHPDoc to skills classes and refactor sync methods --- src/Agent/Skills/SkillsSynchronizer.php | 258 ++++++++++++++++++++++ src/Agent/Skills/SynchronizeResult.php | 130 +++++++++++ src/Command/Skills/SkillsSynchronizer.php | 177 --------------- src/Command/Skills/SynchronizeResult.php | 91 -------- src/Command/SkillsCommand.php | 51 ++--- src/Command/SyncCommand.php | 2 +- 6 files changed, 407 insertions(+), 302 deletions(-) create mode 100644 src/Agent/Skills/SkillsSynchronizer.php create mode 100644 src/Agent/Skills/SynchronizeResult.php delete mode 100644 src/Command/Skills/SkillsSynchronizer.php delete mode 100644 src/Command/Skills/SynchronizeResult.php diff --git a/src/Agent/Skills/SkillsSynchronizer.php b/src/Agent/Skills/SkillsSynchronizer.php new file mode 100644 index 0000000..65b9736 --- /dev/null +++ b/src/Agent/Skills/SkillsSynchronizer.php @@ -0,0 +1,258 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Agent\Skills; + +use Psr\Log\LoggerInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Filesystem\Path; + +/** + * Synchronizes Fast Forward skills into consumer repositories. + * + * This class manages the creation and maintenance of symlinks from a consumer + * repository to the skills packaged within the fast-forward/dev-tools dependency. + * It handles initial sync, idempotent re-runs, and cleanup of broken links. + */ +final class SkillsSynchronizer +{ + private readonly Filesystem $filesystem; + + /** + * @param Filesystem|null $filesystem Filesystem instance for file operations + */ + public function __construct(?Filesystem $filesystem = null) + { + $this->filesystem = $filesystem ?? new Filesystem(); + } + + /** + * Synchronizes skills from the package to the consumer repository. + * + * Ensures the consumer repository contains linked Fast Forward skills by + * creating symlinks to the packaged skills directory. Creates the target + * directory if missing, skips existing valid links, and repairs broken ones. + * + * @param string $skillsDir Absolute path to the consumer's .agents/skills directory + * @param string $packageSkillsPath Absolute path to the packaged skills in the dependency + * @param LoggerInterface $logger Logger for reporting sync operations + * + * @return SynchronizeResult Result containing counts of created, preserved, and removed links + */ + public function synchronize( + string $skillsDir, + string $packageSkillsPath, + LoggerInterface $logger, + ): SynchronizeResult { + $result = new SynchronizeResult(); + + if (! $this->filesystem->exists($packageSkillsPath)) { + $logger->error('No packaged skills found at: ' . $packageSkillsPath); + $result->markFailed(); + + return $result; + } + + if (! $this->filesystem->exists($skillsDir)) { + $this->filesystem->mkdir($skillsDir); + $logger->info('Created .agents/skills directory.'); + } + + $this->syncPackageSkills($skillsDir, $packageSkillsPath, $logger, $result); + + return $result; + } + + /** + * Iterates through all packaged skills and processes each one. + * + * Uses Finder to locate skill directories in the package, then processes + * each as a potential symlink in the consumer repository. + * + * @param string $skillsDir Target directory for symlinks + * @param string $packageSkillsPath Source directory containing packaged skills + * @param LoggerInterface $logger Logger for operation feedback + * @param SynchronizeResult $result Result object to track outcomes + */ + private function syncPackageSkills( + string $skillsDir, + string $packageSkillsPath, + LoggerInterface $logger, + SynchronizeResult $result, + ): void { + $finder = Finder::create() + ->directories() + ->in($packageSkillsPath) + ->depth('== 0'); + + foreach ($finder as $skillDir) { + $skillName = $skillDir->getFilename(); + $targetLink = Path::makeAbsolute($skillName, $skillsDir); + $sourcePath = $skillDir->getRealPath(); + + $this->processSkillLink($skillName, $targetLink, $sourcePath, $logger, $result); + } + } + + /** + * Routes a skill link to the appropriate handling method based on target state. + * + * Determines whether the target path needs creation, preservation, or repair + * based on filesystem checks, then delegates to the corresponding method. + * + * @param string $skillName Name of the skill being processed + * @param string $targetLink Absolute path where the symlink should exist + * @param string $sourcePath Absolute path to the packaged skill directory + * @param LoggerInterface $logger Logger for feedback on actions taken + * @param SynchronizeResult $result Result tracker for reporting outcomes + */ + private function processSkillLink( + string $skillName, + string $targetLink, + string $sourcePath, + LoggerInterface $logger, + SynchronizeResult $result, + ): void { + if (! $this->filesystem->exists($targetLink)) { + $this->createNewLink($skillName, $targetLink, $sourcePath, $logger, $result); + + return; + } + + if (! $this->isSymlink($targetLink)) { + $this->preserveExistingNonSymlink($skillName, $logger, $result); + + return; + } + + $this->processExistingSymlink($skillName, $targetLink, $sourcePath, $logger, $result); + } + + /** + * Creates a new symlink pointing to the packaged skill. + * + * This method is called when no existing item exists at the target path. + * Creates the symlink, logs the creation, and records it in the result. + * + * @param string $skillName Name identifying the skill + * @param string $targetLink Absolute path where the symlink will be created + * @param string $sourcePath Absolute path to the packaged skill directory + * @param LoggerInterface $logger Logger for confirmation message + * @param SynchronizeResult $result Result object for tracking creation + */ + private function createNewLink( + string $skillName, + string $targetLink, + string $sourcePath, + LoggerInterface $logger, + SynchronizeResult $result, + ): void { + $this->filesystem->symlink($sourcePath, $targetLink); + $logger->info('Created link: ' . $skillName . ' -> ' . $sourcePath); + $result->addCreatedLink($skillName); + } + + /** + * Handles an existing non-symlink item at the target path. + * + * When the target exists but is a real directory (not a symlink), this method + * preserves it unchanged and logs the decision. Real directories are not + * replaced to avoid accidental data loss. + * + * @param string $skillName Name of the skill with the conflicting item + * @param LoggerInterface $logger Logger for the preservation notice + * @param SynchronizeResult $result Result tracker for preserved items + */ + private function preserveExistingNonSymlink( + string $skillName, + LoggerInterface $logger, + SynchronizeResult $result, + ): void { + $logger->notice('Existing non-symlink found: ' . $skillName . ' (keeping as is, skipping link creation)'); + $result->addPreservedLink($skillName); + } + + /** + * Evaluates an existing symlink and determines whether to preserve or repair it. + * + * Reads the symlink target and checks if it points to a valid, existing path. + * Delegates to repair if broken, otherwise preserves the valid link in place. + * + * @param string $skillName Name of the skill with the existing symlink + * @param string $targetLink Absolute path to the existing symlink + * @param string $sourcePath Absolute path to the expected source directory + * @param LoggerInterface $logger Logger for preservation or repair messages + * @param SynchronizeResult $result Result tracker for preserved or removed links + */ + private function processExistingSymlink( + string $skillName, + string $targetLink, + string $sourcePath, + LoggerInterface $logger, + SynchronizeResult $result, + ): void { + $linkPath = $this->filesystem->readlink($targetLink, true); + + if (! $linkPath || ! $this->filesystem->exists($linkPath)) { + $this->repairBrokenLink($skillName, $targetLink, $sourcePath, $logger, $result); + + return; + } + + $logger->notice('Preserved existing link: ' . $skillName); + $result->addPreservedLink($skillName); + } + + /** + * Removes a broken symlink and creates a fresh one pointing to the current source. + * + * Called when the existing symlink target either does not exist or points to + * an invalid path. Removes the broken link, logs the repair, records the removal, + * then delegates to createNewLink for the fresh symlink. + * + * @param string $skillName Name of the skill with the broken symlink + * @param string $targetLink Absolute path to the broken symlink + * @param string $sourcePath Absolute path to the current packaged skill + * @param LoggerInterface $logger Logger for repair and creation messages + * @param SynchronizeResult $result Result tracker for removed and created items + */ + private function repairBrokenLink( + string $skillName, + string $targetLink, + string $sourcePath, + LoggerInterface $logger, + SynchronizeResult $result, + ): void { + $this->filesystem->remove($targetLink); + $logger->notice('Existing link is broken: ' . $skillName . ' (removing and recreating)'); + $result->addRemovedBrokenLink($skillName); + + $this->createNewLink($skillName, $targetLink, $sourcePath, $logger, $result); + } + + /** + * Checks if a path is a symbolic link. + * + * @param string $path + */ + private function isSymlink(string $path): bool + { + return is_link($path); + } +} diff --git a/src/Agent/Skills/SynchronizeResult.php b/src/Agent/Skills/SynchronizeResult.php new file mode 100644 index 0000000..b70df60 --- /dev/null +++ b/src/Agent/Skills/SynchronizeResult.php @@ -0,0 +1,130 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Agent\Skills; + +/** + * Result object for skill synchronization operations. + * + * Tracks the outcomes of a synchronization run, including newly created links, + * existing items that were preserved, and broken links that were removed. + * The failed flag indicates whether an error occurred during synchronization. + */ +final class SynchronizeResult +{ + /** + * List of skill names for which new symlinks were created. + * + * @var list + */ + private array $createdLinks = []; + + /** + * List of skill names for which existing items were left unchanged. + * + * @var list + */ + private array $preservedLinks = []; + + /** + * List of skill names whose broken symlinks were removed during sync. + * + * @var list + */ + private array $removedBrokenLinks = []; + + private bool $failed = false; + + /** + * Records a skill for which a new symlink was created. + * + * @param string $link Name of the skill that received a new symlink + */ + public function addCreatedLink(string $link): void + { + $this->createdLinks[] = $link; + } + + /** + * Records a skill whose existing item was preserved unchanged. + * + * @param string $link Name of the skill that was left in place + */ + public function addPreservedLink(string $link): void + { + $this->preservedLinks[] = $link; + } + + /** + * Records a skill whose broken symlink was removed during sync. + * + * @param string $link Name of the skill whose broken link was removed + */ + public function addRemovedBrokenLink(string $link): void + { + $this->removedBrokenLinks[] = $link; + } + + /** + * Marks the synchronization as failed due to an error condition. + */ + public function markFailed(): void + { + $this->failed = true; + } + + /** + * Returns the list of skills for which new symlinks were created. + * + * @return list Skill names of newly created links + */ + public function getCreatedLinks(): array + { + return $this->createdLinks; + } + + /** + * Returns the list of skills whose existing items were preserved. + * + * @return list Skill names of preserved items + */ + public function getPreservedLinks(): array + { + return $this->preservedLinks; + } + + /** + * Returns the list of skills whose broken symlinks were removed. + * + * @return list Skill names of removed broken links + */ + public function getRemovedBrokenLinks(): array + { + return $this->removedBrokenLinks; + } + + /** + * Indicates whether the synchronization encountered a failure. + * + * @return bool True if an error occurred, false otherwise + */ + public function failed(): bool + { + return $this->failed; + } +} diff --git a/src/Command/Skills/SkillsSynchronizer.php b/src/Command/Skills/SkillsSynchronizer.php deleted file mode 100644 index 37d4af9..0000000 --- a/src/Command/Skills/SkillsSynchronizer.php +++ /dev/null @@ -1,177 +0,0 @@ - - * @license https://opensource.org/licenses/MIT MIT License - * - * @see https://github.com/php-fast-forward/dev-tools - * @see https://github.com/php-fast-forward - * @see https://datatracker.ietf.org/doc/html/rfc2119 - */ - -namespace FastForward\DevTools\Command\Skills; - -use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\Finder\Finder; -use Symfony\Component\Filesystem\Path; - -/** - * Synchronizes Fast Forward skills into consumer repositories. - */ -final class SkillsSynchronizer -{ - private readonly Filesystem $filesystem; - - public function __construct(?Filesystem $filesystem = null) - { - $this->filesystem = $filesystem ?? new Filesystem(); - } - - /** - * Synchronizes skills from the package to the consumer repository. - * - * @param string $rootPath The consumer repository root path - * @param string $skillsDir The target .agents/skills directory - * @param string $packageSkillsPath The source skills directory in the package - * @param callable(string): void $logger Callback for logging messages - * - * @return SynchronizeResult The result of the synchronization - */ - public function synchronize( - string $rootPath, - string $skillsDir, - string $packageSkillsPath, - callable $logger, - ): SynchronizeResult { - $result = new SynchronizeResult(); - - if (! $this->filesystem->exists($packageSkillsPath)) { - $logger('No packaged skills found at: ' . $packageSkillsPath . ''); - $result->markFailed(); - - return $result; - } - - if (! $this->filesystem->exists($skillsDir)) { - $this->filesystem->mkdir($skillsDir); - $logger('Created .agents/skills directory.'); - } - - $this->syncPackageSkills($rootPath, $skillsDir, $packageSkillsPath, $logger, $result); - $this->cleanupBrokenLinks($skillsDir, $logger, $result); - - return $result; - } - - /** - * Syncs skills from the package to the consumer repository. - * - * @param string $rootPath - * @param string $skillsDir - * @param string $packageSkillsPath - * @param callable $logger - * @param SynchronizeResult $result - */ - private function syncPackageSkills( - string $rootPath, - string $skillsDir, - string $packageSkillsPath, - callable $logger, - SynchronizeResult $result, - ): void { - $finder = Finder::create() - ->directories() - ->in($packageSkillsPath) - ->depth('== 0'); - - foreach ($finder as $skillDir) { - $skillName = $skillDir->getFilename(); - $targetLink = Path::makeAbsolute($skillName, $skillsDir); - $sourcePath = $skillDir->getRealPath(); - - if ($this->filesystem->exists($targetLink)) { - // Check if existing target is a valid symlink pointing to source - if ($this->isSymlink($targetLink)) { - $existingTarget = readlink($targetLink); - - if ($existingTarget === $sourcePath) { - $logger('Preserved existing link: ' . $skillName . ''); - $result->addPreservedLink($skillName); - - continue; - } - - // Broken or wrong symlink - remove and recreate - $this->filesystem->remove($targetLink); - } else { - // Non-symlink exists - check if it's the same content - // For development mode in dev-tools repo, we might have actual directories - // In that case, offer to convert to symlink - $logger('Found existing directory: ' . $skillName . ' (converting to symlink)'); - $this->filesystem->remove($targetLink); - } - } - - $this->filesystem->symlink($sourcePath, $targetLink); - $logger('Created link: ' . $skillName . ' -> ' . $sourcePath . ''); - $result->addCreatedLink($skillName); - } - } - - /** - * Cleans up broken symlinks in the skills directory. - * - * @param string $skillsDir - * @param callable $logger - * @param SynchronizeResult $result - */ - private function cleanupBrokenLinks(string $skillsDir, callable $logger, SynchronizeResult $result): void - { - if (! $this->filesystem->exists($skillsDir)) { - return; - } - - $items = scandir($skillsDir); - - foreach ($items as $item) { - if ('.' === $item || '..' === $item) { - continue; - } - - $itemPath = Path::makeAbsolute($item, $skillsDir); - - if (! is_link($itemPath)) { - continue; - } - - $target = readlink($itemPath); - - if (false === $target) { - continue; - } - - if (! file_exists($target)) { - $this->filesystem->remove($itemPath); - $logger('Removed broken link: ' . $item . ''); - $result->addRemovedBrokenLink($item); - } - } - } - - /** - * Checks if a path is a symbolic link. - * - * @param string $path - */ - private function isSymlink(string $path): bool - { - return is_link($path); - } -} diff --git a/src/Command/Skills/SynchronizeResult.php b/src/Command/Skills/SynchronizeResult.php deleted file mode 100644 index 71f5d71..0000000 --- a/src/Command/Skills/SynchronizeResult.php +++ /dev/null @@ -1,91 +0,0 @@ - - * @license https://opensource.org/licenses/MIT MIT License - * - * @see https://github.com/php-fast-forward/dev-tools - * @see https://github.com/php-fast-forward - * @see https://datatracker.ietf.org/doc/html/rfc2119 - */ - -namespace FastForward\DevTools\Command\Skills; - -/** - * Result of skill synchronization operation. - */ -final class SynchronizeResult -{ - /** - * @var list - */ - private array $createdLinks = []; - - /** - * @var list - */ - private array $preservedLinks = []; - - /** - * @var list - */ - private array $removedBrokenLinks = []; - - private bool $failed = false; - - public function addCreatedLink(string $link): void - { - $this->createdLinks[] = $link; - } - - public function addPreservedLink(string $link): void - { - $this->preservedLinks[] = $link; - } - - public function addRemovedBrokenLink(string $link): void - { - $this->removedBrokenLinks[] = $link; - } - - public function markFailed(): void - { - $this->failed = true; - } - - /** - * @return list - */ - public function getCreatedLinks(): array - { - return $this->createdLinks; - } - - /** - * @return list - */ - public function getPreservedLinks(): array - { - return $this->preservedLinks; - } - - /** - * @return list - */ - public function getRemovedBrokenLinks(): array - { - return $this->removedBrokenLinks; - } - - public function failed(): bool - { - return $this->failed; - } -} diff --git a/src/Command/SkillsCommand.php b/src/Command/SkillsCommand.php index 8d5e8ec..1955787 100644 --- a/src/Command/SkillsCommand.php +++ b/src/Command/SkillsCommand.php @@ -18,11 +18,10 @@ namespace FastForward\DevTools\Command; -use FastForward\DevTools\Command\Skills\SkillsSynchronizer; -use FastForward\DevTools\Command\Skills\SynchronizeResult; +use FastForward\DevTools\Agent\Skills\SkillsSynchronizer; +use FastForward\DevTools\Agent\Skills\SynchronizeResult; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Filesystem\Path; /** * Synchronizes Fast Forward skills into the consumer repository by managing `.agents/skills` links. @@ -31,6 +30,9 @@ final class SkillsCommand extends AbstractCommand { private readonly SkillsSynchronizer $synchronizer; + /** + * @param SkillsSynchronizer|null $synchronizer + */ public function __construct(?SkillsSynchronizer $synchronizer = null) { $this->synchronizer = $synchronizer ?? new SkillsSynchronizer(); @@ -38,10 +40,13 @@ public function __construct(?SkillsSynchronizer $synchronizer = null) parent::__construct(); } + /** + * @return void + */ protected function configure(): void { $this - ->setName('dev-tools:skills') + ->setName('skills') ->setDescription('Synchronizes Fast Forward skills into .agents/skills directory.') ->setHelp( 'This command ensures the consumer repository contains linked Fast Forward skills ' @@ -49,31 +54,18 @@ protected function configure(): void ); } + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + */ protected function execute(InputInterface $input, OutputInterface $output): int { $output->writeln('Starting skills synchronization...'); - $rootPath = $this->getCurrentWorkingDirectory(); - $skillsDir = Path::makeAbsolute('.agents/skills', $rootPath); - - // Use __DIR__ to get the package path - $packagePath = Path::makeAbsolute('..', __DIR__); - while (! file_exists($packagePath . '/composer.json')) { - $parent = \dirname($packagePath); - if ($parent === $packagePath) { - break; - } - $packagePath = $parent; - } - $packageSkillsPath = Path::makeAbsolute('.agents/skills', $packagePath); - - // If package path equals root path, we're in the dev-tools repo itself - // and skills are already present as regular files (tracked in git) - if ($packagePath === $rootPath && $this->filesystem->exists($skillsDir)) { - $output->writeln('Skills already available in development repository (tracked in git).'); - - return self::SUCCESS; - } + $packageSkillsPath = $this->getDevToolsFile('.agents/skills'); + $skillsDir = $this->getConfigFile('.agents/skills', true); // Normal consumer repository flow if (! $this->filesystem->exists($packageSkillsPath)) { @@ -88,14 +80,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } /** @var SynchronizeResult $result */ - $result = $this->synchronizer->synchronize( - $rootPath, - $skillsDir, - $packageSkillsPath, - static function (string $message) use ($output): void { - $output->writeln($message); - }, - ); + $result = $this->synchronizer->synchronize($skillsDir, $packageSkillsPath, $this->getIO()); if ($result->failed()) { $output->writeln('Skills synchronization failed.'); diff --git a/src/Command/SyncCommand.php b/src/Command/SyncCommand.php index f5d0d4b..5911015 100644 --- a/src/Command/SyncCommand.php +++ b/src/Command/SyncCommand.php @@ -75,7 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->copyDependabotConfig(); $this->addRepositoryWikiGitSubmodule(); $this->runCommand('gitignore', $output); - $this->runCommand('dev-tools:skills', $output); + $this->runCommand('skills', $output); return self::SUCCESS; } From 5e24965ed129245627fb91c97712a61eda492ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 9 Apr 2026 21:18:12 -0300 Subject: [PATCH 4/6] feat: refactor SkillsSynchronizer and SkillsCommand for improved logging and synchronization handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- src/Agent/Skills/SkillsSynchronizer.php | 77 +++++++++++-------------- src/Agent/Skills/SynchronizeResult.php | 3 + src/Command/SkillsCommand.php | 72 ++++++++++++++++++----- 3 files changed, 95 insertions(+), 57 deletions(-) diff --git a/src/Agent/Skills/SkillsSynchronizer.php b/src/Agent/Skills/SkillsSynchronizer.php index 65b9736..9386ce8 100644 --- a/src/Agent/Skills/SkillsSynchronizer.php +++ b/src/Agent/Skills/SkillsSynchronizer.php @@ -18,7 +18,9 @@ namespace FastForward\DevTools\Agent\Skills; -use Psr\Log\LoggerInterface; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\NullLogger; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; use Symfony\Component\Filesystem\Path; @@ -30,16 +32,23 @@ * repository to the skills packaged within the fast-forward/dev-tools dependency. * It handles initial sync, idempotent re-runs, and cleanup of broken links. */ -final class SkillsSynchronizer +final class SkillsSynchronizer implements LoggerAwareInterface { - private readonly Filesystem $filesystem; + use LoggerAwareTrait; /** + * Initializes the synchronizer with an optional filesystem instance. + * + * If no filesystem is provided, a default {@see Filesystem} instance is created. + * * @param Filesystem|null $filesystem Filesystem instance for file operations + * @param Finder $finder Finder instance for locating skill directories in the package */ - public function __construct(?Filesystem $filesystem = null) - { - $this->filesystem = $filesystem ?? new Filesystem(); + public function __construct( + private readonly Filesystem $filesystem = new Filesystem(), + private readonly Finder $finder = new Finder(), + ) { + $this->logger = new NullLogger(); } /** @@ -51,19 +60,15 @@ public function __construct(?Filesystem $filesystem = null) * * @param string $skillsDir Absolute path to the consumer's .agents/skills directory * @param string $packageSkillsPath Absolute path to the packaged skills in the dependency - * @param LoggerInterface $logger Logger for reporting sync operations * * @return SynchronizeResult Result containing counts of created, preserved, and removed links */ - public function synchronize( - string $skillsDir, - string $packageSkillsPath, - LoggerInterface $logger, - ): SynchronizeResult { + public function synchronize(string $skillsDir, string $packageSkillsPath): SynchronizeResult + { $result = new SynchronizeResult(); if (! $this->filesystem->exists($packageSkillsPath)) { - $logger->error('No packaged skills found at: ' . $packageSkillsPath); + $this->logger->error('No packaged skills found at: ' . $packageSkillsPath); $result->markFailed(); return $result; @@ -71,10 +76,10 @@ public function synchronize( if (! $this->filesystem->exists($skillsDir)) { $this->filesystem->mkdir($skillsDir); - $logger->info('Created .agents/skills directory.'); + $this->logger->info('Created .agents/skills directory.'); } - $this->syncPackageSkills($skillsDir, $packageSkillsPath, $logger, $result); + $this->syncPackageSkills($skillsDir, $packageSkillsPath, $result); return $result; } @@ -87,16 +92,14 @@ public function synchronize( * * @param string $skillsDir Target directory for symlinks * @param string $packageSkillsPath Source directory containing packaged skills - * @param LoggerInterface $logger Logger for operation feedback * @param SynchronizeResult $result Result object to track outcomes */ private function syncPackageSkills( string $skillsDir, string $packageSkillsPath, - LoggerInterface $logger, SynchronizeResult $result, ): void { - $finder = Finder::create() + $finder = $this->finder ->directories() ->in($packageSkillsPath) ->depth('== 0'); @@ -106,7 +109,7 @@ private function syncPackageSkills( $targetLink = Path::makeAbsolute($skillName, $skillsDir); $sourcePath = $skillDir->getRealPath(); - $this->processSkillLink($skillName, $targetLink, $sourcePath, $logger, $result); + $this->processSkillLink($skillName, $targetLink, $sourcePath, $result); } } @@ -119,29 +122,27 @@ private function syncPackageSkills( * @param string $skillName Name of the skill being processed * @param string $targetLink Absolute path where the symlink should exist * @param string $sourcePath Absolute path to the packaged skill directory - * @param LoggerInterface $logger Logger for feedback on actions taken * @param SynchronizeResult $result Result tracker for reporting outcomes */ private function processSkillLink( string $skillName, string $targetLink, string $sourcePath, - LoggerInterface $logger, SynchronizeResult $result, ): void { if (! $this->filesystem->exists($targetLink)) { - $this->createNewLink($skillName, $targetLink, $sourcePath, $logger, $result); + $this->createNewLink($skillName, $targetLink, $sourcePath, $result); return; } if (! $this->isSymlink($targetLink)) { - $this->preserveExistingNonSymlink($skillName, $logger, $result); + $this->preserveExistingNonSymlink($skillName, $result); return; } - $this->processExistingSymlink($skillName, $targetLink, $sourcePath, $logger, $result); + $this->processExistingSymlink($skillName, $targetLink, $sourcePath, $result); } /** @@ -153,18 +154,16 @@ private function processSkillLink( * @param string $skillName Name identifying the skill * @param string $targetLink Absolute path where the symlink will be created * @param string $sourcePath Absolute path to the packaged skill directory - * @param LoggerInterface $logger Logger for confirmation message * @param SynchronizeResult $result Result object for tracking creation */ private function createNewLink( string $skillName, string $targetLink, string $sourcePath, - LoggerInterface $logger, SynchronizeResult $result, ): void { $this->filesystem->symlink($sourcePath, $targetLink); - $logger->info('Created link: ' . $skillName . ' -> ' . $sourcePath); + $this->logger->info('Created link: ' . $skillName . ' -> ' . $sourcePath); $result->addCreatedLink($skillName); } @@ -176,15 +175,11 @@ private function createNewLink( * replaced to avoid accidental data loss. * * @param string $skillName Name of the skill with the conflicting item - * @param LoggerInterface $logger Logger for the preservation notice * @param SynchronizeResult $result Result tracker for preserved items */ - private function preserveExistingNonSymlink( - string $skillName, - LoggerInterface $logger, - SynchronizeResult $result, - ): void { - $logger->notice('Existing non-symlink found: ' . $skillName . ' (keeping as is, skipping link creation)'); + private function preserveExistingNonSymlink(string $skillName, SynchronizeResult $result): void + { + $this->logger->notice('Existing non-symlink found: ' . $skillName . ' (keeping as is, skipping link creation)'); $result->addPreservedLink($skillName); } @@ -197,25 +192,23 @@ private function preserveExistingNonSymlink( * @param string $skillName Name of the skill with the existing symlink * @param string $targetLink Absolute path to the existing symlink * @param string $sourcePath Absolute path to the expected source directory - * @param LoggerInterface $logger Logger for preservation or repair messages * @param SynchronizeResult $result Result tracker for preserved or removed links */ private function processExistingSymlink( string $skillName, string $targetLink, string $sourcePath, - LoggerInterface $logger, SynchronizeResult $result, ): void { $linkPath = $this->filesystem->readlink($targetLink, true); if (! $linkPath || ! $this->filesystem->exists($linkPath)) { - $this->repairBrokenLink($skillName, $targetLink, $sourcePath, $logger, $result); + $this->repairBrokenLink($skillName, $targetLink, $sourcePath, $result); return; } - $logger->notice('Preserved existing link: ' . $skillName); + $this->logger->notice('Preserved existing link: ' . $skillName); $result->addPreservedLink($skillName); } @@ -229,21 +222,19 @@ private function processExistingSymlink( * @param string $skillName Name of the skill with the broken symlink * @param string $targetLink Absolute path to the broken symlink * @param string $sourcePath Absolute path to the current packaged skill - * @param LoggerInterface $logger Logger for repair and creation messages * @param SynchronizeResult $result Result tracker for removed and created items */ private function repairBrokenLink( string $skillName, string $targetLink, string $sourcePath, - LoggerInterface $logger, SynchronizeResult $result, ): void { $this->filesystem->remove($targetLink); - $logger->notice('Existing link is broken: ' . $skillName . ' (removing and recreating)'); + $this->logger->notice('Existing link is broken: ' . $skillName . ' (removing and recreating)'); $result->addRemovedBrokenLink($skillName); - $this->createNewLink($skillName, $targetLink, $sourcePath, $logger, $result); + $this->createNewLink($skillName, $targetLink, $sourcePath, $result); } /** @@ -253,6 +244,6 @@ private function repairBrokenLink( */ private function isSymlink(string $path): bool { - return is_link($path); + return null !== $this->filesystem->readlink($path); } } diff --git a/src/Agent/Skills/SynchronizeResult.php b/src/Agent/Skills/SynchronizeResult.php index b70df60..05a186a 100644 --- a/src/Agent/Skills/SynchronizeResult.php +++ b/src/Agent/Skills/SynchronizeResult.php @@ -48,6 +48,9 @@ final class SynchronizeResult */ private array $removedBrokenLinks = []; + /** + * Indicates whether the synchronization encountered a failure. + */ private bool $failed = false; /** diff --git a/src/Command/SkillsCommand.php b/src/Command/SkillsCommand.php index 1955787..3f05dcd 100644 --- a/src/Command/SkillsCommand.php +++ b/src/Command/SkillsCommand.php @@ -22,25 +22,53 @@ use FastForward\DevTools\Agent\Skills\SynchronizeResult; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Filesystem\Filesystem; /** - * Synchronizes Fast Forward skills into the consumer repository by managing `.agents/skills` links. + * Synchronizes packaged Fast Forward skills into the consumer repository. + * + * This command SHALL ensure that the consumer repository contains the expected + * `.agents/skills` directory structure backed by the packaged skill set. The + * command MUST verify that the packaged skills directory exists before any + * synchronization is attempted. If the target skills directory does not exist, + * it SHALL be created before the synchronization process begins. + * + * The synchronization workflow is delegated to {@see SkillsSynchronizer}. This + * command MUST act as an orchestration layer only: it prepares the source and + * target paths, triggers synchronization, and translates the resulting status + * into Symfony Console output and process exit codes. */ final class SkillsCommand extends AbstractCommand { - private readonly SkillsSynchronizer $synchronizer; - /** - * @param SkillsSynchronizer|null $synchronizer + * Initializes the command with an optional skills synchronizer instance. + * + * If no synchronizer is provided, the command SHALL instantiate the default + * {@see SkillsSynchronizer} implementation. Consumers MAY inject a custom + * synchronizer for testing or alternative synchronization behavior, provided + * it preserves the expected contract. + * + * @param SkillsSynchronizer|null $synchronizer the synchronizer responsible + * for applying the skills + * synchronization process + * @param Filesystem|null $filesystem filesystem used to resolve + * and manage the skills + * directory structure */ - public function __construct(?SkillsSynchronizer $synchronizer = null) - { - $this->synchronizer = $synchronizer ?? new SkillsSynchronizer(); - - parent::__construct(); + public function __construct( + private readonly SkillsSynchronizer $synchronizer = new SkillsSynchronizer(), + ?Filesystem $filesystem = null + ) { + parent::__construct($filesystem); } /** + * Configures the command name, description, and help text. + * + * The command metadata MUST clearly describe that the operation synchronizes + * Fast Forward skills into the `.agents/skills` directory and that it manages + * link-based synchronization for packaged skills. + * * @return void */ protected function configure(): void @@ -55,10 +83,25 @@ protected function configure(): void } /** - * @param InputInterface $input - * @param OutputInterface $output + * Executes the skills synchronization workflow. + * + * This method SHALL: + * - announce the start of synchronization; + * - resolve the packaged skills path and consumer target directory; + * - fail when the packaged skills directory does not exist; + * - create the target directory when it is missing; + * - delegate synchronization to {@see SkillsSynchronizer}; + * - return a success or failure exit code based on the synchronization result. * - * @return int + * The command MUST return {@see self::FAILURE} when packaged skills are not + * available or when the synchronizer reports a failure. It MUST return + * {@see self::SUCCESS} only when synchronization completes successfully. + * + * @param InputInterface $input the console input instance provided by Symfony + * @param OutputInterface $output the console output instance used to report progress + * + * @return int The process exit status. This MUST be {@see self::SUCCESS} on + * success and {@see self::FAILURE} on failure. */ protected function execute(InputInterface $input, OutputInterface $output): int { @@ -67,7 +110,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $packageSkillsPath = $this->getDevToolsFile('.agents/skills'); $skillsDir = $this->getConfigFile('.agents/skills', true); - // Normal consumer repository flow if (! $this->filesystem->exists($packageSkillsPath)) { $output->writeln('No packaged skills found at: ' . $packageSkillsPath . ''); @@ -79,8 +121,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln('Created .agents/skills directory.'); } + $this->synchronizer->setLogger($this->getIO()); + /** @var SynchronizeResult $result */ - $result = $this->synchronizer->synchronize($skillsDir, $packageSkillsPath, $this->getIO()); + $result = $this->synchronizer->synchronize($skillsDir, $packageSkillsPath); if ($result->failed()) { $output->writeln('Skills synchronization failed.'); From 143931bd89d05f2094e2c61fef0a65426d30ee7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 9 Apr 2026 21:19:54 -0300 Subject: [PATCH 5/6] feat: Add tests for SkillsSynchronizer and SkillsCommand, including synchronization and link manipulation scenarios. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- tests/Agent/Skills/SkillsSynchronizerTest.php | 270 ++++++++++++++++++ tests/Agent/Skills/SynchronizeResultTest.php | 113 ++++++++ tests/Command/SkillsCommandTest.php | 172 +++++++++++ .../DevToolsCommandProviderTest.php | 2 +- 4 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 tests/Agent/Skills/SkillsSynchronizerTest.php create mode 100644 tests/Agent/Skills/SynchronizeResultTest.php create mode 100644 tests/Command/SkillsCommandTest.php diff --git a/tests/Agent/Skills/SkillsSynchronizerTest.php b/tests/Agent/Skills/SkillsSynchronizerTest.php new file mode 100644 index 0000000..3f13793 --- /dev/null +++ b/tests/Agent/Skills/SkillsSynchronizerTest.php @@ -0,0 +1,270 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Agent\Skills; + +use ArrayIterator; +use FastForward\DevTools\Agent\Skills\SkillsSynchronizer; +use FastForward\DevTools\Agent\Skills\SynchronizeResult; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; +use Psr\Log\LoggerInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Finder\SplFileInfo; + +#[CoversClass(SkillsSynchronizer::class)] +#[UsesClass(SynchronizeResult::class)] +final class SkillsSynchronizerTest extends TestCase +{ + use ProphecyTrait; + + private const string PACKAGE_SKILLS_PATH = '/package/.agents/skills'; + + private const string CONSUMER_SKILLS_PATH = '/consumer/.agents/skills'; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $filesystem; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $finder; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $logger; + + /** + * @return void + */ + protected function setUp(): void + { + $this->filesystem = $this->prophesize(Filesystem::class); + $this->finder = $this->prophesize(Finder::class); + $this->logger = $this->prophesize(LoggerInterface::class); + } + + /** + * @return void + */ + #[Test] + public function synchronizeWithMissingPackagePathWillReturnFailedResult(): void + { + $this->filesystem->exists(self::PACKAGE_SKILLS_PATH)->willReturn(false); + $this->logger->error('No packaged skills found at: ' . self::PACKAGE_SKILLS_PATH)->shouldBeCalledOnce(); + + $result = $this->createSynchronizer() + ->synchronize(self::CONSUMER_SKILLS_PATH, self::PACKAGE_SKILLS_PATH); + + self::assertTrue($result->failed()); + self::assertSame([], $result->getCreatedLinks()); + } + + /** + * @return void + */ + #[Test] + public function synchronizeWithMissingSkillsDirWillCreateItAndCreateLinks(): void + { + $skillOnePath = self::PACKAGE_SKILLS_PATH . '/skill-one'; + $skillTwoPath = self::PACKAGE_SKILLS_PATH . '/skill-two'; + + $this->mockFinder( + $this->createSkillDirectory('skill-one', $skillOnePath), + $this->createSkillDirectory('skill-two', $skillTwoPath), + ); + + $this->filesystem->exists(self::PACKAGE_SKILLS_PATH)->willReturn(true); + $this->filesystem->exists(self::CONSUMER_SKILLS_PATH)->willReturn(false); + $this->filesystem->mkdir(self::CONSUMER_SKILLS_PATH)->shouldBeCalledOnce(); + $this->logger->info('Created .agents/skills directory.') + ->shouldBeCalledOnce(); + + $this->filesystem->exists(self::CONSUMER_SKILLS_PATH . '/skill-one')->willReturn(false); + $this->filesystem->exists(self::CONSUMER_SKILLS_PATH . '/skill-two')->willReturn(false); + $this->filesystem->symlink($skillOnePath, self::CONSUMER_SKILLS_PATH . '/skill-one')->shouldBeCalledOnce(); + $this->filesystem->symlink($skillTwoPath, self::CONSUMER_SKILLS_PATH . '/skill-two')->shouldBeCalledOnce(); + $this->logger->info('Created link: skill-one -> ' . $skillOnePath)->shouldBeCalledOnce(); + $this->logger->info('Created link: skill-two -> ' . $skillTwoPath)->shouldBeCalledOnce(); + + $result = $this->createSynchronizer() + ->synchronize(self::CONSUMER_SKILLS_PATH, self::PACKAGE_SKILLS_PATH); + + self::assertFalse($result->failed()); + self::assertSame(['skill-one', 'skill-two'], $result->getCreatedLinks()); + self::assertSame([], $result->getPreservedLinks()); + self::assertSame([], $result->getRemovedBrokenLinks()); + } + + /** + * @return void + */ + #[Test] + public function synchronizeWillPreserveExistingValidSymlink(): void + { + $skillOnePath = self::PACKAGE_SKILLS_PATH . '/skill-one'; + $targetLink = self::CONSUMER_SKILLS_PATH . '/skill-one'; + + $this->mockFinder($this->createSkillDirectory('skill-one', $skillOnePath)); + + $this->filesystem->exists(self::PACKAGE_SKILLS_PATH)->willReturn(true); + $this->filesystem->exists(self::CONSUMER_SKILLS_PATH)->willReturn(true); + $this->filesystem->exists($targetLink) + ->willReturn(true); + $this->filesystem->readlink($targetLink) + ->willReturn($skillOnePath); + $this->filesystem->readlink($targetLink, true) + ->willReturn($skillOnePath); + $this->filesystem->exists($skillOnePath) + ->willReturn(true); + $this->logger->notice('Preserved existing link: skill-one') + ->shouldBeCalledOnce(); + + $result = $this->createSynchronizer() + ->synchronize(self::CONSUMER_SKILLS_PATH, self::PACKAGE_SKILLS_PATH); + + self::assertFalse($result->failed()); + self::assertSame([], $result->getCreatedLinks()); + self::assertSame(['skill-one'], $result->getPreservedLinks()); + } + + /** + * @return void + */ + #[Test] + public function synchronizeWillHandleExistingBrokenSymlink(): void + { + $skillOnePath = self::PACKAGE_SKILLS_PATH . '/skill-one'; + $targetLink = self::CONSUMER_SKILLS_PATH . '/skill-one'; + $brokenLinkPath = '/obsolete/.agents/skills/skill-one'; + + $this->mockFinder($this->createSkillDirectory('skill-one', $skillOnePath)); + + $this->filesystem->exists(self::PACKAGE_SKILLS_PATH)->willReturn(true); + $this->filesystem->exists(self::CONSUMER_SKILLS_PATH)->willReturn(true); + $this->filesystem->exists($targetLink) + ->willReturn(true); + $this->filesystem->readlink($targetLink) + ->willReturn($brokenLinkPath); + $this->filesystem->readlink($targetLink, true) + ->willReturn($brokenLinkPath); + $this->filesystem->exists($brokenLinkPath) + ->willReturn(false); + $this->filesystem->remove($targetLink) + ->shouldBeCalledOnce(); + $this->filesystem->symlink($skillOnePath, $targetLink) + ->shouldBeCalledOnce(); + $this->logger->notice('Existing link is broken: skill-one (removing and recreating)') + ->shouldBeCalledOnce(); + $this->logger->info('Created link: skill-one -> ' . $skillOnePath)->shouldBeCalledOnce(); + + $result = $this->createSynchronizer() + ->synchronize(self::CONSUMER_SKILLS_PATH, self::PACKAGE_SKILLS_PATH); + + self::assertFalse($result->failed()); + self::assertSame(['skill-one'], $result->getCreatedLinks()); + self::assertSame([], $result->getPreservedLinks()); + self::assertSame(['skill-one'], $result->getRemovedBrokenLinks()); + } + + /** + * @return void + */ + #[Test] + public function synchronizeWillPreserveNonSymlinkDirectoryForSameSkill(): void + { + $skillOnePath = self::PACKAGE_SKILLS_PATH . '/skill-one'; + $targetLink = self::CONSUMER_SKILLS_PATH . '/skill-one'; + + $this->mockFinder($this->createSkillDirectory('skill-one', $skillOnePath)); + + $this->filesystem->exists(self::PACKAGE_SKILLS_PATH)->willReturn(true); + $this->filesystem->exists(self::CONSUMER_SKILLS_PATH)->willReturn(true); + $this->filesystem->exists($targetLink) + ->willReturn(true); + $this->filesystem->readlink($targetLink) + ->willReturn(null); + $this->logger->notice( + 'Existing non-symlink found: skill-one (keeping as is, skipping link creation)' + )->shouldBeCalledOnce(); + + $result = $this->createSynchronizer() + ->synchronize(self::CONSUMER_SKILLS_PATH, self::PACKAGE_SKILLS_PATH); + + self::assertFalse($result->failed()); + self::assertSame([], $result->getCreatedLinks()); + self::assertSame(['skill-one'], $result->getPreservedLinks()); + self::assertSame([], $result->getRemovedBrokenLinks()); + } + + /** + * @param SplFileInfo ...$skills + * + * @return void + */ + private function mockFinder(SplFileInfo ...$skills): void + { + $finder = $this->finder->reveal(); + + $this->finder->directories() + ->willReturn($finder) + ->shouldBeCalledOnce(); + $this->finder->in(self::PACKAGE_SKILLS_PATH)->willReturn($finder)->shouldBeCalledOnce(); + $this->finder->depth('== 0') + ->willReturn($finder) + ->shouldBeCalledOnce(); + $this->finder->getIterator() + ->willReturn(new ArrayIterator($skills)); + } + + /** + * @param string $skillName + * @param string $sourcePath + * + * @return SplFileInfo + */ + private function createSkillDirectory(string $skillName, string $sourcePath): SplFileInfo + { + $skillDirectory = $this->prophesize(SplFileInfo::class); + $skillDirectory->getFilename() + ->willReturn($skillName); + $skillDirectory->getRealPath() + ->willReturn($sourcePath); + + return $skillDirectory->reveal(); + } + + /** + * @return SkillsSynchronizer + */ + private function createSynchronizer(): SkillsSynchronizer + { + $synchronizer = new SkillsSynchronizer($this->filesystem->reveal(), $this->finder->reveal()); + $synchronizer->setLogger($this->logger->reveal()); + + return $synchronizer; + } +} diff --git a/tests/Agent/Skills/SynchronizeResultTest.php b/tests/Agent/Skills/SynchronizeResultTest.php new file mode 100644 index 0000000..0eb3a4d --- /dev/null +++ b/tests/Agent/Skills/SynchronizeResultTest.php @@ -0,0 +1,113 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Agent\Skills; + +use FastForward\DevTools\Agent\Skills\SynchronizeResult; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(SynchronizeResult::class)] +final class SynchronizeResultTest extends TestCase +{ + private SynchronizeResult $result; + + /** + * @return void + */ + protected function setUp(): void + { + $this->result = new SynchronizeResult(); + } + + /** + * @return void + */ + #[Test] + public function newResultWillHaveEmptyListsAndNotFailed(): void + { + self::assertSame([], $this->result->getCreatedLinks()); + self::assertSame([], $this->result->getPreservedLinks()); + self::assertSame([], $this->result->getRemovedBrokenLinks()); + self::assertFalse($this->result->failed()); + } + + /** + * @return void + */ + #[Test] + public function addCreatedLinkWillAddToCreatedList(): void + { + $this->result->addCreatedLink('skill-one'); + $this->result->addCreatedLink('skill-two'); + + self::assertSame(['skill-one', 'skill-two'], $this->result->getCreatedLinks()); + } + + /** + * @return void + */ + #[Test] + public function addPreservedLinkWillAddToPreservedList(): void + { + $this->result->addPreservedLink('existing-skill'); + + self::assertSame(['existing-skill'], $this->result->getPreservedLinks()); + } + + /** + * @return void + */ + #[Test] + public function addRemovedBrokenLinkWillAddToRemovedList(): void + { + $this->result->addRemovedBrokenLink('broken-skill'); + + self::assertSame(['broken-skill'], $this->result->getRemovedBrokenLinks()); + } + + /** + * @return void + */ + #[Test] + public function markFailedWillSetFailedFlag(): void + { + self::assertFalse($this->result->failed()); + + $this->result->markFailed(); + + self::assertTrue($this->result->failed()); + } + + /** + * @return void + */ + #[Test] + public function failedWillReturnFalseAfterMultipleOperations(): void + { + $this->result->addCreatedLink('new-skill'); + $this->result->addPreservedLink('old-skill'); + $this->result->addRemovedBrokenLink('broken-skill'); + + self::assertFalse($this->result->failed()); + self::assertSame(['new-skill'], $this->result->getCreatedLinks()); + self::assertSame(['old-skill'], $this->result->getPreservedLinks()); + self::assertSame(['broken-skill'], $this->result->getRemovedBrokenLinks()); + } +} diff --git a/tests/Command/SkillsCommandTest.php b/tests/Command/SkillsCommandTest.php new file mode 100644 index 0000000..7bfc29e --- /dev/null +++ b/tests/Command/SkillsCommandTest.php @@ -0,0 +1,172 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Command; + +use Composer\IO\IOInterface; +use FastForward\DevTools\Agent\Skills\SkillsSynchronizer; +use FastForward\DevTools\Agent\Skills\SynchronizeResult; +use FastForward\DevTools\Command\SkillsCommand; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; + +use function Safe\getcwd; + +#[CoversClass(SkillsCommand::class)] +#[UsesClass(SkillsSynchronizer::class)] +#[UsesClass(SynchronizeResult::class)] +final class SkillsCommandTest extends AbstractCommandTestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $synchronizer; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $io; + + /** + * @return void + */ + protected function setUp(): void + { + $this->synchronizer = $this->prophesize(SkillsSynchronizer::class); + $this->io = $this->prophesize(IOInterface::class); + + parent::setUp(); + + $this->application->getIO() + ->willReturn($this->io->reveal()); + } + + /** + * @return SkillsCommand + */ + protected function getCommandClass(): SkillsCommand + { + return new SkillsCommand($this->synchronizer->reveal(), $this->filesystem->reveal()); + } + + /** + * @return string + */ + protected function getCommandName(): string + { + return 'skills'; + } + + /** + * @return string + */ + protected function getCommandDescription(): string + { + return 'Synchronizes Fast Forward skills into .agents/skills directory.'; + } + + /** + * @return string + */ + protected function getCommandHelp(): string + { + return 'This command ensures the consumer repository contains linked Fast Forward skills ' + . 'by creating symlinks to the packaged skills and removing broken links.'; + } + + /** + * @return void + */ + #[Test] + public function executeWillFailWhenPackagedSkillsDirectoryDoesNotExist(): void + { + $skillsPath = getcwd() . '/.agents/skills'; + + $this->filesystem->exists($skillsPath) + ->willReturn(false); + $this->output->writeln('Starting skills synchronization...') + ->shouldBeCalledOnce(); + $this->output->writeln('No packaged skills found at: ' . $skillsPath . '') + ->shouldBeCalledOnce(); + $this->synchronizer->setLogger(Argument::cetera())->shouldNotBeCalled(); + $this->synchronizer->synchronize(Argument::cetera())->shouldNotBeCalled(); + + self::assertSame(SkillsCommand::FAILURE, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillCreateSkillsDirectoryWhenItDoesNotExist(): void + { + $skillsPath = getcwd() . '/.agents/skills'; + $result = new SynchronizeResult(); + + $this->filesystem->exists($skillsPath) + ->willReturn(true, false); + $this->filesystem->mkdir($skillsPath) + ->shouldBeCalledOnce(); + $this->synchronizer->setLogger($this->io->reveal()) + ->shouldBeCalledOnce(); + $this->synchronizer->synchronize($skillsPath, $skillsPath) + ->willReturn($result) + ->shouldBeCalledOnce(); + + $this->output->writeln('Starting skills synchronization...') + ->shouldBeCalledOnce(); + $this->output->writeln('Created .agents/skills directory.') + ->shouldBeCalledOnce(); + $this->output->writeln('Skills synchronization completed successfully.') + ->shouldBeCalledOnce(); + + self::assertSame(SkillsCommand::SUCCESS, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillReturnFailureWhenSynchronizerFails(): void + { + $skillsPath = getcwd() . '/.agents/skills'; + $result = new SynchronizeResult(); + $result->markFailed(); + + $this->filesystem->exists($skillsPath) + ->willReturn(true, true); + $this->synchronizer->setLogger($this->io->reveal()) + ->shouldBeCalledOnce(); + $this->synchronizer->synchronize($skillsPath, $skillsPath) + ->willReturn($result) + ->shouldBeCalledOnce(); + + $this->output->writeln('Starting skills synchronization...') + ->shouldBeCalledOnce(); + $this->output->writeln('Skills synchronization failed.') + ->shouldBeCalledOnce(); + + self::assertSame(SkillsCommand::FAILURE, $this->invokeExecute()); + } +} diff --git a/tests/Composer/Capability/DevToolsCommandProviderTest.php b/tests/Composer/Capability/DevToolsCommandProviderTest.php index ba885a7..6b35e08 100644 --- a/tests/Composer/Capability/DevToolsCommandProviderTest.php +++ b/tests/Composer/Capability/DevToolsCommandProviderTest.php @@ -24,7 +24,7 @@ use FastForward\DevTools\Command\GitIgnoreCommand; use FastForward\DevTools\Command\SyncCommand; use FastForward\DevTools\Command\SkillsCommand; -use FastForward\DevTools\Command\Skills\SkillsSynchronizer; +use FastForward\DevTools\Agent\Skills\SkillsSynchronizer; use FastForward\DevTools\Command\PhpDocCommand; use FastForward\DevTools\Command\RefactorCommand; use FastForward\DevTools\Command\ReportsCommand; From 408d217e8bada92da1def957123b41440cd9a8e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 9 Apr 2026 21:23:15 -0300 Subject: [PATCH 6/6] feat: Update documentation to include details about the new skills command for synchronizing packaged agent skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- README.md | 55 ++++++++++++++-- docs/advanced/consumer-automation.rst | 12 ++-- docs/api/commands.rst | 6 +- docs/configuration/overriding-defaults.rst | 5 ++ docs/configuration/tooling-defaults.rst | 6 ++ docs/faq.rst | 17 ++++- docs/getting-started/index.rst | 3 +- docs/getting-started/installation.rst | 13 ++++ docs/getting-started/quickstart.rst | 8 ++- docs/index.rst | 2 +- docs/internals/architecture.rst | 8 ++- docs/running/specialized-commands.rst | 22 ++++++- docs/usage/common-workflows.rst | 13 ++-- docs/usage/index.rst | 1 + docs/usage/syncing-consumer-projects.rst | 15 ++++- docs/usage/syncing-packaged-skills.rst | 75 ++++++++++++++++++++++ 16 files changed, 236 insertions(+), 25 deletions(-) create mode 100644 docs/usage/syncing-packaged-skills.rst diff --git a/README.md b/README.md index ff211d8..fca1529 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # FastForward\DevTools +FastForward DevTools is a Composer plugin that standardizes quality checks, +documentation builds, consumer repository bootstrap, and packaged agent skills +across Fast Forward libraries. + [![PHP Version](https://img.shields.io/badge/php-^8.3-777BB4?logo=php&logoColor=white)](https://www.php.net/releases/) [![Composer Package](https://img.shields.io/badge/composer-fast--forward%2Fdev--tools-F28D1A.svg?logo=composer&logoColor=white)](https://packagist.org/packages/fast-forward/dev-tools) [![Tests](https://img.shields.io/github/actions/workflow/status/php-fast-forward/dev-tools/tests.yml?logo=githubactions&logoColor=white&label=tests&color=22C55E)](https://github.com/php-fast-forward/dev-tools/actions/workflows/tests.yml) @@ -10,11 +14,14 @@ ## ✨ Features -- Aggregates multiple development tools into a single command -- Automates execution of tests, static analysis, and code styling -- First-class support for automated refactoring and docblock generation -- Integrates seamlessly as a Composer plugin without boilerplate -- Configures default setups for QA tools out of the box +- Aggregates refactoring, PHPDoc, code style, tests, and reporting under a + single Composer-facing command vocabulary +- Ships shared workflow stubs, `.editorconfig`, Dependabot configuration, and + other onboarding defaults for consumer repositories +- Synchronizes packaged agent skills into consumer `.agents/skills` + directories using safe link-based updates +- Works both as a Composer plugin and as a local binary +- Preserves local overrides through consumer-first configuration resolution ## 🚀 Installation @@ -58,13 +65,48 @@ composer dev-tools wiki # Generate documentation frontpage and related reports composer dev-tools reports +# Synchronize packaged agent skills into .agents/skills +composer dev-tools skills + # Merges and synchronizes .gitignore files composer dev-tools gitignore -# Installs and synchronizes dev-tools scripts, GitHub Actions workflows, .editorconfig, and ensures the repository wiki is present as a git submodule in .github/wiki +# Installs and synchronizes dev-tools scripts, GitHub Actions workflows, +# .editorconfig, .gitignore rules, packaged skills, and the repository wiki +# submodule in .github/wiki composer dev-tools:sync ``` +The `skills` command keeps `.agents/skills` aligned with the packaged Fast +Forward skill set. It creates missing links, repairs broken links, and +preserves existing non-symlink directories. The `dev-tools:sync` command calls +`skills` automatically after refreshing the rest of the consumer-facing +automation assets. + +## 🧰 Command Summary + +| Command | Purpose | +|---------|---------| +| `composer dev-tools` | Runs the full `standards` pipeline. | +| `composer dev-tools tests` | Runs PHPUnit with local-or-packaged configuration. | +| `composer dev-tools docs` | Builds the HTML documentation site from PSR-4 code and `docs/`. | +| `composer dev-tools skills` | Creates or repairs packaged skill links in `.agents/skills`. | +| `composer dev-tools:sync` | Updates scripts, workflow stubs, `.editorconfig`, `.gitignore`, wiki setup, and packaged skills. | + +## 🔌 Integration + +DevTools integrates with consumer repositories in two ways. The Composer plugin +exposes the command set automatically after installation, and the local binary +keeps the same command vocabulary when you prefer running tools directly from +`vendor/bin/dev-tools`. The consumer sync flow also refreshes `.agents/skills` +so agents can discover the packaged skills shipped with this repository. + +## 🤝 Contributing + +Run `composer dev-tools` before opening a pull request. If you change public +commands or consumer onboarding behavior, update `README.md` and `docs/` +together so downstream libraries keep accurate guidance. + ## 📄 License This package is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details. @@ -73,4 +115,5 @@ This package is licensed under the MIT License. See the [LICENSE](LICENSE) file - [Repository](https://github.com/php-fast-forward/dev-tools) - [Packagist](https://packagist.org/packages/fast-forward/dev-tools) +- [Documentation](https://php-fast-forward.github.io/dev-tools/index.html) - [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119) diff --git a/docs/advanced/consumer-automation.rst b/docs/advanced/consumer-automation.rst index 3b44d72..abb7c66 100644 --- a/docs/advanced/consumer-automation.rst +++ b/docs/advanced/consumer-automation.rst @@ -5,8 +5,8 @@ FastForward DevTools plays two roles at once: - producer: this repository ships reusable workflow templates and default configuration files; -- consumer helper: the ``dev-tools:sync`` command copies those assets into - other Fast Forward libraries. +- consumer helper: the ``dev-tools:sync`` command copies those assets and links + packaged skills into other Fast Forward libraries. Reusable Workflows Versus Consumer Stubs ---------------------------------------- @@ -25,6 +25,9 @@ Reusable Workflows Versus Consumer Stubs - This repository's own Dependabot configuration. * - ``resources/dependabot.yml`` - Template copied into consumer repositories. + * - ``.agents/skills/*`` + - Packaged agent skills linked into consumer repositories by the + ``skills`` command. * - ``.github/wiki`` - Generated Markdown API documentation locally and wiki submodule content in consumer repositories. @@ -48,5 +51,6 @@ Producer Impact --------------- Any change to ``resources/github-actions``, ``resources/dependabot.yml``, -``.github/workflows``, or ``FastForward\DevTools\Command\SyncCommand`` changes -the default onboarding story for every consumer library. +``.agents/skills``, ``.github/workflows``, or +``FastForward\DevTools\Command\SyncCommand`` changes the default onboarding +story for every consumer library. diff --git a/docs/api/commands.rst b/docs/api/commands.rst index 121f114..aa2a04e 100644 --- a/docs/api/commands.rst +++ b/docs/api/commands.rst @@ -39,9 +39,13 @@ resolution, configuration fallback, PSR-4 lookup, and child-command dispatch. * - ``FastForward\DevTools\Command\ReportsCommand`` - ``reports`` - Combines the documentation build with coverage generation. + * - ``FastForward\DevTools\Command\SkillsCommand`` + - ``skills`` + - Synchronizes packaged agent skills into ``.agents/skills``. * - ``FastForward\DevTools\Command\SyncCommand`` - ``dev-tools:sync`` - - Synchronizes consumer-facing scripts and automation assets. + - Synchronizes consumer-facing scripts, automation assets, and packaged + skills. * - ``FastForward\DevTools\Command\GitIgnoreCommand`` - ``gitignore`` - Merges and synchronizes .gitignore files. diff --git a/docs/configuration/overriding-defaults.rst b/docs/configuration/overriding-defaults.rst index c11263e..f1f6b18 100644 --- a/docs/configuration/overriding-defaults.rst +++ b/docs/configuration/overriding-defaults.rst @@ -39,6 +39,10 @@ Commands and Their Configuration Files * - ``docs`` - ``docs/`` or another path passed with ``--source`` - The selected guide source must exist locally. + * - ``skills`` + - ``.agents/skills/`` + - Creates missing local links to packaged skills and preserves existing + non-symlink directories. * - ``dev-tools:sync`` - Consumer repository files - Works directly against local project files such as ``composer.json`` and @@ -106,6 +110,7 @@ What Is Not Overwritten Automatically - existing workflow files in ``.github/workflows/``; - an existing ``.editorconfig``; - an existing ``.github/dependabot.yml``; +- an existing non-symlink directory inside ``.agents/skills/``; - an existing ``.github/wiki`` directory or submodule. .. tip:: diff --git a/docs/configuration/tooling-defaults.rst b/docs/configuration/tooling-defaults.rst index 0c6381c..b093a48 100644 --- a/docs/configuration/tooling-defaults.rst +++ b/docs/configuration/tooling-defaults.rst @@ -28,6 +28,10 @@ create them on day one. * - ``.editorconfig`` - ``dev-tools:sync`` - Copied into the consumer root when missing. + * - ``.agents/skills/*`` + - ``skills`` and ``dev-tools:sync`` + - Packaged agent skill directories exposed to consumer repositories + through symlinks. Generated and Cache Directories ------------------------------- @@ -37,6 +41,8 @@ Generated and Cache Directories coverage data. - ``.github/wiki/`` contains generated Markdown API documentation and, in consumer repositories, the wiki submodule. +- ``.agents/skills/`` contains symlinked packaged skills or consumer-owned + directories kept in place by the ``skills`` command. - ``tmp/cache/phpdoc``, ``tmp/cache/phpunit``, ``tmp/cache/rector``, and ``tmp/cache/.php-cs-fixer.cache`` store tool caches. diff --git a/docs/faq.rst b/docs/faq.rst index 986f3e2..daf3c57 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -20,7 +20,15 @@ Do I always need to run ``dev-tools:sync`` manually? Usually no. The plugin already runs it after ``composer install`` and ``composer update``. Manual sync is most useful when plugins were disabled or after upgrading ``fast-forward/dev-tools`` and wanting to refresh consumer -automation. +automation. That flow also runs ``skills`` so packaged skill links are kept up +to date. + +When should I run ``composer dev-tools skills`` manually? +--------------------------------------------------------- + +Run it when you want to refresh ``.agents/skills`` without rerunning the full +consumer sync flow, especially after upgrading ``fast-forward/dev-tools`` or +after deleting a packaged skill link locally. Why does ``code-style`` touch ``composer.lock``? ------------------------------------------------ @@ -69,6 +77,13 @@ What happens if ``.github/wiki`` already exists? ``dev-tools:sync`` leaves it alone. The wiki submodule is only created when the directory is missing. +What happens if ``.agents/skills/my-skill`` already exists? +----------------------------------------------------------- + +If that path is a real directory instead of a symlink, the ``skills`` command +preserves it and skips link creation. Broken symlinks are repaired, but +consumer-owned directories are not overwritten automatically. + How do I override only one tool without forking the whole package? ------------------------------------------------------------------ diff --git a/docs/getting-started/index.rst b/docs/getting-started/index.rst index cfe9485..798bc77 100644 --- a/docs/getting-started/index.rst +++ b/docs/getting-started/index.rst @@ -11,7 +11,8 @@ By the end of this section you will know how to: - run the generated commands through Composer or directly through the binary; - prepare the minimum ``docs/`` structure required by the documentation pipeline; -- understand which files ``dev-tools:sync`` creates or updates. +- understand which files ``dev-tools:sync`` creates or updates, including + packaged skills under ``.agents/skills``. .. toctree:: :maxdepth: 1 diff --git a/docs/getting-started/installation.rst b/docs/getting-started/installation.rst index dac266e..08614d2 100644 --- a/docs/getting-started/installation.rst +++ b/docs/getting-started/installation.rst @@ -37,6 +37,10 @@ following steps: ``.github/dependabot.yml``. 6. If ``.github/wiki`` is missing, ``dev-tools:sync`` adds it as a Git submodule that points to the repository wiki. +7. ``dev-tools:sync`` runs ``gitignore`` to merge canonical ignore rules into + the consumer project. +8. ``dev-tools:sync`` runs ``skills`` to create or repair packaged skill links + inside ``.agents/skills``. First commands to try --------------------- @@ -45,6 +49,7 @@ After installation, these are the most useful sanity checks: .. code-block:: bash + composer dev-tools skills composer dev-tools tests composer dev-tools docs composer dev-tools @@ -55,6 +60,12 @@ If Composer argument forwarding becomes awkward, call the binary directly: vendor/bin/dev-tools tests --filter=SyncCommandTest +If you want to verify the packaged skills on their own, run: + +.. code-block:: bash + + vendor/bin/dev-tools skills + When manual sync is useful -------------------------- @@ -75,3 +86,5 @@ Or call the binary explicitly: The ``docs`` and ``reports`` commands require a ``docs/`` directory. If your package does not have one yet, create it before running those commands. + The ``skills`` command creates ``.agents/skills`` when needed, but it does + not overwrite an existing non-symlink directory inside that tree. diff --git a/docs/getting-started/quickstart.rst b/docs/getting-started/quickstart.rst index 2904ac4..3c52d24 100644 --- a/docs/getting-started/quickstart.rst +++ b/docs/getting-started/quickstart.rst @@ -5,7 +5,7 @@ This walkthrough is the fastest way to get a new library into a healthy state. 1. Install the package. 2. Create a minimal guide directory. -3. Synchronize shared automation. +3. Synchronize shared automation and packaged skills. 4. Run the focused commands once. 5. Run the full suite before opening a pull request. @@ -38,6 +38,7 @@ Once the package is installed and the guide directory exists, run: .. code-block:: bash composer dev-tools:sync + composer dev-tools skills composer dev-tools tests composer dev-tools docs composer dev-tools @@ -46,7 +47,10 @@ What Each Command Proves ------------------------ - ``composer dev-tools:sync`` proves the consumer repository can receive the - shared scripts and automation assets. + shared scripts, automation assets, and packaged skills during onboarding. +- ``composer dev-tools skills`` proves the packaged skill set can be linked + safely into ``.agents/skills`` without copying files into the consumer + repository. - ``composer dev-tools tests`` proves the packaged or local PHPUnit configuration can execute the current test suite. - ``composer dev-tools docs`` proves the PSR-4 source paths and the guide diff --git a/docs/index.rst b/docs/index.rst index f7cd78c..2fd0b12 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,7 +3,7 @@ Documentation FastForward DevTools is infrastructure for Fast Forward libraries. It ships shared defaults, registers Composer commands, generates documentation, and -synchronizes automation into consumer repositories. +synchronizes automation plus packaged agent skills into consumer repositories. If you are new to the package, start with :doc:`getting-started/index` and :doc:`usage/index`. If you maintain Fast Forward libraries, continue with diff --git a/docs/internals/architecture.rst b/docs/internals/architecture.rst index 0cd2906..6595199 100644 --- a/docs/internals/architecture.rst +++ b/docs/internals/architecture.rst @@ -28,8 +28,12 @@ Consumer Synchronization Lifecycle 4. After ``composer install`` or ``composer update``, the plugin runs ``vendor/bin/dev-tools dev-tools:sync``. 5. ``FastForward\DevTools\Command\SyncCommand`` updates scripts, GitHub - workflow stubs, ``.editorconfig``, ``dependabot.yml``, and the wiki - submodule in the consumer repository. + workflow stubs, ``.editorconfig``, ``dependabot.yml``, ``.gitignore``, and + the wiki submodule in the consumer repository. +6. ``FastForward\DevTools\Command\SkillsCommand`` synchronizes packaged skill + links into the consumer ``.agents/skills`` directory. +7. ``FastForward\DevTools\Agent\Skills\SkillsSynchronizer`` creates missing + links, repairs broken ones, and preserves consumer-owned directories. Documentation Pipeline ---------------------- diff --git a/docs/running/specialized-commands.rst b/docs/running/specialized-commands.rst index e8cd742..674cd43 100644 --- a/docs/running/specialized-commands.rst +++ b/docs/running/specialized-commands.rst @@ -122,6 +122,23 @@ Important details: - it calls ``tests --coverage public/coverage``; - it is the reporting stage used by ``standards``. +``skills`` +---------- + +Synchronizes packaged agent skills into the consumer repository. + +.. code-block:: bash + + composer dev-tools skills + vendor/bin/dev-tools skills + +Important details: + +- it verifies the packaged ``.agents/skills`` directory before doing any work; +- it creates the consumer ``.agents/skills`` directory when missing; +- it creates missing symlinks and repairs broken ones; +- it preserves an existing non-symlink directory instead of overwriting it. + ``dev-tools:sync`` ------------------ @@ -138,7 +155,10 @@ Important details: - it copies missing workflow stubs, ``.editorconfig``, and ``dependabot.yml``; - it creates ``.github/wiki`` as a git submodule when the directory is missing. -- it calls ``gitignore`` to merge the canonical .gitignore with the project's .gitignore. +- it calls ``gitignore`` to merge the canonical .gitignore with the project's + .gitignore; +- it calls ``skills`` so ``.agents/skills`` contains links to the packaged + skill set. ``gitignore`` ------------- diff --git a/docs/usage/common-workflows.rst b/docs/usage/common-workflows.rst index a13db84..3d78b7f 100644 --- a/docs/usage/common-workflows.rst +++ b/docs/usage/common-workflows.rst @@ -22,9 +22,13 @@ Most day-to-day work falls into one of the flows below. * - Refresh only the documentation site - ``composer dev-tools docs`` - Runs phpDocumentor using PSR-4 namespaces and the ``docs/`` guide. + * - Refresh packaged agent skills only + - ``composer dev-tools skills`` + - Creates or repairs symlinks in ``.agents/skills``. * - Publish local automation defaults into a consumer repository - ``composer dev-tools:sync`` - - Updates scripts and copies missing automation assets. + - Updates scripts, copies missing automation assets, and refreshes + packaged skills. * - Regenerate wiki pages - ``composer dev-tools wiki`` - Builds Markdown API pages in ``.github/wiki``. @@ -42,9 +46,10 @@ A Safe Beginner Routine ----------------------- 1. Run ``composer dev-tools tests``. -2. Run ``composer dev-tools docs`` if you changed guides or public APIs. -3. Run ``composer dev-tools:fix`` when you want automated help. -4. Run ``composer dev-tools`` before pushing. +2. Run ``composer dev-tools skills`` if you changed packaged consumer skills. +3. Run ``composer dev-tools docs`` if you changed guides or public APIs. +4. Run ``composer dev-tools:fix`` when you want automated help. +5. Run ``composer dev-tools`` before pushing. .. tip:: diff --git a/docs/usage/index.rst b/docs/usage/index.rst index 4a2b478..e51f639 100644 --- a/docs/usage/index.rst +++ b/docs/usage/index.rst @@ -10,4 +10,5 @@ task-oriented guidance instead of class-by-class reference. common-workflows testing-and-coverage documentation-workflows + syncing-packaged-skills syncing-consumer-projects diff --git a/docs/usage/syncing-consumer-projects.rst b/docs/usage/syncing-consumer-projects.rst index 186f4e0..6d84edf 100644 --- a/docs/usage/syncing-consumer-projects.rst +++ b/docs/usage/syncing-consumer-projects.rst @@ -4,6 +4,8 @@ Syncing Consumer Projects The ``dev-tools:sync`` command is the bridge between this repository and the libraries that consume it. +For the focused skills-only workflow, see :doc:`syncing-packaged-skills`. + What the Command Changes ------------------------ @@ -28,6 +30,10 @@ What the Command Changes * - ``.github/dependabot.yml`` - Copies the packaged Dependabot template. - Only when missing. + * - ``.agents/skills/`` + - Creates or repairs symlinks to packaged agent skills. + - Creates missing links, repairs broken symlinks, and preserves existing + non-symlink directories. * - ``.github/wiki`` - Adds a Git submodule derived from ``git remote origin``. - Only when missing. @@ -39,16 +45,21 @@ When to Run It - after upgrading ``fast-forward/dev-tools`` and wanting new shared workflow stubs; - when onboarding an older repository into the Fast Forward automation model. +- when packaged skills were added or updated and the consumer repository + needs fresh links. What It Needs ------------- - a writable ``composer.json`` in the consumer project; - a configured ``git remote origin`` if the wiki submodule must be created; -- permission to create local ``.github/`` files. +- permission to create local ``.github/`` files; +- permission to create local ``.agents/skills`` entries. .. important:: Workflow stubs, ``.editorconfig``, and ``dependabot.yml`` are copied only when the target file does not already exist. This protects - consumer-specific customizations. + consumer-specific customizations. The ``skills`` phase follows the same + spirit by preserving existing non-symlink directories inside + ``.agents/skills``. diff --git a/docs/usage/syncing-packaged-skills.rst b/docs/usage/syncing-packaged-skills.rst new file mode 100644 index 0000000..fb4aab1 --- /dev/null +++ b/docs/usage/syncing-packaged-skills.rst @@ -0,0 +1,75 @@ +Syncing Packaged Skills +======================= + +The ``skills`` command keeps the consumer repository's ``.agents/skills`` +directory aligned with the skills shipped inside +``fast-forward/dev-tools``. + +Why This Command Exists +----------------------- + +Fast Forward libraries can share agent skills without copying them into every +consumer repository. The packaged skill directories live in this repository, +while consumer repositories receive lightweight symlinks that point back to the +packaged source. + +That approach keeps upgrades simple: + +- updating ``fast-forward/dev-tools`` changes the packaged skill source; +- rerunning ``skills`` repairs missing or broken links; +- consumer-specific directories are preserved when they are not symlinks. + +How to Run It +------------- + +.. code-block:: bash + + composer dev-tools skills + vendor/bin/dev-tools skills + +What the Command Does +--------------------- + +.. list-table:: + :header-rows: 1 + + * - Situation + - Behavior + * - ``.agents/skills`` is missing + - Creates the directory in the consumer repository. + * - A packaged skill is missing locally + - Creates a symlink that points to the packaged skill directory. + * - A valid symlink already exists + - Leaves the link unchanged. + * - A symlink is broken + - Removes it and recreates it with the current packaged target. + * - A real directory already exists at the target path + - Preserves the directory and skips link creation to avoid overwriting + consumer-owned content. + +When to Run It Manually +----------------------- + +Run ``skills`` explicitly when: + +- you upgraded ``fast-forward/dev-tools`` and want to refresh local skill + links; +- someone deleted or broke entries inside ``.agents/skills``; +- Composer plugins were disabled during install, so ``dev-tools:sync`` did not + run automatically; +- you are iterating on packaged skills and want to verify the consumer-facing + links without rerunning the entire repository sync flow. + +Relationship with ``dev-tools:sync`` +------------------------------------ + +``dev-tools:sync`` ends by running ``gitignore`` and ``skills``. That means +the full onboarding command refreshes workflow stubs, repository defaults, and +packaged skills in one pass. + +What the Command Does Not Overwrite +----------------------------------- + +The command does not replace an existing non-symlink directory inside +``.agents/skills``. This protects local experiments, package-specific custom +skills, or directories managed by another tool.