feat(webui): install skills from arbitrary source, uninstall, marketplace categories

This commit is contained in:
m4
2026-08-11 15:07:23 +08:00
parent 9e1e628ab7
commit a7f7adc56b
10 changed files with 1921 additions and 294 deletions
@@ -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();
});
});
@@ -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 }
);
}
}
+100
View File
@@ -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<typeof import("@/lib/server/skills")>();
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();
});
});
+49 -29
View File
@@ -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 });
}
+110
View File
@@ -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);
});
});
+19 -29
View File
@@ -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 });
}
+606 -232
View File
@@ -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<CatalogSkill[]>([]);
const [other, setOther] = useState<SkillCard[]>([]);
@@ -51,13 +100,26 @@ export function SkillsMarketplace() {
const [busy, setBusy] = useState<
Record<string, "install" | "uninstall" | "update">
>({});
// Active left-rail category and the selection within it. Switching the
// category clears the selection.
const [category, setCategory] = useState<Category>("installed");
const [selected, setSelected] = useState<Set<string>>(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<BatchSummary | null>(null);
// Skill whose detail dialog is open (null = closed).
const [detail, setDetail] = useState<SkillDetailTarget | null>(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<TileData[] | null>(
null
);
// "Install from source" dialog state.
const [sourceOpen, setSourceOpen] = useState(false);
const [sourceValue, setSourceValue] = useState("");
const [sourceBusy, setSourceBusy] = useState(false);
const [sourceError, setSourceError] = useState<string | null>(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<void> => {
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<Category, TileData[]> = {
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<Category, number> = {
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<Category, string> = {
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 (
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-[960px] px-4 py-5 sm:px-5 sm:py-6">
<div className="mx-auto max-w-[1100px] px-4 py-5 sm:px-5 sm:py-6">
<header className="mb-4 flex items-start justify-between gap-3">
<div>
<h2 className="text-xl font-semibold sm:text-2xl">
@@ -203,158 +387,348 @@ export function SkillsMarketplace() {
catalog, or remove ones you don&apos;t need.
</p>
</div>
<button
type="button"
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"
>
<RotateCw
className={loading ? "size-4 animate-spin" : "size-4"}
aria-hidden="true"
/>
</button>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => {
setSourceError(null);
setSourceOpen(true);
}}
disabled={batchRunning !== null}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
>
<Plus className="size-3.5" aria-hidden="true" />
Install from source
</button>
<button
type="button"
onClick={() => load(true)}
disabled={loading || batchRunning !== null}
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 disabled:opacity-50"
>
<RotateCw
className={loading ? "size-4 animate-spin" : "size-4"}
aria-hidden="true"
/>
</button>
</div>
</header>
{error && (
<p
role="alert"
className="mb-4 text-sm text-destructive"
>
<p role="alert" className="mb-4 text-sm text-destructive">
{error}
</p>
)}
{summary && (
<div
role="status"
className="mb-4 flex items-start justify-between gap-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm"
>
<div>
<span>
{summary.action === "install" ? "Installed" : "Uninstalled"}{" "}
{okCount} skill{okCount === 1 ? "" : "s"}.
</span>
{failed.length > 0 && (
<span className="ml-2 text-destructive">
{failed.length} failed:{" "}
{failed
.map((f) => `${f.name}${f.error ? ` (${f.error})` : ""}`)
.join(", ")}
</span>
)}
</div>
<button
type="button"
onClick={() => setSummary(null)}
aria-label="Dismiss"
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
>
<X className="size-4" aria-hidden="true" />
</button>
</div>
)}
{loading ? (
<div
className="flex items-center gap-2 text-sm text-muted-foreground"
aria-live="polite"
>
<Loader2
className="size-4 animate-spin"
aria-hidden="true"
/>
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
Loading skills…
</div>
) : (
<div className="space-y-6">
<section>
<h3 className="mb-2.5 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 ? (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<aside className="shrink-0 sm:w-44">
<nav
aria-label="Skill categories"
className="flex gap-1 overflow-x-auto sm:flex-col sm:overflow-visible"
>
{CATEGORY_META.map(({ key, label, icon: Icon }) => {
const active = key === category;
return (
<button
key={key}
type="button"
onClick={() => switchCategory(key)}
aria-current={active ? "true" : undefined}
className={`flex shrink-0 items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors focus-visible:ring-2 focus-visible:ring-ring ${
active
? "bg-accent font-medium text-foreground"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
}`}
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="flex-1 text-left">{label}</span>
<span
className={`rounded-full px-1.5 py-0.5 text-xs tabular-nums ${
active
? "bg-background text-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{counts[key]}
</span>
</button>
);
})}
</nav>
</aside>
<section className="min-w-0 flex-1">
<div className="mb-2.5 flex flex-wrap items-center gap-x-3 gap-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wider text-tertiary">
{CATEGORY_META.find((c) => c.key === category)?.label} (
{activeTiles.length})
</h3>
{selected.size > 0 && (
<div className="ml-auto flex items-center gap-2 text-xs">
<span className="text-muted-foreground">
{selected.size} selected
</span>
<button
type="button"
onClick={() =>
setSelected(new Set(activeTiles.map((t) => t.name)))
}
disabled={batchRunning !== null}
className="rounded-md px-2 py-1 font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
>
Select all
</button>
<button
type="button"
onClick={() => setSelected(new Set())}
disabled={batchRunning !== null}
className="rounded-md px-2 py-1 font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
>
Clear
</button>
{installable.length > 0 && (
<button
type="button"
onClick={() =>
runBatch(
"install",
installable.map((t) => t.name)
)
}
disabled={batchRunning !== null}
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--brand-solid)] px-2.5 py-1 text-xs font-medium text-[var(--brand-foreground)] transition-opacity hover:opacity-90 disabled:opacity-50"
>
Install selected
{category === "official" && ` (${installable.length})`}
</button>
)}
{removable.length > 0 && (
<button
type="button"
onClick={() => setUninstallTargets(removable)}
disabled={batchRunning !== null}
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--brand-solid)] px-2.5 py-1 text-xs font-medium text-[var(--brand-foreground)] transition-opacity hover:opacity-90 disabled:opacity-50"
>
Uninstall selected
{category === "official" && ` (${removable.length})`}
</button>
)}
</div>
)}
</div>
{activeTiles.length === 0 ? (
<p className="text-sm text-muted-foreground">
No skills found in the catalog.
{emptyText[category]}
</p>
) : (
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
{catalog.map((s) => (
<div className="grid grid-cols-1 gap-2.5 lg:grid-cols-2">
{activeTiles.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}
{...s}
checked={selected.has(s.name)}
onCheck={(checked) => 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
}
/>
))}
</div>
)}
</section>
{other.length > 0 && (
<section>
<h3 className="mb-2.5 text-xs font-semibold uppercase tracking-wider text-tertiary">
Other installed skills
</h3>
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
{other.map((s) => (
<SkillTile
key={s.name}
title={s.title}
description={s.description}
installed
busy={busy[s.name]}
onOpen={() =>
setDetail({
name: s.name,
title: s.title,
description: s.description,
installed: true,
})
}
onUninstall={() =>
setUninstallTarget({
name: s.name,
title: s.title,
isCatalog: false,
})
}
/>
))}
</div>
</section>
)}
</div>
)}
</div>
<SkillDetailDialog
skill={detail}
onClose={() => setDetail(null)}
/>
<SkillDetailDialog skill={detail} onClose={() => setDetail(null)} />
<Dialog
open={uninstallTarget !== null}
open={sourceOpen}
onOpenChange={(open) => {
if (!open) setUninstallTarget(null);
if (!open && !sourceBusy) setSourceOpen(false);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Uninstall skill?</DialogTitle>
<DialogDescription>
“{uninstallTarget?.title ?? uninstallTarget?.name}” will be
removed from this Web UI. You can install it again later.
<DialogTitle>Install from source</DialogTitle>
<DialogDescription asChild>
<div>
<p>Install a skill from GitHub or a local directory:</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
<code className="text-xs">owner/repo@skill-name</code>
</li>
<li>
<code className="text-xs">
https://github.com/owner/repo/tree/main/skill-name
</code>
</li>
<li>
<code className="text-xs">./my-skill</code> or{" "}
<code className="text-xs">/path/to/skill</code>
</li>
</ul>
<p className="mt-2">
A directory or repo containing several skills installs them
all.
</p>
</div>
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
void submitSource();
}}
>
<Input
value={sourceValue}
onChange={(e) => setSourceValue(e.target.value)}
placeholder="owner/repo@skill-name"
disabled={sourceBusy}
autoFocus
/>
{sourceError && (
<p role="alert" className="mt-2 text-sm text-destructive">
{sourceError}
</p>
)}
<DialogFooter className="mt-4">
<Button
type="button"
variant="outline"
onClick={() => setSourceOpen(false)}
disabled={sourceBusy}
>
Cancel
</Button>
<Button type="submit" disabled={sourceBusy || !sourceValue.trim()}>
{sourceBusy && (
<Loader2
className="size-3.5 animate-spin"
aria-hidden="true"
/>
)}
{sourceBusy ? "Installing…" : "Install"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={uninstallTargets !== null}
onOpenChange={(open) => {
if (!open) setUninstallTargets(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
Uninstall {uninstallTargets?.length === 1 ? "skill" : "skills"}?
</DialogTitle>
<DialogDescription asChild>
<div>
<p>
{uninstallTargets?.length === 1
? "This skill will be"
: `These ${uninstallTargets?.length ?? 0} skills will be`}{" "}
removed from this Web UI.
</p>
{(() => {
const catNames = new Set(catalog.map((c) => c.name));
const thirdpartyCount = (uninstallTargets ?? []).filter(
(t) => !catNames.has(t.name)
).length;
return thirdpartyCount > 0 ? (
<p className="mt-2 rounded-md border border-destructive/40 bg-destructive/10 px-2.5 py-1.5 text-destructive">
{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 —{" "}
<strong>
once removed, they cannot be reinstalled from here.
</strong>
</p>
) : (
<p className="mt-1 text-muted-foreground">
You can install them again later from the catalog.
</p>
);
})()}
<ul className="mt-2 max-h-48 list-disc overflow-y-auto pl-5">
{uninstallTargets?.map((t) => (
<li key={t.name}>{t.title}</li>
))}
</ul>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setUninstallTarget(null)}
>
<Button variant="outline" onClick={() => setUninstallTargets(null)}>
Cancel
</Button>
<Button
onClick={confirmUninstall}
onClick={() => {
const names = (uninstallTargets ?? []).map((t) => t.name);
setUninstallTargets(null);
void runBatch("uninstall", names);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Uninstall
@@ -374,20 +748,19 @@ function SkillTile({
installedVersion,
latestVersion,
updateAvailable,
checked,
onCheck,
busy,
controlsDisabled,
onOpen,
onInstall,
onUpdate,
onUninstall,
}: {
title: string;
description: string;
meta?: string;
installed: boolean;
installedVersion?: string;
latestVersion?: string;
updateAvailable?: boolean;
}: TileData & {
checked: boolean;
onCheck: (checked: boolean) => void;
busy?: "install" | "uninstall" | "update";
controlsDisabled: boolean;
onOpen?: () => void;
onInstall?: () => void;
onUpdate?: () => void;
@@ -397,11 +770,22 @@ function SkillTile({
? installedVersion && `v${installedVersion}`
: latestVersion && `v${latestVersion}`;
return (
<div className="flex flex-col rounded-lg border border-border bg-card p-3">
<div className="relative flex flex-col rounded-lg border border-border bg-card p-3">
{/* Checkbox lives outside the detail <button> — nesting interactive
elements is invalid and clicks would open the detail dialog. */}
<input
type="checkbox"
checked={checked}
disabled={controlsDisabled}
onChange={(e) => onCheck(e.target.checked)}
onClick={(e) => e.stopPropagation()}
aria-label={`Select ${title}`}
className="absolute left-2 top-2 z-10 size-4 cursor-pointer accent-[var(--brand)] disabled:cursor-not-allowed disabled:opacity-50"
/>
<button
type="button"
onClick={onOpen}
className="-m-1 flex items-start gap-2.5 rounded-md p-1 text-left transition-colors hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring"
className="-m-1 flex items-start gap-2.5 rounded-md p-1 pl-7 text-left transition-colors hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring"
title="View details"
>
<Puzzle
@@ -430,24 +814,18 @@ function SkillTile({
</div>
</button>
<div className="mt-2.5 flex items-center justify-end gap-2">
{installed && updateAvailable && (
{installed && updateAvailable && onUpdate && (
<button
type="button"
onClick={onUpdate}
disabled={!!busy}
disabled={!!busy || controlsDisabled}
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--brand-solid)] px-2.5 py-1 text-xs font-medium text-[var(--brand-foreground)] transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
title={latestVersion ? `Update to v${latestVersion}` : "Update"}
>
{busy === "update" ? (
<Loader2
className="size-3.5 animate-spin"
aria-hidden="true"
/>
<Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
) : (
<ArrowUpCircle
className="size-3.5"
aria-hidden="true"
/>
<ArrowUpCircle className="size-3.5" aria-hidden="true" />
)}
{busy === "update"
? "Updating…"
@@ -456,47 +834,43 @@ function SkillTile({
: "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 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
>
{busy === "uninstall" ? (
<Loader2
className="size-3.5 animate-spin"
aria-hidden="true"
/>
) : (
<Trash2
className="size-3.5"
aria-hidden="true"
/>
{installed
? onUninstall && (
<button
type="button"
onClick={onUninstall}
disabled={!!busy || controlsDisabled}
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 focus-visible:ring-2 focus-visible:ring-ring 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>
)
: onInstall && (
<button
type="button"
onClick={onInstall}
disabled={!!busy || controlsDisabled}
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--brand-solid)] px-2.5 py-1 text-xs font-medium text-[var(--brand-foreground)] transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring 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>
)}
{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-solid)] px-2.5 py-1 text-xs font-medium text-[var(--brand-foreground)] transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring 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>
);
+393
View File
@@ -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<string> {
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<string, string>;
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
);
});
});
+470
View File
@@ -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<SourceInstallResult[]> {
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<string> {
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<boolean> {
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<string[]> {
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<void> {
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<void> => {
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<SourceInstallResult> {
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<SourceInstallResult[]> {
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<string> {
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<TreeBlob[]> {
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<string>();
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<SourceInstallResult> {
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<ParsedSource, { kind: "github" }>,
recordAs: string
): Promise<SourceInstallResult[]> {
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;
}
+38 -4
View File
@@ -300,14 +300,23 @@ async function writeManifest(m: Manifest): Promise<void> {
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<void> {
const manifest = await readManifest();
manifest[name] = commit ? { source, commit } : { source };
await writeManifest(manifest);
}
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);
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<void> {
}
}
/** 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<void> {
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<Set<string>> {
const names = new Set<string>();
for (const dir of SKILL_DIRS) {