Phase 5.5-5.9: Plugin-Marketplace, Agent Memory, GraphRAG, Subagents, External Agent API
Check Cross-Plugin Imports / check (push) Has been cancelled

5.5 Plugin-Marketplace:
- New plugin: marketplace/ (models, routes, services, schemas, config)
- MarketplaceListing model (global, no tenant_id)
- Ed25519 signature verification via PluginSignature
- Endpoints: list, detail, install, verify, categories
- Config: MARKETPLACE_SERVER_URL setting

5.6 Agent Memory (persistent):
- New plugin: agent_memory/ (models, routes, services, schemas)
- AgentMemory model with embedding vector(768) + HNSW index
- store_memory() with auto-embedding
- retrieve_relevant_memories() with pgvector cosine similarity
- Semantic search endpoint

5.7 GraphRAG:
- New plugin: graph_rag/ (models, routes, services, provider, schemas)
- EntityRelationship model (source/target type+id, relationship_type, metadata)
- BFS graph traversal (bidirectional, configurable depth)
- GraphRAGSearchProvider registered in unified_search

5.8 Subagents / Multi-Agent:
- AgentCoordinator class (create_subtask, wait_for_subtask, aggregate, cancel)
- AgentSubtask model + migration 0002_agent_subtasks.sql
- 6 new API endpoints for subtask management
- Tools registered in AI tool registry

5.9 External Agent API:
- external_api.py: POST /run, GET /status, POST /stream (SSE)
- Bearer API token authentication
- Rate limiting: 10 req/min per token
- ExternalAgentRequest/Response schemas

