From c4d03c46e68d71fbe1d39eb5a2a9564a807d4bc6 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Fri, 27 Feb 2026 21:43:46 +0800 Subject: [PATCH] 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 --- README.md | 6 + README_CN.md | 6 + docs/wechat-group-qr.png | Bin 0 -> 4968 bytes package-lock.json | 4 +- package.json | 2 +- .../api/skills/marketplace/install/route.ts | 76 ++++++++ .../api/skills/marketplace/readme/route.ts | 95 ++++++++++ .../api/skills/marketplace/remove/route.ts | 76 ++++++++ .../api/skills/marketplace/search/route.ts | 74 ++++++++ src/app/extensions/page.tsx | 61 ++---- src/app/mcp/page.tsx | 13 ++ src/app/plugins/mcp/page.tsx | 2 +- src/app/plugins/page.tsx | 2 +- src/app/skills/page.tsx | 13 ++ src/components/layout/NavRail.tsx | 13 +- .../skills/InstallProgressDialog.tsx | 179 ++++++++++++++++++ src/components/skills/MarketplaceBrowser.tsx | 138 ++++++++++++++ .../skills/MarketplaceSkillCard.tsx | 59 ++++++ .../skills/MarketplaceSkillDetail.tsx | 173 +++++++++++++++++ src/components/skills/SkillListItem.tsx | 26 +-- src/components/skills/SkillsManager.tsx | 47 ++++- src/i18n/en.ts | 17 ++ src/i18n/zh.ts | 17 ++ src/lib/skills-lock.ts | 22 +++ src/types/index.ts | 27 +++ 25 files changed, 1065 insertions(+), 83 deletions(-) create mode 100644 docs/wechat-group-qr.png create mode 100644 src/app/api/skills/marketplace/install/route.ts create mode 100644 src/app/api/skills/marketplace/readme/route.ts create mode 100644 src/app/api/skills/marketplace/remove/route.ts create mode 100644 src/app/api/skills/marketplace/search/route.ts create mode 100644 src/app/mcp/page.tsx create mode 100644 src/app/skills/page.tsx create mode 100644 src/components/skills/InstallProgressDialog.tsx create mode 100644 src/components/skills/MarketplaceBrowser.tsx create mode 100644 src/components/skills/MarketplaceSkillCard.tsx create mode 100644 src/components/skills/MarketplaceSkillDetail.tsx create mode 100644 src/lib/skills-lock.ts diff --git a/README.md b/README.md index f688031..22847f1 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,12 @@ [中文文档](./README_CN.md) | [日本語](./README_JA.md) +### Join the Community / 加入用户群 + +WeChat Group QR Code + +Scan the QR code to join the WeChat user group for discussions, feedback, and updates. + --- ## Features diff --git a/README_CN.md b/README_CN.md index f2ff22e..11b7a20 100644 --- a/README_CN.md +++ b/README_CN.md @@ -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) +### 加入用户群 + +微信用户群二维码 + +扫描二维码加入微信用户群,交流使用心得、反馈问题和获取最新动态。 + --- ## 功能特性 diff --git a/docs/wechat-group-qr.png b/docs/wechat-group-qr.png new file mode 100644 index 0000000000000000000000000000000000000000..d01343fab2a88e42ffcbfbb4cb0c83f13990e9a2 GIT binary patch literal 4968 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 K1{If`9{dOWrX0Ee literal 0 HcmV?d00001 diff --git a/package-lock.json b/package-lock.json index 1ee6bfb..d7dc950 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8bc3828..1731999 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.17.8", + "version": "0.18.0", "private": true, "author": { "name": "op7418", diff --git a/src/app/api/skills/marketplace/install/route.ts b/src/app/api/skills/marketplace/install/route.ts new file mode 100644 index 0000000..636ec15 --- /dev/null +++ b/src/app/api/skills/marketplace/install/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/skills/marketplace/readme/route.ts b/src/app/api/skills/marketplace/readme/route.ts new file mode 100644 index 0000000..342bc92 --- /dev/null +++ b/src/app/api/skills/marketplace/readme/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; + +// In-memory cache: repo source → Map +const treeCache = new Map; 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 { + 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(); + 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 }); + } +} diff --git a/src/app/api/skills/marketplace/remove/route.ts b/src/app/api/skills/marketplace/remove/route.ts new file mode 100644 index 0000000..e112c8e --- /dev/null +++ b/src/app/api/skills/marketplace/remove/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/skills/marketplace/search/route.ts b/src/app/api/skills/marketplace/search/route.ts new file mode 100644 index 0000000..7cfcb06 --- /dev/null +++ b/src/app/api/skills/marketplace/search/route.ts @@ -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; + 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 } + ); + } +} diff --git a/src/app/extensions/page.tsx b/src/app/extensions/page.tsx index 7beac41..c86f70e 100644 --- a/src/app/extensions/page.tsx +++ b/src/app/extensions/page.tsx @@ -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 ( - - - - } - > - + + ); } - -function ExtensionsPageInner() { - const searchParams = useSearchParams(); - const initialTab = (searchParams.get("tab") as ExtTab) || "skills"; - const [tab, setTab] = useState(initialTab); - const { t } = useTranslation(); - - return ( -
-
-

{t('extensions.title')}

- setTab(v as ExtTab)}> - - {t('extensions.skills')} - {t('extensions.mcpServers')} - - -
-
- {tab === "skills" && } - {tab === "mcp" && } -
-
- ); -} diff --git a/src/app/mcp/page.tsx b/src/app/mcp/page.tsx new file mode 100644 index 0000000..01dd3de --- /dev/null +++ b/src/app/mcp/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { McpManager } from "@/components/plugins/McpManager"; + +export default function McpPage() { + return ( +
+
+ +
+
+ ); +} diff --git a/src/app/plugins/mcp/page.tsx b/src/app/plugins/mcp/page.tsx index f9ebb6b..bfc4759 100644 --- a/src/app/plugins/mcp/page.tsx +++ b/src/app/plugins/mcp/page.tsx @@ -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; } diff --git a/src/app/plugins/page.tsx b/src/app/plugins/page.tsx index b34a740..10775c6 100644 --- a/src/app/plugins/page.tsx +++ b/src/app/plugins/page.tsx @@ -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; } diff --git a/src/app/skills/page.tsx b/src/app/skills/page.tsx new file mode 100644 index 0000000..2511292 --- /dev/null +++ b/src/app/skills/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { SkillsManager } from "@/components/skills/SkillsManager"; + +export default function SkillsPage() { + return ( +
+
+ +
+
+ ); +} diff --git a/src/components/layout/NavRail.tsx b/src/components/layout/NavRail.tsx index 98bad19..30c471d 100644 --- a/src/components/layout/NavRail.tsx +++ b/src/components/layout/NavRail.tsx @@ -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 = { '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 ( diff --git a/src/components/skills/InstallProgressDialog.tsx b/src/components/skills/InstallProgressDialog.tsx new file mode 100644 index 0000000..6088dc9 --- /dev/null +++ b/src/components/skills/InstallProgressDialog.tsx @@ -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("running"); + const [logs, setLogs] = useState([]); + const logsEndRef = useRef(null); + const abortRef = useRef(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 ( + + + + + {phase === "running" && ( + + )} + {phase === "success" && ( + + )} + {phase === "error" && ( + + )} + {phase === "running" + ? t('skills.installing') + : phase === "success" + ? t('skills.installSuccess') + : t('skills.installFailed')} + + + +
+ {logs.length === 0 && phase === "running" && ( + {t('skills.installing')} + )} + {logs.map((line, i) => ( +
+ {line} +
+ ))} +
+
+ + + + + +
+ ); +} diff --git a/src/components/skills/MarketplaceBrowser.tsx b/src/components/skills/MarketplaceBrowser.tsx new file mode 100644 index 0000000..a586d89 --- /dev/null +++ b/src/components/skills/MarketplaceBrowser.tsx @@ -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([]); + const [selected, setSelected] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const debounceRef = useRef>(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 ( +
+ {/* Left: search + results */} +
+
+
+ + setSearch(e.target.value)} + className="pl-7 h-8 text-sm" + /> +
+
+
+
+ {loading && results.length === 0 && ( +
+ +
+ )} + {error && ( +
+

{t('skills.marketplaceError')}

+

{error}

+
+ )} + {!loading && !error && results.length === 0 && ( +
+ +

{t('skills.searchNoResults')}

+
+ )} + {results.map((skill) => ( + setSelected(skill)} + /> + ))} +
+
+
+ + {/* Right: detail */} +
+ {selected ? ( + + ) : ( +
+ +
+

{t('skills.marketplaceHint')}

+

{t('skills.marketplaceHintDesc')}

+
+
+ )} +
+
+ ); +} diff --git a/src/components/skills/MarketplaceSkillCard.tsx b/src/components/skills/MarketplaceSkillCard.tsx new file mode 100644 index 0000000..4cae37c --- /dev/null +++ b/src/components/skills/MarketplaceSkillCard.tsx @@ -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 ( +
+ +
+
+ {skill.name} + {skill.isInstalled ? ( + + + {t('skills.installed')} + + ) : null} +
+
+ {skill.source} + {skill.installs > 0 && ( + + + {skill.installs.toLocaleString()} + + )} +
+
+
+ ); +} diff --git a/src/components/skills/MarketplaceSkillDetail.tsx b/src/components/skills/MarketplaceSkillDetail.tsx new file mode 100644 index 0000000..6f19ec5 --- /dev/null +++ b/src/components/skills/MarketplaceSkillDetail.tsx @@ -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(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 ( +
+ {/* Header */} +
+
+
+ +
+
+
+

{skill.name}

+ {skill.isInstalled && ( + + + {t('skills.installed')} + + )} +
+
+ {skill.source} + {githubUrl && ( + + + + )} + {skill.installs > 0 && ( + + + {skill.installs.toLocaleString()} + + )} +
+
+
+ {skill.isInstalled ? ( + + ) : ( + + )} +
+
+
+ + {/* Body — SKILL.md content */} +
+ {readmeLoading ? ( +
+ +
+ ) : displayContent ? ( +
+ + {displayContent} + +
+ ) : ( +
+

{t('skills.noReadme')}

+
+ )} +
+ + +
+ ); +} diff --git a/src/components/skills/SkillListItem.tsx b/src/components/skills/SkillListItem.tsx index b367992..45232e4 100644 --- a/src/components/skills/SkillListItem.tsx +++ b/src/components/skills/SkillListItem.tsx @@ -68,31 +68,7 @@ export function SkillListItem({ >
-
- /{skill.name} - - {skill.source === "global" ? ( - - ) : skill.source === "installed" ? ( - - ) : ( - - )} - {skill.source === "installed" && skill.installedSource - ? `installed:${skill.installedSource}` - : skill.source} - -
+ /{skill.name}

{skill.description}

diff --git a/src/components/skills/SkillsManager.tsx b/src/components/skills/SkillsManager.tsx index 9c6462b..b2ad687 100644 --- a/src/components/skills/SkillsManager.tsx +++ b/src/components/skills/SkillsManager.tsx @@ -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("local"); const fetchSkills = useCallback(async () => { try { @@ -150,14 +155,45 @@ export function SkillsManager() {
{/* Header */}
-

{t('extensions.skills')}

- +

{t('extensions.skills')}

+ {/* Segmented control */} +
+ + +
+
+ {viewTab === "local" && ( + + )}
{/* Main content */} + {viewTab === "marketplace" ? ( + + ) : (
{/* Left: skill list */}
@@ -288,6 +324,7 @@ export function SkillsManager() { )}
+ )} = { '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': '添加服务器', diff --git a/src/lib/skills-lock.ts b/src/lib/skills-lock.ts new file mode 100644 index 0000000..6a52df1 --- /dev/null +++ b/src/lib/skills-lock.ts @@ -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: {} }; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 32ec413..4bf3da4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; +} + +export interface SkillLockEntry { + source: string; + sourceType: string; + sourceUrl: string; + skillPath?: string; + skillFolderHash: string; + installedAt: string; + updatedAt: string; +} + export interface CreateSkillRequest { name: string; description: string;