feat(webui): install skills from arbitrary source, uninstall, marketplace categories
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user