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

+6
View File
@@ -9,6 +9,12 @@
[中文文档](./README_CN.md) | [日本語](./README_JA.md)
### Join the Community / 加入用户群
<img src="docs/wechat-group-qr.png" width="240" alt="WeChat Group QR Code" />
Scan the QR code to join the WeChat user group for discussions, feedback, and updates.
---
## Features
+6
View File
@@ -9,6 +9,12 @@
[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey)](https://github.com/op7418/CodePilot/releases)
[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
### 加入用户群
<img src="docs/wechat-group-qr.png" width="240" alt="微信用户群二维码" />
扫描二维码加入微信用户群,交流使用心得、反馈问题和获取最新动态。
---
## 功能特性
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "codepilot",
"version": "0.17.8",
"version": "0.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codepilot",
"version": "0.17.8",
"version": "0.18.0",
"dependencies": {
"@ai-sdk/anthropic": "^3.0.47",
"@ai-sdk/google": "^3.0.31",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codepilot",
"version": "0.17.8",
"version": "0.18.0",
"private": true,
"author": {
"name": "op7418",
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import { spawn } from "child_process";
export async function POST(request: Request) {
try {
const body = await request.json();
const { source, global: isGlobal } = body as { source: string; global?: boolean };
if (!source || typeof source !== "string") {
return NextResponse.json(
{ error: "source is required" },
{ status: 400 }
);
}
const args = ["skills", "add", source, "-y", "--agent", "claude-code"];
if (isGlobal !== false) {
args.splice(3, 0, "-g");
}
const child = spawn("npx", args, {
env: { ...process.env },
shell: true,
});
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const send = (event: string, data: string) => {
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
);
};
child.stdout?.on("data", (chunk: Buffer) => {
send("output", chunk.toString());
});
child.stderr?.on("data", (chunk: Buffer) => {
send("output", chunk.toString());
});
child.on("close", (code) => {
if (code === 0) {
send("done", "Install completed successfully");
} else {
send("error", `Process exited with code ${code}`);
}
controller.close();
});
child.on("error", (err) => {
send("error", err.message);
controller.close();
});
},
cancel() {
child.kill();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
} catch (error) {
console.error("[marketplace/install] Error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Install failed" },
{ status: 500 }
);
}
}
@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from "next/server";
// In-memory cache: repo source → Map<skillId, raw-content path>
const treeCache = new Map<string, { paths: Map<string, string>; ts: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
/**
* Use GitHub Git Trees API to find the actual path of a SKILL.md for a given skillId.
* Repos have widely varying structures, so we can't guess the path — we scan the tree.
*/
async function findSkillPath(
source: string,
skillId: string,
signal: AbortSignal
): Promise<string | null> {
const cached = treeCache.get(source);
if (cached && Date.now() - cached.ts < CACHE_TTL) {
return cached.paths.get(skillId) ?? null;
}
// Fetch the full recursive tree for the repo's default branch
const treeUrl = `https://api.github.com/repos/${source}/git/trees/HEAD?recursive=1`;
const res = await fetch(treeUrl, {
signal,
headers: { Accept: "application/vnd.github+json" },
});
if (!res.ok) return null;
const data = await res.json();
const tree: Array<{ path: string; type: string }> = data.tree || [];
// Index all SKILL.md files by their parent directory name (= skillId)
const paths = new Map<string, string>();
for (const item of tree) {
if (item.type !== "blob") continue;
if (!item.path.endsWith("/SKILL.md")) continue;
// Extract the folder name right before SKILL.md
const parts = item.path.split("/");
const folder = parts[parts.length - 2];
if (folder) {
// If there are duplicates, prefer shorter paths (closer to root)
if (!paths.has(folder) || item.path.length < (paths.get(folder)?.length ?? Infinity)) {
paths.set(folder, item.path);
}
}
}
treeCache.set(source, { paths, ts: Date.now() });
return paths.get(skillId) ?? null;
}
export async function GET(request: NextRequest) {
try {
const source = request.nextUrl.searchParams.get("source") || "";
const skillId = request.nextUrl.searchParams.get("skillId") || "";
if (!source || !skillId) {
return NextResponse.json(
{ error: "source and skillId are required" },
{ status: 400 }
);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
// 1. Find the actual path via tree API (cached per repo)
const skillPath = await findSkillPath(source, skillId, controller.signal);
if (!skillPath) {
return NextResponse.json({ content: null }, { status: 200 });
}
// 2. Fetch the raw SKILL.md content
const rawUrl = `https://raw.githubusercontent.com/${source}/HEAD/${skillPath}`;
const res = await fetch(rawUrl, { signal: controller.signal });
if (!res.ok) {
return NextResponse.json({ content: null }, { status: 200 });
}
const content = await res.text();
return NextResponse.json({ content });
} finally {
clearTimeout(timeout);
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return NextResponse.json({ content: null }, { status: 200 });
}
return NextResponse.json({ content: null }, { status: 200 });
}
}
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import { spawn } from "child_process";
export async function POST(request: Request) {
try {
const body = await request.json();
const { skill, global: isGlobal } = body as { skill: string; global?: boolean };
if (!skill || typeof skill !== "string") {
return NextResponse.json(
{ error: "skill name is required" },
{ status: 400 }
);
}
const args = ["skills", "remove", skill, "-y", "--agent", "claude-code"];
if (isGlobal !== false) {
args.splice(3, 0, "-g");
}
const child = spawn("npx", args, {
env: { ...process.env },
shell: true,
});
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const send = (event: string, data: string) => {
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
);
};
child.stdout?.on("data", (chunk: Buffer) => {
send("output", chunk.toString());
});
child.stderr?.on("data", (chunk: Buffer) => {
send("output", chunk.toString());
});
child.on("close", (code) => {
if (code === 0) {
send("done", "Uninstall completed successfully");
} else {
send("error", `Process exited with code ${code}`);
}
controller.close();
});
child.on("error", (err) => {
send("error", err.message);
controller.close();
});
},
cancel() {
child.kill();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
} catch (error) {
console.error("[marketplace/remove] Error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Remove failed" },
{ status: 500 }
);
}
}
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { readLockFile } from "@/lib/skills-lock";
import type { MarketplaceSkill } from "@/types";
export async function GET(request: NextRequest) {
try {
const q = request.nextUrl.searchParams.get("q") || "";
const limit = request.nextUrl.searchParams.get("limit") || "20";
// Skills.sh requires query >= 2 chars; use a popular fallback for empty/short queries
const query = q.length >= 2 ? q : "claude";
const url = new URL("https://skills.sh/api/search");
url.searchParams.set("q", query);
url.searchParams.set("limit", limit);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
let response: Response;
try {
response = await fetch(url.toString(), { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
if (!response.ok) {
return NextResponse.json(
{ error: `Skills.sh API returned ${response.status}` },
{ status: 502 }
);
}
const data = await response.json();
const results: unknown[] = Array.isArray(data) ? data : (data.results || data.skills || []);
// Read lock file to mark installed skills
const lockFile = readLockFile();
const installedSources = new Set(
Object.values(lockFile.skills).map((entry) => entry.source)
);
const skills: MarketplaceSkill[] = results.map((item: unknown) => {
const r = item as Record<string, unknown>;
const source = String(r.source || r.slug || r.name || "");
const installedEntry = Object.values(lockFile.skills).find(
(entry) => entry.source === source
);
return {
id: String(r.id || r.slug || r.name || ""),
skillId: String(r.skillId || r.name || r.slug || ""),
name: String(r.name || r.slug || ""),
installs: Number(r.installs || r.downloads || 0),
source,
isInstalled: installedSources.has(source),
installedAt: installedEntry?.installedAt,
};
});
return NextResponse.json({ skills });
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return NextResponse.json(
{ error: "Skills.sh API request timed out" },
{ status: 504 }
);
}
console.error("[marketplace/search] Error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Search failed" },
{ status: 502 }
);
}
}
+19 -42
View File
@@ -1,51 +1,28 @@
"use client";
import { Suspense, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { HugeiconsIcon } from "@hugeicons/react";
import { Loading02Icon } from "@hugeicons/core-free-icons";
import { SkillsManager } from "@/components/skills/SkillsManager";
import { McpManager } from "@/components/plugins/McpManager";
import { useTranslation } from "@/hooks/useTranslation";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, Suspense } from "react";
type ExtTab = "skills" | "mcp";
function ExtensionsRedirect() {
const router = useRouter();
const searchParams = useSearchParams();
const tab = searchParams.get("tab");
useEffect(() => {
if (tab === "mcp") {
router.replace("/mcp");
} else {
router.replace("/skills");
}
}, [router, tab]);
return null;
}
export default function ExtensionsPage() {
return (
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
}
>
<ExtensionsPageInner />
<Suspense fallback={null}>
<ExtensionsRedirect />
</Suspense>
);
}
function ExtensionsPageInner() {
const searchParams = useSearchParams();
const initialTab = (searchParams.get("tab") as ExtTab) || "skills";
const [tab, setTab] = useState<ExtTab>(initialTab);
const { t } = useTranslation();
return (
<div className="flex h-full flex-col">
<div className="px-6 pt-4 pb-0">
<h1 className="text-xl font-semibold mb-3">{t('extensions.title')}</h1>
<Tabs value={tab} onValueChange={(v) => setTab(v as ExtTab)}>
<TabsList>
<TabsTrigger value="skills">{t('extensions.skills')}</TabsTrigger>
<TabsTrigger value="mcp">{t('extensions.mcpServers')}</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex-1 overflow-hidden p-6 flex flex-col min-h-0">
{tab === "skills" && <SkillsManager />}
{tab === "mcp" && <McpManager />}
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { McpManager } from "@/components/plugins/McpManager";
export default function McpPage() {
return (
<div className="flex h-full flex-col">
<div className="flex-1 overflow-hidden p-6 flex flex-col min-h-0">
<McpManager />
</div>
</div>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
export default function McpRedirect() {
const router = useRouter();
useEffect(() => {
router.replace("/extensions?tab=mcp");
router.replace("/mcp");
}, [router]);
return null;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
export default function PluginsRedirect() {
const router = useRouter();
useEffect(() => {
router.replace("/extensions");
router.replace("/skills");
}, [router]);
return null;
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { SkillsManager } from "@/components/skills/SkillsManager";
export default function SkillsPage() {
return (
<div className="flex h-full flex-col">
<div className="flex-1 overflow-hidden p-6 flex flex-col min-h-0">
<SkillsManager />
</div>
</div>
);
}
+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}
+17
View File
@@ -200,6 +200,23 @@ const en = {
'skills.splitView': 'Split view',
'skills.deleteConfirm': 'Click again to confirm',
'skills.placeholder': 'Write your skill prompt in Markdown...',
'skills.mySkills': 'My Skills',
'skills.marketplace': 'Marketplace',
'skills.marketplaceSearch': 'Search Skills.sh...',
'skills.install': 'Install',
'skills.installed': 'Installed',
'skills.installing': 'Installing...',
'skills.uninstall': 'Uninstall',
'skills.installs': 'installs',
'skills.source': 'Source',
'skills.installedAt': 'Installed At',
'skills.installSuccess': 'Installation Complete',
'skills.installFailed': 'Installation Failed',
'skills.searchNoResults': 'No skills found',
'skills.marketplaceError': 'Failed to load marketplace',
'skills.marketplaceHint': 'Browse the Skills Marketplace',
'skills.marketplaceHintDesc': 'Search and install community skills from Skills.sh',
'skills.noReadme': 'No description available for this skill',
// ── MCP ─────────────────────────────────────────────────────
'mcp.addServer': 'Add Server',
+17
View File
@@ -197,6 +197,23 @@ const zh: Record<TranslationKey, string> = {
'skills.splitView': '分屏视图',
'skills.deleteConfirm': '再次点击以确认',
'skills.placeholder': '用 Markdown 编写技能提示词...',
'skills.mySkills': '我的技能',
'skills.marketplace': '技能市场',
'skills.marketplaceSearch': '搜索 Skills.sh...',
'skills.install': '安装',
'skills.installed': '已安装',
'skills.installing': '安装中...',
'skills.uninstall': '卸载',
'skills.installs': '次安装',
'skills.source': '来源',
'skills.installedAt': '安装时间',
'skills.installSuccess': '安装完成',
'skills.installFailed': '安装失败',
'skills.searchNoResults': '未找到技能',
'skills.marketplaceError': '加载技能市场失败',
'skills.marketplaceHint': '浏览技能市场',
'skills.marketplaceHintDesc': '搜索并安装来自 Skills.sh 的社区技能',
'skills.noReadme': '暂无技能描述',
// ── MCP ─────────────────────────────────────────────────────
'mcp.addServer': '添加服务器',
+22
View File
@@ -0,0 +1,22 @@
import fs from "fs";
import path from "path";
import os from "os";
import type { SkillLockFile } from "@/types";
const LOCK_FILE_PATH = path.join(os.homedir(), ".agents", ".skill-lock.json");
export function readLockFile(): SkillLockFile {
try {
if (!fs.existsSync(LOCK_FILE_PATH)) {
return { version: 0, skills: {} };
}
const raw = fs.readFileSync(LOCK_FILE_PATH, "utf-8");
const parsed = JSON.parse(raw);
return {
version: parsed.version ?? 0,
skills: parsed.skills ?? {},
};
} catch {
return { version: 0, skills: {} };
}
}
+27
View File
@@ -232,6 +232,33 @@ export interface SkillDefinition {
enabled: boolean;
}
// --- Marketplace (Skills.sh) ---
export interface MarketplaceSkill {
id: string;
skillId: string; // e.g. "git-commit"
name: string;
installs: number;
source: string; // e.g. "owner/repo"
isInstalled?: boolean;
installedAt?: string;
}
export interface SkillLockFile {
version: number;
skills: Record<string, SkillLockEntry>;
}
export interface SkillLockEntry {
source: string;
sourceType: string;
sourceUrl: string;
skillPath?: string;
skillFolderHash: string;
installedAt: string;
updatedAt: string;
}
export interface CreateSkillRequest {
name: string;
description: string;