diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts index 5d6ed598..12834083 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -1,10 +1,10 @@ -export type CompanySkillSourceType = "local_path" | "github" | "url" | "catalog"; +export type CompanySkillSourceType = "local_path" | "github" | "url" | "catalog" | "skills_sh"; export type CompanySkillTrustLevel = "markdown_only" | "assets" | "scripts_executables"; export type CompanySkillCompatibility = "compatible" | "unknown" | "invalid"; -export type CompanySkillSourceBadge = "paperclip" | "github" | "local" | "url" | "catalog"; +export type CompanySkillSourceBadge = "paperclip" | "github" | "local" | "url" | "catalog" | "skills_sh"; export interface CompanySkillFileInventoryEntry { path: string; diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index 15bd4e2a..7f1df34b 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -export const companySkillSourceTypeSchema = z.enum(["local_path", "github", "url", "catalog"]); +export const companySkillSourceTypeSchema = z.enum(["local_path", "github", "url", "catalog", "skills_sh"]); export const companySkillTrustLevelSchema = z.enum(["markdown_only", "assets", "scripts_executables"]); export const companySkillCompatibilitySchema = z.enum(["compatible", "unknown", "invalid"]); -export const companySkillSourceBadgeSchema = z.enum(["paperclip", "github", "local", "url", "catalog"]); +export const companySkillSourceBadgeSchema = z.enum(["paperclip", "github", "local", "url", "catalog", "skills_sh"]); export const companySkillFileInventoryEntrySchema = z.object({ path: z.string().min(1), diff --git a/server/src/__tests__/company-skills.test.ts b/server/src/__tests__/company-skills.test.ts index 73afd751..77fd072e 100644 --- a/server/src/__tests__/company-skills.test.ts +++ b/server/src/__tests__/company-skills.test.ts @@ -35,32 +35,36 @@ describe("company skill import source parsing", () => { expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills"); expect(parsed.requestedSkillSlug).toBe("find-skills"); + expect(parsed.originalSkillsShUrl).toBeNull(); expect(parsed.warnings).toEqual([]); }); - it("parses owner/repo/skill shorthand as a GitHub repo plus requested skill", () => { + it("parses owner/repo/skill shorthand as skills.sh-managed", () => { const parsed = parseSkillImportSourceInput("vercel-labs/skills/find-skills"); expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills"); expect(parsed.requestedSkillSlug).toBe("find-skills"); + expect(parsed.originalSkillsShUrl).toBe("https://skills.sh/vercel-labs/skills/find-skills"); }); - it("resolves skills.sh URL with org/repo/skill to GitHub repo", () => { + it("resolves skills.sh URL with org/repo/skill to GitHub repo and preserves original URL", () => { const parsed = parseSkillImportSourceInput( "https://skills.sh/google-labs-code/stitch-skills/design-md", ); expect(parsed.resolvedSource).toBe("https://github.com/google-labs-code/stitch-skills"); expect(parsed.requestedSkillSlug).toBe("design-md"); + expect(parsed.originalSkillsShUrl).toBe("https://skills.sh/google-labs-code/stitch-skills/design-md"); }); - it("resolves skills.sh URL with org/repo (no skill) to GitHub repo", () => { + it("resolves skills.sh URL with org/repo (no skill) to GitHub repo and preserves original URL", () => { const parsed = parseSkillImportSourceInput( "https://skills.sh/vercel-labs/skills", ); expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills"); expect(parsed.requestedSkillSlug).toBeNull(); + expect(parsed.originalSkillsShUrl).toBe("https://skills.sh/vercel-labs/skills"); }); it("parses skills.sh commands whose requested skill differs from the folder name", () => { @@ -70,6 +74,14 @@ describe("company skill import source parsing", () => { expect(parsed.resolvedSource).toBe("https://github.com/remotion-dev/skills"); expect(parsed.requestedSkillSlug).toBe("remotion-best-practices"); + expect(parsed.originalSkillsShUrl).toBeNull(); + }); + + it("does not set originalSkillsShUrl for owner/repo shorthand", () => { + const parsed = parseSkillImportSourceInput("vercel-labs/skills"); + + expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills"); + expect(parsed.originalSkillsShUrl).toBeNull(); }); }); diff --git a/server/src/services/company-export-readme.ts b/server/src/services/company-export-readme.ts index 83c18fdb..56725024 100644 --- a/server/src/services/company-export-readme.ts +++ b/server/src/services/company-export-readme.ts @@ -59,7 +59,7 @@ function mermaidEscape(s: string): string { function skillSourceLabel(skill: CompanyPortabilityManifest["skills"][number]): string { if (skill.sourceLocator) { // For GitHub or URL sources, render as a markdown link - if (skill.sourceType === "github" || skill.sourceType === "url") { + if (skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url") { return `[${skill.sourceType}](${skill.sourceLocator})`; } return skill.sourceLocator; diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 828f68f2..5a91efeb 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -119,7 +119,7 @@ function deriveManifestSkillKey( const sourceKind = asString(metadata?.sourceKind); const owner = normalizeSkillSlug(asString(metadata?.owner)); const repo = normalizeSkillSlug(asString(metadata?.repo)); - if ((sourceType === "github" || sourceKind === "github") && owner && repo) { + if ((sourceType === "github" || sourceType === "skills_sh" || sourceKind === "github" || sourceKind === "skills_sh") && owner && repo) { return `${owner}/${repo}/${slug}`; } if (sourceKind === "paperclip_bundled") { @@ -246,10 +246,10 @@ function deriveSkillExportDirCandidates( pushSuffix("paperclip"); } - if (skill.sourceType === "github") { + if (skill.sourceType === "github" || skill.sourceType === "skills_sh") { pushSuffix(asString(metadata?.repo)); pushSuffix(asString(metadata?.owner)); - pushSuffix("github"); + pushSuffix(skill.sourceType === "skills_sh" ? "skills_sh" : "github"); } else if (skill.sourceType === "url") { try { pushSuffix(skill.sourceLocator ? new URL(skill.sourceLocator).host : null); @@ -1178,7 +1178,7 @@ async function buildSkillSourceEntry(skill: CompanySkill) { }; } - if (skill.sourceType === "github") { + if (skill.sourceType === "github" || skill.sourceType === "skills_sh") { const owner = asString(metadata?.owner); const repo = asString(metadata?.repo); const repoSkillDir = asString(metadata?.repoSkillDir); @@ -1207,7 +1207,7 @@ function shouldReferenceSkillOnExport(skill: CompanySkill, expandReferencedSkill if (expandReferencedSkills) return false; const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null; if (asString(metadata?.sourceKind) === "paperclip_bundled") return true; - return skill.sourceType === "github" || skill.sourceType === "url"; + return skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url"; } async function buildReferencedSkillMarkdown(skill: CompanySkill) { diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 97d8bbf0..c927e3b7 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -66,6 +66,7 @@ export type ImportPackageSkillResult = { type ParsedSkillImportSource = { resolvedSource: string; requestedSkillSlug: string | null; + originalSkillsShUrl: string | null; warnings: string[]; }; @@ -251,7 +252,7 @@ function deriveCanonicalSkillKey( const owner = normalizeSkillSlug(asString(metadata?.owner)); const repo = normalizeSkillSlug(asString(metadata?.repo)); - if ((input.sourceType === "github" || sourceKind === "github") && owner && repo) { + if ((input.sourceType === "github" || input.sourceType === "skills_sh" || sourceKind === "github" || sourceKind === "skills_sh") && owner && repo) { return `${owner}/${repo}/${slug}`; } @@ -561,11 +562,13 @@ export function parseSkillImportSourceInput(rawInput: string): ParsedSkillImport throw unprocessable("Skill source is required."); } + // Key-style imports (org/repo/skill) originate from the skills.sh registry 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), + originalSkillsShUrl: `https://skills.sh/${owner}/${repo}/${skillSlugRaw}`, warnings, }; } @@ -574,6 +577,7 @@ export function parseSkillImportSourceInput(rawInput: string): ParsedSkillImport return { resolvedSource: `https://github.com/${normalizedSource}`, requestedSkillSlug, + originalSkillsShUrl: null, warnings, }; } @@ -585,6 +589,7 @@ export function parseSkillImportSourceInput(rawInput: string): ParsedSkillImport return { resolvedSource: `https://github.com/${owner}/${repo}`, requestedSkillSlug: skillSlugRaw ? normalizeSkillSlug(skillSlugRaw) : requestedSkillSlug, + originalSkillsShUrl: normalizedSource, warnings, }; } @@ -592,6 +597,7 @@ export function parseSkillImportSourceInput(rawInput: string): ParsedSkillImport return { resolvedSource: normalizedSource, requestedSkillSlug, + originalSkillsShUrl: null, warnings, }; } @@ -1292,6 +1298,18 @@ function deriveSkillSourceInfo(skill: CompanySkill): { }; } + if (skill.sourceType === "skills_sh") { + const owner = asString(metadata.owner) ?? null; + const repo = asString(metadata.repo) ?? null; + return { + editable: false, + editableReason: "Skills.sh-managed skills are read-only.", + sourceLabel: skill.sourceLocator ?? (owner && repo ? `${owner}/${repo}` : null), + sourceBadge: "skills_sh", + sourcePath: null, + }; + } + if (skill.sourceType === "github") { const owner = asString(metadata.owner) ?? null; const repo = asString(metadata.repo) ?? null; @@ -1543,7 +1561,7 @@ export function companySkillService(db: Db) { const skill = await getById(skillId); if (!skill || skill.companyId !== companyId) return null; - if (skill.sourceType !== "github") { + if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") { return { supported: false, reason: "Only GitHub-managed skills support update checks.", @@ -1603,7 +1621,7 @@ export function companySkillService(db: Db) { } else { throw notFound("Skill file not found"); } - } else if (skill.sourceType === "github") { + } else if (skill.sourceType === "github" || skill.sourceType === "skills_sh") { const metadata = getSkillMeta(skill); const owner = asString(metadata.owner); const repo = asString(metadata.repo); @@ -2202,6 +2220,17 @@ export function companySkillService(db: Db) { : "No skills were found in the provided source.", ); } + // Override sourceType/sourceLocator for skills imported via skills.sh + if (parsed.originalSkillsShUrl) { + for (const skill of filteredSkills) { + skill.sourceType = "skills_sh"; + skill.sourceLocator = parsed.originalSkillsShUrl; + if (skill.metadata) { + (skill.metadata as Record).sourceKind = "skills_sh"; + } + skill.key = deriveCanonicalSkillKey(companyId, skill); + } + } const imported = await upsertImportedSkills(companyId, filteredSkills); return { imported, warnings }; } diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index afeeb01f..11854cfe 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -148,6 +148,8 @@ function sourceMeta(sourceBadge: CompanySkillSourceBadge, sourceLabel: string | normalizedLabel.includes("skills.sh") || normalizedLabel.includes("vercel-labs/skills"); switch (sourceBadge) { + case "skills_sh": + return { icon: VercelMark, label: sourceLabel ?? "skills.sh", managedLabel: "skills.sh managed" }; case "github": return isSkillsShManaged ? { icon: VercelMark, label: sourceLabel ?? "skills.sh", managedLabel: "skills.sh managed" }