Skip to content

Commit 5e24965

Browse files
committed
feat: refactor SkillsSynchronizer and SkillsCommand for improved logging and synchronization handling
Signed-off-by: Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
1 parent ff69fc8 commit 5e24965

3 files changed

Lines changed: 95 additions & 57 deletions

File tree

src/Agent/Skills/SkillsSynchronizer.php

Lines changed: 34 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818

1919
namespace FastForward\DevTools\Agent\Skills;
2020

21-
use Psr\Log\LoggerInterface;
21+
use Psr\Log\LoggerAwareInterface;
22+
use Psr\Log\LoggerAwareTrait;
23+
use Psr\Log\NullLogger;
2224
use Symfony\Component\Filesystem\Filesystem;
2325
use Symfony\Component\Finder\Finder;
2426
use Symfony\Component\Filesystem\Path;
@@ -30,16 +32,23 @@
3032
* repository to the skills packaged within the fast-forward/dev-tools dependency.
3133
* It handles initial sync, idempotent re-runs, and cleanup of broken links.
3234
*/
33-
final class SkillsSynchronizer
35+
final class SkillsSynchronizer implements LoggerAwareInterface
3436
{
35-
private readonly Filesystem $filesystem;
37+
use LoggerAwareTrait;
3638

3739
/**
40+
* Initializes the synchronizer with an optional filesystem instance.
41+
*
42+
* If no filesystem is provided, a default {@see Filesystem} instance is created.
43+
*
3844
* @param Filesystem|null $filesystem Filesystem instance for file operations
45+
* @param Finder $finder Finder instance for locating skill directories in the package
3946
*/
40-
public function __construct(?Filesystem $filesystem = null)
41-
{
42-
$this->filesystem = $filesystem ?? new Filesystem();
47+
public function __construct(
48+
private readonly Filesystem $filesystem = new Filesystem(),
49+
private readonly Finder $finder = new Finder(),
50+
) {
51+
$this->logger = new NullLogger();
4352
}
4453

4554
/**
@@ -51,30 +60,26 @@ public function __construct(?Filesystem $filesystem = null)
5160
*
5261
* @param string $skillsDir Absolute path to the consumer's .agents/skills directory
5362
* @param string $packageSkillsPath Absolute path to the packaged skills in the dependency
54-
* @param LoggerInterface $logger Logger for reporting sync operations
5563
*
5664
* @return SynchronizeResult Result containing counts of created, preserved, and removed links
5765
*/
58-
public function synchronize(
59-
string $skillsDir,
60-
string $packageSkillsPath,
61-
LoggerInterface $logger,
62-
): SynchronizeResult {
66+
public function synchronize(string $skillsDir, string $packageSkillsPath): SynchronizeResult
67+
{
6368
$result = new SynchronizeResult();
6469

6570
if (! $this->filesystem->exists($packageSkillsPath)) {
66-
$logger->error('No packaged skills found at: ' . $packageSkillsPath);
71+
$this->logger->error('No packaged skills found at: ' . $packageSkillsPath);
6772
$result->markFailed();
6873

6974
return $result;
7075
}
7176

7277
if (! $this->filesystem->exists($skillsDir)) {
7378
$this->filesystem->mkdir($skillsDir);
74-
$logger->info('Created .agents/skills directory.');
79+
$this->logger->info('Created .agents/skills directory.');
7580
}
7681

77-
$this->syncPackageSkills($skillsDir, $packageSkillsPath, $logger, $result);
82+
$this->syncPackageSkills($skillsDir, $packageSkillsPath, $result);
7883

7984
return $result;
8085
}
@@ -87,16 +92,14 @@ public function synchronize(
8792
*
8893
* @param string $skillsDir Target directory for symlinks
8994
* @param string $packageSkillsPath Source directory containing packaged skills
90-
* @param LoggerInterface $logger Logger for operation feedback
9195
* @param SynchronizeResult $result Result object to track outcomes
9296
*/
9397
private function syncPackageSkills(
9498
string $skillsDir,
9599
string $packageSkillsPath,
96-
LoggerInterface $logger,
97100
SynchronizeResult $result,
98101
): void {
99-
$finder = Finder::create()
102+
$finder = $this->finder
100103
->directories()
101104
->in($packageSkillsPath)
102105
->depth('== 0');
@@ -106,7 +109,7 @@ private function syncPackageSkills(
106109
$targetLink = Path::makeAbsolute($skillName, $skillsDir);
107110
$sourcePath = $skillDir->getRealPath();
108111

109-
$this->processSkillLink($skillName, $targetLink, $sourcePath, $logger, $result);
112+
$this->processSkillLink($skillName, $targetLink, $sourcePath, $result);
110113
}
111114
}
112115

@@ -119,29 +122,27 @@ private function syncPackageSkills(
119122
* @param string $skillName Name of the skill being processed
120123
* @param string $targetLink Absolute path where the symlink should exist
121124
* @param string $sourcePath Absolute path to the packaged skill directory
122-
* @param LoggerInterface $logger Logger for feedback on actions taken
123125
* @param SynchronizeResult $result Result tracker for reporting outcomes
124126
*/
125127
private function processSkillLink(
126128
string $skillName,
127129
string $targetLink,
128130
string $sourcePath,
129-
LoggerInterface $logger,
130131
SynchronizeResult $result,
131132
): void {
132133
if (! $this->filesystem->exists($targetLink)) {
133-
$this->createNewLink($skillName, $targetLink, $sourcePath, $logger, $result);
134+
$this->createNewLink($skillName, $targetLink, $sourcePath, $result);
134135

135136
return;
136137
}
137138

138139
if (! $this->isSymlink($targetLink)) {
139-
$this->preserveExistingNonSymlink($skillName, $logger, $result);
140+
$this->preserveExistingNonSymlink($skillName, $result);
140141

141142
return;
142143
}
143144

144-
$this->processExistingSymlink($skillName, $targetLink, $sourcePath, $logger, $result);
145+
$this->processExistingSymlink($skillName, $targetLink, $sourcePath, $result);
145146
}
146147

147148
/**
@@ -153,18 +154,16 @@ private function processSkillLink(
153154
* @param string $skillName Name identifying the skill
154155
* @param string $targetLink Absolute path where the symlink will be created
155156
* @param string $sourcePath Absolute path to the packaged skill directory
156-
* @param LoggerInterface $logger Logger for confirmation message
157157
* @param SynchronizeResult $result Result object for tracking creation
158158
*/
159159
private function createNewLink(
160160
string $skillName,
161161
string $targetLink,
162162
string $sourcePath,
163-
LoggerInterface $logger,
164163
SynchronizeResult $result,
165164
): void {
166165
$this->filesystem->symlink($sourcePath, $targetLink);
167-
$logger->info('Created link: ' . $skillName . ' -> ' . $sourcePath);
166+
$this->logger->info('Created link: ' . $skillName . ' -> ' . $sourcePath);
168167
$result->addCreatedLink($skillName);
169168
}
170169

@@ -176,15 +175,11 @@ private function createNewLink(
176175
* replaced to avoid accidental data loss.
177176
*
178177
* @param string $skillName Name of the skill with the conflicting item
179-
* @param LoggerInterface $logger Logger for the preservation notice
180178
* @param SynchronizeResult $result Result tracker for preserved items
181179
*/
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)');
180+
private function preserveExistingNonSymlink(string $skillName, SynchronizeResult $result): void
181+
{
182+
$this->logger->notice('Existing non-symlink found: ' . $skillName . ' (keeping as is, skipping link creation)');
188183
$result->addPreservedLink($skillName);
189184
}
190185

@@ -197,25 +192,23 @@ private function preserveExistingNonSymlink(
197192
* @param string $skillName Name of the skill with the existing symlink
198193
* @param string $targetLink Absolute path to the existing symlink
199194
* @param string $sourcePath Absolute path to the expected source directory
200-
* @param LoggerInterface $logger Logger for preservation or repair messages
201195
* @param SynchronizeResult $result Result tracker for preserved or removed links
202196
*/
203197
private function processExistingSymlink(
204198
string $skillName,
205199
string $targetLink,
206200
string $sourcePath,
207-
LoggerInterface $logger,
208201
SynchronizeResult $result,
209202
): void {
210203
$linkPath = $this->filesystem->readlink($targetLink, true);
211204

212205
if (! $linkPath || ! $this->filesystem->exists($linkPath)) {
213-
$this->repairBrokenLink($skillName, $targetLink, $sourcePath, $logger, $result);
206+
$this->repairBrokenLink($skillName, $targetLink, $sourcePath, $result);
214207

215208
return;
216209
}
217210

218-
$logger->notice('Preserved existing link: ' . $skillName);
211+
$this->logger->notice('Preserved existing link: ' . $skillName);
219212
$result->addPreservedLink($skillName);
220213
}
221214

@@ -229,21 +222,19 @@ private function processExistingSymlink(
229222
* @param string $skillName Name of the skill with the broken symlink
230223
* @param string $targetLink Absolute path to the broken symlink
231224
* @param string $sourcePath Absolute path to the current packaged skill
232-
* @param LoggerInterface $logger Logger for repair and creation messages
233225
* @param SynchronizeResult $result Result tracker for removed and created items
234226
*/
235227
private function repairBrokenLink(
236228
string $skillName,
237229
string $targetLink,
238230
string $sourcePath,
239-
LoggerInterface $logger,
240231
SynchronizeResult $result,
241232
): void {
242233
$this->filesystem->remove($targetLink);
243-
$logger->notice('Existing link is broken: ' . $skillName . ' (removing and recreating)');
234+
$this->logger->notice('Existing link is broken: ' . $skillName . ' (removing and recreating)');
244235
$result->addRemovedBrokenLink($skillName);
245236

246-
$this->createNewLink($skillName, $targetLink, $sourcePath, $logger, $result);
237+
$this->createNewLink($skillName, $targetLink, $sourcePath, $result);
247238
}
248239

249240
/**
@@ -253,6 +244,6 @@ private function repairBrokenLink(
253244
*/
254245
private function isSymlink(string $path): bool
255246
{
256-
return is_link($path);
247+
return null !== $this->filesystem->readlink($path);
257248
}
258249
}

src/Agent/Skills/SynchronizeResult.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ final class SynchronizeResult
4848
*/
4949
private array $removedBrokenLinks = [];
5050

51+
/**
52+
* Indicates whether the synchronization encountered a failure.
53+
*/
5154
private bool $failed = false;
5255

5356
/**

src/Command/SkillsCommand.php

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,25 +22,53 @@
2222
use FastForward\DevTools\Agent\Skills\SynchronizeResult;
2323
use Symfony\Component\Console\Input\InputInterface;
2424
use Symfony\Component\Console\Output\OutputInterface;
25+
use Symfony\Component\Filesystem\Filesystem;
2526

2627
/**
27-
* Synchronizes Fast Forward skills into the consumer repository by managing `.agents/skills` links.
28+
* Synchronizes packaged Fast Forward skills into the consumer repository.
29+
*
30+
* This command SHALL ensure that the consumer repository contains the expected
31+
* `.agents/skills` directory structure backed by the packaged skill set. The
32+
* command MUST verify that the packaged skills directory exists before any
33+
* synchronization is attempted. If the target skills directory does not exist,
34+
* it SHALL be created before the synchronization process begins.
35+
*
36+
* The synchronization workflow is delegated to {@see SkillsSynchronizer}. This
37+
* command MUST act as an orchestration layer only: it prepares the source and
38+
* target paths, triggers synchronization, and translates the resulting status
39+
* into Symfony Console output and process exit codes.
2840
*/
2941
final class SkillsCommand extends AbstractCommand
3042
{
31-
private readonly SkillsSynchronizer $synchronizer;
32-
3343
/**
34-
* @param SkillsSynchronizer|null $synchronizer
44+
* Initializes the command with an optional skills synchronizer instance.
45+
*
46+
* If no synchronizer is provided, the command SHALL instantiate the default
47+
* {@see SkillsSynchronizer} implementation. Consumers MAY inject a custom
48+
* synchronizer for testing or alternative synchronization behavior, provided
49+
* it preserves the expected contract.
50+
*
51+
* @param SkillsSynchronizer|null $synchronizer the synchronizer responsible
52+
* for applying the skills
53+
* synchronization process
54+
* @param Filesystem|null $filesystem filesystem used to resolve
55+
* and manage the skills
56+
* directory structure
3557
*/
36-
public function __construct(?SkillsSynchronizer $synchronizer = null)
37-
{
38-
$this->synchronizer = $synchronizer ?? new SkillsSynchronizer();
39-
40-
parent::__construct();
58+
public function __construct(
59+
private readonly SkillsSynchronizer $synchronizer = new SkillsSynchronizer(),
60+
?Filesystem $filesystem = null
61+
) {
62+
parent::__construct($filesystem);
4163
}
4264

4365
/**
66+
* Configures the command name, description, and help text.
67+
*
68+
* The command metadata MUST clearly describe that the operation synchronizes
69+
* Fast Forward skills into the `.agents/skills` directory and that it manages
70+
* link-based synchronization for packaged skills.
71+
*
4472
* @return void
4573
*/
4674
protected function configure(): void
@@ -55,10 +83,25 @@ protected function configure(): void
5583
}
5684

5785
/**
58-
* @param InputInterface $input
59-
* @param OutputInterface $output
86+
* Executes the skills synchronization workflow.
87+
*
88+
* This method SHALL:
89+
* - announce the start of synchronization;
90+
* - resolve the packaged skills path and consumer target directory;
91+
* - fail when the packaged skills directory does not exist;
92+
* - create the target directory when it is missing;
93+
* - delegate synchronization to {@see SkillsSynchronizer};
94+
* - return a success or failure exit code based on the synchronization result.
6095
*
61-
* @return int
96+
* The command MUST return {@see self::FAILURE} when packaged skills are not
97+
* available or when the synchronizer reports a failure. It MUST return
98+
* {@see self::SUCCESS} only when synchronization completes successfully.
99+
*
100+
* @param InputInterface $input the console input instance provided by Symfony
101+
* @param OutputInterface $output the console output instance used to report progress
102+
*
103+
* @return int The process exit status. This MUST be {@see self::SUCCESS} on
104+
* success and {@see self::FAILURE} on failure.
62105
*/
63106
protected function execute(InputInterface $input, OutputInterface $output): int
64107
{
@@ -67,7 +110,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int
67110
$packageSkillsPath = $this->getDevToolsFile('.agents/skills');
68111
$skillsDir = $this->getConfigFile('.agents/skills', true);
69112

70-
// Normal consumer repository flow
71113
if (! $this->filesystem->exists($packageSkillsPath)) {
72114
$output->writeln('<comment>No packaged skills found at: ' . $packageSkillsPath . '</comment>');
73115

@@ -79,8 +121,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
79121
$output->writeln('<info>Created .agents/skills directory.</info>');
80122
}
81123

124+
$this->synchronizer->setLogger($this->getIO());
125+
82126
/** @var SynchronizeResult $result */
83-
$result = $this->synchronizer->synchronize($skillsDir, $packageSkillsPath, $this->getIO());
127+
$result = $this->synchronizer->synchronize($skillsDir, $packageSkillsPath);
84128

85129
if ($result->failed()) {
86130
$output->writeln('<error>Skills synchronization failed.</error>');

0 commit comments

Comments
 (0)