# Story 5.12: Plugin Marketplace Client

## Status: Cancelled

## Priority: Low

## Estimated Effort: Large (5-7 days)

## Dependencies: Story 5.10 (dependencies), Story 5.11 (hot-reload)

## Epic: Plugin Ecosystem

---

## Cancellation Notice

**Cancelled:** 2026-01-16

**Reason:** Strategic decision to remove marketplace from fapilog core. A plugin marketplace is premature infrastructure for the current ecosystem size. PyPI + entry points provide sufficient discovery and installation. See Story 6.12 for details.

**Alternative:** Use PyPI search for `fapilog-*` packages and standard `pip install`.

---

## Original Context

A plugin marketplace enables:

- Discoverability of community plugins
- Verified/trusted plugin distribution
- Version management and updates
- Usage analytics for plugin authors

The codebase already has a `marketplace.py` stub. This story implements a functional client.

---

## Problem Statement

1. No central discovery mechanism for fapilog plugins
2. Users must manually find and install plugins
3. No way to verify plugin authenticity
4. No update notifications for installed plugins

---

## Acceptance Criteria

### AC1: Plugin Discovery

- [ ] `fapilog plugins search <query>` - Search marketplace
- [ ] `fapilog plugins list` - List available plugins
- [ ] `fapilog plugins info <name>` - Show plugin details
- [ ] Filter by type, author, rating

### AC2: Plugin Installation

- [ ] `fapilog plugins install <name>` - Install from marketplace
- [ ] `fapilog plugins install <name>==<version>` - Specific version
- [ ] Install validates compatibility before proceeding
- [ ] Install checks dependencies

### AC3: Plugin Updates

- [ ] `fapilog plugins update` - Update all plugins
- [ ] `fapilog plugins update <name>` - Update specific plugin
- [ ] `fapilog plugins outdated` - Show outdated plugins
- [ ] Optional auto-update checking

### AC4: Plugin Publishing (Author Tools)

- [ ] `fapilog plugins publish` - Publish to marketplace
- [ ] Validation before publishing
- [ ] Version bump assistance
- [ ] README/docs extraction

### AC5: Marketplace API

- [ ] REST API client for marketplace
- [ ] Caching of metadata
- [ ] Offline fallback to local cache
- [ ] Rate limiting respect

---

## Technical Design

### 1. Marketplace Client

```python
# src/fapilog/core/marketplace.py
"""Plugin marketplace client."""

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any

import httpx


@dataclass
class MarketplacePlugin:
    """Plugin information from marketplace."""

    name: str
    version: str
    description: str
    author: str
    plugin_type: str
    downloads: int = 0
    rating: float = 0.0
    homepage: str | None = None
    repository: str | None = None
    license: str = "Unknown"
    fapilog_compatibility: str = ">=3.0.0"
    dependencies: list[str] = field(default_factory=list)
    published_at: datetime | None = None
    checksum: str | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> MarketplacePlugin:
        return cls(
            name=data["name"],
            version=data["version"],
            description=data.get("description", ""),
            author=data.get("author", "Unknown"),
            plugin_type=data.get("plugin_type", "unknown"),
            downloads=data.get("downloads", 0),
            rating=data.get("rating", 0.0),
            homepage=data.get("homepage"),
            repository=data.get("repository"),
            license=data.get("license", "Unknown"),
            fapilog_compatibility=data.get("fapilog_compatibility", ">=3.0.0"),
            dependencies=data.get("dependencies", []),
            published_at=datetime.fromisoformat(data["published_at"])
            if data.get("published_at")
            else None,
            checksum=data.get("checksum"),
        )


@dataclass
class SearchResult:
    """Search results from marketplace."""

    plugins: list[MarketplacePlugin]
    total: int
    page: int
    per_page: int


class MarketplaceClient:
    """Client for the fapilog plugin marketplace."""

    DEFAULT_URL = "https://marketplace.fapilog.dev/api/v1"

    def __init__(
        self,
        *,
        base_url: str | None = None,
        api_key: str | None = None,
        cache_dir: Path | None = None,
        timeout: float = 30.0,
    ) -> None:
        self._base_url = base_url or self.DEFAULT_URL
        self._api_key = api_key
        self._cache_dir = cache_dir or Path.home() / ".fapilog" / "marketplace_cache"
        self._timeout = timeout
        self._client: httpx.AsyncClient | None = None

    async def __aenter__(self) -> MarketplaceClient:
        await self.start()
        return self

    async def __aexit__(self, *args: Any) -> None:
        await self.stop()

    async def start(self) -> None:
        headers = {"User-Agent": f"fapilog-marketplace/{self._get_version()}"}
        if self._api_key:
            headers["Authorization"] = f"Bearer {self._api_key}"

        self._client = httpx.AsyncClient(
            base_url=self._base_url,
            headers=headers,
            timeout=self._timeout,
        )

        self._cache_dir.mkdir(parents=True, exist_ok=True)

    async def stop(self) -> None:
        if self._client:
            await self._client.aclose()

    async def search(
        self,
        query: str,
        *,
        plugin_type: str | None = None,
        page: int = 1,
        per_page: int = 20,
    ) -> SearchResult:
        """Search for plugins."""
        params = {
            "q": query,
            "page": page,
            "per_page": per_page,
        }
        if plugin_type:
            params["type"] = plugin_type

        response = await self._request("GET", "/plugins/search", params=params)

        return SearchResult(
            plugins=[MarketplacePlugin.from_dict(p) for p in response["plugins"]],
            total=response["total"],
            page=response["page"],
            per_page=response["per_page"],
        )

    async def get_plugin(self, name: str) -> MarketplacePlugin | None:
        """Get plugin details by name."""
        try:
            response = await self._request("GET", f"/plugins/{name}")
            return MarketplacePlugin.from_dict(response)
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                return None
            raise

    async def get_versions(self, name: str) -> list[str]:
        """Get available versions for a plugin."""
        response = await self._request("GET", f"/plugins/{name}/versions")
        return response["versions"]

    async def download_url(self, name: str, version: str) -> str:
        """Get download URL for a specific version."""
        response = await self._request(
            "GET",
            f"/plugins/{name}/versions/{version}/download",
        )
        return response["url"]

    async def check_compatibility(self, name: str, version: str) -> bool:
        """Check if plugin version is compatible with current fapilog."""
        plugin = await self.get_plugin(name)
        if not plugin:
            return False

        from packaging.specifiers import SpecifierSet
        from packaging.version import Version

        try:
            fapilog_version = Version(self._get_version())
            spec = SpecifierSet(plugin.fapilog_compatibility)
            return fapilog_version in spec
        except Exception:
            return True  # Permissive on parse errors

    async def list_installed(self) -> list[tuple[str, str]]:
        """List installed marketplace plugins.

        Returns list of (name, version) tuples.
        """
        installed_file = self._cache_dir / "installed.json"
        if not installed_file.exists():
            return []

        try:
            data = json.loads(installed_file.read_text())
            return [(p["name"], p["version"]) for p in data.get("plugins", [])]
        except Exception:
            return []

    async def check_updates(self) -> list[tuple[str, str, str]]:
        """Check for plugin updates.

        Returns list of (name, installed_version, latest_version) tuples.
        """
        installed = await self.list_installed()
        updates = []

        for name, current in installed:
            plugin = await self.get_plugin(name)
            if plugin and plugin.version != current:
                updates.append((name, current, plugin.version))

        return updates

    async def _request(
        self,
        method: str,
        path: str,
        **kwargs: Any,
    ) -> dict[str, Any]:
        """Make API request."""
        if self._client is None:
            raise RuntimeError("Client not started")

        response = await self._client.request(method, path, **kwargs)
        response.raise_for_status()
        return response.json()

    def _get_version(self) -> str:
        """Get fapilog version."""
        try:
            from fapilog import __version__
            return __version__
        except Exception:
            return "0.0.0"
```

### 2. Plugin Installer

