feat: Skills.sh marketplace integration + navigation restructure

Skills.sh Marketplace:
- Add marketplace browser with search, detail panel, and install/uninstall
- Search API proxying skills.sh with lock file integration for installed state
- Install/remove APIs using `npx skills` CLI with SSE streaming progress
- SKILL.md content fetching via GitHub Git Trees API with in-memory caching
- MarketplaceSkillCard, MarketplaceSkillDetail, MarketplaceBrowser components
- InstallProgressDialog with real-time SSE log output
- SkillsManager gains "My Skills" / "Marketplace" segmented control
- Type definitions: MarketplaceSkill, SkillLockFile, SkillLockEntry

Navigation restructure:
- Split Extensions page into standalone /skills and /mcp top-level pages
- NavRail: replace single Extensions icon with separate Skills (ZapIcon) and MCP (Plug01Icon) entries
- /extensions page now redirects to /skills or /mcp based on ?tab param
- /plugins redirects updated to point to new routes
- Remove redundant source badges from SkillListItem (section headers suffice)

Other:
- Add WeChat user group QR code to README.md and README_CN.md
- i18n: add 19 marketplace-related keys in en.ts and zh.ts
- Bump version to 0.18.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-27 21:43:46 +08:00
1 parent 495319fe1f
commit c4d03c46e6
25 files changed
+1065 -83

No files matched your search

+7 -6
View File
@@ -7,7 +7,8 @@ import { useCallback, useSyncExternalStore } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Message02Icon,
GridIcon,
ZapIcon,
Plug01Icon,
Image01Icon,
Settings02Icon,
Moon02Icon,
@@ -34,7 +35,8 @@ interface NavRailProps {
const navItems = [
{ href: "/chat", label: "Chats", icon: Message02Icon },
{ href: "/extensions", label: "Extensions", icon: GridIcon },
{ href: "/skills", label: "Skills", icon: ZapIcon },
{ href: "/mcp", label: "MCP", icon: Plug01Icon },
{ href: "/gallery", label: "Gallery", icon: Image01Icon },
{ href: "/settings", label: "Settings", icon: Settings02Icon },
] as const;
@@ -46,7 +48,8 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
const { t } = useTranslation();
const navLabelKeys: Record<string, TranslationKey> = {
'Chats': 'nav.chats',
'Extensions': 'nav.extensions',
'Skills': 'extensions.skills',
'MCP': 'extensions.mcpServers',
'Gallery': 'gallery.title',
'Settings': 'nav.settings',
};
@@ -62,9 +65,7 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
const isActive =
item.href === "/chat"
? pathname === "/chat" || pathname.startsWith("/chat/")
: item.href === "/extensions"
? pathname.startsWith("/extensions")
: pathname === item.href || pathname.startsWith(item.href + "?");
: pathname === item.href || pathname.startsWith(item.href + "/") || pathname.startsWith(item.href + "?");
return (
<Tooltip key={item.href}>
@@ -0,0 +1,179 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import { Loading02Icon, CheckmarkCircle02Icon, Cancel01Icon } from "@hugeicons/core-free-icons";
import { useTranslation } from "@/hooks/useTranslation";
interface InstallProgressDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
action: "install" | "uninstall";
source: string;
skillName: string;
onComplete: () => void;
}
type Phase = "running" | "success" | "error";
export function InstallProgressDialog({
open,
onOpenChange,
action,
source,
skillName,
onComplete,
}: InstallProgressDialogProps) {
const { t } = useTranslation();
const [phase, setPhase] = useState<Phase>("running");
const [logs, setLogs] = useState<string[]>([]);
const logsEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const startProcess = useCallback(async () => {
setPhase("running");
setLogs([]);
const controller = new AbortController();
abortRef.current = controller;
try {
const endpoint =
action === "install"
? "/api/skills/marketplace/install"
: "/api/skills/marketplace/remove";
const body =
action === "install"
? { source, global: true }
: { skill: skillName, global: true };
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok || !res.body) {
setPhase("error");
setLogs((prev) => [...prev, `HTTP ${res.status}: ${res.statusText}`]);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
let currentEvent = "";
for (const line of lines) {
if (line.startsWith("event: ")) {
currentEvent = line.slice(7).trim();
} else if (line.startsWith("data: ")) {
const raw = line.slice(6);
let data: string;
try {
data = JSON.parse(raw);
} catch {
data = raw;
}
if (currentEvent === "output") {
setLogs((prev) => [...prev, data]);
} else if (currentEvent === "done") {
setPhase("success");
} else if (currentEvent === "error") {
setPhase("error");
setLogs((prev) => [...prev, `Error: ${data}`]);
}
}
}
}
} catch (err) {
if ((err as Error).name !== "AbortError") {
setPhase("error");
setLogs((prev) => [...prev, (err as Error).message]);
}
}
}, [action, source, skillName]);
useEffect(() => {
if (open) {
startProcess();
}
return () => {
abortRef.current?.abort();
};
}, [open, startProcess]);
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [logs]);
const handleClose = () => {
abortRef.current?.abort();
if (phase === "success") {
onComplete();
}
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-lg" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{phase === "running" && (
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-blue-500" />
)}
{phase === "success" && (
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-5 w-5 text-green-500" />
)}
{phase === "error" && (
<HugeiconsIcon icon={Cancel01Icon} className="h-5 w-5 text-red-500" />
)}
{phase === "running"
? t('skills.installing')
: phase === "success"
? t('skills.installSuccess')
: t('skills.installFailed')}
</DialogTitle>
</DialogHeader>
<div className="bg-muted/50 rounded-md p-3 max-h-64 overflow-y-auto font-mono text-xs leading-relaxed">
{logs.length === 0 && phase === "running" && (
<span className="text-muted-foreground">{t('skills.installing')}</span>
)}
{logs.map((line, i) => (
<div key={i} className="whitespace-pre-wrap break-all">
{line}
</div>
))}
<div ref={logsEndRef} />
</div>
<DialogFooter>
<Button onClick={handleClose}>
{phase === "running" ? t('common.cancel') : t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,138 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Input } from "@/components/ui/input";
import { HugeiconsIcon } from "@hugeicons/react";
import { Search01Icon, Loading02Icon, Store01Icon } from "@hugeicons/core-free-icons";
import { useTranslation } from "@/hooks/useTranslation";
import { MarketplaceSkillCard } from "./MarketplaceSkillCard";
import { MarketplaceSkillDetail } from "./MarketplaceSkillDetail";
import type { MarketplaceSkill } from "@/types";
interface MarketplaceBrowserProps {
onInstalled: () => void;
}
export function MarketplaceBrowser({ onInstalled }: MarketplaceBrowserProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [results, setResults] = useState<MarketplaceSkill[]>([]);
const [selected, setSelected] = useState<MarketplaceSkill | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const doSearch = useCallback(async (query: string) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (query) params.set("q", query);
params.set("limit", "20");
const res = await fetch(`/api/skills/marketplace/search?${params}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
const data = await res.json();
setResults(data.skills || []);
} catch (err) {
setError((err as Error).message);
setResults([]);
} finally {
setLoading(false);
}
}, []);
// Initial load — fetch popular skills
useEffect(() => {
doSearch("");
}, [doSearch]);
// Debounced search
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
doSearch(search);
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [search, doSearch]);
const handleInstallComplete = useCallback(() => {
// Refresh results to update installed status
doSearch(search);
onInstalled();
}, [search, doSearch, onInstalled]);
return (
<div className="flex gap-4 flex-1 min-h-0">
{/* Left: search + results */}
<div className="w-64 shrink-0 flex flex-col border border-border rounded-lg overflow-hidden">
<div className="p-2 border-b border-border">
<div className="relative">
<HugeiconsIcon
icon={Search01Icon}
className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground"
/>
<Input
placeholder={t('skills.marketplaceSearch')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-7 h-8 text-sm"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="p-1">
{loading && results.length === 0 && (
<div className="flex items-center justify-center py-8">
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<div className="flex flex-col items-center gap-2 py-8 text-muted-foreground px-3">
<p className="text-xs text-center text-red-500">{t('skills.marketplaceError')}</p>
<p className="text-[10px] text-center">{error}</p>
</div>
)}
{!loading && !error && results.length === 0 && (
<div className="flex flex-col items-center gap-2 py-8 text-muted-foreground">
<HugeiconsIcon icon={Store01Icon} className="h-8 w-8 opacity-40" />
<p className="text-xs">{t('skills.searchNoResults')}</p>
</div>
)}
{results.map((skill) => (
<MarketplaceSkillCard
key={skill.id}
skill={skill}
selected={selected?.id === skill.id}
onSelect={() => setSelected(skill)}
/>
))}
</div>
</div>
</div>
{/* Right: detail */}
<div className="flex-1 min-w-0 border border-border rounded-lg overflow-hidden">
{selected ? (
<MarketplaceSkillDetail
key={selected.id}
skill={selected}
onInstallComplete={handleInstallComplete}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
<HugeiconsIcon icon={Store01Icon} className="h-12 w-12 opacity-30" />
<div className="text-center">
<p className="text-sm font-medium">{t('skills.marketplaceHint')}</p>
<p className="text-xs">{t('skills.marketplaceHintDesc')}</p>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,59 @@
"use client";
import { HugeiconsIcon } from "@hugeicons/react";
import { ZapIcon, Download04Icon, CheckmarkCircle02Icon } from "@hugeicons/core-free-icons";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useTranslation } from "@/hooks/useTranslation";
import type { MarketplaceSkill } from "@/types";
interface MarketplaceSkillCardProps {
skill: MarketplaceSkill;
selected: boolean;
onSelect: () => void;
}
export function MarketplaceSkillCard({
skill,
selected,
onSelect,
}: MarketplaceSkillCardProps) {
const { t } = useTranslation();
return (
<div
className={cn(
"group flex items-center gap-2 rounded-md px-3 py-2 cursor-pointer transition-colors",
selected
? "bg-accent text-accent-foreground"
: "hover:bg-accent/50"
)}
onClick={onSelect}
>
<HugeiconsIcon icon={ZapIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{skill.name}</span>
{skill.isInstalled ? (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 border-green-500/40 text-green-600 dark:text-green-400"
>
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-2.5 w-2.5 mr-0.5" />
{t('skills.installed')}
</Badge>
) : null}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{skill.source}</span>
{skill.installs > 0 && (
<span className="flex items-center gap-0.5 shrink-0">
<HugeiconsIcon icon={Download04Icon} className="h-3 w-3" />
{skill.installs.toLocaleString()}
</span>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,173 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Download04Icon,
Delete02Icon,
CheckmarkCircle02Icon,
LinkSquare01Icon,
ZapIcon,
Loading02Icon,
} from "@hugeicons/core-free-icons";
import { useTranslation } from "@/hooks/useTranslation";
import { InstallProgressDialog } from "./InstallProgressDialog";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { MarketplaceSkill } from "@/types";
interface MarketplaceSkillDetailProps {
skill: MarketplaceSkill;
onInstallComplete: () => void;
}
export function MarketplaceSkillDetail({
skill,
onInstallComplete,
}: MarketplaceSkillDetailProps) {
const { t } = useTranslation();
const [showProgress, setShowProgress] = useState(false);
const [progressAction, setProgressAction] = useState<"install" | "uninstall">("install");
const [readme, setReadme] = useState<string | null>(null);
const [readmeLoading, setReadmeLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setReadme(null);
setReadmeLoading(true);
const fetchReadme = async () => {
try {
const params = new URLSearchParams({
source: skill.source,
skillId: skill.skillId,
});
const res = await fetch(`/api/skills/marketplace/readme?${params}`);
if (!cancelled && res.ok) {
const data = await res.json();
setReadme(data.content || null);
}
} catch {
// ignore
} finally {
if (!cancelled) setReadmeLoading(false);
}
};
fetchReadme();
return () => { cancelled = true; };
}, [skill.source, skill.skillId]);
const handleInstall = () => {
setProgressAction("install");
setShowProgress(true);
};
const handleUninstall = () => {
setProgressAction("uninstall");
setShowProgress(true);
};
const githubUrl = skill.source.includes("/")
? `https://github.com/${skill.source}`
: null;
// Strip YAML front matter from readme for display
const displayContent = readme
? readme.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim()
: null;
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b border-border px-6 py-4 shrink-0">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/50 shrink-0">
<HugeiconsIcon icon={ZapIcon} className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold truncate">{skill.name}</h3>
{skill.isInstalled && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 border-green-500/40 text-green-600 dark:text-green-400 shrink-0"
>
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-2.5 w-2.5 mr-0.5" />
{t('skills.installed')}
</Badge>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-sm text-muted-foreground truncate">{skill.source}</span>
{githubUrl && (
<a
href={githubUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<HugeiconsIcon icon={LinkSquare01Icon} className="h-3.5 w-3.5" />
</a>
)}
{skill.installs > 0 && (
<span className="flex items-center gap-0.5 text-xs text-muted-foreground shrink-0">
<HugeiconsIcon icon={Download04Icon} className="h-3 w-3" />
{skill.installs.toLocaleString()}
</span>
)}
</div>
</div>
<div className="shrink-0">
{skill.isInstalled ? (
<Button
variant="destructive"
size="sm"
className="gap-1.5"
onClick={handleUninstall}
>
<HugeiconsIcon icon={Delete02Icon} className="h-3.5 w-3.5" />
{t('skills.uninstall')}
</Button>
) : (
<Button size="sm" className="gap-1.5" onClick={handleInstall}>
<HugeiconsIcon icon={Download04Icon} className="h-3.5 w-3.5" />
{t('skills.install')}
</Button>
)}
</div>
</div>
</div>
{/* Body — SKILL.md content */}
<div className="flex-1 overflow-y-auto min-h-0">
{readmeLoading ? (
<div className="flex items-center justify-center py-12">
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : displayContent ? (
<div className="prose prose-sm dark:prose-invert max-w-none px-6 py-4">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{displayContent}
</ReactMarkdown>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground gap-2">
<p className="text-sm">{t('skills.noReadme')}</p>
</div>
)}
</div>
<InstallProgressDialog
open={showProgress}
onOpenChange={setShowProgress}
action={progressAction}
source={skill.source}
skillName={skill.name}
onComplete={onInstallComplete}
/>
</div>
);
}
+1 -25
View File
@@ -68,31 +68,7 @@ export function SkillListItem({
>
<HugeiconsIcon icon={ZapIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">/{skill.name}</span>
<Badge
variant="outline"
className={cn(
"text-[10px] px-1.5 py-0",
skill.source === "global"
? "border-green-500/40 text-green-600 dark:text-green-400"
: skill.source === "installed"
? "border-orange-500/40 text-orange-600 dark:text-orange-400"
: "border-indigo-500/40 text-indigo-600 dark:text-indigo-400"
)}
>
{skill.source === "global" ? (
<HugeiconsIcon icon={GlobeIcon} className="h-2.5 w-2.5 mr-0.5" />
) : skill.source === "installed" ? (
<HugeiconsIcon icon={Download04Icon} className="h-2.5 w-2.5 mr-0.5" />
) : (
<HugeiconsIcon icon={Plug01Icon} className="h-2.5 w-2.5 mr-0.5" />
)}
{skill.source === "installed" && skill.installedSource
? `installed:${skill.installedSource}`
: skill.source}
</Badge>
</div>
<span className="text-sm font-medium truncate block">/{skill.name}</span>
<p className="text-xs text-muted-foreground truncate">
{skill.description}
</p>
+42 -5
View File
@@ -10,9 +10,13 @@ import { PlusSignIcon, Search01Icon, ZapIcon, Loading02Icon } from "@hugeicons/c
import { SkillListItem } from "./SkillListItem";
import { SkillEditor } from "./SkillEditor";
import { CreateSkillDialog } from "./CreateSkillDialog";
import { MarketplaceBrowser } from "./MarketplaceBrowser";
import { useTranslation } from "@/hooks/useTranslation";
import { cn } from "@/lib/utils";
import type { SkillItem } from "./SkillListItem";
type ViewTab = "local" | "marketplace";
export function SkillsManager() {
const { workingDirectory } = usePanel();
const { t } = useTranslation();
@@ -21,6 +25,7 @@ export function SkillsManager() {
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [viewTab, setViewTab] = useState<ViewTab>("local");
const fetchSkills = useCallback(async () => {
try {
@@ -150,14 +155,45 @@ export function SkillsManager() {
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<h3 className="text-lg font-semibold flex-1">{t('extensions.skills')}</h3>
<Button size="sm" onClick={() => setShowCreate(true)} className="gap-1">
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
{t('skills.newSkill')}
</Button>
<h3 className="text-lg font-semibold">{t('extensions.skills')}</h3>
{/* Segmented control */}
<div className="flex items-center bg-muted rounded-md p-0.5">
<button
className={cn(
"px-3 py-1 text-xs font-medium rounded transition-colors",
viewTab === "local"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => setViewTab("local")}
>
{t('skills.mySkills')}
</button>
<button
className={cn(
"px-3 py-1 text-xs font-medium rounded transition-colors",
viewTab === "marketplace"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => setViewTab("marketplace")}
>
{t('skills.marketplace')}
</button>
</div>
<div className="flex-1" />
{viewTab === "local" && (
<Button size="sm" onClick={() => setShowCreate(true)} className="gap-1">
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
{t('skills.newSkill')}
</Button>
)}
</div>
{/* Main content */}
{viewTab === "marketplace" ? (
<MarketplaceBrowser onInstalled={fetchSkills} />
) : (
<div className="flex gap-4 flex-1 min-h-0">
{/* Left: skill list */}
<div className="w-64 shrink-0 flex flex-col border border-border rounded-lg overflow-hidden">
@@ -288,6 +324,7 @@ export function SkillsManager() {
)}
</div>
</div>
)}
<CreateSkillDialog
open={showCreate}