feat(skills)!: add portable submissions and admin review (#50160)

This commit is contained in:
tin-berri 2026-11-05 00:56:51 -08:00 • committed by GitHub
parent 0ad5d750d4
commit 7e4ad6eb33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 5058 additions and 1280 deletions

View file

@ -0,0 +1,27 @@
SET LOCAL lock_timeout = '10s';
CREATE TABLE IF NOT EXISTS "LiteLLM_SkillSubmission" (
"id" TEXT NOT NULL,
"plugin_name" TEXT NOT NULL,
"operation" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending_review',
"proposal_json" TEXT NOT NULL,
"archive_content" BYTEA,
"submitted_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"base_plugin_id" TEXT,
"base_updated_at" TIMESTAMP(3),
"reviewed_by" TEXT,
"reviewed_at" TIMESTAMP(3),
"rejection_reason" TEXT,
CONSTRAINT "LiteLLM_SkillSubmission_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "LiteLLM_SkillSubmission_submitted_by_created_at_id_idx"
ON "LiteLLM_SkillSubmission"("submitted_by", "created_at" DESC, "id" DESC);
CREATE INDEX IF NOT EXISTS "LiteLLM_SkillSubmission_submitted_by_status_created_at_id_idx"
ON "LiteLLM_SkillSubmission"("submitted_by", "status", "created_at" DESC, "id" DESC);
CREATE INDEX IF NOT EXISTS "LiteLLM_SkillSubmission_status_created_at_id_idx"
ON "LiteLLM_SkillSubmission"("status", "created_at" DESC, "id" DESC);
CREATE INDEX IF NOT EXISTS "LiteLLM_SkillSubmission_created_at_id_idx"
ON "LiteLLM_SkillSubmission"("created_at" DESC, "id" DESC);

View file

@ -1638,6 +1638,27 @@ model LiteLLM_ClaudeCodePluginVersion {
}
model LiteLLM_SkillSubmission {
id String @id @default(uuid())
plugin_name String
operation String
status String @default("pending_review")
proposal_json String
archive_content Bytes?
submitted_by String
created_at DateTime @default(now())
base_plugin_id String?
base_updated_at DateTime?
reviewed_by String?
reviewed_at DateTime?
rejection_reason String?
@@index([submitted_by, created_at(sort: Desc), id(sort: Desc)])
@@index([submitted_by, status, created_at(sort: Desc), id(sort: Desc)])
@@index([status, created_at(sort: Desc), id(sort: Desc)])
@@index([created_at(sort: Desc), id(sort: Desc)])
}
model LiteLLM_ClaudeCodePluginUpload {
plugin_name String @id
content Bytes

View file

@ -342,6 +342,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset(
"LiteLLM_ClaudeCodePluginTable",
"LiteLLM_ClaudeCodePluginUpload",
"LiteLLM_ClaudeCodePluginVersion",
"LiteLLM_SkillSubmission",
"LiteLLM_MemoryTable",
"LiteLLM_AdaptiveRouterState",
"LiteLLM_AdaptiveRouterSession",

View file

@ -1,6 +1,7 @@
from collections.abc import AsyncIterator, Mapping
from dataclasses import dataclass
from datetime import datetime
from types import MappingProxyType
from typing import Literal, Protocol
@ -8,6 +9,16 @@ def has_agent_skill_metadata(manifest: Mapping[str, object]) -> bool:
return isinstance(manifest.get("agent_skill"), dict)
def uploaded_plugin_source(base_url: str, name: str, digest: str) -> Mapping[str, str]:
return MappingProxyType(
{
"source": "archive",
"url": f"{base_url.rstrip('/')}/claude-code/plugins/{name}/archive/{digest}.zip",
"sha256": digest,
}
)
@dataclass(frozen=True, slots=True)
class PluginUpload:
name: str
@ -73,9 +84,24 @@ class PluginVersion:
archive_available: bool
@dataclass(frozen=True, slots=True)
class PluginSnapshot:
id: str
name: str
version: str | None
description: str | None
manifest: Mapping[str, object]
enabled: bool
created_at: datetime | None
updated_at: datetime | None
created_by: str | None
class PluginUploadStore(Protocol):
async def create(self, upload: PluginUpload) -> str | None: ...
async def get_plugin(self, name: str) -> PluginSnapshot | None: ...
async def publish(
self, publication: PluginPublication, expected: PluginPublicationBase | None = None
) -> PublishedPlugin | PluginPublicationError: ...

View file

@ -0,0 +1,94 @@
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Protocol, TypeAlias
from litellm.models.plugin_upload import (
PluginPublication,
PluginPublicationBase,
PluginPublicationError,
PublishedPlugin,
)
SubmissionStatus: TypeAlias = Literal["pending_review", "approved", "rejected"]
SubmissionOperation: TypeAlias = Literal["create", "update"]
@dataclass(frozen=True, slots=True)
class SkillProposal:
name: str
version: str | None
description: str | None
manifest: Mapping[str, object]
@dataclass(frozen=True, slots=True)
class SkillSubmission:
id: str
plugin_name: str
operation: SubmissionOperation
status: SubmissionStatus
proposal: SkillProposal
has_archive: bool
submitted_by: str
created_at: datetime
base_plugin_id: str | None
base_updated_at: datetime | None
reviewed_by: str | None
reviewed_at: datetime | None
rejection_reason: str | None
@dataclass(frozen=True, slots=True)
class SubmissionPage:
submissions: tuple[SkillSubmission, ...]
has_more: bool
@dataclass(frozen=True, slots=True)
class SubmissionLimitExceeded:
message: str
@dataclass(frozen=True, slots=True)
class SubmissionMissing:
pass
@dataclass(frozen=True, slots=True)
class SubmissionConflict:
message: str
@dataclass(frozen=True, slots=True)
class SubmissionReviewed:
submission: SkillSubmission
plugin: PublishedPlugin | None
published_source: Mapping[str, str] | None = None
SubmissionReviewResult: TypeAlias = SubmissionReviewed | SubmissionMissing | SubmissionConflict | PluginPublicationError
class SkillSubmissionStore(Protocol):
async def submit(
self, upload: PluginPublication, submitted_by: str, base: PluginPublicationBase | None
) -> SkillSubmission | SubmissionLimitExceeded: ...
async def list(
self, submitted_by: str | None, status: SubmissionStatus | None, limit: int, offset: int
) -> SubmissionPage: ...
async def get(self, submission_id: str, submitted_by: str | None) -> SkillSubmission | None: ...
async def get_archive(self, submission_id: str, submitted_by: str | None) -> bytes | None: ...
async def review(
self,
submission_id: str,
reviewer: str,
approve: bool,
reason: str | None,
*,
publication_base_url: str | None = None,
) -> SubmissionReviewResult: ...

View file

@ -48,6 +48,13 @@ def _mount_app(prefix: str, attr_name: str = "app") -> Callable[["FastAPI", obje
return _register
def _include_skill_hub(app: "FastAPI", module: object) -> None:
from litellm.proxy.discovery_endpoints.skill_hub_submissions import router
_include_router()(app, module)
app.include_router(router)
@dataclass(frozen=True)
class LazyFeature:
name: str
@ -287,6 +294,7 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = (
name="skill_hub",
module_path="litellm.proxy.discovery_endpoints.skill_hub_endpoints",
path_prefixes=("/skill-hub",),
register_fn=_include_skill_hub,
),
LazyFeature(
name="claude_code_marketplace",

View file

@ -58865,6 +58865,44 @@
"skill_hub": {
"components": {
"schemas": {
"Body_replace_skill_archive_skill_hub_skills__name__upload_put": {
"properties": {
"expected_updated_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Expected Updated At"
},
"file": {
"contentMediaType": "application/octet-stream",
"description": "Replacement Agent Skills ZIP with the same declared skill name",
"title": "File",
"type": "string"
},
"version": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Version"
}
},
"required": [
"file"
],
"title": "Body_replace_skill_archive_skill_hub_skills__name__upload_put",
"type": "object"
},
"Body_upload_skill_skill_hub_skills_upload_post": {
"properties": {
"file": {
@ -58960,6 +58998,41 @@
"title": "ImportSkillRequest",
"type": "object"
},
"SkillHubAgentSkill": {
"properties": {
"description": {
"title": "Description",
"type": "string"
},
"digest": {
"title": "Digest",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
},
"origin": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Origin"
}
},
"required": [
"name",
"description",
"digest",
"origin"
],
"title": "SkillHubAgentSkill",
"type": "object"
},
"SkillHubList": {
"properties": {
"count": {
@ -59073,12 +59146,189 @@
"title": "SkillHubMetadata",
"type": "object"
},
"SkillHubPendingSkill": {
"properties": {
"action": {
"const": "submitted",
"default": "submitted",
"title": "Action",
"type": "string"
},
"proposal": {
"$ref": "#/components/schemas/SkillHubProposal"
},
"status": {
"const": "pending_review",
"default": "pending_review",
"title": "Status",
"type": "string"
},
"submission_id": {
"title": "Submission Id",
"type": "string"
}
},
"required": [
"submission_id",
"proposal"
],
"title": "SkillHubPendingSkill",
"type": "object"
},
"SkillHubProposal": {
"properties": {
"agent_skill": {
"anyOf": [
{
"$ref": "#/components/schemas/SkillHubAgentSkill"
},
{
"type": "null"
}
]
},
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Description"
},
"domain": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Domain"
},
"keywords": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Keywords"
},
"name": {
"title": "Name",
"type": "string"
},
"namespace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Namespace"
},
"source": {
"additionalProperties": {
"type": "string"
},
"title": "Source",
"type": "object"
},
"version": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Version"
}
},
"required": [
"name",
"description",
"version",
"source"
],
"title": "SkillHubProposal",
"type": "object"
},
"SkillHubPublication": {
"additionalProperties": false,
"properties": {},
"title": "SkillHubPublication",
"type": "object"
},
"SkillHubRejection": {
"additionalProperties": false,
"properties": {
"reason": {
"anyOf": [
{
"maxLength": 2000,
"type": "string"
},
{
"type": "null"
}
],
"title": "Reason"
}
},
"title": "SkillHubRejection",
"type": "object"
},
"SkillHubReviewResponse": {
"properties": {
"action": {
"enum": [
"approved",
"rejected"
],
"title": "Action",
"type": "string"
},
"skill": {
"anyOf": [
{
"$ref": "#/components/schemas/SkillHubSkill"
},
{
"type": "null"
}
]
},
"status": {
"const": "success",
"default": "success",
"title": "Status",
"type": "string"
},
"submission": {
"$ref": "#/components/schemas/SkillHubSubmission"
}
},
"required": [
"action",
"submission",
"skill"
],
"title": "SkillHubReviewResponse",
"type": "object"
},
"SkillHubSkill": {
"properties": {
"content_url": {
@ -59103,6 +59353,17 @@
],
"title": "Description"
},
"domain": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Domain"
},
"download_url": {
"anyOf": [
{
@ -59118,10 +59379,35 @@
"title": "Id",
"type": "string"
},
"keywords": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Keywords"
},
"name": {
"title": "Name",
"type": "string"
},
"namespace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Namespace"
},
"source": {
"additionalProperties": {
"type": "string"
@ -59170,6 +59456,174 @@
"title": "SkillHubSkill",
"type": "object"
},
"SkillHubSubmission": {
"properties": {
"base_skill_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Base Skill Id"
},
"base_updated_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Base Updated At"
},
"created_at": {
"format": "date-time",
"title": "Created At",
"type": "string"
},
"has_archive": {
"title": "Has Archive",
"type": "boolean"
},
"id": {
"title": "Id",
"type": "string"
},
"operation": {
"enum": [
"create",
"update"
],
"title": "Operation",
"type": "string"
},
"proposal": {
"$ref": "#/components/schemas/SkillHubProposal"
},
"rejection_reason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Rejection Reason"
},
"reviewed_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Reviewed At"
},
"reviewed_by": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Reviewed By"
},
"skill_name": {
"title": "Skill Name",
"type": "string"
},
"status": {
"enum": [
"pending_review",
"approved",
"rejected"
],
"title": "Status",
"type": "string"
},
"submitted_by": {
"title": "Submitted By",
"type": "string"
}
},
"required": [
"id",
"skill_name",
"operation",
"status",
"proposal",
"has_archive",
"submitted_by",
"created_at",
"base_skill_id",
"base_updated_at",
"reviewed_by",
"reviewed_at",
"rejection_reason"
],
"title": "SkillHubSubmission",
"type": "object"
},
"SkillHubSubmissionDetail": {
"properties": {
"current_skill": {
"anyOf": [
{
"$ref": "#/components/schemas/SkillHubSkill"
},
{
"type": "null"
}
]
},
"submission": {
"$ref": "#/components/schemas/SkillHubSubmission"
}
},
"required": [
"submission",
"current_skill"
],
"title": "SkillHubSubmissionDetail",
"type": "object"
},
"SkillHubSubmissionList": {
"properties": {
"count": {
"title": "Count",
"type": "integer"
},
"has_more": {
"title": "Has More",
"type": "boolean"
},
"submissions": {
"items": {
"$ref": "#/components/schemas/SkillHubSubmission"
},
"title": "Submissions",
"type": "array"
}
},
"required": [
"submissions",
"count",
"has_more"
],
"title": "SkillHubSubmissionList",
"type": "object"
},
"SkillHubVersion": {
"properties": {
"content_kind": {
@ -59421,7 +59875,15 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubSkill"
"anyOf": [
{
"$ref": "#/components/schemas/SkillHubSkill"
},
{
"$ref": "#/components/schemas/SkillHubPendingSkill"
}
],
"title": "Response Import Skill Skill Hub Skills Import Post"
}
}
},
@ -59468,7 +59930,15 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubSkill"
"anyOf": [
{
"$ref": "#/components/schemas/SkillHubSkill"
},
{
"$ref": "#/components/schemas/SkillHubPendingSkill"
}
],
"title": "Response Upload Skill Skill Hub Skills Upload Post"
}
}
},
@ -59583,7 +60053,7 @@
]
},
"put": {
"description": "Replace registry metadata, clearing omitted fields and preserving skill files.",
"description": "Replace registry metadata, preserving files. Members submit an immutable proposal for admin review.",
"operationId": "replace_skill_metadata_skill_hub_skills__name__put",
"parameters": [
{
@ -59611,7 +60081,15 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubSkill"
"anyOf": [
{
"$ref": "#/components/schemas/SkillHubSkill"
},
{
"$ref": "#/components/schemas/SkillHubPendingSkill"
}
],
"title": "Response Replace Skill Metadata Skill Hub Skills Name Put"
}
}
},
@ -59868,6 +60346,72 @@
]
}
},
"/skill-hub/skills/{name}/upload": {
"put": {
"description": "Replace skill files, retaining registry fields. Members propose an update against the original editor timestamp.",
"operationId": "replace_skill_archive_skill_hub_skills__name__upload_put",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"title": "Name",
"type": "string"
}
}
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_replace_skill_archive_skill_hub_skills__name__upload_put"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SkillHubSkill"
},
{
"$ref": "#/components/schemas/SkillHubPendingSkill"
}
],
"title": "Response Replace Skill Archive Skill Hub Skills Name Upload Put"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Replace Skill Archive",
"tags": [
"skill_hub"
]
}
},
"/skill-hub/skills/{name}/versions": {
"get": {
"description": "Read published metadata history. External references are not retained artifacts.",
@ -60053,6 +60597,299 @@
"skill_hub"
]
}
},
"/skill-hub/submissions": {
"get": {
"description": "Read a page of owned proposals. Proxy admins and admin viewers can read every owner's proposals.",
"operationId": "list_submissions_skill_hub_submissions_get",
"parameters": [
{
"in": "query",
"name": "status",
"required": false,
"schema": {
"anyOf": [
{
"enum": [
"pending_review",
"approved",
"rejected"
],
"type": "string"
},
{
"type": "null"
}
],
"title": "Status"
}
},
{
"in": "query",
"name": "limit",
"required": false,
"schema": {
"default": 20,
"maximum": 100,
"minimum": 1,
"title": "Limit",
"type": "integer"
}
},
{
"in": "query",
"name": "offset",
"required": false,
"schema": {
"default": 0,
"minimum": 0,
"title": "Offset",
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubSubmissionList"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "List Submissions",
"tags": [
"skill_hub"
]
}
},
"/skill-hub/submissions/{submission_id}": {
"get": {
"description": "Read an immutable proposal and its currently visible published skill.",
"operationId": "get_submission_skill_hub_submissions__submission_id__get",
"parameters": [
{
"in": "path",
"name": "submission_id",
"required": true,
"schema": {
"title": "Submission Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubSubmissionDetail"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Get Submission",
"tags": [
"skill_hub"
]
}
},
"/skill-hub/submissions/{submission_id}/approve": {
"put": {
"description": "Publish a pending proposal and record its approval atomically. Only proxy admins may review.",
"operationId": "approve_submission_skill_hub_submissions__submission_id__approve_put",
"parameters": [
{
"in": "path",
"name": "submission_id",
"required": true,
"schema": {
"title": "Submission Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubReviewResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Approve Submission",
"tags": [
"skill_hub"
]
}
},
"/skill-hub/submissions/{submission_id}/archive": {
"get": {
"description": "Download an owned proposal as a private portable ZIP. Legacy nonportable archives return 404.",
"operationId": "download_submission_skill_hub_submissions__submission_id__archive_get",
"parameters": [
{
"in": "path",
"name": "submission_id",
"required": true,
"schema": {
"title": "Submission Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/zip": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Download Submission",
"tags": [
"skill_hub"
]
}
},
"/skill-hub/submissions/{submission_id}/reject": {
"put": {
"description": "Reject a pending proposal without changing the published skill. Only proxy admins may review.",
"operationId": "reject_submission_skill_hub_submissions__submission_id__reject_put",
"parameters": [
{
"in": "path",
"name": "submission_id",
"required": true,
"schema": {
"title": "Submission Id",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SkillHubRejection"
},
{
"type": "null"
}
],
"title": "Body"
}
}
}
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillHubReviewResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Reject Submission",
"tags": [
"skill_hub"
]
}
}
}
},

View file

@ -449,7 +449,7 @@ class LiteLLMRoutes(enum.Enum):
"/v1/traces/{trace_id}/spans/{span_id}/error",
)
litellm_native_routes = [
skill_hub_routes = (
"/skill-hub/skills",
"/skill-hub/skills/upload",
"/skill-hub/skills/import",
@ -461,6 +461,15 @@ class LiteLLMRoutes(enum.Enum):
"/skill-hub/skills/{name}/versions/{version_id}/archive",
"/skill-hub/skills/{name}/publish",
"/skill-hub/skills/{name}/unpublish",
"/skill-hub/skills/{name}/upload",
"/skill-hub/submissions",
"/skill-hub/submissions/{submission_id}",
"/skill-hub/submissions/{submission_id}/archive",
"/skill-hub/submissions/{submission_id}/approve",
"/skill-hub/submissions/{submission_id}/reject",
)
litellm_native_routes = [
"/rag/ingest",
"/v1/rag/ingest",
"/rag/query",
@ -709,6 +718,7 @@ class LiteLLMRoutes(enum.Enum):
+ key_management_routes
+ mcp_management_routes
+ list(agent_management_routes)
+ list(skill_hub_routes)
)
spend_tracking_routes = [
@ -951,6 +961,7 @@ class LiteLLMRoutes(enum.Enum):
# Agent registry - reads are role-scoped and writes are proxy-admin-gated
# inside agent_endpoints/endpoints.py
*agent_management_routes,
*skill_hub_routes,
] # routes that manage their own allowed/disallowed logic
## Org Admin Routes ##

View file

@ -5,11 +5,13 @@ from fastapi import Depends
from litellm.models.deployment_warning import DeploymentWarningIgnoreStore
from litellm.models.gateway_activity import GatewayActivityStore
from litellm.models.plugin_upload import PluginUploadStore
from litellm.models.skill_submission import SkillSubmissionStore
from litellm.models.user_banner import UserBannerStore
from litellm.proxy.deps.database import DatabaseDep
from litellm.repositories.deployment_warning_ignore_repository import DeploymentWarningIgnoreRepository
from litellm.repositories.gateway_activity_repository import GatewayActivityRepository
from litellm.repositories.plugin_upload_repository import PluginUploadRepository
from litellm.repositories.skill_submission_repository import SkillSubmissionRepository
from litellm.repositories.user_banner_repository import UserBannerRepository
@ -43,3 +45,12 @@ async def get_plugin_upload_repository(database: DatabaseDep) -> PluginUploadSto
PluginUploadRepositoryDep: TypeAlias = Annotated[PluginUploadStore | None, Depends(get_plugin_upload_repository)]
async def get_skill_submission_repository(database: DatabaseDep) -> SkillSubmissionStore | None:
return None if database is None else SkillSubmissionRepository(database.prisma)
SkillSubmissionRepositoryDep: TypeAlias = Annotated[
SkillSubmissionStore | None, Depends(get_skill_submission_repository)
]

View file

@ -1,5 +1,6 @@
import asyncio
import hashlib
from dataclasses import replace
from datetime import datetime
from typing import Annotated, Final
from urllib.parse import quote
@ -14,13 +15,13 @@ from litellm.llms.custom_httpx.http_handler import (
from litellm.models.plugin_upload import (
PluginArchiveMetadata,
PluginPublication,
PluginPublicationBase,
PluginPublicationError,
PluginSnapshot,
PluginUploadStore,
PluginVersion,
PublishedPlugin,
has_agent_skill_metadata,
)
from litellm.models.skill_submission import SkillSubmission, SkillSubmissionStore
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
MANIFEST_FIELDS,
@ -40,11 +41,17 @@ from litellm.proxy.common_utils.claude_code_marketplace_auth import (
marketplace_key_header_present,
)
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
from litellm.proxy.deps.repositories import PluginUploadRepositoryDep
from litellm.proxy.deps.repositories import PluginUploadRepositoryDep, SkillSubmissionRepositoryDep
from litellm.proxy.discovery_endpoints.agent_skills_archive import (
SkillArchive,
build_agent_skill_archive,
)
from litellm.proxy.discovery_endpoints.skill_hub_mutations import (
SkillMutationError,
archive_writer,
write_skill,
write_target,
)
from litellm.proxy.discovery_endpoints.skill_import import (
MAX_IMPORT_BYTES,
SkillImportError,
@ -52,13 +59,15 @@ from litellm.proxy.discovery_endpoints.skill_import import (
parse_skill_source,
select_imported_skill,
)
from litellm.proxy.hooks.parallel_request_limiter_v3 import PROXY_MaxParallelRequestsHandler_v3
from litellm.types.proxy.claude_code_endpoints import PluginListItem
from litellm.types.proxy.discovery_endpoints.agent_skills_endpoints import AgentSkillsIndexEntry
from litellm.types.proxy.skill_hub import (
ImportSkillRequest,
SkillHubList,
SkillHubMetadata,
SkillHubMutationResponse,
SkillHubPendingSkill,
SkillHubProposal,
SkillHubPublication,
SkillHubSkill,
SkillHubVersion,
@ -92,6 +101,9 @@ def _skill_response(request: Request, plugin: PluginListItem) -> SkillHubSkill:
name=plugin.name,
description=plugin.description,
version=plugin.version,
domain=plugin.domain,
namespace=plugin.namespace,
keywords=None if plugin.keywords is None else tuple(plugin.keywords),
updated_at=None if plugin.updated_at is None else datetime.fromisoformat(plugin.updated_at),
visibility="public" if plugin.enabled else "private",
source={"source": "archive", "url": _download_url(request, plugin.name)} if local_archive else plugin.source,
@ -135,22 +147,28 @@ async def get_skill(
metadata: Final = await store.get_metadata(name)
if metadata is None or not (await resolve_skill_visibility(caller)).allows(metadata):
raise HTTPException(404, "Skill not found")
return _metadata_response(request, metadata)
return skill_metadata_response(request, metadata)
def _metadata_response(request: Request, metadata: PluginArchiveMetadata) -> SkillHubSkill:
def skill_metadata_response(request: Request, metadata: PluginArchiveMetadata) -> SkillHubSkill:
manifest: Final = MANIFEST_FIELDS.validate_json(metadata.manifest_json or "{}")
return _skill_response(
request,
PluginListItem(
id=metadata.id or metadata.name,
name=metadata.name,
description=metadata.description,
version=metadata.version,
source=dict(metadata.source),
has_agent_skill_metadata=metadata.has_agent_skill_metadata,
enabled=metadata.enabled,
created_at=None,
updated_at=metadata.updated_at.isoformat() if metadata.updated_at is not None else None,
PluginListItem.model_validate(
{
"id": metadata.id or metadata.name,
"name": metadata.name,
"description": metadata.description,
"version": metadata.version,
"source": dict(metadata.source),
"has_agent_skill_metadata": metadata.has_agent_skill_metadata,
"enabled": metadata.enabled,
"created_at": None,
"updated_at": metadata.updated_at.isoformat() if metadata.updated_at is not None else None,
"domain": manifest.get("domain"),
"namespace": manifest.get("namespace"),
"keywords": manifest.get("keywords"),
}
),
)
@ -166,70 +184,33 @@ def _require_admin(caller: UserAPIKeyAuth) -> None:
raise HTTPException(403, "Only proxy admins may publish skills")
async def skill_publisher(
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> UserAPIKeyAuth:
from litellm.proxy.proxy_server import proxy_logging_obj
_require_admin(caller)
limiter: Final = proxy_logging_obj.get_proxy_hook("parallel_request_limiter")
if not isinstance(limiter, PROXY_MaxParallelRequestsHandler_v3):
raise HTTPException(503, "Skills Hub publishing requires the default v3 rate limiter")
key_identity: Final = caller.api_key or caller.token
identity: Final = (
f"user:{caller.user_id}" if caller.user_id else f"key:{key_identity}" if key_identity else "local-admin"
)
result: Final = await limiter.atomic_check_and_increment_by_n(
descriptors=[
{
"key": "skill_hub_publish",
"value": hashlib.sha256(identity.encode()).hexdigest(),
"rate_limit": {"requests_per_unit": 10, "window_size": 60},
}
],
increments=[{"requests": 1}],
parent_otel_span=caller.parent_otel_span,
)
if result["overall_code"] == "OVER_LIMIT":
raise HTTPException(
429, "Skills Hub allows 10 uploads or imports per minute per user", headers={"Retry-After": "60"}
)
return caller
@router.put("/{name}", response_model=SkillHubSkill)
@router.put("/{name}", response_model=SkillHubMutationResponse)
async def replace_skill_metadata(
request: Request,
name: str,
body: SkillHubMetadata,
uploads: PluginUploadRepositoryDep,
submissions: SkillSubmissionRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> SkillHubSkill:
"""Replace registry metadata, clearing omitted fields and preserving skill files."""
_require_admin(caller)
store: Final = _require_store(uploads)
metadata: Final = await store.get_metadata(name)
if metadata is None or metadata.id is None:
) -> SkillHubMutationResponse:
"""Replace registry metadata, preserving files. Members submit an immutable proposal for admin review."""
existing: Final = await write_target(name, True, uploads, caller, body.expected_updated_at)
if isinstance(existing, SkillMutationError):
raise HTTPException(existing.status, existing.message)
if existing is None:
raise HTTPException(404, "Skill not found")
if body.expected_updated_at is not None and body.expected_updated_at != metadata.updated_at:
raise HTTPException(409, "The published skill changed. Reload it before publishing an update")
manifest: Final = MANIFEST_FIELDS.validate_json(metadata.manifest_json or "{}")
publication: Final = PluginPublication(
name=name,
version=body.version,
description=body.description,
manifest={
**{key: value for key, value in manifest.items() if key not in _EDITABLE_METADATA},
**{key: value for key, value in existing.manifest.items() if key not in _EDITABLE_METADATA},
**body.model_dump(exclude_none=True),
"name": name,
"source": dict(metadata.source),
},
created_by=caller.user_id,
)
published: Final = await store.publish(publication, PluginPublicationBase(metadata.id, metadata.updated_at))
if isinstance(published, PluginPublicationError):
raise _publication_error(published)
return skill_publication_response(request, publication, published)
return await _mutate(request, publication, existing, _require_store(uploads), submissions, caller)
@router.post("/{name}/publish", response_model=SkillHubSkill)
@ -268,14 +249,15 @@ async def remove_skill(name: str, caller: Annotated[UserAPIKeyAuth, Depends(user
return Response(status_code=204)
@router.post("/upload", response_model=SkillHubSkill)
@router.post("/upload", response_model=SkillHubMutationResponse)
async def upload_skill(
request: Request,
uploads: PluginUploadRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(skill_publisher)],
submissions: SkillSubmissionRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(archive_writer)],
file: Annotated[UploadFile, File(description="Agent Skills ZIP with SKILL.md and optional supporting files")],
version: Annotated[str | None, Form()] = None,
) -> SkillHubSkill:
) -> SkillHubMutationResponse:
"""Publish a portable skill while retaining Claude marketplace compatibility."""
store: Final = _require_store(uploads)
content: Final = await file.read(MAX_IMPORT_BYTES + 1)
@ -284,21 +266,22 @@ async def upload_skill(
archive: Final = await asyncio.to_thread(build_agent_skill_archive, content)
if archive is None:
raise HTTPException(422, "Upload a valid Agent Skill with name and description in SKILL.md")
return await _publish(request, store, caller, archive, version, None)
return await _publish(request, store, submissions, caller, archive, version, None)
def import_http_client() -> httpx.AsyncClient:
return get_async_httpx_client(llm_provider="skill_hub_import").client
@router.post("/import", response_model=SkillHubSkill)
@router.post("/import", response_model=SkillHubMutationResponse)
async def import_skill(
request: Request,
body: ImportSkillRequest,
uploads: PluginUploadRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(skill_publisher)],
submissions: SkillSubmissionRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(archive_writer)],
client: Annotated[httpx.AsyncClient, Depends(import_http_client)],
) -> SkillHubSkill:
) -> SkillHubMutationResponse:
"""Import a public skills.sh or GitHub skill as a portable snapshot."""
store: Final = _require_store(uploads)
source: Final = parse_skill_source(body.url, skill=body.skill, ref=body.ref, path=body.path)
@ -310,26 +293,89 @@ async def import_skill(
archive: Final = await asyncio.to_thread(select_imported_skill, content, source)
if isinstance(archive, SkillImportError):
raise HTTPException(archive.status, archive.message)
return await _publish(request, store, caller, archive, body.version, body.url)
return await _publish(request, store, submissions, caller, archive, body.version, body.url)
async def _publish(
request: Request,
store: PluginUploadStore,
submissions: SkillSubmissionStore | None,
caller: UserAPIKeyAuth,
archive: SkillArchive,
version: str | None,
origin: str | None,
) -> SkillHubSkill:
) -> SkillHubMutationResponse:
existing: Final = await write_target(archive.declared_name or "", False, store, caller)
if isinstance(existing, SkillMutationError):
raise HTTPException(existing.status, existing.message)
publication: Final = await prepare_skill_publication(request, archive, version, origin, caller.user_id)
published: Final = await store.publish(publication)
if isinstance(published, PluginPublicationError):
raise _publication_error(published)
return skill_publication_response(request, publication, published)
return await _mutate(request, publication, existing, store, submissions, caller)
def _publication_error(error: PluginPublicationError) -> HTTPException:
return HTTPException({"invalid_version": 400, "conflict": 409, "not_found": 404}[error.kind], error.message)
async def _mutate(
request: Request,
publication: PluginPublication,
existing: PluginSnapshot | None,
store: PluginUploadStore,
submissions: SkillSubmissionStore | None,
caller: UserAPIKeyAuth,
) -> SkillHubMutationResponse:
result: Final = await write_skill(publication, existing, store, submissions, caller)
if isinstance(result, SkillMutationError):
raise HTTPException(result.status, result.message)
if isinstance(result, SkillSubmission):
return SkillHubPendingSkill(submission_id=result.id, proposal=skill_proposal_response(request, result))
return skill_publication_response(request, publication, result)
def skill_proposal_response(request: Request, submission: SkillSubmission) -> SkillHubProposal:
proposal: Final = submission.proposal
return SkillHubProposal.model_validate(
{
**proposal.manifest,
"name": proposal.name,
"description": proposal.description,
"version": proposal.version,
**(
{
"source": {
"source": "archive",
"url": _url(request, f"skill-hub/submissions/{quote(submission.id, safe='')}/archive"),
}
}
if submission.has_archive
else {}
),
}
)
@router.put("/{name}/upload", response_model=SkillHubMutationResponse)
async def replace_skill_archive(
request: Request,
name: str,
uploads: PluginUploadRepositoryDep,
submissions: SkillSubmissionRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(archive_writer)],
file: Annotated[UploadFile, File(description="Replacement Agent Skills ZIP with the same declared skill name")],
version: Annotated[str | None, Form()] = None,
expected_updated_at: Annotated[datetime | None, Form()] = None,
) -> SkillHubMutationResponse:
"""Replace skill files, retaining registry fields. Members propose an update against the original editor timestamp."""
existing: Final = await write_target(name, True, uploads, caller, expected_updated_at)
if isinstance(existing, SkillMutationError):
raise HTTPException(existing.status, existing.message)
if existing is None:
raise HTTPException(404, "Skill not found")
content: Final = await file.read(MAX_IMPORT_BYTES + 1)
if len(content) > MAX_IMPORT_BYTES:
raise HTTPException(413, "Skill ZIP must be 10 MiB or smaller")
archive: Final = await asyncio.to_thread(build_agent_skill_archive, content)
if archive is None or archive.declared_name != name:
raise HTTPException(422, "Replacement SKILL.md must declare the existing skill name and a description")
prepared: Final = await prepare_skill_publication(request, archive, version, None, caller.user_id)
publication: Final = replace(prepared, manifest={**existing.manifest, **prepared.manifest})
return await _mutate(request, publication, existing, _require_store(uploads), submissions, caller)
def skill_publication_response(
@ -348,6 +394,9 @@ def skill_publication_response(
"has_agent_skill_metadata": has_agent_skill_metadata(publication.manifest),
"created_at": None,
"updated_at": published.updated_at.isoformat(),
"domain": publication.manifest.get("domain"),
"namespace": publication.manifest.get("namespace"),
"keywords": publication.manifest.get("keywords"),
}
),
)

View file

@ -0,0 +1,172 @@
import hashlib
from dataclasses import dataclass
from datetime import datetime
from typing import Annotated, Final
from fastapi import Depends, HTTPException
from typing_extensions import assert_never
from litellm.models.plugin_upload import (
PluginPublication,
PluginPublicationBase,
PluginPublicationError,
PluginSnapshot,
PluginUploadStore,
PublishedPlugin,
)
from litellm.models.skill_submission import (
SkillSubmission,
SkillSubmissionStore,
SubmissionConflict,
SubmissionLimitExceeded,
SubmissionMissing,
SubmissionReviewed,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import resolve_skill_visibility
from litellm.proxy.auth.auth_exception_handler import DB_UNAVAILABLE_FALLBACK_USER_ID
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
from litellm.proxy.hooks.parallel_request_limiter_v3 import PROXY_MaxParallelRequestsHandler_v3
from litellm.repositories.plugin_publication import resolve_plugin_version
from litellm.types.proxy.auth.user_roles import LitellmUserRoles
@dataclass(frozen=True, slots=True)
class SkillMutationError:
status: int
message: str
def writer_error(caller: UserAPIKeyAuth) -> SkillMutationError | None:
if is_proxy_admin(caller):
return None
if (
not caller.user_id
or caller.user_id == DB_UNAVAILABLE_FALLBACK_USER_ID
or caller.user_role in (None, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY)
):
return SkillMutationError(403, "Submitting a skill requires a writable user identity.")
return None
def submission_owner(caller: UserAPIKeyAuth) -> str | None | SkillMutationError:
if is_proxy_admin(caller) or caller.user_role == LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY:
return None
if not caller.user_id or caller.user_id == DB_UNAVAILABLE_FALLBACK_USER_ID:
return SkillMutationError(403, "Reading submissions requires a user identity.")
return caller.user_id
async def archive_writer(caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)]) -> UserAPIKeyAuth:
from litellm.proxy.proxy_server import proxy_logging_obj
error: Final = writer_error(caller)
if error is not None:
raise HTTPException(error.status, error.message)
limiter: Final = proxy_logging_obj.get_proxy_hook("parallel_request_limiter")
if not isinstance(limiter, PROXY_MaxParallelRequestsHandler_v3):
raise HTTPException(503, "Skills Hub publishing requires the default v3 rate limiter")
key_identity: Final = caller.api_key or caller.token
identity: Final = (
f"user:{caller.user_id}" if caller.user_id else f"key:{key_identity}" if key_identity else "local-admin"
)
result: Final = await limiter.atomic_check_and_increment_by_n(
descriptors=[
{
"key": "skill_hub_publish",
"value": hashlib.sha256(identity.encode()).hexdigest(),
"rate_limit": {"requests_per_unit": 10, "window_size": 60},
}
],
increments=[{"requests": 1}],
parent_otel_span=caller.parent_otel_span,
)
if result["overall_code"] == "OVER_LIMIT":
raise HTTPException(
429, "Skills Hub allows 10 uploads or imports per minute per user", headers={"Retry-After": "60"}
)
return caller
async def write_target(
name: str,
updating: bool,
uploads: PluginUploadStore | None,
caller: UserAPIKeyAuth,
expected_updated_at: datetime | None = None,
) -> PluginSnapshot | SkillMutationError | None:
error: Final = writer_error(caller)
if error is not None:
return error
if uploads is None:
return SkillMutationError(503, "Skills Hub requires a connected database")
existing: Final = await uploads.get_plugin(name)
if updating and (existing is None or not (await resolve_skill_visibility(caller)).allows(existing)):
return SkillMutationError(404, "Skill not found")
if existing is not None and expected_updated_at is not None and existing.updated_at != expected_updated_at:
return SkillMutationError(409, "The published skill changed. Reload it before submitting.")
if not updating and existing is not None:
return SkillMutationError(409, f"A skill named '{name}' already exists. Update the existing skill instead.")
return existing
def publication_error(error: PluginPublicationError) -> SkillMutationError:
return SkillMutationError({"invalid_version": 400, "conflict": 409, "not_found": 404}[error.kind], error.message)
async def write_skill(
publication: PluginPublication,
existing: PluginSnapshot | None,
uploads: PluginUploadStore,
submissions: SkillSubmissionStore | None,
caller: UserAPIKeyAuth,
) -> PublishedPlugin | SkillSubmission | SkillMutationError:
error: Final = writer_error(caller)
if error is not None:
return error
validation: Final = resolve_plugin_version(
publication.version, None if existing is None else existing.version, updating=existing is not None
)
if isinstance(validation, PluginPublicationError):
return publication_error(validation)
expected: Final = None if existing is None else PluginPublicationBase(existing.id, existing.updated_at)
if is_proxy_admin(caller):
published: Final = await uploads.publish(publication, expected)
return publication_error(published) if isinstance(published, PluginPublicationError) else published
if submissions is None or not caller.user_id:
return SkillMutationError(503, "Skills Hub requires a connected database")
submission: Final = await submissions.submit(publication, caller.user_id, expected)
return (
SkillMutationError(429, submission.message) if isinstance(submission, SubmissionLimitExceeded) else submission
)
async def review_skill(
submission_id: str,
submissions: SkillSubmissionStore | None,
caller: UserAPIKeyAuth,
approve: bool,
reason: str | None,
publication_base_url: str | None = None,
) -> SubmissionReviewed | SkillMutationError:
if not is_proxy_admin(caller):
return SkillMutationError(403, "Only proxy admins may review skill submissions.")
if submissions is None:
return SkillMutationError(503, "Skills Hub requires a connected database")
result: Final = await submissions.review(
submission_id, caller.user_id or "proxy-admin", approve, reason, publication_base_url=publication_base_url
)
match result:
case SubmissionMissing():
return SkillMutationError(404, "Skill submission not found")
case SubmissionConflict(message=message):
return SkillMutationError(409, message)
case PluginPublicationError(kind="invalid_version") as error:
return publication_error(error)
case PluginPublicationError(message=message):
return SkillMutationError(409, message)
case SubmissionReviewed():
return result
case _:
assert_never(result)

View file

@ -0,0 +1,192 @@
import asyncio
from typing import Annotated, Final
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from litellm.models.plugin_upload import PluginPublication
from litellm.models.skill_submission import SkillSubmission, SkillSubmissionStore, SubmissionStatus
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import resolve_skill_visibility
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.deps.repositories import PluginUploadRepositoryDep, SkillSubmissionRepositoryDep
from litellm.proxy.discovery_endpoints.agent_skills_archive import build_agent_skill_archive
from litellm.proxy.discovery_endpoints.skill_hub_endpoints import (
skill_metadata_response,
skill_proposal_response,
skill_publication_response,
)
from litellm.proxy.discovery_endpoints.skill_hub_mutations import SkillMutationError, review_skill, submission_owner
from litellm.types.proxy.skill_hub import (
SkillHubRejection,
SkillHubReviewResponse,
SkillHubSubmission,
SkillHubSubmissionDetail,
SkillHubSubmissionList,
)
router: Final = APIRouter(prefix="/skill-hub/submissions", tags=["Skills Hub"])
def _store(submissions: SkillSubmissionStore | None) -> SkillSubmissionStore:
if submissions is None:
raise HTTPException(503, "Skills Hub requires a connected database")
return submissions
def _owner(caller: UserAPIKeyAuth) -> str | None:
owner: Final = submission_owner(caller)
if isinstance(owner, SkillMutationError):
raise HTTPException(owner.status, owner.message)
return owner
def _response(request: Request, submission: SkillSubmission) -> SkillHubSubmission:
return SkillHubSubmission(
id=submission.id,
skill_name=submission.plugin_name,
operation=submission.operation,
status=submission.status,
proposal=skill_proposal_response(request, submission),
has_archive=submission.has_archive,
submitted_by=submission.submitted_by,
created_at=submission.created_at,
base_skill_id=submission.base_plugin_id,
base_updated_at=submission.base_updated_at,
reviewed_by=submission.reviewed_by,
reviewed_at=submission.reviewed_at,
rejection_reason=submission.rejection_reason,
)
@router.get("", response_model=SkillHubSubmissionList)
async def list_submissions(
request: Request,
submissions: SkillSubmissionRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
status: SubmissionStatus | None = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
offset: Annotated[int, Query(ge=0)] = 0,
) -> SkillHubSubmissionList:
"""Read a page of owned proposals. Proxy admins and admin viewers can read every owner's proposals."""
owner: Final = _owner(caller)
page: Final = await _store(submissions).list(owner, status, limit, offset)
return SkillHubSubmissionList(
submissions=tuple(_response(request, row) for row in page.submissions),
count=len(page.submissions),
has_more=page.has_more,
)
@router.get("/{submission_id}", response_model=SkillHubSubmissionDetail)
async def get_submission(
request: Request,
submission_id: str,
submissions: SkillSubmissionRepositoryDep,
uploads: PluginUploadRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> SkillHubSubmissionDetail:
"""Read an immutable proposal and its currently visible published skill."""
owner: Final = _owner(caller)
submission: Final = await _store(submissions).get(submission_id, owner)
if submission is None:
raise HTTPException(404, "Skill submission not found")
if uploads is None:
raise HTTPException(503, "Skills Hub requires a connected database")
current: Final = await uploads.get_metadata(submission.plugin_name)
visible: Final = current is not None and (owner is None or (await resolve_skill_visibility(caller)).allows(current))
return SkillHubSubmissionDetail(
submission=_response(request, submission),
current_skill=skill_metadata_response(request, current) if current is not None and visible else None,
)
@router.get(
"/{submission_id}/archive",
response_class=Response,
responses={200: {"content": {"application/zip": {"schema": {"type": "string", "format": "binary"}}}}},
)
async def download_submission(
submission_id: str,
submissions: SkillSubmissionRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> Response:
"""Download an owned proposal as a private portable ZIP. Legacy nonportable archives return 404."""
owner: Final = _owner(caller)
store: Final = _store(submissions)
submission: Final = await store.get(submission_id, owner)
if submission is None:
raise HTTPException(404, "Skill submission not found")
content: Final = await store.get_archive(submission_id, owner)
archive: Final = (
await asyncio.to_thread(build_agent_skill_archive, content, plugin_name=submission.plugin_name)
if content is not None
else None
)
if archive is None:
raise HTTPException(404, "This submission has no portable Agent Skills archive")
return Response(
archive.content,
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{submission.plugin_name}.zip"',
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"ETag": f'"{archive.digest}"',
},
)
async def _review(
request: Request,
submission_id: str,
submissions: SkillSubmissionStore | None,
caller: UserAPIKeyAuth,
approve: bool,
reason: str | None,
) -> SkillHubReviewResponse:
from litellm.proxy.utils import get_custom_url
result: Final = await review_skill(
submission_id, submissions, caller, approve, reason, get_custom_url(str(request.base_url))
)
if isinstance(result, SkillMutationError):
raise HTTPException(result.status, result.message)
proposal: Final = result.submission.proposal
publication: Final = PluginPublication(
name=proposal.name,
version=proposal.version,
description=proposal.description,
manifest={
**proposal.manifest,
**({"source": dict(result.published_source)} if result.published_source else {}),
},
created_by=result.submission.submitted_by,
)
return SkillHubReviewResponse(
action="approved" if approve else "rejected",
submission=_response(request, result.submission),
skill=None if result.plugin is None else skill_publication_response(request, publication, result.plugin),
)
@router.put("/{submission_id}/approve", response_model=SkillHubReviewResponse)
async def approve_submission(
request: Request,
submission_id: str,
submissions: SkillSubmissionRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> SkillHubReviewResponse:
"""Publish a pending proposal and record its approval atomically. Only proxy admins may review."""
return await _review(request, submission_id, submissions, caller, True, None)
@router.put("/{submission_id}/reject", response_model=SkillHubReviewResponse)
async def reject_submission(
request: Request,
submission_id: str,
submissions: SkillSubmissionRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
body: SkillHubRejection | None = None,
) -> SkillHubReviewResponse:
"""Reject a pending proposal without changing the published skill. Only proxy admins may review."""
return await _review(request, submission_id, submissions, caller, False, None if body is None else body.reason)

View file

@ -5,6 +5,7 @@ from typing import Final
from fastapi import HTTPException
from starlette._utils import get_route_path
from starlette.routing import compile_path
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from litellm.proxy.common_utils.anthropic_error_payload import route_error_content
@ -25,7 +26,20 @@ def effective_max_request_size_mb(
) -> float | None:
global_limit: Final = configured if configured is not None and configured > 0 else None
method: Final = scope.get("method")
route_limit: Final = route_limits.get((method, get_route_path(scope))) if isinstance(method, str) else None
path: Final = get_route_path(scope)
exact_limit: Final = route_limits.get((method, path)) if isinstance(method, str) else None
route_limit: Final = (
exact_limit
if exact_limit is not None
else next(
(
limit
for (route_method, route), limit in route_limits.items()
if route_method == method and "{" in route and compile_path(route)[0].fullmatch(path) is not None
),
None,
)
)
if route_limit is None:
return global_limit
return min(route_limit, global_limit) if global_limit is not None else route_limit

View file

@ -2934,7 +2934,11 @@ def configured_max_request_size_mb() -> int | float | None:
_ROUTE_MAX_REQUEST_SIZE_MB: Final[Mapping[tuple[str, str], float]] = MappingProxyType(
{("POST", "/claude-code/plugins/upload"): 11, ("POST", "/skill-hub/skills/upload"): 11}
{
("POST", "/claude-code/plugins/upload"): 11,
("POST", "/skill-hub/skills/upload"): 11,
("PUT", "/skill-hub/skills/{name}/upload"): 11,
}
)

View file

@ -1638,6 +1638,27 @@ model LiteLLM_ClaudeCodePluginVersion {
}
model LiteLLM_SkillSubmission {
id String @id @default(uuid())
plugin_name String
operation String
status String @default("pending_review")
proposal_json String
archive_content Bytes?
submitted_by String
created_at DateTime @default(now())
base_plugin_id String?
base_updated_at DateTime?
reviewed_by String?
reviewed_at DateTime?
rejection_reason String?
@@index([submitted_by, created_at(sort: Desc), id(sort: Desc)])
@@index([submitted_by, status, created_at(sort: Desc), id(sort: Desc)])
@@index([status, created_at(sort: Desc), id(sort: Desc)])
@@index([created_at(sort: Desc), id(sort: Desc)])
}
model LiteLLM_ClaudeCodePluginUpload {
plugin_name String @id
content Bytes

View file

@ -15,6 +15,7 @@ from litellm.models.plugin_upload import (
PluginPublication,
PluginPublicationBase,
PluginPublicationError,
PluginSnapshot,
PluginUpload,
PluginVersion,
PublishedPlugin,
@ -116,6 +117,22 @@ class PluginUploadRepository:
return None
return verified_archive(version.content.decode(), version.manifest_json)
async def get_plugin(self, name: str) -> PluginSnapshot | None:
row: Final = await self._plugins.find_unique(where={"name": name})
if row is None:
return None
return PluginSnapshot(
id=row.id,
name=row.name,
version=row.version,
description=row.description,
manifest=MappingProxyType(_MANIFEST.validate_json(row.manifest_json or "{}")),
enabled=row.enabled,
created_at=row.created_at,
updated_at=row.updated_at,
created_by=row.created_by,
)
async def get_metadata(self, name: str) -> PluginArchiveMetadata | None:
plugin: Final = await self._plugins.find_unique(where={"name": name})
if plugin is None:

View file

@ -0,0 +1,288 @@
import hashlib
from collections.abc import Mapping
from contextlib import AbstractAsyncContextManager
from dataclasses import replace
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Final, Protocol, cast # noqa: TID251 # Prisma's generated delegates are dynamic
from pydantic import BaseModel, ConfigDict, TypeAdapter
from litellm.models.plugin_upload import (
PluginPublication,
PluginPublicationBase,
PluginPublicationError,
PublishedPlugin,
uploaded_plugin_source,
)
from litellm.models.skill_submission import (
SkillProposal,
SkillSubmission,
SubmissionConflict,
SubmissionLimitExceeded,
SubmissionMissing,
SubmissionOperation,
SubmissionPage,
SubmissionReviewed,
SubmissionReviewResult,
SubmissionStatus,
)
from litellm.repositories.plugin_publication import PluginPublicationTransaction, publish_plugin
from litellm.repositories.prisma_protocols import TableActions
if TYPE_CHECKING:
from prisma import models as prisma_models
from litellm.proxy.utils import PrismaClient
class _StoredProposal(BaseModel):
model_config = ConfigDict(frozen=True)
manifest: Mapping[str, object]
version: str | None
def proposal(self, name: str) -> SkillProposal:
return SkillProposal(
name, self.version, _DESCRIPTION.validate_python(self.manifest.get("description")), self.manifest
)
class _PendingUsage(BaseModel):
model_config = ConfigDict(frozen=True)
count: int
storage_bytes: int
class _SubmissionRow(BaseModel):
model_config = ConfigDict(frozen=True)
id: str
plugin_name: str
operation: SubmissionOperation
status: SubmissionStatus
proposal_json: str
has_archive: bool
submitted_by: str
created_at: datetime
base_plugin_id: str | None
base_updated_at: datetime | None
reviewed_by: str | None
reviewed_at: datetime | None
rejection_reason: str | None
def submission(self) -> SkillSubmission:
stored: Final = _StoredProposal.model_validate_json(self.proposal_json)
return SkillSubmission(
id=self.id,
plugin_name=self.plugin_name,
operation=self.operation,
status=self.status,
proposal=stored.proposal(self.plugin_name),
has_archive=self.has_archive,
submitted_by=self.submitted_by,
created_at=self.created_at,
base_plugin_id=self.base_plugin_id,
base_updated_at=self.base_updated_at,
reviewed_by=self.reviewed_by,
reviewed_at=self.reviewed_at,
rejection_reason=self.rejection_reason,
)
_ROWS: Final = TypeAdapter(tuple[_SubmissionRow, ...])
_DESCRIPTION: Final = TypeAdapter[str | None](str | None)
_SOURCE: Final = TypeAdapter(dict[str, str])
_USAGE: Final = TypeAdapter(tuple[_PendingUsage, ...])
_MAX_PENDING_SUBMISSIONS: Final = 20
_MAX_PENDING_BYTES: Final = 50 * 1024 * 1024
_SELECT: Final = """
SELECT id, plugin_name, operation, status, proposal_json,
archive_content IS NOT NULL AS has_archive, submitted_by, created_at,
base_plugin_id, base_updated_at, reviewed_by, reviewed_at, rejection_reason
FROM "LiteLLM_SkillSubmission"
"""
class SubmissionTransaction(PluginPublicationTransaction, Protocol):
@property
def litellm_skillsubmission(self) -> TableActions["prisma_models.LiteLLM_SkillSubmission"]: ...
class SubmissionDatabase(SubmissionTransaction, Protocol):
def tx(self) -> AbstractAsyncContextManager[SubmissionTransaction]: ...
def _submission(row: "prisma_models.LiteLLM_SkillSubmission") -> SkillSubmission:
return _SubmissionRow.model_validate(
{**row.model_dump(), "has_archive": row.archive_content is not None}
).submission()
class SkillSubmissionRepository:
def __init__(self, prisma_client: "PrismaClient") -> None:
self._database = cast( # cast-ok: Prisma dynamically exposes generated delegates and transactions
SubmissionDatabase, prisma_client.writer_db
)
async def submit(
self, upload: PluginPublication, submitted_by: str, base: PluginPublicationBase | None
) -> SkillSubmission | SubmissionLimitExceeded:
from prisma import Base64
proposal_json: Final = _StoredProposal(manifest=dict(upload.manifest), version=upload.version).model_dump_json()
incoming_bytes: Final = len(proposal_json.encode("utf-8")) + len(upload.content or b"")
async with self._database.tx() as transaction:
await transaction.query_raw(
"SELECT 1 AS locked FROM pg_advisory_xact_lock(hashtext('litellm.skill_submissions'), hashtext($1))",
submitted_by,
)
usage: Final = _USAGE.validate_python(
await transaction.query_raw(
"""SELECT COUNT(*) AS count,
COALESCE(SUM(octet_length(proposal_json)
+ COALESCE(octet_length(archive_content), 0)), 0) AS storage_bytes
FROM "LiteLLM_SkillSubmission" WHERE submitted_by = $1 AND status = 'pending_review'""",
submitted_by,
)
)[0]
if usage.count >= _MAX_PENDING_SUBMISSIONS:
return SubmissionLimitExceeded(
"You already have 20 pending skill submissions. "
"Wait for an admin to review one before submitting another."
)
if usage.storage_bytes + incoming_bytes > _MAX_PENDING_BYTES:
return SubmissionLimitExceeded(
"Pending skill submissions cannot exceed 50 MiB of stored metadata and archives per user. "
"Wait for an admin review or submit less data."
)
row: Final = await transaction.litellm_skillsubmission.create(
data={
"plugin_name": upload.name,
"operation": "create" if base is None else "update",
"proposal_json": proposal_json,
"archive_content": None if upload.content is None else Base64.encode(upload.content),
"submitted_by": submitted_by,
"base_plugin_id": None if base is None else base.id,
"base_updated_at": None if base is None else base.updated_at,
}
)
return _submission(row)
async def list(
self, submitted_by: str | None, status: SubmissionStatus | None, limit: int, offset: int
) -> SubmissionPage:
rows: Final = _ROWS.validate_python(
await self._database.query_raw(
_SELECT
+ """WHERE ($1::text IS NULL OR submitted_by = $1)
AND ($2::text IS NULL OR status = $2)
ORDER BY created_at DESC, id DESC LIMIT $3 OFFSET $4""",
submitted_by,
status,
limit + 1,
offset,
)
)
return SubmissionPage(tuple(row.submission() for row in rows[:limit]), has_more=len(rows) > limit)
async def get(self, submission_id: str, submitted_by: str | None) -> SkillSubmission | None:
rows: Final = _ROWS.validate_python(
await self._database.query_raw(
_SELECT + "WHERE id = $1 AND ($2::text IS NULL OR submitted_by = $2)",
submission_id,
submitted_by,
)
)
return None if not rows else rows[0].submission()
async def get_archive(self, submission_id: str, submitted_by: str | None) -> bytes | None:
row: Final = await self._database.litellm_skillsubmission.find_first(
where={"id": submission_id, **({"submitted_by": submitted_by} if submitted_by is not None else {})}
)
return None if row is None or row.archive_content is None else row.archive_content.decode()
async def review(
self,
submission_id: str,
reviewer: str,
approve: bool,
reason: str | None,
*,
publication_base_url: str | None = None,
) -> SubmissionReviewResult:
from prisma.errors import UniqueViolationError
try:
async with self._database.tx() as transaction:
await transaction.query_raw(
'SELECT id FROM "LiteLLM_SkillSubmission" WHERE id = $1 FOR UPDATE', submission_id
)
row: Final = await transaction.litellm_skillsubmission.find_unique(where={"id": submission_id})
if row is None:
return SubmissionMissing()
submission: Final = _submission(row)
if submission.status != "pending_review":
return SubmissionConflict("This submission has already been reviewed.")
content: Final = None if row.archive_content is None else row.archive_content.decode()
if approve and content is not None and not publication_base_url:
return SubmissionConflict("Uploaded skill approval requires a trusted publication URL.")
source: Final = (
uploaded_plugin_source(publication_base_url, row.plugin_name, hashlib.sha256(content).hexdigest())
if approve and content is not None and publication_base_url
else _SOURCE.validate_python(submission.proposal.manifest.get("source"))
)
publication: Final = await _publish_submission(transaction, row, source, content) if approve else None
if isinstance(publication, PluginPublicationError):
return publication
reviewed_at: Final = datetime.now(timezone.utc)
status: Final[SubmissionStatus] = "approved" if approve else "rejected"
await transaction.litellm_skillsubmission.update(
where={"id": submission_id},
data={
"status": status,
"reviewed_by": reviewer,
"reviewed_at": reviewed_at,
"rejection_reason": None if approve else reason,
},
)
return SubmissionReviewed(
submission=replace(
submission,
status=status,
reviewed_by=reviewer,
reviewed_at=reviewed_at,
rejection_reason=None if approve else reason,
),
plugin=publication,
published_source=source if approve else None,
)
except UniqueViolationError:
return SubmissionConflict("A skill with this name is already published. Submit a new update proposal.")
async def _publish_submission(
transaction: PluginPublicationTransaction,
row: "prisma_models.LiteLLM_SkillSubmission",
source: Mapping[str, str],
content: bytes | None,
) -> PublishedPlugin | PluginPublicationError:
stored: Final = _StoredProposal.model_validate_json(row.proposal_json)
proposal: Final = stored.proposal(row.plugin_name)
base: Final = (
None
if row.operation == "create"
else PluginPublicationBase(id=row.base_plugin_id or "", updated_at=row.base_updated_at)
)
return await publish_plugin(
transaction,
PluginPublication(
name=row.plugin_name,
version=stored.version,
description=proposal.description,
manifest={**stored.manifest, "source": dict(source)},
content=content,
created_by=row.submitted_by,
),
base,
)

View file

@ -1,9 +1,10 @@
from collections.abc import Mapping
from datetime import datetime
from typing import Literal
from typing import Literal, TypeAlias
from pydantic import ConfigDict, Field
from litellm.models.skill_submission import SubmissionOperation, SubmissionStatus
from litellm.types.llms.base import LiteLLMBaseModel
@ -19,6 +20,9 @@ class SkillHubSkill(LiteLLMBaseModel):
source: Mapping[str, str]
download_url: str | None = None
content_url: str | None = None
domain: str | None = None
namespace: str | None = None
keywords: tuple[str, ...] | None = None
class SkillHubList(LiteLLMBaseModel):
@ -74,3 +78,85 @@ class SkillHubVersions(LiteLLMBaseModel):
versions: tuple[SkillHubVersion, ...]
next_offset: int | None
class SkillHubAgentSkill(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
name: str
description: str
digest: str
origin: str | None
class SkillHubProposal(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
name: str
description: str | None
version: str | None
domain: str | None = None
namespace: str | None = None
keywords: tuple[str, ...] | None = None
source: Mapping[str, str]
agent_skill: SkillHubAgentSkill | None = None
class SkillHubPendingSkill(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
status: Literal["pending_review"] = "pending_review"
action: Literal["submitted"] = "submitted"
submission_id: str
proposal: SkillHubProposal
SkillHubMutationResponse: TypeAlias = SkillHubSkill | SkillHubPendingSkill
class SkillHubSubmission(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
id: str
skill_name: str
operation: SubmissionOperation
status: SubmissionStatus
proposal: SkillHubProposal
has_archive: bool
submitted_by: str
created_at: datetime
base_skill_id: str | None
base_updated_at: datetime | None
reviewed_by: str | None
reviewed_at: datetime | None
rejection_reason: str | None
class SkillHubSubmissionList(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
submissions: tuple[SkillHubSubmission, ...]
count: int
has_more: bool
class SkillHubSubmissionDetail(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
submission: SkillHubSubmission
current_skill: SkillHubSkill | None
class SkillHubReviewResponse(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
status: Literal["success"] = "success"
action: Literal["approved", "rejected"]
submission: SkillHubSubmission
skill: SkillHubSkill | None
class SkillHubRejection(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
reason: str | None = Field(None, max_length=2000)

View file

@ -1638,6 +1638,27 @@ model LiteLLM_ClaudeCodePluginVersion {
}
model LiteLLM_SkillSubmission {
id String @id @default(uuid())
plugin_name String
operation String
status String @default("pending_review")
proposal_json String
archive_content Bytes?
submitted_by String
created_at DateTime @default(now())
base_plugin_id String?
base_updated_at DateTime?
reviewed_by String?
reviewed_at DateTime?
rejection_reason String?
@@index([submitted_by, created_at(sort: Desc), id(sort: Desc)])
@@index([submitted_by, status, created_at(sort: Desc), id(sort: Desc)])
@@index([status, created_at(sort: Desc), id(sort: Desc)])
@@index([created_at(sort: Desc), id(sort: Desc)])
}
model LiteLLM_ClaudeCodePluginUpload {
plugin_name String @id
content Bytes

View file

@ -0,0 +1,373 @@
import asyncio
import hashlib
import io
import os
import uuid
import zipfile
from collections.abc import AsyncIterator, Mapping, Sequence
from dataclasses import dataclass
from typing import Final, Literal
import psycopg
import pytest
import pytest_asyncio
from pydantic import JsonValue, TypeAdapter
from litellm.models.plugin_upload import (
PluginPublication,
PluginPublicationBase,
PluginPublicationError,
PluginUploadStore,
PublishedPlugin,
)
from litellm.models.skill_submission import (
SkillSubmission,
SkillSubmissionStore,
SubmissionConflict,
SubmissionLimitExceeded,
SubmissionReviewed,
)
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.repositories.plugin_upload_repository import PluginUploadRepository
from litellm.repositories.skill_submission_repository import SkillSubmissionRepository
from tests.integration._support.client import eventually
from tests.integration._support.database import read_rows, write_rows
@dataclass(frozen=True, slots=True)
class PendingUpdate:
publications: PluginUploadStore
submissions: SkillSubmissionStore
original: PluginPublication
proposal: PluginPublication
base: PublishedPlugin
submission: SkillSubmission
def _publication(name: str, description: str, base_url: str = "https://skills.example.test") -> PluginPublication:
buffer: Final = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr("SKILL.md", f"---\nname: {name}\ndescription: {description}\n---\n{description}\n")
content: Final = buffer.getvalue()
digest: Final = hashlib.sha256(content).hexdigest()
return PluginPublication(
name=name,
version=None,
description=description,
manifest={
"name": name,
"description": description,
"extension": {"retained": True},
"source": {
"source": "archive",
"url": f"{base_url}/claude-code/plugins/{name}/archive/{digest}.zip",
"sha256": digest,
},
},
content=content,
created_by=f"{name}-owner",
)
def _published_rows(name: str) -> Sequence[Mapping[str, JsonValue]]:
return read_rows(
"""SELECT 'catalog' AS storage, to_jsonb(p) AS value
FROM "LiteLLM_ClaudeCodePluginTable" p WHERE name=%s
UNION ALL
SELECT 'archive', to_jsonb(a) FROM "LiteLLM_ClaudeCodePluginUpload" a WHERE plugin_name=%s
UNION ALL
SELECT 'history', to_jsonb(v) FROM "LiteLLM_ClaudeCodePluginVersion" v
JOIN "LiteLLM_ClaudeCodePluginTable" p ON p.id=v.plugin_id WHERE p.name=%s
ORDER BY storage, value""",
(name, name, name),
)
@pytest_asyncio.fixture(loop_scope="function")
async def pending_update() -> AsyncIterator[PendingUpdate]:
name: Final = f"integration-submission-{uuid.uuid4().hex}"
client: Final = PrismaClient(os.environ["DATABASE_URL"], ProxyLogging(UserApiKeyCache()))
await client.connect()
try:
publications: Final = PluginUploadRepository(client)
submissions: Final = SkillSubmissionRepository(client)
original: Final = _publication(name, "Published instructions")
base: Final = await publications.publish(original)
assert isinstance(base, PublishedPlugin)
proposal: Final = _publication(name, "Proposed instructions", "https://member.example.test")
submission: Final = await submissions.submit(
proposal, f"{name}-owner", PluginPublicationBase(base.id, base.updated_at)
)
assert isinstance(submission, SkillSubmission)
yield PendingUpdate(publications, submissions, original, proposal, base, submission)
finally:
try:
write_rows('DELETE FROM "LiteLLM_SkillSubmission" WHERE plugin_name=%s', (name,))
write_rows('DELETE FROM "LiteLLM_ClaudeCodePluginTable" WHERE name=%s', (name,))
finally:
await client.disconnect()
async def test_approval_commits_version_archive_history_and_review(pending_update: PendingUpdate) -> None:
case: Final = pending_update
before: Final = await case.publications.get_metadata(case.original.name)
assert before is not None
assert await case.publications.get_content(before) == case.original.content
assert case.submission.proposal.version is None
reviewed: Final = await case.submissions.review(
case.submission.id, "integration-reviewer", True, None, publication_base_url="https://skills.example.test"
)
assert isinstance(reviewed, SubmissionReviewed)
assert reviewed.plugin is not None and reviewed.plugin.version == "1.0.1"
stored: Final = await case.submissions.get(case.submission.id, None)
assert stored is not None
assert (stored.status, stored.reviewed_by, stored.rejection_reason) == (
"approved",
"integration-reviewer",
None,
)
assert stored.reviewed_at is not None
assert stored.proposal == case.submission.proposal
assert await case.submissions.get_archive(stored.id, None) == case.proposal.content
current: Final = await case.publications.get_plugin(case.original.name)
assert current is not None
published_manifest: Final = {
**case.proposal.manifest,
"source": {
**TypeAdapter(dict[str, str]).validate_python(case.submission.proposal.manifest["source"]),
"url": f"https://skills.example.test/claude-code/plugins/{case.original.name}/archive/"
f"{hashlib.sha256(case.proposal.content or b'').hexdigest()}.zip",
},
}
assert (current.id, current.version, current.description, current.manifest) == (
case.base.id,
reviewed.plugin.version,
case.proposal.description,
{**published_manifest, "version": reviewed.plugin.version},
)
metadata: Final = await case.publications.get_metadata(case.original.name)
assert metadata is not None
assert await case.publications.get_content(metadata) == case.proposal.content
history: Final = await case.publications.versions(metadata, 10, 0)
assert tuple((version.version, version.description, version.archive_available) for version in history) == (
("1.0.1", case.proposal.description, True),
("1.0.0", case.original.description, True),
)
for version, publication, manifest in zip(
history, (case.proposal, case.original), (published_manifest, case.original.manifest), strict=True
):
assert version.manifest == {**manifest, "version": version.version}
assert version.published_by == publication.created_by
assert await case.publications.version_content(metadata, version.id) == publication.content
async def test_simultaneous_approve_reject_has_one_winner(pending_update: PendingUpdate) -> None:
case: Final = pending_update
before: Final = _published_rows(case.original.name)
with psycopg.connect(os.environ["DATABASE_URL"]) as blocker:
blocker.execute('SELECT id FROM "LiteLLM_SkillSubmission" WHERE id=%s FOR UPDATE', (case.submission.id,))
blocker_pid: Final = str(blocker.info.backend_pid)
reviews: Final = (
asyncio.create_task(
case.submissions.review(
case.submission.id,
"integration-approver",
True,
None,
publication_base_url="https://skills.example.test",
)
),
asyncio.create_task(
case.submissions.review(case.submission.id, "integration-rejector", False, "Needs revision")
),
)
try:
await asyncio.to_thread(
eventually,
lambda: read_rows(
"""WITH RECURSIVE blocked AS (
SELECT %s::integer AS pid
UNION
SELECT a.pid FROM pg_stat_activity a
JOIN blocked b ON b.pid = ANY(pg_blocking_pids(a.pid))
) SELECT pid FROM blocked WHERE pid<>%s::integer""",
(blocker_pid, blocker_pid),
),
lambda rows: len(rows) == 2,
seconds=3,
)
finally:
blocker.rollback()
results: Final = await asyncio.gather(*reviews)
winners: Final = tuple(result for result in results if isinstance(result, SubmissionReviewed))
assert len(winners) == 1
assert sum(isinstance(result, SubmissionConflict) for result in results) == 1
stored: Final = await case.submissions.get(case.submission.id, None)
assert stored is not None and stored.reviewed_at is not None
winner: Final = winners[0]
assert (stored.status, stored.reviewed_by, stored.rejection_reason) == (
winner.submission.status,
winner.submission.reviewed_by,
winner.submission.rejection_reason,
)
if isinstance(results[0], SubmissionConflict):
assert (stored.status, stored.reviewed_by, stored.rejection_reason) == (
"rejected",
"integration-rejector",
"Needs revision",
)
assert _published_rows(case.original.name) == before
return
assert (stored.status, stored.reviewed_by, stored.rejection_reason) == (
"approved",
"integration-approver",
None,
)
metadata: Final = await case.publications.get_metadata(case.original.name)
assert metadata is not None
assert await case.publications.get_content(metadata) == case.proposal.content
assert tuple(version.version for version in await case.publications.versions(metadata, 10, 0)) == (
"1.0.1",
"1.0.0",
)
async def test_publication_failure_rolls_back_and_leaves_submission_pending(pending_update: PendingUpdate) -> None:
case: Final = pending_update
collision_id: Final = str(uuid.uuid4())
write_rows(
"""INSERT INTO "LiteLLM_ClaudeCodePluginVersion" (id, plugin_id, version)
VALUES (%s, %s, '1.0.1')""",
(collision_id, case.base.id),
)
before: Final = _published_rows(case.original.name)
failed: Final = await case.submissions.review(
case.submission.id, "integration-reviewer", True, None, publication_base_url="https://skills.example.test"
)
assert isinstance(failed, SubmissionConflict)
assert _published_rows(case.original.name) == before
assert await case.submissions.get(case.submission.id, None) == case.submission
assert await case.submissions.get_archive(case.submission.id, None) == case.proposal.content
write_rows('DELETE FROM "LiteLLM_ClaudeCodePluginVersion" WHERE id=%s', (collision_id,))
retried: Final = await case.submissions.review(
case.submission.id, "integration-reviewer", True, None, publication_base_url="https://skills.example.test"
)
assert isinstance(retried, SubmissionReviewed)
assert retried.plugin is not None and retried.plugin.version == "1.0.1"
stored: Final = await case.submissions.get(case.submission.id, None)
assert stored is not None and stored.status == "approved"
@pytest.mark.parametrize("change", ("update", "recreate"))
async def test_stale_target_cannot_publish(
pending_update: PendingUpdate, change: Literal["update", "recreate"]
) -> None:
case: Final = pending_update
intervening: Final = _publication(case.original.name, "Intervening publication")
if change == "recreate":
write_rows('DELETE FROM "LiteLLM_ClaudeCodePluginTable" WHERE name=%s', (case.original.name,))
replacement: Final = await case.publications.publish(
intervening,
PluginPublicationBase(case.base.id, case.base.updated_at) if change == "update" else None,
)
assert isinstance(replacement, PublishedPlugin)
before: Final = _published_rows(case.original.name)
reviewed: Final = await case.submissions.review(
case.submission.id, "integration-reviewer", True, None, publication_base_url="https://skills.example.test"
)
assert isinstance(reviewed, PluginPublicationError) and reviewed.kind == "conflict"
assert _published_rows(case.original.name) == before
assert await case.submissions.get(case.submission.id, None) == case.submission
async def test_concurrent_submissions_share_user_limit_and_review_releases_capacity(
pending_update: PendingUpdate,
) -> None:
case: Final = pending_update
owner: Final = case.submission.submitted_by
base: Final = PluginPublicationBase(case.base.id, case.base.updated_at)
for _ in range(18):
assert isinstance(await case.submissions.submit(case.proposal, owner, base), SkillSubmission)
peer: Final = PrismaClient(os.environ["DATABASE_URL"], ProxyLogging(UserApiKeyCache()))
await peer.connect()
try:
with psycopg.connect(os.environ["DATABASE_URL"]) as blocker:
blocker.execute(
"SELECT pg_advisory_xact_lock(hashtext('litellm.skill_submissions'), hashtext(%s))", (owner,)
)
blocker_pid: Final = str(blocker.info.backend_pid)
requests: Final = (
asyncio.create_task(case.submissions.submit(case.proposal, owner, base)),
asyncio.create_task(SkillSubmissionRepository(peer).submit(case.proposal, owner, base)),
)
try:
await asyncio.to_thread(
eventually,
lambda: read_rows(
"SELECT pid FROM pg_stat_activity WHERE %s::integer = ANY(pg_blocking_pids(pid))",
(blocker_pid,),
),
lambda rows: len(rows) == 2,
seconds=3,
)
finally:
blocker.rollback()
results: Final = await asyncio.gather(*requests)
finally:
await peer.disconnect()
assert sum(isinstance(result, SkillSubmission) for result in results) == 1
assert sum(isinstance(result, SubmissionLimitExceeded) for result in results) == 1
full: Final = await case.submissions.list(owner, "pending_review", 20, 0)
assert (len(full.submissions), full.has_more) == (20, False)
other: Final = await case.submissions.submit(case.proposal, f"{owner}-other", base)
assert isinstance(other, SkillSubmission)
rejected: Final = await case.submissions.review(
case.submission.id, "integration-reviewer", False, "Review complete"
)
assert isinstance(rejected, SubmissionReviewed)
assert await case.submissions.get_archive(case.submission.id, owner) == case.proposal.content
replacement: Final = await case.submissions.submit(case.proposal, owner, base)
assert isinstance(replacement, SkillSubmission)
after: Final = await case.submissions.list(owner, "pending_review", 20, 0)
assert len(after.submissions) == 20
assert all(row.id != case.submission.id and row.submitted_by == owner for row in after.submissions)
async def test_submission_pages_filter_owner_and_status_before_limit(pending_update: PendingUpdate) -> None:
case: Final = pending_update
owner: Final = case.submission.submitted_by
other: Final = f"{owner}-other"
generated: Final = await asyncio.gather(
*(
case.submissions.submit(case.proposal, identity, PluginPublicationBase(case.base.id, case.base.updated_at))
for identity in (owner, other, owner, other)
)
)
submitted: Final = tuple(result for result in generated if isinstance(result, SkillSubmission))
assert len(submitted) == 4
rejected: Final = await case.submissions.review(submitted[0].id, "integration-reviewer", False, None)
assert isinstance(rejected, SubmissionReviewed)
write_rows(
"""UPDATE "LiteLLM_SkillSubmission" SET created_at=TIMESTAMP '9999-01-01'
WHERE plugin_name=%s""",
(case.original.name,),
)
own_pending: Final = tuple(sorted((case.submission.id, submitted[2].id), reverse=True))
first: Final = await case.submissions.list(owner, "pending_review", 1, 0)
second: Final = await case.submissions.list(owner, "pending_review", 1, 1)
empty: Final = await case.submissions.list(owner, "pending_review", 1, 2)
assert (tuple(row.id for row in first.submissions), first.has_more) == (own_pending[:1], True)
assert (tuple(row.id for row in second.submissions), second.has_more) == (own_pending[1:], False)
assert (empty.submissions, empty.has_more) == ((), False)
reviewed: Final = await case.submissions.list(owner, "rejected", 20, 0)
assert (tuple(row.id for row in reviewed.submissions), reviewed.has_more) == ((submitted[0].id,), False)
own_all: Final = await case.submissions.list(owner, None, 20, 0)
assert tuple(row.id for row in own_all.submissions) == tuple(sorted((*own_pending, submitted[0].id), reverse=True))
other_page: Final = await case.submissions.list(other, None, 20, 0)
assert tuple(row.id for row in other_page.submissions) == tuple(
sorted((submitted[1].id, submitted[3].id), reverse=True)
)
global_pending: Final = await case.submissions.list(None, "pending_review", 3, 0)
all_pending: Final = tuple(sorted((*own_pending, submitted[1].id, submitted[3].id), reverse=True))
assert (tuple(row.id for row in global_pending.submissions), global_pending.has_more) == (all_pending[:3], True)

View file

@ -23,7 +23,7 @@ import litellm
from litellm.caching.caching import DualCache
from litellm.proxy import proxy_server
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth
from litellm.proxy.deps.repositories import get_plugin_upload_repository
from litellm.proxy.deps.repositories import get_plugin_upload_repository, get_skill_submission_repository
from litellm.proxy.discovery_endpoints.agent_skills_archive import (
MAX_ARCHIVE_ENTRIES,
MAX_ARCHIVE_UNPACKED_BYTES,
@ -32,7 +32,9 @@ from litellm.proxy.discovery_endpoints.skill_hub_endpoints import import_http_cl
from litellm.proxy.hooks.parallel_request_limiter_v3 import PROXY_MaxParallelRequestsHandler_v3
from litellm.proxy.utils import InternalUsageCache
from litellm.repositories.plugin_upload_repository import PluginUploadRepository
from litellm.repositories.skill_submission_repository import SkillSubmissionRepository
from tests._master_key import MASTER_KEY
from tests.unit.proxy.skill_submission_rows import SubmissionRows
NAME: Final = "portable-notes"
MANIFEST: Final = (
@ -48,6 +50,9 @@ HUB: Final = "/skill-hub/skills"
INDEX: Final = "/.well-known/agent-skills/index.json"
GRANTED_KEY: Final = "sk-hub-granted"
PLAIN_KEY: Final = "sk-hub-plain"
MEMBER_KEY: Final = "sk-hub-member"
OTHER_KEY: Final = "sk-hub-other"
VIEWER_KEY: Final = "sk-hub-viewer"
def _zip(files: Mapping[str, bytes], prefix: str = "") -> bytes:
@ -105,6 +110,8 @@ async def hub(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[HubClient]:
archives.upsert = AsyncMock()
archives.delete_many = AsyncMock()
prisma.db.tx.return_value.__aenter__.return_value = prisma.db
submissions: Final = SubmissionRows()
prisma.db.litellm_skillsubmission = submissions
prisma.db.query_raw = AsyncMock(return_value=[])
prisma.db.litellm_claudecodepluginversion.create = AsyncMock()
prisma.db.litellm_claudecodepluginversion.count = AsyncMock(return_value=0)
@ -129,12 +136,22 @@ async def hub(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[HubClient]:
object_permission=LiteLLM_ObjectPermissionTable(object_permission_id=key, skills=skills),
),
)
for key, role in (
(MEMBER_KEY, proxy_server.LitellmUserRoles.INTERNAL_USER),
(OTHER_KEY, proxy_server.LitellmUserRoles.INTERNAL_USER),
(VIEWER_KEY, proxy_server.LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY),
):
proxy_server.user_api_key_cache.set_cache(
key=proxy_server.hash_token(key),
value=UserAPIKeyAuth(token=proxy_server.hash_token(key), user_role=role, user_id=key),
)
async with (
httpx.AsyncClient(transport=httpx.MockTransport(upstream), trust_env=False) as source_client,
httpx.AsyncClient(transport=httpx.ASGITransport(app=proxy_server.app), base_url="http://test") as client,
):
monkeypatch.setattr(proxy_server.app, "dependency_overrides", {
get_plugin_upload_repository: lambda: PluginUploadRepository(prisma),
get_skill_submission_repository: lambda: SkillSubmissionRepository(prisma),
import_http_client: lambda: source_client,
})
yield HubClient(client, plugins, archives, upstream, clock, prisma.db)
@ -402,24 +419,36 @@ async def test_stored_content_survives_duplicate_upload_and_is_hidden_after_sour
assert (await hub.client.get(INDEX)).json()["skills"] == []
@pytest.mark.parametrize("user_id, admitted", (("publisher", 10), (None, 20)))
@pytest.mark.parametrize("user_id, role, admitted", (
("publisher", proxy_server.LitellmUserRoles.PROXY_ADMIN, 10),
(None, proxy_server.LitellmUserRoles.PROXY_ADMIN, 20),
("publisher", proxy_server.LitellmUserRoles.INTERNAL_USER, 10),
), ids=("admin-user", "admin-key", "member-user"))
async def test_publication_limit_is_shared_by_user_and_route_and_resets(
hub: HubClient, monkeypatch: pytest.MonkeyPatch, user_id: str | None, admitted: int,
hub: HubClient, monkeypatch: pytest.MonkeyPatch, user_id: str | None, role: str, admitted: int,
) -> None:
for key in (PLAIN_KEY, GRANTED_KEY):
proxy_server.user_api_key_cache.set_cache(
key=proxy_server.hash_token(key),
value=UserAPIKeyAuth(
token=proxy_server.hash_token(key), user_id=user_id,
user_role=proxy_server.LitellmUserRoles.PROXY_ADMIN,
user_role=role,
),
)
responses: Final = await asyncio.gather(*(
_upload(hub, b"invalid", PLAIN_KEY) if index % 2 else hub.client.post(
f"{HUB}/import", headers=_headers(GRANTED_KEY), json={"url": "https://example.com/skill"},
) for index in range(20)
))
assert sum(response.status_code in (400, 422) for response in responses) == admitted
async def attempt(index: int) -> httpx.Response:
key: Final = PLAIN_KEY if index % 2 else GRANTED_KEY
if index % 3 == 0:
return await _upload(hub, b"invalid", key)
if index % 3 == 1:
return await hub.client.post(
f"{HUB}/import", headers=_headers(key), json={"url": "https://example.com/skill"},
)
return await hub.client.put(
f"{HUB}/{NAME}/upload", headers=_headers(key), files={"file": ("invalid.zip", b"invalid")},
)
responses: Final = await asyncio.gather(*(attempt(index) for index in range(20)))
assert sum(response.status_code in (400, 404, 422) for response in responses) == admitted
assert sum(response.status_code == 429 for response in responses) == 20 - admitted
assert all(response.headers["retry-after"] == "60" for response in responses if response.status_code == 429)
hub.plugins.create.assert_not_awaited()
@ -530,3 +559,128 @@ async def test_neutral_history_labels_external_sources_and_bounds_pagination(hub
assert (await hub.client.get(
f"{HUB}/{NAME}/versions?limit=101", headers=_headers(MASTER_KEY),
)).status_code == 422
@pytest.fixture
async def pending_hub(hub: HubClient) -> HubClient:
hub.database.query_raw.side_effect = hub.database.litellm_skillsubmission.query_raw
return hub
@pytest.mark.parametrize("operation,approve", (("upload", True), ("import", True), ("upload", False)),
ids=("approve-upload", "approve-import", "reject-upload"))
async def test_member_archive_stays_private_until_admin_review(
pending_hub: HubClient, operation: str, approve: bool,
) -> None:
hub: Final = pending_hub
hub.upstream.return_value = httpx.Response(200, content=_zip(FILES, "repository/"))
pending: Final = (
await _upload(hub, _zip(FILES), MEMBER_KEY, version=None) if operation == "upload"
else await hub.client.post(
f"{HUB}/import", headers=_headers(MEMBER_KEY),
json={"url": f"https://skills.sh/synthetic-author/notes-library/{NAME}"},
)
)
assert pending.status_code == 200, pending.text
receipt: Final = pending.json()
assert receipt["status"] == "pending_review" and receipt["proposal"]["version"] is None
assert receipt["proposal"]["agent_skill"]["name"] == NAME
hub.plugins.create.assert_not_awaited()
hub.database.litellm_claudecodepluginversion.create.assert_not_awaited()
path: Final = f"/skill-hub/submissions/{receipt['submission_id']}"
assert receipt["proposal"]["source"]["url"] == f"http://test{path}/archive"
own: Final = await hub.client.get(path, headers=_headers(MEMBER_KEY))
assert own.json()["current_skill"] is None
assert own.json()["submission"]["proposal"] == receipt["proposal"]
for suffix in ("", "/archive"):
assert (await hub.client.get(path + suffix, headers=_headers(OTHER_KEY))).status_code == 404
_assert_portable(await hub.client.get(path + "/archive", headers=_headers(MEMBER_KEY)))
assert (await hub.client.get(path, headers=_headers(VIEWER_KEY))).status_code == 200
denied: Final = await hub.client.put(path + "/approve", headers=_headers(VIEWER_KEY))
assert denied.status_code == 403, denied.text
assert (await hub.client.get(f"{HUB}/{NAME}/archive")).status_code == 404
if not approve:
rejected: Final = await hub.client.put(
path + "/reject", headers=_headers(MASTER_KEY), json={"reason": "Needs revision"},
)
assert rejected.status_code == 200, rejected.text
assert rejected.json()["skill"] is None and rejected.json()["action"] == "rejected"
assert rejected.json()["submission"]["rejection_reason"] == "Needs revision"
assert rejected.json()["submission"]["proposal"] == receipt["proposal"]
hub.plugins.create.assert_not_awaited()
return
approved: Final = await hub.client.put(path + "/approve", headers=_headers(MASTER_KEY))
assert approved.status_code == 200, approved.text
assert approved.json()["action"] == "approved"
assert approved.json()["skill"]["version"] == "1.0.0"
assert approved.json()["submission"]["proposal"] == receipt["proposal"]
_stored_upload(hub)
_assert_portable(await hub.client.get(approved.json()["skill"]["download_url"]))
assert (await hub.client.put(path + "/reject", headers=_headers(MASTER_KEY))).status_code == 409
listed: Final = await hub.client.get("/skill-hub/submissions?status=approved", headers=_headers(MEMBER_KEY))
assert (listed.json()["count"], listed.json()["has_more"]) == (1, False)
@pytest.mark.parametrize("replacement", (False, True), ids=("metadata", "archive"))
async def test_member_updates_preserve_manifest_and_original_editor_base(pending_hub: HubClient, replacement: bool) -> None:
hub: Final = pending_hub
assert (await _upload(hub, _zip(FILES))).status_code == 200
original: Final = _stored_upload(hub)
manifest: Final = {
**json.loads(original.manifest_json), "domain": "Tools", "namespace": "research",
"keywords": ["notes"], "extension": {"retained": True}, "author": "legacy author",
}
hub.plugins.find_unique.return_value = original.model_copy(update={"manifest_json": json.dumps(manifest)})
before: Final = hub.database.litellm_claudecodepluginversion.create.await_count
updated_files: Final = {**FILES, "references/guide.md": b"Updated supporting instructions.\n"}
expected: Final = original.updated_at.isoformat()
response: Final = (
await hub.client.put(
f"{HUB}/{NAME}/upload", headers=_headers(MEMBER_KEY),
data={"expected_updated_at": expected}, files={"file": ("new.zip", _zip(updated_files), "application/zip")},
) if replacement else await hub.client.put(
f"{HUB}/{NAME}", headers=_headers(MEMBER_KEY),
json={"description": "Reviewed description", "expected_updated_at": expected,
"domain": "Tools", "namespace": "research", "keywords": ["notes"]},
)
)
assert response.status_code == 200, response.text
path: Final = f"/skill-hub/submissions/{response.json()['submission_id']}"
detail: Final = (await hub.client.get(path, headers=_headers(MEMBER_KEY))).json()
assert detail["submission"]["base_updated_at"] == original.updated_at.isoformat().replace("+00:00", "Z")
assert detail["submission"]["proposal"]["domain"] == "Tools"
assert detail["submission"]["proposal"]["keywords"] == ["notes"]
assert detail["current_skill"]["namespace"] == "research"
assert hub.database.litellm_claudecodepluginversion.create.await_count == before
hub.plugins.update.assert_not_awaited()
approved: Final = await hub.client.put(path + "/approve", headers=_headers(MASTER_KEY))
assert approved.status_code == 200, approved.text
stored: Final = json.loads(hub.plugins.find_unique.return_value.manifest_json)
assert (stored["extension"], stored["author"]) == (manifest["extension"], manifest["author"])
assert stored["agent_skill"]["digest"] == response.json()["proposal"]["agent_skill"]["digest"]
assert approved.json()["skill"]["version"] == "2.0.1"
assert approved.json()["submission"]["proposal"]["version"] is None
wrapper: Final = hub.archives.upsert.await_args.kwargs["data"]["update"]["content"].decode()
with zipfile.ZipFile(io.BytesIO(wrapper)) as archive:
assert archive.read(f"skills/{NAME}/references/guide.md") == (
updated_files["references/guide.md"] if replacement else FILES["references/guide.md"]
)
if not replacement:
assert stored["agent_skill"] == manifest["agent_skill"]
@pytest.mark.parametrize("failure,status", (("stale", 409), ("wrong-name", 422), ("viewer", 403)),
ids=("stale", "wrong-name", "viewer"))
async def test_replacement_rejections_do_not_stage_or_change_files(pending_hub: HubClient, failure: str, status: int) -> None:
hub: Final = pending_hub
assert (await _upload(hub, _zip(FILES))).status_code == 200
_stored_upload(hub)
response: Final = await hub.client.put(
f"{HUB}/{NAME}/upload", headers=_headers(VIEWER_KEY if failure == "viewer" else MEMBER_KEY),
data={"expected_updated_at": "2020-01-01T00:00:00Z"} if failure == "stale" else {},
files={"file": ("new.zip", _zip({"SKILL.md": MANIFEST.replace(NAME.encode(), b"different-name")}), "application/zip")},
)
assert response.status_code == status, response.text
assert hub.database.litellm_skillsubmission.rows == ()
hub.plugins.update.assert_not_awaited()
hub.archives.upsert.assert_not_awaited()

View file

@ -1,6 +1,7 @@
import asyncio
import base64
import gzip
import hashlib
import json
import math
import random
@ -59,6 +60,8 @@ def _client(max_decompressed_body_size_mb: float = CAP_MB, root_path: str = "",
routes=[
Route("/v1/systemone", _echo_request, methods=["POST"]),
Route("/claude-code/plugins/upload", _echo_request, methods=["POST"]),
Route("/skill-hub/skills/upload", _echo_request, methods=["POST"]),
Route("/skill-hub/skills/{name}/upload", _echo_request, methods=["PUT"]),
Route("/plugin-proxy/{path:path}", _echo_request, methods=["POST"]),
Route("/claude_code_gateway/v1/{signal}", _echo_request, methods=["POST"]),
]
@ -248,16 +251,21 @@ def test_operator_request_size_limit_caps_the_decoded_body_when_it_is_lower(
@pytest.mark.parametrize(("coding", "compress"), [
("identity", bytes), ("gzip", gzip.compress), ("zstd", zstd.compress),
])
@pytest.mark.parametrize(("method", "path"), [
("POST", "/claude-code/plugins/upload"),
("POST", "/skill-hub/skills/upload"),
("PUT", "/skill-hub/skills/example/upload"),
])
def test_proxy_bounds_skill_uploads_before_authentication_or_body_parsing(
coding: str, compress: Callable[[bytes], bytes],
coding: str, compress: Callable[[bytes], bytes], method: str, path: str,
) -> None:
from litellm.proxy.proxy_server import app
body: Final = random.Random(0).randbytes(6 * MIB) + bytes(6 * MIB)
encoded: Final = compress(body)
response: Final = TestClient(app).post(
"/claude-code/plugins/upload",
response: Final = TestClient(app).request(
method, path,
content=encoded,
headers={"content-encoding": coding, "content-type": "multipart/form-data; boundary=upload"},
)
@ -269,6 +277,32 @@ def test_proxy_bounds_skill_uploads_before_authentication_or_body_parsing(
)
@pytest.mark.parametrize("root_path", ("", "/gateway"))
@pytest.mark.parametrize(("coding", "compress"), [("gzip", gzip.compress), ("zstd", zstd.compress)])
def test_replacement_upload_route_limit_caps_decoded_bytes(
root_path: str, coding: str, compress: Callable[[bytes], bytes],
) -> None:
limit: Final = 1 / 32
client: Final = _client(
root_path=root_path,
route_max_request_size_mb={("PUT", "/skill-hub/skills/{name}/upload"): limit},
)
body: Final = _low_entropy_text(int(limit * MIB))
path: Final = f"{root_path}/skill-hub/skills/example/upload"
headers: Final = {"content-encoding": coding, "content-type": "application/zip"}
within: Final = client.put(path, content=compress(body), headers=headers)
over: Final = client.put(path, content=compress(body + b"x"), headers=headers)
assert within.status_code == 200
decoded: Final = bytes.fromhex(within.json()["body_hex"])
actual: Final = (len(decoded), hashlib.sha256(decoded).hexdigest())
expected: Final = (len(body), hashlib.sha256(body).hexdigest())
assert actual == expected
assert over.status_code == 413
assert over.json() == _decoded_too_large(limit)
@pytest.mark.parametrize(("signature", "content_encoding"), [(GZIP_MAGIC, "gzip"), (ZSTD_FRAME_MAGIC, "zstd")])
def test_compressed_body_over_the_cap_is_rejected_before_decoding(signature: bytes, content_encoding: str):
over_cap_on_the_wire: Final = signature + bytes(int(CAP_MB * MIB))

View file

@ -12,15 +12,20 @@ from litellm.proxy.middleware.request_size_limit_middleware import (
)
@pytest.mark.parametrize(("path", "root_path", "operator_mb", "route_mb"), [
("/chat/completions", "", 1, None),
("/claude-code/plugins/upload", "", None, 1),
("/gateway/claude-code/plugins/upload", "/gateway", None, 1),
("/claude-code/plugins/upload", "", 2, 1),
("/claude-code/plugins/upload", "", 1, 2),
@pytest.mark.parametrize(("method", "path", "root_path", "operator_mb", "route_mb"), [
("POST", "/chat/completions", "", 1, None),
("POST", "/claude-code/plugins/upload", "", None, 1),
("POST", "/skill-hub/skills/upload", "", None, 1),
("POST", "/gateway/claude-code/plugins/upload", "/gateway", None, 1),
("POST", "/claude-code/plugins/upload", "", 2, 1),
("POST", "/claude-code/plugins/upload", "", 1, 2),
("PUT", "/skill-hub/skills/example/upload", "", None, 1),
("PUT", "/gateway/skill-hub/skills/example/upload", "/gateway", None, 1),
("PUT", "/skill-hub/skills/example/upload", "/gateway", None, 1),
("PUT", "/skill-hub/skills/example/upload", "", 1, 2),
])
def test_request_size_limit_middleware_rejects_content_length_before_body_read(
path: str, root_path: str, operator_mb: int | None, route_mb: int | None,
method: str, path: str, root_path: str, operator_mb: int | None, route_mb: int | None,
) -> None:
downstream_called = False
@ -35,13 +40,17 @@ def test_request_size_limit_middleware_rejects_content_length_before_body_read(
app,
get_max_request_size_mb=lambda: operator_mb,
is_request_size_limit_enabled=lambda: operator_mb is not None,
route_max_request_size_mb={("POST", "/claude-code/plugins/upload"): route_mb} if route_mb else None,
route_max_request_size_mb={
("POST", "/claude-code/plugins/upload"): route_mb,
("POST", "/skill-hub/skills/upload"): route_mb,
("PUT", "/skill-hub/skills/{name}/upload"): route_mb,
} if route_mb else None,
),
root_path=root_path,
)
response = client.post(
path,
response = client.request(
method, path,
content=b"x" * (1024 * 1024 + 1),
headers={"content-type": "application/json"},
)
@ -53,14 +62,19 @@ def test_request_size_limit_middleware_rejects_content_length_before_body_read(
@pytest.mark.parametrize(("enabled", "operator_mb"), [(True, 0), (False, 1)])
@pytest.mark.parametrize(("method", "path"), [
("POST", "/chat/completions"),
("PUT", "/claude-code/plugins/upload"),
("POST", "/claude-code/plugins/upload-other"),
("POST", "/claude-code/plugins"),
@pytest.mark.parametrize(("method", "path", "root_path"), [
("POST", "/chat/completions", ""),
("PUT", "/claude-code/plugins/upload", ""),
("POST", "/claude-code/plugins/upload-other", ""),
("POST", "/claude-code/plugins", ""),
("POST", "/skill-hub/skills/example/upload", ""),
("PUT", "/skill-hub/skills/example/upload-other", ""),
("PUT", "/skill-hub/skills/example/nested/upload", ""),
("PUT", "/skill-hub/skills//upload", ""),
("PUT", "/gateway-other/skill-hub/skills/example/upload", "/gateway"),
])
def test_request_size_limit_middleware_zero_or_disabled_global_limit_preserves_other_routes(
enabled: bool, operator_mb: int, method: str, path: str,
enabled: bool, operator_mb: int, method: str, path: str, root_path: str,
) -> None:
downstream_called = False
@ -75,8 +89,12 @@ def test_request_size_limit_middleware_zero_or_disabled_global_limit_preserves_o
app,
get_max_request_size_mb=lambda: operator_mb,
is_request_size_limit_enabled=lambda: enabled,
route_max_request_size_mb={("POST", "/claude-code/plugins/upload"): 1},
)
route_max_request_size_mb={
("POST", "/claude-code/plugins/upload"): 1,
("PUT", "/skill-hub/skills/{name}/upload"): 1,
},
),
root_path=root_path,
)
response = client.request(
@ -91,7 +109,15 @@ def test_request_size_limit_middleware_zero_or_disabled_global_limit_preserves_o
@pytest.mark.asyncio
async def test_request_size_limit_middleware_rejects_streamed_body_without_content_length():
@pytest.mark.parametrize(("method", "path", "root_path", "operator_mb"), [
("POST", "/chat/completions", "", 1),
("POST", "/skill-hub/skills/upload", "", None),
("PUT", "/skill-hub/skills/example/upload", "", None),
("PUT", "/gateway/skill-hub/skills/example/upload", "/gateway", None),
])
async def test_request_size_limit_middleware_rejects_streamed_body_without_content_length(
method: str, path: str, root_path: str, operator_mb: int | None,
) -> None:
received_body_bytes = 0
async def app(scope, receive, send):
@ -109,8 +135,11 @@ async def test_request_size_limit_middleware_rejects_streamed_body_without_conte
middleware = RequestSizeLimitMiddleware(
app,
get_max_request_size_mb=lambda: 1,
is_request_size_limit_enabled=lambda: True,
get_max_request_size_mb=lambda: operator_mb,
is_request_size_limit_enabled=lambda: operator_mb is not None,
route_max_request_size_mb={
("POST", "/skill-hub/skills/upload"): 1, ("PUT", "/skill-hub/skills/{name}/upload"): 1,
},
)
sent_messages: list[Message] = []
receive_messages: list[Message] = [
@ -135,8 +164,9 @@ async def test_request_size_limit_middleware_rejects_streamed_body_without_conte
await middleware(
{
"type": "http",
"method": "POST",
"path": "/chat/completions",
"method": method,
"path": path,
"root_path": root_path,
"headers": [(b"content-type", b"application/json")],
},
receive,
@ -180,9 +210,14 @@ def test_an_oversized_messages_request_gets_the_anthropic_request_too_large_erro
@pytest.mark.asyncio
@pytest.mark.parametrize("path", ("/claude-code/plugins/upload", "/chat/completions"))
@pytest.mark.parametrize(("method", "path", "route"), [
("POST", "/claude-code/plugins/upload", "/claude-code/plugins/upload"),
("POST", "/skill-hub/skills/upload", "/skill-hub/skills/upload"),
("POST", "/chat/completions", "/chat/completions"),
("PUT", "/skill-hub/skills/example/upload", "/skill-hub/skills/{name}/upload"),
])
async def test_chunked_upload_overflow_stops_fastapi_and_auth_multipart_parsing(
path: str,
method: str, path: str, route: str,
) -> None:
from litellm.proxy.proxy_server import app
@ -190,7 +225,7 @@ async def test_chunked_upload_overflow_stops_fastapi_and_auth_multipart_parsing(
app,
get_max_request_size_mb=lambda: None,
is_request_size_limit_enabled=lambda: False,
route_max_request_size_mb={("POST", path): 1 / 1024},
route_max_request_size_mb={(method, route): 1 / 1024},
)
async def chunks() -> AsyncIterator[bytes]:
@ -199,8 +234,8 @@ async def test_chunked_upload_overflow_stops_fastapi_and_auth_multipart_parsing(
pytest.fail("The server consumed more of the upload after its byte limit")
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=limited), base_url="http://test") as client:
response: Final = await client.post(
path,
response: Final = await client.request(
method, path,
content=chunks(),
headers={"content-type": "multipart/form-data; boundary=skill"},
)

View file

@ -0,0 +1,74 @@
from collections.abc import Mapping
from datetime import datetime, timezone
from typing import Final
from prisma import models as prisma_models
class SubmissionRows:
def __init__(self) -> None:
self.rows: tuple[prisma_models.LiteLLM_SkillSubmission, ...] = ()
async def create(self, data: Mapping[str, object]) -> prisma_models.LiteLLM_SkillSubmission:
row: Final = prisma_models.LiteLLM_SkillSubmission.model_validate(
{
"id": f"submission-{len(self.rows)}",
"status": "pending_review",
"created_at": datetime(2026, 1, 1, tzinfo=timezone.utc),
**data,
}
)
self.rows = (*self.rows, row)
return row
async def find_unique(self, where: Mapping[str, object]) -> prisma_models.LiteLLM_SkillSubmission | None:
return next((row for row in self.rows if row.id == where["id"]), None)
async def find_first(self, where: Mapping[str, object]) -> prisma_models.LiteLLM_SkillSubmission | None:
row: Final = await self.find_unique(where)
return None if row is None or where.get("submitted_by", row.submitted_by) != row.submitted_by else row
async def update(
self,
where: Mapping[str, object],
data: Mapping[str, object],
) -> prisma_models.LiteLLM_SkillSubmission | None:
current: Final = await self.find_unique(where)
if current is None:
return None
updated: Final = prisma_models.LiteLLM_SkillSubmission.model_validate({**current.model_dump(), **data})
self.rows = tuple(updated if row.id == current.id else row for row in self.rows)
return updated
async def query_raw(self, query: str, *args: object) -> tuple[Mapping[str, object], ...]:
if 'FROM "LiteLLM_SkillSubmission"' not in query or "FOR UPDATE" in query:
return ()
if "COUNT(*) AS count" in query:
pending: Final = tuple(
row for row in self.rows if row.submitted_by == args[0] and row.status == "pending_review"
)
return (
{
"count": len(pending),
"storage_bytes": sum(
len(row.proposal_json.encode("utf-8"))
+ (0 if row.archive_content is None else len(row.archive_content.decode()))
for row in pending
),
},
)
records: Final = (
tuple(row for row in self.rows if row.id == args[0] and (args[1] is None or row.submitted_by == args[1]))
if "WHERE id = $1" in query
else tuple(row for row in self.rows if args[0] is None or row.submitted_by == args[0])
)
if "WHERE id = $1" in query:
return tuple({**row.model_dump(), "has_archive": row.archive_content is not None} for row in records)
limit, offset = args[2:]
assert isinstance(limit, int) and isinstance(offset, int)
filtered: Final = tuple(row for row in records if args[1] is None or row.status == args[1])
ordered: Final = sorted(filtered, key=lambda row: (row.created_at, row.id), reverse=True)
return tuple(
{**row.model_dump(), "has_archive": row.archive_content is not None}
for row in ordered[offset : offset + limit]
)

View file

@ -1037,11 +1037,6 @@
"count": 1
}
},
"src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/skills/_components/add_plugin_form.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -1745,7 +1740,7 @@
"count": 5
},
"no-restricted-syntax": {
"count": 133
"count": 132
},
"prefer-const": {
"count": 26

View file

@ -0,0 +1,632 @@
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import { setGlobalLitellmHeaderName } from "@/components/networking";
import type { SkillHubSkill, SkillSubmission } from "@/components/claude_code_plugins/types";
import { toast } from "@/lib/toast";
import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel";
const published: SkillHubSkill = {
id: "published-skill",
name: "shared-skill",
source: {
source: "archive",
url: "http://localhost:4000/skill-hub/skills/shared-skill/archive/approved.zip",
sha256: "a".repeat(64),
ref: "approved",
},
version: "2.3.4",
description: "Approved instructions",
keywords: ["sales, finance", "review"],
domain: "Engineering",
namespace: "workflows",
visibility: "public",
download_url: "/skill-hub/skills/shared-skill/archive",
content_url: "/skill-hub/skills/shared-skill/content",
updated_at: "2026-10-20T00:00:00Z",
};
const pending: SkillSubmission = {
id: "submission-1",
skill_name: published.name,
operation: "update",
status: "pending_review",
proposal: {
name: published.name,
agent_skill: {
name: published.name,
description: "Portable instructions",
digest: "sha256:reviewed",
origin: "SKILL.md",
},
source: { source: "github", repo: "example/skills", ref: "proposed-ref" },
version: null,
description: "Proposed instructions",
keywords: published.keywords,
domain: published.domain,
namespace: published.namespace,
},
has_archive: true,
submitted_by: "member@example.org",
created_at: "2026-10-21T00:00:00Z",
base_skill_id: published.id,
base_updated_at: published.updated_at ?? null,
reviewed_by: null,
reviewed_at: null,
rejection_reason: null,
};
const fetchMock = vi.fn<typeof fetch>();
const queryDefaults = testQueryClient.getDefaultOptions();
let catalog: SkillHubSkill[];
let submissions: SkillSubmission[];
let pageRequests: { limit: number; offset: number }[];
let writes: { path: string; method: string; body: RequestInit["body"] }[];
let reviewFailure: string | null;
let directPublication: boolean;
const respondToWrite = (path: string, body: RequestInit["body"]) => {
if (path.endsWith("/approve") || path.endsWith("/reject")) {
if (reviewFailure) return Response.json({ detail: { error: reviewFailure } }, { status: 409 });
const approved = path.endsWith("/approve");
const reason = typeof body === "string" ? JSON.parse(body).reason : null;
submissions = submissions.map((submission) =>
submission.id === pending.id
? { ...submission, status: approved ? "approved" : "rejected", rejection_reason: reason }
: submission,
);
if (approved) catalog = [{ ...published, ...pending.proposal, version: "2.3.5" }];
const result = {
status: "success",
action: approved ? "approved" : "rejected",
submission: submissions[0],
skill: approved ? catalog[0] : null,
};
return Response.json(result);
}
if (directPublication) {
catalog = [{ ...published, version: "3.0.0", description: "Admin-published instructions" }];
return Response.json(catalog[0]);
}
submissions = [pending];
const result = {
status: "pending_review",
action: "submitted",
submission_id: pending.id,
proposal: pending.proposal,
};
return Response.json(result);
};
const respondToSubmissionsPage = (url: URL) => {
const limit = Number(url.searchParams.get("limit") ?? "20");
const offset = Number(url.searchParams.get("offset") ?? "0");
pageRequests.push({ limit, offset });
const page = submissions.slice(offset, offset + limit);
return Response.json({ submissions: page, count: page.length, has_more: offset + limit < submissions.length });
};
const makeSubmissions = (count: number): SkillSubmission[] =>
Array.from({ length: count }, (_, index) => ({
...pending,
id: `submission-${index + 1}`,
skill_name: `skill-${index + 1}`,
proposal: { ...pending.proposal, description: `Instructions ${index + 1}` },
}));
beforeEach(() => {
testQueryClient.clear();
vi.clearAllMocks();
catalog = [published];
submissions = [pending];
pageRequests = [];
writes = [];
reviewFailure = null;
directPublication = false;
fetchMock.mockReset().mockImplementation(async (input, options) => {
const request = input instanceof Request ? input : null;
const url = new URL(request?.url ?? String(input), "http://localhost");
const path = url.pathname;
const method = request?.method ?? options?.method ?? "GET";
if (method !== "GET") {
const body = request ? await request.text() : options?.body;
writes.push({ path, method, body });
return respondToWrite(path, body);
}
if (path === "/skill-hub/skills") {
const limit = Number(url.searchParams.get("limit"));
const offset = Number(url.searchParams.get("offset"));
const skills = catalog.slice(offset, offset + limit);
return Response.json({
skills,
count: skills.length,
next_offset: offset + limit < catalog.length ? offset + limit : null,
});
}
if (path === `/skill-hub/skills/${published.name}`)
return Response.json(catalog.find((skill) => skill.name === published.name));
if (path === `/skill-hub/skills/${published.name}/versions`)
return Response.json({
versions: [
{
id: "published-version",
version: published.version,
description: "Recorded release",
manifest: { source: published.source },
published_at: published.updated_at,
published_by: "maintainer@example.org",
content_kind: "stored_archive",
download_url: "/skill-hub/skills/shared-skill/versions/published-version/archive",
},
],
next_offset: null,
});
if (path === "/skill-hub/submissions") return respondToSubmissionsPage(url);
if (path.endsWith("/archive")) return new Response("zip-bytes", { headers: { "Content-Type": "application/zip" } });
if (path === `/skill-hub/submissions/${pending.id}`)
return Response.json({ submission: submissions[0], current_skill: catalog[0] });
if (path === "/.well-known/litellm-ui-config")
return Response.json({ claude_code_marketplace_auth_required: true });
return Response.json({ detail: "Unexpected request" }, { status: 404 });
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
testQueryClient.setDefaultOptions(queryDefaults);
setGlobalLitellmHeaderName();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const renderSubmissions = (accessToken = "admin-token") =>
renderWithProviders(<ClaudeCodePluginsPanel accessToken={accessToken} userRole="Admin" />, {
searchParams: "tab=submissions",
});
const openEdit = async () => {
fireEvent.click(await screen.findByText(published.name));
fireEvent.click(await screen.findByRole("button", { name: "Edit Skill" }));
return screen.getByRole("dialog");
};
it("paginates the neutral catalog and refreshes detail for a recreated skill", async () => {
catalog = [
published,
...Array.from({ length: 50 }, (_, index) => ({
...published,
id: `skill-${index + 1}`,
name: `catalog-${index + 1}`,
})),
];
renderWithProviders(<ClaudeCodePluginsPanel accessToken="member-token" userRole="Internal User" />);
fireEvent.click(await screen.findByText(published.name));
expect(await screen.findByRole("button", { name: "Edit Skill" })).toBeInTheDocument();
fireEvent.click(screen.getByText("Skills"));
catalog = [
{ ...published, id: "filler", name: "catalog-0" },
...catalog.slice(1),
{ ...published, id: "recreated", description: "Recreated instructions" },
];
expect(await screen.findByText("catalog-49")).toBeInTheDocument();
expect(screen.queryByText("catalog-50")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(await screen.findByText("catalog-50")).toBeInTheDocument();
expect(screen.queryByText("catalog-49")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
fireEvent.click(screen.getByText(published.name));
expect(await screen.findByRole("button", { name: "Edit Skill" })).toBeInTheDocument();
expect(screen.getByText("Recreated instructions")).toBeInTheDocument();
fireEvent.click(screen.getByText("Skills"));
fireEvent.click(screen.getByRole("button", { name: "Previous" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled());
});
it.each(["descriptor", "archive"])("requires a portable %s for a submitted ZIP preview", async (missing) => {
submissions = [
{
...pending,
has_archive: missing !== "archive",
proposal: { ...pending.proposal, agent_skill: missing === "descriptor" ? null : pending.proposal.agent_skill },
},
];
renderSubmissions();
fireEvent.click(await screen.findByRole("button", { name: "Review" }));
expect(await screen.findByText("Currently published")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Download submitted ZIP" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Approve and publish" })).toBeEnabled();
});
it("paginates more than 20 submissions without showing the prior page while the next request loads or fails", async () => {
submissions = makeSubmissions(21);
const defaults = { ...queryDefaults, queries: { ...queryDefaults.queries, staleTime: 0 } };
testQueryClient.setDefaultOptions(defaults);
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<ClaudeCodePluginsPanel accessToken="member-token" userRole="Internal User" />, {
searchParams: "tab=submissions",
onUrlUpdate,
});
expect(await screen.findByText("Instructions 20")).toBeInTheDocument();
expect(screen.queryByText("Instructions 21")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled();
const fetchStub = fetchMock.getMockImplementation()!;
const response = Promise.withResolvers<Response>();
fetchMock.mockImplementationOnce(async () => response.promise);
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(await screen.findByText("Page 2")).toBeInTheDocument();
expect(screen.getByText("Loading submissions...")).toBeInTheDocument();
expect(screen.queryByText("Instructions 1")).not.toBeInTheDocument();
expect(screen.queryByText("Instructions 20")).not.toBeInTheDocument();
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("submissions_page")).toBe("2");
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("tab")).toBe("submissions");
await act(async () => response.resolve(Response.json({ detail: "Unavailable" }, { status: 500 })));
expect(await screen.findByRole("alert")).toHaveTextContent("Could not refresh submissions");
expect(screen.queryByText("Instructions 20")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
fetchMock.mockImplementation(fetchStub);
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText("Instructions 21")).toBeInTheDocument();
expect(screen.queryByText("Instructions 20")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Previous" }));
expect(await screen.findByText("Instructions 1")).toBeInTheDocument();
expect(screen.queryByText("Instructions 21")).not.toBeInTheDocument();
await waitFor(() => expect(pageRequests.at(-1)).toEqual({ limit: 20, offset: 0 }));
expect(pageRequests).toEqual([
{ limit: 20, offset: 0 },
{ limit: 20, offset: 20 },
{ limit: 20, offset: 0 },
]);
});
it("keeps differently sized deep-linked pages in separate caches", async () => {
submissions = makeSubmissions(25);
const first = renderWithProviders(<ClaudeCodePluginsPanel accessToken="member-token" userRole="Internal User" />, {
searchParams: "tab=submissions&submissions_page=2",
});
expect(await screen.findByText("Instructions 21")).toBeInTheDocument();
first.unmount();
renderWithProviders(<ClaudeCodePluginsPanel accessToken="member-token" userRole="Internal User" />, {
searchParams: "tab=submissions&submissions_page=2&submissions_page_size=5",
});
expect(await screen.findByText("Instructions 6")).toBeInTheDocument();
expect(screen.queryByText("Instructions 21")).not.toBeInTheDocument();
expect(pageRequests).toEqual([
{ limit: 20, offset: 20 },
{ limit: 5, offset: 5 },
]);
});
it("refreshes inactive submission pages after a review", async () => {
submissions = makeSubmissions(21);
renderSubmissions();
await screen.findByText("Instructions 1");
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(await screen.findByText("Instructions 21")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Previous" }));
await screen.findByText("Instructions 1");
submissions = submissions.map((submission) =>
submission.id === "submission-21"
? { ...submission, status: "rejected", rejection_reason: "Reviewed by another admin" }
: submission,
);
fireEvent.click(screen.getAllByRole("button", { name: "Review" })[0]);
fireEvent.click(await screen.findByRole("button", { name: "Approve and publish" }));
await screen.findByText("Approved");
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(await screen.findByText("Rejection reason: Reviewed by another admin")).toBeInTheDocument();
expect(screen.getByText("Rejected")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Review" })).not.toBeInTheDocument();
});
it("allows a member submission, explains approval and refreshes their pending list", async () => {
submissions = [];
renderWithProviders(<ClaudeCodePluginsPanel accessToken="member-token" userRole="Internal User" />);
await screen.findByText("Approved instructions");
fireEvent.click(screen.getByRole("tab", { name: "My submissions" }));
await screen.findByText("No submissions yet");
fireEvent.click(screen.getByRole("tab", { name: "Published skills" }));
await waitFor(() =>
expect(screen.getByRole("tab", { name: "Published skills" })).toHaveAttribute("aria-selected", "true"),
);
fireEvent.click(screen.getByRole("button", { name: "+ Add Skill" }));
fireEvent.click(screen.getByRole("radio", { name: "Import URL" }));
fireEvent.change(screen.getByRole("textbox", { name: "Source URL" }), {
target: { value: "https://github.com/example/new-skill" },
});
fireEvent.click(screen.getByRole("button", { name: "Submit for approval" }));
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith("Submitted for admin approval. The published skill is unchanged"),
);
expect(await screen.findByText("Approved instructions")).toBeInTheDocument();
expect(screen.queryByText("Proposed instructions")).not.toBeInTheDocument();
expect(writes[0]).toMatchObject({
path: "/skill-hub/skills/import",
method: "POST",
});
fireEvent.click(screen.getByRole("tab", { name: "My submissions" }));
expect(await screen.findByText("Pending review")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "View details" }));
expect(await screen.findByText("Awaiting admin approval. The published skill remains unchanged")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Approve and publish" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
});
it("retries failed detail loading before enabling edits", async () => {
const fetchStub = fetchMock.getMockImplementation()!;
fetchMock.mockImplementation((input, options) =>
String(input).endsWith(`/skill-hub/skills/${published.name}`)
? Promise.resolve(Response.json({ detail: "Unavailable" }, { status: 500 }))
: fetchStub(input, options),
);
renderWithProviders(<ClaudeCodePluginsPanel accessToken="member-token" userRole="Internal User" />);
fireEvent.click(await screen.findByText(published.name));
expect(await screen.findByRole("alert")).toHaveTextContent("Retry before editing");
expect(screen.queryByRole("button", { name: "Edit Skill" })).not.toBeInTheDocument();
fetchMock.mockImplementation(fetchStub);
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByRole("button", { name: "Edit Skill" })).toBeInTheDocument();
});
it("edits the loaded detail with automatic version intent and preserves the approved skill while review is pending", async () => {
const loaded = { ...published, updated_at: "2026-10-21T12:00:00Z" };
const fetchStub = fetchMock.getMockImplementation()!;
fetchMock.mockImplementation((input, options) =>
options?.method === "GET" && String(input).endsWith(`/skill-hub/skills/${published.name}`)
? Promise.resolve(Response.json(loaded))
: fetchStub(input, options),
);
renderWithProviders(<ClaudeCodePluginsPanel accessToken="member-token" userRole="Internal User" />);
fireEvent.click(await screen.findByText(published.name));
fireEvent.click(screen.getByText("Version history"));
expect(await screen.findByText("Recorded release")).toBeInTheDocument();
fireEvent.click(await screen.findByRole("button", { name: "Edit Skill" }));
const form = screen.getByRole("dialog");
expect(within(form).getByLabelText("Version override (optional)")).toHaveValue("");
expect(within(form).getByLabelText("Namespace (optional)")).toHaveValue(published.namespace);
fireEvent.change(within(form).getByLabelText("Description (Optional)"), {
target: { value: "Proposed instructions" },
});
fireEvent.click(within(form).getByRole("button", { name: "Submit for approval" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(writes[0]).toMatchObject({ path: "/skill-hub/skills/shared-skill", method: "PUT" });
const expectedMetadata = {
version: null,
expected_updated_at: loaded.updated_at,
domain: published.domain,
namespace: published.namespace,
keywords: published.keywords,
description: "Proposed instructions",
};
expect(JSON.parse(String(writes[0].body))).toMatchObject(expectedMetadata);
expect(await screen.findByText("Recorded release")).toBeInTheDocument();
expect(screen.queryByText("Proposed instructions")).not.toBeInTheDocument();
});
it("sends ZIP replacement through PUT upload with automatic version intent and a concurrency timestamp", async () => {
renderWithProviders(<ClaudeCodePluginsPanel accessToken="member-token" userRole="Internal User" />);
const form = await openEdit();
fireEvent.click(within(form).getByRole("radio", { name: "Upload ZIP" }));
const file = new File(["new instructions"], "replacement.zip", { type: "application/zip" });
fireEvent.change(within(form).getByLabelText("Skill ZIP file"), { target: { files: [file] } });
fireEvent.click(within(form).getByRole("button", { name: "Submit for approval" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(writes[0]).toMatchObject({ path: "/skill-hub/skills/shared-skill/upload", method: "PUT" });
const body = writes[0].body as FormData;
expect(body.get("file")).toBe(file);
expect(body.get("version")).toBeNull();
expect(body.get("expected_updated_at")).toBe(published.updated_at);
expect(body.get("metadata")).toBeNull();
expect(screen.getByText("Approved instructions")).toBeInTheDocument();
});
it.each(["metadata", "upload"])(
"keeps direct admin %s publication and wires an explicit version override",
async (sourceType) => {
directPublication = true;
renderWithProviders(<ClaudeCodePluginsPanel accessToken="admin-token" userRole="Admin" />);
const form = await openEdit();
fireEvent.change(within(form).getByLabelText("Version override (optional)"), { target: { value: "3.0.0" } });
fireEvent.change(within(form).getByLabelText("Description (Optional)"), {
target: { value: "Admin-published instructions" },
});
if (sourceType === "upload") {
fireEvent.click(within(form).getByRole("radio", { name: "Upload ZIP" }));
fireEvent.change(within(form).getByLabelText("Skill ZIP file"), {
target: { files: [new File(["skill"], "replacement.zip", { type: "application/zip" })] },
});
}
fireEvent.click(within(form).getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(await screen.findByText("Admin-published instructions")).toBeInTheDocument();
expect(toast.success).toHaveBeenCalledWith("Skill updated successfully");
expect(screen.queryByText("Submitted for admin approval")).not.toBeInTheDocument();
const body = writes[0].body;
if (body instanceof FormData) {
expect(body.get("version")).toBe("3.0.0");
expect(body.get("expected_updated_at")).toBe(published.updated_at);
} else {
expect(JSON.parse(String(body))).toMatchObject({ version: "3.0.0", expected_updated_at: published.updated_at });
}
},
);
it("lets an admin inspect full proposed values, download the private ZIP, approve and see refreshed published details", async () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
vi.stubGlobal(
"URL",
Object.assign(URL, { createObjectURL: vi.fn(() => "blob:submission"), revokeObjectURL: vi.fn() }),
);
setGlobalLitellmHeaderName("X-LiteLLM-Key");
renderSubmissions();
fireEvent.click(await screen.findByRole("button", { name: "Review" }));
expect(await screen.findByText("Currently published")).toBeInTheDocument();
const dialog = within(screen.getByRole("dialog"));
expect(dialog.getByText("Approved instructions")).toBeInTheDocument();
expect(dialog.getByText("Proposed instructions")).toBeInTheDocument();
expect(dialog.getByText("Next patch version on approval")).toBeInTheDocument();
expect(dialog.getByText(/proposed-ref/)).toBeInTheDocument();
expect(dialog.getByText(/sha256:reviewed/)).toBeInTheDocument();
expect(dialog.getAllByText(published.namespace!)).toHaveLength(2);
fireEvent.click(dialog.getByRole("button", { name: "Download submitted ZIP" }));
await waitFor(() => expect(click).toHaveBeenCalledOnce());
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining(`/submissions/${pending.id}/archive`),
expect.objectContaining({
headers: expect.objectContaining({ "X-LiteLLM-Key": "Bearer admin-token" }),
redirect: "error",
}),
);
fireEvent.click(dialog.getByRole("button", { name: "Approve and publish" }));
expect(await screen.findByText("Approved")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "Published skills" }));
await waitFor(() =>
expect(screen.getByRole("tab", { name: "Published skills" })).toHaveAttribute("aria-selected", "true"),
);
expect(await screen.findByRole("cell", { name: "Proposed instructions" })).toBeInTheDocument();
expect(screen.queryByText("Approved instructions")).not.toBeInTheDocument();
fireEvent.click(screen.getByText(published.name));
expect(screen.getByText("Proposed instructions")).toBeInTheDocument();
});
it("saves an admin rejection reason for the author and keeps the published metadata", async () => {
renderSubmissions();
fireEvent.click(await screen.findByRole("button", { name: "Review" }));
fireEvent.change(await screen.findByLabelText("Rejection reason (optional)"), {
target: { value: "Please include setup instructions" },
});
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(await screen.findByText("Rejection reason: Please include setup instructions")).toBeInTheDocument();
expect(screen.getByText("Rejected")).toBeInTheDocument();
expect(writes[0]).toMatchObject({
path: `/skill-hub/submissions/${pending.id}/reject`,
method: "PUT",
body: JSON.stringify({ reason: "Please include setup instructions" }),
});
fireEvent.click(screen.getByRole("tab", { name: "Published skills" }));
await waitFor(() =>
expect(screen.getByRole("tab", { name: "Published skills" })).toHaveAttribute("aria-selected", "true"),
);
expect(await screen.findByRole("cell", { name: "Approved instructions" })).toBeInTheDocument();
});
it("refreshes detail after a review conflict and removes actions when another reviewer has already acted", async () => {
renderSubmissions();
fireEvent.click(await screen.findByRole("button", { name: "Review" }));
await screen.findByRole("button", { name: "Approve and publish" });
reviewFailure = "Submission has already been reviewed";
submissions = [{ ...pending, status: "rejected", rejection_reason: "Already reviewed elsewhere" }];
fireEvent.click(screen.getByRole("button", { name: "Approve and publish" }));
expect(await screen.findByRole("alert")).toHaveTextContent(reviewFailure);
await waitFor(() => expect(screen.queryByRole("button", { name: "Approve and publish" })).not.toBeInTheDocument());
expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
expect(
within(screen.getByRole("dialog")).getByText("Rejection reason: Already reviewed elsewhere"),
).toBeInTheDocument();
});
it("keeps an effective Admin viewer read-only while allowing full submission inspection", async () => {
renderWithProviders(<ClaudeCodePluginsPanel accessToken="viewer-token" userRole="Admin" isViewOnly />);
expect(screen.getByRole("button", { name: "+ Add Skill" })).toBeDisabled();
fireEvent.click(await screen.findByText(published.name));
expect(screen.queryByRole("button", { name: "Edit Skill" })).not.toBeInTheDocument();
fireEvent.click(screen.getByText("Skills"));
fireEvent.click(screen.getByRole("tab", { name: "Submissions" }));
fireEvent.click(await screen.findByRole("button", { name: "View details" }));
expect(await screen.findByText("Currently published")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Approve and publish" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
expect(writes).toEqual([]);
});
it.each(["account", "role", "read-only"])(
"does not show cached submissions after a change of %s when refresh fails",
async (change) => {
const { rerender } = renderSubmissions("first-token");
expect(await screen.findByText("Proposed instructions")).toBeInTheDocument();
fetchMock.mockImplementation(async () => Response.json({ detail: "Unavailable" }, { status: 500 }));
await act(async () =>
rerender(
<ClaudeCodePluginsPanel
accessToken={change === "account" ? "second-token" : "first-token"}
userRole={change === "role" ? "Internal User" : "Admin"}
isViewOnly={change === "read-only"}
/>,
),
);
expect(screen.queryByText("Proposed instructions")).not.toBeInTheDocument();
expect(screen.queryByText("Submitted by member@example.org")).not.toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent("Could not refresh submissions");
},
);
it.each(["role", "read-only"])("does not reuse a published admin snapshot after a change of %s", async (change) => {
const { rerender } = renderWithProviders(<ClaudeCodePluginsPanel accessToken="same-token" userRole="Admin" />);
fireEvent.click(await screen.findByText(published.name));
expect(await screen.findByRole("button", { name: "Edit Skill" })).toBeInTheDocument();
fetchMock.mockImplementation(async () => Response.json({ detail: "Unavailable" }, { status: 500 }));
await act(async () =>
rerender(
<ClaudeCodePluginsPanel
accessToken="same-token"
userRole={change === "role" ? "Internal User" : "Admin"}
isViewOnly={change === "read-only"}
/>,
),
);
expect(screen.queryByText("Approved instructions")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Edit Skill" })).not.toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent("Could not refresh published skills");
});
it("does not reuse admin review metadata when the same account loses its role and detail refresh fails", async () => {
const { rerender } = renderSubmissions("same-token");
fireEvent.click(await screen.findByRole("button", { name: "Review" }));
expect(await screen.findByText("Currently published")).toBeInTheDocument();
expect(within(screen.getByRole("dialog")).getByText("Approved instructions")).toBeInTheDocument();
const fetchStub = fetchMock.getMockImplementation()!;
fetchMock.mockImplementation(async (input, options) => {
const path = new URL(input instanceof Request ? input.url : String(input), "http://localhost").pathname;
if (path === `/skill-hub/submissions/${pending.id}`)
return Response.json({ detail: "Unavailable" }, { status: 500 });
return fetchStub(input, options);
});
await act(async () => rerender(<ClaudeCodePluginsPanel accessToken="same-token" userRole="Internal User" />));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
fireEvent.click(await screen.findByRole("button", { name: "View details" }));
const dialog = within(screen.getByRole("dialog"));
expect(dialog.queryByText("Approved instructions")).not.toBeInTheDocument();
expect(dialog.queryByText("Proposed instructions")).not.toBeInTheDocument();
expect(dialog.queryByRole("button", { name: "Approve and publish" })).not.toBeInTheDocument();
expect(await dialog.findByRole("alert")).toHaveTextContent("Could not refresh submissions");
});
it("ignores an in-flight admin submission list that resolves after the same account loses its role", async () => {
const response = Promise.withResolvers<Response>();
const requested = Promise.withResolvers<void>();
const fetchStub = fetchMock.getMockImplementation()!;
fetchMock.mockImplementation(async (input, options) => {
const path = new URL(input instanceof Request ? input.url : String(input), "http://localhost").pathname;
if (path === "/skill-hub/submissions") {
requested.resolve();
return response.promise;
}
return fetchStub(input, options);
});
const { rerender } = renderSubmissions("same-token");
await requested.promise;
submissions = [];
fetchMock.mockImplementation(fetchStub);
await act(async () => rerender(<ClaudeCodePluginsPanel accessToken="same-token" userRole="Internal User" />));
expect(await screen.findByText("No submissions yet")).toBeInTheDocument();
await act(async () => {
response.resolve(Response.json({ submissions: [pending], count: 1, has_more: false }));
await response.promise;
});
expect(screen.getByText("No submissions yet")).toBeInTheDocument();
expect(screen.queryByText("Proposed instructions")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Review" })).not.toBeInTheDocument();
});

View file

@ -1,15 +1,16 @@
import { render, screen, waitFor } from "@testing-library/react";
import { screen, waitFor } from "@testing-library/react";
import { renderWithProviders as render, testQueryClient } from "@/../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking";
import type { Plugin } from "@/components/claude_code_plugins/types";
import { listSkillHubSkills, deleteSkillHubSkill } from "@/components/networking";
import type { SkillHubSkill } from "@/components/claude_code_plugins/types";
import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel";
vi.mock("@/components/networking", () => ({
getClaudeCodePluginsList: vi.fn(),
deleteClaudeCodePlugin: vi.fn(),
listSkillHubSkills: vi.fn(),
deleteSkillHubSkill: vi.fn(),
}));
vi.mock("./PluginTable", () => ({
@ -20,7 +21,7 @@ vi.mock("./PluginTable", () => ({
onDeleteClick,
}: {
isLoading: boolean;
pluginsList: Plugin[];
pluginsList: SkillHubSkill[];
onDeleteClick: (pluginName: string, displayName: string) => void;
}) => (
<div data-testid="plugin-table">
@ -39,32 +40,34 @@ vi.mock("./PluginTable", () => ({
}));
vi.mock("./add_plugin_form", () => ({ __esModule: true, default: () => null }));
vi.mock("./SkillSubmissions", () => ({ __esModule: true, default: () => null }));
vi.mock("@/components/claude_code_plugins/skill_detail", () => ({ __esModule: true, default: () => null }));
const mockGetClaudeCodePluginsList = vi.mocked(getClaudeCodePluginsList);
const mockDeleteClaudeCodePlugin = vi.mocked(deleteClaudeCodePlugin);
const mockListSkillHubSkills = vi.mocked(listSkillHubSkills);
const mockDeleteSkillHubSkill = vi.mocked(deleteSkillHubSkill);
const skill: Plugin = {
const skill: SkillHubSkill = {
id: "plugin-1",
name: "my-skill",
source: { source: "github", repo: "acme/my-skill" },
enabled: true,
visibility: "public",
};
describe("ClaudeCodePluginsPanel loading state", () => {
beforeEach(() => {
vi.clearAllMocks();
testQueryClient.clear();
});
it("should resolve the loading state when accessToken is null instead of showing the skeleton forever", async () => {
render(<ClaudeCodePluginsPanel accessToken={null} />);
expect(await screen.findByText("table-loaded")).toBeInTheDocument();
expect(mockGetClaudeCodePluginsList).not.toHaveBeenCalled();
expect(mockListSkillHubSkills).not.toHaveBeenCalled();
});
it("should show the loading state until the skills fetch settles", async () => {
let resolveFetch: (value: { plugins: never[]; count: number }) => void = () => {};
mockGetClaudeCodePluginsList.mockReturnValue(
let resolveFetch: (value: { skills: never[]; count: number; next_offset: number | null }) => void = () => {};
mockListSkillHubSkills.mockReturnValue(
new Promise((resolve) => {
resolveFetch = resolve;
}),
@ -72,16 +75,17 @@ describe("ClaudeCodePluginsPanel loading state", () => {
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
expect(screen.getByText("table-loading")).toBeInTheDocument();
resolveFetch({ plugins: [], count: 0 });
resolveFetch({ skills: [], count: 0, next_offset: null });
expect(await screen.findByText("table-loaded")).toBeInTheDocument();
expect(mockGetClaudeCodePluginsList).toHaveBeenCalledWith("sk-test", false);
expect(mockListSkillHubSkills).toHaveBeenCalledWith("sk-test", { limit: 50, offset: 0 });
});
});
describe("ClaudeCodePluginsPanel delete confirmation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetClaudeCodePluginsList.mockResolvedValue({ plugins: [skill], count: 1 });
testQueryClient.clear();
mockListSkillHubSkills.mockResolvedValue({ skills: [skill], count: 1, next_offset: null });
});
it("should ask for confirmation before deleting and name the skill", async () => {
@ -93,20 +97,20 @@ describe("ClaudeCodePluginsPanel delete confirmation", () => {
expect(await screen.findByText(/are you sure you want to delete skill/i)).toBeInTheDocument();
expect(screen.getByText("my-skill")).toBeInTheDocument();
expect(screen.getByText("This action cannot be undone.")).toBeInTheDocument();
expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled();
expect(mockDeleteSkillHubSkill).not.toHaveBeenCalled();
});
it("should delete the skill and refresh the list once confirmed", async () => {
const user = userEvent.setup();
mockDeleteClaudeCodePlugin.mockResolvedValue({});
mockDeleteSkillHubSkill.mockResolvedValue({});
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
await user.click(await screen.findByTestId("row-delete-plugin-1"));
await screen.findByText(/are you sure you want to delete skill/i);
await user.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(mockDeleteClaudeCodePlugin).toHaveBeenCalledWith("sk-test", "my-skill"));
await waitFor(() => expect(mockGetClaudeCodePluginsList).toHaveBeenCalledTimes(2));
await waitFor(() => expect(mockDeleteSkillHubSkill).toHaveBeenCalledWith("sk-test", "my-skill"));
await waitFor(() => expect(mockListSkillHubSkills).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.queryByText(/are you sure you want to delete skill/i)).not.toBeInTheDocument());
});
@ -119,6 +123,6 @@ describe("ClaudeCodePluginsPanel delete confirmation", () => {
await user.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByText(/are you sure you want to delete skill/i)).not.toBeInTheDocument());
expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled();
expect(mockDeleteSkillHubSkill).not.toHaveBeenCalled();
});
});

View file

@ -1,6 +1,8 @@
import React, { useState, useEffect } from "react";
import React, { useState } from "react";
import { useQuery, useQueryClient, type QueryKey } from "@tanstack/react-query";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
AlertDialog,
AlertDialogCancel,
@ -10,66 +12,136 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking";
import { listSkillHubSkills, getSkillHubSkill, deleteSkillHubSkill } from "@/components/networking";
import AddPluginForm from "./add_plugin_form";
import PluginTable from "./PluginTable";
import SkillDetail from "@/components/claude_code_plugins/skill_detail";
import { isAdminRole } from "@/utils/roles";
import SkillDetail, { skillHubDetail } from "@/components/claude_code_plugins/skill_detail";
import { isProxyAdminRole, isProxyAdminTierRole, isViewOnlySessionRole } from "@/utils/roles";
import { toast } from "@/lib/toast";
import { Plugin, ListPluginsResponse } from "@/components/claude_code_plugins/types";
import { isPendingSkill, type SkillHubSkill, type SkillMutationResponse } from "@/components/claude_code_plugins/types";
import { useUrlTab } from "@/hooks/useUrlTab";
import SkillSubmissions from "./SkillSubmissions";
import { useUrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable/useUrlTableState";
interface ClaudeCodePluginsPanelProps {
accessToken: string | null;
userRole?: string;
isViewOnly?: boolean;
}
const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessToken, userRole }) => {
const CATALOG_STATE: UrlTableStateOptions<never> = {
keyPrefix: "skills_",
defaultPageSize: 50,
maxPageSize: 100,
sortFields: ["name"],
defaultSort: { id: "name", desc: false },
filterColumns: [],
};
const SKILLS_TABS = ["skills", "submissions"] as const;
function skillPermissions(accessToken: string | null, userRole: string, isViewOnly: boolean) {
const readOnly =
isViewOnly || isViewOnlySessionRole(userRole) || ["Admin Viewer", "Internal Viewer"].includes(userRole);
const canWrite = Boolean(accessToken && userRole) && !readOnly;
return {
readOnly,
canWrite,
isAdmin: isProxyAdminRole(userRole) && canWrite,
adminView: isProxyAdminTierRole(userRole),
};
}
interface CatalogSkillDetailProps {
skill: SkillHubSkill;
accessToken: string | null;
queryScope: QueryKey;
canWrite: boolean;
onBack: () => void;
onEdit: (skill: SkillHubSkill) => void;
}
function CatalogSkillDetail({ skill, accessToken, queryScope, canWrite, onBack, onEdit }: CatalogSkillDetailProps) {
const { t } = useTranslation();
const [pluginsList, setPluginsList] = useState<Plugin[]>([]);
const detail = useQuery({
queryKey: [...queryScope, "detail", skill.id, skill.name],
queryFn: () => getSkillHubSkill(accessToken!, skill.name),
enabled: Boolean(accessToken),
});
return (
<>
{detail.isError && (
<div role="alert" className="text-sm text-destructive">
{t("skills.panel.detailFailed")}
<Button variant="link" onClick={() => void detail.refetch()}>
{t("skills.submissions.retry")}
</Button>
</div>
)}
<SkillDetail
skill={skillHubDetail(detail.data ?? skill)}
onBack={onBack}
accessToken={accessToken}
onEditClick={canWrite && detail.isSuccess && !detail.isFetching ? () => onEdit(detail.data) : undefined}
/>
</>
);
}
const SkillsPanelContent: React.FC<ClaudeCodePluginsPanelProps> = ({
accessToken,
userRole = "",
isViewOnly = false,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [tab, setTab] = useUrlTab(SKILLS_TABS, "skills");
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [editingSkill, setEditingSkill] = useState<SkillHubSkill>();
const [isDeleting, setIsDeleting] = useState(false);
const [pluginToDelete, setPluginToDelete] = useState<{
name: string;
displayName: string;
} | null>(null);
const [selectedSkill, setSelectedSkill] = useState<Plugin | null>(null);
const isAdmin = userRole ? isAdminRole(userRole) : false;
const fetchPlugins = async () => {
if (!accessToken) {
setIsLoading(false);
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(null);
const { readOnly, canWrite, isAdmin, adminView } = skillPermissions(accessToken, userRole, isViewOnly);
const queryScope = ["skill-hub", accessToken, userRole, readOnly] as const;
const showForm = Boolean(isAddModalVisible || editingSkill) && canWrite;
const { pagination, onPaginationChange } = useUrlTableState(CATALOG_STATE);
const { pageIndex, pageSize } = pagination;
const plugins = useQuery({
queryKey: [...queryScope, "published", pageIndex, pageSize],
queryFn: () => listSkillHubSkills(accessToken!, { limit: pageSize, offset: pageIndex * pageSize }),
enabled: Boolean(accessToken),
});
const pluginsList = plugins.data?.skills ?? [];
const selectedSkill = pluginsList.find((plugin) => plugin.id === selectedSkillId);
const refreshSkills = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryScope, refetchType: "all" }),
queryClient.invalidateQueries({ queryKey: ["skill-versions"], refetchType: "all" }),
]);
};
const handleSaved = async (response: SkillMutationResponse) => {
if (isPendingSkill(response)) {
await queryClient.invalidateQueries({ queryKey: [...queryScope, "submissions"], refetchType: "all" });
return;
}
setIsLoading(true);
try {
const response: ListPluginsResponse = await getClaudeCodePluginsList(accessToken, false);
setPluginsList(response.plugins);
} catch (error) {
console.error("Error fetching skills:", error);
} finally {
setIsLoading(false);
}
await refreshSkills();
};
useEffect(() => {
fetchPlugins();
}, [accessToken]);
const handleDeleteClick = (pluginName: string, displayName: string) => {
setPluginToDelete({ name: pluginName, displayName });
};
const handleDeleteConfirm = async () => {
if (!pluginToDelete || !accessToken) return;
if (!pluginToDelete || !accessToken || !isAdmin) return;
setIsDeleting(true);
try {
await deleteClaudeCodePlugin(accessToken, pluginToDelete.name);
await deleteSkillHubSkill(accessToken, pluginToDelete.name);
toast.success(t("skills.panel.toasts.deleted", { name: pluginToDelete.displayName }));
fetchPlugins();
await refreshSkills();
} catch (error) {
console.error("Error deleting skill:", error);
toast.error(t("skills.panel.toasts.deleteFailed"));
@ -82,51 +154,96 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
{selectedSkill ? (
<SkillDetail
<CatalogSkillDetail
skill={selectedSkill}
onBack={() => setSelectedSkill(null)}
isAdmin={isAdmin}
accessToken={accessToken}
onPublishClick={fetchPlugins}
queryScope={queryScope}
canWrite={canWrite}
onBack={() => setSelectedSkillId(null)}
onEdit={setEditingSkill}
/>
) : (
<>
<div className="flex flex-col gap-2 mb-4">
<h1 className="text-2xl font-bold">{t("skills.panel.title")}</h1>
<p className="text-sm text-muted-foreground">
<Trans
i18nKey="skills.panel.intro"
components={{
endpoint: <code className="bg-muted px-1 rounded-sm">/claude-code/marketplace.json</code>,
}}
/>
</p>
<p className="text-sm text-muted-foreground">{t("skills.panel.intro")}</p>
<div className="mt-2 flex gap-2">
<Button onClick={() => setIsAddModalVisible(true)} disabled={!accessToken || !isAdmin}>
<Button onClick={() => setIsAddModalVisible(true)} disabled={!canWrite}>
{t("skills.panel.addButton")}
</Button>
</div>
</div>
<PluginTable
pluginsList={pluginsList}
isLoading={isLoading}
onDeleteClick={handleDeleteClick}
isAdmin={isAdmin}
onPluginClick={(id) => {
const skill = pluginsList.find((p) => p.id === id);
if (skill) setSelectedSkill(skill);
}}
/>
<Tabs value={tab} onValueChange={(value) => setTab(value === "submissions" ? "submissions" : "skills")}>
<TabsList variant="line">
<TabsTrigger value="skills">{t("skills.panel.tabs.skills")}</TabsTrigger>
<TabsTrigger value="submissions">
{t(adminView ? "skills.panel.tabs.submissions" : "skills.panel.tabs.mySubmissions")}
</TabsTrigger>
</TabsList>
<TabsContent value="skills">
{plugins.isError && (
<div role="alert" className="my-3 text-sm text-destructive">
{t("skills.panel.loadFailed")}
<Button variant="link" onClick={() => void plugins.refetch()}>
{t("skills.submissions.retry")}
</Button>
</div>
)}
<PluginTable
pluginsList={pluginsList}
isLoading={plugins.isLoading}
onDeleteClick={handleDeleteClick}
isAdmin={isAdmin}
onPluginClick={setSelectedSkillId}
/>
<div className="mt-4 flex items-center justify-end gap-3">
<span>{t("skills.submissions.page", { page: pageIndex + 1 })}</span>
<Button
variant="outline"
disabled={pageIndex === 0 || plugins.isFetching}
onClick={() => onPaginationChange({ pageIndex: pageIndex - 1, pageSize })}
>
{t("skills.submissions.previous")}
</Button>
<Button
variant="outline"
disabled={plugins.data?.next_offset == null || plugins.isFetching || plugins.isError}
onClick={() => onPaginationChange({ pageIndex: pageIndex + 1, pageSize })}
>
{t("skills.submissions.next")}
</Button>
</div>
</TabsContent>
<TabsContent value="submissions">
{accessToken && (
<SkillSubmissions
accessToken={accessToken}
queryScope={queryScope}
canReview={isAdmin}
adminView={adminView}
onChanged={refreshSkills}
/>
)}
</TabsContent>
</Tabs>
</>
)}
<AddPluginForm
visible={isAddModalVisible}
onClose={() => setIsAddModalVisible(false)}
accessToken={accessToken}
onSuccess={fetchPlugins}
/>
{showForm && (
<AddPluginForm
key={editingSkill ? editingSkill.id : "new"}
visible
skill={editingSkill}
requiresApproval={!isAdmin}
onClose={() => {
setIsAddModalVisible(false);
setEditingSkill(undefined);
}}
accessToken={accessToken}
onSuccess={(response) => void handleSaved(response)}
/>
)}
{pluginToDelete && (
<AlertDialog
@ -159,4 +276,8 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
);
};
const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = (props) => (
<SkillsPanelContent key={`${props.accessToken}:${props.userRole}:${props.isViewOnly}`} {...props} />
);
export default ClaudeCodePluginsPanel;

View file

@ -2,27 +2,27 @@ import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Plugin } from "@/components/claude_code_plugins/types";
import { SkillHubSkill } from "@/components/claude_code_plugins/types";
import PluginTable from "./PluginTable";
const mockPlugins: Plugin[] = [
const mockPlugins: SkillHubSkill[] = [
{
id: "plugin-id-newer",
name: "newer-skill",
version: "1.2.0",
description: "A skill for testing",
source: { source: "github", repo: "org/newer-skill" },
category: "development",
enabled: true,
created_at: "2025-01-15T10:30:00Z",
domain: "development",
visibility: "public",
updated_at: "2025-01-15T10:30:00Z",
},
{
id: "plugin-id-older",
name: "older-skill",
source: { source: "github", repo: "org/older-skill" },
enabled: false,
created_at: "2024-01-10T09:15:00Z",
visibility: "private",
updated_at: "2024-01-10T09:15:00Z",
},
];
@ -44,7 +44,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", "Domain", "Public", "Updated at"]) {
expect(screen.getByText(header)).toBeInTheDocument();
}
});
@ -54,7 +54,7 @@ describe("PluginTable", () => {
expect(screen.getByText("No skills found")).toBeInTheDocument();
});
it("should sort by created date descending by default", () => {
it("should sort by name ascending by default", () => {
render(<PluginTable {...defaultProps} />);
const rows = screen.getAllByRole("row").slice(1);
expect(within(rows[0]).getByText("newer-skill")).toBeInTheDocument();
@ -75,10 +75,10 @@ describe("PluginTable", () => {
expect(mockOnPluginClick).not.toHaveBeenCalled();
});
it("should badge the category and fall back to Uncategorized", () => {
it("should display the domain and a fallback when unset", () => {
render(<PluginTable {...defaultProps} />);
expect(screen.getByText("development")).toBeInTheDocument();
expect(screen.getByText("Uncategorized")).toBeInTheDocument();
expect(screen.getAllByText("—")[0]).toBeInTheDocument();
});
it("should show whether the skill is public", () => {

View file

@ -6,19 +6,19 @@ import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { DataTable } from "@/components/shared/DataTable";
import { Plugin } from "@/components/claude_code_plugins/types";
import { SkillHubSkill } from "@/components/claude_code_plugins/types";
import { getPluginTableColumns } from "./PluginTableColumns";
interface PluginTableProps {
pluginsList: Plugin[];
pluginsList: SkillHubSkill[];
isLoading: boolean;
onDeleteClick: (pluginName: string, displayName: string) => void;
isAdmin: boolean;
onPluginClick: (pluginId: string) => void;
}
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
const DEFAULT_SORTING: SortingState = [{ id: "name", desc: false }];
function EmptyState() {
const { t } = useTranslation();
@ -45,7 +45,7 @@ const PluginTable: React.FC<PluginTableProps> = ({ pluginsList, isLoading, onDel
return (
<DataTable
data={pluginsList}
paginationMode="client"
paginationMode="none"
columns={columns}
getRowId={(plugin, index) => plugin.id || String(index)}
sortingMode="client"

View file

@ -6,10 +6,8 @@ import { useTranslation } from "react-i18next";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells";
import { getCategoryBadgeColor } from "@/components/claude_code_plugins/helpers";
import i18n from "@/i18n/i18n";
import { Plugin } from "@/components/claude_code_plugins/types";
import { Badge } from "@/components/ui/badge";
import { SkillHubSkill } from "@/components/claude_code_plugins/types";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
@ -21,31 +19,8 @@ import {
import { cn } from "@/lib/cva.config";
import { copyToClipboard } from "@/utils/dataUtils";
const CATEGORY_BADGE_CLASS: Record<ReturnType<typeof getCategoryBadgeColor>, string> = {
blue: "border-info/20 bg-info/10 text-info",
green: "border-success/20 bg-success/10 text-success",
purple:
"border-purple-200 bg-purple-50 text-purple-600 dark:border-purple-800 dark:bg-purple-950 dark:text-purple-300",
red: "border-destructive/20 bg-destructive/10 text-destructive",
orange: "border-warning/20 bg-warning/10 text-warning",
yellow: "border-warning/20 bg-warning/10 text-warning",
gray: "border-border bg-muted text-muted-foreground",
};
function PluginCategoryBadge({ category }: { category?: string }) {
const { t } = useTranslation();
return (
<Badge
variant="outline"
className={cn("whitespace-nowrap font-normal", CATEGORY_BADGE_CLASS[getCategoryBadgeColor(category)])}
>
{category || t("skills.table.uncategorized")}
</Badge>
);
}
interface PluginRowActionsProps {
plugin: Plugin;
plugin: SkillHubSkill;
isAdmin: boolean;
onDeleteClick: (pluginName: string, displayName: string) => void;
}
@ -97,7 +72,7 @@ export const getPluginTableColumns = ({
isAdmin,
onPluginClick,
onDeleteClick,
}: PluginTableColumnsDeps): ColumnDef<Plugin>[] => [
}: PluginTableColumnsDeps): ColumnDef<SkillHubSkill>[] => [
{
id: "name",
accessorKey: "name",
@ -144,37 +119,39 @@ export const getPluginTableColumns = ({
},
},
{
id: "category",
accessorKey: "category",
meta: { title: i18n.t("skills.table.columns.category"), skeleton: "badge" },
header: i18n.t("skills.table.columns.category"),
id: "domain",
accessorKey: "domain",
meta: { title: i18n.t("skills.table.columns.domain"), skeleton: "badge" },
header: i18n.t("skills.table.columns.domain"),
size: 150,
enableSorting: false,
cell: ({ row }) => <PluginCategoryBadge category={row.original.category ?? undefined} />,
cell: ({ row }) => <span>{row.original.domain || "—"}</span>,
},
{
id: "enabled",
accessorKey: "enabled",
id: "visibility",
accessorKey: "visibility",
meta: { title: i18n.t("skills.table.columns.public"), skeleton: "badge" },
header: i18n.t("skills.table.columns.public"),
size: 100,
enableSorting: false,
cell: ({ row }) => (
<StatusBadge
tone={row.original.enabled ? "success" : "neutral"}
label={row.original.enabled ? i18n.t("skills.table.publicYes") : i18n.t("skills.table.publicNo")}
tone={row.original.visibility === "public" ? "success" : "neutral"}
label={
row.original.visibility === "public" ? i18n.t("skills.table.publicYes") : i18n.t("skills.table.publicNo")
}
/>
),
},
{
id: "created_at",
accessorKey: "created_at",
id: "updated_at",
accessorKey: "updated_at",
sortingFn: "datetime",
meta: { title: i18n.t("skills.table.columns.createdAt") },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("skills.table.columns.createdAt")} />,
meta: { title: i18n.t("skills.table.columns.updatedAt") },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("skills.table.columns.updatedAt")} />,
size: 160,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.created_at} />,
cell: ({ row }) => <DateCell value={row.original.updated_at} />,
},
{
id: "actions",

View file

@ -0,0 +1,358 @@
import { useState } from "react";
import { useQuery, type QueryKey } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { downloadBlob } from "@/components/EntityUsageExport/utils";
import {
downloadSkillSubmission,
getSkillSubmission,
listSkillSubmissions,
reviewSkillSubmission,
} from "@/components/networking";
import type { SkillHubSkill, SkillSubmission } from "@/components/claude_code_plugins/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { useUrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable/useUrlTableState";
import { toast } from "@/lib/toast";
const METADATA_FIELDS = ["name", "version", "description", "source", "keywords", "domain", "namespace"] as const;
const displayValue = (value: unknown): string => {
if (value === null || value === undefined || value === "") return "";
return typeof value === "object" ? JSON.stringify(value, null, 2) : String(value);
};
const STATUS_CLASSES = {
pending_review: "border-warning/30 text-warning",
approved: "border-success/30 text-success",
rejected: "border-destructive/30 text-destructive",
};
const SUBMISSION_TABLE_STATE: UrlTableStateOptions<never> = {
keyPrefix: "submissions_",
defaultPageSize: 20,
maxPageSize: 100,
sortFields: ["created_at"],
defaultSort: { id: "created_at", desc: true },
filterColumns: [],
};
function SubmissionStatus({ status }: { status: SkillSubmission["status"] }) {
const { t } = useTranslation();
return (
<Badge variant="outline" className={STATUS_CLASSES[status]}>
{t(`skills.submissions.status.${status}`)}
</Badge>
);
}
function SubmissionSummary({ submission }: { submission: SkillSubmission }) {
const { t } = useTranslation();
return (
<>
<div className="flex flex-wrap items-center gap-3 text-sm">
<SubmissionStatus status={submission.status} />
<span>{t(`skills.submissions.operation.${submission.operation}`)}</span>
<span className="text-muted-foreground">
{t("skills.submissions.submittedBy", { name: submission.submitted_by })}
</span>
</div>
{submission.status === "pending_review" && (
<p className="text-sm text-muted-foreground">{t("skills.submissions.pendingNotice")}</p>
)}
{submission.rejection_reason && (
<p className="text-sm text-destructive">
{t("skills.submissions.rejectionReason", { reason: submission.rejection_reason })}
</p>
)}
{submission.reviewed_at && (
<p className="text-sm text-muted-foreground">
{t("skills.submissions.reviewedBy", {
name: submission.reviewed_by,
date: new Date(submission.reviewed_at).toLocaleString(),
})}
</p>
)}
</>
);
}
function SubmissionComparison({
submission,
current,
}: {
submission: SkillSubmission;
current?: SkillHubSkill | null;
}) {
const { t } = useTranslation();
return (
<div className="overflow-x-auto">
<table className="w-full table-fixed border-collapse text-sm">
<thead>
<tr className="border-b">
<th className="w-1/5 p-2 text-left">{t("skills.submissions.field")}</th>
<th className="p-2 text-left">{t("skills.submissions.current")}</th>
<th className="p-2 text-left">{t("skills.submissions.proposed")}</th>
</tr>
</thead>
<tbody>
{METADATA_FIELDS.map((field) => (
<tr key={field} className="border-b align-top">
<th className="p-2 text-left font-medium">{t(`skills.submissions.fields.${field}`)}</th>
<td className="p-2">
<pre className="whitespace-pre-wrap break-words font-mono text-xs">
{displayValue(current?.[field]) || t("skills.submissions.notSet")}
</pre>
</td>
<td className="p-2">
<pre className="whitespace-pre-wrap break-words font-mono text-xs">
{field === "version" && submission.operation === "update" && !submission.proposal.version
? t("skills.submissions.nextPatchVersion")
: displayValue(submission.proposal[field]) || t("skills.submissions.notSet")}
</pre>
</td>
</tr>
))}
<tr className="border-b align-top">
<th className="p-2 text-left font-medium">{t("skills.submissions.fields.agent_skill")}</th>
<td className="p-2 text-muted-foreground">{t("skills.submissions.descriptorUnavailable")}</td>
<td className="p-2">
<pre className="whitespace-pre-wrap break-words font-mono text-xs">
{displayValue(submission.proposal.agent_skill) || t("skills.submissions.notSet")}
</pre>
</td>
</tr>
</tbody>
</table>
</div>
);
}
interface SubmissionReviewProps {
id: string;
accessToken: string;
queryScope: QueryKey;
canReview: boolean;
onClose: () => void;
onChanged: () => Promise<void>;
}
function SubmissionReview({ id, accessToken, queryScope, canReview, onClose, onChanged }: SubmissionReviewProps) {
const { t } = useTranslation();
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const detail = useQuery({
queryKey: [...queryScope, "submission", id],
queryFn: () => getSkillSubmission(accessToken, id),
});
const { submission, current_skill: current } = detail.data ?? {};
const canDecide = canReview && submission?.status === "pending_review";
const review = async (action: "approve" | "reject") => {
if (!canDecide || busy) return;
setBusy(true);
setError(null);
try {
await reviewSkillSubmission(
accessToken,
id,
action,
action === "reject" ? { reason: reason.trim() || null } : undefined,
);
onClose();
await onChanged();
toast.success(t(action === "approve" ? "skills.submissions.approved" : "skills.submissions.rejected"));
} catch (err) {
setError(err instanceof Error ? err.message : t("skills.submissions.actionFailed"));
await onChanged();
} finally {
setBusy(false);
}
};
const download = async () => {
if (!submission || downloading) return;
setDownloading(true);
setError(null);
try {
downloadBlob(await downloadSkillSubmission(accessToken, id), `${submission.skill_name}.zip`);
} catch (err) {
setError(err instanceof Error ? err.message : t("skills.submissions.downloadFailed"));
} finally {
setDownloading(false);
}
};
return (
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="top-8 max-h-[calc(100dvh-4rem)] translate-y-0 overflow-y-auto sm:max-w-[960px]">
<DialogHeader>
<DialogTitle>{t("skills.submissions.reviewTitle", { name: submission?.skill_name ?? "" })}</DialogTitle>
</DialogHeader>
{detail.isPending && <p>{t("skills.submissions.loading")}</p>}
{detail.isError && (
<div role="alert" className="text-destructive">
{t("skills.submissions.loadFailed")}
<Button variant="link" onClick={() => void detail.refetch()}>
{t("skills.submissions.retry")}
</Button>
</div>
)}
{submission && (
<>
<SubmissionSummary submission={submission} />
{!current && <p className="text-sm text-muted-foreground">{t("skills.submissions.noCurrent")}</p>}
<SubmissionComparison submission={submission} current={current} />
{submission.has_archive && submission.proposal.agent_skill && (
<Button variant="outline" onClick={download} disabled={downloading}>
{t(downloading ? "skills.submissions.downloading" : "skills.submissions.download")}
</Button>
)}
{canDecide && (
<>
<label htmlFor="skill-rejection-reason" className="text-sm font-medium">
{t("skills.submissions.reasonLabel")}
</label>
<Textarea
id="skill-rejection-reason"
value={reason}
maxLength={2000}
onChange={(event) => setReason(event.target.value)}
disabled={busy}
/>
<div className="flex justify-end gap-2">
<Button
variant="destructive"
onClick={() => void review("reject")}
disabled={busy || detail.isFetching || detail.isError}
>
{t("skills.submissions.reject")}
</Button>
<Button onClick={() => void review("approve")} disabled={busy || detail.isFetching || detail.isError}>
{t("skills.submissions.approve")}
</Button>
</div>
</>
)}
</>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
</DialogContent>
</Dialog>
);
}
interface SkillSubmissionsProps {
accessToken: string;
queryScope: QueryKey;
canReview: boolean;
adminView: boolean;
onChanged: () => Promise<void>;
}
export default function SkillSubmissions({
accessToken,
queryScope,
canReview,
adminView,
onChanged,
}: SkillSubmissionsProps) {
const { t } = useTranslation();
const [selectedId, setSelectedId] = useState<string | null>(null);
const { pagination, onPaginationChange } = useUrlTableState(SUBMISSION_TABLE_STATE);
const { pageIndex, pageSize } = pagination;
const submissions = useQuery({
queryKey: [...queryScope, "submissions", pageIndex, pageSize],
queryFn: () => listSkillSubmissions(accessToken, { limit: pageSize, offset: pageIndex * pageSize }),
});
const isEmpty = submissions.isSuccess && submissions.data.submissions.length === 0;
const changePage = (page: number) => {
setSelectedId(null);
onPaginationChange({ pageIndex: page, pageSize });
};
return (
<div className="space-y-4 py-4">
<p className="text-sm text-muted-foreground">
{t(adminView ? "skills.submissions.adminIntro" : "skills.submissions.authorIntro")}
</p>
{submissions.isPending && <p>{t("skills.submissions.loading")}</p>}
{submissions.isError && (
<div role="alert" className="text-sm text-destructive">
{t("skills.submissions.loadFailed")}
<Button variant="link" onClick={() => void submissions.refetch()}>
{t("skills.submissions.retry")}
</Button>
</div>
)}
{isEmpty && (
<p className="py-8 text-center text-sm text-muted-foreground">
{t(pageIndex === 0 ? "skills.submissions.empty" : "skills.submissions.emptyPage")}
</p>
)}
{submissions.data?.submissions.map((submission) => (
<div key={submission.id} className="rounded-lg border bg-card p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h2 className="break-all text-sm font-semibold">{submission.skill_name}</h2>
<SubmissionStatus status={submission.status} />
<span className="text-xs text-muted-foreground">
{t(`skills.submissions.operation.${submission.operation}`)}
</span>
</div>
<p className="text-sm text-muted-foreground">{submission.proposal.description}</p>
<p className="text-xs text-muted-foreground">
{t("skills.submissions.submittedBy", { name: submission.submitted_by })} ·{" "}
{new Date(submission.created_at).toLocaleString()}
</p>
{submission.rejection_reason && (
<p className="text-sm text-destructive">
{t("skills.submissions.rejectionReason", { reason: submission.rejection_reason })}
</p>
)}
</div>
<Button variant="outline" size="sm" onClick={() => setSelectedId(submission.id)}>
{t(
canReview && submission.status === "pending_review"
? "skills.submissions.review"
: "skills.submissions.details",
)}
</Button>
</div>
</div>
))}
<div className="flex items-center justify-end gap-3">
<span className="text-sm text-muted-foreground">{t("skills.submissions.page", { page: pageIndex + 1 })}</span>
<Button
variant="outline"
disabled={pageIndex === 0 || submissions.isFetching}
onClick={() => changePage(pageIndex - 1)}
>
{t("skills.submissions.previous")}
</Button>
<Button
variant="outline"
disabled={!submissions.data?.has_more || submissions.isFetching || submissions.isError}
onClick={() => changePage(pageIndex + 1)}
>
{t("skills.submissions.next")}
</Button>
</div>
{selectedId && (
<SubmissionReview
key={selectedId}
id={selectedId}
accessToken={accessToken}
queryScope={queryScope}
canReview={canReview}
onClose={() => setSelectedId(null)}
onChanged={onChanged}
/>
)}
</div>
);
}

View file

@ -1,440 +1,132 @@
import React from "react";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import AddPluginForm from "./add_plugin_form";
import { registerClaudeCodePlugin, uploadClaudeCodeSkill } from "@/components/networking";
import { toast } from "@/lib/toast";
import { importSkillHubSkill, uploadSkillHubSkill } from "@/components/networking";
vi.mock("@/components/networking", () => ({
registerClaudeCodePlugin: vi.fn().mockResolvedValue({ status: "success" }),
uploadClaudeCodeSkill: vi.fn().mockResolvedValue({ status: "success" }),
importSkillHubSkill: vi.fn().mockResolvedValue({ id: "skill" }),
uploadSkillHubSkill: vi.fn().mockResolvedValue({ id: "skill" }),
}));
const mockRegister = vi.mocked(registerClaudeCodePlugin);
const mockUpload = vi.mocked(uploadClaudeCodeSkill);
const mockMessageError = vi.mocked(toast.error);
const DEFAULT_PROPS = {
visible: true,
onClose: vi.fn(),
accessToken: "sk-test",
onSuccess: vi.fn(),
const importSkill = vi.mocked(importSkillHubSkill);
const uploadSkill = vi.mocked(uploadSkillHubSkill);
const props = { visible: true, onClose: vi.fn(), accessToken: "sk-test", onSuccess: vi.fn() };
const file = new File(["skill archive"], "personal-skill.zip", { type: "application/zip" });
const chooseFile = (selected = file) =>
fireEvent.change(screen.getByLabelText("Skill ZIP file"), { target: { files: [selected] } });
const change = (label: string, value: string) => fireEvent.change(screen.getByLabelText(label), { target: { value } });
const submit = () => fireEvent.click(screen.getByRole("button", { name: "Add Skill" }));
const openImport = () => {
renderWithProviders(<AddPluginForm {...props} />);
fireEvent.click(screen.getByRole("radio", { name: "Import URL" }));
};
const URL_PLACEHOLDER = "https://github.com/org/repo or https://bucket.s3.amazonaws.com/my-skill.zip";
const SUBPATH_PLACEHOLDER = "plugins/my-skill";
const SHA256_PLACEHOLDER = "64 hex characters";
const S3_ZIP_URL = "https://skills-bucket.s3.us-east-1.amazonaws.com/plugins/s3-skill-1.0.0.zip";
const DIGEST = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
describe("AddPluginForm", () => {
beforeEach(() => {
vi.clearAllMocks();
beforeEach(() => vi.clearAllMocks());
it("starts with a portable ZIP and offers host-agnostic imports without guessing metadata", () => {
renderWithProviders(<AddPluginForm {...props} />);
expect(screen.getByRole("radio", { name: "Upload ZIP" })).toBeChecked();
expect(screen.getByText(/frontmatter supplies the skill name and description/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("radio", { name: "Import URL" }));
expect(screen.getByLabelText("Source URL")).toBeInTheDocument();
expect(screen.getByLabelText("Path (optional)")).toBeEnabled();
expect(screen.queryByLabelText("Skill Name")).not.toBeInTheDocument();
});
it("renders the host-agnostic source URL input and subfolder field, hiding the digest until a zip is entered", () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
expect(screen.getByRole("radio", { name: "Source URL" })).toBeChecked();
expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toBeInTheDocument();
expect(screen.getByText("Subfolder path (Optional)")).toBeInTheDocument();
expect(screen.getByPlaceholderText(SUBPATH_PLACEHOLDER)).toBeInTheDocument();
expect(screen.queryByPlaceholderText(SHA256_PLACEHOLDER)).not.toBeInTheDocument();
it.each([
"https://github.com/anthropics/claude-code",
"https://github.com/anthropics/claude-code/tree/main/plugins/my-skill",
"https://gitlab.com/group/repo",
"https://skills-bucket.s3.us-east-1.amazonaws.com/plugins/s3-skill-1.0.0.zip",
])("submits the original source URL %s", async (url) => {
openImport();
change("Source URL", url);
submit();
await waitFor(() => expect(importSkill).toHaveBeenCalledWith("sk-test", { url }));
expect(props.onSuccess).toHaveBeenCalledOnce();
});
it("shows GitHub repo preview for a plain repo URL", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
const urlInput = screen.getByPlaceholderText(URL_PLACEHOLDER);
await act(async () => {
fireEvent.change(urlInput, {
target: { value: "https://github.com/anthropics/claude-code" },
});
});
await waitFor(() => {
expect(screen.getByText(/GitHub repo/)).toBeInTheDocument();
});
it("keeps explicit selector, ref and path choices when a source URL changes", async () => {
openImport();
change("Source URL", "https://gitlab.com/group/repo");
change("Skill selector (optional)", "review");
change("Git ref (optional)", "release/v2");
change("Path (optional)", "skills/review");
change("Version (Optional)", "3.0.0");
const url = "https://github.com/example/skills/tree/release/v2";
change("Source URL", url);
submit();
const expected = { url, skill: "review", ref: "release/v2", path: "skills/review", version: "3.0.0" };
await waitFor(() => expect(importSkill).toHaveBeenCalledWith("sk-test", expected));
});
it("shows git-subdir preview for a tree URL and disables the subfolder field", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
const urlInput = screen.getByPlaceholderText(URL_PLACEHOLDER);
await act(async () => {
fireEvent.change(urlInput, {
target: {
value: "https://github.com/anthropics/claude-code/tree/main/plugins/my-skill",
},
});
});
await waitFor(() => {
expect(screen.getByText(/GitHub subdir/)).toBeInTheDocument();
});
expect(screen.getByPlaceholderText(SUBPATH_PLACEHOLDER)).toBeDisabled();
it.each([
"Unable to import skill",
"A skill named 'review' already exists. Update the existing skill instead of adding it again.",
])("shows the API failure without closing the form: %s", async (message) => {
importSkill.mockRejectedValueOnce(new Error(message));
openImport();
change("Source URL", "https://github.com/example/skills");
submit();
expect(await screen.findByRole("alert")).toHaveTextContent(message);
expect(props.onSuccess).not.toHaveBeenCalled();
expect(props.onClose).not.toHaveBeenCalled();
});
it("shows a raw url preview for a non-github host", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
const urlInput = screen.getByPlaceholderText(URL_PLACEHOLDER);
await act(async () => {
fireEvent.change(urlInput, {
target: { value: "https://gitlab.com/group/repo" },
});
});
await waitFor(() => {
expect(screen.getByText(/Git repo/)).toBeInTheDocument();
});
expect(screen.getByPlaceholderText(SUBPATH_PLACEHOLDER)).toBeEnabled();
});
it("combines a repo URL with a subfolder into a git-subdir preview", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
const urlInput = screen.getByPlaceholderText(URL_PLACEHOLDER);
await act(async () => {
fireEvent.change(urlInput, {
target: { value: "https://gitlab.com/group/repo" },
});
});
const subPathInput = screen.getByPlaceholderText(SUBPATH_PLACEHOLDER);
await act(async () => {
fireEvent.change(subPathInput, { target: { value: "plugins/x" } });
});
await waitFor(() => {
expect(screen.getByText(/Git subdir/)).toBeInTheDocument();
expect(screen.getByText(/plugins\/x/)).toBeInTheDocument();
});
});
it("auto-fills skill name from repo URL", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
const urlInput = screen.getByPlaceholderText(URL_PLACEHOLDER);
await act(async () => {
fireEvent.change(urlInput, {
target: { value: "https://github.com/anthropics/my-awesome-skill" },
});
});
await waitFor(() => {
const nameInput = screen.getByPlaceholderText("my-skill") as HTMLInputElement;
expect(nameInput.value).toBe("my-awesome-skill");
});
});
it("does not auto-fill name when name is already set", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
const nameInput = screen.getByPlaceholderText("my-skill") as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: "existing-name" } });
const urlInput = screen.getByPlaceholderText(URL_PLACEHOLDER);
await act(async () => {
fireEvent.change(urlInput, {
target: { value: "https://github.com/anthropics/other-skill" },
});
});
await waitFor(() => {
expect(nameInput.value).toBe("existing-name");
});
});
const typeUrl = async (value: string) => {
await act(async () => {
fireEvent.change(screen.getByPlaceholderText(URL_PLACEHOLDER), { target: { value } });
});
};
const typeSubPath = async (value: string) => {
await act(async () => {
fireEvent.change(screen.getByPlaceholderText(SUBPATH_PLACEHOLDER), { target: { value } });
});
};
const submit = async () => {
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Add Skill" }));
});
};
it("submits a github repo source", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
await typeUrl("https://github.com/anthropics/claude-code");
await submit();
await waitFor(() => {
expect(mockRegister).toHaveBeenCalledWith(
"sk-test",
expect.objectContaining({ source: { source: "github", repo: "anthropics/claude-code" } }),
);
});
});
it("submits a github subdir source from a tree URL", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
await typeUrl("https://github.com/anthropics/claude-code/tree/main/plugins/my-skill");
await submit();
await waitFor(() => {
expect(mockRegister).toHaveBeenCalledWith(
"sk-test",
expect.objectContaining({
source: { source: "git-subdir", url: "https://github.com/anthropics/claude-code", path: "plugins/my-skill" },
}),
);
});
});
it("submits a raw url source for a gitlab repo", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
await typeUrl("https://gitlab.com/group/repo");
await submit();
await waitFor(() => {
expect(mockRegister).toHaveBeenCalledWith(
"sk-test",
expect.objectContaining({ source: { source: "url", url: "https://gitlab.com/group/repo" } }),
);
});
});
it("submits a git-subdir source from a gitlab repo plus subfolder field", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
await typeUrl("https://gitlab.com/group/repo");
await typeSubPath("plugins/x");
await submit();
await waitFor(() => {
expect(mockRegister).toHaveBeenCalledWith(
"sk-test",
expect.objectContaining({
source: { source: "git-subdir", url: "https://gitlab.com/group/repo", path: "plugins/x" },
}),
);
});
});
it("clears the subfolder field and uses the URL path once a tree URL is entered", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
await typeSubPath("plugins/x");
const subPathInput = screen.getByPlaceholderText(SUBPATH_PLACEHOLDER) as HTMLInputElement;
expect(subPathInput.value).toBe("plugins/x");
await typeUrl("https://github.com/anthropics/claude-code/tree/main/plugins/from-url");
await waitFor(() => {
expect(subPathInput.value).toBe("");
expect(subPathInput).toBeDisabled();
});
await submit();
await waitFor(() => {
expect(mockRegister).toHaveBeenCalledWith(
"sk-test",
expect.objectContaining({
source: {
source: "git-subdir",
url: "https://github.com/anthropics/claude-code",
path: "plugins/from-url",
},
}),
);
});
});
it("shows a zip archive preview, disables the subfolder field, and reveals the digest field", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
await typeUrl(S3_ZIP_URL);
expect(await screen.findByText(/Zip archive/)).toBeInTheDocument();
expect(screen.getByPlaceholderText(SUBPATH_PLACEHOLDER)).toBeDisabled();
expect(screen.getByText("A zip archive is installed as a whole, so this field is disabled")).toBeInTheDocument();
expect(screen.getByPlaceholderText(SHA256_PLACEHOLDER)).toBeInTheDocument();
expect((screen.getByPlaceholderText("my-skill") as HTMLInputElement).value).toBe("s3-skill-1-0-0");
});
it("submits an archive source without a digest when the field is left empty", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
await typeUrl(S3_ZIP_URL);
await submit();
await waitFor(() => {
expect(mockRegister).toHaveBeenCalledWith(
"sk-test",
expect.objectContaining({ source: { source: "archive", url: S3_ZIP_URL } }),
);
});
});
it("submits an archive source pinned to the lowercased digest", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
await typeUrl(S3_ZIP_URL);
await act(async () => {
fireEvent.change(screen.getByPlaceholderText(SHA256_PLACEHOLDER), {
target: { value: ` ${DIGEST.toUpperCase()} ` },
});
});
await submit();
await waitFor(() => {
expect(mockRegister).toHaveBeenCalledWith(
"sk-test",
expect.objectContaining({ source: { source: "archive", url: S3_ZIP_URL, sha256: DIGEST } }),
);
});
});
it("drops the digest once the archive URL changes so a stale checksum is never sent for a new file", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
await typeUrl(S3_ZIP_URL);
await act(async () => {
fireEvent.change(screen.getByPlaceholderText(SHA256_PLACEHOLDER), { target: { value: DIGEST } });
});
const otherZipUrl = S3_ZIP_URL.replace("1.0.0", "1.1.0");
await typeUrl(otherZipUrl);
expect(screen.getByPlaceholderText(SHA256_PLACEHOLDER)).toHaveValue("");
await submit();
await waitFor(() => {
expect(mockRegister).toHaveBeenCalledWith(
"sk-test",
expect.objectContaining({ source: { source: "archive", url: otherZipUrl } }),
);
});
});
it("blocks submission and shows the digest error for a malformed sha256", async () => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
await typeUrl(S3_ZIP_URL);
await act(async () => {
fireEvent.change(screen.getByPlaceholderText(SHA256_PLACEHOLDER), { target: { value: "not-a-digest" } });
});
await submit();
expect(await screen.findByText("SHA-256 must be a 64-character hex digest")).toBeInTheDocument();
expect(mockRegister).not.toHaveBeenCalled();
});
it("surfaces the backend error message when registration fails", async () => {
mockRegister.mockRejectedValueOnce(new Error("Plugin 'claude-code' already exists"));
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
await typeUrl("https://github.com/anthropics/claude-code");
await submit();
await waitFor(() => {
expect(mockMessageError).toHaveBeenCalledWith(expect.stringContaining("Plugin 'claude-code' already exists"));
});
});
it("surfaces the 409 name-conflict reason verbatim without burying it under a generic failure prefix", async () => {
const conflictMessage =
"A skill named 'gitlab' already exists. Update the existing skill instead of adding it again.";
mockRegister.mockRejectedValueOnce(new Error(conflictMessage));
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
await typeUrl("https://github.com/anthropics/claude-code");
await submit();
await waitFor(() => {
expect(mockMessageError).toHaveBeenCalledWith(conflictMessage);
});
expect(mockMessageError).toHaveBeenCalledTimes(1);
});
const chooseUpload = () => fireEvent.click(screen.getByRole("radio", { name: "Upload ZIP" }));
const file = new File(["skill archive"], "personal-skill.zip", { type: "application/zip" });
const chooseFile = (selected = file) =>
fireEvent.change(screen.getByLabelText("Skill ZIP file"), { target: { files: [selected] } });
it("preserves a pending or failed ZIP upload and registers its metadata only after retry succeeds", async () => {
it("preserves the file and explicit version while a pending or failed upload is retried", async () => {
const pending = Promise.withResolvers<never>();
mockUpload.mockReturnValueOnce(pending.promise);
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
await typeUrl(S3_ZIP_URL);
fireEvent.change(screen.getByPlaceholderText(SHA256_PLACEHOLDER), { target: { value: "invalid-for-url" } });
chooseUpload();
uploadSkill.mockReturnValueOnce(pending.promise);
renderWithProviders(<AddPluginForm {...props} />);
chooseFile();
fireEvent.change(screen.getByPlaceholderText("my-skill"), { target: { value: "personal-skill" } });
fireEvent.change(screen.getByPlaceholderText("A skill that helps with..."), { target: { value: "My workflow" } });
await submit();
expect(screen.getByRole("button", { name: "Adding..." })).toBeDisabled();
expect(DEFAULT_PROPS.onClose).not.toHaveBeenCalled();
change("Version (Optional)", "2.0.0");
submit();
expect(await screen.findByRole("button", { name: "Saving..." })).toBeDisabled();
expect(props.onClose).not.toHaveBeenCalled();
await act(async () => pending.reject(new Error("Unable to save skill")));
expect(mockMessageError).toHaveBeenCalledWith("Unable to save skill");
expect(DEFAULT_PROPS.onSuccess).not.toHaveBeenCalled();
expect(screen.getByPlaceholderText("my-skill")).toHaveValue("personal-skill");
await submit();
expect(mockUpload).toHaveBeenLastCalledWith("sk-test", file, {
name: "personal-skill",
description: "My workflow",
});
expect(mockRegister).not.toHaveBeenCalled();
expect(DEFAULT_PROPS.onSuccess).toHaveBeenCalledOnce();
expect(DEFAULT_PROPS.onClose).toHaveBeenCalledOnce();
expect(await screen.findByRole("alert")).toHaveTextContent("Unable to save skill");
expect(props.onSuccess).not.toHaveBeenCalled();
submit();
await waitFor(() => expect(props.onSuccess).toHaveBeenCalledOnce());
expect(uploadSkill).toHaveBeenLastCalledWith("sk-test", file, "2.0.0", undefined);
expect(importSkill).not.toHaveBeenCalled();
expect(props.onClose).toHaveBeenCalledOnce();
});
it.each([
[new File(["text"], "SKILL.md"), "Please choose a .zip file"],
[new File([new Uint8Array(10 * 1024 * 1024 + 1)], "large.zip"), "The ZIP file must be 10 MiB or smaller"],
])("rejects invalid file case %#", async (invalidFile, message) => {
renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
chooseUpload();
fireEvent.change(screen.getByPlaceholderText("my-skill"), { target: { value: "personal-skill" } });
renderWithProviders(<AddPluginForm {...props} />);
chooseFile(invalidFile);
await submit();
expect(screen.getByRole("alert")).toHaveTextContent(message);
expect(mockUpload).not.toHaveBeenCalled();
submit();
expect(await screen.findByRole("alert")).toHaveTextContent(message);
expect(uploadSkill).not.toHaveBeenCalled();
});
it("clears file removal and cancellation, allows reselection, and restores URL validation", async () => {
const { rerender } = renderWithProviders(<AddPluginForm {...DEFAULT_PROPS} />);
chooseUpload();
it("clears a file on mode change, allows reselection and restores source validation", async () => {
renderWithProviders(<AddPluginForm {...props} />);
chooseFile();
fireEvent.click(screen.getByRole("button", { name: "Remove file" }));
expect((screen.getByLabelText("Skill ZIP file") as HTMLInputElement).files).toHaveLength(0);
await submit();
expect(screen.getByRole("alert")).toHaveTextContent("Please choose a .zip file");
fireEvent.click(screen.getByRole("radio", { name: "Import URL" }));
submit();
expect(await screen.findByRole("alert")).toHaveTextContent("Please enter a repository or zip archive URL");
fireEvent.click(screen.getByRole("radio", { name: "Upload ZIP" }));
submit();
expect(await screen.findByRole("alert")).toHaveTextContent("Please choose a .zip file");
chooseFile();
expect(screen.getByRole("button", { name: "Remove file" })).toBeInTheDocument();
submit();
await waitFor(() => expect(uploadSkill).toHaveBeenCalledWith("sk-test", file, "", undefined));
});
it("blocks an invalid version and cancels without publishing", async () => {
renderWithProviders(<AddPluginForm {...props} />);
chooseFile();
change("Version (Optional)", "invalid");
submit();
expect(await screen.findByRole("alert")).toHaveTextContent("Version must be in semantic versioning format");
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
rerender(<AddPluginForm {...DEFAULT_PROPS} visible={false} />);
rerender(<AddPluginForm {...DEFAULT_PROPS} />);
expect(screen.getByRole("radio", { name: "Source URL" })).toBeChecked();
expect(screen.getByPlaceholderText("my-skill")).toHaveValue("");
chooseUpload();
fireEvent.change(screen.getByPlaceholderText("my-skill"), { target: { value: "personal-skill" } });
await submit();
expect(screen.getByRole("alert")).toHaveTextContent("Please choose a .zip file");
fireEvent.click(screen.getByRole("radio", { name: "Source URL" }));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
await submit();
expect(await screen.findByText("Please enter a repository or zip archive URL")).toBeInTheDocument();
expect(mockUpload).not.toHaveBeenCalled();
expect(props.onClose).toHaveBeenCalledOnce();
expect(props.onSuccess).not.toHaveBeenCalled();
expect(uploadSkill).not.toHaveBeenCalled();
});
});

View file

@ -1,565 +1,201 @@
import React, { useState } from "react";
import type { ParseKeys } from "i18next";
import { CircleHelp } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n/i18n";
import { z } from "zod";
import i18n from "@/i18n/i18n";
import { toast } from "@/lib/toast";
import { registerClaudeCodePlugin, uploadClaudeCodeSkill } from "@/components/networking";
import { FieldGroup } from "@/components/ui/field";
import { importSkillHubSkill, updateSkillHubSkill, uploadSkillHubSkill } from "@/components/networking";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { FieldGroup } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useZodForm } from "@/lib/forms/useZodForm";
import {
validatePluginName,
isValidSemanticVersion,
isValidEmail,
parseKeywords,
parseSkillSource,
isValidSubPath,
isValidSha256,
SkillSourcePreview,
} from "@/components/claude_code_plugins/helpers";
import { PluginAuthor, PluginSource, SkillRegisterRequest } from "@/components/claude_code_plugins/types";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { isValidSemanticVersion } from "@/components/claude_code_plugins/helpers";
import { isPendingSkill, type SkillHubSkill, type SkillMutationResponse } from "@/components/claude_code_plugins/types";
import { buildSkillImport, buildSkillMetadata, skillFormValues } from "@/components/claude_code_plugins/skillForm";
interface AddPluginFormProps {
visible: boolean;
onClose: () => void;
accessToken: string | null;
onSuccess: () => void;
onSuccess: (response: SkillMutationResponse) => void;
skill?: SkillHubSkill;
requiresApproval?: boolean;
}
const MAX_SKILL_UPLOAD_BYTES = 10 * 1024 * 1024;
const addPluginShape = {
sourceType: z.enum(["url", "upload"]),
const skillShape = {
mode: z.enum(["upload", "import", "metadata"]),
archiveFile: z
.file()
.refine((file) => file.name.toLowerCase().endsWith(".zip"), {
error: () => i18n.t("skills.addForm.errors.zipRequired"),
})
.max(MAX_SKILL_UPLOAD_BYTES, { error: () => i18n.t("skills.addForm.errors.zipTooLarge") })
.max(10 * 1024 * 1024, { error: () => i18n.t("skills.addForm.errors.zipTooLarge") })
.nullable(),
skillUrl: z.string(),
subPath: z.string(),
sha256: z.string(),
name: z
.string()
.min(1, { error: () => i18n.t("skills.addForm.errors.nameRequired") })
.regex(/^[a-z0-9-]+$/, { error: () => i18n.t("skills.addForm.errors.nameInvalid") }),
url: z.string(),
skill: z.string(),
ref: z.string(),
path: z.string(),
domain: z.string(),
namespace: z.string(),
description: z.string(),
category: z.string().nullable(),
keywords: z.string(),
version: z.string(),
authorName: z.string(),
authorEmail: z.string().refine((value) => value === "" || z.email().safeParse(value).success, {
error: () => i18n.t("skills.addForm.errors.emailInvalid"),
version: z.string().refine((value) => !value.trim() || isValidSemanticVersion(value.trim()), {
error: () => i18n.t("skills.addForm.toasts.invalidVersion"),
}),
};
const addPluginSchema = z.object(addPluginShape).superRefine((values, context) => {
if (values.sourceType === "upload") {
if (!values.archiveFile) {
context.addIssue({ code: "custom", path: ["archiveFile"], message: i18n.t("skills.addForm.errors.zipRequired") });
}
return;
const skillSchema = z.object(skillShape).superRefine((values, context) => {
if (values.mode === "upload" && !values.archiveFile) {
context.addIssue({ code: "custom", path: ["archiveFile"], message: i18n.t("skills.addForm.errors.zipRequired") });
}
if (!values.skillUrl) {
context.addIssue({ code: "custom", path: ["skillUrl"], message: i18n.t("skills.addForm.errors.urlRequired") });
if (values.mode === "import" && !values.url.trim()) {
context.addIssue({ code: "custom", path: ["url"], message: i18n.t("skills.addForm.errors.urlRequired") });
}
if (values.subPath && !isValidSubPath(values.subPath)) {
context.addIssue({ code: "custom", path: ["subPath"], message: i18n.t("skills.addForm.errors.subPathInvalid") });
}
if (!isValidSha256(values.sha256)) {
context.addIssue({ code: "custom", path: ["sha256"], message: i18n.t("skills.addForm.errors.sha256Invalid") });
if (values.mode === "metadata" && values.description.length > 1024) {
context.addIssue({
code: "custom",
path: ["description"],
message: i18n.t("skills.addForm.errors.descriptionTooLong"),
});
}
});
type AddPluginFormValues = z.infer<typeof addPluginSchema>;
const EMPTY_VALUES: AddPluginFormValues = {
sourceType: "url",
archiveFile: null,
skillUrl: "",
subPath: "",
sha256: "",
name: "",
domain: "",
namespace: "",
description: "",
category: null,
keywords: "",
version: "",
authorName: "",
authorEmail: "",
};
const buildAuthor = (values: AddPluginFormValues): PluginAuthor | undefined => {
const name = values.authorName.trim();
const email = values.authorEmail.trim();
if (!name) {
return undefined;
}
return email ? { name, email } : { name };
};
const archiveUrlOf = (preview: SkillSourcePreview | null): string | undefined =>
preview?.parsed.source === "archive" ? preview.parsed.url : undefined;
const withArchiveDigest = (source: PluginSource, sha256: string): PluginSource => {
const digest = sha256.trim();
return source.source === "archive" && digest ? { ...source, sha256: digest.toLowerCase() } : source;
};
const buildSkillMetadata = (values: AddPluginFormValues): Omit<SkillRegisterRequest, "source"> => {
const author = buildAuthor(values);
return {
name: values.name.trim(),
...(values.version ? { version: values.version.trim() } : {}),
...(values.description ? { description: values.description.trim() } : {}),
...(author ? { author } : {}),
...(values.category ? { category: values.category } : {}),
...(values.keywords ? { keywords: parseKeywords(values.keywords) } : {}),
...(values.domain ? { domain: values.domain.trim() } : {}),
...(values.namespace ? { namespace: values.namespace.trim() } : {}),
};
};
const PREDEFINED_CATEGORIES = [
"Development",
"Productivity",
"Learning",
"Security",
"Data & Analytics",
"Integration",
"Testing",
"Documentation",
];
const SUB_PATH_LOCK_REASON_KEY = {
"git-subdir": "skills.addForm.subPathLock.gitSubdir",
archive: "skills.addForm.subPathLock.archive",
} as const satisfies Record<string, ParseKeys>;
type SubPathLock = keyof typeof SUB_PATH_LOCK_REASON_KEY;
const subPathLockFor = (source: PluginSource["source"] | undefined): SubPathLock | null =>
source === "git-subdir" || source === "archive" ? source : null;
const labelWithHint = (label: string, hint: string): React.ReactNode => (
<>
{label}
<Tooltip>
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</>
);
const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessToken, onSuccess }) => {
export default function AddPluginForm({
visible,
onClose,
accessToken,
onSuccess,
skill,
requiresApproval = false,
}: AddPluginFormProps) {
const { t } = useTranslation();
const form = useZodForm(addPluginSchema, { defaultValues: EMPTY_VALUES });
const [isSubmitting, setIsSubmitting] = useState(false);
const [urlPreview, setUrlPreview] = useState<SkillSourcePreview | null>(null);
const [subPathLock, setSubPathLock] = useState<SubPathLock | null>(null);
const [archiveInputKey, setArchiveInputKey] = useState(0);
const isUpload = form.watch("sourceType") === "upload";
const clearArchive = () => {
form.setValue("archiveFile", null);
form.clearErrors("archiveFile");
setArchiveInputKey((key) => key + 1);
};
const resetDraft = () => {
form.reset(EMPTY_VALUES);
setArchiveInputKey((key) => key + 1);
setUrlPreview(null);
setSubPathLock(null);
};
const handleSourceChange = (value: unknown) => {
if (value !== "url" && value !== "upload") return;
form.setValue("sourceType", value);
clearArchive();
form.clearErrors(["skillUrl", "subPath", "sha256"]);
};
const selectArchive = (file: File | undefined) => {
form.setValue("archiveFile", file ?? null, { shouldValidate: true });
if (!file || !addPluginShape.archiveFile.safeParse(file).success) return;
if (!form.getValues("name")) {
form.setValue(
"name",
file.name
.slice(0, -4)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, ""),
);
}
};
const recomputePreview = (skillUrl: string, subPath: string) => {
const lock = subPathLockFor(parseSkillSource(skillUrl)?.parsed.source);
setSubPathLock(lock);
if (lock && form.getValues("subPath")) {
form.setValue("subPath", "");
}
const preview = parseSkillSource(skillUrl, lock ? undefined : subPath);
if (archiveUrlOf(preview) !== archiveUrlOf(urlPreview) && form.getValues("sha256")) {
form.setValue("sha256", "");
}
setUrlPreview(preview);
if (preview && !form.getValues("name")) {
form.setValue("name", preview.suggestedName);
}
};
const handleSubmit = async (values: AddPluginFormValues) => {
if (!accessToken) {
toast.error(t("skills.addForm.toasts.noToken"));
return;
}
if (values.sourceType === "url" && !urlPreview) {
toast.error(t("skills.addForm.toasts.invalidUrl"));
return;
}
if (!validatePluginName(values.name)) {
toast.error(t("skills.addForm.toasts.invalidName"));
return;
}
if (values.version && !isValidSemanticVersion(values.version)) {
toast.error(t("skills.addForm.toasts.invalidVersion"));
return;
}
if (values.authorEmail && !isValidEmail(values.authorEmail)) {
toast.error(t("skills.addForm.toasts.invalidEmail"));
return;
}
setIsSubmitting(true);
const form = useZodForm(skillSchema, { defaultValues: skillFormValues(skill) });
const mode = form.watch("mode");
const [error, setError] = useState<string | null>(null);
const isSubmitting = form.formState.isSubmitting;
const submit = async (values: z.infer<typeof skillSchema>) => {
if (!accessToken) return;
setError(null);
try {
const metadata = buildSkillMetadata(values);
if (values.sourceType === "upload") {
if (!values.archiveFile) return;
await uploadClaudeCodeSkill(accessToken, values.archiveFile, metadata);
} else if (urlPreview) {
await registerClaudeCodePlugin(accessToken, {
...metadata,
source: withArchiveDigest(urlPreview.parsed, values.sha256),
});
}
toast.success(t("skills.addForm.toasts.registered"));
resetDraft();
onSuccess();
const write = () => {
if (values.mode === "upload" && values.archiveFile) {
return uploadSkillHubSkill(accessToken, values.archiveFile, values.version, skill);
}
if (skill) return updateSkillHubSkill(accessToken, skill.name, buildSkillMetadata(values, skill));
return importSkillHubSkill(accessToken, buildSkillImport(values));
};
const response = await write();
const publishedMessage = skill ? "skills.addForm.toasts.updated" : "skills.addForm.toasts.registered";
toast.success(t(isPendingSkill(response) ? "skills.addForm.toasts.submitted" : publishedMessage));
onSuccess(response);
onClose();
} catch (error) {
console.error("Error registering skill:", error);
toast.error(error instanceof Error && error.message ? error.message : t("skills.addForm.toasts.registerFailed"));
} finally {
setIsSubmitting(false);
} catch (err) {
setError(err instanceof Error ? err.message : t("skills.addForm.toasts.registerFailed"));
}
};
const handleCancel = () => {
if (isSubmitting) return;
resetDraft();
onClose();
const changeMode = (value: string) => {
if (value !== "upload" && value !== "import" && value !== "metadata") return;
form.setValue("mode", value);
form.setValue("archiveFile", null);
form.clearErrors();
setError(null);
};
const directLabel = skill ? "skills.addForm.save" : "skills.addForm.submit";
const submitLabel = requiresApproval ? "skills.addForm.submitReview" : directLabel;
const fields =
mode === "import" ? (["url", "skill", "ref", "path"] as const) : (["domain", "namespace", "keywords"] as const);
return (
<Dialog open={visible} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="top-8 max-h-[calc(100dvh-4rem)] translate-y-0 overflow-y-auto sm:max-w-[700px]">
<Dialog open={visible} onOpenChange={(open) => !open && !isSubmitting && onClose()}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{t("skills.addForm.title")}</DialogTitle>
<DialogTitle>{t(skill ? "skills.addForm.editTitle" : "skills.addForm.title")}</DialogTitle>
</DialogHeader>
<TooltipProvider>
<form onSubmit={form.handleSubmit(handleSubmit)} noValidate className="mt-4">
{skill && <p className="text-sm font-medium">{skill.name}</p>}
{requiresApproval && <p className="text-sm text-muted-foreground">{t("skills.addForm.approvalNotice")}</p>}
<form onSubmit={form.handleSubmit(submit)} className="space-y-6">
<fieldset disabled={isSubmitting} className="space-y-6">
<FormField control={form.control} name="mode" label={t("skills.addForm.labels.source")}>
{({ value }) => (
<RadioGroup value={value} onValueChange={changeMode} className="flex gap-5">
{(skill ? (["metadata", "upload"] as const) : (["upload", "import"] as const)).map((option) => (
<label key={option} className="flex items-center gap-2 text-sm">
<RadioGroupItem value={option} />
{t(`skills.addForm.modes.${option}`)}
</label>
))}
</RadioGroup>
)}
</FormField>
<FieldGroup>
<FormField control={form.control} name="sourceType" label={t("skills.addForm.labels.source")}>
{({ value }) => (
<RadioGroup
value={value}
disabled={isSubmitting}
onValueChange={handleSourceChange}
aria-label={t("skills.addForm.labels.source")}
className="flex gap-6"
>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="url" />
{t("skills.addForm.sourceOptions.url")}
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="upload" />
{t("skills.addForm.sourceOptions.upload")}
</label>
</RadioGroup>
)}
</FormField>
{isUpload ? (
{mode === "upload" ? (
<FormField
control={form.control}
name="archiveFile"
label={t("skills.addForm.labels.archive")}
description={t("skills.addForm.hints.archive")}
description={t(skill ? "skills.addForm.replacementHint" : "skills.addForm.uploadHint")}
>
{({ value, ...field }) => (
<>
<Input
{...field}
key={archiveInputKey}
type="file"
accept=".zip,application/zip"
disabled={isSubmitting}
onChange={(event) => selectArchive(event.target.files?.[0])}
/>
{value && (
<Button
type="button"
variant="ghost"
size="sm"
className="self-start"
onClick={clearArchive}
disabled={isSubmitting}
>
{t("skills.addForm.removeFile")}
</Button>
)}
</>
{({ value, onChange, ...field }) => (
<Input
{...field}
type="file"
accept=".zip,application/zip"
onChange={(event) => onChange(event.target.files?.[0] ?? null)}
/>
)}
</FormField>
) : (
<>
<FormField
control={form.control}
name="skillUrl"
label={labelWithHint(t("skills.addForm.labels.sourceUrl"), t("skills.addForm.hints.sourceUrl"))}
>
{({ ref, onChange, ...field }) => (
<Input
{...field}
ref={ref}
placeholder={t("skills.addForm.placeholders.sourceUrl")}
className="rounded-lg"
onChange={(event) => {
onChange(event);
recomputePreview(event.target.value, form.getValues("subPath"));
}}
/>
)}
</FormField>
<FormField
control={form.control}
name="subPath"
label={labelWithHint(t("skills.addForm.labels.subPath"), t("skills.addForm.hints.subPath"))}
description={subPathLock ? t(SUB_PATH_LOCK_REASON_KEY[subPathLock]) : undefined}
>
{({ ref, onChange, ...field }) => (
<Input
{...field}
ref={ref}
placeholder="plugins/my-skill"
className="rounded-lg"
onChange={(event) => {
onChange(event);
recomputePreview(form.getValues("skillUrl"), event.target.value);
}}
disabled={subPathLock !== null}
/>
)}
</FormField>
{urlPreview?.parsed.source === "archive" && (
<FormField
control={form.control}
name="sha256"
label={labelWithHint(t("skills.addForm.labels.sha256"), t("skills.addForm.hints.sha256"))}
>
{({ ref, ...field }) => (
<Input
{...field}
ref={ref}
placeholder={t("skills.addForm.placeholders.sha256")}
className="rounded-lg font-mono"
/>
)}
{mode === "metadata" && (
<FormField control={form.control} name="description" label={t("skills.addForm.labels.description")}>
{(field) => <Textarea {...field} maxLength={1024} />}
</FormField>
)}
{urlPreview && (
<div className="rounded-lg border border-info/20 bg-info/10 px-3 py-2 text-sm text-info">
{t("skills.addForm.detected", { source: urlPreview.label })}
</div>
{fields.map((name) => (
<FormField
key={name}
control={form.control}
name={name}
label={t(`skills.addForm.neutralLabels.${name}`)}
>
{(field) => <Input {...field} />}
</FormField>
))}
{mode === "import" && (
<p className="text-sm text-muted-foreground">{t("skills.addForm.importHint")}</p>
)}
</>
)}
<FormField
control={form.control}
name="name"
label={labelWithHint(t("skills.addForm.labels.name"), t("skills.addForm.hints.name"))}
>
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="my-skill" className="rounded-lg" />}
</FormField>
<div className="flex gap-4">
<FormField
control={form.control}
name="domain"
label={labelWithHint(t("skills.addForm.labels.domain"), t("skills.addForm.hints.domain"))}
className="flex-1"
>
{({ ref, ...field }) => (
<Input
{...field}
ref={ref}
placeholder={t("skills.addForm.placeholders.domain")}
className="rounded-lg"
/>
)}
</FormField>
<FormField
control={form.control}
name="namespace"
label={labelWithHint(t("skills.addForm.labels.namespace"), t("skills.addForm.hints.namespace"))}
className="flex-1"
>
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="workflows" className="rounded-lg" />}
</FormField>
</div>
<FormField
control={form.control}
name="description"
label={labelWithHint(t("skills.addForm.labels.description"), t("skills.addForm.hints.description"))}
>
{({ ref, ...field }) => (
<Textarea
{...field}
ref={ref}
rows={3}
placeholder={t("skills.addForm.placeholders.description")}
maxLength={500}
className="rounded-lg"
/>
)}
</FormField>
<FormField
control={form.control}
name="category"
label={labelWithHint(t("skills.addForm.labels.category"), t("skills.addForm.hints.category"))}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Combobox items={PREDEFINED_CATEGORIES} value={value} onValueChange={onChange}>
<ComboboxInput
id={id}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedBy}
placeholder={t("skills.addForm.placeholders.category")}
className="w-full rounded-lg"
showClear={value != null && value !== ""}
/>
<ComboboxContent>
<ComboboxEmpty>{t("skills.addForm.noCategories")}</ComboboxEmpty>
<ComboboxList>
{(category: string) => (
<ComboboxItem key={category} value={category}>
{category}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)}
</FormField>
<FormField
control={form.control}
name="keywords"
label={labelWithHint(t("skills.addForm.labels.keywords"), t("skills.addForm.hints.keywords"))}
>
{({ ref, ...field }) => (
<Input
{...field}
ref={ref}
placeholder={t("skills.addForm.placeholders.keywords")}
className="rounded-lg"
/>
)}
</FormField>
<FormField
control={form.control}
name="version"
label={labelWithHint(t("skills.addForm.labels.version"), t("skills.addForm.hints.version"))}
label={t(skill ? "skills.addForm.versionOverride" : "skills.addForm.labels.version")}
description={
skill
? t("skills.addForm.versionContext", { version: skill.version ?? t("skills.submissions.notSet") })
: undefined
}
>
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="1.0.0" className="rounded-lg" />}
</FormField>
<FormField
control={form.control}
name="authorName"
label={labelWithHint(t("skills.addForm.labels.authorName"), t("skills.addForm.hints.authorName"))}
>
{({ ref, ...field }) => (
<Input
{...field}
ref={ref}
placeholder={t("skills.addForm.placeholders.authorName")}
className="rounded-lg"
/>
)}
</FormField>
<FormField
control={form.control}
name="authorEmail"
label={labelWithHint(t("skills.addForm.labels.authorEmail"), t("skills.addForm.hints.authorEmail"))}
>
{({ ref, ...field }) => (
<Input {...field} ref={ref} type="email" placeholder="author@example.com" className="rounded-lg" />
)}
{(field) => <Input {...field} placeholder={skill ? t("skills.addForm.automaticVersion") : "1.0.0"} />}
</FormField>
</FieldGroup>
<div className="mt-6 flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleCancel} disabled={isSubmitting}>
{t("skills.addForm.cancel")}
</Button>
<Button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
{isSubmitting && <UiLoadingSpinner className="size-4" />}
{isSubmitting ? t("skills.addForm.adding") : t("skills.addForm.submit")}
</Button>
</div>
</form>
</TooltipProvider>
</fieldset>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
{t("skills.addForm.cancel")}
</Button>
<Button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
{t(isSubmitting ? "skills.addForm.saving" : submitLabel)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
export default AddPluginForm;
}

View file

@ -4,6 +4,6 @@ import ClaudeCodePluginsPanel from "./_components/ClaudeCodePluginsPanel";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export default function Skills() {
const { accessToken, userRole } = useAuthorized();
return <ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />;
const { accessToken, userRole, isViewOnly } = useAuthorized();
return <ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} isViewOnly={isViewOnly} />;
}

View file

@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { Plugin } from "@/components/claude_code_plugins/types";
import { DataTable } from "@/components/shared/DataTable";
import { getSkillHubTableColumns } from "@/components/AIHub/SkillHubTableColumns";
import SkillDetail from "@/components/claude_code_plugins/skill_detail";
import SkillDetail, { legacySkillDetail } from "@/components/claude_code_plugins/skill_detail";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -98,7 +98,7 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
if (selectedSkill) {
return (
<SkillDetail
skill={selectedSkill}
skill={legacySkillDetail(selectedSkill)}
onBack={() => setSelectedSkill(null)}
isAdmin={isAdmin}
accessToken={accessToken}

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { visibleMenuGroups, type MenuGroup, menuGroups } from "@/components/leftnav";
import { toSiTerminology } from "@/lib/terminology";
import { effectiveSessionRole, isViewOnlySessionRole } from "@/utils/roles";
import { flattenNavItems, matchNavItems, paletteScopeForRoute, shortcutLabel, type PaletteNavItem } from "./utils";
describe("paletteScopeForRoute", () => {
@ -48,6 +49,35 @@ describe("visibleMenuGroups", () => {
isTeamAdmin: false,
};
it.each(["internal_user", "internal_user_viewer", "proxy_admin", "proxy_admin_viewer", "org_admin"])(
"keeps Skills reachable for the %s session",
(role) => {
const visible = visibleMenuGroups(menuGroups, {
...context,
userRole: effectiveSessionRole(role),
isViewOnly: isViewOnlySessionRole(role),
});
expect(flattenNavItems(visible).find(({ route }) => route === "skills")).toMatchObject({
label: "Skills",
route: "skills",
});
},
);
it.each(["internal_user", "internal_user_viewer"])("respects the Skills page allowlist for %s", (role) => {
const skillsContext = {
...context,
userRole: effectiveSessionRole(role),
isViewOnly: isViewOnlySessionRole(role),
};
const allowed = visibleMenuGroups(menuGroups, { ...skillsContext, enabledPagesInternalUsers: ["skills"] });
const hidden = visibleMenuGroups(menuGroups, { ...skillsContext, enabledPagesInternalUsers: ["api-keys"] });
expect(flattenNavItems(allowed).map(({ route }) => route)).toEqual(["skills"]);
expect(flattenNavItems(hidden).map(({ route }) => route)).toEqual(["api-keys"]);
});
it("applies the internal-user page allowlist", () => {
const visible = visibleMenuGroups(menuGroups, {
...context,

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { buildSkillImport, buildSkillMetadata, skillFormValues } from "./skillForm";
import type { SkillHubSkill } from "./types";
const skill: SkillHubSkill = {
id: "skill-1",
name: "shared-skill",
version: "2.7.4",
description: "Published instructions",
source: { source: "github", repo: "example/skills", ref: "release", sha: "pinned-commit" },
keywords: ["workflow", "tools"],
domain: "Engineering",
namespace: "workflows",
visibility: "public",
updated_at: "2026-10-20T00:00:00Z",
};
describe("skill form payloads", () => {
it.each([["sales, finance", "review"], [" workflow ", "review", "review"], [], null])(
"preserves unchanged keywords in a complete metadata replacement: %j",
(keywords) => {
const published = { ...skill, keywords };
const values = skillFormValues(published);
expect(values.version).toBe("");
const expected = {
description: "Updated instructions",
keywords,
domain: skill.domain,
namespace: skill.namespace,
version: null,
expected_updated_at: skill.updated_at,
};
expect(buildSkillMetadata({ ...values, description: "Updated instructions" }, published)).toEqual(expected);
},
);
it("parses intentional edits and clears blank metadata without changing the source", () => {
const values = {
...skillFormValues(skill),
domain: "",
namespace: "",
description: "",
keywords: "security, review",
version: "3.0.0",
};
const expected = {
description: null,
domain: null,
namespace: null,
keywords: ["security", "review"],
version: "3.0.0",
expected_updated_at: skill.updated_at,
};
expect(buildSkillMetadata(values, skill)).toEqual(expected);
expect(buildSkillMetadata({ ...values, keywords: "" }, skill).keywords).toEqual([]);
});
it("leaves source URL parsing to the API", () => {
const values = { ...skillFormValues(), url: "https://github.com/example/skills/tree/release/v2/review" };
expect(buildSkillImport(values)).toEqual({ url: values.url });
});
});

View file

@ -0,0 +1,48 @@
import { parseKeywords } from "./helpers";
import type { SkillHubSkill, SkillImport, SkillMetadata } from "./types";
export interface SkillFormValues {
mode: "upload" | "import" | "metadata";
archiveFile: File | null;
url: string;
skill: string;
ref: string;
path: string;
domain: string;
namespace: string;
description: string;
keywords: string;
version: string;
}
export const skillFormValues = (skill?: SkillHubSkill): SkillFormValues => ({
mode: skill ? "metadata" : "upload",
archiveFile: null,
url: "",
skill: "",
ref: "",
path: "",
domain: skill?.domain ?? "",
namespace: skill?.namespace ?? "",
description: skill?.description ?? "",
keywords: skill?.keywords?.join(", ") ?? "",
version: "",
});
export const buildSkillMetadata = (values: SkillFormValues, skill: SkillHubSkill): SkillMetadata => ({
description: values.description.trim() || null,
domain: values.domain.trim() || null,
namespace: values.namespace.trim() || null,
keywords:
values.keywords === skillFormValues(skill).keywords ? skill.keywords ?? null : parseKeywords(values.keywords),
version: values.version.trim() || null,
expected_updated_at: skill.updated_at,
});
export const buildSkillImport = (values: SkillFormValues): SkillImport => ({
url: values.url,
...(values.skill.trim() ? { skill: values.skill.trim() } : {}),
...(values.ref.trim() ? { ref: values.ref.trim() } : {}),
...(values.path.trim() ? { path: values.path.trim() } : {}),
...(values.version.trim() ? { version: values.version.trim() } : {}),
});

View file

@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { getProxyBaseUrl, setGlobalLitellmHeaderName } from "@/components/networking";
import SkillDetail from "./skill_detail";
import SkillDetail, { skillHubDetail } from "./skill_detail";
import type { Plugin } from "./types";
const digest = "a".repeat(64);
@ -23,6 +23,7 @@ const renderSkill = (source = skill.source) =>
renderWithProviders(<SkillDetail skill={{ ...skill, source }} onBack={vi.fn()} accessToken="dashboard-token" />);
beforeEach(() => {
vi.clearAllMocks();
testQueryClient.clear();
fetchMock.mockReset().mockResolvedValue(Response.json({ claude_code_marketplace_auth_required: true }));
vi.stubGlobal("fetch", fetchMock);
@ -34,30 +35,38 @@ afterEach(() => {
vi.unstubAllGlobals();
});
it("retries a private ZIP download through the configured auth header and local route", async () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, "click");
URL.createObjectURL = vi.fn(() => "blob:skill-download");
setGlobalLitellmHeaderName("X-LiteLLM-Key");
renderSkill();
fetchMock.mockResolvedValueOnce(Response.json({ detail: "Archive not found" }, { status: 404 }));
fireEvent.click(screen.getByRole("button", { name: "Download ZIP" }));
expect(screen.getByRole("button", { name: "Downloading…" })).toBeDisabled();
expect(await screen.findByRole("alert")).toHaveTextContent("Archive not found");
expect(click).not.toHaveBeenCalled();
fetchMock.mockResolvedValueOnce(new Response("zip-bytes", { headers: { "Content-Type": "application/zip" } }));
fireEvent.click(screen.getByRole("button", { name: "Download ZIP" }));
await waitFor(() => expect(click).toHaveBeenCalledOnce());
expect((click.mock.contexts[0] as HTMLAnchorElement).download).toBe("my-skill.zip");
expect(fetchMock).toHaveBeenLastCalledWith(
url,
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({ "X-LiteLLM-Key": "Bearer dashboard-token" }),
redirect: "error",
}),
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it.each(["legacy", "portable"])(
"retries a private %s ZIP download through the configured auth header and local route",
async (kind) => {
const click = vi.spyOn(HTMLAnchorElement.prototype, "click");
URL.createObjectURL = vi.fn(() => "blob:skill-download");
setGlobalLitellmHeaderName("X-LiteLLM-Key");
const expectedUrl = kind === "portable" ? `${getProxyBaseUrl()}/skill-hub/skills/my-skill/archive` : url;
const detail =
kind === "portable"
? skillHubDetail({ ...skill, visibility: "private", download_url: "/skill-hub/skills/my-skill/archive" })
: skill;
renderWithProviders(<SkillDetail skill={detail} onBack={vi.fn()} accessToken="dashboard-token" />);
fetchMock.mockResolvedValueOnce(Response.json({ detail: "Archive not found" }, { status: 404 }));
fireEvent.click(screen.getByRole("button", { name: "Download ZIP" }));
expect(screen.getByRole("button", { name: "Downloading…" })).toBeDisabled();
expect(await screen.findByRole("alert")).toHaveTextContent("Archive not found");
expect(click).not.toHaveBeenCalled();
fetchMock.mockResolvedValueOnce(new Response("zip-bytes", { headers: { "Content-Type": "application/zip" } }));
fireEvent.click(screen.getByRole("button", { name: "Download ZIP" }));
await waitFor(() => expect(click).toHaveBeenCalledOnce());
expect((click.mock.contexts[0] as HTMLAnchorElement).download).toBe("my-skill.zip");
expect(fetchMock).toHaveBeenLastCalledWith(
expectedUrl,
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({ "X-LiteLLM-Key": "Bearer dashboard-token" }),
redirect: "error",
}),
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
},
);
it("keeps an external archive as an ordinary link even when its path matches the gateway", () => {
const externalUrl = `https://external.example.com${archivePath}`;

View file

@ -14,20 +14,52 @@ import {
getSourceLink,
MARKETPLACE_KEY_PLACEHOLDER,
} from "./helpers";
import { Plugin } from "./types";
import type { Plugin, SkillHubSkill } from "./types";
import SkillVersionHistory from "./SkillVersionHistory";
type SkillDetailData = Pick<
SkillHubSkill,
"id" | "name" | "source" | "version" | "description" | "domain" | "namespace" | "keywords" | "updated_at"
> &
Pick<Plugin, "enabled"> &
Partial<Pick<Plugin, "created_at" | "category" | "author">> & {
portable?: boolean;
downloadAvailable?: boolean;
};
export const legacySkillDetail = (skill: Plugin): SkillDetailData => skill;
export const skillHubDetail = (skill: SkillHubSkill): SkillDetailData => ({
id: skill.id,
name: skill.name,
source: skill.source,
version: skill.version,
description: skill.description,
domain: skill.domain,
namespace: skill.namespace,
keywords: skill.keywords,
updated_at: skill.updated_at,
enabled: skill.visibility === "public",
portable: true,
downloadAvailable: Boolean(skill.download_url),
});
const SkillSource: React.FC<Pick<SkillDetailProps, "skill" | "accessToken">> = ({ skill, accessToken }) => {
const { t } = useTranslation();
const [downloading, setDownloading] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null);
const { source } = skill;
const archivePath = getManagedSkillArchivePath(
skill.name,
source,
getProxyBaseUrl(),
typeof window !== "undefined" ? window.location.origin : undefined,
);
const portableArchivePath = skill.downloadAvailable
? `/skill-hub/skills/${encodeURIComponent(skill.name)}/archive`
: null;
const archivePath = skill.portable
? portableArchivePath
: getManagedSkillArchivePath(
skill.name,
source,
getProxyBaseUrl(),
typeof window !== "undefined" ? window.location.origin : undefined,
);
const downloadArchive = async () => {
if (!archivePath || downloading) return;
setDownloading(true);
@ -45,7 +77,7 @@ const SkillSource: React.FC<Pick<SkillDetailProps, "skill" | "accessToken">> = (
};
const link = getSourceLink(source);
const href = link && source.source === "git-subdir" && source.path ? `${link}/tree/main/${source.path}` : link;
if (!href && !source.url) return null;
if (!archivePath && !href && !source.url) return null;
return (
<div className="mb-6">
<div className="mb-1 text-xs text-muted-foreground">{t("claudeCodePlugins.skillDetail.overview.source")}</div>
@ -81,14 +113,15 @@ const SkillSource: React.FC<Pick<SkillDetailProps, "skill" | "accessToken">> = (
};
interface SkillDetailProps {
skill: Plugin;
skill: SkillDetailData;
onBack: () => void;
isAdmin?: boolean;
accessToken?: string | null;
onPublishClick?: () => void;
onEditClick?: () => void;
}
const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken }) => {
const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, onEditClick }) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState("overview");
const [copiedKey, setCopiedKey] = useState<string | null>(null);
@ -133,7 +166,7 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken })
const tabs = [
{ key: "overview", label: t("claudeCodePlugins.skillDetail.tabs.overview") },
{ key: "usage", label: t("claudeCodePlugins.skillDetail.tabs.usage") },
...(!skill.portable ? [{ key: "usage", label: t("claudeCodePlugins.skillDetail.tabs.usage") }] : []),
...(accessToken ? [{ key: "history", label: t("claudeCodePlugins.skillDetail.history.title") }] : []),
];
@ -150,7 +183,14 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken })
{/* Header */}
<div className="mb-2">
<h1 className="m-0 text-[28px] font-normal leading-tight text-foreground">{skill.name}</h1>
<div className="flex items-center justify-between gap-4">
<h1 className="m-0 text-[28px] font-normal leading-tight text-foreground">{skill.name}</h1>
{onEditClick && (
<Button variant="outline" onClick={onEditClick}>
{t("skills.panel.editButton")}
</Button>
)}
</div>
{skill.description && (
<p className="mb-0 ml-0 mr-0 mt-2 text-sm leading-relaxed text-muted-foreground">{skill.description}</p>
)}
@ -188,7 +228,7 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken })
{t("claudeCodePlugins.skillDetail.overview.title")}
</h2>
<p className="m-0 mb-4 text-[13px] text-muted-foreground">
{t("claudeCodePlugins.skillDetail.overview.subtitle")}
{t(skill.portable ? "skills.detail.subtitle" : "claudeCodePlugins.skillDetail.overview.subtitle")}
</p>
<table className="w-full border-collapse text-sm">
<thead>
@ -224,7 +264,7 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken })
>
{skill.enabled
? t("claudeCodePlugins.skillDetail.overview.public")
: t("claudeCodePlugins.skillDetail.overview.draft")}
: t(skill.portable ? "skills.detail.private" : "claudeCodePlugins.skillDetail.overview.draft")}
</span>
</div>

View file

@ -19,13 +19,22 @@ export type Plugin = components["schemas"]["PluginListItem"];
export type PluginListItem = Plugin;
export type ListPluginsResponse = components["schemas"]["ListPluginsResponse"];
// Request envelope synced from the OpenAPI spec, with `source` narrowed to our PluginSource
// union and `version` kept optional (the backend supplies its default).
export type SkillRegisterRequest = Omit<components["schemas"]["RegisterPluginRequest"], "source" | "version"> & {
source: PluginSource;
version?: string;
export type SkillRegisterRequest = Omit<components["schemas"]["RegisterPluginRequest"], "version"> & {
version?: components["schemas"]["RegisterPluginRequest"]["version"];
};
export type SkillHubSkill = components["schemas"]["SkillHubSkill"];
export type SkillMetadata = components["schemas"]["SkillHubMetadata"];
export type SkillImport = Omit<components["schemas"]["ImportSkillRequest"], "ref"> &
Partial<Pick<components["schemas"]["ImportSkillRequest"], "ref">>;
export type SkillMutationResponse = SkillHubSkill | components["schemas"]["SkillHubPendingSkill"];
export type SkillSubmission = components["schemas"]["SkillHubSubmission"];
export const isPendingSkill = (
response: SkillMutationResponse,
): response is components["schemas"]["SkillHubPendingSkill"] =>
"status" in response && response.status === "pending_review";
// Public marketplace types
export type MarketplacePluginEntry = Pick<Plugin, "name" | "source"> &
Partial<Pick<Plugin, "version" | "description" | "author" | "homepage" | "keywords" | "category">>;

View file

@ -620,6 +620,14 @@ describe("Sidebar (leftnav)", () => {
expect(screen.queryByRole("link", { name: /Projects/ })).not.toBeInTheDocument();
});
it.each([false, true])("links internal users to Skills with read-only=%s", (isViewOnly) => {
mockUseAuthorized.mockReturnValue({ ...teamMemberAuthorization, isViewOnly });
renderWithProviders(<Sidebar {...defaultProps} />);
expect(screen.getByRole("link", { name: "Skills" })).toHaveAttribute("href", "/ui/skills");
});
it.each([
{ allowlist: ["teams"], visible: false },
{ allowlist: ["teams", "projects"], visible: true },

View file

@ -68,6 +68,7 @@ import { cn } from "@/lib/cva.config";
import { rolesWithCapability } from "../utils/capabilities";
import {
all_admin_roles,
formatUserRole,
internalUserRoles,
isAdminRole,
isUserTeamAdminForAnyTeam,
@ -245,7 +246,13 @@ const menuGroups: MenuGroup[] = [
],
},
{ key: "mcp-servers", page: "mcp-servers", label: "MCP Servers", icon: <Server {...ICON} /> },
{ key: "skills", page: "skills", label: "Skills", icon: <Blocks {...ICON} />, roles: all_admin_roles },
{
key: "skills",
page: "skills",
label: "Skills",
icon: <Blocks {...ICON} />,
roles: [...all_admin_roles, ...internalUserRoles, formatUserRole("org_admin")],
},
{ key: "guardrails", page: "guardrails", label: "Guardrails", icon: <Shield {...ICON} /> },
{
key: "policies",

View file

@ -94,7 +94,12 @@ import { normalizeBackendPageUrlToUiOrigin } from "@/utils/returnUrlUtils";
import { TagNewRequest, TagUpdateRequest, TagListResponse, TagInfoResponse } from "./tag_management/types";
import { Team } from "./key_team_helpers/key_list";
import { EmailEventSettingsResponse, EmailEventSettingsUpdateRequest } from "./email_events/types";
import type { ListPluginsResponse, SkillRegisterRequest } from "./claude_code_plugins/types";
import type {
ListPluginsResponse,
SkillRegisterRequest,
SkillMutationResponse,
SkillImport,
} from "./claude_code_plugins/types";
import type { ModelBudgetUsage, ModelMaxBudget } from "./key_team_helpers/ModelMaxBudgetEditor";
import type { StoredModelMaxBudget } from "./key_team_helpers/modelMaxBudgetPayload";
import type { ObjectPermission } from "./object_permission_types";
@ -6886,11 +6891,6 @@ export const updateUserBanner = async (accessToken: string, banner: UserBannerUp
// Claude Code Marketplace Networking Functions
/**
* List all Claude Code plugins (admin only)
* @param accessToken - Admin access token
* @param enabledOnly - If true, only return enabled plugins (default: false)
*/
export const getClaudeCodePluginsList = async (
accessToken: string,
enabledOnly: boolean = false,
@ -6903,45 +6903,11 @@ export const getClaudeCodePluginsList = async (
return data;
};
/**
* Register a new Claude Code plugin (admin only). Create-only: the proxy returns
* 409 if a plugin with the same name already exists.
* @param accessToken - Admin access token
* @param pluginData - Plugin registration data
*/
export const registerClaudeCodePlugin = async (accessToken: string, pluginData: SkillRegisterRequest) => {
try {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl ? `${proxyBaseUrl}/claude-code/plugins` : `/claude-code/plugins`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(pluginData),
});
if (!response.ok) {
const errorBody = await response.text();
let errorMessage: string;
try {
errorMessage = deriveErrorMessage(JSON.parse(errorBody));
} catch {
errorMessage = errorBody || `Request failed with status ${response.status}`;
}
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to register Claude Code plugin:", error);
throw error;
}
};
export const registerClaudeCodePlugin = (
accessToken: string,
pluginData: SkillRegisterRequest,
): Promise<components["schemas"]["RegisterPluginResponse"]> =>
apiClient.post("/claude-code/plugins", { accessToken, body: pluginData });
export const uploadClaudeCodeSkill = async (
accessToken: string,
@ -6954,6 +6920,64 @@ export const uploadClaudeCodeSkill = async (
return apiClient.post("/claude-code/plugins/upload", { accessToken, rawBody: body });
};
export const listSkillHubSkills = (accessToken: string, query: { limit: number; offset: number }) =>
apiClient.get<components["schemas"]["SkillHubList"]>("/skill-hub/skills", { accessToken, query });
export const getSkillHubSkill = (accessToken: string, name: string) =>
apiClient.get<components["schemas"]["SkillHubSkill"]>(`/skill-hub/skills/${encodeURIComponent(name)}`, {
accessToken,
});
export const importSkillHubSkill = (accessToken: string, body: SkillImport) =>
apiClient.post<SkillMutationResponse>("/skill-hub/skills/import", { accessToken, body });
export const updateSkillHubSkill = (
accessToken: string,
name: string,
body: components["schemas"]["SkillHubMetadata"],
) => apiClient.put<SkillMutationResponse>(`/skill-hub/skills/${encodeURIComponent(name)}`, { accessToken, body });
export const uploadSkillHubSkill = (
accessToken: string,
file: File,
version: string,
skill?: components["schemas"]["SkillHubSkill"],
): Promise<SkillMutationResponse> => {
const body = new FormData();
body.append("file", file);
if (version.trim()) body.append("version", version.trim());
if (skill?.updated_at) body.append("expected_updated_at", skill.updated_at);
return skill
? apiClient.put(`/skill-hub/skills/${encodeURIComponent(skill.name)}/upload`, { accessToken, rawBody: body })
: apiClient.post("/skill-hub/skills/upload", { accessToken, rawBody: body });
};
export const deleteSkillHubSkill = (accessToken: string, name: string) =>
apiClient.delete(`/skill-hub/skills/${encodeURIComponent(name)}`, { accessToken });
export const listSkillSubmissions = (
accessToken: string,
query: { limit: number; offset: number },
): Promise<components["schemas"]["SkillHubSubmissionList"]> =>
apiClient.get("/skill-hub/submissions", { accessToken, query });
export const getSkillSubmission = (
accessToken: string,
id: string,
): Promise<components["schemas"]["SkillHubSubmissionDetail"]> =>
apiClient.get(`/skill-hub/submissions/${encodeURIComponent(id)}`, { accessToken });
export const reviewSkillSubmission = (
accessToken: string,
id: string,
action: "approve" | "reject",
body?: components["schemas"]["SkillHubRejection"],
): Promise<components["schemas"]["SkillHubReviewResponse"]> =>
apiClient.put(`/skill-hub/submissions/${encodeURIComponent(id)}/${action}`, { accessToken, body });
export const downloadSkillSubmission = (accessToken: string, id: string): Promise<Blob> =>
apiClient.getBlob(`/skill-hub/submissions/${encodeURIComponent(id)}/archive`, { accessToken, redirect: "error" });
/**
* Enable a Claude Code plugin (admin only)
* @param accessToken - Admin access token

View file

@ -1599,7 +1599,7 @@
"skills": {
"panel": {
"title": "Skills",
"intro": "Register Claude Code skills. Published skills appear in the Skill Hub for all users and are served via <endpoint/>.",
"intro": "Upload or import portable skills. Review proposed changes before publishing them to the Skills Hub.",
"addButton": "+ Add Skill",
"deleteDialog": {
"title": "Delete Skill",
@ -1611,7 +1611,15 @@
"toasts": {
"deleted": "Skill \"{{name}}\" deleted successfully",
"deleteFailed": "Failed to delete skill"
}
},
"editButton": "Edit Skill",
"tabs": {
"skills": "Published skills",
"submissions": "Submissions",
"mySubmissions": "My submissions"
},
"loadFailed": "Could not refresh published skills. The last loaded list may be out of date",
"detailFailed": "Could not load the latest skill details. Retry before editing"
},
"table": {
"empty": {
@ -1637,7 +1645,9 @@
"category": "Category",
"public": "Public",
"createdAt": "Created At",
"actions": "Actions"
"actions": "Actions",
"domain": "Domain",
"updatedAt": "Updated at"
}
},
"addForm": {
@ -1704,7 +1714,8 @@
"sha256Invalid": "SHA-256 must be a 64-character hex digest",
"nameRequired": "Please enter skill name",
"nameInvalid": "Name must be kebab-case (lowercase, numbers, hyphens only)",
"emailInvalid": "Please enter a valid email"
"emailInvalid": "Please enter a valid email",
"descriptionTooLong": "Description must be 1024 characters or fewer"
},
"toasts": {
"noToken": "No access token available",
@ -1713,8 +1724,98 @@
"invalidVersion": "Version must be in semantic versioning format (e.g., 1.0.0)",
"invalidEmail": "Invalid email format",
"registered": "Skill registered successfully",
"registerFailed": "Failed to register skill"
}
"registerFailed": "Failed to register skill",
"submitted": "Submitted for admin approval. The published skill is unchanged",
"updated": "Skill updated successfully"
},
"editTitle": "Edit Skill",
"approvalNotice": "Your changes will be submitted for admin approval. Published skills stay unchanged until approval",
"submitReview": "Submit for approval",
"save": "Save changes",
"versionOverride": "Version override (optional)",
"versionContext": "Current version {{version}}. Leave blank for the next patch version, or enter a higher version",
"automaticVersion": "Automatic next patch",
"versionOverrideHint": "Leave blank to assign the next patch version when this change is published",
"modes": {
"upload": "Upload ZIP",
"import": "Import URL",
"metadata": "Edit metadata"
},
"neutralLabels": {
"url": "Source URL",
"skill": "Skill selector (optional)",
"ref": "Git ref (optional)",
"path": "Path (optional)",
"domain": "Domain (optional)",
"namespace": "Namespace (optional)",
"keywords": "Keywords (optional)"
},
"uploadHint": "Choose a ZIP up to 10 MiB containing SKILL.md. Its frontmatter supplies the skill name and description.",
"replacementHint": "The replacement SKILL.md supplies the description. Domain, namespace, and keywords are preserved.",
"importHint": "Enter the original source URL. Use a skill selector or path when the repository contains several skills. Leave the ref blank to use HEAD.",
"saving": "Saving..."
},
"submissions": {
"adminIntro": "Review submitted skills and changes before publishing them",
"authorIntro": "Your submissions and review outcomes. Pending changes are not published",
"loading": "Loading submissions...",
"loadFailed": "Could not refresh submissions. Previously loaded details may be out of date",
"retry": "Retry",
"empty": "No submissions yet",
"emptyPage": "No submissions on this page",
"page": "Page {{page}}",
"previous": "Previous",
"next": "Next",
"review": "Review",
"details": "View details",
"reviewTitle": "Submission for {{name}}",
"field": "Field",
"current": "Currently published",
"proposed": "Proposed",
"notSet": "Not set",
"noCurrent": "No currently published skill is available for comparison",
"pendingNotice": "Awaiting admin approval. The published skill remains unchanged",
"submittedBy": "Submitted by {{name}}",
"reviewedBy": "Reviewed by {{name}} on {{date}}",
"rejectionReason": "Rejection reason: {{reason}}",
"reasonLabel": "Rejection reason (optional)",
"approve": "Approve and publish",
"reject": "Reject",
"approved": "Submission approved and published",
"rejected": "Submission rejected. Published skills are unchanged",
"actionFailed": "Could not review this submission",
"download": "Download submitted ZIP",
"downloading": "Downloading...",
"downloadFailed": "Could not download the submitted ZIP",
"status": {
"pending_review": "Pending review",
"approved": "Approved",
"rejected": "Rejected"
},
"operation": {
"create": "New skill",
"update": "Skill update"
},
"fields": {
"name": "Name",
"version": "Version",
"description": "Description",
"source": "Source",
"author": "Author",
"homepage": "Homepage",
"keywords": "Keywords",
"category": "Category",
"domain": "Domain",
"namespace": "Namespace",
"installation_preference": "Installation preference",
"agent_skill": "Portable skill descriptor"
},
"nextPatchVersion": "Next patch version on approval",
"descriptorUnavailable": "The current portable descriptor is not included in this response"
},
"detail": {
"subtitle": "Published skill metadata and portable archive",
"private": "Private"
}
},
"claudeCodePlugins": {

View file

@ -15745,7 +15745,7 @@ export interface paths {
get: operations["get_skill_skill_hub_skills__name__get"];
/**
* Replace Skill Metadata
* @description Replace registry metadata, clearing omitted fields and preserving skill files.
* @description Replace registry metadata, preserving files. Members submit an immutable proposal for admin review.
*/
put: operations["replace_skill_metadata_skill_hub_skills__name__put"];
post?: never;
@ -15839,6 +15839,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/skill-hub/skills/{name}/upload": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Replace Skill Archive
* @description Replace skill files, retaining registry fields. Members propose an update against the original editor timestamp.
*/
put: operations["replace_skill_archive_skill_hub_skills__name__upload_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/skill-hub/skills/{name}/versions": {
parameters: {
query?: never;
@ -15899,6 +15919,106 @@ export interface paths {
patch?: never;
trace?: never;
};
"/skill-hub/submissions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Submissions
* @description Read a page of owned proposals. Proxy admins and admin viewers can read every owner's proposals.
*/
get: operations["list_submissions_skill_hub_submissions_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/skill-hub/submissions/{submission_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Submission
* @description Read an immutable proposal and its currently visible published skill.
*/
get: operations["get_submission_skill_hub_submissions__submission_id__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/skill-hub/submissions/{submission_id}/approve": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Approve Submission
* @description Publish a pending proposal and record its approval atomically. Only proxy admins may review.
*/
put: operations["approve_submission_skill_hub_submissions__submission_id__approve_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/skill-hub/submissions/{submission_id}/archive": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Download Submission
* @description Download an owned proposal as a private portable ZIP. Legacy nonportable archives return 404.
*/
get: operations["download_submission_skill_hub_submissions__submission_id__archive_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/skill-hub/submissions/{submission_id}/reject": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Reject Submission
* @description Reject a pending proposal without changing the published skill. Only proxy admins may review.
*/
put: operations["reject_submission_skill_hub_submissions__submission_id__reject_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/spend/calculate": {
parameters: {
query?: never;
@ -28297,6 +28417,18 @@ export interface components {
/** Token */
token: string;
};
/** Body_replace_skill_archive_skill_hub_skills__name__upload_put */
Body_replace_skill_archive_skill_hub_skills__name__upload_put: {
/** Expected Updated At */
expected_updated_at?: string | null;
/**
* File
* @description Replacement Agent Skills ZIP with the same declared skill name
*/
file: string;
/** Version */
version?: string | null;
};
/** Body_revoke_endpoint_revoke_post */
Body_revoke_endpoint_revoke_post: {
/** Client Id */
@ -46660,6 +46792,17 @@ export interface components {
/** Updated At */
updated_at: string;
};
/** SkillHubAgentSkill */
SkillHubAgentSkill: {
/** Description */
description: string;
/** Digest */
digest: string;
/** Name */
name: string;
/** Origin */
origin: string | null;
};
/** SkillHubList */
SkillHubList: {
/** Count */
@ -46690,20 +46833,85 @@ export interface components {
*/
version?: string | null;
};
/** SkillHubPendingSkill */
SkillHubPendingSkill: {
/**
* Action
* @default submitted
* @constant
*/
action: "submitted";
proposal: components["schemas"]["SkillHubProposal"];
/**
* Status
* @default pending_review
* @constant
*/
status: "pending_review";
/** Submission Id */
submission_id: string;
};
/** SkillHubProposal */
SkillHubProposal: {
agent_skill?: components["schemas"]["SkillHubAgentSkill"] | null;
/** Description */
description: string | null;
/** Domain */
domain?: string | null;
/** Keywords */
keywords?: string[] | null;
/** Name */
name: string;
/** Namespace */
namespace?: string | null;
/** Source */
source: {
[key: string]: string;
};
/** Version */
version: string | null;
};
/** SkillHubPublication */
SkillHubPublication: Record<string, never>;
/** SkillHubRejection */
SkillHubRejection: {
/** Reason */
reason?: string | null;
};
/** SkillHubReviewResponse */
SkillHubReviewResponse: {
/**
* Action
* @enum {string}
*/
action: "approved" | "rejected";
skill: components["schemas"]["SkillHubSkill"] | null;
/**
* Status
* @default success
* @constant
*/
status: "success";
submission: components["schemas"]["SkillHubSubmission"];
};
/** SkillHubSkill */
SkillHubSkill: {
/** Content Url */
content_url?: string | null;
/** Description */
description?: string | null;
/** Domain */
domain?: string | null;
/** Download Url */
download_url?: string | null;
/** Id */
id: string;
/** Keywords */
keywords?: string[] | null;
/** Name */
name: string;
/** Namespace */
namespace?: string | null;
/** Source */
source: {
[key: string]: string;
@ -46718,6 +46926,57 @@ export interface components {
*/
visibility: "public" | "private";
};
/** SkillHubSubmission */
SkillHubSubmission: {
/** Base Skill Id */
base_skill_id: string | null;
/** Base Updated At */
base_updated_at: string | null;
/**
* Created At
* Format: date-time
*/
created_at: string;
/** Has Archive */
has_archive: boolean;
/** Id */
id: string;
/**
* Operation
* @enum {string}
*/
operation: "create" | "update";
proposal: components["schemas"]["SkillHubProposal"];
/** Rejection Reason */
rejection_reason: string | null;
/** Reviewed At */
reviewed_at: string | null;
/** Reviewed By */
reviewed_by: string | null;
/** Skill Name */
skill_name: string;
/**
* Status
* @enum {string}
*/
status: "pending_review" | "approved" | "rejected";
/** Submitted By */
submitted_by: string;
};
/** SkillHubSubmissionDetail */
SkillHubSubmissionDetail: {
current_skill: components["schemas"]["SkillHubSkill"] | null;
submission: components["schemas"]["SkillHubSubmission"];
};
/** SkillHubSubmissionList */
SkillHubSubmissionList: {
/** Count */
count: number;
/** Has More */
has_more: boolean;
/** Submissions */
submissions: components["schemas"]["SkillHubSubmission"][];
};
/** SkillHubVersion */
SkillHubVersion: {
/**
@ -73927,7 +74186,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubSkill"];
"application/json": components["schemas"]["SkillHubSkill"] | components["schemas"]["SkillHubPendingSkill"];
};
};
/** @description Validation Error */
@ -73960,7 +74219,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubSkill"];
"application/json": components["schemas"]["SkillHubSkill"] | components["schemas"]["SkillHubPendingSkill"];
};
};
/** @description Validation Error */
@ -74026,7 +74285,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubSkill"];
"application/json": components["schemas"]["SkillHubSkill"] | components["schemas"]["SkillHubPendingSkill"];
};
};
/** @description Validation Error */
@ -74203,6 +74462,41 @@ export interface operations {
};
};
};
replace_skill_archive_skill_hub_skills__name__upload_put: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_replace_skill_archive_skill_hub_skills__name__upload_put"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubSkill"] | components["schemas"]["SkillHubPendingSkill"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_skill_versions_skill_hub_skills__name__versions_get: {
parameters: {
query?: {
@ -74301,6 +74595,167 @@ export interface operations {
};
};
};
list_submissions_skill_hub_submissions_get: {
parameters: {
query?: {
status?: ("pending_review" | "approved" | "rejected") | null;
limit?: number;
offset?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubSubmissionList"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_submission_skill_hub_submissions__submission_id__get: {
parameters: {
query?: never;
header?: never;
path: {
submission_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubSubmissionDetail"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
approve_submission_skill_hub_submissions__submission_id__approve_put: {
parameters: {
query?: never;
header?: never;
path: {
submission_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubReviewResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
download_submission_skill_hub_submissions__submission_id__archive_get: {
parameters: {
query?: never;
header?: never;
path: {
submission_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/zip": string;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
reject_submission_skill_hub_submissions__submission_id__reject_put: {
parameters: {
query?: never;
header?: never;
path: {
submission_id: string;
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": components["schemas"]["SkillHubRejection"] | null;
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SkillHubReviewResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
calculate_spend_spend_calculate_post: {
parameters: {
query?: never;