Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
150 changes: 150 additions & 0 deletions backend/app/api/endpoints/installed_plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# SPDX-FileCopyrightText: 2026 Weibo, Inc.
#
# SPDX-License-Identifier: Apache-2.0

import logging

from fastapi import APIRouter, Depends, File, Form, HTTPException, 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.claude_plugin_parser import MAX_PLUGIN_PACKAGE_SIZE_BYTES
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__)
PLUGIN_UPLOAD_CHUNK_SIZE_BYTES = 1024 * 1024


@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 _read_plugin_upload(file)
installed = installed_plugin_service.upload_plugin(
Comment thread
coderabbitai[bot] marked this conversation as 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


async def _read_plugin_upload(file: UploadFile) -> bytes:
chunks: list[bytes] = []
total_size = 0
while True:
chunk = await file.read(PLUGIN_UPLOAD_CHUNK_SIZE_BYTES)
if not chunk:
break
total_size += len(chunk)
if total_size > MAX_PLUGIN_PACKAGE_SIZE_BYTES:
raise HTTPException(status_code=413, detail="Plugin package is too large")
chunks.append(chunk)
return b"".join(chunks)


@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