diff --git a/server/src/__tests__/company-skills.test.ts b/server/src/__tests__/company-skills.test.ts new file mode 100644 index 00000000..eb744b9b --- /dev/null +++ b/server/src/__tests__/company-skills.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { parseSkillImportSourceInput } from "../services/company-skills.js"; + +describe("company skill import source parsing", () => { + it("parses a skills.sh command without executing shell input", () => { + const parsed = parseSkillImportSourceInput( + "npx skills add https://github.com/vercel-labs/skills --skill find-skills", + ); + + expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills"); + expect(parsed.requestedSkillSlug).toBe("find-skills"); + expect(parsed.warnings[0]).toContain("skills.sh command"); + }); + + it("parses owner/repo/skill shorthand as a GitHub repo plus requested skill", () => { + const parsed = parseSkillImportSourceInput("vercel-labs/skills/find-skills"); + + expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills"); + expect(parsed.requestedSkillSlug).toBe("find-skills"); + }); +}); diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index cdd7a0e0..d67ec738 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -1,5 +1,6 @@ import { promises as fs } from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { and, asc, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { companySkills } from "@paperclipai/db"; @@ -37,6 +38,12 @@ type ImportedSkill = { metadata: Record | null; }; +type ParsedSkillImportSource = { + resolvedSource: string; + requestedSkillSlug: string | null; + warnings: string[]; +}; + function asString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -51,6 +58,10 @@ function normalizePortablePath(input: string) { return input.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, ""); } +function normalizeSkillSlug(value: string | null | undefined) { + return value ? normalizeAgentUrlKey(value) ?? null : null; +} + function classifyInventoryKind(relativePath: string): CompanySkillFileInventoryEntry["kind"] { const normalized = normalizePortablePath(relativePath).toLowerCase(); if (normalized.endsWith("/skill.md") || normalized === "skill.md") return "skill"; @@ -251,6 +262,90 @@ function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath.replace(/^\/+/, "")}`; } +function extractCommandTokens(raw: string) { + const matches = raw.match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + return matches.map((token) => token.replace(/^['"]|['"]$/g, "")); +} + +export function parseSkillImportSourceInput(rawInput: string): ParsedSkillImportSource { + const trimmed = rawInput.trim(); + if (!trimmed) { + throw unprocessable("Skill source is required."); + } + + const warnings: string[] = []; + let source = trimmed; + let requestedSkillSlug: string | null = null; + + if (/^npx\s+skills\s+add\s+/i.test(trimmed)) { + const tokens = extractCommandTokens(trimmed); + const addIndex = tokens.findIndex( + (token, index) => + token === "add" + && index > 0 + && tokens[index - 1]?.toLowerCase() === "skills", + ); + if (addIndex >= 0) { + source = tokens[addIndex + 1] ?? ""; + for (let index = addIndex + 2; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (token === "--skill") { + requestedSkillSlug = normalizeSkillSlug(tokens[index + 1] ?? null); + index += 1; + continue; + } + if (token.startsWith("--skill=")) { + requestedSkillSlug = normalizeSkillSlug(token.slice("--skill=".length)); + } + } + warnings.push("Parsed a skills.sh command. Paperclip imports the referenced skill package without executing shell input."); + } + } + + const normalizedSource = source.trim(); + if (!normalizedSource) { + throw unprocessable("Skill source is required."); + } + + if (!/^https?:\/\//i.test(normalizedSource) && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalizedSource)) { + const [owner, repo, skillSlugRaw] = normalizedSource.split("/"); + return { + resolvedSource: `https://github.com/${owner}/${repo}`, + requestedSkillSlug: normalizeSkillSlug(skillSlugRaw), + warnings, + }; + } + + if (!/^https?:\/\//i.test(normalizedSource) && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalizedSource)) { + return { + resolvedSource: `https://github.com/${normalizedSource}`, + requestedSkillSlug, + warnings, + }; + } + + return { + resolvedSource: normalizedSource, + requestedSkillSlug, + warnings, + }; +} + +function resolveBundledSkillsRoot() { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + return [ + path.resolve(moduleDir, "../../skills"), + path.resolve(process.cwd(), "skills"), + path.resolve(moduleDir, "../../../skills"), + ]; +} + +function matchesRequestedSkill(relativeSkillPath: string, requestedSkillSlug: string | null) { + if (!requestedSkillSlug) return true; + const skillDir = path.posix.dirname(relativeSkillPath); + return normalizeSkillSlug(path.posix.basename(skillDir)) === requestedSkillSlug; +} + async function walkLocalFiles(root: string, current: string, out: string[]) { const entries = await fs.readdir(current, { withFileTypes: true }); for (const entry of entries) { @@ -336,7 +431,10 @@ async function readLocalSkillImports(sourcePath: string): Promise { +async function readUrlSkillImports( + sourceUrl: string, + requestedSkillSlug: string | null = null, +): Promise<{ skills: ImportedSkill[]; warnings: string[] }> { const url = sourceUrl.trim(); const warnings: string[] = []; if (url.includes("github.com/")) { @@ -369,9 +467,15 @@ async function readUrlSkillImports(sourceUrl: string): Promise<{ skills: Importe const filteredPaths = parsed.filePath ? relativePaths.filter((entry) => entry === path.posix.relative(parsed.basePath || ".", parsed.filePath!)) : relativePaths; - const skillPaths = filteredPaths.filter((entry) => path.posix.basename(entry).toLowerCase() === "skill.md"); + const skillPaths = filteredPaths.filter( + (entry) => path.posix.basename(entry).toLowerCase() === "skill.md" && matchesRequestedSkill(entry, requestedSkillSlug), + ); if (skillPaths.length === 0) { - throw unprocessable("No SKILL.md files were found in the provided GitHub source."); + throw unprocessable( + requestedSkillSlug + ? `Skill ${requestedSkillSlug} was not found in the provided GitHub source.` + : "No SKILL.md files were found in the provided GitHub source.", + ); } const skills: ImportedSkill[] = []; for (const relativeSkillPath of skillPaths) { @@ -467,7 +571,19 @@ export function companySkillService(db: Db) { const agents = agentService(db); const secretsSvc = secretService(db); + async function ensureBundledSkills(companyId: string) { + for (const skillsRoot of resolveBundledSkillsRoot()) { + const stats = await fs.stat(skillsRoot).catch(() => null); + if (!stats?.isDirectory()) continue; + const bundledSkills = await readLocalSkillImports(skillsRoot).catch(() => [] as ImportedSkill[]); + if (bundledSkills.length === 0) continue; + return upsertImportedSkills(companyId, bundledSkills); + } + return []; + } + async function list(companyId: string): Promise { + await ensureBundledSkills(companyId); const rows = await db .select() .from(companySkills) @@ -551,6 +667,7 @@ export function companySkillService(db: Db) { } async function detail(companyId: string, id: string): Promise { + await ensureBundledSkills(companyId); const skill = await getById(id); if (!skill || skill.companyId !== companyId) return null; const usedByAgents = await usage(companyId, skill.slug); @@ -599,15 +716,31 @@ export function companySkillService(db: Db) { } async function importFromSource(companyId: string, source: string): Promise { - const trimmed = source.trim(); - if (!trimmed) { - throw unprocessable("Skill source is required."); - } - const local = !/^https?:\/\//i.test(trimmed); + await ensureBundledSkills(companyId); + const parsed = parseSkillImportSourceInput(source); + const local = !/^https?:\/\//i.test(parsed.resolvedSource); const { skills, warnings } = local - ? { skills: await readLocalSkillImports(trimmed), warnings: [] as string[] } - : await readUrlSkillImports(trimmed); - const imported = await upsertImportedSkills(companyId, skills); + ? { + skills: (await readLocalSkillImports(parsed.resolvedSource)) + .filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug), + warnings: parsed.warnings, + } + : await readUrlSkillImports(parsed.resolvedSource, parsed.requestedSkillSlug) + .then((result) => ({ + skills: result.skills, + warnings: [...parsed.warnings, ...result.warnings], + })); + const filteredSkills = parsed.requestedSkillSlug + ? skills.filter((skill) => skill.slug === parsed.requestedSkillSlug) + : skills; + if (filteredSkills.length === 0) { + throw unprocessable( + parsed.requestedSkillSlug + ? `Skill ${parsed.requestedSkillSlug} was not found in the provided source.` + : "No skills were found in the provided source.", + ); + } + const imported = await upsertImportedSkills(companyId, filteredSkills); return { imported, warnings }; } diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 7c201467..593b7656 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1215,7 +1215,7 @@ function AgentSkillsTab({ return (
-
+
diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 1294d08d..24030c4c 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -153,7 +153,7 @@ function SkillDetailPanel({ return (
-
+
@@ -180,7 +180,7 @@ function SkillDetailPanel({

SKILL.md

- {detail.sourceLocator && ( + {detail.sourceLocator?.startsWith("http") ? ( - )} + ) : detail.sourceLocator ? ( + + {detail.sourceLocator} + + ) : null}
{markdownBody} @@ -318,7 +322,7 @@ export function CompanySkills() { return (
-
+
@@ -362,7 +366,7 @@ export function CompanySkills() { setSource(event.target.value)} - placeholder="Local path, GitHub repo/tree/blob URL, or direct SKILL.md URL" + placeholder="Path, GitHub URL, npx skills add ..., or owner/repo/skill" className="h-10" />