mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-10 08:28:25 +00:00
feat(skills): add sortable Skills Hub catalog and publication audit (#50195)
* feat(skills): add sortable Skills Hub catalog and publication audit * refactor(skills): remove redundant publisher table column * style(skills): apply dashboard formatting * fix(skills): preserve approval audit and complete hub sorting
This commit is contained in:
parent
40a4ebaf6e
commit
a7f9f892be
37 changed files with 1229 additions and 272 deletions
|
|
@ -41,6 +41,14 @@ class PluginArchiveMetadata:
|
|||
description: str | None = None
|
||||
has_agent_skill_metadata: bool = False
|
||||
updated_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
created_by: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginReview:
|
||||
approved_by: str
|
||||
security_scan_passed: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -51,6 +59,7 @@ class PluginPublication:
|
|||
manifest: Mapping[str, object]
|
||||
created_by: str | None
|
||||
content: bytes | None = None
|
||||
review: PluginReview | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -66,6 +75,10 @@ class PublishedPlugin:
|
|||
version: str
|
||||
enabled: bool
|
||||
updated_at: datetime
|
||||
created_at: datetime | None = None
|
||||
created_by: str | None = None
|
||||
review: PluginReview | None = None
|
||||
published_by: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
|
|||
|
|
@ -7683,6 +7683,17 @@
|
|||
"PluginListItem": {
|
||||
"description": "Plugin item in list responses.",
|
||||
"properties": {
|
||||
"approved_by": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Approved By"
|
||||
},
|
||||
"author": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -7715,6 +7726,17 @@
|
|||
],
|
||||
"title": "Created At"
|
||||
},
|
||||
"created_by": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Created By"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -7737,6 +7759,17 @@
|
|||
],
|
||||
"title": "Domain"
|
||||
},
|
||||
"download_count": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Download Count"
|
||||
},
|
||||
"enabled": {
|
||||
"title": "Enabled",
|
||||
"type": "boolean"
|
||||
|
|
@ -7801,6 +7834,22 @@
|
|||
],
|
||||
"title": "Namespace"
|
||||
},
|
||||
"published_by": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Published By"
|
||||
},
|
||||
"security_scan_passed": {
|
||||
"default": false,
|
||||
"title": "Security Scan Passed",
|
||||
"type": "boolean"
|
||||
},
|
||||
"source": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
|
|
@ -59415,6 +59464,14 @@
|
|||
},
|
||||
"title": "Skills",
|
||||
"type": "array"
|
||||
},
|
||||
"tags": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Tags",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
|
@ -59690,6 +59747,17 @@
|
|||
},
|
||||
"SkillHubSkill": {
|
||||
"properties": {
|
||||
"approved_by": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Approved By"
|
||||
},
|
||||
"content_url": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -59701,6 +59769,29 @@
|
|||
],
|
||||
"title": "Content Url"
|
||||
},
|
||||
"created_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Created At"
|
||||
},
|
||||
"created_by": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Created By"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -59732,7 +59823,7 @@
|
|||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Gateway ZIP downloads, included in proxy-admin catalog listings",
|
||||
"description": "Gateway ZIP downloads, including repeat downloads",
|
||||
"title": "Download Count"
|
||||
},
|
||||
"download_url": {
|
||||
|
|
@ -59779,6 +59870,22 @@
|
|||
],
|
||||
"title": "Namespace"
|
||||
},
|
||||
"published_by": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Published By"
|
||||
},
|
||||
"security_scan_passed": {
|
||||
"default": false,
|
||||
"title": "Security Scan Passed",
|
||||
"type": "boolean"
|
||||
},
|
||||
"source": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
|
|
@ -60434,6 +60541,66 @@
|
|||
"title": "Limit",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tag",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": "",
|
||||
"title": "Tag",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "security_scan_passed",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Security Scan Passed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "sort_by",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": "name",
|
||||
"enum": [
|
||||
"name",
|
||||
"download_count",
|
||||
"updated_at",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"published_by",
|
||||
"approved_by",
|
||||
"security_scan_passed"
|
||||
],
|
||||
"title": "Sort By",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "sort_order",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": "asc",
|
||||
"enum": [
|
||||
"asc",
|
||||
"desc"
|
||||
],
|
||||
"title": "Sort Order",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ from litellm.proxy.common_utils.claude_code_marketplace_auth import (
|
|||
)
|
||||
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
|
||||
from litellm.proxy.deps.repositories import PluginUploadRepositoryDep
|
||||
from litellm.repositories.plugin_publication import read_plugin_publisher, read_plugin_review
|
||||
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
|
||||
from litellm.types.proxy.auth.special_headers import SpecialHeaders
|
||||
from litellm.types.proxy.claude_code_endpoints import (
|
||||
|
|
@ -666,6 +667,7 @@ async def register_plugin(
|
|||
response_model=ListPluginsResponse,
|
||||
)
|
||||
async def list_plugins(
|
||||
uploads: PluginUploadRepositoryDep,
|
||||
enabled_only: bool = False,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
) -> ListPluginsResponse:
|
||||
|
|
@ -678,6 +680,17 @@ async def list_plugins(
|
|||
Returns:
|
||||
List of plugins with their metadata.
|
||||
"""
|
||||
catalog: Final = await list_visible_plugins(enabled_only, user_api_key_dict)
|
||||
if uploads is None:
|
||||
raise _error_response(500, CommonProxyErrors.db_not_connected_error.value)
|
||||
counts: Final = await uploads.download_counts(tuple(plugin.id for plugin in catalog.plugins))
|
||||
return ListPluginsResponse(
|
||||
plugins=[plugin.model_copy(update={"download_count": counts.get(plugin.id, 0)}) for plugin in catalog.plugins],
|
||||
count=catalog.count,
|
||||
)
|
||||
|
||||
|
||||
async def list_visible_plugins(enabled_only: bool, user_api_key_dict: UserAPIKeyAuth) -> ListPluginsResponse:
|
||||
try:
|
||||
prisma_client: Final = await get_prisma_client()
|
||||
|
||||
|
|
@ -693,9 +706,14 @@ async def list_plugins(
|
|||
# Parse manifest to get additional fields
|
||||
manifest = json.loads(p.manifest_json) if p.manifest_json else {}
|
||||
|
||||
review = read_plugin_review(MANIFEST_FIELDS.validate_python(manifest))
|
||||
plugin_list.append(
|
||||
PluginListItem(
|
||||
id=p.id,
|
||||
created_by=p.created_by,
|
||||
approved_by=review.approved_by if review else None,
|
||||
published_by=read_plugin_publisher(MANIFEST_FIELDS.validate_python(manifest)),
|
||||
security_scan_passed=review.security_scan_passed if review else False,
|
||||
name=p.name,
|
||||
version=p.version,
|
||||
description=p.description,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import hashlib
|
|||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Final
|
||||
from itertools import chain
|
||||
from typing import Annotated, Final, Literal
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
|
@ -33,7 +34,7 @@ from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketp
|
|||
enable_plugin,
|
||||
get_plugin_downloads,
|
||||
header_key_caller,
|
||||
list_plugins,
|
||||
list_visible_plugins,
|
||||
plugin_archive_source_matches,
|
||||
plugin_archive_url,
|
||||
plugin_downloader,
|
||||
|
|
@ -65,6 +66,7 @@ from litellm.proxy.discovery_endpoints.skill_import import (
|
|||
parse_skill_source,
|
||||
select_imported_skill,
|
||||
)
|
||||
from litellm.repositories.plugin_publication import read_plugin_publisher, read_plugin_review
|
||||
from litellm.types.proxy.claude_code_endpoints import PluginListItem
|
||||
from litellm.types.proxy.discovery_endpoints.agent_skills_endpoints import AgentSkillsIndexEntry
|
||||
from litellm.types.proxy.skill_hub import (
|
||||
|
|
@ -78,6 +80,7 @@ from litellm.types.proxy.skill_hub import (
|
|||
SkillHubSkill,
|
||||
SkillHubVersion,
|
||||
SkillHubVersions,
|
||||
SkillSortField,
|
||||
)
|
||||
|
||||
router: Final = APIRouter(prefix="/skill-hub/skills", tags=["Skills Hub"])
|
||||
|
|
@ -121,6 +124,11 @@ def _skill_response(request: Request, plugin: PluginListItem, download_count: in
|
|||
namespace=plugin.namespace,
|
||||
keywords=None if plugin.keywords is None else tuple(plugin.keywords),
|
||||
updated_at=None if plugin.updated_at is None else datetime.fromisoformat(plugin.updated_at),
|
||||
created_at=None if plugin.created_at is None else datetime.fromisoformat(plugin.created_at),
|
||||
created_by=plugin.created_by,
|
||||
approved_by=plugin.approved_by,
|
||||
published_by=plugin.published_by,
|
||||
security_scan_passed=plugin.security_scan_passed,
|
||||
visibility="public" if plugin.enabled else "private",
|
||||
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,
|
||||
|
|
@ -137,28 +145,74 @@ async def list_skills(
|
|||
query: str = "",
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||
tag: str = "",
|
||||
security_scan_passed: bool | None = None,
|
||||
sort_by: SkillSortField = "name",
|
||||
sort_order: Literal["asc", "desc"] = "asc",
|
||||
) -> SkillHubList:
|
||||
"""Browse the caller's public and granted private skills."""
|
||||
catalog: Final = await list_plugins(enabled_only=False, user_api_key_dict=caller)
|
||||
needle: Final = query.casefold()
|
||||
catalog: Final = await list_visible_plugins(enabled_only=False, user_api_key_dict=caller)
|
||||
needle: Final = query.strip().casefold()
|
||||
selected_tag: Final = tag.strip().casefold()
|
||||
matched: Final = tuple(
|
||||
plugin
|
||||
for plugin in sorted(catalog.plugins, key=lambda item: item.name)
|
||||
if needle in " ".join((plugin.name, plugin.description or "", *(plugin.keywords or ()))).casefold()
|
||||
if needle
|
||||
in " ".join(
|
||||
(
|
||||
plugin.name,
|
||||
plugin.description or "",
|
||||
plugin.domain or "",
|
||||
plugin.namespace or "",
|
||||
plugin.created_by or "",
|
||||
plugin.approved_by or "",
|
||||
plugin.published_by or "",
|
||||
*(plugin.keywords or ()),
|
||||
)
|
||||
).casefold()
|
||||
and (not selected_tag or selected_tag in {word.casefold() for word in plugin.keywords or ()})
|
||||
and (security_scan_passed is None or plugin.security_scan_passed == security_scan_passed)
|
||||
)
|
||||
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)
|
||||
await _require_store(uploads).download_counts(tuple(plugin.id for plugin in matched))
|
||||
if sort_by == "download_count"
|
||||
else {}
|
||||
)
|
||||
ordered: Final = sorted(
|
||||
matched, key=lambda plugin: _skill_sort_key(plugin, sort_by, counts), reverse=sort_order == "desc"
|
||||
)
|
||||
page: Final = ordered[offset : offset + limit]
|
||||
page_counts: Final = (
|
||||
await _require_store(uploads).download_counts(tuple(plugin.id for plugin in page))
|
||||
if sort_by != "download_count"
|
||||
else counts
|
||||
)
|
||||
return SkillHubList(
|
||||
skills=tuple(_skill_response(request, plugin, counts.get(plugin.id)) for plugin in page),
|
||||
skills=tuple(_skill_response(request, plugin, page_counts.get(plugin.id)) for plugin in page),
|
||||
count=len(matched),
|
||||
next_offset=offset + limit if offset + limit < len(matched) else None,
|
||||
tags=tuple(
|
||||
sorted(set(chain.from_iterable(plugin.keywords or () for plugin in catalog.plugins)), key=str.casefold)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _skill_sort_key(plugin: PluginListItem, field: SkillSortField, counts: Mapping[str, int]) -> str | float:
|
||||
if field == "download_count":
|
||||
return counts.get(plugin.id, 0)
|
||||
if field == "security_scan_passed":
|
||||
return int(plugin.security_scan_passed)
|
||||
if field in {"created_at", "updated_at"}:
|
||||
value: Final = plugin.created_at if field == "created_at" else plugin.updated_at
|
||||
return datetime.fromisoformat(value).timestamp() if value else 0
|
||||
return {
|
||||
"name": plugin.name,
|
||||
"created_by": plugin.created_by or "",
|
||||
"published_by": plugin.published_by or "",
|
||||
"approved_by": plugin.approved_by or "",
|
||||
}[field].casefold()
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=SkillHubSkill)
|
||||
async def get_skill(
|
||||
request: Request,
|
||||
|
|
@ -176,6 +230,7 @@ async def get_skill(
|
|||
|
||||
def skill_metadata_response(request: Request, metadata: PluginArchiveMetadata) -> SkillHubSkill:
|
||||
manifest: Final = MANIFEST_FIELDS.validate_json(metadata.manifest_json or "{}")
|
||||
review: Final = read_plugin_review(manifest)
|
||||
return _skill_response(
|
||||
request,
|
||||
PluginListItem.model_validate(
|
||||
|
|
@ -187,7 +242,11 @@ def skill_metadata_response(request: Request, metadata: PluginArchiveMetadata) -
|
|||
"source": dict(metadata.source),
|
||||
"has_agent_skill_metadata": metadata.has_agent_skill_metadata,
|
||||
"enabled": metadata.enabled,
|
||||
"created_at": None,
|
||||
"created_at": metadata.created_at.isoformat() if metadata.created_at is not None else None,
|
||||
"created_by": metadata.created_by,
|
||||
"approved_by": review.approved_by if review else None,
|
||||
"published_by": read_plugin_publisher(manifest),
|
||||
"security_scan_passed": review.security_scan_passed if review else False,
|
||||
"updated_at": metadata.updated_at.isoformat() if metadata.updated_at is not None else None,
|
||||
"domain": manifest.get("domain"),
|
||||
"namespace": manifest.get("namespace"),
|
||||
|
|
@ -416,7 +475,11 @@ def skill_publication_response(
|
|||
"version": published.version,
|
||||
"enabled": published.enabled,
|
||||
"has_agent_skill_metadata": has_agent_skill_metadata(publication.manifest),
|
||||
"created_at": None,
|
||||
"created_at": published.created_at.isoformat() if published.created_at is not None else None,
|
||||
"created_by": published.created_by,
|
||||
"approved_by": published.review.approved_by if published.review else None,
|
||||
"published_by": published.published_by,
|
||||
"security_scan_passed": published.review.security_scan_passed if published.review else False,
|
||||
"updated_at": published.updated_at.isoformat(),
|
||||
"domain": publication.manifest.get("domain"),
|
||||
"namespace": publication.manifest.get("namespace"),
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ from litellm.proxy._types import (
|
|||
CommonProxyErrors,
|
||||
)
|
||||
from litellm.proxy.db.health_check_latest import latest_by_model_name
|
||||
from litellm.proxy.deps.repositories import PluginUploadRepositoryDep
|
||||
from litellm.proxy.utils import get_custom_url
|
||||
from litellm.repositories.plugin_publication import read_plugin_publisher, read_plugin_review
|
||||
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
|
||||
from litellm.router_strategy.complexity_router.fuse_presets import FusePresetCatalog, get_fuse_presets
|
||||
from litellm.types.agents import AgentCard
|
||||
|
|
@ -71,6 +73,9 @@ class _EndpointEntry(TypedDict):
|
|||
|
||||
|
||||
class _PluginRow(Protocol):
|
||||
@property
|
||||
def created_by(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def id(self) -> str: ...
|
||||
|
||||
|
|
@ -366,7 +371,7 @@ async def get_mcp_servers():
|
|||
"/public/skill_hub",
|
||||
tags=["public", "Claude Code Marketplace"],
|
||||
)
|
||||
async def public_skill_hub():
|
||||
async def public_skill_hub(uploads: PluginUploadRepositoryDep):
|
||||
"""Return enabled (public) Claude Code skills, or none when the marketplace requires a key."""
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
|
||||
MANIFEST_FIELDS,
|
||||
|
|
@ -385,13 +390,22 @@ async def public_skill_hub():
|
|||
raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
|
||||
try:
|
||||
plugins: Final = await _plugin_table(prisma_client).find_many(where={"enabled": True})
|
||||
counts: Final[Mapping[str, int]] = (
|
||||
await uploads.download_counts(tuple(plugin.id for plugin in plugins)) if uploads else {}
|
||||
)
|
||||
items: Final = []
|
||||
for plugin in plugins:
|
||||
raw = plugin.manifest_json or {}
|
||||
manifest = json.loads(raw) if isinstance(raw, str) else raw
|
||||
review = read_plugin_review(MANIFEST_FIELDS.validate_python(manifest))
|
||||
items.append(
|
||||
PluginListItem(
|
||||
id=plugin.id,
|
||||
download_count=counts.get(plugin.id),
|
||||
created_by=plugin.created_by,
|
||||
approved_by=review.approved_by if review else None,
|
||||
published_by=read_plugin_publisher(MANIFEST_FIELDS.validate_python(manifest)),
|
||||
security_scan_passed=review.security_scan_passed if review else False,
|
||||
name=plugin.name,
|
||||
enabled=plugin.enabled,
|
||||
created_at=str(plugin.created_at) if plugin.created_at else None,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Final, Protocol
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from semver import Version
|
||||
|
||||
from litellm.models.plugin_upload import (
|
||||
PluginPublication,
|
||||
PluginPublicationBase,
|
||||
PluginPublicationError,
|
||||
PluginReview,
|
||||
PublishedPlugin,
|
||||
)
|
||||
from litellm.repositories.prisma_protocols import TableActions
|
||||
|
|
@ -23,6 +26,22 @@ if TYPE_CHECKING:
|
|||
|
||||
_MANIFEST: Final = TypeAdapter(dict[str, object])
|
||||
_SOURCE: Final = TypeAdapter(dict[str, str])
|
||||
_REVIEW: Final = TypeAdapter(PluginReview)
|
||||
_PUBLISHER: Final = TypeAdapter[str | None](str | None)
|
||||
|
||||
|
||||
def read_plugin_review(manifest: Mapping[str, object]) -> PluginReview | None:
|
||||
try:
|
||||
return _REVIEW.validate_python(manifest.get("_litellm_review"))
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def read_plugin_publisher(manifest: Mapping[str, object]) -> str | None:
|
||||
try:
|
||||
return _PUBLISHER.validate_python(manifest.get("_litellm_published_by"))
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
class PluginPublicationTransaction(Protocol):
|
||||
|
|
@ -122,7 +141,22 @@ async def publish_plugin(
|
|||
)
|
||||
if isinstance(version, PluginPublicationError):
|
||||
return version
|
||||
manifest_json: Final = json.dumps({**publication.manifest, "version": version})
|
||||
current_manifest: Final = _MANIFEST.validate_json(current.manifest_json or "{}") if current is not None else {}
|
||||
same_source: Final = current is not None and current_manifest.get("source") == publication.manifest.get("source")
|
||||
review: Final = publication.review or (read_plugin_review(current_manifest) if same_source else None)
|
||||
publisher: Final = publication.review.approved_by if publication.review is not None else publication.created_by
|
||||
manifest_json: Final = json.dumps(
|
||||
{
|
||||
**{
|
||||
key: value
|
||||
for key, value in publication.manifest.items()
|
||||
if key not in {"_litellm_review", "_litellm_published_by"}
|
||||
},
|
||||
"version": version,
|
||||
**({"_litellm_published_by": publisher} if publisher is not None else {}),
|
||||
**({"_litellm_review": asdict(review)} if review is not None else {}),
|
||||
}
|
||||
)
|
||||
if publication.content is not None and verified_archive(publication.content, manifest_json) is None:
|
||||
return PluginPublicationError("conflict", "Uploaded archive does not match its published source digest")
|
||||
timestamp: Final = datetime.now(timezone.utc)
|
||||
|
|
@ -141,8 +175,18 @@ async def publish_plugin(
|
|||
await tx.litellm_claudecodepluginupload.create(
|
||||
data={"plugin_name": publication.name, "content": Base64.encode(publication.content)}
|
||||
)
|
||||
await _snapshot(tx, created, publication.content, publication.created_by)
|
||||
return PublishedPlugin(created.id, created.name, version, created.enabled, created.updated_at or timestamp)
|
||||
await _snapshot(tx, created, publication.content, publisher)
|
||||
return PublishedPlugin(
|
||||
created.id,
|
||||
created.name,
|
||||
version,
|
||||
created.enabled,
|
||||
created.updated_at or timestamp,
|
||||
created.created_at,
|
||||
created.created_by,
|
||||
review,
|
||||
publisher,
|
||||
)
|
||||
previous_archive: Final = await tx.litellm_claudecodepluginupload.find_unique(where={"plugin_name": current.name})
|
||||
previous_content: Final = (
|
||||
verified_archive(previous_archive.content.decode(), current.manifest_json)
|
||||
|
|
@ -152,11 +196,7 @@ async def publish_plugin(
|
|||
history_count: Final = await tx.litellm_claudecodepluginversion.count(where={"plugin_id": current.id}, take=1)
|
||||
if history_count == 0:
|
||||
await _snapshot(tx, current, previous_content, None)
|
||||
unchanged_content: Final = (
|
||||
previous_content
|
||||
if _MANIFEST.validate_json(current.manifest_json or "{}").get("source") == publication.manifest.get("source")
|
||||
else None
|
||||
)
|
||||
unchanged_content: Final = previous_content if same_source else None
|
||||
content: Final = publication.content if publication.content is not None else unchanged_content
|
||||
updated: Final = await tx.litellm_claudecodeplugintable.update(where={"id": current.id}, data=data)
|
||||
if updated is None:
|
||||
|
|
@ -171,5 +211,15 @@ async def publish_plugin(
|
|||
"update": {"content": Base64.encode(content)},
|
||||
},
|
||||
)
|
||||
await _snapshot(tx, updated, content, publication.created_by)
|
||||
return PublishedPlugin(updated.id, updated.name, version, updated.enabled, updated.updated_at or timestamp)
|
||||
await _snapshot(tx, updated, content, publisher)
|
||||
return PublishedPlugin(
|
||||
updated.id,
|
||||
updated.name,
|
||||
version,
|
||||
updated.enabled,
|
||||
updated.updated_at or timestamp,
|
||||
updated.created_at,
|
||||
updated.created_by,
|
||||
review,
|
||||
publisher,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -287,4 +287,6 @@ def _archive_metadata(plugin: "prisma_models.LiteLLM_ClaudeCodePluginTable") ->
|
|||
description=plugin.description,
|
||||
has_agent_skill_metadata=has_agent_skill_metadata(manifest),
|
||||
updated_at=plugin.updated_at,
|
||||
created_at=plugin.created_at,
|
||||
created_by=plugin.created_by,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ from datetime import datetime, timezone
|
|||
from typing import TYPE_CHECKING, Final, Protocol, cast # noqa: TID251 # Prisma's generated delegates are dynamic
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
||||
|
||||
from litellm.models.plugin_upload import (
|
||||
PluginPublication,
|
||||
PluginPublicationBase,
|
||||
PluginPublicationError,
|
||||
PluginReview,
|
||||
PublishedPlugin,
|
||||
uploaded_plugin_source,
|
||||
)
|
||||
|
|
@ -312,7 +313,9 @@ class SkillSubmissionRepository:
|
|||
if approve and content is not None and publication_base_url
|
||||
else _SOURCE.validate_python(submission.proposal.manifest.get("source"))
|
||||
)
|
||||
publication: Final = await _publish_submission(transaction, row, source, content) if approve else None
|
||||
publication: Final = (
|
||||
await _publish_submission(transaction, row, source, content, reviewer) if approve else None
|
||||
)
|
||||
if isinstance(publication, PluginPublicationError):
|
||||
return publication
|
||||
reviewed_at: Final = datetime.now(timezone.utc)
|
||||
|
|
@ -346,6 +349,7 @@ async def _publish_submission(
|
|||
row: "prisma_models.LiteLLM_SkillSubmission",
|
||||
source: Mapping[str, str],
|
||||
content: bytes | None,
|
||||
reviewer: str,
|
||||
) -> PublishedPlugin | PluginPublicationError:
|
||||
stored: Final = _StoredProposal.model_validate_json(row.proposal_json)
|
||||
proposal: Final = stored.proposal(row.plugin_name)
|
||||
|
|
@ -363,6 +367,24 @@ async def _publish_submission(
|
|||
manifest={**stored.manifest, "source": dict(source)},
|
||||
content=content,
|
||||
created_by=row.submitted_by,
|
||||
review=PluginReview(reviewer, scan_passed_for_archive(row.scan_json, source.get("sha256"))),
|
||||
),
|
||||
base,
|
||||
)
|
||||
|
||||
|
||||
def scan_passed_for_archive(scan_json: str | None, digest: str | None) -> bool:
|
||||
if not scan_json or not digest:
|
||||
return False
|
||||
try:
|
||||
scan: Final = SkillScan.model_validate_json(scan_json)
|
||||
except ValidationError:
|
||||
return False
|
||||
return (
|
||||
scan.status == "completed"
|
||||
and scan.archive_sha256 == digest
|
||||
and scan.report is not None
|
||||
and scan.report.complete
|
||||
and scan.report.risk_score == 0
|
||||
and not scan.report.findings
|
||||
)
|
||||
|
|
|
|||
|
|
@ -154,6 +154,11 @@ class PluginListItem(LiteLLMBaseModel):
|
|||
enabled: bool
|
||||
created_at: str | None
|
||||
updated_at: str | None
|
||||
created_by: str | None = None
|
||||
approved_by: str | None = None
|
||||
published_by: str | None = None
|
||||
security_scan_passed: bool = False
|
||||
download_count: int | None = None
|
||||
has_agent_skill_metadata: bool = Field(default=False, exclude=True)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,17 @@ from pydantic import ConfigDict, Field
|
|||
from litellm.models.skill_submission import SubmissionOperation, SubmissionStatus
|
||||
from litellm.types.llms.base import LiteLLMBaseModel
|
||||
|
||||
SkillSortField: TypeAlias = Literal[
|
||||
"name",
|
||||
"download_count",
|
||||
"updated_at",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"published_by",
|
||||
"approved_by",
|
||||
"security_scan_passed",
|
||||
]
|
||||
|
||||
|
||||
class SkillHubSkill(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
|
@ -16,6 +27,11 @@ class SkillHubSkill(LiteLLMBaseModel):
|
|||
description: str | None = None
|
||||
version: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
created_by: str | None = None
|
||||
approved_by: str | None = None
|
||||
published_by: str | None = None
|
||||
security_scan_passed: bool = False
|
||||
visibility: Literal["public", "private"]
|
||||
source: Mapping[str, str]
|
||||
download_url: str | None = None
|
||||
|
|
@ -23,12 +39,11 @@ 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"
|
||||
)
|
||||
download_count: int | None = Field(None, description="Gateway ZIP downloads, including repeat downloads")
|
||||
|
||||
|
||||
class SkillHubList(LiteLLMBaseModel):
|
||||
tags: tuple[str, ...] = ()
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
skills: tuple[SkillHubSkill, ...]
|
||||
|
|
|
|||
|
|
@ -134,6 +134,8 @@ async def test_approval_commits_version_archive_history_and_review(pending_updat
|
|||
assert current is not None
|
||||
published_manifest: Final = {
|
||||
**case.proposal.manifest,
|
||||
"_litellm_review": {"approved_by": "integration-reviewer", "security_scan_passed": False},
|
||||
"_litellm_published_by": "integration-reviewer",
|
||||
"source": {
|
||||
**TypeAdapter(dict[str, str]).validate_python(case.submission.proposal.manifest["source"]),
|
||||
"url": f"https://skills.example.test/claude-code/plugins/{case.original.name}/archive/"
|
||||
|
|
@ -148,17 +150,22 @@ async def test_approval_commits_version_archive_history_and_review(pending_updat
|
|||
)
|
||||
metadata: Final = await case.publications.get_metadata(case.original.name)
|
||||
assert metadata is not None
|
||||
assert metadata.created_by == before.created_by == case.original.created_by
|
||||
assert metadata.created_at == before.created_at
|
||||
assert metadata.updated_at is not None and before.updated_at is not None
|
||||
assert metadata.updated_at > before.updated_at
|
||||
assert await case.publications.get_content(metadata) == case.proposal.content
|
||||
history: Final = await case.publications.versions(metadata, 10, 0)
|
||||
assert tuple((version.version, version.description, version.archive_available) for version in history) == (
|
||||
("1.0.1", case.proposal.description, True),
|
||||
("1.0.0", case.original.description, True),
|
||||
)
|
||||
assert history[0].published_by == "integration-reviewer"
|
||||
assert history[1].published_by == case.original.created_by
|
||||
for version, publication, manifest in zip(
|
||||
history, (case.proposal, case.original), (published_manifest, case.original.manifest), strict=True
|
||||
history, (case.proposal, case.original), (published_manifest, {**case.original.manifest, "_litellm_published_by": case.original.created_by}), strict=True
|
||||
):
|
||||
assert version.manifest == {**manifest, "version": version.version}
|
||||
assert version.published_by == publication.created_by
|
||||
assert await case.publications.version_content(metadata, version.id) == publication.content
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ def _make_mock_prisma():
|
|||
record.description = data.get("description")
|
||||
record.manifest_json = data.get("manifest_json", "{}")
|
||||
record.enabled = data.get("enabled", True)
|
||||
record.created_by = data.get("created_by")
|
||||
record.created_at = data.get("created_at")
|
||||
record.updated_at = data.get("updated_at")
|
||||
store[data["name"]] = record
|
||||
|
|
@ -221,7 +222,7 @@ async def test_metadata_edits_preserve_uploaded_source_and_reject_a_changed_http
|
|||
await table.create(data={"name": _UPLOAD_NAME, "manifest_json": json.dumps({"source": _UPLOAD_SOURCE})})
|
||||
request: Final = UpdatePluginRequest(source=_UPLOAD_SOURCE, description="Updated description", version="1.0.0")
|
||||
response: Final = await update_plugin(uploads=_uploads(), plugin_name=_UPLOAD_NAME, request=request, user_api_key_dict=_USER)
|
||||
expected: Final = {"name": _UPLOAD_NAME, "source": _UPLOAD_SOURCE, "description": "Updated description", "version": "1.0.0"}
|
||||
expected: Final = {"name": _UPLOAD_NAME, "source": _UPLOAD_SOURCE, "description": "Updated description", "version": "1.0.0", "_litellm_published_by": _USER.user_id}
|
||||
assert response.plugin.description == "Updated description"
|
||||
assert await _read_stored_manifest(_UPLOAD_NAME) == expected
|
||||
with pytest.raises(HTTPException) as error:
|
||||
|
|
@ -354,7 +355,7 @@ async def _register_public_and_private_plugins() -> None:
|
|||
|
||||
|
||||
async def _listed_names(user: UserAPIKeyAuth) -> set[str]:
|
||||
response = await list_plugins(user_api_key_dict=user)
|
||||
response = await list_plugins(uploads=_uploads(), user_api_key_dict=user)
|
||||
return {plugin.name for plugin in response.plugins}
|
||||
|
||||
|
||||
|
|
@ -474,7 +475,7 @@ async def test_update_plugin_propagates_installation_preference_to_get_and_list(
|
|||
)
|
||||
|
||||
plugin = await get_plugin(plugin_name=name, user_api_key_dict=_USER)
|
||||
listed_plugin = (await list_plugins(user_api_key_dict=_USER)).plugins[0]
|
||||
listed_plugin = (await list_plugins(uploads=_uploads(), user_api_key_dict=_USER)).plugins[0]
|
||||
|
||||
assert response.plugin.installation_preference == "auto_install"
|
||||
assert plugin["installation_preference"] == "auto_install"
|
||||
|
|
@ -514,7 +515,7 @@ async def test_stored_unknown_installation_preference_is_not_served(stored_value
|
|||
)
|
||||
|
||||
marketplace = json.loads((await get_marketplace(request=MagicMock())).body)
|
||||
listed_plugin = (await list_plugins(user_api_key_dict=_USER)).plugins[0]
|
||||
listed_plugin = (await list_plugins(uploads=_uploads(), user_api_key_dict=_USER)).plugins[0]
|
||||
plugin = await get_plugin(plugin_name=name, user_api_key_dict=_USER)
|
||||
|
||||
assert marketplace["plugins"] == [{"name": name, "source": _ARCHIVE_SOURCE}]
|
||||
|
|
@ -1288,7 +1289,7 @@ async def test_skill_upload_creates_metadata_and_bytes_atomically_and_rejects_du
|
|||
"url": f"http://test/claude-code/plugins/{_UPLOAD_NAME}/archive/{digest}.zip",
|
||||
"sha256": digest,
|
||||
}
|
||||
assert json.loads(stored["manifest_json"]) == {**metadata, "source": source}
|
||||
assert json.loads(stored["manifest_json"]) == {**metadata, "source": source, "_litellm_published_by": stored["created_by"]}
|
||||
assert stored == {
|
||||
"name": _UPLOAD_NAME, "description": "My notes", "version": "2.0.0",
|
||||
"manifest_json": stored["manifest_json"], "created_by": litellm.proxy.proxy_server.litellm_proxy_admin_name,
|
||||
|
|
|
|||
|
|
@ -196,25 +196,25 @@ 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,
|
||||
@pytest.mark.parametrize("route", (f"{HUB}?limit=1", "/claude-code/plugins"))
|
||||
@pytest.mark.parametrize("key", (MASTER_KEY, PLAIN_KEY))
|
||||
async def test_catalog_counts_are_batched_for_the_page(
|
||||
hub: HubClient, key: str, route: str,
|
||||
) -> 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))
|
||||
response: Final = await hub.client.get(route, 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
|
||||
items: Final = response.json()["plugins" if route == "/claude-code/plugins" else "skills"]
|
||||
expected: Final = {record.id: 7, "other": 0} if route == "/claude-code/plugins" else {record.id: 7}
|
||||
assert {item["id"]: item["download_count"] for item in items} == expected
|
||||
assert hub.database.query_raw.await_count == 1
|
||||
assert set(hub.database.query_raw.await_args.args[1]) == set(expected)
|
||||
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"))
|
||||
|
|
@ -806,3 +806,98 @@ async def test_configured_scan_persists_result_without_changing_manual_approval(
|
|||
assert (await hub.client.get(path, headers=_headers(MASTER_KEY))).json() == before
|
||||
approved: Final = await hub.client.put(path + "/approve", headers=_headers(MASTER_KEY))
|
||||
assert approved.status_code == 200 and approved.json()["submission"]["status"] == "approved"
|
||||
|
||||
|
||||
async def test_catalog_filters_metadata_before_pagination_and_preserves_tag_facets(hub: HubClient) -> None:
|
||||
hub.plugins.find_many.return_value = [
|
||||
prisma_models.LiteLLM_ClaudeCodePluginTable(
|
||||
id=name, name=name, enabled=True, anonymous_download_count=0, created_by="author@example.test",
|
||||
created_at=datetime(2026, 1, 2), updated_at=datetime(2026, 2, 3),
|
||||
manifest_json=json.dumps({
|
||||
"source": {"source": "github", "repo": "example/skills"},
|
||||
"keywords": tags,
|
||||
"_litellm_published_by": "publisher@example.test",
|
||||
**({"_litellm_review": {"approved_by": "reviewer@example.test", "security_scan_passed": scanned}} if approved else {}),
|
||||
}),
|
||||
)
|
||||
for name, tags, approved, scanned in (
|
||||
("a-other", ["support"], False, False),
|
||||
("b-review", ["engineering"], True, False),
|
||||
("c-review", ["engineering"], True, True),
|
||||
)
|
||||
]
|
||||
response = await hub.client.get(HUB, headers=_headers(MASTER_KEY), params={
|
||||
"query": " REVIEWER ", "tag": "ENGINEERING", "limit": 1, "offset": 1,
|
||||
})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["count"] == 2
|
||||
assert body["tags"] == ["engineering", "support"]
|
||||
assert [skill["name"] for skill in body["skills"]] == ["c-review"]
|
||||
skill = body["skills"][0]
|
||||
assert skill["created_by"] == "author@example.test"
|
||||
assert skill["published_by"] == "publisher@example.test"
|
||||
assert skill["approved_by"] == "reviewer@example.test"
|
||||
assert skill["created_at"].startswith("2026-01-02")
|
||||
assert skill["updated_at"].startswith("2026-02-03")
|
||||
assert skill["security_scan_passed"] is True
|
||||
scanned = await hub.client.get(HUB, headers=_headers(MASTER_KEY), params={"security_scan_passed": "true"})
|
||||
assert [skill["name"] for skill in scanned.json()["skills"]] == ["c-review"]
|
||||
unscanned = await hub.client.get(HUB, headers=_headers(MASTER_KEY), params={"security_scan_passed": "false"})
|
||||
assert [skill["name"] for skill in unscanned.json()["skills"]] == ["a-other", "b-review"]
|
||||
assert unscanned.json()["skills"][0]["approved_by"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("has_issues", (False, True))
|
||||
async def test_admin_approval_does_not_override_scan_result(pending_hub: HubClient, has_issues: bool) -> None:
|
||||
hub: Final = pending_hub
|
||||
pending: Final = await _upload(hub, _zip(FILES), MEMBER_KEY, version=None)
|
||||
receipt: Final = pending.json()
|
||||
submission: Final = hub.database.litellm_skillsubmission.rows[0]
|
||||
digest: Final = hashlib.sha256(submission.archive_content.decode()).hexdigest()
|
||||
await hub.database.litellm_skillsubmission.update(
|
||||
where={"id": submission.id}, data={"scan_json": json.dumps({
|
||||
"id": "scan", "requested_by": "admin", "started_at": "2026-01-01T00:00:00Z",
|
||||
"expires_at": "2026-01-01T00:03:00Z", "status": "completed", "archive_sha256": digest,
|
||||
"report": {"scanner_version": "test", "scan_mode": "static-only", "complete": True,
|
||||
"risk_score": 0, "findings": [{"rule_id": "rule", "severity": "LOW",
|
||||
"message": "Issue", "file": "SKILL.md"}] if has_issues else []},
|
||||
})},
|
||||
)
|
||||
approved: Final = await hub.client.put(
|
||||
f"/skill-hub/submissions/{receipt['submission_id']}/approve", headers=_headers(MASTER_KEY),
|
||||
)
|
||||
assert approved.status_code == 200
|
||||
skill: Final = approved.json()["skill"]
|
||||
assert skill["security_scan_passed"] is not has_issues
|
||||
assert skill["created_by"] == MEMBER_KEY
|
||||
assert skill["approved_by"] == approved.json()["submission"]["reviewed_by"]
|
||||
assert skill["published_by"] == skill["approved_by"]
|
||||
_stored_upload(hub)
|
||||
listed: Final = (await hub.client.get(HUB, headers=_headers(MASTER_KEY))).json()["skills"][0]
|
||||
assert listed["security_scan_passed"] is not has_issues
|
||||
assert listed["published_by"] == skill["published_by"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,order,expected", [
|
||||
("name", "asc", "a"), ("name", "desc", "c"),
|
||||
("download_count", "desc", "a"), ("download_count", "asc", "b"),
|
||||
("updated_at", "desc", "c"), ("updated_at", "asc", "b"),
|
||||
])
|
||||
async def test_catalog_sorts_all_matches_before_pagination(
|
||||
hub: HubClient, field: str, order: str, expected: str,
|
||||
) -> None:
|
||||
hub.plugins.find_many.return_value = [
|
||||
prisma_models.LiteLLM_ClaudeCodePluginTable(
|
||||
id=name, name=name, enabled=True, anonymous_download_count=0, updated_at=datetime(2026, 1, day),
|
||||
manifest_json=json.dumps({"source": {"source": "github", "repo": "example/skills"}}),
|
||||
) for name, day in (("a", 2), ("b", 1), ("c", 3))
|
||||
]
|
||||
hub.database.query_raw.return_value = [
|
||||
{"id": name, "download_count": count} for name, count in (("a", 8), ("b", 0), ("c", 3))
|
||||
]
|
||||
response = await hub.client.get(HUB, headers=_headers(PLAIN_KEY), params={
|
||||
"sort_by": field, "sort_order": order, "limit": 1, "offset": 0,
|
||||
})
|
||||
assert response.status_code == 200, response.text
|
||||
assert [skill["name"] for skill in response.json()["skills"]] == [expected]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from litellm.proxy.deps.repositories import get_plugin_upload_repository
|
||||
from litellm.repositories.plugin_upload_repository import PluginUploadRepository
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
|
|
@ -1804,7 +1806,6 @@ def test_public_skill_hub_echoes_registered_installation_preference(preference:
|
|||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import register_plugin
|
||||
from litellm.repositories.plugin_upload_repository import PluginUploadRepository
|
||||
from litellm.types.proxy.claude_code_endpoints import RegisterPluginRequest
|
||||
|
||||
def create(data: Mapping[str, object]) -> LiteLLM_ClaudeCodePluginTable:
|
||||
|
|
@ -1826,6 +1827,7 @@ def test_public_skill_hub_echoes_registered_installation_preference(preference:
|
|||
prisma.db.litellm_claudecodepluginversion.create = AsyncMock()
|
||||
app: Final = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_plugin_upload_repository] = lambda: PluginUploadRepository(prisma)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
|
||||
asyncio.run(
|
||||
|
|
@ -1843,8 +1845,10 @@ def test_public_skill_hub_echoes_registered_installation_preference(preference:
|
|||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"),
|
||||
)
|
||||
)
|
||||
prisma.db.query_raw.return_value = [{"id": "plugin-id", "download_count": 12}]
|
||||
response: Final = TestClient(app).get("/public/skill_hub")
|
||||
|
||||
assert response.json()["plugins"][0]["download_count"] == 12
|
||||
assert response.status_code == 200
|
||||
assert [(p["name"], p["installation_preference"]) for p in response.json()["plugins"]] == [
|
||||
("security-review", preference)
|
||||
|
|
@ -1854,6 +1858,7 @@ def test_public_skill_hub_echoes_registered_installation_preference(preference:
|
|||
_INTERNAL_PLUGIN: Final = SimpleNamespace(
|
||||
id="plugin-id",
|
||||
name="deploy-helper",
|
||||
created_by=None,
|
||||
enabled=True,
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
|
|
@ -1874,8 +1879,11 @@ def _skill_hub_response(find_many: AsyncMock, general_settings: Mapping[str, obj
|
|||
table.find_many = find_many
|
||||
prisma: Final = MagicMock()
|
||||
prisma.db.litellm_claudecodeplugintable = table
|
||||
prisma.writer_db = prisma.db
|
||||
prisma.db.query_raw = AsyncMock(return_value=[])
|
||||
app: Final = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_plugin_upload_repository] = lambda: PluginUploadRepository(prisma)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma),
|
||||
|
|
@ -1909,6 +1917,7 @@ def test_public_skill_hub_500_body_carries_no_exception_text() -> None:
|
|||
def test_public_skill_hub_without_a_database_answers_db_not_connected() -> None:
|
||||
app: Final = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_plugin_upload_repository] = lambda: None
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", None),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from litellm.models.plugin_upload import (
|
|||
PluginPublication,
|
||||
PluginPublicationBase,
|
||||
PluginPublicationError,
|
||||
PluginReview,
|
||||
PublishedPlugin,
|
||||
)
|
||||
from litellm.repositories.plugin_publication import (
|
||||
|
|
@ -144,6 +145,44 @@ async def test_first_legacy_update_preserves_its_original_snapshot(
|
|||
}
|
||||
assert replacement.kwargs["data"] == {
|
||||
"plugin_id": current.id, "version": result.version, "description": "replacement",
|
||||
"manifest_json": json.dumps({"source": source, "version": result.version}),
|
||||
"manifest_json": json.dumps({"source": source, "version": result.version, "_litellm_published_by": "admin"}),
|
||||
"published_at": result.updated_at, "published_by": "admin", "content": None, "archive_available": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("same_source", (True, False))
|
||||
@pytest.mark.parametrize("review", [None, PluginReview("reviewer@example.test", False)])
|
||||
@pytest.mark.asyncio
|
||||
async def test_publication_preserves_only_trusted_review_for_unchanged_source(
|
||||
publication_tx: MagicMock, review: PluginReview | None, same_source: bool,
|
||||
) -> None:
|
||||
original = publication_tx.litellm_claudecodeplugintable.find_unique.return_value
|
||||
stored_review = {"approved_by": "original-reviewer", "security_scan_passed": True}
|
||||
stored_manifest = {**json.loads(original.manifest_json), "_litellm_review": stored_review}
|
||||
current = original.model_copy(update={"manifest_json": json.dumps(stored_manifest)})
|
||||
publication_tx.litellm_claudecodeplugintable.find_unique.return_value = current
|
||||
|
||||
async def update(where: Mapping[str, str], data: Mapping[str, object]) -> LiteLLM_ClaudeCodePluginTable:
|
||||
return current.model_copy(update=dict(data))
|
||||
|
||||
publication_tx.litellm_claudecodeplugintable.update.side_effect = update
|
||||
result = await publish_plugin(
|
||||
cast(PluginPublicationTransaction, publication_tx),
|
||||
PluginPublication("skill", None, "updated", {
|
||||
"_litellm_review": {"approved_by": "forged", "security_scan_passed": True},
|
||||
"source": stored_manifest["source"] if same_source else {"source": "github", "repo": "example/skills"},
|
||||
}, "author", review=review),
|
||||
PluginPublicationBase(current.id, current.updated_at),
|
||||
)
|
||||
assert isinstance(result, PublishedPlugin)
|
||||
manifest = json.loads(publication_tx.litellm_claudecodeplugintable.update.call_args.kwargs["data"]["manifest_json"])
|
||||
assert manifest["_litellm_published_by"] == (review.approved_by if review else "author")
|
||||
expected = (
|
||||
{"approved_by": review.approved_by, "security_scan_passed": review.security_scan_passed} if review
|
||||
else stored_review if same_source else None
|
||||
)
|
||||
assert manifest.get("_litellm_review") == expected
|
||||
assert (None if result.review is None else {
|
||||
"approved_by": result.review.approved_by, "security_scan_passed": result.review.security_scan_passed,
|
||||
}) == expected
|
||||
assert json.loads(publication_tx.litellm_claudecodepluginversion.create.call_args.kwargs["data"]["manifest_json"]).get("_litellm_review") == expected
|
||||
|
|
|
|||
31
tests/unit/repositories/test_skill_submission_repository.py
Normal file
31
tests/unit/repositories/test_skill_submission_repository.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.repositories.skill_submission_repository import scan_passed_for_archive
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status,digest,report,expected", [
|
||||
("completed", "current", {"complete": True, "risk_score": 0, "findings": []}, True),
|
||||
("running", "current", {"complete": True, "risk_score": 0, "findings": []}, False),
|
||||
("failed", "current", {"complete": True, "risk_score": 0, "findings": []}, False),
|
||||
("completed", "previous", {"complete": True, "risk_score": 0, "findings": []}, False),
|
||||
("completed", "current", {"complete": False, "risk_score": 0, "findings": []}, False),
|
||||
("completed", "current", None, False),
|
||||
("completed", "current", {"complete": True, "risk_score": 90, "findings": []}, False),
|
||||
("completed", "current", {"complete": True, "risk_score": 0, "findings": [{"rule_id": "rule", "severity": "LOW", "message": "Issue", "file": "SKILL.md"}]}, False),
|
||||
("completed", "current", {"complete": True, "risk_score": 0}, False),
|
||||
])
|
||||
def test_scan_pass_requires_clean_complete_report_for_published_archive(
|
||||
status: str, digest: str, report: dict[str, object] | None, expected: bool,
|
||||
) -> None:
|
||||
assert scan_passed_for_archive(json.dumps({
|
||||
"id": "scan", "requested_by": "admin", "started_at": "2026-01-01T00:00:00Z",
|
||||
"expires_at": "2026-01-01T00:03:00Z", "status": status, "archive_sha256": digest,
|
||||
"report": None if report is None else {"scanner_version": "test", "scan_mode": "static-only", **report},
|
||||
}), "current") is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("record", [None, "", "{}", "null", "not-json"])
|
||||
def test_missing_or_invalid_scan_is_not_reported_as_passed(record: str | None) -> None:
|
||||
assert scan_passed_for_archive(record, "current") is False
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
|
||||
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
|
||||
import { chooseSelectOption, renderWithProviders, testQueryClient } from "@/../tests/test-utils";
|
||||
import { setGlobalLitellmHeaderName } from "@/components/networking";
|
||||
import type { SkillHubSkill, SkillSubmission } from "@/components/claude_code_plugins/types";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -61,6 +62,7 @@ const pending: SkillSubmission = {
|
|||
const fetchMock = vi.fn<typeof fetch>();
|
||||
const queryDefaults = testQueryClient.getDefaultOptions();
|
||||
let catalog: SkillHubSkill[];
|
||||
let catalogRequests: URL[];
|
||||
let submissions: SkillSubmission[];
|
||||
let pageRequests: { limit: number; offset: number }[];
|
||||
let writes: { path: string; method: string; body: RequestInit["body"] }[];
|
||||
|
|
@ -161,6 +163,7 @@ beforeEach(() => {
|
|||
testQueryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
catalog = [published];
|
||||
catalogRequests = [];
|
||||
submissions = [pending];
|
||||
pageRequests = [];
|
||||
writes = [];
|
||||
|
|
@ -180,14 +183,17 @@ beforeEach(() => {
|
|||
return respondToWrite(path, body);
|
||||
}
|
||||
if (path === "/skill-hub/skills") {
|
||||
catalogRequests.push(url);
|
||||
const limit = Number(url.searchParams.get("limit"));
|
||||
const offset = Number(url.searchParams.get("offset"));
|
||||
const skills = catalog.slice(offset, offset + limit);
|
||||
return Response.json({
|
||||
const result = {
|
||||
tags: ["review", "engineering"],
|
||||
skills,
|
||||
count: skills.length,
|
||||
next_offset: offset + limit < catalog.length ? offset + limit : null,
|
||||
});
|
||||
};
|
||||
return Response.json(result);
|
||||
}
|
||||
if (path === `/skill-hub/skills/${published.name}`)
|
||||
return Response.json(catalog.find((skill) => skill.name === published.name));
|
||||
|
|
@ -250,7 +256,7 @@ it.each(["current", "retained"])("refreshes published counts after a %s download
|
|||
catalog = [{ ...published, download_count: 1 }];
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
fireEvent.click(screen.getByText("Skills"));
|
||||
fireEvent.click(screen.getByText("Skills Hub"));
|
||||
expect(await screen.findByRole("cell", { name: "1" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -266,7 +272,7 @@ it("paginates the neutral catalog and refreshes detail for a recreated skill", a
|
|||
renderWithProviders(<ClaudeCodePluginsPanel accessToken="member-token" userRole="Internal User" />);
|
||||
fireEvent.click(await screen.findByText(published.name));
|
||||
expect(await screen.findByRole("button", { name: "Edit Skill" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Skills"));
|
||||
fireEvent.click(screen.getByText("Skills Hub"));
|
||||
catalog = [
|
||||
{ ...published, id: "filler", name: "catalog-0" },
|
||||
...catalog.slice(1),
|
||||
|
|
@ -281,7 +287,7 @@ it("paginates the neutral catalog and refreshes detail for a recreated skill", a
|
|||
fireEvent.click(screen.getByText(published.name));
|
||||
expect(await screen.findByRole("button", { name: "Edit Skill" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Recreated instructions")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Skills"));
|
||||
fireEvent.click(screen.getByText("Skills Hub"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Previous" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled());
|
||||
});
|
||||
|
|
@ -394,7 +400,7 @@ it("allows a member submission, explains approval and refreshes their pending li
|
|||
await waitFor(() =>
|
||||
expect(screen.getByRole("tab", { name: "Published skills" })).toHaveAttribute("aria-selected", "true"),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add Skill" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Register your skill" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Import URL" }));
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Source URL" }), {
|
||||
target: { value: "https://github.com/example/new-skill" },
|
||||
|
|
@ -593,10 +599,10 @@ it("refreshes detail after a review conflict and removes actions when another re
|
|||
|
||||
it("keeps an effective Admin viewer read-only while allowing full submission inspection", async () => {
|
||||
renderWithProviders(<ClaudeCodePluginsPanel accessToken="viewer-token" userRole="Admin" isViewOnly />);
|
||||
expect(screen.getByRole("button", { name: "+ Add Skill" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "+ Register your skill" })).toBeDisabled();
|
||||
fireEvent.click(await screen.findByText(published.name));
|
||||
expect(screen.queryByRole("button", { name: "Edit Skill" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Skills"));
|
||||
fireEvent.click(screen.getByText("Skills Hub"));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Submissions" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "View details" }));
|
||||
expect(await screen.findByText("Currently published")).toBeInTheDocument();
|
||||
|
|
@ -643,7 +649,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();
|
||||
expect(screen.getByRole("columnheader", { name: "Downloads" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not reuse admin review metadata when the same account loses its role and detail refresh fails", async () => {
|
||||
|
|
@ -762,3 +768,27 @@ it.each([200, 409])("refreshes the running scan after POST %s and ignores an old
|
|||
fireEvent.click(screen.getByRole("button", { name: "Approve and publish" }));
|
||||
expect(await screen.findByText("Approved")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies tag and scan filters across the catalog and resets pagination when searching", async () => {
|
||||
const user = userEvent.setup();
|
||||
catalog = [published, { ...published, id: "second", name: "second-skill" }];
|
||||
renderWithProviders(<ClaudeCodePluginsPanel accessToken="member-token" userRole="Internal User" />, {
|
||||
searchParams: { skills_page: "2", skills_page_size: "1" },
|
||||
});
|
||||
await waitFor(() => expect(catalogRequests.at(-1)?.searchParams.get("offset")).toBe("1"));
|
||||
await screen.findByRole("cell", { name: "Approved instructions" });
|
||||
await user.click(screen.getByRole("button", { name: "Downloads" }));
|
||||
await waitFor(() => expect(catalogRequests.at(-1)?.searchParams.get("sort_by")).toBe("download_count"));
|
||||
expect(catalogRequests.at(-1)?.searchParams.get("offset")).toBe("0");
|
||||
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by tag" }), "engineering");
|
||||
await waitFor(() => expect(catalogRequests.at(-1)?.searchParams.get("tag")).toBe("engineering"));
|
||||
expect(catalogRequests.at(-1)?.searchParams.get("offset")).toBe("0");
|
||||
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Security scan" }), "Security scan: Yes");
|
||||
await waitFor(() => expect(catalogRequests.at(-1)?.searchParams.get("security_scan_passed")).toBe("true"));
|
||||
fireEvent.change(screen.getByRole("textbox", { name: /Search skills/ }), {
|
||||
target: { value: "reviewer@example.test" },
|
||||
});
|
||||
await waitFor(() => expect(catalogRequests.at(-1)?.searchParams.get("query")).toBe("reviewer@example.test"));
|
||||
expect(catalogRequests.at(-1)?.searchParams.get("tag")).toBe("engineering");
|
||||
expect(catalogRequests.at(-1)?.searchParams.get("security_scan_passed")).toBe("true");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -87,7 +87,16 @@ describe("ClaudeCodePluginsPanel loading state", () => {
|
|||
|
||||
resolveFetch({ skills: [], count: 0, next_offset: null });
|
||||
expect(await screen.findByText("table-loaded")).toBeInTheDocument();
|
||||
expect(mockListSkillHubSkills).toHaveBeenCalledWith("sk-test", { limit: 50, offset: 0 });
|
||||
const expectedQuery = {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
query: "",
|
||||
tag: "",
|
||||
security_scan_passed: undefined,
|
||||
sort_by: "name",
|
||||
sort_order: "asc",
|
||||
};
|
||||
expect(mockListSkillHubSkills).toHaveBeenCalledWith("sk-test", expectedQuery);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import type { SkillListQuery } from "@/components/claude_code_plugins/types";
|
||||
import { SkillFilters } from "@/components/claude_code_plugins/SkillFilters";
|
||||
import { useSkillFilters, skillSortQuery } from "@/components/claude_code_plugins/skillCatalog";
|
||||
import React, { useState } from "react";
|
||||
import { useQuery, useQueryClient, type QueryKey } from "@tanstack/react-query";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
|
@ -21,7 +24,6 @@ import { toast } from "@/lib/toast";
|
|||
import { isPendingSkill, type SkillHubSkill, type SkillMutationResponse } from "@/components/claude_code_plugins/types";
|
||||
import { useUrlTab } from "@/hooks/useUrlTab";
|
||||
import SkillSubmissions from "./SkillSubmissions";
|
||||
import { useUrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable/useUrlTableState";
|
||||
|
||||
interface ClaudeCodePluginsPanelProps {
|
||||
accessToken: string | null;
|
||||
|
|
@ -29,14 +31,7 @@ interface ClaudeCodePluginsPanelProps {
|
|||
isViewOnly?: boolean;
|
||||
}
|
||||
|
||||
const CATALOG_STATE: UrlTableStateOptions<never> = {
|
||||
keyPrefix: "skills_",
|
||||
defaultPageSize: 50,
|
||||
maxPageSize: 100,
|
||||
sortFields: ["name"],
|
||||
defaultSort: { id: "name", desc: false },
|
||||
filterColumns: [],
|
||||
};
|
||||
const scanResultFilter = (scan: string): boolean | undefined => (scan === "all" ? undefined : scan === "yes");
|
||||
|
||||
const SKILLS_TABS = ["skills", "submissions"] as const;
|
||||
|
||||
|
|
@ -117,11 +112,20 @@ const SkillsPanelContent: React.FC<ClaudeCodePluginsPanelProps> = ({
|
|||
const { readOnly, canWrite, isAdmin, adminView } = skillPermissions(accessToken, userRole, isViewOnly);
|
||||
const queryScope = ["skill-hub", accessToken, userRole, readOnly] as const;
|
||||
const showForm = Boolean(isAddModalVisible || editingSkill) && canWrite;
|
||||
const { pagination, onPaginationChange } = useUrlTableState(CATALOG_STATE);
|
||||
const filters = useSkillFilters();
|
||||
const { pagination, onPaginationChange, sorting, onSortingChange } = filters;
|
||||
const { pageIndex, pageSize } = pagination;
|
||||
const catalogQuery: SkillListQuery & { limit: number; offset: number } = {
|
||||
limit: pageSize,
|
||||
offset: pageIndex * pageSize,
|
||||
query: filters.search,
|
||||
tag: filters.tag,
|
||||
security_scan_passed: scanResultFilter(filters.scan),
|
||||
...skillSortQuery(sorting),
|
||||
};
|
||||
const plugins = useQuery({
|
||||
queryKey: [...queryScope, "published", pageIndex, pageSize],
|
||||
queryFn: () => listSkillHubSkills(accessToken!, { limit: pageSize, offset: pageIndex * pageSize }),
|
||||
queryKey: [...queryScope, "published", catalogQuery],
|
||||
queryFn: () => listSkillHubSkills(accessToken!, catalogQuery),
|
||||
enabled: Boolean(accessToken),
|
||||
});
|
||||
const pluginsList = plugins.data?.skills ?? [];
|
||||
|
|
@ -193,6 +197,9 @@ const SkillsPanelContent: React.FC<ClaudeCodePluginsPanelProps> = ({
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="skills">
|
||||
<div className="my-4">
|
||||
<SkillFilters {...filters} tags={plugins.data?.tags ?? []} />
|
||||
</div>
|
||||
{plugins.isError && (
|
||||
<div role="alert" className="my-3 text-sm text-destructive">
|
||||
{t("skills.panel.loadFailed")}
|
||||
|
|
@ -204,6 +211,9 @@ const SkillsPanelContent: React.FC<ClaudeCodePluginsPanelProps> = ({
|
|||
<PluginTable
|
||||
pluginsList={pluginsList}
|
||||
isLoading={plugins.isLoading}
|
||||
filtered={filters.active}
|
||||
sorting={sorting}
|
||||
onSortingChange={onSortingChange}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
isAdmin={isAdmin}
|
||||
onPluginClick={setSelectedSkillId}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ const mockOnPluginClick = vi.fn();
|
|||
|
||||
const defaultProps = {
|
||||
pluginsList: mockPlugins,
|
||||
sorting: [{ id: "name", desc: false }],
|
||||
onSortingChange: vi.fn(),
|
||||
isLoading: false,
|
||||
onDeleteClick: mockOnDeleteClick,
|
||||
isAdmin: true,
|
||||
|
|
@ -48,22 +50,24 @@ describe("PluginTable", () => {
|
|||
[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"]) {
|
||||
for (const header of [
|
||||
"Skill Name",
|
||||
"Tags",
|
||||
"Created by",
|
||||
"Approved by",
|
||||
"Created",
|
||||
"Last updated",
|
||||
"Security scan",
|
||||
"Downloads",
|
||||
]) {
|
||||
expect(screen.getByText(header)).toBeInTheDocument();
|
||||
}
|
||||
expect(within(screen.getByRole("row", { name: /newer-skill/ })).getByText(display)).toBeInTheDocument();
|
||||
expect(within(screen.getByRole("row", { name: /newer-skill/ })).getAllByText(display)[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the empty state when data is empty", () => {
|
||||
render(<PluginTable {...defaultProps} pluginsList={[]} />);
|
||||
expect(screen.getByText("No skills found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should sort by name ascending by default", () => {
|
||||
render(<PluginTable {...defaultProps} />);
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
expect(within(rows[0]).getByText("newer-skill")).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText("older-skill")).toBeInTheDocument();
|
||||
expect(screen.getByText("No skills registered yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onPluginClick with the plugin ID when the skill name is clicked", async () => {
|
||||
|
|
@ -89,7 +93,8 @@ describe("PluginTable", () => {
|
|||
it("should show whether the skill is public", () => {
|
||||
render(<PluginTable {...defaultProps} />);
|
||||
expect(screen.getByText("Yes")).toBeInTheDocument();
|
||||
expect(screen.getByText("No")).toBeInTheDocument();
|
||||
const privateRow = screen.getByRole("row", { name: /older-skill/ });
|
||||
expect(within(privateRow).getAllByText("No")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should delete a skill through the actions menu when admin", async () => {
|
||||
|
|
@ -111,7 +116,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();
|
||||
expect(screen.getByRole("columnheader", { name: "Downloads" })).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();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { type OnChangeFn, type SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
|
|
@ -12,30 +12,43 @@ import { getPluginTableColumns } from "./PluginTableColumns";
|
|||
|
||||
interface PluginTableProps {
|
||||
pluginsList: SkillHubSkill[];
|
||||
sorting: SortingState;
|
||||
onSortingChange: OnChangeFn<SortingState>;
|
||||
isLoading: boolean;
|
||||
filtered?: boolean;
|
||||
onDeleteClick: (pluginName: string, displayName: string) => void;
|
||||
isAdmin: boolean;
|
||||
onPluginClick: (pluginId: string) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SORTING: SortingState = [{ id: "name", desc: false }];
|
||||
|
||||
function EmptyState() {
|
||||
function EmptyState({ filtered }: { filtered: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("skills.table.empty.title")}</div>
|
||||
<div className="text-sm text-muted-foreground">{t("skills.table.empty.description")}</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t(filtered ? "aiHub.skillHubDashboard.emptyState.noMatchTitle" : "skills.table.empty.title")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t(filtered ? "aiHub.skillHubDashboard.emptyState.noMatchBody" : "skills.table.empty.description")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PluginTable: React.FC<PluginTableProps> = ({ pluginsList, isLoading, onDeleteClick, isAdmin, onPluginClick }) => {
|
||||
const PluginTable: React.FC<PluginTableProps> = ({
|
||||
pluginsList,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
isLoading,
|
||||
filtered = false,
|
||||
onDeleteClick,
|
||||
isAdmin,
|
||||
onPluginClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const columns = useMemo(
|
||||
() => getPluginTableColumns({ isAdmin, onPluginClick, onDeleteClick }),
|
||||
|
|
@ -48,12 +61,12 @@ const PluginTable: React.FC<PluginTableProps> = ({ pluginsList, isLoading, onDel
|
|||
paginationMode="none"
|
||||
columns={columns}
|
||||
getRowId={(plugin, index) => plugin.id || String(index)}
|
||||
sortingMode="client"
|
||||
sortingMode="server"
|
||||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
onSortingChange={onSortingChange}
|
||||
isLoading={isLoading}
|
||||
loadingMessage={t("skills.table.loading")}
|
||||
noDataMessage={<EmptyState />}
|
||||
noDataMessage={<EmptyState filtered={filtered} />}
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { getSkillMetadataColumns } from "@/components/claude_code_plugins/SkillMetadataColumns";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Copy, MoreHorizontal, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { IdentityCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { SkillHubSkill } from "@/components/claude_code_plugins/types";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
|
@ -143,31 +145,7 @@ export const getPluginTableColumns = ({
|
|||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "updated_at",
|
||||
accessorKey: "updated_at",
|
||||
sortingFn: "datetime",
|
||||
meta: { title: i18n.t("skills.table.columns.updatedAt") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("skills.table.columns.updatedAt")} />,
|
||||
size: 160,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.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>[])
|
||||
: []),
|
||||
...getSkillMetadataColumns<SkillHubSkill>(),
|
||||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const file = new File(["skill archive"], "personal-skill.zip", { type: "applicat
|
|||
const chooseFile = (selected = file) =>
|
||||
fireEvent.change(screen.getByLabelText("Skill ZIP file"), { target: { files: [selected] } });
|
||||
const change = (label: string, value: string) => fireEvent.change(screen.getByLabelText(label), { target: { value } });
|
||||
const submit = () => fireEvent.click(screen.getByRole("button", { name: "Add Skill" }));
|
||||
const submit = () => fireEvent.click(screen.getByRole("button", { name: "Register skill" }));
|
||||
const openImport = () => {
|
||||
renderWithProviders(<AddPluginForm {...props} />);
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Import URL" }));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import type { Plugin } from "@/components/claude_code_plugins/types";
|
||||
import SkillHubDashboard from "./SkillHubDashboard";
|
||||
|
||||
const skills: Plugin[] = [
|
||||
{ id: "alpha", name: "alpha", source: {}, category: "Writing", domain: "Support", enabled: false, download_count: 1 },
|
||||
{
|
||||
id: "beta",
|
||||
name: "beta",
|
||||
source: {},
|
||||
category: "Analysis",
|
||||
domain: "Engineering",
|
||||
enabled: true,
|
||||
download_count: 7,
|
||||
},
|
||||
];
|
||||
|
||||
describe("Skills Hub sorting", () => {
|
||||
it.each(["Category", "Domain", "Status", "Downloads"])("sorts by %s through the table header", async (header) => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SkillHubDashboard skills={skills} isLoading={false} />);
|
||||
expect(screen.getAllByRole("row")[1]).toHaveTextContent("alpha");
|
||||
await user.click(screen.getByRole("button", { name: header, exact: true }));
|
||||
await waitFor(() => expect(screen.getAllByRole("row")[1]).toHaveTextContent("beta"));
|
||||
await user.click(screen.getByRole("button", { name: header, exact: true }));
|
||||
await waitFor(() => expect(screen.getAllByRole("row")[1]).toHaveTextContent("alpha"));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,15 +1,21 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Inbox, Search, X } from "lucide-react";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plugin } from "@/components/claude_code_plugins/types";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { getSkillHubTableColumns } from "@/components/AIHub/SkillHubTableColumns";
|
||||
import SkillDetail, { legacySkillDetail } from "@/components/claude_code_plugins/skill_detail";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { SkillFilters } from "@/components/claude_code_plugins/SkillFilters";
|
||||
import {
|
||||
useSkillFilters,
|
||||
matchesSkillFilters,
|
||||
skillTags,
|
||||
SKILL_SORT_FIELDS,
|
||||
} from "@/components/claude_code_plugins/skillCatalog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
const ALL_DOMAINS = "__all_domains__";
|
||||
const DASHBOARD_SORT_FIELDS = [...SKILL_SORT_FIELDS, "category", "domain", "enabled"];
|
||||
|
||||
interface SkillHubDashboardProps {
|
||||
skills: Plugin[];
|
||||
|
|
@ -50,10 +56,9 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
|
|||
onPublishSuccess,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const filters = useSkillFilters(DASHBOARD_SORT_FIELDS);
|
||||
const [domainFilter, setDomainFilter] = useState<string | undefined>(undefined);
|
||||
const [selectedSkill, setSelectedSkill] = useState<Plugin | null>(null);
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "name", desc: false }]);
|
||||
|
||||
// Derived stats
|
||||
const totalSkills = skills.length;
|
||||
|
|
@ -69,19 +74,10 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
|
|||
if (domainFilter) {
|
||||
result = result.filter((s) => (s.domain || "General") === domainFilter);
|
||||
}
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.description?.toLowerCase().includes(q) ||
|
||||
s.domain?.toLowerCase().includes(q) ||
|
||||
s.namespace?.toLowerCase().includes(q) ||
|
||||
s.keywords?.some((k) => k.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [skills, search, domainFilter]);
|
||||
return result.filter((skill) =>
|
||||
matchesSkillFilters(skill, { search: filters.search, tag: filters.tag, scan: filters.scan }),
|
||||
);
|
||||
}, [skills, filters.search, filters.tag, filters.scan, domainFilter]);
|
||||
|
||||
const columns = useMemo(() => getSkillHubTableColumns({ onSkillClick: setSelectedSkill }), []);
|
||||
|
||||
|
|
@ -93,7 +89,7 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
|
|||
[domains, t],
|
||||
);
|
||||
|
||||
const hasActiveFilter = search.trim().length > 0 || domainFilter != null;
|
||||
const hasActiveFilter = filters.active || domainFilter != null;
|
||||
|
||||
if (selectedSkill) {
|
||||
return (
|
||||
|
|
@ -131,7 +127,7 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
|
|||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{publicPage ? t("aiHub.skillHubDashboard.allPublicSkills") : t("aiHub.skillHubDashboard.allSkills")}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
items={domainItems}
|
||||
value={domainFilter ?? ALL_DOMAINS}
|
||||
|
|
@ -148,28 +144,7 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputGroup className="w-[280px]">
|
||||
<InputGroupAddon>
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder={t("aiHub.skillHubDashboard.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{search !== "" && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
aria-label={t("aiHub.skillHubDashboard.clearSearch")}
|
||||
onClick={() => setSearch("")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
<SkillFilters {...filters} tags={skillTags(skills)} />
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
|
|
@ -178,8 +153,8 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
|
|||
columns={columns}
|
||||
getRowId={(skill, index) => skill.id || String(index)}
|
||||
sortingMode="client"
|
||||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
sorting={filters.sorting}
|
||||
onSortingChange={filters.onSortingChange}
|
||||
isLoading={isLoading}
|
||||
loadingMessage={t("aiHub.skillHubDashboard.loading")}
|
||||
noDataMessage={<SkillsEmptyState filtered={hasActiveFilter} />}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { getSkillMetadataColumns } from "@/components/claude_code_plugins/SkillMetadataColumns";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Copy, ExternalLink, Info, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -159,6 +161,7 @@ export const getSkillHubTableColumns = ({ onSkillClick }: SkillHubTableColumnsDe
|
|||
/>
|
||||
),
|
||||
},
|
||||
...getSkillMetadataColumns<Plugin>(),
|
||||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ describe("visibleMenuGroups", () => {
|
|||
});
|
||||
|
||||
expect(flattenNavItems(visible).find(({ route }) => route === "skills")).toMatchObject({
|
||||
label: "Skills",
|
||||
label: "Skills Hub",
|
||||
route: "skills",
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import { Search } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import type { ScanFilter } from "./skillCatalog";
|
||||
|
||||
interface SkillFiltersProps {
|
||||
search: string;
|
||||
tag: string;
|
||||
scan: ScanFilter;
|
||||
tags: string[];
|
||||
active: boolean;
|
||||
setSearch: (value: string) => void;
|
||||
setTag: (value: string) => void;
|
||||
setScan: (value: ScanFilter) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export function SkillFilters({
|
||||
search,
|
||||
tag,
|
||||
scan,
|
||||
tags,
|
||||
active,
|
||||
setSearch,
|
||||
setTag,
|
||||
setScan,
|
||||
clear,
|
||||
}: SkillFiltersProps) {
|
||||
const { t } = useTranslation();
|
||||
const tagItems = [
|
||||
{ value: "", label: t("skills.filters.allTags") },
|
||||
...[...new Set([...tags, ...(tag ? [tag] : [])])].map((value) => ({ value, label: value })),
|
||||
];
|
||||
const scanItems = [
|
||||
{ value: "all", label: t("skills.filters.allScans") },
|
||||
{ value: "yes", label: t("skills.filters.scanned") },
|
||||
{ value: "no", label: t("skills.filters.notScanned") },
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<InputGroup className="w-full sm:w-80">
|
||||
<InputGroupAddon>
|
||||
<Search className="size-4" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
aria-label={t("skills.filters.search")}
|
||||
placeholder={t("skills.filters.search")}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Select items={tagItems} value={tag} onValueChange={(value) => setTag(value ?? "")}>
|
||||
<SelectTrigger className="w-44" aria-label={t("skills.filters.tag")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tagItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
items={scanItems}
|
||||
value={scan}
|
||||
onValueChange={(value) => setScan(value === "yes" || value === "no" ? value : "all")}
|
||||
>
|
||||
<SelectTrigger className="w-48" aria-label={t("skills.table.columns.securityScan")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scanItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{active && (
|
||||
<Button variant="ghost" size="sm" onClick={clear}>
|
||||
{t("skills.filters.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { DataTableSortHeader } from "@/components/shared/DataTable/DataTableSortHeader";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { DateCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { SkillCatalogMetadata } from "./skillCatalog";
|
||||
|
||||
const IDENTITY_LABELS = {
|
||||
created_by: "skills.table.columns.createdBy",
|
||||
approved_by: "skills.table.columns.approvedBy",
|
||||
} as const;
|
||||
|
||||
export function getSkillMetadataColumns<T extends SkillCatalogMetadata>(): ColumnDef<T>[] {
|
||||
return [
|
||||
{
|
||||
id: "download_count",
|
||||
accessorKey: "download_count",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("skills.table.columns.downloads")} />,
|
||||
size: 120,
|
||||
sortUndefined: "last",
|
||||
accessorFn: (skill) => skill.download_count ?? undefined,
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.download_count?.toLocaleString(i18n.language) ?? "—"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "keywords",
|
||||
header: i18n.t("skills.table.columns.tags"),
|
||||
size: 180,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex max-w-52 flex-wrap gap-1">
|
||||
{row.original.keywords?.length
|
||||
? row.original.keywords.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))
|
||||
: "—"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...(["created_by", "approved_by"] as const).map(
|
||||
(field): ColumnDef<T> => ({
|
||||
id: field,
|
||||
accessorKey: field,
|
||||
size: 190,
|
||||
enableSorting: true,
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t(IDENTITY_LABELS[field])} />,
|
||||
cell: ({ row }) => (
|
||||
<span className="block max-w-48 truncate text-xs" title={row.original[field] ?? undefined}>
|
||||
{row.original[field] || i18n.t("skills.table.notRecorded")}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
),
|
||||
...(["created_at", "updated_at"] as const).map(
|
||||
(field): ColumnDef<T> => ({
|
||||
id: field,
|
||||
accessorKey: field,
|
||||
size: 140,
|
||||
enableSorting: true,
|
||||
accessorFn: (skill) => (skill[field] ? new Date(skill[field]!).getTime() : undefined),
|
||||
sortUndefined: "last",
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader
|
||||
column={column}
|
||||
title={i18n.t(field === "created_at" ? "skills.table.columns.createdAt" : "skills.table.columns.updatedAt")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <DateCell value={row.original[field]} precision="date" />,
|
||||
}),
|
||||
),
|
||||
{
|
||||
id: "security_scan_passed",
|
||||
accessorKey: "security_scan_passed",
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader
|
||||
column={column}
|
||||
title={<span title={i18n.t("skills.table.scanHelp")}>{i18n.t("skills.table.columns.securityScan")}</span>}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span title={i18n.t("skills.table.scanHelp")}>
|
||||
<StatusBadge
|
||||
tone="neutral"
|
||||
label={i18n.t(row.original.security_scan_passed ? "skills.table.publicYes" : "skills.table.publicNo")}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import type { SkillListQuery } from "./types";
|
||||
import type { SortingState } from "@tanstack/react-table";
|
||||
import { parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs";
|
||||
import { useUrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable/useUrlTableState";
|
||||
import { matchesSearchTerm } from "@/utils/searchUtils";
|
||||
|
||||
const SCAN_FILTERS = ["all", "yes", "no"] as const;
|
||||
export type ScanFilter = (typeof SCAN_FILTERS)[number];
|
||||
|
||||
export interface SkillCatalogMetadata {
|
||||
created_by?: string | null;
|
||||
approved_by?: string | null;
|
||||
published_by?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
security_scan_passed?: boolean;
|
||||
download_count?: number | null;
|
||||
keywords?: string[] | null;
|
||||
}
|
||||
|
||||
export const SKILL_SORT_FIELDS = [
|
||||
"name",
|
||||
"download_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"approved_by",
|
||||
"security_scan_passed",
|
||||
] as const satisfies readonly NonNullable<SkillListQuery["sort_by"]>[];
|
||||
|
||||
export function skillSortQuery(sorting: SortingState): Pick<SkillListQuery, "sort_by" | "sort_order"> {
|
||||
return {
|
||||
sort_by: SKILL_SORT_FIELDS.find((field) => field === sorting[0]?.id) ?? "name",
|
||||
sort_order: sorting[0]?.desc ? "desc" : "asc",
|
||||
};
|
||||
}
|
||||
|
||||
const CATALOG_STATE: UrlTableStateOptions<never> = {
|
||||
keyPrefix: "skills_",
|
||||
defaultPageSize: 50,
|
||||
maxPageSize: 100,
|
||||
sortFields: SKILL_SORT_FIELDS,
|
||||
defaultSort: { id: "name", desc: false },
|
||||
filterColumns: [],
|
||||
};
|
||||
const FILTER_PARSERS = {
|
||||
tag: parseAsString.withDefault(""),
|
||||
scan: parseAsStringLiteral(SCAN_FILTERS).withDefault("all"),
|
||||
};
|
||||
const FILTER_OPTIONS = { urlKeys: { tag: "skills_filter_tag", scan: "skills_filter_scan" } };
|
||||
|
||||
export function useSkillFilters(sortFields: readonly string[] = SKILL_SORT_FIELDS) {
|
||||
const table = useUrlTableState({ ...CATALOG_STATE, sortFields });
|
||||
const [filters, setFilters] = useQueryStates(FILTER_PARSERS, FILTER_OPTIONS);
|
||||
const update = (next: { tag?: string; scan?: ScanFilter } | null) => {
|
||||
void setFilters(next);
|
||||
table.onPaginationChange({ ...table.pagination, pageIndex: 0 });
|
||||
};
|
||||
return {
|
||||
...table,
|
||||
...filters,
|
||||
setTag: (tag: string) => update({ tag }),
|
||||
setScan: (scan: ScanFilter) => update({ scan }),
|
||||
clear: () => {
|
||||
update(null);
|
||||
table.setSearch("");
|
||||
},
|
||||
active: Boolean(table.search.trim() || filters.tag || filters.scan !== "all"),
|
||||
};
|
||||
}
|
||||
|
||||
export function skillTags(skills: SkillCatalogMetadata[]): string[] {
|
||||
return [...new Set(skills.flatMap((skill) => skill.keywords ?? []))].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function matchesSkillFilters(
|
||||
skill: SkillCatalogMetadata & {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
domain?: string | null;
|
||||
namespace?: string | null;
|
||||
},
|
||||
filters: { search: string; tag: string; scan: ScanFilter },
|
||||
): boolean {
|
||||
const matchesQuery = matchesSearchTerm(filters.search, [
|
||||
skill.name,
|
||||
skill.description,
|
||||
skill.domain,
|
||||
skill.namespace,
|
||||
skill.created_by,
|
||||
skill.approved_by,
|
||||
skill.published_by,
|
||||
...(skill.keywords ?? []),
|
||||
]);
|
||||
const matchesTag =
|
||||
!filters.tag || (skill.keywords ?? []).some((tag) => tag.toLowerCase() === filters.tag.toLowerCase());
|
||||
const matchesScan = filters.scan === "all" || Boolean(skill.security_scan_passed) === (filters.scan === "yes");
|
||||
return matchesQuery && matchesTag && matchesScan;
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
* API request/response shapes are synced from the generated OpenAPI types in @/lib/http/schema.
|
||||
*/
|
||||
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import type { components, operations } from "@/lib/http/schema";
|
||||
|
||||
export interface PluginSource {
|
||||
source: "github" | "url" | "git-subdir" | "archive";
|
||||
|
|
@ -25,6 +25,8 @@ export type SkillRegisterRequest = Omit<components["schemas"]["RegisterPluginReq
|
|||
version?: components["schemas"]["RegisterPluginRequest"]["version"];
|
||||
};
|
||||
|
||||
export type SkillListQuery = NonNullable<operations["list_skills_skill_hub_skills_get"]["parameters"]["query"]>;
|
||||
|
||||
export type SkillHubSkill = components["schemas"]["SkillHubSkill"];
|
||||
export type SkillMetadata = components["schemas"]["SkillHubMetadata"];
|
||||
export type SkillImport = Omit<components["schemas"]["ImportSkillRequest"], "ref"> &
|
||||
|
|
|
|||
|
|
@ -625,7 +625,7 @@ describe("Sidebar (leftnav)", () => {
|
|||
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole("link", { name: "Skills" })).toHaveAttribute("href", "/ui/skills");
|
||||
expect(screen.getByRole("link", { name: "Skills Hub" })).toHaveAttribute("href", "/ui/skills");
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ const MODELS = [
|
|||
|
||||
const SKILLS: Plugin[] = [
|
||||
{
|
||||
download_count: 5,
|
||||
id: "skill-research",
|
||||
name: "research-assistant",
|
||||
description: "Answers research questions",
|
||||
|
|
@ -92,11 +93,15 @@ const SKILLS: Plugin[] = [
|
|||
category: "Analysis",
|
||||
keywords: ["evidence"],
|
||||
author: { name: "Research maintainers" },
|
||||
created_by: "writer@example.test",
|
||||
approved_by: "reviewer@example.test",
|
||||
security_scan_passed: true,
|
||||
enabled: true,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
},
|
||||
{
|
||||
download_count: 9,
|
||||
id: "skill-review",
|
||||
name: "review-changes",
|
||||
description: "Checks pull requests",
|
||||
|
|
@ -111,6 +116,7 @@ const SKILLS: Plugin[] = [
|
|||
updated_at: null,
|
||||
},
|
||||
{
|
||||
download_count: 2,
|
||||
id: "skill-release",
|
||||
name: "release-notes",
|
||||
description: "Summarizes shipped changes",
|
||||
|
|
@ -522,18 +528,18 @@ describe("PublicAiHub", () => {
|
|||
expect(screen.getByRole("link", { name: /release-notes/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("2 skills")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), { target: { value: "changelog" } });
|
||||
fireEvent.change(screen.getByRole("textbox", { name: /Search skills/ }), { target: { value: "changelog" } });
|
||||
expect(screen.getByRole("link", { name: /release-notes/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /review-changes/ })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), { target: { value: "evidence" } });
|
||||
fireEvent.change(screen.getByRole("textbox", { name: /Search skills/ }), { target: { value: "evidence" } });
|
||||
expect(screen.getByText('No skills match "evidence"')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /^All domains/ }));
|
||||
expect(screen.getByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /release-notes/ })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Clear all" }));
|
||||
await user.click(screen.getByRole("button", { name: "Clear filters" }));
|
||||
expect(screen.getByText("3 skills")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Search skills" })).toHaveValue("");
|
||||
expect(screen.getByRole("textbox", { name: /Search skills/ })).toHaveValue("");
|
||||
expect(screen.getByRole("checkbox", { name: "Engineering" })).not.toBeChecked();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -541,7 +547,7 @@ describe("PublicAiHub", () => {
|
|||
it("finds skills by name, description, namespace, domain, and keywords", async () => {
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab: "skills" } });
|
||||
await screen.findByRole("link", { name: /research-assistant/ });
|
||||
const search = screen.getByRole("textbox", { name: "Search skills" });
|
||||
const search = screen.getByRole("textbox", { name: /Search skills/ });
|
||||
|
||||
for (const value of ["research-assistant", "Answers research questions", "knowledge", "Research", "evidence"]) {
|
||||
fireEvent.change(search, { target: { value } });
|
||||
|
|
@ -586,3 +592,34 @@ describe("PublicAiHub", () => {
|
|||
expect(screen.queryByRole("heading", { level: 2, name: "release-notes" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("filters skills by tag and scan status, searches people, and scopes filters to Skills", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = vi.fn();
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab: "skills" }, onUrlUpdate });
|
||||
await screen.findByRole("link", { name: /research-assistant/ });
|
||||
await user.click(screen.getByRole("button", { name: "Downloads" }));
|
||||
expect(within(screen.getAllByRole("row")[1]).getByRole("link", { name: /review-changes/ })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Downloads" }));
|
||||
expect(within(screen.getAllByRole("row")[1]).getByRole("link", { name: /release-notes/ })).toBeInTheDocument();
|
||||
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by tag" }), "evidence");
|
||||
expect(screen.queryByRole("link", { name: /review-changes/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("writer@example.test")).toBeInTheDocument();
|
||||
expect(screen.getByText("reviewer@example.test")).toBeInTheDocument();
|
||||
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Security scan" }), "Security scan: No");
|
||||
expect(screen.queryByRole("link", { name: /research-assistant/ })).not.toBeInTheDocument();
|
||||
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Security scan" }), "Security scan: Yes");
|
||||
fireEvent.change(screen.getByRole("textbox", { name: /Search skills/ }), {
|
||||
target: { value: "reviewer@example.test" },
|
||||
});
|
||||
expect(await screen.findByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(onUrlUpdate).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
queryString: expect.stringContaining("skills_filter_tag=evidence"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
await user.click(screen.getByRole("tab", { name: "Model Hub" }));
|
||||
expect(screen.queryByRole("combobox", { name: "Filter by tag" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,17 +5,18 @@ import { Puzzle } from "lucide-react";
|
|||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getSourceDisplayText } from "@/components/claude_code_plugins/helpers";
|
||||
import type { Plugin } from "@/components/claude_code_plugins/types";
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable/DataTableSortHeader";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { matchesSearchTerm } from "@/utils/searchUtils";
|
||||
import { SkillFilters } from "@/components/claude_code_plugins/SkillFilters";
|
||||
import { getSkillMetadataColumns } from "@/components/claude_code_plugins/SkillMetadataColumns";
|
||||
import { useSkillFilters, matchesSkillFilters, skillTags } from "@/components/claude_code_plugins/skillCatalog";
|
||||
|
||||
import { CatalogDetails } from "./CatalogDetails";
|
||||
import { CatalogFilters } from "./CatalogFilters";
|
||||
import { LoadFailed, NoResults, NotListed } from "./HubEmpty";
|
||||
import { SearchInput } from "./HubParts";
|
||||
import { SkillInspector } from "./SkillInspector";
|
||||
import { useOpenHref } from "./useHubUrlState";
|
||||
|
||||
|
|
@ -63,7 +64,8 @@ function SkillLink({
|
|||
|
||||
export function SkillsPane({ rows, loading, error, onRetry, selected, onSelect }: SkillsPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = React.useState("");
|
||||
const filters = useSkillFilters();
|
||||
const query = filters.search;
|
||||
const [domains, setDomains] = React.useState<string[]>([]);
|
||||
const skills = React.useMemo(() => [...(rows ?? [])].sort((a, b) => a.name.localeCompare(b.name)), [rows]);
|
||||
const options = React.useMemo(
|
||||
|
|
@ -77,65 +79,34 @@ export function SkillsPane({ rows, loading, error, onRetry, selected, onSelect }
|
|||
const shown = React.useMemo(
|
||||
() =>
|
||||
skills.filter((skill) => {
|
||||
const matchesQuery = matchesSearchTerm(query, [
|
||||
skill.name,
|
||||
skill.description,
|
||||
skill.domain,
|
||||
skill.namespace,
|
||||
...(skill.keywords ?? []),
|
||||
]);
|
||||
const matchesQuery = matchesSkillFilters(skill, {
|
||||
search: filters.search,
|
||||
tag: filters.tag,
|
||||
scan: filters.scan,
|
||||
});
|
||||
const matchesDomain = domains.length === 0 || domains.includes(skill.domain ?? "");
|
||||
return matchesQuery && matchesDomain;
|
||||
}),
|
||||
[skills, query, domains],
|
||||
[skills, filters.search, filters.tag, filters.scan, domains],
|
||||
);
|
||||
const opened = skills.find((skill) => skill.name === selected);
|
||||
const missingName = opened === undefined && !loading ? selected : null;
|
||||
const showRefreshError = error && !loading && shown.length > 0;
|
||||
const filtered = query.trim() !== "" || domains.length > 0;
|
||||
const filtered = filters.active || domains.length > 0;
|
||||
const clear = () => {
|
||||
setQuery("");
|
||||
filters.clear();
|
||||
setDomains([]);
|
||||
};
|
||||
const columns: ColumnDef<Plugin>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: t("publicModelHub.catalog.skill", { defaultValue: "Skill" }),
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader column={column} title={t("publicModelHub.catalog.skill", { defaultValue: "Skill" })} />
|
||||
),
|
||||
meta: { skeleton: "twoLine", className: "min-w-48 whitespace-normal" },
|
||||
cell: ({ row }) => <SkillLink skill={row.original} selected={selected} onSelect={onSelect} />,
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
header: t("publicModelHub.catalog.source", { defaultValue: "Source" }),
|
||||
meta: {
|
||||
className: "hidden max-w-64 text-xs whitespace-normal wrap-anywhere text-muted-foreground lg:table-cell",
|
||||
},
|
||||
cell: ({ row }) => getSourceDisplayText(row.original.source),
|
||||
},
|
||||
{
|
||||
accessorKey: "namespace",
|
||||
header: t("publicModelHub.catalog.namespace", { defaultValue: "Namespace" }),
|
||||
meta: {
|
||||
className:
|
||||
"hidden max-w-40 font-mono text-xs whitespace-normal wrap-anywhere text-muted-foreground xl:table-cell",
|
||||
},
|
||||
cell: ({ row }) => row.original.namespace || "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "category",
|
||||
header: t("publicModelHub.catalog.category", { defaultValue: "Category" }),
|
||||
meta: { className: "max-w-40 text-xs whitespace-normal wrap-anywhere text-muted-foreground" },
|
||||
cell: ({ row }) => row.original.category || "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "version",
|
||||
header: t("publicModelHub.catalog.version", { defaultValue: "Version" }),
|
||||
meta: {
|
||||
className:
|
||||
"hidden max-w-32 font-mono text-xs whitespace-normal wrap-anywhere text-muted-foreground sm:table-cell",
|
||||
},
|
||||
cell: ({ row }) => row.original.version || "-",
|
||||
},
|
||||
...getSkillMetadataColumns<Plugin>(),
|
||||
];
|
||||
const countOptions = {
|
||||
count: shown.length,
|
||||
|
|
@ -183,16 +154,7 @@ export function SkillsPane({ rows, loading, error, onRetry, selected, onSelect }
|
|||
</CatalogFilters>
|
||||
<div data-slot="catalog-results" className="flex min-w-0 flex-col gap-4">
|
||||
<div data-slot="catalog-toolbar" className="flex flex-wrap items-center gap-3">
|
||||
<SearchInput
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t("publicModelHub.catalog.searchSkills", { defaultValue: "Search skills" })}
|
||||
/>
|
||||
{filtered && (
|
||||
<Button variant="ghost" size="sm" onClick={clear}>
|
||||
{t("publicModelHub.catalog.clearAll", { defaultValue: "Clear all" })}
|
||||
</Button>
|
||||
)}
|
||||
<SkillFilters {...filters} tags={skillTags(skills)} active={filtered} clear={clear} />
|
||||
</div>
|
||||
<div data-slot="catalog-count" className="text-sm text-muted-foreground tabular-nums" aria-live="polite">
|
||||
{loading
|
||||
|
|
@ -203,6 +165,9 @@ export function SkillsPane({ rows, loading, error, onRetry, selected, onSelect }
|
|||
<div data-slot="catalog-table" aria-busy={loading}>
|
||||
<DataTable
|
||||
data={shown}
|
||||
sortingMode="client"
|
||||
sorting={filters.sorting}
|
||||
onSortingChange={filters.onSortingChange}
|
||||
columns={columns}
|
||||
getRowId={(skill) => skill.name}
|
||||
isLoading={loading}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { DateCell } from "@/components/shared/table_cells";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
|
@ -32,8 +33,6 @@ function sourceValue(source: Plugin["source"]) {
|
|||
export function SkillInspector({ skill }: { skill: Plugin }) {
|
||||
const { t } = useTranslation();
|
||||
const keywords = skill.keywords ?? [];
|
||||
const addedAt = skill.created_at ? new Date(skill.created_at) : null;
|
||||
const added = addedAt && !Number.isNaN(addedAt.getTime()) ? addedAt.toLocaleDateString() : null;
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<InspectorHeader
|
||||
|
|
@ -52,7 +51,22 @@ export function SkillInspector({ skill }: { skill: Plugin }) {
|
|||
[
|
||||
{ label: t("publicModelHub.skillInspector.facts.source"), wide: true, value: sourceValue(skill.source) },
|
||||
{ label: t("publicModelHub.skillInspector.facts.author"), value: skill.author?.name },
|
||||
{ label: t("publicModelHub.skillInspector.facts.added"), value: added },
|
||||
{ label: t("skills.table.columns.createdAt"), value: <DateCell value={skill.created_at} /> },
|
||||
{ label: t("skills.table.columns.updatedAt"), value: <DateCell value={skill.updated_at} /> },
|
||||
{ label: t("skills.table.columns.createdBy"), value: skill.created_by || t("skills.table.notRecorded") },
|
||||
{
|
||||
label: t("skills.table.columns.publishedBy"),
|
||||
value: skill.published_by || t("skills.table.notRecorded"),
|
||||
},
|
||||
{ label: t("skills.table.columns.approvedBy"), value: skill.approved_by || t("skills.table.notRecorded") },
|
||||
{
|
||||
label: t("skills.table.columns.securityScan"),
|
||||
value: (
|
||||
<span title={t("skills.table.scanHelp")}>
|
||||
{t(skill.security_scan_passed ? "skills.table.publicYes" : "skills.table.publicNo")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1693,9 +1693,9 @@
|
|||
"unavailable": "An install command is unavailable for this gateway URL. Authenticated installs require HTTPS or a localhost gateway"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Skills",
|
||||
"intro": "Upload or import portable skills. Review proposed changes before publishing them to the Skills Hub.",
|
||||
"addButton": "+ Add Skill",
|
||||
"title": "Skills Hub",
|
||||
"intro": "Register and share reusable skills for Codex, Claude Code, and other agents that support the open Agent Skills format.",
|
||||
"addButton": "+ Register your skill",
|
||||
"deleteDialog": {
|
||||
"title": "Delete Skill",
|
||||
"confirm": "Are you sure you want to delete skill: <name/>?",
|
||||
|
|
@ -1718,8 +1718,8 @@
|
|||
},
|
||||
"table": {
|
||||
"empty": {
|
||||
"title": "No skills found",
|
||||
"description": "Add one to get started."
|
||||
"title": "No skills registered yet",
|
||||
"description": "Register your first skill to get started."
|
||||
},
|
||||
"loading": "Loading skills…",
|
||||
"uncategorized": "Uncategorized",
|
||||
|
|
@ -1739,15 +1739,22 @@
|
|||
"description": "Description",
|
||||
"category": "Category",
|
||||
"public": "Public",
|
||||
"createdAt": "Created At",
|
||||
"createdAt": "Created",
|
||||
"actions": "Actions",
|
||||
"domain": "Domain",
|
||||
"updatedAt": "Updated at",
|
||||
"downloads": "Downloads"
|
||||
}
|
||||
"updatedAt": "Last updated",
|
||||
"downloads": "Downloads",
|
||||
"tags": "Tags",
|
||||
"createdBy": "Created by",
|
||||
"publishedBy": "Published by",
|
||||
"approvedBy": "Approved by",
|
||||
"securityScan": "Security scan"
|
||||
},
|
||||
"notRecorded": "Not recorded",
|
||||
"scanHelp": "Yes means this published version has a completed scan with no findings and a risk score of 0. No means it has not passed a recorded scan."
|
||||
},
|
||||
"addForm": {
|
||||
"title": "Add New Skill",
|
||||
"title": "Register your skill",
|
||||
"sourceOptions": {
|
||||
"url": "Source URL",
|
||||
"upload": "Upload ZIP"
|
||||
|
|
@ -1764,22 +1771,22 @@
|
|||
"namespace": "Namespace (Optional)",
|
||||
"description": "Description (Optional)",
|
||||
"category": "Category (Optional)",
|
||||
"keywords": "Keywords (Optional)",
|
||||
"keywords": "Tags (Optional)",
|
||||
"version": "Version (Optional)",
|
||||
"authorName": "Author Name (Optional)",
|
||||
"authorEmail": "Author Email (Optional)"
|
||||
},
|
||||
"hints": {
|
||||
"archive": "Choose a ZIP from your computer, up to 10 MiB. Include SKILL.md at the root or inside one skill folder, along with any supporting files. The skill will use the same visibility settings as skills added by URL.",
|
||||
"sourceUrl": "Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host (e.g. github.com/org/repo or github.com/org/repo/tree/main/my-skill), or an HTTPS link to a .zip archive of the skill hosted on S3 or any static file server. For a private repository use its SSH clone URL (git@ghe.example.com:org/repo.git) so Claude Code clones it with your own SSH key.",
|
||||
"subPath": "Path within the repository where the skill lives (e.g., plugins/my-skill). Leave empty if the skill is at the repo root.",
|
||||
"sha256": "Hex digest of the zip file. Claude Code refuses to install the archive if its checksum does not match.",
|
||||
"sourceUrl": "Link to a Git repository, a skill folder, or a ZIP archive containing SKILL.md and any supporting files. For private repositories, use an SSH clone URL with access configured in your agent.",
|
||||
"subPath": "Path within the repository where the skill lives (e.g., skills/my-skill). Leave empty if the skill is at the repo root.",
|
||||
"sha256": "Optional SHA-256 digest for verifying the ZIP archive’s integrity.",
|
||||
"name": "Unique identifier in kebab-case format (e.g., my-skill)",
|
||||
"domain": "Top-level grouping in the Skill Hub (e.g., Productivity)",
|
||||
"domain": "Top-level grouping in the Skills Hub (e.g., Productivity)",
|
||||
"namespace": "Sub-grouping within domain (e.g., workflows)",
|
||||
"description": "Brief description of what the skill does",
|
||||
"category": "Select a category or enter a custom one",
|
||||
"keywords": "Comma-separated list of keywords for search",
|
||||
"keywords": "Comma-separated list of tags for search",
|
||||
"version": "Semantic version (e.g., 1.0.0)",
|
||||
"authorName": "Name of the skill author or organization",
|
||||
"authorEmail": "Contact email for the skill author"
|
||||
|
|
@ -1800,13 +1807,13 @@
|
|||
"detected": "Detected: {{source}}",
|
||||
"noCategories": "No matching categories",
|
||||
"cancel": "Cancel",
|
||||
"adding": "Adding...",
|
||||
"submit": "Add Skill",
|
||||
"adding": "Registering...",
|
||||
"submit": "Register skill",
|
||||
"errors": {
|
||||
"zipRequired": "Please choose a .zip file",
|
||||
"zipTooLarge": "The ZIP file must be 10 MiB or smaller",
|
||||
"urlRequired": "Please enter a repository or zip archive URL",
|
||||
"subPathInvalid": "Subfolder must be a relative path like plugins/my-skill (letters, numbers, dots, hyphens, underscores)",
|
||||
"subPathInvalid": "Subfolder must be a relative path like skills/my-skill (letters, numbers, dots, hyphens, underscores)",
|
||||
"sha256Invalid": "SHA-256 must be a 64-character hex digest",
|
||||
"nameRequired": "Please enter skill name",
|
||||
"nameInvalid": "Name must be kebab-case (lowercase, numbers, hyphens only)",
|
||||
|
|
@ -1844,7 +1851,7 @@
|
|||
"path": "Path (optional)",
|
||||
"domain": "Domain (optional)",
|
||||
"namespace": "Namespace (optional)",
|
||||
"keywords": "Keywords (optional)"
|
||||
"keywords": "Tags (optional)"
|
||||
},
|
||||
"uploadHint": "Choose a ZIP up to 10 MiB containing SKILL.md. Its frontmatter supplies the skill name and description.",
|
||||
"replacementHint": "The replacement SKILL.md supplies the description. Domain, namespace, and keywords are preserved.",
|
||||
|
|
@ -1921,7 +1928,7 @@
|
|||
"source": "Source",
|
||||
"author": "Author",
|
||||
"homepage": "Homepage",
|
||||
"keywords": "Keywords",
|
||||
"keywords": "Tags",
|
||||
"category": "Category",
|
||||
"domain": "Domain",
|
||||
"namespace": "Namespace",
|
||||
|
|
@ -1934,6 +1941,15 @@
|
|||
"detail": {
|
||||
"subtitle": "Published skill metadata and portable archive",
|
||||
"private": "Private"
|
||||
},
|
||||
"filters": {
|
||||
"allTags": "All tags",
|
||||
"tag": "Filter by tag",
|
||||
"allScans": "All scan statuses",
|
||||
"scanned": "Security scan: Yes",
|
||||
"notScanned": "Security scan: No",
|
||||
"search": "Search skills, tags, or people…",
|
||||
"clear": "Clear filters"
|
||||
}
|
||||
},
|
||||
"claudeCodePlugins": {
|
||||
|
|
@ -1962,7 +1978,7 @@
|
|||
"newer": "Newer",
|
||||
"older": "Older"
|
||||
},
|
||||
"back": "Skills",
|
||||
"back": "Skills Hub",
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"usage": "How to Use",
|
||||
|
|
@ -2016,14 +2032,14 @@
|
|||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"usage": {
|
||||
"title": "Using this skill",
|
||||
"title": "Use with Claude Code",
|
||||
"intro": "Once your proxy is set as a marketplace, enable this skill in Claude Code with one command:",
|
||||
"staleCatalog": "If you see \"Plugin {{name}} not found in marketplace\", update the catalog first:",
|
||||
"notConfigured": "Don't have the marketplace configured yet?",
|
||||
"seeSetup": "See one-time setup →"
|
||||
},
|
||||
"setup": {
|
||||
"title": "One-time marketplace setup",
|
||||
"title": "Claude Code marketplace setup",
|
||||
"removeFirst": "If you added this marketplace before without a key, remove it first in Claude Code. Removing it also uninstalls the plugins you installed from it, so install them again from /plugin after you add the snippet:",
|
||||
"requiresKey": "This proxy requires a LiteLLM key to read the marketplace. Add this to <settings/> and replace <placeholder/> with your key:",
|
||||
"runCommand": "Run this command in Claude Code to register the marketplace:",
|
||||
|
|
@ -2031,7 +2047,7 @@
|
|||
}
|
||||
},
|
||||
"makePublic": {
|
||||
"title": "Publish to Skill Hub",
|
||||
"title": "Publish to Skills Hub",
|
||||
"steps": {
|
||||
"select": "Select Skills",
|
||||
"confirm": "Confirm"
|
||||
|
|
@ -2039,15 +2055,15 @@
|
|||
"step1": {
|
||||
"title": "Select Skills to Publish",
|
||||
"selectAll": "Select All ({{total}})",
|
||||
"description": "Selected skills will be visible to all users in the Skill Hub. Deselected skills will be unpublished.",
|
||||
"description": "Selected skills will be visible to all users in the Skills Hub. Deselected skills will be unpublished.",
|
||||
"empty": "No skills registered yet.",
|
||||
"public": "Public",
|
||||
"selectedCount_one": "<strong>{{count}}</strong> skill will be published",
|
||||
"selectedCount_other": "<strong>{{count}}</strong> skills will be published"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Confirm Publish to Skill Hub",
|
||||
"note": "<strong>Note:</strong> Published skills will be visible to all users in the Skill Hub tab. Skills not in the list below will be unpublished.",
|
||||
"title": "Confirm Publish to Skills Hub",
|
||||
"note": "<strong>Note:</strong> Published skills will be visible to all users in the Skills Hub tab. Skills not in the list below will be unpublished.",
|
||||
"listTitle": "Skills to be published:",
|
||||
"total_one": "Total: <strong>{{count}}</strong> skill will be published",
|
||||
"total_other": "Total: <strong>{{count}}</strong> skills will be published"
|
||||
|
|
@ -2060,8 +2076,8 @@
|
|||
},
|
||||
"toasts": {
|
||||
"selectAtLeastOne": "Please select at least one skill",
|
||||
"published_one": "Skill Hub updated — {{count}} skill(s) published",
|
||||
"published_other": "Skill Hub updated — {{count}} skill(s) published",
|
||||
"published_one": "Skills Hub updated — {{count}} skill(s) published",
|
||||
"published_other": "Skills Hub updated — {{count}} skill(s) published",
|
||||
"publishFailed": "Failed to update skills. Please try again."
|
||||
}
|
||||
}
|
||||
|
|
@ -7102,7 +7118,7 @@
|
|||
"models": "Model Hub",
|
||||
"agents": "Agent Hub",
|
||||
"mcp": "MCP Hub",
|
||||
"skills": "Skill Hub"
|
||||
"skills": "Skills Hub"
|
||||
},
|
||||
"models": {
|
||||
"title": "Available Models",
|
||||
|
|
@ -7265,7 +7281,7 @@
|
|||
"models": "Model Hub",
|
||||
"agents": "Agent Hub",
|
||||
"mcp": "MCP Hub",
|
||||
"skills": "Skill Hub"
|
||||
"skills": "Skills Hub"
|
||||
},
|
||||
"models": {
|
||||
"chatTitle": "Models",
|
||||
|
|
@ -7688,7 +7704,7 @@
|
|||
"agents": "Agents",
|
||||
"mcpShort": "MCP",
|
||||
"mcpServers": "MCP servers",
|
||||
"skills": "Skills"
|
||||
"skills": "Skills Hub"
|
||||
},
|
||||
"header": {
|
||||
"title": "AI Hub",
|
||||
|
|
@ -7954,7 +7970,7 @@
|
|||
"workflowRuns": "Workflow Runs",
|
||||
"memory": "Memory",
|
||||
"mcpServers": "MCP Servers",
|
||||
"skills": "Skills",
|
||||
"skills": "Skills Hub",
|
||||
"guardrails": "Guardrails",
|
||||
"policies": "Policies",
|
||||
"tools": "Tools",
|
||||
|
|
@ -8029,7 +8045,7 @@
|
|||
"uiTheme": "Customize dashboard appearance",
|
||||
"tagManagement": "Organize resources with tags",
|
||||
"prompts": "Manage and version prompt templates",
|
||||
"skills": "Browse and manage Claude Code skills",
|
||||
"skills": "Register and share reusable agent skills in the Skills Hub",
|
||||
"routerSettings": "Configure routing and load balancing settings",
|
||||
"telemetry": "Choose which anonymous usage telemetry this proxy collects and see the last report",
|
||||
"loggingAndAlerts": "Set up logging and alert configurations",
|
||||
|
|
|
|||
37
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
37
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -42114,15 +42114,21 @@ export interface components {
|
|||
* @description Plugin item in list responses.
|
||||
*/
|
||||
PluginListItem: {
|
||||
/** Approved By */
|
||||
approved_by?: string | null;
|
||||
author?: components["schemas"]["PluginAuthor"] | null;
|
||||
/** Category */
|
||||
category?: string | null;
|
||||
/** Created At */
|
||||
created_at: string | null;
|
||||
/** Created By */
|
||||
created_by?: string | null;
|
||||
/** Description */
|
||||
description: string | null;
|
||||
/** Domain */
|
||||
domain?: string | null;
|
||||
/** Download Count */
|
||||
download_count?: number | null;
|
||||
/** Enabled */
|
||||
enabled: boolean;
|
||||
/** Homepage */
|
||||
|
|
@ -42137,6 +42143,13 @@ export interface components {
|
|||
name: string;
|
||||
/** Namespace */
|
||||
namespace?: string | null;
|
||||
/** Published By */
|
||||
published_by?: string | null;
|
||||
/**
|
||||
* Security Scan Passed
|
||||
* @default false
|
||||
*/
|
||||
security_scan_passed: boolean;
|
||||
/** Source */
|
||||
source: {
|
||||
[key: string]: string;
|
||||
|
|
@ -46920,6 +46933,11 @@ export interface components {
|
|||
next_offset: number | null;
|
||||
/** Skills */
|
||||
skills: components["schemas"]["SkillHubSkill"][];
|
||||
/**
|
||||
* Tags
|
||||
* @default []
|
||||
*/
|
||||
tags: string[];
|
||||
};
|
||||
/** SkillHubMetadata */
|
||||
SkillHubMetadata: {
|
||||
|
|
@ -47005,15 +47023,21 @@ export interface components {
|
|||
};
|
||||
/** SkillHubSkill */
|
||||
SkillHubSkill: {
|
||||
/** Approved By */
|
||||
approved_by?: string | null;
|
||||
/** Content Url */
|
||||
content_url?: string | null;
|
||||
/** Created At */
|
||||
created_at?: string | null;
|
||||
/** Created By */
|
||||
created_by?: string | null;
|
||||
/** Description */
|
||||
description?: string | null;
|
||||
/** Domain */
|
||||
domain?: string | null;
|
||||
/**
|
||||
* Download Count
|
||||
* @description Gateway ZIP downloads, included in proxy-admin catalog listings
|
||||
* @description Gateway ZIP downloads, including repeat downloads
|
||||
*/
|
||||
download_count?: number | null;
|
||||
/** Download Url */
|
||||
|
|
@ -47026,6 +47050,13 @@ export interface components {
|
|||
name: string;
|
||||
/** Namespace */
|
||||
namespace?: string | null;
|
||||
/** Published By */
|
||||
published_by?: string | null;
|
||||
/**
|
||||
* Security Scan Passed
|
||||
* @default false
|
||||
*/
|
||||
security_scan_passed: boolean;
|
||||
/** Source */
|
||||
source: {
|
||||
[key: string]: string;
|
||||
|
|
@ -74391,6 +74422,10 @@ export interface operations {
|
|||
query?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
tag?: string;
|
||||
security_scan_passed?: boolean | null;
|
||||
sort_by?: "name" | "download_count" | "updated_at" | "created_at" | "created_by" | "published_by" | "approved_by" | "security_scan_passed";
|
||||
sort_order?: "asc" | "desc";
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue