From 0fb07e5b99fd94254968f0a03f8078caa1f5477b Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Sat, 1 Aug 2026 16:46:48 -0700 Subject: [PATCH] feat: align skills.sh catalog presentation (#3370) * feat: align skills.sh catalog presentation * refactor: share skill detail shell with skills.sh * test: seed skills.sh route fixtures locally * refactor: share full skill detail page view * feat: refine skills.sh detail presentation * feat: refine skills.sh detail metadata * fix: refine skills.sh detail spacing --- convex/devSeed.localFixtures.test.ts | 42 +- convex/devSeed.skillsShRoute.test.ts | 42 + convex/devSeed.ts | 173 +++- scripts/dev-worktree.test.ts | 11 + scripts/dev-worktree.ts | 4 + scripts/seed.test.ts | 14 + scripts/seed.ts | 4 + .../home-listing-section.claw591.test.tsx | 44 +- src/__tests__/home-listing-section.test.tsx | 6 +- src/components/HomeListingSection.tsx | 85 +- src/components/SkillDetailPage.tsx | 227 +++-- ...SkillDetailPageView.interactions.test.tsx} | 16 +- src/components/SkillDetailPageView.test.tsx | 160 ++++ src/components/SkillDetailPageView.tsx | 838 ++++++++++++++++++ src/components/SkillHeader.tsx | 750 ---------------- src/components/SkillInstallSurface.tsx | 37 +- src/components/SkillRelatedSection.test.tsx | 61 ++ src/components/SkillRelatedSection.tsx | 15 +- src/components/SkillsShCatalogDetail.test.tsx | 126 ++- src/components/SkillsShCatalogDetail.tsx | 485 +++++----- src/components/UserBadge.tsx | 5 +- src/components/skillDetailUtils.ts | 9 +- src/lib/skillsShCatalog.ts | 7 - src/lib/trendingApi.ts | 21 + src/styles.css | 140 ++- 25 files changed, 2078 insertions(+), 1244 deletions(-) create mode 100644 convex/devSeed.skillsShRoute.test.ts rename src/components/{SkillHeader.test.tsx => SkillDetailPageView.interactions.test.tsx} (98%) create mode 100644 src/components/SkillDetailPageView.test.tsx create mode 100644 src/components/SkillDetailPageView.tsx delete mode 100644 src/components/SkillHeader.tsx create mode 100644 src/components/SkillRelatedSection.test.tsx diff --git a/convex/devSeed.localFixtures.test.ts b/convex/devSeed.localFixtures.test.ts index 99072338..d3f0b8a6 100644 --- a/convex/devSeed.localFixtures.test.ts +++ b/convex/devSeed.localFixtures.test.ts @@ -176,7 +176,7 @@ function seedSkillArgs(storageId: string) { } describe("devSeed local fixtures", () => { - it("idempotently seeds an activated external row for local canonical search proof", async () => { + it("idempotently seeds an installable skills.sh route for local browser proof", async () => { const { db, tables } = createDb(); await seedCanonicalSearchFixtureHandler(createMutationCtx(db) as never, {}); @@ -184,14 +184,50 @@ describe("devSeed local fixtures", () => { expect(tables.skillsShMirrorRuns).toHaveLength(1); expect(tables.skillsShMirrorDigests).toHaveLength(1); + expect(tables.skillsShMirrorDetails).toHaveLength(1); + expect(tables.skillsShCatalogControls).toHaveLength(1); + expect(tables.skillsShCatalogControls?.[0]).toEqual( + expect.objectContaining({ + key: "global", + mode: "fixture", + mirrorPublicVisibilityEnabled: true, + writesEnabled: false, + scanPlanningEnabled: false, + scanAdmissionEnabled: false, + }), + ); expect(tables.skillsShMirrorDigests?.[0]).toEqual( expect.objectContaining({ - externalId: "acme/skills/risk-auditor", - searchSummary: "Audit agent workflows for security and operational risk.", + externalId: "doany-skills/skills/reddit-automation", + owner: "doany-skills", + repo: "skills", + slug: "reddit-automation", + displayName: "Reddit Automation", + upstreamInstalls: 202_996, active: true, publicVisible: true, installable: true, sourceFreshnessStatus: "observed-only", + detailStatus: "available", + githubPath: "reddit-automation", + githubCommit: "6875ced8582825395c976099fcc6a00734bb09b1", + sourceContentHash: "278abced163b5721c6fec6996f73d521c8901b905b4b2fca45757d1ff0ebbfc6", + }), + ); + expect(tables.skillsShMirrorDetails?.[0]).toEqual( + expect.objectContaining({ + externalId: "doany-skills/skills/reddit-automation", + contentKind: "skill-md", + path: "SKILL.md", + truncated: false, + sourceContentHash: "278abced163b5721c6fec6996f73d521c8901b905b4b2fca45757d1ff0ebbfc6", + }), + ); + expect(tables.skillsShMirrorDetails?.[0]?.content).toContain("# Reddit Automation"); + expect(tables.skillsShMirrorRuns?.[0]).toEqual( + expect.objectContaining({ + status: "completed", + counts: expect.objectContaining({ scansPlanned: 0, scansAdmitted: 0 }), }), ); }); diff --git a/convex/devSeed.skillsShRoute.test.ts b/convex/devSeed.skillsShRoute.test.ts new file mode 100644 index 00000000..39762d83 --- /dev/null +++ b/convex/devSeed.skillsShRoute.test.ts @@ -0,0 +1,42 @@ +/// + +import { convexTest } from "convex-test"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { api, internal } from "./_generated/api"; +import schema from "./schema"; + +const modules = import.meta.glob("./**/*.ts"); + +beforeEach(() => { + vi.stubEnv("CLAWHUB_ENV", "local"); + vi.stubEnv("CLAWHUB_SKILLS_SH_ROLLOUT_MODE", "test"); +}); + +afterEach(() => vi.unstubAllEnvs()); + +it("serves the seeded skills.sh detail through the public local route", async () => { + const t = convexTest(schema, modules); + await t.mutation(internal.devSeed.seedCanonicalSearchFixture, {}); + + await expect( + t.query(api.skillsShMirrorPublic.getByRoute, { + owner: "doany-skills", + repo: "skills", + slug: "reddit-automation", + }), + ).resolves.toMatchObject({ + kind: "external", + entry: { + externalId: "doany-skills/skills/reddit-automation", + displayName: "Reddit Automation", + upstreamInstalls: 202_996, + githubPath: "reddit-automation", + githubCommit: "6875ced8582825395c976099fcc6a00734bb09b1", + content: { + kind: "skill-md", + path: "SKILL.md", + truncated: false, + }, + }, + }); +}); diff --git a/convex/devSeed.ts b/convex/devSeed.ts index 4046dbdb..4a3a67b4 100644 --- a/convex/devSeed.ts +++ b/convex/devSeed.ts @@ -929,21 +929,85 @@ export const seedTestFixtures: ReturnType = internalActio }, }); -const LOCAL_CANONICAL_SEARCH_EXTERNAL_ID = "acme/skills/risk-auditor"; +const LOCAL_SKILLS_SH_EXTERNAL_ID = "doany-skills/skills/reddit-automation"; +const LOCAL_SKILLS_SH_SNAPSHOT_ID = "local-skills-sh-route-v1"; +const LOCAL_SKILLS_SH_COMMIT = "6875ced8582825395c976099fcc6a00734bb09b1"; +const LOCAL_SKILLS_SH_CONTENT_HASH = + "278abced163b5721c6fec6996f73d521c8901b905b4b2fca45757d1ff0ebbfc6"; +const LOCAL_SKILLS_SH_CONTENT = `--- +name: reddit-automation +displayName: Reddit Automation +description: Find relevant Reddit conversations and draft genuinely useful, disclosed replies. +--- -/** Explicit local proof fixture; intentionally not part of shared Test seeding. */ +# Reddit Automation + +Find people on Reddit who genuinely need what you make, then draft a useful response that +honestly discloses who you are. Keep a human in the loop to review and post every reply. + +## When to use + +- Find Reddit conversations relevant to a product. +- Draft a helpful response for a thread the user provides. +- Turn a set of research notes into replies for human review. + +## Guardrails + +- Never auto-post or pretend a draft was published. +- Never invent posts, quotes, or product facts. +- Respect each community's self-promotion rules. +`; + +/** Explicit local/PR-preview fixture; intentionally not part of shared Test seeding. */ export const seedCanonicalSearchFixture = internalMutation({ args: {}, handler: async (ctx) => { const now = Date.now(); + const existingControl = await ctx.db + .query("skillsShCatalogControls") + .withIndex("by_key", (q) => q.eq("key", "global")) + .unique(); + const control = { + key: "global" as const, + mode: "fixture" as const, + discoveryEnabled: false, + writesEnabled: false, + scanPlanningEnabled: false, + scanAdmissionEnabled: false, + publicVisibilityEnabled: false, + mirrorPublicVisibilityEnabled: true, + paused: false, + maxEntriesPerRun: 1, + maxEntriesPerBatch: 1, + maxWritesPerBatch: 1, + maxPlannedScans: 0, + maxScanAdmissionsPerBatch: 0, + maxScanAdmissionsPerRun: 0, + maxScanAdmissionsPerDay: 0, + maxCatalogQueued: 0, + maxCatalogInFlight: 0, + maxNativeQueued: 0, + maxNativeInFlight: 0, + realScanAllowlist: [], + updatedBy: "local-dev-seed", + reason: "Expose the local skills.sh browser fixture without enabling imports or scans.", + updatedAt: now, + }; + if (existingControl) { + await ctx.db.patch(existingControl._id, control); + } else { + await ctx.db.insert("skillsShCatalogControls", control); + } + const existing = await ctx.db .query("skillsShMirrorDigests") - .withIndex("by_external_id", (q) => q.eq("externalId", LOCAL_CANONICAL_SEARCH_EXTERNAL_ID)) + .withIndex("by_external_id", (q) => q.eq("externalId", LOCAL_SKILLS_SH_EXTERNAL_ID)) .unique(); const runId = existing?.lastObservedRunId ?? (await ctx.db.insert("skillsShMirrorRuns", { - snapshotId: "local-canonical-search-v1", + snapshotId: LOCAL_SKILLS_SH_SNAPSHOT_ID, + sourceView: "leaderboard", status: "completed", sourceTotal: 1, sourcePageSize: 1, @@ -959,10 +1023,10 @@ export const seedCanonicalSearchFixture = internalMutation({ quarantined: 0, quarantinedPreserved: 0, conflicts: 0, - detailsInserted: 0, + detailsInserted: 1, detailsUpdated: 0, detailsUnchanged: 0, - detailsMissing: 1, + detailsMissing: 0, detailsTruncated: 0, tombstoned: 0, reactivated: 0, @@ -977,42 +1041,53 @@ export const seedCanonicalSearchFixture = internalMutation({ sourceBytes: 0, }, actor: "local-dev-seed", - reason: "Reusable local canonical mixed-search browser proof fixture.", + reason: "Reusable local skills.sh route browser proof fixture.", startedAt: now, completedAt: now, updatedAt: now, })); const digest = { - externalId: LOCAL_CANONICAL_SEARCH_EXTERNAL_ID, + externalId: LOCAL_SKILLS_SH_EXTERNAL_ID, sourceType: "github" as const, upstreamSourceType: "github", - owner: "acme", + owner: "doany-skills", repo: "skills", - slug: "risk-auditor", - normalizedSlug: "risk auditor", - normalizedSlugFirstToken: "risk", - displayName: "Risk Auditor", - normalizedDisplayName: "risk auditor", - normalizedDisplayNameFirstToken: "risk", - searchSummary: "Audit agent workflows for security and operational risk.", + slug: "reddit-automation", + normalizedSlug: "reddit automation", + normalizedSlugFirstToken: "reddit", + displayName: "Reddit Automation", + normalizedDisplayName: "reddit automation", + normalizedDisplayNameFirstToken: "reddit", + searchSummary: + "Find relevant Reddit conversations and draft genuinely useful, disclosed replies.", searchText: - "Risk Auditor risk-auditor acme skills security risk-management security-audit Audit agent workflows for security and operational risk.", - sourceUrl: "https://skills.sh/acme/skills/risk-auditor", - canonicalRepoUrl: "https://github.com/acme/skills", - githubPath: "skills/risk-auditor", - githubCommit: "0000000000000000000000000000000000000000", - upstreamInstalls: 9_000_000, + "Reddit Automation reddit-automation doany-skills skills automation reddit marketing Find relevant Reddit conversations and draft genuinely useful disclosed replies.", + sourceUrl: `https://www.skills.sh/${LOCAL_SKILLS_SH_EXTERNAL_ID}`, + canonicalRepoUrl: "https://github.com/doany-skills/skills", + githubPath: "reddit-automation", + githubCommit: LOCAL_SKILLS_SH_COMMIT, + sourceContentHash: LOCAL_SKILLS_SH_CONTENT_HASH, + upstreamInstalls: 202_996, upstreamScanners: { - genAgentTrustHub: { status: "unavailable" }, - socket: { status: "unavailable" }, - snyk: { status: "unavailable" }, + genAgentTrustHub: { + status: "pass", + sourceUrl: `https://www.skills.sh/${LOCAL_SKILLS_SH_EXTERNAL_ID}/security/agent-trust-hub`, + }, + socket: { + status: "warn", + sourceUrl: `https://www.skills.sh/${LOCAL_SKILLS_SH_EXTERNAL_ID}/security/socket`, + }, + snyk: { + status: "warn", + sourceUrl: `https://www.skills.sh/${LOCAL_SKILLS_SH_EXTERNAL_ID}/security/snyk`, + }, }, - inferredCategories: ["security"], - inferredTopics: ["risk-management", "security-audit"], + inferredCategories: ["automation"], + inferredTopics: ["reddit", "marketing"], sourceFreshnessStatus: "observed-only" as const, - detailStatus: "missing" as const, - observationFingerprint: "local-canonical-search-v1", - sourceSnapshotId: "local-canonical-search-v1", + detailStatus: "available" as const, + observationFingerprint: LOCAL_SKILLS_SH_CONTENT_HASH, + sourceSnapshotId: LOCAL_SKILLS_SH_SNAPSHOT_ID, lastObservedRunId: runId, active: true, publicVisible: true, @@ -1022,15 +1097,45 @@ export const seedCanonicalSearchFixture = internalMutation({ updatedAt: now, }; + let digestId: Id<"skillsShMirrorDigests">; if (existing) { await ctx.db.patch(existing._id, digest); - return { ok: true as const, digestId: existing._id }; + digestId = existing._id; + } else { + digestId = await ctx.db.insert("skillsShMirrorDigests", { + ...digest, + createdAt: now, + }); } - const digestId = await ctx.db.insert("skillsShMirrorDigests", { - ...digest, + + const existingDetail = await ctx.db + .query("skillsShMirrorDetails") + .withIndex("by_external_id", (q) => q.eq("externalId", LOCAL_SKILLS_SH_EXTERNAL_ID)) + .unique(); + const detail = { + externalId: LOCAL_SKILLS_SH_EXTERNAL_ID, + digestId, + contentKind: "skill-md" as const, + path: "SKILL.md", + content: LOCAL_SKILLS_SH_CONTENT, + contentBytes: new TextEncoder().encode(LOCAL_SKILLS_SH_CONTENT).byteLength, + sourceBytes: new TextEncoder().encode(LOCAL_SKILLS_SH_CONTENT).byteLength, + sourceFileCount: 1, + truncated: false, + sourceContentHash: LOCAL_SKILLS_SH_CONTENT_HASH, + sourceSnapshotId: LOCAL_SKILLS_SH_SNAPSHOT_ID, + lastObservedRunId: runId, + updatedAt: now, + }; + if (existingDetail) { + await ctx.db.patch(existingDetail._id, detail); + return { ok: true as const, digestId, detailId: existingDetail._id }; + } + const detailId = await ctx.db.insert("skillsShMirrorDetails", { + ...detail, createdAt: now, }); - return { ok: true as const, digestId }; + return { ok: true as const, digestId, detailId }; }, }); diff --git a/scripts/dev-worktree.test.ts b/scripts/dev-worktree.test.ts index 2a46950f..d881ed27 100644 --- a/scripts/dev-worktree.test.ts +++ b/scripts/dev-worktree.test.ts @@ -138,6 +138,7 @@ describe("dev-worktree helpers", () => { expect(byName.DEV_AUTH_CONVEX_DEPLOYMENT).toBe("anonymous:anonymous-agent"); expect(byName.SECURITY_SCAN_WORKER_TOKEN).toBe("local-dev-worker-token"); expect(byName.SECURITY_SCAN_DEFAULT_VT_WAIT_MS).toBe("0"); + expect(byName.CLAWHUB_SKILLS_SH_ROLLOUT_MODE).toBe("test"); expect(byName.AUTH_GITHUB_ID).toBe("local-dev"); expect(byName.AUTH_GITHUB_SECRET).toBe("local-dev"); expect(byName.JWT_PRIVATE_KEY).toContain("BEGIN PRIVATE KEY"); @@ -147,6 +148,16 @@ describe("dev-worktree helpers", () => { expect(byName.CONVEX_SITE_URL).toBeUndefined(); }); + it("preserves an explicit local skills.sh rollout override", () => { + const changes = buildLocalConvexEnvChanges({ + CONVEX_DEPLOYMENT: "anonymous:anonymous-agent", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "off", + }); + const byName = Object.fromEntries(changes.map((change) => [change.name, change.value])); + + expect(byName.CLAWHUB_SKILLS_SH_ROLLOUT_MODE).toBe("off"); + }); + it("does not apply local dev auth overrides for cloud Convex URLs", () => { const env: NodeJS.ProcessEnv = { SECURITY_SCAN_WORKER_TOKEN: "remote-worker-token", diff --git a/scripts/dev-worktree.ts b/scripts/dev-worktree.ts index 2081904e..c0d02d87 100644 --- a/scripts/dev-worktree.ts +++ b/scripts/dev-worktree.ts @@ -139,6 +139,10 @@ export function buildLocalConvexEnvChanges(env: NodeJS.ProcessEnv) { { name: "DEV_AUTH_CONVEX_DEPLOYMENT", value: deployment }, { name: "SECURITY_SCAN_WORKER_TOKEN", value: LOCAL_DEV_WORKER_TOKEN }, { name: "SECURITY_SCAN_DEFAULT_VT_WAIT_MS", value: "0" }, + { + name: "CLAWHUB_SKILLS_SH_ROLLOUT_MODE", + value: env.CLAWHUB_SKILLS_SH_ROLLOUT_MODE?.trim() || "test", + }, { name: "JWT_PRIVATE_KEY", value: authKeys.JWT_PRIVATE_KEY }, { name: "JWKS", value: authKeys.JWKS }, { name: "AUTH_GITHUB_ID", value: env.AUTH_GITHUB_ID?.trim() || "local-dev" }, diff --git a/scripts/seed.test.ts b/scripts/seed.test.ts index c2771766..0391d77e 100644 --- a/scripts/seed.test.ts +++ b/scripts/seed.test.ts @@ -8,6 +8,10 @@ describe("shared seed runner", () => { command: "bunx", args: ["convex", "run", "--no-push", "devSeed:seedLocalFixtures"], }, + { + command: "bunx", + args: ["convex", "run", "--no-push", "devSeed:seedCanonicalSearchFixture"], + }, { command: "bun", args: ["scripts/public-corpus/seed-public-corpus.ts"], @@ -29,6 +33,16 @@ describe("shared seed runner", () => { command: "bunx", args: ["convex", "run", "--preview-name", "feature/demo", "devSeed:seedLocalFixtures"], }, + { + command: "bunx", + args: [ + "convex", + "run", + "--preview-name", + "feature/demo", + "devSeed:seedCanonicalSearchFixture", + ], + }, { command: "bun", args: ["scripts/public-corpus/seed-public-corpus.ts", "--preview-name", "feature/demo"], diff --git a/scripts/seed.ts b/scripts/seed.ts index fd03e0e8..a0cb475d 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -42,6 +42,10 @@ export function buildSeedSteps(options: SeedOptions): SeedStep[] { command: "bunx", args: ["convex", "run", ...convexTargetArgs, "devSeed:seedLocalFixtures"], }, + { + command: "bunx", + args: ["convex", "run", ...convexTargetArgs, "devSeed:seedCanonicalSearchFixture"], + }, { command: "bun", args: ["scripts/public-corpus/seed-public-corpus.ts", ...corpusTargetArgs], diff --git a/src/__tests__/home-listing-section.claw591.test.tsx b/src/__tests__/home-listing-section.claw591.test.tsx index 3b235987..d7e9388d 100644 --- a/src/__tests__/home-listing-section.claw591.test.tsx +++ b/src/__tests__/home-listing-section.claw591.test.tsx @@ -1,6 +1,7 @@ /* @vitest-environment jsdom */ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CanonicalTrendingItem } from "../lib/trendingApi"; const navigateMock = vi.fn(); const convexQueryMock = vi.fn(); @@ -114,17 +115,50 @@ describe("HomeListingSection", () => { expect(screen.getByText("29")).toBeTruthy(); expect(screen.queryByText("17")).toBeNull(); expect(screen.queryByText("3")).toBeNull(); - expect(screen.getByText("24h downloads")).toBeTruthy(); - expect(screen.getAllByLabelText("24-hour downloads")).toHaveLength(2); + expect(screen.getByText("Downloads")).toBeTruthy(); + expect(screen.getAllByLabelText("Downloads")).toHaveLength(2); expect(screen.queryByText("24h installs")).toBeNull(); expect(screen.queryByLabelText("24-hour installs")).toBeNull(); expect(screen.queryByText("9K")).toBeNull(); expect(screen.queryByText("8K")).toBeNull(); expect(screen.queryByText("skills.sh")).toBeNull(); + expect(document.querySelector(".home-v2-listing-row-icon")).toBeNull(); + expect(document.querySelector(".home-v2-listing-row-stats svg")).toBeNull(); expect(screen.queryByRole("button", { name: "Grid view" })).toBeNull(); }); + it("identifies skills.sh rows by their source owner and upstream install count", () => { + const external = { + ...makeTrending("reddit-automation", "reddit-automation", 0, 12_345, 0), + id: "skills-sh:doany-skills/skills/reddit-automation", + source: "skills-sh" as const, + canonicalUrl: "/skills-sh/doany-skills/skills/reddit-automation", + publisher: null, + sourceIdentity: { + id: "doany-skills/skills/reddit-automation", + owner: "doany-skills", + repo: "skills", + host: null, + lifetimeInstalls: 12_345, + }, + metrics: { + trending24hDownloads: null, + trending24hInstalls: null, + trending24hBookmarks: null, + lifetimeInstalls: 12_345, + lifetimeInstallsPeriod: "lifetime" as const, + updatedAt: 1, + }, + }; + + render(); + + expect(screen.getByText("@doany-skills")).toBeTruthy(); + expect(screen.getByText("skills.sh")).toBeTruthy(); + expect(screen.getByLabelText("Downloads").textContent).toContain("12.3k"); + }); + it("hides unavailable Trending and falls back to the Featured feed", async () => { render(); @@ -273,7 +307,7 @@ describe("HomeListingSection", () => { }); function initialTrending( - items: ReturnType[], + items: CanonicalTrendingItem[], hasMore = false, trendingState: "available" | "empty" | "unavailable" = items.length ? "available" : "empty", ) { @@ -288,7 +322,7 @@ function initialTrending( }; } -function canonicalPage(items: ReturnType[], nextCursor: string | null = null) { +function canonicalPage(items: CanonicalTrendingItem[], nextCursor: string | null = null) { return { kind: "skills" as const, snapshotId: "snapshot-1", @@ -308,7 +342,7 @@ function makeTrending( installs: number, lifetime: number, downloads = installs, -) { +): CanonicalTrendingItem { return { id: `clawhub:${slug}`, source: "clawhub" as const, diff --git a/src/__tests__/home-listing-section.test.tsx b/src/__tests__/home-listing-section.test.tsx index 4536442b..e5a50b44 100644 --- a/src/__tests__/home-listing-section.test.tsx +++ b/src/__tests__/home-listing-section.test.tsx @@ -167,9 +167,9 @@ describe("HomeListingSection", () => { expect(screen.queryByRole("button", { name: "Grid view" })).toBeNull(); expect(screen.getByText("Demo Plugin")).toBeTruthy(); expect(document.querySelector(".home-v2-listing-list")).toBeTruthy(); - expect(document.querySelector(".marketplace-icon-image")?.getAttribute("src")).toBe( - featuredPlugin.icon, - ); + expect(screen.getByText("Downloads")).toBeTruthy(); + expect(document.querySelector(".home-v2-listing-row-icon")).toBeNull(); + expect(document.querySelector(".home-v2-listing-row-stats svg")).toBeNull(); }); it("previews long skill and plugin names while retaining their full labels", async () => { diff --git a/src/components/HomeListingSection.tsx b/src/components/HomeListingSection.tsx index cbb5fbcf..917e3e39 100644 --- a/src/components/HomeListingSection.tsx +++ b/src/components/HomeListingSection.tsx @@ -1,5 +1,5 @@ import { Link } from "@tanstack/react-router"; -import { Bookmark, CloudOff, Download, Loader2, Moon, Plus } from "lucide-react"; +import { CloudOff, Loader2, Moon, Plus } from "lucide-react"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { fetchHomePluginListing as fetchPluginListing, @@ -20,7 +20,6 @@ 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 { MarketplaceIcon } from "./MarketplaceIcon"; import { OfficialBadge } from "./OfficialBadge"; import { BrowseResultsSkeleton } from "./skeletons/BrowseResultsSkeleton"; @@ -122,33 +121,38 @@ function skillLink(entry: HomeNativeSkillListingEntry) { return `/${encodeURIComponent(owner)}/${encodeURIComponent(entry.skill.slug)}`; } -function HomeListingSkillRow({ entry, showStats }: { entry: SkillPageEntry; showStats: boolean }) { +function HomeListingSkillRow({ entry }: { entry: SkillPageEntry }) { if (isHomeTrendingSkillEntry(entry)) { const item = entry.trending; - const owner = item.publisher?.handle; + const isSkillsSh = item.source === "skills-sh"; + const owner = isSkillsSh + ? (item.sourceIdentity?.owner ?? item.sourceIdentity?.host) + : item.publisher?.handle; + const upstreamInstalls = item.sourceIdentity?.lifetimeInstalls ?? item.metrics.lifetimeInstalls; return ( -
{truncateText(item.displayName, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)} + {isSkillsSh ? skills.sh : null} {owner ? @{owner} : null}

{truncateText(item.summary || "Agent-ready skill pack.", 80)}

- {typeof item.metrics.trending24hDownloads === "number" ? ( -
- -
- } - main={ - <> -
- -
- {headerCategories.length > 0 || headerTopics.length > 0 ? ( -
- {headerCategories.length > 0 ? ( -
- {headerCategories.map((categoryItem) => ( - - - {categoryItem.label} - - ))} -
- ) : null} - {headerCategories.length > 0 && headerTopics.length > 0 ? ( -
- ) : null} -
- -

{displayName}

- {showTitleBadges ? ( -
- {titleBadges.map((badge) => ( - - {badge} - - ))} -
- ) : null} - {nixPlugin ? Plugin bundle (nix) : null} -
- {showHeroMeta ? ( -
- {forkOf && forkOfHref ? ( - - {forkOfLabel} - - {forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""} - {forkOf.skill.slug} - - {forkOf.version ? ( - ({forkOf.version}) - ) : null} - - ) : null} - {canonicalHref ? ( - - canonical - - {canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""} - {canonical?.skill?.slug} - - - ) : null} -
- ) : null} -
-
-

- {headerDescription} -

- {hasSummaryToggle ? ( - - ) : null} -
- {owner || ownerHandle ? ( -
- - {isMobileDetailLayout ? ( -
{renderStarAction()}
- ) : null} -
- ) : null} - - {nixPlugin ? ( -
- Bundles the skill pack, CLI binary, and config requirements in one Nix install. -
- ) : null} -
- - } - > -
- -
- -
-
- - -
- - -
- - - ); -} - -function SignedInActionTooltip({ - children, - isAuthenticated, - message, -}: { - children: ReactNode; - isAuthenticated: boolean; - message: string; -}) { - if (isAuthenticated) return children; - - return ( - - {children} - - {message} - - - ); -} - -function SkillSidebarDeferredStats({ - skill, - owner, - ownerHandle, - formattedStats, - latestVersion, - showArchiveMetadata, - securityAuditSummary, - activityTrend, - activityTrendLoading = false, - hideCreator = false, -}: { - skill: Doc<"skills"> | PublicSkill; - owner: PublicPublisher | null; - ownerHandle: string | null; - formattedStats: ReturnType; - latestVersion: SkillHeaderLatestVersion; - showArchiveMetadata: boolean; - securityAuditSummary?: ReactNode; - activityTrend?: ActivityTrend | null; - activityTrendLoading?: boolean; - hideCreator?: boolean; -}) { - const githubRepositoryLink = getGitHubRepositoryLink(skill); - const downloadsMetricBlock = useDownloadsSidebarMetricBlock({ - allTimeDownloads: skill.stats.downloads, - activityTrend: activityTrend?.downloads, - loading: activityTrendLoading, - }); - - return ( - , - value: formattedStats.downloads, - large: true, - }, - { label: "Repository", value: githubRepositoryLink }, - ...(hideCreator - ? [] - : [ - { - label: "Creator", - value: ( - - ), - }, - ]), - securityAuditSummary - ? { - key: "security-audit", - label: , - value: securityAuditSummary, - } - : { label: "", value: null }, - ...(showArchiveMetadata - ? [ - { - grid: [ - { - label: "Last updated", - value: ( - - {timeAgo(skill.updatedAt)} - - ), - }, - { - label: "Current version", - value: latestVersion?.version ? `v${latestVersion.version}` : "None", - }, - ], - }, - { label: "License", value: PLATFORM_SKILL_LICENSE }, - ] - : [ - { - label: "Last updated", - value: ( - - {timeAgo(skill.updatedAt)} - - ), - }, - ]), - ]} - /> - ); -} diff --git a/src/components/SkillInstallSurface.tsx b/src/components/SkillInstallSurface.tsx index 98b5ed7c..df9fc295 100644 --- a/src/components/SkillInstallSurface.tsx +++ b/src/components/SkillInstallSurface.tsx @@ -44,6 +44,13 @@ type SkillInstallSurfaceProps = { ownerHandle: string | null; ownerId: Id<"users"> | Id<"publishers"> | null; clawdis?: ClawdisSkillMetadata; + installTarget?: string; + skillPageUrl?: string | null; + secondaryInstall?: { + label: string; + command: string; + copyAriaLabel: string; + }; }; export function SkillInstallSurface({ @@ -52,6 +59,8 @@ export function SkillInstallSurface({ ownerHandle, ownerId, clawdis, + installTarget: installTargetOverride, + skillPageUrl: skillPageUrlOverride, }: SkillInstallSurfaceProps) { const headingId = useId(); const [promptMode, setPromptMode] = useState("install-and-setup"); @@ -80,7 +89,8 @@ export function SkillInstallSurface({ const selectedPrompt = PROMPT_OPTIONS.find((option) => option.mode === promptMode) ?? PROMPT_OPTIONS[1]; - const installTarget = buildSkillInstallTarget(ownerHandle, ownerId, slug); + const installTarget = + installTargetOverride ?? buildSkillInstallTarget(ownerHandle, ownerId, slug); const promptPreview = formatOpenClawPrompt({ mode: promptMode, skillName: displayName, @@ -88,6 +98,8 @@ export function SkillInstallSurface({ ownerHandle, ownerId, clawdis, + installTarget: installTargetOverride, + skillPageUrl: skillPageUrlOverride, }); const promptFeedback = @@ -105,6 +117,8 @@ export function SkillInstallSurface({ ownerHandle, ownerId, clawdis, + installTarget: installTargetOverride, + skillPageUrl: skillPageUrlOverride, }); setPromptMode(mode); @@ -193,15 +207,24 @@ export function SkillCommandLineCard({ ownerHandle, ownerId, clawdis, + installTarget: installTargetOverride, + skillPageUrl: skillPageUrlOverride, + secondaryInstall, }: SkillInstallSurfaceProps) { const headingId = useId(); type InstallTab = "cli" | "skills" | "prompt"; const [activeInstallTab, setActiveInstallTab] = useState("cli"); const [installTabDirection, setInstallTabDirection] = useState<"left" | "right">("right"); - const installTarget = buildSkillInstallTarget(ownerHandle, ownerId, slug); + const installTarget = + installTargetOverride ?? buildSkillInstallTarget(ownerHandle, ownerId, slug); const openClawCommand = formatOpenClawInstallCommand(installTarget); - const skillPageUrl = buildSkillPageUrl(ownerHandle, ownerId, slug); - const skillsCliCommand = skillPageUrl ? formatSkillsCliInstallCommand(skillPageUrl) : null; + const skillPageUrl = + skillPageUrlOverride === undefined + ? buildSkillPageUrl(ownerHandle, ownerId, slug) + : skillPageUrlOverride; + const skillsCliCommand = + secondaryInstall?.command ?? + (skillPageUrl ? formatSkillsCliInstallCommand(skillPageUrl) : null); const promptPreview = formatOpenClawPrompt({ mode: "install-and-setup", skillName: displayName, @@ -209,6 +232,8 @@ export function SkillCommandLineCard({ ownerHandle, ownerId, clawdis, + installTarget: installTargetOverride, + skillPageUrl: skillPageUrlOverride, }); const activeInstallText = activeInstallTab === "prompt" @@ -250,7 +275,7 @@ export function SkillCommandLineCard({ aria-pressed={activeInstallTab === "skills"} onClick={() => selectInstallTab("skills")} > - npx skills + {secondaryInstall?.label ?? "npx skills"} ) : null} - ) : null} - - {installable ? ( -
- {buildSkillsShInstallCommands(entry.reference).map(({ client, command }) => ( -
-

- {client} -

-
- - {command} - - -
-
- ))} -
- ) : ( - - - )} - - -
-
-

- Upstream checks -

-

- Upstream checks are separate from ClawHub scanning. -

-
-
- {entry.upstreamChecks.map((check) => ( - - ))} -
-
- - {entry.content ? ( -
-
-

- Stored {entry.content.kind === "skill-md" ? "SKILL.md" : "README"} -

- - {entry.content.path} - -
- {entry.content.truncated ? ( -

- Content is truncated to the stored 64 KiB snapshot. -

- ) : null} - {entry.content.markdown} -
- ) : null} - - + + } + breadcrumbOwnerHref={null} + breadcrumbOwnerLabel={entry.owner ?? "skills.sh"} + breadcrumbSkillHref={entry.route} + creatorContent={ + + } + installContent={installContent} + renderSidebarContent={() => } + > + + ); } -function DetailField({ - label, - value, - mono = false, -}: { - label: string; - value: string; - mono?: boolean; -}) { +function SkillsShSidebar({ entry }: { entry: SkillsShCatalogDetail }) { return ( -
-
{label}
-
+ + {formatCompactStat(entry.upstreamInstalls)} + + ), + large: true, + }, + { + label: "Repository", + value: ( + + + {entry.canonicalGitHubRepo} + + ), + }, + ...(entry.githubCommit + ? [ + { + label: "Commit", + value: {entry.githubCommit.slice(0, 12)}, + }, + ] + : []), + ]} + /> + +
+

Security Audits

+
+ {entry.upstreamChecks.map((check) => ( + + ))} +
+
+ +
+ {entry.githubPath && entry.githubCommit && entry.githubContentHash ? ( + + ) : null} +
+
+ ); +} + +function SkillsShContentTabs({ entry }: { entry: SkillsShCatalogDetail }) { + return ( +
+
+ +
+
- {value} - + {entry.content ? ( + <> + {entry.content.truncated ? ( +

+ Content is truncated to the stored 64 KiB snapshot. +

+ ) : null} +
+ {entry.content.markdown} +
+ + ) : ( +
+

No stored content available

+

This skills.sh listing has no stored Markdown.

+
+ )} +
); } function UpstreamCheck({ check }: { check: SkillsShUpstreamCheck }) { - const presentation = CHECK_PRESENTATION[check.status]; - const Icon = presentation.Icon; - return ( -
-
-
-

{check.sourceStatus}

- {check.checkedAt ? ( -

- Checked {timeAgo(check.checkedAt)} -

- ) : null} - {check.url ? ( - - View result - ) : null} -
+ const content = ( + <> + {check.scanner} + + {check.sourceStatus} + + + ); + return check.url ? ( + + {content} + + ) : ( +
{content}
); } diff --git a/src/components/UserBadge.tsx b/src/components/UserBadge.tsx index 0a3ba5f7..8b1c67fd 100644 --- a/src/components/UserBadge.tsx +++ b/src/components/UserBadge.tsx @@ -33,6 +33,7 @@ type UserBadgeProps = { /** Hero creator row: stack `@handle` below the display name. */ stackMutedHandleBelowName?: boolean; disableTooltip?: boolean; + profileHref?: string | null; }; export function UserBadge({ @@ -46,12 +47,14 @@ export function UserBadge({ showMutedHandle = false, stackMutedHandleBelowName = false, disableTooltip = false, + profileHref, }: UserBadgeProps) { const userName = hasOwnProperty(user, "name") && typeof user.name === "string" ? user.name.trim() : undefined; const displayName = user?.displayName?.trim() || userName || null; const handle = user?.handle ?? fallbackHandle ?? null; - const href = handle ? buildPublisherProfileHref(handle) : null; + const href = + profileHref === undefined ? (handle ? buildPublisherProfileHref(handle) : null) : profileHref; const label = handle ? `@${handle}` : "user"; const image = user?.image ?? null; const showStackedMutedHandle = diff --git a/src/components/skillDetailUtils.ts b/src/components/skillDetailUtils.ts index d3d2814e..76c248e1 100644 --- a/src/components/skillDetailUtils.ts +++ b/src/components/skillDetailUtils.ts @@ -19,6 +19,8 @@ type SkillPromptContext = { ownerHandle: string | null; ownerId: SkillOwnerId | null; clawdis?: ClawdisSkillMetadata; + installTarget?: string; + skillPageUrl?: string | null; }; export function buildSkillHref( @@ -205,9 +207,12 @@ export function formatOpenClawPrompt({ ownerHandle, ownerId, clawdis, + installTarget, + skillPageUrl, }: SkillPromptContext) { - const target = buildSkillInstallTarget(ownerHandle, ownerId, slug); - const pageUrl = buildSkillPageUrl(ownerHandle, ownerId, slug); + const target = installTarget?.trim() || buildSkillInstallTarget(ownerHandle, ownerId, slug); + const pageUrl = + skillPageUrl === undefined ? buildSkillPageUrl(ownerHandle, ownerId, slug) : skillPageUrl; const displayName = skillName.trim() || slug; const requiredEnvVars = new Set(clawdis?.requires?.env ?? []); diff --git a/src/lib/skillsShCatalog.ts b/src/lib/skillsShCatalog.ts index a59124be..2e2dc18b 100644 --- a/src/lib/skillsShCatalog.ts +++ b/src/lib/skillsShCatalog.ts @@ -98,13 +98,6 @@ export function skillsShRepositoryLabel(result: SkillsShSearchResult) { return result.sourceHost ?? "skills.sh"; } -export function buildSkillsShInstallCommands(reference: string) { - return [ - { client: "OpenClaw", command: `openclaw skills install ${reference}` }, - { client: "ClawHub", command: `clawhub install ${reference}` }, - ] as const; -} - export function isSkillsShCatalogInstallable( detail: Pick, ) { diff --git a/src/lib/trendingApi.ts b/src/lib/trendingApi.ts index bdd30ca3..203d772b 100644 --- a/src/lib/trendingApi.ts +++ b/src/lib/trendingApi.ts @@ -16,6 +16,13 @@ export type CanonicalTrendingItem = { image: string | null; official: boolean; } | null; + sourceIdentity?: { + id: string; + owner: string | null; + repo: string | null; + host: string | null; + lifetimeInstalls: number | null; + }; official: boolean; featured: boolean; metrics: { @@ -88,6 +95,19 @@ function isCanonicalPublisher(value: unknown): value is CanonicalTrendingItem["p ); } +function isCanonicalSourceIdentity( + value: unknown, +): value is NonNullable { + if (!isRecord(value)) return false; + return ( + typeof value.id === "string" && + isNullableString(value.owner) && + isNullableString(value.repo) && + isNullableString(value.host) && + isNullableNumber(value.lifetimeInstalls) + ); +} + function isCanonicalTrendingItem(value: unknown): value is LegacyCanonicalTrendingItem { if (!isRecord(value) || !isRecord(value.metrics)) return false; return ( @@ -98,6 +118,7 @@ function isCanonicalTrendingItem(value: unknown): value is LegacyCanonicalTrendi isNullableString(value.summary) && isCanonicalPath(value.canonicalUrl) && isCanonicalPublisher(value.publisher) && + (value.sourceIdentity === undefined || isCanonicalSourceIdentity(value.sourceIdentity)) && typeof value.official === "boolean" && typeof value.featured === "boolean" && (value.metrics.trending24hDownloads === undefined || diff --git a/src/styles.css b/src/styles.css index 6ad11963..16aee5a2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7059,13 +7059,35 @@ code { .skill-hero-taxonomy-row { display: inline-flex; align-items: center; - gap: var(--space-5); + gap: var(--space-3); min-width: 0; color: color-mix(in srgb, var(--ink-soft) 76%, var(--bg)); font-size: 12px; line-height: 1; } +.skill-hero-taxonomy-prefix { + display: inline-flex; + align-items: center; + flex: 0 0 auto; +} + +.skills-sh-sync-source-label { + color: inherit; + font-weight: 520; +} + +.skills-sh-sync-source { + color: inherit; + text-decoration: underline; + text-underline-offset: 3px; +} + +.skills-sh-sync-source:hover, +.skills-sh-sync-source:focus-visible { + color: var(--ink); +} + .skill-category-meta-list { display: inline-flex; align-items: center; @@ -7328,6 +7350,70 @@ code { font-size: 0.8rem; } +.skills-sh-security-audits { + display: grid; + gap: 8px; + padding: 18px 0; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.skills-sh-security-audits h2 { + margin: 0; + color: var(--ink-soft); + font-family: inherit; + font-size: 0.86rem; + font-weight: 700; + line-height: 1.2; +} + +.skills-sh-security-audit-list { + display: grid; +} + +.skills-sh-security-audit-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 36px; + padding: 8px 0; + color: var(--ink); + font-size: 0.8rem; + font-weight: 600; + text-decoration: none; +} + +.skills-sh-security-audit-verdict { + flex: 0 0 auto; + border-radius: var(--oc-radius-control); + text-transform: uppercase; +} + +a.skills-sh-security-audit-row:hover, +a.skills-sh-security-audit-row:focus-visible { + color: var(--ink); + text-decoration: none; + background: var(--hover-bg); +} + +.skills-sh-detail-links { + display: grid; + gap: 8px; +} + +.skills-sh-detail-links .btn { + justify-content: flex-start; + width: 100%; +} + +.skills-sh-content-note { + margin: 0; + padding-bottom: 14px; + color: var(--ink-soft); + font-size: 0.72rem; +} + .skill-sidebar-actions { display: grid; gap: 10px; @@ -8801,6 +8887,28 @@ code { overflow-x: hidden; } + .skills-sh-detail-page .detail-mobile-install .skill-install-command-shell-cli { + align-items: flex-start; + } + + .skills-sh-detail-page .detail-mobile-install .skill-install-command-prompt { + align-self: flex-start; + } + + .skills-sh-detail-page + .detail-mobile-install + .skill-install-command-shell-cli + .skill-install-command, + .skills-sh-detail-page + .detail-mobile-install + .skill-install-command-shell-cli + .skill-install-command + code { + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + } + .detail-mobile-install .skill-install-command-shell-cli .skill-install-command, .detail-mobile-install .skill-install-command-shell-cli .skill-install-command code { white-space: nowrap; @@ -22739,7 +22847,7 @@ a.search-empty-action { .home-v2-listing-head { --home-v2-listing-copy-max: min(54rem, 60vw); display: grid; - grid-template-columns: auto minmax(0, var(--home-v2-listing-copy-max)) 1fr auto; + grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr auto; gap: 16px 20px; align-items: center; padding: 0 4px 8px; @@ -22748,7 +22856,7 @@ a.search-empty-action { } .home-v2-listing-head.has-no-stats { - grid-template-columns: auto minmax(0, var(--home-v2-listing-copy-max)) 1fr; + grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr; } .home-v2-listing-head-icon-spacer { @@ -22767,11 +22875,11 @@ a.search-empty-action { } .home-v2-listing-head-label { - grid-column: 2; + grid-column: 1; } .home-v2-listing-head-stat { - grid-column: 4; + grid-column: 3; justify-self: end; } @@ -23035,7 +23143,7 @@ a.search-empty-action { --home-v2-listing-copy-max: min(54rem, 60vw); position: relative; display: grid; - grid-template-columns: auto minmax(0, var(--home-v2-listing-copy-max)) 1fr auto; + grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr auto; gap: 16px 20px; align-items: center; padding: 13px 4px; @@ -23046,7 +23154,7 @@ a.search-empty-action { } .home-v2-listing-row.has-no-stats { - grid-template-columns: auto minmax(0, var(--home-v2-listing-copy-max)) 1fr; + grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr; } .home-v2-listing-row::before { @@ -23132,7 +23240,7 @@ a.search-empty-action { } .home-v2-listing-row-body { - grid-column: 2; + grid-column: 1; min-width: 0; max-width: var(--home-v2-listing-copy-max); display: grid; @@ -23182,6 +23290,20 @@ a.search-empty-action { white-space: nowrap; } +.home-v2-listing-source-badge { + flex: 0 0 auto; + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--hv2-text-tertiary) 42%, transparent); + border-radius: var(--r-pill); + color: var(--hv2-text-tertiary); + font-family: var(--font-mono), ui-monospace, monospace; + font-size: 9px; + font-weight: 650; + line-height: 1.2; + letter-spacing: 0.02em; + white-space: nowrap; +} + .home-v2-listing-row-summary { margin: 0; font-size: 13px; @@ -23209,7 +23331,7 @@ a.search-empty-action { } .home-v2-listing-row-stats { - grid-column: 4; + grid-column: 3; display: flex; align-items: center; justify-content: flex-end;