feat(claude-code): import multiple skill marketplaces with per-skill access grants

Adds a registry for external Claude Code marketplaces (github/gitlab/bitbucket
repos or direct marketplace.json URLs), synced into the existing plugin table
and namespaced by source ("marketplace--skill") to avoid collisions. Access is
granted per skill, not per marketplace, via a new allowed_skills field on
LiteLLM_ObjectPermissionTable, intersected across key/team/org the same way
MCP server access already works. The existing GET /claude-code/marketplace.json
endpoint stays fully backward compatible with no key; passing ?key= unions in
whatever skills that caller was granted.
This commit is contained in:
Krrish Dholakia 2026-07-10 19:14:23 -07:00
parent dacf1cfb26
commit 106b974e05
40 changed files with 3265 additions and 26 deletions

View file

@ -25,3 +25,4 @@ class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase):
blocked_tools: Optional[List[str]] = []
search_tools: Optional[List[str]] = []
mcp_tool_search_enabled: Optional[bool] = None
allowed_skills: Optional[List[str]] = None

View file

@ -1008,6 +1008,7 @@ class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase):
models: Optional[List[str]] = None
search_tools: Optional[List[str]] = None
mcp_tool_search_enabled: Optional[bool] = None
allowed_skills: Optional[List[str]] = None
from litellm.types.object_permission import ( # noqa: E402

View file

@ -7,5 +7,10 @@ Provides endpoints for Claude Code plugin marketplace integration.
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
router as claude_code_marketplace_router,
)
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace_sources import (
router as claude_code_marketplace_sources_router,
)
claude_code_marketplace_router.include_router(claude_code_marketplace_sources_router)
__all__ = ["claude_code_marketplace_router"]

View file

