diff --git a/src/app/api/skills/install-source/route.test.ts b/src/app/api/skills/install-source/route.test.ts new file mode 100644 index 0000000..73a5c3f --- /dev/null +++ b/src/app/api/skills/install-source/route.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + installFromSource: vi.fn(), +})); + +vi.mock("@/lib/server/skill-source", () => ({ + installFromSource: mocks.installFromSource, +})); + +const routes = await import("./route"); + +function postBody(body: unknown, origin?: string) { + return new NextRequest("http://localhost/api/skills/install-source", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(origin ? { origin } : {}), + }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/skills/install-source", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("installs a single skill and returns its result", async () => { + mocks.installFromSource.mockResolvedValue([{ name: "peft", ok: true }]); + const res = await routes.POST( + postBody({ source: "anthropics/skills@peft" }) + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + results: [{ name: "peft", ok: true }], + }); + expect(mocks.installFromSource).toHaveBeenCalledWith( + "anthropics/skills@peft" + ); + }); + + it("returns every result of a pack install", async () => { + mocks.installFromSource.mockResolvedValue([ + { name: "a", ok: true }, + { name: "b", ok: true }, + ]); + const res = await routes.POST(postBody({ source: "owner/pack" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.results).toHaveLength(2); + }); + + it("returns 400 when every install fails", async () => { + mocks.installFromSource.mockResolvedValue([ + { name: "bad", ok: false, error: "Invalid skill name: bad" }, + ]); + const res = await routes.POST(postBody({ source: "owner/repo@bad" })); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.results[0].ok).toBe(false); + }); + + it("returns 400 with the error message when the source cannot be resolved", async () => { + mocks.installFromSource.mockRejectedValue( + new Error("No SKILL.md found in: owner/repo") + ); + const res = await routes.POST(postBody({ source: "owner/repo" })); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "No SKILL.md found in: owner/repo", + }); + }); + + it("rejects a missing source", async () => { + const res = await routes.POST(postBody({})); + expect(res.status).toBe(400); + expect(mocks.installFromSource).not.toHaveBeenCalled(); + }); + + it("rejects a blank source", async () => { + const res = await routes.POST(postBody({ source: " " })); + expect(res.status).toBe(400); + expect(mocks.installFromSource).not.toHaveBeenCalled(); + }); + + it("rejects cross-origin requests", async () => { + const res = await routes.POST( + postBody({ source: "owner/repo" }, "https://evil.example") + ); + expect(res.status).toBe(403); + expect(mocks.installFromSource).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/skills/install-source/route.ts b/src/app/api/skills/install-source/route.ts new file mode 100644 index 0000000..30e18a9 --- /dev/null +++ b/src/app/api/skills/install-source/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { installFromSource } from "@/lib/server/skill-source"; + +// Install from an arbitrary source: GitHub shorthand (owner/repo@path), +// GitHub URL (.../tree/ref/path), or a local directory. One source can yield +// several skills (a pack) — results are per-item, mirroring the batch +// install/uninstall endpoints: 200 when anything succeeded, 400 otherwise. +export async function POST(request: NextRequest) { + 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 { + source?: unknown; + } | null; + const source = typeof body?.source === "string" ? body.source.trim() : ""; + if (!source) { + return NextResponse.json( + { error: "Missing install source." }, + { status: 400 } + ); + } + + try { + const results = await installFromSource(source); + const anyOk = results.some((r) => r.ok); + return NextResponse.json({ results }, { status: anyOk ? 200 : 400 }); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Failed to install skill.", + }, + { status: 400 } + ); + } +} diff --git a/src/app/api/skills/install/route.test.ts b/src/app/api/skills/install/route.test.ts new file mode 100644 index 0000000..f702a11 --- /dev/null +++ b/src/app/api/skills/install/route.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + installSkill: vi.fn(), +})); + +vi.mock("@/lib/server/skills", async (importOriginal) => { + const original = + await importOriginal(); + return { ...original, installSkill: mocks.installSkill }; +}); + +const routes = await import("./route"); + +function postBody(body: unknown, origin?: string) { + return new NextRequest("http://localhost/api/skills/install", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(origin ? { origin } : {}), + }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/skills/install (batch)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("installs every name and returns per-item results", async () => { + mocks.installSkill.mockResolvedValue({ files: 3 }); + const res = await routes.POST(postBody({ names: ["alpha", "beta"] })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data).toEqual({ + results: [ + { name: "alpha", ok: true, files: 3 }, + { name: "beta", ok: true, files: 3 }, + ], + }); + expect(mocks.installSkill).toHaveBeenCalledTimes(2); + expect(mocks.installSkill).toHaveBeenNthCalledWith(1, "alpha"); + expect(mocks.installSkill).toHaveBeenNthCalledWith(2, "beta"); + }); + + it("continues after a failure and reports per-item errors", async () => { + mocks.installSkill + .mockRejectedValueOnce(new Error("GitHub rate limit reached")) + .mockResolvedValueOnce({ files: 1 }); + const res = await routes.POST(postBody({ names: ["bad", "good"] })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.results).toEqual([ + { name: "bad", ok: false, error: "GitHub rate limit reached" }, + { name: "good", ok: true, files: 1 }, + ]); + }); + + it("returns 400 when every install fails", async () => { + mocks.installSkill.mockRejectedValue(new Error("boom")); + const res = await routes.POST(postBody({ names: ["x", "y"] })); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.results).toHaveLength(2); + expect(data.results.every((r: { ok: boolean }) => !r.ok)).toBe(true); + }); + + it("marks invalid names as failed without calling installSkill", async () => { + mocks.installSkill.mockResolvedValue({ files: 1 }); + const res = await routes.POST( + postBody({ names: ["../evil", "good"] }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.results[0]).toMatchObject({ name: "../evil", ok: false }); + expect(data.results[1]).toMatchObject({ name: "good", ok: true }); + expect(mocks.installSkill).toHaveBeenCalledTimes(1); + expect(mocks.installSkill).toHaveBeenCalledWith("good"); + }); + + it("rejects an empty names array", async () => { + const res = await routes.POST(postBody({ names: [] })); + expect(res.status).toBe(400); + }); + + it("rejects a body without a names array", async () => { + const res = await routes.POST(postBody({ name: "alpha" })); + expect(res.status).toBe(400); + }); + + it("rejects cross-origin requests", async () => { + const res = await routes.POST( + postBody({ names: ["alpha"] }, "https://evil.example") + ); + expect(res.status).toBe(403); + expect(mocks.installSkill).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/skills/install/route.ts b/src/app/api/skills/install/route.ts index 3aaf990..1643941 100644 --- a/src/app/api/skills/install/route.ts +++ b/src/app/api/skills/install/route.ts @@ -1,39 +1,59 @@ import { NextRequest, NextResponse } from "next/server"; -import { installSkill } from "@/lib/server/skills"; +import { installSkill, isValidSkillName } from "@/lib/server/skills"; export const runtime = "nodejs"; +interface ItemResult { + name: string; + ok: boolean; + files?: number; + error?: string; +} + 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) { + // 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: - error instanceof Error ? error.message : "Failed to install skill.", - }, + { error: "Cross-origin installs are not allowed." }, + { status: 403 } + ); + } + + const body = (await request.json().catch(() => null)) as { + names?: unknown; + } | null; + const names = body?.names; + if ( + !Array.isArray(names) || + names.length === 0 || + !names.every((n) => typeof n === "string" && n) + ) { + return NextResponse.json( + { error: "Missing skill names." }, { status: 400 } ); } + + const results: ItemResult[] = []; + for (const name of names as string[]) { + if (!isValidSkillName(name)) { + results.push({ name, ok: false, error: "Invalid skill name." }); + continue; + } + try { + const { files } = await installSkill(name); + results.push({ name, ok: true, files }); + } catch (error) { + results.push({ + name, + ok: false, + error: + error instanceof Error ? error.message : "Failed to install skill.", + }); + } + } + + const anyOk = results.some((r) => r.ok); + return NextResponse.json({ results }, { status: anyOk ? 200 : 400 }); } diff --git a/src/app/api/skills/route.test.ts b/src/app/api/skills/route.test.ts new file mode 100644 index 0000000..3916502 --- /dev/null +++ b/src/app/api/skills/route.test.ts @@ -0,0 +1,110 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { promises as fs } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { NextRequest } from "next/server"; + +// Isolate the global skills tier into a temp dir BEFORE the lib module reads +// EVOSCIENTIST_DATA_DIR at import time. Skill names use a vitest- prefix so a +// missing-name lookup can never collide with a real skill in the legacy +// ~/.config fallback tier. +const dataDir = await fs.mkdtemp(join(tmpdir(), "evoskills-route-test-")); +process.env.EVOSCIENTIST_DATA_DIR = dataDir; + +const routes = await import("./route"); + +const SKILLS_DIR = join(dataDir, "skills"); +const SKILL_A = "vitest-batch-skill-a"; +const SKILL_B = "vitest-batch-skill-b"; +const MISSING = "vitest-definitely-missing-skill"; + +function deleteReq(names: string[]) { + const qs = names.map((n) => `name=${encodeURIComponent(n)}`).join("&"); + return new NextRequest(`http://localhost/api/skills?${qs}`, { + method: "DELETE", + }); +} + +async function makeSkill(name: string) { + const dir = join(SKILLS_DIR, name); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(join(dir, "SKILL.md"), `---\nname: ${name}\n---\n`); +} + +async function readManifest() { + return fs.readFile(join(SKILLS_DIR, ".installed.yaml"), "utf-8"); +} + +afterAll(async () => { + await fs.rm(dataDir, { recursive: true, force: true }); +}); + +describe("DELETE /api/skills (batch)", () => { + beforeEach(async () => { + await fs.rm(SKILLS_DIR, { recursive: true, force: true }); + await fs.mkdir(SKILLS_DIR, { recursive: true }); + }); + + it("removes every named skill and returns per-item results", async () => { + await makeSkill(SKILL_A); + await makeSkill(SKILL_B); + await fs.writeFile( + join(SKILLS_DIR, ".installed.yaml"), + `${SKILL_A}:\n source: EvoScientist/EvoSkills@skills/${SKILL_A}\n${SKILL_B}:\n source: EvoScientist/EvoSkills@skills/${SKILL_B}\n` + ); + + const res = await routes.DELETE(deleteReq([SKILL_A, SKILL_B])); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data).toEqual({ + results: [ + { name: SKILL_A, ok: true }, + { name: SKILL_B, ok: true }, + ], + }); + + await expect(fs.stat(join(SKILLS_DIR, SKILL_A))).rejects.toThrow(); + await expect(fs.stat(join(SKILLS_DIR, SKILL_B))).rejects.toThrow(); + const manifest = await readManifest(); + expect(manifest).not.toContain(SKILL_A); + expect(manifest).not.toContain(SKILL_B); + }); + + it("continues after a missing skill and reports per-item errors", async () => { + await makeSkill(SKILL_A); + const res = await routes.DELETE(deleteReq([MISSING, SKILL_A])); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.results).toEqual([ + { name: MISSING, ok: false, error: "Skill not found." }, + { name: SKILL_A, ok: true }, + ]); + await expect(fs.stat(join(SKILLS_DIR, SKILL_A))).rejects.toThrow(); + }); + + it("returns 400 when every uninstall fails", async () => { + const res = await routes.DELETE(deleteReq([MISSING])); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.results).toEqual([ + { name: MISSING, ok: false, error: "Skill not found." }, + ]); + }); + + it("marks invalid names as failed without touching the disk", async () => { + await makeSkill(SKILL_A); + const res = await routes.DELETE(deleteReq(["..", SKILL_A])); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.results[0]).toMatchObject({ name: "..", ok: false }); + expect(data.results[1]).toMatchObject({ name: SKILL_A, ok: true }); + await expect(fs.stat(SKILLS_DIR)).resolves.toBeTruthy(); + }); + + it("rejects requests without any name", async () => { + const res = await routes.DELETE( + new NextRequest("http://localhost/api/skills", { method: "DELETE" }) + ); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index 340c81c..da6b1ef 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -1,11 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; -import { join, resolve, sep } from "path"; +import { join, sep } from "path"; import { promises as fs } from "fs"; -import { - SKILL_DIRS, - recordUninstall, - isValidSkillName, -} from "@/lib/server/skills"; +import { SKILL_DIRS, uninstallSkill } from "@/lib/server/skills"; // SKILL_DIRS (the global ~/.evoscientist/skills tier + legacy ~/.config // fallback) is the single source of truth, shared with the install route. @@ -89,32 +85,26 @@ export async function GET() { } } -// Uninstall = remove the skill directory. Guard against path traversal and -// only delete inside the known skill dirs. +// Uninstall = remove the skill directory. Supports batch via repeated `name` +// params; each item is independent — one failure doesn't stop the rest. export async function DELETE(req: NextRequest) { - const name = req.nextUrl.searchParams.get("name"); - // Strict name check (blocks dotfiles like `.installed.yaml`, traversal, odd - // chars) — must match install-side validation, not the old slash/`..`-only one. - if (!name || !isValidSkillName(name)) { - return NextResponse.json({ error: "Invalid skill name" }, { status: 400 }); + const names = req.nextUrl.searchParams.getAll("name").filter(Boolean); + if (names.length === 0) { + return NextResponse.json({ error: "Missing skill name" }, { status: 400 }); } - for (const dir of SKILL_DIRS) { - const target = resolve(join(dir, name)); - if (target !== resolve(dir) && !target.startsWith(resolve(dir) + sep)) { - continue; - } + const results: { name: string; ok: boolean; error?: string }[] = []; + for (const name of names) { try { - // Only ever remove an actual skill directory, never a stray file. - const stat = await fs.stat(target); - if (!stat.isDirectory()) continue; - } catch { - continue; // not here + await uninstallSkill(name); + results.push({ name, ok: true }); + } catch (e) { + results.push({ + name, + ok: false, + error: e instanceof Error ? e.message : "Failed to uninstall", + }); } - 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 }); + const anyOk = results.some((r) => r.ok); + return NextResponse.json({ results }, { status: anyOk ? 200 : 400 }); } diff --git a/src/app/components/SkillsMarketplace.tsx b/src/app/components/SkillsMarketplace.tsx index 48eff36..8515027 100644 --- a/src/app/components/SkillsMarketplace.tsx +++ b/src/app/components/SkillsMarketplace.tsx @@ -8,12 +8,19 @@ import { Trash2, Download, ArrowUpCircle, + X, + Plus, + Package, + PackageOpen, + BadgeCheck, + Blocks, } from "lucide-react"; import { SkillDetailDialog, type SkillDetailTarget, } from "@/app/components/SkillDetailDialog"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, @@ -41,6 +48,48 @@ interface CatalogSkill { updateAvailable: boolean; } +interface ItemResult { + name: string; + ok: boolean; + error?: string; +} + +interface BatchSummary { + action: "install" | "uninstall"; + results: ItemResult[]; +} + +/** A tile shown in the grid: catalog skills carry versions/update state, + * local-only skills just the basics. */ +interface TileData { + name: string; + title: string; + description: string; + meta?: string; + installed: boolean; + installedVersion?: string; + latestVersion?: string; + updateAvailable?: boolean; +} + +/** Left-rail categories — mutually exclusive single-select. + * installed: every installed skill (official + third-party) + * available: catalog skills not yet installed + * official: the whole EvoSkills catalog, installed or not + * thirdparty: installed skills that are not in the official catalog */ +type Category = "installed" | "available" | "official" | "thirdparty"; + +const CATEGORY_META: { + key: Category; + label: string; + icon: typeof Package; +}[] = [ + { key: "installed", label: "Installed", icon: Package }, + { key: "available", label: "Not installed", icon: PackageOpen }, + { key: "official", label: "Official", icon: BadgeCheck }, + { key: "thirdparty", label: "Third-party", icon: Blocks }, +]; + export function SkillsMarketplace() { const [catalog, setCatalog] = useState([]); const [other, setOther] = useState([]); @@ -51,13 +100,26 @@ export function SkillsMarketplace() { const [busy, setBusy] = useState< Record >({}); + // Active left-rail category and the selection within it. Switching the + // category clears the selection. + const [category, setCategory] = useState("installed"); + const [selected, setSelected] = useState>(new Set()); + // Non-null while a batch request is in flight — disables all controls. + const [batchRunning, setBatchRunning] = useState< + "install" | "uninstall" | null + >(null); + const [summary, setSummary] = useState(null); // Skill whose detail dialog is open (null = closed). const [detail, setDetail] = useState(null); - const [uninstallTarget, setUninstallTarget] = useState<{ - name: string; - title: string; - isCatalog: boolean; - } | null>(null); + // Names pending batch-uninstall confirmation (null = dialog closed). + const [uninstallTargets, setUninstallTargets] = useState( + null + ); + // "Install from source" dialog state. + const [sourceOpen, setSourceOpen] = useState(false); + const [sourceValue, setSourceValue] = useState(""); + const [sourceBusy, setSourceBusy] = useState(false); + const [sourceError, setSourceError] = useState(null); const load = useCallback(async (refresh = false) => { setLoading(true); @@ -100,6 +162,7 @@ export function SkillsMarketplace() { : "Failed to load installed skills." ); } + setSelected(new Set()); setLoading(false); }, []); @@ -107,84 +170,205 @@ export function SkillsMarketplace() { load(); }, [load]); - // 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" + const setBusyFor = ( + names: string[], + mode: "install" | "uninstall" | "update" ) => { - 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; - }); - } + setBusy((b) => { + const next = { ...b }; + for (const n of names) next[n] = mode; + return next; + }); }; - const uninstall = async (name: string, isCatalog: boolean) => { - setBusy((b) => ({ ...b, [name]: "uninstall" })); + const clearBusyFor = (names: string[]) => { + setBusy((b) => { + const next = { ...b }; + for (const n of names) delete next[n]; + return next; + }); + }; + + /** Shared batch runner: hits an endpoint with a name list, applies the + * per-item results to state, and records a summary. */ + const runBatch = async ( + action: "install" | "uninstall", + names: string[], + busyMode?: "install" | "uninstall" | "update" + ): Promise => { + setBatchRunning(action); + setSummary(null); setError(null); + setBusyFor(names, busyMode ?? action); try { - const res = await fetch(`/api/skills?name=${encodeURIComponent(name)}`, { - method: "DELETE", - }); - if (!res.ok) { - const d = await res.json(); - throw new Error(d.error || "Failed to uninstall"); + const res = + action === "install" + ? await fetch("/api/skills/install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ names }), + }) + : await fetch( + `/api/skills?${names + .map((n) => `name=${encodeURIComponent(n)}`) + .join("&")}`, + { method: "DELETE" } + ); + const d = await res.json(); + const results = (d.results ?? []) as ItemResult[]; + if (results.length === 0) { + throw new Error(d.error || `Failed to ${action}`); } - if (isCatalog) { + const okNames = new Set(results.filter((r) => r.ok).map((r) => r.name)); + if (action === "install") { setCatalog((prev) => - prev.map((s) => (s.name === name ? { ...s, installed: false } : s)) + prev.map((s) => + okNames.has(s.name) + ? { + ...s, + installed: true, + updateAvailable: false, + installedVersion: s.latestVersion ?? s.installedVersion, + } + : s + ) ); } else { - setOther((prev) => prev.filter((s) => s.name !== name)); + setCatalog((prev) => + prev.map((s) => + okNames.has(s.name) ? { ...s, installed: false } : s + ) + ); + setOther((prev) => prev.filter((s) => !okNames.has(s.name))); } + setSelected(new Set()); + setSummary({ action, results }); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to uninstall"); + setError(e instanceof Error ? e.message : `Failed to ${action}`); } finally { - setBusy((b) => { - const next = { ...b }; - delete next[name]; - return next; - }); + clearBusyFor(names); + setBatchRunning(null); } }; - const confirmUninstall = async () => { - if (!uninstallTarget) return; - const target = uninstallTarget; - setUninstallTarget(null); - await uninstall(target.name, target.isCatalog); + const toTile = (s: CatalogSkill): TileData => ({ + name: 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, + }); + + const thirdpartyTiles: TileData[] = other.map((s) => ({ + name: s.name, + title: s.title, + description: s.description, + installed: true, + })); + + const tilesByCategory: Record = { + installed: [...catalog.filter((s) => s.installed).map(toTile), ...thirdpartyTiles], + available: catalog.filter((s) => !s.installed).map(toTile), + official: catalog.map(toTile), + thirdparty: thirdpartyTiles, }; + const activeTiles = tilesByCategory[category]; + const counts: Record = { + installed: tilesByCategory.installed.length, + available: tilesByCategory.available.length, + official: tilesByCategory.official.length, + thirdparty: tilesByCategory.thirdparty.length, + }; + + const switchCategory = (next: Category) => { + if (next === category) return; + setCategory(next); + setSelected(new Set()); + }; + + /** Install from a user-typed source (GitHub shorthand/URL or local path). + * One source can yield several skills (a pack) — the summary banner + * reports per-item outcomes just like a batch install. */ + const submitSource = async () => { + const source = sourceValue.trim(); + if (!source || sourceBusy) return; + setSourceBusy(true); + setSourceError(null); + try { + const res = await fetch("/api/skills/install-source", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source }), + }); + const d = await res.json(); + const results = Array.isArray(d.results) + ? (d.results as ItemResult[]) + : null; + if (results?.some((r) => r.ok)) { + setSourceOpen(false); + setSourceValue(""); + setSummary({ action: "install", results }); + await load(); + } else if (results) { + setSourceError( + results.map((r) => `${r.name}: ${r.error ?? "failed"}`).join("; ") + ); + } else { + setSourceError(d.error || "Failed to install from this source."); + } + } catch (e) { + setSourceError( + e instanceof Error ? e.message : "Failed to install from this source." + ); + } finally { + setSourceBusy(false); + } + }; + + const toggle = (name: string, checked: boolean) => { + setSelected((prev) => { + const next = new Set(prev); + if (checked) next.add(name); + else next.delete(name); + return next; + }); + }; + + const openDetail = (s: TileData) => + setDetail({ + name: s.name, + title: s.title, + description: s.description, + version: s.installed ? s.installedVersion : s.latestVersion, + installed: s.installed, + }); + + // Batch applicability within the current selection. Only the "official" + // view mixes installed and not-installed tiles, so it can offer both + // batch buttons at once; the other categories have a single applicable + // action. + const selectedTiles = activeTiles.filter((t) => selected.has(t.name)); + const installable = selectedTiles.filter((t) => !t.installed); + const removable = selectedTiles.filter((t) => t.installed); + + const emptyText: Record = { + installed: catalogError || "No skills installed yet.", + available: + catalogError ?? "Every skill in the catalog is already installed.", + official: catalogError ?? "The official catalog is unavailable.", + thirdparty: "No third-party skills installed.", + }; + + const okCount = summary?.results.filter((r) => r.ok).length ?? 0; + const failed = summary?.results.filter((r) => !r.ok) ?? []; + return (
-
+

@@ -203,158 +387,348 @@ export function SkillsMarketplace() { catalog, or remove ones you don't need.

- +
+ + +
{error && ( -

+

{error}

)} + {summary && ( +
+
+ + {summary.action === "install" ? "Installed" : "Uninstalled"}{" "} + {okCount} skill{okCount === 1 ? "" : "s"}. + + {failed.length > 0 && ( + + {failed.length} failed:{" "} + {failed + .map((f) => `${f.name}${f.error ? ` (${f.error})` : ""}`) + .join(", ")} + + )} +
+ +
+ )} + {loading ? (
-
) : ( -
-
-

- Official catalog -

- {catalogError ? ( -

{catalogError}

- ) : catalog.length === 0 ? ( +
+ + +
+
+

+ {CATEGORY_META.find((c) => c.key === category)?.label} ( + {activeTiles.length}) +

+ {selected.size > 0 && ( +
+ + {selected.size} selected + + + + {installable.length > 0 && ( + + )} + {removable.length > 0 && ( + + )} +
+ )} +
+ {activeTiles.length === 0 ? (

- No skills found in the catalog. + {emptyText[category]}

) : ( -
- {catalog.map((s) => ( +
+ {activeTiles.map((s) => ( toggle(s.name, checked)} busy={busy[s.name]} - onOpen={() => - setDetail({ - name: s.name, - title: s.title, - description: s.description, - version: s.installed - ? s.installedVersion - : s.latestVersion, - fileCount: s.fileCount, - installed: s.installed, - }) + controlsDisabled={batchRunning !== null} + onOpen={() => openDetail(s)} + onInstall={ + s.installed + ? undefined + : () => runBatch("install", [s.name]) } - onInstall={() => install(s.name)} - onUpdate={() => install(s.name, "update")} - onUninstall={() => - setUninstallTarget({ - name: s.name, - title: s.title, - isCatalog: true, - }) + onUpdate={ + s.installed && s.updateAvailable + ? () => runBatch("install", [s.name], "update") + : undefined + } + onUninstall={ + s.installed + ? () => setUninstallTargets([s]) + : undefined } /> ))}
)}
- - {other.length > 0 && ( -
-

- Other installed skills -

-
- {other.map((s) => ( - - setDetail({ - name: s.name, - title: s.title, - description: s.description, - installed: true, - }) - } - onUninstall={() => - setUninstallTarget({ - name: s.name, - title: s.title, - isCatalog: false, - }) - } - /> - ))} -
-
- )}
)}
- setDetail(null)} - /> + setDetail(null)} /> { - if (!open) setUninstallTarget(null); + if (!open && !sourceBusy) setSourceOpen(false); }} > - Uninstall skill? - - “{uninstallTarget?.title ?? uninstallTarget?.name}” will be - removed from this Web UI. You can install it again later. + Install from source + +
+

Install a skill from GitHub or a local directory:

+
    +
  • + owner/repo@skill-name +
  • +
  • + + https://github.com/owner/repo/tree/main/skill-name + +
  • +
  • + ./my-skill or{" "} + /path/to/skill +
  • +
+

+ A directory or repo containing several skills installs them + all. +

+
+
+
+
{ + e.preventDefault(); + void submitSource(); + }} + > + setSourceValue(e.target.value)} + placeholder="owner/repo@skill-name" + disabled={sourceBusy} + autoFocus + /> + {sourceError && ( +

+ {sourceError} +

+ )} + + + + +
+
+
+ { + if (!open) setUninstallTargets(null); + }} + > + + + + Uninstall {uninstallTargets?.length === 1 ? "skill" : "skills"}? + + +
+

+ {uninstallTargets?.length === 1 + ? "This skill will be" + : `These ${uninstallTargets?.length ?? 0} skills will be`}{" "} + removed from this Web UI. +

+ {(() => { + const catNames = new Set(catalog.map((c) => c.name)); + const thirdpartyCount = (uninstallTargets ?? []).filter( + (t) => !catNames.has(t.name) + ).length; + return thirdpartyCount > 0 ? ( +

+ {thirdpartyCount === (uninstallTargets ?? []).length + ? thirdpartyCount === 1 + ? "This is a third-party skill" + : `All ${thirdpartyCount} are third-party skills` + : `${thirdpartyCount} of these are third-party skills`} + {" "}not in the official catalog —{" "} + + once removed, they cannot be reinstalled from here. + +

+ ) : ( +

+ You can install them again later from the catalog. +

+ ); + })()} +
    + {uninstallTargets?.map((t) => ( +
  • {t.title}
  • + ))} +
+
-
- {installed && updateAvailable && ( + {installed && updateAvailable && onUpdate && ( )} - {installed ? ( - + ) + : onInstall && ( + )} - {busy === "uninstall" ? "Removing…" : "Uninstall"} - - ) : ( - - )}
); diff --git a/src/lib/server/skill-source.test.ts b/src/lib/server/skill-source.test.ts new file mode 100644 index 0000000..1244da9 --- /dev/null +++ b/src/lib/server/skill-source.test.ts @@ -0,0 +1,393 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { promises as fs } from "fs"; +import { homedir, tmpdir } from "os"; +import { join } from "path"; + +// Isolate the global skills tier into a temp dir BEFORE the lib reads +// EVOSCIENTIST_DATA_DIR at import time. +const dataDir = await fs.mkdtemp(join(tmpdir(), "evoskill-source-test-")); +process.env.EVOSCIENTIST_DATA_DIR = dataDir; + +const lib = await import("./skill-source"); + +const SKILLS_DIR = join(dataDir, "skills"); + +let srcRoot: string; + +async function makeSkillDir(rel: string, frontmatterName?: string) { + const dir = join(srcRoot, rel); + await fs.mkdir(dir, { recursive: true }); + const fm = frontmatterName ? `---\nname: ${frontmatterName}\n---\n` : "---\n---\n"; + await fs.writeFile(join(dir, "SKILL.md"), `${fm}# Body\n`); + return dir; +} + +async function readManifest(): Promise { + return fs.readFile(join(SKILLS_DIR, ".installed.yaml"), "utf-8"); +} + +afterAll(async () => { + await fs.rm(dataDir, { recursive: true, force: true }); + await fs.rm(srcRoot, { recursive: true, force: true }); +}); + +beforeEach(async () => { + await fs.rm(SKILLS_DIR, { recursive: true, force: true }); + await fs.mkdir(SKILLS_DIR, { recursive: true }); + srcRoot = await fs.mkdtemp(join(tmpdir(), "evoskill-src-")); +}); + +describe("parseSource", () => { + it("parses the owner/repo@path shorthand", () => { + expect(lib.parseSource("anthropics/skills@peft")).toEqual({ + kind: "github", + repo: "anthropics/skills", + ref: undefined, + path: "peft", + }); + }); + + it("parses a bare owner/repo as a repo-wide source", () => { + expect(lib.parseSource("owner/repo")).toEqual({ + kind: "github", + repo: "owner/repo", + ref: undefined, + path: undefined, + }); + }); + + it("parses a plain GitHub URL", () => { + expect(lib.parseSource("https://github.com/owner/repo")).toEqual({ + kind: "github", + repo: "owner/repo", + ref: undefined, + path: undefined, + }); + }); + + it("parses a GitHub tree URL with ref and path", () => { + expect( + lib.parseSource("https://github.com/owner/repo/tree/main/deep/skill") + ).toEqual({ + kind: "github", + repo: "owner/repo", + ref: "main", + path: "deep/skill", + }); + }); + + it("parses a github.com URL without protocol", () => { + expect(lib.parseSource("github.com/owner/repo/tree/dev/x")).toEqual({ + kind: "github", + repo: "owner/repo", + ref: "dev", + path: "x", + }); + }); + + it("rejects non-GitHub URLs", () => { + expect(() => lib.parseSource("https://gitlab.com/owner/repo")).toThrow(); + }); + + it("rejects unparseable GitHub-ish URLs", () => { + expect(() => + lib.parseSource("https://github.com/owner/repo/pull/1") + ).toThrow(); + }); + + it("rejects empty input", () => { + expect(() => lib.parseSource(" ")).toThrow(); + }); + + it("treats relative and absolute paths as local", () => { + expect(lib.parseSource("./my-skill")).toEqual({ + kind: "local", + path: "./my-skill", + }); + expect(lib.parseSource("/abs/path")).toEqual({ + kind: "local", + path: "/abs/path", + }); + }); + + it("expands ~ in local paths", () => { + const parsed = lib.parseSource("~/skill"); + expect(parsed.kind).toBe("local"); + if (parsed.kind === "local") { + expect(parsed.path).toBe(join(homedir(), "skill")); + } + }); +}); + +describe("installFromSource (local)", () => { + it("installs a single skill directory", async () => { + const dir = await makeSkillDir("my-skill", "my-skill"); + const results = await lib.installFromSource(dir); + expect(results).toEqual([{ name: "my-skill", ok: true }]); + await expect( + fs.stat(join(SKILLS_DIR, "my-skill", "SKILL.md")) + ).resolves.toBeTruthy(); + expect(await readManifest()).toContain(`source: ${dir}`); + }); + + it("uses the frontmatter name, not the directory name", async () => { + const dir = await makeSkillDir("random-dir", "fancy-skill"); + const results = await lib.installFromSource(dir); + expect(results).toEqual([{ name: "fancy-skill", ok: true }]); + await expect( + fs.stat(join(SKILLS_DIR, "fancy-skill", "SKILL.md")) + ).resolves.toBeTruthy(); + }); + + it("resolves a single nested skill one level down", async () => { + await makeSkillDir("pack/only-skill", "only-skill"); + const results = await lib.installFromSource(join(srcRoot, "pack")); + expect(results).toEqual([{ name: "only-skill", ok: true }]); + }); + + it("installs every skill of a multi-skill directory (pack)", async () => { + await makeSkillDir("pack/skill-a", "skill-a"); + await makeSkillDir("pack/skill-b", "skill-b"); + const results = await lib.installFromSource(join(srcRoot, "pack")); + expect(results).toEqual([ + { name: "skill-a", ok: true }, + { name: "skill-b", ok: true }, + ]); + const manifest = await readManifest(); + expect(manifest).toContain("skill-a:"); + expect(manifest).toContain("skill-b:"); + }); + + it("continues a pack install past an invalid skill name", async () => { + await makeSkillDir("pack/good", "good"); + await makeSkillDir("pack/bad", "bad..name"); + const results = await lib.installFromSource(join(srcRoot, "pack")); + expect(results).toHaveLength(2); + expect(results.find((r) => r.name === "good")?.ok).toBe(true); + const bad = results.find((r) => !r.ok); + expect(bad?.error).toMatch(/invalid skill name/i); + await expect( + fs.stat(join(SKILLS_DIR, "good", "SKILL.md")) + ).resolves.toBeTruthy(); + }); + + it("overwrites an existing install of the same name", async () => { + const dir = await makeSkillDir("my-skill", "my-skill"); + await fs.mkdir(join(SKILLS_DIR, "my-skill"), { recursive: true }); + await fs.writeFile(join(SKILLS_DIR, "my-skill", "stale.txt"), "old"); + const results = await lib.installFromSource(dir); + expect(results).toEqual([{ name: "my-skill", ok: true }]); + await expect( + fs.stat(join(SKILLS_DIR, "my-skill", "stale.txt")) + ).rejects.toThrow(); + }); + + it("excludes .git directories from the copy", async () => { + const dir = await makeSkillDir("my-skill", "my-skill"); + await fs.mkdir(join(dir, ".git")); + await fs.writeFile(join(dir, ".git", "config"), "x"); + await lib.installFromSource(dir); + await expect(fs.stat(join(SKILLS_DIR, "my-skill", ".git"))).rejects.toThrow(); + }); + + it("throws when the path does not exist", async () => { + await expect( + lib.installFromSource(join(srcRoot, "missing")) + ).rejects.toThrow(/does not exist/i); + }); + + it("throws when no SKILL.md can be found", async () => { + await fs.mkdir(join(srcRoot, "empty-dir", "nested"), { recursive: true }); + await expect( + lib.installFromSource(join(srcRoot, "empty-dir")) + ).rejects.toThrow(/no skill\.md/i); + }); +}); + +// --------------------------------------------------------------------------- +// GitHub sources — fetch is stubbed; the lib must drive the same GitHub API + +// raw-download flow as catalog installs, pinned to one resolved commit. +// --------------------------------------------------------------------------- + +const COMMIT = "a".repeat(40); + +interface FakeRepo { + blobs: { path: string; size?: number }[]; + /** raw file contents keyed by repo path */ + files: Record; + treeStatus?: number; +} + +function stubGitHub(repo: FakeRepo) { + const mock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/commits/")) { + return new Response(JSON.stringify({ sha: COMMIT }), { status: 200 }); + } + if (url.includes("/git/trees/")) { + if (repo.treeStatus && repo.treeStatus !== 200) { + return new Response("nope", { status: repo.treeStatus }); + } + return new Response( + JSON.stringify({ + tree: repo.blobs.map((b) => ({ + path: b.path, + type: "blob", + size: b.size ?? 10, + })), + }), + { status: 200 } + ); + } + if (url.startsWith("https://raw.githubusercontent.com/")) { + const path = url.split(`/${COMMIT}/`)[1] ?? url.split("/main/")[1]; + if (repo.files[path] !== undefined) { + return new Response(repo.files[path], { status: 200 }); + } + // Any blob listed in the tree is downloadable; content is irrelevant + // except for SKILL.md. + if (repo.blobs.some((b) => b.path === path)) { + return new Response("x", { status: 200 }); + } + return new Response("not found", { status: 404 }); + } + return new Response("unexpected", { status: 500 }); + }); + vi.stubGlobal("fetch", mock); + return mock; +} + +const skillMd = (name: string) => `---\nname: ${name}\n---\n# Body\n`; + +describe("installFromSource (github)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("installs a single skill from owner/repo@path", async () => { + stubGitHub({ + blobs: [ + { path: "peft/SKILL.md" }, + { path: "peft/scripts/run.py" }, + { path: "README.md" }, + ], + files: { "peft/SKILL.md": skillMd("peft") }, + }); + const results = await lib.installFromSource("anthropics/skills@peft"); + expect(results).toEqual([{ name: "peft", ok: true }]); + await expect( + fs.stat(join(SKILLS_DIR, "peft", "SKILL.md")) + ).resolves.toBeTruthy(); + await expect( + fs.stat(join(SKILLS_DIR, "peft", "scripts", "run.py")) + ).resolves.toBeTruthy(); + const manifest = await readManifest(); + expect(manifest).toContain("source: anthropics/skills@peft"); + expect(manifest).toContain(`commit: ${COMMIT}`); + }); + + it("installs a skill at the repo root from a plain URL", async () => { + stubGitHub({ + blobs: [{ path: "SKILL.md" }, { path: "notes.md" }], + files: { "SKILL.md": skillMd("root-skill") }, + }); + const results = await lib.installFromSource( + "https://github.com/owner/repo" + ); + expect(results).toEqual([{ name: "root-skill", ok: true }]); + await expect( + fs.stat(join(SKILLS_DIR, "root-skill", "notes.md")) + ).resolves.toBeTruthy(); + }); + + it("installs a whole pack when the repo has several skills", async () => { + stubGitHub({ + blobs: [ + { path: "skills/alpha/SKILL.md" }, + { path: "skills/beta/SKILL.md" }, + { path: "skills/beta/extra.txt" }, + ], + files: { + "skills/alpha/SKILL.md": skillMd("alpha"), + "skills/beta/SKILL.md": skillMd("beta"), + "skills/beta/extra.txt": "x", + }, + }); + const results = await lib.installFromSource("owner/pack-repo"); + expect(results).toEqual([ + { name: "alpha", ok: true }, + { name: "beta", ok: true }, + ]); + }); + + it("installs every skill under a tree URL pointing at a parent dir", async () => { + stubGitHub({ + blobs: [ + { path: "pack/a/SKILL.md" }, + { path: "pack/b/SKILL.md" }, + { path: "other/c/SKILL.md" }, + ], + files: { + "pack/a/SKILL.md": skillMd("a"), + "pack/b/SKILL.md": skillMd("b"), + "other/c/SKILL.md": skillMd("c"), + }, + }); + const results = await lib.installFromSource( + "https://github.com/owner/repo/tree/main/pack" + ); + expect(results).toEqual([ + { name: "a", ok: true }, + { name: "b", ok: true }, + ]); + await expect(fs.stat(join(SKILLS_DIR, "c"))).rejects.toThrow(); + }); + + it("falls back to a tree search by path basename", async () => { + stubGitHub({ + blobs: [{ path: "nested/deep/hint/SKILL.md" }], + files: { "nested/deep/hint/SKILL.md": skillMd("hint") }, + }); + const results = await lib.installFromSource("owner/repo@hint"); + expect(results).toEqual([{ name: "hint", ok: true }]); + }); + + it("uses a tree URL ref for raw downloads", async () => { + const mock = stubGitHub({ + blobs: [{ path: "x/SKILL.md" }], + files: { "x/SKILL.md": skillMd("x") }, + }); + await lib.installFromSource("https://github.com/owner/repo/tree/dev/x"); + const treeCall = mock.mock.calls.find(([u]) => + String(u).includes("/git/trees/") + ); + expect(String(treeCall?.[0])).toContain(COMMIT); + }); + + it("continues a pack install past an invalid skill name", async () => { + stubGitHub({ + blobs: [{ path: "p/good/SKILL.md" }, { path: "p/bad/SKILL.md" }], + files: { + "p/good/SKILL.md": skillMd("good"), + "p/bad/SKILL.md": skillMd("bad..name"), + }, + }); + const results = await lib.installFromSource("owner/repo@p"); + expect(results.find((r) => r.name === "good")?.ok).toBe(true); + expect(results.find((r) => !r.ok)?.error).toMatch(/invalid skill name/i); + }); + + it("throws when no SKILL.md exists anywhere", async () => { + stubGitHub({ blobs: [{ path: "docs/readme.md" }], files: {} }); + await expect(lib.installFromSource("owner/repo")).rejects.toThrow( + /no skill\.md/i + ); + }); + + it("surfaces GitHub rate limits", async () => { + stubGitHub({ blobs: [], files: {}, treeStatus: 403 }); + await expect(lib.installFromSource("owner/repo")).rejects.toThrow( + /rate limit/i + ); + }); +}); diff --git a/src/lib/server/skill-source.ts b/src/lib/server/skill-source.ts new file mode 100644 index 0000000..a45ade5 --- /dev/null +++ b/src/lib/server/skill-source.ts @@ -0,0 +1,470 @@ +// Install skills from arbitrary sources (mirrors EvoScientist's +// skills_manager.install_skill): GitHub shorthand/URLs and local directories. +// Everything lands in the same global tier as catalog installs +// (SKILLS_INSTALL_DIR) and is recorded in the same .installed.yaml manifest, +// with the user-facing source string as provenance. + +import { homedir } from "os"; +import { dirname, join, resolve, sep } from "path"; +import { promises as fs } from "fs"; +import { randomUUID } from "crypto"; +import { + SKILLS_INSTALL_DIR, + isValidSkillName, + recordInstallSource, +} from "./skills"; + +const MAX_SKILL_FILES = 300; +const MAX_SKILL_BYTES = 25 * 1024 * 1024; + +export interface SourceInstallResult { + name: string; + ok: boolean; + error?: string; +} + +export type ParsedSource = + | { kind: "github"; repo: string; ref?: string; path?: string } + | { kind: "local"; path: string }; + +/** Classify a user-typed source. Mirrors skills_manager._parse_github_url + + * _is_github_url: `owner/repo[@path]` shorthand and github.com URLs are + * remote; everything else is treated as a local path. */ +export function parseSource(raw: string): ParsedSource { + const source = raw.trim(); + if (!source) throw new Error("Missing install source."); + + // Shorthand: owner/repo@path (no protocol) + if (source.includes("@") && !source.includes("://")) { + const [repo, path] = source.split("@", 2); + if (!isRepoSlug(repo) || !path) { + throw new Error(`Cannot parse install source: ${raw}`); + } + return { kind: "github", repo, path }; + } + + const looksLikeUrl = + source.includes("://") || source.startsWith("github.com/"); + if (looksLikeUrl) { + const cleaned = source + .replace(/^https?:\/\//, "") + .replace(/^github\.com\//, "") + .replace(/\/+$/, ""); + if (cleaned === source.replace(/\/+$/, "") && !isRepoSlug(cleaned)) { + // Didn't strip a github.com prefix and isn't owner/repo — foreign host. + throw new Error(`Only GitHub sources are supported: ${raw}`); + } + const tree = cleaned.match(/^([^/]+\/[^/]+)\/tree\/([^/]+)(?:\/(.+))?$/); + if (tree) { + return { kind: "github", repo: tree[1], ref: tree[2], path: tree[3] }; + } + if (isRepoSlug(cleaned)) { + return { kind: "github", repo: cleaned }; + } + throw new Error(`Cannot parse GitHub URL: ${raw}`); + } + + // Bare owner/repo (two non-empty segments, first without a dot — so + // "./foo" and "foo/bar.txt"-style locals don't get misread as repos). + if (isRepoSlug(source)) { + return { kind: "github", repo: source }; + } + + const path = + source.startsWith("~") ? join(homedir(), source.slice(1)) : source; + return { kind: "local", path }; +} + +function isRepoSlug(s: string): boolean { + const parts = s.split("/"); + return ( + parts.length === 2 && + parts.every((p) => p.length > 0) && + !parts[0].includes(".") && + !parts[0].startsWith("~") + ); +} + +export async function installFromSource( + raw: string +): Promise { + const parsed = parseSource(raw); + if (parsed.kind === "local") { + return installFromLocal(parsed.path, raw.trim()); + } + return installFromGitHub(parsed, raw.trim()); +} + +// --------------------------------------------------------------------------- +// Local directories +// --------------------------------------------------------------------------- + +/** Parse just the frontmatter `name` from a SKILL.md (falls back to the + * directory name when absent). */ +async function skillNameFromDir(dir: string): Promise { + const md = await fs.readFile(join(dir, "SKILL.md"), "utf-8"); + const fm = md.match(/^---\s*\n([\s\S]*?)\n---/); + const name = fm?.[1].match(/^name\s*:\s*(.+?)\s*$/m)?.[1]; + const cleaned = name?.replace(/^["']|["']$/g, "").trim(); + return cleaned || dir.split(sep).pop()!; +} + +async function hasSkillMd(dir: string): Promise { + try { + return (await fs.stat(join(dir, "SKILL.md"))).isFile(); + } catch { + return false; + } +} + +/** Directories containing a SKILL.md: *root* itself, its children, or + * grandchildren inside non-skill children (skills_manager._scan_skill_dirs). */ +async function scanSkillDirs(root: string): Promise { + if (await hasSkillMd(root)) return [root]; + const found: string[] = []; + let children: string[] = []; + try { + children = await fs.readdir(root); + } catch { + return found; + } + for (const child of children.sort()) { + if (child.startsWith(".")) continue; + const childDir = join(root, child); + if (!(await fs.stat(childDir).catch(() => null))?.isDirectory()) continue; + if (await hasSkillMd(childDir)) { + found.push(childDir); + continue; + } + for (const grand of (await fs.readdir(childDir)).sort()) { + if (grand.startsWith(".")) continue; + const grandDir = join(childDir, grand); + if (!(await fs.stat(grandDir).catch(() => null))?.isDirectory()) continue; + if (await hasSkillMd(grandDir)) found.push(grandDir); + } + } + return found; +} + +/** Copy a skill directory into the install dir via a temp sibling, then swap + * atomically (same pattern as catalog installs). Skips .git. */ +async function copySkillDir(srcDir: string, name: string): Promise { + const installRoot = resolve(SKILLS_INSTALL_DIR); + const destRoot = resolve(installRoot, name); + if (destRoot !== join(installRoot, name)) { + throw new Error("Invalid skill name."); + } + + let files = 0; + let bytes = 0; + const tmpRoot = join(installRoot, `.installing-${name}-${randomUUID()}`); + const copy = async (from: string, to: string): Promise => { + for (const entry of await fs.readdir(from, { withFileTypes: true })) { + if (entry.name === ".git") continue; + const src = join(from, entry.name); + const dst = join(to, entry.name); + if (entry.isDirectory()) { + await fs.mkdir(dst, { recursive: true }); + await copy(src, dst); + } else if (entry.isFile()) { + files += 1; + if (files > MAX_SKILL_FILES) { + throw new Error("This skill has too many files to install."); + } + const stat = await fs.stat(src); + bytes += stat.size; + if (bytes > MAX_SKILL_BYTES) { + throw new Error("This skill is too large to install."); + } + // Canonicalize: a symlinked source file must not write outside tmp. + const realDst = resolve(dst); + if (realDst !== tmpRoot && !realDst.startsWith(tmpRoot + sep)) { + throw new Error("Invalid file path in skill."); + } + await fs.mkdir(dirname(dst), { recursive: true }); + await fs.copyFile(src, dst); + } + } + }; + + try { + await fs.mkdir(tmpRoot, { recursive: true }); + await copy(srcDir, tmpRoot); + 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; + } +} + +/** Install one already-located skill directory; never throws — per-item + * failures are reported in the result so a pack install can continue. */ +async function installOneLocal( + dir: string, + recordAs: string, + commit: string | null +): Promise { + try { + const name = await skillNameFromDir(dir); + if (!isValidSkillName(name)) { + return { name, ok: false, error: `Invalid skill name: ${name}` }; + } + await copySkillDir(dir, name); + await recordInstallSource(name, recordAs, commit).catch(() => {}); + return { name, ok: true }; + } catch (error) { + const fallback = dir.split(sep).pop() ?? dir; + return { + name: fallback, + ok: false, + error: error instanceof Error ? error.message : "Failed to install skill.", + }; + } +} + +async function installFromLocal( + path: string, + recordAs: string +): Promise { + const root = resolve(path); + const stat = await fs.stat(root).catch(() => null); + if (!stat) throw new Error(`Path does not exist: ${path}`); + if (!stat.isDirectory()) throw new Error(`Not a directory: ${path}`); + + const found = await scanSkillDirs(root); + if (found.length === 0) { + throw new Error(`No SKILL.md found in: ${path}`); + } + const results: SourceInstallResult[] = []; + for (const dir of found) { + results.push(await installOneLocal(dir, recordAs, null)); + } + return results; +} + +// --------------------------------------------------------------------------- +// GitHub +// --------------------------------------------------------------------------- + +const GITHUB_HEADERS = { + Accept: "application/vnd.github+json", + // GitHub rejects API requests without a User-Agent. + "User-Agent": "evoscientist-webui", +}; +const SHA_RE = /^[0-9a-f]{7,40}$/; + +interface TreeBlob { + path: string; + type: "blob" | "tree" | string; + size?: number; +} + +/** Resolve a ref (branch/sha, or the default branch when omitted) to a commit + * SHA so tree + raw downloads all pin the SAME revision. Falls back to the + * ref itself when the API can't resolve it. */ +async function resolveRepoRef(repo: string, ref?: string): Promise { + try { + const res = await fetch( + `https://api.github.com/repos/${repo}/commits/${ref ?? "HEAD"}`, + { 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 through to the unresolvable-ref fallbacks + } + if (ref) return ref; + try { + const res = await fetch(`https://api.github.com/repos/${repo}`, { + headers: GITHUB_HEADERS, + }); + if (res.ok) { + const data = (await res.json()) as { default_branch?: string }; + if (data.default_branch) return data.default_branch; + } + } catch { + // ignore + } + return "main"; +} + +async function fetchRepoTree(repo: string, ref: string): Promise { + 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 repository (GitHub ${res.status}).` + ); + } + const data = (await res.json()) as { tree?: TreeBlob[] }; + if (!Array.isArray(data.tree)) { + throw new Error("Unexpected response from GitHub."); + } + return data.tree; +} + +/** Directories containing a SKILL.md within *path* (or the repo root): the + * path itself, its children, or grandchildren inside non-skill children — + * mirrors skills_manager._scan_skill_dirs. When nothing matches and a path + * was given, falls back to a repo-wide search for its basename + * (_find_skill_in_tree). */ +function skillDirsInTree(tree: TreeBlob[], path?: string): string[] { + const dirs = new Set( + tree + .filter( + (t) => + t.type === "blob" && + (t.path === "SKILL.md" || t.path.endsWith("/SKILL.md")) + ) + .map((t) => + t.path === "SKILL.md" ? "" : t.path.slice(0, -"/SKILL.md".length) + ) + ); + const base = path?.replace(/\/+$/, "") ?? ""; + if (dirs.has(base)) return [base]; + + const prefix = base ? `${base}/` : ""; + const depth1 = new Set(); + for (const d of dirs) { + if (!d.startsWith(prefix)) continue; + if (d.slice(prefix.length).split("/").length === 1) depth1.add(d); + } + const found = [...depth1].sort(); + const depth2: string[] = []; + for (const d of dirs) { + if (!d.startsWith(prefix)) continue; + const segs = d.slice(prefix.length).split("/"); + if (segs.length === 2 && !depth1.has(prefix + segs[0])) depth2.push(d); + } + found.push(...depth2.sort()); + if (found.length > 0) return found; + + if (base) { + const hint = base.split("/").pop()!; + const match = [...dirs] + .filter((d) => d === hint || d.endsWith(`/${hint}`)) + .sort(); + if (match.length > 0) return [match[0]]; + } + return []; +} + +function rawUrl(repo: string, ref: string, path: string): string { + return `https://raw.githubusercontent.com/${repo}/${ref}/${path}`; +} + +async function installOneGitHub( + repo: string, + ref: string, + tree: TreeBlob[], + dir: string, + recordAs: string, + commit: string | null +): Promise { + const prefix = dir ? `${dir}/` : ""; + const blobs = tree.filter( + (t) => + t.type === "blob" && + t.path.startsWith(prefix) && + !t.path.split("/").includes(".git") + ); + try { + 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."); + } + + // The SKILL.md doubles as the name source — fetch it before the rest. + const mdRes = await fetch(rawUrl(repo, ref, `${prefix}SKILL.md`), { + headers: GITHUB_HEADERS, + }); + if (!mdRes.ok) { + throw new Error(`Failed to download SKILL.md (${mdRes.status}).`); + } + const md = await mdRes.text(); + const fmName = md + .match(/^---\s*\n([\s\S]*?)\n---/)?.[1] + .match(/^name\s*:\s*(.+?)\s*$/m)?.[1] + .replace(/^["']|["']$/g, "") + .trim(); + const name = fmName || dir.split("/").pop() || repo.split("/").pop()!; + if (!isValidSkillName(name)) { + return { name, ok: false, error: `Invalid skill name: ${name}` }; + } + + const installRoot = resolve(SKILLS_INSTALL_DIR); + const destRoot = resolve(installRoot, name); + if (destRoot !== join(installRoot, name)) { + throw new Error("Invalid skill name."); + } + + 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(repo, 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); + } + 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; + } + + await recordInstallSource(name, recordAs, commit).catch(() => {}); + return { name, ok: true }; + } catch (error) { + return { + name: dir.split("/").pop() || repo, + ok: false, + error: error instanceof Error ? error.message : "Failed to install skill.", + }; + } +} + +async function installFromGitHub( + parsed: Extract, + recordAs: string +): Promise { + const ref = await resolveRepoRef(parsed.repo, parsed.ref); + const tree = await fetchRepoTree(parsed.repo, ref); + const dirs = skillDirsInTree(tree, parsed.path); + if (dirs.length === 0) { + throw new Error(`No SKILL.md found in: ${recordAs}`); + } + const commit = SHA_RE.test(ref) ? ref : null; + const results: SourceInstallResult[] = []; + for (const dir of dirs) { + results.push( + await installOneGitHub(parsed.repo, ref, tree, dir, recordAs, commit) + ); + } + return results; +} diff --git a/src/lib/server/skills.ts b/src/lib/server/skills.ts index 7d6ce81..e3ac1fe 100644 --- a/src/lib/server/skills.ts +++ b/src/lib/server/skills.ts @@ -300,14 +300,23 @@ async function writeManifest(m: Manifest): Promise { await fs.rename(tmp, path); } +/** Record an install with an explicit provenance source (catalog shorthand, + * arbitrary GitHub URL/shorthand, or a local path). */ +export async function recordInstallSource( + name: string, + source: string, + commit: string | null +): Promise { + const manifest = await readManifest(); + manifest[name] = commit ? { source, commit } : { source }; + await writeManifest(manifest); +} + async function recordInstall( name: string, commit: string | null ): Promise { - const manifest = await readManifest(); - const source = manifestSource(name); - manifest[name] = commit ? { source, commit } : { source }; - await writeManifest(manifest); + await recordInstallSource(name, manifestSource(name), commit); } /** Remove a skill's manifest entry (used by uninstall). No-op if absent. */ @@ -319,6 +328,31 @@ export async function recordUninstall(name: string): Promise { } } +/** Delete one installed skill: its directory (in whichever tier holds it) plus + * its .installed.yaml entry. Throws on invalid name or when not installed. */ +export async function uninstallSkill(name: string): Promise { + if (!isValidSkillName(name)) throw new Error("Invalid skill name."); + for (const dir of SKILL_DIRS) { + const target = resolve(join(dir, name)); + if (target !== resolve(dir) && !target.startsWith(resolve(dir) + sep)) { + continue; + } + try { + // Only ever remove an actual skill directory, never a stray file. + const stat = await fs.stat(target); + if (!stat.isDirectory()) continue; + } catch { + 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; + } + throw new Error("Skill not found."); +} + async function listInstalledNames(): Promise> { const names = new Set(); for (const dir of SKILL_DIRS) {