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:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user