From fcf644551add9661d5e100623cb8a02cff52da2f Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 13:49:18 +0300 Subject: [PATCH 1/2] feat: Cabinet-style skill installer in /skills UI Adds three daemon endpoints and an Install dialog so skills can be pulled from skills.sh / github.com / github:owner/repo[/skill] without leaving the UI. Backend (src/skills/): - import-source.ts: parser for skills.sh URL, github.com URL, and github:owner/repo[@/]skill shorthand. local: explicitly unsupported. - import.ts: previewSkill() hits the GitHub API + raw SKILL.md for the preview card; importSkill() shallow-clones, resolves the skill subtree (direct, /skills/, .claude-plugin manifest, recursive walk), copies with verbatimSymlinks. Tight ref whitelist guards `git clone --branch`. - lock.ts: data/skills-lock.json keyed by skill name; provenance survives restarts, drives the UI's source pill + uninstall button. Endpoints in src/server.ts: - POST /api/skills/preview, POST /api/skills/install, DELETE /api/skills/:key - GET /api/available-skills now returns each skill's lock entry - install can attach to one or more agents in a single call UI (ui/): - New SkillInstallDialog with paste-and-preview flow + agent attach toggles - /skills page gets an Install button; installed skills show source + uninstall confirm; post-install screen surfaces scanSkillDirectory findings inline (warnings never block install). 12/12 parser unit tests; smoke-tested install/list/uninstall against a local daemon (pdf skill from anthropics/skills). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server.ts | 95 ++++- src/skills/import-source.test.ts | 92 +++++ src/skills/import-source.ts | 86 ++++ src/skills/import.ts | 322 +++++++++++++++ src/skills/lock.ts | 48 +++ ui/app/skills/page.tsx | 141 ++++++- ui/components/skills/skill-install-dialog.tsx | 383 ++++++++++++++++++ 7 files changed, 1158 insertions(+), 9 deletions(-) create mode 100644 src/skills/import-source.test.ts create mode 100644 src/skills/import-source.ts create mode 100644 src/skills/import.ts create mode 100644 src/skills/lock.ts create mode 100644 ui/components/skills/skill-install-dialog.tsx 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..285964b --- /dev/null +++ b/src/skills/import-source.test.ts @@ -0,0 +1,92 @@ +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("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..c9ed3f8 --- /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?:\/\/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} /> + + +
+
+ ); +} From 4c60b10eccad8fe06a33fb71740895f98b2a0aad Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 16:05:28 +0300 Subject: [PATCH 2/2] fix(skills): accept www.skills.sh in install source parser skills.sh redirects to www.skills.sh in the browser, so URLs the user pastes from the address bar were rejected with "unrecognized source format". Allow the optional www. prefix; add a regression test for the exact URL that surfaced this. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/skills/import-source.test.ts | 9 +++++++++ src/skills/import-source.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/skills/import-source.test.ts b/src/skills/import-source.test.ts index 285964b..b189ffa 100644 --- a/src/skills/import-source.test.ts +++ b/src/skills/import-source.test.ts @@ -54,6 +54,15 @@ describe("parseSource", () => { }); }); + 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", diff --git a/src/skills/import-source.ts b/src/skills/import-source.ts index c9ed3f8..1d30239 100644 --- a/src/skills/import-source.ts +++ b/src/skills/import-source.ts @@ -29,7 +29,7 @@ export function parseSource(raw: string): ParsedSource | null { } const skillsSh = trimmed.match( - /^https?:\/\/skills\.sh\/([^/?#\s]+)\/([^/?#\s]+)(?:\/([^/?#\s]+))?/, + /^https?:\/\/(?:www\.)?skills\.sh\/([^/?#\s]+)\/([^/?#\s]+)(?:\/([^/?#\s]+))?/, ); if (skillsSh) { return {