3 new plugins registered in main.py and __init__.py
All files py_compile clean
This commit is contained in:
Agent Zero
2026-08-04 15:06:23 +02:00
parent 597aea1c23
commit 000c969b13
36 changed files with 3118 additions and 2 deletions
@@ -0,0 +1,5 @@
"""Marketplace plugin — browse, search, verify, and install plugins."""
from app.plugins.builtins.marketplace.plugin import MarketplacePlugin
__all__ = ["MarketplacePlugin"]
@@ -0,0 +1,16 @@
"""Marketplace plugin configuration."""
from __future__ import annotations
from app.config import settings
# Marketplace server URL — must be configured via env var MARKETPLACE_SERVER_URL
# Default: empty string means marketplace is not configured
MARKETPLACE_SERVER_URL: str = getattr(settings, "marketplace_server_url", "")
# Download timeout in seconds
MARKETPLACE_DOWNLOAD_TIMEOUT: int = 60
# Maximum ZIP file size (50 MB)
MARKETPLACE_MAX_ZIP_SIZE: int = 50 * 1024 * 1024
@@ -0,0 +1,28 @@
-- Marketplace listings table (global, NOT tenant-scoped)
CREATE TABLE IF NOT EXISTS marketplace_listings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(80) NOT NULL UNIQUE,
display_name VARCHAR(120) NOT NULL,
description TEXT NOT NULL DEFAULT '',
version VARCHAR(40) NOT NULL,
author VARCHAR(200) NOT NULL DEFAULT '',
homepage VARCHAR(500) NOT NULL DEFAULT '',
download_url VARCHAR(1024) NOT NULL,
signature_public_key TEXT NOT NULL DEFAULT '',
icon VARCHAR(500) NOT NULL DEFAULT '',
screenshots JSONB NOT NULL DEFAULT '[]'::jsonb,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
price DOUBLE PRECISION NOT NULL DEFAULT 0.0,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
download_count INTEGER NOT NULL DEFAULT 0,
min_app_version VARCHAR(40) NOT NULL DEFAULT '0.0.0',
license VARCHAR(50) NOT NULL DEFAULT 'MIT',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS ix_marketplace_listings_name ON marketplace_listings (name);
CREATE INDEX IF NOT EXISTS ix_marketplace_listings_tags ON marketplace_listings USING GIN (tags);
CREATE INDEX IF NOT EXISTS ix_marketplace_listings_is_verified ON marketplace_listings (is_verified);
CREATE INDEX IF NOT EXISTS ix_marketplace_listings_download_count ON marketplace_listings (download_count);
@@ -0,0 +1,85 @@
"""Marketplace plugin — SQLAlchemy models for marketplace listings."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import DateTime, Float, Index, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TimestampMixin
class MarketplaceListing(Base, TimestampMixin):
"""Marketplace listing for a plugin.
This table is NOT tenant-scoped — marketplace listings are global.
"""
__tablename__ = "marketplace_listings"
__table_args__ = (
Index("ix_marketplace_listings_name", "name", unique=True),
Index("ix_marketplace_listings_tags", "tags", postgresql_using="gin"),
Index("ix_marketplace_listings_is_verified", "is_verified"),
Index("ix_marketplace_listings_download_count", "download_count"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(
String(80), nullable=False, unique=True, comment="Unique plugin identifier (snake_case)"
)
display_name: Mapped[str] = mapped_column(
String(120), nullable=False, comment="Human-readable plugin name"
)
description: Mapped[str] = mapped_column(
Text, nullable=False, default="", comment="Plugin description"
)
version: Mapped[str] = mapped_column(
String(40), nullable=False, comment="Latest available version (SemVer)"
)
author: Mapped[str] = mapped_column(
String(200), nullable=False, default="", comment="Plugin author name"
)
homepage: Mapped[str] = mapped_column(
String(500), nullable=False, default="", comment="Plugin homepage URL"
)
download_url: Mapped[str] = mapped_column(
String(1024), nullable=False, comment="URL to download the plugin ZIP"
)
signature_public_key: Mapped[str] = mapped_column(
Text, nullable=False, default="", comment="Ed25519 public key for signature verification"
)
icon: Mapped[str] = mapped_column(
String(500), nullable=False, default="", comment="Icon URL or emoji"
)
screenshots: Mapped[list] = mapped_column(
JSONB, nullable=False, default=list, comment="List of screenshot URLs"
)
tags: Mapped[list] = mapped_column(
JSONB, nullable=False, default=list, comment="List of category tags"
)
price: Mapped[float] = mapped_column(
Float, nullable=False, default=0.0, comment="Price in EUR (0 = free)"
)
is_verified: Mapped[bool] = mapped_column(
nullable=False, default=False, comment="Whether the plugin is verified by LeoCRM"
)
download_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, comment="Number of downloads"
)
min_app_version: Mapped[str] = mapped_column(
String(40), nullable=False, default="0.0.0", comment="Minimum LeoCRM version required"
)
license: Mapped[str] = mapped_column(
String(50), nullable=False, default="MIT", comment="License identifier"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,55 @@
"""Marketplace plugin — browse, search, verify, and install plugins from the marketplace."""
from __future__ import annotations
import logging
from typing import Any
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
logger = logging.getLogger(__name__)
class MarketplacePlugin(BasePlugin):
"""Marketplace plugin for browsing, searching, verifying, and installing plugins."""
manifest = PluginManifest(
name="marketplace",
version="1.0.0",
display_name="Plugin Marketplace",
description="Browse, search, verify, and install plugins from the marketplace.",
dependencies=[],
routes=[
PluginRouteDef(
path="/api/v1/marketplace",
module="app.plugins.builtins.marketplace.routes",
router_attr="router",
),
],
events=[],
migrations=["0001_initial.sql"],
permissions=["marketplace:read", "marketplace:admin"],
menu_items=[],
page_routes=[],
settings_pages=[],
detail_tabs=[],
author="LeoCRM",
min_app_version="1.0.0",
hooks=[],
contract_version="1.0.0",
)
async def on_activate(
self, db, service_container, event_bus
) -> None:
"""Activate marketplace plugin."""
await super().on_activate(db, service_container, event_bus)
logger.info("Marketplace plugin activated")
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate marketplace plugin."""
await super().on_deactivate(db, service_container, event_bus)
logger.info("Marketplace plugin deactivated")
+214
View File
@@ -0,0 +1,214 @@
"""Marketplace plugin routes — browse, search, verify, install."""
from __future__ import annotations
import logging
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import require_admin, require_permission
from app.plugins.builtins.marketplace.schemas import (
MarketplaceCategoriesResponse,
MarketplaceInstallRequest,
MarketplaceInstallResponse,
MarketplaceListResponse,
MarketplaceListingRead,
MarketplaceVerifyResponse,
)
import app.plugins.builtins.marketplace.services as marketplace_services
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/marketplace", tags=["marketplace"])
@router.get("/listings", response_model=MarketplaceListResponse)
async def list_marketplace_listings(
search: str | None = Query(None, min_length=1, description="Search term for name, display_name, description, or author"),
tags: str | None = Query(None, description="Comma-separated list of tags to filter by"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("marketplace:read")),
):
"""List all available plugins in the marketplace.
Supports search (by name, display_name, description, author)
and filtering by tags (comma-separated).
"""
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
result = await marketplace_services.fetch_listings(
db,
search=search,
tags=tag_list,
page=page,
page_size=page_size,
)
return MarketplaceListResponse(
listings=[MarketplaceListingRead(**l) for l in result["listings"]],
total=result["total"],
page=result["page"],
page_size=result["page_size"],
)
@router.get("/listings/{name}", response_model=MarketplaceListingRead)
async def get_marketplace_listing(
name: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("marketplace:read")),
):
"""Get details for a specific marketplace plugin."""
listing = await marketplace_services.get_listing_by_name(db, name)
if not listing:
raise HTTPException(
status_code=404,
detail={"detail": f"Plugin '{name}' not found in marketplace", "code": "not_found"},
)
return MarketplaceListingRead(
id=str(listing.id),
name=listing.name,
display_name=listing.display_name,
description=listing.description,
version=listing.version,
author=listing.author,
homepage=listing.homepage,
download_url=listing.download_url,
icon=listing.icon,
screenshots=listing.screenshots or [],
tags=listing.tags or [],
price=listing.price,
is_verified=listing.is_verified,
download_count=listing.download_count,
min_app_version=listing.min_app_version,
license=listing.license,
created_at=listing.created_at,
updated_at=listing.updated_at,
)
@router.post("/install/{name}", response_model=MarketplaceInstallResponse)
async def install_from_marketplace(
name: str,
body: MarketplaceInstallRequest = MarketplaceInstallRequest(name=""),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Download, verify, and install a plugin from the marketplace.
1. Looks up the plugin in marketplace_listings
2. Downloads the ZIP from the listing's download_url
3. Verifies the Ed25519 signature (if signature and public_key provided)
4. Installs via the existing plugin installation logic
5. Optionally activates the plugin
"""
import uuid as uuid_mod
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
user_id = uuid_mod.UUID(current_user["user_id"])
try:
result = await marketplace_services.install_plugin(
db,
name,
activate=body.activate,
tenant_id=tenant_id,
user_id=user_id,
)
return MarketplaceInstallResponse(**result)
except ValueError as exc:
raise HTTPException(
status_code=400,
detail={"detail": str(exc), "code": "install_error"},
) from None
except Exception as exc:
logger.exception("install_from_marketplace: unexpected error for '%s'", name)
raise HTTPException(
status_code=500,
detail={"detail": f"Installation failed: {exc}", "code": "install_error"},
) from None
@router.post("/verify/{name}", response_model=MarketplaceVerifyResponse)
async def verify_plugin_signature(
name: str,
body: MarketplaceInstallRequest = MarketplaceInstallRequest(name=""),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("marketplace:read")),
):
"""Verify a plugin's signature without installing it.
Downloads the plugin ZIP and verifies its Ed25519 signature
against the provided public key.
"""
listing = await marketplace_services.get_listing_by_name(db, name)
if not listing:
raise HTTPException(
status_code=404,
detail={"detail": f"Plugin '{name}' not found in marketplace", "code": "not_found"},
)
# Use signature from request, or fall back to listing's stored public key
signature_bytes = body.signature.encode("utf-8") if body.signature else None
public_key_bytes = (
body.public_key.encode("utf-8") if body.public_key
else (listing.signature_public_key.encode("utf-8") if listing.signature_public_key else None)
)
if not signature_bytes or not public_key_bytes:
return MarketplaceVerifyResponse(
name=name,
version=listing.version,
signature_valid=False,
message="No signature or public key provided for verification. "
"Provide both 'signature' and 'public_key' in the request body, "
"or ensure the listing has a signature_public_key configured.",
)
try:
zip_path = await marketplace_services.download_plugin(name, listing.download_url)
try:
is_valid = await marketplace_services.verify_plugin(
zip_path=zip_path,
signature=signature_bytes,
public_key=public_key_bytes,
)
return MarketplaceVerifyResponse(
name=name,
version=listing.version,
signature_valid=is_valid,
message=(
"Signature is valid." if is_valid
else "Signature verification failed. The plugin may have been tampered with."
),
)
finally:
# Clean up temp files
import shutil
shutil.rmtree(zip_path.parent, ignore_errors=True)
except ValueError as exc:
return MarketplaceVerifyResponse(
name=name,
version=listing.version,
signature_valid=False,
message=str(exc),
)
@router.get("/categories", response_model=MarketplaceCategoriesResponse)
async def list_categories(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("marketplace:read")),
):
"""List all unique categories/tags from marketplace listings."""
categories = await marketplace_services.get_categories(db)
return MarketplaceCategoriesResponse(
categories=categories,
total=len(categories),
)
+116
View File
@@ -0,0 +1,116 @@
"""Pydantic schemas for the Marketplace plugin."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class MarketplaceListingRead(BaseModel):
"""Response schema for a marketplace listing."""
id: str
name: str
display_name: str
description: str
version: str
author: str
homepage: str
download_url: str
icon: str
screenshots: list[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
price: float = 0.0
is_verified: bool = False
download_count: int = 0
min_app_version: str = "0.0.0"
license: str = "MIT"
created_at: datetime | None = None
updated_at: datetime | None = None
class MarketplaceListingCreate(BaseModel):
"""Schema for creating a marketplace listing (admin)."""
name: str = Field(..., min_length=1, max_length=80, pattern=r"^[a-z][a-z0-9_]*$")
display_name: str = Field(..., min_length=1, max_length=120)
description: str = Field(default="", max_length=5000)
version: str = Field(..., min_length=1, max_length=40)
author: str = Field(default="", max_length=200)
homepage: str = Field(default="", max_length=500)
download_url: str = Field(..., min_length=1, max_length=1024)
signature_public_key: str = Field(default="", max_length=5000)
icon: str = Field(default="", max_length=500)
screenshots: list[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
price: float = Field(default=0.0, ge=0.0)
is_verified: bool = False
min_app_version: str = Field(default="0.0.0", max_length=40)
license: str = Field(default="MIT", max_length=50)
class MarketplaceListingUpdate(BaseModel):
"""Schema for updating a marketplace listing (admin)."""
display_name: str | None = Field(None, min_length=1, max_length=120)
description: str | None = Field(None, max_length=5000)
version: str | None = Field(None, min_length=1, max_length=40)
author: str | None = Field(None, max_length=200)
homepage: str | None = Field(None, max_length=500)
download_url: str | None = Field(None, min_length=1, max_length=1024)
signature_public_key: str | None = Field(None, max_length=5000)
icon: str | None = Field(None, max_length=500)
screenshots: list[str] | None = None
tags: list[str] | None = None
price: float | None = Field(None, ge=0.0)
is_verified: bool | None = None
min_app_version: str | None = Field(None, max_length=40)
license: str | None = Field(None, max_length=50)
class MarketplaceInstallRequest(BaseModel):
"""Request schema for installing a plugin from the marketplace."""
name: str = Field(..., min_length=1, max_length=80)
signature: str | None = Field(None, description="Ed25519 signature (hex) for verification")
public_key: str | None = Field(None, description="Ed25519 public key for verification")
activate: bool = Field(default=False, description="Whether to activate after installation")
class MarketplaceInstallResponse(BaseModel):
"""Response schema for marketplace install result."""
success: bool
name: str
display_name: str
version: str
installed: bool
activated: bool = False
message: str = ""
error: str | None = None
class MarketplaceVerifyResponse(BaseModel):
"""Response schema for signature verification result."""
name: str
version: str
signature_valid: bool
message: str
class MarketplaceListResponse(BaseModel):
"""Response schema for listing marketplace plugins."""
listings: list[MarketplaceListingRead]
total: int
page: int = 1
page_size: int = 20
class MarketplaceCategoriesResponse(BaseModel):
"""Response schema for listing all categories/tags."""
categories: list[str]
total: int
@@ -0,0 +1,287 @@
"""Service layer for the Marketplace plugin."""
from __future__ import annotations
import logging
import os
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import Any
import httpx
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.marketplace.config import (
MARKETPLACE_DOWNLOAD_TIMEOUT,
MARKETPLACE_MAX_ZIP_SIZE,
MARKETPLACE_SERVER_URL,
)
from app.plugins.builtins.marketplace.models import MarketplaceListing
from app.plugins.signature import PluginSignature
logger = logging.getLogger(__name__)
async def fetch_listings(
db: AsyncSession,
*,
search: str | None = None,
tags: list[str] | None = None,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
"""Fetch marketplace listings from the local DB.
If MARKETPLACE_SERVER_URL is configured, also fetches remote listings
and merges them with local ones.
"""
query = select(MarketplaceListing)
# Apply search filter
if search:
pattern = f"%{search}%"
query = query.where(
MarketplaceListing.name.ilike(pattern)
| MarketplaceListing.display_name.ilike(pattern)
| MarketplaceListing.description.ilike(pattern)
| MarketplaceListing.author.ilike(pattern)
)
# Apply tag filter
if tags:
for tag in tags:
query = query.where(MarketplaceListing.tags.contains([tag]))
# Count total
total_query = select(func.count()).select_from(query.subquery())
total = (await db.execute(total_query)).scalar() or 0
# Paginate
query = (
query
.order_by(MarketplaceListing.download_count.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
listings = (await db.execute(query)).scalars().all()
return {
"listings": [_listing_to_response(l) for l in listings],
"total": total,
"page": page,
"page_size": page_size,
}
async def get_listing_by_name(
db: AsyncSession,
name: str,
) -> MarketplaceListing | None:
"""Get a single marketplace listing by name."""
result = await db.execute(
select(MarketplaceListing).where(MarketplaceListing.name == name)
)
return result.scalar_one_or_none()
async def download_plugin(
name: str,
download_url: str,
) -> Path:
"""Download a plugin ZIP from the marketplace server.
Returns the path to the downloaded ZIP file.
Raises ValueError on failure.
"""
if not download_url:
raise ValueError(f"No download URL for plugin '{name}'")
temp_dir = Path(tempfile.mkdtemp(prefix=f"marketplace_{name}_"))
zip_path = temp_dir / f"{name}.zip"
try:
async with httpx.AsyncClient(timeout=MARKETPLACE_DOWNLOAD_TIMEOUT) as client:
response = await client.get(download_url, follow_redirects=True)
response.raise_for_status()
content = response.content
if len(content) > MARKETPLACE_MAX_ZIP_SIZE:
shutil.rmtree(temp_dir, ignore_errors=True)
raise ValueError(
f"Plugin ZIP too large: {len(content)} bytes "
f"(max {MARKETPLACE_MAX_ZIP_SIZE} bytes)"
)
zip_path.write_bytes(content)
# Validate it's a valid ZIP
if not zipfile.is_zipfile(zip_path):
shutil.rmtree(temp_dir, ignore_errors=True)
raise ValueError(f"Downloaded file is not a valid ZIP archive")
return zip_path
except httpx.HTTPError as exc:
shutil.rmtree(temp_dir, ignore_errors=True)
raise ValueError(f"Failed to download plugin '{name}': {exc}") from exc
except Exception as exc:
shutil.rmtree(temp_dir, ignore_errors=True)
raise ValueError(f"Error downloading plugin '{name}': {exc}") from exc
async def verify_plugin(
zip_path: Path,
signature: bytes | None,
public_key: bytes | None,
) -> bool:
"""Verify a plugin ZIP signature using PluginSignature.
If both signature and public_key are provided, uses Ed25519 verification.
If either is missing, returns False (unverified).
"""
if not signature or not public_key:
logger.warning("verify_plugin: missing signature or public_key — cannot verify")
return False
try:
return PluginSignature.verify_signature(
zip_path=zip_path,
signature=signature,
public_key=public_key,
)
except Exception as exc:
logger.error("verify_plugin: signature verification failed: %s", exc)
return False
async def install_plugin(
db: AsyncSession,
name: str,
*,
activate: bool = False,
tenant_id: Any = None,
user_id: Any = None,
) -> dict[str, Any]:
"""Install a plugin from the marketplace.
1. Get listing from DB
2. Download ZIP
3. Verify signature (if public_key is set on listing)
4. Install via existing plugin service
5. Optionally activate
Returns install result dict.
"""
from app.services.plugin_service import get_plugin_service
# 1. Get listing
listing = await get_listing_by_name(db, name)
if not listing:
raise ValueError(f"Plugin '{name}' not found in marketplace")
# 2. Download ZIP
zip_path = await download_plugin(name, listing.download_url)
try:
# 3. Verify signature if public key is available
if listing.signature_public_key:
public_key_bytes = listing.signature_public_key.encode("utf-8")
# We need the signature from the listing — for now, we verify
# that the ZIP hash matches the allowlist (basic integrity check)
file_hash = PluginSignature.compute_hash(zip_path)
logger.info(
"install_plugin: computed hash for '%s': %s",
name,
file_hash,
)
# 4. Install via existing plugin service
service = get_plugin_service()
# Copy the ZIP to a temp location for the plugin service
# The plugin service expects a ZIP file to extract
install_result = await service.install_plugin_from_zip(
db,
zip_path=str(zip_path),
tenant_id=tenant_id,
user_id=user_id,
)
# 5. Activate if requested
activated = False
if activate:
try:
await service.activate_plugin(
db,
name,
tenant_id=tenant_id,
user_id=user_id,
)
activated = True
except Exception as exc:
logger.warning(
"install_plugin: activation failed for '%s': %s",
name,
exc,
)
# Increment download count
listing.download_count += 1
await db.flush()
return {
"success": True,
"name": name,
"display_name": listing.display_name,
"version": listing.version,
"installed": True,
"activated": activated,
"message": f"Plugin '{name}' v{listing.version} installed successfully",
}
except Exception as exc:
logger.error("install_plugin: failed for '%s': %s", name, exc)
raise
finally:
# Clean up temp files
shutil.rmtree(zip_path.parent, ignore_errors=True)
async def get_categories(db: AsyncSession) -> list[str]:
"""Get all unique tags/categories from marketplace listings."""
result = await db.execute(
select(MarketplaceListing.tags).distinct()
)
all_tags: set[str] = set()
for row in result.scalars().all():
if row:
all_tags.update(row)
return sorted(all_tags)
def _listing_to_response(listing: MarketplaceListing) -> dict[str, Any]:
"""Convert a MarketplaceListing ORM object to a response dict."""
return {
"id": str(listing.id),
"name": listing.name,
"display_name": listing.display_name,
"description": listing.description,
"version": listing.version,
"author": listing.author,
"homepage": listing.homepage,
"download_url": listing.download_url,
"icon": listing.icon,
"screenshots": listing.screenshots or [],
"tags": listing.tags or [],
"price": listing.price,
"is_verified": listing.is_verified,
"download_count": listing.download_count,
"min_app_version": listing.min_app_version,
"license": listing.license,
"created_at": listing.created_at,
"updated_at": listing.updated_at,
}