feat: multiple concurrent budget windows per API key and team (#24883) (#25109)

* feat: multiple concurrent budget windows per API key and team (#24883)

* feat(proxy): add BudgetLimitEntry type and wire budget_limits into key/team models

* feat(schema): add budget_limits Json column to VerificationToken and TeamTable

* feat(migrations): add migration for budget_limits column on keys and teams

* feat(keys): initialize budget_limits windows with reset_at on key create/update

* feat(teams): initialize budget_limits windows with reset_at on team create/update

* feat(auth): add _virtual_key_multi_budget_check and _team_multi_budget_check

* feat(auth): call multi-budget checks from common_checks for keys and teams

* feat(proxy): increment per-window Redis spend counters after each request

* feat(budget): reset individual budget windows on schedule via reset_budget_job

* feat(ui): add hourly option to BudgetDurationDropdown

* feat(ui): add budget_limits field to KeyResponse type

* feat(ui): add Budget Windows editor to key edit view

* feat(ui): add Budget Windows editor to create key form

* fix(proxy): strip budget_limits=None before Prisma upsert to fix login 500

Prisma rejects nullable JSON fields (Json? without @default) when passed as
Python None — it needs the field omitted entirely so the DB stores NULL via
the column's nullable constraint. This was breaking /v2/login because the UI
session key creation path hit the upsert with budget_limits=None.

* ui(key-edit): use antd InputNumber+Button for budget windows, add reset hints

* ui(create-key): use antd InputNumber+Button for budget windows, add reset hints

* docs(users): add multiple budget windows section with API + dashboard walkthrough

* fix: BudgetExceededError returns HTTP 429 instead of 400

- Add status_code=429 to BudgetExceededError class
- auth_exception_handler hardcoded code=400 → code=429

* fix: no-op else branch in multi-budget auth checks causes KeyError

- BudgetLimitEntry objects must be coerced via model_dump() not left as-is
- Move _virtual_key_multi_budget_check into common_checks (was asymmetric
  with _team_multi_budget_check which already lived there)

* fix: len() on JSON string returns char count not window count

Guard with isinstance check + json.loads() before iterating per-window
Redis counters in increment_spend_counters

* fix: silent except:pass hides Redis reset failures in reset_budget_windows

Log Redis counter reset failures as warnings so they are observable

* test: add unit tests for multi-budget window enforcement

5 tests covering: no budget_limits passes, under budget passes,
over hourly window raises 429, over monthly window raises 429,
BudgetLimitEntry objects coerced without KeyError

* fix: key per-window counters stable across reorders (duration key, not index)

* fix: team+key per-window spend increments use duration key, not index

* fix: budget window reset uses duration key; log failures instead of swallowing

* refactor: extract BudgetWindowsEditor to shared component

* refactor: key_edit_view imports BudgetWindowsEditor from shared component

* refactor: create_key_button imports BudgetWindowsEditor from shared component

---------

Co-authored-by: Ishaan Jaffer <ishaanjaffer0324@gmail.com>

* fix(reset_budget_job): extract _reset_expired_window helper to fix PLR0915 too many statements

* feat(skills): Skills Registry & Hub — register skills, browse in AI Hub, public skill hub (#25118)

* feat(skills): add domain and namespace fields to plugin types

* feat(skills): store and return domain/namespace inside manifest_json

* feat(skills): add /public/skill_hub endpoint for unauthenticated access

* feat(skills): whitelist /public/skill_hub from auth requirements

* feat(skills): add domain, namespace to Plugin and RegisterPluginRequest types

* feat(skills): smart URL parser — paste github URL, auto-detect source type and name

* feat(skills): replace enable toggle with Public badge, make rows clickable

* feat(skills): add skill detail view with Overview and How to Use tabs

* feat(skills): add MakeSkillPublicForm modal for publishing skills to the hub

* feat(skills): rename panel to Skills, wire in skill detail view on row click

* feat(skills): add skill hub table columns — name, description, domain, source, status

* feat(skills): add SkillHubDashboard with stats row, domain dropdown filter, and table

* feat(skills): add Skill Hub tab to AI Hub with Select Skills to Make Public button

* feat(skills): move Skills to top-level nav item directly under MCP Servers

* feat(skills): add skillHubPublicCall and NEXT_PUBLIC_BASE_URL support

* feat(skills): add Skill Hub tab to public AI Hub page

* feat(skills): add skills page routing in main app router

* feat(skills): add /skills page route

* chore: update package-lock after npm install

* docs(skills): add Skills Gateway doc page with mermaid architecture diagram

* docs(skills): add Skills Gateway to sidebar under Agent & MCP Gateway

* docs(skills): add loom walkthrough video to Skills Gateway doc

* chore: fixes

---------

Co-authored-by: Ishaan Jaffer <ishaanjaffer0324@gmail.com>
Co-authored-by: Yuneng Jiang <yuneng@berri.ai>
This commit is contained in:
ishaan-berri
2026-04-06 14:02:04 -07:00
committed by GitHub
co-authored by Ishaan Jaffer Yuneng Jiang
parent 414d3966bf
commit 0afffe4366
40 changed files with 2103 additions and 429 deletions
+61
View File
@@ -333,6 +333,67 @@ curl 'http://0.0.0.0:4000/key/generate' \
}'
```
#### **Set multiple budget windows on a key**
Apply multiple concurrent budget limits at different time scales on the same key — for example, cap a key at **$10/day** AND **$100/month**.
**When is this useful?**
A single `budget_duration` window can't prevent a bad day from burning your entire month. Multiple budget windows let you:
- Block a runaway usage spike within the day while still allowing normal monthly spend.
- Give Claude Code rollouts a daily guardrail (`24h`) and a monthly ceiling (`30d`) so a single heavy session doesn't exhaust the whole month.
- Layer fine-grained hourly limits for bursty workloads on top of a weekly cap.
:::info
See [User Budget docs](https://docs.litellm.ai/docs/proxy/users) for more on how budgets work across keys, teams, and users.
:::
**Via API**
Pass `budget_limits` as a list of `{budget_duration, max_budget}` objects:
```bash
curl 'http://0.0.0.0:4000/key/generate' \
--header 'Authorization: Bearer <your-master-key>' \
--header 'Content-Type: application/json' \
--data-raw '{
"budget_limits": [
{"budget_duration": "24h", "max_budget": 10},
{"budget_duration": "30d", "max_budget": 100}
]
}'
```
Each window is tracked independently and resets on its own schedule:
| `budget_duration` | Resets |
|---|---|
| `1h` | Every hour |
| `24h` | Daily at midnight UTC |
| `7d` | Every Sunday at midnight UTC |
| `30d` | 1st of every month at midnight UTC |
**Via Dashboard**
Open **Virtual Keys → Create Key → Optional Settings → Budget Windows**.
![Step 1 - open key settings](https://colony-recorder.s3.amazonaws.com/files/2026-04-01/18930ba5-67c0-4031-afc0-57f37b4e59e4/ascreenshot_ef79d8a000bb41cdacf1bd9827732ee8_text_export.jpeg)
Click **+ Add Budget Window** to add a row, choose the period from the dropdown, and enter the spend cap.
![Step 2 - add a window](https://colony-recorder.s3.amazonaws.com/files/2026-04-01/5ae8c0b3-2d03-41ad-a63c-47b20c350dfe/ascreenshot_1a7dc6c7d65544f38fd8a65604674f22_text_export.jpeg)
Add a second row for a different time period (e.g. monthly $100 on top of a daily $10).
![Step 3 - add second window](https://colony-recorder.s3.amazonaws.com/files/2026-04-01/cbded3a7-1086-4e20-8f0f-de154b76146c/ascreenshot_c51c18752c3b4f8b976d28799b2638b6_text_export.jpeg)
Each window shows the reset schedule below the input so it's always clear when spend resets.
![Step 4 - reset hints](https://colony-recorder.s3.amazonaws.com/files/2026-04-01/8754f121-1640-4892-9dd0-fd4a870418bf/ascreenshot_8079eb0df2194e8f99e5258ba4b3c082_text_export.jpeg)
### ✨ Virtual Key (Model Specific)
+111
View File
@@ -0,0 +1,111 @@
# Skills Gateway
<iframe width="840" height="500" src="https://www.loom.com/embed/cb74eb79df3e4c2b83a6efae54a589f9" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>
LiteLLM acts as a **Skills Registry** — a central place to register, manage, and discover Claude Code skills across your organization. Teams can publish skills once and have agents and developers find them through a single hub.
## How it works
```mermaid
graph TD
Dev["👨‍💻 Developer<br/>registers a skill<br/>(GitHub URL or subdir)"] -->|POST /claude-code/plugins| Proxy["LiteLLM Proxy<br/>(Skills Registry)"]
Admin["🔑 Admin<br/>publishes skill<br/>(marks as public)"] -->|enable via UI or API| Proxy
Proxy -->|GET /public/skill_hub| SkillHub["🗂️ Skill Hub<br/>(AI Hub → Skill Hub tab)"]
Proxy -->|GET /claude-code/marketplace.json| Marketplace["📦 Claude Code<br/>Marketplace endpoint"]
SkillHub --> Human["🧑 Human<br/>browses & discovers skills<br/>in AI Hub UI"]
Marketplace --> Agent["🤖 Agent / Claude Code<br/>installs skill with<br/>/plugin marketplace add &lt;name&gt;"]
style Proxy fill:#1a73e8,color:#fff
style SkillHub fill:#e8f0fe,color:#1a73e8
style Marketplace fill:#e8f0fe,color:#1a73e8
```
## Quick start
### 1. Register a skill
Paste any GitHub URL into the Skills UI — LiteLLM auto-detects the source type and skill name.
```bash
curl -X POST https://your-proxy/claude-code/plugins \
-H "Authorization: Bearer $LITELLM_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "grill-me",
"source": {
"source": "git-subdir",
"url": "https://github.com/mattpocock/skills",
"path": "grill-me"
},
"description": "Interview skill for relentless questioning",
"domain": "Productivity",
"namespace": "interviews"
}'
```
Skills nested in subdirectories (e.g. `github.com/org/repo/tree/main/skill-name`) are supported — LiteLLM parses the URL automatically in the UI.
### 2. Publish to hub
In the Admin UI: **AI Hub → Skill Hub → Select Skills to Make Public**.
Or via API:
```bash
curl -X POST https://your-proxy/claude-code/plugins/grill-me/enable \
-H "Authorization: Bearer $LITELLM_KEY"
```
### 3. Browse the hub
Public skills appear at:
- **Admin UI**: AI Hub → Skill Hub tab
- **Public page**: `/ui/model_hub` → Skill Hub tab (no login required)
- **API**: `GET /public/skill_hub`
### 4. Install in Claude Code
Point Claude Code at your proxy marketplace once:
```json title="~/.claude/settings.json"
{
"extraKnownMarketplaces": {
"my-org": {
"source": "url",
"url": "https://your-proxy/claude-code/marketplace.json"
}
}
}
```
Then install any skill:
```
/plugin marketplace add grill-me
```
## Skill fields
| Field | Description |
|-------|-------------|
| `name` | Unique skill identifier (used in `/plugin marketplace add`) |
| `source` | Git source — `github`, `url`, or `git-subdir` |
| `description` | Short description shown in the hub |
| `domain` | Category for grouping (e.g. `Engineering`, `Productivity`) |
| `namespace` | Subcategory within a domain (e.g. `quality`, `meetings`) |
| `keywords` | Tags for search and filtering |
| `version` | Semver string |
## API reference
| Endpoint | Auth | Description |
|----------|------|-------------|
| `POST /claude-code/plugins` | Required | Register a skill |
| `GET /claude-code/plugins` | Required | List all skills (admin) |
| `POST /claude-code/plugins/{name}/enable` | Required | Publish a skill |
| `POST /claude-code/plugins/{name}/disable` | Required | Unpublish a skill |
| `GET /public/skill_hub` | None | List public skills |
| `GET /claude-code/marketplace.json` | None | Claude Code marketplace manifest |
+7
View File
@@ -333,6 +333,13 @@ const sidebars = {
},
],
},
{
type: "category",
label: "Skills Gateway",
items: [
"skills_gateway",
],
},
],
},
{
@@ -0,0 +1,5 @@
-- AlterTable: add budget_limits column to LiteLLM_VerificationToken
ALTER TABLE "LiteLLM_VerificationToken" ADD COLUMN IF NOT EXISTS "budget_limits" JSONB;
-- AlterTable: add budget_limits column to LiteLLM_TeamTable
ALTER TABLE "LiteLLM_TeamTable" ADD COLUMN IF NOT EXISTS "budget_limits" JSONB;
@@ -141,6 +141,7 @@ model LiteLLM_TeamTable {
team_member_permissions String[] @default([])
access_group_ids String[] @default([])
policies String[] @default([])
budget_limits Json? // multiple concurrent budget windows [{budget_duration, max_budget, reset_at}]
default_team_member_models String[] @default([]) // default allowed_models for newly added team members; empty = no per-member restriction
model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases
allow_team_guardrail_config Boolean @default(false) // if true, team admin can configure guardrails for this team
@@ -402,6 +403,7 @@ model LiteLLM_VerificationToken {
rotation_interval String? // How often to rotate (e.g., "30d", "90d")
last_rotation_at DateTime? // When this key was last rotated
key_rotation_at DateTime? // When this key should next be rotated
budget_limits Json? // multiple concurrent budget windows [{budget_duration, max_budget, reset_at}]
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id])
litellm_project_table LiteLLM_ProjectTable? @relation(fields: [project_id], references: [project_id])
+2 -1
View File
@@ -281,7 +281,7 @@ class Timeout(openai.APITimeoutError): # type: ignore
return _message
class PermissionDeniedError(openai.PermissionDeniedError): # type:ignore
class PermissionDeniedError(openai.PermissionDeniedError): # type: ignore
def __init__(
self,
message,
@@ -847,6 +847,7 @@ class BudgetExceededError(Exception):
):
self.current_cost = current_cost
self.max_budget = max_budget
self.status_code = 429
message = (
message
or f"Budget has been exceeded! Current cost: {current_cost}, Max budget: {max_budget}"
+20
View File
@@ -596,6 +596,7 @@ class LiteLLMRoutes(enum.Enum):
"/public/model_hub",
"/public/agent_hub",
"/public/mcp_hub",
"/public/skill_hub",
"/public/litellm_model_cost_map",
]
)
@@ -868,6 +869,14 @@ class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase):
models: Optional[List[str]] = None
class BudgetLimitEntry(LiteLLMPydanticObjectBase):
"""A single budget window with its own limit and independent reset schedule."""
budget_duration: str # e.g. "24h", "7d", "30d"
max_budget: float # max spend in USD for this window
reset_at: Optional[datetime] = None # populated at creation/reset time
class GenerateRequestBase(LiteLLMPydanticObjectBase):
"""
Overlapping schema between key and user generate/update requests
@@ -887,6 +896,9 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase):
rpm_limit: Optional[int] = None
budget_duration: Optional[str] = None
budget_limits: Optional[List[BudgetLimitEntry]] = (
None # multiple concurrent budget windows
)
allowed_cache_controls: Optional[list] = []
config: Optional[dict] = {}
permissions: Optional[dict] = {}
@@ -991,6 +1003,7 @@ class GenerateKeyResponse(KeyRequestBase):
"permissions",
"model_max_budget",
"router_settings",
"budget_limits",
]
for field in dict_fields:
value = values.get(field)
@@ -1644,6 +1657,9 @@ class TeamBase(LiteLLMPydanticObjectBase):
max_budget: Optional[float] = None
soft_budget: Optional[float] = None
budget_duration: Optional[str] = None
budget_limits: Optional[List[BudgetLimitEntry]] = (
None # multiple concurrent budget windows
)
models: list = []
blocked: bool = False
@@ -1745,6 +1761,8 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase):
enforced_file_expires_after: Optional[dict] = None
router_settings: Optional[dict] = None
access_group_ids: Optional[List[str]] = None
budget_limits: Optional[List[BudgetLimitEntry]] = (
None # multiple concurrent budget windows
default_team_member_models: Optional[List[str]] = (
None # default allowed_models seeded onto new team members
)
@@ -1893,6 +1911,7 @@ class LiteLLM_TeamTable(TeamBase):
"model_max_budget",
"model_aliases",
"router_settings",
"budget_limits",
]
if isinstance(values, BaseModel):
@@ -2378,6 +2397,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase):
last_rotation_at: Optional[datetime] = None # When this key was last rotated
key_rotation_at: Optional[datetime] = None # When this key should next be rotated
router_settings: Optional[dict] = None
budget_limits: Optional[List[dict]] = None # multiple concurrent budget windows
model_config = ConfigDict(protected_namespaces=())
@@ -260,6 +260,10 @@ async def register_plugin(
manifest["keywords"] = request.keywords
if request.category:
manifest["category"] = request.category
if request.domain:
manifest["domain"] = request.domain
if request.namespace:
manifest["namespace"] = request.namespace
# Check if plugin exists
existing = await prisma_client.db.litellm_claudecodeplugintable.find_unique(
@@ -362,6 +366,8 @@ async def list_plugins(
homepage=manifest.get("homepage"),
keywords=manifest.get("keywords"),
category=manifest.get("category"),
domain=manifest.get("domain"),
namespace=manifest.get("namespace"),
enabled=p.enabled,
created_at=p.created_at.isoformat() if p.created_at else None,
updated_at=p.updated_at.isoformat() if p.updated_at else None,
+81
View File
@@ -534,6 +534,17 @@ async def common_checks( # noqa: PLR0915
valid_token=valid_token,
)
# 3.1. Multi-window budget check for team
with tracer.trace("litellm.proxy.auth.common_checks.team_multi_budget_check"):
await _team_multi_budget_check(team_object=team_object)
# 3.2. Multi-window budget check for key
with tracer.trace(
"litellm.proxy.auth.common_checks.virtual_key_multi_budget_check"
):
if valid_token is not None:
await _virtual_key_multi_budget_check(valid_token=valid_token)
# 3.0.5. If team is over soft budget (alert only, doesn't block)
with tracer.trace("litellm.proxy.auth.common_checks.team_soft_budget_check"):
await _team_soft_budget_check(
@@ -2982,6 +2993,43 @@ async def _virtual_key_max_budget_check(
)
async def _virtual_key_multi_budget_check(
valid_token: UserAPIKeyAuth,
):
"""
Raises BudgetExceededError if any budget window in valid_token.budget_limits is exceeded.
Each window has its own Redis counter keyed by spend:key:{token}:window:{budget_duration}.
Using budget_duration (not list index) keeps counters stable when windows are reordered
or removed during a key update.
Note: counters are not seeded from DB on Redis cold-start. After a Redis flush,
per-window spend resets to zero within the current window period. This is an acceptable
trade-off: the DB stores reset_at timestamps but not per-window accumulated spend.
"""
if not valid_token.budget_limits:
return
from litellm.proxy.proxy_server import get_current_spend
for window in valid_token.budget_limits:
w: dict = window if isinstance(window, dict) else window.model_dump()
counter_key = f"spend:key:{valid_token.token}:window:{w['budget_duration']}"
window_spend = await get_current_spend(
counter_key=counter_key,
fallback_spend=0.0,
)
if window_spend >= w["max_budget"]:
raise litellm.BudgetExceededError(
current_cost=window_spend,
max_budget=w["max_budget"],
message=(
f"ExceededBudget: Key over {w['budget_duration']} budget. "
f"Spend=${window_spend:.4f}, Limit=${w['max_budget']:.2f}"
),
)
async def _virtual_key_soft_budget_check(
valid_token: UserAPIKeyAuth,
proxy_logging_obj: ProxyLogging,
@@ -3223,6 +3271,39 @@ async def _team_max_budget_check(
)
async def _team_multi_budget_check(
team_object: Optional[LiteLLM_TeamTable],
):
"""
Raises BudgetExceededError if any budget window in team_object.budget_limits is exceeded.
Each window has its own Redis counter keyed by spend:team:{team_id}:window:{budget_duration}.
Using budget_duration (not list index) keeps counters stable when windows are reordered
or removed during a team update.
"""
if team_object is None or not team_object.budget_limits:
return
from litellm.proxy.proxy_server import get_current_spend
for window in team_object.budget_limits:
w: dict = window if isinstance(window, dict) else window.model_dump()
counter_key = f"spend:team:{team_object.team_id}:window:{w['budget_duration']}"
window_spend = await get_current_spend(
counter_key=counter_key,
fallback_spend=0.0,
)
if window_spend >= w["max_budget"]:
raise litellm.BudgetExceededError(
current_cost=window_spend,
max_budget=w["max_budget"],
message=(
f"ExceededBudget: Team={team_object.team_id} over {w['budget_duration']} budget. "
f"Spend=${window_spend:.4f}, Limit=${w['max_budget']:.2f}"
),
)
async def _team_soft_budget_check(
team_object: Optional[LiteLLM_TeamTable],
valid_token: Optional[UserAPIKeyAuth],
+1 -1
View File
@@ -106,7 +106,7 @@ class UserAPIKeyAuthExceptionHandler:
message=e.message,
type=ProxyErrorTypes.budget_exceeded,
param=None,
code=400,
code=429,
)
if isinstance(e, HTTPException):
raise ProxyException(
+6 -6
View File
@@ -984,9 +984,9 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
route=route,
)
if _end_user_object is not None:
end_user_params[
"allowed_model_region"
] = _end_user_object.allowed_model_region
end_user_params["allowed_model_region"] = (
_end_user_object.allowed_model_region
)
if _end_user_object.litellm_budget_table is not None:
_apply_budget_limits_to_end_user_params(
end_user_params=end_user_params,
@@ -1540,9 +1540,9 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
if _end_user_object is not None:
valid_token_dict.update(end_user_params)
valid_token_dict[
"end_user_object_permission"
] = _end_user_object.object_permission
valid_token_dict["end_user_object_permission"] = (
_end_user_object.object_permission
)
# check if token is from litellm-ui, litellm ui makes keys to allow users to login with sso. These keys can only be used for LiteLLM UI functions
# sso/login, ui/login, /key functions and /user functions
+104 -5
View File
@@ -2,7 +2,7 @@ import asyncio
import json
import time
from datetime import datetime, timedelta, timezone
from typing import List, Literal, Optional, Union
from typing import Any, List, Literal, Optional, Union
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import (
@@ -48,6 +48,9 @@ class ResetBudgetJob:
### RESET ENDUSER (Customer) BUDGET and corresponding Budget duration ###
await self.reset_budget_for_litellm_budget_table()
### RESET MULTI-WINDOW BUDGETS ###
await self.reset_budget_windows()
async def reset_budget_for_litellm_team_members(
self, budgets_to_reset: List[LiteLLM_BudgetTableFull]
):
@@ -549,6 +552,102 @@ class ResetBudgetJob:
)
verbose_proxy_logger.exception("Failed to reset budget for teams: %s", e)
@staticmethod
async def _reset_expired_window(
window: dict,
counter_key: str,
spend_counter_cache: Any,
now: datetime,
) -> bool:
"""Reset a single budget window if expired. Returns True if the window was reset."""
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
reset_at_str = window.get("reset_at")
if not reset_at_str:
return False
reset_at = datetime.fromisoformat(
reset_at_str.replace("Z", "+00:00")
).replace(tzinfo=None)
if reset_at > now:
return False
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=0.0)
if spend_counter_cache.redis_cache is not None:
try:
await spend_counter_cache.redis_cache.async_set_cache(
key=counter_key, value=0.0
)
except Exception as redis_err:
verbose_proxy_logger.warning(
"Failed to reset Redis counter %s: %s", counter_key, redis_err
)
window["reset_at"] = get_budget_reset_time(
budget_duration=window["budget_duration"]
).isoformat()
return True
async def reset_budget_windows(self) -> None:
"""
For keys and teams with budget_limits, reset any individual windows where
reset_at <= now. Only the expired windows are reset; other windows are untouched.
"""
from litellm.proxy.proxy_server import spend_counter_cache
now = datetime.utcnow()
# --- Keys ---
try:
all_keys = await self.prisma_client.db.litellm_verificationtoken.find_many(
where={"budget_limits": {"not": None}} # type: ignore[arg-type]
)
for key in all_keys:
raw = key.budget_limits # type: ignore[attr-defined]
if not raw:
continue
windows: list = raw if isinstance(raw, list) else json.loads(raw)
changed = False
for window in windows:
counter_key = f"spend:key:{key.token}:window:{window['budget_duration']}"
if await ResetBudgetJob._reset_expired_window(
window, counter_key, spend_counter_cache, now
):
changed = True
if changed:
await self.prisma_client.db.litellm_verificationtoken.update(
where={"token": key.token},
data={"budget_limits": json.dumps(windows)}, # type: ignore[arg-type]
)
except Exception as e:
verbose_proxy_logger.exception(
"Failed to reset budget windows for keys: %s", e
)
# --- Teams ---
try:
all_teams = await self.prisma_client.db.litellm_teamtable.find_many(
where={"budget_limits": {"not": None}} # type: ignore[arg-type]
)
for team in all_teams:
raw = team.budget_limits # type: ignore[attr-defined]
if not raw:
continue
windows = raw if isinstance(raw, list) else json.loads(raw)
changed = False
for window in windows:
counter_key = f"spend:team:{team.team_id}:window:{window['budget_duration']}"
if await ResetBudgetJob._reset_expired_window(
window, counter_key, spend_counter_cache, now
):
changed = True
if changed:
await self.prisma_client.db.litellm_teamtable.update(
where={"team_id": team.team_id},
data={"budget_limits": json.dumps(windows)}, # type: ignore[arg-type]
)
except Exception as e:
verbose_proxy_logger.exception(
"Failed to reset budget windows for teams: %s", e
)
@staticmethod
async def _reset_budget_common(
item: Union[LiteLLM_TeamTable, LiteLLM_UserTable, LiteLLM_VerificationToken],
@@ -570,14 +669,14 @@ class ResetBudgetJob:
from litellm.proxy.proxy_server import spend_counter_cache
counter_key = None
if item_type == "key" and hasattr(item, "token") and item.token is not None:
counter_key = f"spend:key:{item.token}"
if item_type == "key" and hasattr(item, "token") and item.token is not None: # type: ignore[union-attr]
counter_key = f"spend:key:{item.token}" # type: ignore[union-attr]
elif (
item_type == "team"
and hasattr(item, "team_id")
and item.team_id is not None
and item.team_id is not None # type: ignore[union-attr]
):
counter_key = f"spend:team:{item.team_id}"
counter_key = f"spend:team:{item.team_id}" # type: ignore[union-attr]
if counter_key is not None:
# Always reset in-memory (local fallback)
@@ -744,9 +744,9 @@ async def _common_key_generation_helper( # noqa: PLR0915
request_type="key", **data_json, table_name="key"
)
response[
"soft_budget"
] = data.soft_budget # include the user-input soft budget in the response
response["soft_budget"] = (
data.soft_budget
) # include the user-input soft budget in the response
response = GenerateKeyResponse(**response)
@@ -1571,6 +1571,19 @@ async def prepare_key_update_data(
non_default_values["budget_reset_at"] = key_reset_at
non_default_values["budget_duration"] = budget_duration
if "budget_limits" in non_default_values and non_default_values["budget_limits"]:
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
raw_windows = non_default_values["budget_limits"]
initialized_windows = []
for window in raw_windows:
w = window if isinstance(window, dict) else window.model_dump()
w["reset_at"] = get_budget_reset_time(
budget_duration=w["budget_duration"]
).isoformat()
initialized_windows.append(w)
non_default_values["budget_limits"] = json.dumps(initialized_windows)
if "object_permission" in non_default_values:
non_default_values = await _handle_update_object_permission(
data_json=non_default_values,
@@ -2832,6 +2845,7 @@ async def generate_key_helper_fn( # noqa: PLR0915
rotation_interval: Optional[str] = None,
router_settings: Optional[dict] = None,
access_group_ids: Optional[list] = None,
budget_limits: Optional[list] = None, # multiple concurrent budget windows
):
from litellm.proxy.proxy_server import premium_user, prisma_client
@@ -2863,6 +2877,18 @@ async def generate_key_helper_fn( # noqa: PLR0915
else:
reset_at = get_budget_reset_time(budget_duration=budget_duration)
# Initialize reset_at for each budget window
budget_limits_json: Optional[str] = None
if budget_limits:
initialized_windows = []
for window in budget_limits:
w = dict(window) if not isinstance(window, dict) else {**window}
w["reset_at"] = get_budget_reset_time(
budget_duration=w["budget_duration"]
).isoformat()
initialized_windows.append(w)
budget_limits_json = json.dumps(initialized_windows)
aliases_json = json.dumps(aliases)
config_json = json.dumps(config)
permissions_json = json.dumps(permissions)
@@ -2945,6 +2971,7 @@ async def generate_key_helper_fn( # noqa: PLR0915
"organization_id": organization_id,
"budget_id": budget_id,
"blocked": blocked,
"budget_limits": budget_limits_json,
"created_by": created_by,
"updated_by": updated_by,
"allowed_routes": allowed_routes or [],
@@ -3222,10 +3249,10 @@ async def delete_verification_tokens(
try:
if prisma_client:
tokens = [_hash_token_if_needed(token=key) for key in tokens]
_keys_being_deleted: List[
LiteLLM_VerificationToken
] = await prisma_client.db.litellm_verificationtoken.find_many(
where={"token": {"in": tokens}}
_keys_being_deleted: List[LiteLLM_VerificationToken] = (
await prisma_client.db.litellm_verificationtoken.find_many(
where={"token": {"in": tokens}}
)
)
if len(_keys_being_deleted) == 0:
@@ -3425,9 +3452,9 @@ async def _rotate_master_key( # noqa: PLR0915
from litellm.proxy.proxy_server import proxy_config
try:
models: Optional[
List
] = await prisma_client.db.litellm_proxymodeltable.find_many()
models: Optional[List] = (
await prisma_client.db.litellm_proxymodeltable.find_many()
)
except Exception:
models = None
# 2. process model table
@@ -4067,11 +4094,11 @@ async def validate_key_list_check(
param="user_id",
code=status.HTTP_403_FORBIDDEN,
)
complete_user_info_db_obj: Optional[
BaseModel
] = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_api_key_dict.user_id},
include={"organization_memberships": True},
complete_user_info_db_obj: Optional[BaseModel] = (
await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_api_key_dict.user_id},
include={"organization_memberships": True},
)
)
if complete_user_info_db_obj is None:
@@ -4154,10 +4181,10 @@ async def _fetch_user_team_objects(
if complete_user_info is None or not complete_user_info.teams:
return []
teams: Optional[
List[BaseModel]
] = await prisma_client.db.litellm_teamtable.find_many(
where={"team_id": {"in": complete_user_info.teams}}
teams: Optional[List[BaseModel]] = (
await prisma_client.db.litellm_teamtable.find_many(
where={"team_id": {"in": complete_user_info.teams}}
)
)
if teams is None:
return []
@@ -978,6 +978,19 @@ async def new_team( # noqa: PLR0915
budget_duration=complete_team_data.budget_duration,
)
# If budget_limits is set, initialize reset_at for each window
if complete_team_data.budget_limits:
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
initialized_windows = []
for window in complete_team_data.budget_limits:
w = window if isinstance(window, dict) else window.model_dump()
w["reset_at"] = get_budget_reset_time(
budget_duration=w["budget_duration"]
).isoformat()
initialized_windows.append(w)
complete_team_data.budget_limits = initialized_windows
## Add Team Member Budget Table
members_with_roles: List[Member] = []
if complete_team_data.members_with_roles is not None:
@@ -1593,6 +1606,18 @@ def _set_budget_reset_at(data: UpdateTeamRequest, updated_kv: dict) -> None:
reset_at = get_budget_reset_time(budget_duration=data.budget_duration)
updated_kv["budget_reset_at"] = reset_at
if data.budget_limits is not None and len(data.budget_limits) > 0:
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
initialized_windows = []
for window in data.budget_limits:
w = window if isinstance(window, dict) else window.model_dump()
w["reset_at"] = get_budget_reset_time(
budget_duration=w["budget_duration"]
).isoformat()
initialized_windows.append(w)
updated_kv["budget_limits"] = json.dumps(initialized_windows)
async def handle_update_object_permission(
data_json: dict, existing_team_row: LiteLLM_TeamTable
+40
View File
@@ -1781,6 +1781,26 @@ async def increment_spend_counters(
increment=response_cost,
)
# Increment per-window budget counters for multi-budget keys
key_obj = await user_api_key_cache.async_get_cache(key=hashed_token)
if key_obj is not None:
key_budget_limits = getattr(key_obj, "budget_limits", None) or (
key_obj.get("budget_limits") if isinstance(key_obj, dict) else None
)
if isinstance(key_budget_limits, str):
key_budget_limits = json.loads(key_budget_limits)
if isinstance(key_budget_limits, list):
for window in key_budget_limits:
duration = (
window["budget_duration"]
if isinstance(window, dict)
else window.budget_duration
)
await spend_counter_cache.async_increment_cache(
key=f"spend:key:{hashed_token}:window:{duration}",
value=response_cost,
)
if team_id is not None:
await _init_and_increment_spend_counter(
counter_key=f"spend:team:{team_id}",
@@ -1788,6 +1808,26 @@ async def increment_spend_counters(
increment=response_cost,
)
# Increment per-window budget counters for multi-budget teams
team_obj = await user_api_key_cache.async_get_cache(key=f"team_id:{team_id}")
if team_obj is not None:
team_budget_limits = getattr(team_obj, "budget_limits", None) or (
team_obj.get("budget_limits") if isinstance(team_obj, dict) else None
)
if isinstance(team_budget_limits, str):
team_budget_limits = json.loads(team_budget_limits)
if isinstance(team_budget_limits, list):
for window in team_budget_limits:
duration = (
window["budget_duration"]
if isinstance(window, dict)
else window.budget_duration
)
await spend_counter_cache.async_increment_cache(
key=f"spend:team:{team_id}:window:{duration}",
value=response_cost,
)
if user_id is not None and team_id is not None:
await _init_and_increment_spend_counter(
counter_key=f"spend:team_member:{user_id}:{team_id}",
@@ -247,6 +247,49 @@ async def get_mcp_servers():
]
@router.get(
"/public/skill_hub",
tags=["public", "Claude Code Marketplace"],
)
async def public_skill_hub():
"""Return enabled (public) Claude Code skills — no auth required."""
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
_get_prisma_client,
)
from litellm.types.proxy.claude_code_endpoints import ListPluginsResponse, PluginListItem
try:
prisma_client = await _get_prisma_client()
plugins = await prisma_client.db.litellm_claudecodeplugintable.find_many(
where={"enabled": True}
)
items = []
for plugin in plugins:
raw = plugin.manifest_json or {}
manifest = json.loads(raw) if isinstance(raw, str) else raw
items.append(
PluginListItem(
id=plugin.id,
name=plugin.name,
enabled=plugin.enabled,
created_at=str(plugin.created_at) if plugin.created_at else None,
updated_at=str(plugin.updated_at) if plugin.updated_at else None,
source=manifest.get("source", {}),
description=manifest.get("description"),
version=manifest.get("version"),
category=manifest.get("category"),
keywords=manifest.get("keywords"),
author=manifest.get("author"),
homepage=manifest.get("homepage"),
domain=manifest.get("domain"),
namespace=manifest.get("namespace"),
)
)
return ListPluginsResponse(plugins=items, count=len(items))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/public/model_hub/info",
tags=["public", "model management"],
+2
View File
@@ -141,6 +141,7 @@ model LiteLLM_TeamTable {
team_member_permissions String[] @default([])
access_group_ids String[] @default([])
policies String[] @default([])
budget_limits Json? // multiple concurrent budget windows [{budget_duration, max_budget, reset_at}]
default_team_member_models String[] @default([]) // default allowed_models for newly added team members; empty = no per-member restriction
model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases
allow_team_guardrail_config Boolean @default(false) // if true, team admin can configure guardrails for this team
@@ -402,6 +403,7 @@ model LiteLLM_VerificationToken {
rotation_interval String? // How often to rotate (e.g., "30d", "90d")
last_rotation_at DateTime? // When this key was last rotated
key_rotation_at DateTime? // When this key should next be rotated
budget_limits Json? // multiple concurrent budget windows [{budget_duration, max_budget, reset_at}]
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id])
litellm_project_table LiteLLM_ProjectTable? @relation(fields: [project_id], references: [project_id])
+11 -11
View File
@@ -1898,9 +1898,9 @@ class ProxyLogging:
normalized_call_type = CallTypes.aembedding.value
if normalized_call_type is not None:
litellm_logging_obj.call_type = normalized_call_type
litellm_logging_obj.model_call_details[
"call_type"
] = normalized_call_type
litellm_logging_obj.model_call_details["call_type"] = (
normalized_call_type
)
# Pass-through endpoints are logged via the callback loop's
# async_post_call_failure_hook — skip pre_call and failure handlers.
if litellm_logging_obj.call_type == CallTypes.pass_through.value:
@@ -2524,8 +2524,7 @@ class PrismaClient:
required_view = "LiteLLM_VerificationTokenView"
expected_views_str = ", ".join(f"'{view}'" for view in expected_views)
pg_schema = os.getenv("DATABASE_SCHEMA", "public")
ret = await self.db.query_raw(
f"""
ret = await self.db.query_raw(f"""
WITH existing_views AS (
SELECT viewname
FROM pg_views
@@ -2537,8 +2536,7 @@ class PrismaClient:
(SELECT COUNT(*) FROM existing_views) AS view_count,
ARRAY_AGG(viewname) AS view_names
FROM existing_views
"""
)
""")
expected_total_views = len(expected_views)
if ret[0]["view_count"] == expected_total_views:
verbose_proxy_logger.info("All necessary views exist!")
@@ -2547,8 +2545,7 @@ class PrismaClient:
## check if required view exists ##
if ret[0]["view_names"] and required_view not in ret[0]["view_names"]:
await self.health_check() # make sure we can connect to db
await self.db.execute_raw(
"""
await self.db.execute_raw("""
CREATE VIEW "LiteLLM_VerificationTokenView" AS
SELECT
v.*,
@@ -2558,8 +2555,7 @@ class PrismaClient:
t.rpm_limit AS team_rpm_limit
FROM "LiteLLM_VerificationToken" v
LEFT JOIN "LiteLLM_TeamTable" t ON v.team_id = t.team_id;
"""
)
""")
verbose_proxy_logger.info(
"LiteLLM_VerificationTokenView Created in DB!"
@@ -3142,6 +3138,10 @@ class PrismaClient:
hashed_token = self.hash_token(token=token)
db_data = self.jsonify_object(data=data)
db_data["token"] = hashed_token
# Prisma rejects nullable JSON fields set to None (no default).
# Strip them so the DB stores NULL via the column's nullable constraint.
if db_data.get("budget_limits") is None:
db_data.pop("budget_limits", None)
print_verbose(
"PrismaClient: Before upsert into litellm_verificationtoken"
)
@@ -49,6 +49,8 @@ class RegisterPluginRequest(BaseModel):
homepage: Optional[str] = Field(None, description="Plugin homepage URL")
keywords: Optional[List[str]] = Field(None, description="Search keywords")
category: Optional[str] = Field(None, description="Plugin category")
domain: Optional[str] = Field(None, description="Skill domain (e.g., 'Productivity')")
namespace: Optional[str] = Field(None, description="Skill namespace within domain (e.g., 'workflows')")
class PluginResponse(BaseModel):
@@ -82,6 +84,8 @@ class PluginListItem(BaseModel):
homepage: Optional[str] = None
keywords: Optional[List[str]] = None
category: Optional[str] = None
domain: Optional[str] = None
namespace: Optional[str] = None
enabled: bool
created_at: Optional[str]
updated_at: Optional[str]
@@ -0,0 +1,137 @@
"""
Unit tests for multi-budget-window enforcement on API keys.
"""
from unittest.mock import AsyncMock, patch
import pytest
import litellm
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.auth_checks import _virtual_key_multi_budget_check
def _make_valid_token(**kwargs) -> UserAPIKeyAuth:
defaults = dict(
token="sk-test-token",
key_name="test",
spend=0.0,
max_budget=None,
budget_limits=[],
)
defaults.update(kwargs)
return UserAPIKeyAuth(**defaults)
@pytest.mark.asyncio
async def test_no_budget_limits_passes():
"""Keys with empty budget_limits should pass without raising."""
token = _make_valid_token(budget_limits=[])
# Should not raise
await _virtual_key_multi_budget_check(valid_token=token)
@pytest.mark.asyncio
async def test_under_budget_passes():
"""Key with spend under all windows should pass."""
token = _make_valid_token(
budget_limits=[
{"budget_duration": "24h", "max_budget": 10.0, "reset_at": None},
{"budget_duration": "30d", "max_budget": 100.0, "reset_at": None},
]
)
with patch(
"litellm.proxy.proxy_server.get_current_spend",
new_callable=AsyncMock,
return_value=1.0, # well under both windows
):
await _virtual_key_multi_budget_check(valid_token=token)
@pytest.mark.asyncio
async def test_over_first_window_raises():
"""Key exceeding the first (daily) window should raise BudgetExceededError."""
token = _make_valid_token(
budget_limits=[
{"budget_duration": "24h", "max_budget": 5.0, "reset_at": None},
{"budget_duration": "30d", "max_budget": 100.0, "reset_at": None},
]
)
spend_by_window = [6.0, 6.0] # over daily, under monthly
call_count = 0
async def fake_get_spend(counter_key, fallback_spend):
nonlocal call_count
val = spend_by_window[call_count]
call_count += 1
return val
with patch(
"litellm.proxy.proxy_server.get_current_spend", side_effect=fake_get_spend
):
with pytest.raises(litellm.BudgetExceededError) as exc_info:
await _virtual_key_multi_budget_check(valid_token=token)
err = exc_info.value
assert err.status_code == 429
assert "24h" in str(err)
assert "Key over" in str(err)
@pytest.mark.asyncio
async def test_over_second_window_raises():
"""Key exceeding only the monthly window should raise BudgetExceededError referencing 30d."""
token = _make_valid_token(
budget_limits=[
{"budget_duration": "24h", "max_budget": 50.0, "reset_at": None},
{"budget_duration": "30d", "max_budget": 5.0, "reset_at": None},
]
)
spend_by_window = [1.0, 10.0] # under daily, over monthly
call_count = 0
async def fake_get_spend(counter_key, fallback_spend):
nonlocal call_count
val = spend_by_window[call_count]
call_count += 1
return val
with patch(
"litellm.proxy.proxy_server.get_current_spend", side_effect=fake_get_spend
):
with pytest.raises(litellm.BudgetExceededError) as exc_info:
await _virtual_key_multi_budget_check(valid_token=token)
err = exc_info.value
assert err.status_code == 429
assert "30d" in str(err)
@pytest.mark.asyncio
async def test_budget_limit_entry_objects_coerced():
"""BudgetLimitEntry Pydantic objects (not dicts) must be handled without KeyError.
While budget_limits is normally serialized as List[dict], the auth check must
tolerate BudgetLimitEntry objects in case they arrive without prior serialization.
"""
from litellm.proxy._types import BudgetLimitEntry
token = _make_valid_token(budget_limits=[])
# Bypass Pydantic validation to simulate BudgetLimitEntry objects reaching the check
object.__setattr__(
token,
"budget_limits",
[BudgetLimitEntry(budget_duration="24h", max_budget=10.0)],
)
with patch(
"litellm.proxy.proxy_server.get_current_spend",
new_callable=AsyncMock,
return_value=1.0,
):
# Should not raise TypeError / KeyError — model_dump() coerces the object
await _virtual_key_multi_budget_check(valid_token=token)
+5 -77
View File
@@ -91,7 +91,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -1773,7 +1772,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -1784,7 +1782,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -1794,14 +1791,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -1969,7 +1964,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -1983,7 +1977,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -1993,7 +1986,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -2317,7 +2309,7 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz",
"integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.1"
@@ -3422,14 +3414,12 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.2.48",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz",
"integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -3471,7 +3461,6 @@
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
@@ -4332,14 +4321,12 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@@ -4353,7 +4340,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -4366,7 +4352,6 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
@@ -4726,7 +4711,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -4752,7 +4736,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -4868,7 +4851,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -4992,7 +4974,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@@ -5017,7 +4998,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -5093,7 +5073,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -5154,7 +5133,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@@ -5568,14 +5546,12 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true,
"license": "MIT"
},
"node_modules/doctrine": {
@@ -6489,7 +6465,6 @@
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@@ -6522,7 +6497,6 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -6560,7 +6534,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -6700,7 +6673,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -6851,7 +6823,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
@@ -7349,7 +7320,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@@ -7402,7 +7372,6 @@
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@@ -7463,7 +7432,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -7509,7 +7477,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -7558,7 +7525,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -7835,7 +7801,6 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -8121,7 +8086,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -8134,7 +8098,6 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
@@ -8588,7 +8551,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -9161,7 +9123,6 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -9175,7 +9136,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -9290,7 +9250,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
@@ -9502,7 +9461,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -9521,7 +9479,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -9866,7 +9823,6 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
@@ -9913,7 +9869,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -9926,7 +9881,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -9936,7 +9890,6 @@
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -9946,7 +9899,7 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.1"
@@ -9965,7 +9918,7 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@@ -9988,7 +9941,6 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -10017,7 +9969,6 @@
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
@@ -10035,7 +9986,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -10061,7 +10011,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -10104,7 +10053,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -10130,7 +10078,6 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -10144,7 +10091,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/prelude-ls": {
@@ -10251,7 +10197,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
@@ -11040,7 +10985,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
@@ -11050,7 +10994,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@@ -11063,7 +11006,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -11361,7 +11303,6 @@
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
@@ -11402,7 +11343,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@@ -11458,7 +11398,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -12099,7 +12038,6 @@
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
@@ -12135,7 +12073,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -12171,7 +12108,6 @@
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -12209,7 +12145,6 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@@ -12226,7 +12161,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -12254,7 +12188,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
@@ -12264,7 +12197,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
@@ -12306,7 +12238,6 @@
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -12373,7 +12304,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -12461,7 +12391,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tsconfig-paths": {
@@ -12578,7 +12507,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -12780,7 +12709,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
@@ -13234,7 +13162,7 @@
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -0,0 +1,17 @@
"use client";
import ClaudeCodePluginsPanel from "@/components/claude_code_plugins";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const SkillsPage = () => {
const { accessToken, userRole } = useAuthorized();
return (
<ClaudeCodePluginsPanel
accessToken={accessToken}
userRole={userRole}
/>
);
};
export default SkillsPage;
+1 -1
View File
@@ -624,7 +624,7 @@ function CreateKeyPageContent() {
<SearchTools accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tag-management" ? (
<TagManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "claude-code-plugins" ? (
) : page == "skills" || page == "claude-code-plugins" ? (
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
) : page == "access-groups" ? (
<AccessGroupsPage />
@@ -5,7 +5,10 @@ import MakeModelPublicForm from "@/components/AIHub/forms/MakeModelPublicForm";
import { mcpHubColumns, MCPServerData } from "@/components/mcp_hub_table_columns";
import { modelHubColumns } from "@/components/model_hub_table_columns";
import UsefulLinksManagement from "@/components/AIHub/UsefulLinksManagement";
import ClaudeCodeMarketplaceTab from "@/components/AIHub/ClaudeCodeMarketplaceTab";
import { getClaudeCodePluginsList } from "@/components/networking";
import { Plugin } from "@/components/claude_code_plugins/types";
import SkillHubDashboard from "@/components/AIHub/SkillHubDashboard";
import MakeSkillPublicForm from "@/components/claude_code_plugins/MakeSkillPublicForm";
import { ModelDataTable } from "@/components/model_dashboard/table";
import ModelFilters from "@/components/model_filters";
import NotificationsManager from "@/components/molecules/notifications_manager";
@@ -78,6 +81,10 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
const [selectedMcpServer, setSelectedMcpServer] = useState<null | MCPServerData>(null);
const [isMcpModalVisible, setIsMcpModalVisible] = useState(false);
const [isMakeMcpPublicModalVisible, setIsMakeMcpPublicModalVisible] = useState(false);
// Skill Hub state
const [skillHubData, setSkillHubData] = useState<Plugin[]>([]);
const [skillLoading, setSkillLoading] = useState<boolean>(false);
const [isMakeSkillPublicModalVisible, setIsMakeSkillPublicModalVisible] = useState(false);
const router = useRouter();
const { data: uiSettings, isLoading: isUISettingsLoading } = useUISettings();
@@ -208,6 +215,25 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
}
}, [publicPage, accessToken]);
// Fetch Skill Hub data — all skills for admins, enabled-only for public page
useEffect(() => {
const fetchSkillData = async () => {
if (!accessToken) return;
try {
setSkillLoading(true);
const enabledOnly = publicPage === true;
const response = await getClaudeCodePluginsList(accessToken, enabledOnly);
setSkillHubData(response.plugins);
} catch (error) {
console.error("Error fetching skill hub data", error);
} finally {
setSkillLoading(false);
}
};
fetchSkillData();
}, [accessToken, publicPage]);
const showModal = (model: ModelGroupInfo) => {
setSelectedModel(model);
setIsModalVisible(true);
@@ -406,7 +432,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
<Tab>Model Hub</Tab>
<Tab>Agent Hub</Tab>
<Tab>MCP Hub</Tab>
<Tab>Claude Code Plugin Marketplace</Tab>
<Tab>Skill Hub</Tab>
</TabList>
<TabPanels>
@@ -492,9 +518,26 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
</div>
</TabPanel>
{/* Plugin Marketplace Tab */}
{/* Skill Hub Tab */}
<TabPanel>
<ClaudeCodeMarketplaceTab publicPage={publicPage} />
{publicPage == false && isAdminRole(userRole || "") && (
<div className="flex justify-end mb-4">
<Button onClick={() => setIsMakeSkillPublicModalVisible(true)}>
Select Skills to Make Public
</Button>
</div>
)}
<SkillHubDashboard
skills={skillHubData}
isLoading={skillLoading}
isAdmin={isAdminRole(userRole || "")}
accessToken={accessToken}
publicPage={publicPage}
onPublishSuccess={async () => {
const response = await getClaudeCodePluginsList(accessToken || "", publicPage === true);
setSkillHubData(response.plugins);
}}
/>
</TabPanel>
</TabPanels>
</TabGroup>
@@ -1055,6 +1098,18 @@ if __name__ == "__main__":
mcpHubData={mcpHubData || []}
onSuccess={handleMakeMcpPublicSuccess}
/>
{/* Make Skill Public Form */}
<MakeSkillPublicForm
visible={isMakeSkillPublicModalVisible}
onClose={() => setIsMakeSkillPublicModalVisible(false)}
accessToken={accessToken || ""}
skillsList={skillHubData}
onSuccess={async () => {
const response = await getClaudeCodePluginsList(accessToken || "", publicPage === true);
setSkillHubData(response.plugins);
}}
/>
</div>
);
};
@@ -0,0 +1,139 @@
import React, { useMemo, useState } from "react";
import { Text } from "@tremor/react";
import { SearchOutlined } from "@ant-design/icons";
import { Input, Select } from "antd";
import { Plugin } from "@/components/claude_code_plugins/types";
import { ModelDataTable } from "@/components/model_dashboard/table";
import { skillHubColumns } from "@/components/skill_hub_table_columns";
import SkillDetail from "@/components/claude_code_plugins/skill_detail";
interface SkillHubDashboardProps {
skills: Plugin[];
isLoading: boolean;
isAdmin?: boolean;
accessToken?: string | null;
publicPage?: boolean;
onPublishSuccess?: () => void;
}
const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
skills,
isLoading,
isAdmin,
accessToken,
publicPage = false,
onPublishSuccess,
}) => {
const [search, setSearch] = useState("");
const [domainFilter, setDomainFilter] = useState<string | undefined>(undefined);
const [selectedSkill, setSelectedSkill] = useState<Plugin | null>(null);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
// Derived stats
const totalSkills = skills.length;
const domains = useMemo(() => [...new Set(skills.map((s) => s.domain).filter(Boolean))], [skills]);
const namespaces = useMemo(() => [...new Set(skills.map((s) => s.namespace).filter(Boolean))], [skills]);
// Filtered table data
const filteredSkills = useMemo(() => {
let result = skills;
if (domainFilter) {
result = result.filter((s) => (s.domain || "General") === domainFilter);
}
if (search.trim()) {
const q = search.toLowerCase();
result = result.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.description?.toLowerCase().includes(q) ||
s.domain?.toLowerCase().includes(q) ||
s.namespace?.toLowerCase().includes(q) ||
s.keywords?.some((k) => k.toLowerCase().includes(q))
);
}
return result;
}, [skills, search, domainFilter]);
if (selectedSkill) {
return (
<SkillDetail
skill={selectedSkill}
onBack={() => setSelectedSkill(null)}
isAdmin={isAdmin}
accessToken={accessToken}
onPublishClick={onPublishSuccess}
/>
);
}
if (isLoading) {
return <div className="text-center py-16 text-gray-400">Loading skills...</div>;
}
return (
<div className="space-y-6">
{/* Stats row */}
<div className="grid grid-cols-3 gap-4">
<div className="border border-gray-200 rounded-lg p-4">
<div className="text-xs text-gray-500 mb-1">Total Skills</div>
<div className="text-2xl font-semibold text-gray-900">{totalSkills}</div>
</div>
<div className="border border-gray-200 rounded-lg p-4">
<div className="text-xs text-gray-500 mb-1">Namespaces</div>
<div className="text-2xl font-semibold text-gray-900">{namespaces.length}</div>
</div>
<div className="border border-gray-200 rounded-lg p-4">
<div className="text-xs text-gray-500 mb-1">Domains</div>
<div className="text-2xl font-semibold text-gray-900">{domains.length}</div>
</div>
</div>
{/* Search + filters + table */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700">
All {publicPage ? "Public " : ""}Skills
</h3>
<div className="flex items-center gap-2">
<Select
placeholder="All Domains"
allowClear
value={domainFilter}
onChange={(val) => setDomainFilter(val)}
style={{ width: 160 }}
options={domains.map((d) => ({ label: d, value: d }))}
/>
<Input
prefix={<SearchOutlined className="text-gray-400" />}
placeholder="Search by name, namespace, or tag…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ width: 280 }}
allowClear
/>
</div>
</div>
<ModelDataTable
columns={skillHubColumns(
(skill) => setSelectedSkill(skill),
copyToClipboard,
publicPage
)}
data={filteredSkills}
isLoading={false}
defaultSorting={[{ id: "name", desc: false }]}
/>
<div className="mt-3 text-center">
<Text className="text-sm text-gray-500">
Showing {filteredSkills.length} of {totalSkills} skill{totalSkills !== 1 ? "s" : ""}
</Text>
</div>
</div>
</div>
);
};
export default SkillHubDashboard;
@@ -7,8 +7,8 @@ import {
} from "./networking";
import AddPluginForm from "./claude_code_plugins/add_plugin_form";
import PluginTable from "./claude_code_plugins/plugin_table";
import SkillDetail from "./claude_code_plugins/skill_detail";
import { isAdminRole } from "@/utils/roles";
import PluginInfoView from "./claude_code_plugins/plugin_info";
import NotificationsManager from "./molecules/notifications_manager";
import { Plugin, ListPluginsResponse } from "./claude_code_plugins/types";
@@ -29,27 +29,22 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({
name: string;
displayName: string;
} | null>(null);
const [selectedPluginId, setSelectedPluginId] = useState<string | null>(
null
);
const [selectedSkill, setSelectedSkill] = useState<Plugin | null>(null);
const isAdmin = userRole ? isAdminRole(userRole) : false;
const fetchPlugins = async () => {
if (!accessToken) {
return;
}
if (!accessToken) return;
setIsLoading(true);
try {
const response: ListPluginsResponse = await getClaudeCodePluginsList(
accessToken,
false // Get all plugins (enabled and disabled)
false
);
console.log(`Claude Code plugins: ${JSON.stringify(response)}`);
setPluginsList(response.plugins);
} catch (error) {
console.error("Error fetching Claude Code plugins:", error);
console.error("Error fetching skills:", error);
} finally {
setIsLoading(false);
}
@@ -59,21 +54,6 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({
fetchPlugins();
}, [accessToken]);
const handleAddPlugin = () => {
if (selectedPluginId) {
setSelectedPluginId(null);
}
setIsAddModalVisible(true);
};
const handleCloseModal = () => {
setIsAddModalVisible(false);
};
const handleSuccess = () => {
fetchPlugins();
};
const handleDeleteClick = (pluginName: string, displayName: string) => {
setPluginToDelete({ name: pluginName, displayName });
};
@@ -84,79 +64,76 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({
setIsDeleting(true);
try {
await deleteClaudeCodePlugin(accessToken, pluginToDelete.name);
NotificationsManager.success(
`Plugin "${pluginToDelete.displayName}" deleted successfully`
);
NotificationsManager.success(`Skill "${pluginToDelete.displayName}" deleted successfully`);
fetchPlugins();
} catch (error) {
console.error("Error deleting plugin:", error);
NotificationsManager.error("Failed to delete plugin");
console.error("Error deleting skill:", error);
NotificationsManager.error("Failed to delete skill");
} finally {
setIsDeleting(false);
setPluginToDelete(null);
}
};
const handleDeleteCancel = () => {
setPluginToDelete(null);
};
return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
<div className="flex flex-col gap-2 mb-4">
<h1 className="text-2xl font-bold">Claude Code Plugins</h1>
<p className="text-sm text-gray-600">
Manage Claude Code marketplace plugins. Add, enable, disable, or
delete plugins that will be available in your marketplace catalog.
Enabled plugins will appear in the public marketplace at{" "}
<code className="bg-gray-100 px-1 rounded">/claude-code/marketplace.json</code>.
</p>
<div className="mt-2">
<Button onClick={handleAddPlugin} disabled={!accessToken || !isAdmin}>
+ Add New Plugin
</Button>
</div>
</div>
{selectedPluginId ? (
<PluginInfoView
pluginId={selectedPluginId}
onClose={() => setSelectedPluginId(null)}
accessToken={accessToken}
{selectedSkill ? (
<SkillDetail
skill={selectedSkill}
onBack={() => setSelectedSkill(null)}
isAdmin={isAdmin}
onPluginUpdated={fetchPlugins}
accessToken={accessToken}
onPublishClick={fetchPlugins}
/>
) : (
<PluginTable
pluginsList={pluginsList}
isLoading={isLoading}
onDeleteClick={handleDeleteClick}
accessToken={accessToken}
onPluginUpdated={fetchPlugins}
isAdmin={isAdmin}
onPluginClick={(id) => setSelectedPluginId(id)}
/>
<>
<div className="flex flex-col gap-2 mb-4">
<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">/claude-code/marketplace.json</code>.
</p>
<div className="mt-2 flex gap-2">
<Button onClick={() => setIsAddModalVisible(true)} disabled={!accessToken || !isAdmin}>
+ Add Skill
</Button>
</div>
</div>
<PluginTable
pluginsList={pluginsList}
isLoading={isLoading}
onDeleteClick={handleDeleteClick}
accessToken={accessToken}
isAdmin={isAdmin}
onPluginClick={(id) => {
const skill = pluginsList.find((p) => p.id === id);
if (skill) setSelectedSkill(skill);
}}
/>
</>
)}
<AddPluginForm
visible={isAddModalVisible}
onClose={handleCloseModal}
onClose={() => setIsAddModalVisible(false)}
accessToken={accessToken}
onSuccess={handleSuccess}
onSuccess={fetchPlugins}
/>
{pluginToDelete && (
<Modal
title="Delete Plugin"
title="Delete Skill"
open={pluginToDelete !== null}
onOk={handleDeleteConfirm}
onCancel={handleDeleteCancel}
onCancel={() => setPluginToDelete(null)}
confirmLoading={isDeleting}
okText="Delete"
okButtonProps={{ danger: true }}
>
<p>
Are you sure you want to delete plugin:{" "}
Are you sure you want to delete skill:{" "}
<strong>{pluginToDelete.displayName}</strong>?
</p>
<p>This action cannot be undone.</p>
@@ -0,0 +1,249 @@
import React, { useState, useEffect } from "react";
import { Modal, Form, Steps, Button, Checkbox } from "antd";
import { Text, Title, Badge } from "@tremor/react";
import { enableClaudeCodePlugin, disableClaudeCodePlugin } from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import { Plugin } from "./types";
const { Step } = Steps;
interface MakeSkillPublicFormProps {
visible: boolean;
onClose: () => void;
accessToken: string;
skillsList: Plugin[];
onSuccess: () => void;
}
const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
visible,
onClose,
accessToken,
skillsList,
onSuccess,
}) => {
const [currentStep, setCurrentStep] = useState(0);
const [selectedSkills, setSelectedSkills] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const handleClose = () => {
setCurrentStep(0);
setSelectedSkills(new Set());
form.resetFields();
onClose();
};
const handleNext = () => {
if (selectedSkills.size === 0) {
NotificationsManager.fromBackend("Please select at least one skill");
return;
}
setCurrentStep(1);
};
const handleSkillSelection = (name: string, checked: boolean) => {
const next = new Set(selectedSkills);
if (checked) {
next.add(name);
} else {
next.delete(name);
}
setSelectedSkills(next);
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedSkills(new Set(skillsList.map((s) => s.name)));
} else {
setSelectedSkills(new Set());
}
};
// Pre-check already-published skills when modal opens
useEffect(() => {
if (visible && skillsList.length > 0) {
setSelectedSkills(new Set(skillsList.filter((s) => s.enabled).map((s) => s.name)));
}
}, [visible, skillsList]);
const handleSubmit = async () => {
if (selectedSkills.size === 0) {
NotificationsManager.fromBackend("Please select at least one skill");
return;
}
setLoading(true);
try {
const selectedSet = selectedSkills;
await Promise.all(
skillsList.map((skill) => {
const shouldBePublic = selectedSet.has(skill.name);
if (shouldBePublic && !skill.enabled) {
return enableClaudeCodePlugin(accessToken, skill.name);
}
if (!shouldBePublic && skill.enabled) {
return disableClaudeCodePlugin(accessToken, skill.name);
}
return Promise.resolve();
})
);
NotificationsManager.success(`Skill Hub updated — ${selectedSkills.size} skill(s) published`);
handleClose();
onSuccess();
} catch (error) {
console.error("Error publishing skills:", error);
NotificationsManager.fromBackend("Failed to update skills. Please try again.");
} finally {
setLoading(false);
}
};
const allSelected =
skillsList.length > 0 && skillsList.every((s) => selectedSkills.has(s.name));
const isIndeterminate = selectedSkills.size > 0 && !allSelected;
const renderStep1 = () => (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Title>Select Skills to Publish</Title>
<Checkbox
checked={allSelected}
indeterminate={isIndeterminate}
onChange={(e) => handleSelectAll(e.target.checked)}
disabled={skillsList.length === 0}
>
Select All ({skillsList.length})
</Checkbox>
</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.
</Text>
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
<div className="space-y-3">
{skillsList.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Text>No skills registered yet.</Text>
</div>
) : (
skillsList.map((skill) => (
<div
key={skill.name}
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-gray-50"
>
<Checkbox
checked={selectedSkills.has(skill.name)}
onChange={(e) => handleSkillSelection(skill.name, e.target.checked)}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<Text className="font-medium font-mono text-sm">{skill.name}</Text>
{skill.enabled && (
<Badge color="green" size="xs">Public</Badge>
)}
</div>
{skill.description && (
<Text className="text-xs text-gray-500 truncate max-w-sm">
{skill.description}
</Text>
)}
</div>
{skill.domain && (
<Badge color="blue" size="xs">{skill.domain}</Badge>
)}
</div>
))
)}
</div>
</div>
{selectedSkills.size > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
<strong>{selectedSkills.size}</strong> skill{selectedSkills.size !== 1 ? "s" : ""} will be published
</Text>
</div>
)}
</div>
);
const renderStep2 = () => (
<div className="space-y-4">
<Title>Confirm Publish to Skill Hub</Title>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<Text className="text-sm text-yellow-800">
<strong>Note:</strong> Published skills will be visible to all users in the Skill Hub tab.
Skills not in the list below will be unpublished.
</Text>
</div>
<div className="space-y-3">
<Text className="font-medium">Skills to be published:</Text>
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
<div className="space-y-2">
{Array.from(selectedSkills).map((name) => {
const skill = skillsList.find((s) => s.name === name);
return (
<div key={name} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<Text className="font-mono text-sm">{name}</Text>
{skill?.domain && <Badge color="blue" size="xs">{skill.domain}</Badge>}
</div>
);
})}
</div>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
Total: <strong>{selectedSkills.size}</strong> skill{selectedSkills.size !== 1 ? "s" : ""} will be published
</Text>
</div>
</div>
);
return (
<Modal
title="Publish to Skill Hub"
open={visible}
onCancel={handleClose}
footer={null}
width={700}
maskClosable={false}
>
<Form form={form} layout="vertical">
<Steps current={currentStep} className="mb-6">
<Step title="Select Skills" />
<Step title="Confirm" />
</Steps>
{currentStep === 0 ? renderStep1() : renderStep2()}
<div className="flex justify-between mt-6">
<Button onClick={currentStep === 0 ? handleClose : () => setCurrentStep(0)}>
{currentStep === 0 ? "Cancel" : "Previous"}
</Button>
<div className="flex space-x-2">
{currentStep === 0 && (
<Button onClick={handleNext} disabled={selectedSkills.size === 0}>
Next
</Button>
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} loading={loading}>
Publish to Hub
</Button>
)}
</div>
</div>
</Form>
</Modal>
);
};
export default MakeSkillPublicForm;
@@ -32,6 +32,80 @@ const PREDEFINED_CATEGORIES = [
"Documentation",
];
interface ParsedSource {
source: "github" | "url" | "git-subdir";
repo?: string;
url?: string;
path?: string;
}
interface ParsePreview {
parsed: ParsedSource;
label: string;
suggestedName: string;
}
function parseGitHubUrl(raw: string): ParsePreview | null {
// Strip protocol and trailing slashes/spaces
let s = raw.trim().replace(/^https?:\/\//, "").replace(/\/+$/, "");
if (!s.startsWith("github.com/")) return null;
// Remove "github.com/"
const rest = s.slice("github.com/".length);
const parts = rest.split("/");
if (parts.length < 2) return null;
const org = parts[0];
const repo = parts[1];
const repoBase = repo.replace(/\.git$/, "");
// github.com/org/repo (exactly 2 parts, or ends with .git)
if (parts.length === 2 || (parts.length === 2 && repoBase)) {
return {
parsed: { source: "github", repo: `${org}/${repoBase}` },
label: `GitHub repo — ${org}/${repoBase}`,
suggestedName: repoBase,
};
}
// github.com/org/repo/tree/branch/folder or /blob/branch/folder/FILE.md
if (
parts.length >= 5 &&
(parts[2] === "tree" || parts[2] === "blob")
) {
// parts[3] = branch, parts[4..] = path segments
const pathParts = parts.slice(4);
// If last segment looks like a file (has extension), drop it
const lastPart = pathParts[pathParts.length - 1];
if (lastPart && lastPart.includes(".")) {
pathParts.pop();
}
if (pathParts.length === 0) {
// Path resolved to repo root — treat as plain github source
return {
parsed: { source: "github", repo: `${org}/${repoBase}` },
label: `GitHub repo — ${org}/${repoBase}`,
suggestedName: repoBase,
};
}
const subPath = pathParts.join("/");
const suggestedName = pathParts[pathParts.length - 1];
return {
parsed: {
source: "git-subdir",
url: `https://github.com/${org}/${repoBase}`,
path: subPath,
},
label: `GitHub subdir — ${org}/${repoBase} @ ${subPath}`,
suggestedName,
};
}
return null;
}
const AddPluginForm: React.FC<AddPluginFormProps> = ({
visible,
onClose,
@@ -40,7 +114,20 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({
}) => {
const [form] = Form.useForm();
const [isSubmitting, setIsSubmitting] = useState(false);
const [sourceType, setSourceType] = useState<"github" | "url" | "git-subdir">("github");
const [urlPreview, setUrlPreview] = useState<ParsePreview | null>(null);
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
const preview = parseGitHubUrl(val);
setUrlPreview(preview);
if (preview) {
// Auto-fill name only if it's currently empty
const currentName = form.getFieldValue("name");
if (!currentName) {
form.setFieldsValue({ name: preview.suggestedName });
}
}
};
const handleSubmit = async (values: any) => {
if (!accessToken) {
@@ -48,98 +135,62 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({
return;
}
// Validate plugin name
if (!urlPreview) {
MessageManager.error("Please enter a valid GitHub URL");
return;
}
if (!validatePluginName(values.name)) {
MessageManager.error(
"Plugin name must be kebab-case (lowercase letters, numbers, and hyphens only)"
"Skill name must be kebab-case (lowercase letters, numbers, and hyphens only)"
);
return;
}
// Validate semantic version if provided
if (values.version && !isValidSemanticVersion(values.version)) {
MessageManager.error(
"Version must be in semantic versioning format (e.g., 1.0.0)"
);
MessageManager.error("Version must be in semantic versioning format (e.g., 1.0.0)");
return;
}
// Validate email if provided
if (values.authorEmail && !isValidEmail(values.authorEmail)) {
MessageManager.error("Invalid email format");
return;
}
// Validate homepage URL if provided
if (values.homepage && !isValidUrl(values.homepage)) {
MessageManager.error("Invalid homepage URL format");
return;
}
// Validate git URL for url/git-subdir source types
if ((sourceType === "url" || sourceType === "git-subdir") && values.url && !isValidUrl(values.url)) {
MessageManager.error("Invalid git URL format");
return;
}
setIsSubmitting(true);
try {
// Build plugin data
const pluginData: any = {
name: values.name.trim(),
source:
sourceType === "github"
? {
source: "github",
repo: values.repo.trim(),
}
: sourceType === "git-subdir"
? {
source: "git-subdir",
url: values.url.trim(),
path: values.path.trim(),
}
: {
source: "url",
url: values.url.trim(),
},
source: urlPreview.parsed,
};
// Add optional fields
if (values.version) {
pluginData.version = values.version.trim();
}
if (values.description) {
pluginData.description = values.description.trim();
}
if (values.version) pluginData.version = values.version.trim();
if (values.description) pluginData.description = values.description.trim();
if (values.authorName || values.authorEmail) {
pluginData.author = {};
if (values.authorName) {
pluginData.author.name = values.authorName.trim();
}
if (values.authorEmail) {
pluginData.author.email = values.authorEmail.trim();
}
}
if (values.homepage) {
pluginData.homepage = values.homepage.trim();
}
if (values.category) {
pluginData.category = values.category;
}
if (values.keywords) {
pluginData.keywords = parseKeywords(values.keywords);
if (values.authorName) pluginData.author.name = values.authorName.trim();
if (values.authorEmail) pluginData.author.email = values.authorEmail.trim();
}
if (values.homepage) pluginData.homepage = values.homepage.trim();
if (values.category) pluginData.category = values.category;
if (values.keywords) pluginData.keywords = parseKeywords(values.keywords);
if (values.domain) pluginData.domain = values.domain.trim();
if (values.namespace) pluginData.namespace = values.namespace.trim();
await registerClaudeCodePlugin(accessToken, pluginData);
MessageManager.success("Plugin registered successfully");
MessageManager.success("Skill registered successfully");
form.resetFields();
setSourceType("github");
setUrlPreview(null);
onSuccess();
onClose();
} catch (error) {
console.error("Error registering plugin:", error);
MessageManager.error("Failed to register plugin");
console.error("Error registering skill:", error);
MessageManager.error("Failed to register skill");
} finally {
setIsSubmitting(false);
}
@@ -147,19 +198,13 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({
const handleCancel = () => {
form.resetFields();
setSourceType("github");
setUrlPreview(null);
onClose();
};
const handleSourceTypeChange = (value: "github" | "url" | "git-subdir") => {
setSourceType(value);
// Clear repo/url/path fields when switching
form.setFieldsValue({ repo: undefined, url: undefined, path: undefined });
};
return (
<Modal
title="Add New Claude Code Plugin"
title="Add New Skill"
open={visible}
onCancel={handleCancel}
footer={null}
@@ -172,111 +217,72 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({
onFinish={handleSubmit}
className="mt-4"
>
{/* Plugin Name */}
{/* Smart URL Input */}
<Form.Item
label="Plugin Name"
label="GitHub URL"
name="skillUrl"
rules={[{ required: true, message: "Please enter a GitHub URL" }]}
tooltip="Paste a GitHub URL — repo, folder, or file link. E.g. github.com/org/repo or github.com/org/repo/tree/main/my-skill"
>
<Input
placeholder="https://github.com/org/repo/tree/main/my-skill"
className="rounded-lg"
onChange={handleUrlChange}
/>
</Form.Item>
{/* Parsed preview */}
{urlPreview && (
<div className="mb-4 px-3 py-2 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">
Detected: {urlPreview.label}
</div>
)}
{/* Skill Name */}
<Form.Item
label="Skill Name"
name="name"
rules={[
{ required: true, message: "Please enter plugin name" },
{ required: true, message: "Please enter skill name" },
{
pattern: /^[a-z0-9-]+$/,
message:
"Name must be kebab-case (lowercase, numbers, hyphens only)",
message: "Name must be kebab-case (lowercase, numbers, hyphens only)",
},
]}
tooltip="Unique identifier in kebab-case format (e.g., my-awesome-plugin)"
tooltip="Unique identifier in kebab-case format (e.g., my-skill)"
>
<Input placeholder="my-awesome-plugin" className="rounded-lg" />
<Input placeholder="my-skill" className="rounded-lg" />
</Form.Item>
{/* Source Type */}
<Form.Item
label="Source Type"
name="sourceType"
initialValue="github"
rules={[{ required: true, message: "Please select source type" }]}
>
<Select onChange={handleSourceTypeChange} className="rounded-lg">
<Option value="github">GitHub</Option>
<Option value="url">Git URL</Option>
<Option value="git-subdir">Git Subdir</Option>
</Select>
</Form.Item>
{/* GitHub Repository */}
{sourceType === "github" && (
{/* Domain and Namespace — side by side */}
<div className="flex gap-4">
<Form.Item
label="GitHub Repository"
name="repo"
rules={[
{ required: true, message: "Please enter repository" },
{
pattern: /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/,
message: "Repository must be in format: org/repo",
},
]}
tooltip="Format: organization/repository (e.g., anthropics/claude-code)"
label="Domain (Optional)"
name="domain"
tooltip="Top-level grouping in the Skill Hub (e.g., Productivity)"
className="flex-1"
>
<Input placeholder="anthropics/claude-code" className="rounded-lg" />
<Input placeholder="Productivity" className="rounded-lg" />
</Form.Item>
)}
{/* Git URL */}
{(sourceType === "url" || sourceType === "git-subdir") && (
<Form.Item
label="Git URL"
name="url"
rules={[{ required: true, message: "Please enter git URL" }]}
tooltip="Full git URL to the repository"
label="Namespace (Optional)"
name="namespace"
tooltip="Sub-grouping within domain (e.g., workflows)"
className="flex-1"
>
<Input
type="url"
placeholder="https://github.com/org/repo.git"
className="rounded-lg"
/>
<Input placeholder="workflows" className="rounded-lg" />
</Form.Item>
)}
{/* Git Subdir Path */}
{sourceType === "git-subdir" && (
<Form.Item
label="Subdirectory Path"
name="path"
rules={[
{ required: true, message: "Please enter subdirectory path" },
{
pattern: /^[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0-9._-]*)*$/,
message:
"Path must be relative segments (alphanumeric, dots, hyphens, underscores), e.g. plugins/plugin-name",
},
]}
tooltip="Path to the plugin directory within the repository (e.g., plugins/plugin-name)"
>
<Input
placeholder="plugins/plugin-name"
className="rounded-lg"
/>
</Form.Item>
)}
{/* Version */}
<Form.Item
label="Version (Optional)"
name="version"
tooltip="Semantic version (e.g., 1.0.0)"
>
<Input placeholder="1.0.0" className="rounded-lg" />
</Form.Item>
</div>
{/* Description */}
<Form.Item
label="Description (Optional)"
name="description"
tooltip="Brief description of what the plugin does"
tooltip="Brief description of what the skill does"
>
<TextArea
rows={3}
placeholder="A plugin that helps with..."
placeholder="A skill that helps with..."
maxLength={500}
className="rounded-lg"
/>
@@ -312,11 +318,20 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({
<Input placeholder="search, web, api" className="rounded-lg" />
</Form.Item>
{/* Version */}
<Form.Item
label="Version (Optional)"
name="version"
tooltip="Semantic version (e.g., 1.0.0)"
>
<Input placeholder="1.0.0" className="rounded-lg" />
</Form.Item>
{/* Author Name */}
<Form.Item
label="Author Name (Optional)"
name="authorName"
tooltip="Name of the plugin author or organization"
tooltip="Name of the skill author or organization"
>
<Input placeholder="Your Name or Organization" className="rounded-lg" />
</Form.Item>
@@ -326,33 +341,19 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({
label="Author Email (Optional)"
name="authorEmail"
rules={[{ type: "email", message: "Please enter a valid email" }]}
tooltip="Contact email for the plugin author"
tooltip="Contact email for the skill author"
>
<Input type="email" placeholder="author@example.com" className="rounded-lg" />
</Form.Item>
{/* Homepage */}
<Form.Item
label="Homepage (Optional)"
name="homepage"
rules={[{ type: "url", message: "Please enter a valid URL" }]}
tooltip="URL to the plugin's homepage or documentation"
>
<Input type="url" placeholder="https://example.com" className="rounded-lg" />
</Form.Item>
{/* Submit Buttons */}
<Form.Item className="mb-0 mt-6">
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={handleCancel}
disabled={isSubmitting}
>
<Button variant="secondary" onClick={handleCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? "Registering..." : "Register Plugin"}
{isSubmitting ? "Adding..." : "Add Skill"}
</Button>
</div>
</Form.Item>
@@ -23,13 +23,9 @@ import {
TableHeaderCell,
TableRow,
} from "@tremor/react";
import { Switch, Tooltip } from "antd";
import { Tooltip } from "antd";
import React, { useState } from "react";
import NotificationsManager from "../molecules/notifications_manager";
import {
disableClaudeCodePlugin,
enableClaudeCodePlugin,
} from "../networking";
import {
getCategoryBadgeColor
} from "./helpers";
@@ -40,7 +36,6 @@ interface PluginTableProps {
isLoading: boolean;
onDeleteClick: (pluginName: string, displayName: string) => void;
accessToken: string | null;
onPluginUpdated: () => void;
isAdmin: boolean;
onPluginClick: (pluginId: string) => void;
}
@@ -50,14 +45,12 @@ const PluginTable: React.FC<PluginTableProps> = ({
isLoading,
onDeleteClick,
accessToken,
onPluginUpdated,
isAdmin,
onPluginClick,
}) => {
const [sorting, setSorting] = useState<SortingState>([
{ id: "created_at", desc: true },
]);
const [togglingPlugin, setTogglingPlugin] = useState<string | null>(null);
const formatDate = (dateString?: string) => {
if (!dateString) return "-";
@@ -70,29 +63,9 @@ const PluginTable: React.FC<PluginTableProps> = ({
NotificationsManager.success("Copied to clipboard!");
};
const handleToggleEnabled = async (plugin: Plugin) => {
if (!accessToken) return;
setTogglingPlugin(plugin.id);
try {
if (plugin.enabled) {
await disableClaudeCodePlugin(accessToken, plugin.name);
NotificationsManager.success(`Plugin "${plugin.name}" disabled`);
} else {
await enableClaudeCodePlugin(accessToken, plugin.name);
NotificationsManager.success(`Plugin "${plugin.name}" enabled`);
}
onPluginUpdated();
} catch (error) {
NotificationsManager.error("Failed to toggle plugin status");
} finally {
setTogglingPlugin(null);
}
};
const columns: ColumnDef<Plugin>[] = [
{
header: "Plugin Name",
header: "Skill Name",
accessorKey: "name",
cell: ({ row }) => {
const plugin = row.original;
@@ -165,32 +138,18 @@ const PluginTable: React.FC<PluginTableProps> = ({
},
},
{
header: "Enabled",
header: "Public",
accessorKey: "enabled",
cell: ({ row }) => {
const plugin = row.original;
return (
<div className="flex items-center gap-2">
<Badge
color={plugin.enabled ? "green" : "gray"}
className="text-xs font-normal"
size="xs"
>
{plugin.enabled ? "Yes" : "No"}
</Badge>
{isAdmin && (
<Tooltip
title={plugin.enabled ? "Disable plugin" : "Enable plugin"}
>
<Switch
size="small"
checked={plugin.enabled}
loading={togglingPlugin === plugin.id}
onChange={() => handleToggleEnabled(plugin)}
/>
</Tooltip>
)}
</div>
<Badge
color={plugin.enabled ? "green" : "gray"}
className="text-xs font-normal"
size="xs"
>
{plugin.enabled ? "Yes" : "No"}
</Badge>
);
},
},
@@ -217,7 +176,7 @@ const PluginTable: React.FC<PluginTableProps> = ({
return (
<div className="flex items-center gap-1">
<Tooltip title="Delete plugin">
<Tooltip title="Delete skill">
<Button
size="xs"
variant="light"
@@ -312,7 +271,11 @@ const PluginTable: React.FC<PluginTableProps> = ({
</TableRow>
) : pluginsList && pluginsList.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="h-8">
<TableRow
key={row.id}
className="h-8 cursor-pointer hover:bg-gray-50"
onClick={() => onPluginClick(row.original.id)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
@@ -333,7 +296,7 @@ const PluginTable: React.FC<PluginTableProps> = ({
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No plugins found. Add one to get started.</p>
<p>No skills found. Add one to get started.</p>
</div>
</TableCell>
</TableRow>
@@ -0,0 +1,366 @@
import React, { useState } from "react";
import { ArrowLeftOutlined, CopyOutlined, CheckOutlined, LinkOutlined } from "@ant-design/icons";
import { formatInstallCommand } from "./helpers";
import { Plugin } from "./types";
interface SkillDetailProps {
skill: Plugin;
onBack: () => void;
isAdmin?: boolean;
accessToken?: string | null;
onPublishClick?: () => void;
}
const SkillDetail: React.FC<SkillDetailProps> = ({
skill,
onBack,
}) => {
const [activeTab, setActiveTab] = useState("overview");
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const copyToClipboard = (text: string, key: string) => {
navigator.clipboard.writeText(text);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
};
const sourceUrl = (() => {
const src = skill.source;
if (src.source === "github" && src.repo) return `https://github.com/${src.repo}`;
if (src.source === "git-subdir" && src.url)
return src.path ? `${src.url}/tree/main/${src.path}` : src.url;
if (src.source === "url" && src.url) return src.url;
return null;
})();
const installCommand = formatInstallCommand(skill);
const detailRows = [
...(skill.category ? [{ property: "Category", value: skill.category }] : []),
...(skill.domain ? [{ property: "Domain", value: skill.domain }] : []),
...(skill.namespace ? [{ property: "Namespace", value: skill.namespace }] : []),
...(skill.version ? [{ property: "Version", value: skill.version }] : []),
...(skill.author?.name ? [{ property: "Author", value: skill.author.name }] : []),
...(skill.created_at
? [{ property: "Added", value: new Date(skill.created_at).toLocaleDateString() }]
: []),
];
const tabs = [
{ key: "overview", label: "Overview" },
{ key: "usage", label: "How to Use" },
];
return (
<div style={{ padding: "24px 32px 24px 0" }}>
{/* Back link */}
<div
onClick={onBack}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
color: "#5f6368",
cursor: "pointer",
fontSize: 14,
marginBottom: 24,
}}
>
<ArrowLeftOutlined style={{ fontSize: 11 }} />
<span>Skills</span>
</div>
{/* Header */}
<div style={{ marginBottom: 8 }}>
<h1 style={{ fontSize: 28, fontWeight: 400, color: "#202124", margin: 0, lineHeight: 1.2 }}>
{skill.name}
</h1>
{skill.description && (
<p style={{ fontSize: 14, color: "#5f6368", margin: "8px 0 0 0", lineHeight: 1.6 }}>
{skill.description}
</p>
)}
</div>
{/* Tab bar */}
<div style={{ borderBottom: "1px solid #dadce0", marginBottom: 28, marginTop: 24 }}>
<div style={{ display: "flex", gap: 0 }}>
{tabs.map((tab) => (
<div
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: "12px 20px",
fontSize: 14,
color: activeTab === tab.key ? "#1a73e8" : "#5f6368",
borderBottom: activeTab === tab.key ? "3px solid #1a73e8" : "3px solid transparent",
cursor: "pointer",
fontWeight: activeTab === tab.key ? 500 : 400,
marginBottom: -1,
}}
>
{tab.label}
</div>
))}
</div>
</div>
{/* Overview tab */}
{activeTab === "overview" && (
<div style={{ display: "flex", gap: 64 }}>
{/* Left column */}
<div style={{ flex: 1, minWidth: 0 }}>
<h2 style={{ fontSize: 18, fontWeight: 400, color: "#202124", margin: "0 0 4px 0" }}>
Skill Details
</h2>
<p style={{ fontSize: 13, color: "#5f6368", margin: "0 0 16px 0" }}>
Metadata registered with this skill
</p>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
<thead>
<tr style={{ borderBottom: "1px solid #dadce0" }}>
<th style={{ textAlign: "left", padding: "12px 0", color: "#5f6368", fontWeight: 500, width: 160 }}>
Property
</th>
<th style={{ textAlign: "left", padding: "12px 0", color: "#5f6368", fontWeight: 500 }}>
{skill.name}
</th>
</tr>
</thead>
<tbody>
{detailRows.map((row, i) => (
<tr key={i} style={{ borderBottom: "1px solid #f1f3f4" }}>
<td style={{ padding: "12px 0", color: "#3c4043" }}>{row.property}</td>
<td style={{ padding: "12px 0", color: "#202124" }}>{row.value}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Right sidebar */}
<div style={{ width: 240, flexShrink: 0 }}>
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 12, color: "#5f6368", marginBottom: 4 }}>Status</div>
<span
style={{
fontSize: 12,
padding: "3px 10px",
borderRadius: 12,
backgroundColor: skill.enabled ? "#e6f4ea" : "#f1f3f4",
color: skill.enabled ? "#137333" : "#5f6368",
fontWeight: 500,
}}
>
{skill.enabled ? "Public" : "Draft"}
</span>
</div>
{sourceUrl && (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 12, color: "#5f6368", marginBottom: 4 }}>Source</div>
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 13, color: "#1a73e8", wordBreak: "break-all", display: "flex", alignItems: "center", gap: 4 }}
>
{sourceUrl.replace("https://", "")}
<LinkOutlined style={{ fontSize: 11, flexShrink: 0 }} />
</a>
</div>
)}
{skill.keywords && skill.keywords.length > 0 && (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 12, color: "#5f6368", marginBottom: 8 }}>Tags</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{skill.keywords.map((kw) => (
<span
key={kw}
style={{
fontSize: 12,
padding: "4px 12px",
borderRadius: 16,
border: "1px solid #dadce0",
color: "#3c4043",
backgroundColor: "#fff",
}}
>
{kw}
</span>
))}
</div>
</div>
)}
<div>
<div style={{ fontSize: 12, color: "#5f6368", marginBottom: 4 }}>Skill ID</div>
<div style={{ fontSize: 12, fontFamily: "monospace", color: "#3c4043", wordBreak: "break-all" }}>
{skill.id}
</div>
</div>
</div>
</div>
)}
{/* How to Use tab */}
{activeTab === "usage" && (
<div style={{ maxWidth: 640 }}>
<h2 style={{ fontSize: 18, fontWeight: 400, color: "#202124", margin: "0 0 8px 0" }}>
Using this skill
</h2>
<p style={{ fontSize: 14, color: "#5f6368", margin: "0 0 24px 0", lineHeight: 1.6 }}>
Once your proxy is set as a marketplace, enable this skill in Claude Code with one command:
</p>
{/* Install command */}
<div
style={{
border: "1px solid #dadce0",
borderRadius: 8,
overflow: "hidden",
marginBottom: 24,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 16px",
backgroundColor: "#f8f9fa",
borderBottom: "1px solid #dadce0",
}}
>
<span style={{ fontSize: 13, color: "#3c4043", fontWeight: 500 }}>
Run in Claude Code
</span>
<button
onClick={() => copyToClipboard(installCommand, "install")}
style={{
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 12,
color: copiedKey === "install" ? "#137333" : "#1a73e8",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
}}
>
{copiedKey === "install" ? <CheckOutlined /> : <CopyOutlined />}
{copiedKey === "install" ? "Copied" : "Copy"}
</button>
</div>
<pre
style={{
margin: 0,
padding: "14px 16px",
fontSize: 14,
fontFamily: "monospace",
color: "#202124",
backgroundColor: "#fff",
}}
>
{installCommand}
</pre>
</div>
<p style={{ fontSize: 13, color: "#5f6368", lineHeight: 1.6, margin: 0 }}>
Don&apos;t have the marketplace configured yet?{" "}
<span
onClick={() => setActiveTab("setup")}
style={{ color: "#1a73e8", cursor: "pointer" }}
>
See one-time setup
</span>
</p>
</div>
)}
{/* Setup tab (linked from usage) */}
{activeTab === "setup" && (
<div style={{ maxWidth: 640 }}>
<h2 style={{ fontSize: 18, fontWeight: 400, color: "#202124", margin: "0 0 8px 0" }}>
One-time marketplace setup
</h2>
<p style={{ fontSize: 14, color: "#5f6368", margin: "0 0 24px 0", lineHeight: 1.6 }}>
Add this to <code style={{ fontSize: 13, backgroundColor: "#f1f3f4", padding: "1px 6px", borderRadius: 4 }}>~/.claude/settings.json</code> to point Claude Code at your proxy:
</p>
<div
style={{
border: "1px solid #dadce0",
borderRadius: 8,
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 16px",
backgroundColor: "#f8f9fa",
borderBottom: "1px solid #dadce0",
}}
>
<span style={{ fontSize: 13, color: "#3c4043", fontWeight: 500 }}>
~/.claude/settings.json
</span>
<button
onClick={() => {
const snippet = JSON.stringify(
{ extraKnownMarketplaces: { "my-org": { source: "url", url: `${typeof window !== "undefined" ? window.location.origin : ""}/claude-code/marketplace.json` } } },
null, 2
);
copyToClipboard(snippet, "settings");
}}
style={{
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 12,
color: copiedKey === "settings" ? "#137333" : "#1a73e8",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
}}
>
{copiedKey === "settings" ? <CheckOutlined /> : <CopyOutlined />}
{copiedKey === "settings" ? "Copied" : "Copy"}
</button>
</div>
<pre
style={{
margin: 0,
padding: "14px 16px",
fontSize: 13,
fontFamily: "monospace",
color: "#202124",
backgroundColor: "#fff",
}}
>
{JSON.stringify(
{
extraKnownMarketplaces: {
"my-org": {
source: "url",
url: `${typeof window !== "undefined" ? window.location.origin : "<proxy-url>"}/claude-code/marketplace.json`,
},
},
},
null,
2
)}
</pre>
</div>
</div>
)}
</div>
);
};
export default SkillDetail;
@@ -4,9 +4,10 @@
*/
export interface PluginSource {
source: "github" | "url";
source: "github" | "url" | "git-subdir";
repo?: string; // Format: "org/repo" for GitHub
url?: string; // Full URL for other sources
path?: string; // Subdirectory path for git-subdir
}
export interface PluginAuthor {
@@ -24,6 +25,8 @@ export interface Plugin {
homepage?: string;
keywords?: string[];
category?: string;
domain?: string;
namespace?: string;
enabled: boolean;
created_at?: string;
updated_at?: string;
@@ -40,6 +43,8 @@ export interface PluginListItem {
homepage?: string;
keywords?: string[];
category?: string;
domain?: string;
namespace?: string;
enabled: boolean;
created_at?: string;
updated_at?: string;
@@ -60,6 +65,8 @@ export interface RegisterPluginRequest {
homepage?: string;
keywords?: string[];
category?: string;
domain?: string;
namespace?: string;
}
export interface RegisterPluginResponse {
@@ -100,9 +107,10 @@ export interface CategoryTab {
export interface PluginFormData {
name: string;
sourceType: "github" | "url";
sourceType: "github" | "url" | "git-subdir";
repo: string;
url: string;
path: string;
version: string;
description: string;
authorName: string;
@@ -110,4 +118,6 @@ export interface PluginFormData {
homepage: string;
category: string;
keywords: string; // Comma-separated string, will be split into array
domain: string;
namespace: string;
}
@@ -25,6 +25,7 @@ const BudgetDurationDropdown: React.FC<BudgetDurationDropdownProps> = ({
placeholder="n/a"
allowClear
>
<Option value="1h">hourly</Option>
<Option value="24h">daily</Option>
<Option value="7d">weekly</Option>
<Option value="30d">monthly</Option>
@@ -36,6 +37,7 @@ export const getBudgetDurationLabel = (value: string | null | undefined): string
if (!value) return "Not set";
const budgetDurationMap: Record<string, string> = {
"1h": "hourly",
"24h": "daily",
"7d": "weekly",
"30d": "monthly",
@@ -0,0 +1,84 @@
import { Button, InputNumber, Select } from "antd";
import React from "react";
export interface BudgetWindowEntry {
budget_duration: string;
max_budget: number | null;
}
export const BUDGET_WINDOW_OPTIONS = [
{ value: "1h", label: "Hourly", resetHint: "Resets every hour" },
{ value: "24h", label: "Daily", resetHint: "Resets daily at midnight UTC" },
{ value: "7d", label: "Weekly", resetHint: "Resets every Sunday at midnight UTC" },
{ value: "30d", label: "Monthly", resetHint: "Resets on the 1st of every month at midnight UTC" },
];
interface BudgetWindowsEditorProps {
value: BudgetWindowEntry[];
onChange: (v: BudgetWindowEntry[]) => void;
}
export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProps) {
const addWindow = () => {
onChange([...value, { budget_duration: "24h", max_budget: null }]);
};
const removeWindow = (idx: number) => {
onChange(value.filter((_, i) => i !== idx));
};
const updateWindow = (idx: number, field: keyof BudgetWindowEntry, fieldValue: string | number | null) => {
const updated = value.map((w, i) => (i === idx ? { ...w, [field]: fieldValue } : w));
onChange(updated);
};
return (
<div>
{value.map((window, idx) => {
const hint = BUDGET_WINDOW_OPTIONS.find((o) => o.value === window.budget_duration)?.resetHint;
return (
<div key={idx} style={{ marginBottom: 12 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Select
value={window.budget_duration}
onChange={(v) => updateWindow(idx, "budget_duration", v)}
style={{ width: 130 }}
options={BUDGET_WINDOW_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
/>
<InputNumber
step={0.01}
min={0}
precision={2}
value={window.max_budget ?? undefined}
onChange={(v) => updateWindow(idx, "max_budget", v ?? null)}
placeholder="Max spend ($)"
style={{ width: 160 }}
prefix="$"
/>
<Button
type="text"
danger
size="small"
onClick={() => removeWindow(idx)}
style={{ padding: "0 4px" }}
>
</Button>
</div>
{hint && (
<div style={{ fontSize: 11, color: "#888", marginTop: 3, marginLeft: 2 }}>
{hint}
</div>
)}
</div>
);
})}
<Button
size="small"
onClick={(e) => { e.preventDefault(); addWindow(); }}
>
+ Add Budget Window
</Button>
</div>
);
}
@@ -95,6 +95,7 @@ export interface KeyResponse {
agent_access_groups?: string[];
};
access_group_ids?: string[];
budget_limits?: Array<{ budget_duration: string; max_budget: number; reset_at?: string }>;
auto_rotate?: boolean;
rotation_interval?: string;
last_rotation_at?: string;
@@ -132,6 +132,13 @@ const menuGroups: MenuGroup[] = [
label: "MCP Servers",
icon: <ToolOutlined />,
},
{
key: "skills",
page: "skills",
label: "Skills",
icon: <ApiOutlined />,
roles: all_admin_roles,
},
{
key: "guardrails",
page: "guardrails",
@@ -267,6 +274,7 @@ const menuGroups: MenuGroup[] = [
label: "AI Hub",
icon: <AppstoreOutlined />,
},
{
key: "learning-resources",
page: "learning-resources",
@@ -308,13 +316,6 @@ const menuGroups: MenuGroup[] = [
icon: <TagsOutlined />,
roles: all_admin_roles,
},
{
key: "claude-code-plugins",
page: "claude-code-plugins",
label: "Claude Code Plugins",
icon: <ToolOutlined />,
roles: all_admin_roles,
},
{
key: "4",
page: "usage",
@@ -81,7 +81,9 @@ const isLocal = process.env.NODE_ENV === "development";
// In dev, if NEXT_PUBLIC_USE_REWRITES=true the Next.js dev server proxies API calls
// to the backend — use relative URLs (null) so rewrites can intercept them.
const defaultProxyBaseUrl =
isLocal && process.env.NEXT_PUBLIC_USE_REWRITES !== "true"
process.env.NEXT_PUBLIC_BASE_URL
? process.env.NEXT_PUBLIC_BASE_URL
: isLocal && process.env.NEXT_PUBLIC_USE_REWRITES !== "true"
? "http://localhost:4000"
: null;
const defaultServerRootPath = "/";
@@ -123,10 +125,11 @@ const updateProxyBaseUrl = (serverRootPath: string, receivedProxyBaseUrl: string
return;
}
const browserLocation = getWindowLocation();
const resolvedDefaultProxyBaseUrl =
isLocal && process.env.NEXT_PUBLIC_USE_REWRITES !== "true"
? "http://localhost:4000"
: browserLocation?.origin ?? null;
const resolvedDefaultProxyBaseUrl = process.env.NEXT_PUBLIC_BASE_URL
? process.env.NEXT_PUBLIC_BASE_URL
: isLocal && process.env.NEXT_PUBLIC_USE_REWRITES !== "true"
? "http://localhost:4000"
: browserLocation?.origin ?? null;
let initialProxyBaseUrl = receivedProxyBaseUrl || resolvedDefaultProxyBaseUrl;
console.log("proxyBaseUrl:", proxyBaseUrl);
console.log("serverRootPath:", serverRootPath);
@@ -2278,6 +2281,19 @@ export const mcpHubPublicServersCall = async () => {
return response.json();
};
export const skillHubPublicCall = async () => {
const url = proxyBaseUrl ? `${proxyBaseUrl}/public/skill_hub` : `/public/skill_hub`;
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
console.error(`skillHubPublicCall failed with status ${response.status}`);
return { plugins: [] };
}
return response.json();
};
export const modelHubCall = async (accessToken: string) => {
/**
* Get all models on proxy
@@ -9,7 +9,7 @@ import { formatNumberWithCommas } from "@/utils/dataUtils";
import { InfoCircleOutlined } from "@ant-design/icons";
import { useQueryClient } from "@tanstack/react-query";
import { Accordion, AccordionBody, AccordionHeader, Button, Col, Grid, Text, TextInput, Title } from "@tremor/react";
import { Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd";
import { Button as Button2, Form, Input, InputNumber, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd";
import debounce from "lodash/debounce";
import React, { useCallback, useEffect, useState } from "react";
import { rolesWithWriteAccess } from "../../utils/roles";
@@ -28,6 +28,7 @@ import TeamDropdown from "../common_components/team_dropdown";
import OrganizationDropdown from "../common_components/OrganizationDropdown";
import ProjectDropdown from "../common_components/ProjectDropdown";
import { CreateUserButton } from "../CreateUserButton";
import { BudgetWindowEntry, BudgetWindowsEditor } from "../key_team_helpers/BudgetWindowsEditor";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
import { Team } from "../key_team_helpers/key_list";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
@@ -153,6 +154,7 @@ export const fetchUserModels = async (
}
};
/**
*
* @deprecated
@@ -201,6 +203,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
const [autoRotationEnabled, setAutoRotationEnabled] = useState<boolean>(false);
const [rotationInterval, setRotationInterval] = useState<string>("30d");
const [routerSettings, setRouterSettings] = useState<RouterSettingsAccordionValue | null>(null);
const [budgetLimits, setBudgetLimits] = useState<BudgetWindowEntry[]>([]);
const [routerSettingsKey, setRouterSettingsKey] = useState<number>(0);
const [agentsList, setAgentsList] = useState<{ agent_id: string; agent_name: string }[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
@@ -218,6 +221,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
setSelectedAgentId(null);
setSelectedOrganizationId(null);
setSelectedProjectId(null);
setBudgetLimits([]);
};
const handleCancel = () => {
@@ -236,6 +240,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
setSelectedAgentId(null);
setSelectedOrganizationId(null);
setSelectedProjectId(null);
setBudgetLimits([]);
};
useEffect(() => {
@@ -519,6 +524,12 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
}
}
// Add multi-window budget limits (filter out incomplete entries)
const validWindows = budgetLimits.filter((w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined);
if (validWindows.length > 0) {
formValues.budget_limits = validWindows;
}
let response;
if (keyOwner === "service_account") {
response = await keyCreateServiceAccountCall(accessToken, formValues);
@@ -540,6 +551,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
setSoftBudget(response["soft_budget"]);
NotificationsManager.success("Virtual Key Created");
form.resetFields();
setBudgetLimits([]);
localStorage.removeItem("userData" + userID);
} catch (error) {
console.log("error in create key:", error);
@@ -1045,6 +1057,22 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
>
<BudgetDurationDropdown onChange={(value) => form.setFieldValue("budget_duration", value)} />
</Form.Item>
<Form.Item
className="mt-4"
label={
<span>
Budget Windows{" "}
<Tooltip title="Set multiple independent budget windows (e.g., hourly $10 AND monthly $200). Each window tracks spend separately and resets on its own schedule.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
>
<BudgetWindowsEditor
value={budgetLimits}
onChange={setBudgetLimits}
/>
</Form.Item>
<Form.Item
className="mt-4"
label={
@@ -10,11 +10,14 @@ import NotificationsManager from "./molecules/notifications_manager";
import Navbar from "./navbar";
import {
agentHubPublicModelsCall,
skillHubPublicCall,
getPublicModelHubInfo,
getUiConfig,
mcpHubPublicServersCall,
modelHubPublicModelsCall,
} from "./networking";
import { Plugin } from "./claude_code_plugins/types";
import SkillHubDashboard from "./AIHub/SkillHubDashboard";
import { generateCodeSnippet } from "./playground/chat_ui/CodeSnippets";
import { getEndpointType } from "./playground/chat_ui/mode_endpoint_mapping";
import { MessageType } from "./playground/chat_ui/types";
@@ -120,6 +123,8 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
const [selectedMcpServer, setSelectedMcpServer] = useState<null | MCPServerData>(null);
const [proxySettings, setProxySettings] = useState<any>({});
const [activeTab, setActiveTab] = useState<string>("models");
const [skillHubData, setSkillHubData] = useState<Plugin[]>([]);
const [skillLoading, setSkillLoading] = useState<boolean>(false);
useEffect(() => {
const initializeAndFetch = async () => {
@@ -180,11 +185,24 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
setUsefulLinks(publicModelHubInfo.useful_links || {});
};
const fetchSkillData = async () => {
try {
setSkillLoading(true);
const response = await skillHubPublicCall();
setSkillHubData(response.plugins ?? []);
} catch (error) {
console.error("There was an error fetching the public skill data", error);
} finally {
setSkillLoading(false);
}
};
fetchPublicModelHubInfo();
fetchPublicData();
fetchAgentData();
fetchMcpData();
fetchSkillData();
};
initializeAndFetch();
@@ -1294,6 +1312,15 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
</div>
</TabPane>
)}
{/* Skill Hub Tab */}
<TabPane tab="Skill Hub" key="skills">
<SkillHubDashboard
skills={skillHubData}
isLoading={skillLoading}
publicPage={true}
/>
</TabPane>
</Tabs>
</Card>
</div>
@@ -0,0 +1,114 @@
import { ColumnDef } from "@tanstack/react-table";
import { Badge, Text } from "@tremor/react";
import { Tooltip } from "antd";
import { CopyOutlined, LinkOutlined } from "@ant-design/icons";
import { Plugin } from "./claude_code_plugins/types";
export const skillHubColumns = (
showModal: (skill: Plugin) => void,
copyToClipboard: (text: string) => void,
publicPage: boolean = false,
): ColumnDef<Plugin>[] => [
{
header: "Skill Name",
accessorKey: "name",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const skill = row.original;
return (
<div className="space-y-1">
<div className="flex items-center space-x-2">
<button
type="button"
className="font-medium text-sm cursor-pointer text-blue-600 hover:underline bg-transparent border-none p-0"
onClick={() => showModal(skill)}
>
{skill.name}
</button>
<Tooltip title="Copy skill name">
<CopyOutlined
onClick={() => copyToClipboard(skill.name)}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
/>
</Tooltip>
</div>
{skill.description && (
<Text className="text-xs text-gray-500 line-clamp-1 md:hidden">
{skill.description}
</Text>
)}
</div>
);
},
},
{
header: "Description",
accessorKey: "description",
enableSorting: false,
cell: ({ row }) => (
<Text className="text-xs line-clamp-2">{row.original.description || "-"}</Text>
),
},
{
header: "Category",
accessorKey: "category",
enableSorting: true,
cell: ({ row }) => {
const cat = row.original.category;
if (!cat) return <Text className="text-xs text-gray-400">-</Text>;
return <Badge color="blue" size="xs">{cat}</Badge>;
},
},
{
header: "Domain",
accessorKey: "domain",
enableSorting: true,
cell: ({ row }) => (
<Text className="text-xs">{row.original.domain || "-"}</Text>
),
},
{
header: "Source",
accessorKey: "source",
enableSorting: false,
cell: ({ row }) => {
const src = row.original.source;
let url: string | null = null;
let label = "-";
if (src?.source === "github" && src.repo) {
url = `https://github.com/${src.repo}`;
label = src.repo;
} else if (src?.source === "git-subdir" && src.url) {
url = src.path ? `${src.url}/tree/main/${src.path}` : src.url;
label = url.replace("https://github.com/", "");
} else if (src?.source === "url" && src.url) {
url = src.url;
label = src.url.replace(/^https?:\/\//, "");
}
if (!url) return <Text className="text-xs text-gray-400">-</Text>;
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-600 hover:underline truncate max-w-[180px]"
title={label}
>
<span className="truncate">{label}</span>
<LinkOutlined className="shrink-0" style={{ fontSize: 10 }} />
</a>
);
},
},
{
header: "Status",
accessorKey: "enabled",
enableSorting: true,
cell: ({ row }) => (
<Badge color={row.original.enabled ? "green" : "gray"} size="xs">
{row.original.enabled ? "Public" : "Draft"}
</Badge>
),
},
];
@@ -5,7 +5,7 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"
import PolicySelector from "@/components/policies/PolicySelector";
import { InfoCircleOutlined } from "@ant-design/icons";
import { TextInput, Button as TremorButton } from "@tremor/react";
import { Form, Input, Select, Switch, Tooltip } from "antd";
import { Button as AntButton, Form, Input, InputNumber, Select, Switch, Tooltip } from "antd";
import { useEffect, useState } from "react";
import { rolesWithWriteAccess } from "../../utils/roles";
import AgentSelector from "../agent_management/AgentSelector";
@@ -16,6 +16,7 @@ import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSel
import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem";
import OrganizationDropdown from "../common_components/OrganizationDropdown";
import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils";
import { BudgetWindowEntry, BudgetWindowsEditor } from "../key_team_helpers/BudgetWindowsEditor";
import { KeyResponse } from "../key_team_helpers/key_list";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
@@ -77,6 +78,7 @@ const getKeyTypeFromRoutes = (allowedRoutes: string[] | null | undefined): strin
return "default";
};
export function KeyEditView({
keyData,
onCancel,
@@ -103,6 +105,9 @@ export function KeyEditView({
const [rotationInterval, setRotationInterval] = useState<string>(keyData.rotation_interval || "");
const [neverExpire, setNeverExpire] = useState<boolean>(!keyData.expires);
const [isKeySaving, setIsKeySaving] = useState(false);
const [budgetLimits, setBudgetLimits] = useState<BudgetWindowEntry[]>(
Array.isArray(keyData.budget_limits) ? keyData.budget_limits : []
);
const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations();
const { data: projects } = useProjects();
const { data: uiSettingsData } = useUISettings();
@@ -274,6 +279,10 @@ export function KeyEditView({
values.duration = null;
}
// Include multi-window budget limits (filter out incomplete entries)
const validWindows = budgetLimits.filter((w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined);
values.budget_limits = validWindows.length > 0 ? validWindows : undefined;
await onSubmit(values);
} finally {
setIsKeySaving(false);
@@ -423,6 +432,22 @@ export function KeyEditView({
</Select>
</Form.Item>
<Form.Item
label={
<span>
Budget Windows{" "}
<Tooltip title="Set multiple independent budget windows (e.g., hourly $10 AND monthly $200). Each window tracks spend separately and resets on its own schedule.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
>
<BudgetWindowsEditor
value={budgetLimits}
onChange={setBudgetLimits}
/>
</Form.Item>
<Form.Item label="TPM Limit" name="tpm_limit">
<NumericalInput min={0} />
</Form.Item>