from __future__ import annotations import logging from dataclasses import dataclass from pathlib import Path from yuxi.channel.domain.model.plugin_registry.plugin_source import PluginOrigin, PluginSource from yuxi.channel.infrastructure.plugin.manifest_parser import ManifestParser logger = logging.getLogger(__name__) @dataclass class PluginCandidate: plugin_id: str source: PluginSource manifest_path: str | None = None manifest_data: dict | None = None class PluginDiscovery: MANIFEST_FILENAME = "yuxi.plugin.yaml" def __init__(self, manifest_parser: ManifestParser | None = None): self._parser = manifest_parser or ManifestParser() async def discover_workspace(self, base_dir: str = "plugins") -> list[PluginCandidate]: return await self._scan_directory(base_dir, PluginOrigin.WORKSPACE) async def discover_installed(self, base_dir: str = "installed/plugins") -> list[PluginCandidate]: return await self._scan_directory(base_dir, PluginOrigin.INSTALLED) async def discover_all(self) -> list[PluginCandidate]: candidates = [] candidates.extend(await self.discover_workspace()) candidates.extend(await self.discover_installed()) return candidates async def find(self, plugin_id: str) -> PluginCandidate | None: all_candidates = await self.discover_all() for candidate in all_candidates: if candidate.plugin_id == plugin_id: return candidate return None async def _scan_directory(self, base_dir: str, origin: PluginOrigin) -> list[PluginCandidate]: candidates = [] base_path = Path(base_dir) if not base_path.exists(): return candidates for plugin_dir in sorted(base_path.iterdir()): if not plugin_dir.is_dir(): continue if plugin_dir.name.startswith("_") or plugin_dir.name.startswith("."): continue manifest_path = plugin_dir / self.MANIFEST_FILENAME if not manifest_path.exists(): logger.debug("skipping %s: no %s found", plugin_dir, self.MANIFEST_FILENAME) continue try: result = await self._parser.parse_file(str(manifest_path)) manifest = result.manifest source = PluginSource( origin=origin, root_dir=str(plugin_dir), entry_file=str(plugin_dir / manifest.entry) if manifest.entry else "", ) candidates.append( PluginCandidate( plugin_id=manifest.id, source=source, manifest_path=str(manifest_path), manifest_data=result.raw, ) ) except Exception as exc: logger.warning("failed to parse manifest in %s: %s", plugin_dir, exc) return candidates