feat(skills): self-service skill submission with admin review

Non-admin registrations land as approval_status=pending_review and disabled, so only skills an admin approves reach marketplace.json and the public Skill Hub. Admins approve or reject with notes through new /claude-code/plugins/{name}/approve and /reject routes, and the Skills page gets a submit button for everyone plus a pending queue with approve and reject for admins.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-08-12 03:03:13 +00:00
parent 06943b6468
commit 7652824325
25 changed files with 1683 additions and 104 deletions

View file

@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "LiteLLM_ClaudeCodePluginTable" ADD COLUMN IF NOT EXISTS "approval_status" TEXT DEFAULT 'active';
ALTER TABLE "LiteLLM_ClaudeCodePluginTable" ADD COLUMN IF NOT EXISTS "review_notes" TEXT;
ALTER TABLE "LiteLLM_ClaudeCodePluginTable" ADD COLUMN IF NOT EXISTS "reviewed_by" TEXT;
ALTER TABLE "LiteLLM_ClaudeCodePluginTable" ADD COLUMN IF NOT EXISTS "reviewed_at" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LiteLLM_ClaudeCodePluginTable_approval_status_idx" ON "LiteLLM_ClaudeCodePluginTable"("approval_status");

View file

@ -1338,17 +1338,22 @@ model LiteLLM_AccessGroupTable {
}
// Claude Code Plugin Marketplace table
model LiteLLM_ClaudeCodePluginTable {
id String @id @default(uuid())
name String @unique
version String?
description String?
manifest_json String?
files_json String? @default("{}")
enabled Boolean @default(true)
created_at DateTime? @default(now())
updated_at DateTime? @default(now()) @updatedAt
created_by String?
id String @id @default(uuid())
name String @unique
version String?
description String?
manifest_json String?
files_json String? @default("{}")
enabled Boolean @default(true)
approval_status String? @default("active")
review_notes String?
reviewed_by String?
reviewed_at DateTime?
created_at DateTime? @default(now())
updated_at DateTime? @default(now()) @updatedAt
created_by String?
@@index([approval_status])
@@map("LiteLLM_ClaudeCodePluginTable")
}

View file

