Skip to content

Commit ff69fc8

Browse files
committed
Add descriptive PHPDoc to skills classes and refactor sync methods
1 parent 17e4006 commit ff69fc8

6 files changed

Lines changed: 407 additions & 302 deletions

File tree

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
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\Agent\Skills;
20+
21+
use Psr\Log\LoggerInterface;
22+
use Symfony\Component\Filesystem\Filesystem;
23+
use Symfony\Component\Finder\Finder;
24+
use Symfony\Component\Filesystem\Path;
25+
26+
/**
27+
* Synchronizes Fast Forward skills into consumer repositories.
28+
*
29+
* This class manages the creation and maintenance of symlinks from a consumer
30+
* repository to the skills packaged within the fast-forward/dev-tools dependency.
31+
* It handles initial sync, idempotent re-runs, and cleanup of broken links.
32+
*/
33+
final class SkillsSynchronizer
34+
{
35+
private readonly Filesystem $filesystem;
36+
37+
/**
38+
* @param Filesystem|null $filesystem Filesystem instance for file operations
39+
*/
40+
public function __construct(?Filesystem $filesystem = null)
41+
{
42+
$this->filesystem = $filesystem ?? new Filesystem();
43+
}
44+
45+
/**
46+
* Synchronizes skills from the package to the consumer repository.
47+
*
48+
* Ensures the consumer repository contains linked Fast Forward skills by
49+
* creating symlinks to the packaged skills directory. Creates the target
50+
* directory if missing, skips existing valid links, and repairs broken ones.
51+
*
52+
* @param string $skillsDir Absolute path to the consumer's .agents/skills directory
53+
* @param string $packageSkillsPath Absolute path to the packaged skills in the dependency
54+
* @param LoggerInterface $logger Logger for reporting sync operations
55+
*
56+
* @return SynchronizeResult Result containing counts of created, preserved, and removed links
57+
*/
58+
public function synchronize(
59+
string $skillsDir,
60+
string $packageSkillsPath,
61+
LoggerInterface $logger,
62+
): SynchronizeResult {
63+
$result = new SynchronizeResult();
64+
65+
if (! $this->filesystem->exists($packageSkillsPath)) {
66+
$logger->error('No packaged skills found at: ' . $packageSkillsPath);
67+
$result->markFailed();
68+
69+
return $result;
70+
}
71+
72+
if (! $this->filesystem->exists($skillsDir)) {
73+
$this->filesystem->mkdir($skillsDir);
74+
$logger->info('Created .agents/skills directory.');
75+
}
76+
77+
$this->syncPackageSkills($skillsDir, $packageSkillsPath, $logger, $result);
78+
79+
return $result;
80+
}
81+
82+
/**
83+
* Iterates through all packaged skills and processes each one.
84+
*
85+
* Uses Finder to locate skill directories in the package, then processes
86+
* each as a potential symlink in the consumer repository.
87+
*
88+
* @param string $skillsDir Target directory for symlinks
89+
* @param string $packageSkillsPath Source directory containing packaged skills
90+
* @param LoggerInterface $logger Logger for operation feedback
91+
* @param SynchronizeResult $result Result object to track outcomes
92+
*/
93+
private function syncPackageSkills(
94+
string $skillsDir,
95+
string $packageSkillsPath,
96+
LoggerInterface $logger,
97+
SynchronizeResult $result,
98+
): void {
99+
$finder = Finder::create()
100+
->directories()
101+
->in($packageSkillsPath)
102+
->depth('== 0');
103+
104+
foreach ($finder as $skillDir) {
105+
$skillName = $skillDir->getFilename();
106+
$targetLink = Path::makeAbsolute($skillName, $skillsDir);
107+
$sourcePath = $skillDir->getRealPath();
108+
109+
$this->processSkillLink($skillName, $targetLink, $sourcePath, $logger, $result);
110+
}
111+
}
112+
113+
/**
114+
* Routes a skill link to the appropriate handling method based on target state.
115+
*
116+
* Determines whether the target path needs creation, preservation, or repair
117+
* based on filesystem checks, then delegates to the corresponding method.
118+
*
119+
* @param string $skillName Name of the skill being processed
120+
* @param string $targetLink Absolute path where the symlink should exist
121+
* @param string $sourcePath Absolute path to the packaged skill directory
122+
* @param LoggerInterface $logger Logger for feedback on actions taken
123+
* @param SynchronizeResult $result Result tracker for reporting outcomes
124+
*/
125+
private function processSkillLink(
126+
string $skillName,
127+
string $targetLink,
128+
string $sourcePath,
129+
LoggerInterface $logger,
130+
SynchronizeResult $result,
131+
): void {
132+
if (! $this->filesystem->exists($targetLink)) {
133+
$this->createNewLink($skillName, $targetLink, $sourcePath, $logger, $result);
134+
135+
return;
136+
}
137+
138+
if (! $this->isSymlink($targetLink)) {
139+
$this->preserveExistingNonSymlink($skillName, $logger, $result);
140+
141+
return;
142+
}
143+
144+
$this->processExistingSymlink($skillName, $targetLink, $sourcePath, $logger, $result);
145+
}
146+
147+
/**
148+
* Creates a new symlink pointing to the packaged skill.
149+
*
150+
* This method is called when no existing item exists at the target path.
151+
* Creates the symlink, logs the creation, and records it in the result.
152+
*
153+
* @param string $skillName Name identifying the skill
154+
* @param string $targetLink Absolute path where the symlink will be created
155+
* @param string $sourcePath Absolute path to the packaged skill directory
156+
* @param LoggerInterface $logger Logger for confirmation message
157+
* @param SynchronizeResult $result Result object for tracking creation
158+
*/
159+
private function createNewLink(
160+
string $skillName,
161+
string $targetLink,
162+
string $sourcePath,
163+
LoggerInterface $logger,
164+
SynchronizeResult $result,
165+
): void {
166+
$this->filesystem->symlink($sourcePath, $targetLink);
167+
$logger->info('Created link: ' . $skillName . ' -> ' . $sourcePath);
168+
$result->addCreatedLink($skillName);
169+
}
170+
171+
/**
172+
* Handles an existing non-symlink item at the target path.
173+
*
174+
* When the target exists but is a real directory (not a symlink), this method
175+
* preserves it unchanged and logs the decision. Real directories are not
176+
* replaced to avoid accidental data loss.
177+
*
178+
* @param string $skillName Name of the skill with the conflicting item
179+
* @param LoggerInterface $logger Logger for the preservation notice
180+
* @param SynchronizeResult $result Result tracker for preserved items
181+
*/
182+
private function preserveExistingNonSymlink(
183+
string $skillName,
184+
LoggerInterface $logger,
185+
SynchronizeResult $result,
186+
): void {
187+
$logger->notice('Existing non-symlink found: ' . $skillName . ' (keeping as is, skipping link creation)');
188+
$result->addPreservedLink($skillName);
189+
}
190+
191+
/**
192+
* Evaluates an existing symlink and determines whether to preserve or repair it.
193+
*
194+
* Reads the symlink target and checks if it points to a valid, existing path.
195+
* Delegates to repair if broken, otherwise preserves the valid link in place.
196+
*
197+
* @param string $skillName Name of the skill with the existing symlink
198+
* @param string $targetLink Absolute path to the existing symlink
199+
* @param string $sourcePath Absolute path to the expected source directory
200+
* @param LoggerInterface $logger Logger for preservation or repair messages
201+
* @param SynchronizeResult $result Result tracker for preserved or removed links
202+
*/
203+
private function processExistingSymlink(
204+
string $skillName,
205+
string $targetLink,
206+
string $sourcePath,
207+
LoggerInterface $logger,
208+
SynchronizeResult $result,
209+
): void {
210+
$linkPath = $this->filesystem->readlink($targetLink, true);
211+
212+
if (! $linkPath || ! $this->filesystem->exists($linkPath)) {
213+
$this->repairBrokenLink($skillName, $targetLink, $sourcePath, $logger, $result);
214+
215+
return;
216+
}
217+
218+
$logger->notice('Preserved existing link: ' . $skillName);
219+
$result->addPreservedLink($skillName);
220+
}
221+
222+
/**
223+
* Removes a broken symlink and creates a fresh one pointing to the current source.
224+
*
225+
* Called when the existing symlink target either does not exist or points to
226+
* an invalid path. Removes the broken link, logs the repair, records the removal,
227+
* then delegates to createNewLink for the fresh symlink.
228+
*
229+
* @param string $skillName Name of the skill with the broken symlink
230+
* @param string $targetLink Absolute path to the broken symlink
231+
* @param string $sourcePath Absolute path to the current packaged skill
232+
* @param LoggerInterface $logger Logger for repair and creation messages
233+
* @param SynchronizeResult $result Result tracker for removed and created items
234+
*/
235+
private function repairBrokenLink(
236+
string $skillName,
237+
string $targetLink,
238+
string $sourcePath,
239+
LoggerInterface $logger,
240+
SynchronizeResult $result,
241+
): void {
242+
$this->filesystem->remove($targetLink);
243+
$logger->notice('Existing link is broken: ' . $skillName . ' (removing and recreating)');
244+
$result->addRemovedBrokenLink($skillName);
245+
246+
$this->createNewLink($skillName, $targetLink, $sourcePath, $logger, $result);
247+
}
248+
249+
/**
250+
* Checks if a path is a symbolic link.
251+
*
252+
* @param string $path
253+
*/
254+
private function isSymlink(string $path): bool
255+
{
256+
return is_link($path);
257+
}
258+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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\Agent\Skills;
20+
21+
/**
22+
* Result object for skill synchronization operations.
23+
*
24+
* Tracks the outcomes of a synchronization run, including newly created links,
25+
* existing items that were preserved, and broken links that were removed.
26+
* The failed flag indicates whether an error occurred during synchronization.
27+
*/
28+
final class SynchronizeResult
29+
{
30+
/**
31+
* List of skill names for which new symlinks were created.
32+
*
33+
* @var list<string>
34+
*/
35+
private array $createdLinks = [];
36+
37+
/**
38+
* List of skill names for which existing items were left unchanged.
39+
*
40+
* @var list<string>
41+
*/
42+
private array $preservedLinks = [];
43+
44+
/**
45+
* List of skill names whose broken symlinks were removed during sync.
46+
*
47+
* @var list<string>
48+
*/
49+
private array $removedBrokenLinks = [];
50+
51+
private bool $failed = false;
52+
53+
/**
54+
* Records a skill for which a new symlink was created.
55+
*
56+
* @param string $link Name of the skill that received a new symlink
57+
*/
58+
public function addCreatedLink(string $link): void
59+
{
60+
$this->createdLinks[] = $link;
61+
}
62+
63+
/**
64+
* Records a skill whose existing item was preserved unchanged.
65+
*
66+
* @param string $link Name of the skill that was left in place
67+
*/
68+
public function addPreservedLink(string $link): void
69+
{
70+
$this->preservedLinks[] = $link;
71+
}
72+
73+
/**
74+
* Records a skill whose broken symlink was removed during sync.
75+
*
76+
* @param string $link Name of the skill whose broken link was removed
77+
*/
78+
public function addRemovedBrokenLink(string $link): void
79+
{
80+
$this->removedBrokenLinks[] = $link;
81+
}
82+
83+
/**
84+
* Marks the synchronization as failed due to an error condition.
85+
*/
86+
public function markFailed(): void
87+
{
88+
$this->failed = true;
89+
}
90+
91+
/**
92+
* Returns the list of skills for which new symlinks were created.
93+
*
94+
* @return list<string> Skill names of newly created links
95+
*/
96+
public function getCreatedLinks(): array
97+
{
98+
return $this->createdLinks;
99+
}
100+
101+
/**
102+
* Returns the list of skills whose existing items were preserved.
103+
*
104+
* @return list<string> Skill names of preserved items
105+
*/
106+
public function getPreservedLinks(): array
107+
{
108+
return $this->preservedLinks;
109+
}
110+
111+
/**
112+
* Returns the list of skills whose broken symlinks were removed.
113+
*
114+
* @return list<string> Skill names of removed broken links
115+
*/
116+
public function getRemovedBrokenLinks(): array
117+
{
118+
return $this->removedBrokenLinks;
119+
}
120+
121+
/**
122+
* Indicates whether the synchronization encountered a failure.
123+
*
124+
* @return bool True if an error occurred, false otherwise
125+
*/
126+
public function failed(): bool
127+
{
128+
return $this->failed;
129+
}
130+
}

0 commit comments

Comments
 (0)