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

Resolves LIT-5465
This commit is contained in:
Yassin Kortam 2026-08-12 09:58:17 -07:00
parent 160548d40b
commit 55a80add53
25 changed files with 2176 additions and 108 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

@ -4315,6 +4315,33 @@
"claude_code_marketplace": {
"components": {
"schemas": {
"ApprovePluginRequest": {
"description": "Administrator approval of a submitted skill.\n\nThe fingerprint binds the approval to the content the administrator read.\nA submitter who edits their submission after it was read, but before it is\napproved, changes the fingerprint, so the approval is refused rather than\npublishing a manifest nobody reviewed.",
"properties": {
"review_notes": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Reviewer feedback shown to the submitter",
"title": "Review Notes"
},
"reviewed_fingerprint": {
"description": "manifest_fingerprint of the submission that was reviewed, from GET /claude-code/plugins",
"title": "Reviewed Fingerprint",
"type": "string"
}
},
"required": [
"reviewed_fingerprint"
],
"title": "ApprovePluginRequest",
"type": "object"
},
"HTTPValidationError": {
"properties": {
"detail": {
@ -4380,6 +4407,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 +4449,17 @@
],
"title": "Created At"
},
"created_by": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Created By"
},
"description": {
"anyOf": [
{
@ -4467,6 +4515,11 @@
],
"title": "Keywords"
},
"manifest_fingerprint": {
"default": "",
"title": "Manifest Fingerprint",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
@ -4482,6 +4535,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 +4614,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 +4810,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 +4832,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 +5106,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 +5118,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 +5174,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 +5221,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 +5309,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 +5365,67 @@
]
}
},
"/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\nreviewed_fingerprint is the manifest_fingerprint returned by\nGET /claude-code/plugins and GET /claude-code/plugins/{plugin_name}. It\nties the approval to the content that was read, so a submitter cannot get\nan edit published by landing it between the read and the approval.\n\nExample:\n ```bash\n FP=$(curl -s http://localhost:4000/claude-code/plugins/my-skill \\\n -H \"Authorization: Bearer sk-admin-...\" | jq -r .manifest_fingerprint)\n curl -X POST http://localhost:4000/claude-code/plugins/my-skill/approve \\\n -H \"Authorization: Bearer sk-admin-...\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\\\"reviewed_fingerprint\\\": \\\"$FP\\\"}\"\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"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApprovePluginRequest"
}
}
},
"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": "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 +5471,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 +5514,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

