mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-07 07:58:09 +00:00
feat(skills)!: add portable submissions and admin review (#50160)
This commit is contained in:
parent
0ad5d750d4
commit
7e4ad6eb33
47 changed files with 5058 additions and 1280 deletions
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -342,6 +342,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset(
|
|||
"LiteLLM_ClaudeCodePluginTable",
|
||||
"LiteLLM_ClaudeCodePluginUpload",
|
||||
"LiteLLM_ClaudeCodePluginVersion",
|
||||
"LiteLLM_SkillSubmission",
|
||||
"LiteLLM_MemoryTable",
|
||||
"LiteLLM_AdaptiveRouterState",
|
||||
"LiteLLM_AdaptiveRouterSession",
|
||||
|
|
|
|||
|
|
@ -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: ...
|
||||
|
|
|
|||
94
litellm/models/skill_submission.py
Normal file
94
litellm/models/skill_submission.py
Normal 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: ...
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 ##
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
|
|
|||
172
litellm/proxy/discovery_endpoints/skill_hub_mutations.py
Normal file
172
litellm/proxy/discovery_endpoints/skill_hub_mutations.py
Normal 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)
|
||||
192
litellm/proxy/discovery_endpoints/skill_hub_submissions.py
Normal file
192
litellm/proxy/discovery_endpoints/skill_hub_submissions.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
288
litellm/repositories/skill_submission_repository.py
Normal file
288
litellm/repositories/skill_submission_repository.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
373
tests/integration/database/test_skill_submissions.py
Normal file
373
tests/integration/database/test_skill_submissions.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
)
|
||||
|
|
|
|||
74
tests/unit/proxy/skill_submission_rows.py
Normal file
74
tests/unit/proxy/skill_submission_rows.py
Normal 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]
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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() } : {}),
|
||||
});
|
||||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">>;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
463
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
463
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue