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:
ishaan-berri 2026-04-03 17:25:07 -07:00 • committed by GitHub
parent 4f6f88fa1b
commit f6911b21c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1419 additions and 384 deletions

View file

@ -0,0 +1,111 @@
# Skills Gateway
<iframe width="840" height="500" src="https://www.loom.com/embed/cb74eb79df3e4c2b83a6efae54a589f9" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>
LiteLLM acts as a **Skills Registry** — a central place to register, manage, and discover Claude Code skills across your organization. Teams can publish skills once and have agents and developers find them through a single hub.
## How it works
```mermaid
graph TD
Dev["👨‍💻 Developer<br/>registers a skill<br/>(GitHub URL or subdir)"] -->|POST /claude-code/plugins| Proxy["LiteLLM Proxy<br/>(Skills Registry)"]
Admin["🔑 Admin<br/>publishes skill<br/>(marks as public)"] -->|enable via UI or API| Proxy
Proxy -->|GET /public/skill_hub| SkillHub["🗂️ Skill Hub<br/>(AI Hub → Skill Hub tab)"]
Proxy -->|GET /claude-code/marketplace.json| Marketplace["📦 Claude Code<br/>Marketplace endpoint"]
SkillHub --> Human["🧑 Human<br/>browses & discovers skills<br/>in AI Hub UI"]
Marketplace --> Agent["🤖 Agent / Claude Code<br/>installs skill with<br/>/plugin marketplace add &lt;name&gt;"]
style Proxy fill:#1a73e8,color:#fff
style SkillHub fill:#e8f0fe,color:#1a73e8
style Marketplace fill:#e8f0fe,color:#1a73e8
```
## Quick start
### 1. Register a skill
Paste any GitHub URL into the Skills UI — LiteLLM auto-detects the source type and skill name.
```bash
curl -X POST https://your-proxy/claude-code/plugins \
-H "Authorization: Bearer $LITELLM_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "grill-me",
"source": {
"source": "git-subdir",
"url": "https://github.com/mattpocock/skills",
"path": "grill-me"
},
"description": "Interview skill for relentless questioning",
"domain": "Productivity",
"namespace": "interviews"
}'
```
Skills nested in subdirectories (e.g. `github.com/org/repo/tree/main/skill-name`) are supported — LiteLLM parses the URL automatically in the UI.
### 2. Publish to hub
In the Admin UI: **AI Hub → Skill Hub → Select Skills to Make Public**.
Or via API:
```bash
curl -X POST https://your-proxy/claude-code/plugins/grill-me/enable \
-H "Authorization: Bearer $LITELLM_KEY"
```
### 3. Browse the hub
Public skills appear at:
- **Admin UI**: AI Hub → Skill Hub tab
- **Public page**: `/ui/model_hub` → Skill Hub tab (no login required)
- **API**: `GET /public/skill_hub`
### 4. Install in Claude Code
Point Claude Code at your proxy marketplace once:
```json title="~/.claude/settings.json"
{
"extraKnownMarketplaces": {
"my-org": {
"source": "url",
"url": "https://your-proxy/claude-code/marketplace.json"
}
}
}
```
Then install any skill:
```
/plugin marketplace add grill-me
```
## Skill fields
| Field | Description |
|-------|-------------|
| `name` | Unique skill identifier (used in `/plugin marketplace add`) |
| `source` | Git source — `github`, `url`, or `git-subdir` |
| `description` | Short description shown in the hub |
| `domain` | Category for grouping (e.g. `Engineering`, `Productivity`) |
| `namespace` | Subcategory within a domain (e.g. `quality`, `meetings`) |
| `keywords` | Tags for search and filtering |
| `version` | Semver string |
## API reference
| Endpoint | Auth | Description |
|----------|------|-------------|
| `POST /claude-code/plugins` | Required | Register a skill |
| `GET /claude-code/plugins` | Required | List all skills (admin) |
| `POST /claude-code/plugins/{name}/enable` | Required | Publish a skill |
| `POST /claude-code/plugins/{name}/disable` | Required | Unpublish a skill |
| `GET /public/skill_hub` | None | List public skills |
| `GET /claude-code/marketplace.json` | None | Claude Code marketplace manifest |

View file

@ -332,6 +332,13 @@ const sidebars = {
},
],
},
{
type: "category",
label: "Skills Gateway",
items: [
"skills_gateway",
],
},
],
},
{

View file

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

View file

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

View file

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

View file

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

View file

@ -91,7 +91,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@ -1773,7 +1772,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@ -1784,7 +1782,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@ -1794,14 +1791,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@ -1969,7 +1964,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@ -1983,7 +1977,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@ -1993,7 +1986,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@ -2317,7 +2309,7 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz",
"integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.1"
@ -3422,14 +3414,12 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.2.48",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz",
"integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@ -3471,7 +3461,6 @@
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
@ -4332,14 +4321,12 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@ -4353,7 +4340,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -4366,7 +4352,6 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
@ -4726,7 +4711,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -4752,7 +4736,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@ -4868,7 +4851,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@ -4992,7 +4974,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@ -5017,7 +4998,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@ -5093,7 +5073,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@ -5154,7 +5133,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@ -5568,14 +5546,12 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true,
"license": "MIT"
},
"node_modules/doctrine": {
@ -6489,7 +6465,6 @@
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@ -6522,7 +6497,6 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@ -6560,7 +6534,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@ -6700,7 +6673,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@ -6851,7 +6823,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
@ -7349,7 +7320,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@ -7402,7 +7372,6 @@
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@ -7463,7 +7432,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -7509,7 +7477,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@ -7558,7 +7525,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@ -7835,7 +7801,6 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@ -8121,7 +8086,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@ -8134,7 +8098,6 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
@ -8588,7 +8551,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@ -9161,7 +9123,6 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@ -9175,7 +9136,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -9290,7 +9250,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
@ -9502,7 +9461,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -9521,7 +9479,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@ -9866,7 +9823,6 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
@ -9913,7 +9869,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@ -9926,7 +9881,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -9936,7 +9890,6 @@
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@ -9946,7 +9899,7 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.1"
@ -9965,7 +9918,7 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@ -9988,7 +9941,6 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -10017,7 +9969,6 @@
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
@ -10035,7 +9986,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -10061,7 +10011,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -10104,7 +10053,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -10130,7 +10078,6 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@ -10144,7 +10091,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/prelude-ls": {
@ -10251,7 +10197,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
@ -11040,7 +10985,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
@ -11050,7 +10994,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@ -11063,7 +11006,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -11361,7 +11303,6 @@
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
@ -11402,7 +11343,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@ -11458,7 +11398,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
@ -12099,7 +12038,6 @@
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
@ -12135,7 +12073,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -12171,7 +12108,6 @@
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@ -12209,7 +12145,6 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@ -12226,7 +12161,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@ -12254,7 +12188,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
@ -12264,7 +12197,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
@ -12306,7 +12238,6 @@
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@ -12373,7 +12304,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@ -12461,7 +12391,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tsconfig-paths": {
@ -12578,7 +12507,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -12780,7 +12709,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
@ -13234,7 +13162,7 @@
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"

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

View file

@ -624,7 +624,7 @@ function CreateKeyPageContent() {
<SearchTools accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tag-management" ? (
<TagManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "claude-code-plugins" ? (
) : page == "skills" || page == "claude-code-plugins" ? (
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
) : page == "access-groups" ? (
<AccessGroupsPage />

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,366 @@
import React, { useState } from "react";
import { ArrowLeftOutlined, CopyOutlined, CheckOutlined, LinkOutlined } from "@ant-design/icons";
import { formatInstallCommand } from "./helpers";
import { Plugin } from "./types";
interface SkillDetailProps {
skill: Plugin;
onBack: () => void;
isAdmin?: boolean;
accessToken?: string | null;
onPublishClick?: () => void;
}
const SkillDetail: React.FC<SkillDetailProps> = ({
skill,
onBack,
}) => {
const [activeTab, setActiveTab] = useState("overview");
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const copyToClipboard = (text: string, key: string) => {
navigator.clipboard.writeText(text);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
};
const sourceUrl = (() => {
const src = skill.source;
if (src.source === "github" && src.repo) return `https://github.com/${src.repo}`;
if (src.source === "git-subdir" && src.url)
return src.path ? `${src.url}/tree/main/${src.path}` : src.url;
if (src.source === "url" && src.url) return src.url;
return null;
})();
const installCommand = formatInstallCommand(skill);
const detailRows = [
...(skill.category ? [{ property: "Category", value: skill.category }] : []),
...(skill.domain ? [{ property: "Domain", value: skill.domain }] : []),
...(skill.namespace ? [{ property: "Namespace", value: skill.namespace }] : []),
...(skill.version ? [{ property: "Version", value: skill.version }] : []),
...(skill.author?.name ? [{ property: "Author", value: skill.author.name }] : []),
...(skill.created_at
? [{ property: "Added", value: new Date(skill.created_at).toLocaleDateString() }]
: []),
];
const tabs = [
{ key: "overview", label: "Overview" },
{ key: "usage", label: "How to Use" },
];
return (
<div style={{ padding: "24px 32px 24px 0" }}>
{/* Back link */}
<div
onClick={onBack}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
color: "#5f6368",
cursor: "pointer",
fontSize: 14,
marginBottom: 24,
}}
>
<ArrowLeftOutlined style={{ fontSize: 11 }} />
<span>Skills</span>
</div>
{/* Header */}
<div style={{ marginBottom: 8 }}>
<h1 style={{ fontSize: 28, fontWeight: 400, color: "#202124", margin: 0, lineHeight: 1.2 }}>
{skill.name}
</h1>
{skill.description && (
<p style={{ fontSize: 14, color: "#5f6368", margin: "8px 0 0 0", lineHeight: 1.6 }}>
{skill.description}
</p>
)}
</div>
{/* Tab bar */}
<div style={{ borderBottom: "1px solid #dadce0", marginBottom: 28, marginTop: 24 }}>
<div style={{ display: "flex", gap: 0 }}>
{tabs.map((tab) => (
<div
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: "12px 20px",
fontSize: 14,
color: activeTab === tab.key ? "#1a73e8" : "#5f6368",
borderBottom: activeTab === tab.key ? "3px solid #1a73e8" : "3px solid transparent",
cursor: "pointer",
fontWeight: activeTab === tab.key ? 500 : 400,
marginBottom: -1,
}}
>
{tab.label}
</div>
))}
</div>
</div>
{/* Overview tab */}
{activeTab === "overview" && (
<div style={{ display: "flex", gap: 64 }}>
{/* Left column */}
<div style={{ flex: 1, minWidth: 0 }}>
<h2 style={{ fontSize: 18, fontWeight: 400, color: "#202124", margin: "0 0 4px 0" }}>
Skill Details
</h2>
<p style={{ fontSize: 13, color: "#5f6368", margin: "0 0 16px 0" }}>
Metadata registered with this skill
</p>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
<thead>
<tr style={{ borderBottom: "1px solid #dadce0" }}>
<th style={{ textAlign: "left", padding: "12px 0", color: "#5f6368", fontWeight: 500, width: 160 }}>
Property
</th>
<th style={{ textAlign: "left", padding: "12px 0", color: "#5f6368", fontWeight: 500 }}>
{skill.name}
</th>
</tr>
</thead>
<tbody>
{detailRows.map((row, i) => (
<tr key={i} style={{ borderBottom: "1px solid #f1f3f4" }}>
<td style={{ padding: "12px 0", color: "#3c4043" }}>{row.property}</td>
<td style={{ padding: "12px 0", color: "#202124" }}>{row.value}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Right sidebar */}
<div style={{ width: 240, flexShrink: 0 }}>
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 12, color: "#5f6368", marginBottom: 4 }}>Status</div>
<span
style={{
fontSize: 12,
padding: "3px 10px",
borderRadius: 12,
backgroundColor: skill.enabled ? "#e6f4ea" : "#f1f3f4",
color: skill.enabled ? "#137333" : "#5f6368",
fontWeight: 500,
}}
>
{skill.enabled ? "Public" : "Draft"}
</span>
</div>
{sourceUrl && (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 12, color: "#5f6368", marginBottom: 4 }}>Source</div>
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 13, color: "#1a73e8", wordBreak: "break-all", display: "flex", alignItems: "center", gap: 4 }}
>
{sourceUrl.replace("https://", "")}
<LinkOutlined style={{ fontSize: 11, flexShrink: 0 }} />
</a>
</div>
)}
{skill.keywords && skill.keywords.length > 0 && (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 12, color: "#5f6368", marginBottom: 8 }}>Tags</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{skill.keywords.map((kw) => (
<span
key={kw}
style={{
fontSize: 12,
padding: "4px 12px",
borderRadius: 16,
border: "1px solid #dadce0",
color: "#3c4043",
backgroundColor: "#fff",
}}
>
{kw}
</span>
))}
</div>
</div>
)}
<div>
<div style={{ fontSize: 12, color: "#5f6368", marginBottom: 4 }}>Skill ID</div>
<div style={{ fontSize: 12, fontFamily: "monospace", color: "#3c4043", wordBreak: "break-all" }}>
{skill.id}
</div>
</div>
</div>
</div>
)}
{/* How to Use tab */}
{activeTab === "usage" && (
<div style={{ maxWidth: 640 }}>
<h2 style={{ fontSize: 18, fontWeight: 400, color: "#202124", margin: "0 0 8px 0" }}>
Using this skill
</h2>
<p style={{ fontSize: 14, color: "#5f6368", margin: "0 0 24px 0", lineHeight: 1.6 }}>
Once your proxy is set as a marketplace, enable this skill in Claude Code with one command:
</p>
{/* Install command */}
<div
style={{
border: "1px solid #dadce0",
borderRadius: 8,
overflow: "hidden",
marginBottom: 24,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 16px",
backgroundColor: "#f8f9fa",
borderBottom: "1px solid #dadce0",
}}
>
<span style={{ fontSize: 13, color: "#3c4043", fontWeight: 500 }}>
Run in Claude Code
</span>
<button
onClick={() => copyToClipboard(installCommand, "install")}
style={{
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 12,
color: copiedKey === "install" ? "#137333" : "#1a73e8",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
}}
>
{copiedKey === "install" ? <CheckOutlined /> : <CopyOutlined />}
{copiedKey === "install" ? "Copied" : "Copy"}
</button>
</div>
<pre
style={{
margin: 0,
padding: "14px 16px",
fontSize: 14,
fontFamily: "monospace",
color: "#202124",
backgroundColor: "#fff",
}}
>
{installCommand}
</pre>
</div>
<p style={{ fontSize: 13, color: "#5f6368", lineHeight: 1.6, margin: 0 }}>
Don&apos;t have the marketplace configured yet?{" "}
<span
onClick={() => setActiveTab("setup")}
style={{ color: "#1a73e8", cursor: "pointer" }}
>
See one-time setup →
</span>
</p>
</div>
)}
{/* Setup tab (linked from usage) */}
{activeTab === "setup" && (
<div style={{ maxWidth: 640 }}>
<h2 style={{ fontSize: 18, fontWeight: 400, color: "#202124", margin: "0 0 8px 0" }}>
One-time marketplace setup
</h2>
<p style={{ fontSize: 14, color: "#5f6368", margin: "0 0 24px 0", lineHeight: 1.6 }}>
Add this to <code style={{ fontSize: 13, backgroundColor: "#f1f3f4", padding: "1px 6px", borderRadius: 4 }}>~/.claude/settings.json</code> to point Claude Code at your proxy:
</p>
<div
style={{
border: "1px solid #dadce0",
borderRadius: 8,
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 16px",
backgroundColor: "#f8f9fa",
borderBottom: "1px solid #dadce0",
}}
>
<span style={{ fontSize: 13, color: "#3c4043", fontWeight: 500 }}>
~/.claude/settings.json
</span>
<button
onClick={() => {
const snippet = JSON.stringify(
{ extraKnownMarketplaces: { "my-org": { source: "url", url: `${typeof window !== "undefined" ? window.location.origin : ""}/claude-code/marketplace.json` } } },
null, 2
);
copyToClipboard(snippet, "settings");
}}
style={{
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 12,
color: copiedKey === "settings" ? "#137333" : "#1a73e8",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
}}
>
{copiedKey === "settings" ? <CheckOutlined /> : <CopyOutlined />}
{copiedKey === "settings" ? "Copied" : "Copy"}
</button>
</div>
<pre
style={{
margin: 0,
padding: "14px 16px",
fontSize: 13,
fontFamily: "monospace",
color: "#202124",
backgroundColor: "#fff",
}}
>
{JSON.stringify(
{
extraKnownMarketplaces: {
"my-org": {
source: "url",
url: `${typeof window !== "undefined" ? window.location.origin : "<proxy-url>"}/claude-code/marketplace.json`,
},
},
},
null,
2
)}
</pre>
</div>
</div>
)}
</div>
);
};
export default SkillDetail;

View file

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

View file

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

View file

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

View file

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

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