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:
1 parent
495319fe1f
commit
c4d03c46e6
25 files changed
+1065
-83
No files matched your search
@@ -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
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
[](https://github.com/op7418/CodePilot/releases)
|
||||
[](LICENSE)
|
||||
|
||||
### 加入用户群
|
||||
|
||||
<img src="docs/wechat-group-qr.png" width="240" alt="微信用户群二维码" />
|
||||
|
||||
扫描二维码加入微信用户群,交流使用心得、反馈问题和获取最新动态。
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
Generated
+2
-2
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '添加服务器',
|
||||
|
||||
@@ -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: {} };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user