@ -4380,6 +4380,16 @@
"PluginListItem": {
"description": "Plugin item in list responses.",
"properties": {
"approval_status": {
"default": "active",
"enum": [
"pending_review",
"active",
"rejected"
],
"title": "Approval Status",
"type": "string"
},
"author": {
"anyOf": [
{
@ -4412,6 +4422,17 @@
],
"title": "Created At"
},
"created_by": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Created By"
},
"description": {
"anyOf": [
{
@ -4482,6 +4503,39 @@
],
"title": "Namespace"
},
"review_notes": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Review Notes"
},
"reviewed_at": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Reviewed At"
},
"reviewed_by": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Reviewed By"
},
"source": {
"additionalProperties": {
"type": "string"
@ -4528,6 +4582,17 @@
"PluginResponse": {
"description": "Plugin information in API responses.",
"properties": {
"approval_status": {
"default": "active",
"description": "Administrator approval state",
"enum": [
"pending_review",
"active",
"rejected"
],
"title": "Approval Status",
"type": "string"
},
"description": {
"anyOf": [
{
@ -4713,7 +4778,7 @@
"description": "Response from plugin registration.",
"properties": {
"action": {
"description": "Action taken (created/updated)",
"description": "Action taken (created/submitted_for_review/updated)",
"title": "Action",
"type": "string"
},
@ -4735,6 +4800,99 @@
"title": "RegisterPluginResponse",
"type": "object"
},
"RejectPluginRequest": {
"description": "Administrator rejection of a submitted skill.",
"properties": {
"review_notes": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Reviewer feedback shown to the submitter",
"title": "Review Notes"
}
},
"title": "RejectPluginRequest",
"type": "object"
},
"ReviewPluginResponse": {
"description": "Response from approving or rejecting a submitted skill.",
"properties": {
"approval_status": {
"description": "Resulting approval state",
"enum": [
"pending_review",
"active",
"rejected"
],
"title": "Approval Status",
"type": "string"
},
"enabled": {
"description": "Whether the skill is now served to users",
"title": "Enabled",
"type": "boolean"
},
"name": {
"description": "Skill name",
"title": "Name",
"type": "string"
},
"review_notes": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Reviewer feedback",
"title": "Review Notes"
},
"reviewed_at": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "ISO timestamp of the review",
"title": "Reviewed At"
},
"reviewed_by": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "User id of the reviewing administrator",
"title": "Reviewed By"
},
"status": {
"description": "Operation status",
"title": "Status",
"type": "string"
}
},
"required": [
"status",
"name",
"approval_status",
"enabled"
],
"title": "ReviewPluginResponse",
"type": "object"
},
"UpdatePluginRequest": {
"description": "Request body for replacing an existing plugin.\n\nThe plugin name is the resource identity and is supplied as the path\nparameter, so it cannot be changed here. This is a full replace: omitted\nfields reset to their defaults, so version is cleared rather than\ndefaulting to the create-time \"1.0.0\".",
"properties": {
@ -4916,7 +5074,7 @@
},
"/claude-code/plugins": {
"get": {
"description": "List all plugins in the marketplace.\n\nParameters:\n - enabled_only: If true, only return enabled plugins\n\nReturns:\n List of plugins with their metadata.",
"description": "List plugins in the marketplace.\n\nAdmins see every skill, including submissions awaiting review. Everyone\nelse sees approved skills plus their own submissions, so a submitter can\ntrack the status of what they sent in.\n\nParameters:\n - enabled_only: If true, only return enabled plugins\n - approval_status: Filter to one approval state, e.g. `pending_review` for the admin review queue\n\nReturns:\n List of plugins with their metadata and review state.",
"operationId": "list_plugins_claude_code_plugins_get",
"parameters": [
{
@ -4928,6 +5086,27 @@
"title": "Enabled Only",
"type": "boolean"
}
},
{
"in": "query",
"name": "approval_status",
"required": false,
"schema": {
"anyOf": [
{
"enum": [
"pending_review",
"active",
"rejected"
],
"type": "string"
},
{
"type": "null"
}
],
"title": "Approval Status"
}
}
],
"responses": {
@ -4963,7 +5142,7 @@
]
},
"post": {
"description": "Register a new plugin in the LiteLLM marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket. Claude Code will clone from the git source\nwhen users install.\n\nThis endpoint is create-only and never overwrites. If a plugin with\nthe same name already exists it returns 409 Conflict; use\nPUT /claude-code/plugins/{plugin_name} to update an existing plugin.\n\nParameters:\n - name: Plugin name (kebab-case)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Registration status (action is always \"created\") and plugin information.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"my-plugin\",\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"1.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```",
"description": "Register a new plugin in the LiteLLM marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket. Claude Code will clone from the git source\nwhen users install.\n\nThis endpoint is create-only and never overwrites. If a plugin with\nthe same name already exists it returns 409 Conflict; use\nPUT /claude-code/plugins/{plugin_name} to update an existing plugin.\n\nCallers that are not proxy admins are self-service submitters: the skill\nis stored with approval_status=pending_review and stays disabled until an\nadmin approves it via POST /claude-code/plugins/{plugin_name}/approve.\n\nParameters:\n - name: Plugin name (kebab-case)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Registration status (\"created\" for admins, \"submitted_for_review\" otherwise)\n and plugin information.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"my-plugin\",\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"1.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```",
"operationId": "register_plugin_claude_code_plugins_post",
"requestBody": {
"content": {
@ -5010,7 +5189,7 @@
},
"/claude-code/plugins/{plugin_name}": {
"delete": {
"description": "Delete a plugin from the marketplace.\n\nParameters:\n - plugin_name: The name of the plugin to delete",
"description": "Delete a plugin from the marketplace. Admins can delete any skill; a\nsubmitter can withdraw one they submitted.\n\nParameters:\n - plugin_name: The name of the plugin to delete",
"operationId": "delete_plugin_claude_code_plugins__plugin_name__delete",
"parameters": [
{
@ -5098,7 +5277,7 @@
]
},
"put": {
"description": "Update an existing plugin in the LiteLLM marketplace.\n\nThe plugin is identified by its name in the path, which is the resource\nidentity and cannot be changed here. This is a full replace, not a merge:\nthe manifest is rebuilt from the request body, so any optional field left\nout is reset to its default (e.g. an omitted version is cleared, not kept).\nSend the full desired state.\n\nReturns 404 if no plugin with the given name exists; use\nPOST /claude-code/plugins to create a new plugin.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Update status (action is always \"updated\") and plugin information.\n\nExample:\n ```bash\n curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"2.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```",
"description": "Update an existing plugin in the LiteLLM marketplace.\n\nThe plugin is identified by its name in the path, which is the resource\nidentity and cannot be changed here. This is a full replace, not a merge:\nthe manifest is rebuilt from the request body, so any optional field left\nout is reset to its default (e.g. an omitted version is cleared, not kept).\nSend the full desired state.\n\nReturns 404 if no plugin with the given name exists; use\nPOST /claude-code/plugins to create a new plugin.\n\nAdmins can update any skill and the review state is left untouched. A\nsubmitter can only update their own skill, and doing so sends it back to\npending review, since the content an admin approved has changed.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Update status (action is always \"updated\") and plugin information.\n\nExample:\n ```bash\n curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"2.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```",
"operationId": "update_plugin_claude_code_plugins__plugin_name__put",
"parameters": [
{
@ -5154,9 +5333,57 @@
]
}
},
"/claude-code/plugins/{plugin_name}/approve": {
"post": {
"description": "Approve a submitted skill (admin only).\n\nApproving sets approval_status=active and publishes the skill to\nmarketplace.json and the public Skill Hub.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins/my-skill/approve \\\n -H \"Authorization: Bearer sk-admin-...\"\n ```",
"operationId": "approve_plugin_claude_code_plugins__plugin_name__approve_post",
"parameters": [
{
"in": "path",
"name": "plugin_name",
"required": true,
"schema": {
"title": "Plugin Name",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReviewPluginResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Approve Plugin",
"tags": [
"claude_code_marketplace"
]
}
},
"/claude-code/plugins/{plugin_name}/disable": {
"post": {
"description": "Disable a plugin without deleting it.\n\nParameters:\n - plugin_name: The name of the plugin to disable",
"description": "Disable a plugin without deleting it. Proxy admins only.\n\nParameters:\n - plugin_name: The name of the plugin to disable",
"operationId": "disable_plugin_claude_code_plugins__plugin_name__disable_post",
"parameters": [
{
@ -5202,7 +5429,7 @@
},
"/claude-code/plugins/{plugin_name}/enable": {
"post": {
"description": "Enable a disabled plugin.\n\nParameters:\n - plugin_name: The name of the plugin to enable",
"description": "Enable a disabled plugin. Proxy admins only.\n\nA skill that has not been approved cannot be enabled here: approve it\nthrough POST /claude-code/plugins/{plugin_name}/approve instead, so the\nreviewer is recorded on the row.\n\nParameters:\n - plugin_name: The name of the plugin to enable",
"operationId": "enable_plugin_claude_code_plugins__plugin_name__enable_post",
"parameters": [
{
@ -5245,6 +5472,64 @@
"claude_code_marketplace"
]
}
},
"/claude-code/plugins/{plugin_name}/reject": {
"post": {
"description": "Reject a submitted skill (admin only).\n\nThe row is kept unpublished so the submitter can read review_notes and fix the submission.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins/my-skill/reject \\\n -H \"Authorization: Bearer sk-admin-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"review_notes\": \"point the source at the skill folder\"}'\n ```",
"operationId": "reject_plugin_claude_code_plugins__plugin_name__reject_post",
"parameters": [
{
"in": "path",
"name": "plugin_name",
"required": true,
"schema": {
"title": "Plugin Name",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RejectPluginRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReviewPluginResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Reject Plugin",
"tags": [
"claude_code_marketplace"
]
}
}
}
},

View file

@ -753,6 +753,10 @@ class LiteLLMRoutes(enum.Enum):
# Create/update/delete and test_connection stay admin-only.
"/search_tools/list",
"/search_tools/ui/available_providers",
# Self-service skill submission. Approve/reject stay admin-only.
"/claude-code/marketplace.json",
"/claude-code/plugins",
"/claude-code/plugins/{plugin_name}",
]
+ spend_tracking_routes
+ key_management_routes

View file

@ -13,14 +13,21 @@ Endpoints:
/claude-code/plugins/{name} - PUT - Update an existing plugin
/claude-code/plugins/{name}/enable - POST - Enable a plugin
/claude-code/plugins/{name}/disable - POST - Disable a plugin
/claude-code/plugins/{name}/approve - POST - Approve a submitted plugin (admin)
/claude-code/plugins/{name}/reject - POST - Reject a submitted plugin (admin)
/claude-code/plugins/{name} - DELETE - Delete a plugin
Skills registered by a non-admin are submissions: they are stored with
approval_status="pending_review" and disabled until an administrator approves
them, so only approved skills reach marketplace.json and the public Skill Hub.
This mirrors the MCP server and guardrail submission flows.
"""
import json
import re
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from typing import Final, Protocol, TypedDict
from typing import Annotated, Final, Protocol, TypedDict
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
@ -28,14 +35,25 @@ from fastapi.responses import JSONResponse
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.resource_ownership import (
get_primary_resource_owner_scope,
get_resource_owner_scopes,
is_proxy_admin,
)
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
from litellm.types.proxy.claude_code_endpoints import (
SKILL_ACTIVE,
SKILL_PENDING_REVIEW,
SKILL_REJECTED,
ListPluginsResponse,
PluginListItem,
PluginResponse,
PluginSpec,
RegisterPluginRequest,
RegisterPluginResponse,
RejectPluginRequest,
ReviewPluginResponse,
SkillApprovalStatus,
UpdatePluginRequest,
)
@ -49,6 +67,10 @@ class _PluginRecord(Protocol):
description: str | None
manifest_json: str | None
enabled: bool
approval_status: str | None
review_notes: str | None
reviewed_by: str | None
reviewed_at: datetime | None
created_at: datetime | None
updated_at: datetime | None
created_by: str | None
@ -65,6 +87,49 @@ class _MarketplaceEntry(TypedDict, total=False):
category: object
def published_skill_filter() -> dict[str, object]: # mutable-ok: prisma query arguments must be plain dicts
"""Where-clause for the skills served to users: admin-approved and enabled."""
return {"enabled": True, "approval_status": SKILL_ACTIVE} # mutable-ok: prisma query arguments are dicts
def _as_approval_status(raw: str | None) -> SkillApprovalStatus:
"""Rows written before approval existed, and any hand-edited row, read back as active."""
match raw:
case "pending_review":
return SKILL_PENDING_REVIEW
case "rejected":
return SKILL_REJECTED
case _:
return SKILL_ACTIVE
def _caller_can_see(plugin: "_PluginRecord", user_api_key_dict: UserAPIKeyAuth) -> bool:
return (
is_proxy_admin(user_api_key_dict)
or _as_approval_status(plugin.approval_status) == SKILL_ACTIVE
or plugin.created_by in get_resource_owner_scopes(user_api_key_dict)
)
def _list_plugins_filter(
*,
enabled_only: bool,
approval_status: SkillApprovalStatus | None,
user_api_key_dict: UserAPIKeyAuth,
) -> dict[str, object]: # mutable-ok: prisma query arguments must be plain dicts
status_terms: Final[tuple[tuple[str, object], ...]] = (
*((("enabled", True),) if enabled_only else ()),
*((("approval_status", approval_status),) if approval_status is not None else ()),
)
if is_proxy_admin(user_api_key_dict):
return dict(status_terms) # mutable-ok: prisma query arguments are dicts
owner_scopes: Final = get_resource_owner_scopes(user_api_key_dict)
own_skills: Final = ({"created_by": {"in": owner_scopes}},) if owner_scopes else () # mutable-ok: prisma dicts
visible: Final = [{"approval_status": SKILL_ACTIVE}, *own_skills] # mutable-ok: prisma query arguments are dicts
return dict((*status_terms, ("OR", visible))) # mutable-ok: prisma query arguments are dicts
async def _get_prisma_client() -> object:
"""Get the prisma client from proxy_server."""
from litellm.proxy.proxy_server import prisma_client
@ -102,7 +167,7 @@ async def get_marketplace():
prisma_client: Final = await _get_prisma_client()
plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many(
where={"enabled": True}
where=published_skill_filter()
)
plugin_list: Final = []
@ -242,6 +307,10 @@ async def register_plugin(
the same name already exists it returns 409 Conflict; use
PUT /claude-code/plugins/{plugin_name} to update an existing plugin.
Callers that are not proxy admins are self-service submitters: the skill
is stored with approval_status=pending_review and stays disabled until an
admin approves it via POST /claude-code/plugins/{plugin_name}/approve.
Parameters:
- name: Plugin name (kebab-case)
- source: Git source reference (github, url, or git-subdir format)
@ -253,7 +322,8 @@ async def register_plugin(
- category: Plugin category (optional)
Returns:
Registration status (action is always "created") and plugin information.
Registration status ("created" for admins, "submitted_for_review" otherwise)
and plugin information.
Example:
```bash
@ -288,6 +358,8 @@ async def register_plugin(
raise _name_conflict_error(request.name)
manifest: Final[Mapping[str, object]] = _build_plugin_manifest(request.name, request)
submitted_for_review: Final = not is_proxy_admin(user_api_key_dict)
approval_status: Final[SkillApprovalStatus] = SKILL_PENDING_REVIEW if submitted_for_review else SKILL_ACTIVE
try:
plugin: Final[_PluginRecord] = await ClaudeCodePluginRepository(prisma_client).table.create(
@ -297,20 +369,21 @@ async def register_plugin(
"description": request.description,
"manifest_json": json.dumps(manifest),
"files_json": "{}",
"enabled": True,
"enabled": not submitted_for_review,
"approval_status": approval_status,
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
"created_by": user_api_key_dict.user_id,
"created_by": user_api_key_dict.user_id or get_primary_resource_owner_scope(user_api_key_dict),
}
)
except UniqueViolationError:
raise _name_conflict_error(request.name)
verbose_proxy_logger.info("Plugin %s created successfully", request.name)
verbose_proxy_logger.info("Plugin %s created with approval_status=%s", request.name, approval_status)
return RegisterPluginResponse(
status="success",
action="created",
action="submitted_for_review" if submitted_for_review else "created",
plugin=PluginResponse(
id=plugin.id,
name=plugin.name,
@ -318,6 +391,7 @@ async def register_plugin(
description=plugin.description,
source=request.source,
enabled=plugin.enabled,
approval_status=approval_status,
),
)
@ -339,23 +413,32 @@ async def register_plugin(
)
async def list_plugins(
enabled_only: bool = False,
approval_status: SkillApprovalStatus | None = None,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
List all plugins in the marketplace.
List plugins in the marketplace.
Admins see every skill, including submissions awaiting review. Everyone
else sees approved skills plus their own submissions, so a submitter can
track the status of what they sent in.
Parameters:
- enabled_only: If true, only return enabled plugins
- approval_status: Filter to one approval state, e.g. `pending_review` for the admin review queue
Returns:
List of plugins with their metadata.
List of plugins with their metadata and review state.
"""
try:
prisma_client: Final = await _get_prisma_client()
where: Final = {"enabled": True} if enabled_only else {}
plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many(
where=where
where=_list_plugins_filter(
enabled_only=enabled_only,
approval_status=approval_status,
user_api_key_dict=user_api_key_dict,
)
)
plugin_list: Final = []
@ -377,6 +460,11 @@ async def list_plugins(
domain=manifest.get("domain"),
namespace=manifest.get("namespace"),
enabled=p.enabled,
approval_status=_as_approval_status(p.approval_status),
review_notes=p.review_notes,
reviewed_by=p.reviewed_by,
reviewed_at=p.reviewed_at.isoformat() if p.reviewed_at else None,
created_by=p.created_by,
created_at=p.created_at.isoformat() if p.created_at else None,
updated_at=p.updated_at.isoformat() if p.updated_at else None,
)
@ -425,7 +513,7 @@ async def get_plugin(
where={"name": plugin_name}
)
if not plugin:
if not plugin or not _caller_can_see(plugin, user_api_key_dict):
raise HTTPException(
status_code=404,
detail={"error": f"Plugin '{plugin_name}' not found"},
@ -444,6 +532,10 @@ async def get_plugin(
"keywords": manifest.get("keywords"),
"category": manifest.get("category"),
"enabled": plugin.enabled,
"approval_status": _as_approval_status(plugin.approval_status),
"review_notes": plugin.review_notes,
"reviewed_by": plugin.reviewed_by,
"reviewed_at": plugin.reviewed_at.isoformat() if plugin.reviewed_at else None,
"created_at": plugin.created_at.isoformat() if plugin.created_at else None,
"updated_at": plugin.updated_at.isoformat() if plugin.updated_at else None,
"created_by": plugin.created_by,
@ -468,6 +560,7 @@ async def get_plugin(
async def update_plugin(
plugin_name: str,
request: UpdatePluginRequest,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
):
"""
Update an existing plugin in the LiteLLM marketplace.
@ -481,6 +574,10 @@ async def update_plugin(
Returns 404 if no plugin with the given name exists; use
POST /claude-code/plugins to create a new plugin.
Admins can update any skill and the review state is left untouched. A
submitter can only update their own skill, and doing so sends it back to
pending review, since the content an admin approved has changed.
Parameters:
- plugin_name: Name of the plugin to update (path parameter)
- source: Git source reference (github, url, or git-subdir format)
@ -519,6 +616,10 @@ async def update_plugin(
if not existing:
raise _error_response(404, f"Plugin '{plugin_name}' not found")
is_admin: Final = is_proxy_admin(user_api_key_dict)
if not is_admin and existing.created_by not in get_resource_owner_scopes(user_api_key_dict):
raise _error_response(403, "Only proxy admins or the submitter can update this skill")
manifest: Final[Mapping[str, object]] = _build_plugin_manifest(plugin_name, request)
plugin: Final[_PluginRecord] = await ClaudeCodePluginRepository(prisma_client).table.update(
@ -529,6 +630,9 @@ async def update_plugin(
"manifest_json": json.dumps(manifest),
"files_json": "{}",
"updated_at": datetime.now(timezone.utc),
**(
{} if is_admin else {"approval_status": SKILL_PENDING_REVIEW, "enabled": False}
), # mutable-ok: prisma query arguments must be plain dicts
},
)
@ -544,6 +648,7 @@ async def update_plugin(
description=plugin.description,
source=request.source,
enabled=plugin.enabled,
approval_status=_as_approval_status(plugin.approval_status),
),
)
@ -554,6 +659,116 @@ async def update_plugin(
raise _error_response(500, f"Update failed: {e}")
async def _record_review(
*,
plugin_name: str,
approval_status: SkillApprovalStatus,
review_notes: str | None,
user_api_key_dict: UserAPIKeyAuth,
) -> ReviewPluginResponse:
if not is_proxy_admin(user_api_key_dict):
raise _error_response(403, "Admin access required to review submitted skills")
prisma_client: Final = await _get_prisma_client()
repository: Final = ClaudeCodePluginRepository(prisma_client)
existing: Final[_PluginRecord | None] = await repository.table.find_unique(
where={"name": plugin_name} # mutable-ok: prisma query arguments must be plain dicts
)
if not existing:
raise _error_response(404, f"Plugin '{plugin_name}' not found")
if _as_approval_status(existing.approval_status) == approval_status:
raise _error_response(400, f"Skill '{plugin_name}' is already {approval_status}")
reviewed_at: Final = datetime.now(timezone.utc)
plugin: Final[_PluginRecord] = await repository.table.update(
where={"name": plugin_name}, # mutable-ok: prisma query arguments must be plain dicts
data={ # mutable-ok: prisma query arguments must be plain dicts
"approval_status": approval_status,
"review_notes": review_notes,
"reviewed_by": user_api_key_dict.user_id,
"reviewed_at": reviewed_at,
"enabled": approval_status == SKILL_ACTIVE,
"updated_at": reviewed_at,
},
)
verbose_proxy_logger.info("Plugin %s reviewed: approval_status=%s", plugin_name, approval_status)
return ReviewPluginResponse(
status="success",
name=plugin.name,
approval_status=approval_status,
enabled=plugin.enabled,
reviewed_by=plugin.reviewed_by,
reviewed_at=plugin.reviewed_at.isoformat() if plugin.reviewed_at else None,
review_notes=plugin.review_notes,
)
@router.post(
"/claude-code/plugins/{plugin_name}/approve",
tags=["Claude Code Marketplace"],
dependencies=[Depends(user_api_key_auth)],
response_model=ReviewPluginResponse,
)
async def approve_plugin(
plugin_name: str,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
):
"""
Approve a submitted skill (admin only).
Approving sets approval_status=active and publishes the skill to
marketplace.json and the public Skill Hub.
Example:
```bash
curl -X POST http://localhost:4000/claude-code/plugins/my-skill/approve \\
-H "Authorization: Bearer sk-admin-..."
```
"""
return await _record_review(
plugin_name=plugin_name,
approval_status=SKILL_ACTIVE,
review_notes=None,
user_api_key_dict=user_api_key_dict,
)
@router.post(
"/claude-code/plugins/{plugin_name}/reject",
tags=["Claude Code Marketplace"],
dependencies=[Depends(user_api_key_auth)],
response_model=ReviewPluginResponse,
)
async def reject_plugin(
plugin_name: str,
request: RejectPluginRequest,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
):
"""
Reject a submitted skill (admin only).
The row is kept unpublished so the submitter can read review_notes and fix the submission.
Example:
```bash
curl -X POST http://localhost:4000/claude-code/plugins/my-skill/reject \\
-H "Authorization: Bearer sk-admin-..." \\
-H "Content-Type: application/json" \\
-d '{"review_notes": "point the source at the skill folder"}'
```
"""
return await _record_review(
plugin_name=plugin_name,
approval_status=SKILL_REJECTED,
review_notes=request.review_notes,
user_api_key_dict=user_api_key_dict,
)
@router.post(
"/claude-code/plugins/{plugin_name}/enable",
tags=["Claude Code Marketplace"],
@ -564,11 +779,18 @@ async def enable_plugin(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Enable a disabled plugin.
Enable a disabled plugin. Proxy admins only.
A skill that has not been approved cannot be enabled here: approve it
through POST /claude-code/plugins/{plugin_name}/approve instead, so the
reviewer is recorded on the row.
Parameters:
- plugin_name: The name of the plugin to enable
"""
if not is_proxy_admin(user_api_key_dict):
raise _error_response(403, "Only proxy admins can publish skills")
try:
prisma_client: Final = await _get_prisma_client()
@ -581,6 +803,13 @@ async def enable_plugin(
detail={"error": f"Plugin '{plugin_name}' not found"},
)
if _as_approval_status(plugin.approval_status) != SKILL_ACTIVE:
raise _error_response(
409,
f"Skill '{plugin_name}' is awaiting review. Approve it via "
f"POST /claude-code/plugins/{plugin_name}/approve",
)
await ClaudeCodePluginRepository(prisma_client).table.update(
where={"name": plugin_name},
data={"enabled": True, "updated_at": datetime.now(timezone.utc)},
@ -609,11 +838,14 @@ async def disable_plugin(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Disable a plugin without deleting it.
Disable a plugin without deleting it. Proxy admins only.
Parameters:
- plugin_name: The name of the plugin to disable
"""
if not is_proxy_admin(user_api_key_dict):
raise _error_response(403, "Only proxy admins can unpublish skills")
try:
prisma_client: Final = await _get_prisma_client()
@ -654,7 +886,8 @@ async def delete_plugin(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Delete a plugin from the marketplace.
Delete a plugin from the marketplace. Admins can delete any skill; a
submitter can withdraw one they submitted.
Parameters:
- plugin_name: The name of the plugin to delete
@ -671,6 +904,11 @@ async def delete_plugin(
detail={"error": f"Plugin '{plugin_name}' not found"},
)
if not is_proxy_admin(user_api_key_dict) and plugin.created_by not in get_resource_owner_scopes(
user_api_key_dict
):
raise _error_response(403, "Only proxy admins or the submitter can delete this skill")
await ClaudeCodePluginRepository(prisma_client).table.delete(where={"name": plugin_name})
verbose_proxy_logger.info("Plugin %s deleted", plugin_name)

View file

@ -248,9 +248,10 @@ async def get_mcp_servers():
tags=["public", "Claude Code Marketplace"],
)
async def public_skill_hub():
"""Return enabled (public) Claude Code skills — no auth required."""
"""Return approved, enabled (public) Claude Code skills. No auth required."""
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
_get_prisma_client,
published_skill_filter,
)
from litellm.types.proxy.claude_code_endpoints import (
ListPluginsResponse,
@ -259,7 +260,7 @@ async def public_skill_hub():
try:
prisma_client: Final = await _get_prisma_client()
plugins: Final = await ClaudeCodePluginRepository(prisma_client).table.find_many(where={"enabled": True})
plugins: Final = await ClaudeCodePluginRepository(prisma_client).table.find_many(where=published_skill_filter())
items: Final = []
for plugin in plugins:
raw = plugin.manifest_json or {}

View file

@ -1338,17 +1338,22 @@ model LiteLLM_AccessGroupTable {
}
// Claude Code Plugin Marketplace table
model LiteLLM_ClaudeCodePluginTable {
id String @id @default(uuid())
name String @unique
version String?
description String?
manifest_json String?
files_json String? @default("{}")
enabled Boolean @default(true)
created_at DateTime? @default(now())
updated_at DateTime? @default(now()) @updatedAt
created_by String?
id String @id @default(uuid())
name String @unique
version String?
description String?
manifest_json String?
files_json String? @default("{}")
enabled Boolean @default(true)
approval_status String? @default("active")
review_notes String?
reviewed_by String?
reviewed_at DateTime?
created_at DateTime? @default(now())
updated_at DateTime? @default(now()) @updatedAt
created_by String?
@@index([approval_status])
@@map("LiteLLM_ClaudeCodePluginTable")
}

View file

@ -2,8 +2,16 @@
Claude Code Marketplace endpoint types for LiteLLM Proxy
"""
from typing import Final, Literal
from pydantic import BaseModel, Field
SkillApprovalStatus = Literal["pending_review", "active", "rejected"]
SKILL_PENDING_REVIEW: Final[SkillApprovalStatus] = "pending_review"
SKILL_ACTIVE: Final[SkillApprovalStatus] = "active"
SKILL_REJECTED: Final[SkillApprovalStatus] = "rejected"
class PluginAuthor(BaseModel):
"""Plugin author information."""
@ -78,16 +86,35 @@ class PluginResponse(BaseModel):
description: str | None = Field(None, description="Plugin description")
source: dict[str, str] = Field(..., description="Git source reference")
enabled: bool = Field(..., description="Whether plugin is enabled")
approval_status: SkillApprovalStatus = Field("active", description="Administrator approval state")
class RegisterPluginResponse(BaseModel):
"""Response from plugin registration."""
status: str = Field(..., description="Operation status")
action: str = Field(..., description="Action taken (created/updated)")
action: str = Field(..., description="Action taken (created/submitted_for_review/updated)")
plugin: PluginResponse = Field(..., description="Plugin information")
class RejectPluginRequest(BaseModel):
"""Administrator rejection of a submitted skill."""
review_notes: str | None = Field(None, description="Reviewer feedback shown to the submitter")
class ReviewPluginResponse(BaseModel):
"""Response from approving or rejecting a submitted skill."""
status: str = Field(..., description="Operation status")
name: str = Field(..., description="Skill name")
approval_status: SkillApprovalStatus = Field(..., description="Resulting approval state")
enabled: bool = Field(..., description="Whether the skill is now served to users")
reviewed_by: str | None = Field(None, description="User id of the reviewing administrator")
reviewed_at: str | None = Field(None, description="ISO timestamp of the review")
review_notes: str | None = Field(None, description="Reviewer feedback")
class PluginListItem(BaseModel):
"""Plugin item in list responses."""
@ -103,6 +130,11 @@ class PluginListItem(BaseModel):
domain: str | None = None
namespace: str | None = None
enabled: bool
approval_status: SkillApprovalStatus = "active"
review_notes: str | None = None
reviewed_by: str | None = None
reviewed_at: str | None = None
created_by: str | None = None
created_at: str | None
updated_at: str | None

View file

@ -1338,17 +1338,22 @@ model LiteLLM_AccessGroupTable {
}
// Claude Code Plugin Marketplace table
model LiteLLM_ClaudeCodePluginTable {
id String @id @default(uuid())
name String @unique
version String?
description String?
manifest_json String?
files_json String? @default("{}")
enabled Boolean @default(true)
created_at DateTime? @default(now())
updated_at DateTime? @default(now()) @updatedAt
created_by String?
id String @id @default(uuid())
name String @unique
version String?
description String?
manifest_json String?
files_json String? @default("{}")
enabled Boolean @default(true)
approval_status String? @default("active")
review_notes String?
reviewed_by String?
reviewed_at DateTime?
created_at DateTime? @default(now())
updated_at DateTime? @default(now()) @updatedAt
created_by String?
@@index([approval_status])
@@map("LiteLLM_ClaudeCodePluginTable")
}

View file

@ -15,11 +15,19 @@ from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.proxy_server import LitellmUserRoles
from litellm.types.proxy.claude_code_endpoints import (
RegisterPluginRequest,
RejectPluginRequest,
UpdatePluginRequest,
)
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
approve_plugin,
delete_plugin,
disable_plugin,
enable_plugin,
get_marketplace,
get_plugin,
list_plugins,
register_plugin,
reject_plugin,
update_plugin,
)
@ -35,20 +43,41 @@ def _make_mock_prisma():
async def _find_unique(where):
return store.get(where.get("name"))
def _matches(record, where):
for key, expected in where.items():
if key == "OR":
if not any(_matches(record, clause) for clause in expected):
return False
continue
actual = getattr(record, key)
if isinstance(expected, dict) and "in" in expected:
if actual not in expected["in"]:
return False
elif actual != expected:
return False
return True
async def _find_many(where=None):
records = list(store.values())
if where and "enabled" in where:
return [r for r in records if r.enabled == where["enabled"]]
return records
if not where:
return records
return [r for r in records if _matches(r, where)]
async def _create(data):
record = MagicMock()
record.id = "test-id"
record.id = f"test-id-{data['name']}"
record.name = data["name"]
record.version = data.get("version")
record.description = data.get("description")
record.manifest_json = data.get("manifest_json", "{}")
record.enabled = data.get("enabled", True)
record.approval_status = data.get("approval_status", "active")
record.review_notes = data.get("review_notes")
record.reviewed_by = data.get("reviewed_by")
record.reviewed_at = data.get("reviewed_at")
record.created_by = data.get("created_by")
record.created_at = data.get("created_at")
record.updated_at = data.get("updated_at")
store[data["name"]] = record
return record
@ -151,6 +180,7 @@ async def test_update_plugin_replaces_existing_source():
response = await update_plugin(
plugin_name=name,
request=UpdatePluginRequest(source=new_source, version="2.0.0", description="updated"),
user_api_key_dict=_USER,
)
assert response.status == "success"
@ -170,6 +200,7 @@ async def test_update_plugin_not_found():
await update_plugin(
plugin_name="does-not-exist",
request=UpdatePluginRequest(source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_USER,
)
assert exc_info.value.status_code == 404
@ -213,6 +244,7 @@ async def test_update_plugin_db_error_maps_to_structured_500():
await update_plugin(
plugin_name=name,
request=UpdatePluginRequest(source={"source": "github", "repo": "org/replacement"}),
user_api_key_dict=_USER,
)
assert exc_info.value.status_code == 500
@ -341,3 +373,269 @@ async def test_register_plugin_unknown_source_type():
assert exc_info.value.status_code == 400
assert "git-subdir" in exc_info.value.detail["error"]
_SUBMITTER = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
api_key="sk-submitter",
user_id="submitter-user",
)
_OTHER_USER = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
api_key="sk-other",
user_id="other-user",
)
async def _stored(name: str):
table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
return await table.find_unique(where={"name": name})
@pytest.mark.asyncio
async def test_non_admin_submission_is_pending_and_unpublished():
response = await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
assert response.action == "submitted_for_review"
assert response.plugin.approval_status == "pending_review"
assert response.plugin.enabled is False
stored = await _stored("submitted-skill")
assert stored.approval_status == "pending_review"
assert stored.enabled is False
assert stored.created_by == "submitter-user"
@pytest.mark.asyncio
async def test_admin_registration_stays_auto_approved():
response = await register_plugin(
request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_USER,
)
assert response.action == "created"
assert response.plugin.approval_status == "active"
assert response.plugin.enabled is True
@pytest.mark.asyncio
async def test_pending_submission_is_not_served_by_marketplace():
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
await register_plugin(
request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_USER,
)
body = json.loads((await get_marketplace()).body)
assert [plugin["name"] for plugin in body["plugins"]] == ["admin-skill"]
@pytest.mark.asyncio
async def test_approval_publishes_submission_and_records_reviewer():
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
response = await approve_plugin(plugin_name="submitted-skill", user_api_key_dict=_USER)
assert response.approval_status == "active"
assert response.enabled is True
assert response.reviewed_by == "test-user"
assert response.reviewed_at is not None
body = json.loads((await get_marketplace()).body)
assert [plugin["name"] for plugin in body["plugins"]] == ["submitted-skill"]
@pytest.mark.asyncio
async def test_rejection_keeps_submission_unpublished_with_notes():
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
response = await reject_plugin(
plugin_name="submitted-skill",
request=RejectPluginRequest(review_notes="point at the skill folder"),
user_api_key_dict=_USER,
)
assert response.approval_status == "rejected"
assert response.enabled is False
body = json.loads((await get_marketplace()).body)
assert body["plugins"] == []
stored = await _stored("submitted-skill")
assert stored.review_notes == "point at the skill folder"
@pytest.mark.asyncio
async def test_non_admin_cannot_review():
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
with pytest.raises(HTTPException) as exc_info:
await approve_plugin(plugin_name="submitted-skill", user_api_key_dict=_SUBMITTER)
assert exc_info.value.status_code == 403
stored = await _stored("submitted-skill")
assert stored.approval_status == "pending_review"
assert stored.enabled is False
@pytest.mark.asyncio
async def test_review_unknown_skill_returns_404():
with pytest.raises(HTTPException) as exc_info:
await approve_plugin(plugin_name="does-not-exist", user_api_key_dict=_USER)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_submitter_sees_own_pending_skill_but_not_another_users():
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
await register_plugin(
request=RegisterPluginRequest(name="other-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_OTHER_USER,
)
await register_plugin(
request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_USER,
)
submitter_view = await list_plugins(user_api_key_dict=_SUBMITTER)
admin_view = await list_plugins(user_api_key_dict=_USER)
assert sorted(p.name for p in submitter_view.plugins) == ["admin-skill", "submitted-skill"]
assert sorted(p.name for p in admin_view.plugins) == ["admin-skill", "other-skill", "submitted-skill"]
@pytest.mark.asyncio
async def test_admin_can_filter_the_pending_queue():
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
await register_plugin(
request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_USER,
)
queue = await list_plugins(approval_status="pending_review", user_api_key_dict=_USER)
assert [p.name for p in queue.plugins] == ["submitted-skill"]
@pytest.mark.asyncio
async def test_get_plugin_hides_another_users_pending_submission():
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
with pytest.raises(HTTPException) as exc_info:
await get_plugin(plugin_name="submitted-skill", user_api_key_dict=_OTHER_USER)
assert exc_info.value.status_code == 404
own = await get_plugin(plugin_name="submitted-skill", user_api_key_dict=_SUBMITTER)
assert own["approval_status"] == "pending_review"
@pytest.mark.asyncio
async def test_enable_cannot_bypass_review():
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
with pytest.raises(HTTPException) as exc_info:
await enable_plugin(plugin_name="submitted-skill", user_api_key_dict=_USER)
assert exc_info.value.status_code == 409
stored = await _stored("submitted-skill")
assert stored.enabled is False
@pytest.mark.asyncio
async def test_non_admin_cannot_publish_or_unpublish():
await register_plugin(
request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_USER,
)
with pytest.raises(HTTPException) as exc_info:
await disable_plugin(plugin_name="admin-skill", user_api_key_dict=_SUBMITTER)
assert exc_info.value.status_code == 403
stored = await _stored("admin-skill")
assert stored.enabled is True
@pytest.mark.asyncio
async def test_submitter_edit_sends_skill_back_to_review():
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
await approve_plugin(plugin_name="submitted-skill", user_api_key_dict=_USER)
await update_plugin(
plugin_name="submitted-skill",
request=UpdatePluginRequest(source={"source": "github", "repo": "org/changed"}),
user_api_key_dict=_SUBMITTER,
)
stored = await _stored("submitted-skill")
assert stored.approval_status == "pending_review"
assert stored.enabled is False
@pytest.mark.asyncio
async def test_unrelated_user_cannot_edit_or_delete_a_submission():
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
with pytest.raises(HTTPException) as update_exc:
await update_plugin(
plugin_name="submitted-skill",
request=UpdatePluginRequest(source={"source": "github", "repo": "org/hijacked"}),
user_api_key_dict=_OTHER_USER,
)
assert update_exc.value.status_code == 403
with pytest.raises(HTTPException) as delete_exc:
await delete_plugin(plugin_name="submitted-skill", user_api_key_dict=_OTHER_USER)
assert delete_exc.value.status_code == 403
assert await _stored("submitted-skill") is not None
@pytest.mark.asyncio
async def test_approving_an_already_active_skill_is_rejected():
await register_plugin(
request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_USER,
)
with pytest.raises(HTTPException) as exc_info:
await approve_plugin(plugin_name="admin-skill", user_api_key_dict=_USER)
assert exc_info.value.status_code == 400

View file

@ -3298,3 +3298,29 @@ def test_user_daily_activity_aggregated_not_covered_by_prefix_match():
route="/user/daily/activity/aggregated",
allowed_routes=["/user/daily/activity"],
)
@pytest.mark.parametrize(
"route, allowed",
[
("/claude-code/marketplace.json", True),
("/claude-code/plugins", True),
("/claude-code/plugins/my-skill", True),
("/claude-code/plugins/my-skill/approve", False),
("/claude-code/plugins/my-skill/reject", False),
("/claude-code/plugins/my-skill/enable", False),
("/claude-code/plugins/my-skill/disable", False),
],
)
def test_skill_submission_routes_open_to_internal_users_but_review_stays_admin_only(
route, allowed
):
"""Internal users can submit and track their own skills, while approving,
rejecting and publishing a skill stays with proxy admins.
"""
assert (
RouteChecks.check_route_access(
route=route, allowed_routes=LiteLLMRoutes.internal_user_routes.value
)
is allowed
)

View file

@ -954,3 +954,48 @@ def test_public_mcp_hub_does_not_expose_upstream_url():
assert all("url" not in item for item in data)
assert secret_url not in response.text
app.dependency_overrides.clear()
def test_public_skill_hub_serves_only_approved_and_enabled_skills():
"""A submission awaiting review is enabled=False and approval_status=pending_review, so
/public/skill_hub must not serve it even if a row is enabled without approval."""
import json as json_lib
import litellm.proxy.proxy_server
app = FastAPI()
app.include_router(router)
client = TestClient(app)
def _record(name, enabled, approval_status):
record = MagicMock()
record.id = name
record.name = name
record.version = "1.0.0"
record.description = name
record.manifest_json = json_lib.dumps({"source": {"source": "github", "repo": f"org/{name}"}})
record.enabled = enabled
record.approval_status = approval_status
record.created_at = datetime.now(timezone.utc)
record.updated_at = datetime.now(timezone.utc)
record.created_by = "someone"
return record
records = [
_record("approved-skill", True, "active"),
_record("pending-skill", False, "pending_review"),
_record("enabled-but-unapproved", True, "pending_review"),
_record("rejected-skill", True, "rejected"),
]
async def _find_many(where=None):
return [r for r in records if all(getattr(r, key) == value for key, value in (where or {}).items())]
mock_client = MagicMock()
mock_client.db.litellm_claudecodeplugintable.find_many = AsyncMock(side_effect=_find_many)
with patch.object(litellm.proxy.proxy_server, "prisma_client", mock_client):
response = client.get("/public/skill_hub")
assert response.status_code == 200
assert [plugin["name"] for plugin in response.json()["plugins"]] == ["approved-skill"]

View file

@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking";
import { getClaudeCodePluginsList, deleteClaudeCodePlugin, reviewClaudeCodePlugin } from "@/components/networking";
import type { Plugin } from "@/components/claude_code_plugins/types";
import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel";
@ -10,6 +10,7 @@ import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel";
vi.mock("@/components/networking", () => ({
getClaudeCodePluginsList: vi.fn(),
deleteClaudeCodePlugin: vi.fn(),
reviewClaudeCodePlugin: vi.fn(),
}));
vi.mock("./PluginTable", () => ({
@ -18,21 +19,28 @@ vi.mock("./PluginTable", () => ({
isLoading,
pluginsList,
onDeleteClick,
onReviewClick,
}: {
isLoading: boolean;
pluginsList: Plugin[];
onDeleteClick: (pluginName: string, displayName: string) => void;
onReviewClick: (plugin: Plugin, decision: "approve" | "reject") => void;
}) => (
<div data-testid="plugin-table">
{isLoading ? "table-loading" : "table-loaded"}
{pluginsList.map((plugin) => (
<button
key={plugin.id}
data-testid={`row-delete-${plugin.id}`}
onClick={() => onDeleteClick(plugin.name, plugin.name)}
>
row delete
</button>
<div key={plugin.id}>
<span>{`row-${plugin.name}`}</span>
<button data-testid={`row-delete-${plugin.id}`} onClick={() => onDeleteClick(plugin.name, plugin.name)}>
row delete
</button>
<button data-testid={`row-approve-${plugin.id}`} onClick={() => onReviewClick(plugin, "approve")}>
row approve
</button>
<button data-testid={`row-reject-${plugin.id}`} onClick={() => onReviewClick(plugin, "reject")}>
row reject
</button>
</div>
))}
</div>
),
@ -43,6 +51,7 @@ vi.mock("@/components/claude_code_plugins/skill_detail", () => ({ __esModule: tr
const mockGetClaudeCodePluginsList = vi.mocked(getClaudeCodePluginsList);
const mockDeleteClaudeCodePlugin = vi.mocked(deleteClaudeCodePlugin);
const mockReviewClaudeCodePlugin = vi.mocked(reviewClaudeCodePlugin);
const skill: Plugin = {
id: "plugin-1",
@ -122,3 +131,68 @@ describe("ClaudeCodePluginsPanel delete confirmation", () => {
expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled();
});
});
describe("ClaudeCodePluginsPanel review queue", () => {
const submission: Plugin = {
id: "plugin-2",
name: "submitted-skill",
source: { source: "github", repo: "acme/submitted-skill" },
enabled: false,
approval_status: "pending_review",
};
beforeEach(() => {
vi.clearAllMocks();
mockGetClaudeCodePluginsList.mockResolvedValue({ plugins: [skill, submission], count: 2 });
});
it("should let a non-admin submit a skill instead of disabling the button", async () => {
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Internal User" />);
const submit = await screen.findByRole("button", { name: "+ Submit Skill" });
expect(submit).toBeEnabled();
expect(screen.queryByTestId("toggle-pending-review")).not.toBeInTheDocument();
});
it("should filter the table down to submissions awaiting review", async () => {
const user = userEvent.setup();
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
await user.click(await screen.findByTestId("toggle-pending-review"));
expect(screen.getByText("row-submitted-skill")).toBeInTheDocument();
expect(screen.queryByText("row-my-skill")).not.toBeInTheDocument();
});
it("should approve a submission and refresh the list", async () => {
const user = userEvent.setup();
mockReviewClaudeCodePlugin.mockResolvedValue({});
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
await user.click(await screen.findByTestId("row-approve-plugin-2"));
await user.click(await screen.findByTestId("review-confirm"));
await waitFor(() =>
expect(mockReviewClaudeCodePlugin).toHaveBeenCalledWith("sk-test", "submitted-skill", "approve", ""),
);
await waitFor(() => expect(mockGetClaudeCodePluginsList).toHaveBeenCalledTimes(2));
});
it("should send the rejection notes typed by the reviewer", async () => {
const user = userEvent.setup();
mockReviewClaudeCodePlugin.mockResolvedValue({});
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
await user.click(await screen.findByTestId("row-reject-plugin-2"));
await user.type(await screen.findByTestId("review-notes"), "point at the skill folder");
await user.click(screen.getByTestId("review-confirm"));
await waitFor(() =>
expect(mockReviewClaudeCodePlugin).toHaveBeenCalledWith(
"sk-test",
"submitted-skill",
"reject",
"point at the skill folder",
),
);
});
});

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
@ -9,9 +9,11 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking";
import { getClaudeCodePluginsList, deleteClaudeCodePlugin, reviewClaudeCodePlugin } from "@/components/networking";
import AddPluginForm from "./add_plugin_form";
import PluginTable from "./PluginTable";
import ReviewSkillDialog, { ReviewDecision } from "./ReviewSkillDialog";
import { countAwaitingReview, isAwaitingReview } from "@/components/claude_code_plugins/helpers";
import SkillDetail from "@/components/claude_code_plugins/skill_detail";
import { isAdminRole } from "@/utils/roles";
import NotificationsManager from "@/components/molecules/notifications_manager";
@ -32,8 +34,16 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
displayName: string;
} | null>(null);
const [selectedSkill, setSelectedSkill] = useState<Plugin | null>(null);
const [pluginToReview, setPluginToReview] = useState<{ plugin: Plugin; decision: ReviewDecision } | null>(null);
const [isReviewing, setIsReviewing] = useState(false);
const [showPendingOnly, setShowPendingOnly] = useState(false);
const isAdmin = userRole ? isAdminRole(userRole) : false;
const pendingCount = useMemo(() => countAwaitingReview(pluginsList), [pluginsList]);
const visiblePlugins = useMemo(
() => (showPendingOnly ? pluginsList.filter(isAwaitingReview) : pluginsList),
[pluginsList, showPendingOnly],
);
const fetchPlugins = async () => {
if (!accessToken) {
@ -77,6 +87,27 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
}
};
const handleReviewConfirm = async (notes: string) => {
if (!pluginToReview || !accessToken) return;
setIsReviewing(true);
try {
await reviewClaudeCodePlugin(accessToken, pluginToReview.plugin.name, pluginToReview.decision, notes);
NotificationsManager.success(
pluginToReview.decision === "approve"
? `Skill "${pluginToReview.plugin.name}" approved and published`
: `Skill "${pluginToReview.plugin.name}" rejected`,
);
fetchPlugins();
} catch (error) {
console.error("Error reviewing skill:", error);
NotificationsManager.error("Failed to review skill");
} finally {
setIsReviewing(false);
setPluginToReview(null);
}
};
return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
{selectedSkill ? (
@ -92,20 +123,32 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
<div className="flex flex-col gap-2 mb-4">
<h1 className="text-2xl font-bold">Skills</h1>
<p className="text-sm text-gray-600">
Register Claude Code skills. Published skills appear in the Skill Hub for all users and are served via{" "}
{isAdmin
? "Register Claude Code skills and review skills submitted by your users. Approved skills appear in the Skill Hub for all users and are served via "
: "Submit Claude Code skills for administrator review. Once approved, a skill appears in the Skill Hub for all users and is served via "}
<code className="bg-gray-100 px-1 rounded-sm">/claude-code/marketplace.json</code>.
</p>
<div className="mt-2 flex gap-2">
<Button onClick={() => setIsAddModalVisible(true)} disabled={!accessToken || !isAdmin}>
+ Add Skill
<Button onClick={() => setIsAddModalVisible(true)} disabled={!accessToken}>
{isAdmin ? "+ Add Skill" : "+ Submit Skill"}
</Button>
{isAdmin && pendingCount > 0 && (
<Button
variant={showPendingOnly ? "default" : "secondary"}
data-testid="toggle-pending-review"
onClick={() => setShowPendingOnly(!showPendingOnly)}
>
{`Awaiting review (${pendingCount})`}
</Button>
)}
</div>
</div>
<PluginTable
pluginsList={pluginsList}
pluginsList={visiblePlugins}
isLoading={isLoading}
onDeleteClick={handleDeleteClick}
onReviewClick={(plugin, decision) => setPluginToReview({ plugin, decision })}
isAdmin={isAdmin}
onPluginClick={(id) => {
const skill = pluginsList.find((p) => p.id === id);
@ -122,6 +165,16 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
onSuccess={fetchPlugins}
/>
{pluginToReview && (
<ReviewSkillDialog
skillName={pluginToReview.plugin.name}
decision={pluginToReview.decision}
isSubmitting={isReviewing}
onCancel={() => setPluginToReview(null)}
onConfirm={handleReviewConfirm}
/>
)}
{pluginToDelete && (
<AlertDialog
open

View file

@ -28,15 +28,26 @@ const mockPlugins: Plugin[] = [
const mockOnDeleteClick = vi.fn();
const mockOnPluginClick = vi.fn();
const mockOnReviewClick = vi.fn();
const defaultProps = {
pluginsList: mockPlugins,
isLoading: false,
onDeleteClick: mockOnDeleteClick,
onReviewClick: mockOnReviewClick,
isAdmin: true,
onPluginClick: mockOnPluginClick,
};
const submittedSkill: Plugin = {
id: "plugin-id-submitted",
name: "submitted-skill",
source: { source: "github", repo: "org/submitted-skill" },
enabled: false,
approval_status: "pending_review",
created_at: "2025-02-01T09:00:00Z",
};
describe("PluginTable", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -44,7 +55,7 @@ describe("PluginTable", () => {
it("should render every column header", () => {
render(<PluginTable {...defaultProps} />);
for (const header of ["Skill Name", "Version", "Description", "Category", "Public", "Created At"]) {
for (const header of ["Skill Name", "Version", "Description", "Category", "Public", "Review", "Created At"]) {
expect(screen.getByText(header)).toBeInTheDocument();
}
});
@ -110,4 +121,38 @@ describe("PluginTable", () => {
expect(await screen.findByTestId("plugin-action-copy")).toBeInTheDocument();
expect(screen.queryByTestId("plugin-action-delete")).not.toBeInTheDocument();
});
it("should badge a submission as pending review and a legacy skill as active", () => {
render(<PluginTable {...defaultProps} pluginsList={[...mockPlugins, submittedSkill]} />);
expect(screen.getByTestId("approval-status-submitted-skill")).toHaveTextContent("Pending Review");
expect(screen.getByTestId("approval-status-newer-skill")).toHaveTextContent("Active");
});
it.each([
["approve", "plugin-action-approve"],
["reject", "plugin-action-reject"],
])("should let an admin %s a submission from the actions menu", async (decision, testId) => {
const user = userEvent.setup();
render(<PluginTable {...defaultProps} pluginsList={[submittedSkill]} />);
await user.click(screen.getByTestId("plugin-actions-submitted-skill"));
await user.click(await screen.findByTestId(testId));
expect(mockOnReviewClick).toHaveBeenCalledWith(submittedSkill, decision);
});
it("should not offer review actions on an already active skill", async () => {
const user = userEvent.setup();
render(<PluginTable {...defaultProps} />);
await user.click(screen.getByTestId("plugin-actions-newer-skill"));
expect(await screen.findByTestId("plugin-action-copy")).toBeInTheDocument();
expect(screen.queryByTestId("plugin-action-approve")).not.toBeInTheDocument();
expect(screen.queryByTestId("plugin-action-reject")).not.toBeInTheDocument();
});
it("should not offer review actions to a non-admin viewing a submission", async () => {
const user = userEvent.setup();
render(<PluginTable {...defaultProps} isAdmin={false} pluginsList={[submittedSkill]} />);
await user.click(screen.getByTestId("plugin-actions-submitted-skill"));
expect(await screen.findByTestId("plugin-action-copy")).toBeInTheDocument();
expect(screen.queryByTestId("plugin-action-approve")).not.toBeInTheDocument();
});
});

View file

@ -13,6 +13,7 @@ interface PluginTableProps {
pluginsList: Plugin[];
isLoading: boolean;
onDeleteClick: (pluginName: string, displayName: string) => void;
onReviewClick: (plugin: Plugin, decision: "approve" | "reject") => void;
isAdmin: boolean;
onPluginClick: (pluginId: string) => void;
}
@ -31,12 +32,19 @@ function EmptyState() {
);
}
const PluginTable: React.FC<PluginTableProps> = ({ pluginsList, isLoading, onDeleteClick, isAdmin, onPluginClick }) => {
const PluginTable: React.FC<PluginTableProps> = ({
pluginsList,
isLoading,
onDeleteClick,
onReviewClick,
isAdmin,
onPluginClick,
}) => {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const columns = useMemo(
() => getPluginTableColumns({ isAdmin, onPluginClick, onDeleteClick }),
[isAdmin, onPluginClick, onDeleteClick],
() => getPluginTableColumns({ isAdmin, onPluginClick, onDeleteClick, onReviewClick }),
[isAdmin, onPluginClick, onDeleteClick, onReviewClick],
);
return (

View file

@ -1,11 +1,15 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Copy, MoreHorizontal, Trash2 } from "lucide-react";
import { Check, Copy, MoreHorizontal, Trash2, X } from "lucide-react";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells";
import { getCategoryBadgeColor } from "@/components/claude_code_plugins/helpers";
import {
getCategoryBadgeColor,
getApprovalStatusDisplay,
isAwaitingReview,
} from "@/components/claude_code_plugins/helpers";
import { Plugin } from "@/components/claude_code_plugins/types";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
@ -44,9 +48,10 @@ interface PluginRowActionsProps {
plugin: Plugin;
isAdmin: boolean;
onDeleteClick: (pluginName: string, displayName: string) => void;
onReviewClick: (plugin: Plugin, decision: "approve" | "reject") => void;
}
function PluginRowActions({ plugin, isAdmin, onDeleteClick }: PluginRowActionsProps) {
function PluginRowActions({ plugin, isAdmin, onDeleteClick, onReviewClick }: PluginRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
@ -64,6 +69,19 @@ function PluginRowActions({ plugin, isAdmin, onDeleteClick }: PluginRowActionsPr
<Copy />
Copy skill ID
</DropdownMenuItem>
{isAdmin && isAwaitingReview(plugin) && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem data-testid="plugin-action-approve" onClick={() => onReviewClick(plugin, "approve")}>
<Check />
Approve
</DropdownMenuItem>
<DropdownMenuItem data-testid="plugin-action-reject" onClick={() => onReviewClick(plugin, "reject")}>
<X />
Reject
</DropdownMenuItem>
</>
)}
{isAdmin && (
<>
<DropdownMenuSeparator />
@ -86,12 +104,14 @@ interface PluginTableColumnsDeps {
isAdmin: boolean;
onPluginClick: (pluginId: string) => void;
onDeleteClick: (pluginName: string, displayName: string) => void;
onReviewClick: (plugin: Plugin, decision: "approve" | "reject") => void;
}
export const getPluginTableColumns = ({
isAdmin,
onPluginClick,
onDeleteClick,
onReviewClick,
}: PluginTableColumnsDeps): ColumnDef<Plugin>[] => [
{
id: "name",
@ -154,6 +174,25 @@ export const getPluginTableColumns = ({
<StatusBadge tone={row.original.enabled ? "success" : "neutral"} label={row.original.enabled ? "Yes" : "No"} />
),
},
{
id: "approval_status",
accessorKey: "approval_status",
meta: { title: "Review", skeleton: "badge" },
header: "Review",
size: 140,
enableSorting: false,
cell: ({ row }) => {
const display = getApprovalStatusDisplay(row.original.approval_status);
return (
<StatusBadge
tone={display.tone}
label={display.label}
tooltip={row.original.review_notes || undefined}
dataTestId={`approval-status-${row.original.name}`}
/>
);
},
},
{
id: "created_at",
accessorKey: "created_at",
@ -173,7 +212,12 @@ export const getPluginTableColumns = ({
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<PluginRowActions plugin={row.original} isAdmin={isAdmin} onDeleteClick={onDeleteClick} />
<PluginRowActions
plugin={row.original}
isAdmin={isAdmin}
onDeleteClick={onDeleteClick}
onReviewClick={onReviewClick}
/>
</div>
),
},

View file

@ -0,0 +1,83 @@
"use client";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
export type ReviewDecision = "approve" | "reject";
interface ReviewSkillDialogProps {
skillName: string;
decision: ReviewDecision;
isSubmitting: boolean;
onCancel: () => void;
onConfirm: (notes: string) => void;
}
const ReviewSkillDialog: React.FC<ReviewSkillDialogProps> = ({
skillName,
decision,
isSubmitting,
onCancel,
onConfirm,
}) => {
const [notes, setNotes] = useState("");
const approving = decision === "approve";
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onCancel();
}}
>
<DialogContent data-testid="review-skill-dialog">
<DialogHeader>
<DialogTitle>{approving ? "Approve skill" : "Reject skill"}</DialogTitle>
<DialogDescription>
{approving
? `Approving "${skillName}" publishes it to the Skill Hub and marketplace.json for all users.`
: `Rejecting "${skillName}" keeps it unpublished. The submitter sees your notes.`}
</DialogDescription>
</DialogHeader>
{!approving && (
<div className="flex flex-col gap-2">
<Label htmlFor="review-notes">Reason for rejection (optional)</Label>
<Textarea
id="review-notes"
data-testid="review-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
placeholder="Point to the repository path that needs fixing"
/>
</div>
)}
<DialogFooter>
<Button variant="secondary" onClick={onCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button
variant={approving ? "default" : "destructive"}
data-testid="review-confirm"
onClick={() => onConfirm(notes)}
disabled={isSubmitting}
>
{approving ? "Approve" : "Reject"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ReviewSkillDialog;

View file

@ -136,8 +136,12 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
setIsSubmitting(true);
try {
await registerClaudeCodePlugin(accessToken, buildRegisterRequest(values, urlPreview.parsed));
MessageManager.success("Skill registered successfully");
const response = await registerClaudeCodePlugin(accessToken, buildRegisterRequest(values, urlPreview.parsed));
MessageManager.success(
response?.action === "submitted_for_review"
? "Skill submitted for administrator review"
: "Skill registered successfully",
);
form.resetFields();
setUrlPreview(null);
setUrlEncodesSubdir(false);

View file

@ -1,8 +1,9 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { Modal, Form, Steps, Button, Checkbox } from "antd";
import { Text, Title, Badge } from "@tremor/react";
import { enableClaudeCodePlugin, disableClaudeCodePlugin } from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import { isApprovedSkill } from "./helpers";
import { Plugin } from "./types";
const { Step } = Steps;
@ -27,6 +28,9 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const publishableSkills = useMemo(() => skillsList.filter(isApprovedSkill), [skillsList]);
const awaitingReviewCount = skillsList.length - publishableSkills.length;
const handleClose = () => {
setCurrentStep(0);
setSelectedSkills(new Set());
@ -54,7 +58,7 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedSkills(new Set(skillsList.map((s) => s.name)));
setSelectedSkills(new Set(publishableSkills.map((s) => s.name)));
} else {
setSelectedSkills(new Set());
}
@ -62,10 +66,10 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
// Pre-check already-published skills when modal opens
useEffect(() => {
if (visible && skillsList.length > 0) {
setSelectedSkills(new Set(skillsList.filter((s) => s.enabled).map((s) => s.name)));
if (visible && publishableSkills.length > 0) {
setSelectedSkills(new Set(publishableSkills.filter((s) => s.enabled).map((s) => s.name)));
}
}, [visible, skillsList]);
}, [visible, publishableSkills]);
const handleSubmit = async () => {
if (selectedSkills.size === 0) {
@ -77,7 +81,7 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
try {
const selectedSet = selectedSkills;
await Promise.all(
skillsList.map((skill) => {
publishableSkills.map((skill) => {
const shouldBePublic = selectedSet.has(skill.name);
if (shouldBePublic && !skill.enabled) {
return enableClaudeCodePlugin(accessToken, skill.name);
@ -100,7 +104,7 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
}
};
const allSelected = skillsList.length > 0 && skillsList.every((s) => selectedSkills.has(s.name));
const allSelected = publishableSkills.length > 0 && publishableSkills.every((s) => selectedSkills.has(s.name));
const isIndeterminate = selectedSkills.size > 0 && !allSelected;
const renderStep1 = () => (
@ -111,9 +115,9 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
checked={allSelected}
indeterminate={isIndeterminate}
onChange={(e) => handleSelectAll(e.target.checked)}
disabled={skillsList.length === 0}
disabled={publishableSkills.length === 0}
>
Select All ({skillsList.length})
Select All ({publishableSkills.length})
</Checkbox>
</div>
@ -121,14 +125,20 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
Selected skills will be visible to all users in the Skill Hub. Deselected skills will be unpublished.
</Text>
{awaitingReviewCount > 0 && (
<Text className="text-sm text-amber-700">
{awaitingReviewCount} submitted skill(s) are not listed here yet. Approve them on the Skills page first.
</Text>
)}
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
<div className="space-y-3">
{skillsList.length === 0 ? (
{publishableSkills.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Text>No skills registered yet.</Text>
</div>
) : (
skillsList.map((skill) => (
publishableSkills.map((skill) => (
<div key={skill.name} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-gray-50">
<Checkbox
checked={selectedSkills.has(skill.name)}
@ -184,7 +194,7 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
<div className="space-y-2">
{Array.from(selectedSkills).map((name) => {
const skill = skillsList.find((s) => s.name === name);
const skill = publishableSkills.find((s) => s.name === name);
return (
<div key={name} className="flex items-center justify-between p-2 bg-gray-50 rounded-sm">
<Text className="font-mono text-sm">{name}</Text>

View file

@ -18,6 +18,10 @@ import {
parseSkillSource,
isValidSubPath,
buildMarketplaceSettingsSnippet,
getApprovalStatusDisplay,
isAwaitingReview,
isApprovedSkill,
countAwaitingReview,
} from "./helpers";
import { MarketplacePluginEntry, PluginSource } from "./types";
@ -587,3 +591,31 @@ describe("isValidSubPath", () => {
expect(isValidSubPath("a//b")).toBe(false);
});
});
describe("getApprovalStatusDisplay", () => {
it("maps each approval state to its badge", () => {
expect(getApprovalStatusDisplay("pending_review")).toEqual({ label: "Pending Review", tone: "warning" });
expect(getApprovalStatusDisplay("rejected")).toEqual({ label: "Rejected", tone: "error" });
expect(getApprovalStatusDisplay("active")).toEqual({ label: "Active", tone: "success" });
});
it("reads a skill registered before approval existed as active", () => {
expect(getApprovalStatusDisplay(undefined)).toEqual({ label: "Active", tone: "success" });
});
});
describe("isAwaitingReview / isApprovedSkill / countAwaitingReview", () => {
it("separates submissions awaiting review from publishable skills", () => {
const skills = [
{ approval_status: "pending_review" as const },
{ approval_status: "pending_review" as const },
{ approval_status: "active" as const },
{ approval_status: "rejected" as const },
{},
];
expect(countAwaitingReview(skills)).toBe(2);
expect(skills.filter(isApprovedSkill)).toEqual([{ approval_status: "active" }, {}]);
expect(isAwaitingReview({ approval_status: "rejected" })).toBe(false);
});
});

View file

@ -2,7 +2,7 @@
* Helper utilities for Claude Code Marketplace
*/
import { PluginSource, MarketplacePluginEntry } from "./types";
import { PluginSource, MarketplacePluginEntry, Plugin, SkillApprovalStatus } from "./types";
export interface SkillSourcePreview {
parsed: PluginSource;
@ -438,3 +438,28 @@ export const formatKeywords = (keywords?: string[]): string => {
return keywords.join(", ");
};
export interface SkillApprovalDisplay {
label: string;
tone: "success" | "warning" | "error";
}
export const getApprovalStatusDisplay = (status?: SkillApprovalStatus): SkillApprovalDisplay => {
switch (status) {
case "pending_review":
return { label: "Pending Review", tone: "warning" };
case "rejected":
return { label: "Rejected", tone: "error" };
default:
return { label: "Active", tone: "success" };
}
};
export const isAwaitingReview = (plugin: Pick<Plugin, "approval_status">): boolean =>
plugin.approval_status === "pending_review";
export const countAwaitingReview = (plugins: Pick<Plugin, "approval_status">[]): number =>
plugins.filter(isAwaitingReview).length;
export const isApprovedSkill = (plugin: Pick<Plugin, "approval_status">): boolean =>
plugin.approval_status === undefined || plugin.approval_status === "active";

View file

@ -16,6 +16,8 @@ export interface PluginSource {
export type PluginAuthor = components["schemas"]["PluginAuthor"];
export type SkillApprovalStatus = components["schemas"]["PluginListItem"]["approval_status"];
export interface Plugin {
id: string;
name: string; // kebab-case
@ -29,6 +31,10 @@ export interface Plugin {
domain?: string;
namespace?: string;
enabled: boolean;
approval_status?: SkillApprovalStatus;
review_notes?: string | null;
reviewed_by?: string | null;
reviewed_at?: string | null;
created_at?: string;
updated_at?: string;
created_by?: string;
@ -47,6 +53,10 @@ export interface PluginListItem {
domain?: string;
namespace?: string;
enabled: boolean;
approval_status?: SkillApprovalStatus;
review_notes?: string | null;
reviewed_by?: string | null;
reviewed_at?: string | null;
created_at?: string;
updated_at?: string;
created_by?: string;

View file

@ -7486,6 +7486,26 @@ export const deleteClaudeCodePlugin = async (accessToken: string, pluginName: st
}
};
/**
* Approve or reject a submitted skill (admin only)
*/
export const reviewClaudeCodePlugin = async (
accessToken: string,
pluginName: string,
decision: "approve" | "reject",
reviewNotes?: string,
) => {
try {
return await apiClient.post(`/claude-code/plugins/${pluginName}/${decision}`, {
accessToken,
...(decision === "reject" ? { body: { review_notes: reviewNotes || null } } : {}),
});
} catch (error) {
console.error(`Failed to ${decision} skill "${pluginName}":`, error);
throw error;
}
};
// Compliance check types and functions
export interface ComplianceCheckResult {

View file

@ -1524,13 +1524,18 @@ export interface paths {
};
/**
* List Plugins
* @description List all plugins in the marketplace.
* @description List plugins in the marketplace.
*
* Admins see every skill, including submissions awaiting review. Everyone
* else sees approved skills plus their own submissions, so a submitter can
* track the status of what they sent in.
*
* Parameters:
* - enabled_only: If true, only return enabled plugins
* - approval_status: Filter to one approval state, e.g. `pending_review` for the admin review queue
*
* Returns:
* List of plugins with their metadata.
* List of plugins with their metadata and review state.
*/
get: operations["list_plugins_claude_code_plugins_get"];
put?: never;
@ -1546,6 +1551,10 @@ export interface paths {
* the same name already exists it returns 409 Conflict; use
* PUT /claude-code/plugins/{plugin_name} to update an existing plugin.
*
* Callers that are not proxy admins are self-service submitters: the skill
* is stored with approval_status=pending_review and stays disabled until an
* admin approves it via POST /claude-code/plugins/{plugin_name}/approve.
*
* Parameters:
* - name: Plugin name (kebab-case)
* - source: Git source reference (github, url, or git-subdir format)
@ -1557,7 +1566,8 @@ export interface paths {
* - category: Plugin category (optional)
*
* Returns:
* Registration status (action is always "created") and plugin information.
* Registration status ("created" for admins, "submitted_for_review" otherwise)
* and plugin information.
*
* Example:
* ```bash
@ -1610,6 +1620,10 @@ export interface paths {
* Returns 404 if no plugin with the given name exists; use
* POST /claude-code/plugins to create a new plugin.
*
* Admins can update any skill and the review state is left untouched. A
* submitter can only update their own skill, and doing so sends it back to
* pending review, since the content an admin approved has changed.
*
* Parameters:
* - plugin_name: Name of the plugin to update (path parameter)
* - source: Git source reference (github, url, or git-subdir format)
@ -1639,7 +1653,8 @@ export interface paths {
post?: never;
/**
* Delete Plugin
* @description Delete a plugin from the marketplace.
* @description Delete a plugin from the marketplace. Admins can delete any skill; a
* submitter can withdraw one they submitted.
*
* Parameters:
* - plugin_name: The name of the plugin to delete
@ -1650,6 +1665,35 @@ export interface paths {
patch?: never;
trace?: never;
};
"/claude-code/plugins/{plugin_name}/approve": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Approve Plugin
* @description Approve a submitted skill (admin only).
*
* Approving sets approval_status=active and publishes the skill to
* marketplace.json and the public Skill Hub.
*
* Example:
* ```bash
* curl -X POST http://localhost:4000/claude-code/plugins/my-skill/approve \
* -H "Authorization: Bearer sk-admin-..."
* ```
*/
post: operations["approve_plugin_claude_code_plugins__plugin_name__approve_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/claude-code/plugins/{plugin_name}/disable": {
parameters: {
query?: never;
@ -1661,7 +1705,7 @@ export interface paths {
put?: never;
/**
* Disable Plugin
* @description Disable a plugin without deleting it.
* @description Disable a plugin without deleting it. Proxy admins only.
*
* Parameters:
* - plugin_name: The name of the plugin to disable
@ -1684,7 +1728,11 @@ export interface paths {
put?: never;
/**
* Enable Plugin
* @description Enable a disabled plugin.
* @description Enable a disabled plugin. Proxy admins only.
*
* A skill that has not been approved cannot be enabled here: approve it
* through POST /claude-code/plugins/{plugin_name}/approve instead, so the
* reviewer is recorded on the row.
*
* Parameters:
* - plugin_name: The name of the plugin to enable
@ -1696,6 +1744,36 @@ export interface paths {
patch?: never;
trace?: never;
};
"/claude-code/plugins/{plugin_name}/reject": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Reject Plugin
* @description Reject a submitted skill (admin only).
*
* The row is kept unpublished so the submitter can read review_notes and fix the submission.
*
* Example:
* ```bash
* curl -X POST http://localhost:4000/claude-code/plugins/my-skill/reject \
* -H "Authorization: Bearer sk-admin-..." \
* -H "Content-Type: application/json" \
* -d '{"review_notes": "point the source at the skill folder"}'
* ```
*/
post: operations["reject_plugin_claude_code_plugins__plugin_name__reject_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/cloudzero/delete": {
parameters: {
query?: never;
@ -11252,7 +11330,7 @@ export interface paths {
};
/**
* Public Skill Hub
* @description Return enabled (public) Claude Code skills no auth required.
* @description Return approved, enabled (public) Claude Code skills. No auth required.
*/
get: operations["public_skill_hub_public_skill_hub_get"];
put?: never;
@ -30318,11 +30396,19 @@ export interface components {
* @description Plugin item in list responses.
*/
PluginListItem: {
/**
* Approval Status
* @default active
* @enum {string}
*/
approval_status: "pending_review" | "active" | "rejected";
author?: components["schemas"]["PluginAuthor"] | null;
/** Category */
category?: string | null;
/** Created At */
created_at: string | null;
/** Created By */
created_by?: string | null;
/** Description */
description: string | null;
/** Domain */
@ -30339,6 +30425,12 @@ export interface components {
name: string;
/** Namespace */
namespace?: string | null;
/** Review Notes */
review_notes?: string | null;
/** Reviewed At */
reviewed_at?: string | null;
/** Reviewed By */
reviewed_by?: string | null;
/** Source */
source: {
[key: string]: string;
@ -30353,6 +30445,13 @@ export interface components {
* @description Plugin information in API responses.
*/
PluginResponse: {
/**
* Approval Status
* @description Administrator approval state
* @default active
* @enum {string}
*/
approval_status: "pending_review" | "active" | "rejected";
/**
* Description
* @description Plugin description
@ -31673,7 +31772,7 @@ export interface components {
RegisterPluginResponse: {
/**
* Action
* @description Action taken (created/updated)
* @description Action taken (created/submitted_for_review/updated)
*/
action: string;
/** @description Plugin information */
@ -31689,6 +31788,17 @@ export interface components {
/** Review Notes */
review_notes?: string | null;
};
/**
* RejectPluginRequest
* @description Administrator rejection of a submitted skill.
*/
RejectPluginRequest: {
/**
* Review Notes
* @description Reviewer feedback shown to the submitter
*/
review_notes?: string | null;
};
/**
* ReminderMarkerPair
* @description One open/close delimiter pair a harness wraps injected context in.
@ -31954,6 +32064,48 @@ export interface components {
/** Timeouterrorretries */
TimeoutErrorRetries?: number | null;
};
/**
* ReviewPluginResponse
* @description Response from approving or rejecting a submitted skill.
*/
ReviewPluginResponse: {
/**
* Approval Status
* @description Resulting approval state
* @enum {string}
*/
approval_status: "pending_review" | "active" | "rejected";
/**
* Enabled
* @description Whether the skill is now served to users
*/
enabled: boolean;
/**
* Name
* @description Skill name
*/
name: string;
/**
* Review Notes
* @description Reviewer feedback
*/
review_notes?: string | null;
/**
* Reviewed At
* @description ISO timestamp of the review
*/
reviewed_at?: string | null;
/**
* Reviewed By
* @description User id of the reviewing administrator
*/
reviewed_by?: string | null;
/**
* Status
* @description Operation status
*/
status: string;
};
/**
* RoleMappings
* @description Configuration for mapping SSO groups to LiteLLM roles.
@ -38209,6 +38361,7 @@ export interface operations {
parameters: {
query?: {
enabled_only?: boolean;
approval_status?: ("pending_review" | "active" | "rejected") | null;
};
header?: never;
path?: never;
@ -38366,6 +38519,37 @@ export interface operations {
};
};
};
approve_plugin_claude_code_plugins__plugin_name__approve_post: {
parameters: {
query?: never;
header?: never;
path: {
plugin_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReviewPluginResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
disable_plugin_claude_code_plugins__plugin_name__disable_post: {
parameters: {
query?: never;
@ -38428,6 +38612,41 @@ export interface operations {
};
};
};
reject_plugin_claude_code_plugins__plugin_name__reject_post: {
parameters: {
query?: never;
header?: never;
path: {
plugin_name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RejectPluginRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReviewPluginResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_cloudzero_settings_cloudzero_delete_delete: {
parameters: {
query?: never;