Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/app/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
groups,
health,
installed_mcps,
installed_plugins,
knowledge,
knowledge_open,
knowledge_transfer,
Expand Down Expand Up @@ -218,6 +219,7 @@
mcp_providers.router, prefix="/mcp-providers", tags=["mcp-providers"]
)
api_router.include_router(installed_mcps.router, prefix="/mcps", tags=["mcps"])
api_router.include_router(installed_plugins.router, prefix="/plugins", tags=["plugins"])
api_router.include_router(
local_executor.router, prefix="/local-executor", tags=["local-executor"]
)
Expand Down
134 changes: 134 additions & 0 deletions backend/app/api/endpoints/installed_plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# SPDX-FileCopyrightText: 2026 Weibo, Inc.
#
# SPDX-License-Identifier: Apache-2.0

import logging

from fastapi import APIRouter, Depends, File, Form, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session

from app.api.dependencies import get_db
from app.core import security
from app.models.user import User
from app.schemas.installed_plugin import (
InstalledPlugin,
InstalledPluginListResponse,
InstalledPluginUpdateRequest,
)
from app.services.device.capability_sync_service import device_capability_sync_service
from app.services.installed_plugin_service import installed_plugin_service

router = APIRouter(tags=["plugins"])
logger = logging.getLogger(__name__)


@router.get("/installed", response_model=InstalledPluginListResponse)
def list_installed_plugins(
db: Session = Depends(get_db),
current_user: User = Depends(security.get_current_user),
) -> InstalledPluginListResponse:
"""List Claude Code plugins installed by the current user."""
return installed_plugin_service.list_installed_plugins(
db=db,
user_id=current_user.id,
)


@router.post(
"/upload",
response_model=InstalledPlugin,
status_code=status.HTTP_201_CREATED,
)
async def upload_plugin(
file: UploadFile = File(...),
enabled: bool = Form(True),
db: Session = Depends(get_db),
current_user: User = Depends(security.get_current_user),
) -> InstalledPlugin:
"""Upload and install a Claude Code plugin ZIP package."""
logger.info(
"Plugin upload requested: user_id=%s filename=%s enabled=%s",
current_user.id,
file.filename,
enabled,
)
content = await file.read()
installed = installed_plugin_service.upload_plugin(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
db=db,
user_id=current_user.id,
package_bytes=content,
filename=file.filename or "plugin.zip",
enabled=enabled,
)
await _sync_global_capabilities(db, current_user.id)
return installed


@router.get("/installed/{installed_id}/download")
def download_installed_plugin(
installed_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(security.get_current_user_jwt_apikey_tasktoken),
) -> FileResponse:
"""Download a user's installed plugin package for local executor sync."""
path = installed_plugin_service.package_path_for_download(
db=db,
user_id=current_user.id,
installed_id=installed_id,
)
return FileResponse(
path,
media_type="application/zip",
filename=path.name,
)


@router.put("/installed/{installed_id}", response_model=InstalledPlugin)
async def update_installed_plugin(
installed_id: int,
request: InstalledPluginUpdateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(security.get_current_user),
) -> InstalledPlugin:
"""Update an installed plugin's enabled state or display metadata."""
installed = installed_plugin_service.update_installed_plugin(
db=db,
user_id=current_user.id,
installed_id=installed_id,
request=request,
)
await _sync_global_capabilities(db, current_user.id)
return installed


@router.delete("/installed/{installed_id}", status_code=status.HTTP_204_NO_CONTENT)
async def uninstall_installed_plugin(
installed_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(security.get_current_user),
) -> None:
"""Uninstall a user-scoped Claude Code plugin."""
installed_plugin_service.uninstall_installed_plugin(
db=db,
user_id=current_user.id,
installed_id=installed_id,
)
await _sync_global_capabilities(db, current_user.id)


async def _sync_global_capabilities(db: Session, user_id: int) -> None:
try:
result = await device_capability_sync_service.sync_user_global_capabilities(
db,
user_id=user_id,
)
logger.info(
"Global capability sync after plugin change completed: user_id=%s synced=%s failed=%s skipped=%s",
user_id,
result.synced,
result.failed,
result.skipped,
)
except Exception:
logger.exception("Failed to sync global capabilities after plugin change")
140 changes: 140 additions & 0 deletions backend/app/schemas/installed_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# SPDX-FileCopyrightText: 2026 Weibo, Inc.
#
# SPDX-License-Identifier: Apache-2.0

