diff --git a/src/server.ts b/src/server.ts index 7719983..aad8688 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,8 +9,11 @@ import { logger } from "./utils/external-logger.js"; import { getPromptTraceById, listPromptTraces } from "./agent/prompt-trace.js"; import { allTools } from "./agent/tools/index.js"; import { getMCPTools } from "./agent/mcp-client.js"; -import { loadSkills, parseFrontmatter } from "./skills/loader.js"; +import { loadSkills, parseFrontmatter, clearSkillCache } from "./skills/loader.js"; import { dataPath, getSkillsDir } from "./utils/paths.js"; +import { importSkill, previewSkill } from "./skills/import.js"; +import { readLock, updateLockEntry, removeLockEntry } from "./skills/lock.js"; +import { scanSkillDirectory } from "./skills/scanner.js"; import { randomUUID } from "node:crypto"; import type { JobQueue } from "./scheduler/job-queue.js"; import type { CronStore } from "./scheduler/cron-store.js"; @@ -598,6 +601,7 @@ export function startServer(deps: ServerDeps) { const defaultSkillsDir = path.resolve("skills"); const dirs = skillsDir !== defaultSkillsDir ? [skillsDir, defaultSkillsDir] : [skillsDir]; const allSkills = loadSkills(dirs); + const lock = readLock(); // Build usedBy map: skill name → agent IDs that have it enabled const usedByMap: Record = {}; @@ -628,15 +632,104 @@ export function startServer(deps: ServerDeps) { return { name: s.name, description: s.description, + dir: s.dir, eligible: s.eligible, requires, usedBy: usedByMap[s.name] ?? [], + lock: lock[s.name] ?? null, }; }); return json(res, { skills }); } catch (err) { return json(res, { error: err instanceof Error ? err.message : String(err) }, 500); } } + // ── Skill install / preview / uninstall ─────────────── + if (req.method === "POST" && pathname === "/api/skills/preview") { + try { + const body = await readBody(req); + const { source } = JSON.parse(body || "{}") as { source?: string }; + if (!source || typeof source !== "string") { + return json(res, { error: "source is required" }, 400); + } + const preview = await previewSkill(source); + return json(res, preview); + } catch (err) { + return json(res, { error: err instanceof Error ? err.message : String(err) }, 400); + } + } + + if (req.method === "POST" && pathname === "/api/skills/install") { + try { + const body = await readBody(req); + const parsed = JSON.parse(body || "{}") as { + source?: string; + ref?: string; + attachToAgents?: string[]; + }; + if (!parsed.source || typeof parsed.source !== "string") { + return json(res, { error: "source is required" }, 400); + } + + const result = await importSkill({ source: parsed.source, ref: parsed.ref }); + updateLockEntry(result.key, { + source: result.source, + sourceType: result.sourceType, + ref: result.ref, + installedAt: new Date().toISOString(), + }); + + // Post-install security scan — warnings only, never blocks. + const scan = scanSkillDirectory(result.destDir); + + // Optional: attach to one or more agents (write into agent.skills). + const attachedAgents: string[] = []; + if (Array.isArray(parsed.attachToAgents) && parsed.attachToAgents.length > 0 && deps.agentStore) { + for (const agentId of parsed.attachToAgents) { + const cfg = deps.agentStore.getConfig(agentId); + if (!cfg) continue; + const next = Array.from(new Set([...(cfg.skills ?? []), result.key])); + deps.agentStore.updateConfig(agentId, { skills: next }); + attachedAgents.push(agentId); + } + } + + clearSkillCache(); + return json(res, { + ok: true, + key: result.key, + source: result.source, + sourceType: result.sourceType, + ref: result.ref, + scan, + attachedAgents, + }); + } catch (err) { + logger.warn("Skill install failed", { error: err instanceof Error ? err.message : String(err) }); + return json(res, { error: err instanceof Error ? err.message : String(err) }, 400); + } + } + + const skillKeyMatch = pathname.match(/^\/api\/skills\/([a-z0-9][a-z0-9-]*)$/); + if (req.method === "DELETE" && skillKeyMatch) { + try { + const key = skillKeyMatch[1]; + const target = path.resolve(getSkillsDir(), key); + const skillsRoot = path.resolve(getSkillsDir()) + path.sep; + if (!target.startsWith(skillsRoot)) { + return json(res, { error: "invalid skill path" }, 400); + } + if (!fs.existsSync(target)) { + return json(res, { error: `skill not found: ${key}` }, 404); + } + fs.rmSync(target, { recursive: true, force: true }); + removeLockEntry(key); + clearSkillCache(); + return json(res, { ok: true, key }); + } catch (err) { + return json(res, { error: err instanceof Error ? err.message : String(err) }, 500); + } + } + // ── Crons CRUD ──────────────────────────────────────── if (req.method === "GET" && pathname === "/api/crons" && deps.cronStore) { try { diff --git a/src/skills/import-source.test.ts b/src/skills/import-source.test.ts new file mode 100644 index 0000000..b189ffa --- /dev/null +++ b/src/skills/import-source.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import { parseSource, isValidSkillKey } from "./import-source.js"; + +describe("parseSource", () => { + test("github shorthand: owner/repo", () => { + expect(parseSource("github:anthropics/skills")).toEqual({ + kind: "github", + owner: "anthropics", + repo: "skills", + skillName: undefined, + }); + }); + + test("github shorthand: owner/repo/skill", () => { + expect(parseSource("github:anthropics/skills/release")).toEqual({ + kind: "github", + owner: "anthropics", + repo: "skills", + skillName: "release", + }); + }); + + test("github shorthand @ form", () => { + expect(parseSource("github:anthropics/skills@release")).toEqual({ + kind: "github", + owner: "anthropics", + repo: "skills", + skillName: "release", + }); + }); + + test("github.com URL", () => { + const r = parseSource("https://github.com/anthropics/skills"); + expect(r?.owner).toBe("anthropics"); + expect(r?.repo).toBe("skills"); + expect(r?.kind).toBe("github"); + }); + + test("github.com URL with .git suffix", () => { + expect(parseSource("https://github.com/anthropics/skills.git")?.repo).toBe("skills"); + }); + + test("github.com URL with /tree/ captures ref", () => { + const r = parseSource("https://github.com/anthropics/skills/tree/main/release"); + expect(r?.ref).toBe("main"); + }); + + test("skills.sh URL", () => { + expect(parseSource("https://skills.sh/anthropics/skills/release")).toEqual({ + kind: "skills_sh", + owner: "anthropics", + repo: "skills", + skillName: "release", + }); + }); + + test("www.skills.sh URL", () => { + expect(parseSource("https://www.skills.sh/juliusbrussee/caveman/caveman")).toEqual({ + kind: "skills_sh", + owner: "juliusbrussee", + repo: "caveman", + skillName: "caveman", + }); + }); + + test("strips `npx skills add` prefix", () => { + expect(parseSource("npx skills add github:anthropics/skills/release")?.skillName).toBe( + "release", + ); + }); + + test("folds --skill flag into skillName", () => { + expect(parseSource("github:anthropics/skills --skill release")?.skillName).toBe("release"); + }); + + test("rejects unknown formats", () => { + expect(parseSource("local:/etc/passwd")).toBeNull(); + expect(parseSource("not a source")).toBeNull(); + expect(parseSource("")).toBeNull(); + }); +}); + +describe("isValidSkillKey", () => { + test("accepts kebab-case", () => { + expect(isValidSkillKey("release")).toBe(true); + expect(isValidSkillKey("my-skill")).toBe(true); + expect(isValidSkillKey("a1-b2-c3")).toBe(true); + }); + + test("rejects path traversal and separators", () => { + expect(isValidSkillKey("../etc")).toBe(false); + expect(isValidSkillKey("foo/bar")).toBe(false); + expect(isValidSkillKey(".hidden")).toBe(false); + expect(isValidSkillKey("Foo")).toBe(false); + expect(isValidSkillKey("foo_bar")).toBe(false); + expect(isValidSkillKey("-leading")).toBe(false); + expect(isValidSkillKey("trailing-")).toBe(false); + expect(isValidSkillKey("")).toBe(false); + expect(isValidSkillKey("a".repeat(65))).toBe(false); + }); +}); diff --git a/src/skills/import-source.ts b/src/skills/import-source.ts new file mode 100644 index 0000000..1d30239 --- /dev/null +++ b/src/skills/import-source.ts @@ -0,0 +1,86 @@ +// Parse a skill install source string into a normalized descriptor. +// +// Accepted forms (v1): +// github:owner/repo +// github:owner/repo/skillName +// github:owner/repo@skillName (CLI-style filter) +// https://github.com/owner/repo +// https://github.com/owner/repo/tree//... +// https://skills.sh/owner/repo +// https://skills.sh/owner/repo/skillName +// `npx skills add ...` / `skills add ...` prefixes are stripped. +// `--skill ` flag is folded into skillName. + +export interface ParsedSource { + kind: "github" | "skills_sh"; + owner: string; + repo: string; + skillName?: string; + ref?: string; +} + +export function parseSource(raw: string): ParsedSource | null { + let trimmed = raw.trim().replace(/^(?:npx\s+)?skills\s+add\s+/i, ""); + const flagMatch = trimmed.match(/\s+--skill[=\s]+([^\s]+)/); + let flagSkill: string | undefined; + if (flagMatch) { + flagSkill = flagMatch[1]; + trimmed = trimmed.replace(flagMatch[0], "").trim(); + } + + const skillsSh = trimmed.match( + /^https?:\/\/(?:www\.)?skills\.sh\/([^/?#\s]+)\/([^/?#\s]+)(?:\/([^/?#\s]+))?/, + ); + if (skillsSh) { + return { + kind: "skills_sh", + owner: skillsSh[1], + repo: skillsSh[2], + skillName: skillsSh[3] ?? flagSkill, + }; + } + + const gh = trimmed.match( + /^https?:\/\/github\.com\/([^/?#\s]+)\/([^/?#\s]+?)(?:\.git)?(?:\/(?:tree|blob)\/([^/?#\s]+))?(?:\/.*)?$/, + ); + if (gh) { + return { + kind: "github", + owner: gh[1], + repo: gh[2], + skillName: flagSkill, + ref: gh[3], + }; + } + + const shorthandAt = trimmed.match(/^github:([^/?#\s]+)\/([^/@?#\s]+)@([^/?#\s]+)$/); + if (shorthandAt) { + return { + kind: "github", + owner: shorthandAt[1], + repo: shorthandAt[2], + skillName: shorthandAt[3], + }; + } + + const shorthand = trimmed.match(/^github:([^/?#\s]+)\/([^/?#\s]+?)(?:\/([^/?#\s]+))?$/); + if (shorthand) { + return { + kind: "github", + owner: shorthand[1], + repo: shorthand[2], + skillName: shorthand[3] ?? flagSkill, + }; + } + + return null; +} + +// Strict kebab-case key. Used as a directory name under the skills root, +// so it must not contain path separators or hidden-dir prefixes. +const SKILL_KEY_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; + +export function isValidSkillKey(s: string): boolean { + if (!s || s.length > 64) return false; + return SKILL_KEY_RE.test(s); +} diff --git a/src/skills/import.ts b/src/skills/import.ts new file mode 100644 index 0000000..51a4213 --- /dev/null +++ b/src/skills/import.ts @@ -0,0 +1,322 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { getSkillsDir } from "../utils/paths.js"; +import { isValidSkillKey, parseSource, type ParsedSource } from "./import-source.js"; + +// Tight ref whitelist — feeds into `git clone --branch ` as a positional. +// Reject anything that could be interpreted as a flag, or contains shell-meaningful chars. +const SAFE_REF_RE = /^[A-Za-z0-9._/-]+$/; + +function isSafeRef(ref: string): boolean { + if (!ref || ref.length > 256) return false; + if (ref.startsWith("-")) return false; + if (ref.includes("..")) return false; + return SAFE_REF_RE.test(ref); +} + +function gitClone(url: string, dest: string, ref?: string): Promise { + return new Promise((resolve, reject) => { + if (ref && !isSafeRef(ref)) { + reject(new Error(`unsafe ref: "${ref}"`)); + return; + } + const args = ["clone", "--depth", "1"]; + if (ref) args.push("--branch", ref); + args.push("--", url, dest); + const child = spawn("git", args, { stdio: ["ignore", "pipe", "pipe"] }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`git clone failed: ${stderr.trim() || `code ${code}`}`)); + }); + }); +} + +async function pathExists(p: string): Promise { + try { await fs.stat(p); return true; } catch { return false; } +} + +// Claude-Code plugin layout: skills listed in .claude-plugin/plugin.json as +// `./tools/...//SKILL.md` entries. +async function resolveFromPluginManifest(repoRoot: string, skillName: string): Promise { + try { + const manifestPath = path.join(repoRoot, ".claude-plugin", "plugin.json"); + const raw = await fs.readFile(manifestPath, "utf-8"); + const parsed = JSON.parse(raw) as { skills?: unknown }; + const skills = Array.isArray(parsed.skills) ? parsed.skills : []; + const repoRootResolved = path.resolve(repoRoot) + path.sep; + for (const entry of skills) { + if (typeof entry !== "string") continue; + const normalized = entry.replace(/^\.\//, ""); + const dir = normalized.replace(/\/SKILL\.md$/i, ""); + if (path.basename(dir) !== skillName) continue; + const abs = path.resolve(repoRoot, dir); + // Boundary check: refuse manifest entries that escape repoRoot. + if (abs !== path.resolve(repoRoot) && !abs.startsWith(repoRootResolved)) continue; + if (await pathExists(abs)) return abs; + } + return null; + } catch { + return null; + } +} + +// Fallback: walk the clone for any dir named that contains SKILL.md. +async function findSkillByDirName(repoRoot: string, skillName: string, maxDepth = 6): Promise { + const SKIP = new Set([".git", "node_modules", ".next", "dist", "build", ".venv", "venv"]); + async function walk(dir: string, depth: number): Promise { + if (depth > maxDepth) return null; + let entries: import("node:fs").Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { return null; } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (SKIP.has(entry.name)) continue; + const childDir = path.join(dir, entry.name); + if (entry.name === skillName) { + if (await pathExists(path.join(childDir, "SKILL.md"))) return childDir; + } + const found = await walk(childDir, depth + 1); + if (found) return found; + } + return null; + } + return walk(repoRoot, 0); +} + +export interface ImportResult { + key: string; + destDir: string; + source: string; // canonical locator (github:owner/repo[/skill]) + sourceType: "github" | "skills_sh"; + ref: string | null; +} + +/** + * Clone the repo to a temp dir, resolve the skill subtree, then atomically + * move into /. Returns the canonical source locator and + * resolved key for the lockfile. + */ +export async function importSkill(opts: { source: string; ref?: string }): Promise { + const parsed: ParsedSource | null = parseSource(opts.source); + if (!parsed) { + throw new Error(`unrecognized source format: ${opts.source}`); + } + + const destRoot = getSkillsDir(); + await fs.mkdir(destRoot, { recursive: true }); + const destRootResolved = path.resolve(destRoot) + path.sep; + + const ref = opts.ref ?? parsed.ref; + const url = `https://github.com/${parsed.owner}/${parsed.repo}.git`; + + const tmp = await fs.mkdtemp(path.join(destRoot, ".import-")); + try { + await gitClone(url, tmp, ref); + + let sourceDir: string; + let key: string; + + if (parsed.skillName) { + if (!isValidSkillKey(parsed.skillName)) { + throw new Error(`skill name "${parsed.skillName}" is not a valid skill key (kebab-case only).`); + } + const direct = path.join(tmp, parsed.skillName); + const nested = path.join(tmp, "skills", parsed.skillName); + if (await pathExists(direct)) sourceDir = direct; + else if (await pathExists(nested)) sourceDir = nested; + else { + const fromManifest = await resolveFromPluginManifest(tmp, parsed.skillName); + if (fromManifest) sourceDir = fromManifest; + else { + const fromWalk = await findSkillByDirName(tmp, parsed.skillName); + if (fromWalk) sourceDir = fromWalk; + else { + throw new Error( + `skill "${parsed.skillName}" not found in ${parsed.owner}/${parsed.repo} ` + + `(looked in /, /skills/, .claude-plugin/plugin.json, recursive walk)`, + ); + } + } + } + key = parsed.skillName; + } else { + if (!isValidSkillKey(parsed.repo)) { + throw new Error( + `repo name "${parsed.repo}" is not a valid skill key (kebab-case only); ` + + `pass a skill name explicitly.`, + ); + } + // Whole repo IS the skill — must contain SKILL.md at root. + if (!(await pathExists(path.join(tmp, "SKILL.md")))) { + throw new Error( + `${parsed.owner}/${parsed.repo} has no SKILL.md at its root — ` + + `pass an explicit skill name (e.g. github:${parsed.owner}/${parsed.repo}/).`, + ); + } + sourceDir = tmp; + key = parsed.repo; + } + + const dest = path.resolve(destRoot, key); + if (!dest.startsWith(destRootResolved) && dest !== path.resolve(destRoot)) { + throw new Error(`resolved dest "${dest}" escapes skills dir`); + } + + await fs.rm(dest, { recursive: true, force: true }); + // verbatimSymlinks: don't follow symlinks out of the clone (defensive + // against a hostile bundle like `evil -> /home/user/.ssh`). + await fs.cp(sourceDir, dest, { recursive: true, verbatimSymlinks: true }); + + const sourceLocator = parsed.skillName + ? `github:${parsed.owner}/${parsed.repo}/${parsed.skillName}` + : `github:${parsed.owner}/${parsed.repo}`; + + return { + key, + destDir: dest, + source: sourceLocator, + sourceType: parsed.kind, + ref: ref ?? null, + }; + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } +} + +// Preview: fetch GitHub repo metadata + (optionally) the resolved SKILL.md +// frontmatter so the UI can show a confidence card before install. + +export interface PreviewResult { + owner: string; + repo: string; + stars: number; + forks: number; + lastCommitISO: string | null; + lastCommitAgeDays: number | null; + defaultBranch: string; + description: string | null; + topics: string[]; + requestedSkill: string | null; + skillMeta: { key: string; name: string; description: string | null; path: string } | null; + sourceLocator: string; +} + +async function fetchRepoMeta(owner: string, repo: string): Promise> { + const url = `https://api.github.com/repos/${owner}/${repo}`; + const headers: Record = { + Accept: "application/vnd.github+json", + "User-Agent": "golem-skill-installer", + }; + if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + const res = await fetch(url, { headers }); + if (!res.ok) { + if (res.status === 404) throw new Error(`repo not found: ${owner}/${repo}`); + if (res.status === 403 || res.status === 429) { + throw new Error( + process.env.GITHUB_TOKEN + ? "GitHub rate-limited (try again shortly)" + : "GitHub rate-limited (set GITHUB_TOKEN to raise the limit)", + ); + } + throw new Error(`GitHub returned ${res.status}`); + } + const data = (await res.json()) as Record; + const pushedAt = typeof data.pushed_at === "string" ? data.pushed_at : null; + const lastCommitAgeDays = pushedAt + ? Math.floor((Date.now() - new Date(pushedAt).getTime()) / (1000 * 60 * 60 * 24)) + : null; + return { + owner, + repo, + stars: typeof data.stargazers_count === "number" ? data.stargazers_count : 0, + forks: typeof data.forks_count === "number" ? data.forks_count : 0, + lastCommitISO: pushedAt, + lastCommitAgeDays, + defaultBranch: typeof data.default_branch === "string" ? data.default_branch : "main", + description: typeof data.description === "string" ? data.description : null, + topics: Array.isArray(data.topics) ? (data.topics as string[]) : [], + }; +} + +// Minimal frontmatter parser — extracts `name:` and `description:` only. +function parseSkillFrontmatter(md: string): { name?: string; description?: string } { + const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!m) return {}; + const block = m[1]; + const out: { name?: string; description?: string } = {}; + for (const line of block.split(/\r?\n/)) { + const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/); + if (!kv) continue; + const key = kv[1]; + let val = kv[2].trim(); + // Strip matching quotes + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + if (key === "name") out.name = val; + else if (key === "description") out.description = val; + } + return out; +} + +async function fetchSkillMeta( + owner: string, + repo: string, + skill: string, + defaultBranch: string, +): Promise { + const headers: Record = { + Accept: "application/vnd.github+json", + "User-Agent": "golem-skill-installer", + }; + if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + + const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`; + const treeRes = await fetch(treeUrl, { headers }); + if (!treeRes.ok) return null; + const treeData = (await treeRes.json()) as { tree?: Array<{ path?: string; type?: string }> }; + const skillPath = (treeData.tree ?? []) + .filter((e) => typeof e.path === "string" && e.path.endsWith("/SKILL.md")) + .map((e) => e.path as string) + .find((p) => { + const segs = p.split("/"); + return segs[segs.length - 2] === skill; + }); + if (!skillPath) return null; + + const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${defaultBranch}/${skillPath}`; + const rawRes = await fetch(rawUrl); + if (!rawRes.ok) return null; + const md = await rawRes.text(); + const fm = parseSkillFrontmatter(md); + return { + key: skill, + name: fm.name?.trim() || skill, + description: fm.description?.trim() || null, + path: skillPath, + }; +} + +export async function previewSkill(source: string): Promise { + const parsed = parseSource(source); + if (!parsed) throw new Error(`unrecognized source format: ${source}`); + const meta = await fetchRepoMeta(parsed.owner, parsed.repo); + let skillMeta: PreviewResult["skillMeta"] = null; + if (parsed.skillName) { + skillMeta = await fetchSkillMeta(parsed.owner, parsed.repo, parsed.skillName, meta.defaultBranch); + } + const sourceLocator = parsed.skillName + ? `github:${parsed.owner}/${parsed.repo}/${parsed.skillName}` + : `github:${parsed.owner}/${parsed.repo}`; + return { + ...meta, + requestedSkill: parsed.skillName ?? null, + skillMeta, + sourceLocator, + }; +} diff --git a/src/skills/lock.ts b/src/skills/lock.ts new file mode 100644 index 0000000..ad8ba7d --- /dev/null +++ b/src/skills/lock.ts @@ -0,0 +1,48 @@ +import fs from "node:fs"; +import path from "node:path"; +import { dataPath } from "../utils/paths.js"; + +export interface SkillLockEntry { + source: string; // canonicalized source locator (e.g. "github:owner/repo/skill") + sourceType: "github" | "skills_sh"; + ref: string | null; + installedAt: string; // ISO timestamp +} + +export type SkillsLock = Record; + +function lockPath(): string { + return dataPath("skills-lock.json"); +} + +export function readLock(): SkillsLock { + const p = lockPath(); + if (!fs.existsSync(p)) return {}; + try { + const raw = fs.readFileSync(p, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object") return parsed as SkillsLock; + return {}; + } catch { + return {}; + } +} + +function writeLock(lock: SkillsLock): void { + const p = lockPath(); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(lock, null, 2), "utf-8"); +} + +export function updateLockEntry(key: string, entry: SkillLockEntry): void { + const lock = readLock(); + lock[key] = entry; + writeLock(lock); +} + +export function removeLockEntry(key: string): void { + const lock = readLock(); + if (!(key in lock)) return; + delete lock[key]; + writeLock(lock); +} diff --git a/ui/app/skills/page.tsx b/ui/app/skills/page.tsx index a3b3337..fb50638 100644 --- a/ui/app/skills/page.tsx +++ b/ui/app/skills/page.tsx @@ -1,12 +1,42 @@ "use client"; +import { useState } from "react"; import { useFetch } from "@/lib/use-api"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { PageHeader } from "@/components/page-header"; import { EmptyState } from "@/components/empty-state"; -import { Sparkles, CheckCircle2, AlertTriangle } from "lucide-react"; +import { + Sparkles, + CheckCircle2, + AlertTriangle, + Plus, + Trash2, + Loader2, + ExternalLink, +} from "lucide-react"; +import { toast } from "sonner"; +import { SkillInstallDialog } from "@/components/skills/skill-install-dialog"; +import { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} from "@/components/ui/alert-dialog"; + +interface SkillLock { + source: string; + sourceType: "github" | "skills_sh"; + ref: string | null; + installedAt: string; +} interface SkillInfo { name: string; @@ -14,6 +44,12 @@ interface SkillInfo { eligible: boolean; requires?: { env?: string[]; bins?: string[] }; usedBy?: string[]; + lock?: SkillLock | null; +} + +interface PlatformAgent { + id: string; + name: string; } function SkillCardSkeleton() { @@ -33,16 +69,42 @@ function SkillCardSkeleton() { } export default function SkillsPage() { - const { data, loading } = useFetch<{ skills: SkillInfo[] }>("/api/available-skills"); + const { data, loading, refetch } = useFetch<{ skills: SkillInfo[] }>("/api/available-skills"); + const { data: agentList } = useFetch<{ agents: PlatformAgent[] }>("/api/platform/agents"); const skills = data?.skills ?? []; + const agents = agentList?.agents ?? []; + + const [installOpen, setInstallOpen] = useState(false); + const [uninstalling, setUninstalling] = useState(null); const eligibleCount = skills.filter((s) => s.eligible).length; + const handleUninstall = async (key: string) => { + setUninstalling(key); + try { + const res = await fetch(`/api/skills/${key}`, { method: "DELETE" }); + const body = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }; + if (!res.ok || !body.ok) throw new Error(body.error || `uninstall failed (${res.status})`); + toast.success(`Uninstalled ${key}`); + await refetch(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Uninstall failed"); + } finally { + setUninstalling(null); + } + }; + return (
0 ? `${eligibleCount} of ${skills.length} skills available` : undefined} + actions={ + + } /> {loading && !data ? ( @@ -55,14 +117,14 @@ export default function SkillsPage() { setInstallOpen(true) }} /> ) : (
{skills.map((skill) => ( - {/* Header */}

{skill.name}

{skill.eligible ? ( @@ -78,12 +140,10 @@ export default function SkillsPage() { )}
- {/* Description */}

{skill.description}

- {/* Missing requirements */} {!skill.eligible && skill.requires && (
{skill.requires.env?.map((v) => ( @@ -99,9 +159,8 @@ export default function SkillsPage() {
)} - {/* Used by */} {skill.usedBy && skill.usedBy.length > 0 && ( -
+
Used by: {skill.usedBy.map((agentId) => ( @@ -110,11 +169,77 @@ export default function SkillsPage() { ))}
)} + + {skill.lock && ( +
+ + + {skill.lock.source.replace(/^github:/, "")} + + + + {uninstalling === skill.name ? ( + + ) : ( + + )} + + } + /> + + + Uninstall {skill.name}? + + This deletes the skill directory from disk. Agents that reference it + will still list it in their config until you remove it manually. + + + + Cancel} /> + handleUninstall(skill.name)} + > + Uninstall + + } + /> + + + +
+ )} ))}
)} + + refetch()} + />
); } diff --git a/ui/components/skills/skill-install-dialog.tsx b/ui/components/skills/skill-install-dialog.tsx new file mode 100644 index 0000000..37a2256 --- /dev/null +++ b/ui/components/skills/skill-install-dialog.tsx @@ -0,0 +1,383 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + Loader2, + AlertTriangle, + Star, + Clock, + CheckCircle2, + ShieldAlert, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +interface PreviewResult { + owner: string; + repo: string; + stars: number; + forks: number; + lastCommitISO: string | null; + lastCommitAgeDays: number | null; + defaultBranch: string; + description: string | null; + topics: string[]; + requestedSkill: string | null; + skillMeta: { key: string; name: string; description: string | null; path: string } | null; + sourceLocator: string; +} + +interface ScanResult { + critical: string[]; + warnings: string[]; + scannedFiles: number; +} + +interface InstallResponse { + ok: boolean; + key: string; + source: string; + scan: ScanResult; + attachedAgents: string[]; +} + +interface AgentOption { + id: string; + name: string; +} + +interface SkillInstallDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + agents: AgentOption[]; + onInstalled?: (key: string) => void | Promise; +} + +export function SkillInstallDialog({ + open, + onOpenChange, + agents, + onInstalled, +}: SkillInstallDialogProps) { + const [source, setSource] = useState(""); + const [previewing, setPreviewing] = useState(false); + const [preview, setPreview] = useState(null); + const [previewError, setPreviewError] = useState(null); + const [importing, setImporting] = useState(false); + const [importError, setImportError] = useState(null); + const [attachTo, setAttachTo] = useState>(new Set()); + const [installResult, setInstallResult] = useState(null); + + // Reset state when dialog opens/closes. + useEffect(() => { + if (!open) { + setSource(""); + setPreview(null); + setPreviewError(null); + setImportError(null); + setAttachTo(new Set()); + setInstallResult(null); + } + }, [open]); + + const handlePreview = useCallback(async () => { + const raw = source.trim(); + if (!raw) return; + setPreviewing(true); + setPreview(null); + setPreviewError(null); + try { + const res = await fetch("/api/skills/preview", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: raw }), + }); + const data = (await res.json()) as PreviewResult & { error?: string }; + if (!res.ok || data.error) throw new Error(data.error || `preview failed (${res.status})`); + setPreview(data); + } catch (err) { + setPreviewError(err instanceof Error ? err.message : "Preview failed"); + } finally { + setPreviewing(false); + } + }, [source]); + + const handleInstall = useCallback(async () => { + setImporting(true); + setImportError(null); + try { + const body: Record = { source }; + if (attachTo.size > 0) body.attachToAgents = Array.from(attachTo); + const res = await fetch("/api/skills/install", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const data = (await res.json()) as InstallResponse & { error?: string }; + if (!res.ok || !data.ok) throw new Error(data.error || "install failed"); + setInstallResult(data); + if (onInstalled) await onInstalled(data.key); + } catch (err) { + setImportError(err instanceof Error ? err.message : "Install failed"); + } finally { + setImporting(false); + } + }, [source, attachTo, onInstalled]); + + const toggleAttach = (id: string) => { + setAttachTo((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const stale = + preview?.lastCommitAgeDays != null && preview.lastCommitAgeDays > 365 + ? "red" + : preview?.lastCommitAgeDays != null && preview.lastCommitAgeDays > 180 + ? "yellow" + : null; + const lowStars = preview != null && preview.stars < 10; + + // Post-install success screen. + if (installResult) { + const { scan } = installResult; + const hasCritical = scan.critical.length > 0; + const hasWarnings = scan.warnings.length > 0; + return ( + + + + + + Installed {installResult.key} + + + Source: {installResult.source} + + + +
+ {installResult.attachedAgents.length > 0 && ( +
+ Attached to:{" "} + {installResult.attachedAgents.map((a) => ( + + {a} + + ))} +
+ )} + +
+ Security scan: scanned {scan.scannedFiles} file{scan.scannedFiles === 1 ? "" : "s"} —{" "} + {hasCritical || hasWarnings + ? `${scan.critical.length} critical, ${scan.warnings.length} warnings` + : "no issues found"} +
+ + {hasCritical && ( +
+
+ + Critical findings — review before using +
+
    + {scan.critical.map((c, i) => ( +
  • {c.replace(/^\[CRITICAL\]\s*/, "")}
  • + ))} +
+
+ )} + {hasWarnings && ( +
+
+ + Warnings +
+
    + {scan.warnings.map((w, i) => ( +
  • {w.replace(/^\[WARNING\]\s*/, "")}
  • + ))} +
+
+ )} +
+ + + Done} /> + +
+
+ ); + } + + return ( + + + + Install skill + + Paste a skills.sh URL, a github.com URL, or a{" "} + github:owner/repo[/skill] shortcode. + + + +
+
+ setSource(e.target.value)} + placeholder="https://skills.sh/anthropics/skills/release" + className="flex-1" + onKeyDown={(e) => { + if (e.key === "Enter") handlePreview(); + }} + /> + +
+ + {previewError && ( +
+ + {previewError} +
+ )} + + {preview && ( +
+
+ + {preview.skillMeta?.name ?? preview.requestedSkill ?? `${preview.owner}/${preview.repo}`} + + {preview.requestedSkill && ( + + {preview.requestedSkill} + + )} +
+ {(preview.skillMeta?.description ?? (preview.requestedSkill ? null : preview.description)) && ( +

+ {preview.skillMeta?.description ?? preview.description} +

+ )} + {preview.requestedSkill && !preview.skillMeta && ( +

+ + Couldn't find {preview.requestedSkill}{" "} + in this repo's file tree — install will still attempt a recursive search. +

+ )} +
+ + from {preview.owner}/{preview.repo} + + + + {preview.stars.toLocaleString()} + + {preview.lastCommitAgeDays != null && ( + + + {preview.lastCommitAgeDays}d ago + + )} +
+ {(stale || lowStars) && ( +
+ {lowStars && ( +
+ + Few stars — limited community adoption. +
+ )} + {stale === "red" && ( +
+ + Stale — last commit over a year ago. May be unmaintained. +
+ )} + {stale === "yellow" && ( +
+ + Last commit over 6 months ago. +
+ )} +
+ )} +
+ )} + + {preview && agents.length > 0 && ( +
+
+ Attach to agents (optional) +
+
+ {agents.map((a) => { + const selected = attachTo.has(a.id); + return ( + + ); + })} +
+
+ )} + + {importError && ( +
+ + {importError} +
+ )} +
+ + + Cancel} /> + + +
+
+ ); +}