From f7e3bc746b6f220de3e686043591427bfc1a2417 Mon Sep 17 00:00:00 2001 From: xuxu777xu <1728019186@qq.com> Date: Sat, 7 Mar 2026 12:55:58 +0800 Subject: [PATCH] feat(plugins): add Claude plugin marketplace - add plugin marketplace routes, page, nav entry, and UI components - harden install and uninstall CLI invocation with validated plugin refs - add marketplace i18n, types, and CLI safety tests --- .../unit/plugin-marketplace-cli.test.ts | 121 +++++++++ .../api/plugins/marketplace/browse/route.ts | 216 +++++++++++++++ .../api/plugins/marketplace/detail/route.ts | 158 +++++++++++ .../api/plugins/marketplace/install/route.ts | 78 ++++++ .../plugins/marketplace/uninstall/route.ts | 78 ++++++ src/app/plugins-market/page.tsx | 19 ++ src/components/layout/NavRail.tsx | 3 + .../marketplace/PluginInstallDialog.tsx | 202 ++++++++++++++ .../marketplace/PluginMarketplaceBrowser.tsx | 208 ++++++++++++++ .../marketplace/PluginMarketplaceCard.tsx | 103 +++++++ .../marketplace/PluginMarketplaceDetail.tsx | 256 ++++++++++++++++++ src/i18n/en.ts | 45 +++ src/i18n/zh.ts | 45 +++ src/lib/platform.ts | 2 +- src/lib/plugin-marketplace-cli.ts | 126 +++++++++ src/types/index.ts | 42 +++ 16 files changed, 1701 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/unit/plugin-marketplace-cli.test.ts create mode 100644 src/app/api/plugins/marketplace/browse/route.ts create mode 100644 src/app/api/plugins/marketplace/detail/route.ts create mode 100644 src/app/api/plugins/marketplace/install/route.ts create mode 100644 src/app/api/plugins/marketplace/uninstall/route.ts create mode 100644 src/app/plugins-market/page.tsx create mode 100644 src/components/plugins/marketplace/PluginInstallDialog.tsx create mode 100644 src/components/plugins/marketplace/PluginMarketplaceBrowser.tsx create mode 100644 src/components/plugins/marketplace/PluginMarketplaceCard.tsx create mode 100644 src/components/plugins/marketplace/PluginMarketplaceDetail.tsx create mode 100644 src/lib/plugin-marketplace-cli.ts diff --git a/src/__tests__/unit/plugin-marketplace-cli.test.ts b/src/__tests__/unit/plugin-marketplace-cli.test.ts new file mode 100644 index 00000000..b7249d59 --- /dev/null +++ b/src/__tests__/unit/plugin-marketplace-cli.test.ts @@ -0,0 +1,121 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildMarketplacePluginRef, + createMarketplacePluginSpawnSpec, +} from '../../lib/plugin-marketplace-cli'; + +describe('buildMarketplacePluginRef', () => { + it('should build a plugin reference from safe name and marketplace', () => { + assert.equal( + buildMarketplacePluginRef({ name: 'safe-plugin', marketplace: 'official.market' }), + 'safe-plugin@official.market' + ); + }); + + it('should allow plugin names without marketplace', () => { + assert.equal( + buildMarketplacePluginRef({ name: 'safe_plugin-1.0' }), + 'safe_plugin-1.0' + ); + }); + + it('should reject unsafe plugin names', () => { + assert.throws( + () => buildMarketplacePluginRef({ name: 'safe-plugin; rm -rf /' }), + /Invalid plugin name/ + ); + }); + + it('should reject unsafe marketplace names', () => { + assert.throws( + () => buildMarketplacePluginRef({ name: 'safe-plugin', marketplace: 'official && evil' }), + /Invalid marketplace name/ + ); + }); +}); + +describe('createMarketplacePluginSpawnSpec', () => { + it('should create install args and shell=true only for trusted cmd wrappers', () => { + const spec = createMarketplacePluginSpawnSpec( + { + action: 'install', + name: 'safe-plugin', + marketplace: 'official.market', + scope: 'project', + }, + { + findClaudeBinary: () => 'C:\\Users\\Admin\\AppData\\Roaming\\npm\\claude.cmd', + getExpandedPath: () => 'TEST_PATH', + needsShell: (binPath) => /\.cmd$/i.test(binPath), + env: { HOME: 'test-home' }, + } + ); + + assert.equal(spec.command, 'C:\\Users\\Admin\\AppData\\Roaming\\npm\\claude.cmd'); + assert.deepEqual(spec.args, ['plugin', 'install', 'safe-plugin@official.market', '--scope', 'project']); + assert.equal(spec.options.shell, true); + assert.ok(spec.options.env); + assert.equal(spec.options.env.PATH, 'TEST_PATH'); + assert.equal(spec.options.env.HOME, 'test-home'); + }); + + it('should create uninstall args without shell when binary is not a wrapper', () => { + const spec = createMarketplacePluginSpawnSpec( + { + action: 'uninstall', + name: 'safe-plugin', + marketplace: 'official.market', + scope: 'user', + }, + { + findClaudeBinary: () => '/usr/local/bin/claude', + getExpandedPath: () => '/usr/local/bin', + needsShell: () => false, + env: {}, + } + ); + + assert.equal(spec.command, '/usr/local/bin/claude'); + assert.deepEqual(spec.args, ['plugin', 'uninstall', 'safe-plugin@official.market', '--scope', 'user']); + assert.equal(spec.options.shell, false); + }); + + it('should reject unsupported install scopes', () => { + assert.throws( + () => createMarketplacePluginSpawnSpec( + { + action: 'install', + name: 'safe-plugin', + scope: 'admin' as 'user', + }, + { + findClaudeBinary: () => '/usr/local/bin/claude', + getExpandedPath: () => '/usr/local/bin', + needsShell: () => false, + env: {}, + } + ), + /Invalid plugin scope/ + ); + }); + + it('should fail fast when Claude CLI is unavailable', () => { + assert.throws( + () => createMarketplacePluginSpawnSpec( + { + action: 'install', + name: 'safe-plugin', + }, + { + findClaudeBinary: () => undefined, + getExpandedPath: () => '', + needsShell: () => false, + env: {}, + } + ), + /Claude CLI not found/ + ); + }); +}); diff --git a/src/app/api/plugins/marketplace/browse/route.ts b/src/app/api/plugins/marketplace/browse/route.ts new file mode 100644 index 00000000..01584089 --- /dev/null +++ b/src/app/api/plugins/marketplace/browse/route.ts @@ -0,0 +1,216 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import type { MarketplacePlugin, PluginComponents } from "@/types"; + +function getMarketplacesDir(): string { + return path.join(os.homedir(), ".claude", "plugins", "marketplaces"); +} + +function getInstalledPlugins(): Map { + const installed = new Map(); + + // Primary source: ~/.claude/plugins/installed_plugins.json + const installedPluginsPath = path.join( + os.homedir(), ".claude", "plugins", "installed_plugins.json" + ); + try { + if (fs.existsSync(installedPluginsPath)) { + const raw = JSON.parse(fs.readFileSync(installedPluginsPath, "utf-8")); + const plugins = raw.plugins || {}; + for (const [key, entries] of Object.entries(plugins)) { + // key format: "name@marketplace" + const pluginName = key.split("@")[0]; + const arr = entries as Array<{ scope?: string }>; + const scope = arr[0]?.scope || "user"; + installed.set(pluginName, { scope }); + installed.set(key, { scope }); + } + } + } catch { + // ignore + } + + // Fallback: ~/.claude/settings.json enabledPlugins + const settingsPath = path.join(os.homedir(), ".claude", "settings.json"); + try { + if (fs.existsSync(settingsPath)) { + const raw = JSON.parse(fs.readFileSync(settingsPath, "utf-8")); + const plugins = raw.enabledPlugins || {}; + if (typeof plugins === "object" && !Array.isArray(plugins)) { + for (const key of Object.keys(plugins)) { + const pluginName = key.split("@")[0]; + if (!installed.has(pluginName)) { + installed.set(pluginName, { scope: "user" }); + } + if (!installed.has(key)) { + installed.set(key, { scope: "user" }); + } + } + } + } + } catch { + // ignore + } + + return installed; +} + +function detectComponents(pluginDir: string): PluginComponents { + return { + hasSkills: fs.existsSync(path.join(pluginDir, "skills")), + hasAgents: fs.existsSync(path.join(pluginDir, "agents")), + hasHooks: + fs.existsSync(path.join(pluginDir, "hooks")) || + fs.existsSync(path.join(pluginDir, "hooks.json")), + hasMcp: fs.existsSync(path.join(pluginDir, ".mcp.json")), + hasLsp: fs.existsSync(path.join(pluginDir, ".lsp.json")), + hasCommands: fs.existsSync(path.join(pluginDir, "commands")), + }; +} + +function readPluginManifest( + pluginDir: string +): Record | null { + const manifestPath = path.join(pluginDir, ".claude-plugin", "plugin.json"); + try { + if (!fs.existsSync(manifestPath)) return null; + return JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + } catch { + return null; + } +} + +function readMarketplaceJson( + marketplaceDir: string +): Record | null { + // Try .claude-plugin/marketplace.json first + const p1 = path.join(marketplaceDir, ".claude-plugin", "marketplace.json"); + const p2 = path.join(marketplaceDir, "marketplace.json"); + for (const p of [p1, p2]) { + try { + if (fs.existsSync(p)) { + return JSON.parse(fs.readFileSync(p, "utf-8")); + } + } catch { + // ignore + } + } + return null; +} + +export async function GET(request: NextRequest) { + try { + const q = (request.nextUrl.searchParams.get("q") || "").toLowerCase(); + const category = request.nextUrl.searchParams.get("category") || ""; + + const marketplacesDir = getMarketplacesDir(); + const installedPlugins = getInstalledPlugins(); + const plugins: MarketplacePlugin[] = []; + + if (!fs.existsSync(marketplacesDir)) { + return NextResponse.json({ plugins: [] }); + } + + const marketplaces = fs.readdirSync(marketplacesDir, { + withFileTypes: true, + }); + + for (const mkt of marketplaces) { + if (!mkt.isDirectory()) continue; + const mktDir = path.join(marketplacesDir, mkt.name); + const mktJson = readMarketplaceJson(mktDir); + const marketplaceName = (mktJson?.name as string) || mkt.name; + + // Scan plugins directory + const pluginsDir = path.join(mktDir, "plugins"); + if (!fs.existsSync(pluginsDir)) continue; + + let pluginEntries: fs.Dirent[]; + try { + pluginEntries = fs.readdirSync(pluginsDir, { withFileTypes: true }); + } catch { + continue; + } + + // Also get plugin info from marketplace.json if available + const mktPluginList = Array.isArray(mktJson?.plugins) + ? (mktJson.plugins as Array>) + : []; + const mktPluginMap = new Map>(); + for (const mp of mktPluginList) { + if (mp.name) mktPluginMap.set(String(mp.name), mp); + } + + for (const entry of pluginEntries) { + if (!entry.isDirectory()) continue; + const pluginDir = path.join(pluginsDir, entry.name); + const manifest = readPluginManifest(pluginDir); + const mktEntry = mktPluginMap.get(entry.name); + + const name = (manifest?.name as string) || entry.name; + const description = + (manifest?.description as string) || + (mktEntry?.description as string) || + ""; + const version = + (manifest?.version as string) || + (mktEntry?.version as string) || + undefined; + const authorObj = (manifest?.author || mktEntry?.author) as + | { name: string; email?: string; url?: string } + | string + | undefined; + const author = + typeof authorObj === "string" + ? { name: authorObj } + : authorObj || undefined; + const cat = + (manifest?.category as string) || + (mktEntry?.category as string) || + undefined; + + const components = detectComponents(pluginDir); + const isInstalled = installedPlugins.has(name); + const installedInfo = installedPlugins.get(name); + + const plugin: MarketplacePlugin = { + name, + description, + version, + author, + marketplace: marketplaceName, + category: cat, + components, + isInstalled, + installedScope: installedInfo?.scope as + | "user" + | "project" + | "local" + | undefined, + }; + + // Apply filters + if (q) { + const searchable = `${name} ${description} ${cat || ""}`.toLowerCase(); + if (!searchable.includes(q)) continue; + } + if (category && cat !== category) continue; + + plugins.push(plugin); + } + } + + return NextResponse.json({ plugins }); + } catch (error) { + console.error("[plugins/marketplace/browse] Error:", error); + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Failed to browse plugins", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/plugins/marketplace/detail/route.ts b/src/app/api/plugins/marketplace/detail/route.ts new file mode 100644 index 00000000..05b6ff32 --- /dev/null +++ b/src/app/api/plugins/marketplace/detail/route.ts @@ -0,0 +1,158 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import os from "os"; + +function getMarketplacesDir(): string { + return path.join(os.homedir(), ".claude", "plugins", "marketplaces"); +} + +function findPluginDir(marketplace: string, pluginName: string): string | null { + const marketplacesDir = getMarketplacesDir(); + if (!fs.existsSync(marketplacesDir)) return null; + + const marketplaces = fs.readdirSync(marketplacesDir, { withFileTypes: true }); + for (const mkt of marketplaces) { + if (!mkt.isDirectory()) continue; + const mktDir = path.join(marketplacesDir, mkt.name); + + // Check if this marketplace matches by name or directory name + let mktName = mkt.name; + const mktJsonPath = path.join(mktDir, ".claude-plugin", "marketplace.json"); + const mktJsonPath2 = path.join(mktDir, "marketplace.json"); + for (const p of [mktJsonPath, mktJsonPath2]) { + try { + if (fs.existsSync(p)) { + const data = JSON.parse(fs.readFileSync(p, "utf-8")); + if (data.name) mktName = data.name; + break; + } + } catch { /* ignore */ } + } + + if (mktName !== marketplace && mkt.name !== marketplace) continue; + + const pluginDir = path.join(mktDir, "plugins", pluginName); + if (fs.existsSync(pluginDir)) return pluginDir; + } + + return null; +} + +export async function GET(request: NextRequest) { + try { + const marketplace = request.nextUrl.searchParams.get("marketplace") || ""; + const pluginName = request.nextUrl.searchParams.get("name") || ""; + + if (!marketplace || !pluginName) { + return NextResponse.json( + { error: "marketplace and name are required" }, + { status: 400 } + ); + } + + const pluginDir = findPluginDir(marketplace, pluginName); + if (!pluginDir) { + return NextResponse.json( + { error: "Plugin not found" }, + { status: 404 } + ); + } + + // Read manifest + let manifest: Record = {}; + const manifestPath = path.join(pluginDir, ".claude-plugin", "plugin.json"); + try { + if (fs.existsSync(manifestPath)) { + manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + } + } catch { /* ignore */ } + + // Read README + let readme: string | null = null; + const readmeNames = ["README.md", "readme.md", "Readme.md", "README"]; + for (const name of readmeNames) { + const readmePath = path.join(pluginDir, name); + if (fs.existsSync(readmePath)) { + readme = fs.readFileSync(readmePath, "utf-8"); + break; + } + } + + // Detect components with details + const components: Record = {}; + + // Skills + const skillsDir = path.join(pluginDir, "skills"); + if (fs.existsSync(skillsDir)) { + try { + components.skills = fs + .readdirSync(skillsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); + } catch { /* ignore */ } + } + + // Commands + const commandsDir = path.join(pluginDir, "commands"); + if (fs.existsSync(commandsDir)) { + try { + components.commands = fs + .readdirSync(commandsDir) + .filter((f) => f.endsWith(".md")) + .map((f) => f.replace(/\.md$/, "")); + } catch { /* ignore */ } + } + + // Agents + const agentsDir = path.join(pluginDir, "agents"); + if (fs.existsSync(agentsDir)) { + try { + components.agents = fs + .readdirSync(agentsDir) + .filter((f) => f.endsWith(".md")) + .map((f) => f.replace(/\.md$/, "")); + } catch { /* ignore */ } + } + + // Hooks + const hasHooks = + fs.existsSync(path.join(pluginDir, "hooks")) || + fs.existsSync(path.join(pluginDir, "hooks.json")); + if (hasHooks) { + components.hooks = ["configured"]; + } + + // MCP + if (fs.existsSync(path.join(pluginDir, ".mcp.json"))) { + try { + const mcpData = JSON.parse( + fs.readFileSync(path.join(pluginDir, ".mcp.json"), "utf-8") + ); + components.mcp = Object.keys(mcpData.mcpServers || mcpData || {}); + } catch { /* ignore */ } + } + + // LSP + if (fs.existsSync(path.join(pluginDir, ".lsp.json"))) { + try { + const lspData = JSON.parse( + fs.readFileSync(path.join(pluginDir, ".lsp.json"), "utf-8") + ); + components.lsp = Object.keys(lspData); + } catch { /* ignore */ } + } + + return NextResponse.json({ + manifest, + readme, + components, + }); + } catch (error) { + console.error("[plugins/marketplace/detail] Error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to load plugin details" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/plugins/marketplace/install/route.ts b/src/app/api/plugins/marketplace/install/route.ts new file mode 100644 index 00000000..fdaa30d0 --- /dev/null +++ b/src/app/api/plugins/marketplace/install/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import { spawn } from "child_process"; + +import { + createMarketplacePluginSpawnSpec, + PluginMarketplaceCliError, +} from "@/lib/plugin-marketplace-cli"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { name, marketplace, scope } = body as { + name: string; + marketplace?: string; + scope?: "user" | "project" | "local"; + }; + + const spec = createMarketplacePluginSpawnSpec({ + action: "install", + name, + marketplace, + scope, + }); + + const child = spawn(spec.command, spec.args, spec.options); + + 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", "Plugin installed 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("[plugins/marketplace/install] Error:", error); + const status = error instanceof PluginMarketplaceCliError ? error.status : 500; + return NextResponse.json( + { error: error instanceof Error ? error.message : "Install failed" }, + { status } + ); + } +} diff --git a/src/app/api/plugins/marketplace/uninstall/route.ts b/src/app/api/plugins/marketplace/uninstall/route.ts new file mode 100644 index 00000000..635bf706 --- /dev/null +++ b/src/app/api/plugins/marketplace/uninstall/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import { spawn } from "child_process"; + +import { + createMarketplacePluginSpawnSpec, + PluginMarketplaceCliError, +} from "@/lib/plugin-marketplace-cli"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { name, marketplace, scope } = body as { + name: string; + marketplace?: string; + scope?: "user" | "project" | "local"; + }; + + const spec = createMarketplacePluginSpawnSpec({ + action: "uninstall", + name, + marketplace, + scope, + }); + + const child = spawn(spec.command, spec.args, spec.options); + + 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", "Plugin uninstalled 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("[plugins/marketplace/uninstall] Error:", error); + const status = error instanceof PluginMarketplaceCliError ? error.status : 500; + return NextResponse.json( + { error: error instanceof Error ? error.message : "Uninstall failed" }, + { status } + ); + } +} diff --git a/src/app/plugins-market/page.tsx b/src/app/plugins-market/page.tsx new file mode 100644 index 00000000..d684f58b --- /dev/null +++ b/src/app/plugins-market/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { PluginMarketplaceBrowser } from "@/components/plugins/marketplace/PluginMarketplaceBrowser"; +import { useTranslation } from "@/hooks/useTranslation"; + +export default function PluginsMarketPage() { + const { t } = useTranslation(); + return ( +
+
+
+

{t("pluginMarket.title")}

+ {t("pluginMarket.subtitle")} +
+ +
+
+ ); +} diff --git a/src/components/layout/NavRail.tsx b/src/components/layout/NavRail.tsx index cddbcd58..e2dfe685 100644 --- a/src/components/layout/NavRail.tsx +++ b/src/components/layout/NavRail.tsx @@ -9,6 +9,7 @@ import { Message02Icon, ZapIcon, Plug01Icon, + Store01Icon, Image01Icon, Settings02Icon, Wifi01Icon, @@ -38,6 +39,7 @@ const navItems = [ { href: "/chat", label: "Chats", icon: Message02Icon }, { href: "/skills", label: "Skills", icon: ZapIcon }, { href: "/mcp", label: "MCP", icon: Plug01Icon }, + { href: "/plugins-market", label: "Plugins", icon: Store01Icon }, { href: "/gallery", label: "Gallery", icon: Image01Icon }, { href: "/bridge", label: "Bridge", icon: Wifi01Icon }, { href: "/settings", label: "Settings", icon: Settings02Icon }, @@ -52,6 +54,7 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi 'Chats': 'nav.chats', 'Skills': 'extensions.skills', 'MCP': 'extensions.mcpServers', + 'Plugins': 'pluginMarket.title', 'Gallery': 'gallery.title', 'Bridge': 'nav.bridge', 'Settings': 'nav.settings', diff --git a/src/components/plugins/marketplace/PluginInstallDialog.tsx b/src/components/plugins/marketplace/PluginInstallDialog.tsx new file mode 100644 index 00000000..6faee9d9 --- /dev/null +++ b/src/components/plugins/marketplace/PluginInstallDialog.tsx @@ -0,0 +1,202 @@ +"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 PluginInstallDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + action: "install" | "uninstall"; + pluginName: string; + marketplace: string; + onComplete: () => void; +} + +type Phase = "running" | "success" | "error"; + +export function PluginInstallDialog({ + open, + onOpenChange, + action, + pluginName, + marketplace, + onComplete, +}: PluginInstallDialogProps) { + 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/plugins/marketplace/install" + : "/api/plugins/marketplace/uninstall"; + + const body = + action === "install" + ? { name: pluginName, marketplace, scope: "user" } + : { name: pluginName, marketplace, scope: "user" }; + + 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, pluginName, marketplace]); + + 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" + ? action === "install" + ? t("pluginMarket.installing") + : t("pluginMarket.uninstalling") + : phase === "success" + ? action === "install" + ? t("pluginMarket.installSuccess") + : t("pluginMarket.uninstallSuccess") + : action === "install" + ? t("pluginMarket.installFailed") + : t("pluginMarket.uninstallFailed")} + + + +
+ {logs.length === 0 && phase === "running" && ( + + {action === "install" + ? t("pluginMarket.installing") + : t("pluginMarket.uninstalling")} + + )} + {logs.map((line, i) => ( +
+ {line} +
+ ))} +
+
+ + + + + +
+ ); +} diff --git a/src/components/plugins/marketplace/PluginMarketplaceBrowser.tsx b/src/components/plugins/marketplace/PluginMarketplaceBrowser.tsx new file mode 100644 index 00000000..9a88520e --- /dev/null +++ b/src/components/plugins/marketplace/PluginMarketplaceBrowser.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { Input } from "@/components/ui/input"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + Search01Icon, + Loading02Icon, + Plug01Icon, +} from "@hugeicons/core-free-icons"; +import { useTranslation } from "@/hooks/useTranslation"; +import { PluginMarketplaceCard } from "./PluginMarketplaceCard"; +import { PluginMarketplaceDetail } from "./PluginMarketplaceDetail"; +import { cn } from "@/lib/utils"; +import type { MarketplacePlugin } from "@/types"; + + +export function PluginMarketplaceBrowser() { + const { t } = useTranslation(); + const [tab, setTab] = useState<"marketplace" | "installed">("marketplace"); + 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); + const res = await fetch(`/api/plugins/marketplace/browse?${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.plugins || []); + } catch (err) { + setError((err as Error).message); + setResults([]); + } finally { + setLoading(false); + } + }, + [] + ); + + // Initial load + 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(() => { + doSearch(search); + }, [search, doSearch]); + + const displayResults = useMemo( + () => tab === "installed" ? results.filter((p) => p.isInstalled) : results, + [tab, results] + ); + + return ( +
+ {/* Tab switcher */} +
+ + +
+ + {/* Main content */} +
+ {/* Left: search + results */} +
+
+
+ + setSearch(e.target.value)} + className="pl-7 h-8 text-sm" + /> +
+
+
+
+ {loading && results.length === 0 && ( +
+ +
+ )} + {error && ( +
+

+ {t("pluginMarket.error")} +

+

{error}

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

+ {tab === "installed" + ? t("pluginMarket.noInstalledPlugins") + : search + ? t("pluginMarket.noResults") + : t("pluginMarket.noPlugins")} +

+ {tab === "installed" ? ( +

+ {t("pluginMarket.noInstalledPluginsDesc")} +

+ ) : !search ? ( +

+ {t("pluginMarket.noPluginsDesc")} +

+ ) : null} +
+ )} + {displayResults.map((plugin) => ( + setSelected(plugin)} + /> + ))} +
+
+
+ + {/* Right: detail */} +
+ {selected ? ( + + ) : ( +
+ +
+

+ {t("pluginMarket.browseHint")} +

+

+ {t("pluginMarket.browseHintDesc")} +

+
+
+ )} +
+
+
+ ); +} diff --git a/src/components/plugins/marketplace/PluginMarketplaceCard.tsx b/src/components/plugins/marketplace/PluginMarketplaceCard.tsx new file mode 100644 index 00000000..ec425e0b --- /dev/null +++ b/src/components/plugins/marketplace/PluginMarketplaceCard.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { HugeiconsIcon } from "@hugeicons/react"; +import { + Plug01Icon, + CheckmarkCircle02Icon, + ZapIcon, + UserIcon, + CodeIcon, +} from "@hugeicons/core-free-icons"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "@/hooks/useTranslation"; +import type { MarketplacePlugin } from "@/types"; + +interface PluginMarketplaceCardProps { + plugin: MarketplacePlugin; + selected: boolean; + onSelect: () => void; +} + +export function PluginMarketplaceCard({ + plugin, + selected, + onSelect, +}: PluginMarketplaceCardProps) { + const { t } = useTranslation(); + + const componentCount = [ + plugin.components.hasSkills, + plugin.components.hasAgents, + plugin.components.hasHooks, + plugin.components.hasMcp, + plugin.components.hasLsp, + plugin.components.hasCommands, + ].filter(Boolean).length; + + return ( +
+ +
+
+ {plugin.name} + {plugin.isInstalled && ( + + + {t("pluginMarket.installed")} + + )} +
+
+ {plugin.description && ( + {plugin.description} + )} +
+
+ {plugin.version && ( + + {t("pluginMarket.version", { version: plugin.version })} + + )} + {plugin.author?.name && ( + + + {plugin.author.name} + + )} + {componentCount > 0 && ( + + + {componentCount} + + )} + {plugin.components.hasSkills && ( + + + + )} +
+
+
+ ); +} diff --git a/src/components/plugins/marketplace/PluginMarketplaceDetail.tsx b/src/components/plugins/marketplace/PluginMarketplaceDetail.tsx new file mode 100644 index 00000000..46c4f6bf --- /dev/null +++ b/src/components/plugins/marketplace/PluginMarketplaceDetail.tsx @@ -0,0 +1,256 @@ +"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, + Plug01Icon, + Loading02Icon, + ZapIcon, + UserIcon, + CodeIcon, + Wifi01Icon, + Settings02Icon, +} from "@hugeicons/core-free-icons"; +import { useTranslation } from "@/hooks/useTranslation"; +import { PluginInstallDialog } from "./PluginInstallDialog"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import type { MarketplacePlugin } from "@/types"; + +interface PluginMarketplaceDetailProps { + plugin: MarketplacePlugin; + onInstallComplete: () => void; +} + +interface DetailData { + manifest: Record; + readme: string | null; + components: Record; +} + +export function PluginMarketplaceDetail({ + plugin, + onInstallComplete, +}: PluginMarketplaceDetailProps) { + const { t } = useTranslation(); + const [showProgress, setShowProgress] = useState(false); + const [progressAction, setProgressAction] = useState<"install" | "uninstall">( + "install" + ); + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setDetail(null); + setLoading(true); + + const fetchDetail = async () => { + try { + const params = new URLSearchParams({ + marketplace: plugin.marketplace, + name: plugin.name, + }); + const res = await fetch(`/api/plugins/marketplace/detail?${params}`); + if (!cancelled && res.ok) { + const data = await res.json(); + setDetail(data); + } + } catch { + // ignore + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchDetail(); + return () => { + cancelled = true; + }; + }, [plugin.marketplace, plugin.name]); + + const handleInstall = () => { + setProgressAction("install"); + setShowProgress(true); + }; + + const handleUninstall = () => { + setProgressAction("uninstall"); + setShowProgress(true); + }; + + const repoUrl = + (detail?.manifest?.repository as string) || + (detail?.manifest?.homepage as string) || + null; + + const displayContent = detail?.readme + ? detail.readme.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim() + : null; + + const componentIcons: Record< + string, + { icon: typeof ZapIcon; label: string } + > = { + skills: { icon: ZapIcon, label: t("pluginMarket.skills") }, + agents: { icon: UserIcon, label: t("pluginMarket.agents") }, + hooks: { icon: Settings02Icon, label: t("pluginMarket.hooks") }, + mcp: { icon: Wifi01Icon, label: t("pluginMarket.mcpServers") }, + lsp: { icon: CodeIcon, label: t("pluginMarket.lspServers") }, + commands: { icon: CodeIcon, label: t("pluginMarket.commands") }, + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+
+

+ {plugin.name} +

+ {plugin.version && ( + + {t("pluginMarket.version", { version: plugin.version })} + + )} + {plugin.isInstalled && ( + + + {t("pluginMarket.installed")} + + )} +
+
+ {plugin.description && ( + + {plugin.description} + + )} + {repoUrl && ( + + + + )} +
+ {plugin.author?.name && ( +
+ + {plugin.author.name} +
+ )} +
+
+ {plugin.isInstalled ? ( + + ) : ( + + )} +
+
+
+ + {/* Components badges */} + {detail?.components && Object.keys(detail.components).length > 0 && ( +
+

+ {t("pluginMarket.components")} +

+
+ {Object.entries(detail.components).map(([key, items]) => { + const info = componentIcons[key]; + if (!info) return null; + return ( + + + {info.label} + {Array.isArray(items) && items.length > 0 && ( + + ({items.length}) + + )} + + ); + })} +
+
+ )} + + {/* Body — README content */} +
+ {loading ? ( +
+ +
+ ) : displayContent ? ( +
+ + {displayContent} + +
+ ) : ( +
+

{t("pluginMarket.noReadme")}

+
+ )} +
+ + +
+ ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 0413ffce..ec59682d 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -221,6 +221,51 @@ const en = { 'skills.marketplaceHintDesc': 'Search and install community skills from Skills.sh', 'skills.noReadme': 'No description available for this skill', + // ── Plugin Marketplace ───────────────────────────────────── + 'pluginMarket.title': 'Plugins', + 'pluginMarket.subtitle': 'Claude Official Plugin Marketplace', + 'pluginMarket.search': 'Search plugins...', + 'pluginMarket.loading': 'Loading plugins...', + 'pluginMarket.noPlugins': 'No plugins available', + 'pluginMarket.noPluginsDesc': 'Add a marketplace to browse plugins', + 'pluginMarket.noResults': 'No matching plugins', + 'pluginMarket.install': 'Install', + 'pluginMarket.installed': 'Installed', + 'pluginMarket.installing': 'Installing...', + 'pluginMarket.uninstall': 'Uninstall', + 'pluginMarket.installSuccess': 'Plugin Installed', + 'pluginMarket.installFailed': 'Installation Failed', + 'pluginMarket.uninstalling': 'Uninstalling...', + 'pluginMarket.uninstallSuccess': 'Plugin Uninstalled', + 'pluginMarket.uninstallFailed': 'Uninstallation Failed', + 'pluginMarket.version': 'v{version}', + 'pluginMarket.by': 'by {author}', + 'pluginMarket.components': 'Components', + 'pluginMarket.skills': 'Skills', + 'pluginMarket.agents': 'Agents', + 'pluginMarket.hooks': 'Hooks', + 'pluginMarket.mcpServers': 'MCP', + 'pluginMarket.lspServers': 'LSP', + 'pluginMarket.commands': 'Commands', + 'pluginMarket.scope': 'Install Scope', + 'pluginMarket.scopeUser': 'User (global)', + 'pluginMarket.scopeProject': 'Project', + 'pluginMarket.scopeLocal': 'Local', + 'pluginMarket.browseHint': 'Browse Plugin Marketplace', + 'pluginMarket.browseHintDesc': 'Discover and install Claude Code plugins', + 'pluginMarket.noReadme': 'No description available', + 'pluginMarket.error': 'Failed to load plugins', + 'pluginMarket.allCategories': 'All', + 'pluginMarket.marketplace': 'Marketplace', + 'pluginMarket.installedTab': 'Installed', + 'pluginMarket.noInstalledPlugins': 'No installed plugins', + 'pluginMarket.noInstalledPluginsDesc': 'Install plugins from the marketplace', + 'pluginMarket.category.code-intelligence': 'Code Intelligence', + 'pluginMarket.category.external-integrations': 'Integrations', + 'pluginMarket.category.development-workflows': 'Workflows', + 'pluginMarket.category.output-styles': 'Output Styles', + 'pluginMarket.category.other': 'Other', + // ── MCP ───────────────────────────────────────────────────── 'mcp.addServer': 'Add Server', 'mcp.loadingServers': 'Loading MCP servers...', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 46833814..89123e81 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -218,6 +218,51 @@ const zh: Record = { 'skills.marketplaceHintDesc': '搜索并安装来自 Skills.sh 的社区技能', 'skills.noReadme': '暂无技能描述', + // ── Plugin Marketplace ───────────────────────────────────── + 'pluginMarket.title': '插件', + 'pluginMarket.subtitle': 'Claude 官方插件市场', + 'pluginMarket.search': '搜索插件...', + 'pluginMarket.loading': '加载插件中...', + 'pluginMarket.noPlugins': '暂无可用插件', + 'pluginMarket.noPluginsDesc': '添加插件市场以浏览插件', + 'pluginMarket.noResults': '未找到匹配的插件', + 'pluginMarket.install': '安装', + 'pluginMarket.installed': '已安装', + 'pluginMarket.installing': '安装中...', + 'pluginMarket.uninstall': '卸载', + 'pluginMarket.installSuccess': '插件已安装', + 'pluginMarket.installFailed': '安装失败', + 'pluginMarket.uninstalling': '卸载中...', + 'pluginMarket.uninstallSuccess': '插件已卸载', + 'pluginMarket.uninstallFailed': '卸载失败', + 'pluginMarket.version': 'v{version}', + 'pluginMarket.by': '作者:{author}', + 'pluginMarket.components': '组件', + 'pluginMarket.skills': '技能', + 'pluginMarket.agents': '代理', + 'pluginMarket.hooks': '钩子', + 'pluginMarket.mcpServers': 'MCP', + 'pluginMarket.lspServers': 'LSP', + 'pluginMarket.commands': '命令', + 'pluginMarket.scope': '安装范围', + 'pluginMarket.scopeUser': '用户(全局)', + 'pluginMarket.scopeProject': '项目', + 'pluginMarket.scopeLocal': '本地', + 'pluginMarket.browseHint': '浏览插件市场', + 'pluginMarket.browseHintDesc': '发现并安装 Claude Code 插件', + 'pluginMarket.noReadme': '暂无描述', + 'pluginMarket.error': '加载插件失败', + 'pluginMarket.allCategories': '全部', + 'pluginMarket.marketplace': '市场', + 'pluginMarket.installedTab': '已安装', + 'pluginMarket.noInstalledPlugins': '暂无已安装插件', + 'pluginMarket.noInstalledPluginsDesc': '从插件市场安装插件', + 'pluginMarket.category.code-intelligence': '代码智能', + 'pluginMarket.category.external-integrations': '集成', + 'pluginMarket.category.development-workflows': '工作流', + 'pluginMarket.category.output-styles': '输出样式', + 'pluginMarket.category.other': '其他', + // ── MCP ───────────────────────────────────────────────────── 'mcp.addServer': '添加服务器', 'mcp.loadingServers': '加载 MCP 服务器中...', diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 0be9860b..7fc17dd2 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -13,7 +13,7 @@ export const isMac = process.platform === 'darwin'; * Whether the given binary path requires shell execution. * On Windows, .cmd/.bat files cannot be executed directly by execFileSync. */ -function needsShell(binPath: string): boolean { +export function needsShell(binPath: string): boolean { return isWindows && /\.(cmd|bat)$/i.test(binPath); } diff --git a/src/lib/plugin-marketplace-cli.ts b/src/lib/plugin-marketplace-cli.ts new file mode 100644 index 00000000..8b3825b7 --- /dev/null +++ b/src/lib/plugin-marketplace-cli.ts @@ -0,0 +1,126 @@ +import type { SpawnOptionsWithoutStdio } from 'child_process'; + +import { findClaudeBinary, getExpandedPath, needsShell } from './platform'; + +export type PluginMarketplaceScope = 'user' | 'project' | 'local'; +type PluginMarketplaceAction = 'install' | 'uninstall'; + +const SAFE_PLUGIN_PART = /^[A-Za-z0-9._-]+$/; +const VALID_SCOPES = new Set(['user', 'project', 'local']); + +export class PluginMarketplaceCliError extends Error { + constructor(message: string, readonly status: number) { + super(message); + this.name = 'PluginMarketplaceCliError'; + } +} + +interface BuildPluginRefInput { + name: string; + marketplace?: string; +} + +interface CreateSpawnSpecInput extends BuildPluginRefInput { + action: PluginMarketplaceAction; + scope?: PluginMarketplaceScope; +} + +interface PluginMarketplaceCliDeps { + findClaudeBinary: () => string | undefined; + getExpandedPath: () => string; + needsShell: (binPath: string) => boolean; + env: Record; +} + +interface PluginSpawnSpec { + command: string; + args: string[]; + options: SpawnOptionsWithoutStdio & { env: NodeJS.ProcessEnv }; +} + +const defaultDeps: PluginMarketplaceCliDeps = { + findClaudeBinary, + getExpandedPath, + needsShell, + env: process.env, +}; + +function validateRequiredString(value: unknown, label: string): string { + if (!value || typeof value !== 'string') { + throw new PluginMarketplaceCliError(`${label} is required`, 400); + } + return value; +} + +function validatePluginPart(value: string, label: string): string { + if (!SAFE_PLUGIN_PART.test(value)) { + throw new PluginMarketplaceCliError(`Invalid ${label}`, 400); + } + return value; +} + +function validateScope(scope?: string): PluginMarketplaceScope | undefined { + if (scope === undefined) return undefined; + if (!VALID_SCOPES.has(scope as PluginMarketplaceScope)) { + throw new PluginMarketplaceCliError('Invalid plugin scope', 400); + } + return scope as PluginMarketplaceScope; +} + +function buildSpawnEnv( + env: Record, + expandedPath: string +): NodeJS.ProcessEnv { + const nextEnv = {} as NodeJS.ProcessEnv; + for (const [key, value] of Object.entries(env)) { + if (typeof value === 'string') { + nextEnv[key] = value; + } + } + nextEnv.PATH = expandedPath; + return nextEnv; +} + +export function buildMarketplacePluginRef({ + name, + marketplace, +}: BuildPluginRefInput): string { + const safeName = validatePluginPart(validateRequiredString(name, 'plugin name'), 'plugin name'); + if (!marketplace) { + return safeName; + } + const safeMarketplace = validatePluginPart( + validateRequiredString(marketplace, 'marketplace name'), + 'marketplace name' + ); + return `${safeName}@${safeMarketplace}`; +} + +export function createMarketplacePluginSpawnSpec( + input: CreateSpawnSpecInput, + deps: PluginMarketplaceCliDeps = defaultDeps +): PluginSpawnSpec { + const command = deps.findClaudeBinary(); + if (!command) { + throw new PluginMarketplaceCliError('Claude CLI not found', 500); + } + + const scope = validateScope(input.scope); + const args = + input.action === 'install' + ? ['plugin', 'install', buildMarketplacePluginRef({ name: input.name, marketplace: input.marketplace })] + : ['plugin', 'uninstall', buildMarketplacePluginRef({ name: input.name, marketplace: input.marketplace })]; + + if (scope) { + args.push('--scope', scope); + } + + return { + command, + args, + options: { + env: buildSpawnEnv(deps.env, deps.getExpandedPath()), + shell: deps.needsShell(command), + }, + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index 51e371bd..f648cf75 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -233,6 +233,48 @@ export interface SkillDefinition { enabled: boolean; } +// --- Plugin Marketplace (Claude Code Plugins) --- + +export interface PluginManifest { + name: string; + version?: string; + description?: string; + author?: { name: string; email?: string; url?: string }; + homepage?: string; + repository?: string; + license?: string; + keywords?: string[]; + category?: string; +} + +export interface PluginComponents { + hasSkills: boolean; + hasAgents: boolean; + hasHooks: boolean; + hasMcp: boolean; + hasLsp: boolean; + hasCommands: boolean; +} + +export interface MarketplacePlugin { + name: string; + description?: string; + version?: string; + author?: { name: string; email?: string; url?: string }; + marketplace: string; + category?: string; + components: PluginComponents; + isInstalled: boolean; + installedScope?: 'user' | 'project' | 'local'; +} + +export interface PluginMarketplaceInfo { + name: string; + owner: { name: string; email?: string }; + pluginCount: number; + source: string; +} + // --- Marketplace (Skills.sh) --- export interface MarketplaceSkill {