mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
- Replaced the legacy tabbed categorization in `PluginsHomeSection` with a tag-driven approach, allowing dynamic filtering based on plugin tags. - Introduced a new `PluginCard` component to encapsulate the rendering of individual plugin cards, improving separation of concerns and maintainability. - Added a `usePluginCategories` hook to manage plugin visibility and filtering logic, enhancing the overall structure and testability of the component. - Implemented a "More" pill for overflow tags in the filter row, improving user interaction with a cleaner UI. - Updated CSS styles to support the new layout and improve visual consistency across the plugins home section. This update significantly enhances the user experience by providing a more flexible and intuitive way to discover and interact with plugins.
233 lines
8 KiB
TypeScript
233 lines
8 KiB
TypeScript
// Wrap a `skills/<id>/` folder as a bundled plugin under
|
|
// `plugins/_official/examples/<id>/`. We copy SKILL.md + side files
|
|
// (example.html, assets/, references/) so the daemon's bundled walker
|
|
// has everything it needs without reaching outside the plugin folder
|
|
// (the registry only resolves SKILL.md / .claude-plugin / open-design.json
|
|
// inside the plugin root — see `apps/daemon/src/plugins/registry.ts`).
|
|
|
|
import path from 'node:path';
|
|
import { readFile, readdir } from 'node:fs/promises';
|
|
import {
|
|
PLUGINS_ROOT,
|
|
SKILLS_DIR,
|
|
TIER_EXAMPLES,
|
|
buildManifest,
|
|
copyFile,
|
|
dedupeTags,
|
|
parseFrontmatter,
|
|
pathExists,
|
|
pluginName,
|
|
writeManifest,
|
|
type RunStats,
|
|
} from './lib.ts';
|
|
|
|
export interface ExampleGeneratorOptions {
|
|
ids?: string[];
|
|
limit?: number;
|
|
dryRun?: boolean;
|
|
}
|
|
|
|
interface SkillFrontmatter {
|
|
name?: string;
|
|
description?: string;
|
|
triggers?: unknown[];
|
|
od?: {
|
|
mode?: string;
|
|
surface?: string;
|
|
platform?: string;
|
|
scenario?: string;
|
|
example_prompt?: string;
|
|
fidelity?: string;
|
|
speaker_notes?: unknown;
|
|
animations?: unknown;
|
|
featured?: unknown;
|
|
design_system?: { requires?: boolean };
|
|
craft?: { requires?: string[] };
|
|
preview?: { type?: string; entry?: string };
|
|
inputs?: Array<Record<string, unknown>>;
|
|
};
|
|
}
|
|
|
|
export async function runExampleGenerator(opts: ExampleGeneratorOptions): Promise<RunStats> {
|
|
const stats: RunStats = { generated: [], skipped: [] };
|
|
let entries;
|
|
try {
|
|
entries = await readdir(SKILLS_DIR, { withFileTypes: true });
|
|
} catch {
|
|
return stats;
|
|
}
|
|
const folders = entries
|
|
.filter((e) => e.isDirectory())
|
|
.map((e) => e.name)
|
|
.filter((n) => !opts.ids || opts.ids.includes(n))
|
|
.sort();
|
|
const slice = opts.limit !== undefined ? folders.slice(0, opts.limit) : folders;
|
|
|
|
for (const id of slice) {
|
|
const srcFolder = path.join(SKILLS_DIR, id);
|
|
const skillPath = path.join(srcFolder, 'SKILL.md');
|
|
if (!(await pathExists(skillPath))) {
|
|
stats.skipped.push({ id, reason: 'missing SKILL.md' });
|
|
continue;
|
|
}
|
|
const raw = await readFile(skillPath, 'utf8');
|
|
const { data } = parseFrontmatter(raw);
|
|
const fm = data as SkillFrontmatter;
|
|
const name = pluginName('example', id);
|
|
const folder = path.join(PLUGINS_ROOT, TIER_EXAMPLES, id);
|
|
|
|
const mode = fm.od?.mode ?? 'prototype';
|
|
const surface = fm.od?.surface ?? inferSurface(mode);
|
|
const scenario = fm.od?.scenario ?? 'design';
|
|
const platform = fm.od?.platform;
|
|
const exampleFile = await sideFiles(srcFolder);
|
|
// The plugin folder ships `example.html` (the baked output), not
|
|
// the original `index.html` the skill renders into the project
|
|
// working directory. Always point preview at the in-folder file
|
|
// so the daemon's preview surface has something to render without
|
|
// running the agent first.
|
|
const previewEntry = exampleFile.hasExample ? 'example.html' : (fm.od?.preview?.entry ?? 'example.html');
|
|
|
|
const manifest = buildManifest({
|
|
name,
|
|
title: humanize(id),
|
|
description: typeof fm.description === 'string' ? fm.description.trim() : '',
|
|
license: 'MIT',
|
|
author: { name: 'Open Design', url: 'https://github.com/nexu-io' },
|
|
homepage: `https://github.com/nexu-io/open-design/tree/main/plugins/_official/${TIER_EXAMPLES}/${id}`,
|
|
tags: dedupeTags([
|
|
'example',
|
|
'first-party',
|
|
mode,
|
|
scenario,
|
|
surface,
|
|
platform,
|
|
...(Array.isArray(fm.triggers) ? fm.triggers.map(String) : []),
|
|
]),
|
|
compat: { agentSkills: [{ path: './SKILL.md' }] },
|
|
od: {
|
|
kind: 'scenario',
|
|
taskKind: 'new-generation',
|
|
mode,
|
|
...(platform ? { platform } : {}),
|
|
scenario,
|
|
surface,
|
|
preview: { type: fm.od?.preview?.type ?? 'html', entry: `./${previewEntry}` },
|
|
useCase: {
|
|
query: derivePrompt(fm),
|
|
...(exampleFile.hasExample
|
|
? { exampleOutputs: [{ path: './example.html', title: humanize(id) }] }
|
|
: {}),
|
|
},
|
|
...(Array.isArray(fm.od?.inputs) && fm.od.inputs.length > 0
|
|
? { inputs: fm.od.inputs.map(normaliseInput) }
|
|
: {}),
|
|
context: {
|
|
skills: [{ path: './SKILL.md' }],
|
|
...(fm.od?.design_system?.requires !== false
|
|
? { designSystem: { primary: true } }
|
|
: {}),
|
|
...(Array.isArray(fm.od?.craft?.requires) && fm.od.craft.requires.length > 0
|
|
? { craft: fm.od.craft.requires }
|
|
: {}),
|
|
assets: exampleFile.assets,
|
|
},
|
|
pipeline: {
|
|
stages: [{ id: 'generate', atoms: ['file-write', 'live-artifact'] }],
|
|
},
|
|
capabilities: ['prompt:inject', 'fs:write'],
|
|
},
|
|
});
|
|
|
|
if (opts.dryRun) {
|
|
stats.generated.push(id);
|
|
continue;
|
|
}
|
|
await writeManifest(folder, manifest);
|
|
await copyFile(skillPath, path.join(folder, 'SKILL.md'));
|
|
for (const rel of exampleFile.assets) {
|
|
const cleanRel = rel.replace(/^\.\//, '');
|
|
const srcAsset = path.join(srcFolder, cleanRel);
|
|
if (await pathExists(srcAsset)) {
|
|
await copyFile(srcAsset, path.join(folder, cleanRel));
|
|
}
|
|
}
|
|
stats.generated.push(id);
|
|
}
|
|
return stats;
|
|
}
|
|
|
|
function inferSurface(mode: string): string {
|
|
if (mode === 'image' || mode === 'video' || mode === 'audio') return mode;
|
|
return 'web';
|
|
}
|
|
|
|
function derivePrompt(fm: SkillFrontmatter): string {
|
|
const explicit = fm.od?.example_prompt;
|
|
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
|
|
const desc = typeof fm.description === 'string' ? fm.description.trim() : '';
|
|
if (!desc) return 'Produce the artifact described in this skill, following its workflow exactly.';
|
|
const collapsed = desc.replace(/\s+/g, ' ').trim();
|
|
return collapsed.slice(0, 320);
|
|
}
|
|
|
|
function humanize(id: string): string {
|
|
return id
|
|
.replace(/[-_]+/g, ' ')
|
|
.split(' ')
|
|
.filter(Boolean)
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
.join(' ');
|
|
}
|
|
|
|
// The plugin manifest schema only accepts a fixed set of input types
|
|
// (`string | text | select | number | boolean`), but the legacy
|
|
// SKILL.md frontmatter freely uses `integer`, `float`, etc. — fine
|
|
// for the agent-level renderer, rejected by the plugin parser. We
|
|
// coerce the long tail down to the closest supported type so the
|
|
// daemon can register the generated plugin without dropping the
|
|
// authored input list.
|
|
function normaliseInput(raw: Record<string, unknown>): Record<string, unknown> {
|
|
const out: Record<string, unknown> = { ...raw };
|
|
const type = typeof raw.type === 'string' ? raw.type.toLowerCase() : undefined;
|
|
if (type === 'integer' || type === 'int' || type === 'float' || type === 'double') {
|
|
out.type = 'number';
|
|
} else if (type && !['string', 'text', 'select', 'number', 'boolean'].includes(type)) {
|
|
out.type = 'string';
|
|
}
|
|
return out;
|
|
}
|
|
|
|
interface SideFileSummary { hasExample: boolean; assets: string[]; }
|
|
|
|
// Side files are everything the SKILL.md references — `example.html`,
|
|
// `assets/*`, `references/*`. We restrict to a small, well-known set so
|
|
// the generated plugin folder stays compact and predictable. A future
|
|
// patch can broaden the allowlist once we audit which file types the
|
|
// daemon's compose path actually needs.
|
|
async function sideFiles(srcFolder: string): Promise<SideFileSummary> {
|
|
const out: string[] = [];
|
|
let hasExample = false;
|
|
for (const candidate of ['example.html']) {
|
|
if (await pathExists(path.join(srcFolder, candidate))) {
|
|
out.push(`./${candidate}`);
|
|
if (candidate === 'example.html') hasExample = true;
|
|
}
|
|
}
|
|
for (const dir of ['assets', 'references']) {
|
|
const abs = path.join(srcFolder, dir);
|
|
if (!(await pathExists(abs))) continue;
|
|
let entries;
|
|
try {
|
|
entries = await readdir(abs, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const e of entries) {
|
|
if (!e.isFile()) continue;
|
|
if (!/\.(md|html|css|js|json|txt|svg|png|jpg|jpeg|webp)$/i.test(e.name)) continue;
|
|
out.push(`./${dir}/${e.name}`);
|
|
}
|
|
}
|
|
return { hasExample, assets: out };
|
|
}
|