Merge remote-tracking branch 'origin/garnet-hemisphere' into reconcile/garnet-main-merge
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 <url></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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
521
docs/plans/plugin-registry.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>` 注册额外 index(Vercel 的、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 + digest;artifact 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
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
BIN
docs/screenshots/skills/deck-guizang-editorial.png
Normal file
|
After Width: | Height: | Size: 474 KiB |
BIN
docs/screenshots/skills/deck-swiss-international.png
Normal file
|
After Width: | Height: | Size: 487 KiB |
BIN
docs/screenshots/skills/doc-kami-parchment.png
Normal file
|
After Width: | Height: | Size: 578 KiB |
BIN
docs/screenshots/skills/frame-glitch-title.png
Normal file
|
After Width: | Height: | Size: 1 MiB |
BIN
docs/screenshots/skills/frame-logo-outro.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/screenshots/skills/magazine-poster.png
Normal file
|
After Width: | Height: | Size: 700 KiB |
BIN
docs/screenshots/skills/vfx-text-cursor.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/screenshots/skills/video-hyperframes.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||