diff --git a/src/app/api/skills/catalog/route.ts b/src/app/api/skills/catalog/route.ts new file mode 100644 index 0000000..78421c5 --- /dev/null +++ b/src/app/api/skills/catalog/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCatalog } from "@/lib/server/skills"; + +export const runtime = "nodejs"; + +export async function GET(request: NextRequest) { + try { + const force = request.nextUrl.searchParams.get("refresh") === "1"; + const skills = await getCatalog(force); + return NextResponse.json({ skills }); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Failed to load the skills catalog.", + }, + { status: 502 } + ); + } +} diff --git a/src/app/api/skills/install/route.ts b/src/app/api/skills/install/route.ts new file mode 100644 index 0000000..3aaf990 --- /dev/null +++ b/src/app/api/skills/install/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { installSkill } from "@/lib/server/skills"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest) { + try { + // Same-origin guard (browsers always send Origin on POST). + const origin = request.headers.get("origin"); + if (origin && origin !== request.nextUrl.origin) { + return NextResponse.json( + { error: "Cross-origin installs are not allowed." }, + { status: 403 } + ); + } + + const body = (await request.json().catch(() => null)) as { + name?: unknown; + } | null; + const name = body?.name; + if (typeof name !== "string" || !name) { + return NextResponse.json( + { error: "Missing skill name." }, + { status: 400 } + ); + } + + const result = await installSkill(name); + return NextResponse.json({ ok: true, ...result }); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Failed to install skill.", + }, + { status: 400 } + ); + } +} diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index 0861765..1224bef 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -1,17 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; -import { homedir } from "os"; import { join, resolve } from "path"; import { promises as fs } from "fs"; +import { SKILL_DIRS, recordUninstall } from "@/lib/server/skills"; -// EvoScientist installs user skills here (confirmed fixed location). We also -// check the ~/.evoscientist/skills global tier as a fallback. -const SKILL_DIRS = [ - join(homedir(), ".config", "evoscientist", "skills"), - join(homedir(), ".evoscientist", "skills"), -]; +// SKILL_DIRS (the global ~/.evoscientist/skills tier + legacy ~/.config +// fallback) is the single source of truth, shared with the install route. interface SkillCard { + /** Directory name — the install/uninstall identity (matches the catalog). */ name: string; + /** Frontmatter name for display; falls back to the directory name. */ + title: string; description: string; dir: string; } @@ -48,11 +47,13 @@ async function readSkills(): Promise { if (!stat.isDirectory()) continue; const md = await fs.readFile(join(skillDir, "SKILL.md"), "utf-8"); const { name, description } = parseFrontmatter(md); - const skillName = name || entry; - if (seen.has(skillName)) continue; - seen.add(skillName); + // Identity is the DIRECTORY name (what install/uninstall/dedup key on); + // the frontmatter name is display-only. + if (seen.has(entry)) continue; + seen.add(entry); skills.push({ - name: skillName, + name: entry, + title: name || entry, description: description || "", dir: skillDir, }); @@ -92,6 +93,9 @@ export async function DELETE(req: NextRequest) { continue; // not here } await fs.rm(target, { recursive: true, force: true }); + // Keep EvoScientist's manifest in sync — drop the entry so onboard/CLI no + // longer list it. Best-effort: don't fail the uninstall on a manifest error. + await recordUninstall(name).catch(() => {}); return NextResponse.json({ ok: true }); } return NextResponse.json({ error: "Skill not found" }, { status: 404 }); diff --git a/src/app/api/workspace/route.ts b/src/app/api/workspace/route.ts index 2514963..68ad228 100644 --- a/src/app/api/workspace/route.ts +++ b/src/app/api/workspace/route.ts @@ -13,6 +13,8 @@ export const runtime = "nodejs"; // Cap how many entries a single directory listing returns so a pathological // directory can't stall the UI or the response. const MAX_ENTRIES = 2000; +// Bound recursive walks (the "by type" view) by depth too. +const MAX_DEPTH = 12; export interface WorkspaceEntry { name: string; @@ -26,6 +28,57 @@ export interface WorkspaceEntry { ext: string; } +/** + * Recursively collect *files* (not dirs) under `relDir`, skipping the hidden/ + * noise set and never following symlinks (so it can't loop or escape). Used by + * the artifacts "by type" view. Bounded by MAX_DEPTH and MAX_ENTRIES. + */ +async function walkFiles( + workspaceDir: string, + relDir: string, + depth: number, + out: WorkspaceEntry[] +): Promise { + if (depth > MAX_DEPTH || out.length >= MAX_ENTRIES) return; + let dir: string; + try { + dir = await safeResolve(workspaceDir, relDir || ""); + } catch { + return; + } + let dirents; + try { + dirents = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const dirent of dirents) { + if (out.length >= MAX_ENTRIES) break; + if (isHiddenEntry(dirent.name)) continue; + // Don't follow symlinks during the recursive walk — avoids loops and any + // escape; a symlinked file is simply omitted from the by-type view. + if (dirent.isSymbolicLink()) continue; + const childRel = relDir ? `${relDir}/${dirent.name}` : dirent.name; + if (dirent.isDirectory()) { + await walkFiles(workspaceDir, childRel, depth + 1, out); + } else if (dirent.isFile()) { + try { + const st = await fs.stat(await safeResolve(workspaceDir, childRel)); + out.push({ + name: dirent.name, + path: childRel, + type: "file", + size: st.size, + mtime: st.mtimeMs, + ext: extname(dirent.name).slice(1).toLowerCase(), + }); + } catch { + continue; + } + } + } +} + export async function GET(request: NextRequest) { try { if (isCrossOrigin(request)) { @@ -36,6 +89,7 @@ export async function GET(request: NextRequest) { } const relPath = request.nextUrl.searchParams.get("path") ?? ""; + const recursive = request.nextUrl.searchParams.get("recursive") === "1"; const workspaceDir = await getWorkspaceDir(); const dir = await safeResolve(workspaceDir, relPath); @@ -47,6 +101,19 @@ export async function GET(request: NextRequest) { ); } + // "By type" view: flat list of every file under the workspace. + if (recursive) { + const files: WorkspaceEntry[] = []; + await walkFiles(workspaceDir, relPath, 0, files); + const truncated = files.length >= MAX_ENTRIES; + return NextResponse.json({ + path: relPath, + recursive: true, + truncated, + entries: files, + }); + } + const dirents = await fs.readdir(dir, { withFileTypes: true }); const entries: WorkspaceEntry[] = []; for (const dirent of dirents) { diff --git a/src/app/components/SkillsMarketplace.tsx b/src/app/components/SkillsMarketplace.tsx index 05f0040..186e6db 100644 --- a/src/app/components/SkillsMarketplace.tsx +++ b/src/app/components/SkillsMarketplace.tsx @@ -1,42 +1,132 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { Loader2, Puzzle, RotateCw, Trash2 } from "lucide-react"; +import { + Loader2, + Puzzle, + RotateCw, + Trash2, + Download, + ArrowUpCircle, +} from "lucide-react"; interface SkillCard { name: string; + title: string; description: string; dir: string; } -export function SkillsMarketplace() { - const [skills, setSkills] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [removing, setRemoving] = useState(null); +interface CatalogSkill { + name: string; + title: string; + description: string; + fileCount: number; + installed: boolean; + latestVersion?: string; + installedVersion?: string; + updateAvailable: boolean; +} - const load = useCallback(async () => { +export function SkillsMarketplace() { + const [catalog, setCatalog] = useState([]); + const [other, setOther] = useState([]); + const [loading, setLoading] = useState(true); + const [catalogError, setCatalogError] = useState(null); + const [error, setError] = useState(null); + // Per-skill in-flight action, keyed by skill name. + const [busy, setBusy] = useState< + Record + >({}); + + const load = useCallback(async (refresh = false) => { setLoading(true); setError(null); - try { - const res = await fetch("/api/skills"); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Failed to load skills"); - setSkills(data.skills ?? []); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to load skills"); - } finally { - setLoading(false); + setCatalogError(null); + const [catRes, instRes] = await Promise.allSettled([ + fetch(`/api/skills/catalog${refresh ? "?refresh=1" : ""}`).then( + async (r) => { + const d = await r.json(); + if (!r.ok) throw new Error(d.error || "Failed to load catalog"); + return (d.skills ?? []) as CatalogSkill[]; + } + ), + fetch("/api/skills").then(async (r) => { + const d = await r.json(); + if (!r.ok) throw new Error(d.error || "Failed to load skills"); + return (d.skills ?? []) as SkillCard[]; + }), + ]); + + const cat = catRes.status === "fulfilled" ? catRes.value : []; + if (catRes.status === "rejected") { + setCatalogError( + catRes.reason instanceof Error + ? catRes.reason.message + : "Failed to load the official catalog." + ); } + setCatalog(cat); + + // Installed skills that aren't in the official catalog (custom/local ones). + if (instRes.status === "fulfilled") { + const catNames = new Set(cat.map((c) => c.name)); + setOther(instRes.value.filter((s) => !catNames.has(s.name))); + } else { + setOther([]); + setError( + instRes.reason instanceof Error + ? instRes.reason.message + : "Failed to load installed skills." + ); + } + setLoading(false); }, []); useEffect(() => { load(); }, [load]); - const uninstall = async (name: string) => { + // Install and update hit the same endpoint (it overwrites + re-records the + // manifest commit); the mode only changes the busy label and success state. + const install = async (name: string, mode: "install" | "update" = "install") => { + setBusy((b) => ({ ...b, [name]: mode })); + setError(null); + try { + const res = await fetch("/api/skills/install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + const d = await res.json(); + if (!res.ok) throw new Error(d.error || "Failed to install"); + setCatalog((prev) => + prev.map((s) => + s.name === name + ? { + ...s, + installed: true, + updateAvailable: false, + installedVersion: s.latestVersion ?? s.installedVersion, + } + : s + ) + ); + } catch (e) { + setError(e instanceof Error ? e.message : `Failed to ${mode}`); + } finally { + setBusy((b) => { + const next = { ...b }; + delete next[name]; + return next; + }); + } + }; + + const uninstall = async (name: string, isCatalog: boolean) => { if (!window.confirm(`Uninstall the "${name}" skill?`)) return; - setRemoving(name); + setBusy((b) => ({ ...b, [name]: "uninstall" })); + setError(null); try { const res = await fetch(`/api/skills?name=${encodeURIComponent(name)}`, { method: "DELETE", @@ -45,11 +135,21 @@ export function SkillsMarketplace() { const d = await res.json(); throw new Error(d.error || "Failed to uninstall"); } - setSkills((prev) => prev.filter((s) => s.name !== name)); + if (isCatalog) { + setCatalog((prev) => + prev.map((s) => (s.name === name ? { ...s, installed: false } : s)) + ); + } else { + setOther((prev) => prev.filter((s) => s.name !== name)); + } } catch (e) { setError(e instanceof Error ? e.message : "Failed to uninstall"); } finally { - setRemoving(null); + setBusy((b) => { + const next = { ...b }; + delete next[name]; + return next; + }); } }; @@ -60,13 +160,21 @@ export function SkillsMarketplace() {

Research Skills

- Skills installed for EvoScientist. Anything you install from - elsewhere shows up here automatically. + Install official skills from the{" "} + + EvoSkills + {" "} + catalog, or remove ones you don't need.

- {loading ? ( -
-
- ) : error ? ( + {error && (

{error}

- ) : skills.length === 0 ? ( -

- No skills installed yet. -

+ )} + + {loading ? ( +
+
) : ( -
- {skills.map((s) => ( -
-
-
+ + )}
)}
); } + +function SkillTile({ + title, + description, + meta, + installed, + installedVersion, + latestVersion, + updateAvailable, + busy, + onInstall, + onUpdate, + onUninstall, +}: { + title: string; + description: string; + meta?: string; + installed: boolean; + installedVersion?: string; + latestVersion?: string; + updateAvailable?: boolean; + busy?: "install" | "uninstall" | "update"; + onInstall?: () => void; + onUpdate?: () => void; + onUninstall?: () => void; +}) { + const versionLabel = installed + ? installedVersion && `v${installedVersion}` + : latestVersion && `v${latestVersion}`; + return ( +
+
+
+
+ {installed && updateAvailable && ( + + )} + {installed ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/app/components/WorkspacePanel.tsx b/src/app/components/WorkspacePanel.tsx index 2a5ab6c..3f0791f 100644 --- a/src/app/components/WorkspacePanel.tsx +++ b/src/app/components/WorkspacePanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronRight, ChevronDown, @@ -9,11 +9,13 @@ import { RefreshCw, Download, Loader2, + Image as ImageIcon, + Database, + Code2, + File as FileIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; -import { - WorkspaceFileDialog, -} from "@/app/components/WorkspaceFileDialog"; +import { WorkspaceFileDialog } from "@/app/components/WorkspaceFileDialog"; import type { WorkspaceEntry } from "@/app/api/workspace/route"; async function listDir(path: string): Promise { @@ -23,19 +25,70 @@ async function listDir(path: string): Promise { return (body?.entries ?? []) as WorkspaceEntry[]; } +async function listAll(): Promise<{ entries: WorkspaceEntry[]; truncated: boolean }> { + const res = await fetch("/api/workspace?recursive=1"); + const body = await res.json().catch(() => null); + if (!res.ok) throw new Error(body?.error || "Failed to load workspace."); + return { + entries: (body?.entries ?? []) as WorkspaceEntry[], + truncated: !!body?.truncated, + }; +} + +// Research-artifact categories for the "by type" view. Order here is render order. +const CATEGORIES = [ + { + key: "docs", + label: "Papers & docs", + Icon: FileText, + exts: ["pdf", "tex", "bib", "md", "markdown", "txt", "docx", "doc", "rtf", "odt"], + }, + { + key: "figures", + label: "Figures", + Icon: ImageIcon, + exts: ["png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "tiff", "tif", "eps"], + }, + { + key: "data", + label: "Data", + Icon: Database, + exts: ["json", "jsonl", "csv", "tsv", "xlsx", "xls", "parquet", "pkl", "npy", "npz", "h5", "hdf5", "db", "sqlite", "yaml", "yml", "xml"], + }, + { + key: "code", + label: "Code", + Icon: Code2, + exts: ["py", "ipynb", "js", "ts", "tsx", "jsx", "sh", "bash", "r", "jl", "cpp", "cc", "c", "h", "hpp", "java", "go", "rs", "m", "rb"], + }, +] as const; +const OTHER = { key: "other", label: "Other", Icon: FileIcon } as const; + +const EXT_TO_CATEGORY: Record = {}; +for (const cat of CATEGORIES) { + for (const ext of cat.exts) EXT_TO_CATEGORY[ext] = cat.key; +} + +type ViewMode = "tree" | "type"; + export function WorkspacePanel() { - // Listing cache keyed by directory path ("" = workspace root). - const [children, setChildren] = useState>( - {} - ); + const [view, setView] = useState("tree"); + + // --- Tree view state (listing cache keyed by dir path; "" = root) --- + const [children, setChildren] = useState>({}); const [expanded, setExpanded] = useState>(new Set()); const [loading, setLoading] = useState>(new Set()); const [rootLoading, setRootLoading] = useState(false); + + // --- By-type view state (flat recursive listing) --- + const [allFiles, setAllFiles] = useState(null); + const [typeLoading, setTypeLoading] = useState(false); + const [truncated, setTruncated] = useState(false); + const [error, setError] = useState(null); - const [selected, setSelected] = useState<{ - path: string; - size: number; - } | null>(null); + const [selected, setSelected] = useState<{ path: string; size: number } | null>( + null + ); const loadDir = useCallback(async (path: string) => { setLoading((prev) => new Set(prev).add(path)); @@ -58,16 +111,21 @@ export function WorkspacePanel() { } }, []); - const refresh = useCallback(async () => { - setRootLoading(true); - // Re-fetch the root plus every currently-expanded directory so an open tree - // stays open and in sync with what the agent has written since. - const toLoad = ["", ...expanded]; - await Promise.allSettled(toLoad.map((p) => loadDir(p))); - setRootLoading(false); - }, [expanded, loadDir]); + const loadAll = useCallback(async () => { + setTypeLoading(true); + try { + const { entries, truncated } = await listAll(); + setAllFiles(entries); + setTruncated(truncated); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load."); + } finally { + setTypeLoading(false); + } + }, []); - // Initial load. loadDir surfaces root failures via `error` state; catch the + // Initial tree load. loadDir surfaces root failures via `error`; catch the // rejection here so it doesn't become an unhandled promise rejection. useEffect(() => { setRootLoading(true); @@ -77,6 +135,25 @@ export function WorkspacePanel() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Load the flat listing the first time the by-type view is opened. + useEffect(() => { + if (view === "type" && allFiles === null && !typeLoading) void loadAll(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [view]); + + const refresh = useCallback(async () => { + setError(null); + if (view === "type") { + await loadAll(); + return; + } + setRootLoading(true); + // Re-fetch the root plus every currently-expanded dir so an open tree stays + // open and in sync with what the agent has written since. + await Promise.allSettled(["", ...expanded].map((p) => loadDir(p))); + setRootLoading(false); + }, [view, expanded, loadDir, loadAll]); + const toggleDir = useCallback( (path: string) => { setExpanded((prev) => { @@ -93,6 +170,21 @@ export function WorkspacePanel() { [children, loadDir] ); + // Group the flat listing into categories (newest first within each group). + const grouped = useMemo(() => { + const map: Record = {}; + for (const f of allFiles ?? []) { + const key = EXT_TO_CATEGORY[f.ext] ?? OTHER.key; + (map[key] ??= []).push(f); + } + for (const list of Object.values(map)) { + list.sort((a, b) => b.mtime - a.mtime); + } + return map; + }, [allFiles]); + + const refreshing = view === "type" ? typeLoading : rootLoading; + const renderEntries = (path: string, depth: number): React.ReactNode => { const entries = children[path]; if (!entries) return null; @@ -146,12 +238,85 @@ export function WorkspacePanel() { }); }; + const renderByType = (): React.ReactNode => { + if (!allFiles) return null; + if (allFiles.length === 0) { + return ( +

+ No files in the workspace yet +

+ ); + } + const groups = [...CATEGORIES, OTHER]; + return ( +
+ {truncated && ( +

+ Showing the first files only — the workspace has more than the limit. +

+ )} + {groups.map((cat) => { + const files = grouped[cat.key]; + if (!files || files.length === 0) return null; + const Icon = cat.Icon; + return ( +
+
+ + {cat.label} + ({files.length}) +
+ {files.map((f) => { + const dir = f.path.includes("/") + ? f.path.slice(0, f.path.lastIndexOf("/")) + : ""; + return ( + + ); + })} +
+ ); + })} +
+ ); + }; + return (
- - Working directory - + {/* Tree / By-type toggle */} +
+ {(["tree", "type"] as const).map((m) => ( + + ))} +
@@ -181,12 +344,20 @@ export function WorkspacePanel() {

{error}

- ) : rootLoading && !children[""] ? ( + ) : view === "tree" ? ( + rootLoading && !children[""] ? ( +
+ +
+ ) : ( +
{renderEntries("", 0)}
+ ) + ) : typeLoading && allFiles === null ? (
) : ( -
{renderEntries("", 0)}
+ renderByType() )} / (the +// GLOBAL tier EvoScientist reads from). We also maintain that tier's +// .installed.yaml manifest so EvoScientist's onboard/CLI stay in sync. Zero +// npm dependencies. + +import { homedir } from "os"; +import { dirname, join, resolve, sep } from "path"; +import { promises as fs } from "fs"; +import { randomUUID } from "crypto"; + +const REPO = "EvoScientist/EvoSkills"; +const BRANCH = "main"; +const SKILLS_PREFIX = "skills/"; + +/** EvoScientist's global data dir — `~/.evoscientist` by default (paths.py + * DATA_DIR), relocatable via EVOSCIENTIST_DATA_DIR, exactly like the backend. */ +function globalDataDir(): string { + const env = process.env.EVOSCIENTIST_DATA_DIR; + if (env && env.trim()) { + return env.startsWith("~") ? join(homedir(), env.slice(1)) : resolve(env); + } + return join(homedir(), ".evoscientist"); +} + +/** + * Where skills install: EvoScientist's GLOBAL skills tier + * (`DATA_DIR/skills` = `~/.evoscientist/skills`), matching `install_skill()`'s + * default global target. NOT `~/.config/evoscientist/skills` — that's the + * pre-migration legacy location (only config.yaml/mcp.yaml still live there). + */ +export const SKILLS_INSTALL_DIR = join(globalDataDir(), "skills"); + +/** Tiers scanned to decide whether a skill is already installed: the global + * dir first, then the legacy ~/.config path as a harmless fallback. */ +export const SKILL_DIRS = [ + SKILLS_INSTALL_DIR, + join(homedir(), ".config", "evoscientist", "skills"), +]; + +// Install guards. +const MAX_SKILL_FILES = 300; +const MAX_SKILL_BYTES = 25 * 1024 * 1024; + +const GITHUB_HEADERS = { + Accept: "application/vnd.github+json", + // GitHub rejects API requests without a User-Agent. + "User-Agent": "evoscientist-webui", +}; + +export interface CatalogSkill { + /** Directory name in the repo (the install identity), e.g. "paper-writing". */ + name: string; + /** SKILL.md frontmatter name, falls back to the dir name. */ + title: string; + description: string; + fileCount: number; + installed: boolean; + /** metadata.version from the upstream SKILL.md (undefined if absent). */ + latestVersion?: string; + /** metadata.version of the locally-installed SKILL.md (undefined if absent). */ + installedVersion?: string; + /** True when installed and the upstream version is strictly newer. */ + updateAvailable: boolean; +} + +/** Per-skill source recorded in .installed.yaml: the `owner/repo@path` shorthand + * with path = `skills/`, so EvoScientist tracks each skill's provenance + * (and its commit-based update check) individually rather than as one pack. */ +function manifestSource(name: string): string { + return `${REPO}@${SKILLS_PREFIX}${name}`; // EvoScientist/EvoSkills@skills/ +} + +interface TreeBlob { + path: string; + type: "blob" | "tree" | string; + size?: number; +} + +/** A skill name must be a single safe path segment. Matches EvoScientist's + * convention (`^[A-Za-z0-9][A-Za-z0-9._-]*$`) — also blocks traversal and odd + * manifest keys. */ +export function isValidSkillName(name: string): boolean { + return ( + name.length <= 128 && + /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && + !name.includes("..") + ); +} + +/** Read `version` only from inside the `metadata:` block — not any stray + * indented `version:` elsewhere in the frontmatter. */ +function getMetadataVersion(fm: string): string | undefined { + const block = fm.match(/^metadata\s*:[ \t]*\n((?:[ \t]+.*(?:\n|$))*)/m); + if (!block) return undefined; + const v = block[1].match(/^[ \t]+version\s*:\s*(.+?)\s*$/m); + return v ? v[1].replace(/^["']|["']$/g, "").trim() : undefined; +} + +function parseFrontmatter(md: string): { + name?: string; + description?: string; + version?: string; +} { + const match = md.match(/^---\s*\n([\s\S]*?)\n---/); + if (!match) return {}; + const fm = match[1]; + // name/description are top-level keys; version is nested under metadata. + const top = (key: string) => { + const m = fm.match(new RegExp(`^${key}\\s*:\\s*(.+?)\\s*$`, "m")); + return m ? m[1].replace(/^["']|["']$/g, "").trim() : undefined; + }; + return { + name: top("name"), + description: top("description"), + version: getMetadataVersion(fm), + }; +} + +/** Compare dotted versions ("1.2.3"). Returns 1 if a>b, -1 if a parseInt(s, 10)); + const pb = b.split(".").map((s) => parseInt(s, 10)); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i += 1) { + const x = pa[i] ?? 0; + const y = pb[i] ?? 0; + if (Number.isNaN(x) || Number.isNaN(y)) return 0; + if (x !== y) return x > y ? 1 : -1; + } + return 0; +} + +/** Read the locally-installed SKILL.md version for `name`, across the tiers. */ +async function readInstalledVersion(name: string): Promise { + for (const dir of SKILL_DIRS) { + try { + const md = await fs.readFile(join(dir, name, "SKILL.md"), "utf-8"); + const v = parseFrontmatter(md).version; + if (v) return v; + } catch { + // not in this tier + } + } + return undefined; +} + +function rawUrl(ref: string, repoPath: string): string { + return `https://raw.githubusercontent.com/${REPO}/${ref}/${repoPath}`; +} + +const SHA_RE = /^[0-9a-f]{7,40}$/; + +// A snapshot of the repo pinned to ONE ref (the branch-head commit SHA when +// resolvable, else the branch name). Catalog + install share it, so the file +// list, the raw downloads, and the recorded commit all refer to the SAME +// revision — the branch can't move out from under a single operation. Cached. +let snapshotCache: { at: number; ref: string; tree: TreeBlob[] } | null = null; +const TREE_TTL_MS = 5 * 60 * 1000; + +/** Resolve the branch to its head commit SHA, falling back to the branch name. */ +async function resolveRef(): Promise { + try { + const res = await fetch( + `https://api.github.com/repos/${REPO}/commits/${BRANCH}`, + { headers: GITHUB_HEADERS } + ); + if (res.ok) { + const data = (await res.json()) as { sha?: string }; + if (typeof data.sha === "string" && SHA_RE.test(data.sha)) return data.sha; + } + } catch { + // fall back to the branch name below + } + return BRANCH; +} + +async function getRepoSnapshot( + force = false +): Promise<{ ref: string; tree: TreeBlob[] }> { + if (!force && snapshotCache && Date.now() - snapshotCache.at < TREE_TTL_MS) { + return { ref: snapshotCache.ref, tree: snapshotCache.tree }; + } + const ref = await resolveRef(); + const res = await fetch( + `https://api.github.com/repos/${REPO}/git/trees/${ref}?recursive=1`, + { headers: GITHUB_HEADERS } + ); + if (!res.ok) { + throw new Error( + res.status === 403 + ? "GitHub rate limit reached — try again in a minute." + : `Couldn't reach the skills catalog (GitHub ${res.status}).` + ); + } + const data = (await res.json()) as { tree?: TreeBlob[]; truncated?: boolean }; + if (!Array.isArray(data.tree)) { + throw new Error("Unexpected response from the skills catalog."); + } + snapshotCache = { at: Date.now(), ref, tree: data.tree }; + return { ref, tree: data.tree }; +} + +/** The commit to record in the manifest — the snapshot ref iff it's a SHA. */ +function commitFromRef(ref: string): string | null { + return ref !== BRANCH && SHA_RE.test(ref) ? ref : null; +} + +// --------------------------------------------------------------------------- +// .installed.yaml manifest (EvoScientist's per-tier skill registry) +// +// Schema: { : { source: , commit?: } }, written by EvoScientist +// via `yaml.safe_dump(sort_keys=True)`. We read/merge/write the same shape so +// onboard's ✓ indicator + commit-based "update available" stay consistent. +// --------------------------------------------------------------------------- + +type ManifestEntry = { source: string; commit?: string }; +type Manifest = Record; + +function manifestPath(): string { + return join(SKILLS_INSTALL_DIR, ".installed.yaml"); +} + +/** Minimal parser for the flat 2-level manifest (name → {source, commit}). */ +function parseManifest(text: string): Manifest { + const out: Manifest = {}; + let current: string | null = null; + for (const line of text.split("\n")) { + if (!line.trim() || line.trimStart().startsWith("#")) continue; + if (/^\S/.test(line)) { + const m = line.match(/^(\S[^:]*?):\s*$/); + current = m ? m[1].trim() : null; + if (current) out[current] = { source: "" }; + } else if (current) { + const m = line.match(/^\s+([A-Za-z_]+)\s*:\s*(.+?)\s*$/); + if (m) { + const value = m[2].replace(/^["']|["']$/g, "").trim(); + if (m[1] === "source") out[current].source = value; + else if (m[1] === "commit") out[current].commit = value; + } + } + } + // Drop entries without a source — matches EvoScientist's loader leniency. + for (const k of Object.keys(out)) if (!out[k].source) delete out[k]; + return out; +} + +async function readManifest(): Promise { + try { + return parseManifest(await fs.readFile(manifestPath(), "utf-8")); + } catch { + return {}; + } +} + +/** Emit matching `yaml.safe_dump(sort_keys=True)`: names sorted, inner keys + * sorted (commit before source), values unquoted (safe for our inputs). */ +function emitManifest(m: Manifest): string { + const names = Object.keys(m).sort(); + if (names.length === 0) return "{}\n"; + let out = ""; + for (const name of names) { + out += `${name}:\n`; + if (m[name].commit) out += ` commit: ${m[name].commit}\n`; + out += ` source: ${m[name].source}\n`; + } + return out; +} + +async function writeManifest(m: Manifest): Promise { + const path = manifestPath(); + const tmp = `${path}.${randomUUID()}.tmp`; + await fs.mkdir(SKILLS_INSTALL_DIR, { recursive: true }); + await fs.writeFile(tmp, emitManifest(m), { mode: 0o600 }); + await fs.rename(tmp, path); +} + +async function recordInstall(name: string, commit: string | null): Promise { + const manifest = await readManifest(); + const source = manifestSource(name); + manifest[name] = commit ? { source, commit } : { source }; + await writeManifest(manifest); +} + +/** Remove a skill's manifest entry (used by uninstall). No-op if absent. */ +export async function recordUninstall(name: string): Promise { + const manifest = await readManifest(); + if (name in manifest) { + delete manifest[name]; + await writeManifest(manifest); + } +} + +async function listInstalledNames(): Promise> { + const names = new Set(); + for (const dir of SKILL_DIRS) { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const e of entries) { + if (e.isDirectory() && !e.name.startsWith(".")) names.add(e.name); + } + } catch { + // dir doesn't exist — skip + } + } + return names; +} + +/** Blobs that live under `skills//`, keyed by skill dir name. */ +function groupSkillBlobs(tree: TreeBlob[]): Map { + const byName = new Map(); + for (const item of tree) { + if (item.type !== "blob" || !item.path.startsWith(SKILLS_PREFIX)) continue; + const rest = item.path.slice(SKILLS_PREFIX.length); + const slash = rest.indexOf("/"); + if (slash <= 0) continue; // a file directly under skills/ (e.g. README) — not a skill + const name = rest.slice(0, slash); + (byName.get(name) ?? byName.set(name, []).get(name)!).push(item); + } + return byName; +} + +export async function getCatalog(force = false): Promise { + const [{ ref, tree }, installed] = await Promise.all([ + getRepoSnapshot(force), + listInstalledNames(), + ]); + const byName = groupSkillBlobs(tree); + + const skills = await Promise.all( + [...byName.entries()].map(async ([name, blobs]) => { + let title = name; + let description = ""; + let latestVersion: string | undefined; + const skillMd = blobs.find( + (b) => b.path === `${SKILLS_PREFIX}${name}/SKILL.md` + ); + if (skillMd) { + try { + const md = await fetch(rawUrl(ref, skillMd.path), { + headers: GITHUB_HEADERS, + }).then((r) => (r.ok ? r.text() : "")); + const fm = parseFrontmatter(md); + title = fm.name || name; + description = fm.description || ""; + latestVersion = fm.version; + } catch { + // best-effort metadata + } + } + const isInstalled = installed.has(name); + const installedVersion = isInstalled + ? await readInstalledVersion(name) + : undefined; + const updateAvailable = + isInstalled && + !!latestVersion && + !!installedVersion && + compareVersions(latestVersion, installedVersion) > 0; + return { + name, + title, + description, + fileCount: blobs.length, + installed: isInstalled, + latestVersion, + installedVersion, + updateAvailable, + } satisfies CatalogSkill; + }) + ); + + return skills.sort((a, b) => a.title.localeCompare(b.title)); +} + +/** Download every file of `skills//` into the install dir, atomically. */ +export async function installSkill( + name: string +): Promise<{ files: number }> { + if (!isValidSkillName(name)) throw new Error("Invalid skill name."); + + const { ref, tree } = await getRepoSnapshot(); + const prefix = `${SKILLS_PREFIX}${name}/`; + const blobs = tree.filter((t) => t.type === "blob" && t.path.startsWith(prefix)); + if (blobs.length === 0) { + throw new Error(`Skill "${name}" was not found in the catalog.`); + } + if (blobs.length > MAX_SKILL_FILES) { + throw new Error("This skill has too many files to install."); + } + const totalBytes = blobs.reduce((sum, b) => sum + (b.size ?? 0), 0); + if (totalBytes > MAX_SKILL_BYTES) { + throw new Error("This skill is too large to install."); + } + + const installRoot = resolve(SKILLS_INSTALL_DIR); + const destRoot = resolve(installRoot, name); + if (destRoot !== join(installRoot, name)) { + throw new Error("Invalid skill name."); + } + + // Download to a temp dir (a dotfile, so it never shows as a skill mid-install), + // then swap atomically. Roll back the temp dir on any failure. + const tmpRoot = join(installRoot, `.installing-${name}-${randomUUID()}`); + try { + await fs.mkdir(tmpRoot, { recursive: true }); + for (const blob of blobs) { + const rel = blob.path.slice(prefix.length); + const target = resolve(tmpRoot, rel); + if (target !== tmpRoot && !target.startsWith(tmpRoot + sep)) { + throw new Error("Invalid file path in skill."); + } + const res = await fetch(rawUrl(ref, blob.path), { + headers: GITHUB_HEADERS, + }); + if (!res.ok) { + throw new Error(`Failed to download ${rel} (${res.status}).`); + } + const buf = Buffer.from(await res.arrayBuffer()); + await fs.mkdir(dirname(target), { recursive: true }); + await fs.writeFile(target, buf); + } + // Replace any existing install, then move the fresh copy into place. + await fs.rm(destRoot, { recursive: true, force: true }); + await fs.mkdir(installRoot, { recursive: true }); + await fs.rename(tmpRoot, destRoot); + } catch (error) { + await fs.rm(tmpRoot, { recursive: true, force: true }).catch(() => {}); + throw error; + } + + // Record provenance in .installed.yaml so EvoScientist's onboard/CLI see the + // skill as installed (with the pinned commit for its update detection — the + // SAME revision the files came from). Best-effort: a manifest failure must not + // fail an otherwise-good install. + try { + await recordInstall(name, commitFromRef(ref)); + } catch { + // ignore manifest write errors + } + + return { files: blobs.length }; +}