diff --git a/php/Console/Commands/AgenticSyncPluginsCcCommand.php b/php/Console/Commands/AgenticSyncPluginsCcCommand.php new file mode 100644 index 0000000..86b06b2 --- /dev/null +++ b/php/Console/Commands/AgenticSyncPluginsCcCommand.php @@ -0,0 +1,274 @@ +pluginsPath(); + $pluginNames = $this->discoverPluginNames($pluginsPath); + + if ($pluginNames === []) { + $this->info("No Claude Code plugin directories found in {$pluginsPath}."); + + return self::SUCCESS; + } + + $profiles = AgentProfile::query() + ->where('enabled', true) + ->get(); + + $claimedProfileIds = []; + $report = []; + $pendingPluginNames = []; + + foreach ($pluginNames as $pluginName) { + $nameMatches = $this->matchProfilesByName($profiles, $pluginName, $claimedProfileIds); + + if ($nameMatches->count() === 1) { + $profile = $nameMatches->first(); + $claimedProfileIds[$profile->id] = $pluginName; + $report[] = $this->mapPluginToProfile($pluginName, $profile, 'name'); + + continue; + } + + if ($nameMatches->count() > 1) { + $report[] = $this->unmappedRow($pluginName, 'ambiguous name match'); + + continue; + } + + $pendingPluginNames[] = $pluginName; + } + + foreach ($pendingPluginNames as $pluginName) { + $pluginNameMatches = $this->matchProfilesByPluginCcName($profiles, $pluginName, $claimedProfileIds); + + if ($pluginNameMatches->count() === 1) { + $profile = $pluginNameMatches->first(); + $claimedProfileIds[$profile->id] = $pluginName; + $report[] = $this->mapPluginToProfile($pluginName, $profile, 'plugin_cc_name'); + + continue; + } + + if ($pluginNameMatches->count() > 1) { + $report[] = $this->unmappedRow($pluginName, 'ambiguous plugin_cc_name match'); + + continue; + } + + $report[] = $this->unmappedRow($pluginName, 'no enabled profile'); + } + + usort($report, static fn (array $left, array $right): int => strcmp((string) $left['plugin'], (string) $right['plugin'])); + + $this->table( + ['Plugin', 'Status', 'Profile', 'Match', 'Action'], + array_map( + static fn (array $row): array => [ + $row['plugin'], + $row['status'], + $row['profile'], + $row['match'], + $row['action'], + ], + $report, + ), + ); + + $mapped = count(array_filter($report, static fn (array $row): bool => $row['status'] === 'mapped')); + $unmapped = count($report) - $mapped; + + $this->info("Mapped {$mapped} Claude Code plugin(s); {$unmapped} unmapped."); + + return self::SUCCESS; + } + + /** + * @return array + */ + private function discoverPluginNames(string $pluginsPath): array + { + if (! is_dir($pluginsPath)) { + return []; + } + + $pluginNames = $this->discoverPluginNamesViaStorage($pluginsPath); + + if ($pluginNames === null) { + $pluginNames = $this->discoverPluginNamesViaFinder($pluginsPath); + } + + sort($pluginNames, SORT_NATURAL | SORT_FLAG_CASE); + + return array_values(array_unique($pluginNames)); + } + + /** + * @return array|null + */ + private function discoverPluginNamesViaStorage(string $pluginsPath): ?array + { + try { + config([ + 'filesystems.disks.'.self::STORAGE_DISK => [ + 'driver' => 'local', + 'root' => $pluginsPath, + ], + ]); + + $disk = Storage::disk(self::STORAGE_DISK); + $directories = $disk->directories('/'); + $pluginNames = []; + + foreach ($directories as $directory) { + $directory = trim($directory, '/'); + + if ($directory === '') { + continue; + } + + if (! $disk->exists($directory.'/plugin.json')) { + continue; + } + + $pluginNames[] = basename($directory); + } + + return $pluginNames; + } catch (\Throwable) { + return null; + } + } + + /** + * @return array + */ + private function discoverPluginNamesViaFinder(string $pluginsPath): array + { + $finder = Finder::create() + ->directories() + ->depth('== 0') + ->in($pluginsPath) + ->sortByName(); + + $pluginNames = []; + + foreach ($finder as $directory) { + $pluginJsonPath = $directory->getRealPath().'/plugin.json'; + + if (! is_file($pluginJsonPath)) { + continue; + } + + $pluginNames[] = $directory->getFilename(); + } + + return $pluginNames; + } + + /** + * @param array $claimedProfileIds + * @return Collection + */ + private function matchProfilesByName(Collection $profiles, string $pluginName, array $claimedProfileIds): Collection + { + $normalisedPluginName = $this->normalise($pluginName); + + return $profiles + ->reject(static fn (AgentProfile $profile): bool => array_key_exists($profile->id, $claimedProfileIds)) + ->filter(fn (AgentProfile $profile): bool => $this->normalise($profile->name) === $normalisedPluginName) + ->values(); + } + + /** + * @param array $claimedProfileIds + * @return Collection + */ + private function matchProfilesByPluginCcName(Collection $profiles, string $pluginName, array $claimedProfileIds): Collection + { + $normalisedPluginName = $this->normalise($pluginName); + + return $profiles + ->reject(static fn (AgentProfile $profile): bool => array_key_exists($profile->id, $claimedProfileIds)) + ->filter(function (AgentProfile $profile) use ($normalisedPluginName): bool { + if (! is_string($profile->plugin_cc_name) || $profile->plugin_cc_name === '') { + return false; + } + + return $this->normalise($profile->plugin_cc_name) === $normalisedPluginName; + }) + ->values(); + } + + /** + * @return array{plugin: string, status: string, profile: string, match: string, action: string} + */ + private function mapPluginToProfile(string $pluginName, AgentProfile $profile, string $match): array + { + $action = $profile->plugin_cc_name === $pluginName ? 'unchanged' : 'updated'; + + if ($action === 'updated') { + $profile->forceFill([ + 'plugin_cc_name' => $pluginName, + ])->save(); + } + + return [ + 'plugin' => $pluginName, + 'status' => 'mapped', + 'profile' => $profile->name, + 'match' => $match, + 'action' => $action, + ]; + } + + /** + * @return array{plugin: string, status: string, profile: string, match: string, action: string} + */ + private function unmappedRow(string $pluginName, string $reason): array + { + return [ + 'plugin' => $pluginName, + 'status' => 'unmapped', + 'profile' => '-', + 'match' => '-', + 'action' => $reason, + ]; + } + + private function pluginsPath(): string + { + $home = getenv('HOME'); + + if (! is_string($home) || $home === '') { + $home = $_SERVER['HOME'] ?? $_ENV['HOME'] ?? ''; + } + + return rtrim((string) $home, '/').'/.claude/plugins'; + } + + private function normalise(string $value): string + { + return strtolower(trim($value)); + } +} diff --git a/php/Migrations/2026_04_25_000002_add_plugin_cc_name_to_agent_profiles.php b/php/Migrations/2026_04_25_000002_add_plugin_cc_name_to_agent_profiles.php new file mode 100644 index 0000000..b43824e --- /dev/null +++ b/php/Migrations/2026_04_25_000002_add_plugin_cc_name_to_agent_profiles.php @@ -0,0 +1,42 @@ +string('plugin_cc_name')->nullable()->after('name'); + }); + } + + public function down(): void + { + if (! Schema::hasTable('agent_profiles')) { + return; + } + + if (! Schema::hasColumn('agent_profiles', 'plugin_cc_name')) { + return; + } + + Schema::table('agent_profiles', function (Blueprint $table): void { + $table->dropColumn('plugin_cc_name'); + }); + } +}; diff --git a/php/Models/AgentProfile.php b/php/Models/AgentProfile.php index 98be511..1fd713d 100644 --- a/php/Models/AgentProfile.php +++ b/php/Models/AgentProfile.php @@ -13,6 +13,7 @@ class AgentProfile extends Model { protected $fillable = [ 'name', + 'plugin_cc_name', 'gateway_url', 'api_key_cipher', 'cost_class', diff --git a/php/tests/Feature/Console/AgenticSyncPluginsCcCommandTest.php b/php/tests/Feature/Console/AgenticSyncPluginsCcCommandTest.php new file mode 100644 index 0000000..791ecc6 --- /dev/null +++ b/php/tests/Feature/Console/AgenticSyncPluginsCcCommandTest.php @@ -0,0 +1,118 @@ + 'base64:'.base64_encode(random_bytes(32))]); + } + + $this->originalHome = getenv('HOME') ?: null; + $this->temporaryHome = sys_get_temp_dir().'/agentic-sync-plugins-cc-'.uniqid('', true); + + File::ensureDirectoryExists($this->temporaryHome); + + putenv("HOME={$this->temporaryHome}"); + $_SERVER['HOME'] = $this->temporaryHome; + $_ENV['HOME'] = $this->temporaryHome; + + $this->app->make(Kernel::class)->registerCommand( + $this->app->make(AgenticSyncPluginsCcCommand::class), + ); +}); + +afterEach(function (): void { + if (is_string($this->originalHome) && $this->originalHome !== '') { + putenv("HOME={$this->originalHome}"); + $_SERVER['HOME'] = $this->originalHome; + $_ENV['HOME'] = $this->originalHome; + } else { + putenv('HOME'); + unset($_SERVER['HOME'], $_ENV['HOME']); + } + + if (isset($this->temporaryHome) && is_string($this->temporaryHome) && File::isDirectory($this->temporaryHome)) { + File::deleteDirectory($this->temporaryHome); + } +}); + +function agenticSyncPluginsCcProfile(array $attributes = []): AgentProfile +{ + return AgentProfile::create(array_merge([ + 'name' => $attributes['name'] ?? 'dispatch-profile', + 'plugin_cc_name' => $attributes['plugin_cc_name'] ?? null, + 'gateway_url' => 'https://gateway.example.com', + 'api_key_cipher' => 'plain-secret', + 'cost_class' => 'A', + 'capability_tags' => ['dispatch'], + 'quota_headroom_pct' => 100, + 'enabled' => true, + ], $attributes)); +} + +function createClaudePluginDirectory(string $home, string $pluginName): void +{ + $pluginPath = $home.'/.claude/plugins/'.$pluginName; + + File::ensureDirectoryExists($pluginPath); + File::put( + $pluginPath.'/plugin.json', + json_encode(['name' => $pluginName], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: '{}', + ); +} + +test('AgenticSyncPluginsCcCommand_handle_Good_maps_plugins_to_enabled_profiles_and_reports_unmapped_entries', function (): void { + $nameMatchedProfile = agenticSyncPluginsCcProfile([ + 'name' => 'claude-ops', + ]); + $aliasMatchedProfile = agenticSyncPluginsCcProfile([ + 'name' => 'dispatch-fleet', + 'plugin_cc_name' => 'legacy-bridge', + ]); + $disabledProfile = agenticSyncPluginsCcProfile([ + 'name' => 'disabled-plugin', + 'enabled' => false, + ]); + $unmatchedProfile = agenticSyncPluginsCcProfile([ + 'name' => 'spare-profile', + ]); + + createClaudePluginDirectory($this->temporaryHome, 'claude-ops'); + createClaudePluginDirectory($this->temporaryHome, 'disabled-plugin'); + createClaudePluginDirectory($this->temporaryHome, 'legacy-bridge'); + createClaudePluginDirectory($this->temporaryHome, 'orphan-plugin'); + + File::ensureDirectoryExists($this->temporaryHome.'/.claude/plugins/cache'); + File::ensureDirectoryExists($this->temporaryHome.'/.claude/plugins/data'); + + $this->artisan('agentic:sync-plugins-cc') + ->expectsTable( + ['Plugin', 'Status', 'Profile', 'Match', 'Action'], + [ + ['claude-ops', 'mapped', 'claude-ops', 'name', 'updated'], + ['disabled-plugin', 'unmapped', '-', '-', 'no enabled profile'], + ['legacy-bridge', 'mapped', 'dispatch-fleet', 'plugin_cc_name', 'unchanged'], + ['orphan-plugin', 'unmapped', '-', '-', 'no enabled profile'], + ], + ) + ->expectsOutput('Mapped 2 Claude Code plugin(s); 2 unmapped.') + ->assertSuccessful(); + + expect($nameMatchedProfile->fresh()->plugin_cc_name)->toBe('claude-ops') + ->and($aliasMatchedProfile->fresh()->plugin_cc_name)->toBe('legacy-bridge') + ->and($disabledProfile->fresh()->plugin_cc_name)->toBeNull() + ->and($unmatchedProfile->fresh()->plugin_cc_name)->toBeNull(); +}); + +test('AgenticSyncPluginsCcCommand_handle_Ugly_reports_when_no_plugin_directories_exist', function (): void { + $this->artisan('agentic:sync-plugins-cc') + ->expectsOutput("No Claude Code plugin directories found in {$this->temporaryHome}/.claude/plugins.") + ->assertSuccessful(); +});