@ -774,6 +774,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,23 @@ 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 hashlib
import json
import re
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from typing import Final, Protocol, TypedDict
from types import MappingProxyType
from typing import Annotated, Final, Protocol, TypedDict
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
@ -28,14 +37,26 @@ 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,
ApprovePluginRequest,
ListPluginsResponse,
PluginListItem,
PluginResponse,
PluginSpec,
RegisterPluginRequest,
RegisterPluginResponse,
RejectPluginRequest,
ReviewPluginResponse,
SkillApprovalStatus,
UpdatePluginRequest,
)
@ -49,6 +70,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 +90,60 @@ 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 _submitter_edit_resets_review(*, is_admin: bool) -> Mapping[str, object]:
if is_admin:
return MappingProxyType({})
return MappingProxyType({"approval_status": SKILL_PENDING_REVIEW, "enabled": False})
def _manifest_fingerprint(manifest_json: str | None) -> str:
"""Names the exact submitted content, so an approval can be tied to the manifest the administrator read."""
return hashlib.sha256((manifest_json or "").encode()).hexdigest()
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 +181,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 +321,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 +336,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 +372,17 @@ 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
owner_scope: Final[str | None] = user_api_key_dict.user_id or get_primary_resource_owner_scope(
user_api_key_dict
)
if submitted_for_review and owner_scope is None:
raise _error_response(
403,
"Cannot submit a skill for review without an identity to attribute it to. "
"Use a key that carries a user, team, or organization.",
)
try:
plugin: Final[_PluginRecord] = await ClaudeCodePluginRepository(prisma_client).table.create(
@ -297,20 +392,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": owner_scope,
}
)
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 +414,7 @@ async def register_plugin(
description=plugin.description,
source=request.source,
enabled=plugin.enabled,
approval_status=approval_status,
),
)
@ -339,23 +436,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 +483,12 @@ async def list_plugins(
domain=manifest.get("domain"),
namespace=manifest.get("namespace"),
enabled=p.enabled,
approval_status=_as_approval_status(p.approval_status),
manifest_fingerprint=_manifest_fingerprint(p.manifest_json),
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 +537,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 +556,11 @@ async def get_plugin(
"keywords": manifest.get("keywords"),
"category": manifest.get("category"),
"enabled": plugin.enabled,
"approval_status": _as_approval_status(plugin.approval_status),
"manifest_fingerprint": _manifest_fingerprint(plugin.manifest_json),
"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 +585,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.
@ -478,9 +596,15 @@ async def update_plugin(
out is reset to its default (e.g. an omitted version is cleared, not kept).
Send the full desired state.
Returns 404 if no plugin with the given name exists; use
Returns 404 if no plugin with the given name exists, and the same 404 for
a skill the caller cannot see, so the status code never reveals that a
pending or rejected submission is sitting under that name; 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)
@ -516,9 +640,13 @@ async def update_plugin(
existing: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
where={"name": plugin_name} # mutable-ok: prisma query arguments must be plain dicts
)
if not existing:
if not existing or not _caller_can_see(existing, user_api_key_dict):
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 +657,7 @@ async def update_plugin(
"manifest_json": json.dumps(manifest),
"files_json": "{}",
"updated_at": datetime.now(timezone.utc),
**_submitter_edit_resets_review(is_admin=is_admin),
},
)
@ -544,6 +673,7 @@ async def update_plugin(
description=plugin.description,
source=request.source,
enabled=plugin.enabled,
approval_status=_as_approval_status(plugin.approval_status),
),
)
@ -554,6 +684,155 @@ async def update_plugin(
raise _error_response(500, f"Update failed: {e}")
def _stale_review_error(plugin_name: str) -> HTTPException:
return _error_response(
409,
f"Skill '{plugin_name}' is no longer the submission that was reviewed. "
"Read it again and review the current content.",
)
async def _record_review(
*,
plugin_name: str,
approval_status: SkillApprovalStatus,
review_notes: str | None,
reviewed_fingerprint: 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}")
if approval_status == SKILL_REJECTED and _as_approval_status(existing.approval_status) == SKILL_ACTIVE:
raise _error_response(
400,
f"Skill '{plugin_name}' is already approved. Disable it to unpublish it. "
"Rejecting applies to a skill awaiting review.",
)
publishes: Final = approval_status == SKILL_ACTIVE
if publishes and _manifest_fingerprint(existing.manifest_json) != reviewed_fingerprint:
raise _stale_review_error(plugin_name)
reviewed_at: Final = datetime.now(timezone.utc)
reviewed_rows: Final[int] = await repository.table.update_many(
where={ # mutable-ok: prisma query arguments must be plain dicts
"name": plugin_name,
**({"manifest_json": existing.manifest_json} if publishes else {}),
},
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": publishes,
"updated_at": reviewed_at,
},
)
if reviewed_rows == 0:
raise _stale_review_error(plugin_name)
verbose_proxy_logger.info("Plugin %s reviewed: approval_status=%s", plugin_name, approval_status)
return ReviewPluginResponse(
status="success",
name=existing.name,
approval_status=approval_status,
enabled=publishes,
reviewed_by=user_api_key_dict.user_id,
reviewed_at=reviewed_at.isoformat(),
review_notes=review_notes,
)
@router.post(
"/claude-code/plugins/{plugin_name}/approve",
tags=["Claude Code Marketplace"], # mutable-ok: FastAPI route decorators take lists
dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI route decorators take lists
response_model=ReviewPluginResponse,
)
async def approve_plugin(
plugin_name: str,
request: ApprovePluginRequest,
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.
reviewed_fingerprint is the manifest_fingerprint returned by
GET /claude-code/plugins and GET /claude-code/plugins/{plugin_name}. It
ties the approval to the content that was read, so a submitter cannot get
an edit published by landing it between the read and the approval.
Example:
```bash
FP=$(curl -s http://localhost:4000/claude-code/plugins/my-skill \\
-H "Authorization: Bearer sk-admin-..." | jq -r .manifest_fingerprint)
curl -X POST http://localhost:4000/claude-code/plugins/my-skill/approve \\
-H "Authorization: Bearer sk-admin-..." \\
-H "Content-Type: application/json" \\
-d "{\\"reviewed_fingerprint\\": \\"$FP\\"}"
```
"""
return await _record_review(
plugin_name=plugin_name,
approval_status=SKILL_ACTIVE,
review_notes=request.review_notes,
reviewed_fingerprint=request.reviewed_fingerprint,
user_api_key_dict=user_api_key_dict,
)
@router.post(
"/claude-code/plugins/{plugin_name}/reject",
tags=["Claude Code Marketplace"], # mutable-ok: FastAPI route decorators take lists
dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI route decorators take lists
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.
An already-approved skill cannot be rejected, since that would hide it from everyone but its
submitter rather than merely unpublishing it. Disable it instead.
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,
reviewed_fingerprint=None,
user_api_key_dict=user_api_key_dict,
)
@router.post(
"/claude-code/plugins/{plugin_name}/enable",
tags=["Claude Code Marketplace"],
@ -564,11 +843,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 +867,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 +902,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 +950,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
@ -665,12 +962,17 @@ async def delete_plugin(
plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
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"},
)
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

@ -82,7 +82,7 @@ class _PluginRow(Protocol):
class _PluginTableActions(Protocol):
def find_many(self, *, where: Mapping[str, bool]) -> Awaitable[Sequence[_PluginRow]]: ...
def find_many(self, *, where: Mapping[str, object]) -> Awaitable[Sequence[_PluginRow]]: ...
def _plugin_table(prisma_client: object) -> _PluginTableActions:
@ -299,9 +299,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,
@ -310,7 +311,7 @@ async def public_skill_hub():
try:
prisma_client: Final = await _get_prisma_client()
plugins: Final = await _plugin_table(prisma_client).find_many(where={"enabled": True})
plugins: Final = await _plugin_table(prisma_client).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,52 @@ 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 ApprovePluginRequest(BaseModel):
"""
Administrator approval of a submitted skill.
The fingerprint binds the approval to the content the administrator read.
A submitter who edits their submission after it was read, but before it is
approved, changes the fingerprint, so the approval is refused rather than
publishing a manifest nobody reviewed.
"""
reviewed_fingerprint: str = Field(
...,
description="manifest_fingerprint of the submission that was reviewed, from GET /claude-code/plugins",
)
review_notes: str | None = Field(None, description="Reviewer feedback shown to the submitter")
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 +147,12 @@ class PluginListItem(BaseModel):
domain: str | None = None
namespace: str | None = None
enabled: bool
approval_status: SkillApprovalStatus = "active"
manifest_fingerprint: str = ""
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

@ -5,6 +5,7 @@ Covers the git-subdir source type added alongside the existing github and url ty
"""
import json
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
@ -12,14 +13,27 @@ from unittest.mock import AsyncMock, MagicMock
import litellm
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.resource_ownership import (
get_primary_resource_owner_scope,
get_resource_owner_scopes,
)
from litellm.proxy.proxy_server import LitellmUserRoles
from litellm.types.proxy.claude_code_endpoints import (
ApprovePluginRequest,
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 +49,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
@ -58,10 +93,18 @@ def _make_mock_prisma():
setattr(record, k, v)
return record
async def _update_many(where, data):
matched = [record for record in store.values() if _matches(record, where)]
for record in matched:
for k, v in data.items():
setattr(record, k, v)
return len(matched)
mock_table.find_unique = AsyncMock(side_effect=_find_unique)
mock_table.find_many = AsyncMock(side_effect=_find_many)
mock_table.create = AsyncMock(side_effect=_create)
mock_table.update = AsyncMock(side_effect=_update)
mock_table.update_many = AsyncMock(side_effect=_update_many)
mock_client.db.litellm_claudecodeplugintable = mock_table
return mock_client
@ -151,6 +194,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 +214,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 +258,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 +387,511 @@ 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})
async def _fingerprint(name: str) -> str:
"""Read the fingerprint the way a reviewer does, off the skill they are looking at."""
details = await get_plugin(plugin_name=name, user_api_key_dict=_USER)
return details["manifest_fingerprint"]
async def _approve(name: str, *, reviewer=None, fingerprint: str | None = None):
return await approve_plugin(
plugin_name=name,
request=ApprovePluginRequest(
reviewed_fingerprint=fingerprint if fingerprint is not None else await _fingerprint(name)
),
user_api_key_dict=reviewer if reviewer is not None else _USER,
)
@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("submitted-skill")
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("submitted-skill", reviewer=_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("does-not-exist", fingerprint="any-fingerprint")
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("submitted-skill")
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():
"""404 rather than 403, since a pending skill is hidden from this caller and the status must not out it."""
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 == 404
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 == 404
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("admin-skill")
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_approval_is_refused_when_the_submission_changed_after_it_was_read():
"""The reviewer's window is minutes long, so an edit landing inside it must not be published."""
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
reviewed_fingerprint = await _fingerprint("submitted-skill")
await update_plugin(
plugin_name="submitted-skill",
request=UpdatePluginRequest(source={"source": "github", "repo": "org/swapped-in-after-review"}),
user_api_key_dict=_SUBMITTER,
)
with pytest.raises(HTTPException) as exc_info:
await _approve("submitted-skill", fingerprint=reviewed_fingerprint)
assert exc_info.value.status_code == 409
stored = await _stored("submitted-skill")
assert stored.approval_status == "pending_review"
assert stored.enabled is False
body = json.loads((await get_marketplace()).body)
assert body["plugins"] == []
@pytest.mark.asyncio
async def test_approval_of_the_reviewed_content_still_succeeds_after_an_unrelated_edit_is_reviewed():
"""Re-reading the changed submission is all it takes to approve it, so the guard is not a dead end."""
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
await update_plugin(
plugin_name="submitted-skill",
request=UpdatePluginRequest(source={"source": "github", "repo": "org/second-attempt"}),
user_api_key_dict=_SUBMITTER,
)
response = await _approve("submitted-skill")
assert response.approval_status == "active"
assert response.enabled is True
body = json.loads((await get_marketplace()).body)
assert [plugin["source"]["repo"] for plugin in body["plugins"]] == ["org/second-attempt"]
@pytest.mark.asyncio
async def test_approval_write_does_not_publish_an_edit_that_lands_after_the_fingerprint_check():
"""The read and the write are separate round trips, so the write itself has to be a compare-and-set."""
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
reviewed_fingerprint = await _fingerprint("submitted-skill")
table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
unpatched_find_unique = table.find_unique.side_effect
async def _edit_after_the_review_read(where):
record = await unpatched_find_unique(where)
# A real read hands back a snapshot, so the fingerprint check sees the pre-edit content
# and passes; only the write can still catch the edit.
snapshot = SimpleNamespace(
name=record.name,
manifest_json=record.manifest_json,
approval_status=record.approval_status,
)
table.find_unique = AsyncMock(side_effect=unpatched_find_unique)
await update_plugin(
plugin_name="submitted-skill",
request=UpdatePluginRequest(source={"source": "github", "repo": "org/raced-in"}),
user_api_key_dict=_SUBMITTER,
)
return snapshot
table.find_unique = AsyncMock(side_effect=_edit_after_the_review_read)
with pytest.raises(HTTPException) as exc_info:
await approve_plugin(
plugin_name="submitted-skill",
request=ApprovePluginRequest(reviewed_fingerprint=reviewed_fingerprint),
user_api_key_dict=_USER,
)
assert exc_info.value.status_code == 409
stored = await _stored("submitted-skill")
assert stored.enabled is False
assert json.loads(stored.manifest_json)["source"]["repo"] == "org/raced-in"
@pytest.mark.asyncio
async def test_rejection_does_not_require_a_fingerprint():
"""Rejecting leaves the skill unpublished either way, so it is not bound to the reviewed content."""
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
await update_plugin(
plugin_name="submitted-skill",
request=UpdatePluginRequest(source={"source": "github", "repo": "org/changed"}),
user_api_key_dict=_SUBMITTER,
)
response = await reject_plugin(
plugin_name="submitted-skill",
request=RejectPluginRequest(review_notes="not this one"),
user_api_key_dict=_USER,
)
assert response.approval_status == "rejected"
assert response.enabled is False
_IDENTITY_LESS = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER)
async def _refusal(coro) -> tuple[int, object]:
with pytest.raises(HTTPException) as exc_info:
await coro
return exc_info.value.status_code, exc_info.value.detail
@pytest.mark.asyncio
async def test_update_of_a_hidden_skill_is_indistinguishable_from_an_absent_one():
"""A 403 here would tell an unrelated user that the name is taken by a pending submission."""
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
edit = UpdatePluginRequest(source={"source": "github", "repo": "org/probe"})
hidden = await _refusal(update_plugin(plugin_name="submitted-skill", request=edit, user_api_key_dict=_OTHER_USER))
absent = await _refusal(update_plugin(plugin_name="no-such-skill", request=edit, user_api_key_dict=_OTHER_USER))
assert absent == (404, {"error": "Plugin 'no-such-skill' not found"})
assert hidden == (404, {"error": "Plugin 'submitted-skill' not found"})
assert (await _stored("submitted-skill")).approval_status == "pending_review"
@pytest.mark.asyncio
async def test_delete_of_a_hidden_skill_is_indistinguishable_from_an_absent_one():
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
hidden = await _refusal(delete_plugin(plugin_name="submitted-skill", user_api_key_dict=_OTHER_USER))
absent = await _refusal(delete_plugin(plugin_name="no-such-skill", user_api_key_dict=_OTHER_USER))
assert absent == (404, {"error": "Plugin 'no-such-skill' not found"})
assert hidden == (404, {"error": "Plugin 'submitted-skill' not found"})
assert await _stored("submitted-skill") is not None
@pytest.mark.asyncio
async def test_a_published_skill_still_refuses_a_non_owner_with_403():
"""Hiding is only for skills the caller cannot see; an active skill is public, so the refusal stays 403."""
await register_plugin(
request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_USER,
)
updated = await _refusal(
update_plugin(
plugin_name="admin-skill",
request=UpdatePluginRequest(source={"source": "github", "repo": "org/probe"}),
user_api_key_dict=_OTHER_USER,
)
)
deleted = await _refusal(delete_plugin(plugin_name="admin-skill", user_api_key_dict=_OTHER_USER))
assert updated[0] == 403
assert deleted[0] == 403
@pytest.mark.asyncio
async def test_submission_without_an_attributable_identity_is_refused():
"""created_by would be null, leaving a pending row its own submitter can never list, read, or withdraw."""
assert get_primary_resource_owner_scope(_IDENTITY_LESS) is None
assert get_resource_owner_scopes(_IDENTITY_LESS) == []
status_code, _ = await _refusal(
register_plugin(
request=RegisterPluginRequest(name="orphan-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_IDENTITY_LESS,
)
)
assert status_code == 403
assert await _stored("orphan-skill") is None
@pytest.mark.asyncio
async def test_rejecting_an_approved_skill_is_refused_and_leaves_it_published():
"""Reject hides a skill from every non-owner, so on an approved skill it is a takedown, not a review step."""
await register_plugin(
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_SUBMITTER,
)
await _approve("submitted-skill")
status_code, _ = await _refusal(
reject_plugin(
plugin_name="submitted-skill",
request=RejectPluginRequest(review_notes="taking this down"),
user_api_key_dict=_USER,
)
)
assert status_code == 400
stored = await _stored("submitted-skill")
assert stored.approval_status == "active"
assert stored.enabled is True
assert stored.review_notes != "taking this down"
body = json.loads((await get_marketplace()).body)
assert [plugin["name"] for plugin in body["plugins"]] == ["submitted-skill"]

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,8 @@ 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 NotificationsManager from "@/components/molecules/notifications_manager";
import type { Plugin } from "@/components/claude_code_plugins/types";
import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel";
@ -10,6 +11,12 @@ import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel";
vi.mock("@/components/networking", () => ({
getClaudeCodePluginsList: vi.fn(),
deleteClaudeCodePlugin: vi.fn(),
reviewClaudeCodePlugin: vi.fn(),
}));
vi.mock("@/components/molecules/notifications_manager", () => ({
__esModule: true,
default: { success: vi.fn(), error: vi.fn(), fromBackend: vi.fn() },
}));
vi.mock("./PluginTable", () => ({
@ -18,21 +25,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 +57,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 +137,88 @@ 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",
manifest_fingerprint: "fingerprint-of-the-reviewed-content",
};
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", {
decision: "approve",
reviewNotes: "",
reviewedFingerprint: "fingerprint-of-the-reviewed-content",
}),
);
await waitFor(() => expect(mockGetClaudeCodePluginsList).toHaveBeenCalledTimes(2));
});
it("should tell the reviewer to look again when the submission changed under them", async () => {
const user = userEvent.setup();
mockReviewClaudeCodePlugin.mockRejectedValue(Object.assign(new Error("conflict"), { status: 409 }));
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(NotificationsManager.error).toHaveBeenCalledWith(
"This submission changed since the list was loaded. Review the refreshed content before approving it",
),
);
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", {
decision: "reject",
reviewNotes: "point at the skill folder",
reviewedFingerprint: "fingerprint-of-the-reviewed-content",
}),
);
});
});

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, reviewFailureMessage } 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,32 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
}
};
const handleReviewConfirm = async (notes: string) => {
if (!pluginToReview || !accessToken) return;
setIsReviewing(true);
try {
await reviewClaudeCodePlugin(accessToken, pluginToReview.plugin.name, {
decision: pluginToReview.decision,
reviewNotes: notes,
reviewedFingerprint: pluginToReview.plugin.manifest_fingerprint,
});
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(reviewFailureMessage(error));
fetchPlugins();
} finally {
setIsReviewing(false);
setPluginToReview(null);
}
};
return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
{selectedSkill ? (
@ -92,20 +128,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 +170,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,11 @@ import {
parseSkillSource,
isValidSubPath,
buildMarketplaceSettingsSnippet,
getApprovalStatusDisplay,
isAwaitingReview,
isApprovedSkill,
countAwaitingReview,
reviewFailureMessage,
} from "./helpers";
import { MarketplacePluginEntry, PluginSource } from "./types";
@ -587,3 +592,46 @@ 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);
});
});
describe("reviewFailureMessage", () => {
it("explains a 409 as the submission having changed, since the reviewer has to look again", () => {
expect(reviewFailureMessage(Object.assign(new Error("conflict"), { status: 409 }))).toBe(
"This submission changed since the list was loaded. Review the refreshed content before approving it",
);
});
it("falls back to a generic message for every other failure", () => {
expect(reviewFailureMessage(Object.assign(new Error("nope"), { status: 403 }))).toBe("Failed to review skill");
expect(reviewFailureMessage(new Error("network down"))).toBe("Failed to review skill");
expect(reviewFailureMessage(null)).toBe("Failed to review skill");
expect(reviewFailureMessage("409")).toBe("Failed to review skill");
});
});

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,43 @@ 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";
const CONFLICT_STATUS = 409;
const isStaleReviewConflict = (error: unknown): boolean =>
typeof error === "object" && error !== null && "status" in error && error.status === CONFLICT_STATUS;
/**
* The proxy answers 409 when the submitter edited the skill after it was listed, so the
* approval no longer refers to the content on screen. Say that rather than "failed", since
* the reviewer has to look at the refreshed row and decide again.
*/
export const reviewFailureMessage = (error: unknown): string =>
isStaleReviewConflict(error)
? "This submission changed since the list was loaded. Review the refreshed content before approving it"
: "Failed to review skill";

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,11 @@ export interface Plugin {
domain?: string;
namespace?: string;
enabled: boolean;
approval_status?: SkillApprovalStatus;
manifest_fingerprint?: string;
review_notes?: string | null;
reviewed_by?: string | null;
reviewed_at?: string | null;
created_at?: string;
updated_at?: string;
created_by?: string;
@ -47,6 +54,11 @@ export interface PluginListItem {
domain?: string;
namespace?: string;
enabled: boolean;
approval_status?: SkillApprovalStatus;
manifest_fingerprint?: string;
review_notes?: string | null;
reviewed_by?: string | null;
reviewed_at?: string | null;
created_at?: string;
updated_at?: string;
created_by?: string;

View file

@ -7489,6 +7489,32 @@ export const deleteClaudeCodePlugin = async (accessToken: string, pluginName: st
}
};
/**
* Approve or reject a submitted skill (admin only)
*/
export interface SkillReview {
decision: "approve" | "reject";
reviewNotes?: string;
/** Fingerprint of the manifest the reviewer read; the proxy 409s if it has changed since. */
reviewedFingerprint?: string;
}
export const reviewClaudeCodePlugin = async (accessToken: string, pluginName: string, review: SkillReview) => {
const { decision, reviewNotes, reviewedFingerprint } = review;
try {
return await apiClient.post(`/claude-code/plugins/${pluginName}/${decision}`, {
accessToken,
body: {
review_notes: reviewNotes || null,
...(decision === "approve" ? { reviewed_fingerprint: reviewedFingerprint ?? "" } : {}),
},
});
} catch (error) {
console.error(`Failed to ${decision} skill "${pluginName}":`, error);
throw error;
}
};
// Compliance check types and functions
export interface ComplianceCheckResult {

View file

@ -1611,13 +1611,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;
@ -1633,6 +1638,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)
@ -1644,7 +1653,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
@ -1697,6 +1707,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)
@ -1726,7 +1740,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
@ -1737,6 +1752,44 @@ 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.
*
* reviewed_fingerprint is the manifest_fingerprint returned by
* GET /claude-code/plugins and GET /claude-code/plugins/{plugin_name}. It
* ties the approval to the content that was read, so a submitter cannot get
* an edit published by landing it between the read and the approval.
*
* Example:
* ```bash
* FP=$(curl -s http://localhost:4000/claude-code/plugins/my-skill \
* -H "Authorization: Bearer sk-admin-..." | jq -r .manifest_fingerprint)
* curl -X POST http://localhost:4000/claude-code/plugins/my-skill/approve \
* -H "Authorization: Bearer sk-admin-..." \
* -H "Content-Type: application/json" \
* -d "{\"reviewed_fingerprint\": \"$FP\"}"
* ```
*/
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;
@ -1748,7 +1801,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
@ -1771,7 +1824,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
@ -1783,6 +1840,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;
@ -11339,7 +11426,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;
@ -21373,6 +21460,27 @@ export interface components {
/** Response Text */
response_text: string;
};
/**
* ApprovePluginRequest
* @description Administrator approval of a submitted skill.
*
* The fingerprint binds the approval to the content the administrator read.
* A submitter who edits their submission after it was read, but before it is
* approved, changes the fingerprint, so the approval is refused rather than
* publishing a manifest nobody reviewed.
*/
ApprovePluginRequest: {
/**
* Review Notes
* @description Reviewer feedback shown to the submitter
*/
review_notes?: string | null;
/**
* Reviewed Fingerprint
* @description manifest_fingerprint of the submission that was reviewed, from GET /claude-code/plugins
*/
reviewed_fingerprint: string;
};
/**
* AttachmentImpactResponse
* @description Response for estimating the impact of a policy attachment.
@ -30415,11 +30523,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 */
@ -30432,10 +30548,21 @@ export interface components {
id: string;
/** Keywords */
keywords?: string[] | null;
/**
* Manifest Fingerprint
* @default
*/
manifest_fingerprint: string;
/** Name */
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;
@ -30450,6 +30577,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
@ -31770,7 +31904,7 @@ export interface components {
RegisterPluginResponse: {
/**
* Action
* @description Action taken (created/updated)
* @description Action taken (created/submitted_for_review/updated)
*/
action: string;
/** @description Plugin information */
@ -31786,6 +31920,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.
@ -32051,6 +32196,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.
@ -38610,6 +38797,7 @@ export interface operations {
parameters: {
query?: {
enabled_only?: boolean;
approval_status?: ("pending_review" | "active" | "rejected") | null;
};
header?: never;
path?: never;
@ -38767,6 +38955,41 @@ export interface operations {
};
};
};
approve_plugin_claude_code_plugins__plugin_name__approve_post: {
parameters: {
query?: never;
header?: never;
path: {
plugin_name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ApprovePluginRequest"];
};
};
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;
@ -38829,6 +39052,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;