first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run

This commit is contained in:
Zakaria
2026-05-04 14:58:14 -04:00
commit a46764fb1b
1210 changed files with 233231 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env node
// Bake a curated handful of community pets from Codex Pet Share into
// the repo so they ship out-of-the-box without users having to hit the
// "Download community pets" button in Pet settings. The daemon scans
// `assets/community-pets/` alongside `${CODEX_HOME:-$HOME/.codex}/pets/`
// so anything written here shows up in the "Recently hatched" grid as
// a built-in pet that any user can adopt with one click.
//
// Run after editing the `BUNDLED_PETS` list below:
// node --experimental-strip-types scripts/bake-community-pets.ts
//
// Flags:
// --force Re-download pets that already exist on disk.
// --out <dir> Destination folder (defaults to assets/community-pets).
import { mkdir, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const PETSHARE_BASE = 'https://ihzwckyzfcuktrljwpha.supabase.co/functions/v1/petshare';
// Hand-picked pets that should ship with the repo. Add to this list
// (and re-run this script) to bundle a new pet. Keep entries sorted
// alphabetically by id so review diffs stay clean.
const BUNDLED_PETS = [
'clippit',
'dario',
'nyako-shigure',
'slavik',
'trump',
'tux',
'yelling-dario',
'yorha-sit-2b',
];
interface PetShareDetail {
id: string;
displayName: string;
description?: string;
spritesheetPath?: string;
ownerName?: string;
tags?: string[];
spritesheetUrl: string;
}
interface PetShareEnvelope {
pet: PetShareDetail;
}
interface ParsedArgs {
out: string;
force: boolean;
}
function parseArgs(argv: string[]): ParsedArgs {
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, '..');
const args: ParsedArgs = {
out: path.join(repoRoot, 'assets', 'community-pets'),
force: false,
};
for (let i = 2; i < argv.length; i++) {
const flag = argv[i];
if (flag === '--force') {
args.force = true;
continue;
}
if (flag === '--out') {
const value = argv[++i];
if (!value) throw new Error('--out expects a value');
args.out = path.resolve(value);
continue;
}
if (flag === '-h' || flag === '--help') {
console.log('Usage: bake-community-pets.ts [--force] [--out <dir>]');
process.exit(0);
}
throw new Error(`unknown flag: ${flag}`);
}
return args;
}
function extOf(url: string | undefined): 'webp' | 'png' | 'gif' {
if (!url) return 'webp';
const clean = url.split('?')[0] ?? '';
const ext = clean.split('.').pop()?.toLowerCase() ?? 'webp';
if (ext === 'png' || ext === 'gif') return ext;
return 'webp';
}
async function pathExists(p: string): Promise<boolean> {
try {
await stat(p);
return true;
} catch {
return false;
}
}
async function fetchPetDetail(id: string): Promise<PetShareDetail> {
const url = `${PETSHARE_BASE}/api/pets/${encodeURIComponent(id)}`;
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`fetch ${id}: ${resp.status} ${resp.statusText}`);
}
const data = (await resp.json()) as PetShareEnvelope;
if (!data?.pet?.id) throw new Error(`fetch ${id}: empty pet payload`);
return data.pet;
}
async function downloadBinary(url: string): Promise<Buffer> {
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`download ${url}: ${resp.status} ${resp.statusText}`);
}
return Buffer.from(await resp.arrayBuffer());
}
function isPlausibleSpritesheet(bytes: Buffer): boolean {
if (bytes.length < 16) return false;
const head = bytes.subarray(0, 12);
const isWebp = head.toString('ascii', 0, 4) === 'RIFF' && head.toString('ascii', 8, 12) === 'WEBP';
const isPng = head.toString('hex', 0, 8) === '89504e470d0a1a0a';
const isGif = head.toString('ascii', 0, 6) === 'GIF87a' || head.toString('ascii', 0, 6) === 'GIF89a';
return isWebp || isPng || isGif;
}
async function bakePet(id: string, outRoot: string, force: boolean): Promise<'wrote' | 'skipped'> {
const detail = await fetchPetDetail(id);
const ext = extOf(detail.spritesheetPath ?? detail.spritesheetUrl);
const dir = path.join(outRoot, id);
const sheetPath = path.join(dir, `spritesheet.${ext}`);
const manifestPath = path.join(dir, 'pet.json');
if (!force && (await pathExists(sheetPath)) && (await pathExists(manifestPath))) {
return 'skipped';
}
const spritesheetUrl = detail.spritesheetUrl.startsWith('http')
? detail.spritesheetUrl
: `${PETSHARE_BASE}${detail.spritesheetUrl}`;
const bytes = await downloadBinary(spritesheetUrl);
if (!isPlausibleSpritesheet(bytes)) {
throw new Error(`${id}: spritesheet is not webp/png/gif`);
}
await mkdir(dir, { recursive: true });
await writeFile(sheetPath, bytes);
// Mirror the manifest shape the daemon's `listCodexPets` reader
// expects, plus an explicit `source` block so the in-repo origin is
// documented next to the bytes (handy when re-baking).
const manifest = {
id: detail.id,
displayName: detail.displayName,
description: detail.description ?? '',
spritesheetPath: `spritesheet.${ext}`,
author: detail.ownerName,
tags: detail.tags ?? [],
source: 'codex-pet-share',
sourceUrl: `https://codex-pet-share.pages.dev/#/pets/${encodeURIComponent(detail.id)}`,
};
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
return 'wrote';
}
async function main(): Promise<void> {
let args: ParsedArgs;
try {
args = parseArgs(process.argv);
} catch (err) {
console.error(String((err as Error).message ?? err));
process.exit(1);
}
console.log(`Destination: ${args.out}`);
await mkdir(args.out, { recursive: true });
let wrote = 0;
let skipped = 0;
let failed = 0;
for (const id of BUNDLED_PETS) {
try {
const result = await bakePet(id, args.out, args.force);
if (result === 'wrote') {
wrote++;
console.log(`+ ${id}`);
} else {
skipped++;
console.log(`= ${id} (skipped, use --force to re-download)`);
}
} catch (err) {
failed++;
console.error(`! ${id}: ${(err as Error).message ?? err}`);
}
}
console.log(`\nDone. wrote=${wrote} skipped=${skipped} failed=${failed} (total=${BUNDLED_PETS.length})`);
if (failed > 0) process.exitCode = 1;
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env node
// Bake self-contained example.html files for each html-ppt full-deck template.
//
// The Examples gallery in apps/web renders each skill's example via an iframe
// `srcdoc`, which has no opener path and can't reach companion CSS files.
// The upstream `templates/full-decks/<name>/index.html` references shared
// assets via `../../../assets/...` paths — we inline those + the per-deck
// `style.css`, drop the runtime <script> (the gallery only shows a static
// snapshot of slide 1), and write the result to:
//
// skills/html-ppt-<name>/example.html
//
// Each per-template skill folder must already exist with a SKILL.md.
import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const HTML_PPT = path.join(ROOT, 'skills', 'html-ppt');
const ASSETS = path.join(HTML_PPT, 'assets');
const FULL_DECKS = path.join(HTML_PPT, 'templates', 'full-decks');
const SKILLS = path.join(ROOT, 'skills');
async function readMaybe(p) {
try {
return await readFile(p, 'utf8');
} catch {
return '';
}
}
const sharedFonts = await readMaybe(path.join(ASSETS, 'fonts.css'));
const sharedBase = await readMaybe(path.join(ASSETS, 'base.css'));
const sharedAnimations = await readMaybe(
path.join(ASSETS, 'animations', 'animations.css'),
);
// Without runtime.js, no slide gets `.is-active`, so the deck would render
// blank. For a static preview we surface every slide and stack them in
// print-style flow: each slide is 100vh, so the gallery thumbnail iframe
// (fixed viewport) naturally lands on slide 1, while the modal/export and
// print-to-PDF flows scroll/page through the full deck. We deliberately do
// not hide later slides — this artifact is also served via
// `/api/skills/:id/example` and reused by share/export, where dropping
// everything past slide 1 would silently truncate the deck.
const STATIC_FALLBACK_CSS = `
/* Static-preview fallback (runtime.js is absent — keep every slide visible) */
.deck{height:auto;min-height:100vh;overflow:visible}
.slide{position:relative;inset:auto;opacity:1;pointer-events:auto;transform:none;height:100vh;page-break-after:always}
.deck-header,.deck-footer,.slide-number,.progress-bar,.notes-overlay,.overview{pointer-events:none}
.notes{display:none!important}
`;
function inlineLink(html, href, content) {
// Replace <link rel="stylesheet" href="..."> regardless of attribute order.
const re = new RegExp(
`<link[^>]*href=["']${href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["'][^>]*>`,
'g',
);
return html.replace(re, `<style>${content}\n</style>`);
}
async function bakeOne(name) {
const indexPath = path.join(FULL_DECKS, name, 'index.html');
const stylePath = path.join(FULL_DECKS, name, 'style.css');
let html = await readMaybe(indexPath);
if (!html) {
console.warn(`[bake] missing ${indexPath}`);
return false;
}
const style = await readMaybe(stylePath);
html = inlineLink(html, '../../../assets/fonts.css', sharedFonts);
html = inlineLink(html, '../../../assets/base.css', sharedBase);
html = inlineLink(
html,
'../../../assets/animations/animations.css',
sharedAnimations,
);
html = inlineLink(html, 'style.css', style);
// Some templates ship a `<link id="theme-link" href="../../../assets/themes/<theme>.css">`
// so the runtime can cycle themes via `T`. The static gallery has no runtime
// and srcdoc can't follow `../../../`, so inline whatever theme the template
// shipped with — that's the look the upstream README screenshots show.
html = html.replace(
/<link[^>]*href=["']\.\.\/\.\.\/\.\.\/assets\/themes\/([\w-]+)\.css["'][^>]*>/g,
(_match, themeName) => {
try {
const css = readFileSync(
path.join(ASSETS, 'themes', `${themeName}.css`),
'utf8',
);
return `<style data-theme="${themeName}">${css}\n</style>`;
} catch {
return '';
}
},
);
// Drop the runtime + any FX runtime references — the static gallery only
// shows slide 1 and these scripts would 404 inside the srcdoc sandbox.
html = html.replace(
/<script[^>]*src=["'][^"']*runtime\.js["'][^>]*><\/script>/g,
'',
);
html = html.replace(
/<script[^>]*src=["'][^"']*fx-runtime\.js["'][^>]*><\/script>/g,
'',
);
// Append the static fallback at the very end of <head> so it overrides
// base.css's `.slide{opacity:0}`. We append rather than prepend to win
// specificity ties without bumping selectors.
html = html.replace(/<\/head>/i, `<style>${STATIC_FALLBACK_CSS}</style></head>`);
const outDir = path.join(SKILLS, `html-ppt-${name}`);
await mkdir(outDir, { recursive: true });
await writeFile(path.join(outDir, 'example.html'), html, 'utf8');
return true;
}
const entries = await readdir(FULL_DECKS, { withFileTypes: true });
const names = entries.filter((e) => e.isDirectory()).map((e) => e.name);
let baked = 0;
for (const name of names) {
if (await bakeOne(name)) baked++;
}
console.log(`[bake] wrote ${baked}/${names.length} example.html files`);
console.log(`[bake] templates: ${names.join(', ')}`);
+120
View File
@@ -0,0 +1,120 @@
import { readdir } from "node:fs/promises";
import path from "node:path";
const repoRoot = path.resolve(import.meta.dirname, "..");
const residualExtensions = new Set([".js", ".mjs", ".cjs"]);
const skippedDirectories = new Set([
".agents",
".astro",
".claude",
".claude-sessions",
".codex",
".cursor",
".git",
".od",
".od-e2e",
".opencode",
".task",
".tmp",
".vite",
"dist",
"node_modules",
"out",
]);
const allowedExactPaths = new Set([
"packages/platform/esbuild.config.mjs",
"packages/sidecar/esbuild.config.mjs",
"packages/sidecar-proto/esbuild.config.mjs",
// Maintainer utility scripts ported from the media branch. They are
// executed directly by Node and are not loaded by the app runtime.
"scripts/import-prompt-templates.mjs",
"scripts/postinstall.mjs",
"apps/packaged/esbuild.config.mjs",
// Browser service workers must be served as JavaScript files.
"apps/web/public/od-notifications-sw.js",
"scripts/bake-html-ppt-examples.mjs",
"scripts/scaffold-html-ppt-skills.mjs",
"scripts/sync-hyperframes-skill.mjs",
"scripts/verify-media-models.mjs",
"tools/dev/bin/tools-dev.mjs",
"tools/dev/esbuild.config.mjs",
"tools/pack/bin/tools-pack.mjs",
"tools/pack/esbuild.config.mjs",
"tools/pack/resources/mac/notarize.cjs",
]);
const allowedPathPrefixes = [
"apps/daemon/dist/",
"apps/web/.next/",
"apps/web/out/",
"generated/",
"e2e/playwright-report/",
"e2e/reports/html/",
"e2e/reports/playwright-html-report/",
"e2e/reports/test-results/",
// Vendored upstream HyperFrames skill helper scripts.
"skills/hyperframes/scripts/",
// Vendored upstream html-ppt skill runtime assets (lewislulu/html-ppt-skill).
"skills/html-ppt/assets/",
"test-results/",
"vendor/",
];
function toRepositoryPath(filePath: string): string {
return path.relative(repoRoot, filePath).split(path.sep).join("/");
}
function isAllowedOutputPath(repositoryPath: string): boolean {
if (allowedExactPaths.has(repositoryPath)) return true;
return allowedPathPrefixes.some((prefix) => repositoryPath.startsWith(prefix));
}
function isSkippedDirectoryName(directoryName: string): boolean {
return skippedDirectories.has(directoryName) || directoryName === ".next" || directoryName.startsWith(".next-");
}
async function collectResidualJavaScript(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const residualFiles: string[] = [];
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
const repositoryPath = toRepositoryPath(fullPath);
if (entry.isDirectory()) {
if (isSkippedDirectoryName(entry.name) || isAllowedOutputPath(`${repositoryPath}/`)) {
continue;
}
residualFiles.push(...(await collectResidualJavaScript(fullPath)));
continue;
}
if (!entry.isFile() || !residualExtensions.has(path.extname(entry.name))) {
continue;
}
if (isAllowedOutputPath(repositoryPath)) {
continue;
}
residualFiles.push(repositoryPath);
}
return residualFiles;
}
const residualFiles = await collectResidualJavaScript(repoRoot);
if (residualFiles.length > 0) {
console.error("Residual project-owned JavaScript files found:");
for (const filePath of residualFiles) {
console.error(`- ${filePath}`);
}
console.error("Convert these files to TypeScript or add a documented generated/vendor/output allowlist entry.");
process.exitCode = 1;
} else {
console.log("Residual JavaScript check passed: project-owned code is TypeScript-only.");
}
+403
View File
@@ -0,0 +1,403 @@
#!/usr/bin/env node
/**
* Pulls down the upstream prompt corpora (CC BY 4.0) and emits curated
* JSON files under `prompt-templates/{image,video}/`. Re-run anytime to
* pick up new featured prompts.
*
* Usage:
* node scripts/import-prompt-templates.mjs
*
* Source READMEs:
* - https://github.com/YouMind-OpenLab/awesome-gpt-image-2 (CC BY 4.0)
* - https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts (CC BY 4.0)
*
* Each upstream README is a structured catalog. Two patterns we care about:
*
* Featured block:
* ### No. N: <Title>
* <badges>
* #### 📖 Description
* <description paragraph>
* #### 📝 Prompt
* ```
* <prompt body>
* ```
* #### 🎬 Video (or 🖼️ Generated Images)
* <preview img / video link>
* #### 📌 Details
* - **Author:** [Name](url)
* - **Source:** [Twitter Post](url)
* - **Published:** ...
*
* All-Prompts block:
* ### <Title>
* <badges>
* > <description>
* #### 📝 Prompt
* ```
* <prompt body>
* ```
* <img src="<thumb>"> | <a href=...>
* **Author:** [Name](url) | **Source:** [Link](url) | **Published:** ...
*
* We pick the featured 6 from each repo (always good) plus a sampled slice
* of the All-Prompts head so the gallery has breadth across categories.
*
* All output JSON carries a `source` block so attribution stays intact.
*/
import { mkdir, writeFile, readdir, unlink, readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const OUT_IMAGE = path.join(ROOT, 'prompt-templates', 'image');
const OUT_VIDEO = path.join(ROOT, 'prompt-templates', 'video');
const SOURCES = [
{
surface: 'image',
repo: 'YouMind-OpenLab/awesome-gpt-image-2',
license: 'CC-BY-4.0',
readmeUrl:
'https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main/README.md',
defaultModel: 'gpt-image-2',
defaultAspect: '1:1',
// Cap how many entries we pull from the "All Prompts" tail to keep the
// committed dataset reviewable. The featured block is always taken.
sampleAllPrompts: 30,
},
{
surface: 'video',
repo: 'YouMind-OpenLab/awesome-seedance-2-prompts',
license: 'CC-BY-4.0',
readmeUrl:
'https://raw.githubusercontent.com/YouMind-OpenLab/awesome-seedance-2-prompts/main/README.md',
defaultModel: 'seedance-2.0',
defaultAspect: '16:9',
sampleAllPrompts: 30,
},
];
async function fetchText(url) {
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`failed ${url}: ${resp.status}`);
}
return resp.text();
}
function slugify(input) {
return input
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
// Featured blocks come between the "🔥 Featured Prompts" / "⭐ Featured" /
// "## 🔥 Featured Prompts" header and the next H2.
function sliceSection(md, headerRe) {
const match = headerRe.exec(md);
if (!match) return '';
const start = match.index + match[0].length;
const next = md.slice(start).search(/\n## /);
if (next === -1) return md.slice(start);
return md.slice(start, start + next);
}
function parseFeaturedBlock(block, ctx) {
const out = [];
// Each featured prompt starts at "### No. N: Title".
const headerRe = /^### No\. \d+: (.+?)\s*$/gm;
const headers = [];
let m;
while ((m = headerRe.exec(block)) !== null) {
headers.push({ index: m.index, end: m.index + m[0].length, title: m[1] });
}
for (let i = 0; i < headers.length; i += 1) {
const h = headers[i];
const next = headers[i + 1]?.index ?? block.length;
const body = block.slice(h.end, next);
const entry = parseEntryBody(body, h.title, ctx, true);
if (entry) out.push(entry);
}
return out;
}
function parseAllPromptsBlock(block, ctx) {
const out = [];
// The "All Prompts" section uses "### <Title>" headers — sometimes
// prefixed with "No. N:" (gpt-image-2 README), sometimes bare
// (seedance README). Both shapes route through parseEntryBody which
// strips the "No. N:" prefix where present.
const headerRe = /^### (.+?)\s*$/gm;
const headers = [];
let m;
while ((m = headerRe.exec(block)) !== null) {
const title = m[1].replace(/^No\.\s*\d+:\s*/, '').trim();
headers.push({ index: m.index, end: m.index + m[0].length, title });
}
for (let i = 0; i < headers.length && out.length < ctx.sampleAllPrompts; i += 1) {
const h = headers[i];
const next = headers[i + 1]?.index ?? block.length;
const body = block.slice(h.end, next);
const entry = parseEntryBody(body, h.title, ctx, false);
if (entry) out.push(entry);
}
return out;
}
function parseEntryBody(body, title, ctx, featured) {
const promptMatch = /#### 📝 Prompt\s*\n+```[a-zA-Z0-9_-]*\n([\s\S]*?)```/m.exec(
body,
);
if (!promptMatch) return null;
const prompt = promptMatch[1].trim();
if (prompt.length < 40) return null;
// The image README structures every entry — featured AND in-list —
// with a "#### 📖 Description" block. The seedance README only does
// that for featured; in-list entries fall back to a leading blockquote.
// Try the structured form first regardless, then fall back.
const description =
extractDescription(body) || extractBlockquoteSummary(body);
const author = extractAuthor(body);
const sourceUrl = extractSourceUrl(body) ?? null;
const previewImage = extractFirstImage(body);
const previewVideo = extractVideoLink(body);
const category = inferCategory(title, ctx.surface);
const tags = inferTags(title, prompt, ctx.surface);
return {
id: slugify(title),
surface: ctx.surface,
title: cleanTitle(title),
summary: (description || cleanTitle(title)).slice(0, 200),
category,
tags,
model: ctx.defaultModel,
aspect: ctx.defaultAspect,
prompt,
previewImageUrl: previewImage ?? undefined,
previewVideoUrl: previewVideo ?? undefined,
source: {
repo: ctx.repo,
license: ctx.license,
author: author ?? undefined,
url: sourceUrl ?? undefined,
},
};
}
function extractDescription(body) {
const m = /#### 📖 Description\s*\n+([\s\S]*?)(?=\n+####|\n+---)/m.exec(body);
return m?.[1]?.trim().replace(/\s+/g, ' ') ?? '';
}
function extractBlockquoteSummary(body) {
const m = /^>\s*(.+?)\s*$/m.exec(body);
return m?.[1]?.trim() ?? '';
}
function extractAuthor(body) {
// Featured: "- **Author:** [Name](url)"
// All-prompts: "**Author:** [Name](url) | ..."
const m = /\*\*Author:\*\*\s*\[([^\]]+)\]/.exec(body);
return m?.[1]?.trim() ?? null;
}
function extractSourceUrl(body) {
const m = /\*\*Source:\*\*\s*\[[^\]]+\]\(([^)]+)\)/.exec(body);
return m?.[1]?.trim() ?? null;
}
function extractFirstImage(body) {
const m = /<img[^>]*src=["']([^"']+)["']/.exec(body);
if (!m) return null;
return m[1];
}
function extractVideoLink(body) {
// 1) Featured entries embed an explicit "<a href=...releases/.../<id>.mp4">"
// download link — prefer it. GitHub releases are stable and don't
// rely on a per-request signed redirect. Catches all 6 featured
// prompts in awesome-seedance-2-prompts.
const releaseLink = /href=["']([^"']+\.mp4)["']/.exec(body);
if (releaseLink) return releaseLink[1];
// 2) All-prompts entries don't expose a static mp4 — they only embed
// the Cloudflare Stream thumbnail. Reconstruct the playable mp4
// from the Stream video id encoded in the thumbnail URL. The
// /downloads/default.mp4 endpoint 302s to a freshly-signed CDN
// URL on every request; the browser follows that transparently
// when set as <video src>. CORS is permissive (`*` on origin)
// and `accept-ranges: bytes` is honored, so seeking works too.
// This is what unlocks an actual video preview for the other
// ~30 sampled templates instead of a static thumbnail.
const streamThumb =
/https?:\/\/([a-z0-9-]+\.cloudflarestream\.com)\/([a-f0-9]{20,})\/thumbnails\/thumbnail\.jpg/i.exec(
body,
);
if (streamThumb) {
return `https://${streamThumb[1]}/${streamThumb[2]}/downloads/default.mp4`;
}
return null;
}
function cleanTitle(raw) {
// "Profile / Avatar - Cyberpunk Anime …" → strip the leading category
// prefix shared by every entry in the same gpt-image-2 bucket. Keeps
// titles scannable on cards without losing meaning.
return raw
.replace(/\s*\(.*\)\s*$/, '')
.replace(/^\s*[-]\s*/, '')
.trim();
}
function inferCategory(title, surface) {
const lower = title.toLowerCase();
if (surface === 'image') {
if (/profile|avatar|portrait/.test(lower)) return 'Profile / Avatar';
if (/social|post|carousel/.test(lower)) return 'Social Media Post';
if (/info[ -]?graphic|chart|diagram/.test(lower)) return 'Infographic';
if (/youtube|thumbnail/.test(lower)) return 'YouTube Thumbnail';
if (/comic|storyboard|panel/.test(lower)) return 'Comic / Storyboard';
if (/poster|flyer/.test(lower)) return 'Poster / Flyer';
if (/ui|app|web design|mockup|landing/.test(lower)) return 'App / Web Design';
if (/product|exploded|merch|packaging/.test(lower)) return 'Product Marketing';
if (/anime|manga/.test(lower)) return 'Anime / Manga';
if (/cinematic|film/.test(lower)) return 'Cinematic';
if (/3d|render|isometric/.test(lower)) return '3D Render';
if (/sketch|line art|pencil/.test(lower)) return 'Sketch / Line Art';
if (/pixel/.test(lower)) return 'Pixel Art';
if (/oil|water[- ]?color/.test(lower)) return 'Painterly';
if (/cyberpunk|sci[- ]?fi|futuristic/.test(lower)) return 'Cyberpunk / Sci-Fi';
if (/landscape|nature/.test(lower)) return 'Landscape';
return 'Illustration';
}
// video
if (/cinematic|film|movie|noir/.test(lower)) return 'Cinematic';
if (/anime|manga/.test(lower)) return 'Anime';
if (/ad|advert|commercial|brand/.test(lower)) return 'Advertising';
if (/ugc|tutorial|vlog/.test(lower)) return 'UGC / Vlog';
if (/meme|tiktok|viral/.test(lower)) return 'Social / Meme';
if (/drama|short film|romance/.test(lower)) return 'Short Film / Drama';
if (/intro|motion graphics|title sequence/.test(lower)) return 'Motion Graphics';
if (/vfx|fantasy|magic/.test(lower)) return 'VFX / Fantasy';
if (/race|action|combat|fight/.test(lower)) return 'Action';
return 'General';
}
function inferTags(title, prompt, surface) {
const set = new Set();
const blob = `${title} ${prompt}`.toLowerCase();
const checks = [
['portrait', /portrait|selfie|headshot/],
['anime', /anime|manga/],
['cinematic', /cinematic|filmic|grain|8k/],
['cyberpunk', /cyberpunk|neon/],
['fantasy', /fantasy|mage|elf|dragon/],
['3d-render', /3d render|unreal engine|render/],
['isometric', /isometric/],
['typography', /typography|kerning|font|lettering/],
['product', /product|packaging|exploded/],
['ugc', /ugc|vlog|selfie cam/],
['cinematic-romance', /romance|pure love|romantic/],
['action', /chase|action|combat|race/],
['food', /food|coffee|kitchen/],
['nature', /forest|river|mountain|landscape/],
];
for (const [tag, re] of checks) {
if (re.test(blob)) set.add(tag);
}
const lim = surface === 'image' ? 4 : 3;
return Array.from(set).slice(0, lim);
}
// Remove previously generated JSON files. Hand-authored templates (those
// whose `source.repo` is not the upstream CC-BY corpus we import from) are
// preserved so first-party curated prompts aren't wiped on re-run.
async function clearDir(dir, upstreamRepo) {
try {
const files = await readdir(dir);
for (const f of files) {
if (!f.endsWith('.json')) continue;
const filePath = path.join(dir, f);
let keep = false;
try {
const parsed = JSON.parse(await readFile(filePath, 'utf8'));
const repo = parsed?.source?.repo;
if (repo && repo !== upstreamRepo) keep = true;
} catch {
// Unparseable file — treat as generated and remove.
}
if (!keep) await unlink(filePath);
}
} catch {
// missing dir is fine — created below.
}
}
async function writeAll(entries, outDir, upstreamRepo) {
await mkdir(outDir, { recursive: true });
await clearDir(outDir, upstreamRepo);
// De-dup on slug; if two entries collide, keep the first (which is the
// featured one — always parsed before "All Prompts"). Hand-authored
// templates already on disk (preserved by clearDir) also take priority
// so we never overwrite curated first-party prompts.
const seen = new Set();
try {
const existing = await readdir(outDir);
for (const f of existing) {
if (f.endsWith('.json')) seen.add(f.replace(/\.json$/, ''));
}
} catch {
// noop
}
let count = 0;
for (const entry of entries) {
if (seen.has(entry.id)) continue;
seen.add(entry.id);
const filePath = path.join(outDir, `${entry.id}.json`);
await writeFile(filePath, `${JSON.stringify(entry, null, 2)}\n`, 'utf8');
count += 1;
}
return count;
}
async function main() {
let totalImage = 0;
let totalVideo = 0;
for (const ctx of SOURCES) {
const md = await fetchText(ctx.readmeUrl);
const featuredBlock = sliceSection(md, /## 🔥 Featured Prompts/m)
|| sliceSection(md, /## ⭐ Featured Prompts/m)
|| sliceSection(md, /## Featured/m);
const allPromptsBlock = sliceSection(md, /## (📋|🎬) All Prompts/m)
|| sliceSection(md, /## All Prompts/m);
const featured = parseFeaturedBlock(featuredBlock, ctx);
const sampled = parseAllPromptsBlock(allPromptsBlock, ctx);
const entries = [...featured, ...sampled];
if (entries.length === 0) {
console.error(`No entries parsed for ${ctx.repo}; check headers.`);
process.exitCode = 1;
continue;
}
const outDir = ctx.surface === 'image' ? OUT_IMAGE : OUT_VIDEO;
const written = await writeAll(entries, outDir, ctx.repo);
if (ctx.surface === 'image') totalImage += written;
else totalVideo += written;
console.log(
`[${ctx.repo}] featured=${featured.length} sampled=${sampled.length} written=${written}${path.relative(ROOT, outDir)}`,
);
}
console.log(`\nDone. ${totalImage} image + ${totalVideo} video templates.`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+49
View File
@@ -0,0 +1,49 @@
import { spawnSync } from "node:child_process";
import { dirname, extname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, "..");
const buildTargets = [
"packages/sidecar-proto",
"packages/sidecar",
"packages/platform",
"tools/dev",
"tools/pack",
];
const jsExtensions = new Set([".js", ".cjs", ".mjs"]);
function resolvePackageManagerInvocation() {
const pnpmExecPath = process.env.npm_execpath;
if (pnpmExecPath != null && pnpmExecPath.length > 0) {
if (jsExtensions.has(extname(pnpmExecPath).toLowerCase())) {
return { argsPrefix: [pnpmExecPath], command: process.execPath };
}
return { argsPrefix: [], command: pnpmExecPath };
}
return { argsPrefix: [], command: process.platform === "win32" ? "pnpm.cmd" : "pnpm" };
}
const packageManager = resolvePackageManagerInvocation();
for (const target of buildTargets) {
const result = spawnSync(
packageManager.command,
[...packageManager.argsPrefix, "-C", target, "run", "build"],
{
cwd: repoRoot,
stdio: "inherit",
},
);
if (result.error != null) {
throw result.error;
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
+236
View File
@@ -0,0 +1,236 @@
import { execFile as execFileCallback } from "node:child_process";
import { appendFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
const execFile = promisify(execFileCallback);
const BETA_TAG = "open-design-beta";
const stableVersionPattern = /^(\d+)\.(\d+)\.(\d+)$/;
const stableTagPattern = /^open-design-v(\d+\.\d+\.\d+)$/;
const betaVersionPattern = /^(\d+\.\d+\.\d+)-beta\.(\d+)$/;
const betaTagPattern = /^open-design-v(\d+\.\d+\.\d+)-beta\.(\d+)(?:\.unsigned)?$/;
type GitHubReleaseAsset = {
id?: number;
name?: string;
};
type GitHubRelease = {
assets?: GitHubReleaseAsset[];
body?: string | null;
draft?: boolean;
name?: string | null;
prerelease?: boolean;
tag_name?: string;
};
type ParsedStableVersion = {
parsed: [number, number, number];
value: string;
};
type ParsedBetaVersion = {
baseVersion: string;
betaNumber: number;
betaVersion: string;
};
function fail(message: string): never {
console.error(`[release-beta] ${message}`);
process.exit(1);
}
function parseStableVersion(value: string): [number, number, number] | null {
const match = stableVersionPattern.exec(value);
if (match == null) return null;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function compareVersions(left: [number, number, number], right: [number, number, number]): number {
const [leftMajor, leftMinor, leftPatch] = left;
const [rightMajor, rightMinor, rightPatch] = right;
const pairs = [
[leftMajor, rightMajor],
[leftMinor, rightMinor],
[leftPatch, rightPatch],
] as const;
for (const [leftPart, rightPart] of pairs) {
if (leftPart > rightPart) return 1;
if (leftPart < rightPart) return -1;
}
return 0;
}
function extractStableVersion(release: GitHubRelease): ParsedStableVersion | null {
const candidates = [release.tag_name, release.name].filter((value): value is string => typeof value === "string");
for (const candidate of candidates) {
const tagMatch = stableTagPattern.exec(candidate);
const value = tagMatch?.[1] ?? candidate.match(/\b(\d+\.\d+\.\d+)\b/)?.[1];
if (value == null) continue;
const parsed = parseStableVersion(value);
if (parsed != null) return { parsed, value };
}
return null;
}
function parseBetaParts(baseVersion: string, betaNumber: string): ParsedBetaVersion {
return {
baseVersion,
betaNumber: Number(betaNumber),
betaVersion: `${baseVersion}-beta.${betaNumber}`,
};
}
function extractBetaVersion(release: GitHubRelease): ParsedBetaVersion | null {
const tagMatch = typeof release.tag_name === "string" ? betaTagPattern.exec(release.tag_name) : null;
if (tagMatch?.[1] != null && tagMatch[2] != null) {
return parseBetaParts(tagMatch[1], tagMatch[2]);
}
const candidates = [release.name, release.body].filter((value): value is string => typeof value === "string");
for (const candidate of candidates) {
const match = candidate.match(/(\d+\.\d+\.\d+)-beta\.(\d+)/);
if (match?.[1] != null && match[2] != null) {
return parseBetaParts(match[1], match[2]);
}
}
return null;
}
function extractBetaVersionFromLatestMacYml(value: string): ParsedBetaVersion | null {
const match = value.match(/^version:\s*["']?([^"'\n]+)["']?\s*$/m);
if (match?.[1] == null) return null;
const betaMatch = betaVersionPattern.exec(match[1]);
if (betaMatch?.[1] == null || betaMatch[2] == null) return null;
return parseBetaParts(betaMatch[1], betaMatch[2]);
}
async function readPackagedVersion(): Promise<string> {
const packageJsonPath = join(process.cwd(), "apps", "packaged", "package.json");
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: unknown };
if (typeof packageJson.version !== "string") {
fail(`missing version in ${packageJsonPath}`);
}
if (!stableVersionPattern.test(packageJson.version)) {
fail(`apps/packaged/package.json version must be a stable x.y.z base version; got ${packageJson.version}`);
}
return packageJson.version;
}
async function fetchReleases(repository: string): Promise<GitHubRelease[]> {
const releases: GitHubRelease[] = [];
for (let page = 1; ; page += 1) {
const { stdout } = await execFile("gh", ["api", `repos/${repository}/releases?per_page=100&page=${page}`]);
const batch = JSON.parse(stdout) as GitHubRelease[];
if (batch.length === 0) break;
releases.push(...batch);
}
return releases;
}
async function fetchReleaseAssetText(repository: string, assetId: number): Promise<string> {
const { stdout } = await execFile("gh", [
"api",
`repos/${repository}/releases/assets/${assetId}`,
"--header",
"Accept: application/octet-stream",
]);
return stdout;
}
function setOutput(name: string, value: string): void {
const outputPath = process.env.GITHUB_OUTPUT;
if (outputPath == null || outputPath.length === 0) return;
appendFileSync(outputPath, `${name}=${value}\n`);
}
const repository = process.env.GITHUB_REPOSITORY ?? fail("GITHUB_REPOSITORY is required");
const signed = process.env.OPEN_DESIGN_RELEASE_SIGNED !== "false";
const packagedVersion = await readPackagedVersion();
const packagedParsed = parseStableVersion(packagedVersion) ?? fail(`invalid packaged version: ${packagedVersion}`);
const releases = await fetchReleases(repository);
let latestStable: ParsedStableVersion | null = null;
for (const release of releases) {
if (release.draft === true || release.prerelease === true) continue;
const stableVersion = extractStableVersion(release);
if (stableVersion == null) continue;
if (latestStable == null || compareVersions(stableVersion.parsed, latestStable.parsed) > 0) {
latestStable = stableVersion;
}
}
if (latestStable != null && compareVersions(packagedParsed, latestStable.parsed) <= 0) {
fail(`packaged base version ${packagedVersion} must be strictly greater than latest stable ${latestStable.value}`);
}
const betaCandidates: ParsedBetaVersion[] = [];
for (const release of releases) {
const beta = extractBetaVersion(release);
if (beta != null) betaCandidates.push(beta);
}
const existingBetaRelease = releases.find((release) => release.tag_name === BETA_TAG);
const latestMacAsset = existingBetaRelease?.assets?.find((asset) => asset.name === "latest-mac.yml");
if (latestMacAsset?.id != null) {
const beta = extractBetaVersionFromLatestMacYml(await fetchReleaseAssetText(repository, latestMacAsset.id));
if (beta != null) betaCandidates.push(beta);
}
let betaNumber = 1;
for (const beta of betaCandidates) {
const existingBase = parseStableVersion(beta.baseVersion);
if (existingBase == null) continue;
const ordering = compareVersions(packagedParsed, existingBase);
if (ordering < 0) {
fail(`packaged base version ${packagedVersion} regressed below current beta base version ${beta.baseVersion}`);
}
if (ordering === 0) {
betaNumber = Math.max(betaNumber, beta.betaNumber + 1);
}
}
const betaVersion = `${packagedVersion}-beta.${betaNumber}`;
const unsignedSuffix = signed ? "" : ".unsigned";
const versionTag = `open-design-v${betaVersion}${unsignedSuffix}`;
const branch = process.env.GITHUB_REF_NAME ?? "";
const commit = process.env.GITHUB_SHA ?? "";
const releaseName = `Open Design Beta ${betaVersion}${signed ? "" : " (unsigned)"}`;
console.log(`[release-beta] channel: beta`);
console.log(`[release-beta] base version: ${packagedVersion}`);
console.log(`[release-beta] beta version: ${betaVersion}`);
console.log(`[release-beta] signed: ${signed ? "true" : "false"}`);
console.log(`[release-beta] fixed beta tag: ${BETA_TAG}`);
console.log(`[release-beta] immutable beta tag: ${versionTag}`);
if (latestStable != null) console.log(`[release-beta] latest stable: ${latestStable.value}`);
setOutput("asset_version_suffix", unsignedSuffix);
setOutput("base_version", packagedVersion);
setOutput("beta_number", String(betaNumber));
setOutput("beta_tag", BETA_TAG);
setOutput("beta_version", betaVersion);
setOutput("branch", branch);
setOutput("commit", commit);
setOutput("latest_stable", latestStable?.value ?? "");
setOutput("release_name", releaseName);
setOutput("signed", signed ? "true" : "false");
setOutput("version_tag", versionTag);
+141
View File
@@ -0,0 +1,141 @@
import { execFile as execFileCallback } from "node:child_process";
import { appendFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
const execFile = promisify(execFileCallback);
const stableVersionPattern = /^(\d+)\.(\d+)\.(\d+)$/;
const stableTagPattern = /^open-design-v(\d+\.\d+\.\d+)$/;
type GitHubRelease = {
draft?: boolean;
name?: string | null;
prerelease?: boolean;
tag_name?: string;
};
type ParsedStableVersion = {
parsed: [number, number, number];
value: string;
};
function fail(message: string): never {
console.error(`[release-stable] ${message}`);
process.exit(1);
}
function parseStableVersion(value: string): [number, number, number] | null {
const match = stableVersionPattern.exec(value);
if (match == null) return null;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function compareVersions(left: [number, number, number], right: [number, number, number]): number {
const [leftMajor, leftMinor, leftPatch] = left;
const [rightMajor, rightMinor, rightPatch] = right;
const pairs = [
[leftMajor, rightMajor],
[leftMinor, rightMinor],
[leftPatch, rightPatch],
] as const;
for (const [leftPart, rightPart] of pairs) {
if (leftPart > rightPart) return 1;
if (leftPart < rightPart) return -1;
}
return 0;
}
function extractStableVersion(release: GitHubRelease): ParsedStableVersion | null {
const candidates = [release.tag_name, release.name].filter((value): value is string => typeof value === "string");
for (const candidate of candidates) {
const tagMatch = stableTagPattern.exec(candidate);
const value = tagMatch?.[1] ?? candidate.match(/\b(\d+\.\d+\.\d+)\b/)?.[1];
if (value == null) continue;
const parsed = parseStableVersion(value);
if (parsed != null) return { parsed, value };
}
return null;
}
async function readPackagedVersion(): Promise<string> {
const packageJsonPath = join(process.cwd(), "apps", "packaged", "package.json");
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: unknown };
if (typeof packageJson.version !== "string") {
fail(`missing version in ${packageJsonPath}`);
}
if (!stableVersionPattern.test(packageJson.version)) {
fail(`apps/packaged/package.json version must be a stable x.y.z base version; got ${packageJson.version}`);
}
return packageJson.version;
}
async function fetchReleases(repository: string): Promise<GitHubRelease[]> {
const releases: GitHubRelease[] = [];
for (let page = 1; ; page += 1) {
const { stdout } = await execFile("gh", ["api", `repos/${repository}/releases?per_page=100&page=${page}`]);
const batch = JSON.parse(stdout) as GitHubRelease[];
if (batch.length === 0) break;
releases.push(...batch);
}
return releases;
}
function setOutput(name: string, value: string): void {
const outputPath = process.env.GITHUB_OUTPUT;
if (outputPath == null || outputPath.length === 0) return;
appendFileSync(outputPath, `${name}=${value}\n`);
}
const repository = process.env.GITHUB_REPOSITORY ?? fail("GITHUB_REPOSITORY is required");
const stableVersion = await readPackagedVersion();
const stableParsed = parseStableVersion(stableVersion) ?? fail(`invalid packaged version: ${stableVersion}`);
const versionTag = `open-design-v${stableVersion}`;
const releases = await fetchReleases(repository);
let latestStable: ParsedStableVersion | null = null;
for (const release of releases) {
if (release.draft === true || release.prerelease === true) continue;
const parsedRelease = extractStableVersion(release);
if (parsedRelease == null) continue;
if (release.tag_name === versionTag) {
fail(`stable release ${versionTag} already exists; bump apps/packaged/package.json before publishing`);
}
if (latestStable == null || compareVersions(parsedRelease.parsed, latestStable.parsed) > 0) {
latestStable = parsedRelease;
}
}
if (latestStable != null && compareVersions(stableParsed, latestStable.parsed) <= 0) {
fail(`packaged stable version ${stableVersion} must be strictly greater than latest stable ${latestStable.value}`);
}
const branch = process.env.GITHUB_REF_NAME ?? "";
const commit = process.env.GITHUB_SHA ?? "";
const releaseName = `Open Design ${stableVersion}`;
console.log(`[release-stable] channel: stable`);
console.log(`[release-stable] version: ${stableVersion}`);
console.log(`[release-stable] version tag: ${versionTag}`);
if (latestStable != null) console.log(`[release-stable] previous stable: ${latestStable.value}`);
setOutput("base_version", stableVersion);
setOutput("branch", branch);
setOutput("commit", commit);
setOutput("previous_stable", latestStable?.value ?? "");
setOutput("release_name", releaseName);
setOutput("stable_version", stableVersion);
setOutput("version_tag", versionTag);
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env node
// Scaffold one Open Design skill per upstream html-ppt full-deck template.
//
// Each generated `skills/html-ppt-<name>/SKILL.md` ships only frontmatter +
// a short body. Authoring guidance, layouts, themes, and animations live in
// the master `skills/html-ppt/` skill — these wrappers only exist so each
// template surfaces as its own card in the Examples gallery and so the
// "Use this prompt" flow can prefill `mode=deck`, scenario, and the right
// example_prompt.
import { writeFile, mkdir } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const SKILLS = path.join(ROOT, 'skills');
const UPSTREAM_URL = 'https://github.com/lewislulu/html-ppt-skill';
// `featured` is a sort priority used by the Examples gallery — smaller wins
// the tie-break, so a curated handful float to the top. Templates without
// `featured` slot in alphabetically after the existing skills.
const TEMPLATES = [
{
slug: 'pitch-deck',
name: 'html-ppt-pitch-deck',
title: 'HTML PPT · Pitch Deck',
scenario: 'finance',
featured: 20,
description:
'Investor-ready 10-slide HTML pitch deck — white + blue→purple gradient hero, big numbers, traction bar chart, $4.5M-style ask page. Use when the user wants a fundraising deck, seed-round pitch, or VC meeting slides.',
triggers: ['pitch deck', 'pitch', 'fundraising', 'seed round', 'investor deck', 'vc deck', 'pitch slides'],
examplePrompt:
'Build a 10-slide pitch deck in HTML for my seed round. Use the html-ppt-pitch-deck full-deck template (white + blue→purple gradient, traction bars, $X.XM ask). Confirm three things first: (1) name + one-line pitch, (2) key traction numbers, (3) ask + use of funds.',
},
{
slug: 'product-launch',
name: 'html-ppt-product-launch',
title: 'HTML PPT · Product Launch',
scenario: 'marketing',
featured: 21,
description:
'Launch keynote deck — dark hero + light content, warm orange→peach accent, feature cards, pricing tiers, CTA. Use when announcing a product, launching a feature, or doing a keynote-style reveal.',
triggers: ['product launch', 'keynote', 'launch deck', 'feature reveal', 'launch slides', '发布会'],
examplePrompt:
'Make a product-launch keynote deck in HTML using the html-ppt-product-launch full-deck template (dark hero, warm orange accent, feature cards, pricing tiers). Confirm: product name + tagline, the 3 key features, and pricing tiers — then write the deck.',
},
{
slug: 'tech-sharing',
name: 'html-ppt-tech-sharing',
title: 'HTML PPT · Tech Sharing',
scenario: 'engineering',
featured: 22,
description:
'Conference / internal tech-talk deck — GitHub-dark, JetBrains Mono, terminal code blocks, agenda + Q&A pages. Use for engineering presentations, internal sharing sessions, conference talks, and code-heavy walkthroughs.',
triggers: ['tech sharing', 'tech talk', '技术分享', 'engineering talk', 'conference talk', 'dev talk'],
examplePrompt:
'帮我用 html-ppt-tech-sharing 模板做一份 8 页的技术分享 PPT。先确认:分享主题、目标听众(同事 / 社区 / 客户)、要不要包含代码片段和 benchmark。GitHub 暗色主题 + JetBrains Monoagenda + Q&A 页备好。',
},
{
slug: 'weekly-report',
name: 'html-ppt-weekly-report',
title: 'HTML PPT · Weekly Report',
scenario: 'operations',
featured: 23,
description:
'Team weekly / status-update deck — corporate clarity, 8-cell KPI grid, shipped list, 8-week bar chart, next-week table. Use for 周报, business reviews, team status updates, and exec dashboards.',
triggers: ['weekly report', '周报', 'status update', 'team report', 'business review', 'wbr'],
examplePrompt:
'用 html-ppt-weekly-report 模板生成一份周报(7 页)。先问我四件事:本周时间范围、3-5 个核心 KPI 数字、本周已发布 / 已完成的事项、下周计划与风险。然后用模板填好 8 周柱状图和下周表格。',
},
{
slug: 'xhs-post',
name: 'html-ppt-xhs-post',
title: 'HTML PPT · 小红书 图文',
scenario: 'marketing',
featured: 24,
description:
'小红书 / Instagram 风 9 页 3:4 竖版图文(810×1080)— 暖色 pastel、虚线 sticker 卡片、底部页码点点。用于发小红书图文、Instagram carousel、品牌种草内容。',
triggers: ['小红书', 'xhs', 'xhs post', 'xiaohongshu', '图文', 'instagram carousel', '种草'],
examplePrompt:
'帮我用 html-ppt-xhs-post 模板做一组 9 张小红书图文(3:4 竖版,810×1080)。先告诉我主题,然后帮我把封面 + 7 页内容 + 结尾 CTA 排好,每页一句标题 + 一段正文 + 关键词 sticker。',
},
{
slug: 'course-module',
name: 'html-ppt-course-module',
title: 'HTML PPT · Course Module',
scenario: 'education',
featured: 25,
description:
'Online-course / workshop module deck — warm paper background + Playfair serif, persistent left sidebar of learning objectives, MCQ self-check page. Use for teaching modules, training materials, workshop slides.',
triggers: ['course module', 'course slides', 'workshop', 'training deck', 'lesson', '教学', '课件'],
examplePrompt:
'Use the html-ppt-course-module template to build a 7-slide module deck. Confirm: module title, 3-5 learning objectives (these stick on the left rail), and the MCQ self-check question. Then assemble the deck with serif headings on warm paper.',
},
{
slug: 'presenter-mode-reveal',
name: 'html-ppt-presenter-mode',
title: 'HTML PPT · Presenter Mode (演讲者模式)',
scenario: 'engineering',
featured: 26,
description:
'演讲者模式专用 deck — tokyo-night 默认主题,5 套主题 T 键切换,每页带 150-300 字逐字稿示例(<aside class="notes">),按 S 打开 popupCURRENT / NEXT / SCRIPT / TIMER 四张磁吸卡片)。用于技术分享、公开演讲、课程讲解,怕忘词或要提词器的场景。',
triggers: ['presenter mode', '演讲者模式', '逐字稿', 'speaker notes', '提词器', 'presenter view', '演讲'],
examplePrompt:
'用 html-ppt-presenter-mode 模板做一份带逐字稿的演讲 PPT。先确认:演讲主题、时长(每页 2-3 分钟)、目标听众。然后帮我每页写 150-300 字的口语化逐字稿(不是讲稿,是提示信号),按 S 能打开 presenter 弹窗。',
},
{
slug: 'xhs-white-editorial',
name: 'html-ppt-xhs-white-editorial',
title: 'HTML PPT · 白底杂志风',
scenario: 'marketing',
featured: 27,
description:
'白底杂志风 deck — 纯白背景 + 顶部 10 色彩虹 bar、80-110px display 标题、紫→蓝→绿→橙→粉渐变文字、马卡龙软卡片组(粉/紫/蓝/绿/橙)、黑底白字 .focus pill、引用大块。同时适合发小红书图文 + 横版 PPT 双用。',
triggers: ['白底杂志', '杂志风', 'xhs editorial', 'white editorial', '小红书白底', 'editorial deck'],
examplePrompt:
'用 html-ppt-xhs-white-editorial 模板做一份白底杂志风 PPT,中文优先。要点:80-110px display 大标题、彩虹顶部 bar、马卡龙软卡片、黑底白字 .focus pill。先告诉我主题和受众,再写 8-12 页。',
},
{
slug: 'graphify-dark-graph',
name: 'html-ppt-graphify-dark-graph',
title: 'HTML PPT · 暗底知识图谱',
scenario: 'engineering',
featured: 28,
description:
'暗底知识图谱 deck — #06060c→#0e1020 深夜渐变 + 漂浮 blur orbs、封面 SVG 力导向图谱、彩虹渐变标题、JetBrains Mono 命令行高亮、glass-morphism 卡片。适合 dev-tool / CLI / 知识图谱 / 数据可视化的发布会,"AI-native + 科幻 + 暖色" 调子。',
triggers: ['知识图谱', 'graph deck', 'dark graph', 'dev tool launch', 'cli launch', 'data viz launch'],
examplePrompt:
'用 html-ppt-graphify-dark-graph 模板做一份 dev-tool 发布会 PPT。深夜渐变背景 + 力导向图谱封面 + 彩虹标题 + JetBrains Mono 命令行。先确认:工具名、核心能力、demo 步骤;要不要现场敲 CLI。',
},
{
slug: 'knowledge-arch-blueprint',
name: 'html-ppt-knowledge-arch-blueprint',
title: 'HTML PPT · 奶油蓝图架构',
scenario: 'engineering',
featured: 29,
description:
'奶油蓝图架构 deck — 奶油纸 #F0EAE0 底色 + 单一锈红 #B5392A 高亮、48px 蓝图网格 mask、2px 黑边硬卡片、pipeline 步骤盒(其中一个抬高)、右侧锈红 insight callout、Playfair 衬线大字、SVG 虚线反馈环。零渐变零软阴影,认真且印刷友好。',
triggers: ['architecture', 'blueprint', 'system design', '架构图', 'data flow', 'engineering whitepaper'],
examplePrompt:
'用 html-ppt-knowledge-arch-blueprint 模板做一份系统架构介绍 PPT。奶油纸底 + 锈红高亮 + 蓝图网格 + pipeline 抬高一格 + 衬线大字。先告诉我系统名 + 5-7 个核心模块 + 数据流方向,再写 8-10 页。',
},
{
slug: 'hermes-cyber-terminal',
name: 'html-ppt-hermes-cyber-terminal',
title: 'HTML PPT · 暗终端测评',
scenario: 'engineering',
featured: 30,
description:
'暗终端 honest-review deck — #0a0c10 黑底 + 56px 赛博网格 + CRT 暗角 + 扫描线、窗口红绿灯 chrome、`$ prompt` 命令行标题、薄荷绿 #7ed3a4 大字、JetBrains Mono、stroke-only 柱状图、blinking 光标、琥珀/绿/红三档 tag、暗色代码块。适合 CLI / agent / dev tool 测评(含 trace、diff、benchmark)。',
triggers: ['terminal review', 'cli review', 'agent review', 'honest review', 'dev tool review', '测评'],
examplePrompt:
'用 html-ppt-hermes-cyber-terminal 模板做一份 CLI / agent 测评 PPT。深色终端风 + scanlines + 命令行标题 + benchmark 柱状图。先确认:被测评对象、3-5 个对比维度、benchmark 数据。',
},
{
slug: 'obsidian-claude-gradient',
name: 'html-ppt-obsidian-claude-gradient',
title: 'HTML PPT · GitHub 暗紫渐变',
scenario: 'engineering',
featured: 31,
description:
'GitHub 暗紫渐变 deck — GitHub-dark #0d1117 + 紫蓝 radial 环境光 + 60px 网格 mask、居中布局、紫色 pill 标签、三色渐变标题(#a855f7→#60a5fa→#34d399)、GitHub 风代码 palette、紫色左边框高亮块。适合开发者工作流 / MCP / Agent / dev tool 教程,类似 GitHub Blog / Linear Changelog。',
triggers: ['github dark', 'developer tutorial', 'mcp tutorial', 'agent tutorial', 'dev workflow', 'changelog deck'],
examplePrompt:
'用 html-ppt-obsidian-claude-gradient 模板做一份开发者教程 PPT。GitHub 暗紫渐变 + 居中布局 + 紫色 pill + 三色渐变标题 + 配置/步骤代码块。先确认:教什么、目标受众、要不要 MCP/Agent 配置示例。',
},
{
slug: 'testing-safety-alert',
name: 'html-ppt-testing-safety-alert',
title: 'HTML PPT · 红琥珀警示',
scenario: 'engineering',
featured: 32,
description:
'红琥珀警示 deck — 顶/底 45° 红黑 hazard 条纹、红色删除线否定标题、L1/L2/L3 绿/琥珀/红 tier 卡片、圆点状态 alert box、policy-yaml 代码块(红左边框 + bad 关键词高亮)、红绿 checklist、Q1 事故堆叠柱状图。适合安全 / 风险 / 事故复盘 / 红队 / 上线前 AI 评审 / policy-as-code。',
triggers: ['safety alert', 'incident', 'red team', 'risk review', '事故复盘', '安全评审', 'policy as code'],
examplePrompt:
'用 html-ppt-testing-safety-alert 模板做一份事故复盘 / 安全评审 PPT。红黑 hazard 条 + 红色删除线 + L1/L2/L3 tier 卡片 + policy-yaml 代码块。先告诉我事件时间线、根因、影响范围。',
},
{
slug: 'xhs-pastel-card',
name: 'html-ppt-xhs-pastel-card',
title: 'HTML PPT · 柔和马卡龙慢生活',
scenario: 'personal',
featured: 33,
description:
'柔和马卡龙慢生活 deck — 奶油 #fef8f1 底 + 三个柔光 blob、Playfair 斜体衬线 display 标题混 sans 正文、28px 圆角马卡龙卡片(桃 / 薄荷 / 天 / 紫 / 柠 / 玫)、Playfair 斜体 01-04 序号、SVG donut 图、chip+page 顶栏。适合生活方式 / 个人成长 / 慢生活 / 情绪类内容,"杂志、手作、不太科技"的感觉。',
triggers: ['pastel', 'macaron', 'lifestyle', 'slow living', '慢生活', '生活方式', '个人成长'],
examplePrompt:
'用 html-ppt-xhs-pastel-card 模板做一份慢生活主题图文。奶油底 + 马卡龙圆角卡片 + Playfair 斜体序号 + donut 图。先告诉我主题(休息 / 暂停 / 自我照顾…)和 5-7 个想说的点。',
},
{
slug: 'dir-key-nav-minimal',
name: 'html-ppt-dir-key-nav-minimal',
title: 'HTML PPT · 8 色极简方向键',
scenario: 'personal',
featured: 34,
description:
'8 页极简方向键 keynote — 每页一个独立单色背景(靛 / 奶 / 绛 / 翠 / 灰 / 紫 / 白 / 炭),各自配色,160px display 标题 + 4px 短粗 accent 线分隔、箭头 → 前缀的 Mono 列表、左下 ← → kbd 提示 + 右下页码、巨大呼吸留白。适合"有话要说但没什么可看"的 keynote、launch、公开演讲。',
triggers: ['minimal keynote', '极简', 'mono color', 'one idea per slide', 'public talk', 'launch keynote'],
examplePrompt:
'用 html-ppt-dir-key-nav-minimal 模板做一份 8 页极简 keynote。每页一个单色背景 + 一句 160px 大标题 + 几条箭头列表。先告诉我演讲主题,然后帮我把 8 个核心观点拍成 8 页(每页一个 idea)。',
},
];
const SKILL_BODY = (t) => `# ${t.title}
A focused entry point into the [\`html-ppt\`](../html-ppt/SKILL.md) master skill that lands the user directly on the **\`${t.slug}\`** full-deck template.
## When this card is picked
The Examples gallery wires "Use this prompt" to the example_prompt above. When you accept that prompt, this card is the right pick if the user wants exactly the visual identity of \`${t.slug}\` (see the upstream [full-decks catalog](../html-ppt/references/full-decks.md) for screenshots and rationale).
## How to author the deck
1. **Read the master skill first.** All authoring rules live in
[\`skills/html-ppt/SKILL.md\`](../html-ppt/SKILL.md) — content/audience checklist,
token rules, layout reuse, presenter mode, the keyboard runtime, and the
"never put presenter-only text on the slide" rule.
2. **Start from the matching template folder:**
\`skills/html-ppt/templates/full-decks/${t.slug}/\` — copy \`index.html\` and
\`style.css\` into the project, keep the \`.tpl-${t.slug}\` body class.
3. **Bring the shared runtime with the template.** The upstream
\`index.html\` links the shared CSS/JS via \`../../../assets/...\` because it
sits three folders deep inside \`skills/html-ppt/templates/full-decks/\`.
Once you copy \`index.html\` into the project, those parent-relative URLs
no longer resolve and \`base.css\`, \`animations.css\`, and \`runtime.js\`
will 404 — meaning the deck never activates and slide navigation is
dead. Pick one of these two recipes per project:
- **Recipe A — copy + rewrite (preferred):** copy
\`skills/html-ppt/assets/fonts.css\`, \`skills/html-ppt/assets/base.css\`,
\`skills/html-ppt/assets/animations/animations.css\`, and
\`skills/html-ppt/assets/runtime.js\` into a project-local
\`assets/\` (with \`assets/animations/animations.css\`), then rewrite the
four \`<link>\`/\`<script>\` tags in \`index.html\` from
\`../../../assets/...\` to the matching project-local paths
(\`assets/fonts.css\`, \`assets/base.css\`,
\`assets/animations/animations.css\`, \`assets/runtime.js\`).
- **Recipe B — inline:** read the same four files and replace each
\`<link rel="stylesheet" href="../../../assets/...">\` with a
\`<style>...</style>\` containing the file's contents, and the
\`<script src="../../../assets/runtime.js">\` with a
\`<script>...</script>\` containing \`runtime.js\`. Yields a single
self-contained \`index.html\`.
Either way, do not ship the upstream \`../../../assets/...\` URLs
verbatim into a project artifact — they only work in-tree.
4. **Pick a theme.** Default tokens look fine; if the user wants a different
feel, swap in any of the 36 themes from \`skills/html-ppt/assets/themes/*.css\`
via \`<link id="theme-link">\` and let \`T\` cycle.
5. **Replace demo content, not classes.** The \`.tpl-${t.slug}\` scoped CSS only
recognises the structural classes shipped in the template — keep them.
6. **Speaker notes go inside \`<aside class="notes">\` or \`<div class="notes">\`** — never as visible text on the slide.
## Attribution
Visual system, layouts, themes and the runtime keyboard model come from
the upstream MIT-licensed [\`lewislulu/html-ppt-skill\`](${UPSTREAM_URL}). The
LICENSE file ships at \`skills/html-ppt/LICENSE\`; please keep it in place when
redistributing.
`;
function frontmatter(t) {
const triggers = t.triggers
.map((s) => ` - "${s.replace(/"/g, '\\"')}"`)
.join('\n');
return [
'---',
`name: ${t.name}`,
`description: ${t.description}`,
'triggers:',
triggers,
'od:',
' mode: deck',
` scenario: ${t.scenario}`,
` featured: ${t.featured}`,
` upstream: "${UPSTREAM_URL}"`,
' preview:',
' type: html',
' entry: index.html',
' design_system:',
' requires: false',
' speaker_notes: true',
' animations: true',
` example_prompt: ${JSON.stringify(t.examplePrompt)}`,
'---',
'',
].join('\n');
}
let wrote = 0;
for (const t of TEMPLATES) {
const dir = path.join(SKILLS, `html-ppt-${t.slug}`);
await mkdir(dir, { recursive: true });
const skillMd = frontmatter(t) + SKILL_BODY(t);
await writeFile(path.join(dir, 'SKILL.md'), skillMd, 'utf8');
wrote++;
}
console.log(`[scaffold] wrote ${wrote} html-ppt-* SKILL.md files`);
+406
View File
@@ -0,0 +1,406 @@
#!/usr/bin/env node
// Sync community Codex pets from the public catalogs into the local
// `${CODEX_HOME:-$HOME/.codex}/pets/` registry that the daemon scans
// in `apps/daemon/src/codex-pets.ts`. Once synced, every pet shows up
// under Settings → Pets → Recently hatched and can be adopted with a
// single click — no manual `pet.json` / `spritesheet.webp` upload.
//
// Sources:
// - Codex Pet Share (https://codex-pet-share.pages.dev) — paginated
// Supabase Functions endpoint, ~170 pets at the time of writing.
// - j20 Hatchery (https://j20.nz/hatchery) — single-shot
// JSON catalog, ~30 pets at the time of writing.
//
// Both catalogs serve a `pet.json` (Codex pet contract) and a
// `spritesheet.webp` (8x9 atlas) per pet, so we just persist them to
// disk in the canonical Codex layout.
//
// Usage:
// node --experimental-strip-types scripts/sync-community-pets.ts
// node --experimental-strip-types scripts/sync-community-pets.ts --out /tmp/pets
// node --experimental-strip-types scripts/sync-community-pets.ts --source petshare
// node --experimental-strip-types scripts/sync-community-pets.ts --force
//
// Flags:
// --out <dir> Destination root. Defaults to
// `${CODEX_HOME:-$HOME/.codex}/pets`.
// --source <name> 'petshare' | 'hatchery' | 'all' (default).
// --force Re-download pets that already have a folder.
// --limit <n> Stop after N pets per source (handy for smoke
// tests).
// --concurrency <n> Parallel downloads. Defaults to 6.
// --no-pet-share Skip the petshare catalog.
// --no-hatchery Skip the hatchery catalog.
import { mkdir, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
const PETSHARE_BASE = 'https://ihzwckyzfcuktrljwpha.supabase.co/functions/v1/petshare';
const HATCHERY_LIST = 'https://j20.nz/hatchery/api/pets.json';
interface Args {
out: string;
sources: Set<'petshare' | 'hatchery'>;
force: boolean;
limit: number | null;
concurrency: number;
}
interface PetTask {
source: 'petshare' | 'hatchery';
// Slug-safe folder name under <out>/.
folder: string;
// Manifest written verbatim to <folder>/pet.json.
manifest: Record<string, unknown>;
// URL of the spritesheet binary.
spritesheetUrl: string;
// Detected file extension ('webp' | 'png' | 'gif').
spritesheetExt: string;
}
function parseArgs(argv: string[]): Args {
const home = process.env.CODEX_HOME?.trim() || path.join(os.homedir(), '.codex');
const args: Args = {
out: path.join(home, 'pets'),
sources: new Set(['petshare', 'hatchery']),
force: false,
limit: null,
concurrency: 6,
};
for (let i = 2; i < argv.length; i++) {
const flag = argv[i];
const next = (): string => {
const v = argv[++i];
if (!v) throw new Error(`flag ${flag} expects a value`);
return v;
};
switch (flag) {
case '--out':
args.out = path.resolve(next());
break;
case '--source': {
const value = next();
if (value === 'all') {
args.sources = new Set(['petshare', 'hatchery']);
} else if (value === 'petshare' || value === 'hatchery') {
args.sources = new Set([value]);
} else {
throw new Error(`unknown --source value: ${value}`);
}
break;
}
case '--no-pet-share':
args.sources.delete('petshare');
break;
case '--no-hatchery':
args.sources.delete('hatchery');
break;
case '--force':
args.force = true;
break;
case '--limit':
args.limit = Math.max(1, Number.parseInt(next(), 10));
break;
case '--concurrency':
args.concurrency = Math.max(1, Number.parseInt(next(), 10));
break;
case '-h':
case '--help':
printHelp();
process.exit(0);
default:
throw new Error(`unknown flag: ${flag}`);
}
}
return args;
}
function printHelp(): void {
console.log(`Sync community Codex pets into ~/.codex/pets
Usage:
node --experimental-strip-types scripts/sync-community-pets.ts [flags]
Flags:
--out <dir> Destination root (default: $CODEX_HOME/pets or ~/.codex/pets)
--source <name> petshare | hatchery | all (default: all)
--no-pet-share Skip the Codex Pet Share catalog
--no-hatchery Skip the j20 Hatchery catalog
--force Re-download pets that already exist on disk
--limit <n> Cap each source at N pets (for smoke tests)
--concurrency <n> Parallel downloads (default: 6)
-h, --help Show this message`);
}
function sanitizeFolder(value: string): string {
return String(value ?? '')
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^[._-]+|[._-]+$/g, '')
.slice(0, 80);
}
function extOf(url: string): string {
const clean = url.split('?')[0] ?? '';
const ext = clean.split('.').pop()?.toLowerCase() ?? 'webp';
if (ext === 'webp' || ext === 'png' || ext === 'gif') return ext;
return 'webp';
}
async function pathExists(p: string): Promise<boolean> {
try {
await stat(p);
return true;
} catch {
return false;
}
}
interface PetSharePet {
id: string;
displayName: string;
description: string;
ownerName?: string;
tags?: string[];
spritesheetUrl: string;
spritesheetPath?: string;
}
interface PetShareResponse {
pets: PetSharePet[];
page: number;
pageSize: number;
total: number;
totalPages: number;
}
async function listPetSharePets(limit: number | null): Promise<PetTask[]> {
const tasks: PetTask[] = [];
let page = 1;
const pageSize = 24;
for (;;) {
const url = `${PETSHARE_BASE}/api/pets?page=${page}&pageSize=${pageSize}`;
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`petshare list page ${page} failed: ${resp.status} ${resp.statusText}`);
}
const data = (await resp.json()) as PetShareResponse;
for (const pet of data.pets) {
const folder = sanitizeFolder(pet.id);
if (!folder) continue;
const spritesheetUrl = pet.spritesheetUrl.startsWith('http')
? pet.spritesheetUrl
: `${PETSHARE_BASE}${pet.spritesheetUrl}`;
const ext = extOf(pet.spritesheetPath ?? spritesheetUrl);
tasks.push({
source: 'petshare',
folder,
manifest: {
id: pet.id,
displayName: pet.displayName,
description: pet.description ?? '',
spritesheetPath: `spritesheet.${ext}`,
author: pet.ownerName,
tags: pet.tags ?? [],
source: 'codex-pet-share',
sourceUrl: `https://codex-pet-share.pages.dev/#/pets/${encodeURIComponent(pet.id)}`,
},
spritesheetUrl,
spritesheetExt: ext,
});
if (limit && tasks.length >= limit) return tasks;
}
if (page >= data.totalPages) break;
page++;
}
return tasks;
}
interface HatcheryPet {
id: string;
displayName: string;
description: string;
petManifestId?: string;
authorLabel?: string;
authorXUrl?: string;
galleryUrl?: string;
petJsonUrl: string;
spritesheetUrl: string;
downloadCount?: number;
createdAt?: string;
}
interface HatcheryResponse {
source: string;
count: number;
pets: HatcheryPet[];
}
async function listHatcheryPets(limit: number | null): Promise<PetTask[]> {
const resp = await fetch(HATCHERY_LIST);
if (!resp.ok) {
throw new Error(`hatchery list failed: ${resp.status} ${resp.statusText}`);
}
const data = (await resp.json()) as HatcheryResponse;
const tasks: PetTask[] = [];
for (const pet of data.pets) {
// Prefer the human-readable manifest id when available — that is
// what users see in their `~/.codex/pets/` listing.
const folder = sanitizeFolder(pet.petManifestId || pet.id);
if (!folder) continue;
// We will rewrite pet.json from the live `petJsonUrl` content, but
// also keep our enriched fields so users can trace the origin.
tasks.push({
source: 'hatchery',
folder,
manifest: {
id: pet.petManifestId || pet.id,
displayName: pet.displayName,
description: pet.description ?? '',
spritesheetPath: 'spritesheet.webp',
author: pet.authorLabel,
authorXUrl: pet.authorXUrl,
source: 'j20-hatchery',
sourceUrl: pet.galleryUrl,
},
spritesheetUrl: pet.spritesheetUrl,
spritesheetExt: extOf(pet.spritesheetUrl),
});
if (limit && tasks.length >= limit) break;
}
return tasks;
}
async function downloadBinary(url: string): Promise<Buffer> {
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`download ${url} failed: ${resp.status} ${resp.statusText}`);
}
const ab = await resp.arrayBuffer();
return Buffer.from(ab);
}
async function writePet(
task: PetTask,
outRoot: string,
force: boolean,
): Promise<'wrote' | 'skipped'> {
const dir = path.join(outRoot, task.folder);
const sheetPath = path.join(dir, `spritesheet.${task.spritesheetExt}`);
const manifestPath = path.join(dir, 'pet.json');
if (!force && (await pathExists(sheetPath)) && (await pathExists(manifestPath))) {
return 'skipped';
}
await mkdir(dir, { recursive: true });
const bytes = await downloadBinary(task.spritesheetUrl);
// Validate the magic bytes minimally — abort writes when the server
// returns an HTML error page (every catalog has had transient hiccups
// at some point), so callers do not end up with `.webp` files that
// are actually `<!doctype html>`.
if (bytes.length < 16) {
throw new Error(`${task.folder}: spritesheet too small (${bytes.length} bytes)`);
}
const head = bytes.subarray(0, 12);
const isWebp = head.toString('ascii', 0, 4) === 'RIFF' && head.toString('ascii', 8, 12) === 'WEBP';
const isPng = head.toString('hex', 0, 8) === '89504e470d0a1a0a';
const isGif = head.toString('ascii', 0, 6) === 'GIF87a' || head.toString('ascii', 0, 6) === 'GIF89a';
if (!isWebp && !isPng && !isGif) {
throw new Error(`${task.folder}: spritesheet is not webp/png/gif`);
}
await writeFile(sheetPath, bytes);
await writeFile(manifestPath, JSON.stringify(task.manifest, null, 2) + '\n', 'utf8');
return 'wrote';
}
async function runPool<T, R>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let cursor = 0;
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
for (;;) {
const idx = cursor++;
if (idx >= items.length) return;
results[idx] = await worker(items[idx]!, idx);
}
});
await Promise.all(workers);
return results;
}
async function main(): Promise<void> {
let args: Args;
try {
args = parseArgs(process.argv);
} catch (err) {
console.error(String((err as Error).message ?? err));
printHelp();
process.exit(1);
}
if (args.sources.size === 0) {
console.error('No sources selected — nothing to do.');
process.exit(1);
}
console.log(`Destination: ${args.out}`);
await mkdir(args.out, { recursive: true });
const tasks: PetTask[] = [];
if (args.sources.has('petshare')) {
process.stdout.write('Fetching codex-pet-share catalog…');
const list = await listPetSharePets(args.limit);
process.stdout.write(` ${list.length} pets\n`);
tasks.push(...list);
}
if (args.sources.has('hatchery')) {
process.stdout.write('Fetching j20 hatchery catalog…');
const list = await listHatcheryPets(args.limit);
process.stdout.write(` ${list.length} pets\n`);
tasks.push(...list);
}
if (tasks.length === 0) {
console.log('No pets to download.');
return;
}
// Earlier sources win when two catalogs publish the same folder name
// (e.g. an upstream "goku" appears in both feeds). De-duplicate so we
// do not race two writers on the same folder.
const dedup = new Map<string, PetTask>();
for (const task of tasks) {
if (!dedup.has(task.folder)) dedup.set(task.folder, task);
}
const unique = Array.from(dedup.values());
let wrote = 0;
let skipped = 0;
let failed = 0;
await runPool(unique, args.concurrency, async (task) => {
try {
const result = await writePet(task, args.out, args.force);
if (result === 'wrote') {
wrote++;
console.log(`+ ${task.source.padEnd(8)} ${task.folder}`);
} else {
skipped++;
}
} catch (err) {
failed++;
console.error(`! ${task.source.padEnd(8)} ${task.folder}: ${(err as Error).message}`);
}
});
console.log(`\nDone. wrote=${wrote} skipped=${skipped} failed=${failed} (total=${unique.length})`);
if (failed > 0) process.exitCode = 1;
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env node
// Sync design-systems/* from the upstream `getdesign` npm package.
//
// Usage:
// 1) curl -sL $(npm view getdesign dist.tarball) -o /tmp/getdesign.tgz
// tar -xzf /tmp/getdesign.tgz -C /tmp
// 2) node --experimental-strip-types scripts/sync-design-systems.ts [/tmp/package/templates]
//
// The script re-creates each brand's design-systems/<slug>/DESIGN.md with a
// `> Category: <name>` line inserted after the H1, mapped from the
// awesome-design-md README. Hand-authored systems (default, warm-editorial)
// are left untouched.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
interface ManifestEntry {
brand: string;
file: string;
description: string;
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SRC = process.argv[2] || '/tmp/package/templates';
const CATEGORY = {
// AI & LLM
claude: 'AI & LLM', cohere: 'AI & LLM', elevenlabs: 'AI & LLM',
minimax: 'AI & LLM', 'mistral.ai': 'AI & LLM', ollama: 'AI & LLM',
'opencode.ai': 'AI & LLM', replicate: 'AI & LLM', runwayml: 'AI & LLM',
'together.ai': 'AI & LLM', voltagent: 'AI & LLM', 'x.ai': 'AI & LLM',
// Developer Tools
cursor: 'Developer Tools', expo: 'Developer Tools', lovable: 'Developer Tools',
raycast: 'Developer Tools', superhuman: 'Developer Tools',
vercel: 'Developer Tools', warp: 'Developer Tools',
// Backend & Data
clickhouse: 'Backend & Data', composio: 'Backend & Data',
hashicorp: 'Backend & Data', mongodb: 'Backend & Data',
posthog: 'Backend & Data', sanity: 'Backend & Data',
sentry: 'Backend & Data', supabase: 'Backend & Data',
// Productivity & SaaS
cal: 'Productivity & SaaS', intercom: 'Productivity & SaaS',
'linear.app': 'Productivity & SaaS', mintlify: 'Productivity & SaaS',
notion: 'Productivity & SaaS', resend: 'Productivity & SaaS',
zapier: 'Productivity & SaaS',
// Design & Creative
airtable: 'Design & Creative', clay: 'Design & Creative',
figma: 'Design & Creative', framer: 'Design & Creative',
miro: 'Design & Creative', webflow: 'Design & Creative',
// Fintech & Crypto
binance: 'Fintech & Crypto', coinbase: 'Fintech & Crypto',
kraken: 'Fintech & Crypto', mastercard: 'Fintech & Crypto',
revolut: 'Fintech & Crypto', stripe: 'Fintech & Crypto', wise: 'Fintech & Crypto',
// E-Commerce & Retail
airbnb: 'E-Commerce & Retail', meta: 'E-Commerce & Retail',
nike: 'E-Commerce & Retail', shopify: 'E-Commerce & Retail',
starbucks: 'E-Commerce & Retail',
// Media & Consumer
apple: 'Media & Consumer', ibm: 'Media & Consumer',
nvidia: 'Media & Consumer', pinterest: 'Media & Consumer',
playstation: 'Media & Consumer', spacex: 'Media & Consumer',
spotify: 'Media & Consumer', theverge: 'Media & Consumer',
uber: 'Media & Consumer', vodafone: 'Media & Consumer', wired: 'Media & Consumer',
// Automotive
bmw: 'Automotive', bugatti: 'Automotive', ferrari: 'Automotive',
lamborghini: 'Automotive', renault: 'Automotive', tesla: 'Automotive',
} as const;
type Brand = keyof typeof CATEGORY;
const slugOf = (brand: string): string => brand.replace(/\./g, '-');
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function readManifest(): ManifestEntry[] {
const raw = readFileSync(path.join(SRC, 'manifest.json'), 'utf8');
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error('manifest.json must contain an array');
}
return parsed.map((entry) => {
if (
typeof entry === 'object' &&
entry !== null &&
'brand' in entry &&
'file' in entry &&
'description' in entry &&
typeof entry.brand === 'string' &&
typeof entry.file === 'string' &&
typeof entry.description === 'string'
) {
return entry;
}
throw new Error('manifest.json contains an invalid entry');
});
}
function main(): void {
let manifest: ManifestEntry[];
try {
manifest = readManifest();
} catch (error) {
console.error(`Could not read manifest.json under ${SRC}: ${errorMessage(error)}`);
console.error('Did you extract the getdesign tarball? See scripts/sync-design-systems.ts header.');
process.exit(1);
}
const written: string[] = [];
const skipped: string[] = [];
for (const entry of manifest) {
const { brand, file, description } = entry;
const cat = CATEGORY[brand as Brand];
if (!cat) { skipped.push(`${brand} (unmapped category)`); continue; }
const slug = slugOf(brand);
let raw: string;
try {
raw = readFileSync(path.join(SRC, file), 'utf8');
} catch (error) {
skipped.push(`${brand} (${errorMessage(error)})`);
continue;
}
const lines = raw.split(/\r?\n/);
const h1 = lines.findIndex((line) => /^#\s+/.test(line));
if (h1 < 0) { skipped.push(`${brand} (no H1)`); continue; }
const head = lines.slice(0, h1 + 1);
const tail = lines.slice(h1 + 1);
while (tail[0] === '') tail.shift();
const body = [
...head,
'',
`> Category: ${cat}`,
`> ${description}`,
'',
...tail,
].join('\n');
const dir = path.join(ROOT, 'design-systems', slug);
mkdirSync(dir, { recursive: true });
writeFileSync(path.join(dir, 'DESIGN.md'), body);
written.push(slug);
}
console.log(`wrote ${written.length} design systems → design-systems/`);
if (skipped.length) {
console.log('skipped:');
for (const entry of skipped) console.log(` - ${entry}`);
}
}
main();
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
// Maintainer tool: refresh the vendored HyperFrames skill in
// `skills/hyperframes/` from the upstream `heygen-com/hyperframes`
// publication.
//
// Why vendor instead of relying on `npx skills add`? Coverage. The
// `skills` CLI only symlinks into a known list of agent dirs (Claude
// Code, Codex, Cursor, Trae, Factory, etc.) — but OD supports a wider
// agent set (Hermes, Kimi, Qwen, BYOK CLIs that aren't on `skills`'s
// allowlist). By vendoring under `skills/hyperframes/` and routing the
// content through OD's own skill scanner (which injects the SKILL.md
// body into the system prompt), every OD-supported agent — including
// BYOK setups — gets HyperFrames guidance uniformly.
//
// This script does NOT auto-merge. Reasons:
// 1. We add an OD-specific frontmatter shim (od.mode/surface/preview/…)
// and an "Open Design integration" section near the top of
// SKILL.md. An auto-merge would either drop the shim (breaking OD
// classification) or duplicate it on every sync.
// 2. Upstream may rename references, restructure subdirs, or change
// `triggers`. A human eye catches that in one read.
//
// What it DOES do:
// - Run `npx skills add heygen-com/hyperframes -y` into a temp dir
// - Diff the upstream `hyperframes/` subtree against the vendored copy
// - Print a summary of changed files (added / modified / removed)
// - Exit non-zero when there's drift, so you notice
//
// Usage:
// node scripts/sync-hyperframes-skill.mjs # show diff
// node scripts/sync-hyperframes-skill.mjs --apply # NOT IMPLEMENTED;
// always reviewed
// by hand
//
// To actually apply: copy the upstream files in by hand, re-add the OD
// frontmatter shim and the "Open Design integration" section.
import { execFile as execFileCb } from 'node:child_process';
import { mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
const execFile = promisify(execFileCb);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, '..');
const VENDORED = path.join(REPO_ROOT, 'skills', 'hyperframes');
async function main() {
const tmpRoot = await mkdtemp(path.join(os.tmpdir(), 'od-hf-sync-'));
try {
console.log(`[sync] installing upstream into ${tmpRoot}`);
// `-y` auto-accepts the install confirmation prompt; we install just
// the `hyperframes` sub-skill (the main one we vendor) to keep the
// probe focused.
await execFile(
'npx',
['-y', 'skills', 'add', 'heygen-com/hyperframes', '-s', 'hyperframes', '-y'],
{ cwd: tmpRoot, timeout: 90_000, maxBuffer: 16 * 1024 * 1024 },
);
const upstream = path.join(tmpRoot, '.agents', 'skills', 'hyperframes');
if (!(await exists(upstream))) {
console.error(
`[sync] upstream not found at expected path: ${upstream}\n` +
' The skills CLI may have changed where it installs to.',
);
process.exit(2);
}
const upstreamFiles = await collect(upstream);
const vendoredFiles = await collect(VENDORED);
const upstreamMap = new Map(upstreamFiles.map((f) => [f.rel, f]));
const vendoredMap = new Map(vendoredFiles.map((f) => [f.rel, f]));
const added = [];
const modified = [];
const removed = [];
for (const [rel, up] of upstreamMap) {
const ven = vendoredMap.get(rel);
if (!ven) {
added.push(rel);
continue;
}
// SKILL.md gets local edits (frontmatter shim + OD integration
// section), so a byte-for-byte compare always reports drift.
// Compare only the body AFTER our injected section by matching
// upstream's first H2 heading. Imperfect but useful as a hint.
if (rel === 'SKILL.md') {
const upstreamMarker = '\n## Approach\n';
const upBody = up.text.includes(upstreamMarker)
? up.text.slice(up.text.indexOf(upstreamMarker))
: up.text;
const venBody = ven.text.includes(upstreamMarker)
? ven.text.slice(ven.text.indexOf(upstreamMarker))
: ven.text;
if (upBody !== venBody) modified.push(`${rel} (body after ## Approach)`);
continue;
}
if (up.text !== ven.text) modified.push(rel);
}
for (const rel of vendoredMap.keys()) {
if (!upstreamMap.has(rel)) removed.push(rel);
}
if (added.length === 0 && modified.length === 0 && removed.length === 0) {
console.log('[sync] vendored copy matches upstream — nothing to do.');
process.exit(0);
}
console.log('\n[sync] DRIFT DETECTED — review and update by hand.\n');
if (added.length) {
console.log(` Added (in upstream, missing locally):`);
for (const r of added) console.log(` + ${r}`);
}
if (modified.length) {
console.log(` Modified upstream:`);
for (const r of modified) console.log(` ~ ${r}`);
}
if (removed.length) {
console.log(` Removed upstream (still vendored locally):`);
for (const r of removed) console.log(` - ${r}`);
}
console.log(
'\n Upstream copy lives at:\n' +
` ${upstream}\n` +
' (script does not auto-apply — re-run with diff tools, then\n' +
' commit the merge by hand. Re-add OD frontmatter shim if it\n' +
' gets dropped during the merge.)',
);
process.exit(1);
} finally {
// Best-effort cleanup. Leaves the upstream dir behind if the user
// wants to inspect it in the failure path.
if (process.env.OD_KEEP_HF_SYNC_TMP) {
console.log(`[sync] OD_KEEP_HF_SYNC_TMP set — leaving ${tmpRoot}`);
} else {
await rm(tmpRoot, { recursive: true, force: true });
}
}
}
async function exists(p) {
try {
await stat(p);
return true;
} catch {
return false;
}
}
async function collect(root) {
const out = [];
await walk(root, '', out);
return out;
}
async function walk(root, rel, out) {
let entries;
try {
entries = await readdir(path.join(root, rel), { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const childRel = rel ? `${rel}/${e.name}` : e.name;
if (e.isDirectory()) {
await walk(root, childRel, out);
continue;
}
if (!e.isFile()) continue;
const text = await readFile(path.join(root, childRel), 'utf8').catch(
() => null,
);
if (text == null) continue;
out.push({ rel: childRel, text });
}
}
main().catch((err) => {
console.error('[sync] failed:', err && err.message ? err.message : err);
process.exit(2);
});
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env node
// Sync apps/web/src/state/litellm-models.json from BerriAI/litellm.
//
// LiteLLM (MIT, https://github.com/BerriAI/litellm) maintains the de-facto
// community catalog of model context/output caps and pricing across every
// major provider. We vendor a filtered slice (chat-mode max_output_tokens
// only) so the web client can default `max_tokens` per model without an
// extra network call at runtime.
//
// Usage:
// node --experimental-strip-types scripts/sync-litellm-models.ts
//
// Re-run periodically (or when a new model the user cares about lands) and
// commit the regenerated JSON. Coverage gaps (e.g. mimo-v2.5-pro) are
// filled by the hand-maintained override table in maxTokens.ts.
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const SOURCE_URL =
'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT_PATH = path.resolve(
__dirname,
'..',
'apps/web/src/state/litellm-models.json',
);
interface LiteLLMEntry {
mode?: string;
max_tokens?: number | string;
max_output_tokens?: number | string;
}
async function main() {
console.log(`fetching ${SOURCE_URL}`);
const res = await fetch(SOURCE_URL);
if (!res.ok) throw new Error(`fetch ${res.status}: ${res.statusText}`);
const raw = (await res.json()) as Record<string, unknown>;
const out: Record<string, number> = {};
let scanned = 0;
for (const [id, value] of Object.entries(raw)) {
if (id === 'sample_spec') continue;
if (!value || typeof value !== 'object') continue;
const entry = value as LiteLLMEntry;
if (entry.mode !== 'chat') continue;
scanned++;
const candidate = entry.max_output_tokens ?? entry.max_tokens;
if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0) {
out[id] = candidate;
}
}
// Sort keys so diffs stay readable when models churn.
const sorted = Object.fromEntries(
Object.entries(out).sort(([a], [b]) => a.localeCompare(b)),
);
const payload = {
_source: SOURCE_URL,
_generated_at: new Date().toISOString().slice(0, 10),
_license:
'BerriAI/litellm is MIT-licensed; see https://github.com/BerriAI/litellm/blob/main/LICENSE',
models: sorted,
};
const json = JSON.stringify(payload, null, 2) + '\n';
writeFileSync(OUT_PATH, json);
console.log(
`wrote ${OUT_PATH} (${Object.keys(sorted).length} models / ${scanned} chat-mode scanned)`,
);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"typeRoots": ["../e2e/node_modules/@types"],
"types": ["node"]
},
"include": ["./**/*.ts"]
}
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env node
// Drift check between the TypeScript source-of-truth registry
// (apps/web/src/media/models.ts) and the TS mirror used by the Node daemon
// (apps/daemon/src/media-models.ts). The two are kept in sync by hand because the
// daemon avoids a TS toolchain at runtime; this script lets CI fail the
// build the moment they diverge.
//
// Usage:
// node scripts/verify-media-models.mjs
//
// Exit codes:
// 0 — registries match
// 1 — drift detected (diff printed to stderr)
// 2 — could not parse one of the registry files
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const TS_PATH = path.join(ROOT, 'apps', 'web', 'src', 'media', 'models.ts');
const JS_PATH = path.join(ROOT, 'apps', 'daemon', 'src', 'media-models.ts');
function fail(msg) {
process.stderr.write(`verify-media-models: ${msg}\n`);
process.exit(1);
}
function parseError(msg) {
process.stderr.write(`verify-media-models: ${msg}\n`);
process.exit(2);
}
// Pull a top-level array literal of `{ id: 'x', ... }` records out of the
// source. We deliberately avoid spinning up a TS compiler — we only need
// the IDs and the bucket shapes the two files agree on.
function extractIds(source, name) {
const re = new RegExp(`export const ${name}[^=]*=\\s*\\[([\\s\\S]*?)\\];`, 'm');
const m = source.match(re);
if (!m) return null;
const ids = [];
const idRe = /\bid:\s*['\"]([^'\"]+)['\"]/g;
let id;
while ((id = idRe.exec(m[1])) != null) ids.push(id[1]);
return ids;
}
function extractAudioIds(source) {
const re = /export const AUDIO_MODELS_BY_KIND[^=]*=\s*\{([\s\S]*?)\n\};/m;
const m = source.match(re);
if (!m) return null;
const body = m[1];
const out = {};
for (const kind of ['music', 'speech', 'sfx']) {
const kre = new RegExp(`${kind}\\s*:\\s*\\[([\\s\\S]*?)\\]`, 'm');
const km = body.match(kre);
if (!km) return null;
const ids = [];
const idRe = /\bid:\s*['\"]([^'\"]+)['\"]/g;
let id;
while ((id = idRe.exec(km[1])) != null) ids.push(id[1]);
out[kind] = ids;
}
return out;
}
function extractNumberArray(source, name) {
const re = new RegExp(`export const ${name}[^=]*=\\s*\\[([^\\]]*)\\]`, 'm');
const m = source.match(re);
if (!m) return null;
return m[1]
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map(Number)
.filter((n) => Number.isFinite(n));
}
function dedupCheck(label, ids) {
const seen = new Set();
for (const id of ids) {
if (seen.has(id)) fail(`duplicate id "${id}" in ${label}`);
seen.add(id);
}
if (ids.length === 0) fail(`${label} is empty`);
}
let ts;
let js;
try {
ts = readFileSync(TS_PATH, 'utf8');
} catch (err) {
parseError(`could not read ${TS_PATH}: ${err.message}`);
}
try {
js = readFileSync(JS_PATH, 'utf8');
} catch (err) {
parseError(`could not read ${JS_PATH}: ${err.message}`);
}
const tsImage = extractIds(ts, 'IMAGE_MODELS');
const tsVideo = extractIds(ts, 'VIDEO_MODELS');
const tsAudio = extractAudioIds(ts);
const tsLengths = extractNumberArray(ts, 'VIDEO_LENGTHS_SEC');
const tsDurations = extractNumberArray(ts, 'AUDIO_DURATIONS_SEC');
const jsImage = extractIds(js, 'IMAGE_MODELS');
const jsVideo = extractIds(js, 'VIDEO_MODELS');
const jsAudio = extractAudioIds(js);
const jsLengths = extractNumberArray(js, 'VIDEO_LENGTHS_SEC');
const jsDurations = extractNumberArray(js, 'AUDIO_DURATIONS_SEC');
if (!tsImage || !tsVideo || !tsAudio) parseError('failed to parse TS registry');
if (!jsImage || !jsVideo || !jsAudio) parseError('failed to parse JS registry');
dedupCheck('IMAGE_MODELS (ts)', tsImage);
dedupCheck('VIDEO_MODELS (ts)', tsVideo);
dedupCheck('IMAGE_MODELS (js)', jsImage);
dedupCheck('VIDEO_MODELS (js)', jsVideo);
for (const kind of ['music', 'speech', 'sfx']) {
dedupCheck(`AUDIO_MODELS_BY_KIND.${kind} (ts)`, tsAudio[kind]);
dedupCheck(`AUDIO_MODELS_BY_KIND.${kind} (js)`, jsAudio[kind]);
}
function diffArrays(label, a, b) {
const aSet = new Set(a);
const bSet = new Set(b);
const onlyA = [...aSet].filter((x) => !bSet.has(x));
const onlyB = [...bSet].filter((x) => !aSet.has(x));
if (onlyA.length === 0 && onlyB.length === 0) return null;
return `${label}: ts only=[${onlyA.join(', ')}], js only=[${onlyB.join(', ')}]`;
}
const diffs = [];
const dImage = diffArrays('IMAGE_MODELS', tsImage, jsImage);
if (dImage) diffs.push(dImage);
const dVideo = diffArrays('VIDEO_MODELS', tsVideo, jsVideo);
if (dVideo) diffs.push(dVideo);
for (const kind of ['music', 'speech', 'sfx']) {
const d = diffArrays(`AUDIO_MODELS_BY_KIND.${kind}`, tsAudio[kind], jsAudio[kind]);
if (d) diffs.push(d);
}
if (tsLengths && jsLengths && tsLengths.join(',') !== jsLengths.join(',')) {
diffs.push(
`VIDEO_LENGTHS_SEC: ts=[${tsLengths.join(', ')}] js=[${jsLengths.join(', ')}]`,
);
}
if (
tsDurations &&
jsDurations &&
tsDurations.join(',') !== jsDurations.join(',')
) {
diffs.push(
`AUDIO_DURATIONS_SEC: ts=[${tsDurations.join(', ')}] js=[${jsDurations.join(', ')}]`,
);
}
if (diffs.length > 0) {
process.stderr.write(
'verify-media-models: drift detected between apps/web/src/media/models.ts and apps/daemon/src/media-models.ts\n',
);
for (const d of diffs) process.stderr.write(` - ${d}\n`);
process.stderr.write(
'\nFix: update both files in lockstep, then re-run this script.\n',
);
process.exit(1);
}
process.stdout.write('verify-media-models: OK (TS + JS registries match)\n');