"""Plugin manifest schema (Pydantic v2).""" from __future__ import annotations from typing import Any from pydantic import BaseModel, Field, field_validator class PluginRouteDef(BaseModel): """Route definition within a plugin manifest.""" path: str = Field(..., description="URL path prefix, e.g. /api/v1/plugin-mail") module: str = Field(..., description="Dotted path to the module containing the APIRouter") router_attr: str = Field( default="router", description="Attribute name of the APIRouter in the module" ) class FieldDefinition(BaseModel): """Field definition for field-level permissions.""" module: str = Field(..., description="Module name (e.g. 'companies', 'contacts')") field: str = Field(..., description="Field name (e.g. 'annual_revenue')") label: str = Field(..., description="Human-readable label") sensitivity: str = Field(default="normal", description="normal|sensitive|critical") # ── Frontend UI definition models (Phase 3) ────────────────────────────────── class FrontendMenuItem(BaseModel): """A sidebar navigation item contributed by a plugin.""" label_key: str = Field(..., description="i18n key for the menu label") label: str = Field(default="", description="Fallback label if i18n key is missing") path: str = Field(..., description="Frontend route path, e.g. /mail") icon: str = Field(default="FileText", description="lucide-react icon name") group: str = Field(default="", description="Optional group label_key for tree-style nesting") order: int = Field(default=100, description="Sort order within the sidebar") badge_key: str = Field(default="", description="Optional store key for badge count") class FrontendPageRoute(BaseModel): """A frontend page route contributed by a plugin.""" path: str = Field(..., description="Frontend route path, e.g. /mail or /mail/settings") component: str = Field( ..., description="Dotted path to the React component, e.g. @/pages/Mail" ) parent: str = Field( default="", description="Parent route path for nested routes (e.g. /settings for a settings sub-page)", ) protected: bool = Field(default=True, description="Whether the route requires authentication") order: int = Field(default=100, description="Sort order") class FrontendDetailTab(BaseModel): """A detail tab contributed by a plugin for entity detail views.""" entity_type: str = Field(..., description="Entity type this tab applies to, e.g. 'contact'") label_key: str = Field(..., description="i18n key for the tab label") label: str = Field(default="", description="Fallback label") component: str = Field(..., description="Dotted path to the React component") icon: str = Field(default="FileText", description="lucide-react icon name") order: int = Field(default=100, description="Sort order within the detail view") permission: str = Field(default="", description="Optional permission required to see this tab") class FrontendSettingsPage(BaseModel): """A settings sub-page contributed by a plugin.""" path: str = Field(..., description="Settings sub-route path, e.g. mail or notifications") label_key: str = Field(..., description="i18n key for the settings nav label") label: str = Field(default="", description="Fallback label") component: str = Field(..., description="Dotted path to the React component") icon: str = Field(default="Settings", description="lucide-react icon name") order: int = Field(default=100, description="Sort order within settings nav") permission: str = Field(default="", description="Optional permission required") class AgentDefinitionContribution(BaseModel): """An agent definition contributed by a plugin manifest.""" name: str = Field(..., min_length=1, max_length=120) description: str = Field(default="", max_length=500) llm_model: str = Field(default="ollama/deepseek-v4-flash", max_length=100) system_prompt: str = Field(default="") tool_ids: list[str] = Field(default_factory=list) heartbeat_interval_seconds: int = Field(default=0, ge=0) mode: str = Field(default="reactive", pattern="^(proactive|reactive)$") max_executions_per_hour: int = Field(default=10, ge=1, le=1000) max_duration_seconds: int = Field(default=300, ge=1, le=86400) budget_limit_usd: float = Field(default=1.0, ge=0.0, le=10000.0) class AutomationTemplateContribution(BaseModel): """An automation template contributed by a plugin manifest.""" name: str = Field(..., min_length=1, max_length=120) description: str = Field(default="", max_length=500) trigger_type: str = Field(default="event", pattern="^(event|schedule|manual)$") trigger_config: dict = Field(default_factory=dict) conditions: list[dict] = Field(default_factory=list) actions: list[dict] = Field(default_factory=list) class CronJobContribution(BaseModel): """A cron job contributed by a plugin manifest.""" name: str = Field(..., min_length=1, max_length=120) cron_expression: str = Field(..., min_length=1, max_length=100) job_type: str = Field(..., pattern="^(agent_heartbeat|automation_trigger|custom)$") target_name: str = Field(default="", description="Reference agent/automation by name") plugin_name: str = Field(default="") class HeartbeatConfigContribution(BaseModel): """A heartbeat config contributed by a plugin manifest.""" agent_name: str = Field(..., min_length=1, max_length=120) interval_seconds: int = Field(..., ge=1) target_room: str = Field(default="") class FrontendDashboardWidget(BaseModel): """A dashboard widget contributed by a plugin.""" id: str = Field(..., description="Unique widget identifier") label_key: str = Field(..., description="i18n key for the widget title") label: str = Field(default="", description="Fallback label") component: str = Field(..., description="Dotted path to the React component") icon: str = Field(default="LayoutDashboard", description="lucide-react icon name") order: int = Field(default=100, description="Sort order on the dashboard") col_span: int = Field(default=1, description="Grid column span (1-4)") row_span: int = Field(default=1, description="Grid row span") permission: str = Field(default="", description="Optional permission required") class MiniAppContribution(BaseModel): """A MiniApp contributed by a plugin manifest.""" app_id: str = Field(..., min_length=1, max_length=80) name: str = Field(..., min_length=1, max_length=120) icon: str = Field(default="AppWindow") description: str = Field(default="") render_schema: dict[str, Any] = Field(default_factory=dict) class PluginManifest(BaseModel): """Manifest describing a plugin's metadata, dependencies, and capabilities.""" name: str = Field( ..., min_length=1, max_length=80, description="Unique plugin identifier (snake_case)" ) version: str = Field(..., min_length=1, max_length=40, description="Semantic version string") display_name: str = Field(..., min_length=1, max_length=120) description: str = Field(default="", max_length=500) dependencies: list[str] = Field( default_factory=list, description="Other plugin names this plugin depends on" ) routes: list[PluginRouteDef] = Field( default_factory=list, description="Route definitions to register on activation" ) events: list[str] = Field( default_factory=list, description="Event names this plugin listens to" ) migrations: list[str] = Field( default_factory=list, description="Migration file names (ordered, e.g. 0001_initial.sql)" ) permissions: list[str] = Field( default_factory=list, description="Required permissions for this plugin" ) is_core: bool = Field( default=False, description="Whether this is a core plugin that cannot be deactivated" ) field_definitions: list[FieldDefinition] = Field( default_factory=list, description="Field definitions for field-level permissions" ) agent_capabilities: list[str] = Field( default_factory=list, description="AI agent capabilities this plugin provides (e.g. 'contact_search', 'email_draft')", ) # ── Frontend UI contributions (Phase 3) ── menu_items: list[FrontendMenuItem] = Field( default_factory=list, description="Sidebar navigation items contributed by this plugin" ) page_routes: list[FrontendPageRoute] = Field( default_factory=list, description="Frontend page routes contributed by this plugin" ) detail_tabs: list[FrontendDetailTab] = Field( default_factory=list, description="Entity detail tabs contributed by this plugin" ) settings_pages: list[FrontendSettingsPage] = Field( default_factory=list, description="Settings sub-pages contributed by this plugin" ) dashboard_widgets: list[FrontendDashboardWidget] = Field( default_factory=list, description="Dashboard widgets contributed by this plugin" ) # ── Plugin Contribution fields (Phase 3.5C) ── agent_definitions: list[AgentDefinitionContribution] = Field( default_factory=list, description="Agent definitions contributed by this plugin" ) automation_templates: list[AutomationTemplateContribution] = Field( default_factory=list, description="Automation templates contributed by this plugin" ) cron_jobs: list[CronJobContribution] = Field( default_factory=list, description="Cron jobs contributed by this plugin" ) heartbeat_configs: list[HeartbeatConfigContribution] = Field( default_factory=list, description="Heartbeat configs contributed by this plugin" ) miniapps: list[MiniAppContribution] = Field( default_factory=list, description="MiniApps contributed by this plugin" ) @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: if not v.replace("_", "").isalnum(): raise ValueError("Plugin name must be alphanumeric with underscores only") return v.lower() model_config = {"extra": "forbid"} PluginManifest.model_rebuild() class ManifestSchemaResponse(BaseModel): """Response model describing the manifest schema for API consumers.""" fields: dict[str, dict[str, str]] example: PluginManifest # Pre-built schema documentation for GET /api/v1/plugins/manifest endpoint MANIFEST_SCHEMA_DOC = ManifestSchemaResponse( fields={ "name": { "type": "str", "required": "true", "description": "Unique plugin identifier (snake_case, max 80 chars)", }, "version": {"type": "str", "required": "true", "description": "Semantic version string"}, "display_name": { "type": "str", "required": "true", "description": "Human-readable plugin name", }, "description": { "type": "str", "required": "false", "description": "Plugin description (max 500 chars)", }, "dependencies": { "type": "list[str]", "required": "false", "description": "Other plugin names required", }, "routes": { "type": "list[PluginRouteDef]", "required": "false", "description": "Route definitions to register", }, "events": { "type": "list[str]", "required": "false", "description": "Event names to listen to", }, "migrations": { "type": "list[str]", "required": "false", "description": "Migration file names (ordered)", }, "permissions": { "type": "list[str]", "required": "false", "description": "Required permissions", }, "menu_items": { "type": "list[FrontendMenuItem]", "required": "false", "description": "Sidebar navigation items (label_key, path, icon, group, order)", }, "page_routes": { "type": "list[FrontendPageRoute]", "required": "false", "description": "Frontend page routes (path, component, parent, protected)", }, "detail_tabs": { "type": "list[FrontendDetailTab]", "required": "false", "description": "Entity detail tabs (entity_type, label_key, component, icon, permission)", }, "settings_pages": { "type": "list[FrontendSettingsPage]", "required": "false", "description": "Settings sub-pages (path, label_key, component, icon, permission)", }, "dashboard_widgets": { "type": "list[FrontendDashboardWidget]", "required": "false", "description": "Dashboard widgets (id, label_key, component, col_span, permission)", }, }, example=PluginManifest( name="example_plugin", version="1.0.0", display_name="Example Plugin", description="An example plugin demonstrating the manifest schema.", dependencies=[], routes=[], events=["contact.created"], migrations=["0001_initial.sql"], permissions=["contacts.read"], menu_items=[ FrontendMenuItem( label_key="nav.examplePlugin", label="Example", path="/example", icon="Sparkles", order=50, ) ], page_routes=[ FrontendPageRoute( path="/example", component="@/pages/Example", protected=True, ) ], ), ) PluginManifest.model_rebuild()