diff --git a/VISION.md b/VISION.md index 6de291ed..1a9bf3ca 100644 --- a/VISION.md +++ b/VISION.md @@ -95,11 +95,11 @@ The filter for skilld-scoped work, precise enough to hold a proposal next to it ### 1. Provenance is the quality signal -A Skill is trustworthy because you can see who wrote it and read it before you run it. Every surface that shows a Skill names the author and links the exact source file in its repository. Registry identity is frozen at admission (ADR-0002) so provenance URLs never rot. Machine-generated metadata is labeled as such and never dressed as authorship. +A Skill is trustworthy because you can see who wrote it and read it before you run it. Every surface that shows a Skill names the author. The Skill page links the exact source file in its repository; other surfaces link the Skill page, which is where a visitor inspects a Skill. Registry identity is frozen at admission (ADR-0002) so provenance URLs never rot. Machine-generated metadata is labeled as such and never dressed as authorship. -**How to apply:** no Skill card, search result, or digest entry ships without author and source link. A layout that drops provenance to save space fails review. Trust signals (official repository, owner-verified) are earned facts, never editorial favors. +**How to apply:** no Skill card, search result, or digest entry ships without its author and a link to its Skill page or its source. A layout that drops provenance to save space fails review. Trust signals (official repository, owner-verified) are earned facts, never editorial favors. -**Test:** from any surface showing a Skill, can the user reach the exact SKILL.md in the author's repository in one click? If not, fail. +**Test:** from any surface showing a Skill, can the user reach its Skill page in one click, and the exact SKILL.md in the author's repository from there? If not, fail. ### 2. Curation over coverage diff --git a/layers/registry/server/data/skill-demos.json b/layers/registry/server/data/skill-demos.json index 86b4fea6..1e822cb7 100644 --- a/layers/registry/server/data/skill-demos.json +++ b/layers/registry/server/data/skill-demos.json @@ -13,14 +13,14 @@ "outputFile": "index.html", "shots": [ { - "file": "desktop.jpg", + "file": "desktop-9954800c7c.jpg", "width": 1440, "height": 4102, "viewport": "desktop", "alt": "Desktop screenshot of the page the Agent built for: Build a single-file landing page for Tidepool, a tide-chart app for surfers. Inline all CSS and JavaScript. Save it as i" }, { - "file": "mobile.jpg", + "file": "mobile-c67e258443.jpg", "width": 390, "height": 5091, "viewport": "mobile", @@ -43,7 +43,7 @@ "outputFile": "diagram.html", "shots": [ { - "file": "desktop.jpg", + "file": "desktop-bb59621956.jpg", "width": 1440, "height": 927, "viewport": "desktop", @@ -66,14 +66,14 @@ "outputFile": "toast.html", "shots": [ { - "file": "desktop.jpg", + "file": "desktop-9263d30c37.jpg", "width": 1440, "height": 900, "viewport": "desktop", "alt": "Desktop screenshot of the page the Agent built for: Build toast.html, a single-file HTML demo of a toast notification stack for Pebblequill, a fictional note-sync app. Show" }, { - "file": "mobile.jpg", + "file": "mobile-578712c2dd.jpg", "width": 390, "height": 844, "viewport": "mobile", @@ -96,15 +96,15 @@ "skillCommit": "69a2169bde0c134f42979e1911755084b1c29962", "recordedAt": "2026-10-06", "video": { - "file": "video.mp4", - "poster": "poster.jpg", + "file": "video-77ba3f4e79.mp4", + "poster": "poster-9c7931fe85.jpg", "width": 1280, "height": 720, "durationSeconds": 12 }, "shots": [ { - "file": "poster.jpg", + "file": "poster-9c7931fe85.jpg", "width": 1280, "height": 720, "viewport": "desktop", @@ -125,7 +125,7 @@ "outputFile": "flow.html", "shots": [ { - "file": "desktop.jpg", + "file": "desktop-70a1bca51d.jpg", "width": 1440, "height": 1060, "viewport": "desktop", @@ -148,14 +148,14 @@ "outputFile": "pricing.html", "shots": [ { - "file": "desktop.jpg", + "file": "desktop-4dce07cf5a.jpg", "width": 1440, "height": 1164, "viewport": "desktop", "alt": "Desktop screenshot of the page the Agent built for: Build pricing.html, a single-file HTML pricing section for Glintwork, a fictional photo-editing app: three plan cards, a" }, { - "file": "mobile.jpg", + "file": "mobile-7180f99b12.jpg", "width": 390, "height": 2192, "viewport": "mobile", @@ -179,15 +179,15 @@ "skillCommit": "c893c5ed52aed84e3e2ee56787de869fccdae6b0", "recordedAt": "2026-10-06", "video": { - "file": "video.mp4", - "poster": "poster.jpg", + "file": "video-0683f0bd0b.mp4", + "poster": "poster-08e839d069.jpg", "width": 1280, "height": 720, "durationSeconds": 21 }, "shots": [ { - "file": "poster.jpg", + "file": "poster-08e839d069.jpg", "width": 1280, "height": 720, "viewport": "desktop", @@ -195,6 +195,35 @@ } ], "pin": 1 + }, + { + "owner": "lemomo-ai", + "repo": "lemo-opuscar", + "name": "lemo-opuscar", + "authorName": "Lemomo", + "sourceUrl": "https://github.com/lemomo-ai/lemo-opuscar/blob/main/plugin/skills/lemo-opuscar/SKILL.md", + "prompt": "Make a 15-second film in the Chinese Ink Wash style about a lighthouse keeper on Wrenhollow, a fictional island, who rescues a paper boat from a storm. No narration. Render the final film as film.mp4 in this folder.", + "agent": "Claude Code", + "agentVersion": "2.1.291", + "model": "claude-opus-5-5", + "skillCommit": "02dce5b6ff6210622788c15319c082e9cb9e7217", + "recordedAt": "2026-10-06", + "video": { + "file": "video-b200a41390.mp4", + "poster": "poster-7f097c6c5c.jpg", + "width": 1280, + "height": 720, + "durationSeconds": 15 + }, + "shots": [ + { + "file": "poster-7f097c6c5c.jpg", + "width": 1280, + "height": 720, + "viewport": "desktop", + "alt": "A frame from the video the Agent rendered for: Make a 15-second film in the Chinese Ink Wash style about a lighthouse keeper on Wrenhollow, a fictional island, who res" + } + ] } ] } diff --git a/layers/registry/server/utils/skill-demos.ts b/layers/registry/server/utils/skill-demos.ts index 9dd93ad0..e28d1530 100644 --- a/layers/registry/server/utils/skill-demos.ts +++ b/layers/registry/server/utils/skill-demos.ts @@ -6,18 +6,20 @@ import manifest from '../data/skill-demos.json' * Demos: one recorded run of a Skill each (GLOSSARY "demo"). * * `scripts/record-skill-demo.ts` runs a fixed prompt through an Agent with the - * Skill loaded, screenshots the output into `public/demos/`, keeps the output - * page in `server/demos/`, and writes the entry below. Merging the pull request - * that adds an entry is the human approval: nothing records into production. + * Skill loaded, uploads the screenshots (or a video and its poster) to the + * `skilld-demo-media` R2 bucket, keeps the output page in `server/demos/`, and + * writes the entry below. Merging the pull request that adds an entry is the + * human approval: the media sits unreferenced until then. * - * A video Skill's demo also carries the rendered video and a poster frame. + * Media lives at `DEMO_MEDIA_ORIGIN`, the bucket's custom domain, under + * content-hashed names, so a URL never changes what it serves. * - * Cull path: delete the entry, its `public/demos////` - * folder, and its `server/demos////` folder. + * Cull path: delete the entry, its `server/demos////` + * folder, and the bucket's `demos////` prefix. */ const shotSchema = z.object({ - /** File name inside `public/demos////`. A raster image only: an SVG can carry script. */ + /** Content-hashed file name under the bucket's `demos////`. A raster image only: an SVG can carry script. */ file: z.string().regex(/^[\w-]+\.(?:png|jpg)$/), width: z.number().int().positive(), height: z.number().int().positive(), @@ -26,7 +28,7 @@ const shotSchema = z.object({ }) const videoSchema = z.object({ - /** H.264 MP4 inside `public/demos////`, re-encoded small for the web. */ + /** Content-hashed H.264 MP4 under the bucket's `demos////`, re-encoded small for the web. */ file: z.string().regex(/^[\w-]+\.mp4$/), /** A JPG frame shown before the video plays. */ poster: z.string().regex(/^[\w-]+\.jpg$/), @@ -83,7 +85,10 @@ export function listSkillDemos(demos: readonly SkillDemoRecord[] = SKILL_DEMOS): || b.recordedAt.localeCompare(a.recordedAt)) } -function demoBase(demo: SkillDemoRecord): string { +/** The `skilld-demo-media` bucket's custom domain. */ +export const DEMO_MEDIA_ORIGIN = 'https://media.skilld.dev' + +function demoPath(demo: SkillDemoRecord): string { return `/demos/${demo.owner}/${demo.repo}/${demo.name}` } @@ -126,8 +131,9 @@ export interface SkillDemoView { } /** `currentCommit` is the Skill's source commit now, when known. Unknown never marks a demo outdated. */ -export function presentSkillDemo(demo: SkillDemoRecord, currentCommit: string | null): SkillDemoView { - const base = demoBase(demo) +export function presentSkillDemo(demo: SkillDemoRecord, currentCommit: string | null, mediaOrigin: string = DEMO_MEDIA_ORIGIN): SkillDemoView { + const path = demoPath(demo) + const media = `${mediaOrigin}${path}` return { owner: demo.owner, repo: demo.repo, @@ -142,18 +148,18 @@ export function presentSkillDemo(demo: SkillDemoRecord, currentCommit: string | skillCommit: demo.skillCommit, recordedAt: demo.recordedAt, outdated: currentCommit !== null && currentCommit !== demo.skillCommit, - liveUrl: demo.outputFile ? `${base}/live` : null, + liveUrl: demo.outputFile ? `${path}/live` : null, video: demo.video ? { - src: `${base}/${demo.video.file}`, - poster: `${base}/${demo.video.poster}`, + src: `${media}/${demo.video.file}`, + poster: `${media}/${demo.video.poster}`, width: demo.video.width, height: demo.video.height, durationSeconds: demo.video.durationSeconds, } : null, shots: demo.shots.map(shot => ({ - src: `${base}/${shot.file}`, + src: `${media}/${shot.file}`, width: shot.width, height: shot.height, alt: shot.alt, diff --git a/public/demos/anthropics/skills/frontend-design/desktop.jpg b/public/demos/anthropics/skills/frontend-design/desktop.jpg deleted file mode 100644 index eace8a3e..00000000 Binary files a/public/demos/anthropics/skills/frontend-design/desktop.jpg and /dev/null differ diff --git a/public/demos/anthropics/skills/frontend-design/mobile.jpg b/public/demos/anthropics/skills/frontend-design/mobile.jpg deleted file mode 100644 index 68bd8875..00000000 Binary files a/public/demos/anthropics/skills/frontend-design/mobile.jpg and /dev/null differ diff --git a/public/demos/cathrynlavery/diagram-design/diagram-design/desktop.jpg b/public/demos/cathrynlavery/diagram-design/diagram-design/desktop.jpg deleted file mode 100644 index 0b6fed9e..00000000 Binary files a/public/demos/cathrynlavery/diagram-design/diagram-design/desktop.jpg and /dev/null differ diff --git a/public/demos/emilkowalski/skills/emil-design-eng/desktop.jpg b/public/demos/emilkowalski/skills/emil-design-eng/desktop.jpg deleted file mode 100644 index 5dc40ad4..00000000 Binary files a/public/demos/emilkowalski/skills/emil-design-eng/desktop.jpg and /dev/null differ diff --git a/public/demos/emilkowalski/skills/emil-design-eng/mobile.jpg b/public/demos/emilkowalski/skills/emil-design-eng/mobile.jpg deleted file mode 100644 index 88c697dd..00000000 Binary files a/public/demos/emilkowalski/skills/emil-design-eng/mobile.jpg and /dev/null differ diff --git a/public/demos/heygen-com/hyperframes/hyperframes/poster.jpg b/public/demos/heygen-com/hyperframes/hyperframes/poster.jpg deleted file mode 100644 index e4f7bfb2..00000000 Binary files a/public/demos/heygen-com/hyperframes/hyperframes/poster.jpg and /dev/null differ diff --git a/public/demos/heygen-com/hyperframes/hyperframes/video.mp4 b/public/demos/heygen-com/hyperframes/hyperframes/video.mp4 deleted file mode 100644 index 4aca4e24..00000000 Binary files a/public/demos/heygen-com/hyperframes/hyperframes/video.mp4 and /dev/null differ diff --git a/public/demos/humanlayer/skills/show-me/desktop.jpg b/public/demos/humanlayer/skills/show-me/desktop.jpg deleted file mode 100644 index a7d64667..00000000 Binary files a/public/demos/humanlayer/skills/show-me/desktop.jpg and /dev/null differ diff --git a/public/demos/jakubkrehel/make-interfaces-feel-better/make-interfaces-feel-better/desktop.jpg b/public/demos/jakubkrehel/make-interfaces-feel-better/make-interfaces-feel-better/desktop.jpg deleted file mode 100644 index 8ea25f0f..00000000 Binary files a/public/demos/jakubkrehel/make-interfaces-feel-better/make-interfaces-feel-better/desktop.jpg and /dev/null differ diff --git a/public/demos/jakubkrehel/make-interfaces-feel-better/make-interfaces-feel-better/mobile.jpg b/public/demos/jakubkrehel/make-interfaces-feel-better/make-interfaces-feel-better/mobile.jpg deleted file mode 100644 index b86cf17e..00000000 Binary files a/public/demos/jakubkrehel/make-interfaces-feel-better/make-interfaces-feel-better/mobile.jpg and /dev/null differ diff --git a/public/demos/latent-spaces/brag/brag/poster.jpg b/public/demos/latent-spaces/brag/brag/poster.jpg deleted file mode 100644 index 9699440b..00000000 Binary files a/public/demos/latent-spaces/brag/brag/poster.jpg and /dev/null differ diff --git a/public/demos/latent-spaces/brag/brag/video.mp4 b/public/demos/latent-spaces/brag/brag/video.mp4 deleted file mode 100644 index 7c6ae10d..00000000 Binary files a/public/demos/latent-spaces/brag/brag/video.mp4 and /dev/null differ diff --git a/scripts/record-skill-demo.ts b/scripts/record-skill-demo.ts index dd7730ec..2b760d0d 100644 --- a/scripts/record-skill-demo.ts +++ b/scripts/record-skill-demo.ts @@ -13,8 +13,10 @@ * 3. An HTML output gets full-page screenshots at desktop width, and at phone * width when the page fits a phone. An MP4 output is re-encoded small and * gets a poster frame. - * 4. Copies the media to `public/demos/`, an HTML page to the registry's + * 4. Uploads the media to the `skilld-demo-media` R2 bucket with `cf`, under + * content-hashed names, copies an HTML page to the registry's * `server/demos/`, and upserts the entry in `server/data/skill-demos.json`. + * The account comes from `CLOUDFLARE_ACCOUNT_ID`, else `wrangler.jsonc`. * * A page Skill gets only Read, Write and Edit. A video Skill has to run its * renderer, so its Bash runs in the Claude Code sandbox: writes stay in the temp @@ -29,9 +31,10 @@ */ import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' import { copyFile, cp, mkdir, mkdtemp, open, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' -import { extname, join, resolve } from 'node:path' +import { extname, join, relative, resolve, sep } from 'node:path' import process from 'node:process' import { fileURLToPath, pathToFileURL } from 'node:url' import { parseArgs, promisify } from 'node:util' @@ -42,7 +45,8 @@ const run = promisify(execFile) const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url))) const MANIFEST = join(ROOT, 'layers/registry/server/data/skill-demos.json') const OUTPUT_DIR = join(ROOT, 'layers/registry/server/demos') -const SHOT_DIR = join(ROOT, 'public/demos') +const MEDIA_BUCKET = 'skilld-demo-media' +const MEDIA_TYPES: Readonly> = { '.jpg': 'image/jpeg', '.png': 'image/png', '.mp4': 'video/mp4' } const API = 'https://skilld.dev/api/v1/skills' const RECORD_TIMEOUT_MS = 20 * 60 * 1000 /** Rendering a film takes far longer than writing a page. */ @@ -290,7 +294,11 @@ async function encodeVideo(source: string, dir: string, prompt: string): Promise } } -/** The named file, or the newest file of the same type anywhere in the folder. */ +/** + * The named file, or the newest file of the same type in the folder. Hidden + * folders and node_modules hold renderer scratch, such as the segments a film + * is cut from, so they never count as the output. + */ async function findOutput(cwd: string, output: string): Promise { const named = join(cwd, output) const found = await stat(named).catch((error: NodeJS.ErrnoException) => { @@ -304,7 +312,8 @@ async function findOutput(cwd: string, output: string): Promise { const wanted = extname(output) const entries = await readdir(cwd, { recursive: true, withFileTypes: true }) const matches = await Promise.all(entries - .filter(entry => entry.isFile() && extname(entry.name) === wanted && !entry.parentPath.includes('node_modules')) + .filter(entry => entry.isFile() && extname(entry.name) === wanted + && !relative(cwd, entry.parentPath).split(sep).some(part => part.startsWith('.') || part === 'node_modules')) .map(async (entry) => { const path = join(entry.parentPath, entry.name) return { path, mtime: (await stat(path)).mtimeMs } @@ -349,6 +358,49 @@ async function writeEntry(entry: DemoEntry): Promise { await writeFile(MANIFEST, `${JSON.stringify({ demos }, null, 2)}\n`) } +async function cloudflareAccount(): Promise { + if (process.env.CLOUDFLARE_ACCOUNT_ID) + return process.env.CLOUDFLARE_ACCOUNT_ID + const config = await readFile(join(ROOT, 'wrangler.jsonc'), 'utf8') + const account = /"account_id":\s*"([0-9a-f]{32})"/.exec(config)?.[1] + if (!account) + throw new Error('Set CLOUDFLARE_ACCOUNT_ID: wrangler.jsonc has no account_id.') + return account +} + +/** + * Uploads each staged file under a content-hashed name, so a media URL never + * changes what it serves, and returns the old name to new name map. + */ +async function publishMedia(owner: string, repo: string, name: string, dir: string, files: string[]): Promise> { + const account = await cloudflareAccount() + const published = new Map() + for (const file of files) { + const local = join(dir, file) + const ext = extname(file) + const hash = createHash('sha256').update(await readFile(local)).digest('hex').slice(0, 10) + const hashed = `${file.slice(0, -ext.length)}-${hash}${ext}` + const key = `demos/${owner}/${repo}/${name}/${hashed}` + await run('cf', ['r2', 'objects', 'put', key, '--bucket-name', MEDIA_BUCKET, '--file', local, '--content-type', MEDIA_TYPES[ext] ?? 'application/octet-stream', '--quiet'], { + env: { ...process.env, CLOUDFLARE_ACCOUNT_ID: account }, + maxBuffer: 16 * 1024 * 1024, + }) + published.set(file, hashed) + } + return published +} + +/** Stages, uploads, and renames the media of one demo. */ +async function publishDemoMedia(owner: string, repo: string, name: string, dir: string, media: Pick): Promise> { + const files = [...new Set([...media.shots.map(shot => shot.file), ...(media.video ? [media.video.file, media.video.poster] : [])])] + const names = await publishMedia(owner, repo, name, dir, files) + const to = (file: string) => names.get(file) ?? file + return { + shots: media.shots.map(shot => ({ ...shot, file: to(shot.file) })), + ...(media.video ? { video: { ...media.video, file: to(media.video.file), poster: to(media.video.poster) } } : {}), + } +} + async function reshoot(owner: string, repo: string, name: string): Promise { const manifest = JSON.parse(await readFile(MANIFEST, 'utf8')) as { demos: DemoEntry[] } const ref = `${owner}/${repo}/${name}`.toLowerCase() @@ -357,11 +409,9 @@ async function reshoot(owner: string, repo: string, name: string): Promise throw new Error(`No recorded demo for ${owner}/${repo}/${name}. Record it first.`) if (!entry.outputFile) throw new Error(`${owner}/${repo}/${name} is a video demo with no page to reshoot. Record it again instead.`) - const shotDir = join(SHOT_DIR, entry.owner, entry.repo, entry.name) - await rm(shotDir, { recursive: true, force: true }) - await mkdir(shotDir, { recursive: true }) + const stage = await mkdtemp(join(tmpdir(), 'skilld-demo-media-')) const page = join(OUTPUT_DIR, entry.owner, entry.repo, entry.name, entry.outputFile) - const shots = await screenshot(page, shotDir, entry.prompt) + const { shots } = await publishDemoMedia(entry.owner, entry.repo, entry.name, stage, { shots: await screenshot(page, stage, entry.prompt) }) // The recorded commit stays; only the provenance fields refresh. const source = await skillSource(entry.owner, entry.repo, entry.name) await upsert({ ...entry, authorName: source.authorName, sourceUrl: source.sourceUrl, shots }) @@ -393,18 +443,16 @@ async function main(): Promise { if (!produced) throw new Error(`The Agent wrote no ${extname(output)} file. Check ${cwd}.`) - const shotDir = join(SHOT_DIR, owner, repo, name) - await rm(shotDir, { recursive: true, force: true }) - await mkdir(shotDir, { recursive: true }) - - const media: Pick = kind === 'video' - ? await encodeVideo(produced, shotDir, prompt) + const stage = await mkdtemp(join(tmpdir(), 'skilld-demo-media-')) + const staged: Pick = kind === 'video' + ? await encodeVideo(produced, stage, prompt) : await (async () => { const outputDir = join(OUTPUT_DIR, owner, repo, name) await mkdir(outputDir, { recursive: true }) await copyFile(produced, join(outputDir, output)) - return { outputFile: output, shots: await screenshot(produced, shotDir, prompt) } + return { outputFile: output, shots: await screenshot(produced, stage, prompt) } })() + const media = { ...staged, ...await publishDemoMedia(owner, repo, name, stage, staged) } await upsert({ owner, @@ -421,7 +469,7 @@ async function main(): Promise { recordedAt: new Date().toISOString().slice(0, 10), ...media, }) - console.log(`Recorded ${skillRef} with ${agent.model}. Review ${shotDir} before you commit. The Agent's folder stays at ${cwd}.`) + console.log(`Recorded ${skillRef} with ${agent.model}. Review the media in ${stage} before you commit. The Agent's folder stays at ${cwd}.`) } await main() diff --git a/test/unit/skill-demos.test.ts b/test/unit/skill-demos.test.ts index 2dbe4c8f..556c90e0 100644 --- a/test/unit/skill-demos.test.ts +++ b/test/unit/skill-demos.test.ts @@ -31,9 +31,9 @@ describe('presentSkillDemo', () => { expect(presentSkillDemo(demo(), null).outdated).toBe(false) }) - it('serves screenshots from public/demos and the output from the sandboxed live route', () => { - const view = presentSkillDemo(demo(), null) - expect(view.shots[0]?.src).toBe('/demos/anthropics/skills/frontend-design/desktop.jpg') + it('serves screenshots from the media bucket and the output from the sandboxed live route', () => { + const view = presentSkillDemo(demo(), null, 'https://media.example') + expect(view.shots[0]?.src).toBe('https://media.example/demos/anthropics/skills/frontend-design/desktop.jpg') expect(view.liveUrl).toBe('/demos/anthropics/skills/frontend-design/live') expect(view.skillPath).toBe('/gh/anthropics/skills/frontend-design') }) @@ -44,10 +44,10 @@ describe('presentSkillDemo for a video Skill', () => { const view = presentSkillDemo(demo({ outputFile: undefined, video: { file: 'video.mp4', poster: 'poster.jpg', width: 1280, height: 720, durationSeconds: 14.2 }, - }), null) + }), null, 'https://media.example') expect(view.liveUrl).toBeNull() - expect(view.video?.src).toBe('/demos/anthropics/skills/frontend-design/video.mp4') - expect(view.video?.poster).toBe('/demos/anthropics/skills/frontend-design/poster.jpg') + expect(view.video?.src).toBe('https://media.example/demos/anthropics/skills/frontend-design/video.mp4') + expect(view.video?.poster).toBe('https://media.example/demos/anthropics/skills/frontend-design/poster.jpg') }) })