```python
# src/fapilog/plugins/installer.py
"""Plugin installation from marketplace."""

from __future__ import annotations

import subprocess
import sys
from dataclasses import dataclass
from typing import Any

from ..core.marketplace import MarketplaceClient, MarketplacePlugin


@dataclass
class InstallResult:
    """Result of plugin installation."""

    success: bool
    plugin_name: str
    version: str
    error: str | None = None


class PluginInstaller:
    """Install plugins from marketplace."""

    def __init__(self, client: MarketplaceClient) -> None:
        self._client = client

    async def install(
        self,
        name: str,
        version: str | None = None,
        *,
        upgrade: bool = False,
    ) -> InstallResult:
        """Install a plugin from marketplace."""

        # Get plugin info
        plugin = await self._client.get_plugin(name)
        if plugin is None:
            return InstallResult(
                success=False,
                plugin_name=name,
                version="",
                error="Plugin not found in marketplace",
            )

        target_version = version or plugin.version

        # Check compatibility
        if not await self._client.check_compatibility(name, target_version):
            return InstallResult(
                success=False,
                plugin_name=name,
                version=target_version,
                error="Plugin not compatible with current fapilog version",
            )

        # Install via pip
        try:
            package_spec = f"fapilog-{name}=={target_version}"
            args = [sys.executable, "-m", "pip", "install"]

            if upgrade:
                args.append("--upgrade")

            args.append(package_spec)

            result = subprocess.run(
                args,
                capture_output=True,
                text=True,
            )

            if result.returncode != 0:
                return InstallResult(
                    success=False,
                    plugin_name=name,
                    version=target_version,
                    error=result.stderr,
                )

            # Record installation
            await self._record_installation(name, target_version)

            return InstallResult(
                success=True,
                plugin_name=name,
                version=target_version,
            )

        except Exception as exc:
            return InstallResult(
                success=False,
                plugin_name=name,
                version=target_version,
                error=str(exc),
            )

    async def _record_installation(self, name: str, version: str) -> None:
        """Record that a plugin was installed."""
        import json

        cache_dir = self._client._cache_dir
        installed_file = cache_dir / "installed.json"

        try:
            if installed_file.exists():
                data = json.loads(installed_file.read_text())
            else:
                data = {"plugins": []}

            # Update or add
            found = False
            for p in data["plugins"]:
                if p["name"] == name:
                    p["version"] = version
                    found = True
                    break

            if not found:
                data["plugins"].append({"name": name, "version": version})

            installed_file.write_text(json.dumps(data, indent=2))

        except Exception:
            pass
```

### 3. CLI Commands

```python
# src/fapilog/cli/plugins.py
"""CLI commands for plugin management."""

import asyncio
import click

from ..core.marketplace import MarketplaceClient
from ..plugins.installer import PluginInstaller


@click.group()
def plugins():
    """Manage fapilog plugins."""
    pass


@plugins.command()
@click.argument("query")
@click.option("--type", "plugin_type", help="Filter by plugin type")
def search(query: str, plugin_type: str | None):
    """Search for plugins in the marketplace."""

    async def _search():
        async with MarketplaceClient() as client:
            result = await client.search(query, plugin_type=plugin_type)

            if not result.plugins:
                click.echo("No plugins found.")
                return

            for plugin in result.plugins:
                click.echo(f"{plugin.name} ({plugin.version})")
                click.echo(f"  {plugin.description}")
                click.echo(f"  Type: {plugin.plugin_type}")
                click.echo(f"  Downloads: {plugin.downloads}")
                click.echo()

    asyncio.run(_search())


@plugins.command()
@click.argument("name")
@click.option("--version", help="Specific version to install")
@click.option("--upgrade", is_flag=True, help="Upgrade if already installed")
def install(name: str, version: str | None, upgrade: bool):
    """Install a plugin from the marketplace."""

    async def _install():
        async with MarketplaceClient() as client:
            installer = PluginInstaller(client)
            result = await installer.install(name, version, upgrade=upgrade)

            if result.success:
                click.echo(f"✓ Installed {result.plugin_name} {result.version}")
            else:
                click.echo(f"✗ Failed to install: {result.error}", err=True)

    asyncio.run(_install())


@plugins.command()
def outdated():
    """Show plugins with available updates."""

    async def _outdated():
        async with MarketplaceClient() as client:
            updates = await client.check_updates()

            if not updates:
                click.echo("All plugins are up to date.")
                return

            for name, current, latest in updates:
                click.echo(f"{name}: {current} → {latest}")

    asyncio.run(_outdated())
```

---

## Test Plan

### Unit Tests

1. **test_marketplace_client.py**

   - Test API methods with mock responses
   - Test caching behavior
   - Test error handling

2. **test_plugin_installer.py**
   - Test install flow
   - Test compatibility checking
   - Test version specification

### Integration Tests (with mock server)

1. **test_marketplace_integration.py**
   - Test full search/install flow
   - Test update checking

---

## Documentation Updates

1. Create `docs/plugins/marketplace.md`
2. Add CLI reference for plugin commands
3. Create publishing guide for plugin authors

---

## Future Considerations

- Signed/verified plugins
- Organization namespaces
- Private marketplace support
- Usage analytics

---

## Related Stories

- Story 5.10: Plugin dependencies (verified during install)
- Story 5.11: Hot-reload (reload from marketplace)

