Skip to content
Open
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ Wegent is an open-source AI-native operating system for defining, organizing, an
- English version: [`docs/en/developer-guide/shared-skill-directory.md`](docs/en/developer-guide/shared-skill-directory.md)
- Runtime local work (Project -> Device Workspace -> LocalTask without DB task rows): [`docs/zh/developer-guide/runtime-local-work.md`](docs/zh/developer-guide/runtime-local-work.md)
- English version: [`docs/en/developer-guide/runtime-local-work.md`](docs/en/developer-guide/runtime-local-work.md)
- System plugin management (admin-managed plugin catalog and Wework manual updates): [`docs/zh/developer-guide/system-plugin-management.md`](docs/zh/developer-guide/system-plugin-management.md)
- English version: [`docs/en/developer-guide/system-plugin-management.md`](docs/en/developer-guide/system-plugin-management.md)

**📚 Documentation Writing Rules:**
- All documentation files MUST include frontmatter with `sidebar_position` for ordering:
Expand Down
132 changes: 132 additions & 0 deletions backend/app/api/endpoints/admin/plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# SPDX-FileCopyrightText: 2026 Weibo, Inc.
#
# SPDX-License-Identifier: Apache-2.0

import logging

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

from app.api.dependencies import get_db
from app.core.security import get_admin_user
from app.models.user import User
from app.schemas.installed_plugin import (
PluginRuntime,
SystemPlugin,
SystemPluginListResponse,
SystemPluginUpdateRequest,
)
from app.services.claude_plugin_parser import MAX_PLUGIN_PACKAGE_SIZE_BYTES
from app.services.installed_plugin_service import installed_plugin_service

router = APIRouter()
logger = logging.getLogger(__name__)
PLUGIN_UPLOAD_CHUNK_SIZE_BYTES = 1024 * 1024


