Skip to content

Commit adf1408

Browse files
committed
Add SkillsCommand to synchronize packaged skills into consumer repositories
1 parent 3ded974 commit adf1408

6 files changed

Lines changed: 386 additions & 0 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of fast-forward/dev-tools.
7+
*
8+
* This source file is subject to the license bundled
9+
* with this source code in the file LICENSE.
10+
*
11+
* @copyright Copyright (c) 2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
12+
* @license https://opensource.org/licenses/MIT MIT License
13+
*
14+
* @see https://github.com/php-fast-forward/dev-tools
15+
* @see https://github.com/php-fast-forward
16+
* @see https://datatracker.ietf.org/doc/html/rfc2119
17+
*/
18+
19+
namespace FastForward\DevTools\Command\Skills;
20+
21+
use Symfony\Component\Filesystem\Filesystem;
22+
use Symfony\Component\Finder\Finder;
23+
use Symfony\Component\Filesystem\Path;
24+
25+
/**
26+
* Synchronizes Fast Forward skills into consumer repositories.
27+
*/
28+
final class SkillsSynchronizer
29+
{
30+
private readonly Filesystem $filesystem;
31+
32+
public function __construct(?Filesystem $filesystem = null)
33+
{
34+
$this->filesystem = $filesystem ?? new Filesystem();
35+
}
36+
37+
/**
38+
* Synchronizes skills from the package to the consumer repository.
39+
*
40+
* @param string $rootPath The consumer repository root path
41+
* @param string $skillsDir The target .agents/skills directory
42+
* @param string $packageSkillsPath The source skills directory in the package
43+
* @param callable(string): void $logger Callback for logging messages
44+
*
45+
* @return SynchronizeResult The result of the synchronization
46+
*/
47+
public function synchronize(
48+
string $rootPath,
49+
string $skillsDir,
50+
string $packageSkillsPath,
51+
callable $logger,
52+
): SynchronizeResult {
53+
$result = new SynchronizeResult();
54+
55+
if (! $this->filesystem->exists($packageSkillsPath)) {
56+
$logger('<comment>No packaged skills found at: ' . $packageSkillsPath . '</comment>');
57+
$result->markFailed();
58+
59+
return $result;
60+
}
61+
62+
if (! $this->filesystem->exists($skillsDir)) {
63+
$this->filesystem->mkdir($skillsDir);
64+
$logger('<info>Created .agents/skills directory.</info>');
65+
}
66+
67+
$this->syncPackageSkills($rootPath, $skillsDir, $packageSkillsPath, $logger, $result);
68+
$this->cleanupBrokenLinks($skillsDir, $logger, $result);
69+
70+
return $result;
71+
}
72+
73+
/**
74+
* Syncs skills from the package to the consumer repository.
75+
*
76+
* @param string $rootPath
77+
* @param string $skillsDir
78+
* @param string $packageSkillsPath
79+
* @param callable $logger
80+
* @param SynchronizeResult $result
81+
*/
82+
private function syncPackageSkills(
83+
string $rootPath,
84+
string $skillsDir,
85+
string $packageSkillsPath,
86+
callable $logger,
87+
SynchronizeResult $result,
88+
): void {
89+
$finder = Finder::create()
90+
->directories()
91+
->in($packageSkillsPath)
92+
->depth('== 0');
93+
94+
foreach ($finder as $skillDir) {
95+
$skillName = $skillDir->getFilename();
96+
$targetLink = Path::makeAbsolute($skillName, $skillsDir);
97+
$sourcePath = $skillDir->getRealPath();
98+
99+
if ($this->filesystem->exists($targetLink)) {
100+
// Check if existing target is a valid symlink pointing to source
101+
if ($this->isSymlink($targetLink)) {
102+
$existingTarget = readlink($targetLink);
103+
104+
if ($existingTarget === $sourcePath) {
105+
$logger('<comment>Preserved existing link: ' . $skillName . '</comment>');
106+
$result->addPreservedLink($skillName);
107+
108+
continue;
109+
}
110+
111+
// Broken or wrong symlink - remove and recreate
112+
$this->filesystem->remove($targetLink);
113+
} else {
114+
// Non-symlink exists - check if it's the same content
115+
// For development mode in dev-tools repo, we might have actual directories
116+
// In that case, offer to convert to symlink
117+
$logger('<comment>Found existing directory: ' . $skillName . ' (converting to symlink)</comment>');
118+
$this->filesystem->remove($targetLink);
119+
}
120+
}
121+
122+
$this->filesystem->symlink($sourcePath, $targetLink);
123+
$logger('<info>Created link: ' . $skillName . ' -> ' . $sourcePath . '</info>');
124+
$result->addCreatedLink($skillName);
125+
}
126+
}
127+
128+
/**
129+
* Cleans up broken symlinks in the skills directory.
130+
*
131+
* @param string $skillsDir
132+
* @param callable $logger
133+
* @param SynchronizeResult $result
134+
*/
135+
private function cleanupBrokenLinks(string $skillsDir, callable $logger, SynchronizeResult $result): void
136+
{
137+
if (! $this->filesystem->exists($skillsDir)) {
138+
return;
139+
}
140+
141+
$items = scandir($skillsDir);
142+
143+
foreach ($items as $item) {
144+
if ('.' === $item || '..' === $item) {
145+
continue;
146+
}
147+
148+
$itemPath = Path::makeAbsolute($item, $skillsDir);
149+
150+
if (! is_link($itemPath)) {
151+
continue;
152+
}
153+
154+
$target = readlink($itemPath);
155+
156+
if (false === $target) {
157+
continue;
158+
}
159+
160+
if (! file_exists($target)) {
161+
$this->filesystem->remove($itemPath);
162+
$logger('<info>Removed broken link: ' . $item . '</info>');
163+
$result->addRemovedBrokenLink($item);
164+
}
165+
}
166+
}
167+
168+
/**
169+
* Checks if a path is a symbolic link.
170+
*
171+
* @param string $path
172+
*/
173+
private function isSymlink(string $path): bool
174+
{
175+
return is_link($path);
176+
}
177+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of fast-forward/dev-tools.
7+
*
8+
* This source file is subject to the license bundled
9+
* with this source code in the file LICENSE.
10+
*
11+
* @copyright Copyright (c) 2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
12+
* @license https://opensource.org/licenses/MIT MIT License
13+
*
14+
* @see https://github.com/php-fast-forward/dev-tools
15+
* @see https://github.com/php-fast-forward
16+
* @see https://datatracker.ietf.org/doc/html/rfc2119
17+
*/
18+
19+
namespace FastForward\DevTools\Command\Skills;
20+
21+
/**
22+
* Result of skill synchronization operation.
23+
*/
24+
final class SynchronizeResult
25+
{
26+
/**
27+
* @var list<string>
28+
*/
29+
private array $createdLinks = [];
30+
31+
/**
32+
* @var list<string>
33+
*/
34+
private array $preservedLinks = [];
35+
36+
/**
37+
* @var list<string>
38+
*/
39+
private array $removedBrokenLinks = [];
40+
41+
private bool $failed = false;
42+
43+
public function addCreatedLink(string $link): void
44+
{
45+
$this->createdLinks[] = $link;
46+
}
47+
48+
public function addPreservedLink(string $link): void
49+
{
50+
$this->preservedLinks[] = $link;
51+
}
52+
53+
public function addRemovedBrokenLink(string $link): void
54+
{
55+
$this->removedBrokenLinks[] = $link;
56+
}
57+
58+
public function markFailed(): void
59+
{
60+
$this->failed = true;
61+
}
62+
63+
/**
64+
* @return list<string>
65+
*/
66+
public function getCreatedLinks(): array
67+
{
68+
return $this->createdLinks;
69+
}
70+
71+
/**
72+
* @return list<string>
73+
*/
74+
public function getPreservedLinks(): array
75+
{
76+
return $this->preservedLinks;
77+
}
78+
79+
/**
80+
* @return list<string>
81+
*/
82+
public function getRemovedBrokenLinks(): array
83+
{
84+
return $this->removedBrokenLinks;
85+
}
86+
87+
public function failed(): bool
88+
{
89+
return $this->failed;
90+
}
91+
}

src/Command/SkillsCommand.php

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of fast-forward/dev-tools.
7+
*
8+
* This source file is subject to the license bundled
9+
* with this source code in the file LICENSE.
10+
*
11+
* @copyright Copyright (c) 2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
12+
* @license https://opensource.org/licenses/MIT MIT License
13+
*
14+
* @see https://github.com/php-fast-forward/dev-tools
15+
* @see https://github.com/php-fast-forward
16+
* @see https://datatracker.ietf.org/doc/html/rfc2119
17+
*/
18+
19+
namespace FastForward\DevTools\Command;
20+
21+
use FastForward\DevTools\Command\Skills\SkillsSynchronizer;
22+
use FastForward\DevTools\Command\Skills\SynchronizeResult;
23+
use Symfony\Component\Console\Input\InputInterface;
24+
use Symfony\Component\Console\Output\OutputInterface;
25+
use Symfony\Component\Filesystem\Path;
26+
27+
/**
28+
* Synchronizes Fast Forward skills into the consumer repository by managing `.agents/skills` links.
29+
*/
30+
final class SkillsCommand extends AbstractCommand
31+
{
32+
private readonly SkillsSynchronizer $synchronizer;
33+
34+
public function __construct(?SkillsSynchronizer $synchronizer = null)
35+
{
36+
$this->synchronizer = $synchronizer ?? new SkillsSynchronizer();
37+
38+
parent::__construct();
39+
}
40+
41+
protected function configure(): void
42+
{
43+
$this
44+
->setName('dev-tools:skills')
45+
->setDescription('Synchronizes Fast Forward skills into .agents/skills directory.')
46+
->setHelp(
47+
'This command ensures the consumer repository contains linked Fast Forward skills '
48+
. 'by creating symlinks to the packaged skills and removing broken links.'
49+
);
50+
}
51+
52+
protected function execute(InputInterface $input, OutputInterface $output): int
53+
{
54+
$output->writeln('<info>Starting skills synchronization...</info>');
55+
56+
$rootPath = $this->getCurrentWorkingDirectory();
57+
$skillsDir = Path::makeAbsolute('.agents/skills', $rootPath);
58+
59+
// Use __DIR__ to get the package path
60+
$packagePath = Path::makeAbsolute('..', __DIR__);
61+
while (! file_exists($packagePath . '/composer.json')) {
62+
$parent = \dirname($packagePath);
63+
if ($parent === $packagePath) {
64+
break;
65+
}
66+
$packagePath = $parent;
67+
}
68+
$packageSkillsPath = Path::makeAbsolute('.agents/skills', $packagePath);
69+
70+
// If package path equals root path, we're in the dev-tools repo itself
71+
// and skills are already present as regular files (tracked in git)
72+
if ($packagePath === $rootPath && $this->filesystem->exists($skillsDir)) {
73+
$output->writeln('<info>Skills already available in development repository (tracked in git).</info>');
74+
75+
return self::SUCCESS;
76+
}
77+
78+
// Normal consumer repository flow
79+
if (! $this->filesystem->exists($packageSkillsPath)) {
80+
$output->writeln('<comment>No packaged skills found at: ' . $packageSkillsPath . '</comment>');
81+
82+
return self::FAILURE;
83+
}
84+
85+
if (! $this->filesystem->exists($skillsDir)) {
86+
$this->filesystem->mkdir($skillsDir);
87+
$output->writeln('<info>Created .agents/skills directory.</info>');
88+
}
89+
90+
/** @var SynchronizeResult $result */
91+
$result = $this->synchronizer->synchronize(
92+
$rootPath,
93+
$skillsDir,
94+
$packageSkillsPath,
95+
static function (string $message) use ($output): void {
96+
$output->writeln($message);
97+
},
98+
);
99+
100+
if ($result->failed()) {
101+
$output->writeln('<error>Skills synchronization failed.</error>');
102+
103+
return self::FAILURE;
104+
}
105+
106+
$output->writeln('<info>Skills synchronization completed successfully.</info>');
107+
108+
return self::SUCCESS;
109+
}
110+
}

src/Command/SyncCommand.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
7575
$this->copyDependabotConfig();
7676
$this->addRepositoryWikiGitSubmodule();
7777
$this->runCommand('gitignore', $output);
78+
$this->runCommand('dev-tools:skills', $output);
7879

7980
return self::SUCCESS;
8081
}

0 commit comments

Comments
 (0)