From 05df6c526565d3f194978e69d676546080de3da6 Mon Sep 17 00:00:00 2001 From: Xi Zhang Date: Mon, 1 Jun 2026 01:47:03 +0100 Subject: [PATCH] feat: add SkillsMarketplace component and implement skills management functionality --- src/app/api/skills/route.ts | 98 ++++++++++++++++ src/app/components/SkillsMarketplace.tsx | 140 +++++++++++++++++++++++ src/app/components/ThreadList.tsx | 28 ++++- src/app/page.tsx | 26 +++-- 4 files changed, 282 insertions(+), 10 deletions(-) create mode 100644 src/app/api/skills/route.ts create mode 100644 src/app/components/SkillsMarketplace.tsx diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts new file mode 100644 index 0000000..0861765 --- /dev/null +++ b/src/app/api/skills/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from "next/server"; +import { homedir } from "os"; +import { join, resolve } from "path"; +import { promises as fs } from "fs"; + +// EvoScientist installs user skills here (confirmed fixed location). We also +// check the ~/.evoscientist/skills global tier as a fallback. +const SKILL_DIRS = [ + join(homedir(), ".config", "evoscientist", "skills"), + join(homedir(), ".evoscientist", "skills"), +]; + +interface SkillCard { + name: string; + description: string; + dir: string; +} + +// Minimal frontmatter parse — we only need name + description. Avoids pulling +// in a YAML dependency. +function parseFrontmatter(md: string): { name?: string; description?: string } { + const match = md.match(/^---\s*\n([\s\S]*?)\n---/); + if (!match) return {}; + const fm = match[1]; + const get = (key: string) => { + const m = fm.match(new RegExp(`^${key}\\s*:\\s*(.+?)\\s*$`, "m")); + if (!m) return undefined; + return m[1].replace(/^["']|["']$/g, "").trim(); + }; + return { name: get("name"), description: get("description") }; +} + +async function readSkills(): Promise { + const skills: SkillCard[] = []; + const seen = new Set(); + for (const dir of SKILL_DIRS) { + let entries: string[] = []; + try { + entries = await fs.readdir(dir); + } catch { + continue; // dir doesn't exist + } + for (const entry of entries) { + if (entry.startsWith(".")) continue; + const skillDir = join(dir, entry); + try { + const stat = await fs.stat(skillDir); + if (!stat.isDirectory()) continue; + const md = await fs.readFile(join(skillDir, "SKILL.md"), "utf-8"); + const { name, description } = parseFrontmatter(md); + const skillName = name || entry; + if (seen.has(skillName)) continue; + seen.add(skillName); + skills.push({ + name: skillName, + description: description || "", + dir: skillDir, + }); + } catch { + // no SKILL.md or unreadable — skip + } + } + } + return skills.sort((a, b) => a.name.localeCompare(b.name)); +} + +export async function GET() { + try { + const skills = await readSkills(); + return NextResponse.json({ skills }); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "Failed to read skills" }, + { status: 500 } + ); + } +} + +// Uninstall = remove the skill directory. Guard against path traversal and +// only delete inside the known skill dirs. +export async function DELETE(req: NextRequest) { + const name = req.nextUrl.searchParams.get("name"); + if (!name || /[\\/]|\.\./.test(name)) { + return NextResponse.json({ error: "Invalid skill name" }, { status: 400 }); + } + for (const dir of SKILL_DIRS) { + const target = resolve(join(dir, name)); + if (!target.startsWith(resolve(dir) + "/")) continue; + try { + await fs.stat(target); + } catch { + continue; // not here + } + await fs.rm(target, { recursive: true, force: true }); + return NextResponse.json({ ok: true }); + } + return NextResponse.json({ error: "Skill not found" }, { status: 404 }); +} diff --git a/src/app/components/SkillsMarketplace.tsx b/src/app/components/SkillsMarketplace.tsx new file mode 100644 index 0000000..2a231f5 --- /dev/null +++ b/src/app/components/SkillsMarketplace.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Loader2, Puzzle, RotateCw, Trash2 } from "lucide-react"; + +interface SkillCard { + name: string; + description: string; + dir: string; +} + +export function SkillsMarketplace() { + const [skills, setSkills] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [removing, setRemoving] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/skills"); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to load skills"); + setSkills(data.skills ?? []); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load skills"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const uninstall = async (name: string) => { + if (!window.confirm(`Uninstall the "${name}" skill?`)) return; + setRemoving(name); + try { + const res = await fetch(`/api/skills?name=${encodeURIComponent(name)}`, { + method: "DELETE", + }); + if (!res.ok) { + const d = await res.json(); + throw new Error(d.error || "Failed to uninstall"); + } + setSkills((prev) => prev.filter((s) => s.name !== name)); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to uninstall"); + } finally { + setRemoving(null); + } + }; + + return ( +
+
+
+
+

Skills

+

+ Skills installed for EvoScientist. Anything you install from + elsewhere shows up here automatically. +

+
+ +
+ + {loading ? ( +
+
+ ) : error ? ( +

{error}

+ ) : skills.length === 0 ? ( +

+ No skills installed yet. +

+ ) : ( +
+ {skills.map((s) => ( +
+
+
+ +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/app/components/ThreadList.tsx b/src/app/components/ThreadList.tsx index 04f1063..66b702d 100644 --- a/src/app/components/ThreadList.tsx +++ b/src/app/components/ThreadList.tsx @@ -2,7 +2,13 @@ import { useEffect, useMemo, useState, useRef, useCallback } from "react"; import { format } from "date-fns"; -import { Loader2, MessageSquare, Search, SquarePen } from "lucide-react"; +import { + Loader2, + MessageSquare, + Puzzle, + Search, + SquarePen, +} from "lucide-react"; import { useQueryState } from "nuqs"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -122,6 +128,7 @@ export function ThreadList({ onInterruptCountChange, }: ThreadListProps) { const [currentThreadId, setThreadId] = useQueryState("threadId"); + const [view, setView] = useQueryState("view"); const [statusFilter, setStatusFilter] = useState("all"); const [search, setSearch] = useState(""); @@ -216,7 +223,10 @@ export function ThreadList({
+
void) | null>(null); const [interruptCount, setInterruptCount] = useState(0); @@ -153,7 +155,10 @@ function HomePageInner({