feat: enhance Skills Marketplace with catalog management and installation features

- Refactored SkillsMarketplace component to manage skills catalog and installed skills.
- Added functionality to install, uninstall, and update skills from the official catalog.
- Introduced loading states and error handling for fetching skills and catalog data.
- Created a new SkillTile component for better UI representation of skills.
- Implemented a new server-side module for managing skills, including fetching the catalog and handling installations.
- Added support for versioning and update checks for installed skills.
- Enhanced WorkspacePanel to support file categorization and improved loading states.
This commit is contained in:
Xi Zhang
2026-06-03 01:25:38 +01:00
parent 3a453b4f0f
commit e345310dad
7 changed files with 1093 additions and 116 deletions
+22
View File
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { getCatalog } from "@/lib/server/skills";
export const runtime = "nodejs";
export async function GET(request: NextRequest) {
try {
const force = request.nextUrl.searchParams.get("refresh") === "1";
const skills = await getCatalog(force);
return NextResponse.json({ skills });
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to load the skills catalog.",
},
{ status: 502 }
);
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { installSkill } from "@/lib/server/skills";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
try {
// Same-origin guard (browsers always send Origin on POST).
const origin = request.headers.get("origin");
if (origin && origin !== request.nextUrl.origin) {
return NextResponse.json(
{ error: "Cross-origin installs are not allowed." },
{ status: 403 }
);
}
const body = (await request.json().catch(() => null)) as {
name?: unknown;
} | null;
const name = body?.name;
if (typeof name !== "string" || !name) {
return NextResponse.json(
{ error: "Missing skill name." },
{ status: 400 }
);
}
const result = await installSkill(name);
return NextResponse.json({ ok: true, ...result });
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Failed to install skill.",
},
{ status: 400 }
);
}
}
+15 -11
View File
@@ -1,17 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { homedir } from "os";
import { join, resolve } from "path";
import { promises as fs } from "fs";
import { SKILL_DIRS, recordUninstall } from "@/lib/server/skills";
// 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"),
];
// SKILL_DIRS (the global ~/.evoscientist/skills tier + legacy ~/.config
// fallback) is the single source of truth, shared with the install route.
interface SkillCard {
/** Directory name — the install/uninstall identity (matches the catalog). */
name: string;
/** Frontmatter name for display; falls back to the directory name. */
title: string;
description: string;
dir: string;
}
@@ -48,11 +47,13 @@ async function readSkills(): Promise<SkillCard[]> {
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);
// Identity is the DIRECTORY name (what install/uninstall/dedup key on);
// the frontmatter name is display-only.
if (seen.has(entry)) continue;
seen.add(entry);
skills.push({
name: skillName,
name: entry,
title: name || entry,
description: description || "",
dir: skillDir,
});
@@ -92,6 +93,9 @@ export async function DELETE(req: NextRequest) {
continue; // not here
}
await fs.rm(target, { recursive: true, force: true });
// Keep EvoScientist's manifest in sync — drop the entry so onboard/CLI no
// longer list it. Best-effort: don't fail the uninstall on a manifest error.
await recordUninstall(name).catch(() => {});
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "Skill not found" }, { status: 404 });
+67
View File
@@ -13,6 +13,8 @@ export const runtime = "nodejs";
// Cap how many entries a single directory listing returns so a pathological
// directory can't stall the UI or the response.
const MAX_ENTRIES = 2000;
// Bound recursive walks (the "by type" view) by depth too.
const MAX_DEPTH = 12;
export interface WorkspaceEntry {
name: string;
@@ -26,6 +28,57 @@ export interface WorkspaceEntry {
ext: string;
}
/**
* Recursively collect *files* (not dirs) under `relDir`, skipping the hidden/
* noise set and never following symlinks (so it can't loop or escape). Used by
* the artifacts "by type" view. Bounded by MAX_DEPTH and MAX_ENTRIES.
*/
async function walkFiles(
workspaceDir: string,
relDir: string,
depth: number,
out: WorkspaceEntry[]
): Promise<void> {
if (depth > MAX_DEPTH || out.length >= MAX_ENTRIES) return;
let dir: string;
try {
dir = await safeResolve(workspaceDir, relDir || "");
} catch {
return;
}
let dirents;
try {
dirents = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const dirent of dirents) {
if (out.length >= MAX_ENTRIES) break;
if (isHiddenEntry(dirent.name)) continue;
// Don't follow symlinks during the recursive walk — avoids loops and any
// escape; a symlinked file is simply omitted from the by-type view.
if (dirent.isSymbolicLink()) continue;
const childRel = relDir ? `${relDir}/${dirent.name}` : dirent.name;
if (dirent.isDirectory()) {
await walkFiles(workspaceDir, childRel, depth + 1, out);
} else if (dirent.isFile()) {
try {
const st = await fs.stat(await safeResolve(workspaceDir, childRel));
out.push({
name: dirent.name,
path: childRel,
type: "file",
size: st.size,
mtime: st.mtimeMs,
ext: extname(dirent.name).slice(1).toLowerCase(),
});
} catch {
continue;
}
}
}
}
export async function GET(request: NextRequest) {
try {
if (isCrossOrigin(request)) {
@@ -36,6 +89,7 @@ export async function GET(request: NextRequest) {
}
const relPath = request.nextUrl.searchParams.get("path") ?? "";
const recursive = request.nextUrl.searchParams.get("recursive") === "1";
const workspaceDir = await getWorkspaceDir();
const dir = await safeResolve(workspaceDir, relPath);
@@ -47,6 +101,19 @@ export async function GET(request: NextRequest) {
);
}
// "By type" view: flat list of every file under the workspace.
if (recursive) {
const files: WorkspaceEntry[] = [];
await walkFiles(workspaceDir, relPath, 0, files);
const truncated = files.length >= MAX_ENTRIES;
return NextResponse.json({
path: relPath,
recursive: true,
truncated,
entries: files,
});
}
const dirents = await fs.readdir(dir, { withFileTypes: true });
const entries: WorkspaceEntry[] = [];
for (const dirent of dirents) {
+301 -75
View File
@@ -1,42 +1,132 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Loader2, Puzzle, RotateCw, Trash2 } from "lucide-react";
import {
Loader2,
Puzzle,
RotateCw,
Trash2,
Download,
ArrowUpCircle,
} from "lucide-react";
interface SkillCard {
name: string;
title: string;
description: string;
dir: string;
}
export function SkillsMarketplace() {
const [skills, setSkills] = useState<SkillCard[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [removing, setRemoving] = useState<string | null>(null);
interface CatalogSkill {
name: string;
title: string;
description: string;
fileCount: number;
installed: boolean;
latestVersion?: string;
installedVersion?: string;
updateAvailable: boolean;
}
const load = useCallback(async () => {
export function SkillsMarketplace() {
const [catalog, setCatalog] = useState<CatalogSkill[]>([]);
const [other, setOther] = useState<SkillCard[]>([]);
const [loading, setLoading] = useState(true);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Per-skill in-flight action, keyed by skill name.
const [busy, setBusy] = useState<
Record<string, "install" | "uninstall" | "update">
>({});
const load = useCallback(async (refresh = false) => {
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);
setCatalogError(null);
const [catRes, instRes] = await Promise.allSettled([
fetch(`/api/skills/catalog${refresh ? "?refresh=1" : ""}`).then(
async (r) => {
const d = await r.json();
if (!r.ok) throw new Error(d.error || "Failed to load catalog");
return (d.skills ?? []) as CatalogSkill[];
}
),
fetch("/api/skills").then(async (r) => {
const d = await r.json();
if (!r.ok) throw new Error(d.error || "Failed to load skills");
return (d.skills ?? []) as SkillCard[];
}),
]);
const cat = catRes.status === "fulfilled" ? catRes.value : [];
if (catRes.status === "rejected") {
setCatalogError(
catRes.reason instanceof Error
? catRes.reason.message
: "Failed to load the official catalog."
);
}
setCatalog(cat);
// Installed skills that aren't in the official catalog (custom/local ones).
if (instRes.status === "fulfilled") {
const catNames = new Set(cat.map((c) => c.name));
setOther(instRes.value.filter((s) => !catNames.has(s.name)));
} else {
setOther([]);
setError(
instRes.reason instanceof Error
? instRes.reason.message
: "Failed to load installed skills."
);
}
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
const uninstall = async (name: string) => {
// Install and update hit the same endpoint (it overwrites + re-records the
// manifest commit); the mode only changes the busy label and success state.
const install = async (name: string, mode: "install" | "update" = "install") => {
setBusy((b) => ({ ...b, [name]: mode }));
setError(null);
try {
const res = await fetch("/api/skills/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const d = await res.json();
if (!res.ok) throw new Error(d.error || "Failed to install");
setCatalog((prev) =>
prev.map((s) =>
s.name === name
? {
...s,
installed: true,
updateAvailable: false,
installedVersion: s.latestVersion ?? s.installedVersion,
}
: s
)
);
} catch (e) {
setError(e instanceof Error ? e.message : `Failed to ${mode}`);
} finally {
setBusy((b) => {
const next = { ...b };
delete next[name];
return next;
});
}
};
const uninstall = async (name: string, isCatalog: boolean) => {
if (!window.confirm(`Uninstall the "${name}" skill?`)) return;
setRemoving(name);
setBusy((b) => ({ ...b, [name]: "uninstall" }));
setError(null);
try {
const res = await fetch(`/api/skills?name=${encodeURIComponent(name)}`, {
method: "DELETE",
@@ -45,11 +135,21 @@ export function SkillsMarketplace() {
const d = await res.json();
throw new Error(d.error || "Failed to uninstall");
}
setSkills((prev) => prev.filter((s) => s.name !== name));
if (isCatalog) {
setCatalog((prev) =>
prev.map((s) => (s.name === name ? { ...s, installed: false } : s))
);
} else {
setOther((prev) => prev.filter((s) => s.name !== name));
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to uninstall");
} finally {
setRemoving(null);
setBusy((b) => {
const next = { ...b };
delete next[name];
return next;
});
}
};
@@ -60,13 +160,21 @@ export function SkillsMarketplace() {
<div>
<h2 className="text-2xl font-semibold">Research Skills</h2>
<p className="mt-1 text-sm text-muted-foreground">
Skills installed for EvoScientist. Anything you install from
elsewhere shows up here automatically.
Install official skills from the{" "}
<a
href="https://github.com/EvoScientist/EvoSkills"
target="_blank"
rel="noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>
EvoSkills
</a>{" "}
catalog, or remove ones you don&apos;t need.
</p>
</div>
<button
type="button"
onClick={load}
onClick={() => load(true)}
disabled={loading}
aria-label="Refresh"
className="rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
@@ -78,69 +186,187 @@ export function SkillsMarketplace() {
</button>
</header>
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2
className="size-4 animate-spin"
aria-hidden="true"
/>
Loading skills…
</div>
) : error ? (
{error && (
<p
role="alert"
className="text-sm text-destructive"
className="mb-4 text-sm text-destructive"
>
{error}
</p>
) : skills.length === 0 ? (
<p className="text-sm text-muted-foreground">
No skills installed yet.
</p>
)}
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
Loading skills…
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{skills.map((s) => (
<div
key={s.name}
className="group relative rounded-lg border border-border bg-card p-4 pr-12"
>
<div className="flex items-start gap-3">
<Puzzle
className="mt-0.5 size-5 shrink-0 text-[var(--brand)]"
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<h3 className="break-words font-medium">{s.name}</h3>
<p className="mt-1 line-clamp-3 text-sm text-muted-foreground">
{s.description || "No description."}
</p>
</div>
<div className="space-y-8">
<section>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-tertiary">
Official catalog
</h3>
{catalogError ? (
<p className="text-sm text-muted-foreground">{catalogError}</p>
) : catalog.length === 0 ? (
<p className="text-sm text-muted-foreground">
No skills found in the catalog.
</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{catalog.map((s) => (
<SkillTile
key={s.name}
title={s.title}
description={s.description}
meta={`${s.fileCount} file${s.fileCount === 1 ? "" : "s"}`}
installed={s.installed}
installedVersion={s.installedVersion}
latestVersion={s.latestVersion}
updateAvailable={s.updateAvailable}
busy={busy[s.name]}
onInstall={() => install(s.name)}
onUpdate={() => install(s.name, "update")}
onUninstall={() => uninstall(s.name, true)}
/>
))}
</div>
<button
type="button"
onClick={() => uninstall(s.name)}
disabled={removing === s.name}
aria-label={`Uninstall ${s.name}`}
title={`Uninstall ${s.name}`}
className="absolute right-2 top-2 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:ring-2 focus-visible:ring-ring md:opacity-0 md:focus-visible:opacity-100 md:group-hover:opacity-100"
>
{removing === s.name ? (
<Loader2
className="size-4 animate-spin"
aria-hidden="true"
)}
</section>
{other.length > 0 && (
<section>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-tertiary">
Other installed skills
</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{other.map((s) => (
<SkillTile
key={s.name}
title={s.title}
description={s.description}
installed
busy={busy[s.name]}
onUninstall={() => uninstall(s.name, false)}
/>
) : (
<Trash2
className="size-4"
aria-hidden="true"
/>
)}
</button>
</div>
))}
))}
</div>
</section>
)}
</div>
)}
</div>
</div>
);
}
function SkillTile({
title,
description,
meta,
installed,
installedVersion,
latestVersion,
updateAvailable,
busy,
onInstall,
onUpdate,
onUninstall,
}: {
title: string;
description: string;
meta?: string;
installed: boolean;
installedVersion?: string;
latestVersion?: string;
updateAvailable?: boolean;
busy?: "install" | "uninstall" | "update";
onInstall?: () => void;
onUpdate?: () => void;
onUninstall?: () => void;
}) {
const versionLabel = installed
? installedVersion && `v${installedVersion}`
: latestVersion && `v${latestVersion}`;
return (
<div className="flex flex-col rounded-lg border border-border bg-card p-4">
<div className="flex items-start gap-3">
<Puzzle
className="mt-0.5 size-5 shrink-0 text-[var(--brand)]"
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<h3 className="break-words font-medium">{title}</h3>
{versionLabel && (
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{versionLabel}
</span>
)}
{meta && (
<span className="shrink-0 text-xs text-muted-foreground">
· {meta}
</span>
)}
</div>
<p className="mt-1 line-clamp-3 text-sm text-muted-foreground">
{description || "No description."}
</p>
</div>
</div>
<div className="mt-3 flex items-center justify-end gap-2">
{installed && updateAvailable && (
<button
type="button"
onClick={onUpdate}
disabled={!!busy}
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--brand)] px-2.5 py-1 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
title={
latestVersion ? `Update to v${latestVersion}` : "Update"
}
>
{busy === "update" ? (
<Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
) : (
<ArrowUpCircle className="size-3.5" aria-hidden="true" />
)}
{busy === "update"
? "Updating…"
: latestVersion
? `Update → v${latestVersion}`
: "Update"}
</button>
)}
{installed ? (
<button
type="button"
onClick={onUninstall}
disabled={!!busy}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
>
{busy === "uninstall" ? (
<Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
) : (
<Trash2 className="size-3.5" aria-hidden="true" />
)}
{busy === "uninstall" ? "Removing…" : "Uninstall"}
</button>
) : (
<button
type="button"
onClick={onInstall}
disabled={!!busy}
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--brand)] px-2.5 py-1 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
>
{busy === "install" ? (
<Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
) : (
<Download className="size-3.5" aria-hidden="true" />
)}
{busy === "install" ? "Installing…" : "Install"}
</button>
)}
</div>
</div>
);
}
+201 -30
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
ChevronRight,
ChevronDown,
@@ -9,11 +9,13 @@ import {
RefreshCw,
Download,
Loader2,
Image as ImageIcon,
Database,
Code2,
File as FileIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
WorkspaceFileDialog,
} from "@/app/components/WorkspaceFileDialog";
import { WorkspaceFileDialog } from "@/app/components/WorkspaceFileDialog";
import type { WorkspaceEntry } from "@/app/api/workspace/route";
async function listDir(path: string): Promise<WorkspaceEntry[]> {
@@ -23,19 +25,70 @@ async function listDir(path: string): Promise<WorkspaceEntry[]> {
return (body?.entries ?? []) as WorkspaceEntry[];
}
async function listAll(): Promise<{ entries: WorkspaceEntry[]; truncated: boolean }> {
const res = await fetch("/api/workspace?recursive=1");
const body = await res.json().catch(() => null);
if (!res.ok) throw new Error(body?.error || "Failed to load workspace.");
return {
entries: (body?.entries ?? []) as WorkspaceEntry[],
truncated: !!body?.truncated,
};
}
// Research-artifact categories for the "by type" view. Order here is render order.
const CATEGORIES = [
{
key: "docs",
label: "Papers & docs",
Icon: FileText,
exts: ["pdf", "tex", "bib", "md", "markdown", "txt", "docx", "doc", "rtf", "odt"],
},
{
key: "figures",
label: "Figures",
Icon: ImageIcon,
exts: ["png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "tiff", "tif", "eps"],
},
{
key: "data",
label: "Data",
Icon: Database,
exts: ["json", "jsonl", "csv", "tsv", "xlsx", "xls", "parquet", "pkl", "npy", "npz", "h5", "hdf5", "db", "sqlite", "yaml", "yml", "xml"],
},
{
key: "code",
label: "Code",
Icon: Code2,
exts: ["py", "ipynb", "js", "ts", "tsx", "jsx", "sh", "bash", "r", "jl", "cpp", "cc", "c", "h", "hpp", "java", "go", "rs", "m", "rb"],
},
] as const;
const OTHER = { key: "other", label: "Other", Icon: FileIcon } as const;
const EXT_TO_CATEGORY: Record<string, string> = {};
for (const cat of CATEGORIES) {
for (const ext of cat.exts) EXT_TO_CATEGORY[ext] = cat.key;
}
type ViewMode = "tree" | "type";
export function WorkspacePanel() {
// Listing cache keyed by directory path ("" = workspace root).
const [children, setChildren] = useState<Record<string, WorkspaceEntry[]>>(
{}
);
const [view, setView] = useState<ViewMode>("tree");
// --- Tree view state (listing cache keyed by dir path; "" = root) ---
const [children, setChildren] = useState<Record<string, WorkspaceEntry[]>>({});
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState<Set<string>>(new Set());
const [rootLoading, setRootLoading] = useState(false);
// --- By-type view state (flat recursive listing) ---
const [allFiles, setAllFiles] = useState<WorkspaceEntry[] | null>(null);
const [typeLoading, setTypeLoading] = useState(false);
const [truncated, setTruncated] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<{
path: string;
size: number;
} | null>(null);
const [selected, setSelected] = useState<{ path: string; size: number } | null>(
null
);
const loadDir = useCallback(async (path: string) => {
setLoading((prev) => new Set(prev).add(path));
@@ -58,16 +111,21 @@ export function WorkspacePanel() {
}
}, []);
const refresh = useCallback(async () => {
setRootLoading(true);
// Re-fetch the root plus every currently-expanded directory so an open tree
// stays open and in sync with what the agent has written since.
const toLoad = ["", ...expanded];
await Promise.allSettled(toLoad.map((p) => loadDir(p)));
setRootLoading(false);
}, [expanded, loadDir]);
const loadAll = useCallback(async () => {
setTypeLoading(true);
try {
const { entries, truncated } = await listAll();
setAllFiles(entries);
setTruncated(truncated);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load.");
} finally {
setTypeLoading(false);
}
}, []);
// Initial load. loadDir surfaces root failures via `error` state; catch the
// Initial tree load. loadDir surfaces root failures via `error`; catch the
// rejection here so it doesn't become an unhandled promise rejection.
useEffect(() => {
setRootLoading(true);
@@ -77,6 +135,25 @@ export function WorkspacePanel() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load the flat listing the first time the by-type view is opened.
useEffect(() => {
if (view === "type" && allFiles === null && !typeLoading) void loadAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
const refresh = useCallback(async () => {
setError(null);
if (view === "type") {
await loadAll();
return;
}
setRootLoading(true);
// Re-fetch the root plus every currently-expanded dir so an open tree stays
// open and in sync with what the agent has written since.
await Promise.allSettled(["", ...expanded].map((p) => loadDir(p)));
setRootLoading(false);
}, [view, expanded, loadDir, loadAll]);
const toggleDir = useCallback(
(path: string) => {
setExpanded((prev) => {
@@ -93,6 +170,21 @@ export function WorkspacePanel() {
[children, loadDir]
);
// Group the flat listing into categories (newest first within each group).
const grouped = useMemo(() => {
const map: Record<string, WorkspaceEntry[]> = {};
for (const f of allFiles ?? []) {
const key = EXT_TO_CATEGORY[f.ext] ?? OTHER.key;
(map[key] ??= []).push(f);
}
for (const list of Object.values(map)) {
list.sort((a, b) => b.mtime - a.mtime);
}
return map;
}, [allFiles]);
const refreshing = view === "type" ? typeLoading : rootLoading;
const renderEntries = (path: string, depth: number): React.ReactNode => {
const entries = children[path];
if (!entries) return null;
@@ -146,12 +238,85 @@ export function WorkspacePanel() {
});
};
const renderByType = (): React.ReactNode => {
if (!allFiles) return null;
if (allFiles.length === 0) {
return (
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
No files in the workspace yet
</p>
);
}
const groups = [...CATEGORIES, OTHER];
return (
<div className="space-y-3">
{truncated && (
<p className="px-1 text-[11px] text-muted-foreground">
Showing the first files only — the workspace has more than the limit.
</p>
)}
{groups.map((cat) => {
const files = grouped[cat.key];
if (!files || files.length === 0) return null;
const Icon = cat.Icon;
return (
<div key={cat.key}>
<div className="mb-0.5 flex items-center gap-1.5 px-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
<Icon className="size-3.5" />
{cat.label}
<span className="text-muted-foreground">({files.length})</span>
</div>
{files.map((f) => {
const dir = f.path.includes("/")
? f.path.slice(0, f.path.lastIndexOf("/"))
: "";
return (
<button
key={f.path}
type="button"
onClick={() => setSelected({ path: f.path, size: f.size })}
className="flex w-full items-center gap-1.5 rounded-md px-1 py-1 text-left text-sm text-foreground transition-colors hover:bg-muted"
title={f.path}
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{f.name}</span>
{dir && (
<span className="ml-auto shrink-0 truncate pl-2 text-[11px] text-muted-foreground">
{dir}
</span>
)}
</button>
);
})}
</div>
);
})}
</div>
);
};
return (
<div className="flex min-h-0 flex-col">
<div className="flex items-center justify-between gap-2 pb-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-tertiary">
Working directory
</span>
{/* Tree / By-type toggle */}
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5 text-xs">
{(["tree", "type"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setView(m)}
className={cn(
"rounded px-2 py-0.5 font-medium transition-colors",
view === m
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
aria-pressed={view === m}
>
{m === "tree" ? "Tree" : "By type"}
</button>
))}
</div>
<div className="flex items-center gap-0.5">
<a
href="/api/workspace/download"
@@ -165,14 +330,12 @@ export function WorkspacePanel() {
<button
type="button"
onClick={refresh}
disabled={rootLoading}
disabled={refreshing}
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
aria-label="Refresh workspace"
title="Refresh"
>
<RefreshCw
className={cn("size-3.5", rootLoading && "animate-spin")}
/>
<RefreshCw className={cn("size-3.5", refreshing && "animate-spin")} />
</button>
</div>
</div>
@@ -181,12 +344,20 @@ export function WorkspacePanel() {
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
{error}
</p>
) : rootLoading && !children[""] ? (
) : view === "tree" ? (
rootLoading && !children[""] ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="-mx-1">{renderEntries("", 0)}</div>
)
) : typeLoading && allFiles === null ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="-mx-1">{renderEntries("", 0)}</div>
renderByType()
)}
<WorkspaceFileDialog
+448
View File
@@ -0,0 +1,448 @@
// Server-only helpers for the official EvoSkills catalog + installing skills.
//
// Catalog source: the public EvoScientist/EvoSkills repo. We read its file tree
// once via the GitHub trees API (1 request, cached) and download individual
// skill files from raw.githubusercontent.com (which doesn't count against the
// API rate limit). Skills install into ~/.evoscientist/skills/<name>/ (the
// GLOBAL tier EvoScientist reads from). We also maintain that tier's
// .installed.yaml manifest so EvoScientist's onboard/CLI stay in sync. Zero
// npm dependencies.
import { homedir } from "os";
import { dirname, join, resolve, sep } from "path";
import { promises as fs } from "fs";
import { randomUUID } from "crypto";
const REPO = "EvoScientist/EvoSkills";
const BRANCH = "main";
const SKILLS_PREFIX = "skills/";
/** EvoScientist's global data dir — `~/.evoscientist` by default (paths.py
* DATA_DIR), relocatable via EVOSCIENTIST_DATA_DIR, exactly like the backend. */
function globalDataDir(): string {
const env = process.env.EVOSCIENTIST_DATA_DIR;
if (env && env.trim()) {
return env.startsWith("~") ? join(homedir(), env.slice(1)) : resolve(env);
}
return join(homedir(), ".evoscientist");
}
/**
* Where skills install: EvoScientist's GLOBAL skills tier
* (`DATA_DIR/skills` = `~/.evoscientist/skills`), matching `install_skill()`'s
* default global target. NOT `~/.config/evoscientist/skills` — that's the
* pre-migration legacy location (only config.yaml/mcp.yaml still live there).
*/
export const SKILLS_INSTALL_DIR = join(globalDataDir(), "skills");
/** Tiers scanned to decide whether a skill is already installed: the global
* dir first, then the legacy ~/.config path as a harmless fallback. */
export const SKILL_DIRS = [
SKILLS_INSTALL_DIR,
join(homedir(), ".config", "evoscientist", "skills"),
];
// Install guards.
const MAX_SKILL_FILES = 300;
const MAX_SKILL_BYTES = 25 * 1024 * 1024;
const GITHUB_HEADERS = {
Accept: "application/vnd.github+json",
// GitHub rejects API requests without a User-Agent.
"User-Agent": "evoscientist-webui",
};
export interface CatalogSkill {
/** Directory name in the repo (the install identity), e.g. "paper-writing". */
name: string;
/** SKILL.md frontmatter name, falls back to the dir name. */
title: string;
description: string;
fileCount: number;
installed: boolean;
/** metadata.version from the upstream SKILL.md (undefined if absent). */
latestVersion?: string;
/** metadata.version of the locally-installed SKILL.md (undefined if absent). */
installedVersion?: string;
/** True when installed and the upstream version is strictly newer. */
updateAvailable: boolean;
}
/** Per-skill source recorded in .installed.yaml: the `owner/repo@path` shorthand
* with path = `skills/<name>`, so EvoScientist tracks each skill's provenance
* (and its commit-based update check) individually rather than as one pack. */
function manifestSource(name: string): string {
return `${REPO}@${SKILLS_PREFIX}${name}`; // EvoScientist/EvoSkills@skills/<name>
}
interface TreeBlob {
path: string;
type: "blob" | "tree" | string;
size?: number;
}
/** A skill name must be a single safe path segment. Matches EvoScientist's
* convention (`^[A-Za-z0-9][A-Za-z0-9._-]*$`) — also blocks traversal and odd
* manifest keys. */
export function isValidSkillName(name: string): boolean {
return (
name.length <= 128 &&
/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) &&
!name.includes("..")
);
}
/** Read `version` only from inside the `metadata:` block — not any stray
* indented `version:` elsewhere in the frontmatter. */
function getMetadataVersion(fm: string): string | undefined {
const block = fm.match(/^metadata\s*:[ \t]*\n((?:[ \t]+.*(?:\n|$))*)/m);
if (!block) return undefined;
const v = block[1].match(/^[ \t]+version\s*:\s*(.+?)\s*$/m);
return v ? v[1].replace(/^["']|["']$/g, "").trim() : undefined;
}
function parseFrontmatter(md: string): {
name?: string;
description?: string;
version?: string;
} {
const match = md.match(/^---\s*\n([\s\S]*?)\n---/);
if (!match) return {};
const fm = match[1];
// name/description are top-level keys; version is nested under metadata.
const top = (key: string) => {
const m = fm.match(new RegExp(`^${key}\\s*:\\s*(.+?)\\s*$`, "m"));
return m ? m[1].replace(/^["']|["']$/g, "").trim() : undefined;
};
return {
name: top("name"),
description: top("description"),
version: getMetadataVersion(fm),
};
}
/** Compare dotted versions ("1.2.3"). Returns 1 if a>b, -1 if a<b, 0 if equal
* or unparseable. Tolerant of differing segment counts and non-numeric tails. */
function compareVersions(a: string, b: string): number {
const pa = a.split(".").map((s) => parseInt(s, 10));
const pb = b.split(".").map((s) => parseInt(s, 10));
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i += 1) {
const x = pa[i] ?? 0;
const y = pb[i] ?? 0;
if (Number.isNaN(x) || Number.isNaN(y)) return 0;
if (x !== y) return x > y ? 1 : -1;
}
return 0;
}
/** Read the locally-installed SKILL.md version for `name`, across the tiers. */
async function readInstalledVersion(name: string): Promise<string | undefined> {
for (const dir of SKILL_DIRS) {
try {
const md = await fs.readFile(join(dir, name, "SKILL.md"), "utf-8");
const v = parseFrontmatter(md).version;
if (v) return v;
} catch {
// not in this tier
}
}
return undefined;
}
function rawUrl(ref: string, repoPath: string): string {
return `https://raw.githubusercontent.com/${REPO}/${ref}/${repoPath}`;
}
const SHA_RE = /^[0-9a-f]{7,40}$/;
// A snapshot of the repo pinned to ONE ref (the branch-head commit SHA when
// resolvable, else the branch name). Catalog + install share it, so the file
// list, the raw downloads, and the recorded commit all refer to the SAME
// revision — the branch can't move out from under a single operation. Cached.
let snapshotCache: { at: number; ref: string; tree: TreeBlob[] } | null = null;
const TREE_TTL_MS = 5 * 60 * 1000;
/** Resolve the branch to its head commit SHA, falling back to the branch name. */
async function resolveRef(): Promise<string> {
try {
const res = await fetch(
`https://api.github.com/repos/${REPO}/commits/${BRANCH}`,
{ headers: GITHUB_HEADERS }
);
if (res.ok) {
const data = (await res.json()) as { sha?: string };
if (typeof data.sha === "string" && SHA_RE.test(data.sha)) return data.sha;
}
} catch {
// fall back to the branch name below
}
return BRANCH;
}
async function getRepoSnapshot(
force = false
): Promise<{ ref: string; tree: TreeBlob[] }> {
if (!force && snapshotCache && Date.now() - snapshotCache.at < TREE_TTL_MS) {
return { ref: snapshotCache.ref, tree: snapshotCache.tree };
}
const ref = await resolveRef();
const res = await fetch(
`https://api.github.com/repos/${REPO}/git/trees/${ref}?recursive=1`,
{ headers: GITHUB_HEADERS }
);
if (!res.ok) {
throw new Error(
res.status === 403
? "GitHub rate limit reached — try again in a minute."
: `Couldn't reach the skills catalog (GitHub ${res.status}).`
);
}
const data = (await res.json()) as { tree?: TreeBlob[]; truncated?: boolean };
if (!Array.isArray(data.tree)) {
throw new Error("Unexpected response from the skills catalog.");
}
snapshotCache = { at: Date.now(), ref, tree: data.tree };
return { ref, tree: data.tree };
}
/** The commit to record in the manifest — the snapshot ref iff it's a SHA. */
function commitFromRef(ref: string): string | null {
return ref !== BRANCH && SHA_RE.test(ref) ? ref : null;
}
// ---------------------------------------------------------------------------
// .installed.yaml manifest (EvoScientist's per-tier skill registry)
//
// Schema: { <name>: { source: <str>, commit?: <sha> } }, written by EvoScientist
// via `yaml.safe_dump(sort_keys=True)`. We read/merge/write the same shape so
// onboard's ✓ indicator + commit-based "update available" stay consistent.
// ---------------------------------------------------------------------------
type ManifestEntry = { source: string; commit?: string };
type Manifest = Record<string, ManifestEntry>;
function manifestPath(): string {
return join(SKILLS_INSTALL_DIR, ".installed.yaml");
}
/** Minimal parser for the flat 2-level manifest (name → {source, commit}). */
function parseManifest(text: string): Manifest {
const out: Manifest = {};
let current: string | null = null;
for (const line of text.split("\n")) {
if (!line.trim() || line.trimStart().startsWith("#")) continue;
if (/^\S/.test(line)) {
const m = line.match(/^(\S[^:]*?):\s*$/);
current = m ? m[1].trim() : null;
if (current) out[current] = { source: "" };
} else if (current) {
const m = line.match(/^\s+([A-Za-z_]+)\s*:\s*(.+?)\s*$/);
if (m) {
const value = m[2].replace(/^["']|["']$/g, "").trim();
if (m[1] === "source") out[current].source = value;
else if (m[1] === "commit") out[current].commit = value;
}
}
}
// Drop entries without a source — matches EvoScientist's loader leniency.
for (const k of Object.keys(out)) if (!out[k].source) delete out[k];
return out;
}
async function readManifest(): Promise<Manifest> {
try {
return parseManifest(await fs.readFile(manifestPath(), "utf-8"));
} catch {
return {};
}
}
/** Emit matching `yaml.safe_dump(sort_keys=True)`: names sorted, inner keys
* sorted (commit before source), values unquoted (safe for our inputs). */
function emitManifest(m: Manifest): string {
const names = Object.keys(m).sort();
if (names.length === 0) return "{}\n";
let out = "";
for (const name of names) {
out += `${name}:\n`;
if (m[name].commit) out += ` commit: ${m[name].commit}\n`;
out += ` source: ${m[name].source}\n`;
}
return out;
}
async function writeManifest(m: Manifest): Promise<void> {
const path = manifestPath();
const tmp = `${path}.${randomUUID()}.tmp`;
await fs.mkdir(SKILLS_INSTALL_DIR, { recursive: true });
await fs.writeFile(tmp, emitManifest(m), { mode: 0o600 });
await fs.rename(tmp, path);
}
async function recordInstall(name: string, commit: string | null): Promise<void> {
const manifest = await readManifest();
const source = manifestSource(name);
manifest[name] = commit ? { source, commit } : { source };
await writeManifest(manifest);
}
/** Remove a skill's manifest entry (used by uninstall). No-op if absent. */
export async function recordUninstall(name: string): Promise<void> {
const manifest = await readManifest();
if (name in manifest) {
delete manifest[name];
await writeManifest(manifest);
}
}
async function listInstalledNames(): Promise<Set<string>> {
const names = new Set<string>();
for (const dir of SKILL_DIRS) {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory() && !e.name.startsWith(".")) names.add(e.name);
}
} catch {
// dir doesn't exist — skip
}
}
return names;
}
/** Blobs that live under `skills/<name>/`, keyed by skill dir name. */
function groupSkillBlobs(tree: TreeBlob[]): Map<string, TreeBlob[]> {
const byName = new Map<string, TreeBlob[]>();
for (const item of tree) {
if (item.type !== "blob" || !item.path.startsWith(SKILLS_PREFIX)) continue;
const rest = item.path.slice(SKILLS_PREFIX.length);
const slash = rest.indexOf("/");
if (slash <= 0) continue; // a file directly under skills/ (e.g. README) — not a skill
const name = rest.slice(0, slash);
(byName.get(name) ?? byName.set(name, []).get(name)!).push(item);
}
return byName;
}
export async function getCatalog(force = false): Promise<CatalogSkill[]> {
const [{ ref, tree }, installed] = await Promise.all([
getRepoSnapshot(force),
listInstalledNames(),
]);
const byName = groupSkillBlobs(tree);
const skills = await Promise.all(
[...byName.entries()].map(async ([name, blobs]) => {
let title = name;
let description = "";
let latestVersion: string | undefined;
const skillMd = blobs.find(
(b) => b.path === `${SKILLS_PREFIX}${name}/SKILL.md`
);
if (skillMd) {
try {
const md = await fetch(rawUrl(ref, skillMd.path), {
headers: GITHUB_HEADERS,
}).then((r) => (r.ok ? r.text() : ""));
const fm = parseFrontmatter(md);
title = fm.name || name;
description = fm.description || "";
latestVersion = fm.version;
} catch {
// best-effort metadata
}
}
const isInstalled = installed.has(name);
const installedVersion = isInstalled
? await readInstalledVersion(name)
: undefined;
const updateAvailable =
isInstalled &&
!!latestVersion &&
!!installedVersion &&
compareVersions(latestVersion, installedVersion) > 0;
return {
name,
title,
description,
fileCount: blobs.length,
installed: isInstalled,
latestVersion,
installedVersion,
updateAvailable,
} satisfies CatalogSkill;
})
);
return skills.sort((a, b) => a.title.localeCompare(b.title));
}
/** Download every file of `skills/<name>/` into the install dir, atomically. */
export async function installSkill(
name: string
): Promise<{ files: number }> {
if (!isValidSkillName(name)) throw new Error("Invalid skill name.");
const { ref, tree } = await getRepoSnapshot();
const prefix = `${SKILLS_PREFIX}${name}/`;
const blobs = tree.filter((t) => t.type === "blob" && t.path.startsWith(prefix));
if (blobs.length === 0) {
throw new Error(`Skill "${name}" was not found in the catalog.`);
}
if (blobs.length > MAX_SKILL_FILES) {
throw new Error("This skill has too many files to install.");
}
const totalBytes = blobs.reduce((sum, b) => sum + (b.size ?? 0), 0);
if (totalBytes > MAX_SKILL_BYTES) {
throw new Error("This skill is too large to install.");
}
const installRoot = resolve(SKILLS_INSTALL_DIR);
const destRoot = resolve(installRoot, name);
if (destRoot !== join(installRoot, name)) {
throw new Error("Invalid skill name.");
}
// Download to a temp dir (a dotfile, so it never shows as a skill mid-install),
// then swap atomically. Roll back the temp dir on any failure.
const tmpRoot = join(installRoot, `.installing-${name}-${randomUUID()}`);
try {
await fs.mkdir(tmpRoot, { recursive: true });
for (const blob of blobs) {
const rel = blob.path.slice(prefix.length);
const target = resolve(tmpRoot, rel);
if (target !== tmpRoot && !target.startsWith(tmpRoot + sep)) {
throw new Error("Invalid file path in skill.");
}
const res = await fetch(rawUrl(ref, blob.path), {
headers: GITHUB_HEADERS,
});
if (!res.ok) {
throw new Error(`Failed to download ${rel} (${res.status}).`);
}
const buf = Buffer.from(await res.arrayBuffer());
await fs.mkdir(dirname(target), { recursive: true });
await fs.writeFile(target, buf);
}
// Replace any existing install, then move the fresh copy into place.
await fs.rm(destRoot, { recursive: true, force: true });
await fs.mkdir(installRoot, { recursive: true });
await fs.rename(tmpRoot, destRoot);
} catch (error) {
await fs.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
throw error;
}
// Record provenance in .installed.yaml so EvoScientist's onboard/CLI see the
// skill as installed (with the pinned commit for its update detection — the
// SAME revision the files came from). Best-effort: a manifest failure must not
// fail an otherwise-good install.
try {
await recordInstall(name, commitFromRef(ref));
} catch {
// ignore manifest write errors
}
return { files: blobs.length };
}