@router.get("/plugins", response_model=SystemPluginListResponse)
async def list_system_plugins(
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> SystemPluginListResponse:
"""List system-managed Claude Code plugins."""
return installed_plugin_service.list_system_plugins(db=db)


@router.post(
"/plugins",
response_model=SystemPlugin,
status_code=status.HTTP_201_CREATED,
)
async def upload_system_plugin(
file: UploadFile = File(...),
enabled: bool = Form(True),
runtime: PluginRuntime = Form("claudecode"),
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> SystemPlugin:
"""Upload or replace a system-managed Claude Code plugin package."""
logger.info(
"System plugin upload requested: admin_id=%s filename=%s enabled=%s runtime=%s",
current_user.id,
file.filename,
enabled,
runtime,
)
package_bytes = await _read_plugin_upload(file)
return installed_plugin_service.upload_system_plugin(
db=db,
package_bytes=package_bytes,
filename=file.filename or "plugin.zip",
enabled=enabled,
runtime=runtime,
)


@router.put("/plugins/{system_plugin_id}", response_model=SystemPlugin)
async def update_system_plugin(
system_plugin_id: int,
request: SystemPluginUpdateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> SystemPlugin:
"""Update system plugin display metadata and visibility."""
return installed_plugin_service.update_system_plugin_metadata(
db=db,
system_plugin_id=system_plugin_id,
display_name=request.displayName,
description=request.description,
enabled=request.enabled,
)


@router.put("/plugins/{system_plugin_id}/package", response_model=SystemPlugin)
async def replace_system_plugin_package(
system_plugin_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> SystemPlugin:
"""Replace a system plugin ZIP package with a new version."""
logger.info(
"System plugin package replacement requested: admin_id=%s plugin_id=%s filename=%s",
current_user.id,
system_plugin_id,
file.filename,
)
package_bytes = await _read_plugin_upload(file)
return installed_plugin_service.replace_system_plugin_package(
db=db,
system_plugin_id=system_plugin_id,
package_bytes=package_bytes,
filename=file.filename or "plugin.zip",
)


@router.delete("/plugins/{system_plugin_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_system_plugin(
system_plugin_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> None:
"""Remove a system-managed plugin from the user-facing catalog."""
installed_plugin_service.delete_system_plugin(
db=db,
system_plugin_id=system_plugin_id,
)


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:
from fastapi import HTTPException

raise HTTPException(status_code=413, detail="Plugin package is too large")
chunks.append(chunk)
return b"".join(chunks)
2 changes: 2 additions & 0 deletions backend/app/api/endpoints/admin/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
device_monitor,
im_channels,
kind_management,
plugins,
public_bots,
public_ghosts,
public_models,
Expand Down Expand Up @@ -48,3 +49,4 @@
router.include_router(device_monitor.router, tags=["admin-device-monitor"])
router.include_router(templates.router, tags=["admin-templates"])
router.include_router(runtime_cleanup.router, tags=["admin-runtime-cleanup"])
router.include_router(plugins.router, tags=["admin-plugins"])
52 changes: 52 additions & 0 deletions backend/app/api/endpoints/installed_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
InstalledPlugin,
InstalledPluginListResponse,
InstalledPluginUpdateRequest,
PluginCatalogListResponse,
)
from app.services.claude_plugin_parser import MAX_PLUGIN_PACKAGE_SIZE_BYTES
from app.services.device.capability_sync_service import device_capability_sync_service
Expand All @@ -39,6 +40,57 @@ def list_installed_plugins(
)


@router.get("/catalog", response_model=PluginCatalogListResponse)
def list_system_plugin_catalog(
db: Session = Depends(get_db),
current_user: User = Depends(security.get_current_user),
) -> PluginCatalogListResponse:
"""List system-managed plugins available to the current user."""
return installed_plugin_service.list_system_plugin_catalog(
db=db,
user_id=current_user.id,
)


@router.post(
"/catalog/{system_plugin_id}/install",
response_model=InstalledPluginListResponse,
status_code=status.HTTP_201_CREATED,
)
async def install_system_plugin(
system_plugin_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(security.get_current_user),
) -> InstalledPluginListResponse:
"""Install a system-managed plugin into the current user's inventory."""
installed = installed_plugin_service.install_system_plugin(
db=db,
user_id=current_user.id,
system_plugin_id=system_plugin_id,
)
await _sync_global_capabilities(db, current_user.id)
return installed


@router.post(
"/catalog/{system_plugin_id}/update",
response_model=InstalledPluginListResponse,
)
async def update_system_plugin_installation(
system_plugin_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(security.get_current_user),
) -> InstalledPluginListResponse:
"""Manually update the current user's installation from the system catalog."""
installed = installed_plugin_service.update_installed_plugin_from_system(
db=db,
user_id=current_user.id,
system_plugin_id=system_plugin_id,
)
await _sync_global_capabilities(db, current_user.id)
return installed


@router.post(
"/upload",
response_model=InstalledPlugin,
Expand Down
58 changes: 57 additions & 1 deletion backend/app/schemas/installed_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"uninstalled",
]

PluginRuntime = Literal["claudecode", "codex"]


class PluginSkillComponent(BaseModel):
"""Skill metadata discovered inside a Claude Code plugin."""
Expand Down Expand Up @@ -55,11 +57,12 @@ class InstalledPluginComponents(BaseModel):
class InstalledPluginSource(BaseModel):
"""Source identity for a user-installed Claude Code plugin."""

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


class InstalledPluginPackageRef(BaseModel):
Expand All @@ -74,6 +77,7 @@ class InstalledPluginSpec(BaseModel):
"""User-scoped Claude Code plugin installation state."""

source: InstalledPluginSource
runtime: PluginRuntime = "claudecode"
displayName: str
description: str = ""
version: Optional[str] = None
Expand Down Expand Up @@ -111,6 +115,50 @@ class InstalledPluginListResponse(BaseModel):
items: List[InstalledPlugin]


class SystemPlugin(BaseModel):
"""System-managed Claude Code plugin catalog item."""

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


class SystemPluginListResponse(BaseModel):
"""Response for admin system plugin management."""

total: int
items: List[SystemPlugin]


class PluginCatalogItem(BaseModel):
"""User-facing system plugin catalog item with install state."""

id: int
name: str
displayName: str
description: str = ""
version: Optional[str] = None
author: Optional[str] = None
enabled: bool = True
installState: PluginInstallState = "not_installed"
installedPluginId: Optional[int] = None
installedPluginIds: Dict[PluginRuntime, int] = Field(default_factory=dict)
variantIds: Dict[PluginRuntime, int] = Field(default_factory=dict)
sourceChecksum: Optional[str] = None
installedChecksum: Optional[str] = None
components: InstalledPluginComponents = Field(
default_factory=InstalledPluginComponents
)


class PluginCatalogListResponse(BaseModel):
"""Response for user-facing plugin catalog."""

items: List[PluginCatalogItem]


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

Expand All @@ -120,6 +168,14 @@ class InstalledPluginUpdateRequest(BaseModel):
description: Optional[str] = None


class SystemPluginUpdateRequest(BaseModel):
"""Request to update system plugin catalog metadata."""

displayName: Optional[str] = None
description: Optional[str] = None
enabled: Optional[bool] = None


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

Expand Down
1 change: 1 addition & 0 deletions backend/app/services/device/capability_sync_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,7 @@ def _plugin_payload(self, installed: Kind) -> dict[str, Any]:
"marketplace": marketplace,
"version": spec.get("version"),
"source": source,
"runtime": spec.get("runtime") or source.get("runtime") or "claudecode",
}
component_states = spec.get("componentStates") or {}
if component_states:
Expand Down
Loading
Loading