feat(skills): show download counts and downloader history (#50165)

This commit is contained in:
tin-berri 2026-11-05 05:25:08 -08:00 • committed by GitHub
parent 79e63d3e06
commit aa7917a3d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1708 additions and 89 deletions

View file

@ -0,0 +1,37 @@
SET LOCAL lock_timeout = '10s';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_attribute
WHERE attrelid = to_regclass('"LiteLLM_ClaudeCodePluginTable"')
AND attname = 'anonymous_download_count' AND NOT attisdropped
) THEN
ALTER TABLE "LiteLLM_ClaudeCodePluginTable"
ADD COLUMN IF NOT EXISTS "anonymous_download_count" BIGINT NOT NULL DEFAULT 0;
END IF;
IF to_regclass('"LiteLLM_ClaudeCodePluginDownload"') IS NULL THEN
CREATE TABLE IF NOT EXISTS "LiteLLM_ClaudeCodePluginDownload" (
"id" BIGSERIAL NOT NULL,
"plugin_id" TEXT NOT NULL,
"downloaded_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"version" TEXT,
"digest" TEXT NOT NULL,
"authenticated" BOOLEAN NOT NULL,
"user_id" TEXT,
"user_email" TEXT,
"key_alias" TEXT,
"team_id" TEXT,
"team_alias" TEXT,
CONSTRAINT "LiteLLM_ClaudeCodePluginDownload_pkey" PRIMARY KEY ("id"),
CONSTRAINT "LiteLLM_ClaudeCodePluginDownload_plugin_id_fkey" FOREIGN KEY ("plugin_id")
REFERENCES "LiteLLM_ClaudeCodePluginTable"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
END IF;
IF to_regclass('"LiteLLM_ClaudeCodePluginDownload_plugin_id_id_idx"') IS NULL THEN
CREATE INDEX IF NOT EXISTS "LiteLLM_ClaudeCodePluginDownload_plugin_id_id_idx"
ON "LiteLLM_ClaudeCodePluginDownload"("plugin_id", "id");
END IF;
END $$;

View file

@ -1617,6 +1617,8 @@ model LiteLLM_ClaudeCodePluginTable {
uploaded_archive LiteLLM_ClaudeCodePluginUpload?
versions LiteLLM_ClaudeCodePluginVersion[]
downloads LiteLLM_ClaudeCodePluginDownload[]
anonymous_download_count BigInt @default(0)
@@map("LiteLLM_ClaudeCodePluginTable")
}
@ -1660,6 +1662,23 @@ model LiteLLM_SkillSubmission {
@@index([created_at(sort: Desc), id(sort: Desc)])
}
model LiteLLM_ClaudeCodePluginDownload {
id BigInt @id @default(autoincrement())
plugin_id String
downloaded_at DateTime @default(now())
version String?
digest String
authenticated Boolean
user_id String?
user_email String?
key_alias String?
team_id String?
team_alias String?
plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([plugin_id, id])
}
model LiteLLM_ClaudeCodePluginUpload {
plugin_name String @id
content Bytes

View file

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

View file

@ -1,6 +1,7 @@
from collections.abc import AsyncIterator, Mapping
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from types import MappingProxyType
from typing import Literal, Protocol
@ -97,6 +98,43 @@ class PluginSnapshot:
created_by: str | None
@dataclass(frozen=True, slots=True)
class PluginDownloader:
authenticated: bool
user_id: str | None
user_email: str | None
key_alias: str | None
team_id: str | None
team_alias: str | None
class PluginDownloadResult(Enum):
RECORDED = "recorded"
SKILL_NOT_FOUND = "skill_not_found"
@dataclass(frozen=True, slots=True)
class PluginDownloadEvent:
id: str
downloaded_at: datetime
version: str | None
digest: str
authenticated: bool
user_id: str | None
user_email: str | None
key_alias: str | None
team_id: str | None
team_alias: str | None
@dataclass(frozen=True, slots=True)
class PluginDownloadsResponse:
total_downloads: int
anonymous_downloads: int
downloads: tuple[PluginDownloadEvent, ...]
next_cursor: str | None
class PluginUploadStore(Protocol):
async def create(self, upload: PluginUpload) -> str | None: ...
@ -117,3 +155,11 @@ class PluginUploadStore(Protocol):
async def get_content(self, metadata: PluginArchiveMetadata) -> bytes | None: ...
def public_metadata(self) -> AsyncIterator[PluginArchiveMetadata]: ...
async def record_download(
self, metadata: PluginArchiveMetadata, digest: str, downloader: PluginDownloader
) -> PluginDownloadResult: ...
async def downloads(self, name: str, limit: int, cursor: int | None) -> PluginDownloadsResponse | None: ...
async def download_counts(self, plugin_ids: tuple[str, ...]) -> Mapping[str, int]: ...

View file

@ -7541,6 +7541,145 @@
"title": "PluginAuthor",
"type": "object"
},
"PluginDownloadEvent": {
"properties": {
"authenticated": {
"title": "Authenticated",
"type": "boolean"
},
"digest": {
"title": "Digest",
"type": "string"
},
"downloaded_at": {
"format": "date-time",
"title": "Downloaded At",
"type": "string"
},
"id": {
"title": "Id",
"type": "string"
},
"key_alias": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Key Alias"
},
"team_alias": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Team Alias"
},
"team_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Team Id"
},
"user_email": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "User Email"
},
"user_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "User Id"
},
"version": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Version"
}
},
"required": [
"id",
"downloaded_at",
"version",
"digest",
"authenticated",
"user_id",
"user_email",
"key_alias",
"team_id",
"team_alias"
],
"title": "PluginDownloadEvent",
"type": "object"
},
"PluginDownloadsResponse": {
"properties": {
"anonymous_downloads": {
"title": "Anonymous Downloads",
"type": "integer"
},
"downloads": {
"items": {
"$ref": "#/components/schemas/PluginDownloadEvent"
},
"title": "Downloads",
"type": "array"
},
"next_cursor": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Next Cursor"
},
"total_downloads": {
"title": "Total Downloads",
"type": "integer"
}
},
"required": [
"total_downloads",
"anonymous_downloads",
"downloads",
"next_cursor"
],
"title": "PluginDownloadsResponse",
"type": "object"
},
"PluginListItem": {
"description": "Plugin item in list responses.",
"properties": {
@ -8703,6 +8842,87 @@
]
}
},
"/claude-code/plugins/{plugin_name}/downloads": {
"get": {
"description": "Admin-only ZIP download totals and authenticated downloader history.\n\nAnonymous downloads contribute only to totals. Counts include repeat requests and client prefetches,\nstarting when tracking is deployed. External sources and installation completion are not observed.",
"operationId": "get_plugin_downloads_claude_code_plugins__plugin_name__downloads_get",
"parameters": [
{
"in": "path",
"name": "plugin_name",
"required": true,
"schema": {
"title": "Plugin Name",
"type": "string"
}
},
{
"description": "Maximum number of download events",
"in": "query",
"name": "limit",
"required": false,
"schema": {
"default": 25,
"description": "Maximum number of download events",
"maximum": 100,
"minimum": 1,
"title": "Limit",
"type": "integer"
}
},
{
"description": "Previous next_cursor",
"in": "query",
"name": "cursor",
"required": false,
"schema": {
"anyOf": [
{
"pattern": "^[1-9][0-9]{0,18}$",
"type": "string"
},
{
"type": "null"
}
],
"description": "Previous next_cursor",
"title": "Cursor"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PluginDownloadsResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Get Plugin Downloads",
"tags": [
"claude_code_marketplace"
]
}
},
"/claude-code/plugins/{plugin_name}/enable": {
"post": {
"description": "Enable a disabled plugin.\n\nRequires a proxy admin API key.\n\nParameters:\n - plugin_name: The name of the plugin to enable",
@ -58998,6 +59218,145 @@
"title": "ImportSkillRequest",
"type": "object"
},
"PluginDownloadEvent": {
"properties": {
"authenticated": {
"title": "Authenticated",
"type": "boolean"
},
"digest": {
"title": "Digest",
"type": "string"
},
"downloaded_at": {
"format": "date-time",
"title": "Downloaded At",
"type": "string"
},
"id": {
"title": "Id",
"type": "string"
},
"key_alias": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Key Alias"
},
"team_alias": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Team Alias"
},
"team_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Team Id"
},
"user_email": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "User Email"
},
"user_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "User Id"
},
"version": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Version"
}
},
"required": [
"id",
"downloaded_at",
"version",
"digest",
"authenticated",
"user_id",
"user_email",
"key_alias",
"team_id",
"team_alias"
],
"title": "PluginDownloadEvent",
"type": "object"
},
"PluginDownloadsResponse": {
"properties": {
"anonymous_downloads": {
"title": "Anonymous Downloads",
"type": "integer"
},
"downloads": {
"items": {
"$ref": "#/components/schemas/PluginDownloadEvent"
},
"title": "Downloads",
"type": "array"
},
"next_cursor": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Next Cursor"
},
"total_downloads": {
"title": "Total Downloads",
"type": "integer"
}
},
"required": [
"total_downloads",
"anonymous_downloads",
"downloads",
"next_cursor"
],
"title": "PluginDownloadsResponse",
"type": "object"
},
"SkillHubAgentSkill": {
"properties": {
"description": {
@ -59364,6 +59723,18 @@
],
"title": "Domain"
},
"download_count": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"description": "Gateway ZIP downloads, included in proxy-admin catalog listings",
"title": "Download Count"
},
"download_url": {
"anyOf": [
{
@ -60840,6 +61211,87 @@
]
}
},
"/skill-hub/skills/{plugin_name}/downloads": {
"get": {
"description": "Admin-only ZIP download totals and authenticated downloader history.\n\nAnonymous downloads contribute only to totals. Counts include repeat requests and client prefetches,\nstarting when tracking is deployed. External sources and installation completion are not observed.",
"operationId": "get_plugin_downloads_skill_hub_skills__plugin_name__downloads_get",
"parameters": [
{
"in": "path",
"name": "plugin_name",
"required": true,
"schema": {
"title": "Plugin Name",
"type": "string"
}
},
{
"description": "Maximum number of download events",
"in": "query",
"name": "limit",
"required": false,
"schema": {
"default": 25,
"description": "Maximum number of download events",
"maximum": 100,
"minimum": 1,
"title": "Limit",
"type": "integer"
}
},
{
"description": "Previous next_cursor",
"in": "query",
"name": "cursor",
"required": false,
"schema": {
"anyOf": [
{
"pattern": "^[1-9][0-9]{0,18}$",
"type": "string"
},
{
"type": "null"
}
],
"description": "Previous next_cursor",
"title": "Cursor"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PluginDownloadsResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Get Plugin Downloads",
"tags": [
"skill_hub"
]
}
},
"/skill-hub/submissions": {
"get": {
"description": "Read a page of owned proposals. Proxy admins and admin viewers can read every owner's proposals.",

View file

@ -457,6 +457,7 @@ class LiteLLMRoutes(enum.Enum):
"/skill-hub/skills/{name}",
"/skill-hub/skills/{name}/archive",
"/skill-hub/skills/{name}/content",
"/skill-hub/skills/{plugin_name}/downloads",
"/skill-hub/skills/{name}/versions",
"/skill-hub/skills/{name}/versions/{version_id}",
"/skill-hub/skills/{name}/versions/{version_id}/archive",
@ -490,6 +491,7 @@ class LiteLLMRoutes(enum.Enum):
"/claude-code/plugins",
"/claude-code/plugins/upload",
"/claude-code/plugins/{plugin_name}/archive/{digest}.zip",
"/claude-code/plugins/{plugin_name}/downloads",
"/claude-code/plugins/{plugin_name}",
]

View file

@ -27,14 +27,17 @@ from datetime import datetime, timezone
from types import MappingProxyType
from typing import Annotated, Final, Protocol, TypedDict, get_args
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import JSONResponse, Response
from pydantic import Json, TypeAdapter
from typing_extensions import ReadOnly
from typing_extensions import ReadOnly, assert_never
from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.url_utils import is_https_url_with_host
from litellm.models.plugin_upload import (
PluginDownloader,
PluginDownloadResult,
PluginDownloadsResponse,
PluginPublication,
PluginPublicationBase,
PluginPublicationError,
@ -240,6 +243,27 @@ async def upload_plugin(
)
def require_download_recorded(result: PluginDownloadResult) -> None:
match result:
case PluginDownloadResult.RECORDED:
return
case PluginDownloadResult.SKILL_NOT_FOUND:
raise _error_response(404, "Skill archive not found")
case _:
assert_never(result)
def plugin_downloader(caller: UserAPIKeyAuth | None) -> PluginDownloader:
return PluginDownloader(
authenticated=caller is not None,
user_id=caller.user_id if caller is not None else None,
user_email=caller.user_email if caller is not None else None,
key_alias=caller.key_alias if caller is not None else None,
team_id=caller.team_id if caller is not None else None,
team_alias=caller.team_alias if caller is not None else None,
)
@router.get(
"/claude-code/plugins/{plugin_name}/archive/{digest}.zip",
tags=["Claude Code Marketplace"],
@ -273,6 +297,7 @@ async def download_plugin_archive(
content: Final = await uploads.get_content(archive)
if content is None:
raise _error_response(404, "Skill archive not found")
require_download_recorded(await uploads.record_download(archive, digest, plugin_downloader(caller)))
return Response(
content=content,
media_type="application/zip",
@ -284,6 +309,35 @@ async def download_plugin_archive(
)
@router.get(
"/claude-code/plugins/{plugin_name}/downloads",
tags=["Claude Code Marketplace"],
response_model=PluginDownloadsResponse,
)
async def get_plugin_downloads(
plugin_name: str,
uploads: PluginUploadRepositoryDep,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
limit: Annotated[int, Query(ge=1, le=100, description="Maximum number of download events")] = 25,
cursor: Annotated[str | None, Query(pattern=r"^[1-9][0-9]{0,18}$", description="Previous next_cursor")] = None,
) -> PluginDownloadsResponse:
"""Admin-only ZIP download totals and authenticated downloader history.
Anonymous downloads contribute only to totals. Counts include repeat requests and client prefetches,
starting when tracking is deployed. External sources and installation completion are not observed.
"""
_require_proxy_admin(user_api_key_dict)
if uploads is None:
raise _error_response(500, CommonProxyErrors.db_not_connected_error.value)
before: Final = int(cursor) if cursor is not None else None
if before is not None and before > 9223372036854775807:
raise _error_response(422, "Invalid download cursor")
result: Final = await uploads.downloads(plugin_name, limit, before)
if result is None:
raise _error_response(404, "Plugin not found")
return result
@router.get(
"/claude-code/marketplace.json",
tags=["Claude Code Marketplace"],

View file

@ -1,6 +1,7 @@
import asyncio
import hashlib
from dataclasses import replace
from collections.abc import Mapping
from dataclasses import dataclass, replace
from datetime import datetime
from typing import Annotated, Final
from urllib.parse import quote
@ -14,6 +15,8 @@ from litellm.llms.custom_httpx.http_handler import (
)
from litellm.models.plugin_upload import (
PluginArchiveMetadata,
PluginDownloader,
PluginDownloadsResponse,
PluginPublication,
PluginSnapshot,
PluginUploadStore,
@ -28,10 +31,13 @@ from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketp
delete_plugin,
disable_plugin,
enable_plugin,
get_plugin_downloads,
header_key_caller,
list_plugins,
plugin_archive_source_matches,
plugin_archive_url,
plugin_downloader,
require_download_recorded,
)
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import resolve_skill_visibility
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.skill_upload_archive import build_uploaded_skill_archive
@ -76,6 +82,16 @@ from litellm.types.proxy.skill_hub import (
router: Final = APIRouter(prefix="/skill-hub/skills", tags=["Skills Hub"])
_EDITABLE_METADATA: Final = frozenset(SkillHubMetadata.model_fields)
router.add_api_route(
"/{plugin_name}/downloads", get_plugin_downloads, methods=["GET"], response_model=PluginDownloadsResponse
)
@dataclass(frozen=True, slots=True)
class VisibleSkillArchive:
archive: SkillArchive
metadata: PluginArchiveMetadata
downloader: PluginDownloader
def _url(request: Request, route: str) -> str:
@ -88,7 +104,7 @@ def _download_url(request: Request, name: str) -> str:
return _url(request, f"skill-hub/skills/{quote(name, safe='')}/archive")
def _skill_response(request: Request, plugin: PluginListItem) -> SkillHubSkill:
def _skill_response(request: Request, plugin: PluginListItem, download_count: int | None = None) -> SkillHubSkill:
local_archive: Final = plugin_archive_source_matches(
request,
plugin.name,
@ -109,12 +125,14 @@ def _skill_response(request: Request, plugin: PluginListItem) -> SkillHubSkill:
source={"source": "archive", "url": _download_url(request, plugin.name)} if local_archive else plugin.source,
download_url=_download_url(request, plugin.name) if local_archive else None,
content_url=_url(request, f"skill-hub/skills/{quote(plugin.name, safe='')}/content") if local_archive else None,
download_count=download_count,
)
@router.get("", response_model=SkillHubList)
async def list_skills(
request: Request,
uploads: PluginUploadRepositoryDep,
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
query: str = "",
offset: Annotated[int, Query(ge=0)] = 0,
@ -128,8 +146,14 @@ async def list_skills(
for plugin in sorted(catalog.plugins, key=lambda item: item.name)
if needle in " ".join((plugin.name, plugin.description or "", *(plugin.keywords or ()))).casefold()
)
page: Final = matched[offset : offset + limit]
counts: Final[Mapping[str, int]] = (
await _require_store(uploads).download_counts(tuple(plugin.id for plugin in page))
if is_proxy_admin(caller)
else {}
)
return SkillHubList(
skills=tuple(_skill_response(request, plugin) for plugin in matched[offset : offset + limit]),
skills=tuple(_skill_response(request, plugin, counts.get(plugin.id)) for plugin in page),
count=len(matched),
next_offset=offset + limit if offset + limit < len(matched) else None,
)
@ -520,14 +544,22 @@ async def download_skill_version(
"""Download a retained publication as a portable ZIP with SKILL.md at its root."""
store: Final = _require_store(uploads)
metadata: Final = await _version_metadata(name, store, caller)
if metadata.id is None:
raise HTTPException(404, "Skill not found")
version: Final = await store.version(metadata, version_id)
if version is None:
raise HTTPException(404, "Skill version not found")
content: Final = await store.version_content(metadata, version_id)
archive: Final = (
await asyncio.to_thread(build_agent_skill_archive, content, plugin_name=name) if content is not None else None
)
if archive is None:
raise HTTPException(404, "This version has no retained Agent Skills archive")
require_download_recorded(
await store.record_download(
replace(metadata, version=version.version),
archive.digest.removeprefix("sha256:"),
plugin_downloader(caller),
)
)
return Response(
archive.content,
media_type="application/zip",
@ -540,7 +572,7 @@ async def download_skill_version(
)
async def _visible_archive(request: Request, name: str, uploads: PluginUploadStore | None) -> SkillArchive:
async def _visible_archive(request: Request, name: str, uploads: PluginUploadStore | None) -> VisibleSkillArchive:
anonymous_allowed: Final = litellm.public_skills_index is True and not marketplace_auth_required()
caller: Final = (
await header_key_caller(request) if marketplace_key_header_present(request) or not anonymous_allowed else None
@ -563,7 +595,7 @@ async def _visible_archive(request: Request, name: str, uploads: PluginUploadSto
)
if archive is None:
raise HTTPException(404, "This skill has no stored Agent Skills archive")
return archive
return VisibleSkillArchive(archive, metadata, plugin_downloader(caller))
@router.get(
@ -575,9 +607,15 @@ async def download_skill(
request: Request, name: str, uploads: PluginUploadRepositoryDep, digest: str | None = None
) -> Response:
"""Download the complete portable skill, with SKILL.md at the ZIP root."""
archive: Final = await _visible_archive(request, name, uploads)
visible: Final = await _visible_archive(request, name, uploads)
archive: Final = visible.archive
if digest is not None and digest != archive.digest:
raise HTTPException(404, "Skill archive version not found")
require_download_recorded(
await _require_store(uploads).record_download(
visible.metadata, archive.digest.removeprefix("sha256:"), visible.downloader
)
)
return Response(
archive.content,
media_type="application/zip",
@ -593,9 +631,9 @@ async def download_skill(
@router.get("/{name}/content", response_class=Response, responses={200: {"content": {"text/markdown": {}}}})
async def skill_content(request: Request, name: str, uploads: PluginUploadRepositoryDep) -> Response:
"""Read the original SKILL.md without downloading supporting files."""
archive: Final = await _visible_archive(request, name, uploads)
visible: Final = await _visible_archive(request, name, uploads)
return Response(
archive.manifest_content,
visible.archive.manifest_content,
media_type="text/markdown",
headers={"Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff"},
)

View file

@ -1617,6 +1617,8 @@ model LiteLLM_ClaudeCodePluginTable {
uploaded_archive LiteLLM_ClaudeCodePluginUpload?
versions LiteLLM_ClaudeCodePluginVersion[]
downloads LiteLLM_ClaudeCodePluginDownload[]
anonymous_download_count BigInt @default(0)
@@map("LiteLLM_ClaudeCodePluginTable")
}
@ -1660,6 +1662,23 @@ model LiteLLM_SkillSubmission {
@@index([created_at(sort: Desc), id(sort: Desc)])
}
model LiteLLM_ClaudeCodePluginDownload {
id BigInt @id @default(autoincrement())
plugin_id String
downloaded_at DateTime @default(now())
version String?
digest String
authenticated Boolean
user_id String?
user_email String?
key_alias String?
team_id String?
team_alias String?
plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([plugin_id, id])
}
model LiteLLM_ClaudeCodePluginUpload {
plugin_name String @id
content Bytes

View file

@ -1,5 +1,6 @@
from collections.abc import AsyncIterator, Sequence
from collections.abc import AsyncIterator, Mapping, Sequence
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from types import MappingProxyType
from typing import (
TYPE_CHECKING,
@ -12,6 +13,10 @@ from pydantic import TypeAdapter, ValidationError
from litellm.models.plugin_upload import (
PluginArchiveMetadata,
PluginDownloader,
PluginDownloadEvent,
PluginDownloadResult,
PluginDownloadsResponse,
PluginPublication,
PluginPublicationBase,
PluginPublicationError,
@ -28,11 +33,33 @@ from litellm.repositories.table_repositories import ClaudeCodePluginRepository
if TYPE_CHECKING:
from prisma import models as prisma_models
from litellm.proxy.db.create_views import SupportsExecuteRaw
from litellm.proxy.utils import PrismaClient
_MANIFEST: Final = TypeAdapter(dict[str, object])
_SOURCE: Final = TypeAdapter(dict[str, str])
PAGE_SIZE: Final = 100
_INCREMENT_ANONYMOUS_DOWNLOADS: Final = """
UPDATE "LiteLLM_ClaudeCodePluginTable"
SET "anonymous_download_count" = "anonymous_download_count" + 1
WHERE "id" = $1
"""
_DOWNLOAD_COUNTS_QUERY: Final = """
SELECT p.id, p.anonymous_download_count + COUNT(d.id) AS download_count
FROM "LiteLLM_ClaudeCodePluginTable" p
LEFT JOIN "LiteLLM_ClaudeCodePluginDownload" d ON d.plugin_id = p.id
WHERE p.id = ANY($1::text[])
GROUP BY p.id
"""
@dataclass(frozen=True, slots=True)
class _DownloadCount:
id: str
download_count: int
_DOWNLOAD_COUNTS: Final = TypeAdapter(tuple[_DownloadCount, ...])
_VERSIONS: Final = TypeAdapter(tuple[PluginVersion, ...])
_VERSION_QUERY: Final = """
SELECT v.id, v.version, v.description, COALESCE(v.manifest_json, '{}')::jsonb AS manifest,
@ -51,11 +78,18 @@ class PluginUploadRepository:
self._db = cast( # cast-ok: the generated Prisma transaction delegate is untyped at this boundary
_PublicationDatabase, prisma_client.writer_db
)
self._writer = cast( # cast-ok: PrismaWrapper dynamically delegates raw statements to its generated client
"SupportsExecuteRaw", prisma_client.writer_db
)
self._plugins = ClaudeCodePluginRepository(prisma_client, use_writer=True).table
self._archives = cast( # cast-ok: Prisma exposes generated table actions through an untyped delegate
TableActions["prisma_models.LiteLLM_ClaudeCodePluginUpload"],
prisma_client.writer_db.litellm_claudecodepluginupload,
)
self._downloads = cast( # cast-ok: Prisma exposes generated table actions through an untyped delegate
TableActions["prisma_models.LiteLLM_ClaudeCodePluginDownload"],
prisma_client.writer_db.litellm_claudecodeplugindownload,
)
async def create(self, upload: PluginUpload) -> str | None:
result: Final = await self.publish(
@ -165,6 +199,7 @@ class PluginUploadRepository:
"plugin_name": metadata.name,
"plugin": {
"is": {
"id": metadata.id,
"manifest_json": metadata.manifest_json,
"enabled": metadata.enabled,
}
@ -173,6 +208,72 @@ class PluginUploadRepository:
)
return None if archive is None else archive.content.decode()
async def record_download(
self, metadata: PluginArchiveMetadata, digest: str, downloader: PluginDownloader
) -> PluginDownloadResult:
from prisma.errors import ForeignKeyViolationError
if metadata.id is None:
return PluginDownloadResult.SKILL_NOT_FOUND
if not downloader.authenticated:
# A counter bounds anonymous storage; raw SQL preserves Prisma's @updatedAt metadata.
affected: Final = await self._writer.execute_raw(_INCREMENT_ANONYMOUS_DOWNLOADS, metadata.id)
return PluginDownloadResult.RECORDED if affected == 1 else PluginDownloadResult.SKILL_NOT_FOUND
try:
await self._downloads.create(
data={
"plugin_id": metadata.id,
"version": metadata.version,
"digest": digest,
"authenticated": downloader.authenticated,
"user_id": downloader.user_id,
"user_email": downloader.user_email,
"key_alias": downloader.key_alias,
"team_id": downloader.team_id,
"team_alias": downloader.team_alias,
}
)
except ForeignKeyViolationError:
return PluginDownloadResult.SKILL_NOT_FOUND
return PluginDownloadResult.RECORDED
async def downloads(self, name: str, limit: int, cursor: int | None) -> PluginDownloadsResponse | None:
plugin: Final = await self._plugins.find_unique(where={"name": name})
if plugin is None:
return None
total: Final = await self._downloads.count(where={"plugin_id": plugin.id})
rows: Final = await self._downloads.find_many(
where={"plugin_id": plugin.id, **({"id": {"lt": cursor}} if cursor is not None else {})},
order={"id": "desc"},
take=limit + 1,
)
return PluginDownloadsResponse(
total_downloads=total + plugin.anonymous_download_count,
anonymous_downloads=plugin.anonymous_download_count,
downloads=tuple(
PluginDownloadEvent(
id=str(row.id),
downloaded_at=row.downloaded_at,
version=row.version,
digest=row.digest,
authenticated=row.authenticated,
user_id=row.user_id,
user_email=row.user_email,
key_alias=row.key_alias,
team_id=row.team_id,
team_alias=row.team_alias,
)
for row in rows[:limit]
),
next_cursor=str(rows[limit - 1].id) if len(rows) > limit else None,
)
async def download_counts(self, plugin_ids: tuple[str, ...]) -> Mapping[str, int]:
if not plugin_ids:
return MappingProxyType({})
rows: Final = _DOWNLOAD_COUNTS.validate_python(await self._db.query_raw(_DOWNLOAD_COUNTS_QUERY, plugin_ids))
return MappingProxyType({row.id: row.download_count for row in rows})
def _archive_metadata(plugin: "prisma_models.LiteLLM_ClaudeCodePluginTable") -> PluginArchiveMetadata:
manifest: Final = _MANIFEST.validate_json(plugin.manifest_json or "{}")

View file

@ -23,6 +23,9 @@ class SkillHubSkill(LiteLLMBaseModel):
domain: str | None = None
namespace: str | None = None
keywords: tuple[str, ...] | None = None
download_count: int | None = Field(
None, description="Gateway ZIP downloads, included in proxy-admin catalog listings"
)
class SkillHubList(LiteLLMBaseModel):

View file

@ -1617,6 +1617,8 @@ model LiteLLM_ClaudeCodePluginTable {
uploaded_archive LiteLLM_ClaudeCodePluginUpload?
versions LiteLLM_ClaudeCodePluginVersion[]
downloads LiteLLM_ClaudeCodePluginDownload[]
anonymous_download_count BigInt @default(0)
@@map("LiteLLM_ClaudeCodePluginTable")
}
@ -1660,6 +1662,23 @@ model LiteLLM_SkillSubmission {
@@index([created_at(sort: Desc), id(sort: Desc)])
}
model LiteLLM_ClaudeCodePluginDownload {
id BigInt @id @default(autoincrement())
plugin_id String
downloaded_at DateTime @default(now())
version String?
digest String
authenticated Boolean
user_id String?
user_email String?
key_alias String?
team_id String?
team_alias String?
plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([plugin_id, id])
}
model LiteLLM_ClaudeCodePluginUpload {
plugin_name String @id
content Bytes

View file

@ -0,0 +1,163 @@
import hashlib
import io
import os
import uuid
import zipfile
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from functools import partial
from pathlib import Path
from typing import Final
import psycopg
import pytest
from pydantic import TypeAdapter
from litellm.models.plugin_upload import PluginDownloadsResponse
from litellm.types.proxy.skill_hub import SkillHubList
from tests.integration._support.actors import GatewayClient, owned_settings_client
from tests.integration._support.client import Gateway, Scenario, object_value, string_value
from tests.integration._support.database import write_rows
PLUGINS: Final = "/claude-code/plugins"
SKILLS: Final = "/skill-hub/skills"
HISTORY: Final = TypeAdapter(PluginDownloadsResponse)
@pytest.fixture(scope="module")
def gateway(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> Iterator[Gateway]:
directory: Final = tmp_path_factory.mktemp("skill-downloads")
with owned_settings_client(GatewayClient(session_gateway, {}), directory, {"public_skills_index": True}) as client:
with client.gateway.scenario() as publisher:
yield replace(client.gateway, key=publisher.key(user_id=publisher.user(user_role="proxy_admin")))
def _delete(gateway: Gateway, name: str) -> None:
assert gateway.request("DELETE", f"{PLUGINS}/{name}").status_code in {200, 404}
def _upload(scenario: Scenario, name: str) -> str:
scenario.cleanups.callback(_delete, scenario.gateway, name)
content: Final = io.BytesIO()
with zipfile.ZipFile(content, "w") as archive:
archive.writestr("SKILL.md", f"---\nname: {name}\ndescription: Synthetic download regression\n---\n")
response: Final = scenario.gateway.request_multipart(
f"{SKILLS}/upload", {"version": "1.2.3"}, {"file": ("skill.zip", content.getvalue(), "application/zip")}
)
assert response.status_code == 200, response.text
return string_value(object_value(scenario.gateway.get(f"{PLUGINS}/{name}")["source"])["url"])
def _download(gateway: Gateway, key: str | None, url: str) -> str:
response: Final = gateway.client.get(
url, headers={"Authorization": f"Bearer {key}"} if key else {}, params={"user_id": "forged-user"}
)
assert response.status_code == 200, response.text
return hashlib.sha256(response.content).hexdigest()
def _history(gateway: Gateway, name: str, **params: str) -> PluginDownloadsResponse:
history: Final = gateway.get(f"{SKILLS}/{name}/downloads", params)
assert gateway.get(f"{PLUGINS}/{name}/downloads", params) == history
return HISTORY.validate_python(history)
def test_attribution_survives_actor_deletion_and_name_reuse_starts_empty(gateway: Gateway) -> None:
with gateway.scenario() as skills:
name: Final = f"download-{uuid.uuid4().hex}"
legacy: Final = _upload(skills, name)
portable: Final = f"{SKILLS}/{name}/archive"
original_id: Final = string_value(gateway.get(f"{SKILLS}/{name}")["id"])
assert gateway.client.get(f"{SKILLS}/{name}/content").status_code == 200
assert _history(gateway, name).total_downloads == 0
assert SkillHubList.model_validate(gateway.get(SKILLS, {"query": name})).skills[0].download_count == 0
with gateway.scenario() as actors:
team: Final = actors.team(team_alias=name)
email: Final = f"{name}@example.test"
user: Final = actors.user(user_role="internal_user", user_email=email)
gateway.post("/team/member_add", {"team_id": team, "member": {"role": "user", "user_id": user}})
key: Final = actors.key(user_id=user, team_id=team, key_alias=name)
digests: Final = tuple(_download(gateway, key, url) for url in (legacy, portable))
for url in (legacy, portable):
_download(gateway, None, url)
assert gateway.request("GET", url, key="sk-invalid").status_code == 401
recorded: Final = _history(gateway, name)
assert (recorded.total_downloads, recorded.anonymous_downloads) == (4, 2)
assert SkillHubList.model_validate(gateway.get(SKILLS, {"query": name})).skills[0].download_count == 4
assert tuple((row.version, row.authenticated, row.user_id, row.user_email, row.key_alias,
row.team_id, row.team_alias) for row in recorded.downloads) == (
("1.2.3", True, user, email, name, team, name),
) * 2
assert tuple(row.digest for row in recorded.downloads) == digests[::-1]
for prefix in (PLUGINS, SKILLS):
assert gateway.request("GET", f"{prefix}/{name}/downloads", key=key).status_code == 403
assert gateway.client.get(f"{prefix}/{name}/downloads").status_code == 401
assert _history(gateway, name) == recorded
_delete(gateway, name)
assert gateway.rows('SELECT id FROM "LiteLLM_ClaudeCodePluginDownload" WHERE plugin_id = %s', (original_id,)) == []
_upload(skills, name)
assert gateway.get(f"{SKILLS}/{name}")["id"] != original_id
assert _history(gateway, name) == PluginDownloadsResponse(0, 0, (), None)
def test_concurrent_counts_and_keyset_pages_preserve_scope_and_deleted_cursors(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
name: Final = f"download-{uuid.uuid4().hex}"
legacy: Final = _upload(scenario, name)
portable: Final = f"{SKILLS}/{name}/archive"
key: Final = scenario.key()
with ThreadPoolExecutor(max_workers=4) as pool:
tuple(pool.map(partial(_download, gateway, key), (legacy, portable) * 4))
initial: Final = _history(gateway, name)
ordered: Final = tuple(row.id for row in initial.downloads)
assert initial.total_downloads == len(frozenset(ordered)) == 8
assert ordered == tuple(sorted(ordered, key=int, reverse=True))
timestamp: Final = gateway.get(f"{PLUGINS}/{name}")["updated_at"]
with ThreadPoolExecutor(max_workers=4) as pool:
tuple(pool.map(partial(_download, gateway, None), (legacy, portable) * 8))
assert gateway.get(f"{PLUGINS}/{name}")["updated_at"] == timestamp
assert _history(gateway, name) == replace(initial, total_downloads=24, anonymous_downloads=16)
first: Final = _history(gateway, name, limit="3")
assert tuple(row.id for row in first.downloads) == ordered[:3]
assert first.next_cursor == ordered[2]
_download(gateway, key, portable)
other: Final = f"{name}-other"
_download(gateway, key, _upload(scenario, other))
write_rows(
'DELETE FROM "LiteLLM_ClaudeCodePluginDownload" WHERE id IN (%s::bigint, %s::bigint)',
(ordered[2], ordered[4]), database_url=gateway.database_url,
)
assert first.next_cursor is not None
second: Final = _history(gateway, name, limit="3", cursor=first.next_cursor)
assert tuple(row.id for row in second.downloads) == (ordered[3], ordered[5], ordered[6])
assert (second.total_downloads, second.anonymous_downloads) == (23, 16)
assert second.next_cursor is not None
last: Final = _history(gateway, name, limit="3", cursor=second.next_cursor)
assert tuple(row.id for row in last.downloads) == (ordered[7],)
assert last.next_cursor is None
assert _history(gateway, other, cursor=first.next_cursor) == PluginDownloadsResponse(1, 0, (), None)
catalog: Final = SkillHubList.model_validate(gateway.get(SKILLS, {"query": name}))
assert {skill.name: skill.download_count for skill in catalog.skills} == {name: 23, other: 1}
def test_repeat_migration_is_catalog_only_while_download_tables_are_locked(gateway: Gateway) -> None:
migration: Final = (Path(__file__).resolve().parents[3] /
"litellm-proxy-extras/litellm_proxy_extras/migrations/20261105094500_add_skill_download_history/migration.sql"
).read_bytes()
with gateway.scenario() as scenario:
name: Final = f"download-{uuid.uuid4().hex}"
url: Final = _upload(scenario, name)
for key in (None, gateway.key):
_download(gateway, key, url)
before: Final = _history(gateway, name)
database_url: Final = gateway.database_url or os.environ["DATABASE_URL"]
with psycopg.connect(database_url) as locker, psycopg.connect(database_url, autocommit=True) as migrator:
locker.execute(
'LOCK TABLE "LiteLLM_ClaudeCodePluginTable", "LiteLLM_ClaudeCodePluginDownload" IN ACCESS EXCLUSIVE MODE'
)
for _ in range(2):
with migrator.transaction():
migrator.execute("SET LOCAL statement_timeout = '500ms'")
migrator.execute(migration)
assert _history(gateway, name) == before

View file

@ -855,6 +855,7 @@ async def test_enable_disable_delete_plugin_reject_non_admin():
enable_plugin(plugin_name=name, user_api_key_dict=_NON_ADMIN_USER),
disable_plugin(plugin_name=name, user_api_key_dict=_NON_ADMIN_USER),
delete_plugin(plugin_name=name, user_api_key_dict=_NON_ADMIN_USER),
claude_code_marketplace.get_plugin_downloads(plugin_name=name, uploads=None, user_api_key_dict=_NON_ADMIN_USER),
):
with pytest.raises(HTTPException) as exc_info:
await coro
@ -1223,13 +1224,15 @@ _UPLOAD_SOURCE: Final = {"source": "archive", "url": f"http://test{_UPLOAD_PATH}
async def upload_storage(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[_UploadClient]:
prisma: Final = litellm.proxy.proxy_server.prisma_client
prisma.writer_db = MagicMock()
prisma.writer_db.execute_raw = AsyncMock(return_value=1)
plugins: Final = prisma.writer_db.litellm_claudecodeplugintable
archives: Final = prisma.writer_db.litellm_claudecodepluginupload
prisma.writer_db.litellm_claudecodeplugindownload.create = AsyncMock(return_value=None)
plugins.create = AsyncMock(return_value=prisma_models.LiteLLM_ClaudeCodePluginTable(
id="uploaded-id", name=_UPLOAD_NAME, enabled=True,
id="uploaded-id", name=_UPLOAD_NAME, enabled=True, anonymous_download_count=0,
))
plugins.find_unique = AsyncMock(return_value=prisma_models.LiteLLM_ClaudeCodePluginTable(
id="uploaded-id", name=_UPLOAD_NAME, enabled=True, manifest_json=json.dumps({"source": _UPLOAD_SOURCE}),
id="uploaded-id", name=_UPLOAD_NAME, enabled=True, anonymous_download_count=0, manifest_json=json.dumps({"source": _UPLOAD_SOURCE}),
))
prisma.writer_db.query_raw = AsyncMock(return_value=[])
prisma.writer_db.tx.return_value.__aenter__.return_value = prisma.writer_db
@ -1335,7 +1338,7 @@ async def test_archive_reads_only_authorized_current_bytes(
client, plugins, archives = upload_storage
manifest: Final = json.dumps({"source": source})
plugins.find_unique.return_value = None if source is None else prisma_models.LiteLLM_ClaudeCodePluginTable(
id="uploaded-id", name=_UPLOAD_NAME, enabled=enabled, manifest_json=manifest,
id="uploaded-id", name=_UPLOAD_NAME, enabled=enabled, anonymous_download_count=0, manifest_json=manifest,
)
if not blob:
archives.find_first.return_value = None
@ -1344,9 +1347,16 @@ async def test_archive_reads_only_authorized_current_bytes(
assert (response.content == b"stored archive") is (status == 200)
plugins.find_unique.assert_awaited_once_with(where={"name": _UPLOAD_NAME})
assert archives.find_first.await_args_list == ([call(where={
"plugin_name": _UPLOAD_NAME, "plugin": {"is": {"manifest_json": manifest, "enabled": enabled}},
"plugin_name": _UPLOAD_NAME, "plugin": {"is": {
"id": "uploaded-id", "manifest_json": manifest, "enabled": enabled,
}},
})] if reads else [])
litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable.find_unique.assert_not_awaited()
events: Final = litellm.proxy.proxy_server.prisma_client.writer_db.litellm_claudecodeplugindownload
assert events.create.await_count == int(status == 200 and key is not None)
assert litellm.proxy.proxy_server.prisma_client.writer_db.execute_raw.await_count == int(
status == 200 and key is None
)
@pytest.mark.asyncio
@ -1376,6 +1386,32 @@ async def test_archive_requires_header_credentials_for_private_content(
assert response.status_code == status, response.text
assert (response.content == b"stored archive") is (status == 200)
assert archives.find_first.await_count == int(status == 200)
events: Final = litellm.proxy.proxy_server.prisma_client.writer_db.litellm_claudecodeplugindownload
assert events.create.await_count == int(status == 200)
@pytest.mark.asyncio
@pytest.mark.parametrize("key, query, exists, status", [
(_LIMITED_KEY, {}, True, 403),
(None, {}, True, 401),
(MASTER_KEY, {"cursor": "9223372036854775808"}, True, 422),
(MASTER_KEY, {"cursor": "0"}, True, 422),
(MASTER_KEY, {"limit": 101}, True, 422),
(MASTER_KEY, {}, False, 404),
])
async def test_download_history_rejects_unauthorized_invalid_and_unknown_requests(
upload_storage: _UploadClient, key: str | None, query: Mapping[str, str | int], exists: bool, status: int,
) -> None:
client, plugins, _ = upload_storage
if not exists:
plugins.find_unique.return_value = None
response: Final = await client.get(
f"/claude-code/plugins/{_UPLOAD_NAME}/downloads",
headers={"Authorization": f"Bearer {key}"} if key else {}, params=query,
)
assert response.status_code == status, response.text
events: Final = litellm.proxy.proxy_server.prisma_client.writer_db.litellm_claudecodeplugindownload
events.find_many.assert_not_called()
@pytest.mark.asyncio

View file

@ -363,18 +363,24 @@ def test_proxy_admin_viewer_allowed_management_reads(allowed_read_route):
)
def test_virtual_key_allowed_routes_with_litellm_routes_member_name_allowed():
@pytest.mark.parametrize("group, route", (
("openai_routes", "/chat/completions"),
("skill_hub_routes", "/skill-hub/skills/release-notes/downloads"),
("management_routes", "/skill-hub/skills/release-notes/downloads"),
("anthropic_routes", "/claude-code/plugins/release-notes/downloads"),
))
def test_virtual_key_allowed_routes_with_litellm_routes_member_name_allowed(group: str, route: str) -> None:
"""Test that virtual key is allowed to call routes when allowed_routes contains LiteLLMRoutes member name"""
# Create a UserAPIKeyAuth with allowed_routes containing a LiteLLMRoutes member name
valid_token = UserAPIKeyAuth(
user_id="test_user",
allowed_routes=["openai_routes"], # This is a member name in LiteLLMRoutes enum
allowed_routes=[group],
)
# Test that a route from the openai_routes group is allowed
result = RouteChecks.is_virtual_key_allowed_to_call_route(
route="/chat/completions", # This is in LiteLLMRoutes.openai_routes.value
route=route,
valid_token=valid_token,
)

View file

@ -97,7 +97,7 @@ def hub_record(
digest: Final = hashlib.sha256(wrapper).hexdigest()
return (
prisma_models.LiteLLM_ClaudeCodePluginTable(
id=f"hub-{name}", name=name, enabled=True, manifest_json=json.dumps({
id=f"hub-{name}", name=name, enabled=True, anonymous_download_count=0, manifest_json=json.dumps({
"source": {
"source": "archive", "sha256": digest,
"url": f"http://testserver/claude-code/plugins/{name}/archive/{digest}.zip",
@ -112,6 +112,7 @@ def hub_record(
def hub_repository(*names: str) -> PluginUploadRepository:
records: Final = tuple(hub_record(name) for name in names)
prisma: Final = MagicMock()
prisma.writer_db.execute_raw = AsyncMock(return_value=1)
plugins: Final = prisma.writer_db.litellm_claudecodeplugintable
plugins.find_many = AsyncMock(return_value=[record for record, _ in records])

View file

@ -17,7 +17,7 @@ from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from prisma import models as prisma_models
from prisma.errors import UniqueViolationError
from prisma.errors import ForeignKeyViolationError, UniqueViolationError
import litellm
from litellm.caching.caching import DualCache
@ -75,6 +75,8 @@ class HubClient:
client: httpx.AsyncClient
plugins: MagicMock
archives: MagicMock
downloads: MagicMock
anonymous_counter: AsyncMock
upstream: MagicMock
clock: MagicMock
database: MagicMock
@ -84,15 +86,18 @@ class HubClient:
async def hub(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[HubClient]:
prisma: Final = MagicMock()
prisma.writer_db = prisma.db
prisma.db.execute_raw = AsyncMock(return_value=1)
prisma.get_data = AsyncMock(return_value=None)
prisma.db.litellm_skillstable.find_many = AsyncMock(return_value=[])
prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
plugins: Final = prisma.db.litellm_claudecodeplugintable
archives: Final = prisma.db.litellm_claudecodepluginupload
async def create_row(data: Mapping[str, object]) -> prisma_models.LiteLLM_ClaudeCodePluginTable:
return prisma_models.LiteLLM_ClaudeCodePluginTable.model_validate({"id": "hub-id", "enabled": True, **data})
return prisma_models.LiteLLM_ClaudeCodePluginTable.model_validate({"id": "hub-id", "enabled": True, "anonymous_download_count": 0, **data})
plugins.create = AsyncMock(side_effect=create_row)
downloads: Final = prisma.db.litellm_claudecodeplugindownload
downloads.create = AsyncMock()
plugins.find_unique = AsyncMock(return_value=None)
plugins.find_many = AsyncMock(return_value=[])
@ -154,7 +159,7 @@ async def hub(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[HubClient]:
get_skill_submission_repository: lambda: SkillSubmissionRepository(prisma),
import_http_client: lambda: source_client,
})
yield HubClient(client, plugins, archives, upstream, clock, prisma.db)
yield HubClient(client, plugins, archives, downloads, prisma.db.execute_raw, upstream, clock, prisma.db)
async def _upload(
@ -171,7 +176,7 @@ async def _upload(
def _stored_upload(hub: HubClient, enabled: bool = True) -> prisma_models.LiteLLM_ClaudeCodePluginTable:
data: Final = hub.plugins.create.await_args.kwargs["data"]
record: Final = prisma_models.LiteLLM_ClaudeCodePluginTable(
id="hub-id", name=data["name"], version=data["version"], description=data["description"],
id="hub-id", name=data["name"], version=data["version"], description=data["description"], anonymous_download_count=0,
manifest_json=data["manifest_json"], enabled=enabled, updated_at=data["updated_at"],
)
hub.plugins.find_unique.return_value = record
@ -191,6 +196,27 @@ def _assert_portable(response: httpx.Response) -> None:
assert stat.S_IMODE(archive.getinfo("scripts/check.sh").external_attr >> 16) == 0o755
@pytest.mark.parametrize("key, expected", ((MASTER_KEY, 7), (PLAIN_KEY, None)))
async def test_catalog_counts_are_admin_only_and_batched_for_the_page(
hub: HubClient, key: str, expected: int | None,
) -> None:
assert (await _upload(hub, _zip(FILES))).status_code == 200
record: Final = _stored_upload(hub)
hub.plugins.find_many.return_value = [record, record.model_copy(update={"id": "other", "name": "zzz-other"})]
hub.database.query_raw.reset_mock()
hub.database.query_raw.return_value = [{"id": record.id, "download_count": 7}]
response: Final = await hub.client.get(f"{HUB}?limit=1", headers=_headers(key))
assert response.status_code == 200, response.text
assert [(item["id"], item["download_count"]) for item in response.json()["skills"]] == [(record.id, expected)]
if expected is None:
hub.database.query_raw.assert_not_awaited()
else:
assert hub.database.query_raw.await_count == 1
assert hub.database.query_raw.await_args.args[1] == (record.id,)
assert (await hub.client.get(f"{HUB}?offset=2", headers=_headers(key))).json()["skills"] == []
assert hub.database.query_raw.await_count == 1
@pytest.mark.parametrize("operation", ("upload", "import"))
@pytest.mark.parametrize("public_base, root_path", (
("http://test", ""), ("https://public.example", ""), ("https://public.example", "/gateway"),
@ -222,6 +248,8 @@ async def test_publication_round_trips_both_formats_without_inflating_on_the_req
assert details.json()["content_url"] == f"{serving_base}{HUB}/{NAME}/content"
catalog: Final = await hub.client.get("/claude-code/plugins", headers=_headers(PLAIN_KEY))
assert catalog.status_code == 200 and "has_agent_skill_metadata" not in catalog.text
hub.downloads.create.assert_not_awaited()
hub.anonymous_counter.assert_not_awaited()
request_thread: Final = threading.get_ident()
decompress: Final = zlib.decompressobj
@ -241,10 +269,13 @@ async def test_publication_round_trips_both_formats_without_inflating_on_the_req
assert (await hub.client.get(entry["url"])).content == portable.content
assert (await hub.client.get(f"{HUB}/{NAME}/archive?digest=sha256:stale")).status_code == 404
legacy_path: Final = httpx.URL(manifest["source"]["url"]).path
legacy: Final = await hub.client.get(legacy_path)
legacy: Final = await hub.client.get(legacy_path, headers=_headers(GRANTED_KEY))
assert legacy.status_code == 200
assert hashlib.sha256(legacy.content).hexdigest() == manifest["source"]["sha256"]
assert (await hub.client.get(legacy_path.replace(manifest["source"]["sha256"], "stale"))).status_code == 404
assert hub.anonymous_counter.await_count == 2
assert hub.anonymous_counter.await_args.args[1:] == (record.id,)
hub.downloads.create.assert_awaited_once()
monkeypatch.setattr(zlib, "decompressobj", decompress)
_assert_portable(portable)
with zipfile.ZipFile(io.BytesIO(legacy.content)) as archive:
@ -317,6 +348,8 @@ async def test_download_policy_requires_publication_or_a_valid_grant(
for suffix in ("archive", "content"):
response: Final = await hub.client.get(f"{HUB}/{NAME}/{suffix}", headers=_headers(key))
assert response.status_code == status, response.text
assert hub.downloads.create.await_count == int(status == 200 and key is not None)
assert hub.anonymous_counter.await_count == int(status == 200 and key is None)
if status != 200:
hub.archives.find_first.assert_not_awaited()
index: Final = await hub.client.get(INDEX)
@ -325,6 +358,21 @@ async def test_download_policy_requires_publication_or_a_valid_grant(
assert [entry["name"] for entry in index.json()["skills"]] == ([NAME] if enabled and not auth_required else [])
@pytest.mark.parametrize("key", (None, GRANTED_KEY))
async def test_deleted_skill_is_not_served_when_download_cannot_be_recorded(hub: HubClient, key: str | None) -> None:
assert (await _upload(hub, _zip(FILES))).status_code == 200
record: Final = _stored_upload(hub)
legacy_url: Final = json.loads(record.manifest_json)["source"]["url"]
hub.anonymous_counter.return_value = 0
hub.downloads.create.side_effect = ForeignKeyViolationError(data={})
for path in (f"{HUB}/{NAME}/archive", legacy_url):
response: Final = await hub.client.get(path, headers=_headers(key))
assert response.status_code == 404, response.text
assert "Skill archive not found" in response.text
assert hub.anonymous_counter.await_count == (2 if key is None else 0)
assert hub.downloads.create.await_count == (0 if key is None else 2)
async def test_search_paginates_visible_matches(hub: HubClient) -> None:
assert (await _upload(hub, _zip(FILES))).status_code == 200
record: Final = _stored_upload(hub)
@ -516,6 +564,7 @@ async def test_neutral_history_list_detail_and_portable_download_share_current_v
original: Final = _stored_upload(hub, enabled=False)
data: Final = hub.database.litellm_claudecodepluginversion.create.await_args.kwargs["data"]
version: Final = prisma_models.LiteLLM_ClaudeCodePluginVersion.model_validate({"id": "retained", **data})
hub.plugins.find_unique.return_value = original.model_copy(update={"version": "3.0.0"})
hub.database.litellm_claudecodepluginversion.find_first.return_value = version
hub.database.query_raw.return_value = [{
"id": version.id, "version": version.version, "description": version.description,
@ -526,7 +575,11 @@ async def test_neutral_history_list_detail_and_portable_download_share_current_v
detail: Final = await hub.client.get(f"{HUB}/{NAME}/versions/retained", headers=_headers(key))
downloaded: Final = await hub.client.get(f"{HUB}/{NAME}/versions/retained/archive", headers=_headers(key))
assert listed.status_code == detail.status_code == downloaded.status_code == status
assert hub.downloads.create.await_count == int(status == 200)
if status == 200:
event: Final = hub.downloads.create.await_args.kwargs["data"]
assert event["version"] == version.version != "3.0.0"
assert event["digest"] == hashlib.sha256(downloaded.content).hexdigest()
assert listed.json() == {"versions": [detail.json()], "next_offset": None}
assert detail.json()["manifest"]["agent_skill"] == json.loads(original.manifest_json)["agent_skill"]
assert detail.json()["download_url"] == f"http://test{HUB}/{NAME}/versions/retained/archive"

View file

@ -1808,7 +1808,8 @@ def test_public_skill_hub_echoes_registered_installation_preference(preference:
from litellm.types.proxy.claude_code_endpoints import RegisterPluginRequest
def create(data: Mapping[str, object]) -> LiteLLM_ClaudeCodePluginTable:
return LiteLLM_ClaudeCodePluginTable.model_validate({"id": "plugin-id", "enabled": True, **data})
defaults: Final = {"id": "plugin-id", "enabled": True, "anonymous_download_count": 0}
return LiteLLM_ClaudeCodePluginTable.model_validate({**defaults, **data})
def find_many(where: Mapping[str, object]) -> list[LiteLLM_ClaudeCodePluginTable]:
return [create(table.create.call_args.kwargs["data"])]

View file

@ -65,7 +65,7 @@ def publication_tx() -> MagicMock:
tx: Final = MagicMock()
tx.query_raw = AsyncMock(return_value=())
tx.litellm_claudecodeplugintable.find_unique = AsyncMock(return_value=LiteLLM_ClaudeCodePluginTable(
id="skill-id", name="skill", version="1.0.0", enabled=False,
id="skill-id", name="skill", version="1.0.0", enabled=False, anonymous_download_count=0,
updated_at=datetime(2025, 1, 1, tzinfo=timezone.utc),
manifest_json=json.dumps({"source": {"source": "archive", "sha256": hashlib.sha256(content).hexdigest()}}),
))

View file

@ -7,17 +7,28 @@ import pytest
from fastapi import Request
from prisma import models as prisma_models
from litellm.models.plugin_upload import PluginArchiveMetadata, PluginDownloader, PluginDownloadResult
from litellm.proxy import proxy_server
from litellm.proxy.discovery_endpoints.skill_hub_endpoints import public_hub_entries
from litellm.repositories.plugin_upload_repository import PluginUploadRepository
@pytest.mark.asyncio
async def test_download_without_stored_identity_returns_missing_skill() -> None:
repository: Final = PluginUploadRepository(MagicMock())
result: Final = await repository.record_download(
PluginArchiveMetadata("missing", True, {}, None), "digest",
PluginDownloader(True, None, None, None, None, None),
)
assert result is PluginDownloadResult.SKILL_NOT_FOUND
def plugin(
name: str, manifest_json: str | None = None, *, host: str = "testserver",
) -> prisma_models.LiteLLM_ClaudeCodePluginTable:
digest: Final = "a" * 64
return prisma_models.LiteLLM_ClaudeCodePluginTable(
id=name, name=name, enabled=True,
id=name, name=name, enabled=True, anonymous_download_count=0,
manifest_json=manifest_json if manifest_json is not None else json.dumps({
"source": {
"source": "archive", "url": f"http://{host}/claude-code/plugins/{name}/archive/{digest}.zip",

View file

@ -25,6 +25,7 @@ const published: SkillHubSkill = {
download_url: "/skill-hub/skills/shared-skill/archive",
content_url: "/skill-hub/skills/shared-skill/content",
updated_at: "2026-10-20T00:00:00Z",
download_count: 0,
};
const pending: SkillSubmission = {
@ -235,6 +236,24 @@ const openEdit = async () => {
return screen.getByRole("dialog");
};
it.each(["current", "retained"])("refreshes published counts after a %s download", async (kind) => {
URL.createObjectURL = vi.fn(() => "blob:skill-download");
renderWithProviders(<ClaudeCodePluginsPanel accessToken="admin-token" userRole="Admin" />);
expect(await screen.findByRole("cell", { name: "0" })).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledOnce();
fireEvent.click(screen.getByText(published.name));
await screen.findByRole("button", { name: "Edit Skill" });
if (kind === "retained") fireEvent.click(screen.getByRole("tab", { name: "Version history" }));
const button = await screen.findByRole("button", {
name: kind === "retained" ? "Download retained ZIP" : "Download ZIP",
});
catalog = [{ ...published, download_count: 1 }];
fireEvent.click(button);
await waitFor(() => expect(button).toBeEnabled());
fireEvent.click(screen.getByText("Skills"));
expect(await screen.findByRole("cell", { name: "1" })).toBeInTheDocument();
});
it("paginates the neutral catalog and refreshes detail for a recreated skill", async () => {
catalog = [
published,
@ -624,6 +643,7 @@ it.each(["role", "read-only"])("does not reuse a published admin snapshot after
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");
expect(screen.queryByRole("columnheader", { name: "Downloads" })).not.toBeInTheDocument();
});
it("does not reuse admin review metadata when the same account loses its role and detail refresh fails", async () => {

View file

@ -1,15 +1,16 @@
import { screen, waitFor } from "@testing-library/react";
import { fireEvent, 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 { listSkillHubSkills, deleteSkillHubSkill } from "@/components/networking";
import { listSkillHubSkills, getSkillHubSkill, deleteSkillHubSkill } from "@/components/networking";
import type { SkillHubSkill } from "@/components/claude_code_plugins/types";
import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel";
vi.mock("@/components/networking", () => ({
listSkillHubSkills: vi.fn(),
getSkillHubSkill: vi.fn(),
deleteSkillHubSkill: vi.fn(),
}));
@ -19,10 +20,12 @@ vi.mock("./PluginTable", () => ({
isLoading,
pluginsList,
onDeleteClick,
onPluginClick,
}: {
isLoading: boolean;
pluginsList: SkillHubSkill[];
onDeleteClick: (pluginName: string, displayName: string) => void;
onPluginClick: (id: string) => void;
}) => (
<div data-testid="plugin-table">
{isLoading ? "table-loading" : "table-loaded"}
@ -30,6 +33,7 @@ vi.mock("./PluginTable", () => ({
<button
key={plugin.id}
data-testid={`row-delete-${plugin.id}`}
onDoubleClick={() => onPluginClick(plugin.id)}
onClick={() => onDeleteClick(plugin.name, plugin.name)}
>
row delete
@ -41,7 +45,13 @@ 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 }));
vi.mock("@/components/claude_code_plugins/skill_detail", () => ({
__esModule: true,
skillHubDetail: (skill: SkillHubSkill) => skill,
default: ({ skill, isAdmin }: { skill: SkillHubSkill; isAdmin?: boolean }) => (
<section aria-label={`Details for ${skill.name}`}>{isAdmin && <button>Downloads</button>}</section>
),
}));
const mockListSkillHubSkills = vi.mocked(listSkillHubSkills);
const mockDeleteSkillHubSkill = vi.mocked(deleteSkillHubSkill);
@ -126,3 +136,28 @@ describe("ClaudeCodePluginsPanel delete confirmation", () => {
expect(mockDeleteSkillHubSkill).not.toHaveBeenCalled();
});
});
describe("ClaudeCodePluginsPanel detail access", () => {
beforeEach(() => {
vi.clearAllMocks();
testQueryClient.clear();
mockListSkillHubSkills.mockResolvedValue({ skills: [skill], count: 1, next_offset: null });
vi.mocked(getSkillHubSkill).mockResolvedValue(skill);
});
it.each([
["Admin", true],
["proxy_admin", true],
["org_admin", false],
["Admin Viewer", false],
["proxy_admin_viewer", false],
["Internal User", false],
["internal_user", false],
] as const)("limits selected skill download access for %s", async (userRole, canViewDownloads) => {
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole={userRole} />);
fireEvent.doubleClick(await screen.findByTestId(`row-delete-${skill.id}`));
expect(screen.getByRole("region", { name: `Details for ${skill.name}` })).toBeInTheDocument();
const downloads = screen.queryByRole("button", { name: "Downloads" });
expect(downloads !== null).toBe(canViewDownloads);
});
});

View file

@ -57,11 +57,20 @@ interface CatalogSkillDetailProps {
accessToken: string | null;
queryScope: QueryKey;
canWrite: boolean;
isAdmin: boolean;
onBack: () => void;
onEdit: (skill: SkillHubSkill) => void;
}
function CatalogSkillDetail({ skill, accessToken, queryScope, canWrite, onBack, onEdit }: CatalogSkillDetailProps) {
function CatalogSkillDetail({
skill,
accessToken,
queryScope,
canWrite,
isAdmin,
onBack,
onEdit,
}: CatalogSkillDetailProps) {
const { t } = useTranslation();
const detail = useQuery({
queryKey: [...queryScope, "detail", skill.id, skill.name],
@ -82,6 +91,7 @@ function CatalogSkillDetail({ skill, accessToken, queryScope, canWrite, onBack,
skill={skillHubDetail(detail.data ?? skill)}
onBack={onBack}
accessToken={accessToken}
isAdmin={isAdmin}
onEditClick={canWrite && detail.isSuccess && !detail.isFetching ? () => onEdit(detail.data) : undefined}
/>
</>
@ -156,6 +166,7 @@ const SkillsPanelContent: React.FC<ClaudeCodePluginsPanelProps> = ({
{selectedSkill ? (
<CatalogSkillDetail
skill={selectedSkill}
isAdmin={isAdmin}
accessToken={accessToken}
queryScope={queryScope}
canWrite={canWrite}

View file

@ -42,11 +42,16 @@ describe("PluginTable", () => {
vi.clearAllMocks();
});
it("should render every column header", () => {
render(<PluginTable {...defaultProps} />);
for (const header of ["Skill Name", "Version", "Description", "Domain", "Public", "Updated at"]) {
it.each([
[null, "—"],
[0, "0"],
[12345, "12,345"],
] as const)("renders headers and download count %s", (download_count, display) => {
render(<PluginTable {...defaultProps} pluginsList={[{ ...mockPlugins[0], download_count }]} />);
for (const header of ["Skill Name", "Version", "Description", "Domain", "Public", "Updated at", "Downloads"]) {
expect(screen.getByText(header)).toBeInTheDocument();
}
expect(within(screen.getByRole("row", { name: /newer-skill/ })).getByText(display)).toBeInTheDocument();
});
it("should display the empty state when data is empty", () => {
@ -106,6 +111,7 @@ describe("PluginTable", () => {
it("should hide the delete action for non-admins but keep copy available", async () => {
const user = userEvent.setup();
render(<PluginTable {...defaultProps} isAdmin={false} />);
expect(screen.queryByRole("columnheader", { name: "Downloads" })).not.toBeInTheDocument();
await user.click(screen.getByTestId("plugin-actions-newer-skill"));
expect(await screen.findByTestId("plugin-action-copy")).toBeInTheDocument();
expect(screen.queryByTestId("plugin-action-delete")).not.toBeInTheDocument();

View file

@ -153,6 +153,21 @@ export const getPluginTableColumns = ({
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.updated_at} />,
},
...(isAdmin
? ([
{
id: "download_count",
accessorKey: "download_count",
meta: { title: i18n.t("skills.table.columns.downloads") },
header: i18n.t("skills.table.columns.downloads"),
size: 120,
enableSorting: false,
cell: ({ row }) => (
<span className="tabular-nums">{row.original.download_count?.toLocaleString(i18n.language) ?? "—"}</span>
),
},
] satisfies ColumnDef<SkillHubSkill>[])
: []),
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },

View file

@ -0,0 +1,132 @@
import { useInfiniteQuery, type QueryFunctionContext } from "@tanstack/react-query";
import { RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { apiClient } from "@/components/networking";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { Plugin, PluginDownloadsResponse } from "./types";
export const skillDownloadsQueryKey = (skill: Pick<Plugin, "id" | "name">, accessToken?: string | null) =>
["skill-downloads", skill.id, skill.name, accessToken] as const;
interface SkillDownloadsProps {
skill: Pick<Plugin, "id" | "name">;
accessToken?: string | null;
}
export function SkillDownloads({ skill, accessToken }: SkillDownloadsProps) {
const { t, i18n } = useTranslation();
const queryOptions = {
queryKey: skillDownloadsQueryKey(skill, accessToken),
queryFn: ({ pageParam, signal }: QueryFunctionContext<ReturnType<typeof skillDownloadsQueryKey>, string | null>) =>
apiClient.get<PluginDownloadsResponse>(`/skill-hub/skills/${encodeURIComponent(skill.name)}/downloads`, {
accessToken,
query: { limit: 25, cursor: pageParam },
signal,
}),
initialPageParam: null as string | null,
getNextPageParam: (page: PluginDownloadsResponse) => page.next_cursor,
staleTime: 0,
refetchOnMount: true,
retry: false,
};
const { data, isPending, isError, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage, refetch } =
useInfiniteQuery(queryOptions);
const downloads = data?.pages.flatMap((page) => page.downloads) ?? [];
const isEmpty = data && !isError && downloads.length === 0;
const unavailable = t("claudeCodePlugins.skillDetail.downloads.unavailable");
return (
<section aria-label={t("claudeCodePlugins.skillDetail.downloads.title")} className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-normal">{t("claudeCodePlugins.skillDetail.downloads.title")}</h2>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
{t("claudeCodePlugins.skillDetail.downloads.description")}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{t("claudeCodePlugins.skillDetail.downloads.attributionNote")}
</p>
</div>
<Button variant="outline" size="sm" disabled={isFetching} onClick={() => void refetch()}>
<RefreshCw className="size-4" />
{t("claudeCodePlugins.skillDetail.downloads.refresh")}
</Button>
</div>
{data && (
<dl className="flex flex-wrap gap-x-8 gap-y-4">
<div>
<dt className="text-sm text-muted-foreground">{t("claudeCodePlugins.skillDetail.downloads.total")}</dt>
<dd aria-label={t("claudeCodePlugins.skillDetail.downloads.total")} className="text-3xl tabular-nums">
{data.pages[0].total_downloads.toLocaleString(i18n.language)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("claudeCodePlugins.skillDetail.downloads.anonymousTotal")}
</dt>
<dd
aria-label={t("claudeCodePlugins.skillDetail.downloads.anonymousTotal")}
className="text-3xl tabular-nums"
>
{data.pages[0].anonymous_downloads.toLocaleString(i18n.language)}
</dd>
</div>
</dl>
)}
{isPending && <p role="status">{t("claudeCodePlugins.skillDetail.downloads.loading")}</p>}
{isError && (
<p role="alert" className="text-sm text-destructive">
{t("claudeCodePlugins.skillDetail.downloads.loadFailed")}
</p>
)}
{isFetching && !isPending && (
<p role="status" className="text-sm text-muted-foreground">
{t(
isFetchingNextPage
? "claudeCodePlugins.skillDetail.downloads.loadingMore"
: "claudeCodePlugins.skillDetail.downloads.refreshing",
)}
</p>
)}
{isEmpty && <p className="text-sm text-muted-foreground">{t("claudeCodePlugins.skillDetail.downloads.empty")}</p>}
{downloads.length > 0 && (
<Table aria-label={t("claudeCodePlugins.skillDetail.downloads.title")}>
<TableHeader>
<TableRow>
<TableHead>{t("claudeCodePlugins.skillDetail.downloads.downloadedAt")}</TableHead>
<TableHead>{t("claudeCodePlugins.skillDetail.downloads.downloader")}</TableHead>
<TableHead>{t("claudeCodePlugins.skillDetail.downloads.key")}</TableHead>
<TableHead>{t("claudeCodePlugins.skillDetail.downloads.team")}</TableHead>
<TableHead>{t("claudeCodePlugins.skillDetail.downloads.version")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{downloads.map((event) => (
<TableRow key={event.id}>
<TableCell>
<time dateTime={event.downloaded_at}>
{new Date(event.downloaded_at).toLocaleString(i18n.language)}
</time>
</TableCell>
<TableCell>
{event.authenticated
? event.user_email || event.user_id || t("claudeCodePlugins.skillDetail.downloads.unidentified")
: t("claudeCodePlugins.skillDetail.downloads.anonymous")}
</TableCell>
<TableCell>{event.key_alias || unavailable}</TableCell>
<TableCell>{event.team_alias || event.team_id || unavailable}</TableCell>
<TableCell>{event.version || unavailable}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{hasNextPage && (
<Button variant="outline" disabled={isFetching} onClick={() => void fetchNextPage()}>
{t("claudeCodePlugins.skillDetail.downloads.loadMore")}
</Button>
)}
</section>
);
}

View file

@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { downloadBlob } from "@/components/EntityUsageExport/utils";
@ -20,6 +20,7 @@ function VersionArchive({
accessToken: string;
}) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [downloading, setDownloading] = useState(false);
const download = async () => {
@ -32,6 +33,7 @@ function VersionArchive({
{ accessToken, redirect: "error" },
);
downloadBlob(content, `skill-${version.version ?? "legacy"}.zip`);
await queryClient.invalidateQueries({ queryKey: ["skill-hub", accessToken] });
} catch {
setError(t("claudeCodePlugins.skillDetail.history.downloadFailed"));
} finally {

View file

@ -17,11 +17,16 @@ import {
buildMarketplaceSettingsSnippet,
} from "./helpers";
describe("getManagedSkillArchivePath", () => {
describe.each(["legacy", "neutral"])("getManagedSkillArchivePath %s", (format) => {
const base = "https://proxy.example.com";
const digest = "a".repeat(64);
const path = `/claude-code/plugins/my-skill/archive/${digest}.zip`;
const source = { source: "archive" as const, url: `${base}${path}`, sha256: digest };
const path =
format === "legacy" ? `/claude-code/plugins/my-skill/archive/${digest}.zip` : "/skill-hub/skills/my-skill/archive";
const source = {
source: "archive" as const,
url: `${base}${path}`,
sha256: format === "legacy" ? digest : undefined,
};
const identify = (overrides: Partial<PluginSource> = {}, name = "my-skill") =>
getManagedSkillArchivePath(name, { ...source, ...overrides }, base);
@ -40,7 +45,7 @@ describe("getManagedSkillArchivePath", () => {
`${base}${path}?redirect=other`,
`${base}${path}#fragment`,
`${base}${path.replace("my-skill", "another-skill")}`,
`${base}${path.replace(digest, "b".repeat(64))}`,
...(format === "legacy" ? [`${base}${path.replace(digest, "b".repeat(64))}`] : []),
])("rejects a different credential recipient or archive: %s", (url) => {
expect(identify({ url })).toBeNull();
});
@ -48,6 +53,7 @@ describe("getManagedSkillArchivePath", () => {
it("requires an archive type, safe skill name and full SHA256 digest", () => {
expect(identify({ source: "url" })).toBeNull();
expect(identify({}, "../my-skill")).toBeNull();
if (format !== "legacy") return;
expect(identify({ sha256: undefined })).toBeNull();
expect(identify({ sha256: "short" })).toBeNull();
});

View file

@ -314,10 +314,12 @@ export const getManagedSkillArchivePath = (
if (source.source !== "archive" || !validatePluginName(name)) {
return null;
}
if (!source.sha256 || !SHA256_REGEX.test(source.sha256)) {
if (source.sha256 && !SHA256_REGEX.test(source.sha256)) {
return null;
}
const path = `/claude-code/plugins/${name}/archive/${source.sha256}.zip`;
const path = source.sha256
? `/claude-code/plugins/${name}/archive/${source.sha256}.zip`
: `/skill-hub/skills/${name}/archive`;
try {
const expectedUrl = new URL(`${proxyBaseUrl.replace(/\/+$/, "")}${path}`, pageOrigin);
return /^https?:$/.test(expectedUrl.protocol) && source.url === expectedUrl.href ? path : null;

View file

@ -3,7 +3,7 @@ import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { getProxyBaseUrl, setGlobalLitellmHeaderName } from "@/components/networking";
import SkillDetail, { skillHubDetail } from "./skill_detail";
import type { Plugin } from "./types";
import type { Plugin, PluginDownloadEvent, PluginDownloadsResponse } from "./types";
const digest = "a".repeat(64);
const archivePath = `/claude-code/plugins/my-skill/archive/${digest}.zip`;
@ -19,13 +19,42 @@ const skill: Plugin = {
updated_at: null,
};
const fetchMock = vi.fn<typeof fetch>();
const renderSkill = (source = skill.source) =>
renderWithProviders(<SkillDetail skill={{ ...skill, source }} onBack={vi.fn()} accessToken="dashboard-token" />);
const skillRequestMock = vi.fn<typeof fetch>();
const renderSkill = (source = skill.source, isAdmin = false) =>
renderWithProviders(
<SkillDetail skill={{ ...skill, source }} onBack={vi.fn()} accessToken="dashboard-token" isAdmin={isAdmin} />,
);
const downloadsUrl = `${getProxyBaseUrl()}/skill-hub/skills/my-skill/downloads?limit=25`;
const event = {
id: "9007199254740993",
downloaded_at: "2026-10-01T10:00:00Z",
version: "1.2.0",
digest,
authenticated: true,
user_id: "reader-id",
user_email: "reader@example.test",
key_alias: "development",
team_id: "team-id",
team_alias: "Engineering",
} satisfies PluginDownloadEvent;
const page = (
downloads: PluginDownloadEvent[] = [],
total_downloads = downloads.length,
next_cursor: string | null = null,
anonymous_downloads = 0,
): PluginDownloadsResponse => ({ downloads, total_downloads, next_cursor, anonymous_downloads });
beforeEach(() => {
vi.clearAllMocks();
testQueryClient.clear();
fetchMock.mockReset().mockResolvedValue(Response.json({ claude_code_marketplace_auth_required: true }));
skillRequestMock.mockReset().mockRejectedValue(new Error("Unexpected skill request"));
fetchMock.mockReset().mockImplementation((input, init) => {
const requestUrl = input instanceof Request ? input.url : String(input);
if (requestUrl.includes("/.well-known/litellm-ui-config")) {
return Promise.resolve(Response.json({ claude_code_marketplace_auth_required: true }));
}
return skillRequestMock(input, init);
});
vi.stubGlobal("fetch", fetchMock);
});
@ -47,16 +76,18 @@ it.each(["legacy", "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 }));
skillRequestMock.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" } }));
skillRequestMock.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(
expect(skillRequestMock).toHaveBeenLastCalledWith(
expectedUrl,
expect.objectContaining({
method: "GET",
@ -68,10 +99,91 @@ it.each(["legacy", "portable"])(
},
);
it("keeps an external archive as an ordinary link even when its path matches the gateway", () => {
it("keeps an external archive as a link while retaining stored download history", async () => {
const externalUrl = `https://external.example.com${archivePath}`;
renderSkill({ ...skill.source, url: externalUrl });
renderSkill({ ...skill.source, url: externalUrl }, true);
expect(screen.getByRole("link")).toHaveAttribute("href", externalUrl);
expect(screen.getByRole("link")).toHaveAttribute("target", "_blank");
expect(screen.queryByRole("button", { name: "Download ZIP" })).not.toBeInTheDocument();
skillRequestMock.mockResolvedValueOnce(Response.json(page([event])));
fireEvent.click(screen.getByRole("tab", { name: "Downloads" }));
expect(await screen.findByRole("cell", { name: event.user_email })).toBeInTheDocument();
expect(screen.getByText(/External downloads are not tracked/)).toBeInTheDocument();
});
it("shows attribution and string cursors, retains rows on failure, and hides populated history on role loss", async () => {
setGlobalLitellmHeaderName("X-LiteLLM-Key");
const view = renderSkill(skill.source, true);
skillRequestMock.mockResolvedValueOnce(Response.json(page([event], 7, event.id, 5)));
fireEvent.click(screen.getByRole("tab", { name: "Downloads" }));
const row = await screen.findByRole("row", { name: /reader@example.test/ });
for (const value of ["development", "Engineering", "1.2.0", new Date(event.downloaded_at).toLocaleString("en")]) {
expect(row).toHaveTextContent(value);
}
expect(screen.getByRole("definition", { name: "Total downloads" })).toHaveTextContent("7");
expect(screen.getByRole("definition", { name: "Anonymous downloads" })).toHaveTextContent("5");
expect(skillRequestMock).toHaveBeenLastCalledWith(
downloadsUrl,
expect.objectContaining({
headers: expect.objectContaining({ "X-LiteLLM-Key": "Bearer dashboard-token" }),
}),
);
skillRequestMock.mockResolvedValueOnce(Response.json({ detail: "Unavailable" }, { status: 500 }));
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load download history");
expect(screen.getByRole("cell", { name: event.user_email })).toBeInTheDocument();
skillRequestMock.mockResolvedValueOnce(Response.json(page([{ ...event, id: "2", user_email: null }], 7, null, 5)));
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
expect(await screen.findByRole("cell", { name: event.user_id })).toBeInTheDocument();
expect(skillRequestMock).toHaveBeenLastCalledWith(`${downloadsUrl}&cursor=${event.id}`, expect.anything());
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
view.rerender(<SkillDetail skill={skill} onBack={vi.fn()} accessToken="dashboard-token" isAdmin={false} />);
expect(screen.queryByRole("tab", { name: "Downloads" })).not.toBeInTheDocument();
expect(screen.queryByRole("table", { name: "Download history" })).not.toBeInTheDocument();
});
it.each([
{ nextSkill: { ...skill, id: "recreated-skill" }, token: "dashboard-token" },
{ nextSkill: skill, token: "another-admin-token" },
])("clears old history while a new scope fails to load", async ({ nextSkill, token }) => {
const view = renderSkill(skill.source, true);
skillRequestMock.mockResolvedValueOnce(Response.json(page([event])));
fireEvent.click(screen.getByRole("tab", { name: "Downloads" }));
expect(await screen.findByRole("cell", { name: event.user_email })).toBeInTheDocument();
const nextPage = Promise.withResolvers<Response>();
skillRequestMock.mockReturnValueOnce(nextPage.promise);
view.rerender(<SkillDetail skill={nextSkill} onBack={vi.fn()} accessToken={token} isAdmin />);
expect(screen.getByRole("status")).toHaveTextContent("Loading download history");
expect(screen.queryByRole("table", { name: "Download history" })).not.toBeInTheDocument();
nextPage.resolve(Response.json({ detail: "Unavailable" }, { status: 500 }));
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load download history");
expect(screen.queryByText("Total downloads")).not.toBeInTheDocument();
});
it.each([skill.source, { source: "archive", url: `${getProxyBaseUrl()}/skill-hub/skills/my-skill/archive` }])(
"refreshes canonical history after a successful ZIP download and refreshes anonymous-only totals",
async (source) => {
URL.createObjectURL = vi.fn(() => "blob:skill-download");
renderSkill(source, true);
skillRequestMock.mockResolvedValueOnce(Response.json(page()));
fireEvent.click(screen.getByRole("tab", { name: "Downloads" }));
expect(await screen.findByText("No authenticated download history yet.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "Overview" }));
skillRequestMock
.mockResolvedValueOnce(new Response("zip-bytes"))
.mockImplementation(() => Promise.resolve(Response.json(page([event]))));
fireEvent.click(screen.getByRole("button", { name: "Download ZIP" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Download ZIP" })).toBeEnabled());
expect(skillRequestMock).toHaveBeenLastCalledWith(downloadsUrl, expect.anything());
fireEvent.click(screen.getByRole("tab", { name: "Downloads" }));
expect(await screen.findByRole("cell", { name: event.user_email })).toBeInTheDocument();
await waitFor(() => expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled());
skillRequestMock.mockResolvedValue(Response.json(page([], 5, null, 5)));
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
expect(await screen.findByText("No authenticated download history yet.")).toBeInTheDocument();
expect(screen.getByRole("definition", { name: "Total downloads" })).toHaveTextContent("5");
skillRequestMock.mockResolvedValueOnce(Response.json({ versions: [], next_offset: null }));
fireEvent.click(screen.getByRole("tab", { name: "Version history" }));
expect(await screen.findByText(/No recorded releases yet/)).toBeInTheDocument();
},
);

View file

@ -1,4 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, screen } from "@testing-library/react";
import { renderWithProviders as render } from "@/../tests/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Plugin } from "./types";

View file

@ -1,4 +1,5 @@
import React, { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, Copy, Download, Link2 } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { useClaudeCodeMarketplaceRequiresKey } from "@/app/(dashboard)/hooks/claudeCodeMarketplace/useClaudeCodeMarketplaceRequiresKey";
@ -6,6 +7,8 @@ import { cn } from "@/lib/cva.config";
import { downloadBlob } from "@/components/EntityUsageExport/utils";
import { apiClient, getProxyBaseUrl } from "@/components/networking";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { SkillDownloads, skillDownloadsQueryKey } from "./SkillDownloads";
import {
buildMarketplaceSettingsSnippet,
formatInstallCommand,
@ -44,22 +47,14 @@ export const skillHubDetail = (skill: SkillHubSkill): SkillDetailData => ({
downloadAvailable: Boolean(skill.download_url),
});
const SkillSource: React.FC<Pick<SkillDetailProps, "skill" | "accessToken">> = ({ skill, accessToken }) => {
const SkillSource: React.FC<
Pick<SkillDetailProps, "skill" | "accessToken" | "isAdmin"> & { archivePath: string | null }
> = ({ skill, accessToken, isAdmin, archivePath }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [downloading, setDownloading] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null);
const { source } = skill;
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);
@ -67,6 +62,14 @@ const SkillSource: React.FC<Pick<SkillDetailProps, "skill" | "accessToken">> = (
try {
const blob = await apiClient.getBlob(archivePath, { accessToken, redirect: "error" });
downloadBlob(blob, `${skill.name}.zip`);
if (isAdmin) {
await queryClient.invalidateQueries({
queryKey: skillDownloadsQueryKey(skill, accessToken),
exact: true,
refetchType: "all",
});
await queryClient.invalidateQueries({ queryKey: ["skill-hub", accessToken] });
}
} catch (error) {
setDownloadError(
error instanceof Error ? error.message : t("claudeCodePlugins.skillDetail.overview.downloadFailed"),
@ -121,11 +124,23 @@ interface SkillDetailProps {
onEditClick?: () => void;
}
const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, onEditClick }) => {
const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, onEditClick, isAdmin = false }) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState("overview");
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const marketplaceRequiresKey = useClaudeCodeMarketplaceRequiresKey();
const visibleTab = activeTab === "downloads" && !isAdmin ? "overview" : activeTab;
const portableArchivePath = skill.downloadAvailable
? `/skill-hub/skills/${encodeURIComponent(skill.name)}/archive`
: null;
const archivePath = skill.portable
? portableArchivePath
: getManagedSkillArchivePath(
skill.name,
skill.source,
getProxyBaseUrl(),
typeof window !== "undefined" ? window.location.origin : undefined,
);
const copyToClipboard = (text: string, key: string) => {
navigator.clipboard.writeText(text);
@ -168,10 +183,11 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, o
{ key: "overview", label: t("claudeCodePlugins.skillDetail.tabs.overview") },
...(!skill.portable ? [{ key: "usage", label: t("claudeCodePlugins.skillDetail.tabs.usage") }] : []),
...(accessToken ? [{ key: "history", label: t("claudeCodePlugins.skillDetail.history.title") }] : []),
...(isAdmin ? [{ key: "downloads", label: t("claudeCodePlugins.skillDetail.tabs.downloads") }] : []),
];
return (
<div className="py-6 pl-0 pr-8">
<Tabs value={visibleTab} onValueChange={setActiveTab} className="block py-6 pl-0 pr-8">
{/* Back link */}
<div
onClick={onBack}
@ -198,30 +214,32 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, o
{/* Tab bar */}
<div className="mb-7 mt-6 border-b border-border">
<div className="flex">
<TabsList variant="line" className="h-auto p-0">
{tabs.map((tab) => (
<div
<TabsTrigger
key={tab.key}
onClick={() => setActiveTab(tab.key)}
value={tab.key}
className={cn(
"-mb-px cursor-pointer border-b-[3px] px-5 py-3 text-sm",
activeTab === tab.key
"-mb-px h-auto cursor-pointer rounded-none border-b-[3px] px-5 py-3 text-sm after:hidden",
visibleTab === tab.key
? "border-info font-medium text-info"
: "border-transparent font-normal text-muted-foreground",
)}
>
{tab.label}
</div>
</TabsTrigger>
))}
</div>
</TabsList>
</div>
{/* Overview tab */}
{activeTab === "history" && accessToken && (
<SkillVersionHistory key={`${skill.id}:${accessToken}`} skill={skill} accessToken={accessToken} />
{visibleTab === "history" && accessToken && (
<TabsContent value="history">
<SkillVersionHistory key={`${skill.id}:${accessToken}`} skill={skill} accessToken={accessToken} />
</TabsContent>
)}
{activeTab === "overview" && (
<div className="flex gap-16">
{visibleTab === "overview" && (
<TabsContent value="overview" className="flex gap-16">
{/* Left column */}
<div className="min-w-0 flex-1">
<h2 className="m-0 mb-1 text-lg font-normal text-foreground">
@ -268,7 +286,13 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, o
</span>
</div>
<SkillSource skill={skill} accessToken={accessToken} />
<SkillSource
key={skill.id}
skill={skill}
accessToken={accessToken}
isAdmin={isAdmin}
archivePath={archivePath}
/>
{skill.keywords && skill.keywords.length > 0 && (
<div className="mb-6">
@ -295,12 +319,12 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, o
<div className="break-all font-mono text-xs text-foreground">{skill.id}</div>
</div>
</div>
</div>
</TabsContent>
)}
{/* How to Use tab */}
{activeTab === "usage" && (
<div className="max-w-[640px]">
{visibleTab === "usage" && (
<TabsContent value="usage" className="max-w-[640px]">
<h2 className="m-0 mb-2 text-lg font-normal text-foreground">
{t("claudeCodePlugins.skillDetail.usage.title")}
</h2>
@ -346,12 +370,12 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, o
{t("claudeCodePlugins.skillDetail.usage.seeSetup")}
</span>
</p>
</div>
</TabsContent>
)}
{/* Setup tab (linked from usage) */}
{activeTab === "setup" && (
<div className="max-w-[640px]">
{visibleTab === "setup" && (
<TabsContent value="setup" className="max-w-[640px]">
<h2 className="m-0 mb-2 text-lg font-normal text-foreground">
{t("claudeCodePlugins.skillDetail.setup.title")}
</h2>
@ -447,9 +471,14 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, o
</div>
<pre className="m-0 bg-card px-4 py-3.5 font-mono text-[13px] text-foreground">{settingsSnippet}</pre>
</div>
</div>
</TabsContent>
)}
</div>
{isAdmin && visibleTab === "downloads" && (
<TabsContent value="downloads">
<SkillDownloads skill={skill} accessToken={accessToken} />
</TabsContent>
)}
</Tabs>
);
};

View file

@ -18,6 +18,8 @@ export type PluginAuthor = components["schemas"]["PluginAuthor"];
export type Plugin = components["schemas"]["PluginListItem"];
export type PluginListItem = Plugin;
export type ListPluginsResponse = components["schemas"]["ListPluginsResponse"];
export type PluginDownloadEvent = components["schemas"]["PluginDownloadEvent"];
export type PluginDownloadsResponse = components["schemas"]["PluginDownloadsResponse"];
export type SkillRegisterRequest = Omit<components["schemas"]["RegisterPluginRequest"], "version"> & {
version?: components["schemas"]["RegisterPluginRequest"]["version"];

View file

@ -1647,7 +1647,8 @@
"createdAt": "Created At",
"actions": "Actions",
"domain": "Domain",
"updatedAt": "Updated at"
"updatedAt": "Updated at",
"downloads": "Downloads"
}
},
"addForm": {
@ -1869,7 +1870,30 @@
"back": "Skills",
"tabs": {
"overview": "Overview",
"usage": "How to Use"
"usage": "How to Use",
"downloads": "Downloads"
},
"downloads": {
"title": "Download history",
"description": "ZIP downloads served by this gateway, including repeat downloads and client prefetches. Counts do not confirm installations. External downloads are not tracked.",
"attributionNote": "History identifies authenticated callers or shared-key owners. Anonymous downloads are included in totals without individual history.",
"total": "Total downloads",
"anonymousTotal": "Anonymous downloads",
"downloadedAt": "Downloaded at",
"downloader": "User or key owner",
"key": "Key alias",
"team": "Team",
"version": "Version",
"anonymous": "Anonymous",
"unidentified": "Authenticated, no user identity",
"unavailable": "Not recorded",
"loading": "Loading download history…",
"loadFailed": "Could not load download history. Try refreshing.",
"empty": "No authenticated download history yet.",
"refresh": "Refresh",
"refreshing": "Refreshing download history…",
"loadMore": "Load more",
"loadingMore": "Loading more downloads…"
},
"properties": {
"category": "Category",

View file

@ -2708,6 +2708,29 @@ export interface paths {
patch?: never;
trace?: never;
};
"/claude-code/plugins/{plugin_name}/downloads": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Plugin Downloads
* @description Admin-only ZIP download totals and authenticated downloader history.
*
* Anonymous downloads contribute only to totals. Counts include repeat requests and client prefetches,
* starting when tracking is deployed. External sources and installation completion are not observed.
*/
get: operations["get_plugin_downloads_claude_code_plugins__plugin_name__downloads_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/claude-code/plugins/{plugin_name}/enable": {
parameters: {
query?: never;
@ -15919,6 +15942,29 @@ export interface paths {
patch?: never;
trace?: never;
};
"/skill-hub/skills/{plugin_name}/downloads": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Plugin Downloads
* @description Admin-only ZIP download totals and authenticated downloader history.
*
* Anonymous downloads contribute only to totals. Counts include repeat requests and client prefetches,
* starting when tracking is deployed. External sources and installation completion are not observed.
*/
get: operations["get_plugin_downloads_skill_hub_skills__plugin_name__downloads_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/skill-hub/submissions": {
parameters: {
query?: never;
@ -42026,6 +42072,43 @@ export interface components {
*/
url: string;
};
/** PluginDownloadEvent */
PluginDownloadEvent: {
/** Authenticated */
authenticated: boolean;
/** Digest */
digest: string;
/**
* Downloaded At
* Format: date-time
*/
downloaded_at: string;
/** Id */
id: string;
/** Key Alias */
key_alias: string | null;
/** Team Alias */
team_alias: string | null;
/** Team Id */
team_id: string | null;
/** User Email */
user_email: string | null;
/** User Id */
user_id: string | null;
/** Version */
version: string | null;
};
/** PluginDownloadsResponse */
PluginDownloadsResponse: {
/** Anonymous Downloads */
anonymous_downloads: number;
/** Downloads */
downloads: components["schemas"]["PluginDownloadEvent"][];
/** Next Cursor */
next_cursor: string | null;
/** Total Downloads */
total_downloads: number;
};
/**
* PluginListItem
* @description Plugin item in list responses.
@ -46928,6 +47011,11 @@ export interface components {
description?: string | null;
/** Domain */
domain?: string | null;
/**
* Download Count
* @description Gateway ZIP downloads, included in proxy-admin catalog listings
*/
download_count?: number | null;
/** Download Url */
download_url?: string | null;
/** Id */
@ -57038,6 +57126,42 @@ export interface operations {
};
};
};
get_plugin_downloads_claude_code_plugins__plugin_name__downloads_get: {
parameters: {
query?: {
/** @description Maximum number of download events */
limit?: number;
/** @description Previous next_cursor */
cursor?: string | null;
};
header?: never;
path: {
plugin_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PluginDownloadsResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
enable_plugin_claude_code_plugins__plugin_name__enable_post: {
parameters: {
query?: never;
@ -74722,6 +74846,42 @@ export interface operations {
};
};
};
get_plugin_downloads_skill_hub_skills__plugin_name__downloads_get: {
parameters: {
query?: {
/** @description Maximum number of download events */
limit?: number;
/** @description Previous next_cursor */
cursor?: string | null;
};
header?: never;
path: {
plugin_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PluginDownloadsResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_submissions_skill_hub_submissions_get: {
parameters: {
query?: {