import { existsSync, rmSync } from "node:fs"; import { join } from "node:path"; import { getWorkspaceSkillsDir } from "../util/platform.js"; import { commitStagedSkillInstall, createSkillInstallStagingDir, installSkillDependenciesIfPresent, writeSkillFilesToDir, } from "./catalog-install.js"; import { computeSkillHash, writeInstallMeta } from "./install-meta.js"; import type { AuditResponse, PartnerAudit, RiskLevel, SkillAuditData, } from "./skillssh-audit-types.js"; export type { AuditResponse, PartnerAudit, RiskLevel, SkillAuditData }; // ─── Types ─────────────────────────────────────────────────────────────────── export interface SkillsShSearchResult { id: string; // e.g. "vercel-labs/agent-skills/vercel-react-best-practices" skillId: string; // e.g. "vercel-react-best-practices" name: string; installs: number; source: string; // e.g. "vercel-labs/agent-skills" } export interface ResolvedSkillSource { owner: string; repo: string; skillSlug: string; ref?: string; } /** Map of relative file paths to their string contents */ export type SkillFiles = Record; // ─── Display helpers ───────────────────────────────────────────────────────── const RISK_DISPLAY: Record = { safe: "PASS", low: "PASS", medium: "WARN", high: "FAIL", critical: "FAIL", unknown: "?", }; const PROVIDER_DISPLAY: Record = { ath: "ATH", socket: "Socket", snyk: "Snyk", }; export function riskToDisplay(risk: RiskLevel): string { return RISK_DISPLAY[risk] ?? "?"; } export function providerDisplayName(provider: string): string { return PROVIDER_DISPLAY[provider] ?? provider; } export function formatAuditBadges(auditData: SkillAuditData): string { const providers = Object.keys(auditData); if (providers.length === 0) { return "Security: no audit data"; } const badges = providers.map((provider) => { const audit = auditData[provider]!; const display = riskToDisplay(audit.risk); const name = providerDisplayName(provider); return `[${name}:${display}]`; }); return `Security: ${badges.join(" ")}`; } // ─── API clients ───────────────────────────────────────────────────────────── export async function searchSkillsRegistry( query: string, limit?: number, ): Promise { const params = new URLSearchParams({ q: query }); if (limit != null) { params.set("limit", String(limit)); } const url = `https://skills.sh/api/search?${params.toString()}`; const response = await fetch(url, { signal: AbortSignal.timeout(10_000), }); if (!response.ok) { throw new Error( `skills.sh search failed: HTTP ${response.status} ${response.statusText}`, ); } const data = (await response.json()) as { skills: SkillsShSearchResult[] }; return data.skills ?? []; } export async function fetchSkillAudits( source: string, skillSlugs: string[], ): Promise { if (skillSlugs.length === 0) { return {}; } const params = new URLSearchParams({ source, skills: skillSlugs.join(","), }); const url = `https://add-skill.vercel.sh/audit?${params.toString()}`; const response = await fetch(url, { signal: AbortSignal.timeout(10_000), }); if (!response.ok) { throw new Error( `Audit fetch failed: HTTP ${response.status} ${response.statusText}`, ); } return (await response.json()) as AuditResponse; } // ─── Source resolution ────────────────────────────────────────────────────── /** * Parse a skill source string into owner, repo, and skill slug. * * Supported formats: * - `owner/repo@skill-name` * - `owner/repo/skill-name` * - `https://github.com/owner/repo/tree//skills/skill-name` */ export function resolveSkillSource(source: string): ResolvedSkillSource { // Full GitHub URL — capture the branch for ref passthrough // Branch capture uses non-greedy `.+?` to handle branch names with slashes (e.g. feature/new-flow) const urlMatch = source.match( /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/tree\/(.+?)\/skills\/([a-z0-9][a-z0-9._-]*)\/?$/, ); if (urlMatch) { return { owner: urlMatch[1]!, repo: urlMatch[2]!, skillSlug: urlMatch[4]!, ref: urlMatch[3]!, }; } // owner/repo@skill-name — restrict slug to safe characters const atMatch = source.match(/^([^/]+)\/([^/@]+)@([a-z0-9][a-z0-9._-]*)$/); if (atMatch) { return { owner: atMatch[1]!, repo: atMatch[2]!, skillSlug: atMatch[3]! }; } // owner/repo/skill-name (exactly 3 segments) — restrict slug to safe characters const slashMatch = source.match(/^([^/]+)\/([^/]+)\/([a-z0-9][a-z0-9._-]*)$/); if (slashMatch) { return { owner: slashMatch[1]!, repo: slashMatch[2]!, skillSlug: slashMatch[3]!, }; } throw new Error( `Invalid skill source "${source}". Expected one of:\n` + ` owner/repo@skill-name\n` + ` owner/repo/skill-name\n` + ` https://github.com/owner/repo/tree//skills/skill-name`, ); } // ─── GitHub fetch ─────────────────────────────────────────────────────────── export interface GitHubContentsEntry { name: string; type: "file" | "dir"; download_url: string | null; } /** Build common headers for GitHub API requests (User-Agent + optional auth). */ export function githubHeaders(): Record { const headers: Record = { Accept: "application/vnd.github.v3+json", "User-Agent": "vellum-assistant", }; const token = process.env.GITHUB_TOKEN; if (token) { headers["Authorization"] = `token ${token}`; } return headers; } export interface GitHubTreeEntry { path: string; type: "blob" | "tree"; } /** * Search the repo tree for a directory containing `/SKILL.md`. * Returns the directory path (e.g. "examples/skills-tool/skills/csv") or null. */ export async function findSkillDirInTree( owner: string, repo: string, skillSlug: string, ref: string, headers: Record, ): Promise { const treeUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`; const response = await fetch(treeUrl, { headers, signal: AbortSignal.timeout(15_000), }); if (response.status === 404) { return null; } if (!response.ok) { throw new Error( `GitHub API error while searching repo tree: HTTP ${response.status} ${response.statusText}`, ); } const data = (await response.json()) as { tree: GitHubTreeEntry[] }; const suffix = `${skillSlug}/SKILL.md`; const match = data.tree.find( (entry) => entry.type === "blob" && (entry.path === suffix || entry.path.endsWith(`/${suffix}`)), ); if (!match) { return null; } // Return the directory containing SKILL.md (strip the trailing /SKILL.md) return match.path.slice(0, -"/SKILL.md".length); } /** * Fetch SKILL.md and supporting files from a GitHub-hosted skills directory. * * First tries the conventional `skills//` path. If that returns a 404, * falls back to searching the full repo tree for `/SKILL.md` at any * depth (handles repos like `vercel-labs/bash-tool` where skills live at * non-standard paths like `examples/skills-tool/skills/csv/`). * * Uses the GitHub Contents API for directory listing and file downloads. * Recursively fetches subdirectories (e.g. scripts/, references/). */ export async function fetchSkillFromGitHub( owner: string, repo: string, skillSlug: string, ref?: string, ): Promise { const headers = githubHeaders(); async function fetchDir( subpath: string, prefix: string, ): Promise { let apiUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${subpath}`; if (ref) { apiUrl += `?ref=${encodeURIComponent(ref)}`; } const response = await fetch(apiUrl, { headers, signal: AbortSignal.timeout(15_000), }); if (!response.ok) { throw new Error( `GitHub API error: HTTP ${response.status} ${response.statusText}`, ); } const entries = (await response.json()) as GitHubContentsEntry[]; if (!Array.isArray(entries)) { throw new Error( `Expected a directory listing for ${subpath}/ but got a single file`, ); } const files: SkillFiles = {}; for (const entry of entries) { const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.type === "dir") { // Recursively fetch subdirectory contents const subFiles = await fetchDir( `${subpath}/${entry.name}`, relativePath, ); Object.assign(files, subFiles); continue; } if (entry.type !== "file" || !entry.download_url) { continue; } const fileResponse = await fetch(entry.download_url, { headers, signal: AbortSignal.timeout(10_000), }); if (!fileResponse.ok) { throw new Error( `Failed to download ${relativePath}: HTTP ${fileResponse.status}`, ); } files[relativePath] = await fileResponse.text(); } return files; } // Try the conventional skills// path first const conventionalPath = `skills/${encodeURIComponent(skillSlug)}`; let skillDirPath = conventionalPath; const probeUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${conventionalPath}${ref ? `?ref=${encodeURIComponent(ref)}` : ""}`; const probeResponse = await fetch(probeUrl, { headers, signal: AbortSignal.timeout(15_000), }); if (probeResponse.status === 404) { // Fall back to searching the repo tree for /SKILL.md at any path const treeRef = ref ?? "HEAD"; const foundPath = await findSkillDirInTree( owner, repo, skillSlug, treeRef, headers, ); if (!foundPath) { throw new Error( `Skill "${skillSlug}" not found in ${owner}/${repo}. ` + `Searched skills/${skillSlug}/ and the full repo tree.`, ); } skillDirPath = foundPath; } else if (!probeResponse.ok) { throw new Error( `GitHub API error: HTTP ${probeResponse.status} ${probeResponse.statusText}`, ); } // If we already have the probe response for the conventional path and it was // successful, we can use it directly instead of re-fetching. let files: SkillFiles; if (skillDirPath === conventionalPath && probeResponse.ok) { const entries = (await probeResponse.json()) as GitHubContentsEntry[]; if (!Array.isArray(entries)) { throw new Error( `Expected a directory listing for ${conventionalPath}/ but got a single file`, ); } // Fetch the directory contents from the already-parsed probe response const result: SkillFiles = {}; for (const entry of entries) { if (entry.type === "dir") { const subFiles = await fetchDir( `${conventionalPath}/${entry.name}`, entry.name, ); Object.assign(result, subFiles); continue; } if (entry.type !== "file" || !entry.download_url) { continue; } const fileResponse = await fetch(entry.download_url, { headers, signal: AbortSignal.timeout(10_000), }); if (!fileResponse.ok) { throw new Error( `Failed to download ${entry.name}: HTTP ${fileResponse.status}`, ); } result[entry.name] = await fileResponse.text(); } files = result; } else { files = await fetchDir(skillDirPath, ""); } if (!files["SKILL.md"]) { throw new Error(`SKILL.md not found in ${owner}/${repo}/${skillDirPath}/`); } return files; } // ─── External skill installation ──────────────────────────────────────────── // ─── Slug validation ──────────────────────────────────────────────────────── const VALID_SKILL_SLUG = /^[a-z0-9][a-z0-9._-]*$/; /** * Validate that a skill slug is safe for use in filesystem paths. * Follows the same pattern as `validateManagedSkillId` in managed-store.ts. */ export function validateSkillSlug(slug: string): void { if (!slug || typeof slug !== "string") { throw new Error("Skill slug is required"); } if (slug.includes("..") || slug.includes("/") || slug.includes("\\")) { throw new Error( `Invalid skill slug "${slug}": must not contain path traversal characters`, ); } if (!VALID_SKILL_SLUG.test(slug)) { throw new Error( `Invalid skill slug "${slug}": must start with a lowercase letter or digit and contain only lowercase letters, digits, dots, hyphens, and underscores`, ); } } /** * Install a community skill from a GitHub-hosted skills.sh registry repo. * * 1. Validates the skill slug for path safety * 2. Fetches all files from `skills//` in the source repo * 3. Writes them to a non-discovered staging dir with path traversal protection * 4. Writes `install-meta.json` with origin metadata * 5. Installs npm dependencies (if package.json exists) * 6. Atomically swaps the staged skill into `/skills//` * * Auto-enable and memory seeding are handled by the caller (e.g. * `postInstallSkill()` in the daemon, or left to the user for CLI installs). */ export async function installExternalSkill( owner: string, repo: string, skillSlug: string, overwrite: boolean, ref?: string, contactId?: string, ): Promise { // Validate slug before using in filesystem paths validateSkillSlug(skillSlug); const skillDir = join(getWorkspaceSkillsDir(), skillSlug); const skillFilePath = join(skillDir, "SKILL.md"); if (existsSync(skillFilePath) && !overwrite) { throw new Error( `Skill "${skillSlug}" is already installed. Use --overwrite to replace it.`, ); } const files = await fetchSkillFromGitHub(owner, repo, skillSlug, ref); const stagedDir = createSkillInstallStagingDir(); try { writeSkillFilesToDir(files, stagedDir); writeInstallMeta(stagedDir, { origin: "skillssh", slug: skillSlug, sourceRepo: `${owner}/${repo}`, installedAt: new Date().toISOString(), ...(contactId ? { installedBy: contactId } : {}), author: "user", contentHash: computeSkillHash(stagedDir) ?? undefined, }); await installSkillDependenciesIfPresent(stagedDir); commitStagedSkillInstall(skillSlug, stagedDir); } catch (err) { rmSync(stagedDir, { recursive: true, force: true }); throw err; } }