mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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
This commit is contained in:
parent
4f6f88fa1b
commit
f6911b21c5
21 changed files with 1419 additions and 384 deletions
111
docs/my-website/docs/skills_gateway.md
Normal file
111
docs/my-website/docs/skills_gateway.md
Normal 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 <name>"]
|
||||
|
||||
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 |
|
||||
|
|
@ -332,6 +332,13 @@ const sidebars = {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Skills Gateway",
|
||||
items: [
|
||||
"skills_gateway",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -595,6 +595,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/public/model_hub",
|
||||
"/public/agent_hub",
|
||||
"/public/mcp_hub",
|
||||
"/public/skill_hub",
|
||||
"/public/litellm_model_cost_map",
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
82
ui/litellm-dashboard/package-lock.json
generated
82
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
17
ui/litellm-dashboard/src/app/(dashboard)/skills/page.tsx
Normal file
17
ui/litellm-dashboard/src/app/(dashboard)/skills/page.tsx
Normal file
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
139
ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.tsx
Normal file
139
ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.tsx
Normal file
|
|
@ -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'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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -268,6 +275,7 @@ const menuGroups: MenuGroup[] = [
|
|||
label: "AI Hub",
|
||||
icon: <AppstoreOutlined />,
|
||||
},
|
||||
|
||||
{
|
||||
key: "learning-resources",
|
||||
page: "learning-resources",
|
||||
|
|
@ -309,13 +317,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);
|
||||
|
|
@ -2274,6 +2277,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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
114
ui/litellm-dashboard/src/components/skill_hub_table_columns.tsx
Normal file
114
ui/litellm-dashboard/src/components/skill_hub_table_columns.tsx
Normal file
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
Loading…
Add table
Reference in a new issue