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:
tin-berri 2026-11-05 16:57:33 -08:00 • committed by GitHub
parent 40a4ebaf6e
commit a7f9f892be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1229 additions and 272 deletions

View file

@ -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)

View file

@ -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": {

View file

@ -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,

View file

@ -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"),

View file

@ -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,

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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
)

View file

@ -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)

View file

@ -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, ...]

View file

@ -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

View file

@ -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,

View file

@ -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]

View file

@ -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),

View file

@ -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

View 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

View file

@ -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");
});

View file

@ -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);
});
});

View file

@ -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}

View file

@ -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();

View file

@ -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"
/>
);

View file

@ -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" },

View file

@ -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" }));

View file

@ -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"));
});
});

View file

@ -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} />}

View file

@ -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" },

View file

@ -59,7 +59,7 @@ describe("visibleMenuGroups", () => {
});
expect(flattenNavItems(visible).find(({ route }) => route === "skills")).toMatchObject({
label: "Skills",
label: "Skills Hub",
route: "skills",
});
},

View file

@ -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>
);
}

View file

@ -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>
),
},
];
}

View file

@ -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;
}

View file

@ -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"> &

View file

@ -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([

View file

@ -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();
});

View file

@ -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}

View file

@ -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>
),
},
],
[
{

View file

@ -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",

View file

@ -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;