mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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:
parent
dacf1cfb26
commit
106b974e05
40 changed files with 3265 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)},
|
||||
)
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue