mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
feat(skills): self-service skill submission with admin review
Non-admin registrations land as approval_status=pending_review and disabled, so only skills an admin approves reach marketplace.json and the public Skill Hub. Admins approve or reject with notes through new /claude-code/plugins/{name}/approve and /reject routes, and the Skills page gets a submit button for everyone plus a pending queue with approve and reject for admins.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
06943b6468
commit
7652824325
25 changed files with 1683 additions and 104 deletions
|
|
@ -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");
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4380,6 +4380,16 @@
|
|||
"PluginListItem": {
|
||||
"description": "Plugin item in list responses.",
|
||||
"properties": {
|
||||
"approval_status": {
|
||||
"default": "active",
|
||||
"enum": [
|
||||
"pending_review",
|
||||
"active",
|
||||
"rejected"
|
||||
],
|
||||
"title": "Approval Status",
|
||||
"type": "string"
|
||||
},
|
||||
"author": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -4412,6 +4422,17 @@
|
|||
],
|
||||
"title": "Created At"
|
||||
},
|
||||
"created_by": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Created By"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -4482,6 +4503,39 @@
|
|||
],
|
||||
"title": "Namespace"
|
||||
},
|
||||
"review_notes": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Review Notes"
|
||||
},
|
||||
"reviewed_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Reviewed At"
|
||||
},
|
||||
"reviewed_by": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Reviewed By"
|
||||
},
|
||||
"source": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
|
|
@ -4528,6 +4582,17 @@
|
|||
"PluginResponse": {
|
||||
"description": "Plugin information in API responses.",
|
||||
"properties": {
|
||||
"approval_status": {
|
||||
"default": "active",
|
||||
"description": "Administrator approval state",
|
||||
"enum": [
|
||||
"pending_review",
|
||||
"active",
|
||||
"rejected"
|
||||
],
|
||||
"title": "Approval Status",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -4713,7 +4778,7 @@
|
|||
"description": "Response from plugin registration.",
|
||||
"properties": {
|
||||
"action": {
|
||||
"description": "Action taken (created/updated)",
|
||||
"description": "Action taken (created/submitted_for_review/updated)",
|
||||
"title": "Action",
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -4735,6 +4800,99 @@
|
|||
"title": "RegisterPluginResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"RejectPluginRequest": {
|
||||
"description": "Administrator rejection of a submitted skill.",
|
||||
"properties": {
|
||||
"review_notes": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Reviewer feedback shown to the submitter",
|
||||
"title": "Review Notes"
|
||||
}
|
||||
},
|
||||
"title": "RejectPluginRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"ReviewPluginResponse": {
|
||||
"description": "Response from approving or rejecting a submitted skill.",
|
||||
"properties": {
|
||||
"approval_status": {
|
||||
"description": "Resulting approval state",
|
||||
"enum": [
|
||||
"pending_review",
|
||||
"active",
|
||||
"rejected"
|
||||
],
|
||||
"title": "Approval Status",
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Whether the skill is now served to users",
|
||||
"title": "Enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Skill name",
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"review_notes": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Reviewer feedback",
|
||||
"title": "Review Notes"
|
||||
},
|
||||
"reviewed_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "ISO timestamp of the review",
|
||||
"title": "Reviewed At"
|
||||
},
|
||||
"reviewed_by": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "User id of the reviewing administrator",
|
||||
"title": "Reviewed By"
|
||||
},
|
||||
"status": {
|
||||
"description": "Operation status",
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"name",
|
||||
"approval_status",
|
||||
"enabled"
|
||||
],
|
||||
"title": "ReviewPluginResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"UpdatePluginRequest": {
|
||||
"description": "Request body for replacing an existing plugin.\n\nThe plugin name is the resource identity and is supplied as the path\nparameter, so it cannot be changed here. This is a full replace: omitted\nfields reset to their defaults, so version is cleared rather than\ndefaulting to the create-time \"1.0.0\".",
|
||||
"properties": {
|
||||
|
|
@ -4916,7 +5074,7 @@
|
|||
},
|
||||
"/claude-code/plugins": {
|
||||
"get": {
|
||||
"description": "List all plugins in the marketplace.\n\nParameters:\n - enabled_only: If true, only return enabled plugins\n\nReturns:\n List of plugins with their metadata.",
|
||||
"description": "List plugins in the marketplace.\n\nAdmins see every skill, including submissions awaiting review. Everyone\nelse sees approved skills plus their own submissions, so a submitter can\ntrack the status of what they sent in.\n\nParameters:\n - enabled_only: If true, only return enabled plugins\n - approval_status: Filter to one approval state, e.g. `pending_review` for the admin review queue\n\nReturns:\n List of plugins with their metadata and review state.",
|
||||
"operationId": "list_plugins_claude_code_plugins_get",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -4928,6 +5086,27 @@
|
|||
"title": "Enabled Only",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "approval_status",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"pending_review",
|
||||
"active",
|
||||
"rejected"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Approval Status"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
|
@ -4963,7 +5142,7 @@
|
|||
]
|
||||
},
|
||||
"post": {
|
||||
"description": "Register a new plugin in the LiteLLM marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket. Claude Code will clone from the git source\nwhen users install.\n\nThis endpoint is create-only and never overwrites. If a plugin with\nthe same name already exists it returns 409 Conflict; use\nPUT /claude-code/plugins/{plugin_name} to update an existing plugin.\n\nParameters:\n - name: Plugin name (kebab-case)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Registration status (action is always \"created\") and plugin information.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"my-plugin\",\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"1.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```",
|
||||
"description": "Register a new plugin in the LiteLLM marketplace.\n\nLiteLLM acts as a registry/discovery layer. Plugins are hosted on\nGitHub/GitLab/Bitbucket. Claude Code will clone from the git source\nwhen users install.\n\nThis endpoint is create-only and never overwrites. If a plugin with\nthe same name already exists it returns 409 Conflict; use\nPUT /claude-code/plugins/{plugin_name} to update an existing plugin.\n\nCallers that are not proxy admins are self-service submitters: the skill\nis stored with approval_status=pending_review and stays disabled until an\nadmin approves it via POST /claude-code/plugins/{plugin_name}/approve.\n\nParameters:\n - name: Plugin name (kebab-case)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Registration status (\"created\" for admins, \"submitted_for_review\" otherwise)\n and plugin information.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"my-plugin\",\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"1.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```",
|
||||
"operationId": "register_plugin_claude_code_plugins_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
|
|
@ -5010,7 +5189,7 @@
|
|||
},
|
||||
"/claude-code/plugins/{plugin_name}": {
|
||||
"delete": {
|
||||
"description": "Delete a plugin from the marketplace.\n\nParameters:\n - plugin_name: The name of the plugin to delete",
|
||||
"description": "Delete a plugin from the marketplace. Admins can delete any skill; a\nsubmitter can withdraw one they submitted.\n\nParameters:\n - plugin_name: The name of the plugin to delete",
|
||||
"operationId": "delete_plugin_claude_code_plugins__plugin_name__delete",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -5098,7 +5277,7 @@
|
|||
]
|
||||
},
|
||||
"put": {
|
||||
"description": "Update an existing plugin in the LiteLLM marketplace.\n\nThe plugin is identified by its name in the path, which is the resource\nidentity and cannot be changed here. This is a full replace, not a merge:\nthe manifest is rebuilt from the request body, so any optional field left\nout is reset to its default (e.g. an omitted version is cleared, not kept).\nSend the full desired state.\n\nReturns 404 if no plugin with the given name exists; use\nPOST /claude-code/plugins to create a new plugin.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Update status (action is always \"updated\") and plugin information.\n\nExample:\n ```bash\n curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"2.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```",
|
||||
"description": "Update an existing plugin in the LiteLLM marketplace.\n\nThe plugin is identified by its name in the path, which is the resource\nidentity and cannot be changed here. This is a full replace, not a merge:\nthe manifest is rebuilt from the request body, so any optional field left\nout is reset to its default (e.g. an omitted version is cleared, not kept).\nSend the full desired state.\n\nReturns 404 if no plugin with the given name exists; use\nPOST /claude-code/plugins to create a new plugin.\n\nAdmins can update any skill and the review state is left untouched. A\nsubmitter can only update their own skill, and doing so sends it back to\npending review, since the content an admin approved has changed.\n\nParameters:\n - plugin_name: Name of the plugin to update (path parameter)\n - source: Git source reference (github, url, or git-subdir format)\n - version: Semantic version (optional)\n - description: Plugin description (optional)\n - author: Author information (optional)\n - homepage: Plugin homepage URL (optional)\n - keywords: Search keywords (optional)\n - category: Plugin category (optional)\n\nReturns:\n Update status (action is always \"updated\") and plugin information.\n\nExample:\n ```bash\n curl -X PUT http://localhost:4000/claude-code/plugins/my-plugin \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"source\": {\"source\": \"github\", \"repo\": \"org/my-plugin\"},\n \"version\": \"2.0.0\",\n \"description\": \"My awesome plugin\"\n }'\n ```",
|
||||
"operationId": "update_plugin_claude_code_plugins__plugin_name__put",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -5154,9 +5333,57 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/claude-code/plugins/{plugin_name}/approve": {
|
||||
"post": {
|
||||
"description": "Approve a submitted skill (admin only).\n\nApproving sets approval_status=active and publishes the skill to\nmarketplace.json and the public Skill Hub.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins/my-skill/approve \\\n -H \"Authorization: Bearer sk-admin-...\"\n ```",
|
||||
"operationId": "approve_plugin_claude_code_plugins__plugin_name__approve_post",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "plugin_name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Plugin Name",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReviewPluginResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Approve Plugin",
|
||||
"tags": [
|
||||
"claude_code_marketplace"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/claude-code/plugins/{plugin_name}/disable": {
|
||||
"post": {
|
||||
"description": "Disable a plugin without deleting it.\n\nParameters:\n - plugin_name: The name of the plugin to disable",
|
||||
"description": "Disable a plugin without deleting it. Proxy admins only.\n\nParameters:\n - plugin_name: The name of the plugin to disable",
|
||||
"operationId": "disable_plugin_claude_code_plugins__plugin_name__disable_post",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -5202,7 +5429,7 @@
|
|||
},
|
||||
"/claude-code/plugins/{plugin_name}/enable": {
|
||||
"post": {
|
||||
"description": "Enable a disabled plugin.\n\nParameters:\n - plugin_name: The name of the plugin to enable",
|
||||
"description": "Enable a disabled plugin. Proxy admins only.\n\nA skill that has not been approved cannot be enabled here: approve it\nthrough POST /claude-code/plugins/{plugin_name}/approve instead, so the\nreviewer is recorded on the row.\n\nParameters:\n - plugin_name: The name of the plugin to enable",
|
||||
"operationId": "enable_plugin_claude_code_plugins__plugin_name__enable_post",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -5245,6 +5472,64 @@
|
|||
"claude_code_marketplace"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/claude-code/plugins/{plugin_name}/reject": {
|
||||
"post": {
|
||||
"description": "Reject a submitted skill (admin only).\n\nThe row is kept unpublished so the submitter can read review_notes and fix the submission.\n\nExample:\n ```bash\n curl -X POST http://localhost:4000/claude-code/plugins/my-skill/reject \\\n -H \"Authorization: Bearer sk-admin-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"review_notes\": \"point the source at the skill folder\"}'\n ```",
|
||||
"operationId": "reject_plugin_claude_code_plugins__plugin_name__reject_post",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "plugin_name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Plugin Name",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RejectPluginRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReviewPluginResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Reject Plugin",
|
||||
"tags": [
|
||||
"claude_code_marketplace"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -753,6 +753,10 @@ class LiteLLMRoutes(enum.Enum):
|
|||
# Create/update/delete and test_connection stay admin-only.
|
||||
"/search_tools/list",
|
||||
"/search_tools/ui/available_providers",
|
||||
# Self-service skill submission. Approve/reject stay admin-only.
|
||||
"/claude-code/marketplace.json",
|
||||
"/claude-code/plugins",
|
||||
"/claude-code/plugins/{plugin_name}",
|
||||
]
|
||||
+ spend_tracking_routes
|
||||
+ key_management_routes
|
||||
|
|
|
|||
|
|
@ -13,14 +13,21 @@ Endpoints:
|
|||
/claude-code/plugins/{name} - PUT - Update an existing plugin
|
||||
/claude-code/plugins/{name}/enable - POST - Enable a plugin
|
||||
/claude-code/plugins/{name}/disable - POST - Disable a plugin
|
||||
/claude-code/plugins/{name}/approve - POST - Approve a submitted plugin (admin)
|
||||
/claude-code/plugins/{name}/reject - POST - Reject a submitted plugin (admin)
|
||||
/claude-code/plugins/{name} - DELETE - Delete a plugin
|
||||
|
||||
Skills registered by a non-admin are submissions: they are stored with
|
||||
approval_status="pending_review" and disabled until an administrator approves
|
||||
them, so only approved skills reach marketplace.json and the public Skill Hub.
|
||||
This mirrors the MCP server and guardrail submission flows.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import Final, Protocol, TypedDict
|
||||
from typing import Annotated, Final, Protocol, TypedDict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
|
@ -28,14 +35,25 @@ from fastapi.responses import JSONResponse
|
|||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.resource_ownership import (
|
||||
get_primary_resource_owner_scope,
|
||||
get_resource_owner_scopes,
|
||||
is_proxy_admin,
|
||||
)
|
||||
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
|
||||
from litellm.types.proxy.claude_code_endpoints import (
|
||||
SKILL_ACTIVE,
|
||||
SKILL_PENDING_REVIEW,
|
||||
SKILL_REJECTED,
|
||||
ListPluginsResponse,
|
||||
PluginListItem,
|
||||
PluginResponse,
|
||||
PluginSpec,
|
||||
RegisterPluginRequest,
|
||||
RegisterPluginResponse,
|
||||
RejectPluginRequest,
|
||||
ReviewPluginResponse,
|
||||
SkillApprovalStatus,
|
||||
UpdatePluginRequest,
|
||||
)
|
||||
|
||||
|
|
@ -49,6 +67,10 @@ class _PluginRecord(Protocol):
|
|||
description: str | None
|
||||
manifest_json: str | None
|
||||
enabled: bool
|
||||
approval_status: str | None
|
||||
review_notes: str | None
|
||||
reviewed_by: str | None
|
||||
reviewed_at: datetime | None
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
created_by: str | None
|
||||
|
|
@ -65,6 +87,49 @@ class _MarketplaceEntry(TypedDict, total=False):
|
|||
category: object
|
||||
|
||||
|
||||
def published_skill_filter() -> dict[str, object]: # mutable-ok: prisma query arguments must be plain dicts
|
||||
"""Where-clause for the skills served to users: admin-approved and enabled."""
|
||||
return {"enabled": True, "approval_status": SKILL_ACTIVE} # mutable-ok: prisma query arguments are dicts
|
||||
|
||||
|
||||
def _as_approval_status(raw: str | None) -> SkillApprovalStatus:
|
||||
"""Rows written before approval existed, and any hand-edited row, read back as active."""
|
||||
match raw:
|
||||
case "pending_review":
|
||||
return SKILL_PENDING_REVIEW
|
||||
case "rejected":
|
||||
return SKILL_REJECTED
|
||||
case _:
|
||||
return SKILL_ACTIVE
|
||||
|
||||
|
||||
def _caller_can_see(plugin: "_PluginRecord", user_api_key_dict: UserAPIKeyAuth) -> bool:
|
||||
return (
|
||||
is_proxy_admin(user_api_key_dict)
|
||||
or _as_approval_status(plugin.approval_status) == SKILL_ACTIVE
|
||||
or plugin.created_by in get_resource_owner_scopes(user_api_key_dict)
|
||||
)
|
||||
|
||||
|
||||
def _list_plugins_filter(
|
||||
*,
|
||||
enabled_only: bool,
|
||||
approval_status: SkillApprovalStatus | None,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> dict[str, object]: # mutable-ok: prisma query arguments must be plain dicts
|
||||
status_terms: Final[tuple[tuple[str, object], ...]] = (
|
||||
*((("enabled", True),) if enabled_only else ()),
|
||||
*((("approval_status", approval_status),) if approval_status is not None else ()),
|
||||
)
|
||||
if is_proxy_admin(user_api_key_dict):
|
||||
return dict(status_terms) # mutable-ok: prisma query arguments are dicts
|
||||
|
||||
owner_scopes: Final = get_resource_owner_scopes(user_api_key_dict)
|
||||
own_skills: Final = ({"created_by": {"in": owner_scopes}},) if owner_scopes else () # mutable-ok: prisma dicts
|
||||
visible: Final = [{"approval_status": SKILL_ACTIVE}, *own_skills] # mutable-ok: prisma query arguments are dicts
|
||||
return dict((*status_terms, ("OR", visible))) # mutable-ok: prisma query arguments are dicts
|
||||
|
||||
|
||||
async def _get_prisma_client() -> object:
|
||||
"""Get the prisma client from proxy_server."""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
|
@ -102,7 +167,7 @@ async def get_marketplace():
|
|||
prisma_client: Final = await _get_prisma_client()
|
||||
|
||||
plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many(
|
||||
where={"enabled": True}
|
||||
where=published_skill_filter()
|
||||
)
|
||||
|
||||
plugin_list: Final = []
|
||||
|
|
@ -242,6 +307,10 @@ async def register_plugin(
|
|||
the same name already exists it returns 409 Conflict; use
|
||||
PUT /claude-code/plugins/{plugin_name} to update an existing plugin.
|
||||
|
||||
Callers that are not proxy admins are self-service submitters: the skill
|
||||
is stored with approval_status=pending_review and stays disabled until an
|
||||
admin approves it via POST /claude-code/plugins/{plugin_name}/approve.
|
||||
|
||||
Parameters:
|
||||
- name: Plugin name (kebab-case)
|
||||
- source: Git source reference (github, url, or git-subdir format)
|
||||
|
|
@ -253,7 +322,8 @@ async def register_plugin(
|
|||
- category: Plugin category (optional)
|
||||
|
||||
Returns:
|
||||
Registration status (action is always "created") and plugin information.
|
||||
Registration status ("created" for admins, "submitted_for_review" otherwise)
|
||||
and plugin information.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
|
|
@ -288,6 +358,8 @@ async def register_plugin(
|
|||
raise _name_conflict_error(request.name)
|
||||
|
||||
manifest: Final[Mapping[str, object]] = _build_plugin_manifest(request.name, request)
|
||||
submitted_for_review: Final = not is_proxy_admin(user_api_key_dict)
|
||||
approval_status: Final[SkillApprovalStatus] = SKILL_PENDING_REVIEW if submitted_for_review else SKILL_ACTIVE
|
||||
|
||||
try:
|
||||
plugin: Final[_PluginRecord] = await ClaudeCodePluginRepository(prisma_client).table.create(
|
||||
|
|
@ -297,20 +369,21 @@ async def register_plugin(
|
|||
"description": request.description,
|
||||
"manifest_json": json.dumps(manifest),
|
||||
"files_json": "{}",
|
||||
"enabled": True,
|
||||
"enabled": not submitted_for_review,
|
||||
"approval_status": approval_status,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
"created_by": user_api_key_dict.user_id,
|
||||
"created_by": user_api_key_dict.user_id or get_primary_resource_owner_scope(user_api_key_dict),
|
||||
}
|
||||
)
|
||||
except UniqueViolationError:
|
||||
raise _name_conflict_error(request.name)
|
||||
|
||||
verbose_proxy_logger.info("Plugin %s created successfully", request.name)
|
||||
verbose_proxy_logger.info("Plugin %s created with approval_status=%s", request.name, approval_status)
|
||||
|
||||
return RegisterPluginResponse(
|
||||
status="success",
|
||||
action="created",
|
||||
action="submitted_for_review" if submitted_for_review else "created",
|
||||
plugin=PluginResponse(
|
||||
id=plugin.id,
|
||||
name=plugin.name,
|
||||
|
|
@ -318,6 +391,7 @@ async def register_plugin(
|
|||
description=plugin.description,
|
||||
source=request.source,
|
||||
enabled=plugin.enabled,
|
||||
approval_status=approval_status,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -339,23 +413,32 @@ async def register_plugin(
|
|||
)
|
||||
async def list_plugins(
|
||||
enabled_only: bool = False,
|
||||
approval_status: SkillApprovalStatus | None = None,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
List all plugins in the marketplace.
|
||||
List plugins in the marketplace.
|
||||
|
||||
Admins see every skill, including submissions awaiting review. Everyone
|
||||
else sees approved skills plus their own submissions, so a submitter can
|
||||
track the status of what they sent in.
|
||||
|
||||
Parameters:
|
||||
- enabled_only: If true, only return enabled plugins
|
||||
- approval_status: Filter to one approval state, e.g. `pending_review` for the admin review queue
|
||||
|
||||
Returns:
|
||||
List of plugins with their metadata.
|
||||
List of plugins with their metadata and review state.
|
||||
"""
|
||||
try:
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
|
||||
where: Final = {"enabled": True} if enabled_only else {}
|
||||
plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many(
|
||||
where=where
|
||||
where=_list_plugins_filter(
|
||||
enabled_only=enabled_only,
|
||||
approval_status=approval_status,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
)
|
||||
|
||||
plugin_list: Final = []
|
||||
|
|
@ -377,6 +460,11 @@ async def list_plugins(
|
|||
domain=manifest.get("domain"),
|
||||
namespace=manifest.get("namespace"),
|
||||
enabled=p.enabled,
|
||||
approval_status=_as_approval_status(p.approval_status),
|
||||
review_notes=p.review_notes,
|
||||
reviewed_by=p.reviewed_by,
|
||||
reviewed_at=p.reviewed_at.isoformat() if p.reviewed_at else None,
|
||||
created_by=p.created_by,
|
||||
created_at=p.created_at.isoformat() if p.created_at else None,
|
||||
updated_at=p.updated_at.isoformat() if p.updated_at else None,
|
||||
)
|
||||
|
|
@ -425,7 +513,7 @@ async def get_plugin(
|
|||
where={"name": plugin_name}
|
||||
)
|
||||
|
||||
if not plugin:
|
||||
if not plugin or not _caller_can_see(plugin, user_api_key_dict):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Plugin '{plugin_name}' not found"},
|
||||
|
|
@ -444,6 +532,10 @@ async def get_plugin(
|
|||
"keywords": manifest.get("keywords"),
|
||||
"category": manifest.get("category"),
|
||||
"enabled": plugin.enabled,
|
||||
"approval_status": _as_approval_status(plugin.approval_status),
|
||||
"review_notes": plugin.review_notes,
|
||||
"reviewed_by": plugin.reviewed_by,
|
||||
"reviewed_at": plugin.reviewed_at.isoformat() if plugin.reviewed_at else None,
|
||||
"created_at": plugin.created_at.isoformat() if plugin.created_at else None,
|
||||
"updated_at": plugin.updated_at.isoformat() if plugin.updated_at else None,
|
||||
"created_by": plugin.created_by,
|
||||
|
|
@ -468,6 +560,7 @@ async def get_plugin(
|
|||
async def update_plugin(
|
||||
plugin_name: str,
|
||||
request: UpdatePluginRequest,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
):
|
||||
"""
|
||||
Update an existing plugin in the LiteLLM marketplace.
|
||||
|
|
@ -481,6 +574,10 @@ async def update_plugin(
|
|||
Returns 404 if no plugin with the given name exists; use
|
||||
POST /claude-code/plugins to create a new plugin.
|
||||
|
||||
Admins can update any skill and the review state is left untouched. A
|
||||
submitter can only update their own skill, and doing so sends it back to
|
||||
pending review, since the content an admin approved has changed.
|
||||
|
||||
Parameters:
|
||||
- plugin_name: Name of the plugin to update (path parameter)
|
||||
- source: Git source reference (github, url, or git-subdir format)
|
||||
|
|
@ -519,6 +616,10 @@ async def update_plugin(
|
|||
if not existing:
|
||||
raise _error_response(404, f"Plugin '{plugin_name}' not found")
|
||||
|
||||
is_admin: Final = is_proxy_admin(user_api_key_dict)
|
||||
if not is_admin and existing.created_by not in get_resource_owner_scopes(user_api_key_dict):
|
||||
raise _error_response(403, "Only proxy admins or the submitter can update this skill")
|
||||
|
||||
manifest: Final[Mapping[str, object]] = _build_plugin_manifest(plugin_name, request)
|
||||
|
||||
plugin: Final[_PluginRecord] = await ClaudeCodePluginRepository(prisma_client).table.update(
|
||||
|
|
@ -529,6 +630,9 @@ async def update_plugin(
|
|||
"manifest_json": json.dumps(manifest),
|
||||
"files_json": "{}",
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
**(
|
||||
{} if is_admin else {"approval_status": SKILL_PENDING_REVIEW, "enabled": False}
|
||||
), # mutable-ok: prisma query arguments must be plain dicts
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -544,6 +648,7 @@ async def update_plugin(
|
|||
description=plugin.description,
|
||||
source=request.source,
|
||||
enabled=plugin.enabled,
|
||||
approval_status=_as_approval_status(plugin.approval_status),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -554,6 +659,116 @@ async def update_plugin(
|
|||
raise _error_response(500, f"Update failed: {e}")
|
||||
|
||||
|
||||
async def _record_review(
|
||||
*,
|
||||
plugin_name: str,
|
||||
approval_status: SkillApprovalStatus,
|
||||
review_notes: str | None,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> ReviewPluginResponse:
|
||||
if not is_proxy_admin(user_api_key_dict):
|
||||
raise _error_response(403, "Admin access required to review submitted skills")
|
||||
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
repository: Final = ClaudeCodePluginRepository(prisma_client)
|
||||
|
||||
existing: Final[_PluginRecord | None] = await repository.table.find_unique(
|
||||
where={"name": plugin_name} # mutable-ok: prisma query arguments must be plain dicts
|
||||
)
|
||||
if not existing:
|
||||
raise _error_response(404, f"Plugin '{plugin_name}' not found")
|
||||
|
||||
if _as_approval_status(existing.approval_status) == approval_status:
|
||||
raise _error_response(400, f"Skill '{plugin_name}' is already {approval_status}")
|
||||
|
||||
reviewed_at: Final = datetime.now(timezone.utc)
|
||||
plugin: Final[_PluginRecord] = await repository.table.update(
|
||||
where={"name": plugin_name}, # mutable-ok: prisma query arguments must be plain dicts
|
||||
data={ # mutable-ok: prisma query arguments must be plain dicts
|
||||
"approval_status": approval_status,
|
||||
"review_notes": review_notes,
|
||||
"reviewed_by": user_api_key_dict.user_id,
|
||||
"reviewed_at": reviewed_at,
|
||||
"enabled": approval_status == SKILL_ACTIVE,
|
||||
"updated_at": reviewed_at,
|
||||
},
|
||||
)
|
||||
|
||||
verbose_proxy_logger.info("Plugin %s reviewed: approval_status=%s", plugin_name, approval_status)
|
||||
|
||||
return ReviewPluginResponse(
|
||||
status="success",
|
||||
name=plugin.name,
|
||||
approval_status=approval_status,
|
||||
enabled=plugin.enabled,
|
||||
reviewed_by=plugin.reviewed_by,
|
||||
reviewed_at=plugin.reviewed_at.isoformat() if plugin.reviewed_at else None,
|
||||
review_notes=plugin.review_notes,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claude-code/plugins/{plugin_name}/approve",
|
||||
tags=["Claude Code Marketplace"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=ReviewPluginResponse,
|
||||
)
|
||||
async def approve_plugin(
|
||||
plugin_name: str,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
):
|
||||
"""
|
||||
Approve a submitted skill (admin only).
|
||||
|
||||
Approving sets approval_status=active and publishes the skill to
|
||||
marketplace.json and the public Skill Hub.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
curl -X POST http://localhost:4000/claude-code/plugins/my-skill/approve \\
|
||||
-H "Authorization: Bearer sk-admin-..."
|
||||
```
|
||||
"""
|
||||
return await _record_review(
|
||||
plugin_name=plugin_name,
|
||||
approval_status=SKILL_ACTIVE,
|
||||
review_notes=None,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claude-code/plugins/{plugin_name}/reject",
|
||||
tags=["Claude Code Marketplace"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=ReviewPluginResponse,
|
||||
)
|
||||
async def reject_plugin(
|
||||
plugin_name: str,
|
||||
request: RejectPluginRequest,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
):
|
||||
"""
|
||||
Reject a submitted skill (admin only).
|
||||
|
||||
The row is kept unpublished so the submitter can read review_notes and fix the submission.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
curl -X POST http://localhost:4000/claude-code/plugins/my-skill/reject \\
|
||||
-H "Authorization: Bearer sk-admin-..." \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"review_notes": "point the source at the skill folder"}'
|
||||
```
|
||||
"""
|
||||
return await _record_review(
|
||||
plugin_name=plugin_name,
|
||||
approval_status=SKILL_REJECTED,
|
||||
review_notes=request.review_notes,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claude-code/plugins/{plugin_name}/enable",
|
||||
tags=["Claude Code Marketplace"],
|
||||
|
|
@ -564,11 +779,18 @@ async def enable_plugin(
|
|||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Enable a disabled plugin.
|
||||
Enable a disabled plugin. Proxy admins only.
|
||||
|
||||
A skill that has not been approved cannot be enabled here: approve it
|
||||
through POST /claude-code/plugins/{plugin_name}/approve instead, so the
|
||||
reviewer is recorded on the row.
|
||||
|
||||
Parameters:
|
||||
- plugin_name: The name of the plugin to enable
|
||||
"""
|
||||
if not is_proxy_admin(user_api_key_dict):
|
||||
raise _error_response(403, "Only proxy admins can publish skills")
|
||||
|
||||
try:
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
|
||||
|
|
@ -581,6 +803,13 @@ async def enable_plugin(
|
|||
detail={"error": f"Plugin '{plugin_name}' not found"},
|
||||
)
|
||||
|
||||
if _as_approval_status(plugin.approval_status) != SKILL_ACTIVE:
|
||||
raise _error_response(
|
||||
409,
|
||||
f"Skill '{plugin_name}' is awaiting review. Approve it via "
|
||||
f"POST /claude-code/plugins/{plugin_name}/approve",
|
||||
)
|
||||
|
||||
await ClaudeCodePluginRepository(prisma_client).table.update(
|
||||
where={"name": plugin_name},
|
||||
data={"enabled": True, "updated_at": datetime.now(timezone.utc)},
|
||||
|
|
@ -609,11 +838,14 @@ async def disable_plugin(
|
|||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Disable a plugin without deleting it.
|
||||
Disable a plugin without deleting it. Proxy admins only.
|
||||
|
||||
Parameters:
|
||||
- plugin_name: The name of the plugin to disable
|
||||
"""
|
||||
if not is_proxy_admin(user_api_key_dict):
|
||||
raise _error_response(403, "Only proxy admins can unpublish skills")
|
||||
|
||||
try:
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
|
||||
|
|
@ -654,7 +886,8 @@ async def delete_plugin(
|
|||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Delete a plugin from the marketplace.
|
||||
Delete a plugin from the marketplace. Admins can delete any skill; a
|
||||
submitter can withdraw one they submitted.
|
||||
|
||||
Parameters:
|
||||
- plugin_name: The name of the plugin to delete
|
||||
|
|
@ -671,6 +904,11 @@ async def delete_plugin(
|
|||
detail={"error": f"Plugin '{plugin_name}' not found"},
|
||||
)
|
||||
|
||||
if not is_proxy_admin(user_api_key_dict) and plugin.created_by not in get_resource_owner_scopes(
|
||||
user_api_key_dict
|
||||
):
|
||||
raise _error_response(403, "Only proxy admins or the submitter can delete this skill")
|
||||
|
||||
await ClaudeCodePluginRepository(prisma_client).table.delete(where={"name": plugin_name})
|
||||
|
||||
verbose_proxy_logger.info("Plugin %s deleted", plugin_name)
|
||||
|
|
|
|||
|
|
@ -248,9 +248,10 @@ async def get_mcp_servers():
|
|||
tags=["public", "Claude Code Marketplace"],
|
||||
)
|
||||
async def public_skill_hub():
|
||||
"""Return enabled (public) Claude Code skills — no auth required."""
|
||||
"""Return approved, enabled (public) Claude Code skills. No auth required."""
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
|
||||
_get_prisma_client,
|
||||
published_skill_filter,
|
||||
)
|
||||
from litellm.types.proxy.claude_code_endpoints import (
|
||||
ListPluginsResponse,
|
||||
|
|
@ -259,7 +260,7 @@ async def public_skill_hub():
|
|||
|
||||
try:
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
plugins: Final = await ClaudeCodePluginRepository(prisma_client).table.find_many(where={"enabled": True})
|
||||
plugins: Final = await ClaudeCodePluginRepository(prisma_client).table.find_many(where=published_skill_filter())
|
||||
items: Final = []
|
||||
for plugin in plugins:
|
||||
raw = plugin.manifest_json or {}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,16 @@
|
|||
Claude Code Marketplace endpoint types for LiteLLM Proxy
|
||||
"""
|
||||
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
SkillApprovalStatus = Literal["pending_review", "active", "rejected"]
|
||||
|
||||
SKILL_PENDING_REVIEW: Final[SkillApprovalStatus] = "pending_review"
|
||||
SKILL_ACTIVE: Final[SkillApprovalStatus] = "active"
|
||||
SKILL_REJECTED: Final[SkillApprovalStatus] = "rejected"
|
||||
|
||||
|
||||
class PluginAuthor(BaseModel):
|
||||
"""Plugin author information."""
|
||||
|
|
@ -78,16 +86,35 @@ class PluginResponse(BaseModel):
|
|||
description: str | None = Field(None, description="Plugin description")
|
||||
source: dict[str, str] = Field(..., description="Git source reference")
|
||||
enabled: bool = Field(..., description="Whether plugin is enabled")
|
||||
approval_status: SkillApprovalStatus = Field("active", description="Administrator approval state")
|
||||
|
||||
|
||||
class RegisterPluginResponse(BaseModel):
|
||||
"""Response from plugin registration."""
|
||||
|
||||
status: str = Field(..., description="Operation status")
|
||||
action: str = Field(..., description="Action taken (created/updated)")
|
||||
action: str = Field(..., description="Action taken (created/submitted_for_review/updated)")
|
||||
plugin: PluginResponse = Field(..., description="Plugin information")
|
||||
|
||||
|
||||
class RejectPluginRequest(BaseModel):
|
||||
"""Administrator rejection of a submitted skill."""
|
||||
|
||||
review_notes: str | None = Field(None, description="Reviewer feedback shown to the submitter")
|
||||
|
||||
|
||||
class ReviewPluginResponse(BaseModel):
|
||||
"""Response from approving or rejecting a submitted skill."""
|
||||
|
||||
status: str = Field(..., description="Operation status")
|
||||
name: str = Field(..., description="Skill name")
|
||||
approval_status: SkillApprovalStatus = Field(..., description="Resulting approval state")
|
||||
enabled: bool = Field(..., description="Whether the skill is now served to users")
|
||||
reviewed_by: str | None = Field(None, description="User id of the reviewing administrator")
|
||||
reviewed_at: str | None = Field(None, description="ISO timestamp of the review")
|
||||
review_notes: str | None = Field(None, description="Reviewer feedback")
|
||||
|
||||
|
||||
class PluginListItem(BaseModel):
|
||||
"""Plugin item in list responses."""
|
||||
|
||||
|
|
@ -103,6 +130,11 @@ class PluginListItem(BaseModel):
|
|||
domain: str | None = None
|
||||
namespace: str | None = None
|
||||
enabled: bool
|
||||
approval_status: SkillApprovalStatus = "active"
|
||||
review_notes: str | None = None
|
||||
reviewed_by: str | None = None
|
||||
reviewed_at: str | None = None
|
||||
created_by: str | None = None
|
||||
created_at: str | None
|
||||
updated_at: str | None
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,11 +15,19 @@ from litellm.proxy._types import UserAPIKeyAuth
|
|||
from litellm.proxy.proxy_server import LitellmUserRoles
|
||||
from litellm.types.proxy.claude_code_endpoints import (
|
||||
RegisterPluginRequest,
|
||||
RejectPluginRequest,
|
||||
UpdatePluginRequest,
|
||||
)
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
|
||||
approve_plugin,
|
||||
delete_plugin,
|
||||
disable_plugin,
|
||||
enable_plugin,
|
||||
get_marketplace,
|
||||
get_plugin,
|
||||
list_plugins,
|
||||
register_plugin,
|
||||
reject_plugin,
|
||||
update_plugin,
|
||||
)
|
||||
|
||||
|
|
@ -35,20 +43,41 @@ def _make_mock_prisma():
|
|||
async def _find_unique(where):
|
||||
return store.get(where.get("name"))
|
||||
|
||||
def _matches(record, where):
|
||||
for key, expected in where.items():
|
||||
if key == "OR":
|
||||
if not any(_matches(record, clause) for clause in expected):
|
||||
return False
|
||||
continue
|
||||
actual = getattr(record, key)
|
||||
if isinstance(expected, dict) and "in" in expected:
|
||||
if actual not in expected["in"]:
|
||||
return False
|
||||
elif actual != expected:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _find_many(where=None):
|
||||
records = list(store.values())
|
||||
if where and "enabled" in where:
|
||||
return [r for r in records if r.enabled == where["enabled"]]
|
||||
return records
|
||||
if not where:
|
||||
return records
|
||||
return [r for r in records if _matches(r, where)]
|
||||
|
||||
async def _create(data):
|
||||
record = MagicMock()
|
||||
record.id = "test-id"
|
||||
record.id = f"test-id-{data['name']}"
|
||||
record.name = data["name"]
|
||||
record.version = data.get("version")
|
||||
record.description = data.get("description")
|
||||
record.manifest_json = data.get("manifest_json", "{}")
|
||||
record.enabled = data.get("enabled", True)
|
||||
record.approval_status = data.get("approval_status", "active")
|
||||
record.review_notes = data.get("review_notes")
|
||||
record.reviewed_by = data.get("reviewed_by")
|
||||
record.reviewed_at = data.get("reviewed_at")
|
||||
record.created_by = data.get("created_by")
|
||||
record.created_at = data.get("created_at")
|
||||
record.updated_at = data.get("updated_at")
|
||||
store[data["name"]] = record
|
||||
return record
|
||||
|
||||
|
|
@ -151,6 +180,7 @@ async def test_update_plugin_replaces_existing_source():
|
|||
response = await update_plugin(
|
||||
plugin_name=name,
|
||||
request=UpdatePluginRequest(source=new_source, version="2.0.0", description="updated"),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
assert response.status == "success"
|
||||
|
|
@ -170,6 +200,7 @@ async def test_update_plugin_not_found():
|
|||
await update_plugin(
|
||||
plugin_name="does-not-exist",
|
||||
request=UpdatePluginRequest(source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
|
@ -213,6 +244,7 @@ async def test_update_plugin_db_error_maps_to_structured_500():
|
|||
await update_plugin(
|
||||
plugin_name=name,
|
||||
request=UpdatePluginRequest(source={"source": "github", "repo": "org/replacement"}),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
|
|
@ -341,3 +373,269 @@ async def test_register_plugin_unknown_source_type():
|
|||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "git-subdir" in exc_info.value.detail["error"]
|
||||
|
||||
|
||||
_SUBMITTER = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
api_key="sk-submitter",
|
||||
user_id="submitter-user",
|
||||
)
|
||||
|
||||
_OTHER_USER = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
api_key="sk-other",
|
||||
user_id="other-user",
|
||||
)
|
||||
|
||||
|
||||
async def _stored(name: str):
|
||||
table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
|
||||
return await table.find_unique(where={"name": name})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_submission_is_pending_and_unpublished():
|
||||
response = await register_plugin(
|
||||
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_SUBMITTER,
|
||||
)
|
||||
|
||||
assert response.action == "submitted_for_review"
|
||||
assert response.plugin.approval_status == "pending_review"
|
||||
assert response.plugin.enabled is False
|
||||
|
||||
stored = await _stored("submitted-skill")
|
||||
assert stored.approval_status == "pending_review"
|
||||
assert stored.enabled is False
|
||||
assert stored.created_by == "submitter-user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_registration_stays_auto_approved():
|
||||
response = await register_plugin(
|
||||
request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
assert response.action == "created"
|
||||
assert response.plugin.approval_status == "active"
|
||||
assert response.plugin.enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_submission_is_not_served_by_marketplace():
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_SUBMITTER,
|
||||
)
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
body = json.loads((await get_marketplace()).body)
|
||||
|
||||
assert [plugin["name"] for plugin in body["plugins"]] == ["admin-skill"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approval_publishes_submission_and_records_reviewer():
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_SUBMITTER,
|
||||
)
|
||||
|
||||
response = await approve_plugin(plugin_name="submitted-skill", user_api_key_dict=_USER)
|
||||
|
||||
assert response.approval_status == "active"
|
||||
assert response.enabled is True
|
||||
assert response.reviewed_by == "test-user"
|
||||
assert response.reviewed_at is not None
|
||||
|
||||
body = json.loads((await get_marketplace()).body)
|
||||
assert [plugin["name"] for plugin in body["plugins"]] == ["submitted-skill"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejection_keeps_submission_unpublished_with_notes():
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_SUBMITTER,
|
||||
)
|
||||
|
||||
response = await reject_plugin(
|
||||
plugin_name="submitted-skill",
|
||||
request=RejectPluginRequest(review_notes="point at the skill folder"),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
assert response.approval_status == "rejected"
|
||||
assert response.enabled is False
|
||||
|
||||
body = json.loads((await get_marketplace()).body)
|
||||
assert body["plugins"] == []
|
||||
|
||||
stored = await _stored("submitted-skill")
|
||||
assert stored.review_notes == "point at the skill folder"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_cannot_review():
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_SUBMITTER,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await approve_plugin(plugin_name="submitted-skill", user_api_key_dict=_SUBMITTER)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
stored = await _stored("submitted-skill")
|
||||
assert stored.approval_status == "pending_review"
|
||||
assert stored.enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_unknown_skill_returns_404():
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await approve_plugin(plugin_name="does-not-exist", user_api_key_dict=_USER)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submitter_sees_own_pending_skill_but_not_another_users():
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_SUBMITTER,
|
||||
)
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="other-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_OTHER_USER,
|
||||
)
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
submitter_view = await list_plugins(user_api_key_dict=_SUBMITTER)
|
||||
admin_view = await list_plugins(user_api_key_dict=_USER)
|
||||
|
||||
assert sorted(p.name for p in submitter_view.plugins) == ["admin-skill", "submitted-skill"]
|
||||
assert sorted(p.name for p in admin_view.plugins) == ["admin-skill", "other-skill", "submitted-skill"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_can_filter_the_pending_queue():
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_SUBMITTER,
|
||||
)
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
queue = await list_plugins(approval_status="pending_review", user_api_key_dict=_USER)
|
||||
|
||||
assert [p.name for p in queue.plugins] == ["submitted-skill"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_plugin_hides_another_users_pending_submission():
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_SUBMITTER,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_plugin(plugin_name="submitted-skill", user_api_key_dict=_OTHER_USER)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
own = await get_plugin(plugin_name="submitted-skill", user_api_key_dict=_SUBMITTER)
|
||||
assert own["approval_status"] == "pending_review"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enable_cannot_bypass_review():
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_SUBMITTER,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await enable_plugin(plugin_name="submitted-skill", user_api_key_dict=_USER)
|
||||
|
||||
assert exc_info.value.status_code == 409
|
||||
stored = await _stored("submitted-skill")
|
||||
assert stored.enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_cannot_publish_or_unpublish():
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await disable_plugin(plugin_name="admin-skill", user_api_key_dict=_SUBMITTER)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
stored = await _stored("admin-skill")
|
||||
assert stored.enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submitter_edit_sends_skill_back_to_review():
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_SUBMITTER,
|
||||
)
|
||||
await approve_plugin(plugin_name="submitted-skill", user_api_key_dict=_USER)
|
||||
|
||||
await update_plugin(
|
||||
plugin_name="submitted-skill",
|
||||
request=UpdatePluginRequest(source={"source": "github", "repo": "org/changed"}),
|
||||
user_api_key_dict=_SUBMITTER,
|
||||
)
|
||||
|
||||
stored = await _stored("submitted-skill")
|
||||
assert stored.approval_status == "pending_review"
|
||||
assert stored.enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unrelated_user_cannot_edit_or_delete_a_submission():
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="submitted-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_SUBMITTER,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as update_exc:
|
||||
await update_plugin(
|
||||
plugin_name="submitted-skill",
|
||||
request=UpdatePluginRequest(source={"source": "github", "repo": "org/hijacked"}),
|
||||
user_api_key_dict=_OTHER_USER,
|
||||
)
|
||||
assert update_exc.value.status_code == 403
|
||||
|
||||
with pytest.raises(HTTPException) as delete_exc:
|
||||
await delete_plugin(plugin_name="submitted-skill", user_api_key_dict=_OTHER_USER)
|
||||
assert delete_exc.value.status_code == 403
|
||||
|
||||
assert await _stored("submitted-skill") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approving_an_already_active_skill_is_rejected():
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name="admin-skill", source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await approve_plugin(plugin_name="admin-skill", user_api_key_dict=_USER)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking";
|
||||
import { getClaudeCodePluginsList, deleteClaudeCodePlugin, reviewClaudeCodePlugin } from "@/components/networking";
|
||||
import type { Plugin } from "@/components/claude_code_plugins/types";
|
||||
|
||||
import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel";
|
||||
|
|
@ -10,6 +10,7 @@ import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel";
|
|||
vi.mock("@/components/networking", () => ({
|
||||
getClaudeCodePluginsList: vi.fn(),
|
||||
deleteClaudeCodePlugin: vi.fn(),
|
||||
reviewClaudeCodePlugin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./PluginTable", () => ({
|
||||
|
|
@ -18,21 +19,28 @@ vi.mock("./PluginTable", () => ({
|
|||
isLoading,
|
||||
pluginsList,
|
||||
onDeleteClick,
|
||||
onReviewClick,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
pluginsList: Plugin[];
|
||||
onDeleteClick: (pluginName: string, displayName: string) => void;
|
||||
onReviewClick: (plugin: Plugin, decision: "approve" | "reject") => void;
|
||||
}) => (
|
||||
<div data-testid="plugin-table">
|
||||
{isLoading ? "table-loading" : "table-loaded"}
|
||||
{pluginsList.map((plugin) => (
|
||||
<button
|
||||
key={plugin.id}
|
||||
data-testid={`row-delete-${plugin.id}`}
|
||||
onClick={() => onDeleteClick(plugin.name, plugin.name)}
|
||||
>
|
||||
row delete
|
||||
</button>
|
||||
<div key={plugin.id}>
|
||||
<span>{`row-${plugin.name}`}</span>
|
||||
<button data-testid={`row-delete-${plugin.id}`} onClick={() => onDeleteClick(plugin.name, plugin.name)}>
|
||||
row delete
|
||||
</button>
|
||||
<button data-testid={`row-approve-${plugin.id}`} onClick={() => onReviewClick(plugin, "approve")}>
|
||||
row approve
|
||||
</button>
|
||||
<button data-testid={`row-reject-${plugin.id}`} onClick={() => onReviewClick(plugin, "reject")}>
|
||||
row reject
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
|
|
@ -43,6 +51,7 @@ vi.mock("@/components/claude_code_plugins/skill_detail", () => ({ __esModule: tr
|
|||
|
||||
const mockGetClaudeCodePluginsList = vi.mocked(getClaudeCodePluginsList);
|
||||
const mockDeleteClaudeCodePlugin = vi.mocked(deleteClaudeCodePlugin);
|
||||
const mockReviewClaudeCodePlugin = vi.mocked(reviewClaudeCodePlugin);
|
||||
|
||||
const skill: Plugin = {
|
||||
id: "plugin-1",
|
||||
|
|
@ -122,3 +131,68 @@ describe("ClaudeCodePluginsPanel delete confirmation", () => {
|
|||
expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaudeCodePluginsPanel review queue", () => {
|
||||
const submission: Plugin = {
|
||||
id: "plugin-2",
|
||||
name: "submitted-skill",
|
||||
source: { source: "github", repo: "acme/submitted-skill" },
|
||||
enabled: false,
|
||||
approval_status: "pending_review",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetClaudeCodePluginsList.mockResolvedValue({ plugins: [skill, submission], count: 2 });
|
||||
});
|
||||
|
||||
it("should let a non-admin submit a skill instead of disabling the button", async () => {
|
||||
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Internal User" />);
|
||||
const submit = await screen.findByRole("button", { name: "+ Submit Skill" });
|
||||
expect(submit).toBeEnabled();
|
||||
expect(screen.queryByTestId("toggle-pending-review")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should filter the table down to submissions awaiting review", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
|
||||
|
||||
await user.click(await screen.findByTestId("toggle-pending-review"));
|
||||
|
||||
expect(screen.getByText("row-submitted-skill")).toBeInTheDocument();
|
||||
expect(screen.queryByText("row-my-skill")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should approve a submission and refresh the list", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockReviewClaudeCodePlugin.mockResolvedValue({});
|
||||
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
|
||||
|
||||
await user.click(await screen.findByTestId("row-approve-plugin-2"));
|
||||
await user.click(await screen.findByTestId("review-confirm"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockReviewClaudeCodePlugin).toHaveBeenCalledWith("sk-test", "submitted-skill", "approve", ""),
|
||||
);
|
||||
await waitFor(() => expect(mockGetClaudeCodePluginsList).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("should send the rejection notes typed by the reviewer", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockReviewClaudeCodePlugin.mockResolvedValue({});
|
||||
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
|
||||
|
||||
await user.click(await screen.findByTestId("row-reject-plugin-2"));
|
||||
await user.type(await screen.findByTestId("review-notes"), "point at the skill folder");
|
||||
await user.click(screen.getByTestId("review-confirm"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockReviewClaudeCodePlugin).toHaveBeenCalledWith(
|
||||
"sk-test",
|
||||
"submitted-skill",
|
||||
"reject",
|
||||
"point at the skill folder",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -9,9 +9,11 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking";
|
||||
import { getClaudeCodePluginsList, deleteClaudeCodePlugin, reviewClaudeCodePlugin } from "@/components/networking";
|
||||
import AddPluginForm from "./add_plugin_form";
|
||||
import PluginTable from "./PluginTable";
|
||||
import ReviewSkillDialog, { ReviewDecision } from "./ReviewSkillDialog";
|
||||
import { countAwaitingReview, isAwaitingReview } from "@/components/claude_code_plugins/helpers";
|
||||
import SkillDetail from "@/components/claude_code_plugins/skill_detail";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
|
@ -32,8 +34,16 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
|
|||
displayName: string;
|
||||
} | null>(null);
|
||||
const [selectedSkill, setSelectedSkill] = useState<Plugin | null>(null);
|
||||
const [pluginToReview, setPluginToReview] = useState<{ plugin: Plugin; decision: ReviewDecision } | null>(null);
|
||||
const [isReviewing, setIsReviewing] = useState(false);
|
||||
const [showPendingOnly, setShowPendingOnly] = useState(false);
|
||||
|
||||
const isAdmin = userRole ? isAdminRole(userRole) : false;
|
||||
const pendingCount = useMemo(() => countAwaitingReview(pluginsList), [pluginsList]);
|
||||
const visiblePlugins = useMemo(
|
||||
() => (showPendingOnly ? pluginsList.filter(isAwaitingReview) : pluginsList),
|
||||
[pluginsList, showPendingOnly],
|
||||
);
|
||||
|
||||
const fetchPlugins = async () => {
|
||||
if (!accessToken) {
|
||||
|
|
@ -77,6 +87,27 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
|
|||
}
|
||||
};
|
||||
|
||||
const handleReviewConfirm = async (notes: string) => {
|
||||
if (!pluginToReview || !accessToken) return;
|
||||
|
||||
setIsReviewing(true);
|
||||
try {
|
||||
await reviewClaudeCodePlugin(accessToken, pluginToReview.plugin.name, pluginToReview.decision, notes);
|
||||
NotificationsManager.success(
|
||||
pluginToReview.decision === "approve"
|
||||
? `Skill "${pluginToReview.plugin.name}" approved and published`
|
||||
: `Skill "${pluginToReview.plugin.name}" rejected`,
|
||||
);
|
||||
fetchPlugins();
|
||||
} catch (error) {
|
||||
console.error("Error reviewing skill:", error);
|
||||
NotificationsManager.error("Failed to review skill");
|
||||
} finally {
|
||||
setIsReviewing(false);
|
||||
setPluginToReview(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
|
||||
{selectedSkill ? (
|
||||
|
|
@ -92,20 +123,32 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
|
|||
<div className="flex flex-col gap-2 mb-4">
|
||||
<h1 className="text-2xl font-bold">Skills</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
Register Claude Code skills. Published skills appear in the Skill Hub for all users and are served via{" "}
|
||||
{isAdmin
|
||||
? "Register Claude Code skills and review skills submitted by your users. Approved skills appear in the Skill Hub for all users and are served via "
|
||||
: "Submit Claude Code skills for administrator review. Once approved, a skill appears in the Skill Hub for all users and is served via "}
|
||||
<code className="bg-gray-100 px-1 rounded-sm">/claude-code/marketplace.json</code>.
|
||||
</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button onClick={() => setIsAddModalVisible(true)} disabled={!accessToken || !isAdmin}>
|
||||
+ Add Skill
|
||||
<Button onClick={() => setIsAddModalVisible(true)} disabled={!accessToken}>
|
||||
{isAdmin ? "+ Add Skill" : "+ Submit Skill"}
|
||||
</Button>
|
||||
{isAdmin && pendingCount > 0 && (
|
||||
<Button
|
||||
variant={showPendingOnly ? "default" : "secondary"}
|
||||
data-testid="toggle-pending-review"
|
||||
onClick={() => setShowPendingOnly(!showPendingOnly)}
|
||||
>
|
||||
{`Awaiting review (${pendingCount})`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PluginTable
|
||||
pluginsList={pluginsList}
|
||||
pluginsList={visiblePlugins}
|
||||
isLoading={isLoading}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onReviewClick={(plugin, decision) => setPluginToReview({ plugin, decision })}
|
||||
isAdmin={isAdmin}
|
||||
onPluginClick={(id) => {
|
||||
const skill = pluginsList.find((p) => p.id === id);
|
||||
|
|
@ -122,6 +165,16 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
|
|||
onSuccess={fetchPlugins}
|
||||
/>
|
||||
|
||||
{pluginToReview && (
|
||||
<ReviewSkillDialog
|
||||
skillName={pluginToReview.plugin.name}
|
||||
decision={pluginToReview.decision}
|
||||
isSubmitting={isReviewing}
|
||||
onCancel={() => setPluginToReview(null)}
|
||||
onConfirm={handleReviewConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pluginToDelete && (
|
||||
<AlertDialog
|
||||
open
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ import {
|
|||
parseSkillSource,
|
||||
isValidSubPath,
|
||||
buildMarketplaceSettingsSnippet,
|
||||
getApprovalStatusDisplay,
|
||||
isAwaitingReview,
|
||||
isApprovedSkill,
|
||||
countAwaitingReview,
|
||||
} from "./helpers";
|
||||
import { MarketplacePluginEntry, PluginSource } from "./types";
|
||||
|
||||
|
|
@ -587,3 +591,31 @@ describe("isValidSubPath", () => {
|
|||
expect(isValidSubPath("a//b")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getApprovalStatusDisplay", () => {
|
||||
it("maps each approval state to its badge", () => {
|
||||
expect(getApprovalStatusDisplay("pending_review")).toEqual({ label: "Pending Review", tone: "warning" });
|
||||
expect(getApprovalStatusDisplay("rejected")).toEqual({ label: "Rejected", tone: "error" });
|
||||
expect(getApprovalStatusDisplay("active")).toEqual({ label: "Active", tone: "success" });
|
||||
});
|
||||
|
||||
it("reads a skill registered before approval existed as active", () => {
|
||||
expect(getApprovalStatusDisplay(undefined)).toEqual({ label: "Active", tone: "success" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAwaitingReview / isApprovedSkill / countAwaitingReview", () => {
|
||||
it("separates submissions awaiting review from publishable skills", () => {
|
||||
const skills = [
|
||||
{ approval_status: "pending_review" as const },
|
||||
{ approval_status: "pending_review" as const },
|
||||
{ approval_status: "active" as const },
|
||||
{ approval_status: "rejected" as const },
|
||||
{},
|
||||
];
|
||||
|
||||
expect(countAwaitingReview(skills)).toBe(2);
|
||||
expect(skills.filter(isApprovedSkill)).toEqual([{ approval_status: "active" }, {}]);
|
||||
expect(isAwaitingReview({ approval_status: "rejected" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Helper utilities for Claude Code Marketplace
|
||||
*/
|
||||
|
||||
import { PluginSource, MarketplacePluginEntry } from "./types";
|
||||
import { PluginSource, MarketplacePluginEntry, Plugin, SkillApprovalStatus } from "./types";
|
||||
|
||||
export interface SkillSourcePreview {
|
||||
parsed: PluginSource;
|
||||
|
|
@ -438,3 +438,28 @@ export const formatKeywords = (keywords?: string[]): string => {
|
|||
|
||||
return keywords.join(", ");
|
||||
};
|
||||
|
||||
export interface SkillApprovalDisplay {
|
||||
label: string;
|
||||
tone: "success" | "warning" | "error";
|
||||
}
|
||||
|
||||
export const getApprovalStatusDisplay = (status?: SkillApprovalStatus): SkillApprovalDisplay => {
|
||||
switch (status) {
|
||||
case "pending_review":
|
||||
return { label: "Pending Review", tone: "warning" };
|
||||
case "rejected":
|
||||
return { label: "Rejected", tone: "error" };
|
||||
default:
|
||||
return { label: "Active", tone: "success" };
|
||||
}
|
||||
};
|
||||
|
||||
export const isAwaitingReview = (plugin: Pick<Plugin, "approval_status">): boolean =>
|
||||
plugin.approval_status === "pending_review";
|
||||
|
||||
export const countAwaitingReview = (plugins: Pick<Plugin, "approval_status">[]): number =>
|
||||
plugins.filter(isAwaitingReview).length;
|
||||
|
||||
export const isApprovedSkill = (plugin: Pick<Plugin, "approval_status">): boolean =>
|
||||
plugin.approval_status === undefined || plugin.approval_status === "active";
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export interface PluginSource {
|
|||
|
||||
export type PluginAuthor = components["schemas"]["PluginAuthor"];
|
||||
|
||||
export type SkillApprovalStatus = components["schemas"]["PluginListItem"]["approval_status"];
|
||||
|
||||
export interface Plugin {
|
||||
id: string;
|
||||
name: string; // kebab-case
|
||||
|
|
@ -29,6 +31,10 @@ export interface Plugin {
|
|||
domain?: string;
|
||||
namespace?: string;
|
||||
enabled: boolean;
|
||||
approval_status?: SkillApprovalStatus;
|
||||
review_notes?: string | null;
|
||||
reviewed_by?: string | null;
|
||||
reviewed_at?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
created_by?: string;
|
||||
|
|
@ -47,6 +53,10 @@ export interface PluginListItem {
|
|||
domain?: string;
|
||||
namespace?: string;
|
||||
enabled: boolean;
|
||||
approval_status?: SkillApprovalStatus;
|
||||
review_notes?: string | null;
|
||||
reviewed_by?: string | null;
|
||||
reviewed_at?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
created_by?: string;
|
||||
|
|
|
|||
|
|
@ -7486,6 +7486,26 @@ export const deleteClaudeCodePlugin = async (accessToken: string, pluginName: st
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Approve or reject a submitted skill (admin only)
|
||||
*/
|
||||
export const reviewClaudeCodePlugin = async (
|
||||
accessToken: string,
|
||||
pluginName: string,
|
||||
decision: "approve" | "reject",
|
||||
reviewNotes?: string,
|
||||
) => {
|
||||
try {
|
||||
return await apiClient.post(`/claude-code/plugins/${pluginName}/${decision}`, {
|
||||
accessToken,
|
||||
...(decision === "reject" ? { body: { review_notes: reviewNotes || null } } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${decision} skill "${pluginName}":`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Compliance check types and functions
|
||||
|
||||
export interface ComplianceCheckResult {
|
||||
|
|
|
|||
235
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
235
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -1524,13 +1524,18 @@ export interface paths {
|
|||
};
|
||||
/**
|
||||
* List Plugins
|
||||
* @description List all plugins in the marketplace.
|
||||
* @description List plugins in the marketplace.
|
||||
*
|
||||
* Admins see every skill, including submissions awaiting review. Everyone
|
||||
* else sees approved skills plus their own submissions, so a submitter can
|
||||
* track the status of what they sent in.
|
||||
*
|
||||
* Parameters:
|
||||
* - enabled_only: If true, only return enabled plugins
|
||||
* - approval_status: Filter to one approval state, e.g. `pending_review` for the admin review queue
|
||||
*
|
||||
* Returns:
|
||||
* List of plugins with their metadata.
|
||||
* List of plugins with their metadata and review state.
|
||||
*/
|
||||
get: operations["list_plugins_claude_code_plugins_get"];
|
||||
put?: never;
|
||||
|
|
@ -1546,6 +1551,10 @@ export interface paths {
|
|||
* the same name already exists it returns 409 Conflict; use
|
||||
* PUT /claude-code/plugins/{plugin_name} to update an existing plugin.
|
||||
*
|
||||
* Callers that are not proxy admins are self-service submitters: the skill
|
||||
* is stored with approval_status=pending_review and stays disabled until an
|
||||
* admin approves it via POST /claude-code/plugins/{plugin_name}/approve.
|
||||
*
|
||||
* Parameters:
|
||||
* - name: Plugin name (kebab-case)
|
||||
* - source: Git source reference (github, url, or git-subdir format)
|
||||
|
|
@ -1557,7 +1566,8 @@ export interface paths {
|
|||
* - category: Plugin category (optional)
|
||||
*
|
||||
* Returns:
|
||||
* Registration status (action is always "created") and plugin information.
|
||||
* Registration status ("created" for admins, "submitted_for_review" otherwise)
|
||||
* and plugin information.
|
||||
*
|
||||
* Example:
|
||||
* ```bash
|
||||
|
|
@ -1610,6 +1620,10 @@ export interface paths {
|
|||
* Returns 404 if no plugin with the given name exists; use
|
||||
* POST /claude-code/plugins to create a new plugin.
|
||||
*
|
||||
* Admins can update any skill and the review state is left untouched. A
|
||||
* submitter can only update their own skill, and doing so sends it back to
|
||||
* pending review, since the content an admin approved has changed.
|
||||
*
|
||||
* Parameters:
|
||||
* - plugin_name: Name of the plugin to update (path parameter)
|
||||
* - source: Git source reference (github, url, or git-subdir format)
|
||||
|
|
@ -1639,7 +1653,8 @@ export interface paths {
|
|||
post?: never;
|
||||
/**
|
||||
* Delete Plugin
|
||||
* @description Delete a plugin from the marketplace.
|
||||
* @description Delete a plugin from the marketplace. Admins can delete any skill; a
|
||||
* submitter can withdraw one they submitted.
|
||||
*
|
||||
* Parameters:
|
||||
* - plugin_name: The name of the plugin to delete
|
||||
|
|
@ -1650,6 +1665,35 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/claude-code/plugins/{plugin_name}/approve": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Approve Plugin
|
||||
* @description Approve a submitted skill (admin only).
|
||||
*
|
||||
* Approving sets approval_status=active and publishes the skill to
|
||||
* marketplace.json and the public Skill Hub.
|
||||
*
|
||||
* Example:
|
||||
* ```bash
|
||||
* curl -X POST http://localhost:4000/claude-code/plugins/my-skill/approve \
|
||||
* -H "Authorization: Bearer sk-admin-..."
|
||||
* ```
|
||||
*/
|
||||
post: operations["approve_plugin_claude_code_plugins__plugin_name__approve_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/claude-code/plugins/{plugin_name}/disable": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1661,7 +1705,7 @@ export interface paths {
|
|||
put?: never;
|
||||
/**
|
||||
* Disable Plugin
|
||||
* @description Disable a plugin without deleting it.
|
||||
* @description Disable a plugin without deleting it. Proxy admins only.
|
||||
*
|
||||
* Parameters:
|
||||
* - plugin_name: The name of the plugin to disable
|
||||
|
|
@ -1684,7 +1728,11 @@ export interface paths {
|
|||
put?: never;
|
||||
/**
|
||||
* Enable Plugin
|
||||
* @description Enable a disabled plugin.
|
||||
* @description Enable a disabled plugin. Proxy admins only.
|
||||
*
|
||||
* A skill that has not been approved cannot be enabled here: approve it
|
||||
* through POST /claude-code/plugins/{plugin_name}/approve instead, so the
|
||||
* reviewer is recorded on the row.
|
||||
*
|
||||
* Parameters:
|
||||
* - plugin_name: The name of the plugin to enable
|
||||
|
|
@ -1696,6 +1744,36 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/claude-code/plugins/{plugin_name}/reject": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Reject Plugin
|
||||
* @description Reject a submitted skill (admin only).
|
||||
*
|
||||
* The row is kept unpublished so the submitter can read review_notes and fix the submission.
|
||||
*
|
||||
* Example:
|
||||
* ```bash
|
||||
* curl -X POST http://localhost:4000/claude-code/plugins/my-skill/reject \
|
||||
* -H "Authorization: Bearer sk-admin-..." \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -d '{"review_notes": "point the source at the skill folder"}'
|
||||
* ```
|
||||
*/
|
||||
post: operations["reject_plugin_claude_code_plugins__plugin_name__reject_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/cloudzero/delete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -11252,7 +11330,7 @@ export interface paths {
|
|||
};
|
||||
/**
|
||||
* Public Skill Hub
|
||||
* @description Return enabled (public) Claude Code skills — no auth required.
|
||||
* @description Return approved, enabled (public) Claude Code skills. No auth required.
|
||||
*/
|
||||
get: operations["public_skill_hub_public_skill_hub_get"];
|
||||
put?: never;
|
||||
|
|
@ -30318,11 +30396,19 @@ export interface components {
|
|||
* @description Plugin item in list responses.
|
||||
*/
|
||||
PluginListItem: {
|
||||
/**
|
||||
* Approval Status
|
||||
* @default active
|
||||
* @enum {string}
|
||||
*/
|
||||
approval_status: "pending_review" | "active" | "rejected";
|
||||
author?: components["schemas"]["PluginAuthor"] | null;
|
||||
/** Category */
|
||||
category?: string | null;
|
||||
/** Created At */
|
||||
created_at: string | null;
|
||||
/** Created By */
|
||||
created_by?: string | null;
|
||||
/** Description */
|
||||
description: string | null;
|
||||
/** Domain */
|
||||
|
|
@ -30339,6 +30425,12 @@ export interface components {
|
|||
name: string;
|
||||
/** Namespace */
|
||||
namespace?: string | null;
|
||||
/** Review Notes */
|
||||
review_notes?: string | null;
|
||||
/** Reviewed At */
|
||||
reviewed_at?: string | null;
|
||||
/** Reviewed By */
|
||||
reviewed_by?: string | null;
|
||||
/** Source */
|
||||
source: {
|
||||
[key: string]: string;
|
||||
|
|
@ -30353,6 +30445,13 @@ export interface components {
|
|||
* @description Plugin information in API responses.
|
||||
*/
|
||||
PluginResponse: {
|
||||
/**
|
||||
* Approval Status
|
||||
* @description Administrator approval state
|
||||
* @default active
|
||||
* @enum {string}
|
||||
*/
|
||||
approval_status: "pending_review" | "active" | "rejected";
|
||||
/**
|
||||
* Description
|
||||
* @description Plugin description
|
||||
|
|
@ -31673,7 +31772,7 @@ export interface components {
|
|||
RegisterPluginResponse: {
|
||||
/**
|
||||
* Action
|
||||
* @description Action taken (created/updated)
|
||||
* @description Action taken (created/submitted_for_review/updated)
|
||||
*/
|
||||
action: string;
|
||||
/** @description Plugin information */
|
||||
|
|
@ -31689,6 +31788,17 @@ export interface components {
|
|||
/** Review Notes */
|
||||
review_notes?: string | null;
|
||||
};
|
||||
/**
|
||||
* RejectPluginRequest
|
||||
* @description Administrator rejection of a submitted skill.
|
||||
*/
|
||||
RejectPluginRequest: {
|
||||
/**
|
||||
* Review Notes
|
||||
* @description Reviewer feedback shown to the submitter
|
||||
*/
|
||||
review_notes?: string | null;
|
||||
};
|
||||
/**
|
||||
* ReminderMarkerPair
|
||||
* @description One open/close delimiter pair a harness wraps injected context in.
|
||||
|
|
@ -31954,6 +32064,48 @@ export interface components {
|
|||
/** Timeouterrorretries */
|
||||
TimeoutErrorRetries?: number | null;
|
||||
};
|
||||
/**
|
||||
* ReviewPluginResponse
|
||||
* @description Response from approving or rejecting a submitted skill.
|
||||
*/
|
||||
ReviewPluginResponse: {
|
||||
/**
|
||||
* Approval Status
|
||||
* @description Resulting approval state
|
||||
* @enum {string}
|
||||
*/
|
||||
approval_status: "pending_review" | "active" | "rejected";
|
||||
/**
|
||||
* Enabled
|
||||
* @description Whether the skill is now served to users
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Name
|
||||
* @description Skill name
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Review Notes
|
||||
* @description Reviewer feedback
|
||||
*/
|
||||
review_notes?: string | null;
|
||||
/**
|
||||
* Reviewed At
|
||||
* @description ISO timestamp of the review
|
||||
*/
|
||||
reviewed_at?: string | null;
|
||||
/**
|
||||
* Reviewed By
|
||||
* @description User id of the reviewing administrator
|
||||
*/
|
||||
reviewed_by?: string | null;
|
||||
/**
|
||||
* Status
|
||||
* @description Operation status
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
/**
|
||||
* RoleMappings
|
||||
* @description Configuration for mapping SSO groups to LiteLLM roles.
|
||||
|
|
@ -38209,6 +38361,7 @@ export interface operations {
|
|||
parameters: {
|
||||
query?: {
|
||||
enabled_only?: boolean;
|
||||
approval_status?: ("pending_review" | "active" | "rejected") | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
@ -38366,6 +38519,37 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
approve_plugin_claude_code_plugins__plugin_name__approve_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
plugin_name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ReviewPluginResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
disable_plugin_claude_code_plugins__plugin_name__disable_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -38428,6 +38612,41 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
reject_plugin_claude_code_plugins__plugin_name__reject_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
plugin_name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["RejectPluginRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ReviewPluginResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_cloudzero_settings_cloudzero_delete_delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue