feat: ingest and render skill presentation metadata (#3261)

* feat: ingest skill presentation metadata

* feat: render skill icons and clean titles

* feat: add skill presentation backfill

* fix: preserve hosted icons during backfill

* docs: clarify backfill icon ownership

* fix: track skill presentation provenance
This commit is contained in:
Patrick Erichsen
2026-07-24 18:36:28 -05:00
committed by GitHub
parent bb6ed6eae6
commit fd610627e0
47 changed files with 2528 additions and 99 deletions
+4 -1
View File
@@ -1,3 +1,6 @@
{
"functions": "convex"
"functions": "convex",
"node": {
"externalPackages": ["sharp"]
}
}
+12
View File
@@ -124,6 +124,8 @@ import type * as lib_skillCards from "../lib/skillCards.js";
import type * as lib_skillDownloadBackfill from "../lib/skillDownloadBackfill.js";
import type * as lib_skillFileAccess from "../lib/skillFileAccess.js";
import type * as lib_skillInstallBackfill from "../lib/skillInstallBackfill.js";
import type * as lib_skillPresentation from "../lib/skillPresentation.js";
import type * as lib_skillPresentationBackfill from "../lib/skillPresentationBackfill.js";
import type * as lib_skillPublish from "../lib/skillPublish.js";
import type * as lib_skillQuality from "../lib/skillQuality.js";
import type * as lib_skillSafety from "../lib/skillSafety.js";
@@ -171,6 +173,10 @@ import type * as securityDatasetNode from "../securityDatasetNode.js";
import type * as securityScan from "../securityScan.js";
import type * as securityScanDispatch from "../securityScanDispatch.js";
import type * as skillCards from "../skillCards.js";
import type * as skillPresentationAssets from "../skillPresentationAssets.js";
import type * as skillPresentationAssetsHttp from "../skillPresentationAssetsHttp.js";
import type * as skillPresentationBackfill from "../skillPresentationBackfill.js";
import type * as skillPresentationImageNode from "../skillPresentationImageNode.js";
import type * as skillStatEvents from "../skillStatEvents.js";
import type * as skillTransfers from "../skillTransfers.js";
import type * as skills from "../skills.js";
@@ -308,6 +314,8 @@ declare const fullApi: ApiFromModules<{
"lib/skillDownloadBackfill": typeof lib_skillDownloadBackfill;
"lib/skillFileAccess": typeof lib_skillFileAccess;
"lib/skillInstallBackfill": typeof lib_skillInstallBackfill;
"lib/skillPresentation": typeof lib_skillPresentation;
"lib/skillPresentationBackfill": typeof lib_skillPresentationBackfill;
"lib/skillPublish": typeof lib_skillPublish;
"lib/skillQuality": typeof lib_skillQuality;
"lib/skillSafety": typeof lib_skillSafety;
@@ -355,6 +363,10 @@ declare const fullApi: ApiFromModules<{
securityScan: typeof securityScan;
securityScanDispatch: typeof securityScanDispatch;
skillCards: typeof skillCards;
skillPresentationAssets: typeof skillPresentationAssets;
skillPresentationAssetsHttp: typeof skillPresentationAssetsHttp;
skillPresentationBackfill: typeof skillPresentationBackfill;
skillPresentationImageNode: typeof skillPresentationImageNode;
skillStatEvents: typeof skillStatEvents;
skillTransfers: typeof skillTransfers;
skills: typeof skills;
+14 -5
View File
@@ -307,10 +307,19 @@ describe("catalog feed projection", () => {
it("projects only published skills from verified organization publishers", async () => {
const result = (await listOfficialSkillEntriesHandler(
makeCtx([makeSkill({ summary: "Deploy AIQ services.", icon: "lucide:rocket" })], {
"publishers:1": { _id: "publishers:1", kind: "org", handle: "openclaw" },
"skillVersions:1": makeSkillVersion(),
}),
makeCtx(
[
makeSkill({
displayName: "🚀 Demo skill",
summary: "Deploy AIQ services.",
icon: `/api/v1/skill-icons/${"a".repeat(64)}`,
}),
],
{
"publishers:1": { _id: "publishers:1", kind: "org", handle: "openclaw" },
"skillVersions:1": makeSkillVersion(),
},
),
{ publisherId: "publishers:1", cursor: null },
)) as { entries: unknown[]; isDone: boolean };
@@ -321,7 +330,7 @@ describe("catalog feed projection", () => {
id: "@openclaw/demo",
title: "Demo skill",
description: "Deploy AIQ services.",
icon: "lucide:rocket",
icon: `https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`,
version: "1.2.3",
state: "available",
featured: false,
+13 -3
View File
@@ -28,6 +28,7 @@ import {
getSkillFileModerationInfoFromSkill,
isPublicSkillVersionAvailableForSkill,
} from "./lib/skillFileAccess";
import { isHostedSkillPresentationIconPath, stripPresentationEmoji } from "./lib/skillPresentation";
const CATALOG_FEED_DESCRIPTION = "Official OpenClaw plugins published on ClawHub.";
const CATALOG_FEED_PAGE_SIZE = 100;
@@ -119,7 +120,7 @@ async function buildEntry(
const packageName = pkg.name.trim();
const id = pkg.normalizedName.trim();
const title = pkg.displayName.trim() || packageName;
const title = stripPresentationEmoji(pkg.displayName.trim()) || packageName;
const description = pkg.summary?.trim();
const icon = pkg.icon?.trim();
const version = release.version.trim();
@@ -209,9 +210,9 @@ async function buildSkillEntry(
const publisherId = owner.handle?.trim();
const slug = skill.slug.trim();
const title = skill.displayName.trim() || slug;
const title = stripPresentationEmoji(skill.displayName.trim()) || slug;
const description = skill.summary?.trim();
const icon = skill.icon?.trim();
const icon = catalogFeedIconUrl(skill.icon);
const highlightedAt = skill.badges?.highlighted?.at;
const packageName = `@${publisherId}/${slug}`;
if (!publisherId || !slug || !title) return null;
@@ -312,6 +313,15 @@ async function buildSkillEntry(
};
}
function catalogFeedIconUrl(value: string | undefined) {
const icon = value?.trim();
if (!icon) return undefined;
if (isHostedSkillPresentationIconPath(icon)) {
return `https://clawhub.ai${icon}`;
}
return icon.startsWith("https://") ? icon : undefined;
}
export const listOfficialPublisherPage = internalQuery({
args: {
cursor: v.union(v.string(), v.null()),
+13 -2
View File
@@ -2937,6 +2937,9 @@ describe("verifyGitHubSkillHandler", () => {
const commit = "3".repeat(40);
const zip = zipSync({
"skills-main/skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
"skills-main/skills/aiq-deploy/agents/openai.yaml": new TextEncoder().encode(
"interface:\n display_name: AIQ Deploy Console\n short_description: OpenAI-specific summary.\n",
),
"skills-main/skills/aiq-deploy/scripts/deploy.sh": new TextEncoder().encode(
"#!/bin/sh\necho deploy\n",
),
@@ -3026,10 +3029,10 @@ describe("verifyGitHubSkillHandler", () => {
);
expect(result).toMatchObject({ ok: true, queued: true });
expect(store).toHaveBeenCalledTimes(2);
expect(store).toHaveBeenCalledTimes(3);
expect((store.mock.calls[0]?.[0] as Blob | undefined)?.type).toBe("application/octet-stream");
expect(runMutation).toHaveBeenCalledTimes(3);
expect(events).toEqual(["prepare", "store", "store", "append", "finalize"]);
expect(events).toEqual(["prepare", "store", "store", "store", "append", "finalize"]);
const [prepareMutation, prepareArgs] = runMutation.mock.calls[0] ?? [];
expect(getFunctionName(prepareMutation as Parameters<typeof getFunctionName>[0])).toBe(
"securityScan:prepareGitHubSkillScanRequestInternal",
@@ -3039,6 +3042,13 @@ describe("verifyGitHubSkillHandler", () => {
skillId: "skills:aiq-deploy",
contentHash,
commit,
parsed: {
frontmatter: {},
presentation: {
displayName: "AIQ Deploy Console",
summary: "OpenAI-specific summary.",
},
},
staticScan: expect.objectContaining({ status: "clean" }),
}),
);
@@ -3054,6 +3064,7 @@ describe("verifyGitHubSkillHandler", () => {
chunkIndex: 0,
files: expect.arrayContaining([
expect.objectContaining({ path: "SKILL.md" }),
expect.objectContaining({ path: "agents/openai.yaml" }),
expect.objectContaining({ path: "scripts/deploy.sh" }),
]),
}),
+76 -3
View File
@@ -35,6 +35,7 @@ import {
getRuntimeRolloutCapabilities,
isLegacyNvidiaSkillSource,
} from "./lib/rolloutCapabilities";
import { buildSkillPresentationIconPath } from "./lib/skillPresentation";
import { isMacJunkPath, parseFrontmatter } from "./lib/skills";
import {
getSkillBySlugForPublisher,
@@ -43,6 +44,10 @@ import {
import { chunkSkillScanRequestFiles } from "./lib/skillScanRequestFiles";
import { syncSkillSearchDigestForSkill } from "./lib/skillSearchDigest";
import { assertValidSkillSlug } from "./lib/skillSlugValidator";
import {
isDecodableSkillPresentationRaster,
storeSkillPresentationAsset,
} from "./skillPresentationAssets";
const DEFAULT_BRANCH = "main";
const GITHUB_SKILL_SCAN_ACTION_LEASE_MS = 15 * 60 * 1000;
@@ -197,6 +202,14 @@ const discoveredSkillMetadataValidator = v.object({
path: v.string(),
skillMarkdownPath: v.string(),
skillCardMarkdownPath: v.optional(v.string()),
iconAsset: v.optional(
v.object({
path: v.string(),
sha256: v.string(),
contentType: v.string(),
size: v.number(),
}),
),
contentHash: v.string(),
});
@@ -210,6 +223,14 @@ const discoveredSkillContentValidator = v.object({
skillMarkdown: v.string(),
skillCardMarkdownPath: v.optional(v.string()),
skillCardMarkdown: v.optional(v.string()),
iconAsset: v.optional(
v.object({
path: v.string(),
sha256: v.string(),
contentType: v.string(),
size: v.number(),
}),
),
contentHash: v.string(),
});
@@ -993,6 +1014,7 @@ async function applyGenericGitHubSkillSourceSyncHandler(
slug: discovered.slug,
displayName: discovered.displayName,
summary: discovered.summary,
icon: iconForDiscoveredGitHubSkill(discovered),
ownerUserId: args.ownerUserId,
ownerPublisherId: args.ownerPublisherId,
installKind: "github",
@@ -1079,6 +1101,7 @@ async function applyGenericGitHubSkillSourceSyncHandler(
const patch = {
displayName: discovered.displayName,
summary: discovered.summary,
icon: iconForDiscoveredGitHubSkill(discovered),
ownerUserId: args.ownerUserId,
ownerPublisherId: args.ownerPublisherId,
githubPath: discovered.path,
@@ -1117,6 +1140,7 @@ async function applyGenericGitHubSkillSourceSyncHandler(
const patch = {
displayName: discovered.displayName,
summary: discovered.summary,
icon: iconForDiscoveredGitHubSkill(discovered),
ownerUserId: args.ownerUserId,
ownerPublisherId: args.ownerPublisherId,
installKind: "github" as const,
@@ -1236,6 +1260,7 @@ async function upsertGitHubSkillCandidate(
githubContentHash: args.discovered.contentHash,
displayName: args.discovered.displayName,
summary: args.discovered.summary,
icon: iconForDiscoveredGitHubSkill(args.discovered),
upstreamVersion: args.discovered.upstreamVersion,
skillMarkdownPath: undefined,
skillMarkdown: undefined,
@@ -1611,6 +1636,7 @@ export async function applyGitHubSkillVerificationResultHandler(
const patch = {
displayName: candidate.displayName,
summary: candidate.summary,
icon: candidate.icon,
installKind: "github" as const,
githubSourceId: candidate.githubSourceId,
githubPath: candidate.githubPath,
@@ -1713,6 +1739,7 @@ export async function verifyGitHubSkillHandler(
}
const { snapshot, entries } = await fetchGitHubSkillSourceSnapshotWithEntries(
ctx,
{
repo: target.source.repo,
ref: target.skill.githubCurrentCommit,
@@ -1744,6 +1771,7 @@ export async function verifyGitHubSkillHandler(
files: listGitHubSkillFiles(entries, discovered.path),
fileContents: listGitHubSkillTextContents(entries, discovered.path),
});
const presentationIcon = iconForDiscoveredGitHubSkill(discovered);
const prepared = (await ctx.runMutation(
internal.securityScan.prepareGitHubSkillScanRequestInternal,
@@ -1752,7 +1780,14 @@ export async function verifyGitHubSkillHandler(
contentHash: args.contentHash,
commit: target.skill.githubCurrentCommit,
...(args.force ? { force: true } : {}),
parsed: { frontmatter: parseFrontmatter(discovered.skillMarkdown) },
parsed: {
frontmatter: parseFrontmatter(discovered.skillMarkdown),
presentation: {
displayName: discovered.displayName,
...(discovered.summary ? { summary: discovered.summary } : {}),
...(presentationIcon ? { icon: presentationIcon } : {}),
},
},
staticScan,
},
)) as GitHubSkillVerificationResult | undefined;
@@ -1806,6 +1841,7 @@ export async function configurePublicGitHubSkillSourceHandler(
},
)) as GitHubSkillSourceSetupContext;
const snapshot = await fetchGitHubSkillSourceSnapshot(
ctx,
{
repo: metadata.repo,
defaultBranch: metadata.defaultBranch,
@@ -1848,6 +1884,7 @@ async function applyFetchedGitHubSkillSourceSnapshot(
snapshot: GitHubSkillSourceSnapshot;
},
) {
await persistGitHubSkillPresentationAssets(ctx, args.snapshot);
const result = (await ctx.runMutation(
internal.githubSkillSync.applyGitHubSkillSourceSyncInternal,
{
@@ -1870,11 +1907,42 @@ function toGitHubSkillSourceMetadataSnapshot(
return {
...snapshot,
skills: snapshot.skills.map(
({ skillMarkdown: _skillMarkdown, skillCardMarkdown: _skillCardMarkdown, ...skill }) => skill,
({
skillMarkdown: _skillMarkdown,
skillCardMarkdown: _skillCardMarkdown,
iconBytes: _iconBytes,
...skill
}) => skill,
),
};
}
async function persistGitHubSkillPresentationAssets(
ctx: ActionCtx,
snapshot: GitHubSkillSourceSnapshot,
) {
for (const skill of snapshot.skills) {
if (!skill.iconAsset || !skill.iconBytes) continue;
await storeSkillPresentationAsset(ctx, {
bytes: skill.iconBytes,
sha256: skill.iconAsset.sha256,
contentType: skill.iconAsset.contentType as
| "image/png"
| "image/jpeg"
| "image/webp"
| "image/svg+xml",
});
}
}
function iconForDiscoveredGitHubSkill(
discovered: GitHubSkillSourceMetadataSnapshot["skills"][number],
) {
return discovered.iconAsset
? buildSkillPresentationIconPath(discovered.iconAsset.sha256)
: undefined;
}
async function persistGitHubSkillContentsForSnapshot(
ctx: ActionCtx,
result: SyncOneResult,
@@ -1929,7 +1997,7 @@ export const syncGitHubSkillSource: ReturnType<typeof action> = action({
{ publisherId: ownerPublisherId },
)) as Id<"users">;
const metadata = await fetchPublicGitHubRepoMetadata(repo, fetch);
const snapshot = await fetchGitHubSkillSourceSnapshot({
const snapshot = await fetchGitHubSkillSourceSnapshot(ctx, {
repo,
defaultBranch: args.defaultBranch ?? source?.defaultBranch ?? metadata.defaultBranch,
});
@@ -2012,6 +2080,7 @@ export async function syncGitHubSkillSourcesHandler(
throw new ConvexError("GitHub repository authorization no longer matches.");
}
const snapshot = await fetchGitHubSkillSourceSnapshot(
ctx,
{
repo: metadata.repo,
defaultBranch: source.defaultBranch ?? metadata.defaultBranch,
@@ -2098,6 +2167,7 @@ export async function syncGitHubSkillSourcesHandler(
}
async function fetchGitHubSkillSourceSnapshot(
ctx: Pick<ActionCtx, "runAction">,
{
repo,
defaultBranch,
@@ -2108,6 +2178,7 @@ async function fetchGitHubSkillSourceSnapshot(
fetcher: typeof fetch = fetch,
) {
const { snapshot } = await fetchGitHubSkillSourceSnapshotWithEntries(
ctx,
{
repo,
ref: defaultBranch,
@@ -2119,6 +2190,7 @@ async function fetchGitHubSkillSourceSnapshot(
}
async function fetchGitHubSkillSourceSnapshotWithEntries(
ctx: Pick<ActionCtx, "runAction">,
{
repo,
ref,
@@ -2141,6 +2213,7 @@ async function fetchGitHubSkillSourceSnapshotWithEntries(
defaultBranch,
commit: resolved.commit,
entries,
validateRasterIcon: (args) => isDecodableSkillPresentationRaster(ctx, args),
});
return { snapshot, entries };
}
+7
View File
@@ -70,11 +70,18 @@ import {
packageInspectorClaimHttp,
packageInspectorResultsHttp,
} from "./packageInspectorHttp";
import { skillPresentationAssetHttp } from "./skillPresentationAssetsHttp";
const http = installRateLimitedRoutes(httpRouter());
auth.addHttpRoutes(http);
http.route({
pathPrefix: "/api/v1/skill-icons/",
method: "GET",
handler: skillPresentationAssetHttp,
});
// Convex routes HEAD through the matching GET action and strips the body.
http.route({
pathPrefix: "/api/v1/agent-skills/",
+50 -4
View File
@@ -6,13 +6,24 @@ import {
} from "./githubSkillSync";
const encoder = new TextEncoder();
const validPng = Uint8Array.from(
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
);
function bytes(text: string) {
return encoder.encode(text);
}
function repoEntries(entries: Record<string, string>) {
return Object.fromEntries(Object.entries(entries).map(([path, text]) => [path, bytes(text)]));
function repoEntries(entries: Record<string, string | Uint8Array>) {
return Object.fromEntries(
Object.entries(entries).map(([path, value]) => [
path,
value instanceof Uint8Array ? value : bytes(value),
]),
);
}
describe("parseSkillsShDisplayManifest", () => {
@@ -69,11 +80,14 @@ describe("buildGitHubSkillSourceSnapshot", () => {
"skills/aiq-deploy/SKILL.md":
"---\nname: AIQ Deploy\nversion: 0.2.0\ndescription: Deploy AgentIQ workflows.\n---\n# AIQ Deploy\n",
"skills/aiq-deploy/skill-card.md": "# Card\n",
"skills/aiq-deploy/agents/openai.yaml":
"interface:\n display_name: '🚀 AIQ Deploy Console'\n short_description: Deploy from the OpenAI console.\n icon_small: ../assets/icon.png\n icon_large: assets/icon.png\n",
"skills/vision-helper/SKILL.md": "# Vision Helper\n",
"skills.sh.json": JSON.stringify({
groupings: [{ title: "Agentic AI", skills: ["aiq-deploy"] }],
}),
});
baseEntries["skills/aiq-deploy/assets/icon.png"] = validPng;
const changedEntries = {
...baseEntries,
"skills/aiq-deploy/skill-card.md": bytes("# Card changed\n"),
@@ -100,8 +114,15 @@ describe("buildGitHubSkillSourceSnapshot", () => {
expect.arrayContaining([
expect.objectContaining({
slug: "aiq-deploy",
displayName: "AIQ Deploy",
summary: "Deploy AgentIQ workflows.",
displayName: "AIQ Deploy Console",
summary: "Deploy from the OpenAI console.",
iconAsset: {
path: "assets/icon.png",
contentType: "image/png",
size: validPng.byteLength,
sha256: expect.stringMatching(/^[a-f\d]{64}$/),
},
iconBytes: validPng,
upstreamVersion: "0.2.0",
path: "skills/aiq-deploy",
skillMarkdownPath: "skills/aiq-deploy/SKILL.md",
@@ -150,6 +171,26 @@ describe("buildGitHubSkillSourceSnapshot", () => {
expect(changed.skills[0]?.contentHash).not.toBe(base.skills[0]?.contentHash);
});
it("falls back to icon_large when icon_small cannot be decoded", async () => {
const snapshot = await buildGitHubSkillSourceSnapshot({
repo: "NVIDIA/skills",
defaultBranch: "main",
commit: "1".repeat(40),
entries: repoEntries({
"skills/demo/SKILL.md": "# Demo\n",
"skills/demo/agents/openai.yaml":
"interface:\n icon_small: assets/icon.png\n icon_large: assets/icon.svg\n",
"skills/demo/assets/icon.png": validPng,
"skills/demo/assets/icon.svg": '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
}),
validateRasterIcon: async () => false,
});
expect(snapshot.skills[0]).toMatchObject({
iconAsset: { path: "assets/icon.svg", contentType: "image/svg+xml" },
});
});
it("rejects duplicate normalized skill slugs before syncing content", async () => {
await expect(
buildGitHubSkillSourceSnapshot({
@@ -218,6 +259,9 @@ describe("buildGitHubSkillSyncPlan", () => {
commit: "2".repeat(40),
entries: repoEntries({
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy v2\n",
"skills/aiq-deploy/agents/openai.yaml":
"interface:\n display_name: AIQ Deploy Console\n icon_small: assets/icon.png\n",
"skills/aiq-deploy/assets/icon.png": validPng,
"skills.sh.json": JSON.stringify({
groupings: [{ title: "Agentic AI", skills: ["aiq-deploy"] }],
}),
@@ -248,6 +292,8 @@ describe("buildGitHubSkillSyncPlan", () => {
skillId: "skills:aiq-deploy",
slug: "aiq-deploy",
patch: expect.objectContaining({
displayName: "AIQ Deploy Console",
icon: expect.stringMatching(/^\/api\/v1\/skill-icons\/[a-f\d]{64}$/),
githubCurrentCommit: "2".repeat(40),
githubCurrentContentHash: snapshot.skills[0]?.contentHash,
githubScanStatus: "pending",
+94 -3
View File
@@ -3,6 +3,13 @@ import {
shouldPreserveSecurityScanStateForUnchangedContent,
type SourceBackedSkillScanStatus,
} from "./securityScanPolicy";
import {
buildSkillPresentationIconPath,
OPENAI_SKILL_PRESENTATION_PATH,
parseOpenAiSkillPresentation,
resolveSkillPresentation,
validateSkillPresentationIcon,
} from "./skillPresentation";
import { getFrontmatterValue, parseFrontmatter } from "./skills";
export type GitHubSkillScanStatus = SourceBackedSkillScanStatus;
@@ -42,12 +49,19 @@ export type DiscoveredGitHubSkill = {
skillMarkdown: string;
skillCardMarkdownPath?: string;
skillCardMarkdown?: string;
iconAsset?: {
path: string;
sha256: string;
contentType: string;
size: number;
};
iconBytes?: Uint8Array;
contentHash: string;
};
export type DiscoveredGitHubSkillMetadata = Omit<
DiscoveredGitHubSkill,
"skillMarkdown" | "skillCardMarkdown"
"skillMarkdown" | "skillCardMarkdown" | "iconBytes"
>;
export type ExistingGitHubSkillForSync = {
@@ -55,6 +69,7 @@ export type ExistingGitHubSkillForSync = {
slug: string;
displayName: string;
summary?: string;
icon?: string;
latestVersionSummary?: {
version: string;
createdAt: number;
@@ -166,11 +181,16 @@ export async function buildGitHubSkillSourceSnapshot({
defaultBranch,
commit,
entries,
validateRasterIcon,
}: {
repo: string;
defaultBranch: string;
commit: string;
entries: Record<string, Uint8Array>;
validateRasterIcon?: (args: {
bytes: Uint8Array;
contentType: "image/png" | "image/jpeg" | "image/webp";
}) => Promise<boolean>;
}): Promise<GitHubSkillSourceSnapshot> {
const normalizedEntries = normalizeEntryMap(entries);
const manifestBytes = normalizedEntries["skills.sh.json"];
@@ -193,6 +213,57 @@ export async function buildGitHubSkillSourceSnapshot({
const frontmatterDescription = getFrontmatterValue(frontmatter, "description")?.trim();
const frontmatterVersion = getFrontmatterValue(frontmatter, "version")?.trim();
const heading = firstMarkdownHeading(markdown);
const skillDisplayName = frontmatterName || heading || titleizeSlug(slug);
const openAiPresentationPath = findSkillRelativeFilePath(
normalizedEntries,
path,
OPENAI_SKILL_PRESENTATION_PATH,
);
const openAiPresentationBytes = openAiPresentationPath
? normalizedEntries[openAiPresentationPath]
: undefined;
const openAiPresentation = openAiPresentationBytes
? parseOpenAiSkillPresentation(decodeUtf8(openAiPresentationBytes))
: null;
const presentation = resolveSkillPresentation({
openAi: openAiPresentation,
skillDisplayName,
skillDescription: frontmatterDescription,
slug,
});
let iconBytes: Uint8Array | undefined;
let iconAsset: DiscoveredGitHubSkill["iconAsset"];
for (const iconPath of presentation.iconPaths ?? []) {
const iconAssetPath = findSkillRelativeFilePath(normalizedEntries, path, iconPath);
const candidateBytes = iconAssetPath ? normalizedEntries[iconAssetPath] : undefined;
if (!candidateBytes) continue;
try {
const validated = validateSkillPresentationIcon({
path: iconPath,
bytes: candidateBytes,
});
if (
validated.contentType !== "image/svg+xml" &&
validateRasterIcon &&
!(await validateRasterIcon({
bytes: candidateBytes,
contentType: validated.contentType,
}))
) {
continue;
}
iconAsset = {
path: iconPath,
sha256: await sha256Hex(candidateBytes),
contentType: validated.contentType,
size: validated.size,
};
iconBytes = candidateBytes;
break;
} catch {
continue;
}
}
const skillCardMarkdownPath = findFolderFilePath(
normalizedEntries,
path,
@@ -211,14 +282,15 @@ export async function buildGitHubSkillSourceSnapshot({
skills.push({
slug,
displayName: frontmatterName || heading || titleizeSlug(slug),
...(frontmatterDescription ? { summary: frontmatterDescription } : {}),
displayName: presentation.displayName,
...(presentation.summary ? { summary: presentation.summary } : {}),
...(frontmatterVersion ? { upstreamVersion: frontmatterVersion } : {}),
path,
skillMarkdownPath: skillMdPath,
skillMarkdown: markdown,
...(skillCardMarkdownPath ? { skillCardMarkdownPath } : {}),
...(skillCardMarkdown !== undefined ? { skillCardMarkdown } : {}),
...(iconAsset && iconBytes ? { iconAsset, iconBytes: new Uint8Array(iconBytes) } : {}),
contentHash: await computeGitHubSkillFolderContentHash(normalizedEntries, path),
});
}
@@ -315,6 +387,9 @@ export function buildGitHubSkillSyncPlan({
slug: discovered.slug,
displayName: discovered.displayName,
summary: discovered.summary,
icon: discovered.iconAsset
? buildSkillPresentationIconPath(discovered.iconAsset.sha256)
: undefined,
ownerUserId,
ownerPublisherId,
installKind: "github",
@@ -369,11 +444,18 @@ export function buildGitHubSkillSyncPlan({
!currentContentUnchanged ||
existing.displayName !== discovered.displayName ||
(existing.summary ?? undefined) !== (discovered.summary ?? undefined) ||
(existing.icon ?? undefined) !==
(discovered.iconAsset
? buildSkillPresentationIconPath(discovered.iconAsset.sha256)
: undefined) ||
(existing.githubPath ?? undefined) !== discovered.path ||
!sameLatestVersionSummary(existing.latestVersionSummary, nextLatestVersionSummary);
const patch = {
displayName: discovered.displayName,
summary: discovered.summary,
icon: discovered.iconAsset
? buildSkillPresentationIconPath(discovered.iconAsset.sha256)
: undefined,
ownerUserId,
...(ownerPublisherId ? { ownerPublisherId } : {}),
githubSourceId: sourceId,
@@ -605,6 +687,15 @@ function findFolderFilePath(
});
}
function findSkillRelativeFilePath(
entries: Record<string, Uint8Array>,
folderPath: string,
relativePath: string,
) {
const expected = folderPath ? `${folderPath}/${relativePath}` : relativePath;
return Object.hasOwn(entries, expected) ? expected : undefined;
}
function normalizeRepoPath(path: string) {
if (path.includes("\u0000")) return "";
const normalized = path
+3
View File
@@ -179,6 +179,9 @@ export const RETENTION_POLICIES = {
"packages",
),
skillVersions: permanent("Canonical skill version records."),
skillPresentationAssets: permanent(
"Immutable content-addressed icon copies referenced by skill presentation metadata.",
),
skillVersionFingerprints: derived("Fingerprint projection of skill versions.", "skillVersions"),
skillBadges: permanent("Curated skill badges."),
skillEmbeddings: derived("Search embedding projection of skill versions.", "skillVersions"),
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import {
buildSkillPresentationIconPath,
MAX_SKILL_PRESENTATION_DISPLAY_NAME_LENGTH,
MAX_SKILL_PRESENTATION_SHORT_DESCRIPTION_LENGTH,
parseOpenAiSkillPresentation,
resolveSkillPresentation,
stripPresentationEmoji,
validateSkillPresentationIcon,
} from "./skillPresentation";
const encoder = new TextEncoder();
const validPng = Uint8Array.from(
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
);
const validJpeg = Uint8Array.from(
Buffer.from(
"/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAABgj/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABykX//Z",
"base64",
),
);
const validWebp = Uint8Array.from(
Buffer.from(
"UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoBAAEAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9Lg/9KQAAA=",
"base64",
),
);
describe("parseOpenAiSkillPresentation", () => {
it("keeps supported interface metadata and resolves the preferred icon path", () => {
expect(
parseOpenAiSkillPresentation(`
interface:
display_name: "✨ Better Search"
short_description: Search across project knowledge.
icon_small: ./assets/icon-small.png
icon_large: assets/icon-large.webp
brand_color: "#112233"
default_prompt: Ignore this for catalog rendering.
policy:
allow_implicit_invocation: true
`),
).toEqual({
displayName: "✨ Better Search",
shortDescription: "Search across project knowledge.",
iconPaths: ["assets/icon-small.png", "assets/icon-large.webp"],
});
});
it("drops traversal, absolute, URL, and malformed icon references", () => {
for (const icon of ["../secret.png", "/tmp/icon.png", "https://example.com/icon.png"]) {
expect(
parseOpenAiSkillPresentation(`interface:\n display_name: Demo\n icon_small: ${icon}\n`),
).toEqual({ displayName: "Demo" });
}
expect(parseOpenAiSkillPresentation("{not yaml")).toBeNull();
});
it("drops presentation text that exceeds catalog field limits", () => {
expect(
parseOpenAiSkillPresentation(`interface:
display_name: ${"n".repeat(MAX_SKILL_PRESENTATION_DISPLAY_NAME_LENGTH + 1)}
short_description: ${"s".repeat(MAX_SKILL_PRESENTATION_SHORT_DESCRIPTION_LENGTH + 1)}
icon_small: assets/icon.png
`),
).toEqual({ iconPaths: ["assets/icon.png"] });
});
});
describe("resolveSkillPresentation", () => {
it("uses publisher overrides before OpenAI metadata and SKILL.md metadata", () => {
expect(
resolveSkillPresentation({
publisherDisplayName: "🚀 Publisher Name",
publisherSummary: "Publisher summary.",
openAi: {
displayName: "OpenAI Name",
shortDescription: "OpenAI summary.",
iconPaths: ["assets/icon.png"],
},
skillDisplayName: "Skill Name",
skillDescription: "Skill summary.",
slug: "skill-name",
}),
).toEqual({
displayName: "Publisher Name",
displayNameSource: "publisher",
summary: "Publisher summary.",
summarySource: "publisher",
iconPaths: ["assets/icon.png"],
});
});
it("falls back through OpenAI metadata, SKILL.md, and slug while removing emoji", () => {
expect(
resolveSkillPresentation({
openAi: { displayName: "🧭 OpenAI Name", shortDescription: "OpenAI summary." },
skillDisplayName: "Skill Name",
skillDescription: "Skill summary.",
slug: "skill-name",
}),
).toEqual({
displayName: "OpenAI Name",
displayNameSource: "openai",
summary: "OpenAI summary.",
summarySource: "openai",
});
expect(resolveSkillPresentation({ slug: "skill-name" })).toEqual({
displayName: "Skill Name",
displayNameSource: "slug",
});
});
});
describe("skill presentation icons", () => {
it("accepts matching PNG, JPEG, WebP, and SVG assets", () => {
const fixtures = [
{
path: "assets/icon.png",
bytes: validPng,
contentType: "image/png",
},
{
path: "assets/icon.jpg",
bytes: validJpeg,
contentType: "image/jpeg",
},
{
path: "assets/icon.webp",
bytes: validWebp,
contentType: "image/webp",
},
{
path: "assets/icon.svg",
bytes: encoder.encode('<svg xmlns="http://www.w3.org/2000/svg"></svg>'),
contentType: "image/svg+xml",
},
];
for (const fixture of fixtures) {
expect(validateSkillPresentationIcon(fixture)).toEqual({
contentType: fixture.contentType,
size: fixture.bytes.byteLength,
});
}
});
it("rejects mismatched, active SVG, and oversized assets", () => {
expect(() =>
validateSkillPresentationIcon({
path: "icon.gif",
bytes: encoder.encode("GIF89a"),
}),
).toThrow(/unsupported/i);
expect(() =>
validateSkillPresentationIcon({
path: "icon.png",
bytes: encoder.encode("<svg></svg>"),
}),
).toThrow(/invalid png/i);
expect(() =>
validateSkillPresentationIcon({
path: "icon.png",
bytes: validPng.slice(0, 24),
}),
).toThrow(/invalid png/i);
expect(() =>
validateSkillPresentationIcon({
path: "icon.svg",
bytes: encoder.encode("<svg><script>alert(1)</script></svg>"),
}),
).toThrow(/unsafe svg/i);
expect(() =>
validateSkillPresentationIcon({
path: "icon.png",
bytes: new Uint8Array(512 * 1024 + 1),
}),
).toThrow(/512KB/i);
});
it("builds stable content-addressed paths", () => {
expect(buildSkillPresentationIconPath("A".repeat(64))).toBe(
`/api/v1/skill-icons/${"a".repeat(64)}`,
);
expect(() => buildSkillPresentationIconPath("nope")).toThrow(/sha-256/i);
});
});
describe("stripPresentationEmoji", () => {
it("removes emoji sequences and tidies the rendered title", () => {
expect(stripPresentationEmoji(" 🚀 Super ✨ Skill ")).toBe("Super Skill");
expect(stripPresentationEmoji("🧑🏽‍💻 Dev Tools")).toBe("Dev Tools");
});
});
+328
View File
@@ -0,0 +1,328 @@
import { parseDocument } from "yaml";
export const OPENAI_SKILL_PRESENTATION_PATH = "agents/openai.yaml";
export const MAX_SKILL_PRESENTATION_YAML_BYTES = 64 * 1024;
export const MAX_SKILL_PRESENTATION_ICON_BYTES = 512 * 1024;
export const MAX_SKILL_PRESENTATION_DISPLAY_NAME_LENGTH = 120;
export const MAX_SKILL_PRESENTATION_SHORT_DESCRIPTION_LENGTH = 300;
const ICON_CONTENT_TYPES = {
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
} as const;
const PRESENTATION_EMOJI_PATTERN =
/\p{Extended_Pictographic}|\p{Emoji_Presentation}|\p{Emoji_Modifier}|\p{Regional_Indicator}|\u200D|\uFE0F|\u20E3/gu;
export type OpenAiSkillPresentation = {
displayName?: string;
shortDescription?: string;
iconPaths?: string[];
};
export type ResolvedSkillPresentation = {
displayName: string;
displayNameSource: "publisher" | "openai" | "skill" | "slug";
summary?: string;
summarySource?: "publisher" | "openai" | "skill" | "generated";
iconPaths?: string[];
};
export function parseOpenAiSkillPresentation(
raw: string | undefined | null,
): OpenAiSkillPresentation | null {
if (!raw?.trim()) return null;
if (new TextEncoder().encode(raw).byteLength > MAX_SKILL_PRESENTATION_YAML_BYTES) return null;
try {
const document = parseDocument(raw);
if (document.errors.length > 0) return null;
const parsed = document.toJS({ maxAliasCount: 20 }) as unknown;
if (!isRecord(parsed) || !isRecord(parsed.interface)) return null;
const interfaceMetadata = parsed.interface;
const displayName = cleanText(
interfaceMetadata.display_name,
MAX_SKILL_PRESENTATION_DISPLAY_NAME_LENGTH,
);
const shortDescription = cleanText(
interfaceMetadata.short_description,
MAX_SKILL_PRESENTATION_SHORT_DESCRIPTION_LENGTH,
);
const iconPaths = [
normalizeSkillPresentationPath(interfaceMetadata.icon_small),
normalizeSkillPresentationPath(interfaceMetadata.icon_large),
].filter(
(path, index, paths): path is string => Boolean(path) && paths.indexOf(path) === index,
);
if (!displayName && !shortDescription && iconPaths.length === 0) return null;
return {
...(displayName ? { displayName } : {}),
...(shortDescription ? { shortDescription } : {}),
...(iconPaths.length > 0 ? { iconPaths } : {}),
};
} catch {
return null;
}
}
export function resolveSkillPresentation(args: {
publisherDisplayName?: string | null;
publisherSummary?: string | null;
openAi?: OpenAiSkillPresentation | null;
skillDisplayName?: string | null;
skillDescription?: string | null;
slug: string;
}): ResolvedSkillPresentation {
const publisherDisplayName = cleanText(args.publisherDisplayName);
const openAiDisplayName = cleanText(args.openAi?.displayName);
const skillDisplayName = cleanText(args.skillDisplayName);
const displayName = stripPresentationEmoji(
publisherDisplayName ?? openAiDisplayName ?? skillDisplayName ?? titleizeSlug(args.slug),
);
const displayNameSource = displayName
? publisherDisplayName
? "publisher"
: openAiDisplayName
? "openai"
: skillDisplayName
? "skill"
: "slug"
: "slug";
const publisherSummary = cleanText(args.publisherSummary);
const openAiSummary = cleanText(args.openAi?.shortDescription);
const skillSummary = cleanText(args.skillDescription);
const summary = publisherSummary ?? openAiSummary ?? skillSummary;
const summarySource = publisherSummary
? "publisher"
: openAiSummary
? "openai"
: skillSummary
? "skill"
: undefined;
const iconPaths = (args.openAi?.iconPaths ?? [])
.map(normalizeSkillPresentationPath)
.filter((path, index, paths): path is string => Boolean(path) && paths.indexOf(path) === index);
return {
displayName: displayName || titleizeSlug(args.slug),
displayNameSource,
...(summary && summarySource ? { summary, summarySource } : {}),
...(iconPaths.length > 0 ? { iconPaths } : {}),
};
}
export function normalizeSkillPresentationPath(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const path = value
.trim()
.replaceAll("\\", "/")
.replace(/^\.\/+/, "");
if (!path || path.startsWith("/") || path.includes("\0")) return undefined;
if (/^[a-z][a-z\d+.-]*:/i.test(path) || path.startsWith("//")) return undefined;
if (path.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
return undefined;
}
if (/(?:^|\/)%2e(?:%2e)?(?:\/|$)/i.test(path)) return undefined;
return path;
}
export function validateSkillPresentationIcon(args: {
path: string;
bytes: Uint8Array;
contentType?: string | null;
}): { contentType: (typeof ICON_CONTENT_TYPES)[keyof typeof ICON_CONTENT_TYPES]; size: number } {
const extension = iconExtension(args.path);
if (!extension) throw new Error("Unsupported skill presentation icon type.");
const expectedContentType = ICON_CONTENT_TYPES[extension];
if (args.bytes.byteLength > MAX_SKILL_PRESENTATION_ICON_BYTES) {
throw new Error("Skill presentation icon exceeds the 512KB limit.");
}
if (args.bytes.byteLength === 0) throw new Error("Skill presentation icon is empty.");
const suppliedContentType = args.contentType?.split(";", 1)[0]?.trim().toLowerCase();
if (suppliedContentType && suppliedContentType !== expectedContentType) {
throw new Error(`Skill presentation icon content type does not match ${extension}.`);
}
if (expectedContentType === "image/png" && !isStructurallyValidPng(args.bytes)) {
throw new Error("Invalid PNG skill presentation icon.");
}
if (expectedContentType === "image/jpeg" && !isStructurallyValidJpeg(args.bytes)) {
throw new Error("Invalid JPEG skill presentation icon.");
}
if (expectedContentType === "image/webp" && !isStructurallyValidWebp(args.bytes)) {
throw new Error("Invalid WebP skill presentation icon.");
}
if (expectedContentType === "image/svg+xml") validateSafeSvg(args.bytes);
return { contentType: expectedContentType, size: args.bytes.byteLength };
}
export function buildSkillPresentationIconPath(sha256: string) {
const normalized = sha256.trim().toLowerCase();
if (!/^[a-f\d]{64}$/.test(normalized)) throw new Error("A SHA-256 digest is required.");
return `/api/v1/skill-icons/${normalized}`;
}
export function isHostedSkillPresentationIconPath(value: string | null | undefined) {
return /^\/api\/v1\/skill-icons\/[a-f\d]{64}$/u.test(value?.trim() ?? "");
}
export function stripPresentationEmoji(value: string) {
return value.replace(PRESENTATION_EMOJI_PATTERN, " ").replace(/\s+/g, " ").trim();
}
function validateSafeSvg(bytes: Uint8Array) {
let svg: string;
try {
svg = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
throw new Error("Invalid SVG skill presentation icon.");
}
if (!/^\s*(?:<\?xml[^>]*>\s*)?<svg(?:\s|>)/i.test(svg)) {
throw new Error("Invalid SVG skill presentation icon.");
}
if (
/<\/?(?:script|foreignObject|iframe|object|embed|audio|video)\b/i.test(svg) ||
/\son[a-z]+\s*=/i.test(svg) ||
/(?:javascript|vbscript)\s*:/i.test(svg) ||
/<!DOCTYPE|<!ENTITY/i.test(svg) ||
/\b(?:href|xlink:href)\s*=\s*["']\s*(?:https?:|\/\/|data:)/i.test(svg)
) {
throw new Error("Unsafe SVG skill presentation icon.");
}
}
function iconExtension(path: string): keyof typeof ICON_CONTENT_TYPES | undefined {
const match = /\.[a-z\d]+$/i.exec(path.trim());
const extension = match?.[0]?.toLowerCase();
return extension && Object.hasOwn(ICON_CONTENT_TYPES, extension)
? (extension as keyof typeof ICON_CONTENT_TYPES)
: undefined;
}
function hasPrefix(bytes: Uint8Array, expected: number[]) {
return expected.every((value, index) => bytes[index] === value);
}
function isStructurallyValidPng(bytes: Uint8Array) {
if (!hasPrefix(bytes, [137, 80, 78, 71, 13, 10, 26, 10])) return false;
let offset = 8;
let sawHeader = false;
let sawImageData = false;
while (offset + 12 <= bytes.byteLength) {
const length = readUint32Be(bytes, offset);
const type = ascii(bytes, offset + 4, offset + 8);
const nextOffset = offset + 12 + length;
if (nextOffset > bytes.byteLength) return false;
if (!sawHeader) {
if (type !== "IHDR" || length !== 13) return false;
if (readUint32Be(bytes, offset + 8) === 0 || readUint32Be(bytes, offset + 12) === 0) {
return false;
}
sawHeader = true;
} else if (type === "IDAT") {
sawImageData = true;
} else if (type === "IEND") {
return length === 0 && sawImageData && nextOffset === bytes.byteLength;
}
offset = nextOffset;
}
return false;
}
function isStructurallyValidJpeg(bytes: Uint8Array) {
if (
bytes.byteLength < 12 ||
!hasPrefix(bytes, [255, 216, 255]) ||
bytes.at(-2) !== 255 ||
bytes.at(-1) !== 217
) {
return false;
}
let offset = 2;
while (offset + 4 <= bytes.byteLength - 2) {
if (bytes[offset] !== 255) return false;
while (bytes[offset] === 255) offset += 1;
const marker = bytes[offset];
offset += 1;
if (marker === undefined || marker === 0 || marker === 255) return false;
if (marker === 217) return false;
if (marker === 218) break;
if (marker === 1 || (marker >= 208 && marker <= 215)) continue;
if (offset + 2 > bytes.byteLength - 2) return false;
const segmentLength = readUint16Be(bytes, offset);
if (segmentLength < 2 || offset + segmentLength > bytes.byteLength - 2) return false;
if (isJpegStartOfFrame(marker)) {
return (
segmentLength >= 7 &&
readUint16Be(bytes, offset + 3) > 0 &&
readUint16Be(bytes, offset + 5) > 0
);
}
offset += segmentLength;
}
return false;
}
function isStructurallyValidWebp(bytes: Uint8Array) {
if (
bytes.byteLength < 30 ||
ascii(bytes, 0, 4) !== "RIFF" ||
ascii(bytes, 8, 12) !== "WEBP" ||
readUint32Le(bytes, 4) + 8 !== bytes.byteLength
) {
return false;
}
const chunkType = ascii(bytes, 12, 16);
const chunkLength = readUint32Le(bytes, 16);
const paddedLength = chunkLength + (chunkLength % 2);
if (20 + paddedLength > bytes.byteLength) return false;
if (chunkType === "VP8X") return chunkLength >= 10;
if (chunkType === "VP8L") return chunkLength >= 5 && bytes[20] === 47;
return chunkType === "VP8 " && chunkLength >= 10;
}
function isJpegStartOfFrame(marker: number) {
return marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker);
}
function readUint16Be(bytes: Uint8Array, offset: number) {
return ((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0);
}
function readUint32Be(bytes: Uint8Array, offset: number) {
return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, false);
}
function readUint32Le(bytes: Uint8Array, offset: number) {
return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, true);
}
function ascii(bytes: Uint8Array, start: number, end: number) {
return String.fromCharCode(...bytes.slice(start, end));
}
function cleanText(value: unknown, maxLength?: number) {
if (typeof value !== "string") return undefined;
const normalized = value.replace(/\s+/g, " ").trim();
if (!normalized || (maxLength !== undefined && normalized.length > maxLength)) return undefined;
return normalized;
}
function titleizeSlug(slug: string) {
const title = slug
.trim()
.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (character) => character.toUpperCase())
.trim();
return title || "Skill";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
preserveHistoricalHostedIcon,
resolveHistoricalSkillPresentation,
} from "./skillPresentationBackfill";
describe("resolveHistoricalSkillPresentation", () => {
it("adopts OpenAI presentation values when stored values came from frontmatter", () => {
expect(
resolveHistoricalSkillPresentation({
slug: "demo-skill",
currentDisplayName: "Demo Skill",
currentSummary: "Frontmatter summary",
frontmatter: { name: "demo-skill", description: "Frontmatter summary" },
openAi: {
displayName: "OpenAI Demo",
shortDescription: "OpenAI summary",
iconPaths: ["assets/icon.png"],
},
}),
).toEqual({
displayName: "OpenAI Demo",
displayNameSource: "openai",
summary: "OpenAI summary",
summarySource: "openai",
iconPaths: ["assets/icon.png"],
});
});
it("preserves historical publisher title and summary overrides", () => {
expect(
resolveHistoricalSkillPresentation({
slug: "demo-skill",
currentDisplayName: "Publisher Title",
currentSummary: "Publisher summary",
frontmatter: { name: "demo-skill", description: "Frontmatter summary" },
openAi: {
displayName: "OpenAI Demo",
shortDescription: "OpenAI summary",
iconPaths: ["assets/icon.png"],
},
}),
).toEqual({
displayName: "Publisher Title",
displayNameSource: "publisher",
summary: "Publisher summary",
summarySource: "publisher",
iconPaths: ["assets/icon.png"],
});
});
it("strips emoji while preserving an explicit publisher title", () => {
expect(
resolveHistoricalSkillPresentation({
slug: "demo-skill",
currentDisplayName: "✨ Publisher Title",
frontmatter: {},
openAi: { displayName: "OpenAI Demo" },
}).displayName,
).toBe("Publisher Title");
});
});
describe("preserveHistoricalHostedIcon", () => {
const hostedIcon = `/api/v1/skill-icons/${"a".repeat(64)}`;
it("preserves an immutable hosted icon when source revalidation is unavailable", () => {
expect(preserveHistoricalHostedIcon(undefined, hostedIcon)).toBe(hostedIcon);
});
it("does not preserve arbitrary legacy icon values", () => {
expect(
preserveHistoricalHostedIcon("https://example.com/icon.png", "icon.png"),
).toBeUndefined();
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { Doc } from "../_generated/dataModel";
import {
isHostedSkillPresentationIconPath,
resolveSkillPresentation,
stripPresentationEmoji,
type OpenAiSkillPresentation,
} from "./skillPresentation";
import { getFrontmatterMetadata, getFrontmatterValue } from "./skills";
export function resolveHistoricalSkillPresentation(args: {
slug: string;
currentDisplayName: string;
currentSummary?: string;
frontmatter: Doc<"skillVersions">["parsed"]["frontmatter"];
openAi: OpenAiSkillPresentation;
}) {
const skillDisplayName = getFrontmatterValue(args.frontmatter, "name")?.trim();
const defaultDisplayName = resolveSkillPresentation({ slug: args.slug }).displayName;
const publisherDisplayName = [skillDisplayName, defaultDisplayName].some(
(candidate) =>
candidate &&
stripPresentationEmoji(candidate) === stripPresentationEmoji(args.currentDisplayName),
)
? undefined
: args.currentDisplayName;
const frontmatterMetadata = getFrontmatterMetadata(args.frontmatter);
const nestedDescription =
frontmatterMetadata &&
typeof frontmatterMetadata === "object" &&
!Array.isArray(frontmatterMetadata) &&
typeof (frontmatterMetadata as Record<string, unknown>).description === "string"
? ((frontmatterMetadata as Record<string, unknown>).description as string).trim()
: undefined;
const frontmatterDescription =
nestedDescription || getFrontmatterValue(args.frontmatter, "description")?.trim();
const currentSummary = args.currentSummary?.trim();
const publisherSummary =
currentSummary && currentSummary !== frontmatterDescription ? currentSummary : undefined;
return resolveSkillPresentation({
publisherDisplayName,
publisherSummary,
openAi: args.openAi,
skillDisplayName,
skillDescription: frontmatterDescription,
slug: args.slug,
});
}
export function preserveHistoricalHostedIcon(
...icons: Array<string | null | undefined>
): string | undefined {
return icons.find((icon): icon is string => isHostedSkillPresentationIconPath(icon));
}
+216
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import { MAX_PUBLISH_FILE_BYTES } from "./publishLimits";
import {
@@ -12,6 +13,202 @@ vi.mock("./embeddings", () => ({
}));
describe("skillPublish", () => {
it("normalizes agents/openai.yaml presentation metadata and hosts its icon", async () => {
const skillMarkdown =
"---\nname: Demo Skill\ndescription: SKILL.md summary.\n---\n# Demo Skill\n";
const openAiYaml =
"interface:\n display_name: '✨ OpenAI Demo'\n short_description: OpenAI summary.\n icon_small: assets/missing.png\n icon_large: assets/icon.png\n";
const iconBytes = validPng();
const stored = new Map<string, Blob>([
["_storage:skill", new Blob([skillMarkdown], { type: "text/markdown" })],
["_storage:openai", new Blob([openAiYaml], { type: "application/yaml" })],
["_storage:icon", new Blob([iconBytes], { type: "image/png" })],
]);
const runMutation = vi.fn(async (_ref: unknown, args: Record<string, unknown>) => {
if ("contentType" in args && "storageId" in args && !("version" in args)) {
return { ...args, _id: "skillPresentationAssets:1", createdAt: 1 };
}
if ("version" in args && "embedding" in args) {
return { skillId: "skills:demo", versionId: "skillVersions:demo" };
}
return null;
});
const ctx = {
runAction: vi.fn(async () => true),
runQuery: vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ _id: "users:1", handle: "demo", createdAt: 1 })
.mockResolvedValueOnce(null),
runMutation,
scheduler: { runAfter: vi.fn() },
storage: {
get: vi.fn(async (storageId: string) => stored.get(storageId) ?? null),
store: vi.fn(async () => "_storage:hosted-icon"),
delete: vi.fn(async () => undefined),
},
};
await publishVersionForUser(
ctx as never,
"users:1" as never,
{
slug: "demo-skill",
displayName: "Demo Skill",
version: "1.0.0",
changelog: "Initial release",
files: [
file("_storage:skill", "SKILL.md", skillMarkdown.length, "text/markdown"),
file("_storage:openai", "agents/openai.yaml", openAiYaml.length, "application/yaml"),
file("_storage:icon", "assets/icon.png", iconBytes.byteLength, "image/png"),
],
},
{
bypassGitHubAccountAge: true,
bypassQualityGate: true,
skipWebhook: true,
},
);
const insertCall = runMutation.mock.calls.find(
([, args]) =>
"version" in (args as Record<string, unknown>) &&
"embedding" in (args as Record<string, unknown>),
);
expect(insertCall?.[1]).toMatchObject({
displayName: "OpenAI Demo",
summary: "OpenAI summary.",
icon: expect.stringMatching(/^\/api\/v1\/skill-icons\/[a-f\d]{64}$/),
parsed: {
presentation: {
displayName: "OpenAI Demo",
summary: "OpenAI summary.",
icon: expect.stringMatching(/^\/api\/v1\/skill-icons\/[a-f\d]{64}$/),
},
},
});
expect(ctx.storage.store).toHaveBeenCalledOnce();
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
sha256: createHash("sha256").update(iconBytes).digest("hex"),
}),
);
});
it("lets changed OpenAI metadata replace unchanged derived publish values", async () => {
const skillMarkdown = "---\nname: Demo Skill\ndescription: SKILL summary.\n---\n# Demo Skill\n";
const openAiYaml =
"interface:\n display_name: OpenAI Demo v2\n short_description: OpenAI summary v2.\n";
const stored = new Map<string, Blob>([
["_storage:skill", new Blob([skillMarkdown], { type: "text/markdown" })],
["_storage:openai", new Blob([openAiYaml], { type: "application/yaml" })],
]);
const runMutation = vi.fn(async (_ref: unknown, args: Record<string, unknown>) =>
"version" in args && "embedding" in args
? { skillId: "skills:demo", versionId: "skillVersions:v2" }
: null,
);
const ctx = {
runQuery: vi
.fn()
.mockResolvedValueOnce({
_id: "skills:demo",
slug: "demo-skill",
displayName: "OpenAI Demo v1",
summary: "OpenAI summary v1.",
latestVersionId: "skillVersions:v1",
})
.mockResolvedValueOnce({ _id: "users:1", handle: "demo", createdAt: 1 })
.mockResolvedValueOnce({
_id: "skillVersions:v1",
parsed: {
frontmatter: {},
presentation: {
displayName: "OpenAI Demo v1",
displayNameSource: "openai",
summary: "OpenAI summary v1.",
summarySource: "openai",
},
},
}),
runMutation,
scheduler: { runAfter: vi.fn() },
storage: {
get: vi.fn(async (storageId: string) => stored.get(storageId) ?? null),
},
};
await publishVersionForUser(
ctx as never,
"users:1" as never,
{
slug: "demo-skill",
displayName: "OpenAI Demo v1",
summary: "OpenAI summary v1.",
version: "2.0.0",
changelog: "Presentation refresh",
files: [
file("_storage:skill", "SKILL.md", skillMarkdown.length, "text/markdown"),
file("_storage:openai", "agents/openai.yaml", openAiYaml.length, "application/yaml"),
],
},
{
bypassGitHubAccountAge: true,
bypassQualityGate: true,
skipWebhook: true,
},
);
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
displayName: "OpenAI Demo v2",
summary: "OpenAI summary v2.",
parsed: {
frontmatter: expect.anything(),
metadata: undefined,
clawdis: undefined,
license: expect.anything(),
presentation: {
displayName: "OpenAI Demo v2",
displayNameSource: "openai",
summary: "OpenAI summary v2.",
summarySource: "openai",
},
},
}),
);
});
it("rejects icon digest changes and propagates asset persistence failures", async () => {
const iconBytes = validPng();
const iconFile = file("_storage:icon", "assets/icon.png", iconBytes.byteLength, "image/png");
const storage = {
get: vi.fn(async () => new Blob([iconBytes], { type: "image/png" })),
store: vi.fn(async () => {
throw new Error("storage unavailable");
}),
delete: vi.fn(async () => undefined),
};
const ctx = {
runAction: vi.fn(async () => true),
runQuery: vi.fn(async () => null),
runMutation: vi.fn(),
storage,
};
await expect(
__test.hostDirectSkillPresentationIcon(ctx as never, [iconFile], [iconFile.path]),
).rejects.toThrow(/changed during upload/i);
expect(storage.store).not.toHaveBeenCalled();
iconFile.sha256 = createHash("sha256").update(iconBytes).digest("hex");
await expect(
__test.hostDirectSkillPresentationIcon(ctx as never, [iconFile], [iconFile.path]),
).rejects.toThrow("storage unavailable");
});
it("publishes long display names without rewriting the stored label", async () => {
const displayName = "A".repeat(120);
const skillMarkdown = `---\ndescription: Long compatibility name.\n---\n# ${displayName}\n`;
@@ -1302,3 +1499,22 @@ description: Expert guidance for sushi-rolls.
expect(quality.decision).toBe("pass");
});
});
function file(storageId: string, path: string, size: number, contentType: string) {
return {
path,
size,
storageId: storageId as never,
sha256: "a".repeat(64),
contentType,
};
}
function validPng() {
return Uint8Array.from(
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
);
}
+126 -10
View File
@@ -10,6 +10,10 @@ import semver from "semver";
import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx, MutationCtx } from "../_generated/server";
import {
isDecodableSkillPresentationRaster,
storeSkillPresentationAsset,
} from "../skillPresentationAssets";
import { getSkillBadgeMap, isSkillHighlighted } from "./badges";
import { generateChangelogForPublish } from "./changelog";
import { generateEmbedding } from "./embeddings";
@@ -22,6 +26,14 @@ import {
MAX_PUBLISH_TOTAL_BYTES,
} from "./publishLimits";
import { isSkillCardPath } from "./skillCards";
import {
MAX_SKILL_PRESENTATION_YAML_BYTES,
OPENAI_SKILL_PRESENTATION_PATH,
parseOpenAiSkillPresentation,
resolveSkillPresentation,
stripPresentationEmoji,
validateSkillPresentationIcon,
} from "./skillPresentation";
import {
computeQualitySignals,
evaluateQuality,
@@ -190,7 +202,7 @@ async function publishVersionForUserInternal(
const normalizedSlug = normalizeSkillSlug(args.slug);
if (!normalizedSlug) throw new ConvexError("Slug is required.");
const displayName = args.displayName.trim();
let displayName = stripPresentationEmoji(args.displayName.trim());
if (!displayName) throw new ConvexError("Display name required");
if (!semver.valid(version)) {
throw new ConvexError("Version must be valid semver");
@@ -300,16 +312,64 @@ async function publishVersionForUserInternal(
if (explicitSummary && explicitSummary.length > MAX_PUBLISH_SUMMARY_LENGTH) {
throw new ConvexError(`Summary must be ${MAX_PUBLISH_SUMMARY_LENGTH} characters or less`);
}
const openAiFile = publishFiles.find(
(file) => file.path.toLowerCase() === OPENAI_SKILL_PRESENTATION_PATH,
);
const openAiPresentation =
openAiFile && openAiFile.size <= MAX_SKILL_PRESENTATION_YAML_BYTES
? await fetchText(ctx, openAiFile.storageId)
.then(parseOpenAiSkillPresentation)
.catch(() => null)
: null;
const existingLatestVersion = existingSkill?.latestVersionId
? ((await ctx.runQuery(internal.skills.getVersionByIdInternal, {
versionId: existingSkill.latestVersionId,
})) as Doc<"skillVersions"> | null)
: null;
const existingPresentation = existingLatestVersion?.parsed.presentation;
const skillDisplayName = getFrontmatterValue(frontmatter, "name")?.trim();
const defaultDisplayName = resolveSkillPresentation({ slug }).displayName;
const reusesDerivedDisplayName =
existingPresentation?.displayNameSource !== undefined &&
existingPresentation.displayNameSource !== "publisher" &&
existingPresentation.displayName === displayName &&
existingSkill?.displayName === displayName;
const publisherDisplayName =
reusesDerivedDisplayName ||
[skillDisplayName, defaultDisplayName].some(
(candidate) => candidate && stripPresentationEmoji(candidate) === displayName,
)
? undefined
: displayName;
const reusesDerivedSummary =
explicitSummary !== undefined &&
existingPresentation?.summarySource !== undefined &&
existingPresentation.summarySource !== "publisher" &&
existingPresentation.summary === explicitSummary &&
existingSkill?.summary === explicitSummary;
const publisherSummary =
explicitSummary && explicitSummary !== summaryFromFrontmatter && !reusesDerivedSummary
? explicitSummary
: undefined;
const presentation = resolveSkillPresentation({
publisherDisplayName,
publisherSummary,
openAi: openAiPresentation,
skillDisplayName,
skillDescription: summaryFromFrontmatter,
slug,
});
displayName = presentation.displayName;
const shouldDeferAiEnrichment = options.stagePrePublicationChecks === true;
const summary =
explicitSummary ||
publisherSummary ||
(shouldDeferAiEnrichment
? (summaryFromFrontmatter ?? existingSkill?.summary ?? "")
? (presentation.summary ?? existingSkill?.summary ?? "")
: await generateSkillSummary({
slug,
displayName,
readmeText,
currentSummary: summaryFromFrontmatter ?? existingSkill?.summary ?? undefined,
currentSummary: presentation.summary ?? existingSkill?.summary ?? undefined,
}));
let qualityAssessment: QualityAssessment | null = null;
@@ -425,6 +485,7 @@ async function publishVersionForUserInternal(
throw new ConvexError(formatEmbeddingError(error));
}),
]);
const icon = await hostDirectSkillPresentationIcon(ctx, publishFiles, presentation.iconPaths);
const skillInsertArgs = {
userId,
@@ -458,18 +519,27 @@ async function publishVersionForUserInternal(
metadata,
clawdis,
license: PLATFORM_SKILL_LICENSE,
presentation: {
displayName,
displayNameSource: presentation.displayNameSource,
...(summary ? { summary } : {}),
...(summary ? { summarySource: presentation.summarySource ?? ("generated" as const) } : {}),
...(icon ? { icon } : {}),
},
},
summary,
icon,
staticScan,
embedding,
deferredAiEnrichment: shouldDeferAiEnrichment
? ({
summary: explicitSummary
? { mode: "literal", literal: explicitSummary }
: {
mode: "generate",
currentSummary: summaryFromFrontmatter ?? existingSkill?.summary ?? undefined,
},
summary:
publisherSummary || presentation.summary
? { mode: "literal", literal: publisherSummary ?? presentation.summary }
: {
mode: "generate",
currentSummary: existingSkill?.summary ?? undefined,
},
changelog: {
source: changelogSource,
supplied: suppliedChangelog,
@@ -923,6 +993,7 @@ export const __test = {
toStructuralFingerprint,
derivePublishFilesFromStorage,
buildSkillPublishAttemptIdempotencyKey,
hostDirectSkillPresentationIcon,
};
export async function queueHighlightedWebhook(ctx: MutationCtx, skillId: Id<"skills">) {
@@ -959,6 +1030,51 @@ export async function fetchText(
return text;
}
async function hostDirectSkillPresentationIcon(
ctx: Pick<ActionCtx, "runAction" | "runMutation" | "runQuery" | "storage">,
files: SafePublishFile[],
iconPaths: string[] | undefined,
) {
for (const iconPath of iconPaths ?? []) {
const file = files.find((candidate) => candidate.path === iconPath);
if (!file) continue;
const blob = await ctx.storage.get(file.storageId);
if (!blob) {
throw new ConvexError("Skill presentation icon could not be read. Please retry.");
}
const bytes = new Uint8Array(await blob.arrayBuffer());
let validated: ReturnType<typeof validateSkillPresentationIcon>;
try {
validated = validateSkillPresentationIcon({
path: file.path,
bytes,
contentType: file.contentType ?? blob.type,
});
} catch {
continue;
}
if (
validated.contentType !== "image/svg+xml" &&
!(await isDecodableSkillPresentationRaster(ctx, {
bytes,
contentType: validated.contentType,
}))
) {
continue;
}
const sha256 = await sha256Hex(bytes);
if (sha256 !== file.sha256.toLowerCase()) {
throw new ConvexError("Skill presentation icon changed during upload. Please retry.");
}
return await storeSkillPresentationAsset(ctx, {
bytes,
sha256,
contentType: validated.contentType,
});
}
return undefined;
}
async function fetchPreviewText(
ctx: { storage: { get: (id: Id<"_storage">) => Promise<Blob | null> } },
storageId: Id<"_storage">,
+61
View File
@@ -505,6 +505,7 @@ const githubSkillCandidates = defineTable({
githubContentHash: v.string(),
displayName: v.string(),
summary: v.optional(v.string()),
icon: v.optional(v.string()),
upstreamVersion: v.optional(v.string()),
skillMarkdownPath: v.optional(v.string()),
skillMarkdown: v.optional(v.string()),
@@ -1089,6 +1090,29 @@ const skillVersions = defineTable({
clawdis: v.optional(v.any()),
moltbot: v.optional(v.any()),
license: v.optional(v.literal(PLATFORM_SKILL_LICENSE)),
presentation: v.optional(
v.object({
displayName: v.string(),
displayNameSource: v.optional(
v.union(
v.literal("publisher"),
v.literal("openai"),
v.literal("skill"),
v.literal("slug"),
),
),
summary: v.optional(v.string()),
summarySource: v.optional(
v.union(
v.literal("publisher"),
v.literal("openai"),
v.literal("skill"),
v.literal("generated"),
),
),
icon: v.optional(v.string()),
}),
),
}),
createdBy: v.id("users"),
createdAt: v.number(),
@@ -1176,6 +1200,19 @@ const publishAttemptStatusValidator = v.union(
v.literal("expired"),
);
const skillPresentationAssets = defineTable({
sha256: v.string(),
storageId: v.id("_storage"),
contentType: v.union(
v.literal("image/png"),
v.literal("image/jpeg"),
v.literal("image/webp"),
v.literal("image/svg+xml"),
),
size: v.number(),
createdAt: v.number(),
}).index("by_sha256", ["sha256"]);
const publishAttemptCheckStateValidator = v.object({
status: v.union(
v.literal("pending"),
@@ -1978,6 +2015,29 @@ const skillScanRequests = defineTable({
clawdis: v.optional(v.any()),
moltbot: v.optional(v.any()),
license: v.optional(v.literal(PLATFORM_SKILL_LICENSE)),
presentation: v.optional(
v.object({
displayName: v.string(),
displayNameSource: v.optional(
v.union(
v.literal("publisher"),
v.literal("openai"),
v.literal("skill"),
v.literal("slug"),
),
),
summary: v.optional(v.string()),
summarySource: v.optional(
v.union(
v.literal("publisher"),
v.literal("openai"),
v.literal("skill"),
v.literal("generated"),
),
),
icon: v.optional(v.string()),
}),
),
}),
),
sha256hash: v.optional(v.string()),
@@ -3961,6 +4021,7 @@ export default defineSchema({
packageTopicSearchDigest,
packagePluginCategorySearchDigest,
skillVersions,
skillPresentationAssets,
publishAttempts,
skillVersionFingerprints,
skillBadges,
+23
View File
@@ -1394,6 +1394,29 @@ export const prepareGitHubSkillScanRequestInternal = internalMutation({
force: v.optional(v.boolean()),
parsed: v.object({
frontmatter: v.record(v.string(), v.any()),
presentation: v.optional(
v.object({
displayName: v.string(),
displayNameSource: v.optional(
v.union(
v.literal("publisher"),
v.literal("openai"),
v.literal("skill"),
v.literal("slug"),
),
),
summary: v.optional(v.string()),
summarySource: v.optional(
v.union(
v.literal("publisher"),
v.literal("openai"),
v.literal("skill"),
v.literal("generated"),
),
),
icon: v.optional(v.string()),
}),
),
}),
staticScan: staticScanResultValidator,
},
+101
View File
@@ -0,0 +1,101 @@
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc } from "./_generated/dataModel";
import type { ActionCtx, QueryCtx } from "./_generated/server";
import { internalMutation, internalQuery } from "./functions";
import { buildSkillPresentationIconPath } from "./lib/skillPresentation";
const skillPresentationContentTypeValidator = v.union(
v.literal("image/png"),
v.literal("image/jpeg"),
v.literal("image/webp"),
v.literal("image/svg+xml"),
);
type SkillPresentationContentType = Doc<"skillPresentationAssets">["contentType"];
export const getBySha256Internal = internalQuery({
args: { sha256: v.string() },
handler: async (ctx, args) => getSkillPresentationAssetByHash(ctx, args.sha256),
});
export const registerInternal = internalMutation({
args: {
sha256: v.string(),
storageId: v.id("_storage"),
contentType: skillPresentationContentTypeValidator,
size: v.number(),
},
handler: async (ctx, args) => {
const sha256 = normalizeSha256(args.sha256);
const existing = await getSkillPresentationAssetByHash(ctx, sha256);
if (existing) return existing;
const assetId = await ctx.db.insert("skillPresentationAssets", {
...args,
sha256,
createdAt: Date.now(),
});
return (await ctx.db.get(assetId)) as Doc<"skillPresentationAssets">;
},
});
export async function storeSkillPresentationAsset(
ctx: Pick<ActionCtx, "runMutation" | "runQuery" | "storage">,
args: {
bytes: Uint8Array;
sha256: string;
contentType: SkillPresentationContentType;
},
) {
const sha256 = normalizeSha256(args.sha256);
const existing = (await ctx.runQuery(internal.skillPresentationAssets.getBySha256Internal, {
sha256,
})) as Doc<"skillPresentationAssets"> | null;
if (existing) return buildSkillPresentationIconPath(existing.sha256);
const storageId = await ctx.storage.store(
new Blob([new Uint8Array(args.bytes)], { type: args.contentType }),
);
try {
const asset = (await ctx.runMutation(internal.skillPresentationAssets.registerInternal, {
sha256,
storageId,
contentType: args.contentType,
size: args.bytes.byteLength,
})) as Doc<"skillPresentationAssets">;
if (asset.storageId !== storageId) await ctx.storage.delete(storageId);
return buildSkillPresentationIconPath(asset.sha256);
} catch (error) {
await ctx.storage.delete(storageId);
throw error;
}
}
export async function isDecodableSkillPresentationRaster(
ctx: Pick<ActionCtx, "runAction">,
args: {
bytes: Uint8Array;
contentType: Exclude<SkillPresentationContentType, "image/svg+xml">;
},
) {
return (await ctx.runAction(internal.skillPresentationImageNode.validateRasterInternal, {
bytes: new Uint8Array(args.bytes).buffer,
contentType: args.contentType,
})) as boolean;
}
async function getSkillPresentationAssetByHash(ctx: Pick<QueryCtx, "db">, rawSha256: string) {
const sha256 = normalizeSha256(rawSha256);
return await ctx.db
.query("skillPresentationAssets")
.withIndex("by_sha256", (query) => query.eq("sha256", sha256))
.unique();
}
function normalizeSha256(value: string) {
const normalized = value.trim().toLowerCase();
if (!/^[a-f\d]{64}$/.test(normalized)) throw new Error("Invalid skill icon SHA-256 digest.");
return normalized;
}
export type { SkillPresentationContentType };
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from "vitest";
import { skillPresentationAssetHandler } from "./skillPresentationAssetsHttp";
const sha256 = "a".repeat(64);
const bytes = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0]);
const asset = {
_id: "skillPresentationAssets:1",
_creationTime: 1,
sha256,
storageId: "_storage:icon",
contentType: "image/png",
size: bytes.byteLength,
createdAt: 1,
};
describe("skillPresentationAssetHandler", () => {
it("serves immutable exact bytes with image security headers", async () => {
const ctx = {
runQuery: vi.fn(async () => asset),
storage: { get: vi.fn(async () => new Blob([bytes], { type: "image/png" })) },
};
const response = await skillPresentationAssetHandler(
ctx as never,
new Request(`https://clawhub.ai/api/v1/skill-icons/${sha256}`),
);
expect(response.status).toBe(200);
expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes);
expect(response.headers.get("content-type")).toBe("image/png");
expect(response.headers.get("cache-control")).toContain("immutable");
expect(response.headers.get("etag")).toBe(`"sha256:${sha256}"`);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("returns 304 for the immutable content validator", async () => {
const ctx = {
runQuery: vi.fn(async () => asset),
storage: { get: vi.fn() },
};
const response = await skillPresentationAssetHandler(
ctx as never,
new Request(`https://clawhub.ai/api/v1/skill-icons/${sha256}`, {
headers: { "If-None-Match": `"sha256:${sha256}"` },
}),
);
expect(response.status).toBe(304);
expect(ctx.storage.get).not.toHaveBeenCalled();
});
});
+59
View File
@@ -0,0 +1,59 @@
import { internal } from "./_generated/api";
import type { Doc } from "./_generated/dataModel";
import type { ActionCtx } from "./_generated/server";
import { httpAction } from "./functions";
import { corsHeaders, mergeHeaders } from "./lib/httpHeaders";
const ICON_PATH_PREFIX = "/api/v1/skill-icons/";
export async function skillPresentationAssetHandler(ctx: ActionCtx, request: Request) {
const url = new URL(request.url);
const sha256 = url.pathname.startsWith(ICON_PATH_PREFIX)
? url.pathname.slice(ICON_PATH_PREFIX.length).toLowerCase()
: "";
if (!/^[a-f\d]{64}$/.test(sha256)) return iconText("Not found", 404);
const asset = (await ctx.runQuery(internal.skillPresentationAssets.getBySha256Internal, {
sha256,
})) as Doc<"skillPresentationAssets"> | null;
if (!asset) return iconText("Not found", 404);
const etag = `"sha256:${asset.sha256}"`;
if (request.headers.get("if-none-match") === etag) {
return new Response(null, { status: 304, headers: iconHeaders(asset, etag) });
}
const blob = await ctx.storage.get(asset.storageId);
if (!blob) return iconText("Not found", 404);
return new Response(new Uint8Array(await blob.arrayBuffer()), {
status: 200,
headers: iconHeaders(asset, etag),
});
}
function iconHeaders(asset: Doc<"skillPresentationAssets">, etag: string) {
return mergeHeaders(
{
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": asset.contentType,
"Content-Length": String(asset.size),
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
ETag: etag,
"X-Content-SHA256": asset.sha256,
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
},
corsHeaders(),
);
}
function iconText(value: string, status: number) {
return new Response(value, {
status,
headers: mergeHeaders(
{ "Cache-Control": "no-store", "Content-Type": "text/plain; charset=utf-8" },
corsHeaders(),
),
});
}
export const skillPresentationAssetHttp = httpAction(skillPresentationAssetHandler);
+461
View File
@@ -0,0 +1,461 @@
import { ConvexError, v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery } from "./_generated/server";
import { sha256Hex } from "./lib/clawpack";
import {
MAX_SKILL_PRESENTATION_YAML_BYTES,
OPENAI_SKILL_PRESENTATION_PATH,
buildSkillPresentationIconPath,
parseOpenAiSkillPresentation,
validateSkillPresentationIcon,
} from "./lib/skillPresentation";
import {
preserveHistoricalHostedIcon,
resolveHistoricalSkillPresentation,
} from "./lib/skillPresentationBackfill";
import { syncSkillSearchDigestForSkill } from "./lib/skillSearchDigest";
import {
isDecodableSkillPresentationRaster,
storeSkillPresentationAsset,
type SkillPresentationContentType,
} from "./skillPresentationAssets";
const DEFAULT_BATCH_SIZE = 20;
const MAX_BATCH_SIZE = 50;
const DEFAULT_MAX_BATCHES = 50;
const MAX_MAX_BATCHES = 500;
const APPLY_CONFIRM = "backfill-skill-presentation-metadata";
const MAX_SAMPLES = 25;
type BackfillCandidate = {
skillId: Id<"skills">;
slug: string;
displayName: string;
summary?: string;
skillIcon?: string;
versionId: Id<"skillVersions">;
versionIcon?: string;
parsed: Doc<"skillVersions">["parsed"];
files: Doc<"skillVersions">["files"];
};
type BackfillPage = {
candidates: BackfillCandidate[];
cursor: string | null;
isDone: boolean;
stats: {
skillsScanned: number;
skippedDeleted: number;
skippedGitHub: number;
missingLatestVersion: number;
unavailableLatestVersion: number;
};
};
type BackfillStats = BackfillPage["stats"] & {
metadataFilesFound: number;
eligibleSkills: number;
eligibleSkillsWithIcon: number;
missingMetadataBlob: number;
invalidMetadata: number;
missingIconFile: number;
invalidIcon: number;
alreadyCurrent: number;
wouldPatchSkills: number;
patchedSkills: number;
changedBeforeApply: number;
};
type BackfillSample = {
skillId: Id<"skills">;
versionId: Id<"skillVersions">;
slug: string;
displayName: string;
nextDisplayName: string;
hasIcon: boolean;
};
type BackfillResult = {
ok: true;
dryRun: boolean;
confirmRequired?: typeof APPLY_CONFIRM;
cursor: string | null;
isDone: boolean;
stats: BackfillStats;
samples: BackfillSample[];
};
type PreparedIcon = {
bytes: Uint8Array;
contentType: SkillPresentationContentType;
sha256: string;
};
export const getBackfillPageInternal = internalQuery({
args: {
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
},
handler: async (ctx, args): Promise<BackfillPage> => {
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
const page = await ctx.db
.query("skills")
.order("asc")
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
const candidates: BackfillCandidate[] = [];
const stats = {
skillsScanned: page.page.length,
skippedDeleted: 0,
skippedGitHub: 0,
missingLatestVersion: 0,
unavailableLatestVersion: 0,
};
for (const skill of page.page) {
if (skill.softDeletedAt !== undefined) {
stats.skippedDeleted += 1;
continue;
}
if (skill.installKind === "github") {
stats.skippedGitHub += 1;
continue;
}
if (!skill.latestVersionId) {
stats.missingLatestVersion += 1;
continue;
}
const version = await ctx.db.get(skill.latestVersionId);
if (
!version ||
version.skillId !== skill._id ||
version.softDeletedAt !== undefined ||
version.ownerDeletedAt !== undefined ||
version.publicationStatus === "pending" ||
version.publicationStatus === "blocked"
) {
stats.unavailableLatestVersion += 1;
continue;
}
candidates.push({
skillId: skill._id,
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary,
skillIcon: skill.icon,
versionId: version._id,
versionIcon: version.icon,
parsed: version.parsed,
files: version.files,
});
}
return {
candidates,
cursor: page.continueCursor,
isDone: page.isDone,
stats,
};
},
});
export const applyBackfillPatchInternal = internalMutation({
args: {
confirm: v.string(),
skillId: v.id("skills"),
versionId: v.id("skillVersions"),
displayName: v.string(),
displayNameSource: v.union(
v.literal("publisher"),
v.literal("openai"),
v.literal("skill"),
v.literal("slug"),
),
summary: v.optional(v.string()),
summarySource: v.optional(
v.union(
v.literal("publisher"),
v.literal("openai"),
v.literal("skill"),
v.literal("generated"),
),
),
icon: v.optional(v.string()),
},
handler: async (ctx, args) => {
if (args.confirm !== APPLY_CONFIRM) {
throw new ConvexError(`Pass confirm="${APPLY_CONFIRM}" to apply.`);
}
const skill = await ctx.db.get(args.skillId);
const version = await ctx.db.get(args.versionId);
if (
!skill ||
!version ||
skill.latestVersionId !== version._id ||
version.skillId !== skill._id ||
skill.softDeletedAt !== undefined ||
version.softDeletedAt !== undefined ||
version.ownerDeletedAt !== undefined ||
version.publicationStatus === "pending" ||
version.publicationStatus === "blocked"
) {
return { patched: false as const, reason: "changed_before_apply" as const };
}
const presentation = {
displayName: args.displayName,
displayNameSource: args.displayNameSource,
...(args.summary && args.summarySource
? { summary: args.summary, summarySource: args.summarySource }
: {}),
...(args.icon ? { icon: args.icon } : {}),
};
await ctx.db.patch(version._id, {
parsed: { ...version.parsed, presentation },
// Legacy publisher icons are independent of agents/openai.yaml metadata.
...(args.icon ? { icon: args.icon } : {}),
});
await ctx.db.patch(skill._id, {
displayName: args.displayName,
...(args.summary ? { summary: args.summary } : {}),
...(args.icon ? { icon: args.icon } : {}),
...(skill.latestVersionSummary && args.summary
? {
latestVersionSummary: {
...skill.latestVersionSummary,
description: args.summary,
},
}
: {}),
});
await syncSkillSearchDigestForSkill(ctx, await ctx.db.get(skill._id));
return { patched: true as const };
},
});
export const runInternal = internalAction({
args: {
dryRun: v.optional(v.boolean()),
confirm: v.optional(v.string()),
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
},
handler: async (ctx, args): Promise<BackfillResult> => {
// This stays action-driven instead of using @convex-dev/migrations because
// every candidate needs storage reads and raster validation before a write.
const dryRun = args.dryRun !== false;
if (!dryRun && args.confirm !== APPLY_CONFIRM) {
throw new ConvexError(`Pass confirm="${APPLY_CONFIRM}" to apply.`);
}
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES);
const stats = emptyStats();
const samples: BackfillSample[] = [];
let cursor: string | null = args.cursor ?? null;
let isDone = false;
for (let batchIndex = 0; batchIndex < maxBatches; batchIndex += 1) {
const page = (await ctx.runQuery(internal.skillPresentationBackfill.getBackfillPageInternal, {
cursor: cursor ?? undefined,
batchSize: args.batchSize,
})) as BackfillPage;
addPageStats(stats, page.stats);
cursor = page.cursor;
isDone = page.isDone;
for (const candidate of page.candidates) {
const metadataFile = candidate.files.find(
(file) => file.path.toLowerCase() === OPENAI_SKILL_PRESENTATION_PATH,
);
if (!metadataFile) continue;
stats.metadataFilesFound += 1;
const metadataBlob = await ctx.storage.get(metadataFile.storageId);
if (!metadataBlob) {
stats.missingMetadataBlob += 1;
continue;
}
if (metadataBlob.size > MAX_SKILL_PRESENTATION_YAML_BYTES) {
stats.invalidMetadata += 1;
continue;
}
const openAi = parseOpenAiSkillPresentation(await metadataBlob.text());
if (!openAi) {
stats.invalidMetadata += 1;
continue;
}
stats.eligibleSkills += 1;
const presentation = resolveHistoricalSkillPresentation({
slug: candidate.slug,
currentDisplayName: candidate.displayName,
currentSummary: candidate.summary,
frontmatter: candidate.parsed.frontmatter,
openAi,
});
const preparedIcon = await prepareIcon(ctx, candidate.files, presentation.iconPaths, stats);
const iconPath = preparedIcon
? buildSkillPresentationIconPath(preparedIcon.sha256)
: preserveHistoricalHostedIcon(
candidate.parsed.presentation?.icon,
candidate.versionIcon,
candidate.skillIcon,
);
if (preparedIcon) stats.eligibleSkillsWithIcon += 1;
const nextPresentation = {
displayName: presentation.displayName,
displayNameSource: presentation.displayNameSource,
...(presentation.summary && presentation.summarySource
? { summary: presentation.summary, summarySource: presentation.summarySource }
: {}),
...(iconPath ? { icon: iconPath } : {}),
};
const alreadyCurrent =
samePresentation(candidate.parsed.presentation, nextPresentation) &&
candidate.displayName === presentation.displayName &&
(candidate.summary ?? undefined) === (presentation.summary ?? undefined) &&
// An absent presentation icon must not clear an unrelated publisher icon.
(!iconPath || (candidate.versionIcon === iconPath && candidate.skillIcon === iconPath));
if (alreadyCurrent) {
stats.alreadyCurrent += 1;
continue;
}
stats.wouldPatchSkills += 1;
if (samples.length < MAX_SAMPLES) {
samples.push({
skillId: candidate.skillId,
versionId: candidate.versionId,
slug: candidate.slug,
displayName: candidate.displayName,
nextDisplayName: presentation.displayName,
hasIcon: Boolean(preparedIcon),
});
}
if (dryRun) continue;
const storedIcon = preparedIcon
? await storeSkillPresentationAsset(ctx, preparedIcon)
: iconPath;
const result = (await ctx.runMutation(
internal.skillPresentationBackfill.applyBackfillPatchInternal,
{
confirm: args.confirm as string,
skillId: candidate.skillId,
versionId: candidate.versionId,
displayName: presentation.displayName,
displayNameSource: presentation.displayNameSource,
summary: presentation.summary,
summarySource: presentation.summarySource,
icon: storedIcon,
},
)) as { patched: boolean; reason?: "changed_before_apply" };
if (result.patched) stats.patchedSkills += 1;
else stats.changedBeforeApply += 1;
}
if (isDone) break;
}
return {
ok: true,
dryRun,
...(dryRun ? { confirmRequired: APPLY_CONFIRM } : {}),
cursor,
isDone,
stats,
samples,
};
},
});
async function prepareIcon(
ctx: ActionCtx,
files: Doc<"skillVersions">["files"],
iconPaths: string[] | undefined,
stats: BackfillStats,
): Promise<PreparedIcon | undefined> {
for (const iconPath of iconPaths ?? []) {
const file = files.find((candidate) => candidate.path === iconPath);
if (!file) {
stats.missingIconFile += 1;
continue;
}
const blob = await ctx.storage.get(file.storageId);
if (!blob) {
stats.missingIconFile += 1;
continue;
}
const bytes = new Uint8Array(await blob.arrayBuffer());
try {
const validated = validateSkillPresentationIcon({
path: iconPath,
bytes,
contentType: file.contentType,
});
if (
validated.contentType !== "image/svg+xml" &&
!(await isDecodableSkillPresentationRaster(ctx, {
bytes,
contentType: validated.contentType,
}))
) {
stats.invalidIcon += 1;
continue;
}
return {
bytes,
contentType: validated.contentType,
sha256: await sha256Hex(bytes),
};
} catch {
stats.invalidIcon += 1;
}
}
return undefined;
}
function emptyStats(): BackfillStats {
return {
skillsScanned: 0,
skippedDeleted: 0,
skippedGitHub: 0,
missingLatestVersion: 0,
unavailableLatestVersion: 0,
metadataFilesFound: 0,
eligibleSkills: 0,
eligibleSkillsWithIcon: 0,
missingMetadataBlob: 0,
invalidMetadata: 0,
missingIconFile: 0,
invalidIcon: 0,
alreadyCurrent: 0,
wouldPatchSkills: 0,
patchedSkills: 0,
changedBeforeApply: 0,
};
}
function addPageStats(target: BackfillStats, page: BackfillPage["stats"]) {
target.skillsScanned += page.skillsScanned;
target.skippedDeleted += page.skippedDeleted;
target.skippedGitHub += page.skippedGitHub;
target.missingLatestVersion += page.missingLatestVersion;
target.unavailableLatestVersion += page.unavailableLatestVersion;
}
function samePresentation(
current: Doc<"skillVersions">["parsed"]["presentation"],
next: NonNullable<Doc<"skillVersions">["parsed"]["presentation"]>,
) {
return (
current?.displayName === next.displayName &&
current.displayNameSource === next.displayNameSource &&
(current.summary ?? undefined) === (next.summary ?? undefined) &&
(current.summarySource ?? undefined) === (next.summarySource ?? undefined) &&
(current.icon ?? undefined) === (next.icon ?? undefined)
);
}
function clampInt(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, Math.floor(value)));
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { validateRasterInternal } from "./skillPresentationImageNode";
type WrappedHandler = {
_handler: (
ctx: unknown,
args: { bytes: ArrayBuffer; contentType: "image/png" },
) => Promise<boolean>;
};
const validateRaster = (validateRasterInternal as unknown as WrappedHandler)._handler;
const png = Uint8Array.from(
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
);
describe("validateRasterInternal", () => {
it("fully decodes valid raster bytes and rejects truncated images", async () => {
await expect(
validateRaster({}, { bytes: new Uint8Array(png).buffer, contentType: "image/png" }),
).resolves.toBe(true);
await expect(
validateRaster(
{},
{ bytes: new Uint8Array(png.slice(0, 40)).buffer, contentType: "image/png" },
),
).resolves.toBe(false);
});
});
+34
View File
@@ -0,0 +1,34 @@
"use node";
import { v } from "convex/values";
import sharp from "sharp";
import { internalAction } from "./_generated/server";
const MAX_ICON_PIXELS = 2048 * 2048;
export const validateRasterInternal = internalAction({
args: {
bytes: v.bytes(),
contentType: v.union(v.literal("image/png"), v.literal("image/jpeg"), v.literal("image/webp")),
},
handler: async (_ctx, args) => {
try {
const input = Buffer.from(args.bytes);
const image = sharp(input, { failOn: "warning", limitInputPixels: MAX_ICON_PIXELS });
const metadata = await image.metadata();
const expectedFormat = args.contentType === "image/jpeg" ? "jpeg" : args.contentType.slice(6);
if (
metadata.format !== expectedFormat ||
!metadata.width ||
!metadata.height ||
metadata.width * metadata.height > MAX_ICON_PIXELS
) {
return false;
}
await image.raw().toBuffer();
return true;
} catch {
return false;
}
},
});
+26
View File
@@ -526,6 +526,32 @@ describe("skills.insertVersion latest-tag protection", () => {
});
});
it("clears a stale hosted presentation icon when the new latest omits it", async () => {
const skill = buildExistingSkill({
icon: `/api/v1/skill-icons/${"a".repeat(64)}`,
});
const { ctx, captured } = buildCtx(skill);
await insertVersionHandler(
ctx as never,
buildPublishArgs({ version: "2.1.0", icon: undefined }) as never,
);
expect(captured.skillPatches.at(-1)).toHaveProperty("icon", undefined);
});
it("preserves a legacy publisher icon when the new latest omits presentation metadata", async () => {
const skill = buildExistingSkill({ icon: "lucide:Plug" });
const { ctx, captured } = buildCtx(skill);
await insertVersionHandler(
ctx as never,
buildPublishArgs({ version: "2.1.0", icon: undefined }) as never,
);
expect(captured.skillPatches.at(-1)).toMatchObject({ icon: "lucide:Plug" });
});
it("publishes suspicious prepublication results as active and flagged", async () => {
const skill = buildExistingSkill();
const { ctx, captured } = buildCtx(skill);
+38 -7
View File
@@ -137,6 +137,7 @@ import {
sourceSkillVersionFiles,
} from "./lib/skillCards";
import { isPublicSkillVersionAvailableForSkill } from "./lib/skillFileAccess";
import { isHostedSkillPresentationIconPath } from "./lib/skillPresentation";
import {
fetchText,
queueHighlightedWebhook,
@@ -575,6 +576,8 @@ function latestVersionSummaryFromSkillVersion(
function skillSummaryFromSkillVersion(
version: Pick<Doc<"skillVersions">, "parsed"> | null | undefined,
) {
const presentationSummary = version?.parsed?.presentation?.summary?.trim();
if (presentationSummary) return presentationSummary;
return version?.parsed?.frontmatter
? getFrontmatterValue(version.parsed.frontmatter, "description")?.trim() || undefined
: undefined;
@@ -583,6 +586,8 @@ function skillSummaryFromSkillVersion(
function skillDisplayNameFromSkillVersion(
version: Pick<Doc<"skillVersions">, "parsed"> | null | undefined,
) {
const presentationDisplayName = version?.parsed?.presentation?.displayName?.trim();
if (presentationDisplayName) return presentationDisplayName;
return version?.parsed?.frontmatter
? getFrontmatterValue(version.parsed.frontmatter, "name")?.trim() || undefined
: undefined;
@@ -2171,9 +2176,7 @@ function toPublicSkillVersion(
version: Doc<"skillVersions"> | null | undefined,
): PublicSkillVersion | null {
if (!version) return null;
const description = version.parsed?.frontmatter
? getFrontmatterValue(version.parsed.frontmatter, "description")?.trim()
: undefined;
const description = skillSummaryFromSkillVersion(version);
return {
_id: version._id,
_creationTime: version._creationTime,
@@ -12137,6 +12140,7 @@ type SkillPendingPublishArgs = {
userId: Id<"users">;
ownerPublisherId?: Id<"publishers">;
displayName: string;
icon?: string;
version: string;
changelog: string;
changelogSource?: "auto" | "user";
@@ -12200,6 +12204,7 @@ export const insertVersion = internalMutation({
migrateOwner: v.optional(v.boolean()),
slug: v.string(),
displayName: v.string(),
icon: v.optional(v.string()),
version: v.string(),
changelog: v.string(),
changelogSource: v.optional(v.union(v.literal("auto"), v.literal("user"))),
@@ -12240,6 +12245,29 @@ export const insertVersion = internalMutation({
metadata: v.optional(v.any()),
clawdis: v.optional(v.any()),
license: v.optional(v.literal(PLATFORM_SKILL_LICENSE)),
presentation: v.optional(
v.object({
displayName: v.string(),
displayNameSource: v.optional(
v.union(
v.literal("publisher"),
v.literal("openai"),
v.literal("skill"),
v.literal("slug"),
),
),
summary: v.optional(v.string()),
summarySource: v.optional(
v.union(
v.literal("publisher"),
v.literal("openai"),
v.literal("skill"),
v.literal("generated"),
),
),
icon: v.optional(v.string()),
}),
),
}),
summary: v.optional(v.string()),
qualityAssessment: v.optional(
@@ -12706,7 +12734,7 @@ export const insertVersion = internalMutation({
slug,
displayName: args.displayName,
summary: summaryValue,
icon: undefined,
icon: args.icon,
ownerUserId: userId,
ownerPublisherId,
canonicalSkillId,
@@ -12793,7 +12821,7 @@ export const insertVersion = internalMutation({
sourceProvenance: args.sourceProvenance,
changelog: args.changelog,
changelogSource: args.changelogSource,
icon: undefined,
icon: args.icon,
files: args.files,
parsed: args.parsed,
staticScan: args.staticScan,
@@ -12893,7 +12921,9 @@ export const insertVersion = internalMutation({
const basePatch: SkillModerationPatch = {
displayName: nextDisplayName,
summary: nextSummary ?? undefined,
icon: skill.icon,
icon: isNewLatest
? (args.icon ?? (isHostedSkillPresentationIconPath(skill.icon) ? undefined : skill.icon))
: skill.icon,
ownerPublisherId: skill.ownerPublisherId ?? ownerPublisherId,
latestVersionId: isNewLatest ? versionId : skill.latestVersionId,
latestVersionSummary: isNewLatest
@@ -12902,7 +12932,8 @@ export const insertVersion = internalMutation({
createdAt: now,
changelog: args.changelog,
changelogSource: args.changelogSource,
description: getFrontmatterValue(args.parsed.frontmatter, "description")?.trim(),
description:
args.summary ?? getFrontmatterValue(args.parsed.frontmatter, "description")?.trim(),
clawdis: args.parsed.clawdis,
}
: skill.latestVersionSummary,
+27 -6
View File
@@ -544,6 +544,7 @@ export async function publishSkillVersion(
ownerHandle: string;
slug: string;
displayName: string;
expectedDisplayName?: string;
version: string;
versionLabel: string;
changelog: string;
@@ -553,6 +554,7 @@ export async function publishSkillVersion(
files?: Array<{ path: string; contents: string | Uint8Array }>;
},
) {
const expectedDisplayName = args.expectedDisplayName ?? args.displayName;
const skillDir = testInfo.outputPath(`${args.slug}-${args.version}`);
await mkdir(skillDir, { recursive: true });
await writeFile(
@@ -584,7 +586,12 @@ export async function publishSkillVersion(
// upload. Treat that navigation as success instead of waiting and retrying.
if (pathname === "/dashboard") return "staged";
if (detailUrlPattern.test(pathname)) {
if (await isPublishedDetailCurrentVersionVisible(page, args)) {
if (
await isPublishedDetailCurrentVersionVisible(page, {
...args,
displayName: expectedDisplayName,
})
) {
return "published";
}
if (await versionExists()) return "staged";
@@ -615,7 +622,12 @@ export async function publishSkillVersion(
await page.goto(skillDetailPath(args.ownerHandle, args.slug), {
waitUntil: "domcontentloaded",
});
if (!(await isPublishedDetailCurrentVersionVisible(page, args))) {
if (
!(await isPublishedDetailCurrentVersionVisible(page, {
...args,
displayName: expectedDisplayName,
}))
) {
const completion = await completeMockPrePublicationChecks({
kind: "skill",
slug: args.slug,
@@ -628,14 +640,20 @@ export async function publishSkillVersion(
await page.goto(skillDetailPath(args.ownerHandle, args.slug), {
waitUntil: "domcontentloaded",
});
await expectPublishedDetailCurrentVersion(page, args);
await expectPublishedDetailCurrentVersion(page, {
...args,
displayName: expectedDisplayName,
});
break;
} catch (error) {
await page.goto(skillDetailPath(args.ownerHandle, args.slug), {
waitUntil: "domcontentloaded",
});
try {
await expectPublishedDetailCurrentVersion(page, args);
await expectPublishedDetailCurrentVersion(page, {
...args,
displayName: expectedDisplayName,
});
if (!args.versionExists || (await versionExists())) break;
await page.goto(publishUrl, { waitUntil: "domcontentloaded" });
await waitForPublishSkillForm(page);
@@ -656,7 +674,7 @@ export async function publishSkillVersion(
expect(actualOwnerHandle?.toLowerCase()).toContain(args.ownerHandle.toLowerCase());
expect(actualSlug).toBe(args.slug);
expect(new URL(page.url()).pathname).toBe(buildSkillDetailHref(actualOwnerHandle!, args.slug));
await expectPublishedDetailPage(page, args.displayName);
await expectPublishedDetailPage(page, expectedDisplayName);
const successDialog = page.getByRole("dialog", { name: /it's alive/i });
if (await successDialog.isVisible().catch(() => false)) {
try {
@@ -669,6 +687,9 @@ export async function publishSkillVersion(
await waitForHydration(page);
}
}
await expectPublishedDetailCurrentVersion(page, args);
await expectPublishedDetailCurrentVersion(page, {
...args,
displayName: expectedDisplayName,
});
return actualOwnerHandle!;
}
@@ -0,0 +1,138 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
import { waitForHydration } from "../helpers/runtimeErrors";
import { publishSkillVersion, signInAsLocalPublisher, skillMd } from "./helpers";
test.skip(
process.env.VITE_ENABLE_DEV_AUTH !== "1",
"skill presentation metadata tests require the local dev auth runner",
);
test.setTimeout(600_000);
const iconBytes = Uint8Array.from(
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
);
test("agents/openai.yaml drives hosted skill presentation in UI and feed", async ({
page,
}, testInfo) => {
await signInAsLocalPublisher(page, "officialOrgMember");
const ownerHandle = "local-official-org";
const slug = `pw-presentation-${Date.now().toString(36)}`;
const version = "1.0.0";
await publishSkillVersion(page, testInfo, {
ownerHandle,
slug,
displayName: "Presentation Fixture",
expectedDisplayName: "OpenAI Presentation Fixture",
version,
versionLabel: "presentation metadata release",
changelog: "Verify normalized presentation metadata and hosted icon delivery.",
skillMarkdown: skillMd({
slug,
displayName: "Presentation Fixture",
versionLabel: "presentation metadata release",
}).replace(`name: ${slug}`, "name: Presentation Fixture"),
files: [
{
path: "agents/openai.yaml",
contents:
"interface:\n display_name: '✨ OpenAI Presentation Fixture'\n short_description: Feed-ready OpenAI summary.\n icon_small: assets/icon.png\n",
},
{ path: "assets/icon.png", contents: iconBytes },
],
});
await waitForHydration(page);
await expect(page.locator("h1.skill-page-title")).toHaveText("OpenAI Presentation Fixture");
await expect(page.getByText("Feed-ready OpenAI summary.")).toBeVisible();
const icon = page.locator(".skill-hero-title-row img.marketplace-icon-image");
await expect(icon).toBeVisible();
await expect
.poll(() =>
icon.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0),
)
.toBe(true);
const iconUrl = await icon.getAttribute("src");
expect(iconUrl).toMatch(/^\/api\/v1\/skill-icons\/[a-f\d]{64}$/);
const iconResponse = await page.request.get(new URL(iconUrl!, page.url()).toString());
expect(iconResponse.status()).toBe(200);
expect(new Uint8Array(await iconResponse.body())).toEqual(iconBytes);
expect(iconResponse.headers()["cache-control"]).toContain("immutable");
markLatestVersionDownloadableForLocalFeed(ownerHandle, slug);
publishCatalogFeeds();
const feedResponse = await page.request.get(`${convexSiteUrl()}/api/v1/feeds/skills`);
expect(feedResponse.status()).toBe(200);
const feed = (await feedResponse.json()) as {
entries: Array<{ id: string; title?: string; description?: string; icon?: string }>;
};
expect(feed.entries).toContainEqual(
expect.objectContaining({
id: `@${ownerHandle}/${slug}`,
title: "OpenAI Presentation Fixture",
description: "Feed-ready OpenAI summary.",
icon: expect.stringMatching(/^https:\/\/clawhub\.ai\/api\/v1\/skill-icons\/[a-f\d]{64}$/),
}),
);
});
function convexSiteUrl() {
const url = process.env.VITE_CONVEX_SITE_URL;
if (!url) throw new Error("VITE_CONVEX_SITE_URL is required");
return url.replace(/\/$/u, "");
}
function publishCatalogFeeds() {
runLocalConvex("catalogFeed:publish", {
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
});
}
function markLatestVersionDownloadableForLocalFeed(ownerHandle: string, slug: string) {
const result = runLocalConvex("skills:getBySlug", { ownerHandle, slug }) as {
latestVersion?: { _id?: string } | null;
};
const versionId = result.latestVersion?._id;
if (!versionId) throw new Error("Published skill version was not available");
runLocalConvex("skills:updateVersionScanResultsInternal", {
versionId,
sha256hash: "a".repeat(64),
});
}
function runLocalConvex(functionName: string, args: Record<string, unknown>) {
const config = JSON.parse(readFileSync(".convex/local/default/config.json", "utf8")) as {
deploymentName?: string;
};
if (!config.deploymentName) throw new Error("Local Convex deployment name was not available");
const result = spawnSync(
"bunx",
[
"convex",
"run",
"--typecheck",
"disable",
"--codegen",
"disable",
functionName,
JSON.stringify(args),
],
{
cwd: process.cwd(),
env: { ...process.env, CONVEX_DEPLOYMENT: `local:${config.deploymentName}` },
encoding: "utf8",
},
);
if (result.status !== 0) {
throw new Error(`Convex function ${functionName} failed:\n${result.stdout}\n${result.stderr}`);
}
return result.stdout.trim() ? (JSON.parse(result.stdout) as unknown) : null;
}
+12 -5
View File
@@ -31,6 +31,7 @@ import { gravatarUrl } from "../lib/gravatar";
import { PRIMARY_NAV_ITEMS, SECONDARY_NAV_ITEMS } from "../lib/nav-items";
import { buildPublisherProfileHref, buildSkillDetailHref } from "../lib/ownerRoute";
import { buildPluginDetailHref, displayPluginPackageName } from "../lib/pluginRoutes";
import { presentationTitle } from "../lib/presentationTitle";
import { SITE_NAME } from "../lib/site";
import { applyTheme, useThemeMode } from "../lib/theme";
import { clearAuthError, setAuthError } from "../lib/useAuthError";
@@ -1019,16 +1020,22 @@ function getTypeaheadOptionId(item: TypeaheadItem) {
function TypeaheadRowIcon({ item }: { item: TypeaheadItem }) {
if (item.kind === "skill") {
const label = item.result.skill.displayName || item.result.skill.slug;
const label = presentationTitle(item.result.skill.displayName, item.result.skill.slug);
return (
<span className="navbar-search-typeahead-icon" aria-hidden="true">
<MarketplaceIcon kind="skill" label={label} skill={item.result.skill} size="xs" />
<MarketplaceIcon
kind="skill"
label={label}
imageUrl={item.result.skill.icon}
skill={item.result.skill}
size="xs"
/>
</span>
);
}
if (item.kind === "plugin") {
const label = item.result.plugin.displayName || item.result.plugin.name;
const label = presentationTitle(item.result.plugin.displayName, item.result.plugin.name);
return (
<span className="navbar-search-typeahead-icon" aria-hidden="true">
<MarketplaceIcon
@@ -1062,7 +1069,7 @@ function getTypeaheadRowBody(item: TypeaheadItem) {
if (item.kind === "skill") {
const owner = item.result.ownerHandle ? `@${item.result.ownerHandle}` : "Skill";
return {
title: item.result.skill.displayName,
title: presentationTitle(item.result.skill.displayName, item.result.skill.slug),
meta: (
<TypeaheadPublisherMeta
owner={owner}
@@ -1076,7 +1083,7 @@ function getTypeaheadRowBody(item: TypeaheadItem) {
const packageName = displayPluginPackageName(item.result.plugin.name);
const owner = item.result.plugin.ownerHandle ? `@${item.result.plugin.ownerHandle}` : null;
return {
title: item.result.plugin.displayName,
title: presentationTitle(item.result.plugin.displayName, item.result.plugin.name),
meta: owner ? (
<TypeaheadPublisherMeta
owner={owner}
+19 -6
View File
@@ -44,6 +44,7 @@ import {
import { formatCompactStat } from "../lib/numberFormat";
import { fetchPluginCatalog, type PackageListItem } from "../lib/packageApi";
import { buildPluginDetailHref } from "../lib/pluginRoutes";
import { presentationTitle } from "../lib/presentationTitle";
import type { PublicSkill, PublicUser } from "../lib/publicUser";
import { PUBLIC_CATALOG_NAME_PREVIEW_LENGTH, truncateText } from "../lib/truncateText";
import { HomeListingCategorySelect } from "./HomeListingCategorySelect";
@@ -181,7 +182,7 @@ function skillLink(entry: SkillPageEntry) {
function HomeListingSkillRow({ entry, showStats }: { entry: SkillPageEntry; showStats: boolean }) {
const handle = entry.ownerHandle || entry.owner?.handle;
const name = entry.skill.displayName || entry.skill.slug;
const name = presentationTitle(entry.skill.displayName, entry.skill.slug);
return (
<Link
@@ -189,7 +190,13 @@ function HomeListingSkillRow({ entry, showStats }: { entry: SkillPageEntry; show
className={`home-v2-listing-row${showStats ? "" : " has-no-stats"}`}
>
<span className="home-v2-listing-row-icon" aria-hidden="true">
<MarketplaceIcon kind="skill" label={name} skill={entry.skill} size="sm" />
<MarketplaceIcon
kind="skill"
label={name}
imageUrl={entry.skill.icon}
skill={entry.skill}
size="sm"
/>
</span>
<div className="home-v2-listing-row-body">
<div className="home-v2-listing-row-title">
@@ -219,7 +226,7 @@ function HomeListingSkillRow({ entry, showStats }: { entry: SkillPageEntry; show
}
function HomeListingPluginRow({ plugin }: { plugin: PackageListItem }) {
const name = plugin.displayName || plugin.name;
const name = presentationTitle(plugin.displayName, plugin.name);
const pluginHref = buildPluginDetailHref(plugin.name, { ownerHandle: plugin.ownerHandle });
return (
@@ -259,7 +266,7 @@ function HomeListingPluginRow({ plugin }: { plugin: PackageListItem }) {
function HomeListingSkillCard({ entry, showStats }: { entry: SkillPageEntry; showStats: boolean }) {
const handle = entry.ownerHandle || entry.owner?.handle;
const name = entry.skill.displayName || entry.skill.slug;
const name = presentationTitle(entry.skill.displayName, entry.skill.slug);
return (
<Link
@@ -270,7 +277,13 @@ function HomeListingSkillCard({ entry, showStats }: { entry: SkillPageEntry; sho
>
<div className="home-v2-listing-card-head">
<span className="home-v2-listing-card-icon" aria-hidden="true">
<MarketplaceIcon kind="skill" label={name} skill={entry.skill} size="sm" />
<MarketplaceIcon
kind="skill"
label={name}
imageUrl={entry.skill.icon}
skill={entry.skill}
size="sm"
/>
</span>
<div className="home-v2-listing-card-id">
<span className="home-v2-listing-card-name" title={name}>
@@ -299,7 +312,7 @@ function HomeListingSkillCard({ entry, showStats }: { entry: SkillPageEntry; sho
}
function HomeListingPluginCard({ plugin }: { plugin: PackageListItem }) {
const name = plugin.displayName || plugin.name;
const name = presentationTitle(plugin.displayName, plugin.name);
const pluginHref = buildPluginDetailHref(plugin.name, { ownerHandle: plugin.ownerHandle });
return (
+23 -5
View File
@@ -3,12 +3,13 @@ import { describe, expect, it } from "vitest";
import { MarketplaceIcon } from "./MarketplaceIcon";
describe("MarketplaceIcon", () => {
it("renders skill glyphs from the primary resolved skill category instead of the custom icon", () => {
it("renders hosted skill icons before the category fallback", () => {
const imageUrl = `/api/v1/skill-icons/${"a".repeat(64)}`;
const { container } = render(
<MarketplaceIcon
kind="skill"
label="Custom Icon Skill"
icon="lucide:Plug"
imageUrl={imageUrl}
skill={{
slug: "custom-icon-skill",
displayName: "Custom Icon Skill",
@@ -18,9 +19,26 @@ describe("MarketplaceIcon", () => {
/>,
);
const glyph = container.querySelector("svg.marketplace-icon-glyph");
expect(glyph?.classList.contains("lucide-wrench")).toBe(true);
expect(glyph?.classList.contains("lucide-plug")).toBe(false);
expect(container.querySelector("img")?.getAttribute("src")).toBe(imageUrl);
expect(container.querySelector("svg.marketplace-icon-glyph")).toBeNull();
});
it("ignores legacy skill custom-icon values", () => {
const { container } = render(
<MarketplaceIcon
kind="skill"
label="Legacy Icon Skill"
imageUrl="lucide:Plug"
skill={{
slug: "legacy-icon-skill",
displayName: "Legacy Icon Skill",
categories: ["developer-tools"],
}}
/>,
);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg.marketplace-icon-glyph")).toBeTruthy();
});
it("renders Slash for skills whose stored category cannot resolve", () => {
+8 -3
View File
@@ -8,8 +8,6 @@ type MarketplaceIconProps = {
label: string;
imageUrl?: string | null;
categorySlug?: string | null;
/** Legacy skill custom-icon value. Ignored for rendering. */
icon?: string | null;
skill?: {
categories?: readonly string[] | null;
inferredCategories?: readonly string[] | null;
@@ -59,7 +57,10 @@ export function MarketplaceIcon({
? (getCategoryIconComponent(pluginCategory.icon) ?? MARKETPLACE_KIND_ICONS.plugin)
: MARKETPLACE_KIND_ICONS[kind];
const hashedTone = hashTone(label);
const visibleImageUrl = imageUrl && failedImageUrl !== imageUrl ? imageUrl : null;
const supportedImageUrl =
kind !== "skill" || isHostedSkillPresentationIcon(imageUrl) ? imageUrl : null;
const visibleImageUrl =
supportedImageUrl && failedImageUrl !== supportedImageUrl ? supportedImageUrl : null;
return (
<span
@@ -90,3 +91,7 @@ export function MarketplaceIcon({
</span>
);
}
function isHostedSkillPresentationIcon(value: string | null | undefined) {
return /^\/api\/v1\/skill-icons\/[a-f\d]{64}$/u.test(value ?? "");
}
+10 -8
View File
@@ -5,6 +5,7 @@ import { BrowseCategoryIcon } from "../lib/browseCategoryIcons";
import { formatCompactStat } from "../lib/numberFormat";
import type { PackageListItem } from "../lib/packageApi";
import { buildPluginDetailHref } from "../lib/pluginRoutes";
import { presentationTitle } from "../lib/presentationTitle";
import { PUBLIC_CATALOG_NAME_PREVIEW_LENGTH, truncateText } from "../lib/truncateText";
import { CatalogTopicList } from "./CatalogTopicList";
import { MarketplaceIcon } from "./MarketplaceIcon";
@@ -50,25 +51,26 @@ export function PluginListItem({ item, variant = "list", href }: PluginListItemP
.map((category) => category.label)
.join(", ");
const pluginHref = href ?? buildPluginDetailHref(item.name, { ownerHandle: item.ownerHandle });
const displayName = presentationTitle(item.displayName, item.name);
if (variant === "card") {
return (
<Link
to={pluginHref}
className="card skill-card plugin-card"
aria-label={`Plugin: ${item.displayName}`}
aria-label={`Plugin: ${displayName}`}
>
<div className="skill-card-header">
<MarketplaceIcon
kind="plugin"
label={item.displayName}
label={displayName}
imageUrl={item.icon}
categorySlug={primaryCategory?.slug}
size="md"
/>
<div className="skill-card-identity">
<h3 className="skill-card-title" title={item.displayName}>
{truncateText(item.displayName, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)}
<h3 className="skill-card-title" title={displayName}>
{truncateText(displayName, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)}
</h3>
<span className="skill-card-owner-row">
<span className="skill-card-owner">
@@ -111,19 +113,19 @@ export function PluginListItem({ item, variant = "list", href }: PluginListItemP
<Link
to={pluginHref}
className="skill-list-item skill-list-item-with-taxonomy"
aria-label={`Plugin: ${item.displayName}`}
aria-label={`Plugin: ${displayName}`}
>
<MarketplaceIcon
kind="plugin"
label={item.displayName}
label={displayName}
imageUrl={item.icon}
categorySlug={primaryCategory?.slug}
/>
<div className="skill-list-item-body">
<div className="skill-list-item-main">
<span className="skill-list-item-identity">
<span className="skill-list-item-name" title={item.displayName}>
{truncateText(item.displayName, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)}
<span className="skill-list-item-name" title={displayName}>
{truncateText(displayName, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)}
</span>
{item.ownerHandle ? (
<span className="skill-list-item-owner">@{item.ownerHandle}</span>
+10 -3
View File
@@ -2,6 +2,7 @@ import { Link } from "@tanstack/react-router";
import { Download } from "lucide-react";
import { formatCompactStat } from "../lib/numberFormat";
import { buildPublisherProfileHref } from "../lib/ownerRoute";
import { presentationTitle } from "../lib/presentationTitle";
import {
type PublicPublisherListItem,
type PublicPublisherPublishedItem,
@@ -23,7 +24,11 @@ function PublishedRail({ items }: { items: PublicPublisherPublishedItem[] }) {
<span className="publisher-published-rail" aria-label="Published packages">
{items.slice(0, 3).map((item) => (
<span className="publisher-published-rail-item" key={`${item.kind}:${item.displayName}`}>
<MarketplaceIcon kind={item.kind} label={item.displayName} size="xs" />
<MarketplaceIcon
kind={item.kind}
label={presentationTitle(item.displayName, item.slug ?? "")}
size="xs"
/>
</span>
))}
</span>
@@ -75,11 +80,13 @@ export function PublisherListItem({
<span key={`${item.kind}:${item.displayName}`}>
<MarketplaceIcon
kind={item.kind}
label={item.displayName}
label={presentationTitle(item.displayName, item.slug ?? "")}
skill={item.kind === "skill" ? item : undefined}
size="xs"
/>
<span className="publisher-card-featured-label">{item.displayName}</span>
<span className="publisher-card-featured-label">
{presentationTitle(item.displayName, item.slug ?? "")}
</span>
<span className="publisher-card-featured-downloads">
<Download size={12} aria-hidden="true" />
<span>{formatCompactStat(readPublicDownloadCount(item))}</span>
+6 -4
View File
@@ -2,6 +2,7 @@ import { Link } from "@tanstack/react-router";
import type { ReactNode } from "react";
import { BrowseCategoryIcon } from "../lib/browseCategoryIcons";
import { getSkillCategoryForSkill } from "../lib/categories";
import { presentationTitle } from "../lib/presentationTitle";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { PUBLIC_CATALOG_NAME_PREVIEW_LENGTH, truncateText } from "../lib/truncateText";
import { CatalogTopicList } from "./CatalogTopicList";
@@ -45,6 +46,7 @@ export function SkillCard({
? ["Official", ...nonOfficialBadges]
: badges;
const primaryCategory = getSkillCategoryForSkill(skill);
const displayName = presentationTitle(skill.displayName, skill.slug);
const hasSecondaryTags =
visibleBadges.length || chip || platformLabels?.length || skill.topics?.length;
const hasTags = primaryCategory || hasSecondaryTags;
@@ -54,14 +56,14 @@ export function SkillCard({
<div className="skill-card-header">
<MarketplaceIcon
kind="skill"
label={skill.displayName}
icon={skill.icon}
label={displayName}
imageUrl={skill.icon}
skill={skill}
size="md"
/>
<div className="skill-card-identity">
<h3 className="skill-card-title" title={skill.displayName}>
{truncateText(skill.displayName, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)}
<h3 className="skill-card-title" title={displayName}>
{truncateText(displayName, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)}
</h3>
{ownerHandle ? (
<span className="skill-card-owner-row">
+27
View File
@@ -556,6 +556,33 @@ describe("SkillHeader", () => {
expect(screen.queryByText("Demo summary")).toBeNull();
});
it("prefers the resolved presentation summary for the latest version", () => {
renderHeader({
latestVersion: {
_id: "skillVersions:demo" as Id<"skillVersions">,
_creationTime: 1,
skillId: skill._id,
version: "1.0.0",
changelog: "Initial release",
files: [],
parsed: {
presentation: {
displayName: "OpenAI Presentation Fixture",
summary: "Feed-ready OpenAI summary.",
},
description: "Full uploaded description.",
frontmatter: {},
},
createdBy: "users:owner" as Id<"users">,
createdAt: 1,
},
});
expect(screen.getByText("Feed-ready OpenAI summary.")).toBeTruthy();
expect(screen.queryByText("Full uploaded description.")).toBeNull();
expect(screen.queryByText("Demo summary")).toBeNull();
});
it("keeps the download action hidden on the detail header", () => {
const { container } = renderHeader({
latestVersion: {
+18 -6
View File
@@ -15,6 +15,7 @@ import {
} from "../lib/categories";
import { formatSkillStatsTriplet } from "../lib/numberFormat";
import { buildPublisherProfileHref } from "../lib/ownerRoute";
import { presentationTitle } from "../lib/presentationTitle";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { timeAgo } from "../lib/timeAgo";
import { useHeroCreatorPublisher } from "../lib/useHeroCreatorPublisher";
@@ -24,6 +25,7 @@ import { DetailHero, DETAIL_HERO_TOPIC_LIMIT } from "./DetailPageShell";
import { DetailSecuritySummaryLabel } from "./DetailSecuritySummary";
import { useDownloadsSidebarMetricBlock } from "./DownloadsMetricCard";
import { InlineCodeSummary } from "./InlineCodeSummary";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { SidebarMetadata } from "./SidebarMetadata";
import { buildSkillHref } from "./skillDetailUtils";
import { SkillCommandLineCard } from "./SkillInstallSurface";
@@ -72,11 +74,13 @@ type SkillHeaderLatestVersion =
function getLatestVersionDescription(latestVersion: SkillHeaderLatestVersion) {
const parsed = latestVersion?.parsed;
const description =
typeof parsed?.description === "string"
? parsed.description
: typeof parsed?.frontmatter?.description === "string"
? parsed.frontmatter.description
: null;
typeof parsed?.presentation?.summary === "string"
? parsed.presentation.summary
: typeof parsed?.description === "string"
? parsed.description
: typeof parsed?.frontmatter?.description === "string"
? parsed.frontmatter.description
: null;
return description?.trim() || null;
}
@@ -314,6 +318,7 @@ export function SkillHeader({
{renderSidebarActions()}
</>
);
const displayName = presentationTitle(skill.displayName, skill.slug);
return (
<>
@@ -418,7 +423,14 @@ export function SkillHeader({
</div>
) : null}
<div className="skill-hero-title-row">
<h1 className="skill-page-title">{skill.displayName}</h1>
<MarketplaceIcon
kind="skill"
label={displayName}
imageUrl={skill.icon}
skill={skill}
size="md"
/>
<h1 className="skill-page-title">{displayName}</h1>
{showTitleBadges ? (
<div className="skill-title-badges">
{titleBadges.map((badge) => (
+5 -3
View File
@@ -3,6 +3,7 @@ import { Download, Star } from "lucide-react";
import { getSkillBadges } from "../lib/badges";
import { getSkillCategoriesForSkill } from "../lib/categories";
import { formatCompactStat } from "../lib/numberFormat";
import { presentationTitle } from "../lib/presentationTitle";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { timeAgo } from "../lib/timeAgo";
import { PUBLIC_CATALOG_NAME_PREVIEW_LENGTH, truncateText } from "../lib/truncateText";
@@ -31,6 +32,7 @@ export function SkillListItem({
const badges = getSkillBadges(skill);
const isOfficial = badges.includes("Official") || owner?.official === true;
const categories = getSkillCategoriesForSkill(skill);
const displayName = presentationTitle(skill.displayName, skill.slug);
const categoryLabel = categories
.slice(0, 3)
.map((category) => category.label)
@@ -38,12 +40,12 @@ export function SkillListItem({
return (
<Link to={href} className="skill-list-item skill-list-item-skill skill-list-item-with-taxonomy">
<MarketplaceIcon kind="skill" label={skill.displayName} icon={skill.icon} skill={skill} />
<MarketplaceIcon kind="skill" label={displayName} imageUrl={skill.icon} skill={skill} />
<div className="skill-list-item-body">
<div className="skill-list-item-main">
<span className="skill-list-item-identity">
<span className="skill-list-item-name" title={skill.displayName}>
{truncateText(skill.displayName, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)}
<span className="skill-list-item-name" title={displayName}>
{truncateText(displayName, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)}
</span>
{handle ? <span className="skill-list-item-owner">@{handle}</span> : null}
</span>
+1 -1
View File
@@ -183,7 +183,7 @@ export function SkillPublishSuccessDialog({
<MarketplaceIcon
kind="skill"
label={displayName}
icon={skill?.icon}
imageUrl={skill?.icon}
skill={skill}
tone="muted"
/>
+5 -2
View File
@@ -2,6 +2,7 @@ import { Download, Star } from "lucide-react";
import { BrowseCategoryIcon } from "../lib/browseCategoryIcons";
import { buildSkillCategoryBrowseHref, type SkillCategory } from "../lib/categories";
import { formatSkillStatsTriplet } from "../lib/numberFormat";
import { presentationTitle } from "../lib/presentationTitle";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { buildSkillHref } from "./skillDetailUtils";
@@ -79,20 +80,22 @@ export function SkillRelatedSection({
ownerId,
entry.skill.slug,
);
const displayName = presentationTitle(entry.skill.displayName, entry.skill.slug);
return (
<a key={entry.skill._id} href={href} className="related-skill-row">
<span className="related-skill-icon" aria-hidden="true">
<MarketplaceIcon
kind="skill"
label={entry.skill.displayName}
label={displayName}
imageUrl={entry.skill.icon}
skill={entry.skill}
size="sm"
/>
</span>
<span className="related-skill-copy">
<span className="related-skill-title-line">
<span className="related-skill-name">{entry.skill.displayName}</span>
<span className="related-skill-name">{displayName}</span>
{isCompact ? (
<span className="related-skill-owner-inline">@{owner}</span>
) : null}
@@ -2,6 +2,7 @@ import { Download, EyeOff } from "lucide-react";
import type { ReactNode } from "react";
import { formatCompactStat } from "../../lib/numberFormat";
import { buildPluginDetailHref } from "../../lib/pluginRoutes";
import { presentationTitle } from "../../lib/presentationTitle";
import { timeAgo } from "../../lib/timeAgo";
import { truncateText } from "../../lib/truncateText";
import {
@@ -104,7 +105,7 @@ function SkillListRow({
<CatalogRow
href={detailHref}
kindLabel="Skill"
title={skill.displayName}
title={presentationTitle(skill.displayName, skill.slug)}
version={skill.latestVersion?.version}
titleAccessory={visibilityIcon(visibility.label)}
secondary={packageRowSecondary(skill.updatedAt)}
@@ -131,7 +132,7 @@ function PluginListRow({
<CatalogRow
href={buildPluginDetailHref(pkg.name, { ownerHandle })}
kindLabel="Plugin"
title={pkg.displayName}
title={presentationTitle(pkg.displayName, pkg.name)}
version={pkg.latestVersion ?? pkg.latestRelease?.version}
secondary={packageRowSecondary(pkg.updatedAt)}
status={packageArtifactStatus(pkg)}
@@ -244,10 +245,18 @@ function SkillGridCard({ skill, ownerHandle }: { skill: DashboardSkill; ownerHan
return (
<DashboardCatalogGridCard
href={detailHref}
title={skill.displayName}
title={presentationTitle(skill.displayName, skill.slug)}
summary={skill.summary}
summaryFallback="Agent-ready skill pack."
icon={<MarketplaceIcon kind="skill" label={skill.displayName} skill={skill} size="sm" />}
icon={
<MarketplaceIcon
kind="skill"
label={presentationTitle(skill.displayName, skill.slug)}
imageUrl={skill.icon}
skill={skill}
size="sm"
/>
}
kindLabel="Skill"
status={skillArtifactStatus(skill)}
downloads={skill.stats?.downloads ?? 0}
@@ -261,10 +270,17 @@ function PluginGridCard({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHand
return (
<DashboardCatalogGridCard
href={buildPluginDetailHref(pkg.name, { ownerHandle })}
title={pkg.displayName}
title={presentationTitle(pkg.displayName, pkg.name)}
summary={pkg.summary}
summaryFallback="Gateway plugin for OpenClaw workflows."
icon={<MarketplaceIcon kind="plugin" label={pkg.displayName} size="sm" />}
icon={
<MarketplaceIcon
kind="plugin"
label={presentationTitle(pkg.displayName, pkg.name)}
imageUrl={pkg.icon}
size="sm"
/>
}
kindLabel="Plugin"
status={packageArtifactStatus(pkg)}
downloads={pkg.stats.downloads ?? 0}
+2
View File
@@ -7,6 +7,7 @@ export type DashboardSkill = Pick<
| "slug"
| "displayName"
| "summary"
| "icon"
| "ownerUserId"
| "ownerPublisherId"
| "canonicalSkillId"
@@ -56,6 +57,7 @@ export type DashboardPackage = {
runtimeId?: string | null;
sourceRepo?: string | null;
summary?: string | null;
icon?: string | null;
latestVersion?: string | null;
inspectorWarningCount?: number;
topInspectorFinding?: {
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { presentationTitle } from "./presentationTitle";
describe("presentationTitle", () => {
it("removes emoji without changing the underlying identity fallback", () => {
expect(presentationTitle("🚀 Ship It ✨", "ship-it")).toBe("Ship It");
expect(presentationTitle("🧑🏽‍💻", "developer-tools")).toBe("developer-tools");
});
});
+7
View File
@@ -0,0 +1,7 @@
const PRESENTATION_EMOJI_PATTERN =
/\p{Extended_Pictographic}|\p{Emoji_Presentation}|\p{Emoji_Modifier}|\p{Regional_Indicator}|\u200D|\uFE0F|\u20E3/gu;
export function presentationTitle(value: string, fallback = "") {
const title = value.replace(PRESENTATION_EMOJI_PATTERN, " ").replace(/\s+/g, " ").trim();
return title || fallback;
}
+1
View File
@@ -16,6 +16,7 @@ export type UnifiedSkillResult = {
slug: string;
displayName: string;
summary?: string | null;
icon?: string;
categories?: string[] | null;
inferredCategories?: string[] | null;
latestVersionId?: string | null;
+6 -2
View File
@@ -17,6 +17,7 @@ import {
type MarketplaceIconComponent,
} from "../lib/marketplaceIcons";
import { formatCompactStat } from "../lib/numberFormat";
import { presentationTitle } from "../lib/presentationTitle";
type AuditTarget = "skills" | "plugins";
type AuditFeedStatus = "loading" | "idle" | "loadingMore" | "done";
@@ -278,7 +279,10 @@ function AuditTableRow({ row }: { row: AuditRow }) {
const clawScanStatus = getClawScanDisplayStatus(latest?.llmAnalysis ?? null);
const vtStatus = getVirusTotalDisplayStatus(latest?.vtAnalysis ?? null);
const ownerHandle = row.kind === "plugin" ? row.package.ownerHandle : row.ownerHandle;
const displayName = row.kind === "plugin" ? row.package.displayName : row.skill.displayName;
const displayName =
row.kind === "plugin"
? presentationTitle(row.package.displayName, row.package.name)
: presentationTitle(row.skill.displayName, row.skill.slug);
const summary = row.kind === "plugin" ? row.package.summary : row.skill.summary;
return (
@@ -287,7 +291,7 @@ function AuditTableRow({ row }: { row: AuditRow }) {
<MarketplaceIcon
kind={row.kind}
label={displayName}
icon={row.kind === "skill" ? row.skill.icon : undefined}
imageUrl={row.kind === "skill" ? row.skill.icon : undefined}
skill={row.kind === "skill" ? row.skill : null}
/>
<div className="audits-item-copy">
+4 -1
View File
@@ -98,6 +98,7 @@ import {
parseScopedPackageName,
} from "../../lib/pluginRoutes";
import { formatValidationFindingMessage } from "../../lib/pluginValidationFormat";
import { presentationTitle } from "../../lib/presentationTitle";
import { buildReadmeAssetBaseUrl } from "../../lib/readmeAssetBaseUrl";
import { timeAgo } from "../../lib/timeAgo";
import { useAuthStatus } from "../../lib/useAuthStatus";
@@ -1680,7 +1681,9 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
</div>
) : null}
<div className="skill-hero-title-row">
<h1 className="skill-page-title">{pkg.displayName}</h1>
<h1 className="skill-page-title">
{presentationTitle(pkg.displayName, pkg.name)}
</h1>
{isDownloadBlocked ? (
<div className="skill-title-actions">
<Badge variant="destructive">Download blocked</Badge>