Merge remote-tracking branch 'origin/garnet-hemisphere' into reconcile/garnet-main-merge

This commit is contained in:
lefarcen 2026-05-13 23:52:33 +08:00
commit d83b228c81
620 changed files with 13092 additions and 240 deletions

View file

@ -1220,7 +1220,7 @@ Common options:
return;
}
for (const m of rows) {
console.log(`${m.id} trust=${m.trust} url=${m.url}`);
console.log(`${m.id} version=${m.version ?? 'unknown'} spec=${m.specVersion ?? 'unknown'} trust=${m.trust} url=${m.url}`);
}
return;
}
@ -1251,7 +1251,9 @@ Common options:
matches.push({
marketplaceId: mp.id,
marketplaceUrl: mp.url,
marketplaceVersion: mp.version,
name: p.name,
version: p.version,
source: p.source,
description: p.description ?? '',
tags: p.tags ?? [],
@ -1267,7 +1269,7 @@ Common options:
return;
}
for (const m of matches) {
console.log(`${m.name}\t${m.source}\t${m.marketplaceId}\t${m.description}`);
console.log(`${m.name}@${m.version}\t${m.source}\t${m.marketplaceId}@${m.marketplaceVersion}\t${m.description}`);
}
return;
}

View file

@ -164,6 +164,7 @@ export function applyPlugin(input: ApplyInput): ApplyComputed {
const snapshot: AppliedPluginSnapshot = {
snapshotId: '',
pluginId: input.plugin.id,
pluginSpecVersion: manifest.specVersion,
pluginVersion: input.plugin.version,
manifestSourceDigest: digest,
sourceMarketplaceId: input.plugin.sourceMarketplaceId,

View file

@ -177,6 +177,7 @@ async function readSkillBody(
function buildPortableManifest(snapshot: AppliedPluginSnapshot): Record<string, unknown> {
return {
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
specVersion: snapshot.pluginSpecVersion ?? '1.0.0',
name: snapshot.pluginId,
title: snapshot.pluginTitle ?? snapshot.pluginId,
version: snapshot.pluginVersion,

View file

@ -19,7 +19,10 @@ import {
parseMarketplace,
type MarketplaceParseResult,
} from '@open-design/plugin-runtime';
import type { MarketplaceManifest } from '@open-design/contracts';
import {
OPEN_DESIGN_PLUGIN_SPEC_VERSION,
type MarketplaceManifest,
} from '@open-design/contracts';
type SqliteDb = Database.Database;
@ -28,6 +31,8 @@ export type MarketplaceTrustTier = 'official' | 'trusted' | 'restricted';
export interface MarketplaceRow {
id: string;
url: string;
specVersion: string;
version: string;
trust: MarketplaceTrustTier;
manifest: MarketplaceManifest;
addedAt: number;
@ -100,56 +105,77 @@ export async function addMarketplace(
const now = Date.now();
const trust = input.trust ?? 'restricted';
db.prepare(
`INSERT INTO plugin_marketplaces (id, url, trust, manifest_json, added_at, refreshed_at)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(id, input.url, trust, text, now, now);
`INSERT INTO plugin_marketplaces (id, url, spec_version, version, trust, manifest_json, added_at, refreshed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(id, input.url, parsed.manifest.specVersion, parsed.manifest.version, trust, text, now, now);
return {
ok: true,
row: { id, url: input.url, trust, manifest: parsed.manifest, addedAt: now, refreshedAt: now },
row: {
id,
url: input.url,
specVersion: parsed.manifest.specVersion,
version: parsed.manifest.version,
trust,
manifest: parsed.manifest,
addedAt: now,
refreshedAt: now,
},
warnings: [],
};
}
export function listMarketplaces(db: SqliteDb): MarketplaceRow[] {
const rows = db
.prepare(`SELECT id, url, trust, manifest_json, added_at, refreshed_at FROM plugin_marketplaces ORDER BY added_at ASC`)
.prepare(`SELECT id, url, spec_version, version, trust, manifest_json, added_at, refreshed_at FROM plugin_marketplaces ORDER BY added_at ASC`)
.all() as Array<{
id: string;
url: string;
spec_version: string;
version: string;
trust: MarketplaceTrustTier;
manifest_json: string;
added_at: number;
refreshed_at: number;
}>;
return rows.map((r) => ({
id: r.id,
url: r.url,
trust: r.trust,
manifest: safeParseManifest(r.manifest_json),
addedAt: r.added_at,
refreshedAt: r.refreshed_at,
}));
return rows.map((r) => {
const manifest = safeParseManifest(r.manifest_json);
return {
id: r.id,
url: r.url,
specVersion: r.spec_version || manifest.specVersion,
version: r.version === '0.0.0' ? manifest.version : r.version,
trust: r.trust,
manifest,
addedAt: r.added_at,
refreshedAt: r.refreshed_at,
};
});
}
export function getMarketplace(db: SqliteDb, id: string): MarketplaceRow | null {
const row = db
.prepare(`SELECT id, url, trust, manifest_json, added_at, refreshed_at FROM plugin_marketplaces WHERE id = ?`)
.prepare(`SELECT id, url, spec_version, version, trust, manifest_json, added_at, refreshed_at FROM plugin_marketplaces WHERE id = ?`)
.get(id) as
| undefined
| {
id: string;
url: string;
spec_version: string;
version: string;
trust: MarketplaceTrustTier;
manifest_json: string;
added_at: number;
refreshed_at: number;
};
if (!row) return null;
const manifest = safeParseManifest(row.manifest_json);
return {
id: row.id,
url: row.url,
specVersion: row.spec_version || manifest.specVersion,
version: row.version === '0.0.0' ? manifest.version : row.version,
trust: row.trust,
manifest: safeParseManifest(row.manifest_json),
manifest,
addedAt: row.added_at,
refreshedAt: row.refreshed_at,
};
@ -198,11 +224,17 @@ export async function refreshMarketplace(
return { ok: false, status: 422, message: 'marketplace manifest failed validation', errors: parsed.errors };
}
const now = Date.now();
db.prepare(`UPDATE plugin_marketplaces SET manifest_json = ?, refreshed_at = ? WHERE id = ?`)
.run(text, now, id);
db.prepare(`UPDATE plugin_marketplaces SET spec_version = ?, version = ?, manifest_json = ?, refreshed_at = ? WHERE id = ?`)
.run(parsed.manifest.specVersion, parsed.manifest.version, text, now, id);
return {
ok: true,
row: { ...existing, manifest: parsed.manifest, refreshedAt: now },
row: {
...existing,
specVersion: parsed.manifest.specVersion,
version: parsed.manifest.version,
manifest: parsed.manifest,
refreshedAt: now,
},
};
}
@ -222,10 +254,54 @@ function safeParseManifest(raw: string): MarketplaceManifest {
} catch {
// fall through
}
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('legacy marketplace manifest is not an object');
}
const legacy = parsed as Record<string, unknown>;
const metadata = typeof legacy['metadata'] === 'object' && legacy['metadata'] !== null
? legacy['metadata'] as Record<string, unknown>
: {};
const plugins = Array.isArray(legacy?.['plugins'])
? (legacy['plugins'] as unknown[]).flatMap((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return [];
const obj = entry as Record<string, unknown>;
const name = typeof obj['name'] === 'string' ? obj['name'] : '';
const source = typeof obj['source'] === 'string' ? obj['source'] : '';
if (!name || !source) return [];
return [{
...obj,
name,
source,
version: typeof obj['version'] === 'string' && obj['version'].length > 0
? obj['version']
: '0.0.0',
}];
})
: [];
return {
...legacy,
specVersion: typeof legacy['specVersion'] === 'string'
? legacy['specVersion'] as string
: OPEN_DESIGN_PLUGIN_SPEC_VERSION,
name: typeof legacy['name'] === 'string' ? legacy['name'] as string : 'unknown',
version: typeof legacy['version'] === 'string' && (legacy['version'] as string).length > 0
? legacy['version'] as string
: typeof metadata['version'] === 'string' && metadata['version'].length > 0
? metadata['version']
: '0.0.0',
plugins,
} as MarketplaceManifest;
} catch {
// fall through
}
// Last-resort fallback: return a minimal shape so the caller doesn't
// explode if a database row was stored before a schema patch.
return {
specVersion: OPEN_DESIGN_PLUGIN_SPEC_VERSION,
name: 'unknown',
version: '0.0.0',
plugins: [],
} as MarketplaceManifest;
}
@ -245,7 +321,10 @@ export interface ResolvedPluginEntry {
marketplaceId: string;
marketplaceUrl: string;
marketplaceTrust: MarketplaceTrustTier;
marketplaceSpecVersion: string;
marketplaceVersion: string;
pluginName: string;
pluginVersion: string;
source: string;
description?: string;
}
@ -265,7 +344,10 @@ export function resolvePluginInMarketplaces(
marketplaceId: row.id,
marketplaceUrl: row.url,
marketplaceTrust: row.trust,
marketplaceSpecVersion: row.specVersion,
marketplaceVersion: row.version,
pluginName: entry.name,
pluginVersion: entry.version,
source: entry.source,
};
if (entry.description) result.description = entry.description;

View file

@ -39,6 +39,8 @@ export function migratePlugins(db: SqliteDb): void {
CREATE TABLE IF NOT EXISTS plugin_marketplaces (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
spec_version TEXT NOT NULL DEFAULT '1.0.0',
version TEXT NOT NULL DEFAULT '0.0.0',
trust TEXT NOT NULL,
manifest_json TEXT NOT NULL,
added_at INTEGER NOT NULL,
@ -51,6 +53,7 @@ export function migratePlugins(db: SqliteDb): void {
conversation_id TEXT,
run_id TEXT,
plugin_id TEXT NOT NULL,
plugin_spec_version TEXT NOT NULL DEFAULT '1.0.0',
plugin_version TEXT NOT NULL,
manifest_source_digest TEXT NOT NULL,
source_marketplace_id TEXT,
@ -128,6 +131,20 @@ export function migratePlugins(db: SqliteDb): void {
CREATE INDEX IF NOT EXISTS idx_genui_run ON genui_surfaces(run_id);
`);
const marketplaceCols = db.prepare(`PRAGMA table_info(plugin_marketplaces)`).all() as DbRow[];
if (!marketplaceCols.some((c) => c['name'] === 'spec_version')) {
db.exec(`ALTER TABLE plugin_marketplaces ADD COLUMN spec_version TEXT NOT NULL DEFAULT '1.0.0'`);
}
if (!marketplaceCols.some((c) => c['name'] === 'version')) {
db.exec(`ALTER TABLE plugin_marketplaces ADD COLUMN version TEXT NOT NULL DEFAULT '0.0.0'`);
}
db.exec(`CREATE INDEX IF NOT EXISTS idx_marketplaces_version ON plugin_marketplaces(version)`);
const snapshotCols = db.prepare(`PRAGMA table_info(applied_plugin_snapshots)`).all() as DbRow[];
if (!snapshotCols.some((c) => c['name'] === 'plugin_spec_version')) {
db.exec(`ALTER TABLE applied_plugin_snapshots ADD COLUMN plugin_spec_version TEXT NOT NULL DEFAULT '1.0.0'`);
}
// Back-reference columns. SQLite has no IF NOT EXISTS for ALTER; check
// pragma_table_info first. Mirrors the upstream pattern in db.ts.
const projectCols = db.prepare(`PRAGMA table_info(projects)`).all() as DbRow[];

View file

@ -258,6 +258,7 @@ export function resolvePluginSnapshot(input: ResolveSnapshotInput): ResolveSnaps
conversationId: input.conversationId ?? null,
runId: input.runId ?? null,
pluginId: result.appliedPlugin.pluginId,
pluginSpecVersion: result.appliedPlugin.pluginSpecVersion ?? plugin.manifest.specVersion,
pluginVersion: result.appliedPlugin.pluginVersion,
pluginTitle: result.appliedPlugin.pluginTitle,
pluginDescription: result.appliedPlugin.pluginDescription,

View file

@ -100,6 +100,7 @@ export async function scaffoldPlugin(input: ScaffoldInput): Promise<ScaffoldResu
const manifest: Record<string, unknown> = {
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
specVersion: '1.0.0',
name: input.id,
title,
version: '0.1.0',
@ -142,7 +143,7 @@ export async function scaffoldPlugin(input: ScaffoldInput): Promise<ScaffoldResu
'## Files',
'',
'- `SKILL.md` — the canonical agent skill body.',
'- `open-design.json` — the Open Design marketplace sidecar.',
'- `open-design.json` — the versioned Open Design marketplace sidecar.',
'',
'Edit `SKILL.md` to teach the agent how to perform the workflow.',
'Edit `open-design.json` to refine the marketplace card and inputs.',

View file

@ -48,6 +48,7 @@ export function diffSnapshots(input: DiffSnapshotsInput): SnapshotDiffReport {
// Identity + lineage.
diffScalar(entries, 'snapshotId', a.snapshotId, b.snapshotId);
diffScalar(entries, 'pluginId', a.pluginId, b.pluginId);
diffScalar(entries, 'pluginSpecVersion', a.pluginSpecVersion, b.pluginSpecVersion);
diffScalar(entries, 'pluginVersion', a.pluginVersion, b.pluginVersion);
diffScalar(entries, 'manifestSourceDigest', a.manifestSourceDigest, b.manifestSourceDigest);
diffScalar(entries, 'sourceMarketplaceId', a.sourceMarketplaceId, b.sourceMarketplaceId);

View file

@ -15,15 +15,16 @@
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { readPluginEnvKnobs } from '../app-config.js';
import type {
AppliedPluginSnapshot,
GenUISurfaceSpec,
McpServerSpec,
PluginAssetRef,
PluginConnectorBinding,
PluginConnectorRef,
PluginPipeline,
ResolvedContext,
import {
OPEN_DESIGN_PLUGIN_SPEC_VERSION,
type AppliedPluginSnapshot,
type GenUISurfaceSpec,
type McpServerSpec,
type PluginAssetRef,
type PluginConnectorBinding,
type PluginConnectorRef,
type PluginPipeline,
type ResolvedContext,
} from '@open-design/contracts';
type SqliteDb = Database.Database;
@ -34,6 +35,7 @@ export interface CreateSnapshotInput {
conversationId?: string | null | undefined;
runId?: string | null | undefined;
pluginId: string;
pluginSpecVersion?: string | null | undefined;
pluginVersion: string;
pluginTitle?: string | undefined;
pluginDescription?: string | undefined;
@ -69,7 +71,7 @@ export function createSnapshot(db: SqliteDb, input: CreateSnapshotInput): Applie
db.prepare(`
INSERT INTO applied_plugin_snapshots (
id, project_id, conversation_id, run_id, plugin_id, plugin_version,
id, project_id, conversation_id, run_id, plugin_id, plugin_spec_version, plugin_version,
manifest_source_digest, source_marketplace_id, pinned_ref, task_kind,
inputs_json, resolved_context_json, pipeline_json, genui_surfaces_json,
capabilities_granted, capabilities_required, assets_staged_json,
@ -77,13 +79,14 @@ export function createSnapshot(db: SqliteDb, input: CreateSnapshotInput): Applie
plugin_title, plugin_description, query_text,
status, applied_at, expires_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'fresh', ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'fresh', ?, ?)
`).run(
id,
input.projectId,
input.conversationId ?? null,
input.runId ?? null,
input.pluginId,
input.pluginSpecVersion ?? OPEN_DESIGN_PLUGIN_SPEC_VERSION,
input.pluginVersion,
input.manifestSourceDigest,
input.sourceMarketplaceId ?? null,
@ -280,6 +283,7 @@ function buildSnapshot(args: {
const snapshot: AppliedPluginSnapshot = {
snapshotId: id,
pluginId: input.pluginId,
pluginSpecVersion: input.pluginSpecVersion ?? OPEN_DESIGN_PLUGIN_SPEC_VERSION,
pluginVersion: input.pluginVersion,
manifestSourceDigest: input.manifestSourceDigest,
sourceMarketplaceId: input.sourceMarketplaceId ?? undefined,
@ -309,6 +313,7 @@ export function rowToSnapshot(row: DbRow): AppliedPluginSnapshot {
const snapshot: AppliedPluginSnapshot = {
snapshotId: String(row['id']),
pluginId: String(row['plugin_id']),
pluginSpecVersion: row['plugin_spec_version'] != null ? String(row['plugin_spec_version']) : undefined,
pluginVersion: String(row['plugin_version']),
manifestSourceDigest: String(row['manifest_source_digest']),
sourceMarketplaceId: row['source_marketplace_id'] != null ? String(row['source_marketplace_id']) : undefined,

View file

@ -4679,6 +4679,7 @@ export async function startServer({
const fsp = await import('node:fs/promises');
const root = path.resolve(plugin.fsPath) + path.sep;
let resolved: string | null = null;
let resolvedRel: string | null = null;
for (const rel of candidates) {
if (rel.includes('..') || rel.startsWith('/') || rel.includes('\0')) continue;
const full = path.resolve(plugin.fsPath, rel);
@ -4697,6 +4698,7 @@ export async function startServer({
return;
}
resolved = full;
resolvedRel = rel;
break;
} catch {
// try next candidate
@ -4706,13 +4708,44 @@ export async function startServer({
res.status(404).json({ error: 'preview not found' });
return;
}
const buf = await fsp.readFile(resolved);
let contentPath = resolved;
let buf = await fsp.readFile(resolved);
if (resolvedRel && /(^|\/)example-slides\.html$/i.test(resolvedRel)) {
const templateRel = resolvedRel.replace(
/(^|\/)example-slides\.html$/i,
'$1template.html',
);
const templateFull = path.resolve(plugin.fsPath, templateRel);
const templateInside =
(templateFull + path.sep).startsWith(root) ||
templateFull === path.resolve(plugin.fsPath);
if (templateInside) {
try {
const st = await fsp.stat(templateFull);
const lst = await fsp.lstat(templateFull);
if (!lst.isSymbolicLink() && st.isFile() && st.size <= 5 * 1024 * 1024) {
const title =
typeof plugin.title === 'string'
? plugin.title
: typeof plugin.manifest?.title === 'string'
? plugin.manifest.title
: req.params.id;
const tplHtml = await fsp.readFile(templateFull, 'utf8');
const slidesHtml = buf.toString('utf8');
buf = Buffer.from(assembleExample(tplHtml, slidesHtml, title), 'utf8');
contentPath = templateFull;
}
} catch {
// Keep the raw fallback if the companion template is missing.
}
}
}
res.setHeader(
'Content-Security-Policy',
"default-src 'none'; img-src 'self' data: blob:; media-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-ancestors 'self'",
);
res.setHeader('X-Content-Type-Options', 'nosniff');
const ext = path.extname(resolved).toLowerCase();
const ext = path.extname(contentPath).toLowerCase();
const ct =
ext === '.html' ? 'text/html; charset=utf-8'
: ext === '.js' ? 'application/javascript; charset=utf-8'
@ -4750,6 +4783,12 @@ export async function startServer({
// `assets/template.html`. Returning 404 in that case lit up white
// tiles in the home gallery, so the candidates list always extends
// past the declared entry to walk a curated fallback chain.
//
// `assets/example-slides.html` is a special case: for guizang-ppt it
// is intentionally only the slide fragment. The old skill preview
// assembled it into `assets/template.html` at request time; the plugin
// route mirrors that so the marketplace card keeps the WebGL/e-ink
// magazine treatment instead of rendering unstyled fragments.
function collectPluginPreviewCandidates(plugin: unknown): string[] {
const candidates: string[] = [];
const seen = new Set<string>();
@ -5107,7 +5146,11 @@ export async function startServer({
recordPluginEvent({
kind: 'plugin.marketplace-refreshed',
pluginId: '',
details: { marketplaceId: req.params.id },
details: {
marketplaceId: req.params.id,
marketplaceVersion: result.row.version,
specVersion: result.row.specVersion,
},
});
} catch { /* best-effort */ }
res.json(result.row);
@ -5440,6 +5483,7 @@ export async function startServer({
// the digest match guarantees byte-equality (§8.2.1).
rerun: {
pluginId: snapshot.pluginId,
pluginSpecVersion: snapshot.pluginSpecVersion,
pluginVersion: snapshot.pluginVersion,
inputs: snapshot.inputs,
manifestSourceDigest: snapshot.manifestSourceDigest,

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "sample-plugin",
"title": "Sample Plugin",
"version": "1.0.0",

View file

@ -25,10 +25,12 @@ let db: Database.Database;
let tmpDir: string;
const VALID_MANIFEST = JSON.stringify({
specVersion: '1.0.0',
name: 'test-marketplace',
version: '1.0.0',
metadata: { description: 'fixture', version: '1.0.0' },
plugins: [
{ name: 'sample-plugin', source: 'github:open-design/sample-plugin' },
{ name: 'sample-plugin', source: 'github:open-design/sample-plugin', version: '0.1.0' },
],
});
@ -65,6 +67,8 @@ describe('marketplaces', () => {
throw new Error(`expected ok: ${JSON.stringify(result)}`);
}
expect(result.row.url).toBe('https://example.com/marketplace.json');
expect(result.row.specVersion).toBe('1.0.0');
expect(result.row.version).toBe('1.0.0');
expect(result.row.trust).toBe('restricted');
expect(result.row.manifest.plugins).toHaveLength(1);
expect(listMarketplaces(db)).toHaveLength(1);
@ -103,13 +107,16 @@ describe('marketplaces', () => {
updatedManifest.plugins.push({
name: 'new-plugin',
source: 'github:open-design/new-plugin',
version: '0.2.0',
});
updatedManifest.version = '1.0.1';
const refreshed = await refreshMarketplace(
db,
added.row.id,
fixtureFetcher(JSON.stringify(updatedManifest)),
);
if (!refreshed.ok) throw new Error('refresh failed');
expect(refreshed.row.version).toBe('1.0.1');
expect(refreshed.row.manifest.plugins).toHaveLength(2);
expect(refreshed.row.refreshedAt).toBeGreaterThanOrEqual(added.row.refreshedAt);
});
@ -136,6 +143,8 @@ describe('resolvePluginInMarketplaces', () => {
const resolved = resolvePluginInMarketplaces(db, 'sample-plugin');
expect(resolved).not.toBeNull();
expect(resolved!.source).toBe('github:open-design/sample-plugin');
expect(resolved!.pluginVersion).toBe('0.1.0');
expect(resolved!.marketplaceVersion).toBe('1.0.0');
expect(resolved!.marketplaceTrust).toBe('restricted');
});
@ -159,8 +168,10 @@ describe('resolvePluginInMarketplaces', () => {
it('walks marketplaces in registration order, first hit wins', async () => {
const otherManifest = JSON.stringify({
specVersion: '1.0.0',
name: 'other',
plugins: [{ name: 'sample-plugin', source: 'github:other/sample' }],
version: '1.0.0',
plugins: [{ name: 'sample-plugin', source: 'github:other/sample', version: '0.9.0' }],
});
const first = await addMarketplace(db, {
url: 'https://first.example/marketplace.json',

View file

@ -61,9 +61,13 @@ beforeEach(async () => {
const folder = path.join(pluginRoot, PLUGIN_ID);
await mkdir(path.join(folder, 'assets'), { recursive: true });
// Deliberately omit ./index.html so the declared entry is stale.
await writeFile(
path.join(folder, 'assets', 'template.html'),
'<!DOCTYPE html><title>template</title><main id="deck"><!-- SLIDES_HERE --></main>',
);
await writeFile(
path.join(folder, 'assets', 'example-slides.html'),
'<!DOCTYPE html><title>fallback</title><p>fallback body via assets</p>',
'<section class="slide hero dark"><p>fallback body via assets</p></section>',
);
await writeFile(
path.join(folder, 'open-design.json'),
@ -113,7 +117,7 @@ afterEach(async () => {
});
describe('GET /api/plugins/:id/preview — fallback chain', () => {
it('falls back to od.context.assets[] HTML when the declared entry is missing', async () => {
it('assembles example-slides fragments with the sibling template when the declared entry is missing', async () => {
const resp = await fetch(`${baseUrl}/api/plugins/${PLUGIN_ID}/preview`);
if (resp.status !== 200) {
const text = await resp.text();
@ -121,6 +125,8 @@ describe('GET /api/plugins/:id/preview — fallback chain', () => {
}
expect(resp.headers.get('content-type')).toMatch(/text\/html/);
const body = await resp.text();
expect(body).toContain('<main id="deck">');
expect(body).toContain('fallback body via assets');
expect(body).toContain('Preview fallback fixture | Open Design Example');
});
});

View file

@ -6,14 +6,21 @@ import {
type PluginSourceKind,
} from '@open-design/contracts';
import {
addPluginMarketplace,
applyPlugin,
installPluginSource,
listPluginMarketplaces,
listPlugins,
refreshPluginMarketplace,
removePluginMarketplace,
setPluginMarketplaceTrust,
type PluginInstallOutcome,
type PluginShareAction,
type PluginShareProjectOutcome,
type PluginMarketplaceEntry,
type PluginMarketplace,
type PluginMarketplaceMutationOutcome,
type PluginMarketplaceTrust,
uploadPluginFolder,
uploadPluginZip,
} from '../state/projects';
@ -22,7 +29,7 @@ import { PluginDetailsModal } from './PluginDetailsModal';
import { PluginsHomeSection } from './PluginsHomeSection';
import { useI18n } from '../i18n';
type PluginsTab = 'community' | 'mine' | 'marketplaces' | 'team';
type PluginsTab = 'installed' | 'available' | 'sources' | 'team';
const USER_SOURCE_KINDS = new Set<PluginSourceKind>([
'user',
@ -37,12 +44,11 @@ const PLUGINS_TABS: ReadonlyArray<{
id: PluginsTab;
label: string;
hint: string;
disabled?: boolean;
}> = [
{ id: 'community', label: 'Official', hint: 'Open Design catalog' },
{ id: 'mine', label: 'My plugins', hint: 'User-installed' },
{ id: 'marketplaces', label: 'Marketplaces', hint: 'Coming soon', disabled: true },
{ id: 'team', label: 'Team / Enterprise', hint: 'Coming soon' },
{ id: 'installed', label: 'Installed', hint: 'Ready to use' },
{ id: 'available', label: 'Available', hint: 'From sources' },
{ id: 'sources', label: 'Sources', hint: 'Catalogs' },
{ id: 'team', label: 'Team', hint: 'Enterprise' },
];
const PLUGIN_SHARE_DETAILS: Record<PluginShareAction, {
@ -95,9 +101,11 @@ export function PluginsView({
const [plugins, setPlugins] = useState<InstalledPluginRecord[]>([]);
const [marketplaces, setMarketplaces] = useState<PluginMarketplace[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<PluginsTab>('community');
const [activeTab, setActiveTab] = useState<PluginsTab>('installed');
const [importOpen, setImportOpen] = useState(false);
const [pendingApplyId, setPendingApplyId] = useState<string | null>(null);
const [pendingInstallEntry, setPendingInstallEntry] = useState<string | null>(null);
const [pendingSourceAction, setPendingSourceAction] = useState<string | null>(null);
const [pendingShareAction, setPendingShareAction] = useState<{
pluginId: string;
action: PluginShareAction;
@ -136,15 +144,22 @@ export function PluginsView({
() => plugins.filter((plugin) => USER_SOURCE_KINDS.has(plugin.sourceKind)),
[plugins],
);
const availablePlugins = useMemo(
() => buildAvailablePlugins(marketplaces, plugins),
[marketplaces, plugins],
);
async function finishImport(work: () => Promise<PluginInstallOutcome>) {
async function finishImport(
work: () => Promise<PluginInstallOutcome>,
targetTab: PluginsTab = 'installed',
) {
setNotice(null);
const outcome = await work();
setNotice(outcome);
if (outcome.ok) {
setImportOpen(false);
await refresh();
setActiveTab('mine');
setActiveTab(targetTab);
}
return outcome;
}
@ -203,6 +218,28 @@ export function PluginsView({
setShareConfirm({ sourceRecord: record, action, actionRecord });
}
async function handleInstallAvailable(plugin: AvailableMarketplacePlugin) {
setPendingInstallEntry(plugin.key);
const outcome = await finishImport(
() => installPluginSource(plugin.entry.name),
plugin.installed ? 'installed' : 'available',
);
setPendingInstallEntry(null);
if (outcome.ok && plugin.installed) setActiveTab('installed');
}
async function handleMarketplaceMutation(
actionKey: string,
work: () => Promise<PluginMarketplaceMutationOutcome>,
) {
setPendingSourceAction(actionKey);
setNotice(null);
const outcome = await work();
setPendingSourceAction(null);
setNotice(outcome);
if (outcome.ok) await refresh();
}
return (
<section className="plugins-view" aria-labelledby="plugins-title">
<header className="plugins-view__hero">
@ -212,8 +249,8 @@ export function PluginsView({
Plugins
</h1>
<p className="plugins-view__lede">
Browse plugins by workflow: import sources, create artifacts,
export downstream, refine existing work, or extend the catalog.
Browse installed workflows, discover registry entries, manage
sources, and prepare plugins for team distribution.
</p>
</div>
<div className="plugins-view__hero-actions">
@ -244,9 +281,9 @@ export function PluginsView({
</header>
<div className="plugins-view__stats" aria-label="Plugin summary">
<StatCard label="Official" value={officialPlugins.length} />
<StatCard label="My plugins" value={userPlugins.length} />
<StatCard label="Marketplaces" value={marketplaces.length} />
<StatCard label="Installed" value={plugins.length} />
<StatCard label="Available" value={availablePlugins.length} />
<StatCard label="Sources" value={marketplaces.length} />
</div>
<nav className="plugins-view__tabs" role="tablist" aria-label="Plugin areas">
@ -258,18 +295,13 @@ export function PluginsView({
type="button"
role="tab"
aria-selected={active}
aria-disabled={tab.disabled ? 'true' : undefined}
disabled={tab.disabled}
className={[
'plugins-view__tab',
active ? ' is-active' : '',
tab.disabled ? ' is-disabled' : '',
]
.filter(Boolean)
.join('')}
onClick={() => {
if (!tab.disabled) setActiveTab(tab.id);
}}
onClick={() => setActiveTab(tab.id)}
data-testid={`plugins-tab-${tab.id}`}
>
<span className="plugins-view__tab-label">{tab.label}</span>
@ -284,45 +316,75 @@ export function PluginsView({
<div className="plugins-view__gallery">
{loading ? <div className="plugins-view__empty">Loading plugins</div> : null}
{!loading && activeTab === 'community' ? (
<PluginsHomeSection
plugins={officialPlugins}
loading={false}
activePluginId={activePlugin?.record.id ?? null}
pendingApplyId={pendingApplyId}
pendingShareAction={pendingShareAction}
{!loading && activeTab === 'installed' ? (
<>
<PluginsHomeSection
plugins={officialPlugins}
loading={false}
activePluginId={activePlugin?.record.id ?? null}
pendingApplyId={pendingApplyId}
pendingShareAction={pendingShareAction}
onUse={(record) => void handleUsePlugin(record)}
onOpenDetails={setDetailsRecord}
onCreatePlugin={onCreatePlugin}
title="Official"
subtitle="Bundled Open Design workflows already available in this runtime."
emptyMessage="No official plugins are registered yet. Restart the daemon if this looks wrong."
/>
<PluginsHomeSection
plugins={userPlugins}
loading={false}
activePluginId={activePlugin?.record.id ?? null}
pendingApplyId={pendingApplyId}
pendingShareAction={pendingShareAction}
onUse={(record) => void handleUsePlugin(record)}
onOpenDetails={setDetailsRecord}
onPluginShareAction={(record, action) =>
requestPluginShareTask(record, action)
}
onCreatePlugin={onCreatePlugin}
title="My plugins"
subtitle="Local, imported, and marketplace-installed plugins in your user registry."
emptyMessage="No user plugins yet. Use Create / Import or install an Available entry."
/>
</>
) : null}
{!loading && activeTab === 'available' ? (
<AvailablePluginsPanel
plugins={availablePlugins}
pendingKey={pendingInstallEntry}
onInstall={(plugin) => void handleInstallAvailable(plugin)}
onUse={(record) => void handleUsePlugin(record)}
onOpenDetails={setDetailsRecord}
onCreatePlugin={onCreatePlugin}
title="Official"
subtitle="First-party Open Design workflows packaged as plugins. Pick one to load a starter prompt, or use @ search from Home."
emptyMessage="No official plugins are registered yet. Restart the daemon if this looks wrong."
onOpenInstalled={setDetailsRecord}
/>
) : null}
{!loading && activeTab === 'mine' ? (
<PluginsHomeSection
plugins={userPlugins}
loading={false}
activePluginId={activePlugin?.record.id ?? null}
pendingApplyId={pendingApplyId}
pendingShareAction={pendingShareAction}
onUse={(record) => void handleUsePlugin(record)}
onOpenDetails={setDetailsRecord}
onPluginShareAction={(record, action) =>
requestPluginShareTask(record, action)
{!loading && activeTab === 'sources' ? (
<SourcesPanel
marketplaces={marketplaces}
pendingAction={pendingSourceAction}
onAdd={(url, trust) =>
void handleMarketplaceMutation('add', () => addPluginMarketplace({ url, trust }))
}
onRefresh={(marketplace) =>
void handleMarketplaceMutation(`refresh:${marketplace.id}`, () =>
refreshPluginMarketplace(marketplace.id),
)
}
onRemove={(marketplace) =>
void handleMarketplaceMutation(`remove:${marketplace.id}`, () =>
removePluginMarketplace(marketplace.id),
)
}
onTrust={(marketplace, trust) =>
void handleMarketplaceMutation(`trust:${marketplace.id}:${trust}`, () =>
setPluginMarketplaceTrust(marketplace.id, trust),
)
}
onCreatePlugin={onCreatePlugin}
title="My plugins"
subtitle="Your imported workflow plugins. Tag them by intent so they appear beside the official Import, Create, Export, Refine, and Extend starters."
emptyMessage="No user plugins yet. Use Create / Import to install from GitHub, a daemon-local path, an HTTPS archive, or a marketplace name."
/>
) : null}
{!loading && activeTab === 'marketplaces' ? (
<MarketplacesPanel marketplaces={marketplaces} />
) : null}
{activeTab === 'team' ? <TeamPanel /> : null}
</div>
@ -566,19 +628,178 @@ function Notice({
);
}
function MarketplacesPanel({ marketplaces }: { marketplaces: PluginMarketplace[] }) {
interface AvailableMarketplacePlugin {
key: string;
marketplace: PluginMarketplace;
entry: PluginMarketplaceEntry;
installed: InstalledPluginRecord | null;
status: 'install' | 'use' | 'upgrade';
}
function AvailablePluginsPanel({
plugins,
pendingKey,
onInstall,
onUse,
onOpenInstalled,
}: {
plugins: AvailableMarketplacePlugin[];
pendingKey: string | null;
onInstall: (plugin: AvailableMarketplacePlugin) => void;
onUse: (record: InstalledPluginRecord) => void;
onOpenInstalled: (record: InstalledPluginRecord) => void;
}) {
return (
<section className="plugins-view__section" aria-labelledby="plugins-marketplaces-title">
<section className="plugins-view__section" aria-labelledby="plugins-available-title">
<div className="plugins-view__section-head">
<div>
<h2 id="plugins-marketplaces-title">Configured marketplaces</h2>
<p>Marketplace manifests can resolve bare plugin names during install.</p>
<h2 id="plugins-available-title">Available from sources</h2>
<p>Catalog entries discovered from configured marketplaces.</p>
</div>
<span className="plugins-view__section-count">{plugins.length}</span>
</div>
{plugins.length === 0 ? (
<div className="plugins-view__empty">
No available entries yet. Add a source in the Sources tab.
</div>
) : (
<div className="plugins-view__available-list">
{plugins.map((plugin) => {
const title = plugin.entry.title ?? plugin.entry.name;
const installedSameVersion =
plugin.installed &&
(!plugin.entry.version || plugin.installed.version === plugin.entry.version);
return (
<article key={plugin.key} className="plugins-view__available-card">
<div className="plugins-view__available-main">
<div className="plugins-view__row-title">
<span>{title}</span>
<span className={`plugins-view__trust trust-${plugin.marketplace.trust}`}>
{plugin.marketplace.trust}
</span>
</div>
{plugin.entry.description ? <p>{plugin.entry.description}</p> : null}
<div className="plugins-view__meta">
<span>{plugin.entry.name}</span>
{plugin.entry.version ? <span>v{plugin.entry.version}</span> : null}
<span>{plugin.marketplace.manifest.name ?? plugin.marketplace.url}</span>
{plugin.entry.tags?.slice(0, 3).map((tag) => (
<span key={`${plugin.key}:${tag}`}>{tag}</span>
))}
</div>
</div>
<div className="plugins-view__row-actions">
{plugin.installed ? (
<button
type="button"
className="plugins-view__secondary"
onClick={() => onOpenInstalled(plugin.installed!)}
>
Details
</button>
) : null}
{plugin.installed && installedSameVersion ? (
<button
type="button"
className="plugins-view__primary"
onClick={() => onUse(plugin.installed!)}
>
Use
</button>
) : (
<button
type="button"
className="plugins-view__primary"
onClick={() => onInstall(plugin)}
disabled={pendingKey === plugin.key}
data-testid={`plugins-available-install-${plugin.entry.name}`}
>
{pendingKey === plugin.key
? 'Installing…'
: plugin.installed
? 'Upgrade'
: 'Install'}
</button>
)}
</div>
</article>
);
})}
</div>
)}
</section>
);
}
function SourcesPanel({
marketplaces,
pendingAction,
onAdd,
onRefresh,
onRemove,
onTrust,
}: {
marketplaces: PluginMarketplace[];
pendingAction: string | null;
onAdd: (url: string, trust: PluginMarketplaceTrust) => void;
onRefresh: (marketplace: PluginMarketplace) => void;
onRemove: (marketplace: PluginMarketplace) => void;
onTrust: (marketplace: PluginMarketplace, trust: PluginMarketplaceTrust) => void;
}) {
const [url, setUrl] = useState('');
const [trust, setTrust] = useState<PluginMarketplaceTrust>('restricted');
const trimmedUrl = url.trim();
return (
<section className="plugins-view__section" aria-labelledby="plugins-sources-title">
<div className="plugins-view__section-head">
<div>
<h2 id="plugins-sources-title">Registry sources</h2>
<p>Marketplace catalogs that feed Available plugin entries.</p>
</div>
<span className="plugins-view__section-count">{marketplaces.length}</span>
</div>
<form
className="plugins-view__source-manager"
onSubmit={(event) => {
event.preventDefault();
if (!trimmedUrl) return;
onAdd(trimmedUrl, trust);
setUrl('');
}}
>
<label htmlFor="plugin-marketplace-url">Source URL</label>
<div className="plugins-view__source-row">
<input
id="plugin-marketplace-url"
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://open-design.ai/marketplace/open-design-marketplace.json"
disabled={pendingAction === 'add'}
/>
<select
value={trust}
onChange={(event) => setTrust(event.target.value as PluginMarketplaceTrust)}
disabled={pendingAction === 'add'}
aria-label="Default trust"
>
<option value="restricted">Restricted</option>
<option value="trusted">Trusted</option>
<option value="official">Official</option>
</select>
<button
type="submit"
className="plugins-view__primary"
disabled={!trimmedUrl || pendingAction === 'add'}
>
{pendingAction === 'add' ? 'Adding…' : 'Add source'}
</button>
</div>
</form>
{marketplaces.length === 0 ? (
<div className="plugins-view__empty">
No marketplaces registered yet. Add one with <code>od marketplace add &lt;url&gt;</code>.
No registry sources configured yet.
</div>
) : (
<div className="plugins-view__marketplaces">
@ -589,10 +810,41 @@ function MarketplacesPanel({ marketplaces }: { marketplaces: PluginMarketplace[]
<a href={marketplace.url} target="_blank" rel="noreferrer">
{marketplace.url}
</a>
<div className="plugins-view__meta">
<span>{marketplace.trust}</span>
<span>{marketplace.manifest.plugins?.length ?? 0} plugins</span>
{marketplace.version ? <span>catalog v{marketplace.version}</span> : null}
</div>
</div>
<div className="plugins-view__meta">
<span>{marketplace.trust}</span>
<span>{marketplace.manifest.plugins?.length ?? 0} plugins</span>
<div className="plugins-view__source-actions">
<select
value={marketplace.trust}
onChange={(event) =>
onTrust(marketplace, event.target.value as PluginMarketplaceTrust)
}
aria-label={`Trust for ${marketplace.manifest.name ?? marketplace.url}`}
disabled={pendingAction?.startsWith(`trust:${marketplace.id}:`)}
>
<option value="restricted">Restricted</option>
<option value="trusted">Trusted</option>
<option value="official">Official</option>
</select>
<button
type="button"
className="plugins-view__secondary"
onClick={() => onRefresh(marketplace)}
disabled={pendingAction === `refresh:${marketplace.id}`}
>
{pendingAction === `refresh:${marketplace.id}` ? 'Refreshing…' : 'Refresh'}
</button>
<button
type="button"
className="plugins-view__danger"
onClick={() => onRemove(marketplace)}
disabled={pendingAction === `remove:${marketplace.id}`}
>
{pendingAction === `remove:${marketplace.id}` ? 'Removing…' : 'Remove'}
</button>
</div>
</article>
))}
@ -871,6 +1123,45 @@ function FileImportPanel({
);
}
function buildAvailablePlugins(
marketplaces: PluginMarketplace[],
installed: InstalledPluginRecord[],
): AvailableMarketplacePlugin[] {
const installedByName = new Map<string, InstalledPluginRecord>();
for (const plugin of installed) {
for (const key of pluginLookupKeys(plugin)) {
installedByName.set(key, plugin);
}
}
return marketplaces.flatMap((marketplace) => {
const entries = marketplace.manifest.plugins ?? [];
return entries.map((entry) => {
const installedPlugin = installedByName.get(normalizePluginName(entry.name)) ?? null;
const sameVersion =
installedPlugin &&
(!entry.version || installedPlugin.version === entry.version);
return {
key: `${marketplace.id}:${entry.name}:${entry.version ?? ''}`,
marketplace,
entry,
installed: installedPlugin,
status: installedPlugin ? (sameVersion ? 'use' : 'upgrade') : 'install',
};
});
});
}
function pluginLookupKeys(plugin: InstalledPluginRecord): string[] {
const keys = new Set<string>();
keys.add(normalizePluginName(plugin.id));
if (plugin.manifest?.name) keys.add(normalizePluginName(plugin.manifest.name));
return Array.from(keys);
}
function normalizePluginName(name: string): string {
return name.trim().toLowerCase();
}
function TeamPanel() {
return (
<section className="plugins-view__team" aria-labelledby="plugins-team-title">

View file

@ -72,6 +72,7 @@ export function PluginMetaSections({ record, omit, compact, heading }: Props) {
const [copied, setCopied] = useState(false);
const manifest: PluginManifest = record.manifest ?? ({} as PluginManifest);
const specVersion = typeof manifest.specVersion === 'string' ? manifest.specVersion : '';
const od = manifest.od ?? {};
const description = manifest.description ?? '';
const query = resolvePluginQueryFallback(od.useCase?.query);
@ -514,6 +515,14 @@ export function PluginMetaSections({ record, omit, compact, heading }: Props) {
<code>v{record.version}</code>
</dd>
</div>
{specVersion ? (
<div>
<dt>Spec</dt>
<dd>
<code>v{specVersion}</code>
</dd>
</div>
) : null}
<div>
<dt>Trust</dt>
<dd>

View file

@ -699,7 +699,10 @@ export function applyFacetSelection(
export function isFeaturedPlugin(record: InstalledPluginRecord): boolean {
const od = (record.manifest?.od ?? {}) as Record<string, unknown>;
return od.featured === true;
return (
od.featured === true ||
(typeof od.featured === 'number' && Number.isFinite(od.featured))
);
}
// Free-text search across the obvious user-facing surface area: title,

View file

@ -9,7 +9,7 @@
// The score is a deterministic linear sum of signals already
// present on the manifest:
//
// featured flag → +1000 (curator pick wins)
// featured flag/rank → +1000+ (curator pick wins)
// has video preview → +700 (motion is rare; lead with it)
// has image poster → +500
// has both video + poster → +200 (extra polish bonus)
@ -79,9 +79,11 @@ function kindOf(record: InstalledPluginRecord): string {
return typeof od?.kind === 'string' ? od.kind.toLowerCase() : '';
}
function isFeatured(record: InstalledPluginRecord): boolean {
function featuredRank(record: InstalledPluginRecord): number | null {
const od = (record.manifest?.od ?? {}) as Record<string, unknown>;
return od.featured === true;
if (od.featured === true) return 0;
if (typeof od.featured !== 'number' || !Number.isFinite(od.featured)) return null;
return Math.max(0, od.featured);
}
function richTagCount(record: InstalledPluginRecord): number {
@ -95,7 +97,8 @@ function richTagCount(record: InstalledPluginRecord): number {
export function pluginVisualScore(record: InstalledPluginRecord): number {
let score = 0;
if (isFeatured(record)) score += 1000;
const rank = featuredRank(record);
if (rank !== null) score += 1000 + Math.max(0, 100 - rank);
const preview = readPreview(record);
const hasPoster =
@ -137,17 +140,26 @@ export function pluginVisualScore(record: InstalledPluginRecord): number {
return score;
}
// Stable sort: visual score descending, then title ascending so tiles
// at the same score band still order deterministically.
// Stable sort: curated featured rank first, then visual score descending,
// then title ascending so tiles at the same score band still order
// deterministically.
export function sortByVisualAppeal<T extends InstalledPluginRecord>(
records: readonly T[],
): T[] {
const annotated = records.map((r, idx) => ({
record: r,
rank: featuredRank(r),
score: pluginVisualScore(r),
idx,
}));
annotated.sort((a, b) => {
const aFeatured = a.rank !== null;
const bFeatured = b.rank !== null;
if (aFeatured || bFeatured) {
if (aFeatured && !bFeatured) return -1;
if (!aFeatured && bFeatured) return 1;
if (a.rank !== b.rank) return (a.rank ?? 0) - (b.rank ?? 0);
}
if (b.score !== a.score) return b.score - a.score;
const aTitle = a.record.title || a.record.id;
const bTitle = b.record.title || b.record.id;

View file

@ -724,13 +724,37 @@ export async function uninstallPlugin(id: string): Promise<boolean> {
export interface PluginMarketplace {
id: string;
url: string;
trust: 'official' | 'trusted' | 'restricted';
trust: PluginMarketplaceTrust;
specVersion?: string;
version?: string;
addedAt?: number;
refreshedAt?: number;
manifest: {
name?: string;
plugins?: Array<{ name: string; source: string; description?: string }>;
version?: string;
plugins?: PluginMarketplaceEntry[];
};
}
export type PluginMarketplaceTrust = 'official' | 'trusted' | 'restricted';
export interface PluginMarketplaceEntry {
name: string;
source: string;
version?: string;
ref?: string;
tags?: string[];
title?: string;
description?: string;
icon?: string;
}
export interface PluginMarketplaceMutationOutcome {
ok: boolean;
marketplace?: PluginMarketplace;
message: string;
}
export async function listPluginMarketplaces(): Promise<PluginMarketplace[]> {
try {
const resp = await fetch('/api/marketplaces');
@ -742,6 +766,82 @@ export async function listPluginMarketplaces(): Promise<PluginMarketplace[]> {
}
}
export async function addPluginMarketplace(input: {
url: string;
trust: PluginMarketplaceTrust;
}): Promise<PluginMarketplaceMutationOutcome> {
try {
const resp = await fetch('/api/marketplaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return readPluginMarketplaceOutcome(resp, 'Marketplace source added.');
} catch (err) {
return { ok: false, message: (err as Error).message };
}
}
export async function refreshPluginMarketplace(
id: string,
): Promise<PluginMarketplaceMutationOutcome> {
try {
const resp = await fetch(`/api/marketplaces/${encodeURIComponent(id)}/refresh`, {
method: 'POST',
});
return readPluginMarketplaceOutcome(resp, 'Marketplace source refreshed.');
} catch (err) {
return { ok: false, message: (err as Error).message };
}
}
export async function removePluginMarketplace(
id: string,
): Promise<PluginMarketplaceMutationOutcome> {
try {
const resp = await fetch(`/api/marketplaces/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
if (!resp.ok) {
return { ok: false, message: await readErrorMessage(resp) };
}
return { ok: true, message: 'Marketplace source removed.' };
} catch (err) {
return { ok: false, message: (err as Error).message };
}
}
export async function setPluginMarketplaceTrust(
id: string,
trust: PluginMarketplaceTrust,
): Promise<PluginMarketplaceMutationOutcome> {
try {
const resp = await fetch(`/api/marketplaces/${encodeURIComponent(id)}/trust`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trust }),
});
return readPluginMarketplaceOutcome(resp, 'Marketplace trust updated.');
} catch (err) {
return { ok: false, message: (err as Error).message };
}
}
async function readPluginMarketplaceOutcome(
resp: Response,
successMessage: string,
): Promise<PluginMarketplaceMutationOutcome> {
if (!resp.ok) {
return { ok: false, message: await readErrorMessage(resp) };
}
const marketplace = (await resp.json().catch(() => null)) as PluginMarketplace | null;
return {
ok: true,
...(marketplace ? { marketplace } : {}),
message: successMessage,
};
}
export async function applyPlugin(
pluginId: string,
options: {

View file

@ -189,7 +189,8 @@
}
.plugins-view__list,
.plugins-view__marketplaces {
.plugins-view__marketplaces,
.plugins-view__available-list {
display: flex;
flex-direction: column;
gap: 9px;
@ -197,7 +198,9 @@
.plugins-view__row,
.plugins-view__marketplace,
.plugins-view__available-card,
.plugins-view__install-card,
.plugins-view__source-manager,
.plugins-view__team {
border: 1px solid var(--border);
border-radius: var(--radius);
@ -314,7 +317,8 @@
opacity: 0.55;
}
.plugins-view__marketplace {
.plugins-view__marketplace,
.plugins-view__available-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
@ -322,6 +326,17 @@
padding: 13px;
}
.plugins-view__available-main {
min-width: 0;
}
.plugins-view__available-main p {
margin: 5px 0 0;
color: var(--text-muted);
font-size: 12.5px;
line-height: 1.45;
}
.plugins-view__marketplace a {
display: inline-block;
margin-top: 5px;
@ -330,6 +345,40 @@
word-break: break-all;
}
.plugins-view__source-manager {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
}
.plugins-view__source-manager label {
color: var(--text-strong);
font-size: 13px;
font-weight: 650;
}
.plugins-view__source-actions {
display: flex;
align-items: center;
gap: 7px;
flex: 0 0 auto;
}
.plugins-view__source-actions select,
.plugins-view__source-row select {
min-height: 32px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
color: var(--text);
font-size: 12px;
}
.plugins-view__source-actions select {
min-width: 112px;
}
.plugins-view__install-card {
display: flex;
flex-direction: column;
@ -625,7 +674,9 @@
.plugins-view__hero,
.plugins-view__hero-actions,
.plugins-view__row,
.plugins-view__marketplace {
.plugins-view__marketplace,
.plugins-view__available-card,
.plugins-view__source-actions {
flex-direction: column;
}
@ -636,7 +687,8 @@
}
.plugins-view__row-actions,
.plugins-view__source-row {
.plugins-view__source-row,
.plugins-view__source-actions {
width: 100%;
}

View file

@ -5,10 +5,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPluginRecord, PluginSourceKind, TrustTier } from '@open-design/contracts';
import { PluginsView } from '../../src/components/PluginsView';
import {
addPluginMarketplace,
applyPlugin,
installPluginSource,
listPluginMarketplaces,
listPlugins,
refreshPluginMarketplace,
removePluginMarketplace,
setPluginMarketplaceTrust,
type PluginShareProjectOutcome,
uploadPluginFolder,
uploadPluginZip,
@ -19,10 +23,14 @@ vi.mock('../../src/router', () => ({
}));
vi.mock('../../src/state/projects', () => ({
addPluginMarketplace: vi.fn(),
applyPlugin: vi.fn(),
installPluginSource: vi.fn(),
listPluginMarketplaces: vi.fn(),
listPlugins: vi.fn(),
refreshPluginMarketplace: vi.fn(),
removePluginMarketplace: vi.fn(),
setPluginMarketplaceTrust: vi.fn(),
uninstallPlugin: vi.fn(),
uploadPluginFolder: vi.fn(),
uploadPluginZip: vi.fn(),
@ -63,6 +71,10 @@ function makePlugin(
const mockedListPlugins = vi.mocked(listPlugins);
const mockedListMarketplaces = vi.mocked(listPluginMarketplaces);
const mockedInstallPluginSource = vi.mocked(installPluginSource);
const mockedAddMarketplace = vi.mocked(addPluginMarketplace);
const mockedRefreshMarketplace = vi.mocked(refreshPluginMarketplace);
const mockedRemoveMarketplace = vi.mocked(removePluginMarketplace);
const mockedSetMarketplaceTrust = vi.mocked(setPluginMarketplaceTrust);
const mockedApplyPlugin = vi.mocked(applyPlugin);
const mockedUploadPluginFolder = vi.mocked(uploadPluginFolder);
const mockedUploadPluginZip = vi.mocked(uploadPluginZip);
@ -79,10 +91,34 @@ beforeEach(() => {
trust: 'official',
manifest: {
name: 'Example Catalog',
plugins: [{ name: 'remote-plugin', source: 'github:owner/repo' }],
version: '1.0.0',
plugins: [{
name: 'remote-plugin',
title: 'Remote Plugin',
source: 'github:owner/repo',
version: '1.2.0',
description: 'Remote catalog plugin.',
tags: ['deck'],
}],
},
},
]);
mockedAddMarketplace.mockResolvedValue({
ok: true,
message: 'Marketplace source added.',
});
mockedRefreshMarketplace.mockResolvedValue({
ok: true,
message: 'Marketplace source refreshed.',
});
mockedRemoveMarketplace.mockResolvedValue({
ok: true,
message: 'Marketplace source removed.',
});
mockedSetMarketplaceTrust.mockResolvedValue({
ok: true,
message: 'Marketplace trust updated.',
});
mockedInstallPluginSource.mockResolvedValue({
ok: true,
plugin: makePlugin('new-plugin', 'github', 'restricted'),
@ -150,20 +186,20 @@ describe('PluginsView', () => {
expect(screen.queryByRole('dialog', { name: 'Create or import a plugin' })).toBeNull();
});
it('groups official and user-installed plugins while keeping marketplaces coming soon', async () => {
it('shows installed plugins and available registry entries', async () => {
render(<PluginsView />);
await waitFor(() => expect(screen.getAllByText('Official Plugin').length).toBeGreaterThan(0));
expect(screen.queryByText('User Plugin')).toBeNull();
const myPluginsTab = screen.getByTestId('plugins-tab-mine');
const marketplacesTab = screen.getByTestId('plugins-tab-marketplaces');
expect(myPluginsTab.getAttribute('aria-disabled')).toBeNull();
expect(marketplacesTab.getAttribute('aria-disabled')).toBe('true');
fireEvent.click(myPluginsTab);
expect(screen.getAllByText('User Plugin').length).toBeGreaterThan(0);
expect(screen.queryByText('Official Plugin')).toBeNull();
const availableTab = screen.getByTestId('plugins-tab-available');
const sourcesTab = screen.getByTestId('plugins-tab-sources');
expect(availableTab.getAttribute('aria-disabled')).toBeNull();
expect(sourcesTab.getAttribute('aria-disabled')).toBeNull();
fireEvent.click(availableTab);
expect(await screen.findByText('Remote Plugin')).toBeTruthy();
expect(screen.getByText('Example Catalog')).toBeTruthy();
});
it('installs from a supported source string', async () => {
@ -183,10 +219,53 @@ describe('PluginsView', () => {
),
);
expect(await screen.findByText('Installed New Plugin.')).toBeTruthy();
expect(screen.getByTestId('plugins-tab-mine').getAttribute('aria-selected')).toBe('true');
expect(screen.getByTestId('plugins-tab-installed').getAttribute('aria-selected')).toBe('true');
expect(screen.getAllByText('User Plugin').length).toBeGreaterThan(0);
});
it('installs an available marketplace entry by name', async () => {
render(<PluginsView />);
fireEvent.click(await screen.findByTestId('plugins-tab-available'));
fireEvent.click(await screen.findByTestId('plugins-available-install-remote-plugin'));
await waitFor(() =>
expect(mockedInstallPluginSource).toHaveBeenCalledWith('remote-plugin'),
);
expect(await screen.findByText('Installed New Plugin.')).toBeTruthy();
});
it('manages registry sources from the Sources tab', async () => {
render(<PluginsView />);
fireEvent.click(await screen.findByTestId('plugins-tab-sources'));
fireEvent.change(screen.getByLabelText('Source URL'), {
target: { value: 'https://example.com/next.json' },
});
fireEvent.click(screen.getByRole('button', { name: 'Add source' }));
await waitFor(() =>
expect(mockedAddMarketplace).toHaveBeenCalledWith({
url: 'https://example.com/next.json',
trust: 'restricted',
}),
);
expect(await screen.findByText('Marketplace source added.')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
await waitFor(() => expect(mockedRefreshMarketplace).toHaveBeenCalledWith('catalog-1'));
fireEvent.change(screen.getByLabelText('Trust for Example Catalog'), {
target: { value: 'trusted' },
});
await waitFor(() =>
expect(mockedSetMarketplaceTrust).toHaveBeenCalledWith('catalog-1', 'trusted'),
);
fireEvent.click(screen.getByRole('button', { name: 'Remove' }));
await waitFor(() => expect(mockedRemoveMarketplace).toHaveBeenCalledWith('catalog-1'));
});
it('uploads zip and folder plugins from the import dialog', async () => {
render(<PluginsView />);
@ -257,7 +336,6 @@ describe('PluginsView', () => {
/>,
);
fireEvent.click(await screen.findByTestId('plugins-tab-mine'));
const publish = await screen.findByTestId('plugins-home-publish-github-user-plugin');
expect(publish.textContent).toContain('Publish');
fireEvent.click(publish);

View file

@ -295,8 +295,9 @@ describe('applyFacetSelection', () => {
});
describe('isFeaturedPlugin', () => {
it('returns true only for od.featured === true (strict)', () => {
it('returns true for boolean featured picks and numeric curator ranks', () => {
expect(isFeaturedPlugin(fixture({ id: 'a', od: { featured: true } }))).toBe(true);
expect(isFeaturedPlugin(fixture({ id: 'ranked', od: { featured: 4 } }))).toBe(true);
expect(isFeaturedPlugin(fixture({ id: 'b', od: { featured: 'true' } }))).toBe(false);
expect(isFeaturedPlugin(fixture({ id: 'c' }))).toBe(false);
});

View file

@ -54,6 +54,12 @@ describe('pluginVisualScore', () => {
);
});
it('uses numeric featured ranks to order curated picks', () => {
const lead = fixture({ id: 'lead', od: { featured: 2 } });
const later = fixture({ id: 'later', od: { featured: 19 } });
expect(pluginVisualScore(lead)).toBeGreaterThan(pluginVisualScore(later));
});
it('ranks media-rich plugins above plain scenarios', () => {
const text = fixture({ id: 'text' });
const deckHtml = fixture({
@ -116,6 +122,32 @@ describe('sortByVisualAppeal', () => {
expect(sorted[sorted.length - 1]).toBe('plain');
});
it('keeps numeric featured rank ahead of media bonuses', () => {
const records = [
fixture({
id: 'hyperframes',
od: {
surface: 'video',
mode: 'video',
preview: { type: 'video', video: 'r.mp4', poster: 'r.png' },
featured: 0.13,
},
}),
fixture({
id: 'guizang',
od: {
mode: 'deck',
preview: { type: 'html', entry: './index.html' },
featured: 0.01,
},
}),
fixture({ id: 'huashu', od: { mode: 'prototype', featured: 0.03 } }),
fixture({ id: 'kami', od: { mode: 'deck', featured: 0.06 } }),
];
const sorted = sortByVisualAppeal(records).map((r) => r.id);
expect(sorted).toEqual(['guizang', 'huashu', 'kami', 'hyperframes']);
});
it('keeps the original list reference unchanged (returns a new array)', () => {
const records = [
fixture({ id: 'a' }),

View file

@ -19,7 +19,7 @@ triggers:
od:
mode: deck
scenario: marketing
featured: 9
featured: 0.02
default_for: deck
upstream: "https://github.com/op7418/guizang-ppt-skill"
preview:

View file

@ -21,7 +21,7 @@ od:
surface: web
mode: deck
scenario: marketing
featured: 4
featured: 0.06
audience: founders, researchers, design studios, conference talks
tone: editorial, restrained, print-first
scale: 6-15 viewport-locked slides

View file

@ -23,7 +23,7 @@ od:
mode: prototype
platform: desktop
scenario: marketing
featured: 3
featured: 0.05
audience: founders, design studios, OSS maintainers, researchers
tone: editorial, restrained, print-first
scale: viewport-anchored long-form single page

View file

@ -0,0 +1,521 @@
# Open Design Plugin Registry — Plan (living)
> **One sentence:** Turn the existing `open-design-marketplace.json` federation
> into a real npm-/clawhub-/skills.sh-style **registry**: GitHub repo as the v1
> storage backend, `od` CLI as the canonical client, official site as one
> rendered consumer, and the whole thing pluggable so a third party can stand
> up their own Open Design plugin source with one config line.
Source spec: [`docs/plugins-spec.md`](../plugins-spec.md) · zh-CN
[`docs/plugins-spec.zh-CN.md`](../plugins-spec.zh-CN.md).
Sibling plan: [`plugins-implementation.md`](./plugins-implementation.md) —
that file owns the per-plugin runtime/apply/snapshot pipeline. **This file
owns the registry / distribution / website / multi-source story.**
References (shape, not API):
- [Clawhub](https://documentation.openclaw.ai/clawhub) — agent-era plugin
catalog with GitHub-PR submission and a single rendered index site.
- [skills.sh](https://skills.sh) — community skill registry with `id:vendor/name`
addressing and copy-to-clipboard install commands.
- [npm registry](https://docs.npmjs.com/cli) — versioned packages, scoped names,
publish/yank lifecycle, lockfiles, integrity hashes.
---
## 0. Invariants (lock first; never violate without a spec patch)
- [ ] **R1. CLI is the canonical client.** Every UI action and every website
feature must be expressible as one `od` subcommand. UI / site are renderers.
- [ ] **R2. Storage backend is swappable.** GitHub repo is the v1 backend, but
daemon code talks to a `RegistryBackend` interface. Replacing GitHub with a
managed DB later must be a one-file swap, not a refactor.
- [ ] **R3. `SKILL.md` floor stays portable.** A plugin published to OD's
registry must still install cleanly as a plain agent skill in Claude
Code / Cursor / Codex / Gemini CLI / OpenClaw / Hermes. `open-design.json`
remains an additive sidecar (per spec §1).
- [ ] **R4. Trust vocabulary is one set, everywhere.** Contracts, daemon, CLI,
UI, and website all use **`official` / `trusted` / `restricted`**. (Today
`marketplace.ts` ships `official|trusted|untrusted` and the runtime ships
`bundled|trusted|restricted` — these get unified in P0.)
- [ ] **R5. Federation is the default, not the exception.** OD's own registry
is one source among many. Adding a third-party registry URL is symmetric
to adding ours; no special-casing in code paths.
- [ ] **R6. Provenance is preserved end-to-end.** Every installed plugin
carries `sourceMarketplaceId` + `marketplaceTrust` + resolved transport
(`github` / `url` / `local` / `bundled`), so UI and audit can answer
"where did this come from?" without re-resolving.
---
## 1. Target architecture
### 1.0 Product surface map
The registry architecture has three stable layers:
```text
Registry supply network
GitHub repo in v1, database backend later.
Owns package identity, versions, trust, integrity, yanking, provenance.
od CLI
Canonical client and automation API.
Owns login/whoami, publish, pack, doctor, search, install, upgrade.
Product UI
Human operating console.
Owns fast use, discovery, source management, provenance/risk display.
```
Concrete relationship:
```text
Plugin source repo
open-design.json includes plugin.repo
|
| od plugin validate / pack / publish
v
Plugin artifact
GitHub ref, GitHub Release .tgz, HTTPS archive, or local folder
|
v
Registry index
v1: open-design/plugin-registry or this repo
community/**/open-design.json
generated open-design-marketplace.json
future: DatabaseRegistryBackend
|
| od marketplace search / od plugin install
v
Installed plugin
local runnable record with sourceMarketplaceId, trust, integrity, resolved ref
```
Product surface semantics:
- **Home / Official starters** is not a separate registry. It is a curated
shortcut shelf for already-installed bundled/official workflows so users can
immediately click `Use`.
- **Plugins / Installed** is the full local runnable inventory: bundled
official plugins, user-created plugins, direct GitHub/URL/local installs, and
marketplace-installed plugins.
- **Plugins / Available** is the registry discovery layer: entries from
configured Sources that are not installed yet or have upgrades available.
- **Plugins / Sources** is the registry source manager: official, community,
self-hosted, and enterprise catalogs; trust tier; refresh; remove; auth/cache
status later.
- **Plugins / Team** is the enterprise governance layer: private catalogs,
organization allowlists, approvals, audit, and policy.
- **open-design.ai/marketplace** is the public renderer of the official
registry source, not a separate source of truth.
Agent consumption boundary:
```text
User-added registry
Sources -> Available -> Install -> Installed -> agent context/runtime
Packaged official plugins
bundled with OD -> Installed/bundled -> Home Official starters -> agent
```
`Available` is not directly consumable by the agent. It is a supply pool. The
agent consumes the installed set: bundled official plugins, user-created
plugins, direct GitHub/URL/local installs, and marketplace-installed plugins. A
future "Use from Available" convenience action may auto-install first, but it
must still create an installed record before execution.
Authoring and publishing mirror the consumption loop:
```text
Create plugin
-> agent-assisted authoring
-> od plugin scaffold / validate / local install / pack
-> od plugin login/whoami through gh
-> od plugin publish
-> GitHub registry PR
-> generated open-design-marketplace.json
-> Available for downstream users after refresh
```
The `Create plugin` button should therefore launch an agent workflow that helps
the user describe the plugin, writes `SKILL.md` and `open-design.json`, adds
examples/preview metadata, validates locally, installs a test copy, packs it,
and then drives the GitHub-backed publish PR. The CLI remains canonical; the
agent is the product wrapper around the CLI workflow.
v1 registry scope is intentionally simple: a GitHub repo with reviewable source
entries plus a generated `open-design-marketplace.json`. The JSON is what
daemon/CLI/UI fetch; the source entries are what humans review in PRs. This can
start in the main Open Design repo, but the code path must still be expressed as
`RegistryBackend` so moving to `open-design/plugin-registry` or a database later
does not change the product model.
### 1.1 Storage abstraction
```text
packages/registry-protocol/ ← NEW pure TS, schema + interface
├── backend.ts ← RegistryBackend interface
├── types.ts ← RegistryEntry, RegistryVersion,
│ PublishRequest, SearchResult, …
└── tests/
apps/daemon/src/registry/ ← side-effect zone
├── github-backend.ts ← v1: PR-based publish, raw.git read
├── http-backend.ts ← already-exists shape (marketplace.json)
├── local-backend.ts ← dev/test fixture
├── cache.ts ← on-disk cache + ETag refresh
├── lockfile.ts ← od-plugin-lock.json writer/reader
└── publish.ts ← orchestrates pack → fork → PR
```
`RegistryBackend` (sketch — exact shape lands in P0):
```ts
interface RegistryBackend {
id: string; // e.g. "official" | "acme-private"
kind: 'github' | 'http' | 'local' | 'db';
trust: MarketplaceTrust;
// read
list(filter?: ListFilter): Promise<RegistryEntry[]>;
search(q: string): Promise<SearchResult[]>;
resolve(name: string, range?: string): Promise<ResolvedEntry>;
manifest(name: string, version: string): Promise<PluginManifest>;
// write (optional — http/db may be read-only from CLI perspective)
publish?(req: PublishRequest): Promise<PublishOutcome>;
yank?(name: string, version: string, reason: string): Promise<void>;
// health
doctor(): Promise<DoctorReport>;
}
```
### 1.2 GitHub-backed v1 layout
A dedicated public repo — proposed **`open-design/plugin-registry`** — owns the
canonical official catalog. Third parties fork the same shape and point their
own `marketplace.json` URL at it.
```text
open-design/plugin-registry/
├── plugins/
│ └── <vendor>/<plugin-name>/
│ ├── manifest.json ← latest copy of open-design.json
│ ├── versions/
│ │ ├── 0.1.0.json ← frozen manifest snapshot per version
│ │ └── 0.2.0.json
│ ├── README.md ← rendered on the website
│ ├── OWNERS ← GitHub usernames with publish rights
│ └── tarball.txt ← canonical archive URL (GitHub release)
├── marketplace.json ← generated index; what daemons fetch
├── schema/
│ └── open-design.marketplace.v1.json
├── .github/workflows/
│ ├── validate-pr.yml ← schema + manifest + license + a11y
│ └── publish-index.yml ← rebuild + commit marketplace.json
└── docs/ ← "how to publish" guide
```
**Why a separate repo:** keeps the main monorepo green, gives publishers a
clear contribution target, lets us mirror the same shape for third-party
registries verbatim.
**Tarball storage:** plugins ship as `.tgz` (per existing `od plugin pack`).
v1 uses **GitHub Releases** of the plugin's own source repo as the canonical
archive host; the registry only stores a URL + integrity hash, never the bytes.
This keeps the registry repo small and makes the storage layer trivially
swappable.
**Namespace policy:** published package ids are always `vendor/plugin-name`.
The vendor is the GitHub org/user or enterprise org namespace. The full id is
stable after publish; rename means new id plus alias/deprecation metadata.
**Source repo policy:** accept "anything that packs". The source repo does not
need a special layout if `od plugin validate` and `od plugin pack` pass. The
manifest must include `plugin.repo` in `open-design.json`, pointing to the
canonical source repository or subdirectory.
**Tarball fallback:** GitHub Releases are the default archive host, but raw
HTTPS/object-storage archive URLs are accepted when Releases are unavailable.
Fallback archives require integrity hashes.
### 1.3 Publish flow (PR-based)
`od plugin publish` end-to-end:
1. Run `od plugin pack` → produces `<vendor>-<name>-<version>.tgz` + manifest digest.
2. Read or create a GitHub release on the plugin's **own** source repo,
upload the tarball, capture release asset URL + SHA-256.
3. `gh auth status` to confirm login (no token persisted by `od`).
4. Fork (or reuse fork of) `open-design/plugin-registry` via `gh repo fork`.
5. Check out a branch `publish/<vendor>-<name>-<version>`.
6. Write/refresh `plugins/<vendor>/<name>/manifest.json` and
`versions/<version>.json`, append entry to a per-plugin index, run
`pnpm run validate` locally.
7. `gh pr create` with a templated body containing manifest summary,
capability/permission list, tarball URL, integrity hash, changelog link.
8. CI in the registry repo runs `validate-pr.yml` — schema, license header,
tarball download + checksum, manifest replay, optional preview render.
9. On merge, `publish-index.yml` regenerates `marketplace.json` and pushes
it to `main`. GitHub Pages / CDN serves it at
`https://open-design.ai/marketplace/open-design-marketplace.json`.
**Yanking** uses the same PR shape with a `yanked: true, reason: "..."` patch
to the version JSON. Daemons treat yanked versions as unresolvable but
preserve them for `od plugin info <name>@<v>`.
Yanking never hard-deletes bytes or metadata. New installs refuse yanked
versions; exact lockfile replay may still install with a warning as long as
the archive remains reachable and integrity matches.
### 1.4 Install / resolve flow
```
od plugin install <name>[@<range>]
→ daemon iterates configured backends in trust order
→ first match: download tarball, verify SHA-256, extract to
.od/plugins/<name>/<version>/, write InstalledPluginRecord with
full provenance (sourceMarketplaceId, marketplaceTrust, source URL,
sourceDigest, resolved ref)
→ record into .od/od-plugin-lock.json for reproducibility
```
`od plugin upgrade [--policy latest|pinned]` re-resolves against lockfile and
applies new versions atomically (no in-place mutation; new version directory,
swap symlink, rollback on failure).
### 1.5 Login model
- `gh` is a first-class dependency of `od` registry workflows. Installing
`od` should ensure `gh` is present when the platform channel can bootstrap
it; otherwise the installer fails with exact remediation.
- `od plugin login` wraps `gh auth login` with Open Design copy, scopes, and
host guidance. `od plugin whoami` wraps `gh auth status` plus `gh api user`.
- `od plugin logout` may wrap `gh auth logout`, but only after explicit
confirmation because it affects the user's global GitHub CLI session.
- The daemon never stores GitHub tokens. GitHub.com and GitHub Enterprise auth
stay in `gh`; daemon code goes through a narrow `GhClient` abstraction.
- Generic bearer/header/basic/mTLS auth profiles are reserved for future
non-GitHub HTTPS or database backends. They must not leak into
`marketplace.json`.
- Database backend (future) defines its own auth in `RegistryBackend.kind=db`;
CLI surface stays unchanged.
### 1.6 Website renderers
Two consumers of the same `marketplace.json`:
- **Official site (open-design.ai/marketplace)** — static, SSG against
`https://open-design.ai/marketplace/open-design-marketplace.json`. Browse, search,
copy install command, render plugin README, preview asset, capability
& permission summary, version history, publisher links.
- **Self-hosted third-party site** — out of the box, anyone running the
same registry repo template gets the static site as a copy-paste
GitHub Pages action. They publish their own catalog URL, users add it
with `od marketplace add <url>`.
UI never special-cases the official catalog beyond the trust tier; the
website is one rendering of a generic data source.
### 1.7 Web app surface (apps/web)
Update `apps/web/src/components/PluginsView.tsx` from "installed list"
into a multi-source registry browser:
- **Tabs:** Installed · Available · Sources · Team
- **Available:** cards grouped by `sourceMarketplaceId` and trust tier.
Filter by capability, tag, publisher. Each card: title, version, publisher,
trust badge, install button.
- **Sources:** Add URL · Refresh · Remove · Trust toggle · auth status
for private catalogs · "view manifest" drawer.
- **Plugin detail drawer:** provenance line ("from official · trusted ·
github.com/foo/bar@v0.2.0 · sha256:abcd…"), permissions, README, install
command, available versions, yanked-version warning.
The first UI slice has already unlocked `Available` and `Sources` using cached
marketplace manifests and existing `/api/marketplaces` endpoints. Follow-up
work should move large-catalog browsing to typed paginated APIs and add
provenance-aware `--from <marketplace-id>` install semantics.
---
## 2. Phased plan
### P0 — Contract closure (unblocks everything else)
Goal: every later phase can assume one trust vocabulary, full provenance,
and a versioned marketplace entry.
- [ ] **P0.1 Unify trust tier vocabulary.** In `packages/contracts/src/plugins/marketplace.ts`,
rename `MarketplaceTrust` from `official|trusted|untrusted`
`official|trusted|restricted`. Migrate daemon, CLI, UI, fixtures.
- [ ] **P0.2 Marketplace entry v1.1 fields.** Extend `MarketplacePluginEntrySchema`
with optional `versions[]`, `integrity` (sha256), `publisher`, `homepage`,
`license`, `capabilitiesSummary`, `yanked`. Stay `.passthrough()` for
community extensions (clawhub tags etc.).
- [ ] **P0.3 Install provenance.** Plumb `sourceMarketplaceId`,
`marketplaceTrust`, `marketplacePluginName`, and the resolved transport
source through `apps/daemon/src/server.ts` install path (currently around
line 3880). The `InstalledPluginRecord` already has these fields — make
sure they actually get populated when install comes via marketplace.
- [ ] **P0.4 Default trust inheritance.** Installs from `official`/`trusted`
catalogs default to `trusted`; installs from `restricted` catalogs stay
`restricted`. Document in spec §6 patch.
- [ ] **P0.5 Registry protocol package.** New
`packages/registry-protocol/` with the `RegistryBackend` interface,
shared zod schemas, and pure tests. No runtime deps.
### P1 — Headless / CLI completeness
Goal: every registry action you'd want from the website works on the CLI
first, headless, JSON-emitting.
- [ ] **P1.1 New CLI subcommands.**
- `od marketplace plugins <id> [--json]`
- `od marketplace search <query> [--json]`
- `od marketplace doctor [<id>]`
- `od plugin install <name>@<version|range>`
- `od plugin upgrade [--policy latest|pinned]`
- `od plugin yank <name>@<version> --reason "..."`
- `od plugin info <name> [--version <v>] [--json]`
- `od plugin login` -> wraps `gh auth login` with OD-specific host/scope guidance.
- `od plugin whoami` -> wraps `gh auth status` plus `gh api user`.
- [ ] **P1.2 GitHub backend module.** `apps/daemon/src/registry/github-backend.ts`
implements `RegistryBackend` against `open-design/plugin-registry`. Uses
`gh` CLI for auth-required ops (fork, PR), raw HTTPS for reads, on-disk
cache with ETag for `marketplace.json`.
- [ ] **P1.3 Publish orchestrator.** `apps/daemon/src/registry/publish.ts`:
pack → upload release → fork → branch → commit → PR. Idempotent: re-running
on the same version short-circuits if PR is open.
- [ ] **P1.4 Lockfile.** `.od/od-plugin-lock.json` records resolved
`name@version` + integrity + `sourceMarketplaceId`. `od plugin install`
honors lock on second run; `od plugin upgrade` rewrites it.
- [ ] **P1.5 Private GitHub catalog auth.** `od marketplace login <id>`
delegates to `gh auth login` for the catalog host. GitHub credentials stay
in `gh`, never in `marketplace.json` or `installed_plugins`. Generic token
profiles are reserved for future non-GitHub HTTPS or database backends.
- [ ] **P1.6 Integrity verification.** Verify SHA-256 of downloaded tarball
against the marketplace entry's `integrity` field before extracting. Fail
closed if mismatch. Store digest on the install record.
- [ ] **P1.7 Headless e2e tests** under `apps/daemon/tests/`:
- add marketplace → search → install by name → run → provenance asserted
in `installed_plugins` row.
- lockfile reproduces same install on a fresh daemon data dir.
- integrity-mismatch tarball is rejected.
### P2 — Web app: from "installed page" to "registry browser"
- [x] **P2.1 Unlock source management.** `/plugins` now has `Installed /
Available / Sources / Team` tabs. Sources can add URL, refresh, remove, and
change trust tier through existing `/api/marketplaces` endpoints.
- [x] **P2.2 Available tab entry slice.** Available cards are built from
configured marketplace manifests and show Install / Use / Upgrade states.
Current install still uses the existing bare-name resolver; typed source
selection and provenance-aware `--from <marketplace-id>` remain backend work.
- [ ] **P2.3 Home official shelf semantics.** Rename the Home page `Official`
plugin shelf copy to `Official starters` or `Official installed`, and add a
lightweight `Browse registry` path into `/plugins`. Home remains fast-use;
`/plugins` remains registry discovery/management.
- [ ] **P2.4 Agent-assisted Create plugin flow.** The `Create plugin` action
should start an agent workflow that gathers intent, scaffolds the plugin,
writes `SKILL.md`/`open-design.json`, validates, installs a local test copy,
packs, checks `gh` login/whoami, and publishes by opening a GitHub registry
PR through `od plugin publish`.
- [ ] **P2.5 Plugin detail drawer.** Provenance line, permissions, capability
summary, version dropdown, install command (copy-to-clipboard, matches
`od plugin install <name>@<version>`).
- [ ] **P2.6 Team / Private marketplace UI.** Private URL field, auth status
pill, default trust, org allowlist, refresh schedule, audit log link.
Wired to `/api/marketplaces/:id/auth` (new).
- [ ] **P2.7 Trust badge consistency.** Same `official / trusted / restricted`
visual language across cards, drawer, Sources tab, install confirm
modal.
### P3 — Official website + ecosystem
- [ ] **P3.1 Stand up `open-design/plugin-registry` repo.** Schema, validation
workflow, index-publishing workflow, OWNERS, contribution guide. Seed with
the bundled plugins currently shipped in `plugins/_official/`.
- [ ] **P3.2 Static site renderer.** Either reuse `apps/web` SSG mode or
ship a small standalone Next.js site in `apps/registry-site/`. Inputs:
`marketplace.json` + per-plugin `manifest.json` + `README.md`. Hosted at
`open-design.ai/marketplace` (canonical, with `open-design.ai/plugins` as an alias) and reproducible by third parties
via a GitHub Pages template.
- [ ] **P3.3 Submission guide.** `docs/publishing-a-plugin.md` + zh-CN. The
guide must be runnable end-to-end with `od plugin init`
`od plugin pack``od plugin publish`, no manual JSON editing.
- [ ] **P3.4 Self-host kit.** `docs/self-hosting-a-registry.md` — copy the
`plugin-registry` repo template, point `od marketplace add` at it, done.
Also documents the future DB-backend swap path so enterprises know the
exit option exists.
- [ ] **P3.5 `od plugin publish --to <marketplace-id>`.** Lets third-party
catalog owners accept submissions from their own users using the same CLI.
- [ ] **P3.6 Registry doctor.** `od marketplace doctor` validates every entry
is downloadable, manifest parseable, checksum match, permissions present.
Surface in web Sources tab too.
- [ ] **P3.7 Publisher verification.** Lightweight: GitHub-org-based publisher
identity, signed PR by an `OWNERS` entry. Heavier sigstore/cosign signing
deferred — open question §4.
### P4 — Future / non-blocking
- [ ] **P4.1 DB-backed RegistryBackend.** Same interface, SQLite or Postgres.
Validates R2.
- [ ] **P4.2 Search index.** Server-side typesense/meilisearch for the website;
CLI still works against `marketplace.json` directly.
- [ ] **P4.3 Web-of-trust badges.** Show downloads, install count (opt-in
telemetry), recent activity. Strictly opt-in per spec privacy stance.
- [ ] **P4.4 Marketplace lockfile signing.** Reproducible registry: sign
`marketplace.json` with the registry's GitHub Actions OIDC token so clients
can verify catalog integrity, not just tarball integrity.
---
## 3. Resolved decisions
1. **Namespace policy.** Registry package ids are `vendor/plugin-name`.
Flat ids remain a compatibility path for existing local/bundled plugins,
but public publish requires the namespaced id.
2. **Plugin source-of-truth repo.** The source repo can be any shape that
survives `od plugin validate` and `od plugin pack`. Registry publish
requires a `plugin.repo` field in `open-design.json` pointing to source.
3. **Tarball hosting fallback.** If GitHub Releases are unavailable
(enterprise / mirror), raw HTTPS or object-storage archive URLs are
accepted with mandatory integrity hash.
4. **Signing.** sigstore/cosign is deferred to P4. Start with PR-author
identity + SHA-256, escalate if ecosystem grows.
5. **Yanking vs deletion.** Never hard-delete a version. Only mark it
`yanked: true` with reason; new installs refuse, exact existing locks can
still replay with warning when integrity matches.
6. **Plugin ID stability.** A published package id is immutable. A renamed
plugin gets a new id plus alias/deprecation metadata; old id stays
traceable for historical installs and lockfiles.
---
## 4. Definition of done (registry v1)
- [ ] `R1``R6` invariants each have a regression test that fails when
violated.
- [ ] A third party can fork `open-design/plugin-registry`, change two
config values, run one workflow, and have a working OD plugin source at
their own URL — verified with an e2e fixture catalog.
- [ ] Every UI action in `PluginsView.tsx` Sources/Available tabs is
expressible as one `od` CLI invocation, verified by a script that drives
both surfaces against the same fixture.
- [ ] One real third-party publisher (target: a Claude-skills-community
contributor) has published a plugin via `od plugin publish` end-to-end
without writing JSON by hand.
---
## 5. Cross-references
- Trust tier rename touches `packages/contracts/src/plugins/marketplace.ts`,
`apps/daemon/src/cli.ts` (marketplace subcommands), `apps/daemon/src/server.ts`
(install path), `apps/web/src/components/PluginsView.tsx`.
- Provenance plumbing touches `apps/daemon/src/server.ts` (~line 3880) and
`apps/daemon/src/cli.ts` (~line 1183).
- Existing per-plugin runtime work (apply, snapshot, pipeline) is owned by
[`plugins-implementation.md`](./plugins-implementation.md); this plan does
not modify any of those modules.

View file

@ -222,6 +222,7 @@ Rules of authorship:
```json
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "make-a-deck",
"title": "Make a deck",
"version": "1.0.0",
@ -346,6 +347,8 @@ Rules of authorship:
### 5.1 Field reference
- `compat.*` — relative paths to inherited files. The loader concatenates their content into the OD prompt stack assembled by [`composeSystemPrompt()`](../apps/daemon/src/prompts/system.ts).
- `specVersion` — the Open Design plugin spec version used to interpret the manifest. This is distinct from plugin `version` and is frozen into apply snapshots for replay.
- `version` — the plugin package version. Bump it whenever behavior, metadata, pipeline, inputs, or bundled assets change in a way users may need to audit.
- `od.kind` — registry classification (`skill` / `scenario` / `atom` / `bundle`).
- `od.taskKind` — one of the four product scenarios (`new-generation` / `code-migration` / `figma-migration` / `tune-collab`, see §1 "Four product scenarios"). Drives marketplace filters, default input templates, and the recommended pipeline starting point.
- `od.preview` — drives the marketplace card and detail page. `entry` is served sandboxed via the daemon (the existing `/api/skills/:id/example` plumbing extended to plugins).
@ -422,16 +425,21 @@ Mirrors [`anthropics/skills/.claude-plugin/marketplace.json`](https://raw.github
```json
{
"$schema": "https://open-design.ai/schemas/marketplace.v1.json",
"specVersion": "1.0.0",
"name": "open-design-official",
"version": "1.0.0",
"owner": { "name": "Open Design", "url": "https://open-design.ai" },
"metadata": { "description": "First-party plugins", "version": "1.0.0" },
"plugins": [
{ "name": "make-a-deck", "source": "github:open-design/plugins/make-a-deck", "tags": ["deck"] },
{ "name": "tweet-card", "source": "https://files.../tweet-card-1.0.0.tgz", "tags": ["marketing"] }
{ "name": "make-a-deck", "version": "1.0.0", "source": "github:open-design/plugins/make-a-deck", "tags": ["deck"] },
{ "name": "tweet-card", "version": "1.0.0", "source": "https://files.../tweet-card-1.0.0.tgz", "tags": ["marketing"] }
]
}
```
The marketplace top-level `version` is the catalog snapshot version; every `plugins[]` entry also declares the listed plugin version. Installers still verify the target folder's own `open-design.json` after fetching, but registry search, audit logs, and marketplace refresh events can now reason about catalog and plugin versions before install.
Multiple marketplaces coexist — the user runs `od marketplace add <url>` to register additional indexes (Vercel's, OpenClaw's clawhub, an enterprise team's private catalog). By default, a user-added marketplace is only a discovery source and plugins from it still install as `restricted`; only the built-in official marketplace or a marketplace explicitly trusted through `od marketplace add <url> --trust` / `od marketplace trust <id>` can pass through default `trusted` status.
## 7. Discovery and install
@ -550,6 +558,7 @@ export interface ApplyResult {
export interface AppliedPluginSnapshot {
snapshotId: string;
pluginId: string;
pluginSpecVersion: string;
pluginVersion: string;
manifestSourceDigest: string;
sourceMarketplaceId?: string;
@ -610,7 +619,7 @@ Lives in `packages/contracts/src/plugins/apply.ts`. Re-exported from [`packages/
The daemon therefore must:
1. **At apply time** — hash the hydrated manifest plus inputs into `manifestSourceDigest`, then write `pluginVersion`, `pinnedRef`, `sourceMarketplaceId`, `resolvedContext`, `capabilitiesGranted`, `assetsStaged`, **`connectorsRequired` / `connectorsResolved` (cross-checked against the connector subsystem's current `status`)**, and **`mcpServers` (the MCP server set active at apply time)** into `appliedPlugin` and return it to the caller.
1. **At apply time** — hash the hydrated manifest plus inputs into `manifestSourceDigest`, then write `pluginSpecVersion`, `pluginVersion`, `pinnedRef`, `sourceMarketplaceId`, `resolvedContext`, `capabilitiesGranted`, `assetsStaged`, **`connectorsRequired` / `connectorsResolved` (cross-checked against the connector subsystem's current `status`)**, and **`mcpServers` (the MCP server set active at apply time)** into `appliedPlugin` and return it to the caller.
2. **At project create / run start** — write the client-supplied `appliedPlugin` (or the daemon's server-side re-resolved snapshot) into the SQLite `applied_plugin_snapshots` table (§11.4) and FK-link it from `runs` / `conversations`.
3. **Replay**`od run replay <runId>` and `od plugin export <runId>` must reconstruct prompt and assets from the snapshot rather than the live manifest, so old runs remain reproducible after plugin upgrades.
4. **Audit** — UI ProjectView shows snapshot id + version + digest at the top; artifact provenance (§11.5 ArtifactManifest) reverse-resolves plugin source via the snapshot id.
@ -664,6 +673,7 @@ A `restricted` plugin can never reach P3/P4/P5 unless the user grants the capabi
Trust records must bind to provenance, not just a name:
- `pluginId`
- `specVersion`
- `version` or resolved git SHA / archive digest
- source marketplace id, if any
- granted capabilities

View file

@ -222,6 +222,7 @@ my-plugin/
```json
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "make-a-deck",
"title": "Make a deck",
"version": "1.0.0",
@ -346,6 +347,8 @@ my-plugin/
### 5.1 字段说明
- `compat.*`指向继承格式文件的相对路径。loader 会把它们的内容合并进 [`composeSystemPrompt()`](../apps/daemon/src/prompts/system.ts) 组装出的 OD prompt stack。
- `specVersion`:解释此 manifest 时使用的 Open Design 插件规范版本。它独立于插件 `version`,并会冻结到 apply snapshot便于 replay。
- `version`插件包自身版本。只要行为、元数据、pipeline、inputs 或随包 assets 出现用户需要审计的变化,就应该 bump。
- `od.kind`registry 里的分类(`skill` / `scenario` / `atom` / `bundle`)。
- `od.taskKind`:四类产品场景之一(`new-generation` / `code-migration` / `figma-migration` / `tune-collab`§1「四类产品场景」。决定 marketplace filter、初始 inputs 模板、推荐 pipeline 起点。
- `od.preview`:驱动 marketplace 卡片和详情页。`entry` 通过 daemon 以 sandboxed 方式服务(扩展现有 `/api/skills/:id/example` plumbing
@ -422,16 +425,21 @@ export type ContextItem =
```json
{
"$schema": "https://open-design.ai/schemas/marketplace.v1.json",
"specVersion": "1.0.0",
"name": "open-design-official",
"version": "1.0.0",
"owner": { "name": "Open Design", "url": "https://open-design.ai" },
"metadata": { "description": "First-party plugins", "version": "1.0.0" },
"plugins": [
{ "name": "make-a-deck", "source": "github:open-design/plugins/make-a-deck", "tags": ["deck"] },
{ "name": "tweet-card", "source": "https://files.../tweet-card-1.0.0.tgz", "tags": ["marketing"] }
{ "name": "make-a-deck", "version": "1.0.0", "source": "github:open-design/plugins/make-a-deck", "tags": ["deck"] },
{ "name": "tweet-card", "version": "1.0.0", "source": "https://files.../tweet-card-1.0.0.tgz", "tags": ["marketing"] }
]
}
```
Marketplace 顶层 `version` 是 catalog snapshot 版本;每个 `plugins[]` entry 也声明被列入的插件版本。Installer 抓取后仍会校验目标文件夹自己的 `open-design.json`,但 registry search、审计日志和 marketplace refresh events 可以在安装前就理解 catalog 与插件版本。
可以同时存在多个 marketplaces。用户通过 `od marketplace add <url>` 注册额外 indexVercel 的、OpenClaw 的 clawhub、企业私有 catalog。默认情况下用户添加的 marketplace 只是 discovery source它里面的插件仍然以 `restricted` 安装;只有官方内置 marketplace 或用户显式执行 `od marketplace add <url> --trust` / `od marketplace trust <id>` 后,来自该 marketplace 的插件才可以默认继承 `trusted`
## 7. 发现与安装
@ -550,6 +558,7 @@ export interface ApplyResult {
export interface AppliedPluginSnapshot {
snapshotId: string;
pluginId: string;
pluginSpecVersion: string;
pluginVersion: string;
manifestSourceDigest: string;
sourceMarketplaceId?: string;
@ -609,7 +618,7 @@ export interface InputFieldSpec {
因此 daemon 必须:
1. **Apply 时**:把 hydrated manifest 与 inputs 一起 hash 成 `manifestSourceDigest`,连同 `pluginVersion`、`pinnedRef`、`sourceMarketplaceId`、`resolvedContext`、`capabilitiesGranted`、`assetsStaged`、**`connectorsRequired` / `connectorsResolved`(参考 connector 子系统当前 `status`**、**`mcpServers`apply 时启用的 MCP server set** 写入 `appliedPlugin`,返回给 caller。
1. **Apply 时**:把 hydrated manifest 与 inputs 一起 hash 成 `manifestSourceDigest`,连同 `pluginSpecVersion`、`pluginVersion`、`pinnedRef`、`sourceMarketplaceId`、`resolvedContext`、`capabilitiesGranted`、`assetsStaged`、**`connectorsRequired` / `connectorsResolved`(参考 connector 子系统当前 `status`**、**`mcpServers`apply 时启用的 MCP server set** 写入 `appliedPlugin`,返回给 caller。
2. **Project create / run start 时**:把客户端提交的 `appliedPlugin`(或 daemon 在 server-side 重新解析得到的 snapshot写入 SQLite `applied_plugin_snapshots`§11.4),并在 `runs` / `conversations` 表中以 FK 指向。
3. **Replay**`od run replay <runId>` 与 `od plugin export <runId>` 必须从 snapshot 而非 live manifest 还原 prompt 与 assets使老 run 在插件升级后仍可复现。
4. **Audit**UI 的 ProjectView 顶端展示 snapshot id + version + digestartifact provenance§11.5 ArtifactManifest通过 snapshot id 反查 plugin source。
@ -663,6 +672,7 @@ flowchart LR
信任记录必须绑定 provenance而不是只绑定名称
- `pluginId`
- `specVersion`
- `version` 或 resolved git SHA / archive digest
- source marketplace id如果有
- granted capabilities

View file

@ -4,11 +4,13 @@
"title": "Open Design plugin marketplace index (v1)",
"description": "Schema for `open-design-marketplace.json` federated catalog files. See docs/plugins-spec.md §6.",
"type": "object",
"required": ["name", "plugins"],
"required": ["specVersion", "name", "version", "plugins"],
"additionalProperties": true,
"properties": {
"$schema": { "type": "string", "format": "uri" },
"specVersion": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1 },
"version": { "type": "string", "minLength": 1 },
"owner": {
"type": "object",
"properties": {
@ -29,11 +31,11 @@
"type": "array",
"items": {
"type": "object",
"required": ["name", "source"],
"required": ["name", "source", "version"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"source": { "type": "string", "minLength": 1 },
"version": { "type": "string" },
"version": { "type": "string", "minLength": 1 },
"ref": { "type": "string" },
"tags": { "type": "array", "items": { "type": "string" } },
"title": { "type": "string" },

View file

@ -4,10 +4,11 @@
"title": "Open Design plugin manifest (v1)",
"description": "Schema for `open-design.json` sidecar files. Companion of `SKILL.md`; never duplicates skill body content. See docs/plugins-spec.md §5.",
"type": "object",
"required": ["name", "version"],
"required": ["specVersion", "name", "version"],
"additionalProperties": true,
"properties": {
"$schema": { "type": "string", "format": "uri" },
"specVersion": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"title": { "type": "string", "minLength": 1 },
"version": { "type": "string", "minLength": 1 },

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

View file

@ -41,6 +41,7 @@ export type PluginConnectorBinding = z.infer<typeof PluginConnectorBindingSchema
export const AppliedPluginSnapshotSchema = z.object({
snapshotId: z.string(),
pluginId: z.string(),
pluginSpecVersion: z.string().optional(),
pluginVersion: z.string(),
manifestSourceDigest: z.string(),
sourceMarketplaceId: z.string().optional(),

View file

@ -5,6 +5,10 @@ import { z } from 'zod';
// outputs (synthesized PluginManifest from SKILL.md frontmatter or claude
// plugin.json) parse cleanly without losing forward-compatible fields.
export const OPEN_DESIGN_PLUGIN_SPEC_VERSION = '1.0.0';
export const OpenDesignSpecVersionSchema = z.string().min(1);
export const ReferenceSchema = z.object({
ref: z.string().optional(),
path: z.string().optional(),
@ -134,6 +138,7 @@ export type PluginConnectorRef = z.infer<typeof PluginConnectorRefSchema>;
export const PluginManifestSchema = z.object({
$schema: z.string().optional(),
specVersion: OpenDesignSpecVersionSchema.optional(),
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/),
title: z.string().optional(),
version: z.string().min(1),

View file

@ -1,4 +1,8 @@
import { z } from 'zod';
import {
OPEN_DESIGN_PLUGIN_SPEC_VERSION,
OpenDesignSpecVersionSchema,
} from './manifest.js';
// `open-design-marketplace.json` schema (v1). Mirrors
// `docs/schemas/open-design.marketplace.v1.json`. The federated catalog
@ -7,7 +11,7 @@ import { z } from 'zod';
export const MarketplacePluginEntrySchema = z.object({
name: z.string().min(1),
source: z.string().min(1),
version: z.string().optional(),
version: z.string().min(1),
ref: z.string().optional(),
tags: z.array(z.string()).optional(),
title: z.string().optional(),
@ -18,8 +22,10 @@ export const MarketplacePluginEntrySchema = z.object({
export type MarketplacePluginEntry = z.infer<typeof MarketplacePluginEntrySchema>;
export const MarketplaceManifestSchema = z.object({
$schema: z.string().optional(),
name: z.string().min(1),
$schema: z.string().optional(),
specVersion: OpenDesignSpecVersionSchema.default(OPEN_DESIGN_PLUGIN_SPEC_VERSION),
name: z.string().min(1),
version: z.string().min(1),
owner: z.object({
name: z.string().optional(),
url: z.string().optional(),

View file

@ -1,10 +1,15 @@
import { describe, expect, it } from 'vitest';
import {
OPEN_DESIGN_PLUGIN_SPEC_VERSION,
PluginManifestSchema,
resolveLocalizedText,
} from '../src/plugins/manifest.js';
describe('plugin manifest localized text', () => {
it('exports the current plugin spec version for manifests and registries', () => {
expect(OPEN_DESIGN_PLUGIN_SPEC_VERSION).toBe('1.0.0');
});
it('accepts legacy string use-case queries', () => {
const manifest = PluginManifestSchema.parse({
name: 'sample-plugin',

View file

@ -1,6 +1,7 @@
import type {
InputField,
PluginManifest,
import {
OPEN_DESIGN_PLUGIN_SPEC_VERSION,
type InputField,
type PluginManifest,
} from '@open-design/contracts';
import { parseFrontmatter, type FrontmatterObject, type FrontmatterValue } from '../parsers/frontmatter.js';
@ -79,6 +80,7 @@ export function adaptAgentSkill(
: undefined;
const manifest: PluginManifest = {
specVersion: OPEN_DESIGN_PLUGIN_SPEC_VERSION,
name,
title,
version,

View file

@ -1,4 +1,7 @@
import type { PluginManifest } from '@open-design/contracts';
import {
OPEN_DESIGN_PLUGIN_SPEC_VERSION,
type PluginManifest,
} from '@open-design/contracts';
// Adapter from a `.claude-plugin/plugin.json` file to a synthesized
// PluginManifest. Phase 1 keeps the mapping minimal — name / version /
@ -54,6 +57,7 @@ export function adaptClaudePlugin(
warnings.push(`claude-plugin declares ${commands} command(s); v1 OD apply does not auto-register hooks. Add them via od.context.claudePlugins[].`);
}
const manifest: PluginManifest = {
specVersion: OPEN_DESIGN_PLUGIN_SPEC_VERSION,
name: safeName,
title: typeof obj['title'] === 'string' ? obj['title'] : safeName,
version,
@ -69,6 +73,7 @@ export function adaptClaudePlugin(
function synthesizeFallback(folderId: string, compatPath: string): PluginManifest {
return {
specVersion: OPEN_DESIGN_PLUGIN_SPEC_VERSION,
name: folderId,
title: folderId,
version: '0.0.0',

View file

@ -1,4 +1,8 @@
import { PluginManifestSchema, type PluginManifest } from '@open-design/contracts';
import {
OPEN_DESIGN_PLUGIN_SPEC_VERSION,
PluginManifestSchema,
type PluginManifest,
} from '@open-design/contracts';
export interface ManifestParseSuccess {
ok: true;
@ -41,5 +45,12 @@ export function parseManifestObject(value: unknown): ManifestParseResult {
errors: result.error.issues.map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`),
};
}
return { ok: true, manifest: result.data, warnings: [] };
return {
ok: true,
manifest: {
specVersion: OPEN_DESIGN_PLUGIN_SPEC_VERSION,
...result.data,
},
warnings: [],
};
}

View file

@ -57,14 +57,33 @@ describe('parseManifest', () => {
describe('parseMarketplace', () => {
it('accepts a tiny catalog', () => {
const result = parseMarketplace(JSON.stringify({
specVersion: '1.0.0',
name: 'open-design-official',
plugins: [{ name: 'make-a-deck', source: 'github:open-design/plugins/make-a-deck' }],
version: '1.0.0',
plugins: [{ name: 'make-a-deck', source: 'github:open-design/plugins/make-a-deck', version: '0.1.0' }],
}));
expect(result.ok).toBe(true);
});
it('rejects when catalog version is missing', () => {
const result = parseMarketplace(JSON.stringify({
name: 'no-version',
plugins: [{ name: 'make-a-deck', source: 'github:open-design/plugins/make-a-deck', version: '0.1.0' }],
}));
expect(result.ok).toBe(false);
});
it('rejects when plugin entry version is missing', () => {
const result = parseMarketplace(JSON.stringify({
name: 'missing-plugin-version',
version: '1.0.0',
plugins: [{ name: 'make-a-deck', source: 'github:open-design/plugins/make-a-deck' }],
}));
expect(result.ok).toBe(false);
});
it('rejects when plugins is missing', () => {
const result = parseMarketplace(JSON.stringify({ name: 'no-plugins' }));
const result = parseMarketplace(JSON.stringify({ name: 'no-plugins', version: '1.0.0' }));
expect(result.ok).toBe(false);
});
});

View file

@ -5,16 +5,17 @@ This directory owns Open Design plugin content and plugin authoring material.
## Boundaries
- `plugins/_official/` contains bundled first-party plugins. The daemon boot walker scans only this subtree and registers it as `source_kind='bundled'`.
- `plugins/community/` is the community authoring kit. It is documentation, starter material, and example source for contributors; it must not be treated as an installed first-party catalog.
- `plugins/spec/` is the portable plugin specification and authoring kit. It is documentation, starter material, and example source for contributors and external agents; it must not be treated as an installed first-party catalog.
- Keep runnable plugin examples portable: every example should have a `SKILL.md`; add `open-design.json` only as the OD sidecar.
- Keep `SKILL.md` bodies free of OD-only marketplace metadata. Put OD display, inputs, preview, pipeline, capabilities, and source information in `open-design.json`.
- Do not import app-private code from plugin content. A plugin may reference OD atoms, design systems, craft docs, assets, scripts, MCP servers, or connectors through the manifest.
## Authoring Rules
- New community examples belong under `plugins/community/examples/<plugin-id>/`.
- New spec examples belong under `plugins/spec/examples/<plugin-id>/`.
- New first-party bundled plugins belong under `plugins/_official/<tier>/<plugin-id>/` only when the product should auto-register them on daemon startup.
- Use the v1 JSON schema at `docs/schemas/open-design.plugin.v1.json`.
- Contribution-facing spec docs are bilingual. When editing `README.md`, `SPEC.md`, `CONTRIBUTING.md`, `AGENT-DEVELOPMENT.md`, or example README files under `plugins/spec/`, update the matching `*.zh-CN.md` mirror in the same change.
- Prefer TypeScript for project-owned scripts. Avoid adding new `.js`, `.mjs`, or `.cjs` files unless they are generated, vendored, or explicitly allowlisted by `scripts/guard.ts`.
- Keep example plugins concise and agent-readable. Move long reference material to `references/` and tell the agent when to load it.
@ -30,6 +31,5 @@ pnpm --filter @open-design/plugin-runtime typecheck
When the daemon CLI is built and available, also validate runnable plugin folders with:
```bash
od plugin validate ./plugins/community/examples/<plugin-id>
od plugin validate ./plugins/spec/examples/<plugin-id>
```

View file

@ -1,17 +1,20 @@
# Open Design Plugins
Language: English | [简体中文](README.zh-CN.md)
This directory has two different jobs:
- `_official/` - first-party plugins bundled with Open Design. The daemon scans this tree at startup and registers these plugins as official.
- `community/` - the public authoring kit for people and agents who want to build plugins, test them, publish them, or open a PR back to Open Design.
- `spec/` - the portable plugin specification, templates, examples, and agent handoff kit for building, testing, publishing, or opening a PR back to Open Design.
The common contract is the same everywhere: a plugin is a portable agent skill folder with a `SKILL.md`, plus an optional `open-design.json` sidecar that gives Open Design marketplace metadata, inputs, previews, pipelines, and trust/capability hints.
The common contract is the same everywhere: a plugin is a portable agent skill folder with a `SKILL.md`, plus an optional versioned `open-design.json` sidecar that gives Open Design marketplace metadata, inputs, previews, pipelines, and trust/capability hints.
Start here:
- Community authoring kit: [`community/README.md`](community/README.md)
- Community plugin spec: [`community/SPEC.md`](community/SPEC.md)
- Agent handoff guide: [`community/AGENT-DEVELOPMENT.md`](community/AGENT-DEVELOPMENT.md)
- Plugin spec kit: [`spec/README.md`](spec/README.md)
- Plugin authoring spec: [`spec/SPEC.md`](spec/SPEC.md)
- Agent handoff guide: [`spec/AGENT-DEVELOPMENT.md`](spec/AGENT-DEVELOPMENT.md)
- Registry publishing strategy: [`spec/PUBLISHING-REGISTRIES.md`](spec/PUBLISHING-REGISTRIES.md)
- Full product spec: [`../docs/plugins-spec.md`](../docs/plugins-spec.md)
- Manifest schema: [`../docs/schemas/open-design.plugin.v1.json`](../docs/schemas/open-design.plugin.v1.json)
- Marketplace schema: [`../docs/schemas/open-design.marketplace.v1.json`](../docs/schemas/open-design.marketplace.v1.json)

20
plugins/README.zh-CN.md Normal file
View file

@ -0,0 +1,20 @@
# Open Design 插件
语言:[English](README.md) | 简体中文
这个目录有两类职责:
- `_official/` - Open Design 随包发布的一方插件。daemon 启动时会扫描这个目录,并把这些插件注册为 official。
- `spec/` - 可移植插件规范、模板、示例和 agent handoff 包,用于构建、测试、发布插件,或向 Open Design 提交 PR。
所有插件共享同一个基础契约:插件是一个可移植的 agent skill 文件夹,包含 `SKILL.md`,并可选添加带版本的 `open-design.json` sidecar。`open-design.json` 负责 Open Design marketplace 元数据、输入项、预览、pipeline、信任与能力声明。
从这里开始:
- 插件规范包:[`spec/README.zh-CN.md`](spec/README.zh-CN.md)
- 插件作者规范:[`spec/SPEC.zh-CN.md`](spec/SPEC.zh-CN.md)
- Agent handoff 指南:[`spec/AGENT-DEVELOPMENT.zh-CN.md`](spec/AGENT-DEVELOPMENT.zh-CN.md)
- Registry 发布策略:[`spec/PUBLISHING-REGISTRIES.zh-CN.md`](spec/PUBLISHING-REGISTRIES.zh-CN.md)
- 完整产品 spec[`../docs/plugins-spec.zh-CN.md`](../docs/plugins-spec.zh-CN.md)
- Manifest schema[`../docs/schemas/open-design.plugin.v1.json`](../docs/schemas/open-design.plugin.v1.json)
- Marketplace schema[`../docs/schemas/open-design.marketplace.v1.json`](../docs/schemas/open-design.marketplace.v1.json)

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "build-test",
"title": "Build test",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "code-import",
"title": "Code import",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "critique-theater",
"title": "Critique theater",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-extract",
"title": "Design extract",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "diff-review",
"title": "Diff review",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "direction-picker",
"title": "Direction picker",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "discovery-question-form",
"title": "Discovery question form",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "figma-extract",
"title": "Figma extract",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "handoff",
"title": "Handoff",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "patch-edit",
"title": "Patch edit",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "rewrite-plan",
"title": "Rewrite plan",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "todo-write",
"title": "Todo write",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "token-map",
"title": "Token map",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-agentic",
"title": "Agentic",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-airbnb",
"title": "Airbnb",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-airtable",
"title": "Airtable",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-ant",
"title": "Ant",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-apple",
"title": "Apple",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-application",
"title": "Application",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-arc",
"title": "Arc Browser",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-artistic",
"title": "Artistic",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-atelier-zero",
"title": "Atelier Zero",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-bento",
"title": "Bento",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-binance",
"title": "Binance.US",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-bmw-m",
"title": "BMW M",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-bmw",
"title": "BMW",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-bold",
"title": "Bold",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-brutalism",
"title": "Brutalism",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-bugatti",
"title": "Bugatti",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-cafe",
"title": "Cafe",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-cal",
"title": "Cal.com",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-canva",
"title": "Canva",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-claude",
"title": "Claude (Anthropic)",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-clay",
"title": "Clay",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-claymorphism",
"title": "Claymorphism",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-clean",
"title": "Clean",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-clickhouse",
"title": "ClickHouse",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-cohere",
"title": "Cohere",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-coinbase",
"title": "Coinbase",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-colorful",
"title": "Colorful",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-composio",
"title": "Composio",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-contemporary",
"title": "Contemporary",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-corporate",
"title": "Corporate",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-cosmic",
"title": "Cosmic",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-creative",
"title": "Creative",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-cursor",
"title": "Cursor",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-dashboard",
"title": "Dashboard",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-default",
"title": "Neutral Modern",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-discord",
"title": "Discord",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-dithered",
"title": "Dithered",
"version": "0.1.0",

View file

@ -1,5 +1,6 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "design-system-doodle",
"title": "Doodle",
"version": "0.1.0",

Some files were not shown because too many files have changed in this diff Show more