from typing import Any, Dict, List, Literal, Optional

from pydantic import BaseModel, Field, field_validator

PluginInstallState = Literal[
"not_installed",
"installed",
"update_available",
"unavailable",
"failed",
"uninstalled",
]


class PluginSkillComponent(BaseModel):
"""Skill metadata discovered inside a Claude Code plugin."""

name: str
description: str = ""
path: str


class PluginPathComponent(BaseModel):
"""Path-based plugin component such as command, agent, hook, or binary."""

name: str
path: str


class PluginMCPComponent(BaseModel):
"""MCP server entry discovered from a plugin .mcp.json file."""

name: str
server: Dict[str, Any] = Field(default_factory=dict)


class InstalledPluginComponents(BaseModel):
"""Claude Code plugin component inventory."""

skills: List[PluginSkillComponent] = Field(default_factory=list)
commands: List[PluginPathComponent] = Field(default_factory=list)
agents: List[PluginPathComponent] = Field(default_factory=list)
hooks: List[PluginPathComponent] = Field(default_factory=list)
mcps: List[PluginMCPComponent] = Field(default_factory=list)
lsps: List[PluginPathComponent] = Field(default_factory=list)
monitors: List[PluginPathComponent] = Field(default_factory=list)
bins: List[PluginPathComponent] = Field(default_factory=list)
settings: Optional[Dict[str, Any]] = None


class InstalledPluginSource(BaseModel):
"""Source identity for a user-installed Claude Code plugin."""

type: Literal["upload", "marketplace", "local"] = "upload"
providerKey: str = "claude-code"
pluginKey: str
catalogItemId: Optional[str] = None
marketplace: Optional[str] = None


class InstalledPluginPackageRef(BaseModel):
"""Stored package reference for an installed plugin."""

storageKey: str
checksum: str
sizeBytes: int


class InstalledPluginSpec(BaseModel):
"""User-scoped Claude Code plugin installation state."""

source: InstalledPluginSource
displayName: str
description: str = ""
version: Optional[str] = None
author: Optional[str] = None
installState: PluginInstallState = "installed"
enabled: bool = True
componentStates: Dict[str, bool] = Field(default_factory=dict)
manifest: Dict[str, Any] = Field(default_factory=dict)
components: InstalledPluginComponents = Field(
default_factory=InstalledPluginComponents
)
packageRef: Optional[InstalledPluginPackageRef] = None
sourcePayload: Optional[Dict[str, Any]] = None


class InstalledPluginStatus(BaseModel):
"""Runtime status for an InstalledPlugin CRD."""

state: str = "Available"


class InstalledPlugin(BaseModel):
"""InstalledPlugin CRD stored in the existing kinds table."""

apiVersion: str = "agent.wecode.io/v1"
kind: Literal["InstalledPlugin"] = "InstalledPlugin"
metadata: Dict[str, Any]
spec: InstalledPluginSpec
status: InstalledPluginStatus = Field(default_factory=InstalledPluginStatus)


class InstalledPluginListResponse(BaseModel):
"""Response for listing user-installed Claude Code plugins."""

items: List[InstalledPlugin]


class InstalledPluginUpdateRequest(BaseModel):
"""Request to update installed plugin runtime state."""

enabled: Optional[bool] = None
componentStates: Optional[Dict[str, bool]] = None
displayName: Optional[str] = None
description: Optional[str] = None


class PluginUploadInfo(BaseModel):
"""Normalized plugin metadata parsed from an uploaded package."""

name: str
displayName: str
description: str = ""
version: Optional[str] = None
author: Optional[str] = None
manifest: Dict[str, Any] = Field(default_factory=dict)
components: InstalledPluginComponents

@field_validator("name")
@classmethod
def validate_name(cls, value: str) -> str:
cleaned = value.strip()
if not cleaned:
raise ValueError("name is required")
return cleaned[:100]
Loading
Loading