#!/usr/bin/env python3 """ Agent Marketplace API ===================== REST API for the agent template marketplace. Endpoints: - /marketplace/templates - Browse and search templates - /marketplace/templates/{id} - Get template details - /marketplace/publish - Publish new template - /marketplace/install - Install template to project - /marketplace/ratings - Rate and review templates """ import hashlib import json import os import re import secrets import sqlite3 from datetime import datetime, timezone from pathlib import Path from typing import Optional, List from contextlib import contextmanager from fastapi import APIRouter, HTTPException, Query, Depends from pydantic import BaseModel, Field # ============================================================================= # Configuration # ============================================================================= DB_PATH = Path("/opt/agent-governance/ledger/governance.db") TEMPLATE_STORAGE = Path("/opt/agent-governance/marketplace/templates") TEMPLATE_STORAGE.mkdir(parents=True, exist_ok=True) # ============================================================================= # Database # ============================================================================= @contextmanager def get_db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: yield conn finally: conn.close() def row_to_dict(row: sqlite3.Row) -> dict: return dict(row) def rows_to_list(rows: list) -> list: return [row_to_dict(row) for row in rows] # ============================================================================= # Models # ============================================================================= class TenantContext(BaseModel): """Simplified tenant context for marketplace""" tenant_id: str = "default" project_id: str = "default" user_id: Optional[str] = None role: str = "viewer" class TemplateCreate(BaseModel): name: str slug: str description: Optional[str] = None category: str = "general" tier_required: int = 0 is_public: bool = False license: str = "MIT" repository_url: Optional[str] = None documentation_url: Optional[str] = None tags: List[str] = [] capabilities: List[str] = [] requirements: Optional[dict] = None class TemplateVersionCreate(BaseModel): version: str # Semantic version: 1.0.0 release_notes: Optional[str] = None config_schema: Optional[dict] = None template_content: dict # Full template definition is_stable: bool = True class TemplateInstall(BaseModel): template_id: str version: Optional[str] = None # None = latest config_overrides: Optional[dict] = None class TemplateRating(BaseModel): rating: int = Field(ge=1, le=5) review_title: Optional[str] = None review_text: Optional[str] = None class TemplateUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None category: Optional[str] = None is_public: Optional[bool] = None tags: Optional[List[str]] = None capabilities: Optional[List[str]] = None # ============================================================================= # Helper Functions # ============================================================================= def parse_version(version: str) -> int: """Convert semver to sortable integer: 1.2.3 -> 10203""" parts = version.split(".") if len(parts) != 3: return 0 try: major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2]) return major * 10000 + minor * 100 + patch except ValueError: return 0 def validate_slug(slug: str) -> bool: """Validate slug format: lowercase, alphanumeric, hyphens only""" return bool(re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', slug)) or len(slug) == 1 def update_template_stats(conn, template_id: str, stat: str, increment: int = 1): """Update template statistics""" conn.execute(f""" INSERT INTO template_stats (template_id, {stat}, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(template_id) DO UPDATE SET {stat} = {stat} + ?, updated_at = datetime('now') """, (template_id, increment, increment)) def calculate_avg_rating(conn, template_id: str): """Recalculate average rating for template""" cursor = conn.execute(""" SELECT AVG(rating) as avg, COUNT(*) as count FROM template_ratings WHERE template_id = ? """, (template_id,)) row = cursor.fetchone() conn.execute(""" UPDATE template_stats SET avg_rating = ?, rating_count = ?, updated_at = datetime('now') WHERE template_id = ? """, (row["avg"] or 0, row["count"], template_id)) # ============================================================================= # Router # ============================================================================= router = APIRouter(prefix="/marketplace", tags=["Marketplace"]) # ============================================================================= # Browse & Search Endpoints # ============================================================================= @router.get("/templates") async def list_templates( category: Optional[str] = None, tag: Optional[str] = None, search: Optional[str] = None, author: Optional[str] = None, verified_only: bool = False, sort: str = Query("popular", pattern="^(popular|recent|rating|downloads)$"), limit: int = Query(20, le=100), offset: int = Query(0, ge=0) ): """List and search marketplace templates""" with get_db() as conn: # Use FTS for search, regular query otherwise if search: cursor = conn.execute(""" SELECT t.*, ts.total_downloads, ts.avg_rating, ts.rating_count, ts.trending_score FROM agent_templates t LEFT JOIN template_stats ts ON t.template_id = ts.template_id WHERE t.template_id IN ( SELECT template_id FROM template_search WHERE template_search MATCH ? ) AND t.is_public = 1 ORDER BY ts.trending_score DESC LIMIT ? OFFSET ? """, (search, limit, offset)) else: query = """ SELECT t.*, ts.total_downloads, ts.avg_rating, ts.rating_count, ts.trending_score FROM agent_templates t LEFT JOIN template_stats ts ON t.template_id = ts.template_id WHERE t.is_public = 1 """ params = [] if category: query += " AND t.category = ?" params.append(category) if tag: query += " AND t.tags LIKE ?" params.append(f'%"{tag}"%') if author: query += " AND t.author_id = ?" params.append(author) if verified_only: query += " AND t.is_verified = 1" # Sorting sort_map = { "popular": "ts.trending_score DESC", "recent": "t.created_at DESC", "rating": "ts.avg_rating DESC", "downloads": "ts.total_downloads DESC" } query += f" ORDER BY {sort_map.get(sort, 'ts.trending_score DESC')}" query += " LIMIT ? OFFSET ?" params.extend([limit, offset]) cursor = conn.execute(query, params) templates = rows_to_list(cursor.fetchall()) # Parse JSON fields for t in templates: t["tags"] = json.loads(t["tags"]) if t.get("tags") else [] t["capabilities"] = json.loads(t["capabilities"]) if t.get("capabilities") else [] return {"templates": templates, "count": len(templates)} @router.get("/templates/{template_id}") async def get_template(template_id: str): """Get template details with versions""" with get_db() as conn: # Get template cursor = conn.execute(""" SELECT t.*, ts.total_downloads, ts.avg_rating, ts.rating_count, ts.active_installs, ts.trending_score FROM agent_templates t LEFT JOIN template_stats ts ON t.template_id = ts.template_id WHERE t.template_id = ? """, (template_id,)) template = cursor.fetchone() if not template: raise HTTPException(status_code=404, detail="Template not found") template = row_to_dict(template) template["tags"] = json.loads(template["tags"]) if template.get("tags") else [] template["capabilities"] = json.loads(template["capabilities"]) if template.get("capabilities") else [] template["requirements"] = json.loads(template["requirements"]) if template.get("requirements") else {} # Get versions cursor = conn.execute(""" SELECT version_id, version, version_number, release_notes, is_stable, is_deprecated, published_at FROM template_versions WHERE template_id = ? ORDER BY version_number DESC """, (template_id,)) template["versions"] = rows_to_list(cursor.fetchall()) # Get recent ratings cursor = conn.execute(""" SELECT rating, review_title, review_text, created_at FROM template_ratings WHERE template_id = ? ORDER BY created_at DESC LIMIT 5 """, (template_id,)) template["recent_reviews"] = rows_to_list(cursor.fetchall()) return template @router.get("/templates/{template_id}/versions/{version}") async def get_template_version(template_id: str, version: str): """Get specific version of a template with full content""" with get_db() as conn: cursor = conn.execute(""" SELECT * FROM template_versions WHERE template_id = ? AND version = ? """, (template_id, version)) ver = cursor.fetchone() if not ver: raise HTTPException(status_code=404, detail="Version not found") ver = row_to_dict(ver) ver["config_schema"] = json.loads(ver["config_schema"]) if ver.get("config_schema") else {} ver["template_content"] = json.loads(ver["template_content"]) if ver.get("template_content") else {} return ver @router.get("/categories") async def list_categories(): """List marketplace categories""" with get_db() as conn: cursor = conn.execute(""" SELECT c.*, COUNT(t.template_id) as template_count FROM marketplace_categories c LEFT JOIN agent_templates t ON c.category_id = t.category AND t.is_public = 1 WHERE c.is_active = 1 GROUP BY c.category_id ORDER BY c.sort_order """) categories = rows_to_list(cursor.fetchall()) return {"categories": categories} # ============================================================================= # Publishing Endpoints # ============================================================================= @router.post("/templates") async def create_template( template: TemplateCreate, ctx: TenantContext = None ): """Create a new template (publish to marketplace)""" if ctx is None: ctx = TenantContext() if not validate_slug(template.slug): raise HTTPException(status_code=400, detail="Invalid slug format") template_id = f"tpl-{secrets.token_hex(8)}" with get_db() as conn: try: conn.execute(""" INSERT INTO agent_templates (template_id, name, slug, description, author_id, author_name, category, tier_required, is_public, license, repository_url, documentation_url, tags, capabilities, requirements) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( template_id, template.name, template.slug, template.description, ctx.tenant_id, ctx.user_id or ctx.tenant_id, template.category, template.tier_required, 1 if template.is_public else 0, template.license, template.repository_url, template.documentation_url, json.dumps(template.tags), json.dumps(template.capabilities), json.dumps(template.requirements) if template.requirements else None )) # Initialize stats conn.execute(""" INSERT INTO template_stats (template_id) VALUES (?) """, (template_id,)) conn.commit() except sqlite3.IntegrityError as e: raise HTTPException(status_code=409, detail=f"Template slug already exists: {e}") return {"status": "created", "template_id": template_id} @router.post("/templates/{template_id}/versions") async def create_version( template_id: str, version: TemplateVersionCreate, ctx: TenantContext = None ): """Publish a new version of a template""" if ctx is None: ctx = TenantContext() # Verify ownership with get_db() as conn: cursor = conn.execute(""" SELECT author_id FROM agent_templates WHERE template_id = ? """, (template_id,)) template = cursor.fetchone() if not template: raise HTTPException(status_code=404, detail="Template not found") if template["author_id"] != ctx.tenant_id: raise HTTPException(status_code=403, detail="Not template owner") version_id = f"ver-{secrets.token_hex(8)}" version_number = parse_version(version.version) content_json = json.dumps(version.template_content) file_hash = hashlib.sha256(content_json.encode()).hexdigest() try: conn.execute(""" INSERT INTO template_versions (version_id, template_id, version, version_number, release_notes, config_schema, template_content, file_hash, is_stable) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( version_id, template_id, version.version, version_number, version.release_notes, json.dumps(version.config_schema) if version.config_schema else None, content_json, file_hash, 1 if version.is_stable else 0 )) # Update template timestamp conn.execute(""" UPDATE agent_templates SET updated_at = datetime('now') WHERE template_id = ? """, (template_id,)) conn.commit() except sqlite3.IntegrityError: raise HTTPException(status_code=409, detail="Version already exists") return {"status": "created", "version_id": version_id, "version": version.version} @router.patch("/templates/{template_id}") async def update_template( template_id: str, update: TemplateUpdate, ctx: TenantContext = None ): """Update template metadata""" if ctx is None: ctx = TenantContext() with get_db() as conn: # Verify ownership cursor = conn.execute(""" SELECT author_id FROM agent_templates WHERE template_id = ? """, (template_id,)) template = cursor.fetchone() if not template: raise HTTPException(status_code=404, detail="Template not found") if template["author_id"] != ctx.tenant_id: raise HTTPException(status_code=403, detail="Not template owner") updates = [] params = [] if update.name is not None: updates.append("name = ?") params.append(update.name) if update.description is not None: updates.append("description = ?") params.append(update.description) if update.category is not None: updates.append("category = ?") params.append(update.category) if update.is_public is not None: updates.append("is_public = ?") params.append(1 if update.is_public else 0) if update.tags is not None: updates.append("tags = ?") params.append(json.dumps(update.tags)) if update.capabilities is not None: updates.append("capabilities = ?") params.append(json.dumps(update.capabilities)) if not updates: raise HTTPException(status_code=400, detail="No fields to update") updates.append("updated_at = datetime('now')") params.append(template_id) conn.execute( f"UPDATE agent_templates SET {', '.join(updates)} WHERE template_id = ?", params ) conn.commit() return {"status": "updated", "template_id": template_id} # ============================================================================= # Installation Endpoints # ============================================================================= @router.post("/install") async def install_template( install: TemplateInstall, ctx: TenantContext = None ): """Install a template to current project""" if ctx is None: ctx = TenantContext() with get_db() as conn: # Get template cursor = conn.execute(""" SELECT template_id, is_public, author_id FROM agent_templates WHERE template_id = ? """, (install.template_id,)) template = cursor.fetchone() if not template: raise HTTPException(status_code=404, detail="Template not found") # Check access (public or owner) if not template["is_public"] and template["author_id"] != ctx.tenant_id: raise HTTPException(status_code=403, detail="Template not accessible") # Get version (latest stable if not specified) if install.version: cursor = conn.execute(""" SELECT version_id FROM template_versions WHERE template_id = ? AND version = ? """, (install.template_id, install.version)) else: cursor = conn.execute(""" SELECT version_id FROM template_versions WHERE template_id = ? AND is_stable = 1 ORDER BY version_number DESC LIMIT 1 """, (install.template_id,)) version = cursor.fetchone() if not version: raise HTTPException(status_code=404, detail="Version not found") # Install conn.execute(""" INSERT INTO template_installations (template_id, version_id, tenant_id, project_id, config_overrides) VALUES (?, ?, ?, ?, ?) ON CONFLICT(template_id, tenant_id, project_id) DO UPDATE SET version_id = ?, config_overrides = ?, updated_at = datetime('now') """, ( install.template_id, version["version_id"], ctx.tenant_id, ctx.project_id, json.dumps(install.config_overrides) if install.config_overrides else None, version["version_id"], json.dumps(install.config_overrides) if install.config_overrides else None )) # Update stats update_template_stats(conn, install.template_id, "total_installs") update_template_stats(conn, install.template_id, "active_installs") # Record download conn.execute(""" INSERT INTO template_downloads (template_id, version_id, tenant_id) VALUES (?, ?, ?) """, (install.template_id, version["version_id"], ctx.tenant_id)) update_template_stats(conn, install.template_id, "total_downloads") conn.commit() return {"status": "installed", "template_id": install.template_id} @router.get("/installed") async def list_installed( ctx: TenantContext = None ): """List templates installed in current project""" if ctx is None: ctx = TenantContext() with get_db() as conn: cursor = conn.execute(""" SELECT i.*, t.name, t.description, t.category, v.version FROM template_installations i JOIN agent_templates t ON i.template_id = t.template_id LEFT JOIN template_versions v ON i.version_id = v.version_id WHERE i.tenant_id = ? AND i.project_id = ? ORDER BY i.installed_at DESC """, (ctx.tenant_id, ctx.project_id)) installed = rows_to_list(cursor.fetchall()) return {"installed": installed, "count": len(installed)} @router.delete("/installed/{template_id}") async def uninstall_template( template_id: str, ctx: TenantContext = None ): """Uninstall a template from current project""" if ctx is None: ctx = TenantContext() with get_db() as conn: cursor = conn.execute(""" DELETE FROM template_installations WHERE template_id = ? AND tenant_id = ? AND project_id = ? """, (template_id, ctx.tenant_id, ctx.project_id)) conn.commit() if cursor.rowcount == 0: raise HTTPException(status_code=404, detail="Installation not found") # Update active installs conn.execute(""" UPDATE template_stats SET active_installs = active_installs - 1, updated_at = datetime('now') WHERE template_id = ? AND active_installs > 0 """, (template_id,)) conn.commit() return {"status": "uninstalled", "template_id": template_id} # ============================================================================= # Rating Endpoints # ============================================================================= @router.post("/templates/{template_id}/rate") async def rate_template( template_id: str, rating: TemplateRating, ctx: TenantContext = None ): """Rate and review a template""" if ctx is None: ctx = TenantContext() with get_db() as conn: # Check if template exists cursor = conn.execute(""" SELECT 1 FROM agent_templates WHERE template_id = ? """, (template_id,)) if not cursor.fetchone(): raise HTTPException(status_code=404, detail="Template not found") # Check if user has installed (verified reviewer) cursor = conn.execute(""" SELECT 1 FROM template_installations WHERE template_id = ? AND tenant_id = ? """, (template_id, ctx.tenant_id)) is_verified = 1 if cursor.fetchone() else 0 conn.execute(""" INSERT INTO template_ratings (template_id, tenant_id, user_id, rating, review_title, review_text, is_verified_user) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(template_id, tenant_id) DO UPDATE SET rating = ?, review_title = ?, review_text = ?, updated_at = datetime('now') """, ( template_id, ctx.tenant_id, ctx.user_id, rating.rating, rating.review_title, rating.review_text, is_verified, rating.rating, rating.review_title, rating.review_text )) # Recalculate average calculate_avg_rating(conn, template_id) conn.commit() return {"status": "rated", "template_id": template_id, "rating": rating.rating} @router.get("/templates/{template_id}/ratings") async def get_ratings( template_id: str, limit: int = Query(20, le=100), offset: int = Query(0, ge=0) ): """Get ratings for a template""" with get_db() as conn: cursor = conn.execute(""" SELECT rating, review_title, review_text, is_verified_user, created_at FROM template_ratings WHERE template_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ? """, (template_id, limit, offset)) ratings = rows_to_list(cursor.fetchall()) # Get summary cursor = conn.execute(""" SELECT avg_rating, rating_count FROM template_stats WHERE template_id = ? """, (template_id,)) stats = cursor.fetchone() return { "ratings": ratings, "count": len(ratings), "avg_rating": stats["avg_rating"] if stats else 0, "total_ratings": stats["rating_count"] if stats else 0 } # ============================================================================= # My Templates (Author View) # ============================================================================= @router.get("/my-templates") async def list_my_templates( ctx: TenantContext = None ): """List templates created by current tenant""" if ctx is None: ctx = TenantContext() with get_db() as conn: cursor = conn.execute(""" SELECT t.*, ts.total_downloads, ts.avg_rating, ts.rating_count, ts.active_installs FROM agent_templates t LEFT JOIN template_stats ts ON t.template_id = ts.template_id WHERE t.author_id = ? ORDER BY t.updated_at DESC """, (ctx.tenant_id,)) templates = rows_to_list(cursor.fetchall()) for t in templates: t["tags"] = json.loads(t["tags"]) if t.get("tags") else [] return {"templates": templates, "count": len(templates)}