Set sourceType to skills_sh for skills imported from skills.sh URLs

When skills are imported via skills.sh URLs or key-style imports
(org/repo/skill), the stored sourceType is now "skills_sh" with the
original skills.sh URL as sourceLocator, instead of "github" with the
resolved GitHub URL.

- Add "skills_sh" to CompanySkillSourceType and CompanySkillSourceBadge
- Track originalSkillsShUrl in parseSkillImportSourceInput
- Override sourceType/sourceLocator in importFromSource for skills.sh
- Handle skills_sh in key derivation, source info, update checks,
  file reads, portability export, and UI badge rendering

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-19 14:15:35 -05:00
parent ce69ebd2ec
commit ca3fdb3957
7 changed files with 59 additions and 16 deletions

View File

@@ -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;

View File

@@ -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),

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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<string, unknown>).sourceKind = "skills_sh";
}
skill.key = deriveCanonicalSkillKey(companyId, skill);
}
}
const imported = await upsertImportedSkills(companyId, filteredSkills);
return { imported, warnings };
}

View File

@@ -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" }