@ -18,14 +18,25 @@ Endpoints:
import json
import re
from datetime import datetime, timezone
from typing import Any, Dict
from typing import Any, Dict, FrozenSet, Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request, Security
from fastapi.responses import JSONResponse
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy._types import CommonProxyErrors, ProxyException, UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_authz import (
get_allowed_skills,
)
from litellm.proxy.auth.user_api_key_auth import (
anthropic_api_key_header,
api_key_header,
azure_api_key_header,
azure_apim_header,
custom_litellm_key_header,
google_ai_studio_api_key_header,
user_api_key_auth,
)
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
from litellm.types.proxy.claude_code_endpoints import (
ListPluginsResponse,
@ -36,6 +47,34 @@ from litellm.types.proxy.claude_code_endpoints import (
router = APIRouter()
async def _optional_user_api_key_auth(
request: Request,
api_key: str = Security(api_key_header),
azure_api_key: str = Security(azure_api_key_header),
anthropic_api_key: Optional[str] = Security(anthropic_api_key_header),
google_ai_studio_api_key: Optional[str] = Security(google_ai_studio_api_key_header),
azure_apim_key: Optional[str] = Security(azure_apim_header),
custom_litellm_key: Optional[str] = Security(custom_litellm_key_header),
) -> Optional[UserAPIKeyAuth]:
"""
Resolve UserAPIKeyAuth if a key is present (header or, for this route,
the `key` query param wired up via RouteChecks.is_claude_code_marketplace_route),
but never raise - this route must stay accessible with no key at all.
"""
try:
return await user_api_key_auth(
request=request,
api_key=api_key,
azure_api_key_header=azure_api_key,
anthropic_api_key_header=anthropic_api_key,
google_ai_studio_api_key_header=google_ai_studio_api_key,
azure_apim_header=azure_apim_key,
custom_litellm_key_header=custom_litellm_key,
)
except (HTTPException, ProxyException):
return None
async def _get_prisma_client():
"""Get the prisma client from proxy_server."""
from litellm.proxy.proxy_server import prisma_client
@ -52,7 +91,9 @@ async def _get_prisma_client():
"/claude-code/marketplace.json",
tags=["Claude Code Marketplace"],
)
async def get_marketplace():
async def get_marketplace(
user_api_key_dict: Optional[UserAPIKeyAuth] = Depends(_optional_user_api_key_auth), # noqa: B008 # DI idiom
):
"""
Serve marketplace.json for Claude Code plugin discovery.
@ -60,6 +101,10 @@ async def get_marketplace():
- claude plugin marketplace add <url>
- claude plugin install <name>@<marketplace>
No key is required - unauthenticated requests see every enabled plugin.
A valid key (header, or `?key=` query param for the CLI) additionally
unlocks any plugin whose name is in that key/team/org's allowed_skills.
Returns:
Marketplace catalog with list of available plugins and their git sources.
@ -72,7 +117,18 @@ async def get_marketplace():
try:
prisma_client = await _get_prisma_client()
plugins = await ClaudeCodePluginRepository(prisma_client).table.find_many(where={"enabled": True})
allowed_skills: FrozenSet[str] = (
await get_allowed_skills(user_api_key_dict, prisma_client)
if user_api_key_dict is not None
else frozenset()
)
where = (
{"OR": [{"enabled": True}, {"name": {"in": list(allowed_skills)}}]}
if allowed_skills
else {"enabled": True}
)
plugins = await ClaudeCodePluginRepository(prisma_client).table.find_many(where=where)
plugin_list = []
for plugin in plugins:

View file

@ -0,0 +1,47 @@
"""Startup backfill for the default Claude Code skill marketplace.
Ensures a ``LiteLLM_SkillMarketplaceTable`` row named ``litellm`` (source_type
``managed``) exists, then attaches every pre-existing ``LiteLLM_ClaudeCodePluginTable``
row with a null ``marketplace_id`` to it. Those rows predate the multi-marketplace
feature and were hand-registered directly, so they belong to the default marketplace
by definition - this only stamps the FK, it never renames or re-enables anything.
Idempotent: a healed fleet has no null-``marketplace_id`` rows and the backfill exits
after two queries.
"""
from litellm._logging import verbose_proxy_logger
from litellm.proxy.utils import PrismaClient
DEFAULT_MARKETPLACE_NAME = "litellm"
async def backfill_default_skill_marketplace(prisma_client: PrismaClient) -> int:
"""Upsert the default marketplace row and backfill orphaned plugin rows onto it.
Returns the number of plugin rows backfilled.
"""
default_marketplace = await prisma_client.db.litellm_skillmarketplacetable.upsert(
where={"name": DEFAULT_MARKETPLACE_NAME},
data={
"create": {
"name": DEFAULT_MARKETPLACE_NAME,
"display_name": "LiteLLM (default)",
"source_type": "managed",
},
"update": {},
},
)
orphaned = await prisma_client.db.litellm_claudecodeplugintable.update_many(
where={"marketplace_id": None},
data={"marketplace_id": default_marketplace.id},
)
orphaned_count = orphaned if isinstance(orphaned, int) else getattr(orphaned, "count", 0)
if orphaned_count:
verbose_proxy_logger.info(
"skill marketplace backfill: attached %d pre-existing plugin row(s) to the default '%s' marketplace",
orphaned_count,
DEFAULT_MARKETPLACE_NAME,
)
return orphaned_count

View file

@ -0,0 +1,314 @@
"""
CLAUDE CODE MARKETPLACE SOURCES
Manages external Claude Code marketplace sources (git repos / marketplace.json URLs)
that LiteLLM syncs plugin listings from into LiteLLM_ClaudeCodePluginTable.
Endpoints:
/claude-code/marketplaces - POST - Register an external marketplace source
/claude-code/marketplaces - GET - List registered marketplace sources
/claude-code/marketplaces/{name} - GET - Get a marketplace source's details
/claude-code/marketplaces/{name}/sync - POST - Re-sync a marketplace source
/claude-code/marketplaces/{name} - DELETE - Disable a marketplace source and its plugins
"""
import re
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace_sync import (
MarketplaceRow,
resolve_and_sync,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.repositories.table_repositories import (
ClaudeCodePluginRepository,
SkillMarketplaceRepository,
)
from litellm.types.proxy.claude_code_endpoints import (
ListMarketplacesResponse,
MarketplaceSourceResponse,
RegisterMarketplaceRequest,
SyncMarketplaceResponse,
)
router = APIRouter()
_MARKETPLACE_NAME_RE = re.compile(r"^[a-z0-9-]+$")
async def _get_prisma_client():
"""Get the prisma client from proxy_server."""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
return prisma_client
def _require_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> None:
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail={"error": "Only proxy admins can manage Claude Code marketplace sources"},
)
def _derive_marketplace_slug(source: str) -> str:
trimmed = source.rstrip("/")
trimmed = trimmed[: -len(".git")] if trimmed.endswith(".git") else trimmed
last_segment = trimmed.rsplit("/", 1)[-1]
return re.sub(r"[^a-z0-9]+", "-", last_segment.lower()).strip("-")
async def _resolve_marketplace_name(prisma_client, request: RegisterMarketplaceRequest) -> str:
slug = request.name if request.name else _derive_marketplace_slug(request.source)
if not slug or not _MARKETPLACE_NAME_RE.match(slug):
raise HTTPException(
status_code=400,
detail={"error": "Marketplace name must be kebab-case (lowercase letters, numbers, hyphens)"},
)
existing = await SkillMarketplaceRepository(prisma_client).table.find_unique(where={"name": slug})
if existing:
raise HTTPException(
status_code=400,
detail={"error": f"Marketplace '{slug}' already exists"},
)
return slug
async def _get_marketplace_or_404(prisma_client, name: str):
marketplace = await SkillMarketplaceRepository(prisma_client).table.find_unique(where={"name": name})
if not marketplace:
raise HTTPException(
status_code=404,
detail={"error": f"Marketplace '{name}' not found"},
)
return marketplace
async def _count_plugins(prisma_client, marketplace_id: str) -> int:
return await ClaudeCodePluginRepository(prisma_client).table.count(where={"marketplace_id": marketplace_id})
def _to_marketplace_source_response(marketplace, plugin_count: Optional[int]) -> MarketplaceSourceResponse:
return MarketplaceSourceResponse(
id=marketplace.id,
name=marketplace.name,
display_name=marketplace.display_name,
source_type=marketplace.source_type,
source_ref=marketplace.source_ref,
branch=marketplace.branch,
enabled=marketplace.enabled,
sync_status=marketplace.sync_status,
sync_error=marketplace.sync_error,
last_synced_at=marketplace.last_synced_at.isoformat() if marketplace.last_synced_at else None,
plugin_count=plugin_count,
created_at=marketplace.created_at.isoformat() if marketplace.created_at else None,
updated_at=marketplace.updated_at.isoformat() if marketplace.updated_at else None,
)
async def _sync_and_build_response(prisma_client, marketplace) -> SyncMarketplaceResponse:
marketplace_row = MarketplaceRow(
id=marketplace.id,
name=marketplace.name,
source_ref=marketplace.source_ref,
branch=marketplace.branch,
)
result = await resolve_and_sync(prisma_client, marketplace_row)
refreshed = await SkillMarketplaceRepository(prisma_client).table.find_unique(where={"id": marketplace.id})
return SyncMarketplaceResponse(
status=result.status,
marketplace=_to_marketplace_source_response(refreshed, result.plugin_count),
)
@router.post(
"/claude-code/marketplaces",
tags=["Claude Code Marketplace"],
dependencies=[Depends(user_api_key_auth)],
response_model=SyncMarketplaceResponse,
)
async def register_marketplace(
request: RegisterMarketplaceRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI DI idiom
):
"""
Register and sync an external Claude Code marketplace source.
Parameters:
- source: 'org/repo' shorthand, a git URL, or a direct URL to a marketplace.json file
- name: Marketplace slug to register under (optional, derived from source if omitted)
Returns:
Sync status and the registered marketplace's details.
"""
try:
prisma_client = await _get_prisma_client()
_require_proxy_admin(user_api_key_dict)
name = await _resolve_marketplace_name(prisma_client, request)
marketplace = await SkillMarketplaceRepository(prisma_client).table.create(
data={
"name": name,
"display_name": name,
"source_type": "claude_marketplace_json",
"source_ref": request.source,
"sync_status": "pending",
"created_by": user_api_key_dict.user_id,
}
)
verbose_proxy_logger.info(f"Marketplace {name} registered, syncing now")
return await _sync_and_build_response(prisma_client, marketplace)
except HTTPException:
raise
except Exception as e: # noqa: BLE001 # top-level endpoint boundary
verbose_proxy_logger.exception(f"Error registering marketplace: {e}")
raise HTTPException(
status_code=500,
detail={"error": f"Registration failed: {str(e)}"},
)
@router.get(
"/claude-code/marketplaces",
tags=["Claude Code Marketplace"],
dependencies=[Depends(user_api_key_auth)],
response_model=ListMarketplacesResponse,
)
async def list_marketplaces(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI DI idiom
):
"""List all registered marketplace sources."""
try:
prisma_client = await _get_prisma_client()
_require_proxy_admin(user_api_key_dict)
marketplaces = await SkillMarketplaceRepository(prisma_client).table.find_many()
marketplace_list = [
_to_marketplace_source_response(marketplace, await _count_plugins(prisma_client, marketplace.id))
for marketplace in marketplaces
]
return ListMarketplacesResponse(marketplaces=marketplace_list, count=len(marketplace_list))
except HTTPException:
raise
except Exception as e: # noqa: BLE001 # top-level endpoint boundary
verbose_proxy_logger.exception(f"Error listing marketplaces: {e}")
raise HTTPException(
status_code=500,
detail={"error": str(e)},
)
@router.get(
"/claude-code/marketplaces/{marketplace_name}",
tags=["Claude Code Marketplace"],
dependencies=[Depends(user_api_key_auth)],
response_model=MarketplaceSourceResponse,
)
async def get_marketplace_source(
marketplace_name: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI DI idiom
):
"""Get details of a registered marketplace source."""
try:
prisma_client = await _get_prisma_client()
_require_proxy_admin(user_api_key_dict)
marketplace = await _get_marketplace_or_404(prisma_client, marketplace_name)
plugin_count = await _count_plugins(prisma_client, marketplace.id)
return _to_marketplace_source_response(marketplace, plugin_count)
except HTTPException:
raise
except Exception as e: # noqa: BLE001 # top-level endpoint boundary
verbose_proxy_logger.exception(f"Error getting marketplace: {e}")
raise HTTPException(
status_code=500,
detail={"error": str(e)},
)
@router.post(
"/claude-code/marketplaces/{marketplace_name}/sync",
tags=["Claude Code Marketplace"],
dependencies=[Depends(user_api_key_auth)],
response_model=SyncMarketplaceResponse,
)
async def sync_marketplace(
marketplace_name: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI DI idiom
):
"""Re-sync a registered marketplace source."""
try:
prisma_client = await _get_prisma_client()
_require_proxy_admin(user_api_key_dict)
marketplace = await _get_marketplace_or_404(prisma_client, marketplace_name)
verbose_proxy_logger.info(f"Re-syncing marketplace {marketplace_name}")
return await _sync_and_build_response(prisma_client, marketplace)
except HTTPException:
raise
except Exception as e: # noqa: BLE001 # top-level endpoint boundary
verbose_proxy_logger.exception(f"Error syncing marketplace: {e}")
raise HTTPException(
status_code=500,
detail={"error": str(e)},
)
@router.delete(
"/claude-code/marketplaces/{marketplace_name}",
tags=["Claude Code Marketplace"],
dependencies=[Depends(user_api_key_auth)],
)
async def delete_marketplace(
marketplace_name: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI DI idiom
):
"""Disable a marketplace source and all plugins it registered."""
try:
prisma_client = await _get_prisma_client()
_require_proxy_admin(user_api_key_dict)
marketplace = await _get_marketplace_or_404(prisma_client, marketplace_name)
await SkillMarketplaceRepository(prisma_client).table.update(
where={"name": marketplace_name},
data={"enabled": False},
)
await ClaudeCodePluginRepository(prisma_client).table.update_many(
where={"marketplace_id": marketplace.id},
data={"enabled": False},
)
verbose_proxy_logger.info(f"Marketplace {marketplace_name} disabled")
return {"status": "success", "message": f"Marketplace '{marketplace_name}' disabled"}
except HTTPException:
raise
except Exception as e: # noqa: BLE001 # top-level endpoint boundary
verbose_proxy_logger.exception(f"Error deleting marketplace: {e}")
raise HTTPException(
status_code=500,
detail={"error": str(e)},
)

View file

@ -0,0 +1,607 @@
"""
Sync logic for externally-hosted Claude Code marketplaces.
Given a ``LiteLLM_SkillMarketplaceTable`` row pointing at a git host (or a
direct URL), this module fetches the marketplace's plugin/skill catalog and
upserts the resolved entries into ``LiteLLM_ClaudeCodePluginTable`` so they
can be served through the existing marketplace/plugin endpoints.
Two upstream catalog shapes are supported:
- ``.claude-plugin/marketplace.json`` at the repo root (the Claude Code
plugin marketplace spec).
- a bare ``skills/`` directory of ``SKILL.md`` files (one or two levels
deep), for repos that don't publish a marketplace.json.
"""
import asyncio
import json
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import (
Any, # noqa: TID251 # prisma_client crosses the untyped prisma ORM boundary; see MarketplaceRow
Literal,
)
from urllib.parse import urlparse
import httpx
import yaml
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, get_async_httpx_client
from litellm.repositories.table_repositories import (
ClaudeCodePluginRepository,
SkillMarketplaceRepository,
)
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.proxy.claude_code_endpoints import (
GithubSource,
GitSubdirSource,
MarketplaceSourceType,
PluginSourceConfig,
UrlSource,
)
DEFAULT_SYNC_TIMEOUT_SECONDS = 10.0
SourceHost = Literal["github", "gitlab", "bitbucket", "url"]
SyncErrorReason = Literal["unreachable", "http_error", "invalid_json", "invalid_schema"]
@dataclass(frozen=True, slots=True)
class MarketplaceRow:
"""The subset of ``LiteLLM_SkillMarketplaceTable`` this module needs.
Callers construct this from the Prisma row before calling
``resolve_and_sync`` - keeps the untyped ORM boundary to a single
conversion point instead of threading ``Any`` through every helper.
"""
id: str
name: str
source_ref: str | None
branch: str | None
@dataclass(frozen=True, slots=True)
class ResolvedSource:
host: SourceHost
repo_or_url: str
branch: str = "main"
@dataclass(frozen=True, slots=True)
class MarketplaceSyncError(Exception):
"""Raised internally for any failure while syncing one marketplace.
Always caught by ``resolve_and_sync`` and turned into a ``SyncResult`` -
it never escapes this module.
"""
reason: SyncErrorReason
detail: str
def __str__(self) -> str:
return f"{self.reason}: {self.detail}"
@dataclass(frozen=True, slots=True)
class SyncResult:
status: Literal["success", "error"]
error: str | None
plugin_count: int
@dataclass(frozen=True, slots=True)
class ResolvedPluginEntry:
stored_name: str
display_name: str
description: str | None
source: PluginSourceConfig
# --- source_ref parsing -----------------------------------------------------
_BARE_SHORTHAND_RE = re.compile(r"^[\w.-]+/[\w.-]+$")
_HOST_MARKERS: tuple[tuple[str, Literal["github", "gitlab", "bitbucket"]], ...] = (
("github.com", "github"),
("gitlab.com", "gitlab"),
("bitbucket.org", "bitbucket"),
)
def _extract_repo_path(raw: str) -> str:
normalized = raw if "://" in raw else f"https://{raw}"
path = urlparse(normalized).path.strip("/")
path = path.removesuffix(".git")
segments = path.split("/")
return "/".join(segments[:2])
def _parse_source_ref(raw: str) -> ResolvedSource:
stripped = raw.strip()
if _BARE_SHORTHAND_RE.match(stripped):
return ResolvedSource(host="github", repo_or_url=stripped)
for marker, host in _HOST_MARKERS:
if marker in stripped:
return ResolvedSource(host=host, repo_or_url=_extract_repo_path(stripped))
return ResolvedSource(host="url", repo_or_url=stripped)
def _build_manifest_url(resolved: ResolvedSource, branch: str) -> str:
match resolved.host:
case "github":
return f"https://raw.githubusercontent.com/{resolved.repo_or_url}/{branch}/.claude-plugin/marketplace.json"
case "gitlab":
return f"https://gitlab.com/{resolved.repo_or_url}/-/raw/{branch}/.claude-plugin/marketplace.json"
case "bitbucket":
return f"https://bitbucket.org/{resolved.repo_or_url}/raw/{branch}/.claude-plugin/marketplace.json"
case "url":
return resolved.repo_or_url
def _git_clone_url(resolved: ResolvedSource) -> str:
match resolved.host:
case "github":
return f"https://github.com/{resolved.repo_or_url}.git"
case "gitlab":
return f"https://gitlab.com/{resolved.repo_or_url}.git"
case "bitbucket":
return f"https://bitbucket.org/{resolved.repo_or_url}.git"
case "url":
return resolved.repo_or_url
# --- HTTP -----------------------------------------------------------------
async def _http_get(client: AsyncHTTPHandler, url: str, *, timeout: float) -> httpx.Response:
try:
# async_safe_get is SSRF-guarded (resolves + validates every redirect
# hop) - required here because the target URL is admin-supplied.
return await async_safe_get(client, url, headers={}, timeout=timeout)
except SSRFError as exc:
raise MarketplaceSyncError(reason="unreachable", detail=str(exc)) from exc
except httpx.HTTPError as exc:
raise MarketplaceSyncError(reason="unreachable", detail=str(exc)) from exc
def _parse_json_body(response: httpx.Response) -> object:
try:
return response.json()
except json.JSONDecodeError as exc:
raise MarketplaceSyncError(reason="invalid_json", detail=str(exc)) from exc
# --- marketplace.json parsing ----------------------------------------------
class _ExternalMarketplaceOwner(BaseModel):
name: str
email: str | None = None
class _ExternalMarketplacePluginEntry(BaseModel):
name: str
description: str | None = None
source: str | dict[str, str]
skills: list[str] | None = None
class _ExternalMarketplaceManifest(BaseModel):
name: str
owner: _ExternalMarketplaceOwner | None = None
plugins: list[_ExternalMarketplacePluginEntry] = Field(default_factory=list)
def _parse_marketplace_manifest(response: httpx.Response) -> _ExternalMarketplaceManifest:
body = _parse_json_body(response)
try:
return _ExternalMarketplaceManifest.model_validate(body)
except ValidationError as exc:
raise MarketplaceSyncError(reason="invalid_schema", detail=str(exc)) from exc
def _normalize_relative_path(raw: str) -> str | None:
"""Normalize a marketplace.json ``source`` relative path, rejecting traversal.
Returns None (rather than raising) for anything unsafe so the caller can
skip just that one plugin entry instead of failing the whole sync.
"""
stripped = raw.strip()
if stripped in ("", ".", "./"):
return ""
trimmed = stripped.removeprefix("./")
trimmed = trimmed.strip("/")
if not trimmed:
return ""
segments = trimmed.split("/")
if any(segment in ("", "..") for segment in segments):
return None
return "/".join(segments)
def _resolve_relative_plugin_source(resolved: ResolvedSource, raw_path: str) -> PluginSourceConfig | None:
if resolved.host == "url":
# A raw marketplace.json URL isn't necessarily backed by a git repo we
# can derive a clone URL from, so a relative plugin source can't be
# resolved against it.
return None
normalized = _normalize_relative_path(raw_path)
if normalized is None:
return None
if normalized == "":
if resolved.host == "github":
return GithubSource(repo=resolved.repo_or_url)
return UrlSource(url=_git_clone_url(resolved))
return GitSubdirSource(url=_git_clone_url(resolved), path=normalized)
def _resolve_manifest_entry_source(
resolved: ResolvedSource, raw_source: str | dict[str, str]
) -> PluginSourceConfig | None:
if isinstance(raw_source, str):
return _resolve_relative_plugin_source(resolved, raw_source)
try:
return TypeAdapter(PluginSourceConfig).validate_python(raw_source)
except ValidationError:
return None
def _build_single_manifest_entry(
marketplace_name: str,
resolved: ResolvedSource,
raw_entry: _ExternalMarketplacePluginEntry,
) -> ResolvedPluginEntry | None:
plugin_source = _resolve_manifest_entry_source(resolved, raw_entry.source)
if plugin_source is None:
verbose_proxy_logger.warning(
"skill-marketplace-sync: skipping plugin %r in marketplace %r, unresolvable source %r",
raw_entry.name,
marketplace_name,
raw_entry.source,
)
return None
return ResolvedPluginEntry(
stored_name=f"{marketplace_name}--{raw_entry.name}",
display_name=raw_entry.name,
description=raw_entry.description,
source=plugin_source,
)
def _build_manifest_plugin_entries(
marketplace_name: str,
resolved: ResolvedSource,
manifest: _ExternalMarketplaceManifest,
) -> tuple[ResolvedPluginEntry, ...]:
resolved_entries = (
_build_single_manifest_entry(marketplace_name, resolved, raw_entry) for raw_entry in manifest.plugins
)
return tuple(entry for entry in resolved_entries if entry is not None)
# --- skills/ directory discovery (GitHub only) ------------------------------
class _GithubContentsEntry(BaseModel):
name: str
path: str
type: Literal["file", "dir", "symlink", "submodule"]
@dataclass(frozen=True, slots=True)
class _DiscoveredSkillDoc:
skill_md_path: str
plugin_source_path: str
class _SkillFrontmatter(BaseModel):
name: str | None = None
description: str | None = None
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL)
def _parse_skill_frontmatter(content: str) -> _SkillFrontmatter:
match = _FRONTMATTER_RE.match(content)
if not match:
return _SkillFrontmatter()
try:
raw = yaml.safe_load(match.group(1))
except yaml.YAMLError:
return _SkillFrontmatter()
if not isinstance(raw, dict):
return _SkillFrontmatter()
try:
return _SkillFrontmatter.model_validate(raw)
except ValidationError:
return _SkillFrontmatter()
async def _list_github_contents(
client: AsyncHTTPHandler, repo: str, path: str, branch: str, *, timeout: float
) -> tuple[_GithubContentsEntry, ...]:
# NOTE: unauthenticated GitHub Contents API calls are capped at 60 req/hr
# per source IP. No auth-token support yet - known v1 limitation.
url = f"https://api.github.com/repos/{repo}/contents/{path}?ref={branch}"
response = await _http_get(client, url, timeout=timeout)
if response.status_code == 404:
return ()
if response.status_code >= 400:
raise MarketplaceSyncError(
reason="http_error",
detail=f"GitHub contents API returned {response.status_code} for {url}",
)
body = _parse_json_body(response)
try:
return tuple(TypeAdapter(list[_GithubContentsEntry]).validate_python(body))
except ValidationError as exc:
raise MarketplaceSyncError(reason="invalid_schema", detail=str(exc)) from exc
async def _skill_md_exists(client: AsyncHTTPHandler, repo: str, branch: str, dir_path: str, *, timeout: float) -> bool:
url = f"https://raw.githubusercontent.com/{repo}/{branch}/{dir_path}/SKILL.md"
response = await _http_get(client, url, timeout=timeout)
return response.status_code == 200
async def _check_nested_skill_doc(
client: AsyncHTTPHandler,
repo: str,
branch: str,
category_path: str,
entry: _GithubContentsEntry,
*,
timeout: float,
) -> _DiscoveredSkillDoc | None:
nested_path = f"{category_path}/{entry.name}"
if await _skill_md_exists(client, repo, branch, nested_path, timeout=timeout):
return _DiscoveredSkillDoc(skill_md_path=f"{nested_path}/SKILL.md", plugin_source_path=nested_path)
return None
async def _discover_skill_docs_under(
client: AsyncHTTPHandler,
repo: str,
branch: str,
entry: _GithubContentsEntry,
*,
timeout: float,
) -> tuple[_DiscoveredSkillDoc, ...]:
flat_path = f"skills/{entry.name}"
if await _skill_md_exists(client, repo, branch, flat_path, timeout=timeout):
return (_DiscoveredSkillDoc(skill_md_path=f"{flat_path}/SKILL.md", plugin_source_path=flat_path),)
# Not a flat skill - try one level of catalog nesting (skills/<category>/<name>/SKILL.md).
nested_entries = await _list_github_contents(client, repo, flat_path, branch, timeout=timeout)
nested_docs = await asyncio.gather(
*(
_check_nested_skill_doc(client, repo, branch, flat_path, sub_entry, timeout=timeout)
for sub_entry in nested_entries
if sub_entry.type == "dir"
)
)
return tuple(doc for doc in nested_docs if doc is not None)
async def _discover_github_skill_docs(
client: AsyncHTTPHandler, repo: str, branch: str, *, timeout: float
) -> tuple[_DiscoveredSkillDoc, ...]:
top_level = await _list_github_contents(client, repo, "skills", branch, timeout=timeout)
grouped = await asyncio.gather(
*(
_discover_skill_docs_under(client, repo, branch, entry, timeout=timeout)
for entry in top_level
if entry.type == "dir"
)
)
return tuple(doc for docs in grouped for doc in docs)
async def _fetch_skill_entry(
client: AsyncHTTPHandler,
repo: str,
branch: str,
marketplace_name: str,
doc: _DiscoveredSkillDoc,
*,
timeout: float,
) -> ResolvedPluginEntry | None:
url = f"https://raw.githubusercontent.com/{repo}/{branch}/{doc.skill_md_path}"
response = await _http_get(client, url, timeout=timeout)
if response.status_code != 200:
return None
frontmatter = _parse_skill_frontmatter(response.text)
folder_name = doc.plugin_source_path.rsplit("/", 1)[-1]
skill_name = frontmatter.name or folder_name
return ResolvedPluginEntry(
stored_name=f"{marketplace_name}--{skill_name}",
display_name=skill_name,
description=frontmatter.description,
source=GitSubdirSource(url=f"https://github.com/{repo}.git", path=doc.plugin_source_path),
)
# --- top-level fetch orchestration -----------------------------------------
async def _fetch_marketplace_entries(
marketplace_row: MarketplaceRow,
) -> tuple[tuple[ResolvedPluginEntry, ...], MarketplaceSourceType]:
if not marketplace_row.source_ref:
raise MarketplaceSyncError(reason="invalid_schema", detail="marketplace has no source_ref to sync from")
resolved = _parse_source_ref(marketplace_row.source_ref)
branch = marketplace_row.branch or resolved.branch
client = get_async_httpx_client(
llm_provider=httpxSpecialProvider.PassThroughEndpoint,
params={"timeout": DEFAULT_SYNC_TIMEOUT_SECONDS},
)
manifest_url = _build_manifest_url(resolved, branch)
manifest_response = await _http_get(client, manifest_url, timeout=DEFAULT_SYNC_TIMEOUT_SECONDS)
if manifest_response.status_code == 200:
manifest = _parse_marketplace_manifest(manifest_response)
entries = _build_manifest_plugin_entries(marketplace_row.name, resolved, manifest)
return entries, "claude_marketplace_json"
if manifest_response.status_code == 404 and resolved.host == "github":
docs = await _discover_github_skill_docs(
client, resolved.repo_or_url, branch, timeout=DEFAULT_SYNC_TIMEOUT_SECONDS
)
fetched = await asyncio.gather(
*(
_fetch_skill_entry(
client,
resolved.repo_or_url,
branch,
marketplace_row.name,
doc,
timeout=DEFAULT_SYNC_TIMEOUT_SECONDS,
)
for doc in docs
)
)
entries = tuple(entry for entry in fetched if entry is not None)
return entries, "skills_dir"
if manifest_response.status_code == 404:
raise MarketplaceSyncError(
reason="http_error",
detail=(
f"no .claude-plugin/marketplace.json found for host={resolved.host!r} and "
"directory-scan fallback is only supported for GitHub"
),
)
raise MarketplaceSyncError(
reason="http_error",
detail=f"manifest fetch returned HTTP {manifest_response.status_code} for {manifest_url}",
)
# --- persistence -------------------------------------------------------------
def _build_plugin_manifest_json(entry: ResolvedPluginEntry) -> str:
return json.dumps(
{
"name": entry.display_name,
"source": entry.source.model_dump(),
"description": entry.description,
}
)
async def _upsert_single_plugin(
repository: ClaudeCodePluginRepository, marketplace_id: str, entry: ResolvedPluginEntry
) -> None:
now = datetime.now(timezone.utc)
manifest_json = _build_plugin_manifest_json(entry)
await repository.table.upsert(
where={"name": entry.stored_name},
data={
"create": {
"name": entry.stored_name,
"description": entry.description,
"manifest_json": manifest_json,
"files_json": "{}",
"enabled": False,
"marketplace_id": marketplace_id,
"created_at": now,
"updated_at": now,
},
"update": {
"description": entry.description,
"manifest_json": manifest_json,
"marketplace_id": marketplace_id,
"updated_at": now,
},
},
)
# prisma_client has no importable type stubs in this codebase (generated at
# runtime, never imported by name) - every repository in
# litellm/repositories/ types it as Any for the same reason.
async def _upsert_plugin_entries(
prisma_client: Any, # noqa: ANN401 # see comment above
marketplace_id: str,
entries: tuple[ResolvedPluginEntry, ...],
) -> None:
repository = ClaudeCodePluginRepository(prisma_client)
await asyncio.gather(*(_upsert_single_plugin(repository, marketplace_id, entry) for entry in entries))
async def _soft_disable_stale_plugins(
prisma_client: Any, # noqa: ANN401 # prisma_client has no importable type stubs, see _upsert_plugin_entries
marketplace_id: str,
entries: tuple[ResolvedPluginEntry, ...],
) -> None:
repository = ClaudeCodePluginRepository(prisma_client)
existing = await repository.table.find_many(where={"marketplace_id": marketplace_id})
fresh_names = frozenset(entry.stored_name for entry in entries)
stale = tuple(row for row in existing if row.name not in fresh_names and row.enabled)
await asyncio.gather(*(repository.table.update(where={"name": row.name}, data={"enabled": False}) for row in stale))
async def _record_sync_success(
prisma_client: Any, # noqa: ANN401 # prisma_client has no importable type stubs, see _upsert_plugin_entries
marketplace_row: MarketplaceRow,
source_type: MarketplaceSourceType,
) -> None:
await SkillMarketplaceRepository(prisma_client).table.update(
where={"id": marketplace_row.id},
data={
"sync_status": "success",
"sync_error": None,
"source_type": source_type,
"last_synced_at": datetime.now(timezone.utc),
},
)
async def _record_sync_failure(
prisma_client: Any, # noqa: ANN401 # prisma_client has no importable type stubs, see _upsert_plugin_entries
marketplace_row: MarketplaceRow,
detail: str,
) -> None:
await SkillMarketplaceRepository(prisma_client).table.update(
where={"id": marketplace_row.id},
data={
"sync_status": "error",
"sync_error": detail,
"last_synced_at": datetime.now(timezone.utc),
},
)
async def resolve_and_sync(
prisma_client: Any, # noqa: ANN401 # prisma_client has no importable type stubs, see _upsert_plugin_entries
marketplace_row: MarketplaceRow,
) -> SyncResult:
"""Fetch ``marketplace_row``'s upstream catalog and sync it into the plugin table.
Never raises - any failure is recorded on the marketplace row
(``sync_status="error"``) and reflected in the returned ``SyncResult``.
"""
try:
entries, source_type = await _fetch_marketplace_entries(marketplace_row)
except MarketplaceSyncError as exc:
verbose_proxy_logger.warning("skill-marketplace-sync: failed to sync %r: %s", marketplace_row.name, exc)
await _record_sync_failure(prisma_client, marketplace_row, str(exc))
return SyncResult(status="error", error=str(exc), plugin_count=0)
await _upsert_plugin_entries(prisma_client, marketplace_row.id, entries)
await _soft_disable_stale_plugins(prisma_client, marketplace_row.id, entries)
await _record_sync_success(prisma_client, marketplace_row, source_type)
return SyncResult(status="success", error=None, plugin_count=len(entries))

View file

@ -0,0 +1,137 @@
from typing import Optional
from litellm._logging import verbose_logger
from litellm.proxy._types import UI_TEAM_ID, UserAPIKeyAuth
from litellm.proxy.utils import PrismaClient
async def _get_allowed_skills_for_key(
user_api_key_dict: UserAPIKeyAuth,
prisma_client: Optional[PrismaClient],
) -> frozenset[str]:
"""Key's own allowed_skills ceiling from its object_permission.
object_permission is normally already loaded onto the auth dict by
get_key_object() in the main auth flow; fall back to a DB lookup by
object_permission_id for the rare case it wasn't.
"""
from litellm.proxy.auth.auth_checks import get_object_permission
from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache
key_object_permission = user_api_key_dict.object_permission
if key_object_permission is None and user_api_key_dict.object_permission_id and prisma_client is not None:
key_object_permission = await get_object_permission(
object_permission_id=user_api_key_dict.object_permission_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=user_api_key_dict.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
if key_object_permission is None:
return frozenset()
return frozenset(key_object_permission.allowed_skills or [])
async def _get_allowed_skills_for_team(
user_api_key_dict: UserAPIKeyAuth,
prisma_client: Optional[PrismaClient],
) -> frozenset[str]:
"""Team's allowed_skills ceiling from team.object_permission."""
from litellm.proxy.auth.auth_checks import get_team_object
from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache
if not user_api_key_dict.team_id or prisma_client is None:
return frozenset()
if user_api_key_dict.team_id == UI_TEAM_ID:
return frozenset()
team_obj = await get_team_object(
team_id=user_api_key_dict.team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=user_api_key_dict.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
if team_obj.object_permission is None:
return frozenset()
return frozenset(team_obj.object_permission.allowed_skills or [])
async def _get_allowed_skills_for_org(
user_api_key_dict: UserAPIKeyAuth,
prisma_client: Optional[PrismaClient],
) -> frozenset[str]:
"""Org's allowed_skills ceiling from org.object_permission."""
from litellm.proxy.auth.auth_checks import get_object_permission, get_org_object
from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache
if not user_api_key_dict.org_id or prisma_client is None:
return frozenset()
org_obj = await get_org_object(
org_id=user_api_key_dict.org_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=user_api_key_dict.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
if org_obj is None or not org_obj.object_permission_id:
return frozenset()
org_object_permission = await get_object_permission(
object_permission_id=org_obj.object_permission_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=user_api_key_dict.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
if org_object_permission is None:
return frozenset()
return frozenset(org_object_permission.allowed_skills or [])
async def get_allowed_skills(
user_api_key_dict: UserAPIKeyAuth,
prisma_client: Optional[PrismaClient],
) -> frozenset[str]:
"""
Resolve the set of Claude Code skill names (already-namespaced, e.g.
"anthropic-agent-skills--document-skills") this key is allowed to see,
beyond whatever the default/managed marketplace already exposes.
Permission hierarchy (all rules are intersections, mirrors
MCPRequestHandler.get_allowed_mcp_servers for key/team/org):
- An empty/missing allowed_skills list at a given level means that level
places no restriction and is skipped from the intersection.
- key/team: if both restrict, intersect; if only one restricts, use it.
- org: acts as a ceiling. If the org has an explicit list, the key/team
result is capped to it; if there's no lower-level restriction, the org
list becomes the result outright.
"""
try:
key_skills = await _get_allowed_skills_for_key(user_api_key_dict, prisma_client)
team_skills = await _get_allowed_skills_for_team(user_api_key_dict, prisma_client)
if not team_skills:
allowed_skills = key_skills
elif not key_skills:
allowed_skills = team_skills
else:
allowed_skills = key_skills & team_skills
has_lower_level_restrictions = bool(key_skills or team_skills)
if user_api_key_dict.org_id:
org_skills = await _get_allowed_skills_for_org(user_api_key_dict, prisma_client)
if org_skills:
allowed_skills = allowed_skills & org_skills if has_lower_level_restrictions else org_skills
except Exception as e: # noqa: BLE001 # never let an authz lookup crash the caller
verbose_logger.warning(f"Failed to get allowed skills: {e!s}")
return frozenset()
else:
return allowed_skills

View file

@ -714,6 +714,17 @@ class RouteChecks:
return True
return False
@staticmethod
def is_claude_code_marketplace_route(route: str) -> bool:
"""
Returns True if this is the Claude Code plugin marketplace discovery route.
The Claude Code CLI fetches this route without custom headers, so it
allows passing key=api_key in the query params (mirrors the Google
generateContent carve-out above).
"""
return route == "/claude-code/marketplace.json"
# HTTP methods that are intrinsically read-only and therefore safe to
# default-allow for PROXY_ADMIN_VIEW_ONLY. Anything else (POST/PUT/PATCH/
# DELETE) is treated as a write attempt and goes through the explicit

View file

@ -595,13 +595,16 @@ def get_api_key(
passed_in_key = azure_apim_header
api_key = azure_apim_header
elif (
RouteChecks.is_generate_content_route(route=route)
(
RouteChecks.is_generate_content_route(route=route)
or RouteChecks.is_claude_code_marketplace_route(route=route)
)
and request is not None
and _safe_get_request_query_params(request).get("key")
):
google_auth_key: str = _safe_get_request_query_params(request).get("key") or ""
passed_in_key = google_auth_key
api_key = google_auth_key
query_param_key: str = _safe_get_request_query_params(request).get("key") or ""
passed_in_key = query_param_key
api_key = query_param_key
elif pass_through_endpoints is not None:
for endpoint in pass_through_endpoints:
if endpoint.get("path", "") == route:

View file

@ -5960,6 +5960,8 @@ class ProxyConfig:
if self._should_load_db_object(object_type="policies"):
await self._init_policies_in_db(prisma_client=prisma_client)
await self._init_skill_marketplace_backfill_in_db(prisma_client=prisma_client)
if self._should_load_db_object(object_type="vector_stores"):
await self._init_vector_stores_in_db(prisma_client=prisma_client)
@ -6447,6 +6449,20 @@ class ProxyConfig:
"litellm.proxy.proxy_server.py::ProxyConfig:_init_tool_policy_in_db - {}".format(str(e))
)
async def _init_skill_marketplace_backfill_in_db(self, prisma_client: PrismaClient):
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace_backfill import (
backfill_default_skill_marketplace,
)
try:
await backfill_default_skill_marketplace(prisma_client=prisma_client)
except Exception as e: # noqa: BLE001 # startup backfill must never block proxy boot
verbose_proxy_logger.exception(
"litellm.proxy.proxy_server.py::ProxyConfig:_init_skill_marketplace_backfill_in_db - {}".format(
str(e)
)
)
async def _init_vector_stores_in_db(self, prisma_client: PrismaClient):
from litellm.vector_stores.vector_store_registry import VectorStoreRegistry

View file

@ -280,6 +280,7 @@ model LiteLLM_ObjectPermissionTable {
mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user
search_tools String[] @default([]) // search_tool_name values this key/team/user may call
mcp_tool_search_enabled Boolean?
allowed_skills String[] @default([]) // Namespaced Claude Code skill names ("marketplace--skill") granted to this key/team/org
teams LiteLLM_TeamTable[]
projects LiteLLM_ProjectTable[]
verification_tokens LiteLLM_VerificationToken[]
@ -1261,6 +1262,7 @@ model LiteLLM_ClaudeCodePluginTable {
manifest_json String?
files_json String? @default("{}")
enabled Boolean @default(true)
marketplace_id String?
created_at DateTime? @default(now())
updated_at DateTime? @default(now()) @updatedAt
created_by String?
@ -1268,6 +1270,28 @@ model LiteLLM_ClaudeCodePluginTable {
@@map("LiteLLM_ClaudeCodePluginTable")
}
// A registered external Claude Code marketplace source (git repo or URL) that
// litellm syncs skill/plugin listings from into LiteLLM_ClaudeCodePluginTable.
model LiteLLM_SkillMarketplaceTable {
id String @id @default(uuid())
name String @unique
display_name String?
source_type String // "claude_marketplace_json" | "skills_dir" | "managed"
source_ref String?
branch String? @default("main")
owner_name String?
owner_email String?
enabled Boolean @default(true)
sync_status String @default("pending") // "pending" | "success" | "error"
sync_error String?
last_synced_at DateTime?
created_at DateTime? @default(now())
updated_at DateTime? @default(now()) @updatedAt
created_by String?
@@map("LiteLLM_SkillMarketplaceTable")
}
// User/team-scoped memory store with a GLOBAL unique key.
// `value` is a string (typically markdown/text meant for LLM context);
// `metadata` is an optional JSON envelope for structured tags without schema changes.

View file

@ -61,6 +61,10 @@ class ClaudeCodePluginRepository(PrismaTableRepository):
table_name = "litellm_claudecodeplugintable"
class SkillMarketplaceRepository(PrismaTableRepository):
table_name = "litellm_skillmarketplacetable"
class TeamMembershipRepository(PrismaTableRepository):
table_name = "litellm_teammembership"

View file

@ -25,3 +25,4 @@ class ObjectPermissionDict(TypedDict, total=False):
models: Optional[list[str]]
search_tools: Optional[list[str]]
mcp_tool_search_enabled: Optional[bool]
allowed_skills: Optional[list[str]]

View file

@ -2,9 +2,10 @@
Claude Code Marketplace endpoint types for LiteLLM Proxy
"""
from typing import Dict, List, Optional
from typing import Dict, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from typing_extensions import Annotated
class PluginAuthor(BaseModel):
@ -122,3 +123,72 @@ class MarketplaceResponse(BaseModel):
name: str = Field(..., description="Marketplace identifier")
owner: PluginOwner = Field(..., description="Marketplace owner")
plugins: List[MarketplacePluginEntry] = Field(default_factory=list, description="Available plugins")
# --- Multi-marketplace import (LiteLLM_SkillMarketplaceTable) ---
MarketplaceSourceType = Literal["claude_marketplace_json", "skills_dir", "managed"]
class GithubSource(BaseModel):
source: Literal["github"] = "github"
repo: str = Field(..., description="'org/repo'")
class UrlSource(BaseModel):
source: Literal["url"] = "url"
url: str
class GitSubdirSource(BaseModel):
source: Literal["git-subdir"] = "git-subdir"
url: str
path: str
PluginSourceConfig = Annotated[
Union[GithubSource, UrlSource, GitSubdirSource],
Field(discriminator="source"),
]
class RegisterMarketplaceRequest(BaseModel):
"""Request body for importing an external Claude Code marketplace."""
source: str = Field(
...,
description=(
"'org/repo' shorthand, a github/gitlab/bitbucket URL, or a direct URL to a marketplace.json file"
),
)
name: Optional[str] = Field(
None, description="Marketplace slug to register under. Defaults to a slug derived from the source."
)
class MarketplaceSourceResponse(BaseModel):
"""A registered marketplace source and its current sync state."""
id: str
name: str
display_name: Optional[str] = None
source_type: MarketplaceSourceType
source_ref: Optional[str] = None
branch: Optional[str] = None
enabled: bool
sync_status: str
sync_error: Optional[str] = None
last_synced_at: Optional[str] = None
plugin_count: Optional[int] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
class ListMarketplacesResponse(BaseModel):
marketplaces: List[MarketplaceSourceResponse]
count: int
class SyncMarketplaceResponse(BaseModel):
status: str
marketplace: MarketplaceSourceResponse

View file

@ -6,7 +6,7 @@ lint.extend-select = ["T20", "PGH004", "RUF008", "RUF009", "RUF100"]
# litellm's own ruff config both rely on suppressions this config can't see.
lint.external = [
# Enforced by the strict-rule gate (scripts/ruff_strict_gate.py + ruff-strict.toml)
"C901",
"C901", "ANN401", "TID251", "B008",
# Enforced by upstream litellm's ruff config, but not run in this repo's CI
"PLC0415", "E402", "BLE001", "ARG002", "S102", "S324", "S606", "D401", "F403", "F405",
]

View file

@ -69,14 +69,22 @@ def mock_prisma_client():
plugin_name = where.get("name")
return plugins_store.get(plugin_name)
def _matches_clause(plugin, clause):
"""Support the small subset of Prisma where-clause shapes get_marketplace
actually issues: {"enabled": bool} and {"name": {"in": [...]}}."""
if "enabled" in clause:
return plugin.enabled == clause["enabled"]
if "name" in clause:
return plugin.name in clause["name"].get("in", [])
return False
async def find_many(where=None):
"""Mock find_many - returns list of plugins matching where clause."""
if where is None or where == {}:
return list(plugins_store.values())
enabled = where.get("enabled")
if enabled is not None:
return [p for p in plugins_store.values() if p.enabled == enabled]
return list(plugins_store.values())
if "OR" in where:
return [p for p in plugins_store.values() if any(_matches_clause(p, c) for c in where["OR"])]
return [p for p in plugins_store.values() if _matches_clause(p, where)]
async def create(data):
"""Mock create - creates a new plugin."""
@ -242,6 +250,121 @@ async def test_get_marketplace(mock_prisma_client):
)
@pytest.mark.asyncio
async def test_get_marketplace_no_key_unaffected_by_imported_disabled_skills(
mock_prisma_client,
):
"""Backward-compat regression test: an unauthenticated request to
marketplace.json must be byte-for-byte the same as before the
marketplace-import feature existed - it only ever sees enabled=True rows,
even once a marketplace sync has imported (and left disabled) new skills.
"""
setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma_client)
setattr(litellm.proxy.proxy_server, "master_key", "sk-1234")
await litellm.proxy.proxy_server.prisma_client.connect()
public_plugin_name = f"public-plugin-{int(time.time())}"
imported_disabled_name = f"imported-private-skill-{int(time.time())}"
user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
api_key="sk-1234",
user_id="test-user",
)
await register_plugin(
request=RegisterPluginRequest(
name=public_plugin_name,
source={"source": "github", "repo": "test-org/public-plugin"},
version="1.0.0",
description="Always-public plugin",
),
user_api_key_dict=user_api_key_dict,
)
# Simulate a marketplace sync having imported a skill, left disabled
# pending admin opt-in (mirrors resolve_and_sync's upsert semantics).
await mock_prisma_client.db.litellm_claudecodeplugintable.create(
data={
"name": imported_disabled_name,
"version": "1.0.0",
"description": "Imported but not yet enabled",
"manifest_json": json.dumps(
{"source": {"source": "github", "repo": "org/private-skill"}}
),
"enabled": False,
}
)
response_before_key_check = await get_marketplace(user_api_key_dict=None)
body_before = json.loads(response_before_key_check.body.decode())
response_no_key = await get_marketplace(user_api_key_dict=None)
body_no_key = json.loads(response_no_key.body.decode())
# No-key responses must be deterministic/identical across calls, and must
# never include the imported-but-disabled skill.
assert body_no_key == body_before
names = {p["name"] for p in body_no_key["plugins"]}
assert public_plugin_name in names
assert imported_disabled_name not in names
# Cleanup
await mock_prisma_client.db.litellm_claudecodeplugintable.delete(
where={"name": public_plugin_name}
)
await mock_prisma_client.db.litellm_claudecodeplugintable.delete(
where={"name": imported_disabled_name}
)
@pytest.mark.asyncio
async def test_get_marketplace_with_key_unlocks_allowed_imported_skill(
mock_prisma_client,
):
"""A key whose object_permission.allowed_skills grants an imported
(still disabled) skill sees that skill in marketplace.json alongside the
always-public entries - without a key, that same skill stays hidden."""
from litellm.models.object_permission import LiteLLM_ObjectPermissionTable
setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma_client)
setattr(litellm.proxy.proxy_server, "master_key", "sk-1234")
await litellm.proxy.proxy_server.prisma_client.connect()
imported_disabled_name = f"imported-scoped-skill-{int(time.time())}"
await mock_prisma_client.db.litellm_claudecodeplugintable.create(
data={
"name": imported_disabled_name,
"version": "1.0.0",
"description": "Imported, granted to one key only",
"manifest_json": json.dumps(
{"source": {"source": "github", "repo": "org/scoped-skill"}}
),
"enabled": False,
}
)
response_no_key = await get_marketplace(user_api_key_dict=None)
body_no_key = json.loads(response_no_key.body.decode())
assert imported_disabled_name not in {p["name"] for p in body_no_key["plugins"]}
scoped_key = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
api_key="sk-scoped",
user_id="scoped-user",
object_permission=LiteLLM_ObjectPermissionTable(
object_permission_id="perm-1", allowed_skills=[imported_disabled_name]
),
)
response_with_key = await get_marketplace(user_api_key_dict=scoped_key)
body_with_key = json.loads(response_with_key.body.decode())
names_with_key = {p["name"] for p in body_with_key["plugins"]}
assert imported_disabled_name in names_with_key
# Cleanup
await mock_prisma_client.db.litellm_claudecodeplugintable.delete(
where={"name": imported_disabled_name}
)
@pytest.mark.asyncio
async def test_register_plugin_git_subdir(mock_prisma_client):
"""Test registering a plugin with git-subdir source type."""

View file

@ -0,0 +1,299 @@
"""
Unit tests for claude_code_marketplace_sources.py.
Covers the register/list/get/sync/delete marketplace-source routes and their
proxy-admin gating. resolve_and_sync itself is exercised separately in
test_claude_code_marketplace_sync.py, so here it's replaced with a stub that
reports success - these tests are only about the endpoint/DB-orchestration
layer built on top of it.
"""
import uuid
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
import litellm
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints import (
claude_code_marketplace_sources as sources_module,
)
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace_sources import (
delete_marketplace,
get_marketplace_source,
list_marketplaces,
register_marketplace,
sync_marketplace,
)
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace_sync import (
SyncResult,
)
from litellm.types.proxy.claude_code_endpoints import RegisterMarketplaceRequest
class _FakeTable:
def __init__(self):
self._rows: dict = {}
@staticmethod
def _matches(record, where):
return all(getattr(record, k, None) == v for k, v in where.items())
async def find_unique(self, where):
for record in self._rows.values():
if self._matches(record, where):
return record
return None
async def find_many(self, where=None):
if not where:
return list(self._rows.values())
return [r for r in self._rows.values() if self._matches(r, where)]
async def count(self, where=None):
return len(await self.find_many(where))
async def create(self, data):
# Mirrors the LiteLLM_SkillMarketplaceTable / LiteLLM_ClaudeCodePluginTable
# prisma schema defaults, since this fake has no DB layer to apply them.
record_data = {
"display_name": None,
"branch": "main",
"enabled": True,
"sync_error": None,
"last_synced_at": None,
"created_at": None,
"updated_at": None,
"description": None,
"version": None,
"marketplace_id": None,
**data,
}
record_data.setdefault("id", str(uuid.uuid4()))
record = SimpleNamespace(**record_data)
self._rows[record.id] = record
return record
async def update(self, where, data):
record = await self.find_unique(where)
if record is None:
raise ValueError(f"no record matching {where}")
for k, v in data.items():
setattr(record, k, v)
return record
async def update_many(self, where, data):
matched = await self.find_many(where)
for record in matched:
for k, v in data.items():
setattr(record, k, v)
return len(matched)
def _make_fake_prisma_client():
client = SimpleNamespace()
client.db = SimpleNamespace(
litellm_skillmarketplacetable=_FakeTable(),
litellm_claudecodeplugintable=_FakeTable(),
)
return client
_ADMIN = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
api_key="sk-admin",
user_id="admin-user",
)
_NON_ADMIN = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
api_key="sk-regular",
user_id="regular-user",
)
@pytest.fixture(autouse=True)
def prisma_client(monkeypatch):
client = _make_fake_prisma_client()
monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", client)
monkeypatch.setattr(litellm.proxy.proxy_server, "master_key", "sk-admin")
monkeypatch.setattr(
sources_module,
"resolve_and_sync",
AsyncMock(return_value=SyncResult(status="success", error=None, plugin_count=3)),
)
return client
@pytest.mark.asyncio
async def test_register_marketplace_success(prisma_client):
request = RegisterMarketplaceRequest(source="anthropics/skills", name="anthropic-skills")
response = await register_marketplace(request=request, user_api_key_dict=_ADMIN)
assert response.status == "success"
assert response.marketplace.name == "anthropic-skills"
assert response.marketplace.source_ref == "anthropics/skills"
assert response.marketplace.plugin_count == 3
stored = await prisma_client.db.litellm_skillmarketplacetable.find_unique(where={"name": "anthropic-skills"})
assert stored is not None
@pytest.mark.asyncio
async def test_register_marketplace_derives_name_from_source(prisma_client):
request = RegisterMarketplaceRequest(source="anthropics/skills")
response = await register_marketplace(request=request, user_api_key_dict=_ADMIN)
assert response.marketplace.name == "skills"
@pytest.mark.asyncio
async def test_register_marketplace_duplicate_name_rejected(prisma_client):
request = RegisterMarketplaceRequest(source="anthropics/skills", name="dup-skills")
await register_marketplace(request=request, user_api_key_dict=_ADMIN)
with pytest.raises(HTTPException) as exc_info:
await register_marketplace(request=request, user_api_key_dict=_ADMIN)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_list_marketplaces(prisma_client):
await register_marketplace(
request=RegisterMarketplaceRequest(source="anthropics/skills", name="anthropic-skills"),
user_api_key_dict=_ADMIN,
)
await register_marketplace(
request=RegisterMarketplaceRequest(source="vercel-labs/skills", name="vercel-skills"),
user_api_key_dict=_ADMIN,
)
response = await list_marketplaces(user_api_key_dict=_ADMIN)
assert response.count == 2
assert {m.name for m in response.marketplaces} == {"anthropic-skills", "vercel-skills"}
@pytest.mark.asyncio
async def test_get_marketplace_source(prisma_client):
await register_marketplace(
request=RegisterMarketplaceRequest(source="anthropics/skills", name="anthropic-skills"),
user_api_key_dict=_ADMIN,
)
marketplace = await prisma_client.db.litellm_skillmarketplacetable.find_unique(where={"name": "anthropic-skills"})
for plugin_name in ["anthropic-skills--doc", "anthropic-skills--example", "anthropic-skills--api"]:
await prisma_client.db.litellm_claudecodeplugintable.create(
data={"name": plugin_name, "marketplace_id": marketplace.id}
)
response = await get_marketplace_source(marketplace_name="anthropic-skills", user_api_key_dict=_ADMIN)
assert response.name == "anthropic-skills"
assert response.source_ref == "anthropics/skills"
assert response.plugin_count == 3
@pytest.mark.asyncio
async def test_get_marketplace_source_not_found(prisma_client):
with pytest.raises(HTTPException) as exc_info:
await get_marketplace_source(marketplace_name="does-not-exist", user_api_key_dict=_ADMIN)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_sync_marketplace_reinvokes_resolve_and_sync(prisma_client):
await register_marketplace(
request=RegisterMarketplaceRequest(source="anthropics/skills", name="anthropic-skills"),
user_api_key_dict=_ADMIN,
)
assert sources_module.resolve_and_sync.call_count == 1
response = await sync_marketplace(marketplace_name="anthropic-skills", user_api_key_dict=_ADMIN)
assert response.status == "success"
assert sources_module.resolve_and_sync.call_count == 2
@pytest.mark.asyncio
async def test_sync_marketplace_not_found(prisma_client):
with pytest.raises(HTTPException) as exc_info:
await sync_marketplace(marketplace_name="does-not-exist", user_api_key_dict=_ADMIN)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_delete_marketplace_disables_marketplace_and_its_plugins(prisma_client):
await register_marketplace(
request=RegisterMarketplaceRequest(source="anthropics/skills", name="anthropic-skills"),
user_api_key_dict=_ADMIN,
)
marketplace = await prisma_client.db.litellm_skillmarketplacetable.find_unique(where={"name": "anthropic-skills"})
await prisma_client.db.litellm_claudecodeplugintable.create(
data={"name": "anthropic-skills--doc", "enabled": True, "marketplace_id": marketplace.id}
)
response = await delete_marketplace(marketplace_name="anthropic-skills", user_api_key_dict=_ADMIN)
assert response["status"] == "success"
refreshed_marketplace = await prisma_client.db.litellm_skillmarketplacetable.find_unique(
where={"name": "anthropic-skills"}
)
assert refreshed_marketplace.enabled is False
refreshed_plugin = await prisma_client.db.litellm_claudecodeplugintable.find_unique(
where={"name": "anthropic-skills--doc"}
)
assert refreshed_plugin.enabled is False
@pytest.mark.asyncio
async def test_delete_marketplace_not_found(prisma_client):
with pytest.raises(HTTPException) as exc_info:
await delete_marketplace(marketplace_name="does-not-exist", user_api_key_dict=_ADMIN)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_non_admin_forbidden_on_all_marketplace_routes(prisma_client):
"""Regression test for the admin-gating decision: a real non-admin
UserAPIKeyAuth (not a mock) must be rejected by every marketplace-source
route, both before and after a marketplace exists."""
request = RegisterMarketplaceRequest(source="anthropics/skills", name="anthropic-skills")
with pytest.raises(HTTPException) as exc_info:
await register_marketplace(request=request, user_api_key_dict=_NON_ADMIN)
assert exc_info.value.status_code == 403
# Seed a marketplace (as an admin) so the remaining routes have something
# to act on - a 404 short-circuit before the role check would falsely
# pass this test.
await register_marketplace(request=request, user_api_key_dict=_ADMIN)
with pytest.raises(HTTPException) as exc_info:
await list_marketplaces(user_api_key_dict=_NON_ADMIN)
assert exc_info.value.status_code == 403
with pytest.raises(HTTPException) as exc_info:
await get_marketplace_source(marketplace_name="anthropic-skills", user_api_key_dict=_NON_ADMIN)
assert exc_info.value.status_code == 403
with pytest.raises(HTTPException) as exc_info:
await sync_marketplace(marketplace_name="anthropic-skills", user_api_key_dict=_NON_ADMIN)
assert exc_info.value.status_code == 403
with pytest.raises(HTTPException) as exc_info:
await delete_marketplace(marketplace_name="anthropic-skills", user_api_key_dict=_NON_ADMIN)
assert exc_info.value.status_code == 403
# None of the non-admin calls should have mutated anything.
stored = await prisma_client.db.litellm_skillmarketplacetable.find_unique(where={"name": "anthropic-skills"})
assert stored.enabled is True

View file

@ -0,0 +1,424 @@
"""
Unit tests for claude_code_marketplace_sync.py.
Covers source-ref host detection, syncing an external marketplace.json into
LiteLLM_ClaudeCodePluginTable (including relative-source rewriting and the
GitHub-only skills/ directory-scan fallback), error classification, and the
idempotent/soft-disable upsert semantics.
"""
import json
import uuid
from datetime import datetime, timedelta
from types import SimpleNamespace
import httpx
import pytest
from litellm.proxy.anthropic_endpoints.claude_code_endpoints import (
claude_code_marketplace_sync as sync_module,
)
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace_sync import (
MarketplaceSyncError,
_parse_source_ref,
resolve_and_sync,
)
class _FakeTable:
def __init__(self):
self._rows: dict = {}
@staticmethod
def _matches(record, where):
return all(getattr(record, k, None) == v for k, v in where.items())
async def find_unique(self, where):
for record in self._rows.values():
if self._matches(record, where):
return record
return None
async def find_many(self, where=None):
if not where:
return list(self._rows.values())
return [r for r in self._rows.values() if self._matches(r, where)]
async def count(self, where=None):
return len(await self.find_many(where))
async def create(self, data):
record_data = dict(data)
record_data.setdefault("id", str(uuid.uuid4()))
record = SimpleNamespace(**record_data)
self._rows[record.id] = record
return record
async def update(self, where, data):
record = await self.find_unique(where)
if record is None:
raise ValueError(f"no record matching {where}")
for k, v in data.items():
setattr(record, k, v)
return record
async def update_many(self, where, data):
matched = await self.find_many(where)
for record in matched:
for k, v in data.items():
setattr(record, k, v)
return len(matched)
async def upsert(self, where, data):
existing = await self.find_unique(where)
if existing is not None:
for k, v in data["update"].items():
setattr(existing, k, v)
return existing
return await self.create(data["create"])
async def delete(self, where):
record = await self.find_unique(where)
if record is not None:
del self._rows[record.id]
return record
def _make_fake_prisma_client():
client = SimpleNamespace()
client.db = SimpleNamespace(
litellm_skillmarketplacetable=_FakeTable(),
litellm_claudecodeplugintable=_FakeTable(),
)
return client
class _FakeClock:
def __init__(self):
self._counter = 0
def now(self, tz=None):
self._counter += 1
return datetime(2024, 1, 1, tzinfo=tz) + timedelta(seconds=self._counter)
_ANTHROPIC_SKILLS_MANIFEST = {
"name": "anthropic-agent-skills",
"owner": {"name": "Keith Lazuka", "email": "klazuka@anthropic.com"},
"metadata": {"description": "Anthropic example skills", "version": "1.0.0"},
"plugins": [
{
"name": "document-skills",
"description": (
"Collection of document processing suite including Excel, Word, PowerPoint, and PDF capabilities"
),
"source": "./",
"strict": False,
"skills": ["./skills/xlsx", "./skills/docx", "./skills/pptx", "./skills/pdf"],
},
{
"name": "example-skills",
"description": "Collection of example skills demonstrating various capabilities",
"source": "./",
"strict": False,
"skills": ["./skills/algorithmic-art"],
},
{
"name": "claude-api",
"description": "Claude API and SDK documentation skill",
"source": "./",
"strict": False,
"skills": ["./skills/claude-api"],
},
],
}
_FIND_SKILLS_SKILL_MD = """---
name: find-skills
description: Helps users discover and install agent skills across GitHub repositories.
---
# Find Skills
Body content describing how to discover and install skills.
"""
_ANTHROPIC_MANIFEST_URL = "https://raw.githubusercontent.com/anthropics/skills/main/.claude-plugin/marketplace.json"
async def _create_marketplace(client, *, name, source_ref, branch=None):
return await client.db.litellm_skillmarketplacetable.create(
data={
"name": name,
"source_type": "claude_marketplace_json",
"source_ref": source_ref,
"branch": branch,
"sync_status": "pending",
}
)
@pytest.mark.parametrize(
"raw,expected_host,expected_repo",
[
("anthropics/skills", "github", "anthropics/skills"),
("https://github.com/anthropics/skills", "github", "anthropics/skills"),
("https://gitlab.com/foo/bar", "gitlab", "foo/bar"),
("https://bitbucket.org/foo/bar", "bitbucket", "foo/bar"),
("https://example.com/marketplace.json", "url", "https://example.com/marketplace.json"),
],
)
def test_parse_source_ref(raw, expected_host, expected_repo):
resolved = _parse_source_ref(raw)
assert resolved.host == expected_host
assert resolved.repo_or_url == expected_repo
def test_marketplace_sync_error_str_includes_reason_and_detail():
err = MarketplaceSyncError(reason="unreachable", detail="boom")
assert str(err) == "unreachable: boom"
assert err.reason == "unreachable"
assert err.detail == "boom"
@pytest.mark.asyncio
async def test_resolve_and_sync_rewrites_relative_sources(monkeypatch):
"""Regression test: a '.claude-plugin/marketplace.json' plugin entry whose
``source`` is the relative shorthand './' must be rewritten into a resolvable
github reference, never left as the bare './' string Claude Code can't install."""
client = _make_fake_prisma_client()
marketplace = await _create_marketplace(client, name="anthropic-agent-skills", source_ref="anthropics/skills")
async def _get(http_client, url, **kwargs):
assert url == _ANTHROPIC_MANIFEST_URL
return httpx.Response(200, json=_ANTHROPIC_SKILLS_MANIFEST)
monkeypatch.setattr(sync_module, "async_safe_get", _get)
result = await resolve_and_sync(client, marketplace)
assert result.status == "success"
assert result.plugin_count == 3
plugins = await client.db.litellm_claudecodeplugintable.find_many(where={"marketplace_id": marketplace.id})
plugins_by_name = {p.name: p for p in plugins}
assert set(plugins_by_name) == {
"anthropic-agent-skills--document-skills",
"anthropic-agent-skills--example-skills",
"anthropic-agent-skills--claude-api",
}
for plugin in plugins_by_name.values():
assert plugin.enabled is False
source = json.loads(plugin.manifest_json)["source"]
assert source == {"source": "github", "repo": "anthropics/skills"}
assert source != "./"
refreshed_marketplace = await client.db.litellm_skillmarketplacetable.find_unique(where={"id": marketplace.id})
assert refreshed_marketplace.sync_status == "success"
@pytest.mark.asyncio
async def test_resolve_and_sync_falls_back_to_skills_directory_scan(monkeypatch):
"""A repo with no marketplace.json (404) falls back to scanning skills/*/SKILL.md
frontmatter (github-only), pulling name/description from the frontmatter."""
client = _make_fake_prisma_client()
marketplace = await _create_marketplace(client, name="vercel-skills", source_ref="vercel-labs/skills")
manifest_url = "https://raw.githubusercontent.com/vercel-labs/skills/main/.claude-plugin/marketplace.json"
contents_url = "https://api.github.com/repos/vercel-labs/skills/contents/skills?ref=main"
skill_md_url = "https://raw.githubusercontent.com/vercel-labs/skills/main/skills/find-skills/SKILL.md"
async def _get(http_client, url, **kwargs):
if url == manifest_url:
return httpx.Response(404)
if url == contents_url:
return httpx.Response(
200,
json=[{"name": "find-skills", "path": "skills/find-skills", "type": "dir"}],
)
if url == skill_md_url:
return httpx.Response(200, text=_FIND_SKILLS_SKILL_MD)
raise AssertionError(f"unexpected url requested: {url}")
monkeypatch.setattr(sync_module, "async_safe_get", _get)
result = await resolve_and_sync(client, marketplace)
assert result.status == "success"
assert result.plugin_count == 1
plugins = await client.db.litellm_claudecodeplugintable.find_many(where={"marketplace_id": marketplace.id})
assert len(plugins) == 1
plugin = plugins[0]
assert plugin.name == "vercel-skills--find-skills"
assert plugin.description == "Helps users discover and install agent skills across GitHub repositories."
source = json.loads(plugin.manifest_json)["source"]
assert source == {
"source": "git-subdir",
"url": "https://github.com/vercel-labs/skills.git",
"path": "skills/find-skills",
}
@pytest.mark.asyncio
async def test_resolve_and_sync_connection_failure_is_unreachable(monkeypatch):
client = _make_fake_prisma_client()
marketplace = await _create_marketplace(client, name="flaky-marketplace", source_ref="org/flaky-repo")
async def _get(http_client, url, **kwargs):
raise httpx.ConnectError("connection refused", request=httpx.Request("GET", url))
monkeypatch.setattr(sync_module, "async_safe_get", _get)
result = await resolve_and_sync(client, marketplace)
assert result.status == "error"
refreshed = await client.db.litellm_skillmarketplacetable.find_unique(where={"id": marketplace.id})
assert refreshed.sync_status == "error"
assert refreshed.sync_error.startswith("unreachable:")
@pytest.mark.asyncio
async def test_resolve_and_sync_non_200_is_http_error(monkeypatch):
client = _make_fake_prisma_client()
marketplace = await _create_marketplace(client, name="broken-marketplace", source_ref="org/broken-repo")
async def _get(http_client, url, **kwargs):
return httpx.Response(500)
monkeypatch.setattr(sync_module, "async_safe_get", _get)
result = await resolve_and_sync(client, marketplace)
assert result.status == "error"
refreshed = await client.db.litellm_skillmarketplacetable.find_unique(where={"id": marketplace.id})
assert refreshed.sync_status == "error"
assert refreshed.sync_error.startswith("http_error:")
@pytest.mark.asyncio
async def test_resolve_and_sync_malformed_json_is_invalid_json(monkeypatch):
client = _make_fake_prisma_client()
marketplace = await _create_marketplace(client, name="badjson-marketplace", source_ref="org/badjson-repo")
async def _get(http_client, url, **kwargs):
return httpx.Response(200, text="{not valid json")
monkeypatch.setattr(sync_module, "async_safe_get", _get)
result = await resolve_and_sync(client, marketplace)
assert result.status == "error"
refreshed = await client.db.litellm_skillmarketplacetable.find_unique(where={"id": marketplace.id})
assert refreshed.sync_status == "error"
assert refreshed.sync_error.startswith("invalid_json:")
@pytest.mark.asyncio
async def test_resolve_and_sync_schema_violation_is_invalid_schema(monkeypatch):
client = _make_fake_prisma_client()
marketplace = await _create_marketplace(client, name="badschema-marketplace", source_ref="org/badschema-repo")
async def _get(http_client, url, **kwargs):
# Valid JSON, but missing the required top-level "name" field.
return httpx.Response(200, json={"foo": "bar"})
monkeypatch.setattr(sync_module, "async_safe_get", _get)
result = await resolve_and_sync(client, marketplace)
assert result.status == "error"
refreshed = await client.db.litellm_skillmarketplacetable.find_unique(where={"id": marketplace.id})
assert refreshed.sync_status == "error"
assert refreshed.sync_error.startswith("invalid_schema:")
@pytest.mark.asyncio
async def test_resolve_and_sync_non_github_404_does_not_attempt_dir_scan(monkeypatch):
"""Regression test: the skills/ directory-scan fallback is GitHub-only. A
gitlab/bitbucket/plain-url source that 404s on its manifest path must fail
the sync outright and must never hit the GitHub contents API."""
client = _make_fake_prisma_client()
marketplace = await _create_marketplace(client, name="gitlab-marketplace", source_ref="https://gitlab.com/foo/bar")
manifest_url = "https://gitlab.com/foo/bar/-/raw/main/.claude-plugin/marketplace.json"
calls = []
async def _get(http_client, url, **kwargs):
calls.append(url)
return httpx.Response(404)
monkeypatch.setattr(sync_module, "async_safe_get", _get)
result = await resolve_and_sync(client, marketplace)
assert result.status == "error"
refreshed = await client.db.litellm_skillmarketplacetable.find_unique(where={"id": marketplace.id})
assert refreshed.sync_status == "error"
assert calls == [manifest_url]
assert all("api.github.com" not in url for url in calls)
@pytest.mark.asyncio
async def test_resolve_and_sync_is_idempotent(monkeypatch):
monkeypatch.setattr(sync_module, "datetime", _FakeClock())
client = _make_fake_prisma_client()
marketplace = await _create_marketplace(client, name="anthropic-agent-skills", source_ref="anthropics/skills")
async def _get(http_client, url, **kwargs):
return httpx.Response(200, json=_ANTHROPIC_SKILLS_MANIFEST)
monkeypatch.setattr(sync_module, "async_safe_get", _get)
await resolve_and_sync(client, marketplace)
first_pass = await client.db.litellm_claudecodeplugintable.find_many(where={"marketplace_id": marketplace.id})
assert len(first_pass) == 3
# Extract the value now - the stored record is a live, mutable object that
# the second sync will update in place, so keeping a reference to the
# record itself (rather than its updated_at value) would falsely "advance"
# this too.
first_updated_at = next(p for p in first_pass if p.name == "anthropic-agent-skills--claude-api").updated_at
await resolve_and_sync(client, marketplace)
second_pass = await client.db.litellm_claudecodeplugintable.find_many(where={"marketplace_id": marketplace.id})
assert len(second_pass) == 3
target_after = next(p for p in second_pass if p.name == "anthropic-agent-skills--claude-api")
assert target_after.updated_at > first_updated_at
@pytest.mark.asyncio
async def test_resolve_and_sync_soft_disables_stale_plugin(monkeypatch):
client = _make_fake_prisma_client()
marketplace = await _create_marketplace(client, name="anthropic-agent-skills", source_ref="anthropics/skills")
async def _get_full(http_client, url, **kwargs):
return httpx.Response(200, json=_ANTHROPIC_SKILLS_MANIFEST)
monkeypatch.setattr(sync_module, "async_safe_get", _get_full)
await resolve_and_sync(client, marketplace)
stale_name = "anthropic-agent-skills--claude-api"
stale_plugin = await client.db.litellm_claudecodeplugintable.find_unique(where={"name": stale_name})
assert stale_plugin is not None
# Simulate an admin having manually enabled this skill before the next sync.
stale_plugin.enabled = True
reduced_manifest = {
**_ANTHROPIC_SKILLS_MANIFEST,
"plugins": [p for p in _ANTHROPIC_SKILLS_MANIFEST["plugins"] if p["name"] != "claude-api"],
}
async def _get_reduced(http_client, url, **kwargs):
return httpx.Response(200, json=reduced_manifest)
monkeypatch.setattr(sync_module, "async_safe_get", _get_reduced)
result = await resolve_and_sync(client, marketplace)
assert result.status == "success"
assert result.plugin_count == 2
refreshed_stale = await client.db.litellm_claudecodeplugintable.find_unique(where={"name": stale_name})
assert refreshed_stale is not None
assert refreshed_stale.enabled is False

View file

@ -0,0 +1,168 @@
"""
Unit tests for claude_code_skill_authz.get_allowed_skills.
This is the authorization gate that decides which non-public (disabled)
Claude Code skills a key/team/org can additionally see via the
`GET /claude-code/marketplace.json?key=...` endpoint. The intersection/ceiling
rules deliberately mirror MCPRequestHandler.get_allowed_mcp_servers:
- an empty/missing allowed_skills list at a given level means that level
places no restriction and is skipped from the intersection
- key/team: if both restrict, intersect; if only one restricts, use it
- org: acts as a ceiling - if the org has an explicit list, it caps whatever
the key/team level resolved to; if nothing lower restricts, the org list
becomes the result outright
Team/org lookups are stubbed at their real collaborator functions
(auth_checks.get_team_object / get_org_object / get_object_permission) rather
than by faking the underlying prisma/cache stack those already have their own
tests for - this keeps these tests focused on get_allowed_skills's own
aggregation logic.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from litellm.models.object_permission import LiteLLM_ObjectPermissionTable
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_authz import (
get_allowed_skills,
)
from litellm.proxy.auth import auth_checks
@pytest.fixture(autouse=True)
def _default_team_org_lookups(monkeypatch):
"""By default, nobody has a team/org - team_id/org_id being unset on the
key is what actually short-circuits these, but stub them anyway so a test
that forgets to override a lookup fails loudly instead of hitting a real DB."""
monkeypatch.setattr(auth_checks, "get_team_object", AsyncMock(return_value=None))
monkeypatch.setattr(auth_checks, "get_org_object", AsyncMock(return_value=None))
monkeypatch.setattr(auth_checks, "get_object_permission", AsyncMock(return_value=None))
def _object_permission(allowed_skills):
return LiteLLM_ObjectPermissionTable(object_permission_id="perm-1", allowed_skills=allowed_skills)
def _key_auth(*, allowed_skills=None, team_id=None, org_id=None):
object_permission = _object_permission(allowed_skills) if allowed_skills is not None else None
return UserAPIKeyAuth(
api_key="sk-test",
user_id="test-user",
team_id=team_id,
org_id=org_id,
object_permission=object_permission,
)
def _mock_org(monkeypatch, *, allowed_skills):
monkeypatch.setattr(
auth_checks, "get_org_object", AsyncMock(return_value=SimpleNamespace(object_permission_id="org-perm-1"))
)
monkeypatch.setattr(
auth_checks,
"get_object_permission",
AsyncMock(return_value=_object_permission(allowed_skills)),
)
def _mock_team(monkeypatch, *, allowed_skills):
monkeypatch.setattr(
auth_checks,
"get_team_object",
AsyncMock(return_value=SimpleNamespace(object_permission=_object_permission(allowed_skills))),
)
@pytest.mark.asyncio
async def test_org_grants_with_no_team_or_key_restriction_becomes_the_effective_set(monkeypatch):
"""Org narrows, nothing else does -> org's list becomes the ceiling outright."""
key_auth = _key_auth(allowed_skills=None, org_id="org-1")
_mock_org(monkeypatch, allowed_skills=["a--x"])
result = await get_allowed_skills(key_auth, prisma_client=object())
assert result == frozenset({"a--x"})
@pytest.mark.asyncio
async def test_org_grants_nothing_key_grants_becomes_the_effective_set(monkeypatch):
"""Key narrows, org places no restriction -> key's list wins as-is."""
key_auth = _key_auth(allowed_skills=["a--x"], org_id=None)
result = await get_allowed_skills(key_auth, prisma_client=object())
assert result == frozenset({"a--x"})
@pytest.mark.asyncio
async def test_org_grants_superset_of_key_intersects_to_key(monkeypatch):
"""Both org and key restrict -> org caps the key's (tighter) list via intersection."""
key_auth = _key_auth(allowed_skills=["a--x"], org_id="org-1")
_mock_org(monkeypatch, allowed_skills=["a--x", "a--y"])
result = await get_allowed_skills(key_auth, prisma_client=object())
assert result == frozenset({"a--x"})
@pytest.mark.asyncio
async def test_disjoint_org_and_key_grants_intersect_to_empty(monkeypatch):
"""Org and key each restrict to disjoint sets -> no overlap, effective set is empty."""
key_auth = _key_auth(allowed_skills=["a--y"], org_id="org-1")
_mock_org(monkeypatch, allowed_skills=["a--x"])
result = await get_allowed_skills(key_auth, prisma_client=object())
assert result == frozenset()
@pytest.mark.asyncio
async def test_no_permissions_anywhere_returns_empty_set(monkeypatch):
"""Nobody (key/team/org) has any object_permission at all.
Mirrors MCPRequestHandler.get_allowed_mcp_servers's fallback: with zero
restrictions defined at any level, the function returns an empty
collection rather than "everything" - callers (get_marketplace) treat an
empty allowed_skills result as "nothing extra beyond the public catalog",
which is the correct, safe default.
"""
key_auth = _key_auth(allowed_skills=None, team_id=None, org_id=None)
result = await get_allowed_skills(key_auth, prisma_client=object())
assert result == frozenset()
@pytest.mark.asyncio
async def test_team_grants_with_no_key_restriction_becomes_the_effective_set(monkeypatch):
key_auth = _key_auth(allowed_skills=None, team_id="team-1")
_mock_team(monkeypatch, allowed_skills=["a--x"])
result = await get_allowed_skills(key_auth, prisma_client=object())
assert result == frozenset({"a--x"})
@pytest.mark.asyncio
async def test_key_and_team_grants_intersect(monkeypatch):
key_auth = _key_auth(allowed_skills=["a--x", "a--z"], team_id="team-1")
_mock_team(monkeypatch, allowed_skills=["a--x", "a--y"])
result = await get_allowed_skills(key_auth, prisma_client=object())
assert result == frozenset({"a--x"})
@pytest.mark.asyncio
async def test_org_ceiling_applies_on_top_of_key_team_intersection(monkeypatch):
"""Full chain: key/team intersect first, then org caps the result further."""
key_auth = _key_auth(allowed_skills=["a--x", "a--y"], team_id="team-1", org_id="org-1")
_mock_team(monkeypatch, allowed_skills=["a--x", "a--y", "a--z"])
_mock_org(monkeypatch, allowed_skills=["a--x"])
result = await get_allowed_skills(key_auth, prisma_client=object())
assert result == frozenset({"a--x"})

View file

@ -1,7 +1,7 @@
{
"@typescript-eslint/no-explicit-any": 1977,
"@typescript-eslint/no-explicit-any": 1985,
"complexity": 129,
"local/no-large-inline-object-arg": 509,
"local/no-large-inline-object-arg": 510,
"local/no-long-condition-chain": 233,
"max-depth": 59,
"no-console": 16

View file

@ -0,0 +1,21 @@
import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { getClaudeCodeMarketplaces } from "@/components/networking";
import { ListMarketplacesResponse, MarketplaceSource } from "@/components/claude_code_plugins/types";
import useAuthorized from "../useAuthorized";
const claudeCodeMarketplaceKeys = createQueryKeys("claudeCodeMarketplaces");
export const useClaudeCodeMarketplaces = () => {
const { accessToken } = useAuthorized();
return useQuery<MarketplaceSource[]>({
queryKey: claudeCodeMarketplaceKeys.list(),
queryFn: async () => {
const response: ListMarketplacesResponse = await getClaudeCodeMarketplaces(accessToken!);
return response.marketplaces;
},
enabled: !!accessToken,
});
};
export { claudeCodeMarketplaceKeys };

View file

@ -0,0 +1,21 @@
import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { getClaudeCodePluginsList } from "@/components/networking";
import { ListPluginsResponse, PluginListItem } from "@/components/claude_code_plugins/types";
import useAuthorized from "../useAuthorized";
const claudeCodePluginKeys = createQueryKeys("claudeCodePlugins");
export const useClaudeCodePlugins = () => {
const { accessToken } = useAuthorized();
return useQuery<PluginListItem[]>({
queryKey: claudeCodePluginKeys.list(),
queryFn: async () => {
const response: ListPluginsResponse = await getClaudeCodePluginsList(accessToken!, false);
return response.plugins;
},
enabled: !!accessToken,
});
};
export { claudeCodePluginKeys };

View file

@ -1,13 +1,21 @@
import React, { useState, useEffect } from "react";
import { Button } from "@tremor/react";
import { Modal } from "antd";
import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking";
import {
getClaudeCodePluginsList,
deleteClaudeCodePlugin,
getClaudeCodeMarketplaces,
syncClaudeCodeMarketplace,
deleteClaudeCodeMarketplace,
} from "@/components/networking";
import AddPluginForm from "./add_plugin_form";
import AddMarketplaceForm from "./add_marketplace_form";
import MarketplaceTable from "./marketplace_table";
import PluginTable from "./plugin_table";
import SkillDetail from "@/components/claude_code_plugins/skill_detail";
import { isAdminRole } from "@/utils/roles";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { Plugin, ListPluginsResponse } from "@/components/claude_code_plugins/types";
import { Plugin, ListPluginsResponse, MarketplaceSource, ListMarketplacesResponse } from "@/components/claude_code_plugins/types";
interface ClaudeCodePluginsPanelProps {
accessToken: string | null;
@ -25,6 +33,16 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
} | null>(null);
const [selectedSkill, setSelectedSkill] = useState<Plugin | null>(null);
const [marketplacesList, setMarketplacesList] = useState<MarketplaceSource[]>([]);
const [isAddMarketplaceModalVisible, setIsAddMarketplaceModalVisible] = useState(false);
const [isMarketplacesLoading, setIsMarketplacesLoading] = useState(false);
const [isDeletingMarketplace, setIsDeletingMarketplace] = useState(false);
const [syncingMarketplaceName, setSyncingMarketplaceName] = useState<string | null>(null);
const [marketplaceToDelete, setMarketplaceToDelete] = useState<{
name: string;
displayName: string;
} | null>(null);
const isAdmin = userRole ? isAdminRole(userRole) : false;
const fetchPlugins = async () => {
@ -41,8 +59,23 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
}
};
const fetchMarketplaces = async () => {
if (!accessToken) return;
setIsMarketplacesLoading(true);
try {
const response: ListMarketplacesResponse = await getClaudeCodeMarketplaces(accessToken);
setMarketplacesList(response.marketplaces);
} catch (error) {
console.error("Error fetching marketplaces:", error);
} finally {
setIsMarketplacesLoading(false);
}
};
useEffect(() => {
fetchPlugins();
fetchMarketplaces();
}, [accessToken]);
const handleDeleteClick = (pluginName: string, displayName: string) => {
@ -66,6 +99,45 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
}
};
const handleSyncMarketplace = async (marketplaceName: string) => {
if (!accessToken) return;
setSyncingMarketplaceName(marketplaceName);
try {
await syncClaudeCodeMarketplace(accessToken, marketplaceName);
NotificationsManager.success(`Marketplace "${marketplaceName}" synced successfully`);
fetchMarketplaces();
fetchPlugins();
} catch (error) {
console.error("Error syncing marketplace:", error);
NotificationsManager.error("Failed to sync marketplace");
} finally {
setSyncingMarketplaceName(null);
}
};
const handleDeleteMarketplaceClick = (marketplaceName: string, displayName: string) => {
setMarketplaceToDelete({ name: marketplaceName, displayName });
};
const handleDeleteMarketplaceConfirm = async () => {
if (!marketplaceToDelete || !accessToken) return;
setIsDeletingMarketplace(true);
try {
await deleteClaudeCodeMarketplace(accessToken, marketplaceToDelete.name);
NotificationsManager.success(`Marketplace "${marketplaceToDelete.displayName}" deleted successfully`);
fetchMarketplaces();
fetchPlugins();
} catch (error) {
console.error("Error deleting marketplace:", error);
NotificationsManager.error("Failed to delete marketplace");
} finally {
setIsDeletingMarketplace(false);
setMarketplaceToDelete(null);
}
};
return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
{selectedSkill ? (
@ -79,10 +151,34 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
) : (
<>
<div className="flex flex-col gap-2 mb-4">
<h1 className="text-2xl font-bold">Marketplaces</h1>
<p className="text-sm text-gray-600">
Import external Claude Code plugin marketplaces. Their skills are namespaced (e.g.{" "}
<code className="bg-gray-100 px-1 rounded-sm">marketplace-name--skill-name</code>) and can be granted to
orgs, teams, or keys.
</p>
<div className="mt-2 flex gap-2">
<Button onClick={() => setIsAddMarketplaceModalVisible(true)} disabled={!accessToken || !isAdmin}>
+ Add Marketplace
</Button>
</div>
</div>
<MarketplaceTable
marketplacesList={marketplacesList}
isLoading={isMarketplacesLoading}
isAdmin={isAdmin}
syncingName={syncingMarketplaceName}
onSyncClick={handleSyncMarketplace}
onDeleteClick={handleDeleteMarketplaceClick}
/>
<div className="flex flex-col gap-2 mb-4 mt-10">
<h1 className="text-2xl font-bold">Skills</h1>
<p className="text-sm text-gray-600">
Register Claude Code skills. Published skills appear in the Skill Hub for all users and are served via{" "}
<code className="bg-gray-100 px-1 rounded-sm">/claude-code/marketplace.json</code>.
Register Claude Code skills. Public skills appear in the Skill Hub for all users and are served via{" "}
<code className="bg-gray-100 px-1 rounded-sm">/claude-code/marketplace.json</code>. Non-public skills
require a per-skill grant on an org, team, or key.
</p>
<div className="mt-2 flex gap-2">
<Button onClick={() => setIsAddModalVisible(true)} disabled={!accessToken || !isAdmin}>
@ -112,6 +208,13 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
onSuccess={fetchPlugins}
/>
<AddMarketplaceForm
visible={isAddMarketplaceModalVisible}
onClose={() => setIsAddMarketplaceModalVisible(false)}
accessToken={accessToken}
onSuccess={fetchMarketplaces}
/>
{pluginToDelete && (
<Modal
title="Delete Skill"
@ -128,6 +231,23 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
<p>This action cannot be undone.</p>
</Modal>
)}
{marketplaceToDelete && (
<Modal
title="Delete Marketplace"
open={marketplaceToDelete !== null}
onOk={handleDeleteMarketplaceConfirm}
onCancel={() => setMarketplaceToDelete(null)}
confirmLoading={isDeletingMarketplace}
okText="Delete"
okButtonProps={{ danger: true }}
>
<p>
Are you sure you want to delete marketplace: <strong>{marketplaceToDelete.displayName}</strong>?
</p>
<p>This does not delete skills already imported from it, but they will no longer sync.</p>
</Modal>
)}
</div>
);
};

View file

@ -0,0 +1,87 @@
import React, { useState } from "react";
import { Modal, Form, Input, Button } from "antd";
import { registerClaudeCodeMarketplace } from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
interface AddMarketplaceFormProps {
visible: boolean;
onClose: () => void;
accessToken: string | null;
onSuccess: () => void;
}
interface AddMarketplaceFormValues {
source: string;
name?: string;
}
const AddMarketplaceForm: React.FC<AddMarketplaceFormProps> = ({ visible, onClose, accessToken, onSuccess }) => {
const [form] = Form.useForm();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleCancel = () => {
form.resetFields();
onClose();
};
const handleSubmit = async (values: AddMarketplaceFormValues) => {
if (!accessToken) {
NotificationsManager.error("No access token available");
return;
}
setIsSubmitting(true);
try {
await registerClaudeCodeMarketplace(accessToken, {
source: values.source.trim(),
...(values.name?.trim() ? { name: values.name.trim() } : {}),
});
NotificationsManager.success("Marketplace imported successfully");
form.resetFields();
onSuccess();
onClose();
} catch (error) {
console.error("Error registering marketplace:", error);
const reason = error instanceof Error && error.message ? error.message : "Failed to import marketplace";
NotificationsManager.error(`Failed to import marketplace: ${reason}`);
} finally {
setIsSubmitting(false);
}
};
return (
<Modal title="Add Marketplace" open={visible} onCancel={handleCancel} footer={null} width={520} className="top-8">
<Form form={form} layout="vertical" onFinish={handleSubmit} className="mt-4">
<Form.Item
label="Repository"
name="source"
rules={[{ required: true, message: "Please enter a repository (org/repo) or URL" }]}
tooltip="A GitHub org/repo (e.g. anthropics/claude-code-marketplace) or a full git URL"
>
<Input placeholder="org/repo or https://github.com/org/repo" className="rounded-lg" />
</Form.Item>
<Form.Item
label="Name (Optional)"
name="name"
tooltip="Marketplace identifier used to namespace its skills. Defaults to the repository name"
>
<Input placeholder="my-marketplace" className="rounded-lg" />
</Form.Item>
<Form.Item className="mb-0 mt-6">
<div className="flex justify-end gap-2">
<Button onClick={handleCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button type="primary" htmlType="submit" loading={isSubmitting}>
{isSubmitting ? "Importing..." : "Add Marketplace"}
</Button>
</div>
</Form.Item>
</Form>
</Modal>
);
};
export default AddMarketplaceForm;

View file

@ -0,0 +1,180 @@
import { RefreshIcon, TrashIcon } from "@heroicons/react/outline";
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { Button, Tooltip } from "antd";
import React from "react";
import { DateCell, StatusBadge, StatusTone } from "@/components/shared/table_cells";
import { MarketplaceSource } from "@/components/claude_code_plugins/types";
interface MarketplaceTableProps {
marketplacesList: MarketplaceSource[];
isLoading: boolean;
isAdmin: boolean;
syncingName: string | null;
onSyncClick: (marketplaceName: string) => void;
onDeleteClick: (marketplaceName: string, displayName: string) => void;
}
const SYNC_STATUS_TONE: Record<string, StatusTone> = {
synced: "success",
syncing: "info",
pending: "warning",
error: "error",
};
const syncStatusTone = (status: string): StatusTone => SYNC_STATUS_TONE[status] ?? "neutral";
const MarketplaceTable: React.FC<MarketplaceTableProps> = ({
marketplacesList,
isLoading,
isAdmin,
syncingName,
onSyncClick,
onDeleteClick,
}) => {
const columns: ColumnDef<MarketplaceSource>[] = [
{
header: "Marketplace",
accessorKey: "name",
cell: ({ row }) => {
const marketplace = row.original;
return (
<div className="flex flex-col">
<span className="font-mono text-xs text-gray-900">{marketplace.name}</span>
{marketplace.display_name && marketplace.display_name !== marketplace.name && (
<span className="text-xs text-gray-500">{marketplace.display_name}</span>
)}
</div>
);
},
},
{
header: "Source",
accessorKey: "source_type",
cell: ({ row }) => {
const marketplace = row.original;
return (
<Tooltip title={marketplace.source_ref}>
<span className="text-xs text-gray-600">{marketplace.source_type}</span>
</Tooltip>
);
},
},
{
header: "Sync Status",
accessorKey: "sync_status",
cell: ({ row }) => {
const marketplace = row.original;
return (
<StatusBadge
tone={syncStatusTone(marketplace.sync_status)}
label={marketplace.sync_status}
tooltip={marketplace.sync_error || undefined}
/>
);
},
},
{
header: "Skills",
accessorKey: "plugin_count",
cell: ({ row }) => <span className="text-xs text-gray-600">{row.original.plugin_count}</span>,
},
{
header: "Last Synced",
accessorKey: "last_synced_at",
cell: ({ row }) => <DateCell value={row.original.last_synced_at} />,
},
...(isAdmin
? [
{
header: "Actions",
id: "actions",
cell: ({ row }: { row: { original: MarketplaceSource } }) => {
const marketplace = row.original;
return (
<div className="flex items-center gap-1">
<Tooltip title="Sync now">
<Button
size="small"
type="text"
onClick={() => onSyncClick(marketplace.name)}
icon={<RefreshIcon className="h-4 w-4" />}
loading={syncingName === marketplace.name}
disabled={syncingName !== null}
/>
</Tooltip>
<Tooltip title="Delete marketplace">
<Button
size="small"
type="text"
onClick={() => onDeleteClick(marketplace.name, marketplace.display_name || marketplace.name)}
icon={<TrashIcon className="h-4 w-4" />}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
/>
</Tooltip>
</div>
);
},
},
]
: []),
];
const table = useReactTable({
data: marketplacesList,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<table className="min-w-full [&_td]:py-0.5 [&_th]:py-1">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="py-1 h-8 px-4 text-left text-xs font-medium text-gray-500">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>Loading...</p>
</div>
</td>
</tr>
)}
{!isLoading && marketplacesList.length === 0 && (
<tr>
<td colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No marketplaces imported. Add one to get started.</p>
</div>
</td>
</tr>
)}
{!isLoading &&
marketplacesList.length > 0 &&
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="h-8 border-t border-gray-100">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="py-0.5 px-4 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export default MarketplaceTable;

View file

@ -107,7 +107,13 @@ const PluginTable: React.FC<PluginTableProps> = ({
accessorKey: "enabled",
cell: ({ row }) => {
const plugin = row.original;
return <StatusBadge tone={plugin.enabled ? "success" : "neutral"} label={plugin.enabled ? "Yes" : "No"} />;
return (
<StatusBadge
tone={plugin.enabled ? "success" : "neutral"}
label={plugin.enabled ? "Yes" : "No"}
tooltip={plugin.enabled ? "Visible without a key" : "Requires an assigned grant"}
/>
);
},
},
{

View file

@ -118,7 +118,8 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
</div>
<Text className="text-sm text-gray-600">
Selected skills will be visible to all users in the Skill Hub. Deselected skills will be unpublished.
Selected skills are visible without a key. Deselected skills require an assigned grant (
<code>allowed_skills</code>) on the org, team, or key.
</Text>
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">

View file

@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../../tests/test-utils";
import SkillPermissionsPicker from "./SkillPermissionsPicker";
vi.mock("@/app/(dashboard)/hooks/claudeCodeMarketplaces/useClaudeCodeMarketplaces", () => ({
useClaudeCodeMarketplaces: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/claudeCodePlugins/useClaudeCodePlugins", () => ({
useClaudeCodePlugins: vi.fn(),
}));
import { useClaudeCodeMarketplaces } from "@/app/(dashboard)/hooks/claudeCodeMarketplaces/useClaudeCodeMarketplaces";
import { useClaudeCodePlugins } from "@/app/(dashboard)/hooks/claudeCodePlugins/useClaudeCodePlugins";
const mockUseMarketplaces = vi.mocked(useClaudeCodeMarketplaces);
const mockUsePlugins = vi.mocked(useClaudeCodePlugins);
const marketplaceFixture = (overrides: Partial<Record<string, unknown>> = {}) => ({
id: "mkt-1",
name: "anthropic-agent-skills",
display_name: "Anthropic Agent Skills",
source_type: "github",
source_ref: "anthropics/agent-skills",
enabled: true,
sync_status: "synced",
plugin_count: 2,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
...overrides,
});
const otherMarketplaceFixture = marketplaceFixture({
id: "mkt-2",
name: "other-marketplace",
display_name: "Other Marketplace",
plugin_count: 1,
});
const pluginFixture = (name: string) => ({
id: name,
name,
enabled: false,
source: { source: "github" as const },
});
describe("SkillPermissionsPicker", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("bulk-adds every skill belonging to a marketplace when its checkbox is checked", async () => {
mockUseMarketplaces.mockReturnValue({ data: [marketplaceFixture()], isLoading: false } as any);
mockUsePlugins.mockReturnValue({
data: [
pluginFixture("anthropic-agent-skills--document-skills"),
pluginFixture("anthropic-agent-skills--pdf-skills"),
],
isLoading: false,
} as any);
const onChange = vi.fn();
renderWithProviders(<SkillPermissionsPicker accessToken="tok" value={[]} onChange={onChange} />);
const marketplaceCheckbox = screen.getByRole("checkbox", { name: /Anthropic Agent Skills/ });
await userEvent.click(marketplaceCheckbox);
expect(onChange).toHaveBeenCalledWith(
expect.arrayContaining(["anthropic-agent-skills--document-skills", "anthropic-agent-skills--pdf-skills"]),
);
expect(onChange.mock.calls[0][0]).toHaveLength(2);
});
it("removes only that marketplace's skills when its checkbox is unchecked, leaving other marketplaces untouched", async () => {
mockUseMarketplaces.mockReturnValue({
data: [marketplaceFixture(), otherMarketplaceFixture],
isLoading: false,
} as any);
mockUsePlugins.mockReturnValue({
data: [
pluginFixture("anthropic-agent-skills--document-skills"),
pluginFixture("anthropic-agent-skills--pdf-skills"),
pluginFixture("other-marketplace--some-skill"),
],
isLoading: false,
} as any);
const onChange = vi.fn();
renderWithProviders(
<SkillPermissionsPicker
accessToken="tok"
value={["anthropic-agent-skills--document-skills", "anthropic-agent-skills--pdf-skills", "other-marketplace--some-skill"]}
onChange={onChange}
/>,
);
// Both marketplace checkboxes render fully checked given the value above.
const firstMarketplaceCheckbox = screen.getByRole("checkbox", { name: /Anthropic Agent Skills/ });
await userEvent.click(firstMarketplaceCheckbox);
expect(onChange).toHaveBeenCalledWith(["other-marketplace--some-skill"]);
});
it("removes a single individually-unchecked skill without touching its marketplace siblings", async () => {
mockUseMarketplaces.mockReturnValue({ data: [marketplaceFixture()], isLoading: false } as any);
mockUsePlugins.mockReturnValue({
data: [
pluginFixture("anthropic-agent-skills--document-skills"),
pluginFixture("anthropic-agent-skills--pdf-skills"),
],
isLoading: false,
} as any);
const onChange = vi.fn();
renderWithProviders(
<SkillPermissionsPicker
accessToken="tok"
value={["anthropic-agent-skills--document-skills", "anthropic-agent-skills--pdf-skills"]}
onChange={onChange}
/>,
);
const skillCheckbox = screen.getByRole("checkbox", { name: /document-skills/ });
await userEvent.click(skillCheckbox);
expect(onChange).toHaveBeenCalledWith(["anthropic-agent-skills--pdf-skills"]);
});
it("adds a single individually-checked skill without requiring the marketplace checkbox", async () => {
mockUseMarketplaces.mockReturnValue({ data: [marketplaceFixture()], isLoading: false } as any);
mockUsePlugins.mockReturnValue({
data: [
pluginFixture("anthropic-agent-skills--document-skills"),
pluginFixture("anthropic-agent-skills--pdf-skills"),
],
isLoading: false,
} as any);
const onChange = vi.fn();
renderWithProviders(<SkillPermissionsPicker accessToken="tok" value={[]} onChange={onChange} />);
const skillCheckbox = screen.getByRole("checkbox", { name: /pdf-skills/ });
await userEvent.click(skillCheckbox);
expect(onChange).toHaveBeenCalledWith(["anthropic-agent-skills--pdf-skills"]);
});
});

View file

@ -0,0 +1,127 @@
import React, { useMemo } from "react";
import { Spin, Checkbox } from "antd";
import { useClaudeCodeMarketplaces } from "@/app/(dashboard)/hooks/claudeCodeMarketplaces/useClaudeCodeMarketplaces";
import { useClaudeCodePlugins } from "@/app/(dashboard)/hooks/claudeCodePlugins/useClaudeCodePlugins";
import { PluginListItem } from "./types";
interface SkillPermissionsPickerProps {
accessToken: string;
value?: string[];
onChange: (skills: string[]) => void;
disabled?: boolean;
}
const marketplacePrefix = (marketplaceName: string) => `${marketplaceName}--`;
const SkillPermissionsPicker: React.FC<SkillPermissionsPickerProps> = ({ value, onChange, disabled = false }) => {
const { data: marketplaces = [], isLoading: marketplacesLoading } = useClaudeCodeMarketplaces();
const { data: allSkills = [], isLoading: skillsLoading } = useClaudeCodePlugins();
const selected = useMemo(() => value ?? [], [value]);
const selectedSet = useMemo(() => new Set(selected), [selected]);
const skillsByMarketplace = useMemo(() => {
return marketplaces.reduce<Record<string, PluginListItem[]>>((acc, marketplace) => {
const prefix = marketplacePrefix(marketplace.name);
return { ...acc, [marketplace.name]: allSkills.filter((skill) => skill.name.startsWith(prefix)) };
}, {});
}, [marketplaces, allSkills]);
const handleToggleMarketplace = (marketplaceName: string, checked: boolean) => {
if (disabled) return;
const prefix = marketplacePrefix(marketplaceName);
if (checked) {
const skillNames = (skillsByMarketplace[marketplaceName] || []).map((skill) => skill.name);
onChange(Array.from(new Set([...selected, ...skillNames])));
} else {
onChange(selected.filter((name) => !name.startsWith(prefix)));
}
};
const handleToggleSkill = (skillName: string, checked: boolean) => {
if (disabled) return;
onChange(
checked ? Array.from(new Set([...selected, skillName])) : selected.filter((name) => name !== skillName),
);
};
if (marketplacesLoading) {
return (
<div className="flex items-center justify-center py-8">
<Spin size="large" />
<span className="ml-3 text-gray-500">Loading marketplaces...</span>
</div>
);
}
if (marketplaces.length === 0) {
return <p className="text-gray-500">No marketplaces imported yet. Add one from the Skills page.</p>;
}
return (
<div className="space-y-4">
{marketplaces.map((marketplace) => {
const skills = skillsByMarketplace[marketplace.name] || [];
const selectedCount = skills.filter((skill) => selectedSet.has(skill.name)).length;
const checked = skills.length > 0 && selectedCount === skills.length;
const indeterminate = selectedCount > 0 && selectedCount < skills.length;
return (
<div key={marketplace.name} className="border rounded-lg bg-gray-50">
<div className="flex items-center justify-between p-4 border-b bg-white rounded-t-lg">
<Checkbox
checked={checked}
indeterminate={indeterminate}
disabled={disabled || skills.length === 0}
onChange={(e) => handleToggleMarketplace(marketplace.name, e.target.checked)}
>
<p className="font-semibold text-gray-900">{marketplace.display_name || marketplace.name}</p>
<p className="text-sm text-gray-500">
{marketplace.source_type} · {selectedCount}/{skills.length} skills selected
</p>
</Checkbox>
</div>
<div className="p-4">
{skillsLoading && (
<div className="flex items-center justify-center py-4">
<Spin />
<span className="ml-3 text-gray-500">Loading skills...</span>
</div>
)}
{!skillsLoading && skills.length === 0 && (
<p className="text-gray-500">No skills found for this marketplace</p>
)}
{!skillsLoading && skills.length > 0 && (
<div className="space-y-2">
{skills.map((skill) => {
const isSelected = selectedSet.has(skill.name);
return (
<label key={skill.name} className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={isSelected}
onChange={() => handleToggleSkill(skill.name, !isSelected)}
disabled={disabled}
className="mt-0.5"
/>
<span className="flex-1 min-w-0 flex items-center gap-2">
<span className="font-medium text-gray-900">{skill.name}</span>
<span className="text-sm text-gray-500">- {skill.description || "No description"}</span>
</span>
</label>
);
})}
</div>
)}
</div>
</div>
);
})}
</div>
);
};
export default SkillPermissionsPicker;

View file

@ -87,6 +87,45 @@ export interface MarketplaceResponse {
plugins: MarketplacePluginEntry[];
}
// Marketplace management types
// Hand-written (not generated from schema.d.ts): the /claude-code/marketplaces admin endpoints
// are not yet reflected in the OpenAPI spec this dashboard was built against.
export interface MarketplaceSource {
id: string;
name: string;
display_name?: string;
source_type: string;
source_ref: string;
branch?: string;
enabled: boolean;
sync_status: string;
sync_error?: string;
last_synced_at?: string;
plugin_count: number;
created_at: string;
updated_at: string;
}
export interface RegisterMarketplaceRequest {
source: string;
name?: string;
}
export interface RegisterMarketplaceResponse {
status: string;
marketplace: MarketplaceSource;
}
export interface ListMarketplacesResponse {
marketplaces: MarketplaceSource[];
count: number;
}
export interface DeleteMarketplaceResponse {
status: string;
message: string;
}
// UI-specific types
export interface CategoryTab {
key: string;

View file

@ -95,6 +95,7 @@ export interface KeyResponse {
vector_stores: string[];
agents?: string[];
agent_access_groups?: string[];
allowed_skills?: string[];
};
access_group_ids?: string[];
budget_fallbacks?: Record<string, string[]>;

View file

@ -26,7 +26,7 @@ import { clearTokenCookies, storeLoginToken } from "@/utils/cookieUtils";
import { TagNewRequest, TagUpdateRequest, TagListResponse, TagInfoResponse } from "./tag_management/types";
import { Team } from "./key_team_helpers/key_list";
import { EmailEventSettingsResponse, EmailEventSettingsUpdateRequest } from "./email_events/types";
import type { SkillRegisterRequest } from "./claude_code_plugins/types";
import type { RegisterMarketplaceRequest, SkillRegisterRequest } from "./claude_code_plugins/types";
import { jsonFields } from "./common_components/check_openapi_schema";
import NotificationsManager from "./molecules/notifications_manager";
import type { MCPUserEnvVarsStatus } from "./mcp_tools/types";
@ -206,6 +206,7 @@ export interface Organization {
mcp_servers: string[];
mcp_access_groups?: string[];
vector_stores: string[];
allowed_skills?: string[];
};
}
@ -7391,6 +7392,78 @@ export const deleteClaudeCodePlugin = async (accessToken: string, pluginName: st
}
};
/**
* Register (and sync) a new external Claude Code marketplace (admin only)
* @param accessToken - Admin access token
* @param marketplaceData - Marketplace source to import
*/
export const registerClaudeCodeMarketplace = async (
accessToken: string,
marketplaceData: RegisterMarketplaceRequest,
) => {
try {
return await apiClient.post(`/claude-code/marketplaces`, { accessToken, body: marketplaceData });
} catch (error) {
console.error("Failed to register Claude Code marketplace:", error);
throw error;
}
};
/**
* List all imported Claude Code marketplaces (admin only)
* @param accessToken - Admin access token
*/
export const getClaudeCodeMarketplaces = async (accessToken: string) => {
try {
return await apiClient.get(`/claude-code/marketplaces`, { accessToken });
} catch (error) {
console.error("Failed to fetch Claude Code marketplaces:", error);
throw error;
}
};
/**
* Get a single Claude Code marketplace by name (admin only)
* @param accessToken - Admin access token
* @param marketplaceName - Name of the marketplace
*/
export const getClaudeCodeMarketplaceByName = async (accessToken: string, marketplaceName: string) => {
try {
return await apiClient.get(`/claude-code/marketplaces/${marketplaceName}`, { accessToken });
} catch (error) {
console.error(`Failed to fetch marketplace "${marketplaceName}":`, error);
throw error;
}
};
/**
* Re-sync a Claude Code marketplace (admin only)
* @param accessToken - Admin access token
* @param marketplaceName - Name of the marketplace to sync
*/
export const syncClaudeCodeMarketplace = async (accessToken: string, marketplaceName: string) => {
try {
return await apiClient.post(`/claude-code/marketplaces/${marketplaceName}/sync`, { accessToken });
} catch (error) {
console.error(`Failed to sync marketplace "${marketplaceName}":`, error);
throw error;
}
};
/**
* Delete a Claude Code marketplace (admin only)
* @param accessToken - Admin access token
* @param marketplaceName - Name of the marketplace to delete
*/
export const deleteClaudeCodeMarketplace = async (accessToken: string, marketplaceName: string) => {
try {
return await apiClient.delete(`/claude-code/marketplaces/${marketplaceName}`, { accessToken });
} catch (error) {
console.error(`Failed to delete marketplace "${marketplaceName}":`, error);
throw error;
}
};
// Compliance check types and functions
export interface ComplianceCheckResult {

View file

@ -14,6 +14,7 @@ interface ObjectPermission {
agents?: string[];
agent_access_groups?: string[];
search_tools?: string[];
allowed_skills?: string[];
}
interface ObjectPermissionsViewProps {
@ -37,6 +38,7 @@ export function ObjectPermissionsView({
const agents = objectPermission?.agents || [];
const agentAccessGroups = objectPermission?.agent_access_groups || [];
const searchTools = objectPermission?.search_tools || [];
const allowedSkills = objectPermission?.allowed_skills || [];
const content = (
<div className={variant === "card" ? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" : "space-y-4"}>
@ -59,6 +61,14 @@ export function ObjectPermissionsView({
<Text className="mt-1 block text-xs text-gray-700">{searchTools.join(", ")}</Text>
)}
</div>
<div className="rounded-md border border-gray-100 p-4">
<Text className="text-sm font-medium text-gray-800">Skills</Text>
{allowedSkills.length === 0 ? (
<Text className="mt-1 block text-xs text-gray-500">No skill grants only public skills are accessible.</Text>
) : (
<Text className="mt-1 block text-xs text-gray-700">{allowedSkills.join(", ")}</Text>
)}
</div>
</div>
);

View file

@ -286,6 +286,7 @@ vi.mock("../common_components/team_dropdown", () => ({
),
}));
vi.mock("../CreateUserButton", () => ({ CreateUserButton: () => null }));
vi.mock("../claude_code_plugins/SkillPermissionsPicker", () => ({ default: () => null }));
vi.mock("../mcp_server_management/MCPServerSelector", () => ({ default: () => null }));
vi.mock("../mcp_server_management/MCPToolPermissions", () => ({ default: () => null }));
vi.mock("../shared/numerical_input", () => ({ default: () => null }));

View file

@ -37,6 +37,7 @@ import {
hasAllModelsSentinel,
} from "../key_team_helpers/fetch_available_models_team_key";
import { Team } from "../key_team_helpers/key_list";
import SkillPermissionsPicker from "../claude_code_plugins/SkillPermissionsPicker";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants";
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
@ -523,6 +524,16 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
delete formValues.allowed_agents_and_groups;
}
// Transform allowed_skills into object_permission format
if (formValues.allowed_skills && formValues.allowed_skills.length > 0) {
if (!formValues.object_permission) {
formValues.object_permission = {};
}
formValues.object_permission.allowed_skills = formValues.allowed_skills;
// Remove the original field as it's now part of object_permission
delete formValues.allowed_skills;
}
// Add model_aliases if any are defined
if (Object.keys(modelAliases).length > 0) {
formValues.aliases = JSON.stringify(modelAliases);
@ -1536,6 +1547,32 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
</AccordionBody>
</Accordion>
<Accordion className="mt-4 mb-4">
<AccordionHeader>
<b>Skills</b>
</AccordionHeader>
<AccordionBody>
<Form.Item
label={
<span>
Allowed Skills{" "}
<Tooltip title="Select which imported marketplace skills this key can access">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="allowed_skills"
help="Select skills this key can access. Public skills are always accessible without a grant"
>
<SkillPermissionsPicker
onChange={(skills) => form.setFieldValue("allowed_skills", skills)}
value={form.getFieldValue("allowed_skills")}
accessToken={accessToken}
/>
</Form.Item>
</AccordionBody>
</Accordion>
{premiumUser ? (
<Accordion className="mt-4 mb-4">
<AccordionHeader>

View file

@ -12,6 +12,7 @@ import { CheckIcon, CopyIcon } from "lucide-react";
import React, { useMemo, useState } from "react";
import MemberTable from "../common_components/MemberTable";
import UserSearchModal from "../common_components/user_search_modal";
import SkillPermissionsPicker from "../claude_code_plugins/SkillPermissionsPicker";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import { ModelSelect } from "../ModelSelect/ModelSelect";
import NotificationsManager from "../molecules/notifications_manager";
@ -139,7 +140,11 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
};
// Handle object_permission updates
if (values.vector_stores !== undefined || values.mcp_servers_and_groups !== undefined) {
if (
values.vector_stores !== undefined ||
values.mcp_servers_and_groups !== undefined ||
values.allowed_skills !== undefined
) {
updateData.object_permission = {
...orgData?.object_permission,
vector_stores: values.vector_stores || [],
@ -157,6 +162,10 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
updateData.object_permission.mcp_access_groups = accessGroups;
}
}
if (values.allowed_skills !== undefined) {
updateData.object_permission.allowed_skills = values.allowed_skills || [];
}
}
const response = await organizationUpdateCall(accessToken, updateData);
@ -372,6 +381,7 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
servers: orgData.object_permission?.mcp_servers || [],
accessGroups: orgData.object_permission?.mcp_access_groups || [],
},
allowed_skills: orgData.object_permission?.allowed_skills || [],
}}
layout="vertical"
>
@ -438,6 +448,14 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
/>
</Form.Item>
<Form.Item label="Skills" name="allowed_skills">
<SkillPermissionsPicker
onChange={(skills) => form.setFieldValue("allowed_skills", skills)}
value={form.getFieldValue("allowed_skills")}
accessToken={accessToken || ""}
/>
</Form.Item>
<Form.Item label="Metadata" name="metadata">
<Input.TextArea rows={4} />
</Form.Item>

View file

@ -41,6 +41,7 @@ import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSel
import { unfurlWildcardModelsInList } from "../key_team_helpers/fetch_available_models_team_key";
import GuardrailSettingsView from "../GuardrailSettingsView";
import LoggingSettingsView from "../logging_settings_view";
import SkillPermissionsPicker from "../claude_code_plugins/SkillPermissionsPicker";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
import { ModelSelect } from "../ModelSelect/ModelSelect";
@ -127,6 +128,7 @@ export interface TeamData {
agents?: string[];
agent_access_groups?: string[];
search_tools?: string[];
allowed_skills?: string[];
};
team_member_budget_table: {
max_budget: number;
@ -609,6 +611,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
updateData.object_permission.search_tools = values.object_permission_search_tools;
}
// Handle skill grants
if (values.allowed_skills !== undefined) {
updateData.object_permission.allowed_skills = values.allowed_skills || [];
}
delete values.allowed_skills;
// Pass access_group_ids to the update request
if (values.access_group_ids !== undefined) {
updateData.access_group_ids = values.access_group_ids;
@ -994,6 +1002,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
access_group_ids: info.access_group_ids || [],
default_team_member_models: info.default_team_member_models || [],
allowed_passthrough_routes: info.metadata?.allowed_passthrough_routes || [],
allowed_skills: info.object_permission?.allowed_skills || [],
}}
layout="vertical"
>
@ -1393,6 +1402,14 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
/>
</Form.Item>
<Form.Item label="Skills" name="allowed_skills">
<SkillPermissionsPicker
onChange={(skills) => form.setFieldValue("allowed_skills", skills)}
value={form.getFieldValue("allowed_skills")}
accessToken={accessToken || ""}
/>
</Form.Item>
<Accordion className="mt-4 mb-4">
<AccordionHeader>
<b>Search Tool Settings</b>

View file

@ -26,6 +26,7 @@ import {
} from "../key_team_helpers/TagRateLimitEditor";
import { excludeProxyWideSentinel, hasAllModelsSentinel } from "../key_team_helpers/fetch_available_models_team_key";
import { KeyResponse } from "../key_team_helpers/key_list";
import SkillPermissionsPicker from "../claude_code_plugins/SkillPermissionsPicker";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants";
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
@ -204,6 +205,7 @@ export function KeyEditView({
agents: keyData.object_permission?.agents || [],
accessGroups: keyData.object_permission?.agent_access_groups || [],
},
allowed_skills: keyData.object_permission?.allowed_skills || [],
logging_settings: extractLoggingSettings(keyData.metadata),
disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks)
? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks)
@ -233,6 +235,7 @@ export function KeyEditView({
accessGroups: keyData.object_permission?.mcp_access_groups || [],
},
mcp_tool_permissions: keyData.object_permission?.mcp_tool_permissions || {},
allowed_skills: keyData.object_permission?.allowed_skills || [],
throttle_on_budget_exceeded: keyData.metadata?.throttle_on_budget_exceeded || false,
logging_settings: extractLoggingSettings(keyData.metadata),
disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks)
@ -750,6 +753,14 @@ export function KeyEditView({
/>
</Form.Item>
<Form.Item label="Skills" name="allowed_skills" help="Select skills this key can access, beyond public skills">
<SkillPermissionsPicker
onChange={(skills) => form.setFieldValue("allowed_skills", skills)}
value={form.getFieldValue("allowed_skills")}
accessToken={accessToken || ""}
/>
</Form.Item>
<Form.Item
label={
<span>

View file

@ -237,6 +237,16 @@ export default function KeyInfoView({
delete formValues.agents_and_groups;
}
// Handle skill grants
if (formValues.allowed_skills !== undefined) {
formValues.object_permission = {
...currentKeyData.object_permission,
...formValues.object_permission,
allowed_skills: formValues.allowed_skills || [],
};
delete formValues.allowed_skills;
}
formValues.max_budget = mapEmptyStringToNull(formValues.max_budget);
formValues.tpm_limit = mapEmptyStringToNull(formValues.tpm_limit);
formValues.rpm_limit = mapEmptyStringToNull(formValues.rpm_limit);